Update project files

This commit is contained in:
2026-07-17 09:48:48 +01:00
parent 63e5871e61
commit c6023d8d7b
29 changed files with 1309 additions and 718 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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
View 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',
})
},
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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">

View File

@@ -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) {

View File

@@ -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' },

View File

@@ -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>

View File

@@ -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"

View File

@@ -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">

View File

@@ -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('')

View File

@@ -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"

View File

@@ -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>

View File

@@ -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()

View File

@@ -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('')

View File

@@ -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>

View File

@@ -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) {

229
src/types/models.ts Normal file
View 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
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

22
tsconfig.json Normal file
View 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"]
}