Hotfix: expand landing-page search to all requests

This commit is contained in:
2026-03-03 13:24:25 +13:00
parent 5f2dc52771
commit 42d4caa474
11 changed files with 212 additions and 59 deletions

View File

@@ -1 +1 @@
0203262044 0303261323

View File

@@ -1,2 +1,2 @@
BUILD_NUMBER = "0203262044" BUILD_NUMBER = "0303261323"
CHANGELOG = '2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit' CHANGELOG = '2026-03-02|Hotfix: add logged-out password reset flow\n2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit'

View File

@@ -1841,6 +1841,7 @@ def get_cached_requests(
requested_by_norm: Optional[str] = None, requested_by_norm: Optional[str] = None,
requested_by_id: Optional[int] = None, requested_by_id: Optional[int] = None,
since_iso: Optional[str] = None, since_iso: Optional[str] = None,
status_codes: Optional[list[int]] = None,
) -> list[Dict[str, Any]]: ) -> list[Dict[str, Any]]:
query = """ query = """
SELECT request_id, media_id, media_type, status, title, year, requested_by, SELECT request_id, media_id, media_type, status, title, year, requested_by,
@@ -1858,6 +1859,10 @@ def get_cached_requests(
if since_iso: if since_iso:
conditions.append("created_at >= ?") conditions.append("created_at >= ?")
params.append(since_iso) params.append(since_iso)
if status_codes:
placeholders = ", ".join("?" for _ in status_codes)
conditions.append(f"status IN ({placeholders})")
params.extend(status_codes)
if conditions: if conditions:
query += " WHERE " + " AND ".join(conditions) query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY created_at DESC, request_id DESC LIMIT ? OFFSET ?" query += " ORDER BY created_at DESC, request_id DESC LIMIT ? OFFSET ?"
@@ -1865,11 +1870,12 @@ def get_cached_requests(
with _connect() as conn: with _connect() as conn:
rows = conn.execute(query, tuple(params)).fetchall() rows = conn.execute(query, tuple(params)).fetchall()
logger.debug( logger.debug(
"requests_cache list: count=%s requested_by_norm=%s requested_by_id=%s since_iso=%s", "requests_cache list: count=%s requested_by_norm=%s requested_by_id=%s since_iso=%s status_codes=%s",
len(rows), len(rows),
requested_by_norm, requested_by_norm,
requested_by_id, requested_by_id,
since_iso, since_iso,
status_codes,
) )
results: list[Dict[str, Any]] = [] results: list[Dict[str, Any]] = []
for row in rows: for row in rows:
@@ -1903,6 +1909,7 @@ def get_cached_requests_count(
requested_by_norm: Optional[str] = None, requested_by_norm: Optional[str] = None,
requested_by_id: Optional[int] = None, requested_by_id: Optional[int] = None,
since_iso: Optional[str] = None, since_iso: Optional[str] = None,
status_codes: Optional[list[int]] = None,
) -> int: ) -> int:
query = "SELECT COUNT(*) FROM requests_cache" query = "SELECT COUNT(*) FROM requests_cache"
params: list[Any] = [] params: list[Any] = []
@@ -1916,6 +1923,10 @@ def get_cached_requests_count(
if since_iso: if since_iso:
conditions.append("created_at >= ?") conditions.append("created_at >= ?")
params.append(since_iso) params.append(since_iso)
if status_codes:
placeholders = ", ".join("?" for _ in status_codes)
conditions.append(f"status IN ({placeholders})")
params.extend(status_codes)
if conditions: if conditions:
query += " WHERE " + " AND ".join(conditions) query += " WHERE " + " AND ".join(conditions)
with _connect() as conn: with _connect() as conn:

View File

@@ -1012,6 +1012,7 @@ async def requests_all(
take: int = 50, take: int = 50,
skip: int = 0, skip: int = 0,
days: Optional[int] = None, days: Optional[int] = None,
stage: str = "all",
user: Dict[str, str] = Depends(get_current_user), user: Dict[str, str] = Depends(get_current_user),
) -> Dict[str, Any]: ) -> Dict[str, Any]:
if user.get("role") != "admin": if user.get("role") != "admin":
@@ -1021,8 +1022,9 @@ async def requests_all(
since_iso = None since_iso = None
if days is not None and int(days) > 0: if days is not None and int(days) > 0:
since_iso = (datetime.now(timezone.utc) - timedelta(days=int(days))).isoformat() since_iso = (datetime.now(timezone.utc) - timedelta(days=int(days))).isoformat()
rows = get_cached_requests(limit=take, offset=skip, since_iso=since_iso) status_codes = requests_router.request_stage_filter_codes(stage)
total = get_cached_requests_count(since_iso=since_iso) rows = get_cached_requests(limit=take, offset=skip, since_iso=since_iso, status_codes=status_codes)
total = get_cached_requests_count(since_iso=since_iso, status_codes=status_codes)
results = [] results = []
for row in rows: for row in rows:
status = row.get("status") status = row.get("status")

View File

@@ -76,6 +76,7 @@ def _request_actions_brief(entries: Any) -> list[dict[str, Any]]:
async def events_stream( async def events_stream(
request: Request, request: Request,
recent_days: int = 90, recent_days: int = 90,
recent_stage: str = "all",
user: Dict[str, Any] = Depends(get_current_user_event_stream), user: Dict[str, Any] = Depends(get_current_user_event_stream),
) -> StreamingResponse: ) -> StreamingResponse:
recent_days = max(0, min(int(recent_days or 90), 3650)) recent_days = max(0, min(int(recent_days or 90), 3650))
@@ -103,6 +104,7 @@ async def events_stream(
take=recent_take, take=recent_take,
skip=0, skip=0,
days=recent_days, days=recent_days,
stage=recent_stage,
user=user, user=user,
) )
results = recent_payload.get("results") if isinstance(recent_payload, dict) else [] results = recent_payload.get("results") if isinstance(recent_payload, dict) else []
@@ -110,6 +112,7 @@ async def events_stream(
"type": "home_recent", "type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(), "ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days, "days": recent_days,
"stage": recent_stage,
"results": results if isinstance(results, list) else [], "results": results if isinstance(results, list) else [],
} }
except Exception as exc: except Exception as exc:
@@ -117,6 +120,7 @@ async def events_stream(
"type": "home_recent", "type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(), "ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days, "days": recent_days,
"stage": recent_stage,
"error": str(exc), "error": str(exc),
} }
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str) signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)

View File

@@ -91,6 +91,17 @@ STATUS_LABELS = {
6: "Partially ready", 6: "Partially ready",
} }
REQUEST_STAGE_CODES = {
"all": None,
"pending": [1],
"approved": [2],
"declined": [3],
"ready": [4],
"working": [5],
"partial": [6],
"in_progress": [2, 5, 6],
}
def _cache_get(key: str) -> Optional[Dict[str, Any]]: def _cache_get(key: str) -> Optional[Dict[str, Any]]:
cached = _detail_cache.get(key) cached = _detail_cache.get(key)
@@ -152,6 +163,23 @@ def _status_label(value: Any) -> str:
return "Unknown" return "Unknown"
def normalize_request_stage_filter(value: Optional[str]) -> str:
if not isinstance(value, str):
return "all"
normalized = value.strip().lower().replace("-", "_").replace(" ", "_")
if not normalized:
return "all"
if normalized in {"processing", "inprogress"}:
normalized = "in_progress"
return normalized if normalized in REQUEST_STAGE_CODES else "all"
def request_stage_filter_codes(value: Optional[str]) -> Optional[list[int]]:
normalized = normalize_request_stage_filter(value)
codes = REQUEST_STAGE_CODES.get(normalized)
return list(codes) if codes else None
def _normalize_username(value: Any) -> Optional[str]: def _normalize_username(value: Any) -> Optional[str]:
if not isinstance(value, str): if not isinstance(value, str):
return None return None
@@ -1063,6 +1091,7 @@ def _get_recent_from_cache(
limit: int, limit: int,
offset: int, offset: int,
since_iso: Optional[str], since_iso: Optional[str],
status_codes: Optional[list[int]] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
items = _recent_cache.get("items") or [] items = _recent_cache.get("items") or []
results = [] results = []
@@ -1078,6 +1107,8 @@ def _get_recent_from_cache(
item_dt = _parse_iso_datetime(candidate) item_dt = _parse_iso_datetime(candidate)
if not item_dt or item_dt < since_dt: if not item_dt or item_dt < since_dt:
continue continue
if status_codes and item.get("status") not in status_codes:
continue
results.append(item) results.append(item)
return results[offset : offset + limit] return results[offset : offset + limit]
@@ -1521,6 +1552,7 @@ async def recent_requests(
take: int = 6, take: int = 6,
skip: int = 0, skip: int = 0,
days: int = 90, days: int = 90,
stage: str = "all",
user: Dict[str, str] = Depends(get_current_user), user: Dict[str, str] = Depends(get_current_user),
) -> dict: ) -> dict:
runtime = get_runtime_settings() runtime = get_runtime_settings()
@@ -1542,9 +1574,17 @@ async def recent_requests(
since_iso = None since_iso = None
if days > 0: if days > 0:
since_iso = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() since_iso = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
status_codes = request_stage_filter_codes(stage)
if _recent_cache_stale(): if _recent_cache_stale():
_refresh_recent_cache_from_db() _refresh_recent_cache_from_db()
rows = _get_recent_from_cache(requested_by, requested_by_id, take, skip, since_iso) rows = _get_recent_from_cache(
requested_by,
requested_by_id,
take,
skip,
since_iso,
status_codes=status_codes,
)
cache_mode = (runtime.artwork_cache_mode or "remote").lower() cache_mode = (runtime.artwork_cache_mode or "remote").lower()
allow_title_hydrate = False allow_title_hydrate = False
allow_artwork_hydrate = client.configured() allow_artwork_hydrate = client.configured()
@@ -1733,6 +1773,8 @@ async def search_requests(
request_id = None request_id = None
status = None status = None
status_label = None status_label = None
requested_by = None
accessible = False
media_info = item.get("mediaInfo") or {} media_info = item.get("mediaInfo") or {}
media_info_id = media_info.get("id") media_info_id = media_info.get("id")
requests = media_info.get("requests") requests = media_info.get("requests")
@@ -1741,27 +1783,23 @@ async def search_requests(
status = requests[0].get("status") status = requests[0].get("status")
status_label = _status_label(status) status_label = _status_label(status)
elif isinstance(media_info_id, int): elif isinstance(media_info_id, int):
username_norm = _normalize_username(user.get("username", ""))
requested_by_id = user.get("jellyseerr_user_id")
requested_by = None if user.get("role") == "admin" else username_norm
requested_by_id = None if user.get("role") == "admin" else requested_by_id
cached = get_cached_request_by_media_id( cached = get_cached_request_by_media_id(
media_info_id, media_info_id,
requested_by_norm=requested_by,
requested_by_id=requested_by_id,
) )
if cached: if cached:
request_id = cached.get("request_id") request_id = cached.get("request_id")
status = cached.get("status") status = cached.get("status")
status_label = _status_label(status) status_label = _status_label(status)
if user.get("role") != "admin": if isinstance(request_id, int):
if isinstance(request_id, int): details = get_request_cache_payload(request_id)
if not isinstance(details, dict):
details = await _get_request_details(client, request_id) details = await _get_request_details(client, request_id)
if not _request_matches_user(details, user.get("username", "")): requested_by = _request_display_name(details)
continue if user.get("role") == "admin":
else: accessible = True
continue elif isinstance(details, dict):
accessible = _request_matches_user(details, user.get("username", ""))
results.append( results.append(
{ {
@@ -1772,6 +1810,8 @@ async def search_requests(
"requestId": request_id, "requestId": request_id,
"status": status, "status": status,
"statusLabel": status_label, "statusLabel": status_label,
"requestedBy": requested_by,
"accessible": accessible,
} }
) )

View File

@@ -15,6 +15,17 @@ type RequestRow = {
createdAt?: string | null createdAt?: string | null
} }
const REQUEST_STAGE_OPTIONS = [
{ value: 'all', label: 'All stages' },
{ value: 'pending', label: 'Waiting for approval' },
{ value: 'approved', label: 'Approved' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'working', label: 'Working on it' },
{ value: 'partial', label: 'Partially ready' },
{ value: 'ready', label: 'Ready to watch' },
{ value: 'declined', label: 'Declined' },
]
const formatDateTime = (value?: string | null) => { const formatDateTime = (value?: string | null) => {
if (!value) return 'Unknown' if (!value) return 'Unknown'
const date = new Date(value) const date = new Date(value)
@@ -30,6 +41,7 @@ export default function AdminRequestsAllPage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [pageSize, setPageSize] = useState(50) const [pageSize, setPageSize] = useState(50)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [stage, setStage] = useState('all')
const pageCount = useMemo(() => { const pageCount = useMemo(() => {
if (!total || pageSize <= 0) return 1 if (!total || pageSize <= 0) return 1
@@ -46,8 +58,15 @@ export default function AdminRequestsAllPage() {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const skip = (page - 1) * pageSize const skip = (page - 1) * pageSize
const params = new URLSearchParams({
take: String(pageSize),
skip: String(skip),
})
if (stage !== 'all') {
params.set('stage', stage)
}
const response = await authFetch( const response = await authFetch(
`${baseUrl}/admin/requests/all?take=${pageSize}&skip=${skip}` `${baseUrl}/admin/requests/all?${params.toString()}`
) )
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
@@ -74,7 +93,7 @@ export default function AdminRequestsAllPage() {
useEffect(() => { useEffect(() => {
void load() void load()
}, [page, pageSize]) }, [page, pageSize, stage])
useEffect(() => { useEffect(() => {
if (page > pageCount) { if (page > pageCount) {
@@ -82,6 +101,10 @@ export default function AdminRequestsAllPage() {
} }
}, [pageCount, page]) }, [pageCount, page])
useEffect(() => {
setPage(1)
}, [stage])
return ( return (
<AdminShell <AdminShell
title="All requests" title="All requests"
@@ -98,6 +121,16 @@ export default function AdminRequestsAllPage() {
<span>{total.toLocaleString()} total</span> <span>{total.toLocaleString()} total</span>
</div> </div>
<div className="admin-toolbar-actions"> <div className="admin-toolbar-actions">
<label className="admin-select">
<span>Stage</span>
<select value={stage} onChange={(e) => setStage(e.target.value)}>
{REQUEST_STAGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="admin-select"> <label className="admin-select">
<span>Per page</span> <span>Per page</span>
<select value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}> <select value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}>

View File

@@ -1527,6 +1527,13 @@ button span {
color: var(--ink-muted); color: var(--ink-muted);
} }
.recent-filter-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.recent-filter select { .recent-filter select {
padding: 8px 12px; padding: 8px 12px;
font-size: 13px; font-size: 13px;

View File

@@ -22,6 +22,17 @@ const normalizeRecentResults = (items: any[]) =>
} }
}) })
const REQUEST_STAGE_OPTIONS = [
{ value: 'all', label: 'All stages' },
{ value: 'pending', label: 'Waiting' },
{ value: 'approved', label: 'Approved' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'working', label: 'Working' },
{ value: 'partial', label: 'Partial' },
{ value: 'ready', label: 'Ready' },
{ value: 'declined', label: 'Declined' },
]
export default function HomePage() { export default function HomePage() {
const router = useRouter() const router = useRouter()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
@@ -38,11 +49,20 @@ export default function HomePage() {
const [recentError, setRecentError] = useState<string | null>(null) const [recentError, setRecentError] = useState<string | null>(null)
const [recentLoading, setRecentLoading] = useState(false) const [recentLoading, setRecentLoading] = useState(false)
const [searchResults, setSearchResults] = useState< const [searchResults, setSearchResults] = useState<
{ title: string; year?: number; type?: string; requestId?: number; statusLabel?: string }[] {
title: string
year?: number
type?: string
requestId?: number
statusLabel?: string
requestedBy?: string | null
accessible?: boolean
}[]
>([]) >([])
const [searchError, setSearchError] = useState<string | null>(null) const [searchError, setSearchError] = useState<string | null>(null)
const [role, setRole] = useState<string | null>(null) const [role, setRole] = useState<string | null>(null)
const [recentDays, setRecentDays] = useState(90) const [recentDays, setRecentDays] = useState(90)
const [recentStage, setRecentStage] = useState('all')
const [authReady, setAuthReady] = useState(false) const [authReady, setAuthReady] = useState(false)
const [servicesStatus, setServicesStatus] = useState< const [servicesStatus, setServicesStatus] = useState<
{ overall: string; services: { name: string; status: string; message?: string }[] } | null { overall: string; services: { name: string; status: string; message?: string }[] } | null
@@ -143,9 +163,14 @@ export default function HomePage() {
setRole(userRole) setRole(userRole)
setAuthReady(true) setAuthReady(true)
const take = userRole === 'admin' ? 50 : 6 const take = userRole === 'admin' ? 50 : 6
const response = await authFetch( const params = new URLSearchParams({
`${baseUrl}/requests/recent?take=${take}&days=${recentDays}` take: String(take),
) days: String(recentDays),
})
if (recentStage !== 'all') {
params.set('stage', recentStage)
}
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`)
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
clearToken() clearToken()
@@ -167,7 +192,7 @@ export default function HomePage() {
} }
load() load()
}, [recentDays]) }, [recentDays, recentStage])
useEffect(() => { useEffect(() => {
if (!authReady) { if (!authReady) {
@@ -222,7 +247,14 @@ export default function HomePage() {
try { try {
const streamToken = await getEventStreamToken() const streamToken = await getEventStreamToken()
if (closed) return if (closed) return
const streamUrl = `${baseUrl}/events/stream?stream_token=${encodeURIComponent(streamToken)}&recent_days=${encodeURIComponent(String(recentDays))}` const params = new URLSearchParams({
stream_token: streamToken,
recent_days: String(recentDays),
})
if (recentStage !== 'all') {
params.set('recent_stage', recentStage)
}
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
source = new EventSource(streamUrl) source = new EventSource(streamUrl)
source.onopen = () => { source.onopen = () => {
@@ -282,7 +314,7 @@ export default function HomePage() {
setLiveStreamConnected(false) setLiveStreamConnected(false)
source?.close() source?.close()
} }
}, [authReady, recentDays]) }, [authReady, recentDays, recentStage])
const runSearch = async (term: string) => { const runSearch = async (term: string) => {
try { try {
@@ -299,14 +331,16 @@ export default function HomePage() {
const data = await response.json() const data = await response.json()
if (Array.isArray(data?.results)) { if (Array.isArray(data?.results)) {
setSearchResults( setSearchResults(
data.results.map((item: any) => ({ data.results.map((item: any) => ({
title: item.title, title: item.title,
year: item.year, year: item.year,
type: item.type, type: item.type,
requestId: item.requestId, requestId: item.requestId,
statusLabel: item.statusLabel, statusLabel: item.statusLabel,
})) requestedBy: item.requestedBy ?? null,
) accessible: Boolean(item.accessible),
}))
)
setSearchError(null) setSearchError(null)
} }
} catch (error) { } catch (error) {
@@ -403,19 +437,34 @@ export default function HomePage() {
<div className="recent-header"> <div className="recent-header">
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2> <h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
{authReady && ( {authReady && (
<label className="recent-filter"> <div className="recent-filter-group">
<span>Show</span> <label className="recent-filter">
<select <span>Show</span>
value={recentDays} <select
onChange={(event) => setRecentDays(Number(event.target.value))} value={recentDays}
> onChange={(event) => setRecentDays(Number(event.target.value))}
<option value={0}>All</option> >
<option value={30}>30 days</option> <option value={0}>All</option>
<option value={60}>60 days</option> <option value={30}>30 days</option>
<option value={90}>90 days</option> <option value={60}>60 days</option>
<option value={180}>180 days</option> <option value={90}>90 days</option>
</select> <option value={180}>180 days</option>
</label> </select>
</label>
<label className="recent-filter">
<span>Stage</span>
<select
value={recentStage}
onChange={(event) => setRecentStage(event.target.value)}
>
{REQUEST_STAGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
)} )}
</div> </div>
<div className="recent-grid"> <div className="recent-grid">
@@ -467,9 +516,10 @@ export default function HomePage() {
<aside className="side-panel"> <aside className="side-panel">
<section className="main-panel find-panel"> <section className="main-panel find-panel">
<div className="find-header"> <div className="find-header">
<h1>Find my request</h1> <h1>Search all requests</h1>
<p className="lede"> <p className="lede">
Search by title + year, paste a request number, or pick from your recent requests. Search any request by title + year or request number and see whether it already
exists in the system.
</p> </p>
</div> </div>
<div className="find-controls"> <div className="find-controls">
@@ -517,15 +567,21 @@ export default function HomePage() {
<button <button
key={`${item.title || 'Untitled'}-${index}`} key={`${item.title || 'Untitled'}-${index}`}
type="button" type="button"
disabled={!item.requestId} disabled={!item.requestId || !item.accessible}
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)} onClick={() =>
item.requestId && item.accessible && router.push(`/requests/${item.requestId}`)
}
> >
{item.title || 'Untitled'} {item.year ? `(${item.year})` : ''}{' '} {item.title || 'Untitled'} {item.year ? `(${item.year})` : ''}{' '}
{!item.requestId {!item.requestId
? '- not requested' ? '- not requested'
: item.statusLabel : !item.accessible
? `- ${item.statusLabel}` ? `- ${item.statusLabel || 'Requested'} · requested by ${
: ''} item.requestedBy || 'another user'
}`
: item.statusLabel
? `- ${item.statusLabel}`
: ''}
</button> </button>
)) ))
)} )}

View File

@@ -1,12 +1,12 @@
{ {
"name": "magent-frontend", "name": "magent-frontend",
"version": "0203262044", "version": "0303261323",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "magent-frontend", "name": "magent-frontend",
"version": "0203262044", "version": "0303261323",
"dependencies": { "dependencies": {
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.4", "react": "19.2.4",

View File

@@ -1,7 +1,7 @@
{ {
"name": "magent-frontend", "name": "magent-frontend",
"private": true, "private": true,
"version": "0203262044", "version": "0303261323",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",