Commiting work
This commit is contained in:
2
.github/copilot-instructions.md
vendored
Normal file
2
.github/copilot-instructions.md
vendored
Normal 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
|
||||||
@@ -4,7 +4,10 @@ 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'
|
||||||
@@ -62,8 +65,11 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/admin" element={isSiteAdmin ? <AdminPage /> : <Navigate to="/" replace />} />
|
<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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,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="/profile" onClick={closeMenu}>Profile</NavLink></li>
|
||||||
{isSiteAdmin && <li><NavLink to="/admin" onClick={closeMenu}>Admin</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>}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -37,6 +37,9 @@ function AdminPage() {
|
|||||||
const [inviteEmail, setInviteEmail] = useState('')
|
const [inviteEmail, setInviteEmail] = useState('')
|
||||||
const [userForm, setUserForm] = useState(EMPTY_USER_FORM)
|
const [userForm, setUserForm] = useState(EMPTY_USER_FORM)
|
||||||
const [createdUser, setCreatedUser] = useState(null)
|
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()
|
||||||
@@ -71,6 +74,9 @@ function AdminPage() {
|
|||||||
setInviteEmail('')
|
setInviteEmail('')
|
||||||
setUserForm(EMPTY_USER_FORM)
|
setUserForm(EMPTY_USER_FORM)
|
||||||
setCreatedUser(null)
|
setCreatedUser(null)
|
||||||
|
setHouseholdHistory([])
|
||||||
|
setHouseholdHistoryLoading(false)
|
||||||
|
setHouseholdHistoryError('')
|
||||||
setErrorMessage('')
|
setErrorMessage('')
|
||||||
setStatusMessage('')
|
setStatusMessage('')
|
||||||
return
|
return
|
||||||
@@ -105,6 +111,45 @@ 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))
|
||||||
@@ -237,7 +282,27 @@ function AdminPage() {
|
|||||||
lastName: userForm.lastName.trim() || null,
|
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()
|
resetUserForm()
|
||||||
setStatusMessage(result?.message || 'User created.')
|
setStatusMessage(result?.message || 'User created.')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -409,7 +474,7 @@ function AdminPage() {
|
|||||||
|
|
||||||
<p className="form-note">
|
<p className="form-note">
|
||||||
{isSiteAdmin
|
{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.'}
|
: 'Only site admins can create users from this page.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -511,6 +576,44 @@ function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
597
src/pages/MealPlannersPage/MealPlannersPage.jsx
Normal file
597
src/pages/MealPlannersPage/MealPlannersPage.jsx
Normal 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
139
src/pages/PlanningPage.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/pages/ProfilePage/ProfilePage.css
Normal file
56
src/pages/ProfilePage/ProfilePage.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
242
src/pages/ProfilePage/ProfilePage.jsx
Normal file
242
src/pages/ProfilePage/ProfilePage.jsx
Normal 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
|
||||||
560
src/pages/ShoppingListsPage/ShoppingListsPage.jsx
Normal file
560
src/pages/ShoppingListsPage/ShoppingListsPage.jsx
Normal 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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,52 +208,160 @@ 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>}
|
||||||
|
|
||||||
<section className="panel users-page-panel">
|
<div className="users-management-grid">
|
||||||
<div className="section-heading">
|
<section className="panel">
|
||||||
<h3>User Directory</h3>
|
<div className="section-heading">
|
||||||
<button type="button" className="btn btn-secondary" onClick={loadUsers}>
|
<h3>{editingUserId ? 'Edit User' : 'User Editor'}</h3>
|
||||||
Refresh users
|
{editingUserId && (
|
||||||
</button>
|
<button type="button" className="btn btn-secondary" onClick={resetEditor}>
|
||||||
</div>
|
Clear editor
|
||||||
|
</button>
|
||||||
<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>
|
</div>
|
||||||
) : 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 : []
|
|
||||||
|
|
||||||
return (
|
<p className="form-note users-page-note">
|
||||||
<article className="user-card" key={user.id ?? user.email}>
|
Roles are replaced with the comma-separated list you submit. Leave the password blank if you do not want to reset it.
|
||||||
<strong>{formatUserName(user)}</strong>
|
</p>
|
||||||
<div className="entity-meta">{user.email || 'No email provided.'}</div>
|
|
||||||
<div className="entity-meta">ID: {user.id || 'Not set'}</div>
|
{!editingUserId ? (
|
||||||
<div className="users-role-list">
|
<div className="empty-state compact-empty-state">Choose a user from the directory to load the edit form.</div>
|
||||||
{roles.length === 0 ? (
|
) : (
|
||||||
<span className="users-role-badge users-role-badge--empty">No roles</span>
|
<form className="editor-form" onSubmit={handleUserSubmit}>
|
||||||
) : (
|
<div className="users-editor-meta">
|
||||||
roles.map(role => (
|
<strong>{formatUserName(userForm)}</strong>
|
||||||
<span className="users-role-badge" key={role}>{role}</span>
|
<div className="entity-meta">Editing user ID: {editingUserId}</div>
|
||||||
))
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
<div className="field-group">
|
||||||
</article>
|
<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={handleRefreshUsers}>
|
||||||
|
Refresh users
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</section>
|
{users.length === 0 ? (
|
||||||
|
<div className="empty-state compact-empty-state">No users were returned.</div>
|
||||||
|
) : (
|
||||||
|
<div className="users-grid">
|
||||||
|
{users.map(directoryUser => {
|
||||||
|
const roles = Array.isArray(directoryUser.roles) ? directoryUser.roles : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
roles.map(role => (
|
||||||
|
<span className="users-role-badge" key={role}>{role}</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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: '' }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user