From 63e5871e61ee86fcb05a8b843e09208056488162 Mon Sep 17 00:00:00 2001 From: Luke Betteridge Date: Tue, 12 May 2026 08:40:45 +0100 Subject: [PATCH] Commiting work --- .github/copilot-instructions.md | 2 + src/App.jsx | 6 + src/api/client.js | 86 +++ src/components/Navbar/Navbar.jsx | 3 + src/context/AuthContext.jsx | 35 +- src/pages/AdminPage/AdminPage.jsx | 109 +++- src/pages/BarcodePage/BarcodePage.css | 18 +- src/pages/BarcodePage/BarcodePage.jsx | 104 ++- src/pages/InventoryPage/InventoryPage.jsx | 123 +++- .../MealPlannersPage/MealPlannersPage.jsx | 597 ++++++++++++++++++ src/pages/PlanningPage.css | 139 ++++ src/pages/ProfilePage/ProfilePage.css | 56 ++ src/pages/ProfilePage/ProfilePage.jsx | 242 +++++++ .../ShoppingListsPage/ShoppingListsPage.jsx | 560 ++++++++++++++++ src/pages/UsersPage/UsersPage.css | 34 +- src/pages/UsersPage/UsersPage.jsx | 373 +++++++++-- src/utils/inventoryItemUtils.js | 6 +- src/utils/searchUtils.js | 23 + 18 files changed, 2388 insertions(+), 128 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 src/pages/MealPlannersPage/MealPlannersPage.jsx create mode 100644 src/pages/PlanningPage.css create mode 100644 src/pages/ProfilePage/ProfilePage.css create mode 100644 src/pages/ProfilePage/ProfilePage.jsx create mode 100644 src/pages/ShoppingListsPage/ShoppingListsPage.jsx diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..06c7ed6 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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 \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index 4d3df7a..cb545da 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,7 +4,10 @@ import Navbar from './components/Navbar/Navbar.jsx' import AdminPage from './pages/AdminPage/AdminPage.jsx' import HomePage from './pages/HomePage/HomePage.jsx' import InventoryPage from './pages/InventoryPage/InventoryPage.jsx' +import MealPlannersPage from './pages/MealPlannersPage/MealPlannersPage.jsx' +import ProfilePage from './pages/ProfilePage/ProfilePage.jsx' import SearchPage from './pages/SearchPage/SearchPage.jsx' +import ShoppingListsPage from './pages/ShoppingListsPage/ShoppingListsPage.jsx' import BarcodePage from './pages/BarcodePage/BarcodePage.jsx' import UsersPage from './pages/UsersPage/UsersPage.jsx' import { useAuth } from './context/AuthContext.jsx' @@ -62,8 +65,11 @@ function App() { } /> : } /> + } /> } /> } /> + } /> + } /> } /> } /> diff --git a/src/api/client.js b/src/api/client.js index 4c12fda..d292d6b 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -293,6 +293,13 @@ export const profileApi = { getProtectedData() { return requestJson('/api/profile/data') }, + + updateProfile(payload) { + return requestJson('/api/profile', { + method: 'PUT', + body: payload, + }) + }, } export const locationsApi = { @@ -300,6 +307,10 @@ export const locationsApi = { return requestJson('/api/locations') }, + getLocationHistory(id) { + return requestJson(`/api/locations/${id}/history`) + }, + createLocation(payload) { return requestJson('/api/locations', { method: 'POST', @@ -366,6 +377,10 @@ export const householdsApi = { return requestJson('/api/households') }, + getHouseholdHistory(id) { + return requestJson(`/api/households/${id}/history`) + }, + createHousehold(payload) { return requestJson('/api/households', { method: 'POST', @@ -398,4 +413,75 @@ export const usersApi = { getUsers() { return requestJson('/api/users') }, + + getUser(id) { + return requestJson(`/api/users/${id}`) + }, + + updateUser(id, payload) { + return requestJson(`/api/users/${id}`, { + method: 'PUT', + body: payload, + }) + }, +} + +export const shoppingListsApi = { + getShoppingLists() { + return requestJson('/api/shoppinglists') + }, + + getShoppingList(id) { + return requestJson(`/api/shoppinglists/${id}`) + }, + + createShoppingList(payload) { + return requestJson('/api/shoppinglists', { + method: 'POST', + body: payload, + }) + }, + + updateShoppingList(id, payload) { + return requestJson(`/api/shoppinglists/${id}`, { + method: 'PUT', + body: payload, + }) + }, + + deleteShoppingList(id) { + return requestJson(`/api/shoppinglists/${id}`, { + method: 'DELETE', + }) + }, +} + +export const mealPlannersApi = { + getMealPlanners() { + return requestJson('/api/mealplanners') + }, + + getMealPlanner(id) { + return requestJson(`/api/mealplanners/${id}`) + }, + + createMealPlanner(payload) { + return requestJson('/api/mealplanners', { + method: 'POST', + body: payload, + }) + }, + + updateMealPlanner(id, payload) { + return requestJson(`/api/mealplanners/${id}`, { + method: 'PUT', + body: payload, + }) + }, + + deleteMealPlanner(id) { + return requestJson(`/api/mealplanners/${id}`, { + method: 'DELETE', + }) + }, } diff --git a/src/components/Navbar/Navbar.jsx b/src/components/Navbar/Navbar.jsx index e8eced8..97afbe5 100644 --- a/src/components/Navbar/Navbar.jsx +++ b/src/components/Navbar/Navbar.jsx @@ -83,9 +83,12 @@ function Navbar({ theme, onToggleTheme }) { {isAuthenticated ? ( <>
  • Homepage
  • +
  • Profile
  • {isSiteAdmin &&
  • Admin
  • }
  • Inventory
  • Search
  • +
  • Shopping Lists
  • +
  • Meal Planners
  • Barcode Scanner
  • {isSiteAdmin &&
  • Users
  • } diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx index 01ecb5c..6e80679 100644 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.jsx @@ -8,11 +8,37 @@ import { } from '../api/client.js' const AuthContext = createContext(null) +const SITE_ADMIN_ROLES = new Set(['Site Admin', 'Admin']) export function AuthProvider({ children }) { const [session, setSession] = useState(() => getStoredSession()) const [initializing, setInitializing] = useState(() => Boolean(getStoredSession())) + function setCurrentUserProfile(profile) { + const currentSession = getStoredSession() + + if (!currentSession) { + return null + } + + return saveSession({ + ...currentSession, + user: profile, + }) + } + + async function refreshProfile() { + const currentSession = getStoredSession() + + if (!currentSession) { + return null + } + + const profile = await profileApi.getProfile() + setCurrentUserProfile(profile) + return profile + } + useEffect(() => subscribeToSessionChanges(setSession), []) useEffect(() => { @@ -32,10 +58,7 @@ export function AuthProvider({ children }) { const profile = await profileApi.getProfile() if (!cancelled) { - saveSession({ - ...existingSession, - user: profile, - }) + setCurrentUserProfile(profile) } } catch { // The API client already clears invalid sessions after a failed refresh. @@ -60,11 +83,13 @@ export function AuthProvider({ children }) { session, user, isAuthenticated: Boolean(session?.accessToken), - isSiteAdmin: userRoles.includes('Admin'), + isSiteAdmin: userRoles.some(role => SITE_ADMIN_ROLES.has(role)), initializing, login: authApi.login, register: authApi.register, logout: authApi.logout, + refreshProfile, + setCurrentUserProfile, } return {children} diff --git a/src/pages/AdminPage/AdminPage.jsx b/src/pages/AdminPage/AdminPage.jsx index f01224f..83bdf7a 100644 --- a/src/pages/AdminPage/AdminPage.jsx +++ b/src/pages/AdminPage/AdminPage.jsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { householdsApi } from '../../api/client.js' +import { householdsApi, usersApi } from '../../api/client.js' import { useAuth } from '../../context/AuthContext.jsx' import { formatDate } from '../../utils/searchUtils.js' import './AdminPage.css' @@ -37,6 +37,9 @@ function AdminPage() { const [inviteEmail, setInviteEmail] = useState('') const [userForm, setUserForm] = useState(EMPTY_USER_FORM) const [createdUser, setCreatedUser] = useState(null) + const [householdHistory, setHouseholdHistory] = useState([]) + const [householdHistoryLoading, setHouseholdHistoryLoading] = useState(false) + const [householdHistoryError, setHouseholdHistoryError] = useState('') async function loadHouseholds(preferredSelectionId = '') { const response = await householdsApi.getHouseholds() @@ -71,6 +74,9 @@ function AdminPage() { setInviteEmail('') setUserForm(EMPTY_USER_FORM) setCreatedUser(null) + setHouseholdHistory([]) + setHouseholdHistoryLoading(false) + setHouseholdHistoryError('') setErrorMessage('') setStatusMessage('') return @@ -105,6 +111,45 @@ function AdminPage() { } }, [isAuthenticated]) + useEffect(() => { + let cancelled = false + + async function loadSelectedHouseholdHistory() { + if (!isAuthenticated || !selectedHouseholdId) { + setHouseholdHistory([]) + setHouseholdHistoryLoading(false) + setHouseholdHistoryError('') + return + } + + setHouseholdHistoryLoading(true) + setHouseholdHistoryError('') + + try { + const response = await householdsApi.getHouseholdHistory(selectedHouseholdId) + + if (!cancelled) { + setHouseholdHistory(Array.isArray(response) ? response : []) + } + } catch (error) { + if (!cancelled) { + setHouseholdHistory([]) + setHouseholdHistoryError(error.message) + } + } finally { + if (!cancelled) { + setHouseholdHistoryLoading(false) + } + } + } + + loadSelectedHouseholdHistory() + + return () => { + cancelled = true + } + }, [isAuthenticated, selectedHouseholdId]) + const selectedHousehold = households.find(household => household.id === selectedHouseholdId) ?? null const editingHousehold = households.find(household => household.id === editingHouseholdId) ?? null const canManageSelectedHousehold = Boolean(selectedHousehold && (isSiteAdmin || selectedHousehold.isCurrentUserHouseholdAdmin)) @@ -237,7 +282,27 @@ function AdminPage() { lastName: userForm.lastName.trim() || null, }) - setCreatedUser(result?.user ?? null) + try { + const users = await usersApi.getUsers() + const matchingUser = Array.isArray(users) + ? users.find(existingUser => existingUser.email?.toLowerCase() === email.toLowerCase()) + : null + + setCreatedUser(matchingUser ?? { + email, + firstName: userForm.firstName.trim(), + lastName: userForm.lastName.trim(), + roles: [], + }) + } catch { + setCreatedUser({ + email, + firstName: userForm.firstName.trim(), + lastName: userForm.lastName.trim(), + roles: [], + }) + } + resetUserForm() setStatusMessage(result?.message || 'User created.') } catch (error) { @@ -409,7 +474,7 @@ function AdminPage() {

    {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.'}

    @@ -511,6 +576,44 @@ function AdminPage() { )} + +
    +
    +

    Household History

    + {selectedHousehold?.name || 'No household selected'} +
    + + {householdHistoryLoading && ( +
    Loading household history...
    + )} + + {householdHistoryError && ( +
    {householdHistoryError}
    + )} + + {!selectedHousehold ? ( +
    Select a household to review its audit history.
    + ) : householdHistory.length === 0 ? ( +
    No household history was returned.
    + ) : ( +
    + {householdHistory.map(entry => ( +
    +
    + {entry.action} +
    + {formatDate(entry.changedAt) || 'Unknown date'} by {entry.changedByEmail || 'Unknown user'} +
    +
    {entry.description || 'No description recorded.'}
    + {entry.affectedUserEmail && ( +
    Affected member: {entry.affectedUserEmail}
    + )} +
    +
    + ))} +
    + )} +
    diff --git a/src/pages/BarcodePage/BarcodePage.css b/src/pages/BarcodePage/BarcodePage.css index e4754a4..c63c5eb 100644 --- a/src/pages/BarcodePage/BarcodePage.css +++ b/src/pages/BarcodePage/BarcodePage.css @@ -164,17 +164,6 @@ flex-wrap: wrap; } -.scanner-status { - padding: 12px 14px; - border-radius: 14px; - background: var(--status-info-bg); - border: 1px solid var(--status-info-border); - color: var(--status-info-text); - line-height: 1.5; - flex: 1; - min-width: 240px; -} - .camera-controls, .manual-entry-actions, .quick-actions, @@ -239,6 +228,13 @@ border: 1px solid transparent; } +.scan-chip--barcode { + justify-content: flex-start; + font-family: Consolas, 'Courier New', monospace; + max-width: 100%; + word-break: break-word; +} + .scan-chip.is-success { background: var(--status-success-bg); border-color: var(--status-success-border); diff --git a/src/pages/BarcodePage/BarcodePage.jsx b/src/pages/BarcodePage/BarcodePage.jsx index 26544b9..ccce4aa 100644 --- a/src/pages/BarcodePage/BarcodePage.jsx +++ b/src/pages/BarcodePage/BarcodePage.jsx @@ -15,8 +15,6 @@ import { import { formatAmount, formatDate } from '../../utils/searchUtils.js' import './BarcodePage.css' -const CAMERA_IDLE_MESSAGE = 'Start the camera and hold a barcode inside the frame.' -const CAMERA_LIVE_MESSAGE = 'Camera live. Hold the barcode steady inside the frame.' const MATCH_CANDIDATE_LIMIT = 10 const CAMERA_READERS = [ 'ean_reader', @@ -84,11 +82,7 @@ function BarcodePage() { const matchCandidates = getMatchCandidates(items, matchQuery, lastScannedBarcode) const hasScannedBarcode = Boolean(normalizeBarcode(lastScannedBarcode)) const hasExactMatches = matchingItems.length > 0 - const cameraStatus = cameraActive - ? lastScanSource === 'camera' && hasScannedBarcode - ? `Captured ${lastScannedBarcode}. Keep scanning or review the matches below.` - : CAMERA_LIVE_MESSAGE - : CAMERA_IDLE_MESSAGE + const quickAddSourceItem = matchingItems.find(item => item.id === editingItemId) ?? matchingItems[0] ?? null const fetchInventoryData = useCallback(async () => { const [nextLocations, nextItems] = await Promise.all([ @@ -314,6 +308,42 @@ function BarcodePage() { setErrorMessage('') } + async function handleQuickAdd() { + if (!hasScannedBarcode) { + return + } + + if (!quickAddSourceItem) { + openCreateForm() + setStatusMessage('No exact match found. Complete the form to add a new item.') + return + } + + setSubmitting(true) + setErrorMessage('') + setStatusMessage('') + + try { + const createdItem = await inventoryApi.createInventoryItem(buildInventoryPayload({ + ...mapItemToForm(quickAddSourceItem), + expiryDate: '', + useByDate: '', + })) + + await refreshInventoryData() + + if (createdItem?.id) { + setStatusMessage(`Quick added ${quickAddSourceItem.name || 'item'} without expiry or use by dates.`) + } else { + setStatusMessage('Quick add completed without expiry or use by dates.') + } + } catch (error) { + setErrorMessage(error.message) + } finally { + setSubmitting(false) + } + } + async function handleRefreshInventory() { setInventoryLoading(true) setErrorMessage('') @@ -349,9 +379,10 @@ function BarcodePage() { event.preventDefault() const name = itemForm.name.trim() + const barcode = itemForm.barcode.trim() - if (!name) { - setErrorMessage('Item name is required.') + if (!name && !barcode) { + setErrorMessage('Provide an item name or a barcode.') return } @@ -449,25 +480,15 @@ function BarcodePage() {
    -
    -

    Camera Scanner

    -
    - -
    - - +
    + {hasScannedBarcode && ( + + {lastScannedBarcode} + + )} + + {hasScannedBarcode ? (hasExactMatches ? `${matchingItems.length} match${matchingItems.length === 1 ? '' : 'es'}` : 'No exact matches') : 'Ready to scan'} +
    @@ -479,7 +500,7 @@ function BarcodePage() { {!cameraActive && (
    Ready to scan - Use the rear camera when available and place the barcode inside the guide. + Use the rear camera when available. Place the barcode inside the guide.
    )} @@ -489,7 +510,6 @@ function BarcodePage() {
    -
    {cameraStatus}
    {!cameraActive ? (
    )} + +
    + +
    -
    + {/*
    Last scanned barcode {hasScannedBarcode ? lastScannedBarcode : 'Waiting for a scan'} @@ -613,7 +644,7 @@ function BarcodePage() { Clear scan
    -
    +
    */}
    @@ -684,7 +715,7 @@ function BarcodePage() { {!editorMode ? (
    -

    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.

    +

    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.

    ) : (
    @@ -697,7 +728,6 @@ function BarcodePage() { value={itemForm.name} onChange={event => setItemForm(current => ({ ...current, name: event.target.value }))} placeholder="Whole Milk" - required />
    @@ -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.

    )} + + {editorMode !== 'update' && ( +

    + If the name is blank but the barcode resolves through the API lookup, the item name can be filled automatically. +

    + )} )}
    diff --git a/src/pages/InventoryPage/InventoryPage.jsx b/src/pages/InventoryPage/InventoryPage.jsx index 4742cd3..2c22ec8 100644 --- a/src/pages/InventoryPage/InventoryPage.jsx +++ b/src/pages/InventoryPage/InventoryPage.jsx @@ -28,19 +28,36 @@ function InventoryPage() { const [statusMessage, setStatusMessage] = useState('') const [locations, setLocations] = useState([]) const [items, setItems] = useState([]) + const [selectedLocationId, setSelectedLocationId] = useState('') + const [locationHistory, setLocationHistory] = useState([]) + const [locationHistoryLoading, setLocationHistoryLoading] = useState(false) + const [locationHistoryError, setLocationHistoryError] = useState('') const [editingLocationId, setEditingLocationId] = useState('') const [editingItemId, setEditingItemId] = useState('') const [selectedItem, setSelectedItem] = useState(null) const [locationForm, setLocationForm] = useState(EMPTY_LOCATION_FORM) const [itemForm, setItemForm] = useState(createItemForm()) + function syncLocations(nextLocations) { + setLocations(nextLocations) + setSelectedLocationId(currentLocationId => { + const targetLocationId = currentLocationId || editingLocationId + + if (nextLocations.some(location => location.id === targetLocationId)) { + return targetLocationId + } + + return nextLocations[0]?.id ?? '' + }) + } + async function loadPageData() { const [nextLocations, nextItems] = await Promise.all([ locationsApi.getLocations(), inventoryApi.getInventoryItems(), ]) - setLocations(nextLocations) + syncLocations(nextLocations) setItems(nextItems) } @@ -51,6 +68,10 @@ function InventoryPage() { if (!isAuthenticated) { setLocations([]) setItems([]) + setSelectedLocationId('') + setLocationHistory([]) + setLocationHistoryLoading(false) + setLocationHistoryError('') setSelectedItem(null) setEditingItemId('') setEditingLocationId('') @@ -71,7 +92,7 @@ function InventoryPage() { ]) if (!cancelled) { - setLocations(nextLocations) + syncLocations(nextLocations) setItems(nextItems) } } catch (error) { @@ -92,6 +113,47 @@ function InventoryPage() { } }, [isAuthenticated]) + useEffect(() => { + let cancelled = false + + async function loadSelectedLocationHistory() { + if (!isAuthenticated || !selectedLocationId) { + setLocationHistory([]) + setLocationHistoryLoading(false) + setLocationHistoryError('') + return + } + + setLocationHistoryLoading(true) + setLocationHistoryError('') + + try { + const response = await locationsApi.getLocationHistory(selectedLocationId) + + if (!cancelled) { + setLocationHistory(Array.isArray(response) ? response : []) + } + } catch (error) { + if (!cancelled) { + setLocationHistory([]) + setLocationHistoryError(error.message) + } + } finally { + if (!cancelled) { + setLocationHistoryLoading(false) + } + } + } + + loadSelectedLocationHistory() + + return () => { + cancelled = true + } + }, [isAuthenticated, selectedLocationId]) + + const selectedLocation = locations.find(location => location.id === selectedLocationId) ?? null + function resetLocationEditor() { setEditingLocationId('') setLocationForm(EMPTY_LOCATION_FORM) @@ -192,9 +254,10 @@ function InventoryPage() { event.preventDefault() const name = itemForm.name.trim() + const barcode = itemForm.barcode.trim() - if (!name) { - setErrorMessage('Item name is required.') + if (!name && !barcode) { + setErrorMessage('Provide an item name or a barcode.') return } @@ -320,7 +383,7 @@ function InventoryPage() { ) : ( locations.map(location => (
    @@ -333,6 +396,7 @@ function InventoryPage() { type="button" className="btn btn-secondary" onClick={() => { + setSelectedLocationId(location.id) setEditingLocationId(location.id) setLocationForm({ name: location.name, @@ -342,6 +406,13 @@ function InventoryPage() { > Edit + + )} +
    + +

    + Meal planners are household-scoped. Choose a household, set the schedule, and add the inventory items required for that meal. +

    + +
    +
    +
    + + setMealPlannerForm(currentForm => ({ ...currentForm, name: event.target.value }))} + placeholder="Pasta night" + required + /> +
    + +
    + + +
    + +
    + + setMealPlannerForm(currentForm => ({ ...currentForm, plannedDate: event.target.value }))} + required + /> +
    + +
    + + setMealPlannerForm(currentForm => ({ ...currentForm, plannedTime: event.target.value }))} + required + /> +
    +
    + +
    +

    Meal Items

    + +
    + + {mealPlannerForm.items.length === 0 ? ( +
    No meal items yet. Add a row when you are ready.
    + ) : ( +
    + {mealPlannerForm.items.map((item, index) => ( +
    +
    + Item {index + 1} + +
    + +
    +
    + + +
    + +
    + + updateItemRow(index, { amountRequired: event.target.value })} + placeholder="1" + /> +
    + +
    + + updateItemRow(index, { amountType: event.target.value })} + placeholder="litres" + /> +
    +
    +
    + ))} +
    + )} + + {editingMealPlannerId && ( +

    + Household selection is locked while editing because the update endpoint only replaces the name, schedule, and planner items. +

    + )} + +
    + + +
    +
    + + +
    +
    +

    Meal Planners

    + {mealPlanners.length} total +
    + + {mealPlanners.length === 0 ? ( +
    No meal planners were returned.
    + ) : ( +
    + {mealPlanners.map(mealPlanner => ( +
    +
    +
    + {mealPlanner.name} +
    Household: {resolveHouseholdName(households, mealPlanner.householdId)}
    +
    + Planned for {formatDate(mealPlanner.plannedDate) || 'Unknown date'} at {formatTime(mealPlanner.plannedTime) || 'Unknown time'} +
    +
    + Created {formatDate(mealPlanner.createdAt) || 'Unknown date'} by {mealPlanner.createdByEmail || 'Unknown user'} +
    +
    + + {mealPlanner.items?.length ?? 0} item(s) +
    + +
    + {(mealPlanner.items ?? []).length === 0 ? ( +
    No items added yet.
    + ) : ( + mealPlanner.items.map(item => ( +
    +
    + {item.inventoryItemName || 'Unnamed item'} +
    + {formatAmount(item.amountRequired, item.amountType)} + {item.inventoryItemBarcode ? ` | ${item.inventoryItemBarcode}` : ''} +
    +
    + + Required +
    + )) + )} +
    + +
    + + + +
    +
    + ))} +
    + )} +
    +
    + + )} + + ) +} + +export default MealPlannersPage \ No newline at end of file diff --git a/src/pages/PlanningPage.css b/src/pages/PlanningPage.css new file mode 100644 index 0000000..1cef98f --- /dev/null +++ b/src/pages/PlanningPage.css @@ -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; + } +} \ No newline at end of file diff --git a/src/pages/ProfilePage/ProfilePage.css b/src/pages/ProfilePage/ProfilePage.css new file mode 100644 index 0000000..0dba69e --- /dev/null +++ b/src/pages/ProfilePage/ProfilePage.css @@ -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; + } +} \ No newline at end of file diff --git a/src/pages/ProfilePage/ProfilePage.jsx b/src/pages/ProfilePage/ProfilePage.jsx new file mode 100644 index 0000000..d47a135 --- /dev/null +++ b/src/pages/ProfilePage/ProfilePage.jsx @@ -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 ( + <> +

    Profile

    +
    + + {!isAuthenticated ? ( +
    + Sign in on the dashboard before updating your profile. +
    + ) : ( + <> + {errorMessage &&
    {errorMessage}
    } + {statusMessage &&
    {statusMessage}
    } + {loading &&
    Refreshing your profile...
    } + {saving &&
    Saving profile changes...
    } + +
    +
    +
    +

    Account Summary

    + +
    + +
    + {[user?.firstName, user?.lastName].filter(Boolean).join(' ') || user?.email || 'Signed in user'} +
    Email: {user?.email || 'Not available'}
    +
    User ID: {user?.id || 'Not available'}
    +
    + +
    + Assigned roles +
    + {roles.length === 0 ? ( + No roles assigned + ) : ( + roles.map(role => ( + {role} + )) + )} +
    +
    +
    + +
    +
    +

    Update Profile

    +
    + +

    + Leave the password fields blank unless you want to change your password. First and last name fields can be cleared. +

    + +
    +
    + + setProfileForm(currentForm => ({ ...currentForm, email: event.target.value }))} + placeholder="user@example.com" + required + /> +
    + +
    +
    + + setProfileForm(currentForm => ({ ...currentForm, firstName: event.target.value }))} + placeholder="Alex" + /> +
    + +
    + + setProfileForm(currentForm => ({ ...currentForm, lastName: event.target.value }))} + placeholder="Smith" + /> +
    +
    + +
    +
    + + setProfileForm(currentForm => ({ ...currentForm, currentPassword: event.target.value }))} + placeholder="Required to change password" + /> +
    + +
    + + setProfileForm(currentForm => ({ ...currentForm, newPassword: event.target.value }))} + placeholder="Leave blank to keep current password" + /> +
    +
    + +
    + + +
    +
    +
    +
    + + )} + + ) +} + +export default ProfilePage \ No newline at end of file diff --git a/src/pages/ShoppingListsPage/ShoppingListsPage.jsx b/src/pages/ShoppingListsPage/ShoppingListsPage.jsx new file mode 100644 index 0000000..2b84034 --- /dev/null +++ b/src/pages/ShoppingListsPage/ShoppingListsPage.jsx @@ -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 ( + <> +

    Shopping Lists

    +
    + + {!isAuthenticated ? ( +
    + Sign in on the dashboard before managing shopping lists. +
    + ) : ( + <> + {errorMessage &&
    {errorMessage}
    } + {statusMessage &&
    {statusMessage}
    } + {loading &&
    Syncing shopping lists...
    } + +
    +
    +
    +

    {editingShoppingListId ? 'Edit Shopping List' : 'Create Shopping List'}

    + {editingShoppingListId && ( + + )} +
    + +

    + Shopping lists are household-scoped. Pick the household first, then add inventory items that belong to members of that household. +

    + +
    +
    +
    + + setShoppingListForm(currentForm => ({ ...currentForm, name: event.target.value }))} + placeholder="Weekend shop" + required + /> +
    + +
    + + +
    +
    + +
    +

    List Items

    + +
    + + {shoppingListForm.items.length === 0 ? ( +
    No list items yet. Add a row when you are ready.
    + ) : ( +
    + {shoppingListForm.items.map((item, index) => ( +
    +
    + Item {index + 1} + +
    + +
    +
    + + +
    + +
    + + updateItemRow(index, { amountRequired: event.target.value })} + placeholder="2" + /> +
    + +
    + + updateItemRow(index, { amountType: event.target.value })} + placeholder="cartons" + /> +
    + +
    + updateItemRow(index, { isPurchased: event.target.checked })} + /> + +
    +
    +
    + ))} +
    + )} + + {editingShoppingListId && ( +

    + Household selection is locked while editing because the update endpoint only replaces the name and list items. +

    + )} + +
    + + +
    +
    +
    + +
    +
    +

    Shopping Lists

    + {shoppingLists.length} total +
    + + {shoppingLists.length === 0 ? ( +
    No shopping lists were returned.
    + ) : ( +
    + {shoppingLists.map(shoppingList => ( +
    +
    +
    + {shoppingList.name} +
    Household: {resolveHouseholdName(households, shoppingList.householdId)}
    +
    + Created {formatDate(shoppingList.createdAt) || 'Unknown date'} by {shoppingList.createdByEmail || 'Unknown user'} +
    +
    + + {shoppingList.items?.length ?? 0} item(s) +
    + +
    + {(shoppingList.items ?? []).length === 0 ? ( +
    No items added yet.
    + ) : ( + shoppingList.items.map(item => ( +
    +
    + {item.inventoryItemName || 'Unnamed item'} +
    + {formatAmount(item.amountRequired, item.amountType)} + {item.inventoryItemBarcode ? ` | ${item.inventoryItemBarcode}` : ''} +
    +
    + + + {item.isPurchased ? 'Purchased' : 'Pending'} + +
    + )) + )} +
    + +
    + + + +
    +
    + ))} +
    + )} +
    +
    + + )} + + ) +} + +export default ShoppingListsPage \ No newline at end of file diff --git a/src/pages/UsersPage/UsersPage.css b/src/pages/UsersPage/UsersPage.css index 0f416fc..7acb879 100644 --- a/src/pages/UsersPage/UsersPage.css +++ b/src/pages/UsersPage/UsersPage.css @@ -1,4 +1,8 @@ -.users-page-panel { +.users-management-grid { + display: grid; + grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.1fr); + gap: 20px; + align-items: start; margin-top: 24px; } @@ -6,6 +10,10 @@ margin-bottom: 20px; } +.users-page-panel { + min-width: 0; +} + .users-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); @@ -14,13 +22,18 @@ .user-card { display: grid; - gap: 8px; + gap: 10px; padding: 18px; border-radius: 12px; background: var(--color-surface-muted); border: 1px solid var(--color-border-muted); } +.user-card.is-selected { + border-color: var(--color-focus); + box-shadow: 0 0 0 3px var(--color-focus-ring-strong); +} + .users-role-list { display: flex; flex-wrap: wrap; @@ -44,4 +57,21 @@ background: var(--color-surface-subtle); color: var(--color-text-muted); border-color: var(--color-border-muted); +} + +.users-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.users-editor-meta { + display: grid; + gap: 6px; +} + +@media (max-width: 900px) { + .users-management-grid { + grid-template-columns: 1fr; + } } \ No newline at end of file diff --git a/src/pages/UsersPage/UsersPage.jsx b/src/pages/UsersPage/UsersPage.jsx index 80060a1..7ce5323 100644 --- a/src/pages/UsersPage/UsersPage.jsx +++ b/src/pages/UsersPage/UsersPage.jsx @@ -3,6 +3,14 @@ import { usersApi } from '../../api/client.js' import { useAuth } from '../../context/AuthContext.jsx' import './UsersPage.css' +const EMPTY_USER_FORM = { + email: '', + firstName: '', + lastName: '', + password: '', + rolesInput: '', +} + function formatUserName(user) { const fullName = [user.firstName, user.lastName] .filter(Boolean) @@ -11,49 +19,178 @@ function formatUserName(user) { return fullName || user.email || 'Unnamed user' } +function mapUserToForm(user) { + return { + email: user?.email ?? '', + firstName: user?.firstName ?? '', + lastName: user?.lastName ?? '', + password: '', + rolesInput: Array.isArray(user?.roles) ? user.roles.join(', ') : '', + } +} + +function parseRoles(value) { + return value + .split(',') + .map(role => role.trim()) + .filter(Boolean) +} + function UsersPage() { - const { isAuthenticated, isSiteAdmin } = useAuth() + const { isAuthenticated, isSiteAdmin, refreshProfile, user } = useAuth() const [users, setUsers] = useState([]) const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) const [errorMessage, setErrorMessage] = useState('') const [statusMessage, setStatusMessage] = useState('') - const [endpointUnavailable, setEndpointUnavailable] = useState(false) + const [selectedUserId, setSelectedUserId] = useState('') + const [editingUserId, setEditingUserId] = useState('') + const [userForm, setUserForm] = useState(EMPTY_USER_FORM) - async function loadUsers() { + async function loadUsers(preferredSelectionId = '') { + const response = await usersApi.getUsers() + const nextUsers = Array.isArray(response) ? response : [] + + setUsers(nextUsers) + setSelectedUserId(currentSelectionId => { + const targetSelectionId = preferredSelectionId || currentSelectionId + + if (nextUsers.some(existingUser => existingUser.id === targetSelectionId)) { + return targetSelectionId + } + + return nextUsers[0]?.id ?? '' + }) + setEditingUserId(currentEditingId => ( + nextUsers.some(existingUser => existingUser.id === currentEditingId) + ? currentEditingId + : '' + )) + } + + useEffect(() => { + let cancelled = false + + async function loadInitialUsers() { + if (!isAuthenticated || !isSiteAdmin) { + setUsers([]) + setErrorMessage('') + setStatusMessage('') + setSelectedUserId('') + setEditingUserId('') + setUserForm(EMPTY_USER_FORM) + return + } + + setLoading(true) + setErrorMessage('') + + try { + const response = await usersApi.getUsers() + + if (cancelled) return + + const nextUsers = Array.isArray(response) ? response : [] + setUsers(nextUsers) + setSelectedUserId(nextUsers[0]?.id ?? '') + } catch (error) { + if (!cancelled) { + setErrorMessage(error.message) + } + } finally { + if (!cancelled) { + setLoading(false) + } + } + } + + loadInitialUsers() + + return () => { + cancelled = true + } + }, [isAuthenticated, isSiteAdmin]) + + function resetEditor() { + setEditingUserId('') + setUserForm(EMPTY_USER_FORM) + } + + async function handleRefreshUsers() { setLoading(true) setErrorMessage('') setStatusMessage('') try { - const response = await usersApi.getUsers() - setUsers(Array.isArray(response) ? response : []) - setEndpointUnavailable(false) + await loadUsers(selectedUserId) } catch (error) { - setUsers([]) - - if (error.status === 404) { - setEndpointUnavailable(true) - setStatusMessage('GET /api/users is not available in the current backend build yet.') - return - } - setErrorMessage(error.message) } finally { setLoading(false) } } - useEffect(() => { - if (!isAuthenticated || !isSiteAdmin) { - setUsers([]) - setErrorMessage('') - setStatusMessage('') - setEndpointUnavailable(false) + async function handleEditUser(userId) { + setLoading(true) + setErrorMessage('') + setStatusMessage('') + + try { + const nextUser = await usersApi.getUser(userId) + setSelectedUserId(nextUser.id) + setEditingUserId(nextUser.id) + setUserForm(mapUserToForm(nextUser)) + } catch (error) { + setErrorMessage(error.message) + } finally { + setLoading(false) + } + } + + async function handleUserSubmit(event) { + event.preventDefault() + + if (!editingUserId) { + setErrorMessage('Select a user before editing details.') return } - loadUsers() - }, [isAuthenticated, isSiteAdmin]) + const email = userForm.email.trim() + + if (!email) { + setErrorMessage('User email is required.') + return + } + + setSaving(true) + setErrorMessage('') + setStatusMessage('') + + try { + const updatedUser = await usersApi.updateUser(editingUserId, { + email, + firstName: userForm.firstName, + lastName: userForm.lastName, + password: userForm.password || undefined, + roles: parseRoles(userForm.rolesInput), + }) + + await loadUsers(updatedUser.id) + setSelectedUserId(updatedUser.id) + setEditingUserId(updatedUser.id) + setUserForm(mapUserToForm(updatedUser)) + + if (user?.id === updatedUser.id) { + await refreshProfile() + } + + setStatusMessage('User updated.') + } catch (error) { + setErrorMessage(error.message) + } finally { + setSaving(false) + } + } return ( <> @@ -62,7 +199,7 @@ function UsersPage() { {!isAuthenticated ? (
    - Sign in on the dashboard before using the users endpoint. + Sign in on the dashboard before using the protected users endpoints.
    ) : !isSiteAdmin ? (
    @@ -71,52 +208,160 @@ function UsersPage() { ) : ( <> {errorMessage &&
    {errorMessage}
    } - {statusMessage &&
    {statusMessage}
    } + {statusMessage &&
    {statusMessage}
    } {loading &&
    Loading users...
    } + {saving &&
    Saving user changes...
    } -
    -
    -

    User Directory

    - -
    - -

    - This page is intentionally minimal. It is ready to consume a backend users endpoint as soon as that contract exists. -

    - - {endpointUnavailable ? ( -
    - Add `GET /api/users` to the backend to populate this page. +
    +
    +
    +

    {editingUserId ? 'Edit User' : 'User Editor'}

    + {editingUserId && ( + + )}
    - ) : users.length === 0 ? ( -
    No users were returned.
    - ) : ( -
    - {users.map(user => { - const roles = Array.isArray(user.roles) ? user.roles : [] - return ( -
    - {formatUserName(user)} -
    {user.email || 'No email provided.'}
    -
    ID: {user.id || 'Not set'}
    -
    - {roles.length === 0 ? ( - No roles - ) : ( - roles.map(role => ( - {role} - )) - )} -
    -
    - ) - })} +

    + Roles are replaced with the comma-separated list you submit. Leave the password blank if you do not want to reset it. +

    + + {!editingUserId ? ( +
    Choose a user from the directory to load the edit form.
    + ) : ( +
    +
    + {formatUserName(userForm)} +
    Editing user ID: {editingUserId}
    +
    + +
    + + setUserForm(current => ({ ...current, email: event.target.value }))} + placeholder="user@example.com" + required + /> +
    + +
    +
    + + setUserForm(current => ({ ...current, firstName: event.target.value }))} + placeholder="Alex" + /> +
    + +
    + + setUserForm(current => ({ ...current, lastName: event.target.value }))} + placeholder="Smith" + /> +
    +
    + +
    + + setUserForm(current => ({ ...current, password: event.target.value }))} + placeholder="Leave blank to keep the current password" + /> +
    + +
    + + setUserForm(current => ({ ...current, rolesInput: event.target.value }))} + placeholder="Site Admin, Admin" + /> +
    + +
    + + +
    +
    + )} +
    + +
    +
    +

    User Directory

    +
    - )} -
    + + {users.length === 0 ? ( +
    No users were returned.
    + ) : ( +
    + {users.map(directoryUser => { + const roles = Array.isArray(directoryUser.roles) ? directoryUser.roles : [] + + return ( +
    + {formatUserName(directoryUser)} +
    {directoryUser.email || 'No email provided.'}
    +
    ID: {directoryUser.id || 'Not set'}
    +
    + {roles.length === 0 ? ( + No roles + ) : ( + roles.map(role => ( + {role} + )) + )} +
    + +
    + + +
    +
    + ) + })} +
    + )} +
    +
    )} diff --git a/src/utils/inventoryItemUtils.js b/src/utils/inventoryItemUtils.js index e7ba872..d0121b8 100644 --- a/src/utils/inventoryItemUtils.js +++ b/src/utils/inventoryItemUtils.js @@ -22,13 +22,13 @@ export function createItemForm(barcode = '') { } export function buildInventoryPayload(form, includeBlankText = false) { - const payload = { - name: form.name.trim(), - } + const payload = {} + const name = form.name.trim() const barcode = normalizeBarcode(form.barcode) const amountType = form.amountType.trim() + if (name || includeBlankText) payload.name = name if (barcode || includeBlankText) payload.barcode = barcode if (amountType || includeBlankText) payload.amountType = amountType diff --git a/src/utils/searchUtils.js b/src/utils/searchUtils.js index cdb58db..9514e13 100644 --- a/src/utils/searchUtils.js +++ b/src/utils/searchUtils.js @@ -9,11 +9,34 @@ export function formatDate(dateStr) { return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) } +export function formatTime(timeStr) { + const normalizedTime = toTimeInputValue(timeStr) + + if (!normalizedTime) return '' + + const [hours, minutes] = normalizedTime.split(':').map(Number) + + if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return normalizedTime + + const date = new Date() + date.setHours(hours, minutes, 0, 0) + + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + }) +} + export function toDateInputValue(dateStr) { if (!dateStr) return '' return String(dateStr).slice(0, 10) } +export function toTimeInputValue(timeStr) { + if (!timeStr) return '' + return String(timeStr).slice(0, 5) +} + export function getExpiryStatus(expiryDate) { if (!expiryDate) return { status: 'Unknown', color: '#999', text: '' }