372 lines
12 KiB
JavaScript
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
|