import { useEffect, useMemo, useState } from 'react'; import { Sidebar } from '../../layout/components/Sidebar'; import { Topbar } from '../../layout/components/Topbar'; import type { NotificationInterface } from '../../../mvvm/models/notification.interface'; import type { NotificationSettingInterface } from '../../../mvvm/models/notification-setting.interface'; import type { OccupationCategorizationInterface, SubAreaInterface } from '../../../mvvm/models/occupation-categorization.interface'; import type { OccupationInterface } from '../../../mvvm/models/occupation.interface'; import { AiJobAgentViewModel } from '../../../mvvm/viewmodels/AiJobAgentViewModel'; interface AiJobAgentPageProps { onLogout: () => Promise; onNavigate: (key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') => void; onOpenJob: (jobId: string, fromJobnet: boolean) => void; } type OccupationTree = OccupationCategorizationInterface[]; const PAGE_LIMIT = 20; function createEmptySetting(): NotificationSettingInterface { return { id: null, jobAgentName: '', workTimeDay: false, workTimeEvening: false, workTimeNight: false, workTimeWeekend: false, workTypePermanent: false, workTypeFreelance: false, workTypePartTime: false, workTypeSubstitute: false, workTypeTemporary: false, workDistance: 50, distanceCenterName: '', latitude: null, longitude: null, partTimeHours: null, notifyOnPush: false, notifyOnSms: false, searchText: '', escoIds: [], }; } function normalizeSetting(setting: NotificationSettingInterface): NotificationSettingInterface { return { ...createEmptySetting(), ...setting, id: setting.id ?? null, jobAgentName: setting.jobAgentName ?? '', distanceCenterName: setting.distanceCenterName ?? '', searchText: setting.searchText ?? '', escoIds: Array.isArray(setting.escoIds) ? setting.escoIds : [], workDistance: typeof setting.workDistance === 'number' ? setting.workDistance : 50, }; } function mapTree(source: OccupationCategorizationInterface[]): OccupationTree { return source.map((area) => ({ ...area, expanded: Boolean(area.expanded), activated: Boolean(area.activated), someIsActive: Boolean(area.someIsActive), subAreas: area.subAreas.map((subArea) => ({ ...subArea, expanded: Boolean(subArea.expanded), activated: Boolean(subArea.activated), someIsActive: Boolean(subArea.someIsActive), occupations: subArea.occupations.map((occupation) => ({ ...occupation, activated: Boolean(occupation.activated), })), })), })); } function applySelectedEscos(tree: OccupationTree, escoIds: number[]): OccupationTree { const selected = new Set(escoIds); return tree.map((area) => { const subAreas = area.subAreas.map((subArea) => { const occupations = subArea.occupations.map((occupation) => ({ ...occupation, activated: selected.has(occupation.id), })); const activeCount = occupations.filter((occ) => occ.activated).length; return { ...subArea, occupations, activated: activeCount > 0 && activeCount === occupations.length, someIsActive: activeCount > 0 && activeCount < occupations.length, }; }); const allActive = subAreas.length > 0 && subAreas.every((item) => item.activated); const someActive = subAreas.some((item) => item.activated || item.someIsActive); return { ...area, subAreas, activated: allActive, someIsActive: someActive && !allActive, }; }); } function collectSelectedEscos(tree: OccupationTree): number[] { const ids: number[] = []; for (const area of tree) { for (const subArea of area.subAreas) { for (const occupation of subArea.occupations) { if (occupation.activated) { ids.push(occupation.id); } } } } return ids; } function formatDate(value: Date | string): string { const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) { return ''; } return date.toLocaleDateString('da-DK', { day: '2-digit', month: 'short', year: 'numeric', }); } function companyInitial(value: string | null | undefined): string { if (!value?.trim()) { return 'Ar'; } return value.trim().slice(0, 1).toUpperCase(); } function hasAnyActiveJobAgent(settings: NotificationSettingInterface[]): boolean { return settings.some((setting) => (setting.escoIds?.length ?? 0) > 0); } export function AiJobAgentPage({ onLogout, onNavigate, onOpenJob }: AiJobAgentPageProps) { const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('arbejd.sidebar.collapsed') === '1'); const [showSettings, setShowSettings] = useState(false); const [showWorkAreas, setShowWorkAreas] = useState(false); const [isLoadingSettings, setIsLoadingSettings] = useState(true); const [isLoadingNotifications, setIsLoadingNotifications] = useState(true); const [isLoadingMoreNotifications, setIsLoadingMoreNotifications] = useState(false); const [isSavingSetting, setIsSavingSetting] = useState(false); const [isSearchingPlaces, setIsSearchingPlaces] = useState(false); const [hasMoreNotifications, setHasMoreNotifications] = useState(true); const [error, setError] = useState(null); const [notificationSettings, setNotificationSettings] = useState([]); const [notifications, setNotifications] = useState([]); const [selectedJobAgentId, setSelectedJobAgentId] = useState('new'); const [editingSetting, setEditingSetting] = useState(createEmptySetting()); const [occupationTree, setOccupationTree] = useState([]); const [searchOccupationWord, setSearchOccupationWord] = useState(''); const [placeSuggestions, setPlaceSuggestions] = useState>([]); const viewModel = useMemo(() => new AiJobAgentViewModel(), []); useEffect(() => { let active = true; async function bootstrap() { setError(null); setIsLoadingSettings(true); setIsLoadingNotifications(true); try { const [settings, rawTree, firstNotifications] = await Promise.all([ viewModel.getNotificationSettings(), viewModel.getOccupationTree(), viewModel.getNotifications(0, PAGE_LIMIT), ]); if (!active) { return; } const normalizedSettings = settings.map(normalizeSetting); const mappedTree = mapTree(rawTree); const initialSetting = normalizedSettings[0] ?? createEmptySetting(); const initialId = normalizedSettings[0]?.id ?? 'new'; setNotificationSettings(normalizedSettings); setSelectedJobAgentId(initialId); setEditingSetting(initialSetting); setOccupationTree(applySelectedEscos(mappedTree, initialSetting.escoIds)); setNotifications(firstNotifications); setHasMoreNotifications(firstNotifications.length === PAGE_LIMIT); } catch (loadError) { if (!active) { return; } setError(loadError instanceof Error ? loadError.message : 'Kunne ikke indlæse AI JobAgent.'); } finally { if (active) { setIsLoadingSettings(false); setIsLoadingNotifications(false); } } } void bootstrap(); return () => { active = false; }; }, [viewModel]); useEffect(() => { const query = editingSetting.distanceCenterName?.trim() ?? ''; if (!showSettings || query.length < 3) { setPlaceSuggestions([]); return; } const timeout = window.setTimeout(() => { setIsSearchingPlaces(true); void viewModel .searchPlaces(query) .then((suggestions) => { setPlaceSuggestions(suggestions); }) .catch(() => { setPlaceSuggestions([]); }) .finally(() => setIsSearchingPlaces(false)); }, 350); return () => window.clearTimeout(timeout); }, [editingSetting.distanceCenterName, showSettings, viewModel]); const filteredOccupations = useMemo(() => { const query = searchOccupationWord.trim().toLowerCase(); if (!query) { return []; } const list: Array<{ areaCode: number; subAreaCode: number; occupation: OccupationInterface }> = []; for (const area of occupationTree) { for (const subArea of area.subAreas) { for (const occupation of subArea.occupations) { if (occupation.name.toLowerCase().includes(query)) { list.push({ areaCode: area.areaCode, subAreaCode: subArea.subAreaCode, occupation }); } } } } return list.slice(0, 40); }, [occupationTree, searchOccupationWord]); const selectedEscoCount = useMemo(() => collectSelectedEscos(occupationTree).length, [occupationTree]); const unseenCount = useMemo( () => notifications.filter((notification) => !notification.seenByUser).length, [notifications], ); function selectJobAgent(value: number | 'new') { setSelectedJobAgentId(value); setShowWorkAreas(false); setSearchOccupationWord(''); if (value === 'new') { const empty = createEmptySetting(); setEditingSetting(empty); setOccupationTree((prev) => applySelectedEscos(prev, [])); return; } const setting = notificationSettings.find((item) => item.id === value); const normalized = normalizeSetting(setting ?? createEmptySetting()); setEditingSetting(normalized); setOccupationTree((prev) => applySelectedEscos(prev, normalized.escoIds)); } function updateTreeByArea(areaCode: number, active: boolean) { setOccupationTree((prev) => prev.map((area) => { if (area.areaCode !== areaCode) { return area; } const subAreas = area.subAreas.map((subArea) => ({ ...subArea, activated: active, someIsActive: false, occupations: subArea.occupations.map((occupation) => ({ ...occupation, activated: active, })), })); return { ...area, subAreas, activated: active, someIsActive: false, }; }), ); } function updateTreeBySubArea(areaCode: number, subAreaCode: number, active: boolean) { setOccupationTree((prev) => prev.map((area) => { if (area.areaCode !== areaCode) { return area; } const subAreas = area.subAreas.map((subArea) => { if (subArea.subAreaCode !== subAreaCode) { return subArea; } return { ...subArea, activated: active, someIsActive: false, occupations: subArea.occupations.map((occupation) => ({ ...occupation, activated: active, })), }; }); const allActive = subAreas.length > 0 && subAreas.every((item) => item.activated); const someActive = subAreas.some((item) => item.activated || item.someIsActive); return { ...area, subAreas, activated: allActive, someIsActive: someActive && !allActive, }; }), ); } function updateTreeByOccupation(areaCode: number, subAreaCode: number, occupationId: number) { setOccupationTree((prev) => prev.map((area) => { if (area.areaCode !== areaCode) { return area; } const subAreas = area.subAreas.map((subArea) => { if (subArea.subAreaCode !== subAreaCode) { return subArea; } const occupations = subArea.occupations.map((occupation) => occupation.id === occupationId ? { ...occupation, activated: !occupation.activated } : occupation, ); const activeCount = occupations.filter((occupation) => occupation.activated).length; return { ...subArea, occupations, activated: activeCount > 0 && activeCount === occupations.length, someIsActive: activeCount > 0 && activeCount < occupations.length, }; }); const allActive = subAreas.length > 0 && subAreas.every((item) => item.activated); const someActive = subAreas.some((item) => item.activated || item.someIsActive); return { ...area, subAreas, activated: allActive, someIsActive: someActive && !allActive, }; }), ); } async function saveSetting() { if (!editingSetting.jobAgentName?.trim()) { return; } setIsSavingSetting(true); setError(null); try { const payload: NotificationSettingInterface = { ...editingSetting, jobAgentName: editingSetting.jobAgentName.trim(), searchText: editingSetting.searchText?.trim() || null, distanceCenterName: editingSetting.distanceCenterName?.trim() || null, escoIds: collectSelectedEscos(occupationTree), }; await viewModel.saveNotificationSetting(selectedJobAgentId, payload); const settings = (await viewModel.getNotificationSettings()).map(normalizeSetting); setNotificationSettings(settings); if (selectedJobAgentId === 'new') { const created = settings.find((item) => item.jobAgentName === payload.jobAgentName) ?? settings[0]; if (created?.id != null) { setSelectedJobAgentId(created.id); setEditingSetting(created); setOccupationTree((prev) => applySelectedEscos(prev, created.escoIds)); } } else { const updated = settings.find((item) => item.id === selectedJobAgentId); if (updated) { setEditingSetting(updated); setOccupationTree((prev) => applySelectedEscos(prev, updated.escoIds)); } } setShowSettings(false); setShowWorkAreas(false); } catch (saveError) { setError(saveError instanceof Error ? saveError.message : 'Kunne ikke gemme jobagent indstillinger.'); } finally { setIsSavingSetting(false); } } async function deleteCurrentSetting() { if (selectedJobAgentId === 'new') { return; } setIsSavingSetting(true); setError(null); try { await viewModel.deleteNotificationSetting(selectedJobAgentId); const settings = (await viewModel.getNotificationSettings()).map(normalizeSetting); setNotificationSettings(settings); const next = settings[0] ?? createEmptySetting(); setSelectedJobAgentId(settings[0]?.id ?? 'new'); setEditingSetting(next); setOccupationTree((prev) => applySelectedEscos(prev, next.escoIds)); } catch (deleteError) { setError(deleteError instanceof Error ? deleteError.message : 'Kunne ikke slette jobagent.'); } finally { setIsSavingSetting(false); } } async function selectPlaceSuggestion(placeId: string) { const details = await viewModel.getPlaceDetails(placeId); if (!details) { return; } setEditingSetting((prev) => ({ ...prev, distanceCenterName: details.address, latitude: details.latitude, longitude: details.longitude, })); setPlaceSuggestions([]); } async function openNotificationJob(notification: NotificationInterface) { if (!notification.seenByUser) { void viewModel.markNotificationSeen(notification.id); setNotifications((prev) => prev.map((item) => (item.id === notification.id ? { ...item, seenByUser: true } : item)), ); } const fromJobnet = Boolean(notification.jobnetPostingId); const jobId = fromJobnet ? notification.jobnetPostingId : notification.jobPostingId; if (jobId) { onOpenJob(jobId, fromJobnet); } } async function toggleNotificationBookmark(notification: NotificationInterface) { const save = !notification.saved; try { await viewModel.toggleNotificationBookmark(notification, save); setNotifications((prev) => prev.map((item) => (item.id === notification.id ? { ...item, saved: save } : item)), ); } catch { setError('Kunne ikke opdatere gemt status på notifikation.'); } } async function loadMoreNotifications() { if (!hasMoreNotifications || isLoadingMoreNotifications) { return; } setIsLoadingMoreNotifications(true); setError(null); try { const next = await viewModel.getNotifications(notifications.length, PAGE_LIMIT); setNotifications((prev) => [...prev, ...next]); setHasMoreNotifications(next.length === PAGE_LIMIT); } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Kunne ikke indlæse flere notifikationer.'); } finally { setIsLoadingMoreNotifications(false); } } return (
setSidebarCollapsed((prev) => { const next = !prev; window.localStorage.setItem('arbejd.sidebar.collapsed', next ? '1' : '0'); return next; })} onSelect={(key) => { if (key === 'dashboard' || key === 'cv' || key === 'jobs' || key === 'beskeder' || key === 'ai-jobagent' || key === 'ai-agent' || key === 'simulator' || key === 'abonnement') { onNavigate(key); } }} />
{error ?

{error}

: null}
AI JobAgent

Automatisk jobmatch med dit CV

Jobagenten følger nye opslag og fremhæver relevante job baseret på dine valgte områder, arbejdstype og afstand.

{!isLoadingSettings ? (
Jobagenter {notificationSettings.length}
Aktive filtre {selectedEscoCount}
Nye notifikationer {unseenCount}

{hasAnyActiveJobAgent(notificationSettings) ? 'Mindst én jobagent er aktiv.' : 'Vælg stillingstyper og områder for at aktivere en jobagent.'}

) : (

Indlæser jobagenter...

)}
{showSettings ? (
Opsaetning

Jobagent indstillinger

{selectedJobAgentId !== 'new' ? ( ) : null}
Arbejdsområder
Arbejdstype
{editingSetting.workTypePartTime ? ( setEditingSetting((prev) => ({ ...prev, partTimeHours: event.target.value ? Number(event.target.value) : null, })) } placeholder="Timer pr. uge" /> ) : null}
Center for afstand setEditingSetting((prev) => ({ ...prev, distanceCenterName: event.target.value, latitude: null, longitude: null, })) } placeholder="Søg adresse" /> {isSearchingPlaces ? Søger adresser... : null} {placeSuggestions.length > 0 ? (
{placeSuggestions.map((suggestion) => ( ))}
) : null}
Arbejdsafstand: {editingSetting.workDistance ?? 50} km setEditingSetting((prev) => ({ ...prev, workDistance: Number(event.target.value) }))} />
{showWorkAreas ? (
Arbejdsområder
{selectedEscoCount} valgt
setSearchOccupationWord(event.target.value)} placeholder="Søg arbejdsområde" /> {searchOccupationWord.trim() && filteredOccupations.length > 0 ? (
{filteredOccupations.map((entry) => ( ))}
) : (
{occupationTree.map((area) => (
{area.expanded ? (
{area.subAreas.map((subArea: SubAreaInterface) => (
{subArea.expanded ? (
{subArea.occupations.map((occupation) => ( ))}
) : null}
))}
) : null}
))}
)}
) : null}
) : null}
Live feed

Notifikationer

{notifications.length}
{isLoadingNotifications ?

Indlæser notifikationer...

: null} {!isLoadingNotifications && notifications.length === 0 ? (

Ingen notifikationer endnu.

) : null}
{notifications.map((notification) => (
void openNotificationJob(notification)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); void openNotificationJob(notification); } }} >
{notification.logoUrl ? ( {notification.companyName ) : (
{companyInitial(notification.companyName)}
)}

{notification.companyName || 'Ukendt virksomhed'}

{notification.city ? `${notification.city} ${notification.zip || ''}` : 'Ukendt lokation'}

{notification.jobTitle || 'Jobagent match'}

{notification.escoTitle || 'AI JobAgent forslag'}

{notification.seenByUser ? 'Match fra din jobagent baseret på dine valgte filtre.' : 'Nyt match fra din jobagent.'}

{formatDate(notification.notificationDate)} {notification.distance ? {Number(notification.distance).toFixed(1)} km : null} {notification.seenByUser ? 'Set' : 'Ny'}
))}
{hasMoreNotifications ? ( ) : null}
); }