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. |
|
||||
| `/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
|
||||
|
||||
### Dashboard (`src/pages/HomePage/HomePage.jsx`)
|
||||
### Dashboard (`src/pages/HomePage/HomePage.tsx`)
|
||||
|
||||
When signed out:
|
||||
|
||||
@@ -42,7 +42,7 @@ When signed in:
|
||||
- `GET /api/locations`
|
||||
- `GET /api/inventoryitems`
|
||||
|
||||
### Inventory (`src/pages/InventoryPage/InventoryPage.jsx`)
|
||||
### Inventory (`src/pages/InventoryPage/InventoryPage.tsx`)
|
||||
|
||||
Locations:
|
||||
|
||||
@@ -59,7 +59,7 @@ Inventory items:
|
||||
- `PUT /api/inventoryitems/{id}`
|
||||
- `DELETE /api/inventoryitems/{id}`
|
||||
|
||||
### Admin (`src/pages/AdminPage/AdminPage.jsx`)
|
||||
### Admin (`src/pages/AdminPage/AdminPage.tsx`)
|
||||
|
||||
Households:
|
||||
|
||||
@@ -79,7 +79,7 @@ Notes:
|
||||
- User creation on this page uses the register contract: `email`, `password`, `confirmPassword`, `firstName`, and `lastName`.
|
||||
- The register endpoint does not assign roles, so new users are created without admin access by default.
|
||||
|
||||
### Search (`src/pages/SearchPage/SearchPage.jsx`)
|
||||
### Search (`src/pages/SearchPage/SearchPage.tsx`)
|
||||
|
||||
Initial page data:
|
||||
|
||||
@@ -96,13 +96,13 @@ Note:
|
||||
- The backend search API only supports the `q` query parameter.
|
||||
- Location, amount, expiry-date filtering, and pagination are applied client-side after the API search returns.
|
||||
|
||||
### Barcode (`src/pages/BarcodePage/BarcodePage.jsx`)
|
||||
### Barcode (`src/pages/BarcodePage/BarcodePage.tsx`)
|
||||
|
||||
- `GET /api/search/items?q={barcode}`
|
||||
|
||||
The barcode page scans via Quagga2, then searches the backend for inventory items whose barcode or other searchable fields match the scanned value.
|
||||
|
||||
### Users (`src/pages/UsersPage/UsersPage.jsx`)
|
||||
### Users (`src/pages/UsersPage/UsersPage.tsx`)
|
||||
|
||||
- `GET /api/users`
|
||||
|
||||
@@ -111,7 +111,7 @@ Notes:
|
||||
- The users page is intentionally minimal and is ready to consume a future users endpoint.
|
||||
- The current C# repo does not expose `GET /api/users` yet, so the page shows a placeholder when that endpoint returns `404`.
|
||||
|
||||
### Shared Auth Support (`src/api/client.js`)
|
||||
### Shared Auth Support (`src/api/client.ts`)
|
||||
|
||||
The shared API client also supports:
|
||||
|
||||
@@ -124,7 +124,7 @@ Notes:
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -157,8 +157,8 @@ After changing the env file, restart the Vite dev server.
|
||||
|
||||
Auth/session logic lives in:
|
||||
|
||||
- `src/api/client.js`
|
||||
- `src/context/AuthContext.jsx`
|
||||
- `src/api/client.ts`
|
||||
- `src/context/AuthContext.tsx`
|
||||
|
||||
Behavior:
|
||||
|
||||
@@ -268,15 +268,15 @@ Use `/users` to:
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `src/api/client.js` | API base URL, fetch wrapper, auth headers, token refresh, endpoint helpers |
|
||||
| `src/context/AuthContext.jsx` | Shared auth/session state for the React app |
|
||||
| `src/App.jsx` | Route definitions |
|
||||
| `src/pages/AdminPage/AdminPage.jsx` | Household administration UI |
|
||||
| `src/pages/HomePage/HomePage.jsx` | Dashboard and login UI |
|
||||
| `src/pages/InventoryPage/InventoryPage.jsx` | Location and inventory CRUD UI |
|
||||
| `src/pages/SearchPage/SearchPage.jsx` | API-backed search UI |
|
||||
| `src/pages/BarcodePage/BarcodePage.jsx` | Barcode scanning and API lookup UI |
|
||||
| `src/pages/UsersPage/UsersPage.jsx` | Users endpoint scaffold and list view |
|
||||
| `src/api/client.ts` | API base URL, fetch wrapper, auth headers, token refresh, endpoint helpers |
|
||||
| `src/context/AuthContext.tsx` | Shared auth/session state for the React app |
|
||||
| `src/App.tsx` | Route definitions |
|
||||
| `src/pages/AdminPage/AdminPage.tsx` | Household administration UI |
|
||||
| `src/pages/HomePage/HomePage.tsx` | Dashboard and login UI |
|
||||
| `src/pages/InventoryPage/InventoryPage.tsx` | Location and inventory CRUD UI |
|
||||
| `src/pages/SearchPage/SearchPage.tsx` | API-backed search UI |
|
||||
| `src/pages/BarcodePage/BarcodePage.tsx` | Barcode scanning and API lookup UI |
|
||||
| `src/pages/UsersPage/UsersPage.tsx` | Users endpoint scaffold and list view |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
62
package-lock.json
generated
62
package-lock.json
generated
@@ -15,7 +15,11 @@
|
||||
"react-router-dom": "^7.13.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.3"
|
||||
}
|
||||
},
|
||||
@@ -908,6 +912,36 @@
|
||||
"license": "MIT",
|
||||
"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": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
||||
@@ -947,6 +981,13 @@
|
||||
"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": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/cwise-compiler/-/cwise-compiler-1.1.3.tgz",
|
||||
@@ -1598,6 +1639,27 @@
|
||||
"license": "0BSD",
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "webpage-playground",
|
||||
"version": "1.0.0",
|
||||
"description": "A modern inventory management demo app featuring multiple pages for browsing, searching, and scanning barcodes. Built with vanilla HTML, CSS, and JavaScript.",
|
||||
"description": "A modern inventory management demo app featuring multiple pages for browsing, searching, and scanning barcodes. Built with React, TypeScript, and CSS.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"repository": {
|
||||
@@ -22,7 +23,11 @@
|
||||
"react-router-dom": "^7.13.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Navigate, Routes, Route } from 'react-router-dom'
|
||||
import Navbar from './components/Navbar/Navbar.jsx'
|
||||
import AdminPage from './pages/AdminPage/AdminPage.jsx'
|
||||
import HomePage from './pages/HomePage/HomePage.jsx'
|
||||
import InventoryPage from './pages/InventoryPage/InventoryPage.jsx'
|
||||
import MealPlannersPage from './pages/MealPlannersPage/MealPlannersPage.jsx'
|
||||
import ProfilePage from './pages/ProfilePage/ProfilePage.jsx'
|
||||
import SearchPage from './pages/SearchPage/SearchPage.jsx'
|
||||
import ShoppingListsPage from './pages/ShoppingListsPage/ShoppingListsPage.jsx'
|
||||
import BarcodePage from './pages/BarcodePage/BarcodePage.jsx'
|
||||
import UsersPage from './pages/UsersPage/UsersPage.jsx'
|
||||
import { useAuth } from './context/AuthContext.jsx'
|
||||
import Navbar from './components/Navbar/Navbar'
|
||||
import AdminPage from './pages/AdminPage/AdminPage'
|
||||
import HomePage from './pages/HomePage/HomePage'
|
||||
import InventoryPage from './pages/InventoryPage/InventoryPage'
|
||||
import MealPlannersPage from './pages/MealPlannersPage/MealPlannersPage'
|
||||
import ProfilePage from './pages/ProfilePage/ProfilePage'
|
||||
import SearchPage from './pages/SearchPage/SearchPage'
|
||||
import ShoppingListsPage from './pages/ShoppingListsPage/ShoppingListsPage'
|
||||
import BarcodePage from './pages/BarcodePage/BarcodePage'
|
||||
import UsersPage from './pages/UsersPage/UsersPage'
|
||||
import { useAuth } from './context/AuthContext'
|
||||
|
||||
const THEME_STORAGE_KEY = 'pantry-theme'
|
||||
export type Theme = 'light' | 'dark'
|
||||
|
||||
function resolveInitialTheme() {
|
||||
function resolveInitialTheme(): Theme {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'light'
|
||||
}
|
||||
@@ -40,7 +41,7 @@ if (typeof document !== 'undefined') {
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated, isSiteAdmin } = useAuth()
|
||||
const [theme, setTheme] = useState(initialTheme)
|
||||
const [theme, setTheme] = useState<Theme>(initialTheme)
|
||||
|
||||
useEffect(() => {
|
||||
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'
|
||||
|
||||
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 (
|
||||
<div className="item-component">
|
||||
<div className="item-component__img">
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
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'
|
||||
|
||||
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]
|
||||
.filter(part => typeof part === 'string' && part.trim())
|
||||
.join(' ')
|
||||
@@ -30,7 +37,7 @@ function MoonIcon() {
|
||||
)
|
||||
}
|
||||
|
||||
function Navbar({ theme, onToggleTheme }) {
|
||||
function Navbar({ theme, onToggleTheme }: NavbarProps) {
|
||||
const { isAuthenticated, isSiteAdmin, logout, user } = useAuth()
|
||||
const location = useLocation()
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
@@ -115,7 +122,7 @@ function Navbar({ theme, onToggleTheme }) {
|
||||
{isAuthenticated && (
|
||||
<button type="button" className="nav-button" onClick={() => {
|
||||
closeMenu()
|
||||
logout()
|
||||
void logout()
|
||||
}}>
|
||||
Sign out
|
||||
</button>
|
||||
@@ -4,7 +4,22 @@ import './PieChart.css'
|
||||
const RADIUS = 10
|
||||
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 total = segments.reduce((acc, seg) => acc + seg.value, 0)
|
||||
let currentOffset = 0
|
||||
@@ -15,7 +30,7 @@ function PieChart({ segments }) {
|
||||
currentOffset += segmentLength
|
||||
return { ...seg, segmentLength, offset }
|
||||
})
|
||||
}, [segments])
|
||||
}, [segments]) satisfies ComputedPieSegment[]
|
||||
|
||||
return (
|
||||
<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 {
|
||||
authApi,
|
||||
getStoredSession,
|
||||
profileApi,
|
||||
saveSession,
|
||||
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'])
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [session, setSession] = useState(() => getStoredSession())
|
||||
const [initializing, setInitializing] = useState(() => Boolean(getStoredSession()))
|
||||
|
||||
function setCurrentUserProfile(profile) {
|
||||
function setCurrentUserProfile(profile: User): AuthSession | null {
|
||||
const currentSession = getStoredSession()
|
||||
|
||||
if (!currentSession) {
|
||||
@@ -27,7 +45,7 @@ export function AuthProvider({ children }) {
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshProfile() {
|
||||
async function refreshProfile(): Promise<User | null> {
|
||||
const currentSession = getStoredSession()
|
||||
|
||||
if (!currentSession) {
|
||||
@@ -1,10 +1,12 @@
|
||||
function getDate(daysFromNow) {
|
||||
import type { DemoInventoryItem } from '../types/models'
|
||||
|
||||
function getDate(daysFromNow: number): string {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + daysFromNow)
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
export const inventoryData = [
|
||||
export const inventoryData: DemoInventoryItem[] = [
|
||||
// Pantry items
|
||||
{ id: 1, name: 'Pasta', location: 'Pantry', quantity: 5, unit: 'boxes', expiryDate: getDate(120), img: 'https://picsum.photos/seed/pasta/200/200' },
|
||||
{ id: 2, name: 'Rice', location: 'Pantry', quantity: 3, unit: 'bags', expiryDate: getDate(180), img: 'https://picsum.photos/seed/rice/200/200' },
|
||||
@@ -1,11 +1,17 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import { AuthProvider } from './context/AuthContext.jsx'
|
||||
import App from './App'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
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>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
@@ -1,15 +1,29 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { householdsApi, usersApi } from '../../api/client.js'
|
||||
import { useAuth } from '../../context/AuthContext.jsx'
|
||||
import { formatDate } from '../../utils/searchUtils.js'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { householdsApi, usersApi } from '../../api/client'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { formatDate } from '../../utils/searchUtils'
|
||||
import type { Household, HouseholdHistoryEntry, HouseholdMember, User } from '../../types/models'
|
||||
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: '',
|
||||
description: '',
|
||||
}
|
||||
|
||||
const EMPTY_USER_FORM = {
|
||||
const EMPTY_USER_FORM: UserCreateForm = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
@@ -17,7 +31,7 @@ const EMPTY_USER_FORM = {
|
||||
confirmPassword: '',
|
||||
}
|
||||
|
||||
function formatPersonName(person) {
|
||||
function formatPersonName(person: User | HouseholdMember): string {
|
||||
const fullName = [person.firstName, person.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
@@ -30,18 +44,18 @@ function AdminPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [statusMessage, setStatusMessage] = useState('')
|
||||
const [households, setHouseholds] = useState([])
|
||||
const [households, setHouseholds] = useState<Household[]>([])
|
||||
const [selectedHouseholdId, setSelectedHouseholdId] = 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 [userForm, setUserForm] = useState(EMPTY_USER_FORM)
|
||||
const [createdUser, setCreatedUser] = useState(null)
|
||||
const [householdHistory, setHouseholdHistory] = useState([])
|
||||
const [userForm, setUserForm] = useState<UserCreateForm>(EMPTY_USER_FORM)
|
||||
const [createdUser, setCreatedUser] = useState<User | null>(null)
|
||||
const [householdHistory, setHouseholdHistory] = useState<HouseholdHistoryEntry[]>([])
|
||||
const [householdHistoryLoading, setHouseholdHistoryLoading] = useState(false)
|
||||
const [householdHistoryError, setHouseholdHistoryError] = useState('')
|
||||
|
||||
async function loadHouseholds(preferredSelectionId = '') {
|
||||
async function loadHouseholds(preferredSelectionId = ''): Promise<void> {
|
||||
const response = await householdsApi.getHouseholds()
|
||||
const nextHouseholds = Array.isArray(response) ? response : []
|
||||
|
||||
@@ -167,7 +181,7 @@ function AdminPage() {
|
||||
setUserForm(EMPTY_USER_FORM)
|
||||
}
|
||||
|
||||
async function handleHouseholdSubmit(event) {
|
||||
async function handleHouseholdSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const name = householdForm.name.trim()
|
||||
@@ -208,7 +222,7 @@ function AdminPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInviteSubmit(event) {
|
||||
async function handleInviteSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const email = inviteEmail.trim()
|
||||
@@ -244,7 +258,7 @@ function AdminPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUserSubmit(event) {
|
||||
async function handleUserSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const email = userForm.email.trim()
|
||||
@@ -289,6 +303,7 @@ function AdminPage() {
|
||||
: null
|
||||
|
||||
setCreatedUser(matchingUser ?? {
|
||||
id: '',
|
||||
email,
|
||||
firstName: userForm.firstName.trim(),
|
||||
lastName: userForm.lastName.trim(),
|
||||
@@ -296,6 +311,7 @@ function AdminPage() {
|
||||
})
|
||||
} catch {
|
||||
setCreatedUser({
|
||||
id: '',
|
||||
email,
|
||||
firstName: userForm.firstName.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?')
|
||||
|
||||
if (!confirmed) return
|
||||
@@ -405,7 +421,7 @@ function AdminPage() {
|
||||
<label htmlFor="household-description">Description</label>
|
||||
<textarea
|
||||
id="household-description"
|
||||
rows="3"
|
||||
rows={3}
|
||||
value={householdForm.description}
|
||||
onChange={event => setHouseholdForm(current => ({ ...current, description: event.target.value }))}
|
||||
placeholder="Shared pantry and fridge access"
|
||||
@@ -703,4 +719,4 @@ function AdminPage() {
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminPage
|
||||
export default AdminPage
|
||||
@@ -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 type { QuaggaJSCodeReader, QuaggaJSResultCallbackFunction } from '@ericblade/quagga2'
|
||||
import {
|
||||
inventoryApi,
|
||||
locationsApi,
|
||||
} from '../../api/client.js'
|
||||
import { useAuth } from '../../context/AuthContext.jsx'
|
||||
} from '../../api/client'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import {
|
||||
buildInventoryPayload,
|
||||
createItemForm,
|
||||
findItemsByBarcode,
|
||||
mapItemToForm,
|
||||
normalizeBarcode,
|
||||
} from '../../utils/inventoryItemUtils.js'
|
||||
import { formatAmount, formatDate } from '../../utils/searchUtils.js'
|
||||
} from '../../utils/inventoryItemUtils'
|
||||
import { formatAmount, formatDate } from '../../utils/searchUtils'
|
||||
import type { InventoryItem, InventoryItemForm, Location } from '../../types/models'
|
||||
import './BarcodePage.css'
|
||||
|
||||
const MATCH_CANDIDATE_LIMIT = 10
|
||||
const CAMERA_READERS = [
|
||||
const CAMERA_READERS: QuaggaJSCodeReader[] = [
|
||||
'ean_reader',
|
||||
'ean_8_reader',
|
||||
'upc_reader',
|
||||
@@ -25,14 +27,23 @@ const CAMERA_READERS = [
|
||||
'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 === 'hardware-scanner') return 'Scanner'
|
||||
if (source === 'keyboard-paste') return 'Paste'
|
||||
return 'Manual'
|
||||
}
|
||||
|
||||
function getMatchCandidates(items, query, scannedBarcode) {
|
||||
function getMatchCandidates(items: InventoryItem[], query: string, scannedBarcode: string): InventoryItem[] {
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
const normalizedBarcode = normalizeBarcode(scannedBarcode)
|
||||
|
||||
@@ -57,25 +68,25 @@ function getMatchCandidates(items, query, scannedBarcode) {
|
||||
|
||||
function BarcodePage() {
|
||||
const { isAuthenticated } = useAuth()
|
||||
const [mode, setMode] = useState('camera')
|
||||
const [mode, setMode] = useState<ScanMode>('camera')
|
||||
const [cameraActive, setCameraActive] = useState(false)
|
||||
const [manualBarcodeInput, setManualBarcodeInput] = useState('')
|
||||
const [inventoryLoading, setInventoryLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [statusMessage, setStatusMessage] = useState('')
|
||||
const [lastScannedBarcode, setLastScannedBarcode] = useState('')
|
||||
const [lastScanSource, setLastScanSource] = useState('')
|
||||
const [locations, setLocations] = useState([])
|
||||
const [items, setItems] = useState([])
|
||||
const [editorMode, setEditorMode] = useState('')
|
||||
const [lastScanSource, setLastScanSource] = useState<ScanSource>('')
|
||||
const [locations, setLocations] = useState<Location[]>([])
|
||||
const [items, setItems] = useState<InventoryItem[]>([])
|
||||
const [editorMode, setEditorMode] = useState<EditorMode>('')
|
||||
const [editingItemId, setEditingItemId] = useState('')
|
||||
const [itemForm, setItemForm] = useState(createItemForm())
|
||||
const [itemForm, setItemForm] = useState<InventoryItemForm>(createItemForm())
|
||||
const [matchQuery, setMatchQuery] = useState('')
|
||||
|
||||
const inputRef = useRef(null)
|
||||
const scannerTargetRef = useRef(null)
|
||||
const detectedHandlerRef = useRef(null)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const scannerTargetRef = useRef<HTMLDivElement | null>(null)
|
||||
const detectedHandlerRef = useRef<QuaggaJSResultCallbackFunction | null>(null)
|
||||
const lastCameraScanRef = useRef({ barcode: '', at: 0 })
|
||||
|
||||
const matchingItems = findItemsByBarcode(items, lastScannedBarcode)
|
||||
@@ -84,7 +95,7 @@ function BarcodePage() {
|
||||
const hasExactMatches = matchingItems.length > 0
|
||||
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([
|
||||
locationsApi.getLocations(),
|
||||
inventoryApi.getInventoryItems(),
|
||||
@@ -93,7 +104,7 @@ function BarcodePage() {
|
||||
return { nextLocations, nextItems }
|
||||
}, [])
|
||||
|
||||
const applyInventoryData = useCallback(({ nextLocations, nextItems }) => {
|
||||
const applyInventoryData = useCallback(({ nextLocations, nextItems }: InventoryData) => {
|
||||
setLocations(nextLocations)
|
||||
setItems(nextItems)
|
||||
}, [])
|
||||
@@ -126,7 +137,7 @@ function BarcodePage() {
|
||||
setCameraActive(false)
|
||||
}, [clearScannerTarget])
|
||||
|
||||
const onBarcodeScanned = useCallback((barcode, inputType) => {
|
||||
const onBarcodeScanned = useCallback((barcode: string | null | undefined, inputType: ScanSource) => {
|
||||
const normalizedBarcode = normalizeBarcode(barcode)
|
||||
|
||||
if (!normalizedBarcode) {
|
||||
@@ -206,15 +217,17 @@ function BarcodePage() {
|
||||
throw new Error('The camera preview is not ready yet. Try again.')
|
||||
}
|
||||
|
||||
const scannerTarget = scannerTargetRef.current
|
||||
|
||||
stopCamera()
|
||||
setErrorMessage('')
|
||||
setStatusMessage('')
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
Quagga.init({
|
||||
inputStream: {
|
||||
type: 'LiveStream',
|
||||
target: scannerTargetRef.current,
|
||||
target: scannerTarget,
|
||||
constraints: {
|
||||
facingMode: { ideal: 'environment' },
|
||||
width: { ideal: 1280 },
|
||||
@@ -242,7 +255,7 @@ function BarcodePage() {
|
||||
})
|
||||
})
|
||||
|
||||
const detectedHandler = result => {
|
||||
const detectedHandler: QuaggaJSResultCallbackFunction = result => {
|
||||
const barcode = result?.codeResult?.code
|
||||
|
||||
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() {
|
||||
setInventoryLoading(true)
|
||||
setErrorMessage('')
|
||||
@@ -358,7 +391,7 @@ function BarcodePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditItem(itemId) {
|
||||
async function handleEditItem(itemId: string) {
|
||||
setSubmitting(true)
|
||||
setErrorMessage('')
|
||||
setStatusMessage('')
|
||||
@@ -375,7 +408,7 @@ function BarcodePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleItemSubmit(event) {
|
||||
async function handleItemSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
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(
|
||||
currentBarcode
|
||||
? `Replace the current barcode on ${itemName} with ${lastScannedBarcode}?`
|
||||
@@ -521,6 +554,24 @@ function BarcodePage() {
|
||||
</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
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
@@ -574,6 +625,22 @@ function BarcodePage() {
|
||||
>
|
||||
Check barcode
|
||||
</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
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
@@ -592,16 +659,6 @@ function BarcodePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="quick-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleQuickAdd}
|
||||
disabled={!hasScannedBarcode || submitting || inventoryLoading}
|
||||
>
|
||||
Quick Add
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* <section className="panel scan-summary-panel">
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import PieChart from '../../components/PieChart/PieChart.jsx'
|
||||
import ItemCard from '../../components/ItemCard/ItemCard.jsx'
|
||||
import { inventoryApi, locationsApi } from '../../api/client.js'
|
||||
import { useAuth } from '../../context/AuthContext.jsx'
|
||||
import { formatAmount, formatDate, getExpiryStatus } from '../../utils/searchUtils.js'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import PieChart from '../../components/PieChart/PieChart'
|
||||
import ItemCard from '../../components/ItemCard/ItemCard'
|
||||
import { inventoryApi, locationsApi } from '../../api/client'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { formatAmount, formatDate, getExpiryStatus } from '../../utils/searchUtils'
|
||||
import type { PieSegment } from '../../components/PieChart/PieChart'
|
||||
import type { InventoryItem, Location } from '../../types/models'
|
||||
import './HomePage.css'
|
||||
|
||||
const PIE_COLORS = ['#2563eb', '#0f766e', '#9333ea', '#ea580c', '#dc2626', '#0891b2']
|
||||
@@ -13,7 +15,7 @@ const INITIAL_LOGIN_FORM = {
|
||||
password: '',
|
||||
}
|
||||
|
||||
function buildPieSegments(items, locations) {
|
||||
function buildPieSegments(items: InventoryItem[], locations: Location[]): PieSegment[] {
|
||||
const counts = new Map(locations.map(location => [location.name, 0]))
|
||||
|
||||
items.forEach(item => {
|
||||
@@ -30,7 +32,7 @@ function buildPieSegments(items, locations) {
|
||||
}))
|
||||
}
|
||||
|
||||
function sortByExpiry(items) {
|
||||
function sortByExpiry(items: InventoryItem[]): InventoryItem[] {
|
||||
return [...items].sort((left, right) => {
|
||||
const leftDate = left.expiryDate ? new Date(left.expiryDate).getTime() : Number.MAX_SAFE_INTEGER
|
||||
const rightDate = right.expiryDate ? new Date(right.expiryDate).getTime() : Number.MAX_SAFE_INTEGER
|
||||
@@ -45,8 +47,8 @@ function HomePage() {
|
||||
const [formStatus, setFormStatus] = useState('')
|
||||
const [dashboardError, setDashboardError] = useState('')
|
||||
const [dashboardLoading, setDashboardLoading] = useState(false)
|
||||
const [locations, setLocations] = useState([])
|
||||
const [items, setItems] = useState([])
|
||||
const [locations, setLocations] = useState<Location[]>([])
|
||||
const [items, setItems] = useState<InventoryItem[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -90,7 +92,7 @@ function HomePage() {
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
async function handleLoginSubmit(event) {
|
||||
async function handleLoginSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setFormError('')
|
||||
setFormStatus('')
|
||||
@@ -1,22 +1,33 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
inventoryApi,
|
||||
locationsApi,
|
||||
} from '../../api/client.js'
|
||||
import { useAuth } from '../../context/AuthContext.jsx'
|
||||
} from '../../api/client'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import {
|
||||
buildInventoryPayload,
|
||||
createItemForm,
|
||||
mapItemToForm,
|
||||
} from '../../utils/inventoryItemUtils.js'
|
||||
} from '../../utils/inventoryItemUtils'
|
||||
import {
|
||||
formatAmount,
|
||||
formatDate,
|
||||
getExpiryStatus,
|
||||
} from '../../utils/searchUtils.js'
|
||||
} from '../../utils/searchUtils'
|
||||
import type {
|
||||
InventoryItem,
|
||||
InventoryItemForm,
|
||||
Location,
|
||||
LocationHistoryEntry,
|
||||
} from '../../types/models'
|
||||
import './InventoryPage.css'
|
||||
|
||||
const EMPTY_LOCATION_FORM = {
|
||||
interface LocationForm {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const EMPTY_LOCATION_FORM: LocationForm = {
|
||||
name: '',
|
||||
description: '',
|
||||
}
|
||||
@@ -26,19 +37,19 @@ function InventoryPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [statusMessage, setStatusMessage] = useState('')
|
||||
const [locations, setLocations] = useState([])
|
||||
const [items, setItems] = useState([])
|
||||
const [locations, setLocations] = useState<Location[]>([])
|
||||
const [items, setItems] = useState<InventoryItem[]>([])
|
||||
const [selectedLocationId, setSelectedLocationId] = useState('')
|
||||
const [locationHistory, setLocationHistory] = useState([])
|
||||
const [locationHistory, setLocationHistory] = useState<LocationHistoryEntry[]>([])
|
||||
const [locationHistoryLoading, setLocationHistoryLoading] = useState(false)
|
||||
const [locationHistoryError, setLocationHistoryError] = useState('')
|
||||
const [editingLocationId, setEditingLocationId] = useState('')
|
||||
const [editingItemId, setEditingItemId] = useState('')
|
||||
const [selectedItem, setSelectedItem] = useState(null)
|
||||
const [locationForm, setLocationForm] = useState(EMPTY_LOCATION_FORM)
|
||||
const [itemForm, setItemForm] = useState(createItemForm())
|
||||
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null)
|
||||
const [locationForm, setLocationForm] = useState<LocationForm>(EMPTY_LOCATION_FORM)
|
||||
const [itemForm, setItemForm] = useState<InventoryItemForm>(createItemForm())
|
||||
|
||||
function syncLocations(nextLocations) {
|
||||
function syncLocations(nextLocations: Location[]) {
|
||||
setLocations(nextLocations)
|
||||
setSelectedLocationId(currentLocationId => {
|
||||
const targetLocationId = currentLocationId || editingLocationId
|
||||
@@ -165,7 +176,7 @@ function InventoryPage() {
|
||||
setItemForm(createItemForm())
|
||||
}
|
||||
|
||||
async function handleLocationSubmit(event) {
|
||||
async function handleLocationSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
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.')
|
||||
|
||||
if (!confirmed) return
|
||||
@@ -233,7 +244,7 @@ function InventoryPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditItem(itemId) {
|
||||
async function handleEditItem(itemId: string) {
|
||||
setLoading(true)
|
||||
setErrorMessage('')
|
||||
setStatusMessage('')
|
||||
@@ -250,7 +261,7 @@ function InventoryPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleItemSubmit(event) {
|
||||
async function handleItemSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
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?')
|
||||
|
||||
if (!confirmed) return
|
||||
@@ -360,7 +371,7 @@ function InventoryPage() {
|
||||
<label htmlFor="location-description">Description</label>
|
||||
<textarea
|
||||
id="location-description"
|
||||
rows="3"
|
||||
rows={3}
|
||||
value={locationForm.description}
|
||||
onChange={event => setLocationForm(current => ({ ...current, description: event.target.value }))}
|
||||
placeholder="Main kitchen shelf"
|
||||
@@ -1,20 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
householdsApi,
|
||||
inventoryApi,
|
||||
mealPlannersApi,
|
||||
} from '../../api/client.js'
|
||||
import { useAuth } from '../../context/AuthContext.jsx'
|
||||
} from '../../api/client'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import {
|
||||
formatAmount,
|
||||
formatDate,
|
||||
formatTime,
|
||||
toDateInputValue,
|
||||
toTimeInputValue,
|
||||
} from '../../utils/searchUtils.js'
|
||||
} from '../../utils/searchUtils'
|
||||
import type {
|
||||
Household,
|
||||
InventoryItem,
|
||||
MealPlanner,
|
||||
MealPlannerForm,
|
||||
MealPlannerItemForm,
|
||||
MealPlannerItemPayload,
|
||||
} from '../../types/models'
|
||||
import '../PlanningPage.css'
|
||||
|
||||
function createMealPlannerItemForm() {
|
||||
function createMealPlannerItemForm(): MealPlannerItemForm {
|
||||
return {
|
||||
inventoryItemId: '',
|
||||
amountRequired: '',
|
||||
@@ -22,7 +30,7 @@ function createMealPlannerItemForm() {
|
||||
}
|
||||
}
|
||||
|
||||
function createMealPlannerForm(householdId = '') {
|
||||
function createMealPlannerForm(householdId = ''): MealPlannerForm {
|
||||
return {
|
||||
name: '',
|
||||
householdId,
|
||||
@@ -32,7 +40,7 @@ function createMealPlannerForm(householdId = '') {
|
||||
}
|
||||
}
|
||||
|
||||
function mapMealPlannerToForm(mealPlanner) {
|
||||
function mapMealPlannerToForm(mealPlanner: MealPlanner | null): MealPlannerForm {
|
||||
return {
|
||||
name: mealPlanner?.name ?? '',
|
||||
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'
|
||||
}
|
||||
|
||||
function toApiTimeValue(timeValue) {
|
||||
function toApiTimeValue(timeValue: string): string {
|
||||
if (!timeValue) return ''
|
||||
return timeValue.length === 5 ? `${timeValue}:00` : timeValue
|
||||
}
|
||||
|
||||
function normalizeMealPlannerItems(items) {
|
||||
function normalizeMealPlannerItems(items: MealPlannerItemForm[]): MealPlannerItemPayload[] {
|
||||
const normalizedItems = items
|
||||
.filter(item => item.inventoryItemId || item.amountRequired !== '' || item.amountType.trim())
|
||||
.map((item, index) => {
|
||||
@@ -97,12 +105,12 @@ function MealPlannersPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [statusMessage, setStatusMessage] = useState('')
|
||||
const [households, setHouseholds] = useState([])
|
||||
const [inventoryItems, setInventoryItems] = useState([])
|
||||
const [mealPlanners, setMealPlanners] = useState([])
|
||||
const [households, setHouseholds] = useState<Household[]>([])
|
||||
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([])
|
||||
const [mealPlanners, setMealPlanners] = useState<MealPlanner[]>([])
|
||||
const [selectedMealPlannerId, setSelectedMealPlannerId] = useState('')
|
||||
const [editingMealPlannerId, setEditingMealPlannerId] = useState('')
|
||||
const [mealPlannerForm, setMealPlannerForm] = useState(createMealPlannerForm())
|
||||
const [mealPlannerForm, setMealPlannerForm] = useState<MealPlannerForm>(createMealPlannerForm())
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -169,7 +177,7 @@ function MealPlannersPage() {
|
||||
}))
|
||||
}, [editingMealPlannerId, households, mealPlannerForm.householdId])
|
||||
|
||||
async function loadPageData(preferredSelectionId = '') {
|
||||
async function loadPageData(preferredSelectionId = ''): Promise<void> {
|
||||
const [nextHouseholdsResponse, nextInventoryResponse, nextMealPlannersResponse] = await Promise.all([
|
||||
householdsApi.getHouseholds(),
|
||||
inventoryApi.getInventoryItems(),
|
||||
@@ -204,7 +212,7 @@ function MealPlannersPage() {
|
||||
setMealPlannerForm(createMealPlannerForm(households[0]?.id ?? ''))
|
||||
}
|
||||
|
||||
function updateItemRow(index, updates) {
|
||||
function updateItemRow(index: number, updates: Partial<MealPlannerItemForm>) {
|
||||
setMealPlannerForm(currentForm => ({
|
||||
...currentForm,
|
||||
items: currentForm.items.map((item, itemIndex) => (
|
||||
@@ -222,14 +230,14 @@ function MealPlannersPage() {
|
||||
}))
|
||||
}
|
||||
|
||||
function removeItemRow(index) {
|
||||
function removeItemRow(index: number) {
|
||||
setMealPlannerForm(currentForm => ({
|
||||
...currentForm,
|
||||
items: currentForm.items.filter((_, itemIndex) => itemIndex !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
async function handleEditMealPlanner(mealPlannerId) {
|
||||
async function handleEditMealPlanner(mealPlannerId: string) {
|
||||
setLoading(true)
|
||||
setErrorMessage('')
|
||||
setStatusMessage('')
|
||||
@@ -246,7 +254,7 @@ function MealPlannersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMealPlannerSubmit(event) {
|
||||
async function handleMealPlannerSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
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?')
|
||||
|
||||
if (!confirmed) return
|
||||
@@ -543,7 +551,7 @@ function MealPlannersPage() {
|
||||
{(mealPlanner.items ?? []).length === 0 ? (
|
||||
<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>
|
||||
<strong>{item.inventoryItemName || 'Unnamed item'}</strong>
|
||||
@@ -594,4 +602,4 @@ function MealPlannersPage() {
|
||||
)
|
||||
}
|
||||
|
||||
export default MealPlannersPage
|
||||
export default MealPlannersPage
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { profileApi } from '../../api/client.js'
|
||||
import { useAuth } from '../../context/AuthContext.jsx'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { profileApi } from '../../api/client'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import type { User } from '../../types/models'
|
||||
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: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
@@ -11,7 +20,7 @@ const EMPTY_PROFILE_FORM = {
|
||||
newPassword: '',
|
||||
}
|
||||
|
||||
function createProfileForm(user) {
|
||||
function createProfileForm(user: User | null): ProfileForm {
|
||||
return {
|
||||
email: user?.email ?? '',
|
||||
firstName: user?.firstName ?? '',
|
||||
@@ -27,7 +36,7 @@ function ProfilePage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = 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 : []
|
||||
|
||||
@@ -64,7 +73,7 @@ function ProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProfileSubmit(event) {
|
||||
async function handleProfileSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const email = profileForm.email.trim()
|
||||
@@ -239,4 +248,4 @@ function ProfilePage() {
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfilePage
|
||||
export default ProfilePage
|
||||
@@ -1,21 +1,26 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import ItemCard from '../../components/ItemCard/ItemCard.jsx'
|
||||
import ItemCard from '../../components/ItemCard/ItemCard'
|
||||
import {
|
||||
inventoryApi,
|
||||
locationsApi,
|
||||
searchApi,
|
||||
} from '../../api/client.js'
|
||||
import { useAuth } from '../../context/AuthContext.jsx'
|
||||
} from '../../api/client'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import {
|
||||
filterInventoryItems,
|
||||
formatAmount,
|
||||
formatDate,
|
||||
getExpiryStatus,
|
||||
} from '../../utils/searchUtils.js'
|
||||
import { getPaginatedResults } from '../../utils/paginationUtils.js'
|
||||
} from '../../utils/searchUtils'
|
||||
import { getPaginatedResults } from '../../utils/paginationUtils'
|
||||
import type { InventoryItem, Location } from '../../types/models'
|
||||
import './SearchPage.css'
|
||||
|
||||
function ExpiryBadge({ expiryDate }) {
|
||||
interface ExpiryBadgeProps {
|
||||
expiryDate?: string | null
|
||||
}
|
||||
|
||||
function ExpiryBadge({ expiryDate }: ExpiryBadgeProps) {
|
||||
const status = getExpiryStatus(expiryDate)
|
||||
const badgeClass = status.status === 'Expired'
|
||||
? 'expiry-expired'
|
||||
@@ -40,9 +45,9 @@ function SearchPage() {
|
||||
const [maxExpiryDate, setMaxExpiryDate] = useState('')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(15)
|
||||
const [locations, setLocations] = useState([])
|
||||
const [matchingLocations, setMatchingLocations] = useState([])
|
||||
const [results, setResults] = useState([])
|
||||
const [locations, setLocations] = useState<Location[]>([])
|
||||
const [matchingLocations, setMatchingLocations] = useState<Location[]>([])
|
||||
const [results, setResults] = useState<InventoryItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
householdsApi,
|
||||
inventoryApi,
|
||||
shoppingListsApi,
|
||||
} from '../../api/client.js'
|
||||
import { useAuth } from '../../context/AuthContext.jsx'
|
||||
import { formatAmount, formatDate } from '../../utils/searchUtils.js'
|
||||
} from '../../api/client'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { formatAmount, formatDate } from '../../utils/searchUtils'
|
||||
import type {
|
||||
Household,
|
||||
InventoryItem,
|
||||
ShoppingList,
|
||||
ShoppingListForm,
|
||||
ShoppingListItemForm,
|
||||
ShoppingListItemPayload,
|
||||
} from '../../types/models'
|
||||
import '../PlanningPage.css'
|
||||
|
||||
function createShoppingListItemForm() {
|
||||
function createShoppingListItemForm(): ShoppingListItemForm {
|
||||
return {
|
||||
inventoryItemId: '',
|
||||
amountRequired: '',
|
||||
@@ -17,7 +25,7 @@ function createShoppingListItemForm() {
|
||||
}
|
||||
}
|
||||
|
||||
function createShoppingListForm(householdId = '') {
|
||||
function createShoppingListForm(householdId = ''): ShoppingListForm {
|
||||
return {
|
||||
name: '',
|
||||
householdId,
|
||||
@@ -25,7 +33,7 @@ function createShoppingListForm(householdId = '') {
|
||||
}
|
||||
}
|
||||
|
||||
function mapShoppingListToForm(shoppingList) {
|
||||
function mapShoppingListToForm(shoppingList: ShoppingList | null): ShoppingListForm {
|
||||
return {
|
||||
name: shoppingList?.name ?? '',
|
||||
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'
|
||||
}
|
||||
|
||||
function normalizeShoppingListItems(items) {
|
||||
function normalizeShoppingListItems(items: ShoppingListItemForm[]): ShoppingListItemPayload[] {
|
||||
const normalizedItems = items
|
||||
.filter(item => item.inventoryItemId || item.amountRequired !== '' || item.amountType.trim() || item.isPurchased)
|
||||
.map((item, index) => {
|
||||
@@ -85,12 +93,12 @@ function ShoppingListsPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [statusMessage, setStatusMessage] = useState('')
|
||||
const [households, setHouseholds] = useState([])
|
||||
const [inventoryItems, setInventoryItems] = useState([])
|
||||
const [shoppingLists, setShoppingLists] = useState([])
|
||||
const [households, setHouseholds] = useState<Household[]>([])
|
||||
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([])
|
||||
const [shoppingLists, setShoppingLists] = useState<ShoppingList[]>([])
|
||||
const [selectedShoppingListId, setSelectedShoppingListId] = useState('')
|
||||
const [editingShoppingListId, setEditingShoppingListId] = useState('')
|
||||
const [shoppingListForm, setShoppingListForm] = useState(createShoppingListForm())
|
||||
const [shoppingListForm, setShoppingListForm] = useState<ShoppingListForm>(createShoppingListForm())
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -157,7 +165,7 @@ function ShoppingListsPage() {
|
||||
}))
|
||||
}, [editingShoppingListId, households, shoppingListForm.householdId])
|
||||
|
||||
async function loadPageData(preferredSelectionId = '') {
|
||||
async function loadPageData(preferredSelectionId = ''): Promise<void> {
|
||||
const [nextHouseholdsResponse, nextInventoryResponse, nextShoppingListsResponse] = await Promise.all([
|
||||
householdsApi.getHouseholds(),
|
||||
inventoryApi.getInventoryItems(),
|
||||
@@ -192,7 +200,7 @@ function ShoppingListsPage() {
|
||||
setShoppingListForm(createShoppingListForm(households[0]?.id ?? ''))
|
||||
}
|
||||
|
||||
function updateItemRow(index, updates) {
|
||||
function updateItemRow(index: number, updates: Partial<ShoppingListItemForm>) {
|
||||
setShoppingListForm(currentForm => ({
|
||||
...currentForm,
|
||||
items: currentForm.items.map((item, itemIndex) => (
|
||||
@@ -210,14 +218,14 @@ function ShoppingListsPage() {
|
||||
}))
|
||||
}
|
||||
|
||||
function removeItemRow(index) {
|
||||
function removeItemRow(index: number) {
|
||||
setShoppingListForm(currentForm => ({
|
||||
...currentForm,
|
||||
items: currentForm.items.filter((_, itemIndex) => itemIndex !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
async function handleEditShoppingList(shoppingListId) {
|
||||
async function handleEditShoppingList(shoppingListId: string) {
|
||||
setLoading(true)
|
||||
setErrorMessage('')
|
||||
setStatusMessage('')
|
||||
@@ -234,7 +242,7 @@ function ShoppingListsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShoppingListSubmit(event) {
|
||||
async function handleShoppingListSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
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?')
|
||||
|
||||
if (!confirmed) return
|
||||
@@ -504,7 +512,7 @@ function ShoppingListsPage() {
|
||||
{(shoppingList.items ?? []).length === 0 ? (
|
||||
<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>
|
||||
<strong>{item.inventoryItemName || 'Unnamed item'}</strong>
|
||||
@@ -557,4 +565,4 @@ function ShoppingListsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
export default ShoppingListsPage
|
||||
export default ShoppingListsPage
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usersApi } from '../../api/client.js'
|
||||
import { useAuth } from '../../context/AuthContext.jsx'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { usersApi } from '../../api/client'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import type { User } from '../../types/models'
|
||||
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: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
@@ -11,7 +20,7 @@ const EMPTY_USER_FORM = {
|
||||
rolesInput: '',
|
||||
}
|
||||
|
||||
function formatUserName(user) {
|
||||
function formatUserName(user: Pick<User, 'email' | 'firstName' | 'lastName'>): string {
|
||||
const fullName = [user.firstName, user.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
@@ -19,7 +28,7 @@ function formatUserName(user) {
|
||||
return fullName || user.email || 'Unnamed user'
|
||||
}
|
||||
|
||||
function mapUserToForm(user) {
|
||||
function mapUserToForm(user: User | null): UserEditForm {
|
||||
return {
|
||||
email: user?.email ?? '',
|
||||
firstName: user?.firstName ?? '',
|
||||
@@ -29,7 +38,7 @@ function mapUserToForm(user) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseRoles(value) {
|
||||
function parseRoles(value: string): string[] {
|
||||
return value
|
||||
.split(',')
|
||||
.map(role => role.trim())
|
||||
@@ -38,16 +47,16 @@ function parseRoles(value) {
|
||||
|
||||
function UsersPage() {
|
||||
const { isAuthenticated, isSiteAdmin, refreshProfile, user } = useAuth()
|
||||
const [users, setUsers] = useState([])
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [statusMessage, setStatusMessage] = useState('')
|
||||
const [selectedUserId, setSelectedUserId] = useState('')
|
||||
const [editingUserId, setEditingUserId] = useState('')
|
||||
const [userForm, setUserForm] = useState(EMPTY_USER_FORM)
|
||||
const [userForm, setUserForm] = useState<UserEditForm>(EMPTY_USER_FORM)
|
||||
|
||||
async function loadUsers(preferredSelectionId = '') {
|
||||
async function loadUsers(preferredSelectionId = ''): Promise<void> {
|
||||
const response = await usersApi.getUsers()
|
||||
const nextUsers = Array.isArray(response) ? response : []
|
||||
|
||||
@@ -130,7 +139,7 @@ function UsersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditUser(userId) {
|
||||
async function handleEditUser(userId: string) {
|
||||
setLoading(true)
|
||||
setErrorMessage('')
|
||||
setStatusMessage('')
|
||||
@@ -147,7 +156,7 @@ function UsersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUserSubmit(event) {
|
||||
async function handleUserSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!editingUserId) {
|
||||
@@ -368,4 +377,4 @@ function UsersPage() {
|
||||
)
|
||||
}
|
||||
|
||||
export default UsersPage
|
||||
export default UsersPage
|
||||
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: '',
|
||||
barcode: '',
|
||||
expiryDate: '',
|
||||
@@ -10,19 +11,19 @@ export const EMPTY_ITEM_FORM = {
|
||||
locationId: '',
|
||||
}
|
||||
|
||||
export function normalizeBarcode(value) {
|
||||
export function normalizeBarcode(value: unknown): string {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
export function createItemForm(barcode = '') {
|
||||
export function createItemForm(barcode = ''): InventoryItemForm {
|
||||
return {
|
||||
...EMPTY_ITEM_FORM,
|
||||
barcode: normalizeBarcode(barcode),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInventoryPayload(form, includeBlankText = false) {
|
||||
const payload = {}
|
||||
export function buildInventoryPayload(form: InventoryItemForm, includeBlankText = false): InventoryItemPayload {
|
||||
const payload: InventoryItemPayload = {}
|
||||
const name = form.name.trim()
|
||||
|
||||
const barcode = normalizeBarcode(form.barcode)
|
||||
@@ -40,7 +41,7 @@ export function buildInventoryPayload(form, includeBlankText = false) {
|
||||
return payload
|
||||
}
|
||||
|
||||
export function mapItemToForm(item) {
|
||||
export function mapItemToForm(item: InventoryItem): InventoryItemForm {
|
||||
return {
|
||||
name: item.name ?? '',
|
||||
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)
|
||||
|
||||
if (!normalizedBarcode) {
|
||||
@@ -60,4 +61,4 @@ export function findItemsByBarcode(items, barcode) {
|
||||
}
|
||||
|
||||
return items.filter(item => normalizeBarcode(item.barcode) === 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 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 ''
|
||||
|
||||
const normalizedDate = toDateInputValue(dateStr)
|
||||
@@ -9,7 +26,7 @@ export function formatDate(dateStr) {
|
||||
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)
|
||||
|
||||
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 ''
|
||||
return String(dateStr).slice(0, 10)
|
||||
}
|
||||
|
||||
export function toTimeInputValue(timeStr) {
|
||||
export function toTimeInputValue(timeStr: string | null | undefined): string {
|
||||
if (!timeStr) return ''
|
||||
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: '' }
|
||||
|
||||
const today = new Date()
|
||||
@@ -50,7 +67,7 @@ export function getExpiryStatus(expiryDate) {
|
||||
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) {
|
||||
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'
|
||||
|
||||
const unit = amountType?.trim() ?? ''
|
||||
return unit ? `${amount} ${unit}` : String(amount)
|
||||
}
|
||||
|
||||
export function filterInventoryItems(items, filters = {}) {
|
||||
export function filterInventoryItems(items: InventoryItem[], filters: InventoryFilters = {}): InventoryItem[] {
|
||||
const {
|
||||
locationId = '',
|
||||
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