910 lines
37 KiB
TypeScript
910 lines
37 KiB
TypeScript
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<void>;
|
|
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<string | null>(null);
|
|
const [notificationSettings, setNotificationSettings] = useState<NotificationSettingInterface[]>([]);
|
|
const [notifications, setNotifications] = useState<NotificationInterface[]>([]);
|
|
const [selectedJobAgentId, setSelectedJobAgentId] = useState<number | 'new'>('new');
|
|
const [editingSetting, setEditingSetting] = useState<NotificationSettingInterface>(createEmptySetting());
|
|
const [occupationTree, setOccupationTree] = useState<OccupationTree>([]);
|
|
const [searchOccupationWord, setSearchOccupationWord] = useState('');
|
|
const [placeSuggestions, setPlaceSuggestions] = useState<Array<{ place_id?: string; description?: string }>>([]);
|
|
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 (
|
|
<section className="dashboard-layout">
|
|
<Sidebar
|
|
collapsed={sidebarCollapsed}
|
|
activeKey="ai-jobagent"
|
|
onToggle={() => 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);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<main className="dashboard-main">
|
|
<Topbar title="AI JobAgent" userName="Anders Jensen" planLabel="Jobseeker Pro" onLogout={onLogout} />
|
|
|
|
<div className="dashboard-scroll">
|
|
{error ? <p className="status error">{error}</p> : null}
|
|
|
|
<article className="glass-panel dash-card jobagent-hero-card">
|
|
<div className="jobagent-hero-top">
|
|
<div>
|
|
<span className="jobagent-kicker">AI JobAgent</span>
|
|
<h3>Automatisk jobmatch med dit CV</h3>
|
|
<p>
|
|
Jobagenten følger nye opslag og fremhæver relevante job baseret på dine valgte områder,
|
|
arbejdstype og afstand.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="primary-btn"
|
|
onClick={() => setShowSettings((prev) => !prev)}
|
|
disabled={isLoadingSettings}
|
|
>
|
|
{showSettings ? 'Luk indstillinger' : 'Åbn indstillinger'}
|
|
</button>
|
|
</div>
|
|
|
|
{!isLoadingSettings ? (
|
|
<div className="jobagent-hero-metrics">
|
|
<div className="jobagent-metric-pill">
|
|
<span>Jobagenter</span>
|
|
<strong>{notificationSettings.length}</strong>
|
|
</div>
|
|
<div className="jobagent-metric-pill">
|
|
<span>Aktive filtre</span>
|
|
<strong>{selectedEscoCount}</strong>
|
|
</div>
|
|
<div className="jobagent-metric-pill">
|
|
<span>Nye notifikationer</span>
|
|
<strong>{unseenCount}</strong>
|
|
</div>
|
|
<p className="jobagent-summary-note">
|
|
{hasAnyActiveJobAgent(notificationSettings)
|
|
? 'Mindst én jobagent er aktiv.'
|
|
: 'Vælg stillingstyper og områder for at aktivere en jobagent.'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<p className="jobagent-summary-note">Indlæser jobagenter...</p>
|
|
)}
|
|
</article>
|
|
|
|
<section className="jobagent-layout-grid">
|
|
{showSettings ? (
|
|
<article className="glass-panel dash-card jobagent-settings-card">
|
|
<div className="jobagent-settings-top">
|
|
<div>
|
|
<span className="jobagent-kicker">Opsaetning</span>
|
|
<h4>Jobagent indstillinger</h4>
|
|
</div>
|
|
<div className="jobagent-settings-actions">
|
|
{selectedJobAgentId !== 'new' ? (
|
|
<button type="button" className="secondary-btn danger" onClick={() => void deleteCurrentSetting()} disabled={isSavingSetting}>
|
|
Slet
|
|
</button>
|
|
) : null}
|
|
<button type="button" className="primary-btn" onClick={() => void saveSetting()} disabled={isSavingSetting || !editingSetting.jobAgentName?.trim()}>
|
|
{isSavingSetting ? 'Gemmer...' : 'Gem'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="jobagent-settings-grid">
|
|
<label className="jobagent-field">
|
|
<span>Vælg jobagent</span>
|
|
<select
|
|
className="field-input"
|
|
value={selectedJobAgentId === 'new' ? 'new' : String(selectedJobAgentId)}
|
|
onChange={(event) => {
|
|
const value = event.target.value;
|
|
selectJobAgent(value === 'new' ? 'new' : Number(value));
|
|
}}
|
|
>
|
|
{notificationSettings.map((setting) => (
|
|
<option key={setting.id ?? Math.random()} value={String(setting.id)}>
|
|
{setting.jobAgentName?.trim() || 'Uden navn'}
|
|
</option>
|
|
))}
|
|
<option value="new">Opret ny jobagent</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label className="jobagent-field">
|
|
<span>Jobagent navn</span>
|
|
<input
|
|
className="field-input"
|
|
value={editingSetting.jobAgentName ?? ''}
|
|
onChange={(event) => setEditingSetting((prev) => ({ ...prev, jobAgentName: event.target.value }))}
|
|
placeholder="Fx. Min jobagent"
|
|
/>
|
|
</label>
|
|
|
|
<label className="jobagent-field">
|
|
<span>Søgetekst</span>
|
|
<input
|
|
className="field-input"
|
|
value={editingSetting.searchText ?? ''}
|
|
onChange={(event) => setEditingSetting((prev) => ({ ...prev, searchText: event.target.value }))}
|
|
placeholder="Fritekst til søgning"
|
|
/>
|
|
</label>
|
|
|
|
<div className="jobagent-field">
|
|
<span>Arbejdsområder</span>
|
|
<button type="button" className="secondary-btn" onClick={() => setShowWorkAreas((prev) => !prev)}>
|
|
{showWorkAreas ? 'Luk arbejdsområder' : 'Åbn arbejdsområder'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="jobagent-field">
|
|
<span>Arbejdstype</span>
|
|
<div className="jobagent-toggle-row">
|
|
<button
|
|
type="button"
|
|
className={editingSetting.workTypePermanent ? 'tab-btn active' : 'tab-btn'}
|
|
onClick={() => setEditingSetting((prev) => ({ ...prev, workTypePermanent: !prev.workTypePermanent }))}
|
|
>
|
|
Fast
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={editingSetting.workTypePartTime ? 'tab-btn active' : 'tab-btn'}
|
|
onClick={() => setEditingSetting((prev) => ({ ...prev, workTypePartTime: !prev.workTypePartTime }))}
|
|
>
|
|
Deltid
|
|
</button>
|
|
</div>
|
|
{editingSetting.workTypePartTime ? (
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
max={37}
|
|
className="field-input"
|
|
value={editingSetting.partTimeHours ?? ''}
|
|
onChange={(event) =>
|
|
setEditingSetting((prev) => ({
|
|
...prev,
|
|
partTimeHours: event.target.value ? Number(event.target.value) : null,
|
|
}))
|
|
}
|
|
placeholder="Timer pr. uge"
|
|
/>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="jobagent-field jobagent-location-field">
|
|
<span>Center for afstand</span>
|
|
<input
|
|
className="field-input"
|
|
value={editingSetting.distanceCenterName ?? ''}
|
|
onChange={(event) =>
|
|
setEditingSetting((prev) => ({
|
|
...prev,
|
|
distanceCenterName: event.target.value,
|
|
latitude: null,
|
|
longitude: null,
|
|
}))
|
|
}
|
|
placeholder="Søg adresse"
|
|
/>
|
|
{isSearchingPlaces ? <small>Søger adresser...</small> : null}
|
|
{placeSuggestions.length > 0 ? (
|
|
<div className="jobagent-place-suggestions glass-panel">
|
|
{placeSuggestions.map((suggestion) => (
|
|
<button
|
|
key={`${suggestion.place_id}-${suggestion.description}`}
|
|
type="button"
|
|
onClick={() => suggestion.place_id && void selectPlaceSuggestion(suggestion.place_id)}
|
|
>
|
|
{suggestion.description}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="jobagent-field jobagent-distance-field">
|
|
<span>Arbejdsafstand: {editingSetting.workDistance ?? 50} km</span>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={500}
|
|
value={editingSetting.workDistance ?? 50}
|
|
onChange={(event) => setEditingSetting((prev) => ({ ...prev, workDistance: Number(event.target.value) }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{showWorkAreas ? (
|
|
<section className="jobagent-workareas-card">
|
|
<div className="jobagent-workareas-top">
|
|
<h5>Arbejdsområder</h5>
|
|
<span>{selectedEscoCount} valgt</span>
|
|
</div>
|
|
<div className="jobagent-workareas">
|
|
<input
|
|
className="field-input"
|
|
value={searchOccupationWord}
|
|
onChange={(event) => setSearchOccupationWord(event.target.value)}
|
|
placeholder="Søg arbejdsområde"
|
|
/>
|
|
|
|
{searchOccupationWord.trim() && filteredOccupations.length > 0 ? (
|
|
<div className="jobagent-workareas-search-list">
|
|
{filteredOccupations.map((entry) => (
|
|
<label key={`${entry.areaCode}-${entry.subAreaCode}-${entry.occupation.id}`} className="jobagent-checkline">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(entry.occupation.activated)}
|
|
onChange={() => updateTreeByOccupation(entry.areaCode, entry.subAreaCode, entry.occupation.id)}
|
|
/>
|
|
<span>{entry.occupation.name}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="jobagent-workareas-tree">
|
|
{occupationTree.map((area) => (
|
|
<div key={area.areaCode} className="jobagent-workarea-node">
|
|
<div className="jobagent-workarea-row">
|
|
<button type="button" className="jobagent-expand-btn" onClick={() =>
|
|
setOccupationTree((prev) => prev.map((item) => item.areaCode === area.areaCode ? { ...item, expanded: !item.expanded } : item))
|
|
}>
|
|
{area.expanded ? '▾' : '▸'}
|
|
</button>
|
|
<label className="jobagent-checkline">
|
|
<input
|
|
type="checkbox"
|
|
checked={area.activated || area.someIsActive}
|
|
onChange={() => updateTreeByArea(area.areaCode, !(area.activated || area.someIsActive))}
|
|
/>
|
|
<span>{area.areaName}</span>
|
|
</label>
|
|
</div>
|
|
|
|
{area.expanded ? (
|
|
<div className="jobagent-workarea-children">
|
|
{area.subAreas.map((subArea: SubAreaInterface) => (
|
|
<div key={`${area.areaCode}-${subArea.subAreaCode}`} className="jobagent-workarea-node">
|
|
<div className="jobagent-workarea-row">
|
|
<button type="button" className="jobagent-expand-btn" onClick={() =>
|
|
setOccupationTree((prev) =>
|
|
prev.map((item) =>
|
|
item.areaCode === area.areaCode
|
|
? {
|
|
...item,
|
|
subAreas: item.subAreas.map((child) =>
|
|
child.subAreaCode === subArea.subAreaCode ? { ...child, expanded: !child.expanded } : child,
|
|
),
|
|
}
|
|
: item,
|
|
),
|
|
)
|
|
}>
|
|
{subArea.expanded ? '▾' : '▸'}
|
|
</button>
|
|
<label className="jobagent-checkline">
|
|
<input
|
|
type="checkbox"
|
|
checked={subArea.activated || subArea.someIsActive}
|
|
onChange={() => updateTreeBySubArea(area.areaCode, subArea.subAreaCode, !(subArea.activated || subArea.someIsActive))}
|
|
/>
|
|
<span>{subArea.subAreaName}</span>
|
|
</label>
|
|
</div>
|
|
|
|
{subArea.expanded ? (
|
|
<div className="jobagent-workarea-children">
|
|
{subArea.occupations.map((occupation) => (
|
|
<label key={occupation.id} className="jobagent-checkline">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(occupation.activated)}
|
|
onChange={() => updateTreeByOccupation(area.areaCode, subArea.subAreaCode, occupation.id)}
|
|
/>
|
|
<span>{occupation.name}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
</article>
|
|
) : null}
|
|
|
|
<article className="glass-panel dash-card jobagent-notifications-card">
|
|
<div className="jobagent-notifications-top">
|
|
<div>
|
|
<span className="jobagent-kicker">Live feed</span>
|
|
<h4>Notifikationer</h4>
|
|
</div>
|
|
<span className="jobagent-notification-count">{notifications.length}</span>
|
|
</div>
|
|
{isLoadingNotifications ? <p>Indlæser notifikationer...</p> : null}
|
|
|
|
{!isLoadingNotifications && notifications.length === 0 ? (
|
|
<p>Ingen notifikationer endnu.</p>
|
|
) : null}
|
|
|
|
<div className="jobagent-notification-list">
|
|
<div className="jobs-results-grid jobagent-results-grid">
|
|
{notifications.map((notification) => (
|
|
<article
|
|
key={notification.id}
|
|
className={notification.seenByUser ? 'glass-panel jobs-result-card jobagent-result-card' : 'glass-panel jobs-result-card jobagent-result-card unseen'}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => void openNotificationJob(notification)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
void openNotificationJob(notification);
|
|
}
|
|
}}
|
|
>
|
|
<div className="jobs-result-top">
|
|
<div className="jobs-result-brand">
|
|
{notification.logoUrl ? (
|
|
<img className="jobs-result-logo-img" src={notification.logoUrl} alt={notification.companyName || 'Virksomhed'} />
|
|
) : (
|
|
<div className="jobs-result-logo">{companyInitial(notification.companyName)}</div>
|
|
)}
|
|
<div>
|
|
<p className="jobs-result-company">{notification.companyName || 'Ukendt virksomhed'}</p>
|
|
<p className="jobs-result-address">
|
|
{notification.city ? `${notification.city} ${notification.zip || ''}` : 'Ukendt lokation'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={notification.saved ? 'jobs-bookmark-icon active' : 'jobs-bookmark-icon'}
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
onClickCapture={(event) => event.stopPropagation()}
|
|
onClick={() => void toggleNotificationBookmark(notification)}
|
|
aria-label={notification.saved ? 'Fjern gemt job' : 'Gem job'}
|
|
title={notification.saved ? 'Fjern gemt' : 'Gem job'}
|
|
>
|
|
{notification.saved ? '★' : '☆'}
|
|
</button>
|
|
</div>
|
|
|
|
<h5 className="jobs-result-title">{notification.jobTitle || 'Jobagent match'}</h5>
|
|
<p className="jobs-result-occupation">{notification.escoTitle || 'AI JobAgent forslag'}</p>
|
|
<p className="jobs-result-description">
|
|
{notification.seenByUser ? 'Match fra din jobagent baseret på dine valgte filtre.' : 'Nyt match fra din jobagent.'}
|
|
</p>
|
|
|
|
<div className="jobs-result-tags">
|
|
<span className="chip">{formatDate(notification.notificationDate)}</span>
|
|
{notification.distance ? <span className="chip">{Number(notification.distance).toFixed(1)} km</span> : null}
|
|
<span className="chip">{notification.seenByUser ? 'Set' : 'Ny'}</span>
|
|
</div>
|
|
|
|
<div className="jobs-result-footer">
|
|
<button
|
|
type="button"
|
|
className="primary-btn jobs-card-primary-btn"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
void openNotificationJob(notification);
|
|
}}
|
|
>
|
|
Åbn job
|
|
</button>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{hasMoreNotifications ? (
|
|
<button type="button" className="secondary-btn jobagent-load-more" onClick={() => void loadMoreNotifications()} disabled={isLoadingMoreNotifications}>
|
|
{isLoadingMoreNotifications ? 'Indlæser...' : 'Indlæs flere'}
|
|
</button>
|
|
) : null}
|
|
</article>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
</section>
|
|
);
|
|
}
|