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. | | `/barcode` | Scan a barcode and search the inventory API for matches. |
| `/users` | Site-admin users page scaffold for loading all users once the backend endpoint is available. | | `/users` | Site-admin users page scaffold for loading all users once the backend endpoint is available. |
Routing is defined in `src/App.jsx`. Routing is defined in `src/App.tsx`.
## API Endpoint Map ## API Endpoint Map
### Dashboard (`src/pages/HomePage/HomePage.jsx`) ### Dashboard (`src/pages/HomePage/HomePage.tsx`)
When signed out: When signed out:
@@ -42,7 +42,7 @@ When signed in:
- `GET /api/locations` - `GET /api/locations`
- `GET /api/inventoryitems` - `GET /api/inventoryitems`
### Inventory (`src/pages/InventoryPage/InventoryPage.jsx`) ### Inventory (`src/pages/InventoryPage/InventoryPage.tsx`)
Locations: Locations:
@@ -59,7 +59,7 @@ Inventory items:
- `PUT /api/inventoryitems/{id}` - `PUT /api/inventoryitems/{id}`
- `DELETE /api/inventoryitems/{id}` - `DELETE /api/inventoryitems/{id}`
### Admin (`src/pages/AdminPage/AdminPage.jsx`) ### Admin (`src/pages/AdminPage/AdminPage.tsx`)
Households: Households:
@@ -79,7 +79,7 @@ Notes:
- User creation on this page uses the register contract: `email`, `password`, `confirmPassword`, `firstName`, and `lastName`. - User creation on this page uses the register contract: `email`, `password`, `confirmPassword`, `firstName`, and `lastName`.
- The register endpoint does not assign roles, so new users are created without admin access by default. - The register endpoint does not assign roles, so new users are created without admin access by default.
### Search (`src/pages/SearchPage/SearchPage.jsx`) ### Search (`src/pages/SearchPage/SearchPage.tsx`)
Initial page data: Initial page data:
@@ -96,13 +96,13 @@ Note:
- The backend search API only supports the `q` query parameter. - The backend search API only supports the `q` query parameter.
- Location, amount, expiry-date filtering, and pagination are applied client-side after the API search returns. - Location, amount, expiry-date filtering, and pagination are applied client-side after the API search returns.
### Barcode (`src/pages/BarcodePage/BarcodePage.jsx`) ### Barcode (`src/pages/BarcodePage/BarcodePage.tsx`)
- `GET /api/search/items?q={barcode}` - `GET /api/search/items?q={barcode}`
The barcode page scans via Quagga2, then searches the backend for inventory items whose barcode or other searchable fields match the scanned value. The barcode page scans via Quagga2, then searches the backend for inventory items whose barcode or other searchable fields match the scanned value.
### Users (`src/pages/UsersPage/UsersPage.jsx`) ### Users (`src/pages/UsersPage/UsersPage.tsx`)
- `GET /api/users` - `GET /api/users`
@@ -111,7 +111,7 @@ Notes:
- The users page is intentionally minimal and is ready to consume a future users endpoint. - The users page is intentionally minimal and is ready to consume a future users endpoint.
- The current C# repo does not expose `GET /api/users` yet, so the page shows a placeholder when that endpoint returns `404`. - The current C# repo does not expose `GET /api/users` yet, so the page shows a placeholder when that endpoint returns `404`.
### Shared Auth Support (`src/api/client.js`) ### Shared Auth Support (`src/api/client.ts`)
The shared API client also supports: The shared API client also supports:
@@ -124,7 +124,7 @@ Notes:
## API Configuration ## API Configuration
The API base URL is configured in `src/api/client.js`. The API base URL is configured in `src/api/client.ts`.
Current setup: Current setup:
@@ -157,8 +157,8 @@ After changing the env file, restart the Vite dev server.
Auth/session logic lives in: Auth/session logic lives in:
- `src/api/client.js` - `src/api/client.ts`
- `src/context/AuthContext.jsx` - `src/context/AuthContext.tsx`
Behavior: Behavior:
@@ -268,15 +268,15 @@ Use `/users` to:
| File | Purpose | | File | Purpose |
| --- | --- | | --- | --- |
| `src/api/client.js` | API base URL, fetch wrapper, auth headers, token refresh, endpoint helpers | | `src/api/client.ts` | API base URL, fetch wrapper, auth headers, token refresh, endpoint helpers |
| `src/context/AuthContext.jsx` | Shared auth/session state for the React app | | `src/context/AuthContext.tsx` | Shared auth/session state for the React app |
| `src/App.jsx` | Route definitions | | `src/App.tsx` | Route definitions |
| `src/pages/AdminPage/AdminPage.jsx` | Household administration UI | | `src/pages/AdminPage/AdminPage.tsx` | Household administration UI |
| `src/pages/HomePage/HomePage.jsx` | Dashboard and login UI | | `src/pages/HomePage/HomePage.tsx` | Dashboard and login UI |
| `src/pages/InventoryPage/InventoryPage.jsx` | Location and inventory CRUD UI | | `src/pages/InventoryPage/InventoryPage.tsx` | Location and inventory CRUD UI |
| `src/pages/SearchPage/SearchPage.jsx` | API-backed search UI | | `src/pages/SearchPage/SearchPage.tsx` | API-backed search UI |
| `src/pages/BarcodePage/BarcodePage.jsx` | Barcode scanning and API lookup UI | | `src/pages/BarcodePage/BarcodePage.tsx` | Barcode scanning and API lookup UI |
| `src/pages/UsersPage/UsersPage.jsx` | Users endpoint scaffold and list view | | `src/pages/UsersPage/UsersPage.tsx` | Users endpoint scaffold and list view |
## Troubleshooting ## Troubleshooting

View File

@@ -7,6 +7,6 @@
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

62
package-lock.json generated
View File

@@ -15,7 +15,11 @@
"react-router-dom": "^7.13.2" "react-router-dom": "^7.13.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.1",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"typescript": "^6.0.3",
"vite": "^8.0.3" "vite": "^8.0.3"
} }
}, },
@@ -908,6 +912,36 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/react": {
"version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
@@ -947,6 +981,13 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cwise-compiler": { "node_modules/cwise-compiler": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/cwise-compiler/-/cwise-compiler-1.1.3.tgz", "resolved": "https://registry.npmjs.org/cwise-compiler/-/cwise-compiler-1.1.3.tgz",
@@ -1598,6 +1639,27 @@
"license": "0BSD", "license": "0BSD",
"optional": true "optional": true
}, },
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
"node_modules/uniq": { "node_modules/uniq": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz",

View File

@@ -1,11 +1,12 @@
{ {
"name": "webpage-playground", "name": "webpage-playground",
"version": "1.0.0", "version": "1.0.0",
"description": "A modern inventory management demo app featuring multiple pages for browsing, searching, and scanning barcodes. Built with vanilla HTML, CSS, and JavaScript.", "description": "A modern inventory management demo app featuring multiple pages for browsing, searching, and scanning barcodes. Built with React, TypeScript, and CSS.",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview" "preview": "vite preview"
}, },
"repository": { "repository": {
@@ -22,7 +23,11 @@
"react-router-dom": "^7.13.2" "react-router-dom": "^7.13.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.1",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"typescript": "^6.0.3",
"vite": "^8.0.3" "vite": "^8.0.3"
} }
} }

View File

@@ -1,20 +1,21 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Navigate, Routes, Route } from 'react-router-dom' import { Navigate, Routes, Route } from 'react-router-dom'
import Navbar from './components/Navbar/Navbar.jsx' import Navbar from './components/Navbar/Navbar'
import AdminPage from './pages/AdminPage/AdminPage.jsx' import AdminPage from './pages/AdminPage/AdminPage'
import HomePage from './pages/HomePage/HomePage.jsx' import HomePage from './pages/HomePage/HomePage'
import InventoryPage from './pages/InventoryPage/InventoryPage.jsx' import InventoryPage from './pages/InventoryPage/InventoryPage'
import MealPlannersPage from './pages/MealPlannersPage/MealPlannersPage.jsx' import MealPlannersPage from './pages/MealPlannersPage/MealPlannersPage'
import ProfilePage from './pages/ProfilePage/ProfilePage.jsx' import ProfilePage from './pages/ProfilePage/ProfilePage'
import SearchPage from './pages/SearchPage/SearchPage.jsx' import SearchPage from './pages/SearchPage/SearchPage'
import ShoppingListsPage from './pages/ShoppingListsPage/ShoppingListsPage.jsx' import ShoppingListsPage from './pages/ShoppingListsPage/ShoppingListsPage'
import BarcodePage from './pages/BarcodePage/BarcodePage.jsx' import BarcodePage from './pages/BarcodePage/BarcodePage'
import UsersPage from './pages/UsersPage/UsersPage.jsx' import UsersPage from './pages/UsersPage/UsersPage'
import { useAuth } from './context/AuthContext.jsx' import { useAuth } from './context/AuthContext'
const THEME_STORAGE_KEY = 'pantry-theme' const THEME_STORAGE_KEY = 'pantry-theme'
export type Theme = 'light' | 'dark'
function resolveInitialTheme() { function resolveInitialTheme(): Theme {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return 'light' return 'light'
} }
@@ -40,7 +41,7 @@ if (typeof document !== 'undefined') {
function App() { function App() {
const { isAuthenticated, isSiteAdmin } = useAuth() const { isAuthenticated, isSiteAdmin } = useAuth()
const [theme, setTheme] = useState(initialTheme) const [theme, setTheme] = useState<Theme>(initialTheme)
useEffect(() => { useEffect(() => {
document.documentElement.dataset.theme = theme document.documentElement.dataset.theme = theme

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' import './ItemCard.css'
function ItemCard({ imgSrc = '', imgAlt = '', text1 = '', text2 = '', text3 = '', editable = false, imgWidth = 80, imgHeight = 80, children }) { interface ItemCardProps {
imgSrc?: string
imgAlt?: string
text1?: string
text2?: string
text3?: string
editable?: boolean
imgWidth?: number
imgHeight?: number
children?: ReactNode
}
function ItemCard({
imgSrc = '',
imgAlt = '',
text1 = '',
text2 = '',
text3 = '',
editable = false,
imgWidth = 80,
imgHeight = 80,
children,
}: ItemCardProps) {
return ( return (
<div className="item-component"> <div className="item-component">
<div className="item-component__img"> <div className="item-component__img">

View File

@@ -1,11 +1,18 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { NavLink, useLocation } from 'react-router-dom' import { NavLink, useLocation } from 'react-router-dom'
import { useAuth } from '../../context/AuthContext.jsx' import { useAuth } from '../../context/AuthContext'
import type { Theme } from '../../App'
import type { User } from '../../types/models'
import './Navbar.css' import './Navbar.css'
const MOBILE_NAV_BREAKPOINT = 760 const MOBILE_NAV_BREAKPOINT = 760
function formatUserDisplayName(user) { interface NavbarProps {
theme: Theme
onToggleTheme: () => void
}
function formatUserDisplayName(user: User | null): string {
const fullName = [user?.firstName, user?.lastName] const fullName = [user?.firstName, user?.lastName]
.filter(part => typeof part === 'string' && part.trim()) .filter(part => typeof part === 'string' && part.trim())
.join(' ') .join(' ')
@@ -30,7 +37,7 @@ function MoonIcon() {
) )
} }
function Navbar({ theme, onToggleTheme }) { function Navbar({ theme, onToggleTheme }: NavbarProps) {
const { isAuthenticated, isSiteAdmin, logout, user } = useAuth() const { isAuthenticated, isSiteAdmin, logout, user } = useAuth()
const location = useLocation() const location = useLocation()
const [isMenuOpen, setIsMenuOpen] = useState(false) const [isMenuOpen, setIsMenuOpen] = useState(false)
@@ -115,7 +122,7 @@ function Navbar({ theme, onToggleTheme }) {
{isAuthenticated && ( {isAuthenticated && (
<button type="button" className="nav-button" onClick={() => { <button type="button" className="nav-button" onClick={() => {
closeMenu() closeMenu()
logout() void logout()
}}> }}>
Sign out Sign out
</button> </button>

View File

@@ -4,7 +4,22 @@ import './PieChart.css'
const RADIUS = 10 const RADIUS = 10
const CIRCUMFERENCE = 2 * Math.PI * RADIUS const CIRCUMFERENCE = 2 * Math.PI * RADIUS
function PieChart({ segments }) { export interface PieSegment {
label: string
value: number
color: string
}
interface ComputedPieSegment extends PieSegment {
segmentLength: number
offset: number
}
interface PieChartProps {
segments: PieSegment[]
}
function PieChart({ segments }: PieChartProps) {
const computedSegments = useMemo(() => { const computedSegments = useMemo(() => {
const total = segments.reduce((acc, seg) => acc + seg.value, 0) const total = segments.reduce((acc, seg) => acc + seg.value, 0)
let currentOffset = 0 let currentOffset = 0
@@ -15,7 +30,7 @@ function PieChart({ segments }) {
currentOffset += segmentLength currentOffset += segmentLength
return { ...seg, segmentLength, offset } return { ...seg, segmentLength, offset }
}) })
}, [segments]) }, [segments]) satisfies ComputedPieSegment[]
return ( return (
<div className="chart-container"> <div className="chart-container">

View File

@@ -1,20 +1,38 @@
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { import {
authApi, authApi,
getStoredSession, getStoredSession,
profileApi, profileApi,
saveSession, saveSession,
subscribeToSessionChanges, subscribeToSessionChanges,
} from '../api/client.js' } from '../api/client'
import type { AuthCredentials, AuthResponse, AuthSession, RegisterPayload, User } from '../types/models'
const AuthContext = createContext(null) interface AuthContextValue {
session: AuthSession | null
user: User | null
isAuthenticated: boolean
isSiteAdmin: boolean
initializing: boolean
login: (payload: AuthCredentials) => Promise<AuthResponse>
register: (payload: RegisterPayload) => Promise<AuthResponse>
logout: () => Promise<void>
refreshProfile: () => Promise<User | null>
setCurrentUserProfile: (profile: User) => AuthSession | null
}
interface AuthProviderProps {
children: ReactNode
}
const AuthContext = createContext<AuthContextValue | null>(null)
const SITE_ADMIN_ROLES = new Set(['Site Admin', 'Admin']) const SITE_ADMIN_ROLES = new Set(['Site Admin', 'Admin'])
export function AuthProvider({ children }) { export function AuthProvider({ children }: AuthProviderProps) {
const [session, setSession] = useState(() => getStoredSession()) const [session, setSession] = useState(() => getStoredSession())
const [initializing, setInitializing] = useState(() => Boolean(getStoredSession())) const [initializing, setInitializing] = useState(() => Boolean(getStoredSession()))
function setCurrentUserProfile(profile) { function setCurrentUserProfile(profile: User): AuthSession | null {
const currentSession = getStoredSession() const currentSession = getStoredSession()
if (!currentSession) { if (!currentSession) {
@@ -27,7 +45,7 @@ export function AuthProvider({ children }) {
}) })
} }
async function refreshProfile() { async function refreshProfile(): Promise<User | null> {
const currentSession = getStoredSession() const currentSession = getStoredSession()
if (!currentSession) { if (!currentSession) {

View File

@@ -1,10 +1,12 @@
function getDate(daysFromNow) { import type { DemoInventoryItem } from '../types/models'
function getDate(daysFromNow: number): string {
const date = new Date() const date = new Date()
date.setDate(date.getDate() + daysFromNow) date.setDate(date.getDate() + daysFromNow)
return date.toISOString().split('T')[0] return date.toISOString().split('T')[0]
} }
export const inventoryData = [ export const inventoryData: DemoInventoryItem[] = [
// Pantry items // Pantry items
{ id: 1, name: 'Pasta', location: 'Pantry', quantity: 5, unit: 'boxes', expiryDate: getDate(120), img: 'https://picsum.photos/seed/pasta/200/200' }, { id: 1, name: 'Pasta', location: 'Pantry', quantity: 5, unit: 'boxes', expiryDate: getDate(120), img: 'https://picsum.photos/seed/pasta/200/200' },
{ id: 2, name: 'Rice', location: 'Pantry', quantity: 3, unit: 'bags', expiryDate: getDate(180), img: 'https://picsum.photos/seed/rice/200/200' }, { id: 2, name: 'Rice', location: 'Pantry', quantity: 3, unit: 'bags', expiryDate: getDate(180), img: 'https://picsum.photos/seed/rice/200/200' },

View File

@@ -1,11 +1,17 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx' import App from './App'
import { AuthProvider } from './context/AuthContext.jsx' import { AuthProvider } from './context/AuthContext'
import './App.css' import './App.css'
createRoot(document.getElementById('root')).render( const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('Root element not found.')
}
createRoot(rootElement).render(
<StrictMode> <StrictMode>
<AuthProvider> <AuthProvider>
<BrowserRouter> <BrowserRouter>

View File

@@ -1,15 +1,29 @@
import { useEffect, useState } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { householdsApi, usersApi } from '../../api/client.js' import { householdsApi, usersApi } from '../../api/client'
import { useAuth } from '../../context/AuthContext.jsx' import { useAuth } from '../../context/AuthContext'
import { formatDate } from '../../utils/searchUtils.js' import { formatDate } from '../../utils/searchUtils'
import type { Household, HouseholdHistoryEntry, HouseholdMember, User } from '../../types/models'
import './AdminPage.css' import './AdminPage.css'
const EMPTY_HOUSEHOLD_FORM = { interface HouseholdForm {
name: string
description: string
}
interface UserCreateForm {
firstName: string
lastName: string
email: string
password: string
confirmPassword: string
}
const EMPTY_HOUSEHOLD_FORM: HouseholdForm = {
name: '', name: '',
description: '', description: '',
} }
const EMPTY_USER_FORM = { const EMPTY_USER_FORM: UserCreateForm = {
firstName: '', firstName: '',
lastName: '', lastName: '',
email: '', email: '',
@@ -17,7 +31,7 @@ const EMPTY_USER_FORM = {
confirmPassword: '', confirmPassword: '',
} }
function formatPersonName(person) { function formatPersonName(person: User | HouseholdMember): string {
const fullName = [person.firstName, person.lastName] const fullName = [person.firstName, person.lastName]
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
@@ -30,18 +44,18 @@ function AdminPage() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')
const [statusMessage, setStatusMessage] = useState('') const [statusMessage, setStatusMessage] = useState('')
const [households, setHouseholds] = useState([]) const [households, setHouseholds] = useState<Household[]>([])
const [selectedHouseholdId, setSelectedHouseholdId] = useState('') const [selectedHouseholdId, setSelectedHouseholdId] = useState('')
const [editingHouseholdId, setEditingHouseholdId] = useState('') const [editingHouseholdId, setEditingHouseholdId] = useState('')
const [householdForm, setHouseholdForm] = useState(EMPTY_HOUSEHOLD_FORM) const [householdForm, setHouseholdForm] = useState<HouseholdForm>(EMPTY_HOUSEHOLD_FORM)
const [inviteEmail, setInviteEmail] = useState('') const [inviteEmail, setInviteEmail] = useState('')
const [userForm, setUserForm] = useState(EMPTY_USER_FORM) const [userForm, setUserForm] = useState<UserCreateForm>(EMPTY_USER_FORM)
const [createdUser, setCreatedUser] = useState(null) const [createdUser, setCreatedUser] = useState<User | null>(null)
const [householdHistory, setHouseholdHistory] = useState([]) const [householdHistory, setHouseholdHistory] = useState<HouseholdHistoryEntry[]>([])
const [householdHistoryLoading, setHouseholdHistoryLoading] = useState(false) const [householdHistoryLoading, setHouseholdHistoryLoading] = useState(false)
const [householdHistoryError, setHouseholdHistoryError] = useState('') const [householdHistoryError, setHouseholdHistoryError] = useState('')
async function loadHouseholds(preferredSelectionId = '') { async function loadHouseholds(preferredSelectionId = ''): Promise<void> {
const response = await householdsApi.getHouseholds() const response = await householdsApi.getHouseholds()
const nextHouseholds = Array.isArray(response) ? response : [] const nextHouseholds = Array.isArray(response) ? response : []
@@ -167,7 +181,7 @@ function AdminPage() {
setUserForm(EMPTY_USER_FORM) setUserForm(EMPTY_USER_FORM)
} }
async function handleHouseholdSubmit(event) { async function handleHouseholdSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
const name = householdForm.name.trim() const name = householdForm.name.trim()
@@ -208,7 +222,7 @@ function AdminPage() {
} }
} }
async function handleInviteSubmit(event) { async function handleInviteSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
const email = inviteEmail.trim() const email = inviteEmail.trim()
@@ -244,7 +258,7 @@ function AdminPage() {
} }
} }
async function handleUserSubmit(event) { async function handleUserSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
const email = userForm.email.trim() const email = userForm.email.trim()
@@ -289,6 +303,7 @@ function AdminPage() {
: null : null
setCreatedUser(matchingUser ?? { setCreatedUser(matchingUser ?? {
id: '',
email, email,
firstName: userForm.firstName.trim(), firstName: userForm.firstName.trim(),
lastName: userForm.lastName.trim(), lastName: userForm.lastName.trim(),
@@ -296,6 +311,7 @@ function AdminPage() {
}) })
} catch { } catch {
setCreatedUser({ setCreatedUser({
id: '',
email, email,
firstName: userForm.firstName.trim(), firstName: userForm.firstName.trim(),
lastName: userForm.lastName.trim(), lastName: userForm.lastName.trim(),
@@ -312,7 +328,7 @@ function AdminPage() {
} }
} }
async function handleLeaveHousehold(householdId) { async function handleLeaveHousehold(householdId: string) {
const confirmed = window.confirm('Leave this household?') const confirmed = window.confirm('Leave this household?')
if (!confirmed) return if (!confirmed) return
@@ -405,7 +421,7 @@ function AdminPage() {
<label htmlFor="household-description">Description</label> <label htmlFor="household-description">Description</label>
<textarea <textarea
id="household-description" id="household-description"
rows="3" rows={3}
value={householdForm.description} value={householdForm.description}
onChange={event => setHouseholdForm(current => ({ ...current, description: event.target.value }))} onChange={event => setHouseholdForm(current => ({ ...current, description: event.target.value }))}
placeholder="Shared pantry and fridge access" placeholder="Shared pantry and fridge access"
@@ -703,4 +719,4 @@ function AdminPage() {
) )
} }
export default AdminPage export default AdminPage

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 Quagga from '@ericblade/quagga2'
import type { QuaggaJSCodeReader, QuaggaJSResultCallbackFunction } from '@ericblade/quagga2'
import { import {
inventoryApi, inventoryApi,
locationsApi, locationsApi,
} from '../../api/client.js' } from '../../api/client'
import { useAuth } from '../../context/AuthContext.jsx' import { useAuth } from '../../context/AuthContext'
import { import {
buildInventoryPayload, buildInventoryPayload,
createItemForm, createItemForm,
findItemsByBarcode, findItemsByBarcode,
mapItemToForm, mapItemToForm,
normalizeBarcode, normalizeBarcode,
} from '../../utils/inventoryItemUtils.js' } from '../../utils/inventoryItemUtils'
import { formatAmount, formatDate } from '../../utils/searchUtils.js' import { formatAmount, formatDate } from '../../utils/searchUtils'
import type { InventoryItem, InventoryItemForm, Location } from '../../types/models'
import './BarcodePage.css' import './BarcodePage.css'
const MATCH_CANDIDATE_LIMIT = 10 const MATCH_CANDIDATE_LIMIT = 10
const CAMERA_READERS = [ const CAMERA_READERS: QuaggaJSCodeReader[] = [
'ean_reader', 'ean_reader',
'ean_8_reader', 'ean_8_reader',
'upc_reader', 'upc_reader',
@@ -25,14 +27,23 @@ const CAMERA_READERS = [
'code_39_reader', 'code_39_reader',
] ]
function getScanSourceLabel(source) { type ScanMode = 'camera' | 'keyboard'
type ScanSource = '' | 'camera' | 'hardware-scanner' | 'keyboard-paste' | 'manual'
type EditorMode = '' | 'create' | 'update'
interface InventoryData {
nextLocations: Location[]
nextItems: InventoryItem[]
}
function getScanSourceLabel(source: ScanSource): string {
if (source === 'camera') return 'Camera' if (source === 'camera') return 'Camera'
if (source === 'hardware-scanner') return 'Scanner' if (source === 'hardware-scanner') return 'Scanner'
if (source === 'keyboard-paste') return 'Paste' if (source === 'keyboard-paste') return 'Paste'
return 'Manual' return 'Manual'
} }
function getMatchCandidates(items, query, scannedBarcode) { function getMatchCandidates(items: InventoryItem[], query: string, scannedBarcode: string): InventoryItem[] {
const normalizedQuery = query.trim().toLowerCase() const normalizedQuery = query.trim().toLowerCase()
const normalizedBarcode = normalizeBarcode(scannedBarcode) const normalizedBarcode = normalizeBarcode(scannedBarcode)
@@ -57,25 +68,25 @@ function getMatchCandidates(items, query, scannedBarcode) {
function BarcodePage() { function BarcodePage() {
const { isAuthenticated } = useAuth() const { isAuthenticated } = useAuth()
const [mode, setMode] = useState('camera') const [mode, setMode] = useState<ScanMode>('camera')
const [cameraActive, setCameraActive] = useState(false) const [cameraActive, setCameraActive] = useState(false)
const [manualBarcodeInput, setManualBarcodeInput] = useState('') const [manualBarcodeInput, setManualBarcodeInput] = useState('')
const [inventoryLoading, setInventoryLoading] = useState(false) const [inventoryLoading, setInventoryLoading] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [errorMessage, setErrorMessage] = useState(null) const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [statusMessage, setStatusMessage] = useState('') const [statusMessage, setStatusMessage] = useState('')
const [lastScannedBarcode, setLastScannedBarcode] = useState('') const [lastScannedBarcode, setLastScannedBarcode] = useState('')
const [lastScanSource, setLastScanSource] = useState('') const [lastScanSource, setLastScanSource] = useState<ScanSource>('')
const [locations, setLocations] = useState([]) const [locations, setLocations] = useState<Location[]>([])
const [items, setItems] = useState([]) const [items, setItems] = useState<InventoryItem[]>([])
const [editorMode, setEditorMode] = useState('') const [editorMode, setEditorMode] = useState<EditorMode>('')
const [editingItemId, setEditingItemId] = useState('') const [editingItemId, setEditingItemId] = useState('')
const [itemForm, setItemForm] = useState(createItemForm()) const [itemForm, setItemForm] = useState<InventoryItemForm>(createItemForm())
const [matchQuery, setMatchQuery] = useState('') const [matchQuery, setMatchQuery] = useState('')
const inputRef = useRef(null) const inputRef = useRef<HTMLInputElement | null>(null)
const scannerTargetRef = useRef(null) const scannerTargetRef = useRef<HTMLDivElement | null>(null)
const detectedHandlerRef = useRef(null) const detectedHandlerRef = useRef<QuaggaJSResultCallbackFunction | null>(null)
const lastCameraScanRef = useRef({ barcode: '', at: 0 }) const lastCameraScanRef = useRef({ barcode: '', at: 0 })
const matchingItems = findItemsByBarcode(items, lastScannedBarcode) const matchingItems = findItemsByBarcode(items, lastScannedBarcode)
@@ -84,7 +95,7 @@ function BarcodePage() {
const hasExactMatches = matchingItems.length > 0 const hasExactMatches = matchingItems.length > 0
const quickAddSourceItem = matchingItems.find(item => item.id === editingItemId) ?? matchingItems[0] ?? null const quickAddSourceItem = matchingItems.find(item => item.id === editingItemId) ?? matchingItems[0] ?? null
const fetchInventoryData = useCallback(async () => { const fetchInventoryData = useCallback(async (): Promise<InventoryData> => {
const [nextLocations, nextItems] = await Promise.all([ const [nextLocations, nextItems] = await Promise.all([
locationsApi.getLocations(), locationsApi.getLocations(),
inventoryApi.getInventoryItems(), inventoryApi.getInventoryItems(),
@@ -93,7 +104,7 @@ function BarcodePage() {
return { nextLocations, nextItems } return { nextLocations, nextItems }
}, []) }, [])
const applyInventoryData = useCallback(({ nextLocations, nextItems }) => { const applyInventoryData = useCallback(({ nextLocations, nextItems }: InventoryData) => {
setLocations(nextLocations) setLocations(nextLocations)
setItems(nextItems) setItems(nextItems)
}, []) }, [])
@@ -126,7 +137,7 @@ function BarcodePage() {
setCameraActive(false) setCameraActive(false)
}, [clearScannerTarget]) }, [clearScannerTarget])
const onBarcodeScanned = useCallback((barcode, inputType) => { const onBarcodeScanned = useCallback((barcode: string | null | undefined, inputType: ScanSource) => {
const normalizedBarcode = normalizeBarcode(barcode) const normalizedBarcode = normalizeBarcode(barcode)
if (!normalizedBarcode) { if (!normalizedBarcode) {
@@ -206,15 +217,17 @@ function BarcodePage() {
throw new Error('The camera preview is not ready yet. Try again.') throw new Error('The camera preview is not ready yet. Try again.')
} }
const scannerTarget = scannerTargetRef.current
stopCamera() stopCamera()
setErrorMessage('') setErrorMessage('')
setStatusMessage('') setStatusMessage('')
await new Promise((resolve, reject) => { await new Promise<void>((resolve, reject) => {
Quagga.init({ Quagga.init({
inputStream: { inputStream: {
type: 'LiveStream', type: 'LiveStream',
target: scannerTargetRef.current, target: scannerTarget,
constraints: { constraints: {
facingMode: { ideal: 'environment' }, facingMode: { ideal: 'environment' },
width: { ideal: 1280 }, width: { ideal: 1280 },
@@ -242,7 +255,7 @@ function BarcodePage() {
}) })
}) })
const detectedHandler = result => { const detectedHandler: QuaggaJSResultCallbackFunction = result => {
const barcode = result?.codeResult?.code const barcode = result?.codeResult?.code
if (!barcode) { if (!barcode) {
@@ -344,6 +357,26 @@ function BarcodePage() {
} }
} }
async function handleQuickRemove() {
if (!hasScannedBarcode || !quickAddSourceItem) {
return
}
setSubmitting(true)
setErrorMessage('')
setStatusMessage('')
try {
await inventoryApi.deleteInventoryItem(quickAddSourceItem.id)
await refreshInventoryData()
setStatusMessage(`Quick removed ${quickAddSourceItem.name || 'item'}.`)
} catch (error) {
setErrorMessage(error.message)
} finally {
setSubmitting(false)
}
}
async function handleRefreshInventory() { async function handleRefreshInventory() {
setInventoryLoading(true) setInventoryLoading(true)
setErrorMessage('') setErrorMessage('')
@@ -358,7 +391,7 @@ function BarcodePage() {
} }
} }
async function handleEditItem(itemId) { async function handleEditItem(itemId: string) {
setSubmitting(true) setSubmitting(true)
setErrorMessage('') setErrorMessage('')
setStatusMessage('') setStatusMessage('')
@@ -375,7 +408,7 @@ function BarcodePage() {
} }
} }
async function handleItemSubmit(event) { async function handleItemSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
const name = itemForm.name.trim() const name = itemForm.name.trim()
@@ -424,7 +457,7 @@ function BarcodePage() {
} }
} }
async function handleMatchItem(itemId, itemName, currentBarcode) { async function handleMatchItem(itemId: string, itemName: string, currentBarcode?: string | null) {
const confirmed = window.confirm( const confirmed = window.confirm(
currentBarcode currentBarcode
? `Replace the current barcode on ${itemName} with ${lastScannedBarcode}?` ? `Replace the current barcode on ${itemName} with ${lastScannedBarcode}?`
@@ -521,6 +554,24 @@ function BarcodePage() {
</button> </button>
)} )}
<button
type="button"
className="btn btn-primary"
onClick={handleQuickAdd}
disabled={!hasScannedBarcode || submitting || inventoryLoading}
>
Quick Add
</button>
<button
type="button"
className="btn btn-danger"
onClick={handleQuickRemove}
disabled={!hasScannedBarcode || !hasExactMatches || submitting || inventoryLoading}
>
Quick Remove
</button>
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
@@ -574,6 +625,22 @@ function BarcodePage() {
> >
Check barcode Check barcode
</button> </button>
<button
type="button"
className="btn btn-primary"
onClick={handleQuickAdd}
disabled={!hasScannedBarcode || submitting || inventoryLoading}
>
Quick Add
</button>
<button
type="button"
className="btn btn-danger"
onClick={handleQuickRemove}
disabled={!hasScannedBarcode || !hasExactMatches || submitting || inventoryLoading}
>
Quick Remove
</button>
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
@@ -592,16 +659,6 @@ function BarcodePage() {
</div> </div>
)} )}
<div className="quick-actions">
<button
type="button"
className="btn btn-primary"
onClick={handleQuickAdd}
disabled={!hasScannedBarcode || submitting || inventoryLoading}
>
Quick Add
</button>
</div>
</section> </section>
{/* <section className="panel scan-summary-panel"> {/* <section className="panel scan-summary-panel">

View File

@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import PieChart from '../../components/PieChart/PieChart.jsx' import PieChart from '../../components/PieChart/PieChart'
import ItemCard from '../../components/ItemCard/ItemCard.jsx' import ItemCard from '../../components/ItemCard/ItemCard'
import { inventoryApi, locationsApi } from '../../api/client.js' import { inventoryApi, locationsApi } from '../../api/client'
import { useAuth } from '../../context/AuthContext.jsx' import { useAuth } from '../../context/AuthContext'
import { formatAmount, formatDate, getExpiryStatus } from '../../utils/searchUtils.js' import { formatAmount, formatDate, getExpiryStatus } from '../../utils/searchUtils'
import type { PieSegment } from '../../components/PieChart/PieChart'
import type { InventoryItem, Location } from '../../types/models'
import './HomePage.css' import './HomePage.css'
const PIE_COLORS = ['#2563eb', '#0f766e', '#9333ea', '#ea580c', '#dc2626', '#0891b2'] const PIE_COLORS = ['#2563eb', '#0f766e', '#9333ea', '#ea580c', '#dc2626', '#0891b2']
@@ -13,7 +15,7 @@ const INITIAL_LOGIN_FORM = {
password: '', password: '',
} }
function buildPieSegments(items, locations) { function buildPieSegments(items: InventoryItem[], locations: Location[]): PieSegment[] {
const counts = new Map(locations.map(location => [location.name, 0])) const counts = new Map(locations.map(location => [location.name, 0]))
items.forEach(item => { items.forEach(item => {
@@ -30,7 +32,7 @@ function buildPieSegments(items, locations) {
})) }))
} }
function sortByExpiry(items) { function sortByExpiry(items: InventoryItem[]): InventoryItem[] {
return [...items].sort((left, right) => { return [...items].sort((left, right) => {
const leftDate = left.expiryDate ? new Date(left.expiryDate).getTime() : Number.MAX_SAFE_INTEGER const leftDate = left.expiryDate ? new Date(left.expiryDate).getTime() : Number.MAX_SAFE_INTEGER
const rightDate = right.expiryDate ? new Date(right.expiryDate).getTime() : Number.MAX_SAFE_INTEGER const rightDate = right.expiryDate ? new Date(right.expiryDate).getTime() : Number.MAX_SAFE_INTEGER
@@ -45,8 +47,8 @@ function HomePage() {
const [formStatus, setFormStatus] = useState('') const [formStatus, setFormStatus] = useState('')
const [dashboardError, setDashboardError] = useState('') const [dashboardError, setDashboardError] = useState('')
const [dashboardLoading, setDashboardLoading] = useState(false) const [dashboardLoading, setDashboardLoading] = useState(false)
const [locations, setLocations] = useState([]) const [locations, setLocations] = useState<Location[]>([])
const [items, setItems] = useState([]) const [items, setItems] = useState<InventoryItem[]>([])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -90,7 +92,7 @@ function HomePage() {
} }
}, [isAuthenticated]) }, [isAuthenticated])
async function handleLoginSubmit(event) { async function handleLoginSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
setFormError('') setFormError('')
setFormStatus('') setFormStatus('')

View File

@@ -1,22 +1,33 @@
import { useEffect, useState } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { import {
inventoryApi, inventoryApi,
locationsApi, locationsApi,
} from '../../api/client.js' } from '../../api/client'
import { useAuth } from '../../context/AuthContext.jsx' import { useAuth } from '../../context/AuthContext'
import { import {
buildInventoryPayload, buildInventoryPayload,
createItemForm, createItemForm,
mapItemToForm, mapItemToForm,
} from '../../utils/inventoryItemUtils.js' } from '../../utils/inventoryItemUtils'
import { import {
formatAmount, formatAmount,
formatDate, formatDate,
getExpiryStatus, getExpiryStatus,
} from '../../utils/searchUtils.js' } from '../../utils/searchUtils'
import type {
InventoryItem,
InventoryItemForm,
Location,
LocationHistoryEntry,
} from '../../types/models'
import './InventoryPage.css' import './InventoryPage.css'
const EMPTY_LOCATION_FORM = { interface LocationForm {
name: string
description: string
}
const EMPTY_LOCATION_FORM: LocationForm = {
name: '', name: '',
description: '', description: '',
} }
@@ -26,19 +37,19 @@ function InventoryPage() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')
const [statusMessage, setStatusMessage] = useState('') const [statusMessage, setStatusMessage] = useState('')
const [locations, setLocations] = useState([]) const [locations, setLocations] = useState<Location[]>([])
const [items, setItems] = useState([]) const [items, setItems] = useState<InventoryItem[]>([])
const [selectedLocationId, setSelectedLocationId] = useState('') const [selectedLocationId, setSelectedLocationId] = useState('')
const [locationHistory, setLocationHistory] = useState([]) const [locationHistory, setLocationHistory] = useState<LocationHistoryEntry[]>([])
const [locationHistoryLoading, setLocationHistoryLoading] = useState(false) const [locationHistoryLoading, setLocationHistoryLoading] = useState(false)
const [locationHistoryError, setLocationHistoryError] = useState('') const [locationHistoryError, setLocationHistoryError] = useState('')
const [editingLocationId, setEditingLocationId] = useState('') const [editingLocationId, setEditingLocationId] = useState('')
const [editingItemId, setEditingItemId] = useState('') const [editingItemId, setEditingItemId] = useState('')
const [selectedItem, setSelectedItem] = useState(null) const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null)
const [locationForm, setLocationForm] = useState(EMPTY_LOCATION_FORM) const [locationForm, setLocationForm] = useState<LocationForm>(EMPTY_LOCATION_FORM)
const [itemForm, setItemForm] = useState(createItemForm()) const [itemForm, setItemForm] = useState<InventoryItemForm>(createItemForm())
function syncLocations(nextLocations) { function syncLocations(nextLocations: Location[]) {
setLocations(nextLocations) setLocations(nextLocations)
setSelectedLocationId(currentLocationId => { setSelectedLocationId(currentLocationId => {
const targetLocationId = currentLocationId || editingLocationId const targetLocationId = currentLocationId || editingLocationId
@@ -165,7 +176,7 @@ function InventoryPage() {
setItemForm(createItemForm()) setItemForm(createItemForm())
} }
async function handleLocationSubmit(event) { async function handleLocationSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
const name = locationForm.name.trim() const name = locationForm.name.trim()
@@ -202,7 +213,7 @@ function InventoryPage() {
} }
} }
async function handleDeleteLocation(locationId) { async function handleDeleteLocation(locationId: string) {
const confirmed = window.confirm('Delete this location? Inventory items still linked to it may block deletion.') const confirmed = window.confirm('Delete this location? Inventory items still linked to it may block deletion.')
if (!confirmed) return if (!confirmed) return
@@ -233,7 +244,7 @@ function InventoryPage() {
} }
} }
async function handleEditItem(itemId) { async function handleEditItem(itemId: string) {
setLoading(true) setLoading(true)
setErrorMessage('') setErrorMessage('')
setStatusMessage('') setStatusMessage('')
@@ -250,7 +261,7 @@ function InventoryPage() {
} }
} }
async function handleItemSubmit(event) { async function handleItemSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
const name = itemForm.name.trim() const name = itemForm.name.trim()
@@ -292,7 +303,7 @@ function InventoryPage() {
} }
} }
async function handleDeleteItem(itemId) { async function handleDeleteItem(itemId: string) {
const confirmed = window.confirm('Delete this inventory item?') const confirmed = window.confirm('Delete this inventory item?')
if (!confirmed) return if (!confirmed) return
@@ -360,7 +371,7 @@ function InventoryPage() {
<label htmlFor="location-description">Description</label> <label htmlFor="location-description">Description</label>
<textarea <textarea
id="location-description" id="location-description"
rows="3" rows={3}
value={locationForm.description} value={locationForm.description}
onChange={event => setLocationForm(current => ({ ...current, description: event.target.value }))} onChange={event => setLocationForm(current => ({ ...current, description: event.target.value }))}
placeholder="Main kitchen shelf" placeholder="Main kitchen shelf"

View File

@@ -1,20 +1,28 @@
import { useEffect, useState } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { import {
householdsApi, householdsApi,
inventoryApi, inventoryApi,
mealPlannersApi, mealPlannersApi,
} from '../../api/client.js' } from '../../api/client'
import { useAuth } from '../../context/AuthContext.jsx' import { useAuth } from '../../context/AuthContext'
import { import {
formatAmount, formatAmount,
formatDate, formatDate,
formatTime, formatTime,
toDateInputValue, toDateInputValue,
toTimeInputValue, toTimeInputValue,
} from '../../utils/searchUtils.js' } from '../../utils/searchUtils'
import type {
Household,
InventoryItem,
MealPlanner,
MealPlannerForm,
MealPlannerItemForm,
MealPlannerItemPayload,
} from '../../types/models'
import '../PlanningPage.css' import '../PlanningPage.css'
function createMealPlannerItemForm() { function createMealPlannerItemForm(): MealPlannerItemForm {
return { return {
inventoryItemId: '', inventoryItemId: '',
amountRequired: '', amountRequired: '',
@@ -22,7 +30,7 @@ function createMealPlannerItemForm() {
} }
} }
function createMealPlannerForm(householdId = '') { function createMealPlannerForm(householdId = ''): MealPlannerForm {
return { return {
name: '', name: '',
householdId, householdId,
@@ -32,7 +40,7 @@ function createMealPlannerForm(householdId = '') {
} }
} }
function mapMealPlannerToForm(mealPlanner) { function mapMealPlannerToForm(mealPlanner: MealPlanner | null): MealPlannerForm {
return { return {
name: mealPlanner?.name ?? '', name: mealPlanner?.name ?? '',
householdId: mealPlanner?.householdId ?? '', householdId: mealPlanner?.householdId ?? '',
@@ -48,16 +56,16 @@ function mapMealPlannerToForm(mealPlanner) {
} }
} }
function resolveHouseholdName(households, householdId) { function resolveHouseholdName(households: Household[], householdId: string): string {
return households.find(household => household.id === householdId)?.name ?? 'Unknown household' return households.find(household => household.id === householdId)?.name ?? 'Unknown household'
} }
function toApiTimeValue(timeValue) { function toApiTimeValue(timeValue: string): string {
if (!timeValue) return '' if (!timeValue) return ''
return timeValue.length === 5 ? `${timeValue}:00` : timeValue return timeValue.length === 5 ? `${timeValue}:00` : timeValue
} }
function normalizeMealPlannerItems(items) { function normalizeMealPlannerItems(items: MealPlannerItemForm[]): MealPlannerItemPayload[] {
const normalizedItems = items const normalizedItems = items
.filter(item => item.inventoryItemId || item.amountRequired !== '' || item.amountType.trim()) .filter(item => item.inventoryItemId || item.amountRequired !== '' || item.amountType.trim())
.map((item, index) => { .map((item, index) => {
@@ -97,12 +105,12 @@ function MealPlannersPage() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')
const [statusMessage, setStatusMessage] = useState('') const [statusMessage, setStatusMessage] = useState('')
const [households, setHouseholds] = useState([]) const [households, setHouseholds] = useState<Household[]>([])
const [inventoryItems, setInventoryItems] = useState([]) const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([])
const [mealPlanners, setMealPlanners] = useState([]) const [mealPlanners, setMealPlanners] = useState<MealPlanner[]>([])
const [selectedMealPlannerId, setSelectedMealPlannerId] = useState('') const [selectedMealPlannerId, setSelectedMealPlannerId] = useState('')
const [editingMealPlannerId, setEditingMealPlannerId] = useState('') const [editingMealPlannerId, setEditingMealPlannerId] = useState('')
const [mealPlannerForm, setMealPlannerForm] = useState(createMealPlannerForm()) const [mealPlannerForm, setMealPlannerForm] = useState<MealPlannerForm>(createMealPlannerForm())
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -169,7 +177,7 @@ function MealPlannersPage() {
})) }))
}, [editingMealPlannerId, households, mealPlannerForm.householdId]) }, [editingMealPlannerId, households, mealPlannerForm.householdId])
async function loadPageData(preferredSelectionId = '') { async function loadPageData(preferredSelectionId = ''): Promise<void> {
const [nextHouseholdsResponse, nextInventoryResponse, nextMealPlannersResponse] = await Promise.all([ const [nextHouseholdsResponse, nextInventoryResponse, nextMealPlannersResponse] = await Promise.all([
householdsApi.getHouseholds(), householdsApi.getHouseholds(),
inventoryApi.getInventoryItems(), inventoryApi.getInventoryItems(),
@@ -204,7 +212,7 @@ function MealPlannersPage() {
setMealPlannerForm(createMealPlannerForm(households[0]?.id ?? '')) setMealPlannerForm(createMealPlannerForm(households[0]?.id ?? ''))
} }
function updateItemRow(index, updates) { function updateItemRow(index: number, updates: Partial<MealPlannerItemForm>) {
setMealPlannerForm(currentForm => ({ setMealPlannerForm(currentForm => ({
...currentForm, ...currentForm,
items: currentForm.items.map((item, itemIndex) => ( items: currentForm.items.map((item, itemIndex) => (
@@ -222,14 +230,14 @@ function MealPlannersPage() {
})) }))
} }
function removeItemRow(index) { function removeItemRow(index: number) {
setMealPlannerForm(currentForm => ({ setMealPlannerForm(currentForm => ({
...currentForm, ...currentForm,
items: currentForm.items.filter((_, itemIndex) => itemIndex !== index), items: currentForm.items.filter((_, itemIndex) => itemIndex !== index),
})) }))
} }
async function handleEditMealPlanner(mealPlannerId) { async function handleEditMealPlanner(mealPlannerId: string) {
setLoading(true) setLoading(true)
setErrorMessage('') setErrorMessage('')
setStatusMessage('') setStatusMessage('')
@@ -246,7 +254,7 @@ function MealPlannersPage() {
} }
} }
async function handleMealPlannerSubmit(event) { async function handleMealPlannerSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
const name = mealPlannerForm.name.trim() const name = mealPlannerForm.name.trim()
@@ -311,7 +319,7 @@ function MealPlannersPage() {
} }
} }
async function handleDeleteMealPlanner(mealPlannerId) { async function handleDeleteMealPlanner(mealPlannerId: string) {
const confirmed = window.confirm('Delete this meal planner?') const confirmed = window.confirm('Delete this meal planner?')
if (!confirmed) return if (!confirmed) return
@@ -543,7 +551,7 @@ function MealPlannersPage() {
{(mealPlanner.items ?? []).length === 0 ? ( {(mealPlanner.items ?? []).length === 0 ? (
<div className="entity-meta">No items added yet.</div> <div className="entity-meta">No items added yet.</div>
) : ( ) : (
mealPlanner.items.map(item => ( (mealPlanner.items ?? []).map(item => (
<div className="planning-summary-row" key={item.inventoryItemId}> <div className="planning-summary-row" key={item.inventoryItemId}>
<div> <div>
<strong>{item.inventoryItemName || 'Unnamed item'}</strong> <strong>{item.inventoryItemName || 'Unnamed item'}</strong>
@@ -594,4 +602,4 @@ function MealPlannersPage() {
) )
} }
export default MealPlannersPage export default MealPlannersPage

View File

@@ -1,9 +1,18 @@
import { useEffect, useState } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { profileApi } from '../../api/client.js' import { profileApi } from '../../api/client'
import { useAuth } from '../../context/AuthContext.jsx' import { useAuth } from '../../context/AuthContext'
import type { User } from '../../types/models'
import './ProfilePage.css' import './ProfilePage.css'
const EMPTY_PROFILE_FORM = { interface ProfileForm {
email: string
firstName: string
lastName: string
currentPassword: string
newPassword: string
}
const EMPTY_PROFILE_FORM: ProfileForm = {
email: '', email: '',
firstName: '', firstName: '',
lastName: '', lastName: '',
@@ -11,7 +20,7 @@ const EMPTY_PROFILE_FORM = {
newPassword: '', newPassword: '',
} }
function createProfileForm(user) { function createProfileForm(user: User | null): ProfileForm {
return { return {
email: user?.email ?? '', email: user?.email ?? '',
firstName: user?.firstName ?? '', firstName: user?.firstName ?? '',
@@ -27,7 +36,7 @@ function ProfilePage() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')
const [statusMessage, setStatusMessage] = useState('') const [statusMessage, setStatusMessage] = useState('')
const [profileForm, setProfileForm] = useState(() => createProfileForm(user)) const [profileForm, setProfileForm] = useState<ProfileForm>(() => createProfileForm(user))
const roles = Array.isArray(user?.roles) ? user.roles : [] const roles = Array.isArray(user?.roles) ? user.roles : []
@@ -64,7 +73,7 @@ function ProfilePage() {
} }
} }
async function handleProfileSubmit(event) { async function handleProfileSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
const email = profileForm.email.trim() const email = profileForm.email.trim()
@@ -239,4 +248,4 @@ function ProfilePage() {
) )
} }
export default ProfilePage export default ProfilePage

View File

@@ -1,21 +1,26 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import ItemCard from '../../components/ItemCard/ItemCard.jsx' import ItemCard from '../../components/ItemCard/ItemCard'
import { import {
inventoryApi, inventoryApi,
locationsApi, locationsApi,
searchApi, searchApi,
} from '../../api/client.js' } from '../../api/client'
import { useAuth } from '../../context/AuthContext.jsx' import { useAuth } from '../../context/AuthContext'
import { import {
filterInventoryItems, filterInventoryItems,
formatAmount, formatAmount,
formatDate, formatDate,
getExpiryStatus, getExpiryStatus,
} from '../../utils/searchUtils.js' } from '../../utils/searchUtils'
import { getPaginatedResults } from '../../utils/paginationUtils.js' import { getPaginatedResults } from '../../utils/paginationUtils'
import type { InventoryItem, Location } from '../../types/models'
import './SearchPage.css' import './SearchPage.css'
function ExpiryBadge({ expiryDate }) { interface ExpiryBadgeProps {
expiryDate?: string | null
}
function ExpiryBadge({ expiryDate }: ExpiryBadgeProps) {
const status = getExpiryStatus(expiryDate) const status = getExpiryStatus(expiryDate)
const badgeClass = status.status === 'Expired' const badgeClass = status.status === 'Expired'
? 'expiry-expired' ? 'expiry-expired'
@@ -40,9 +45,9 @@ function SearchPage() {
const [maxExpiryDate, setMaxExpiryDate] = useState('') const [maxExpiryDate, setMaxExpiryDate] = useState('')
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(15) const [pageSize, setPageSize] = useState(15)
const [locations, setLocations] = useState([]) const [locations, setLocations] = useState<Location[]>([])
const [matchingLocations, setMatchingLocations] = useState([]) const [matchingLocations, setMatchingLocations] = useState<Location[]>([])
const [results, setResults] = useState([]) const [results, setResults] = useState<InventoryItem[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')

View File

@@ -1,14 +1,22 @@
import { useEffect, useState } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { import {
householdsApi, householdsApi,
inventoryApi, inventoryApi,
shoppingListsApi, shoppingListsApi,
} from '../../api/client.js' } from '../../api/client'
import { useAuth } from '../../context/AuthContext.jsx' import { useAuth } from '../../context/AuthContext'
import { formatAmount, formatDate } from '../../utils/searchUtils.js' import { formatAmount, formatDate } from '../../utils/searchUtils'
import type {
Household,
InventoryItem,
ShoppingList,
ShoppingListForm,
ShoppingListItemForm,
ShoppingListItemPayload,
} from '../../types/models'
import '../PlanningPage.css' import '../PlanningPage.css'
function createShoppingListItemForm() { function createShoppingListItemForm(): ShoppingListItemForm {
return { return {
inventoryItemId: '', inventoryItemId: '',
amountRequired: '', amountRequired: '',
@@ -17,7 +25,7 @@ function createShoppingListItemForm() {
} }
} }
function createShoppingListForm(householdId = '') { function createShoppingListForm(householdId = ''): ShoppingListForm {
return { return {
name: '', name: '',
householdId, householdId,
@@ -25,7 +33,7 @@ function createShoppingListForm(householdId = '') {
} }
} }
function mapShoppingListToForm(shoppingList) { function mapShoppingListToForm(shoppingList: ShoppingList | null): ShoppingListForm {
return { return {
name: shoppingList?.name ?? '', name: shoppingList?.name ?? '',
householdId: shoppingList?.householdId ?? '', householdId: shoppingList?.householdId ?? '',
@@ -40,11 +48,11 @@ function mapShoppingListToForm(shoppingList) {
} }
} }
function resolveHouseholdName(households, householdId) { function resolveHouseholdName(households: Household[], householdId: string): string {
return households.find(household => household.id === householdId)?.name ?? 'Unknown household' return households.find(household => household.id === householdId)?.name ?? 'Unknown household'
} }
function normalizeShoppingListItems(items) { function normalizeShoppingListItems(items: ShoppingListItemForm[]): ShoppingListItemPayload[] {
const normalizedItems = items const normalizedItems = items
.filter(item => item.inventoryItemId || item.amountRequired !== '' || item.amountType.trim() || item.isPurchased) .filter(item => item.inventoryItemId || item.amountRequired !== '' || item.amountType.trim() || item.isPurchased)
.map((item, index) => { .map((item, index) => {
@@ -85,12 +93,12 @@ function ShoppingListsPage() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')
const [statusMessage, setStatusMessage] = useState('') const [statusMessage, setStatusMessage] = useState('')
const [households, setHouseholds] = useState([]) const [households, setHouseholds] = useState<Household[]>([])
const [inventoryItems, setInventoryItems] = useState([]) const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([])
const [shoppingLists, setShoppingLists] = useState([]) const [shoppingLists, setShoppingLists] = useState<ShoppingList[]>([])
const [selectedShoppingListId, setSelectedShoppingListId] = useState('') const [selectedShoppingListId, setSelectedShoppingListId] = useState('')
const [editingShoppingListId, setEditingShoppingListId] = useState('') const [editingShoppingListId, setEditingShoppingListId] = useState('')
const [shoppingListForm, setShoppingListForm] = useState(createShoppingListForm()) const [shoppingListForm, setShoppingListForm] = useState<ShoppingListForm>(createShoppingListForm())
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -157,7 +165,7 @@ function ShoppingListsPage() {
})) }))
}, [editingShoppingListId, households, shoppingListForm.householdId]) }, [editingShoppingListId, households, shoppingListForm.householdId])
async function loadPageData(preferredSelectionId = '') { async function loadPageData(preferredSelectionId = ''): Promise<void> {
const [nextHouseholdsResponse, nextInventoryResponse, nextShoppingListsResponse] = await Promise.all([ const [nextHouseholdsResponse, nextInventoryResponse, nextShoppingListsResponse] = await Promise.all([
householdsApi.getHouseholds(), householdsApi.getHouseholds(),
inventoryApi.getInventoryItems(), inventoryApi.getInventoryItems(),
@@ -192,7 +200,7 @@ function ShoppingListsPage() {
setShoppingListForm(createShoppingListForm(households[0]?.id ?? '')) setShoppingListForm(createShoppingListForm(households[0]?.id ?? ''))
} }
function updateItemRow(index, updates) { function updateItemRow(index: number, updates: Partial<ShoppingListItemForm>) {
setShoppingListForm(currentForm => ({ setShoppingListForm(currentForm => ({
...currentForm, ...currentForm,
items: currentForm.items.map((item, itemIndex) => ( items: currentForm.items.map((item, itemIndex) => (
@@ -210,14 +218,14 @@ function ShoppingListsPage() {
})) }))
} }
function removeItemRow(index) { function removeItemRow(index: number) {
setShoppingListForm(currentForm => ({ setShoppingListForm(currentForm => ({
...currentForm, ...currentForm,
items: currentForm.items.filter((_, itemIndex) => itemIndex !== index), items: currentForm.items.filter((_, itemIndex) => itemIndex !== index),
})) }))
} }
async function handleEditShoppingList(shoppingListId) { async function handleEditShoppingList(shoppingListId: string) {
setLoading(true) setLoading(true)
setErrorMessage('') setErrorMessage('')
setStatusMessage('') setStatusMessage('')
@@ -234,7 +242,7 @@ function ShoppingListsPage() {
} }
} }
async function handleShoppingListSubmit(event) { async function handleShoppingListSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
const name = shoppingListForm.name.trim() const name = shoppingListForm.name.trim()
@@ -287,7 +295,7 @@ function ShoppingListsPage() {
} }
} }
async function handleDeleteShoppingList(shoppingListId) { async function handleDeleteShoppingList(shoppingListId: string) {
const confirmed = window.confirm('Delete this shopping list?') const confirmed = window.confirm('Delete this shopping list?')
if (!confirmed) return if (!confirmed) return
@@ -504,7 +512,7 @@ function ShoppingListsPage() {
{(shoppingList.items ?? []).length === 0 ? ( {(shoppingList.items ?? []).length === 0 ? (
<div className="entity-meta">No items added yet.</div> <div className="entity-meta">No items added yet.</div>
) : ( ) : (
shoppingList.items.map(item => ( (shoppingList.items ?? []).map(item => (
<div className="planning-summary-row" key={item.inventoryItemId}> <div className="planning-summary-row" key={item.inventoryItemId}>
<div> <div>
<strong>{item.inventoryItemName || 'Unnamed item'}</strong> <strong>{item.inventoryItemName || 'Unnamed item'}</strong>
@@ -557,4 +565,4 @@ function ShoppingListsPage() {
) )
} }
export default ShoppingListsPage export default ShoppingListsPage

View File

@@ -1,9 +1,18 @@
import { useEffect, useState } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { usersApi } from '../../api/client.js' import { usersApi } from '../../api/client'
import { useAuth } from '../../context/AuthContext.jsx' import { useAuth } from '../../context/AuthContext'
import type { User } from '../../types/models'
import './UsersPage.css' import './UsersPage.css'
const EMPTY_USER_FORM = { interface UserEditForm {
email: string
firstName: string
lastName: string
password: string
rolesInput: string
}
const EMPTY_USER_FORM: UserEditForm = {
email: '', email: '',
firstName: '', firstName: '',
lastName: '', lastName: '',
@@ -11,7 +20,7 @@ const EMPTY_USER_FORM = {
rolesInput: '', rolesInput: '',
} }
function formatUserName(user) { function formatUserName(user: Pick<User, 'email' | 'firstName' | 'lastName'>): string {
const fullName = [user.firstName, user.lastName] const fullName = [user.firstName, user.lastName]
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
@@ -19,7 +28,7 @@ function formatUserName(user) {
return fullName || user.email || 'Unnamed user' return fullName || user.email || 'Unnamed user'
} }
function mapUserToForm(user) { function mapUserToForm(user: User | null): UserEditForm {
return { return {
email: user?.email ?? '', email: user?.email ?? '',
firstName: user?.firstName ?? '', firstName: user?.firstName ?? '',
@@ -29,7 +38,7 @@ function mapUserToForm(user) {
} }
} }
function parseRoles(value) { function parseRoles(value: string): string[] {
return value return value
.split(',') .split(',')
.map(role => role.trim()) .map(role => role.trim())
@@ -38,16 +47,16 @@ function parseRoles(value) {
function UsersPage() { function UsersPage() {
const { isAuthenticated, isSiteAdmin, refreshProfile, user } = useAuth() const { isAuthenticated, isSiteAdmin, refreshProfile, user } = useAuth()
const [users, setUsers] = useState([]) const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')
const [statusMessage, setStatusMessage] = useState('') const [statusMessage, setStatusMessage] = useState('')
const [selectedUserId, setSelectedUserId] = useState('') const [selectedUserId, setSelectedUserId] = useState('')
const [editingUserId, setEditingUserId] = useState('') const [editingUserId, setEditingUserId] = useState('')
const [userForm, setUserForm] = useState(EMPTY_USER_FORM) const [userForm, setUserForm] = useState<UserEditForm>(EMPTY_USER_FORM)
async function loadUsers(preferredSelectionId = '') { async function loadUsers(preferredSelectionId = ''): Promise<void> {
const response = await usersApi.getUsers() const response = await usersApi.getUsers()
const nextUsers = Array.isArray(response) ? response : [] const nextUsers = Array.isArray(response) ? response : []
@@ -130,7 +139,7 @@ function UsersPage() {
} }
} }
async function handleEditUser(userId) { async function handleEditUser(userId: string) {
setLoading(true) setLoading(true)
setErrorMessage('') setErrorMessage('')
setStatusMessage('') setStatusMessage('')
@@ -147,7 +156,7 @@ function UsersPage() {
} }
} }
async function handleUserSubmit(event) { async function handleUserSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
if (!editingUserId) { if (!editingUserId) {
@@ -368,4 +377,4 @@ function UsersPage() {
) )
} }
export default UsersPage export default UsersPage

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: '', name: '',
barcode: '', barcode: '',
expiryDate: '', expiryDate: '',
@@ -10,19 +11,19 @@ export const EMPTY_ITEM_FORM = {
locationId: '', locationId: '',
} }
export function normalizeBarcode(value) { export function normalizeBarcode(value: unknown): string {
return String(value ?? '').trim() return String(value ?? '').trim()
} }
export function createItemForm(barcode = '') { export function createItemForm(barcode = ''): InventoryItemForm {
return { return {
...EMPTY_ITEM_FORM, ...EMPTY_ITEM_FORM,
barcode: normalizeBarcode(barcode), barcode: normalizeBarcode(barcode),
} }
} }
export function buildInventoryPayload(form, includeBlankText = false) { export function buildInventoryPayload(form: InventoryItemForm, includeBlankText = false): InventoryItemPayload {
const payload = {} const payload: InventoryItemPayload = {}
const name = form.name.trim() const name = form.name.trim()
const barcode = normalizeBarcode(form.barcode) const barcode = normalizeBarcode(form.barcode)
@@ -40,7 +41,7 @@ export function buildInventoryPayload(form, includeBlankText = false) {
return payload return payload
} }
export function mapItemToForm(item) { export function mapItemToForm(item: InventoryItem): InventoryItemForm {
return { return {
name: item.name ?? '', name: item.name ?? '',
barcode: item.barcode ?? '', barcode: item.barcode ?? '',
@@ -52,7 +53,7 @@ export function mapItemToForm(item) {
} }
} }
export function findItemsByBarcode(items, barcode) { export function findItemsByBarcode(items: InventoryItem[], barcode: string): InventoryItem[] {
const normalizedBarcode = normalizeBarcode(barcode) const normalizedBarcode = normalizeBarcode(barcode)
if (!normalizedBarcode) { if (!normalizedBarcode) {
@@ -60,4 +61,4 @@ export function findItemsByBarcode(items, barcode) {
} }
return items.filter(item => normalizeBarcode(item.barcode) === normalizedBarcode) return items.filter(item => normalizeBarcode(item.barcode) === 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 totalItems = filteredItems.length
const totalPages = Math.ceil(totalItems / pageSize) || 1 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 '' if (!dateStr) return ''
const normalizedDate = toDateInputValue(dateStr) const normalizedDate = toDateInputValue(dateStr)
@@ -9,7 +26,7 @@ export function formatDate(dateStr) {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} }
export function formatTime(timeStr) { export function formatTime(timeStr: string | null | undefined): string {
const normalizedTime = toTimeInputValue(timeStr) const normalizedTime = toTimeInputValue(timeStr)
if (!normalizedTime) return '' if (!normalizedTime) return ''
@@ -27,17 +44,17 @@ export function formatTime(timeStr) {
}) })
} }
export function toDateInputValue(dateStr) { export function toDateInputValue(dateStr: string | null | undefined): string {
if (!dateStr) return '' if (!dateStr) return ''
return String(dateStr).slice(0, 10) return String(dateStr).slice(0, 10)
} }
export function toTimeInputValue(timeStr) { export function toTimeInputValue(timeStr: string | null | undefined): string {
if (!timeStr) return '' if (!timeStr) return ''
return String(timeStr).slice(0, 5) return String(timeStr).slice(0, 5)
} }
export function getExpiryStatus(expiryDate) { export function getExpiryStatus(expiryDate: string | null | undefined): ExpiryStatus {
if (!expiryDate) return { status: 'Unknown', color: '#999', text: '' } if (!expiryDate) return { status: 'Unknown', color: '#999', text: '' }
const today = new Date() const today = new Date()
@@ -50,7 +67,7 @@ export function getExpiryStatus(expiryDate) {
return { status: 'Unknown', color: '#999', text: '' } return { status: 'Unknown', color: '#999', text: '' }
} }
const daysUntilExpiry = Math.floor((expiry - today) / (1000 * 60 * 60 * 24)) const daysUntilExpiry = Math.floor((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
if (daysUntilExpiry < 0) { if (daysUntilExpiry < 0) {
return { status: 'Expired', color: '#f44336', days: daysUntilExpiry, text: `Expired ${Math.abs(daysUntilExpiry)} days ago` } return { status: 'Expired', color: '#f44336', days: daysUntilExpiry, text: `Expired ${Math.abs(daysUntilExpiry)} days ago` }
@@ -63,14 +80,17 @@ export function getExpiryStatus(expiryDate) {
} }
} }
export function formatAmount(amount, amountType = '') { export function formatAmount(
amount: number | string | null | undefined,
amountType: string | null | undefined = '',
): string {
if (amount == null || amount === '') return 'Amount not set' if (amount == null || amount === '') return 'Amount not set'
const unit = amountType?.trim() ?? '' const unit = amountType?.trim() ?? ''
return unit ? `${amount} ${unit}` : String(amount) return unit ? `${amount} ${unit}` : String(amount)
} }
export function filterInventoryItems(items, filters = {}) { export function filterInventoryItems(items: InventoryItem[], filters: InventoryFilters = {}): InventoryItem[] {
const { const {
locationId = '', locationId = '',
minAmount = 0, minAmount = 0,

1
src/vite-env.d.ts vendored Normal file
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"]
}