import { FormEvent, useEffect, useMemo, useState } from "react"; import { Alert, Box, Button, Card, CardActions, CardContent, Chip, CircularProgress, FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from "@mui/material"; import SearchIcon from "@mui/icons-material/Search"; import AddIcon from "@mui/icons-material/Add"; import BookmarkAddOutlinedIcon from "@mui/icons-material/BookmarkAddOutlined"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import RefreshIcon from "@mui/icons-material/Refresh"; import { useNavigate } from "react-router-dom"; import { api } from "../api"; import { useI18n } from "../i18n/I18nProvider"; import { useDialogActions } from "../dialogs"; type DiscoveredJob = { id: string; title: string; company?: string; location?: string; modifiedAt?: string; deadline?: string; url: string; source: string; sourceName?: string; acquisitionType?: string; retrievedAt?: string; countryCode: string; isNew?: boolean; }; type SavedSearch = { id: number; name: string; query: string; location: string; isActive: boolean; lastRunAtUtc?: string; resultCount: number; dismissedCount: number }; type SavedSearchRun = { search: SavedSearch; jobs: Array<{ job: DiscoveredJob; isNew: boolean; isDismissed: boolean }> }; type SortOrder = "updated" | "deadline" | "title"; export default function JobDiscoveryPage() { const navigate = useNavigate(); const { language, t } = useI18n(); const { confirmAction } = useDialogActions(); const [query, setQuery] = useState(""); const [location, setLocation] = useState(""); const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [hasSearched, setHasSearched] = useState(false); const [lastSearch, setLastSearch] = useState({ query: "", location: "" }); const [sortOrder, setSortOrder] = useState("updated"); const [savedSearches, setSavedSearches] = useState([]); const [savedName, setSavedName] = useState(""); const [savingSearch, setSavingSearch] = useState(false); const [activeSavedSearchId, setActiveSavedSearchId] = useState(null); const [updatingSavedSearchId, setUpdatingSavedSearchId] = useState(null); useEffect(() => { let active = true; api.get("/job-discovery/saved-searches") .then((response) => { if (active) setSavedSearches(Array.isArray(response.data) ? response.data : []); }) .catch(() => { /* One-off discovery remains usable if saved-search storage is unavailable. */ }); return () => { active = false; }; }, []); const runSearch = async (nextQuery: string, nextLocation: string) => { setLoading(true); setError(""); setJobs([]); setHasSearched(true); setLastSearch({ query: nextQuery, location: nextLocation }); try { const response = await api.get("/job-discovery/search", { params: { q: nextQuery || undefined, location: nextLocation || undefined } }); setJobs(response.data ?? []); } catch { setError(t("jobDiscoveryUnavailable")); } finally { setLoading(false); } }; const search = (event: FormEvent) => { event.preventDefault(); void runSearch(query.trim(), location.trim()); }; const saveSearch = async () => { const name = savedName.trim() || query.trim() || location.trim() || t("jobDiscoverySavedDefaultName"); setSavingSearch(true); setError(""); try { const response = await api.post("/job-discovery/saved-searches", { name, query: query.trim(), location: location.trim() }); setSavedSearches((current) => [response.data, ...current]); setSavedName(""); } catch { setError(t("jobDiscoverySaveSearchFailed")); } finally { setSavingSearch(false); } }; const runSavedSearch = async (saved: SavedSearch) => { setLoading(true); setError(""); setHasSearched(true); setActiveSavedSearchId(saved.id); setQuery(saved.query); setLocation(saved.location); setLastSearch({ query: saved.query, location: saved.location }); try { const response = await api.post(`/job-discovery/saved-searches/${saved.id}/run`); const visible = response.data.jobs.filter((item) => !item.isDismissed).map((item) => ({ ...item.job, isNew: item.isNew })); setJobs(visible); setSavedSearches((current) => current.map((item) => item.id === saved.id ? response.data.search : item)); } catch { setError(t("jobDiscoveryUnavailable")); } finally { setLoading(false); } }; const deleteSavedSearch = async (saved: SavedSearch) => { if (!(await confirmAction(t("jobDiscoveryDeleteSavedConfirm", { name: saved.name }), { title: t("jobDiscoveryDeleteSavedTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return; try { await api.delete(`/job-discovery/saved-searches/${saved.id}`); setSavedSearches((current) => current.filter((item) => item.id !== saved.id)); if (activeSavedSearchId === saved.id) setActiveSavedSearchId(null); } catch { setError(t("jobDiscoveryDeleteSavedFailed")); } }; const toggleSavedSearchAlerts = async (saved: SavedSearch) => { setUpdatingSavedSearchId(saved.id); setError(""); try { const response = await api.patch(`/job-discovery/saved-searches/${saved.id}`, { isActive: !saved.isActive }); setSavedSearches((current) => current.map((item) => item.id === saved.id ? response.data : item)); } catch { setError(t("jobDiscoveryUpdateAlertsFailed")); } finally { setUpdatingSavedSearchId(null); } }; const dismissJob = async (jobId: string) => { if (activeSavedSearchId === null) return; try { await api.patch(`/job-discovery/saved-searches/${activeSavedSearchId}/results/${encodeURIComponent(jobId)}/dismiss`, { isDismissed: true }); setJobs((current) => current.filter((item) => item.id !== jobId)); setSavedSearches((current) => current.map((item) => item.id === activeSavedSearchId ? { ...item, dismissedCount: item.dismissedCount + 1 } : item)); } catch { setError(t("jobDiscoveryDismissFailed")); } }; const sortedJobs = useMemo(() => [...jobs].sort((left, right) => { if (sortOrder === "title") return left.title.localeCompare(right.title); if (sortOrder === "deadline") { const leftDeadline = left.deadline ? new Date(left.deadline).getTime() : Number.MAX_SAFE_INTEGER; const rightDeadline = right.deadline ? new Date(right.deadline).getTime() : Number.MAX_SAFE_INTEGER; return leftDeadline - rightDeadline; } return new Date(right.modifiedAt || 0).getTime() - new Date(left.modifiedAt || 0).getTime(); }), [jobs, sortOrder]); const formatDate = (value?: string) => value ? new Date(value).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : null; return ( setQuery(event.target.value)} /> setLocation(event.target.value)} /> {t("jobDiscoverySavedSearches")} {t("jobDiscoverySavedSearchesHelp")} setSavedName(event.target.value)} /> {savedSearches.length === 0 ? {t("jobDiscoveryNoSavedSearches")} : ( {savedSearches.map((saved) => ( {saved.name} {[saved.query, saved.location].filter(Boolean).join(" · ") || t("jobDiscoveryAllRecent")}{saved.lastRunAtUtc ? ` · ${t("jobDiscoveryLastRun", { date: formatDate(saved.lastRunAtUtc) ?? "" })}` : ""} void toggleSavedSearchAlerts(saved)} />} label={{t("jobDiscoveryAutomaticAlerts")}} /> ))} )} {t("jobDiscoverySourceHelp")} {error ? void runSearch(lastSearch.query, lastSearch.location)} disabled={loading}>{t("retry")}}>{error} : null} {!loading && !hasSearched ? {t("jobDiscoveryGuidance")} : null} {!loading && hasSearched && !error && jobs.length === 0 ? {t("jobDiscoveryNoResults")} : null} {loading ? {t("jobDiscoverySearching")} : null} {jobs.length > 0 ? ( {t(jobs.length === 1 ? "jobDiscoveryResultOne" : "jobDiscoveryResultMany", { count: jobs.length })} setSortOrder(event.target.value as SortOrder)} sx={{ minWidth: 190 }}> {t("jobDiscoverySortUpdated")} {t("jobDiscoverySortDeadline")} {t("jobDiscoverySortTitle")} ) : null} {sortedJobs.map((job) => ( {job.isNew ? : null} {job.title} {[job.company, job.location || t("jobDiscoveryLocationMissing")].filter(Boolean).join(" · ")} {job.acquisitionType === "searched" ? t("jobDiscoverySearchedListing") : t("jobDiscoverySourceTypeMissing")} {formatDate(job.retrievedAt) ? ` · ${t("jobDiscoveryRetrieved", { date: formatDate(job.retrievedAt) ?? "" })}` : ""} {formatDate(job.modifiedAt) ? {t("jobDiscoveryListingUpdated", { date: formatDate(job.modifiedAt) ?? "" })} : null} {formatDate(job.deadline) ? {t("jobDiscoveryDeadline", { date: formatDate(job.deadline) ?? "" })} : null} {t("jobDiscoveryWorkArrangementMissing")} {activeSavedSearchId !== null ? : null} ))} ); }