Initial React project
This commit is contained in:
909
src/presentation/ai-jobagent/pages/AiJobAgentPage.tsx
Normal file
909
src/presentation/ai-jobagent/pages/AiJobAgentPage.tsx
Normal file
@@ -0,0 +1,909 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user