Compare commits

...

3 Commits

21 changed files with 2612 additions and 147 deletions

2
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,2 @@
When talking about the API for this project, use the following filepath to find all the files and documentation : "C:\Source\Programming Projects\pantry-manager-api-csharp"
Always update the README with the newest details and changes

View File

@@ -10,6 +10,7 @@ This app is no longer a static demo. It now uses the backend API for authenticat
- Added a shared API client with JWT handling, refresh-token retry, and normalized error messages. - Added a shared API client with JWT handling, refresh-token retry, and normalized error messages.
- Added shared auth/session state with `localStorage` persistence. - Added shared auth/session state with `localStorage` persistence.
- Updated the dashboard to support login and live inventory summary data. - Updated the dashboard to support login and live inventory summary data.
- Added a site-admin user creation form on the Admin page backed by `POST /api/auth/register`.
- Added a dedicated inventory management page for CRUD operations. - Added a dedicated inventory management page for CRUD operations.
- Updated search to call the backend search endpoints. - Updated search to call the backend search endpoints.
- Updated barcode scanning so scans look up matching inventory items through the API. - Updated barcode scanning so scans look up matching inventory items through the API.
@@ -20,7 +21,7 @@ This app is no longer a static demo. It now uses the backend API for authenticat
| Route | Purpose | | Route | Purpose |
| --- | --- | | --- | --- |
| `/` | Dashboard. Shows login when signed out, or inventory summary, chart, and nearest expiry data when signed in. | | `/` | Dashboard. Shows login when signed out, or inventory summary, chart, and nearest expiry data when signed in. |
| `/admin` | Household administration page for listing, creating, editing, inviting, and leaving households. | | `/admin` | Site-admin page for household administration and user creation. |
| `/inventory` | Manage locations and inventory items. | | `/inventory` | Manage locations and inventory items. |
| `/search` | Search items and locations, then filter results further in the UI. | | `/search` | Search items and locations, then filter results further in the UI. |
| `/barcode` | Scan a barcode and search the inventory API for matches. | | `/barcode` | Scan a barcode and search the inventory API for matches. |
@@ -68,10 +69,15 @@ Households:
- `POST /api/households/{id}/invite` - `POST /api/households/{id}/invite`
- `DELETE /api/households/{id}/leave` - `DELETE /api/households/{id}/leave`
Users:
- `POST /api/auth/register`
Notes: Notes:
- Household creation is limited to users with the backend `Admin` role. - The `/admin` route is only visible and accessible for users with the backend `Admin` role.
- Household editing and member invites are available when the current user is a household admin or site admin. - User creation on this page uses the register contract: `email`, `password`, `confirmPassword`, `firstName`, and `lastName`.
- The register endpoint does not assign roles, so new users are created without admin access by default.
### Search (`src/pages/SearchPage/SearchPage.jsx`) ### Search (`src/pages/SearchPage/SearchPage.jsx`)
@@ -114,8 +120,7 @@ The shared API client also supports:
Notes: Notes:
- `register` is still implemented in the client helper, but there is currently no registration form in the UI. - `register` is exposed in the Admin page for site admins and does not replace the current signed-in session.
- If you need to create a user, use the backend Swagger UI or another client to call `POST /api/auth/register`.
## API Configuration ## API Configuration
@@ -207,7 +212,7 @@ npm run preview
2. Enter an existing user email and password 2. Enter an existing user email and password
3. Sign in to unlock the protected routes and API-backed data 3. Sign in to unlock the protected routes and API-backed data
There is no registration form in the UI at the moment. There is no public registration form in the signed-out UI. Site admins can create accounts from `/admin`.
### Inventory Page ### Inventory Page
@@ -226,11 +231,11 @@ Important update behavior:
Use `/admin` to: Use `/admin` to:
- Review all households returned for the current user - Review household data as a site admin
- Create a new household when the signed-in user has the site admin role - Create a new household
- Edit households the signed-in user administers
- Invite members by email to the selected household - Invite members by email to the selected household
- Leave a household from the same page - Leave a household from the same page
- Create a new user account through the register endpoint
### Search Page ### Search Page

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Webpage Playground</title> <title>Pantry Manager</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,10 +1,13 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { 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.jsx'
import AdminPage from './pages/AdminPage/AdminPage.jsx' import AdminPage from './pages/AdminPage/AdminPage.jsx'
import HomePage from './pages/HomePage/HomePage.jsx' import HomePage from './pages/HomePage/HomePage.jsx'
import InventoryPage from './pages/InventoryPage/InventoryPage.jsx' import InventoryPage from './pages/InventoryPage/InventoryPage.jsx'
import MealPlannersPage from './pages/MealPlannersPage/MealPlannersPage.jsx'
import ProfilePage from './pages/ProfilePage/ProfilePage.jsx'
import SearchPage from './pages/SearchPage/SearchPage.jsx' import SearchPage from './pages/SearchPage/SearchPage.jsx'
import ShoppingListsPage from './pages/ShoppingListsPage/ShoppingListsPage.jsx'
import BarcodePage from './pages/BarcodePage/BarcodePage.jsx' import BarcodePage from './pages/BarcodePage/BarcodePage.jsx'
import UsersPage from './pages/UsersPage/UsersPage.jsx' import UsersPage from './pages/UsersPage/UsersPage.jsx'
import { useAuth } from './context/AuthContext.jsx' import { useAuth } from './context/AuthContext.jsx'
@@ -36,7 +39,7 @@ if (typeof document !== 'undefined') {
} }
function App() { function App() {
const { isAuthenticated } = useAuth() const { isAuthenticated, isSiteAdmin } = useAuth()
const [theme, setTheme] = useState(initialTheme) const [theme, setTheme] = useState(initialTheme)
useEffect(() => { useEffect(() => {
@@ -61,9 +64,12 @@ function App() {
{isAuthenticated ? ( {isAuthenticated ? (
<Routes> <Routes>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={isSiteAdmin ? <AdminPage /> : <Navigate to="/" replace />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/inventory" element={<InventoryPage />} /> <Route path="/inventory" element={<InventoryPage />} />
<Route path="/search" element={<SearchPage />} /> <Route path="/search" element={<SearchPage />} />
<Route path="/shopping-lists" element={<ShoppingListsPage />} />
<Route path="/meal-planners" element={<MealPlannersPage />} />
<Route path="/barcode" element={<BarcodePage />} /> <Route path="/barcode" element={<BarcodePage />} />
<Route path="/users" element={<UsersPage />} /> <Route path="/users" element={<UsersPage />} />
</Routes> </Routes>

View File

@@ -293,6 +293,13 @@ export const profileApi = {
getProtectedData() { getProtectedData() {
return requestJson('/api/profile/data') return requestJson('/api/profile/data')
}, },
updateProfile(payload) {
return requestJson('/api/profile', {
method: 'PUT',
body: payload,
})
},
} }
export const locationsApi = { export const locationsApi = {
@@ -300,6 +307,10 @@ export const locationsApi = {
return requestJson('/api/locations') return requestJson('/api/locations')
}, },
getLocationHistory(id) {
return requestJson(`/api/locations/${id}/history`)
},
createLocation(payload) { createLocation(payload) {
return requestJson('/api/locations', { return requestJson('/api/locations', {
method: 'POST', method: 'POST',
@@ -366,6 +377,10 @@ export const householdsApi = {
return requestJson('/api/households') return requestJson('/api/households')
}, },
getHouseholdHistory(id) {
return requestJson(`/api/households/${id}/history`)
},
createHousehold(payload) { createHousehold(payload) {
return requestJson('/api/households', { return requestJson('/api/households', {
method: 'POST', method: 'POST',
@@ -398,4 +413,75 @@ export const usersApi = {
getUsers() { getUsers() {
return requestJson('/api/users') 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',
})
},
} }

View File

@@ -5,6 +5,14 @@ import './Navbar.css'
const MOBILE_NAV_BREAKPOINT = 760 const MOBILE_NAV_BREAKPOINT = 760
function formatUserDisplayName(user) {
const fullName = [user?.firstName, user?.lastName]
.filter(part => typeof part === 'string' && part.trim())
.join(' ')
return fullName || user?.email || 'Signed in'
}
function SunIcon() { function SunIcon() {
return ( return (
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
@@ -27,6 +35,7 @@ function Navbar({ theme, onToggleTheme }) {
const location = useLocation() const location = useLocation()
const [isMenuOpen, setIsMenuOpen] = useState(false) const [isMenuOpen, setIsMenuOpen] = useState(false)
const nextThemeLabel = theme === 'dark' ? 'Switch to light' : 'Switch to dark' const nextThemeLabel = theme === 'dark' ? 'Switch to light' : 'Switch to dark'
const userDisplayName = formatUserDisplayName(user)
useEffect(() => { useEffect(() => {
setIsMenuOpen(false) setIsMenuOpen(false)
@@ -74,9 +83,12 @@ function Navbar({ theme, onToggleTheme }) {
{isAuthenticated ? ( {isAuthenticated ? (
<> <>
<li><NavLink to="/" onClick={closeMenu}>Homepage</NavLink></li> <li><NavLink to="/" onClick={closeMenu}>Homepage</NavLink></li>
<li><NavLink to="/admin" onClick={closeMenu}>Admin</NavLink></li> <li><NavLink to="/profile" onClick={closeMenu}>Profile</NavLink></li>
{isSiteAdmin && <li><NavLink to="/admin" onClick={closeMenu}>Admin</NavLink></li>}
<li><NavLink to="/inventory" onClick={closeMenu}>Inventory</NavLink></li> <li><NavLink to="/inventory" onClick={closeMenu}>Inventory</NavLink></li>
<li><NavLink to="/search" onClick={closeMenu}>Search</NavLink></li> <li><NavLink to="/search" onClick={closeMenu}>Search</NavLink></li>
<li><NavLink to="/shopping-lists" onClick={closeMenu}>Shopping Lists</NavLink></li>
<li><NavLink to="/meal-planners" onClick={closeMenu}>Meal Planners</NavLink></li>
<li><NavLink to="/barcode" onClick={closeMenu}>Barcode Scanner</NavLink></li> <li><NavLink to="/barcode" onClick={closeMenu}>Barcode Scanner</NavLink></li>
{isSiteAdmin && <li><NavLink to="/users" onClick={closeMenu}>Users</NavLink></li>} {isSiteAdmin && <li><NavLink to="/users" onClick={closeMenu}>Users</NavLink></li>}
</> </>
@@ -99,7 +111,7 @@ function Navbar({ theme, onToggleTheme }) {
<MoonIcon /> <MoonIcon />
</span> </span>
</button> </button>
<span className="nav-status__text">{isAuthenticated ? user?.email ?? 'Signed in' : 'Signed out'}</span> <span className="nav-status__text">{isAuthenticated ? userDisplayName : 'Signed out'}</span>
{isAuthenticated && ( {isAuthenticated && (
<button type="button" className="nav-button" onClick={() => { <button type="button" className="nav-button" onClick={() => {
closeMenu() closeMenu()

View File

@@ -8,11 +8,37 @@ import {
} from '../api/client.js' } from '../api/client.js'
const AuthContext = createContext(null) const AuthContext = createContext(null)
const SITE_ADMIN_ROLES = new Set(['Site Admin', 'Admin'])
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
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) {
const currentSession = getStoredSession()
if (!currentSession) {
return null
}
return saveSession({
...currentSession,
user: profile,
})
}
async function refreshProfile() {
const currentSession = getStoredSession()
if (!currentSession) {
return null
}
const profile = await profileApi.getProfile()
setCurrentUserProfile(profile)
return profile
}
useEffect(() => subscribeToSessionChanges(setSession), []) useEffect(() => subscribeToSessionChanges(setSession), [])
useEffect(() => { useEffect(() => {
@@ -32,10 +58,7 @@ export function AuthProvider({ children }) {
const profile = await profileApi.getProfile() const profile = await profileApi.getProfile()
if (!cancelled) { if (!cancelled) {
saveSession({ setCurrentUserProfile(profile)
...existingSession,
user: profile,
})
} }
} catch { } catch {
// The API client already clears invalid sessions after a failed refresh. // The API client already clears invalid sessions after a failed refresh.
@@ -60,11 +83,13 @@ export function AuthProvider({ children }) {
session, session,
user, user,
isAuthenticated: Boolean(session?.accessToken), isAuthenticated: Boolean(session?.accessToken),
isSiteAdmin: userRoles.includes('Admin'), isSiteAdmin: userRoles.some(role => SITE_ADMIN_ROLES.has(role)),
initializing, initializing,
login: authApi.login, login: authApi.login,
register: authApi.register, register: authApi.register,
logout: authApi.logout, logout: authApi.logout,
refreshProfile,
setCurrentUserProfile,
} }
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>

View File

@@ -78,6 +78,21 @@
min-width: 0; min-width: 0;
} }
.admin-created-user-card {
margin-top: 20px;
padding: 16px;
border-radius: 12px;
background: var(--color-surface-muted);
border: 1px solid var(--color-border-muted);
display: grid;
gap: 12px;
}
.admin-created-user-copy {
display: grid;
gap: 6px;
}
@media (max-width: 700px) { @media (max-width: 700px) {
.member-row { .member-row {
flex-direction: column; flex-direction: column;

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { householdsApi } from '../../api/client.js' import { householdsApi, usersApi } from '../../api/client.js'
import { useAuth } from '../../context/AuthContext.jsx' import { useAuth } from '../../context/AuthContext.jsx'
import { formatDate } from '../../utils/searchUtils.js' import { formatDate } from '../../utils/searchUtils.js'
import './AdminPage.css' import './AdminPage.css'
@@ -9,16 +9,24 @@ const EMPTY_HOUSEHOLD_FORM = {
description: '', description: '',
} }
function formatMemberName(member) { const EMPTY_USER_FORM = {
const fullName = [member.firstName, member.lastName] firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
}
function formatPersonName(person) {
const fullName = [person.firstName, person.lastName]
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
return fullName || member.email || 'Unnamed member' return fullName || person.email || 'Unnamed person'
} }
function AdminPage() { function AdminPage() {
const { isAuthenticated, isSiteAdmin } = useAuth() const { isAuthenticated, isSiteAdmin, register } = useAuth()
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('')
@@ -27,6 +35,11 @@ function AdminPage() {
const [editingHouseholdId, setEditingHouseholdId] = useState('') const [editingHouseholdId, setEditingHouseholdId] = useState('')
const [householdForm, setHouseholdForm] = useState(EMPTY_HOUSEHOLD_FORM) const [householdForm, setHouseholdForm] = useState(EMPTY_HOUSEHOLD_FORM)
const [inviteEmail, setInviteEmail] = useState('') const [inviteEmail, setInviteEmail] = useState('')
const [userForm, setUserForm] = useState(EMPTY_USER_FORM)
const [createdUser, setCreatedUser] = useState(null)
const [householdHistory, setHouseholdHistory] = useState([])
const [householdHistoryLoading, setHouseholdHistoryLoading] = useState(false)
const [householdHistoryError, setHouseholdHistoryError] = useState('')
async function loadHouseholds(preferredSelectionId = '') { async function loadHouseholds(preferredSelectionId = '') {
const response = await householdsApi.getHouseholds() const response = await householdsApi.getHouseholds()
@@ -59,6 +72,11 @@ function AdminPage() {
setEditingHouseholdId('') setEditingHouseholdId('')
setHouseholdForm(EMPTY_HOUSEHOLD_FORM) setHouseholdForm(EMPTY_HOUSEHOLD_FORM)
setInviteEmail('') setInviteEmail('')
setUserForm(EMPTY_USER_FORM)
setCreatedUser(null)
setHouseholdHistory([])
setHouseholdHistoryLoading(false)
setHouseholdHistoryError('')
setErrorMessage('') setErrorMessage('')
setStatusMessage('') setStatusMessage('')
return return
@@ -93,18 +111,62 @@ function AdminPage() {
} }
}, [isAuthenticated]) }, [isAuthenticated])
useEffect(() => {
let cancelled = false
async function loadSelectedHouseholdHistory() {
if (!isAuthenticated || !selectedHouseholdId) {
setHouseholdHistory([])
setHouseholdHistoryLoading(false)
setHouseholdHistoryError('')
return
}
setHouseholdHistoryLoading(true)
setHouseholdHistoryError('')
try {
const response = await householdsApi.getHouseholdHistory(selectedHouseholdId)
if (!cancelled) {
setHouseholdHistory(Array.isArray(response) ? response : [])
}
} catch (error) {
if (!cancelled) {
setHouseholdHistory([])
setHouseholdHistoryError(error.message)
}
} finally {
if (!cancelled) {
setHouseholdHistoryLoading(false)
}
}
}
loadSelectedHouseholdHistory()
return () => {
cancelled = true
}
}, [isAuthenticated, selectedHouseholdId])
const selectedHousehold = households.find(household => household.id === selectedHouseholdId) ?? null const selectedHousehold = households.find(household => household.id === selectedHouseholdId) ?? null
const editingHousehold = households.find(household => household.id === editingHouseholdId) ?? null const editingHousehold = households.find(household => household.id === editingHouseholdId) ?? null
const canManageSelectedHousehold = Boolean(selectedHousehold && (isSiteAdmin || selectedHousehold.isCurrentUserHouseholdAdmin)) const canManageSelectedHousehold = Boolean(selectedHousehold && (isSiteAdmin || selectedHousehold.isCurrentUserHouseholdAdmin))
const canSubmitHouseholdForm = editingHouseholdId const canSubmitHouseholdForm = editingHouseholdId
? Boolean(editingHousehold && (isSiteAdmin || editingHousehold.isCurrentUserHouseholdAdmin)) ? Boolean(editingHousehold && (isSiteAdmin || editingHousehold.isCurrentUserHouseholdAdmin))
: isSiteAdmin : isSiteAdmin
const createdUserRoles = Array.isArray(createdUser?.roles) ? createdUser.roles : []
function resetHouseholdEditor() { function resetHouseholdEditor() {
setEditingHouseholdId('') setEditingHouseholdId('')
setHouseholdForm(EMPTY_HOUSEHOLD_FORM) setHouseholdForm(EMPTY_HOUSEHOLD_FORM)
} }
function resetUserForm() {
setUserForm(EMPTY_USER_FORM)
}
async function handleHouseholdSubmit(event) { async function handleHouseholdSubmit(event) {
event.preventDefault() event.preventDefault()
@@ -182,6 +244,74 @@ function AdminPage() {
} }
} }
async function handleUserSubmit(event) {
event.preventDefault()
const email = userForm.email.trim()
if (!isSiteAdmin) {
setErrorMessage('Only site admins can create users from the admin page.')
return
}
if (!email) {
setErrorMessage('User email is required.')
return
}
if (!userForm.password) {
setErrorMessage('A password is required to create a user.')
return
}
if (userForm.password !== userForm.confirmPassword) {
setErrorMessage('Passwords do not match.')
return
}
setLoading(true)
setErrorMessage('')
setStatusMessage('')
try {
const result = await register({
email,
password: userForm.password,
confirmPassword: userForm.confirmPassword,
firstName: userForm.firstName.trim() || null,
lastName: userForm.lastName.trim() || null,
})
try {
const users = await usersApi.getUsers()
const matchingUser = Array.isArray(users)
? users.find(existingUser => existingUser.email?.toLowerCase() === email.toLowerCase())
: null
setCreatedUser(matchingUser ?? {
email,
firstName: userForm.firstName.trim(),
lastName: userForm.lastName.trim(),
roles: [],
})
} catch {
setCreatedUser({
email,
firstName: userForm.firstName.trim(),
lastName: userForm.lastName.trim(),
roles: [],
})
}
resetUserForm()
setStatusMessage(result?.message || 'User created.')
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
async function handleLeaveHousehold(householdId) { async function handleLeaveHousehold(householdId) {
const confirmed = window.confirm('Leave this household?') const confirmed = window.confirm('Leave this household?')
@@ -221,7 +351,7 @@ function AdminPage() {
<> <>
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>} {errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
{statusMessage && <div className="status-banner status-banner--success">{statusMessage}</div>} {statusMessage && <div className="status-banner status-banner--success">{statusMessage}</div>}
{loading && <div className="status-banner status-banner--info">Syncing household data...</div>} {loading && <div className="status-banner status-banner--info">Processing admin request...</div>}
<section className="panel admin-summary-panel"> <section className="panel admin-summary-panel">
<h3>Household Summary</h3> <h3>Household Summary</h3>
@@ -335,6 +465,155 @@ function AdminPage() {
</> </>
)} )}
</section> </section>
<section className="panel">
<div className="section-heading">
<h3>Create User</h3>
<span className="subtle-text">{createdUser?.email || 'Register endpoint'}</span>
</div>
<p className="form-note">
{isSiteAdmin
? 'This creates a new account with the register endpoint. Use the users page afterwards if you need to assign site-admin roles.'
: 'Only site admins can create users from this page.'}
</p>
<form className="editor-form" onSubmit={handleUserSubmit}>
<div className="form-grid">
<div className="field-group">
<label htmlFor="register-first-name">First Name</label>
<input
id="register-first-name"
type="text"
value={userForm.firstName}
onChange={event => setUserForm(current => ({ ...current, firstName: event.target.value }))}
placeholder="Jordan"
disabled={!isSiteAdmin}
/>
</div>
<div className="field-group">
<label htmlFor="register-last-name">Last Name</label>
<input
id="register-last-name"
type="text"
value={userForm.lastName}
onChange={event => setUserForm(current => ({ ...current, lastName: event.target.value }))}
placeholder="Lee"
disabled={!isSiteAdmin}
/>
</div>
</div>
<div className="field-group">
<label htmlFor="register-email">Email</label>
<input
id="register-email"
type="email"
value={userForm.email}
onChange={event => setUserForm(current => ({ ...current, email: event.target.value }))}
placeholder="new-user@example.com"
disabled={!isSiteAdmin}
required
/>
</div>
<div className="form-grid">
<div className="field-group">
<label htmlFor="register-password">Password</label>
<input
id="register-password"
type="password"
value={userForm.password}
onChange={event => setUserForm(current => ({ ...current, password: event.target.value }))}
placeholder="Create a password"
disabled={!isSiteAdmin}
required
/>
</div>
<div className="field-group">
<label htmlFor="register-confirm-password">Confirm Password</label>
<input
id="register-confirm-password"
type="password"
value={userForm.confirmPassword}
onChange={event => setUserForm(current => ({ ...current, confirmPassword: event.target.value }))}
placeholder="Repeat the password"
disabled={!isSiteAdmin}
required
/>
</div>
</div>
<div className="button-row">
<button type="submit" className="btn btn-primary" disabled={!isSiteAdmin}>
Create user
</button>
<button type="button" className="btn btn-secondary" onClick={resetUserForm} disabled={!isSiteAdmin}>
Clear
</button>
</div>
</form>
{createdUser && (
<div className="admin-created-user-card">
<div className="admin-created-user-copy">
<strong>{formatPersonName(createdUser)}</strong>
<div className="entity-meta">{createdUser.email || 'No email provided.'}</div>
<div className="entity-meta">ID: {createdUser.id || 'Not available'}</div>
</div>
<div className="admin-badge-row">
{createdUserRoles.length === 0 ? (
<span className="member-badge">No roles assigned</span>
) : (
createdUserRoles.map(role => (
<span className="member-badge" key={role}>{role}</span>
))
)}
</div>
</div>
)}
</section>
<section className="panel">
<div className="section-heading">
<h3>Household History</h3>
<span className="subtle-text">{selectedHousehold?.name || 'No household selected'}</span>
</div>
{householdHistoryLoading && (
<div className="status-banner status-banner--info">Loading household history...</div>
)}
{householdHistoryError && (
<div className="status-banner status-banner--error">{householdHistoryError}</div>
)}
{!selectedHousehold ? (
<div className="empty-state compact-empty-state">Select a household to review its audit history.</div>
) : householdHistory.length === 0 ? (
<div className="empty-state compact-empty-state">No household history was returned.</div>
) : (
<div className="entity-list">
{householdHistory.map(entry => (
<article className="entity-row" key={entry.id}>
<div className="entity-copy">
<strong>{entry.action}</strong>
<div className="entity-meta">
{formatDate(entry.changedAt) || 'Unknown date'} by {entry.changedByEmail || 'Unknown user'}
</div>
<div className="entity-meta">{entry.description || 'No description recorded.'}</div>
{entry.affectedUserEmail && (
<div className="entity-meta">Affected member: {entry.affectedUserEmail}</div>
)}
</div>
</article>
))}
</div>
)}
</section>
</div> </div>
<section className="panel admin-list-panel"> <section className="panel admin-list-panel">
@@ -371,7 +650,7 @@ function AdminPage() {
{(household.members ?? []).map(member => ( {(household.members ?? []).map(member => (
<div className="member-row" key={member.userId}> <div className="member-row" key={member.userId}>
<div className="member-copy"> <div className="member-copy">
<strong>{formatMemberName(member)}</strong> <strong>{formatPersonName(member)}</strong>
<span className="entity-meta">{member.email}</span> <span className="entity-meta">{member.email}</span>
</div> </div>
{member.isHouseholdAdmin && <span className="member-badge">Admin</span>} {member.isHouseholdAdmin && <span className="member-badge">Admin</span>}

View File

@@ -164,17 +164,6 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.scanner-status {
padding: 12px 14px;
border-radius: 14px;
background: var(--status-info-bg);
border: 1px solid var(--status-info-border);
color: var(--status-info-text);
line-height: 1.5;
flex: 1;
min-width: 240px;
}
.camera-controls, .camera-controls,
.manual-entry-actions, .manual-entry-actions,
.quick-actions, .quick-actions,
@@ -239,6 +228,13 @@
border: 1px solid transparent; border: 1px solid transparent;
} }
.scan-chip--barcode {
justify-content: flex-start;
font-family: Consolas, 'Courier New', monospace;
max-width: 100%;
word-break: break-word;
}
.scan-chip.is-success { .scan-chip.is-success {
background: var(--status-success-bg); background: var(--status-success-bg);
border-color: var(--status-success-border); border-color: var(--status-success-border);

View File

@@ -15,8 +15,6 @@ import {
import { formatAmount, formatDate } from '../../utils/searchUtils.js' import { formatAmount, formatDate } from '../../utils/searchUtils.js'
import './BarcodePage.css' import './BarcodePage.css'
const CAMERA_IDLE_MESSAGE = 'Start the camera and hold a barcode inside the frame.'
const CAMERA_LIVE_MESSAGE = 'Camera live. Hold the barcode steady inside the frame.'
const MATCH_CANDIDATE_LIMIT = 10 const MATCH_CANDIDATE_LIMIT = 10
const CAMERA_READERS = [ const CAMERA_READERS = [
'ean_reader', 'ean_reader',
@@ -84,11 +82,7 @@ function BarcodePage() {
const matchCandidates = getMatchCandidates(items, matchQuery, lastScannedBarcode) const matchCandidates = getMatchCandidates(items, matchQuery, lastScannedBarcode)
const hasScannedBarcode = Boolean(normalizeBarcode(lastScannedBarcode)) const hasScannedBarcode = Boolean(normalizeBarcode(lastScannedBarcode))
const hasExactMatches = matchingItems.length > 0 const hasExactMatches = matchingItems.length > 0
const cameraStatus = cameraActive const quickAddSourceItem = matchingItems.find(item => item.id === editingItemId) ?? matchingItems[0] ?? null
? lastScanSource === 'camera' && hasScannedBarcode
? `Captured ${lastScannedBarcode}. Keep scanning or review the matches below.`
: CAMERA_LIVE_MESSAGE
: CAMERA_IDLE_MESSAGE
const fetchInventoryData = useCallback(async () => { const fetchInventoryData = useCallback(async () => {
const [nextLocations, nextItems] = await Promise.all([ const [nextLocations, nextItems] = await Promise.all([
@@ -314,6 +308,42 @@ function BarcodePage() {
setErrorMessage('') setErrorMessage('')
} }
async function handleQuickAdd() {
if (!hasScannedBarcode) {
return
}
if (!quickAddSourceItem) {
openCreateForm()
setStatusMessage('No exact match found. Complete the form to add a new item.')
return
}
setSubmitting(true)
setErrorMessage('')
setStatusMessage('')
try {
const createdItem = await inventoryApi.createInventoryItem(buildInventoryPayload({
...mapItemToForm(quickAddSourceItem),
expiryDate: '',
useByDate: '',
}))
await refreshInventoryData()
if (createdItem?.id) {
setStatusMessage(`Quick added ${quickAddSourceItem.name || 'item'} without expiry or use by dates.`)
} else {
setStatusMessage('Quick add completed without expiry or use by dates.')
}
} catch (error) {
setErrorMessage(error.message)
} finally {
setSubmitting(false)
}
}
async function handleRefreshInventory() { async function handleRefreshInventory() {
setInventoryLoading(true) setInventoryLoading(true)
setErrorMessage('') setErrorMessage('')
@@ -349,9 +379,10 @@ function BarcodePage() {
event.preventDefault() event.preventDefault()
const name = itemForm.name.trim() const name = itemForm.name.trim()
const barcode = itemForm.barcode.trim()
if (!name) { if (!name && !barcode) {
setErrorMessage('Item name is required.') setErrorMessage('Provide an item name or a barcode.')
return return
} }
@@ -449,25 +480,15 @@ function BarcodePage() {
<div className="barcode-workspace"> <div className="barcode-workspace">
<section className="panel scanner-panel"> <section className="panel scanner-panel">
<div className="scanner-header"> <div className="scanner-header">
<div> <div className="scan-summary-meta">
<h3>Camera Scanner</h3> {hasScannedBarcode && (
</div> <span className="scan-chip is-neutral scan-chip--barcode">
{lastScannedBarcode}
<div className="mode-toggle" role="tablist" aria-label="Scanner modes"> </span>
<button )}
type="button" <span className={`scan-chip ${hasExactMatches ? 'is-success' : hasScannedBarcode ? 'is-warning' : 'is-neutral'}`}>
className={`mode-btn ${mode === 'camera' ? 'active' : ''}`} {hasScannedBarcode ? (hasExactMatches ? `${matchingItems.length} match${matchingItems.length === 1 ? '' : 'es'}` : 'No exact matches') : 'Ready to scan'}
onClick={switchToCamera} </span>
>
Camera
</button>
<button
type="button"
className={`mode-btn ${mode === 'keyboard' ? 'active' : ''}`}
onClick={switchToKeyboard}
>
Keyboard
</button>
</div> </div>
</div> </div>
@@ -479,7 +500,7 @@ function BarcodePage() {
{!cameraActive && ( {!cameraActive && (
<div className="camera-placeholder"> <div className="camera-placeholder">
<strong>Ready to scan</strong> <strong>Ready to scan</strong>
<span>Use the rear camera when available and place the barcode inside the guide.</span> <span>Use the rear camera when available. Place the barcode inside the guide.</span>
</div> </div>
)} )}
@@ -489,7 +510,6 @@ function BarcodePage() {
</div> </div>
<div className="camera-toolbar"> <div className="camera-toolbar">
<div className="scanner-status">{cameraStatus}</div>
<div className="camera-controls"> <div className="camera-controls">
{!cameraActive ? ( {!cameraActive ? (
<button type="button" className="btn btn-primary" onClick={startCamera}> <button type="button" className="btn btn-primary" onClick={startCamera}>
@@ -571,9 +591,20 @@ function BarcodePage() {
</p> </p>
</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">
<div className="scan-summary-card"> <div className="scan-summary-card">
<span className="scan-summary-label">Last scanned barcode</span> <span className="scan-summary-label">Last scanned barcode</span>
<strong className="scan-summary-value">{hasScannedBarcode ? lastScannedBarcode : 'Waiting for a scan'}</strong> <strong className="scan-summary-value">{hasScannedBarcode ? lastScannedBarcode : 'Waiting for a scan'}</strong>
@@ -613,7 +644,7 @@ function BarcodePage() {
Clear scan Clear scan
</button> </button>
</div> </div>
</section> </section> */}
</div> </div>
<div className="barcode-management-grid"> <div className="barcode-management-grid">
@@ -684,7 +715,7 @@ function BarcodePage() {
{!editorMode ? ( {!editorMode ? (
<div className="editor-empty-state"> <div className="editor-empty-state">
<p>Choose Add new item to use the scanned barcode on a new record, or Update item on one of the exact matches to edit it here.</p> <p>Use Quick Add to clone an exact barcode match without expiry dates, or to open the create form when there is no exact match. Use Update item on one of the exact matches to edit it here.</p>
</div> </div>
) : ( ) : (
<form className="editor-form" onSubmit={handleItemSubmit}> <form className="editor-form" onSubmit={handleItemSubmit}>
@@ -697,7 +728,6 @@ function BarcodePage() {
value={itemForm.name} value={itemForm.name}
onChange={event => setItemForm(current => ({ ...current, name: event.target.value }))} onChange={event => setItemForm(current => ({ ...current, name: event.target.value }))}
placeholder="Whole Milk" placeholder="Whole Milk"
required
/> />
</div> </div>
@@ -797,6 +827,12 @@ function BarcodePage() {
Blank text fields are sent as empty strings. Blank amount, date, and location values are skipped because the API only updates provided values. Blank text fields are sent as empty strings. Blank amount, date, and location values are skipped because the API only updates provided values.
</p> </p>
)} )}
{editorMode !== 'update' && (
<p className="form-note">
If the name is blank but the barcode resolves through the API lookup, the item name can be filled automatically.
</p>
)}
</form> </form>
)} )}
</section> </section>

View File

@@ -28,19 +28,36 @@ function InventoryPage() {
const [statusMessage, setStatusMessage] = useState('') const [statusMessage, setStatusMessage] = useState('')
const [locations, setLocations] = useState([]) const [locations, setLocations] = useState([])
const [items, setItems] = useState([]) const [items, setItems] = useState([])
const [selectedLocationId, setSelectedLocationId] = useState('')
const [locationHistory, setLocationHistory] = useState([])
const [locationHistoryLoading, setLocationHistoryLoading] = useState(false)
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(null)
const [locationForm, setLocationForm] = useState(EMPTY_LOCATION_FORM) const [locationForm, setLocationForm] = useState(EMPTY_LOCATION_FORM)
const [itemForm, setItemForm] = useState(createItemForm()) const [itemForm, setItemForm] = useState(createItemForm())
function syncLocations(nextLocations) {
setLocations(nextLocations)
setSelectedLocationId(currentLocationId => {
const targetLocationId = currentLocationId || editingLocationId
if (nextLocations.some(location => location.id === targetLocationId)) {
return targetLocationId
}
return nextLocations[0]?.id ?? ''
})
}
async function loadPageData() { async function loadPageData() {
const [nextLocations, nextItems] = await Promise.all([ const [nextLocations, nextItems] = await Promise.all([
locationsApi.getLocations(), locationsApi.getLocations(),
inventoryApi.getInventoryItems(), inventoryApi.getInventoryItems(),
]) ])
setLocations(nextLocations) syncLocations(nextLocations)
setItems(nextItems) setItems(nextItems)
} }
@@ -51,6 +68,10 @@ function InventoryPage() {
if (!isAuthenticated) { if (!isAuthenticated) {
setLocations([]) setLocations([])
setItems([]) setItems([])
setSelectedLocationId('')
setLocationHistory([])
setLocationHistoryLoading(false)
setLocationHistoryError('')
setSelectedItem(null) setSelectedItem(null)
setEditingItemId('') setEditingItemId('')
setEditingLocationId('') setEditingLocationId('')
@@ -71,7 +92,7 @@ function InventoryPage() {
]) ])
if (!cancelled) { if (!cancelled) {
setLocations(nextLocations) syncLocations(nextLocations)
setItems(nextItems) setItems(nextItems)
} }
} catch (error) { } catch (error) {
@@ -92,6 +113,47 @@ function InventoryPage() {
} }
}, [isAuthenticated]) }, [isAuthenticated])
useEffect(() => {
let cancelled = false
async function loadSelectedLocationHistory() {
if (!isAuthenticated || !selectedLocationId) {
setLocationHistory([])
setLocationHistoryLoading(false)
setLocationHistoryError('')
return
}
setLocationHistoryLoading(true)
setLocationHistoryError('')
try {
const response = await locationsApi.getLocationHistory(selectedLocationId)
if (!cancelled) {
setLocationHistory(Array.isArray(response) ? response : [])
}
} catch (error) {
if (!cancelled) {
setLocationHistory([])
setLocationHistoryError(error.message)
}
} finally {
if (!cancelled) {
setLocationHistoryLoading(false)
}
}
}
loadSelectedLocationHistory()
return () => {
cancelled = true
}
}, [isAuthenticated, selectedLocationId])
const selectedLocation = locations.find(location => location.id === selectedLocationId) ?? null
function resetLocationEditor() { function resetLocationEditor() {
setEditingLocationId('') setEditingLocationId('')
setLocationForm(EMPTY_LOCATION_FORM) setLocationForm(EMPTY_LOCATION_FORM)
@@ -192,9 +254,10 @@ function InventoryPage() {
event.preventDefault() event.preventDefault()
const name = itemForm.name.trim() const name = itemForm.name.trim()
const barcode = itemForm.barcode.trim()
if (!name) { if (!name && !barcode) {
setErrorMessage('Item name is required.') setErrorMessage('Provide an item name or a barcode.')
return return
} }
@@ -320,7 +383,7 @@ function InventoryPage() {
) : ( ) : (
locations.map(location => ( locations.map(location => (
<div <div
className={`entity-row ${editingLocationId === location.id ? 'is-selected' : ''}`} className={`entity-row ${selectedLocationId === location.id || editingLocationId === location.id ? 'is-selected' : ''}`}
key={location.id} key={location.id}
> >
<div className="entity-copy"> <div className="entity-copy">
@@ -333,6 +396,7 @@ function InventoryPage() {
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => { onClick={() => {
setSelectedLocationId(location.id)
setEditingLocationId(location.id) setEditingLocationId(location.id)
setLocationForm({ setLocationForm({
name: location.name, name: location.name,
@@ -342,6 +406,13 @@ function InventoryPage() {
> >
Edit Edit
</button> </button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setSelectedLocationId(location.id)}
>
History
</button>
<button <button
type="button" type="button"
className="btn btn-danger" className="btn btn-danger"
@@ -356,6 +427,41 @@ function InventoryPage() {
</div> </div>
</section> </section>
<section className="panel">
<div className="section-heading">
<h3>Location History</h3>
<span className="subtle-text">{selectedLocation?.name || 'No location selected'}</span>
</div>
{locationHistoryLoading && (
<div className="status-banner status-banner--info">Loading location history...</div>
)}
{locationHistoryError && (
<div className="status-banner status-banner--error">{locationHistoryError}</div>
)}
{!selectedLocation ? (
<div className="empty-state compact-empty-state">Select a location to review its audit history.</div>
) : locationHistory.length === 0 ? (
<div className="empty-state compact-empty-state">No location history was returned.</div>
) : (
<div className="entity-list">
{locationHistory.map(entry => (
<article className="entity-row" key={entry.id}>
<div className="entity-copy">
<strong>{entry.action}</strong>
<div className="entity-meta">
{formatDate(entry.changedAt) || 'Unknown date'} by {entry.changedByEmail || 'Unknown user'}
</div>
<div className="entity-meta">{entry.description || 'No description recorded.'}</div>
</div>
</article>
))}
</div>
)}
</section>
<section className="panel"> <section className="panel">
<div className="section-heading"> <div className="section-heading">
<h3>{editingItemId ? 'Edit Item' : 'Add Item'}</h3> <h3>{editingItemId ? 'Edit Item' : 'Add Item'}</h3>
@@ -376,7 +482,6 @@ function InventoryPage() {
value={itemForm.name} value={itemForm.name}
onChange={event => setItemForm(current => ({ ...current, name: event.target.value }))} onChange={event => setItemForm(current => ({ ...current, name: event.target.value }))}
placeholder="Whole Milk" placeholder="Whole Milk"
required
/> />
</div> </div>
@@ -464,6 +569,12 @@ function InventoryPage() {
Blank text fields are sent as empty strings. Blank amount, date, and location values are skipped because the API only updates provided values. Blank text fields are sent as empty strings. Blank amount, date, and location values are skipped because the API only updates provided values.
</p> </p>
)} )}
{!editingItemId && (
<p className="form-note">
If the name is blank but the barcode matches a cached or external lookup, the API can fill the item name for you.
</p>
)}
</form> </form>
{selectedItem && ( {selectedItem && (

View File

@@ -0,0 +1,597 @@
import { useEffect, useState } from 'react'
import {
householdsApi,
inventoryApi,
mealPlannersApi,
} from '../../api/client.js'
import { useAuth } from '../../context/AuthContext.jsx'
import {
formatAmount,
formatDate,
formatTime,
toDateInputValue,
toTimeInputValue,
} from '../../utils/searchUtils.js'
import '../PlanningPage.css'
function createMealPlannerItemForm() {
return {
inventoryItemId: '',
amountRequired: '',
amountType: '',
}
}
function createMealPlannerForm(householdId = '') {
return {
name: '',
householdId,
plannedDate: '',
plannedTime: '',
items: [createMealPlannerItemForm()],
}
}
function mapMealPlannerToForm(mealPlanner) {
return {
name: mealPlanner?.name ?? '',
householdId: mealPlanner?.householdId ?? '',
plannedDate: toDateInputValue(mealPlanner?.plannedDate),
plannedTime: toTimeInputValue(mealPlanner?.plannedTime),
items: Array.isArray(mealPlanner?.items) && mealPlanner.items.length > 0
? mealPlanner.items.map(item => ({
inventoryItemId: item.inventoryItemId ?? '',
amountRequired: item.amountRequired == null ? '' : String(item.amountRequired),
amountType: item.amountType ?? '',
}))
: [createMealPlannerItemForm()],
}
}
function resolveHouseholdName(households, householdId) {
return households.find(household => household.id === householdId)?.name ?? 'Unknown household'
}
function toApiTimeValue(timeValue) {
if (!timeValue) return ''
return timeValue.length === 5 ? `${timeValue}:00` : timeValue
}
function normalizeMealPlannerItems(items) {
const normalizedItems = items
.filter(item => item.inventoryItemId || item.amountRequired !== '' || item.amountType.trim())
.map((item, index) => {
const amountRequired = Number(item.amountRequired)
const amountType = item.amountType.trim()
if (!item.inventoryItemId) {
throw new Error(`Choose an inventory item for row ${index + 1}.`)
}
if (!Number.isFinite(amountRequired) || amountRequired <= 0) {
throw new Error(`Amount required must be greater than zero for row ${index + 1}.`)
}
if (!amountType) {
throw new Error(`Amount type is required for row ${index + 1}.`)
}
return {
inventoryItemId: item.inventoryItemId,
amountRequired,
amountType,
}
})
const distinctIds = new Set(normalizedItems.map(item => item.inventoryItemId))
if (distinctIds.size !== normalizedItems.length) {
throw new Error('Each inventory item can only appear once in a meal planner.')
}
return normalizedItems
}
function MealPlannersPage() {
const { isAuthenticated } = useAuth()
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [statusMessage, setStatusMessage] = useState('')
const [households, setHouseholds] = useState([])
const [inventoryItems, setInventoryItems] = useState([])
const [mealPlanners, setMealPlanners] = useState([])
const [selectedMealPlannerId, setSelectedMealPlannerId] = useState('')
const [editingMealPlannerId, setEditingMealPlannerId] = useState('')
const [mealPlannerForm, setMealPlannerForm] = useState(createMealPlannerForm())
useEffect(() => {
let cancelled = false
async function loadInitialData() {
if (!isAuthenticated) {
setHouseholds([])
setInventoryItems([])
setMealPlanners([])
setSelectedMealPlannerId('')
setEditingMealPlannerId('')
setMealPlannerForm(createMealPlannerForm())
setErrorMessage('')
setStatusMessage('')
return
}
setLoading(true)
setErrorMessage('')
try {
const [nextHouseholdsResponse, nextInventoryResponse, nextMealPlannersResponse] = await Promise.all([
householdsApi.getHouseholds(),
inventoryApi.getInventoryItems(),
mealPlannersApi.getMealPlanners(),
])
if (cancelled) return
const nextHouseholds = Array.isArray(nextHouseholdsResponse) ? nextHouseholdsResponse : []
const nextInventoryItems = Array.isArray(nextInventoryResponse) ? nextInventoryResponse : []
const nextMealPlanners = Array.isArray(nextMealPlannersResponse) ? nextMealPlannersResponse : []
setHouseholds(nextHouseholds)
setInventoryItems(nextInventoryItems)
setMealPlanners(nextMealPlanners)
setSelectedMealPlannerId(nextMealPlanners[0]?.id ?? '')
} catch (error) {
if (!cancelled) {
setErrorMessage(error.message)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
loadInitialData()
return () => {
cancelled = true
}
}, [isAuthenticated])
useEffect(() => {
if (editingMealPlannerId || mealPlannerForm.householdId || households.length === 0) {
return
}
setMealPlannerForm(currentForm => ({
...currentForm,
householdId: households[0].id,
}))
}, [editingMealPlannerId, households, mealPlannerForm.householdId])
async function loadPageData(preferredSelectionId = '') {
const [nextHouseholdsResponse, nextInventoryResponse, nextMealPlannersResponse] = await Promise.all([
householdsApi.getHouseholds(),
inventoryApi.getInventoryItems(),
mealPlannersApi.getMealPlanners(),
])
const nextHouseholds = Array.isArray(nextHouseholdsResponse) ? nextHouseholdsResponse : []
const nextInventoryItems = Array.isArray(nextInventoryResponse) ? nextInventoryResponse : []
const nextMealPlanners = Array.isArray(nextMealPlannersResponse) ? nextMealPlannersResponse : []
setHouseholds(nextHouseholds)
setInventoryItems(nextInventoryItems)
setMealPlanners(nextMealPlanners)
setSelectedMealPlannerId(currentSelectionId => {
const targetSelectionId = preferredSelectionId || currentSelectionId
if (nextMealPlanners.some(mealPlanner => mealPlanner.id === targetSelectionId)) {
return targetSelectionId
}
return nextMealPlanners[0]?.id ?? ''
})
setEditingMealPlannerId(currentEditingId => (
nextMealPlanners.some(mealPlanner => mealPlanner.id === currentEditingId)
? currentEditingId
: ''
))
}
function resetEditor() {
setEditingMealPlannerId('')
setMealPlannerForm(createMealPlannerForm(households[0]?.id ?? ''))
}
function updateItemRow(index, updates) {
setMealPlannerForm(currentForm => ({
...currentForm,
items: currentForm.items.map((item, itemIndex) => (
itemIndex === index
? { ...item, ...updates }
: item
)),
}))
}
function addItemRow() {
setMealPlannerForm(currentForm => ({
...currentForm,
items: [...currentForm.items, createMealPlannerItemForm()],
}))
}
function removeItemRow(index) {
setMealPlannerForm(currentForm => ({
...currentForm,
items: currentForm.items.filter((_, itemIndex) => itemIndex !== index),
}))
}
async function handleEditMealPlanner(mealPlannerId) {
setLoading(true)
setErrorMessage('')
setStatusMessage('')
try {
const mealPlanner = await mealPlannersApi.getMealPlanner(mealPlannerId)
setSelectedMealPlannerId(mealPlanner.id)
setEditingMealPlannerId(mealPlanner.id)
setMealPlannerForm(mapMealPlannerToForm(mealPlanner))
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
async function handleMealPlannerSubmit(event) {
event.preventDefault()
const name = mealPlannerForm.name.trim()
if (!name) {
setErrorMessage('Meal planner name is required.')
return
}
if (!editingMealPlannerId && !mealPlannerForm.householdId) {
setErrorMessage('Choose a household before creating a meal planner.')
return
}
if (!mealPlannerForm.plannedDate) {
setErrorMessage('Choose a planned date.')
return
}
if (!mealPlannerForm.plannedTime) {
setErrorMessage('Choose a planned time.')
return
}
let normalizedItems
try {
normalizedItems = normalizeMealPlannerItems(mealPlannerForm.items)
} catch (error) {
setErrorMessage(error.message)
return
}
setLoading(true)
setErrorMessage('')
setStatusMessage('')
try {
const payload = {
name,
plannedDate: mealPlannerForm.plannedDate,
plannedTime: toApiTimeValue(mealPlannerForm.plannedTime),
items: normalizedItems,
}
const result = editingMealPlannerId
? await mealPlannersApi.updateMealPlanner(editingMealPlannerId, payload)
: await mealPlannersApi.createMealPlanner({
...payload,
householdId: mealPlannerForm.householdId,
})
await loadPageData(result.id)
setSelectedMealPlannerId(result.id)
setEditingMealPlannerId(result.id)
setMealPlannerForm(mapMealPlannerToForm(result))
setStatusMessage(editingMealPlannerId ? 'Meal planner updated.' : 'Meal planner created.')
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
async function handleDeleteMealPlanner(mealPlannerId) {
const confirmed = window.confirm('Delete this meal planner?')
if (!confirmed) return
setLoading(true)
setErrorMessage('')
setStatusMessage('')
try {
await mealPlannersApi.deleteMealPlanner(mealPlannerId)
await loadPageData(selectedMealPlannerId === mealPlannerId ? '' : selectedMealPlannerId)
if (editingMealPlannerId === mealPlannerId) {
resetEditor()
}
setStatusMessage('Meal planner deleted.')
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
const inventoryOptions = [...inventoryItems].sort((left, right) => (
(left.name ?? '').localeCompare(right.name ?? '')
))
return (
<>
<h2>Meal Planners</h2>
<hr />
{!isAuthenticated ? (
<div className="auth-required">
Sign in on the dashboard before managing meal planners.
</div>
) : (
<>
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
{statusMessage && <div className="status-banner status-banner--success">{statusMessage}</div>}
{loading && <div className="status-banner status-banner--info">Syncing meal planners...</div>}
<div className="planning-grid">
<section className="panel">
<div className="section-heading">
<h3>{editingMealPlannerId ? 'Edit Meal Planner' : 'Create Meal Planner'}</h3>
{editingMealPlannerId && (
<button type="button" className="btn btn-secondary" onClick={resetEditor}>
New meal planner
</button>
)}
</div>
<p className="form-note planning-form-note">
Meal planners are household-scoped. Choose a household, set the schedule, and add the inventory items required for that meal.
</p>
<form className="editor-form" onSubmit={handleMealPlannerSubmit}>
<div className="form-grid">
<div className="field-group">
<label htmlFor="meal-planner-name">Name</label>
<input
id="meal-planner-name"
type="text"
value={mealPlannerForm.name}
onChange={event => setMealPlannerForm(currentForm => ({ ...currentForm, name: event.target.value }))}
placeholder="Pasta night"
required
/>
</div>
<div className="field-group">
<label htmlFor="meal-planner-household">Household</label>
<select
id="meal-planner-household"
value={mealPlannerForm.householdId}
onChange={event => setMealPlannerForm(currentForm => ({
...currentForm,
householdId: event.target.value,
items: [],
}))}
disabled={Boolean(editingMealPlannerId)}
>
<option value="">Select a household</option>
{households.map(household => (
<option key={household.id} value={household.id}>{household.name}</option>
))}
</select>
</div>
<div className="field-group">
<label htmlFor="meal-planner-date">Planned Date</label>
<input
id="meal-planner-date"
type="date"
value={mealPlannerForm.plannedDate}
onChange={event => setMealPlannerForm(currentForm => ({ ...currentForm, plannedDate: event.target.value }))}
required
/>
</div>
<div className="field-group">
<label htmlFor="meal-planner-time">Planned Time</label>
<input
id="meal-planner-time"
type="time"
value={mealPlannerForm.plannedTime}
onChange={event => setMealPlannerForm(currentForm => ({ ...currentForm, plannedTime: event.target.value }))}
required
/>
</div>
</div>
<div className="section-heading">
<h3>Meal Items</h3>
<button type="button" className="btn btn-secondary" onClick={addItemRow}>
Add item
</button>
</div>
{mealPlannerForm.items.length === 0 ? (
<div className="empty-state compact-empty-state">No meal items yet. Add a row when you are ready.</div>
) : (
<div className="planning-item-stack">
{mealPlannerForm.items.map((item, index) => (
<div className="planning-item-row" key={`meal-planner-item-${index + 1}`}>
<div className="planning-item-row-header">
<strong>Item {index + 1}</strong>
<button type="button" className="btn btn-secondary" onClick={() => removeItemRow(index)}>
Remove
</button>
</div>
<div className="form-grid planning-item-grid">
<div className="field-group">
<label htmlFor={`meal-planner-item-id-${index}`}>Inventory Item</label>
<select
id={`meal-planner-item-id-${index}`}
value={item.inventoryItemId}
onChange={event => updateItemRow(index, { inventoryItemId: event.target.value })}
>
<option value="">Select an inventory item</option>
{inventoryOptions.map(inventoryItem => (
<option key={inventoryItem.id} value={inventoryItem.id}>
{inventoryItem.name} ({inventoryItem.location?.name || 'No location'})
</option>
))}
</select>
</div>
<div className="field-group">
<label htmlFor={`meal-planner-item-amount-${index}`}>Amount Required</label>
<input
id={`meal-planner-item-amount-${index}`}
type="number"
min="0"
step="0.1"
value={item.amountRequired}
onChange={event => updateItemRow(index, { amountRequired: event.target.value })}
placeholder="1"
/>
</div>
<div className="field-group">
<label htmlFor={`meal-planner-item-type-${index}`}>Amount Type</label>
<input
id={`meal-planner-item-type-${index}`}
type="text"
value={item.amountType}
onChange={event => updateItemRow(index, { amountType: event.target.value })}
placeholder="litres"
/>
</div>
</div>
</div>
))}
</div>
)}
{editingMealPlannerId && (
<p className="form-note">
Household selection is locked while editing because the update endpoint only replaces the name, schedule, and planner items.
</p>
)}
<div className="button-row">
<button type="submit" className="btn btn-primary">
{editingMealPlannerId ? 'Update meal planner' : 'Create meal planner'}
</button>
<button type="button" className="btn btn-secondary" onClick={resetEditor}>
Clear form
</button>
</div>
</form>
</section>
<section className="panel">
<div className="section-heading">
<h3>Meal Planners</h3>
<span className="subtle-text">{mealPlanners.length} total</span>
</div>
{mealPlanners.length === 0 ? (
<div className="empty-state compact-empty-state">No meal planners were returned.</div>
) : (
<div className="planning-list">
{mealPlanners.map(mealPlanner => (
<article
className={`planning-card ${selectedMealPlannerId === mealPlanner.id ? 'is-selected' : ''}`}
key={mealPlanner.id}
>
<div className="planning-card-header">
<div>
<strong>{mealPlanner.name}</strong>
<div className="entity-meta">Household: {resolveHouseholdName(households, mealPlanner.householdId)}</div>
<div className="entity-meta">
Planned for {formatDate(mealPlanner.plannedDate) || 'Unknown date'} at {formatTime(mealPlanner.plannedTime) || 'Unknown time'}
</div>
<div className="entity-meta">
Created {formatDate(mealPlanner.createdAt) || 'Unknown date'} by {mealPlanner.createdByEmail || 'Unknown user'}
</div>
</div>
<span className="planning-chip">{mealPlanner.items?.length ?? 0} item(s)</span>
</div>
<div className="planning-summary-list">
{(mealPlanner.items ?? []).length === 0 ? (
<div className="entity-meta">No items added yet.</div>
) : (
mealPlanner.items.map(item => (
<div className="planning-summary-row" key={item.inventoryItemId}>
<div>
<strong>{item.inventoryItemName || 'Unnamed item'}</strong>
<div className="entity-meta">
{formatAmount(item.amountRequired, item.amountType)}
{item.inventoryItemBarcode ? ` | ${item.inventoryItemBarcode}` : ''}
</div>
</div>
<span className="planning-status-chip is-neutral">Required</span>
</div>
))
)}
</div>
<div className="planning-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => setSelectedMealPlannerId(mealPlanner.id)}
>
Select
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => handleEditMealPlanner(mealPlanner.id)}
>
Edit
</button>
<button
type="button"
className="btn btn-danger"
onClick={() => handleDeleteMealPlanner(mealPlanner.id)}
>
Delete
</button>
</div>
</article>
))}
</div>
)}
</section>
</div>
</>
)}
</>
)
}
export default MealPlannersPage

139
src/pages/PlanningPage.css Normal file
View File

@@ -0,0 +1,139 @@
.planning-grid {
display: grid;
grid-template-columns: minmax(320px, 0.95fr) minmax(0, 1.05fr);
gap: 20px;
align-items: start;
margin-top: 24px;
}
.planning-form-note {
margin-bottom: 20px;
}
.planning-item-stack {
display: grid;
gap: 14px;
}
.planning-item-row {
display: grid;
gap: 14px;
padding: 16px;
border-radius: 12px;
background: var(--color-surface-muted);
border: 1px solid var(--color-border-muted);
}
.planning-item-row-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.planning-item-grid {
align-items: start;
}
.planning-checkbox-row {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
color: var(--color-text-soft);
}
.planning-checkbox-row input {
width: auto;
}
.planning-list {
display: grid;
gap: 16px;
}
.planning-card {
display: grid;
gap: 14px;
padding: 18px;
border-radius: 12px;
background: var(--color-surface-muted);
border: 1px solid var(--color-border-muted);
}
.planning-card.is-selected {
border-color: var(--color-focus);
box-shadow: 0 0 0 3px var(--color-focus-ring-strong);
}
.planning-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.planning-chip,
.planning-status-chip {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.planning-chip {
background: var(--status-info-bg);
color: var(--status-info-text);
border: 1px solid var(--status-info-border);
}
.planning-summary-list {
display: grid;
gap: 10px;
}
.planning-summary-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 10px;
background: var(--color-surface);
border: 1px solid var(--color-border-subtle);
}
.planning-status-chip.is-success {
background: var(--status-success-bg);
color: var(--status-success-text);
border: 1px solid var(--status-success-border);
}
.planning-status-chip.is-neutral {
background: var(--color-surface-subtle);
color: var(--color-text-muted);
border: 1px solid var(--color-border-muted);
}
.planning-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
@media (max-width: 900px) {
.planning-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 700px) {
.planning-card-header,
.planning-summary-row,
.planning-item-row-header {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -0,0 +1,56 @@
.profile-grid {
display: grid;
grid-template-columns: minmax(260px, 0.8fr) minmax(0, 1.2fr);
gap: 20px;
align-items: start;
margin-top: 24px;
}
.profile-summary-panel {
display: grid;
gap: 18px;
}
.profile-meta {
display: grid;
gap: 8px;
}
.profile-role-section {
display: grid;
gap: 10px;
}
.profile-role-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.profile-role-badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
background: var(--status-info-bg);
color: var(--status-info-text);
border: 1px solid var(--status-info-border);
}
.profile-role-badge--empty {
background: var(--color-surface-subtle);
color: var(--color-text-muted);
border-color: var(--color-border-muted);
}
.profile-form-note {
margin-bottom: 20px;
}
@media (max-width: 900px) {
.profile-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,242 @@
import { useEffect, useState } from 'react'
import { profileApi } from '../../api/client.js'
import { useAuth } from '../../context/AuthContext.jsx'
import './ProfilePage.css'
const EMPTY_PROFILE_FORM = {
email: '',
firstName: '',
lastName: '',
currentPassword: '',
newPassword: '',
}
function createProfileForm(user) {
return {
email: user?.email ?? '',
firstName: user?.firstName ?? '',
lastName: user?.lastName ?? '',
currentPassword: '',
newPassword: '',
}
}
function ProfilePage() {
const { isAuthenticated, refreshProfile, setCurrentUserProfile, user } = useAuth()
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [statusMessage, setStatusMessage] = useState('')
const [profileForm, setProfileForm] = useState(() => createProfileForm(user))
const roles = Array.isArray(user?.roles) ? user.roles : []
useEffect(() => {
if (!isAuthenticated) {
setProfileForm(EMPTY_PROFILE_FORM)
return
}
setProfileForm(currentForm => ({
...currentForm,
email: user?.email ?? '',
firstName: user?.firstName ?? '',
lastName: user?.lastName ?? '',
}))
}, [isAuthenticated, user?.email, user?.firstName, user?.lastName])
async function handleRefreshProfile() {
setLoading(true)
setErrorMessage('')
setStatusMessage('')
try {
const latestProfile = await refreshProfile()
if (latestProfile) {
setProfileForm(createProfileForm(latestProfile))
setStatusMessage('Profile refreshed.')
}
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
async function handleProfileSubmit(event) {
event.preventDefault()
const email = profileForm.email.trim()
if (!email) {
setErrorMessage('Email is required.')
return
}
if (profileForm.newPassword && !profileForm.currentPassword) {
setErrorMessage('Current password is required to change your password.')
return
}
setSaving(true)
setErrorMessage('')
setStatusMessage('')
try {
const updatedProfile = await profileApi.updateProfile({
email,
firstName: profileForm.firstName,
lastName: profileForm.lastName,
currentPassword: profileForm.currentPassword || undefined,
newPassword: profileForm.newPassword || undefined,
})
setCurrentUserProfile(updatedProfile)
setProfileForm(createProfileForm(updatedProfile))
setStatusMessage('Profile updated.')
} catch (error) {
setErrorMessage(error.message)
} finally {
setSaving(false)
}
}
return (
<>
<h2>Profile</h2>
<hr />
{!isAuthenticated ? (
<div className="auth-required">
Sign in on the dashboard before updating your profile.
</div>
) : (
<>
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
{statusMessage && <div className="status-banner status-banner--success">{statusMessage}</div>}
{loading && <div className="status-banner status-banner--info">Refreshing your profile...</div>}
{saving && <div className="status-banner status-banner--info">Saving profile changes...</div>}
<div className="profile-grid">
<section className="panel profile-summary-panel">
<div className="section-heading">
<h3>Account Summary</h3>
<button type="button" className="btn btn-secondary" onClick={handleRefreshProfile}>
Refresh profile
</button>
</div>
<div className="profile-meta">
<strong>{[user?.firstName, user?.lastName].filter(Boolean).join(' ') || user?.email || 'Signed in user'}</strong>
<div className="entity-meta">Email: {user?.email || 'Not available'}</div>
<div className="entity-meta">User ID: {user?.id || 'Not available'}</div>
</div>
<div className="profile-role-section">
<span className="subtle-text">Assigned roles</span>
<div className="profile-role-list">
{roles.length === 0 ? (
<span className="profile-role-badge profile-role-badge--empty">No roles assigned</span>
) : (
roles.map(role => (
<span className="profile-role-badge" key={role}>{role}</span>
))
)}
</div>
</div>
</section>
<section className="panel">
<div className="section-heading">
<h3>Update Profile</h3>
</div>
<p className="form-note profile-form-note">
Leave the password fields blank unless you want to change your password. First and last name fields can be cleared.
</p>
<form className="editor-form" onSubmit={handleProfileSubmit}>
<div className="field-group">
<label htmlFor="profile-email">Email</label>
<input
id="profile-email"
type="email"
value={profileForm.email}
onChange={event => setProfileForm(currentForm => ({ ...currentForm, email: event.target.value }))}
placeholder="user@example.com"
required
/>
</div>
<div className="form-grid">
<div className="field-group">
<label htmlFor="profile-first-name">First Name</label>
<input
id="profile-first-name"
type="text"
value={profileForm.firstName}
onChange={event => setProfileForm(currentForm => ({ ...currentForm, firstName: event.target.value }))}
placeholder="Alex"
/>
</div>
<div className="field-group">
<label htmlFor="profile-last-name">Last Name</label>
<input
id="profile-last-name"
type="text"
value={profileForm.lastName}
onChange={event => setProfileForm(currentForm => ({ ...currentForm, lastName: event.target.value }))}
placeholder="Smith"
/>
</div>
</div>
<div className="form-grid">
<div className="field-group">
<label htmlFor="profile-current-password">Current Password</label>
<input
id="profile-current-password"
type="password"
value={profileForm.currentPassword}
onChange={event => setProfileForm(currentForm => ({ ...currentForm, currentPassword: event.target.value }))}
placeholder="Required to change password"
/>
</div>
<div className="field-group">
<label htmlFor="profile-new-password">New Password</label>
<input
id="profile-new-password"
type="password"
value={profileForm.newPassword}
onChange={event => setProfileForm(currentForm => ({ ...currentForm, newPassword: event.target.value }))}
placeholder="Leave blank to keep current password"
/>
</div>
</div>
<div className="button-row">
<button type="submit" className="btn btn-primary" disabled={saving}>
Save profile
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setProfileForm(createProfileForm(user))}
disabled={saving}
>
Reset form
</button>
</div>
</form>
</section>
</div>
</>
)}
</>
)
}
export default ProfilePage

View File

@@ -0,0 +1,560 @@
import { useEffect, useState } from 'react'
import {
householdsApi,
inventoryApi,
shoppingListsApi,
} from '../../api/client.js'
import { useAuth } from '../../context/AuthContext.jsx'
import { formatAmount, formatDate } from '../../utils/searchUtils.js'
import '../PlanningPage.css'
function createShoppingListItemForm() {
return {
inventoryItemId: '',
amountRequired: '',
amountType: '',
isPurchased: false,
}
}
function createShoppingListForm(householdId = '') {
return {
name: '',
householdId,
items: [createShoppingListItemForm()],
}
}
function mapShoppingListToForm(shoppingList) {
return {
name: shoppingList?.name ?? '',
householdId: shoppingList?.householdId ?? '',
items: Array.isArray(shoppingList?.items) && shoppingList.items.length > 0
? shoppingList.items.map(item => ({
inventoryItemId: item.inventoryItemId ?? '',
amountRequired: item.amountRequired == null ? '' : String(item.amountRequired),
amountType: item.amountType ?? '',
isPurchased: Boolean(item.isPurchased),
}))
: [createShoppingListItemForm()],
}
}
function resolveHouseholdName(households, householdId) {
return households.find(household => household.id === householdId)?.name ?? 'Unknown household'
}
function normalizeShoppingListItems(items) {
const normalizedItems = items
.filter(item => item.inventoryItemId || item.amountRequired !== '' || item.amountType.trim() || item.isPurchased)
.map((item, index) => {
const amountRequired = Number(item.amountRequired)
const amountType = item.amountType.trim()
if (!item.inventoryItemId) {
throw new Error(`Choose an inventory item for row ${index + 1}.`)
}
if (!Number.isFinite(amountRequired) || amountRequired <= 0) {
throw new Error(`Amount required must be greater than zero for row ${index + 1}.`)
}
if (!amountType) {
throw new Error(`Amount type is required for row ${index + 1}.`)
}
return {
inventoryItemId: item.inventoryItemId,
amountRequired,
amountType,
isPurchased: Boolean(item.isPurchased),
}
})
const distinctIds = new Set(normalizedItems.map(item => item.inventoryItemId))
if (distinctIds.size !== normalizedItems.length) {
throw new Error('Each inventory item can only appear once in a shopping list.')
}
return normalizedItems
}
function ShoppingListsPage() {
const { isAuthenticated } = useAuth()
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [statusMessage, setStatusMessage] = useState('')
const [households, setHouseholds] = useState([])
const [inventoryItems, setInventoryItems] = useState([])
const [shoppingLists, setShoppingLists] = useState([])
const [selectedShoppingListId, setSelectedShoppingListId] = useState('')
const [editingShoppingListId, setEditingShoppingListId] = useState('')
const [shoppingListForm, setShoppingListForm] = useState(createShoppingListForm())
useEffect(() => {
let cancelled = false
async function loadInitialData() {
if (!isAuthenticated) {
setHouseholds([])
setInventoryItems([])
setShoppingLists([])
setSelectedShoppingListId('')
setEditingShoppingListId('')
setShoppingListForm(createShoppingListForm())
setErrorMessage('')
setStatusMessage('')
return
}
setLoading(true)
setErrorMessage('')
try {
const [nextHouseholdsResponse, nextInventoryResponse, nextShoppingListsResponse] = await Promise.all([
householdsApi.getHouseholds(),
inventoryApi.getInventoryItems(),
shoppingListsApi.getShoppingLists(),
])
if (cancelled) return
const nextHouseholds = Array.isArray(nextHouseholdsResponse) ? nextHouseholdsResponse : []
const nextInventoryItems = Array.isArray(nextInventoryResponse) ? nextInventoryResponse : []
const nextShoppingLists = Array.isArray(nextShoppingListsResponse) ? nextShoppingListsResponse : []
setHouseholds(nextHouseholds)
setInventoryItems(nextInventoryItems)
setShoppingLists(nextShoppingLists)
setSelectedShoppingListId(nextShoppingLists[0]?.id ?? '')
} catch (error) {
if (!cancelled) {
setErrorMessage(error.message)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
loadInitialData()
return () => {
cancelled = true
}
}, [isAuthenticated])
useEffect(() => {
if (editingShoppingListId || shoppingListForm.householdId || households.length === 0) {
return
}
setShoppingListForm(currentForm => ({
...currentForm,
householdId: households[0].id,
}))
}, [editingShoppingListId, households, shoppingListForm.householdId])
async function loadPageData(preferredSelectionId = '') {
const [nextHouseholdsResponse, nextInventoryResponse, nextShoppingListsResponse] = await Promise.all([
householdsApi.getHouseholds(),
inventoryApi.getInventoryItems(),
shoppingListsApi.getShoppingLists(),
])
const nextHouseholds = Array.isArray(nextHouseholdsResponse) ? nextHouseholdsResponse : []
const nextInventoryItems = Array.isArray(nextInventoryResponse) ? nextInventoryResponse : []
const nextShoppingLists = Array.isArray(nextShoppingListsResponse) ? nextShoppingListsResponse : []
setHouseholds(nextHouseholds)
setInventoryItems(nextInventoryItems)
setShoppingLists(nextShoppingLists)
setSelectedShoppingListId(currentSelectionId => {
const targetSelectionId = preferredSelectionId || currentSelectionId
if (nextShoppingLists.some(shoppingList => shoppingList.id === targetSelectionId)) {
return targetSelectionId
}
return nextShoppingLists[0]?.id ?? ''
})
setEditingShoppingListId(currentEditingId => (
nextShoppingLists.some(shoppingList => shoppingList.id === currentEditingId)
? currentEditingId
: ''
))
}
function resetEditor() {
setEditingShoppingListId('')
setShoppingListForm(createShoppingListForm(households[0]?.id ?? ''))
}
function updateItemRow(index, updates) {
setShoppingListForm(currentForm => ({
...currentForm,
items: currentForm.items.map((item, itemIndex) => (
itemIndex === index
? { ...item, ...updates }
: item
)),
}))
}
function addItemRow() {
setShoppingListForm(currentForm => ({
...currentForm,
items: [...currentForm.items, createShoppingListItemForm()],
}))
}
function removeItemRow(index) {
setShoppingListForm(currentForm => ({
...currentForm,
items: currentForm.items.filter((_, itemIndex) => itemIndex !== index),
}))
}
async function handleEditShoppingList(shoppingListId) {
setLoading(true)
setErrorMessage('')
setStatusMessage('')
try {
const shoppingList = await shoppingListsApi.getShoppingList(shoppingListId)
setSelectedShoppingListId(shoppingList.id)
setEditingShoppingListId(shoppingList.id)
setShoppingListForm(mapShoppingListToForm(shoppingList))
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
async function handleShoppingListSubmit(event) {
event.preventDefault()
const name = shoppingListForm.name.trim()
if (!name) {
setErrorMessage('Shopping list name is required.')
return
}
if (!editingShoppingListId && !shoppingListForm.householdId) {
setErrorMessage('Choose a household before creating a shopping list.')
return
}
let normalizedItems
try {
normalizedItems = normalizeShoppingListItems(shoppingListForm.items)
} catch (error) {
setErrorMessage(error.message)
return
}
setLoading(true)
setErrorMessage('')
setStatusMessage('')
try {
const payload = {
name,
items: normalizedItems,
}
const result = editingShoppingListId
? await shoppingListsApi.updateShoppingList(editingShoppingListId, payload)
: await shoppingListsApi.createShoppingList({
...payload,
householdId: shoppingListForm.householdId,
})
await loadPageData(result.id)
setSelectedShoppingListId(result.id)
setEditingShoppingListId(result.id)
setShoppingListForm(mapShoppingListToForm(result))
setStatusMessage(editingShoppingListId ? 'Shopping list updated.' : 'Shopping list created.')
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
async function handleDeleteShoppingList(shoppingListId) {
const confirmed = window.confirm('Delete this shopping list?')
if (!confirmed) return
setLoading(true)
setErrorMessage('')
setStatusMessage('')
try {
await shoppingListsApi.deleteShoppingList(shoppingListId)
await loadPageData(selectedShoppingListId === shoppingListId ? '' : selectedShoppingListId)
if (editingShoppingListId === shoppingListId) {
resetEditor()
}
setStatusMessage('Shopping list deleted.')
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
const inventoryOptions = [...inventoryItems].sort((left, right) => (
(left.name ?? '').localeCompare(right.name ?? '')
))
return (
<>
<h2>Shopping Lists</h2>
<hr />
{!isAuthenticated ? (
<div className="auth-required">
Sign in on the dashboard before managing shopping lists.
</div>
) : (
<>
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
{statusMessage && <div className="status-banner status-banner--success">{statusMessage}</div>}
{loading && <div className="status-banner status-banner--info">Syncing shopping lists...</div>}
<div className="planning-grid">
<section className="panel">
<div className="section-heading">
<h3>{editingShoppingListId ? 'Edit Shopping List' : 'Create Shopping List'}</h3>
{editingShoppingListId && (
<button type="button" className="btn btn-secondary" onClick={resetEditor}>
New shopping list
</button>
)}
</div>
<p className="form-note planning-form-note">
Shopping lists are household-scoped. Pick the household first, then add inventory items that belong to members of that household.
</p>
<form className="editor-form" onSubmit={handleShoppingListSubmit}>
<div className="form-grid">
<div className="field-group">
<label htmlFor="shopping-list-name">Name</label>
<input
id="shopping-list-name"
type="text"
value={shoppingListForm.name}
onChange={event => setShoppingListForm(currentForm => ({ ...currentForm, name: event.target.value }))}
placeholder="Weekend shop"
required
/>
</div>
<div className="field-group">
<label htmlFor="shopping-list-household">Household</label>
<select
id="shopping-list-household"
value={shoppingListForm.householdId}
onChange={event => setShoppingListForm(currentForm => ({
...currentForm,
householdId: event.target.value,
items: [],
}))}
disabled={Boolean(editingShoppingListId)}
>
<option value="">Select a household</option>
{households.map(household => (
<option key={household.id} value={household.id}>{household.name}</option>
))}
</select>
</div>
</div>
<div className="section-heading">
<h3>List Items</h3>
<button type="button" className="btn btn-secondary" onClick={addItemRow}>
Add item
</button>
</div>
{shoppingListForm.items.length === 0 ? (
<div className="empty-state compact-empty-state">No list items yet. Add a row when you are ready.</div>
) : (
<div className="planning-item-stack">
{shoppingListForm.items.map((item, index) => (
<div className="planning-item-row" key={`shopping-list-item-${index + 1}`}>
<div className="planning-item-row-header">
<strong>Item {index + 1}</strong>
<button type="button" className="btn btn-secondary" onClick={() => removeItemRow(index)}>
Remove
</button>
</div>
<div className="form-grid planning-item-grid">
<div className="field-group">
<label htmlFor={`shopping-list-item-id-${index}`}>Inventory Item</label>
<select
id={`shopping-list-item-id-${index}`}
value={item.inventoryItemId}
onChange={event => updateItemRow(index, { inventoryItemId: event.target.value })}
>
<option value="">Select an inventory item</option>
{inventoryOptions.map(inventoryItem => (
<option key={inventoryItem.id} value={inventoryItem.id}>
{inventoryItem.name} ({inventoryItem.location?.name || 'No location'})
</option>
))}
</select>
</div>
<div className="field-group">
<label htmlFor={`shopping-list-item-amount-${index}`}>Amount Required</label>
<input
id={`shopping-list-item-amount-${index}`}
type="number"
min="0"
step="0.1"
value={item.amountRequired}
onChange={event => updateItemRow(index, { amountRequired: event.target.value })}
placeholder="2"
/>
</div>
<div className="field-group">
<label htmlFor={`shopping-list-item-type-${index}`}>Amount Type</label>
<input
id={`shopping-list-item-type-${index}`}
type="text"
value={item.amountType}
onChange={event => updateItemRow(index, { amountType: event.target.value })}
placeholder="cartons"
/>
</div>
<div className="planning-checkbox-row">
<input
id={`shopping-list-item-purchased-${index}`}
type="checkbox"
checked={item.isPurchased}
onChange={event => updateItemRow(index, { isPurchased: event.target.checked })}
/>
<label htmlFor={`shopping-list-item-purchased-${index}`}>Purchased</label>
</div>
</div>
</div>
))}
</div>
)}
{editingShoppingListId && (
<p className="form-note">
Household selection is locked while editing because the update endpoint only replaces the name and list items.
</p>
)}
<div className="button-row">
<button type="submit" className="btn btn-primary">
{editingShoppingListId ? 'Update shopping list' : 'Create shopping list'}
</button>
<button type="button" className="btn btn-secondary" onClick={resetEditor}>
Clear form
</button>
</div>
</form>
</section>
<section className="panel">
<div className="section-heading">
<h3>Shopping Lists</h3>
<span className="subtle-text">{shoppingLists.length} total</span>
</div>
{shoppingLists.length === 0 ? (
<div className="empty-state compact-empty-state">No shopping lists were returned.</div>
) : (
<div className="planning-list">
{shoppingLists.map(shoppingList => (
<article
className={`planning-card ${selectedShoppingListId === shoppingList.id ? 'is-selected' : ''}`}
key={shoppingList.id}
>
<div className="planning-card-header">
<div>
<strong>{shoppingList.name}</strong>
<div className="entity-meta">Household: {resolveHouseholdName(households, shoppingList.householdId)}</div>
<div className="entity-meta">
Created {formatDate(shoppingList.createdAt) || 'Unknown date'} by {shoppingList.createdByEmail || 'Unknown user'}
</div>
</div>
<span className="planning-chip">{shoppingList.items?.length ?? 0} item(s)</span>
</div>
<div className="planning-summary-list">
{(shoppingList.items ?? []).length === 0 ? (
<div className="entity-meta">No items added yet.</div>
) : (
shoppingList.items.map(item => (
<div className="planning-summary-row" key={item.inventoryItemId}>
<div>
<strong>{item.inventoryItemName || 'Unnamed item'}</strong>
<div className="entity-meta">
{formatAmount(item.amountRequired, item.amountType)}
{item.inventoryItemBarcode ? ` | ${item.inventoryItemBarcode}` : ''}
</div>
</div>
<span className={`planning-status-chip ${item.isPurchased ? 'is-success' : 'is-neutral'}`}>
{item.isPurchased ? 'Purchased' : 'Pending'}
</span>
</div>
))
)}
</div>
<div className="planning-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => setSelectedShoppingListId(shoppingList.id)}
>
Select
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => handleEditShoppingList(shoppingList.id)}
>
Edit
</button>
<button
type="button"
className="btn btn-danger"
onClick={() => handleDeleteShoppingList(shoppingList.id)}
>
Delete
</button>
</div>
</article>
))}
</div>
)}
</section>
</div>
</>
)}
</>
)
}
export default ShoppingListsPage

View File

@@ -1,4 +1,8 @@
.users-page-panel { .users-management-grid {
display: grid;
grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.1fr);
gap: 20px;
align-items: start;
margin-top: 24px; margin-top: 24px;
} }
@@ -6,6 +10,10 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.users-page-panel {
min-width: 0;
}
.users-grid { .users-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
@@ -14,13 +22,18 @@
.user-card { .user-card {
display: grid; display: grid;
gap: 8px; gap: 10px;
padding: 18px; padding: 18px;
border-radius: 12px; border-radius: 12px;
background: var(--color-surface-muted); background: var(--color-surface-muted);
border: 1px solid var(--color-border-muted); border: 1px solid var(--color-border-muted);
} }
.user-card.is-selected {
border-color: var(--color-focus);
box-shadow: 0 0 0 3px var(--color-focus-ring-strong);
}
.users-role-list { .users-role-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -45,3 +58,20 @@
color: var(--color-text-muted); color: var(--color-text-muted);
border-color: var(--color-border-muted); border-color: var(--color-border-muted);
} }
.users-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.users-editor-meta {
display: grid;
gap: 6px;
}
@media (max-width: 900px) {
.users-management-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -3,6 +3,14 @@ import { usersApi } from '../../api/client.js'
import { useAuth } from '../../context/AuthContext.jsx' import { useAuth } from '../../context/AuthContext.jsx'
import './UsersPage.css' import './UsersPage.css'
const EMPTY_USER_FORM = {
email: '',
firstName: '',
lastName: '',
password: '',
rolesInput: '',
}
function formatUserName(user) { function formatUserName(user) {
const fullName = [user.firstName, user.lastName] const fullName = [user.firstName, user.lastName]
.filter(Boolean) .filter(Boolean)
@@ -11,49 +19,178 @@ function formatUserName(user) {
return fullName || user.email || 'Unnamed user' return fullName || user.email || 'Unnamed user'
} }
function mapUserToForm(user) {
return {
email: user?.email ?? '',
firstName: user?.firstName ?? '',
lastName: user?.lastName ?? '',
password: '',
rolesInput: Array.isArray(user?.roles) ? user.roles.join(', ') : '',
}
}
function parseRoles(value) {
return value
.split(',')
.map(role => role.trim())
.filter(Boolean)
}
function UsersPage() { function UsersPage() {
const { isAuthenticated, isSiteAdmin } = useAuth() const { isAuthenticated, isSiteAdmin, refreshProfile, user } = useAuth()
const [users, setUsers] = useState([]) const [users, setUsers] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')
const [statusMessage, setStatusMessage] = useState('') const [statusMessage, setStatusMessage] = useState('')
const [endpointUnavailable, setEndpointUnavailable] = useState(false) const [selectedUserId, setSelectedUserId] = useState('')
const [editingUserId, setEditingUserId] = useState('')
const [userForm, setUserForm] = useState(EMPTY_USER_FORM)
async function loadUsers() { async function loadUsers(preferredSelectionId = '') {
const response = await usersApi.getUsers()
const nextUsers = Array.isArray(response) ? response : []
setUsers(nextUsers)
setSelectedUserId(currentSelectionId => {
const targetSelectionId = preferredSelectionId || currentSelectionId
if (nextUsers.some(existingUser => existingUser.id === targetSelectionId)) {
return targetSelectionId
}
return nextUsers[0]?.id ?? ''
})
setEditingUserId(currentEditingId => (
nextUsers.some(existingUser => existingUser.id === currentEditingId)
? currentEditingId
: ''
))
}
useEffect(() => {
let cancelled = false
async function loadInitialUsers() {
if (!isAuthenticated || !isSiteAdmin) {
setUsers([])
setErrorMessage('')
setStatusMessage('')
setSelectedUserId('')
setEditingUserId('')
setUserForm(EMPTY_USER_FORM)
return
}
setLoading(true)
setErrorMessage('')
try {
const response = await usersApi.getUsers()
if (cancelled) return
const nextUsers = Array.isArray(response) ? response : []
setUsers(nextUsers)
setSelectedUserId(nextUsers[0]?.id ?? '')
} catch (error) {
if (!cancelled) {
setErrorMessage(error.message)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
loadInitialUsers()
return () => {
cancelled = true
}
}, [isAuthenticated, isSiteAdmin])
function resetEditor() {
setEditingUserId('')
setUserForm(EMPTY_USER_FORM)
}
async function handleRefreshUsers() {
setLoading(true) setLoading(true)
setErrorMessage('') setErrorMessage('')
setStatusMessage('') setStatusMessage('')
try { try {
const response = await usersApi.getUsers() await loadUsers(selectedUserId)
setUsers(Array.isArray(response) ? response : [])
setEndpointUnavailable(false)
} catch (error) { } catch (error) {
setUsers([])
if (error.status === 404) {
setEndpointUnavailable(true)
setStatusMessage('GET /api/users is not available in the current backend build yet.')
return
}
setErrorMessage(error.message) setErrorMessage(error.message)
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
useEffect(() => { async function handleEditUser(userId) {
if (!isAuthenticated || !isSiteAdmin) { setLoading(true)
setUsers([])
setErrorMessage('') setErrorMessage('')
setStatusMessage('') setStatusMessage('')
setEndpointUnavailable(false)
try {
const nextUser = await usersApi.getUser(userId)
setSelectedUserId(nextUser.id)
setEditingUserId(nextUser.id)
setUserForm(mapUserToForm(nextUser))
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
async function handleUserSubmit(event) {
event.preventDefault()
if (!editingUserId) {
setErrorMessage('Select a user before editing details.')
return return
} }
loadUsers() const email = userForm.email.trim()
}, [isAuthenticated, isSiteAdmin])
if (!email) {
setErrorMessage('User email is required.')
return
}
setSaving(true)
setErrorMessage('')
setStatusMessage('')
try {
const updatedUser = await usersApi.updateUser(editingUserId, {
email,
firstName: userForm.firstName,
lastName: userForm.lastName,
password: userForm.password || undefined,
roles: parseRoles(userForm.rolesInput),
})
await loadUsers(updatedUser.id)
setSelectedUserId(updatedUser.id)
setEditingUserId(updatedUser.id)
setUserForm(mapUserToForm(updatedUser))
if (user?.id === updatedUser.id) {
await refreshProfile()
}
setStatusMessage('User updated.')
} catch (error) {
setErrorMessage(error.message)
} finally {
setSaving(false)
}
}
return ( return (
<> <>
@@ -62,7 +199,7 @@ function UsersPage() {
{!isAuthenticated ? ( {!isAuthenticated ? (
<div className="auth-required"> <div className="auth-required">
Sign in on the dashboard before using the users endpoint. Sign in on the dashboard before using the protected users endpoints.
</div> </div>
) : !isSiteAdmin ? ( ) : !isSiteAdmin ? (
<div className="auth-required"> <div className="auth-required">
@@ -71,37 +208,127 @@ function UsersPage() {
) : ( ) : (
<> <>
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>} {errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
{statusMessage && <div className="status-banner status-banner--info">{statusMessage}</div>} {statusMessage && <div className="status-banner status-banner--success">{statusMessage}</div>}
{loading && <div className="status-banner status-banner--info">Loading users...</div>} {loading && <div className="status-banner status-banner--info">Loading users...</div>}
{saving && <div className="status-banner status-banner--info">Saving user changes...</div>}
<div className="users-management-grid">
<section className="panel">
<div className="section-heading">
<h3>{editingUserId ? 'Edit User' : 'User Editor'}</h3>
{editingUserId && (
<button type="button" className="btn btn-secondary" onClick={resetEditor}>
Clear editor
</button>
)}
</div>
<p className="form-note users-page-note">
Roles are replaced with the comma-separated list you submit. Leave the password blank if you do not want to reset it.
</p>
{!editingUserId ? (
<div className="empty-state compact-empty-state">Choose a user from the directory to load the edit form.</div>
) : (
<form className="editor-form" onSubmit={handleUserSubmit}>
<div className="users-editor-meta">
<strong>{formatUserName(userForm)}</strong>
<div className="entity-meta">Editing user ID: {editingUserId}</div>
</div>
<div className="field-group">
<label htmlFor="users-email">Email</label>
<input
id="users-email"
type="email"
value={userForm.email}
onChange={event => setUserForm(current => ({ ...current, email: event.target.value }))}
placeholder="user@example.com"
required
/>
</div>
<div className="form-grid">
<div className="field-group">
<label htmlFor="users-first-name">First Name</label>
<input
id="users-first-name"
type="text"
value={userForm.firstName}
onChange={event => setUserForm(current => ({ ...current, firstName: event.target.value }))}
placeholder="Alex"
/>
</div>
<div className="field-group">
<label htmlFor="users-last-name">Last Name</label>
<input
id="users-last-name"
type="text"
value={userForm.lastName}
onChange={event => setUserForm(current => ({ ...current, lastName: event.target.value }))}
placeholder="Smith"
/>
</div>
</div>
<div className="field-group">
<label htmlFor="users-password">Reset Password</label>
<input
id="users-password"
type="password"
value={userForm.password}
onChange={event => setUserForm(current => ({ ...current, password: event.target.value }))}
placeholder="Leave blank to keep the current password"
/>
</div>
<div className="field-group">
<label htmlFor="users-roles">Roles</label>
<input
id="users-roles"
type="text"
value={userForm.rolesInput}
onChange={event => setUserForm(current => ({ ...current, rolesInput: event.target.value }))}
placeholder="Site Admin, Admin"
/>
</div>
<div className="button-row">
<button type="submit" className="btn btn-primary" disabled={saving}>
Save user
</button>
<button type="button" className="btn btn-secondary" onClick={resetEditor} disabled={saving}>
Reset editor
</button>
</div>
</form>
)}
</section>
<section className="panel users-page-panel"> <section className="panel users-page-panel">
<div className="section-heading"> <div className="section-heading">
<h3>User Directory</h3> <h3>User Directory</h3>
<button type="button" className="btn btn-secondary" onClick={loadUsers}> <button type="button" className="btn btn-secondary" onClick={handleRefreshUsers}>
Refresh users Refresh users
</button> </button>
</div> </div>
<p className="form-note users-page-note"> {users.length === 0 ? (
This page is intentionally minimal. It is ready to consume a backend users endpoint as soon as that contract exists.
</p>
{endpointUnavailable ? (
<div className="empty-state compact-empty-state">
Add `GET /api/users` to the backend to populate this page.
</div>
) : users.length === 0 ? (
<div className="empty-state compact-empty-state">No users were returned.</div> <div className="empty-state compact-empty-state">No users were returned.</div>
) : ( ) : (
<div className="users-grid"> <div className="users-grid">
{users.map(user => { {users.map(directoryUser => {
const roles = Array.isArray(user.roles) ? user.roles : [] const roles = Array.isArray(directoryUser.roles) ? directoryUser.roles : []
return ( return (
<article className="user-card" key={user.id ?? user.email}> <article
<strong>{formatUserName(user)}</strong> className={`user-card ${selectedUserId === directoryUser.id ? 'is-selected' : ''}`}
<div className="entity-meta">{user.email || 'No email provided.'}</div> key={directoryUser.id ?? directoryUser.email}
<div className="entity-meta">ID: {user.id || 'Not set'}</div> >
<strong>{formatUserName(directoryUser)}</strong>
<div className="entity-meta">{directoryUser.email || 'No email provided.'}</div>
<div className="entity-meta">ID: {directoryUser.id || 'Not set'}</div>
<div className="users-role-list"> <div className="users-role-list">
{roles.length === 0 ? ( {roles.length === 0 ? (
<span className="users-role-badge users-role-badge--empty">No roles</span> <span className="users-role-badge users-role-badge--empty">No roles</span>
@@ -111,12 +338,30 @@ function UsersPage() {
)) ))
)} )}
</div> </div>
<div className="users-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => setSelectedUserId(directoryUser.id)}
>
Select
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => handleEditUser(directoryUser.id)}
>
Edit
</button>
</div>
</article> </article>
) )
})} })}
</div> </div>
)} )}
</section> </section>
</div>
</> </>
)} )}
</> </>

View File

@@ -22,13 +22,13 @@ export function createItemForm(barcode = '') {
} }
export function buildInventoryPayload(form, includeBlankText = false) { export function buildInventoryPayload(form, includeBlankText = false) {
const payload = { const payload = {}
name: form.name.trim(), const name = form.name.trim()
}
const barcode = normalizeBarcode(form.barcode) const barcode = normalizeBarcode(form.barcode)
const amountType = form.amountType.trim() const amountType = form.amountType.trim()
if (name || includeBlankText) payload.name = name
if (barcode || includeBlankText) payload.barcode = barcode if (barcode || includeBlankText) payload.barcode = barcode
if (amountType || includeBlankText) payload.amountType = amountType if (amountType || includeBlankText) payload.amountType = amountType

View File

@@ -9,11 +9,34 @@ 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) {
const normalizedTime = toTimeInputValue(timeStr)
if (!normalizedTime) return ''
const [hours, minutes] = normalizedTime.split(':').map(Number)
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return normalizedTime
const date = new Date()
date.setHours(hours, minutes, 0, 0)
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
})
}
export function toDateInputValue(dateStr) { export function toDateInputValue(dateStr) {
if (!dateStr) return '' if (!dateStr) return ''
return String(dateStr).slice(0, 10) return String(dateStr).slice(0, 10)
} }
export function toTimeInputValue(timeStr) {
if (!timeStr) return ''
return String(timeStr).slice(0, 5)
}
export function getExpiryStatus(expiryDate) { export function getExpiryStatus(expiryDate) {
if (!expiryDate) return { status: 'Unknown', color: '#999', text: '' } if (!expiryDate) return { status: 'Unknown', color: '#999', text: '' }