Files
pantry-management-frontend/src/pages/SearchPage/SearchPage.jsx

372 lines
12 KiB
JavaScript

import { useEffect, useState } from 'react'
import ItemCard from '../../components/ItemCard/ItemCard.jsx'
import {
inventoryApi,
locationsApi,
searchApi,
} from '../../api/client.js'
import { useAuth } from '../../context/AuthContext.jsx'
import {
filterInventoryItems,
formatAmount,
formatDate,
getExpiryStatus,
} from '../../utils/searchUtils.js'
import { getPaginatedResults } from '../../utils/paginationUtils.js'
import './SearchPage.css'
function ExpiryBadge({ expiryDate }) {
const status = getExpiryStatus(expiryDate)
const badgeClass = status.status === 'Expired'
? 'expiry-expired'
: (status.status === 'Soon' || status.status === 'Today')
? 'expiry-soon'
: 'expiry-fresh'
return (
<div className={`item-expiry-badge ${badgeClass}`}>
{formatDate(expiryDate) || 'No expiry date'}
</div>
)
}
function SearchPage() {
const { isAuthenticated } = useAuth()
const [searchName, setSearchName] = useState('')
const [locationId, setLocationId] = useState('')
const [minAmount, setMinAmount] = useState('0')
const [maxAmount, setMaxAmount] = useState('999')
const [minExpiryDate, setMinExpiryDate] = useState('')
const [maxExpiryDate, setMaxExpiryDate] = useState('')
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(15)
const [locations, setLocations] = useState([])
const [matchingLocations, setMatchingLocations] = useState([])
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
useEffect(() => {
let cancelled = false
async function loadInitialData() {
if (!isAuthenticated) {
setLocations([])
setMatchingLocations([])
setResults([])
setErrorMessage('')
return
}
setLoading(true)
setErrorMessage('')
try {
const [nextLocations, nextItems] = await Promise.all([
locationsApi.getLocations(),
inventoryApi.getInventoryItems(),
])
if (!cancelled) {
setLocations(nextLocations)
setMatchingLocations([])
setResults(nextItems)
}
} catch (error) {
if (!cancelled) {
setErrorMessage(error.message)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
loadInitialData()
return () => {
cancelled = true
}
}, [isAuthenticated])
async function performSearch() {
const parsedMinAmount = minAmount === '' ? 0 : Number(minAmount)
const parsedMaxAmount = maxAmount === '' ? Infinity : Number(maxAmount)
const safeMinAmount = Number.isFinite(parsedMinAmount) ? parsedMinAmount : 0
const safeMaxAmount = maxAmount === '' || !Number.isFinite(parsedMaxAmount) ? Infinity : parsedMaxAmount
if (minExpiryDate && maxExpiryDate && minExpiryDate > maxExpiryDate) {
alert('Start date cannot be after end date')
return
}
if (safeMinAmount > safeMaxAmount) {
alert('Minimum amount cannot be greater than maximum amount')
return
}
setLoading(true)
setErrorMessage('')
try {
const trimmedQuery = searchName.trim()
const [nextItems, nextMatchingLocations] = trimmedQuery
? await Promise.all([
searchApi.searchItems(trimmedQuery),
searchApi.searchLocations(trimmedQuery),
])
: await Promise.all([
inventoryApi.getInventoryItems(),
Promise.resolve([]),
])
const filteredItems = filterInventoryItems(nextItems, {
locationId,
minAmount: safeMinAmount,
maxAmount: safeMaxAmount,
minExpiryDate,
maxExpiryDate,
})
setCurrentPage(1)
setResults(filteredItems)
setMatchingLocations(nextMatchingLocations)
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
async function resetForm() {
setSearchName('')
setLocationId('')
setMinAmount('0')
setMaxAmount('999')
setMinExpiryDate('')
setMaxExpiryDate('')
setCurrentPage(1)
setPageSize(15)
setMatchingLocations([])
setErrorMessage('')
if (!isAuthenticated) {
setResults([])
return
}
setLoading(true)
try {
const [nextLocations, nextItems] = await Promise.all([
locationsApi.getLocations(),
inventoryApi.getInventoryItems(),
])
setLocations(nextLocations)
setResults(nextItems)
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
const paginationData = getPaginatedResults(results, currentPage, pageSize)
return (
<>
<h2>Search Inventory</h2>
<hr />
{!isAuthenticated ? (
<div className="auth-required">
Sign in on the dashboard before using the protected item and location search endpoints.
</div>
) : (
<>
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
{loading && <div className="status-banner status-banner--info">Searching the API...</div>}
<div className="search-container panel">
<div className="search-form">
<div className="form-group">
<label htmlFor="search-name">Item / Location / Barcode Search</label>
<input
type="text"
id="search-name"
placeholder="milk, fridge, 01234567890..."
autoComplete="off"
value={searchName}
onChange={event => setSearchName(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
performSearch()
}
}}
/>
</div>
<div className="form-group">
<label htmlFor="search-location">Storage location</label>
<select id="search-location" value={locationId} onChange={event => setLocationId(event.target.value)}>
<option value="">All locations</option>
{locations.map(location => (
<option key={location.id} value={location.id}>{location.name}</option>
))}
</select>
</div>
<div className="form-group">
<label>Amount range</label>
<div className="quantity-range">
<div className="form-group">
<label htmlFor="min-amount" style={{ fontSize: '12px' }}>Min</label>
<input
type="number"
id="min-amount"
min="0"
step="0.1"
value={minAmount}
onChange={event => setMinAmount(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="max-amount" style={{ fontSize: '12px' }}>Max</label>
<input
type="number"
id="max-amount"
min="0"
step="0.1"
value={maxAmount}
onChange={event => setMaxAmount(event.target.value)}
/>
</div>
</div>
</div>
<div className="form-group">
<label>Expiry date range</label>
<div className="date-range">
<div className="form-group">
<label htmlFor="min-expiry-date" style={{ fontSize: '12px' }}>Start date</label>
<input
type="date"
id="min-expiry-date"
value={minExpiryDate}
onChange={event => setMinExpiryDate(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="max-expiry-date" style={{ fontSize: '12px' }}>End date</label>
<input
type="date"
id="max-expiry-date"
value={maxExpiryDate}
onChange={event => setMaxExpiryDate(event.target.value)}
/>
</div>
</div>
</div>
<div className="search-buttons">
<button type="button" className="btn btn-primary" onClick={performSearch}>Search</button>
<button type="button" className="btn btn-secondary" onClick={resetForm}>Reset</button>
</div>
</div>
</div>
{searchName.trim() && (
<div className="location-results panel">
<h3>Matching Locations</h3>
{matchingLocations.length === 0 ? (
<div className="empty-state">No locations matched that query.</div>
) : (
<div className="location-results-grid">
{matchingLocations.map(location => (
<div className="location-result-card" key={location.id}>
<strong>{location.name}</strong>
<div>{location.description || 'No description provided.'}</div>
</div>
))}
</div>
)}
</div>
)}
<div className="results-container panel">
<div className="results-info">
{results.length === 0
? 'No results'
: `Found ${results.length} item${results.length !== 1 ? 's' : ''} (showing ${paginationData.startIndex}-${paginationData.endIndex})`
}
</div>
<div className="pagination-controls">
<div className="pagination-size">
<label htmlFor="page-size-select">Items per page:</label>
<select
id="page-size-select"
value={pageSize}
onChange={event => {
setPageSize(parseInt(event.target.value, 10))
setCurrentPage(1)
}}
>
<option value="15">15</option>
<option value="30">30</option>
<option value="60">60</option>
</select>
</div>
<div className="pagination-nav">
<button
type="button"
className="btn"
disabled={paginationData.currentPage === 1}
onClick={() => setCurrentPage(page => Math.max(1, page - 1))}
>
Previous
</button>
<div className="pagination-info">
Page {paginationData.currentPage} of {paginationData.totalPages}
</div>
<button
type="button"
className="btn"
disabled={paginationData.currentPage === paginationData.totalPages}
onClick={() => setCurrentPage(page => page + 1)}
>
Next
</button>
</div>
</div>
{results.length === 0 ? (
<div className="no-results">No items found matching your search criteria.</div>
) : (
<div className="item-component-grid">
{paginationData.items.map(item => (
<ItemCard
key={item.id}
imgAlt={item.name}
text1={item.name}
text2={`${item.location?.name || 'No location'} | ${formatAmount(item.amount, item.amountType)}`}
text3={`Barcode: ${item.barcode || 'Not set'} | Expires: ${formatDate(item.expiryDate) || 'Not set'}`}
editable={false}
>
<ExpiryBadge expiryDate={item.expiryDate} />
</ItemCard>
))}
</div>
)}
</div>
</>
)}
</>
)
}
export default SearchPage