Commiting work

This commit is contained in:
2026-05-12 08:40:45 +01:00
parent 86082c50d1
commit 63e5871e61
18 changed files with 2388 additions and 128 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

@@ -4,7 +4,10 @@ import Navbar from './components/Navbar/Navbar.jsx'
import AdminPage from './pages/AdminPage/AdminPage.jsx'
import HomePage from './pages/HomePage/HomePage.jsx'
import InventoryPage from './pages/InventoryPage/InventoryPage.jsx'
import MealPlannersPage from './pages/MealPlannersPage/MealPlannersPage.jsx'
import ProfilePage from './pages/ProfilePage/ProfilePage.jsx'
import SearchPage from './pages/SearchPage/SearchPage.jsx'
import ShoppingListsPage from './pages/ShoppingListsPage/ShoppingListsPage.jsx'
import BarcodePage from './pages/BarcodePage/BarcodePage.jsx'
import UsersPage from './pages/UsersPage/UsersPage.jsx'
import { useAuth } from './context/AuthContext.jsx'
@@ -62,8 +65,11 @@ function App() {
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={isSiteAdmin ? <AdminPage /> : <Navigate to="/" replace />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/inventory" element={<InventoryPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/shopping-lists" element={<ShoppingListsPage />} />
<Route path="/meal-planners" element={<MealPlannersPage />} />
<Route path="/barcode" element={<BarcodePage />} />
<Route path="/users" element={<UsersPage />} />
</Routes>

View File

@@ -293,6 +293,13 @@ export const profileApi = {
getProtectedData() {
return requestJson('/api/profile/data')
},
updateProfile(payload) {
return requestJson('/api/profile', {
method: 'PUT',
body: payload,
})
},
}
export const locationsApi = {
@@ -300,6 +307,10 @@ export const locationsApi = {
return requestJson('/api/locations')
},
getLocationHistory(id) {
return requestJson(`/api/locations/${id}/history`)
},
createLocation(payload) {
return requestJson('/api/locations', {
method: 'POST',
@@ -366,6 +377,10 @@ export const householdsApi = {
return requestJson('/api/households')
},
getHouseholdHistory(id) {
return requestJson(`/api/households/${id}/history`)
},
createHousehold(payload) {
return requestJson('/api/households', {
method: 'POST',
@@ -398,4 +413,75 @@ export const usersApi = {
getUsers() {
return requestJson('/api/users')
},
getUser(id) {
return requestJson(`/api/users/${id}`)
},
updateUser(id, payload) {
return requestJson(`/api/users/${id}`, {
method: 'PUT',
body: payload,
})
},
}
export const shoppingListsApi = {
getShoppingLists() {
return requestJson('/api/shoppinglists')
},
getShoppingList(id) {
return requestJson(`/api/shoppinglists/${id}`)
},
createShoppingList(payload) {
return requestJson('/api/shoppinglists', {
method: 'POST',
body: payload,
})
},
updateShoppingList(id, payload) {
return requestJson(`/api/shoppinglists/${id}`, {
method: 'PUT',
body: payload,
})
},
deleteShoppingList(id) {
return requestJson(`/api/shoppinglists/${id}`, {
method: 'DELETE',
})
},
}
export const mealPlannersApi = {
getMealPlanners() {
return requestJson('/api/mealplanners')
},
getMealPlanner(id) {
return requestJson(`/api/mealplanners/${id}`)
},
createMealPlanner(payload) {
return requestJson('/api/mealplanners', {
method: 'POST',
body: payload,
})
},
updateMealPlanner(id, payload) {
return requestJson(`/api/mealplanners/${id}`, {
method: 'PUT',
body: payload,
})
},
deleteMealPlanner(id) {
return requestJson(`/api/mealplanners/${id}`, {
method: 'DELETE',
})
},
}

View File

@@ -83,9 +83,12 @@ function Navbar({ theme, onToggleTheme }) {
{isAuthenticated ? (
<>
<li><NavLink to="/" onClick={closeMenu}>Homepage</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="/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>
{isSiteAdmin && <li><NavLink to="/users" onClick={closeMenu}>Users</NavLink></li>}
</>

View File

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

View File

@@ -1,5 +1,5 @@
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 { formatDate } from '../../utils/searchUtils.js'
import './AdminPage.css'
@@ -37,6 +37,9 @@ function AdminPage() {
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 = '') {
const response = await householdsApi.getHouseholds()
@@ -71,6 +74,9 @@ function AdminPage() {
setInviteEmail('')
setUserForm(EMPTY_USER_FORM)
setCreatedUser(null)
setHouseholdHistory([])
setHouseholdHistoryLoading(false)
setHouseholdHistoryError('')
setErrorMessage('')
setStatusMessage('')
return
@@ -105,6 +111,45 @@ function AdminPage() {
}
}, [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 editingHousehold = households.find(household => household.id === editingHouseholdId) ?? null
const canManageSelectedHousehold = Boolean(selectedHousehold && (isSiteAdmin || selectedHousehold.isCurrentUserHouseholdAdmin))
@@ -237,7 +282,27 @@ function AdminPage() {
lastName: userForm.lastName.trim() || null,
})
setCreatedUser(result?.user ?? 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) {
@@ -409,7 +474,7 @@ function AdminPage() {
<p className="form-note">
{isSiteAdmin
? 'This creates a new account with the register endpoint. Role assignment still happens separately in the backend.'
? '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>
@@ -511,6 +576,44 @@ function AdminPage() {
</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>
<section className="panel admin-list-panel">

View File

@@ -164,17 +164,6 @@
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,
.manual-entry-actions,
.quick-actions,
@@ -239,6 +228,13 @@
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 {
background: var(--status-success-bg);
border-color: var(--status-success-border);

View File

@@ -15,8 +15,6 @@ import {
import { formatAmount, formatDate } from '../../utils/searchUtils.js'
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 CAMERA_READERS = [
'ean_reader',
@@ -84,11 +82,7 @@ function BarcodePage() {
const matchCandidates = getMatchCandidates(items, matchQuery, lastScannedBarcode)
const hasScannedBarcode = Boolean(normalizeBarcode(lastScannedBarcode))
const hasExactMatches = matchingItems.length > 0
const cameraStatus = cameraActive
? lastScanSource === 'camera' && hasScannedBarcode
? `Captured ${lastScannedBarcode}. Keep scanning or review the matches below.`
: CAMERA_LIVE_MESSAGE
: CAMERA_IDLE_MESSAGE
const quickAddSourceItem = matchingItems.find(item => item.id === editingItemId) ?? matchingItems[0] ?? null
const fetchInventoryData = useCallback(async () => {
const [nextLocations, nextItems] = await Promise.all([
@@ -314,6 +308,42 @@ function BarcodePage() {
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() {
setInventoryLoading(true)
setErrorMessage('')
@@ -349,9 +379,10 @@ function BarcodePage() {
event.preventDefault()
const name = itemForm.name.trim()
const barcode = itemForm.barcode.trim()
if (!name) {
setErrorMessage('Item name is required.')
if (!name && !barcode) {
setErrorMessage('Provide an item name or a barcode.')
return
}
@@ -449,25 +480,15 @@ function BarcodePage() {
<div className="barcode-workspace">
<section className="panel scanner-panel">
<div className="scanner-header">
<div>
<h3>Camera Scanner</h3>
</div>
<div className="mode-toggle" role="tablist" aria-label="Scanner modes">
<button
type="button"
className={`mode-btn ${mode === 'camera' ? 'active' : ''}`}
onClick={switchToCamera}
>
Camera
</button>
<button
type="button"
className={`mode-btn ${mode === 'keyboard' ? 'active' : ''}`}
onClick={switchToKeyboard}
>
Keyboard
</button>
<div className="scan-summary-meta">
{hasScannedBarcode && (
<span className="scan-chip is-neutral scan-chip--barcode">
{lastScannedBarcode}
</span>
)}
<span className={`scan-chip ${hasExactMatches ? 'is-success' : hasScannedBarcode ? 'is-warning' : 'is-neutral'}`}>
{hasScannedBarcode ? (hasExactMatches ? `${matchingItems.length} match${matchingItems.length === 1 ? '' : 'es'}` : 'No exact matches') : 'Ready to scan'}
</span>
</div>
</div>
@@ -479,7 +500,7 @@ function BarcodePage() {
{!cameraActive && (
<div className="camera-placeholder">
<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>
)}
@@ -489,7 +510,6 @@ function BarcodePage() {
</div>
<div className="camera-toolbar">
<div className="scanner-status">{cameraStatus}</div>
<div className="camera-controls">
{!cameraActive ? (
<button type="button" className="btn btn-primary" onClick={startCamera}>
@@ -571,9 +591,20 @@ function BarcodePage() {
</p>
</div>
)}
<div className="quick-actions">
<button
type="button"
className="btn btn-primary"
onClick={handleQuickAdd}
disabled={!hasScannedBarcode || submitting || inventoryLoading}
>
Quick Add
</button>
</div>
</section>
<section className="panel scan-summary-panel">
{/* <section className="panel scan-summary-panel">
<div className="scan-summary-card">
<span className="scan-summary-label">Last scanned barcode</span>
<strong className="scan-summary-value">{hasScannedBarcode ? lastScannedBarcode : 'Waiting for a scan'}</strong>
@@ -613,7 +644,7 @@ function BarcodePage() {
Clear scan
</button>
</div>
</section>
</section> */}
</div>
<div className="barcode-management-grid">
@@ -684,7 +715,7 @@ function BarcodePage() {
{!editorMode ? (
<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>
) : (
<form className="editor-form" onSubmit={handleItemSubmit}>
@@ -697,7 +728,6 @@ function BarcodePage() {
value={itemForm.name}
onChange={event => setItemForm(current => ({ ...current, name: event.target.value }))}
placeholder="Whole Milk"
required
/>
</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.
</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>
)}
</section>

View File

@@ -28,19 +28,36 @@ function InventoryPage() {
const [statusMessage, setStatusMessage] = useState('')
const [locations, setLocations] = 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 [editingItemId, setEditingItemId] = useState('')
const [selectedItem, setSelectedItem] = useState(null)
const [locationForm, setLocationForm] = useState(EMPTY_LOCATION_FORM)
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() {
const [nextLocations, nextItems] = await Promise.all([
locationsApi.getLocations(),
inventoryApi.getInventoryItems(),
])
setLocations(nextLocations)
syncLocations(nextLocations)
setItems(nextItems)
}
@@ -51,6 +68,10 @@ function InventoryPage() {
if (!isAuthenticated) {
setLocations([])
setItems([])
setSelectedLocationId('')
setLocationHistory([])
setLocationHistoryLoading(false)
setLocationHistoryError('')
setSelectedItem(null)
setEditingItemId('')
setEditingLocationId('')
@@ -71,7 +92,7 @@ function InventoryPage() {
])
if (!cancelled) {
setLocations(nextLocations)
syncLocations(nextLocations)
setItems(nextItems)
}
} catch (error) {
@@ -92,6 +113,47 @@ function InventoryPage() {
}
}, [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() {
setEditingLocationId('')
setLocationForm(EMPTY_LOCATION_FORM)
@@ -192,9 +254,10 @@ function InventoryPage() {
event.preventDefault()
const name = itemForm.name.trim()
const barcode = itemForm.barcode.trim()
if (!name) {
setErrorMessage('Item name is required.')
if (!name && !barcode) {
setErrorMessage('Provide an item name or a barcode.')
return
}
@@ -320,7 +383,7 @@ function InventoryPage() {
) : (
locations.map(location => (
<div
className={`entity-row ${editingLocationId === location.id ? 'is-selected' : ''}`}
className={`entity-row ${selectedLocationId === location.id || editingLocationId === location.id ? 'is-selected' : ''}`}
key={location.id}
>
<div className="entity-copy">
@@ -333,6 +396,7 @@ function InventoryPage() {
type="button"
className="btn btn-secondary"
onClick={() => {
setSelectedLocationId(location.id)
setEditingLocationId(location.id)
setLocationForm({
name: location.name,
@@ -342,6 +406,13 @@ function InventoryPage() {
>
Edit
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setSelectedLocationId(location.id)}
>
History
</button>
<button
type="button"
className="btn btn-danger"
@@ -356,6 +427,41 @@ function InventoryPage() {
</div>
</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">
<div className="section-heading">
<h3>{editingItemId ? 'Edit Item' : 'Add Item'}</h3>
@@ -376,7 +482,6 @@ function InventoryPage() {
value={itemForm.name}
onChange={event => setItemForm(current => ({ ...current, name: event.target.value }))}
placeholder="Whole Milk"
required
/>
</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.
</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>
{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;
}
@@ -6,6 +10,10 @@
margin-bottom: 20px;
}
.users-page-panel {
min-width: 0;
}
.users-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
@@ -14,13 +22,18 @@
.user-card {
display: grid;
gap: 8px;
gap: 10px;
padding: 18px;
border-radius: 12px;
background: var(--color-surface-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 {
display: flex;
flex-wrap: wrap;
@@ -45,3 +58,20 @@
color: var(--color-text-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 './UsersPage.css'
const EMPTY_USER_FORM = {
email: '',
firstName: '',
lastName: '',
password: '',
rolesInput: '',
}
function formatUserName(user) {
const fullName = [user.firstName, user.lastName]
.filter(Boolean)
@@ -11,49 +19,178 @@ function formatUserName(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() {
const { isAuthenticated, isSiteAdmin } = useAuth()
const { isAuthenticated, isSiteAdmin, refreshProfile, user } = useAuth()
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [errorMessage, setErrorMessage] = 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)
setErrorMessage('')
setStatusMessage('')
try {
const response = await usersApi.getUsers()
setUsers(Array.isArray(response) ? response : [])
setEndpointUnavailable(false)
await loadUsers(selectedUserId)
} 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)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!isAuthenticated || !isSiteAdmin) {
setUsers([])
async function handleEditUser(userId) {
setLoading(true)
setErrorMessage('')
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
}
loadUsers()
}, [isAuthenticated, isSiteAdmin])
const email = userForm.email.trim()
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 (
<>
@@ -62,7 +199,7 @@ function UsersPage() {
{!isAuthenticated ? (
<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>
) : !isSiteAdmin ? (
<div className="auth-required">
@@ -71,37 +208,127 @@ function UsersPage() {
) : (
<>
{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>}
{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">
<div className="section-heading">
<h3>User Directory</h3>
<button type="button" className="btn btn-secondary" onClick={loadUsers}>
<button type="button" className="btn btn-secondary" onClick={handleRefreshUsers}>
Refresh users
</button>
</div>
<p className="form-note users-page-note">
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 ? (
{users.length === 0 ? (
<div className="empty-state compact-empty-state">No users were returned.</div>
) : (
<div className="users-grid">
{users.map(user => {
const roles = Array.isArray(user.roles) ? user.roles : []
{users.map(directoryUser => {
const roles = Array.isArray(directoryUser.roles) ? directoryUser.roles : []
return (
<article className="user-card" key={user.id ?? user.email}>
<strong>{formatUserName(user)}</strong>
<div className="entity-meta">{user.email || 'No email provided.'}</div>
<div className="entity-meta">ID: {user.id || 'Not set'}</div>
<article
className={`user-card ${selectedUserId === directoryUser.id ? 'is-selected' : ''}`}
key={directoryUser.id ?? directoryUser.email}
>
<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">
{roles.length === 0 ? (
<span className="users-role-badge users-role-badge--empty">No roles</span>
@@ -111,12 +338,30 @@ function UsersPage() {
))
)}
</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>
)
})}
</div>
)}
</section>
</div>
</>
)}
</>

View File

@@ -22,13 +22,13 @@ export function createItemForm(barcode = '') {
}
export function buildInventoryPayload(form, includeBlankText = false) {
const payload = {
name: form.name.trim(),
}
const payload = {}
const name = form.name.trim()
const barcode = normalizeBarcode(form.barcode)
const amountType = form.amountType.trim()
if (name || includeBlankText) payload.name = name
if (barcode || includeBlankText) payload.barcode = barcode
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' })
}
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) {
if (!dateStr) return ''
return String(dateStr).slice(0, 10)
}
export function toTimeInputValue(timeStr) {
if (!timeStr) return ''
return String(timeStr).slice(0, 5)
}
export function getExpiryStatus(expiryDate) {
if (!expiryDate) return { status: 'Unknown', color: '#999', text: '' }