Initial React project

This commit is contained in:
Johan
2026-02-14 10:46:50 +01:00
commit 6c1f178ba9
5884 changed files with 1701440 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
import { useCallback, useMemo, useState } from 'react';
import type { CvSuggestionInterface } from '../../../mvvm/models/cv-suggestion.interface';
import type { EscoInterface } from '../../../mvvm/models/esco.interface';
import type { JobAgentFilterInterface } from '../../../mvvm/models/job-agent-filter.interface';
import type { PaymentOverview } from '../../../mvvm/models/payment-overview.interface';
import { AiAgentViewModel } from '../../../mvvm/viewmodels/AiAgentViewModel';
interface AiAgentState {
paymentOverview: PaymentOverview | null;
jobAgentFilters: JobAgentFilterInterface[];
cvSuggestions: CvSuggestionInterface[];
escos: EscoInterface[];
}
const INITIAL_STATE: AiAgentState = {
paymentOverview: null,
jobAgentFilters: [],
cvSuggestions: [],
escos: [],
};
export function useAiAgentViewModel() {
const viewModel = useMemo(() => new AiAgentViewModel(), []);
const [data, setData] = useState<AiAgentState>(INITIAL_STATE);
const [isLoading, setIsLoading] = useState(false);
const [isMutating, setIsMutating] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const next = await viewModel.loadInitialData();
setData(next);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Could not load AI Agent data.');
} finally {
setIsLoading(false);
}
}, [viewModel]);
const addEscoToFilter = useCallback(
async (escoId: number) => {
setIsMutating(true);
setError(null);
try {
await viewModel.addEscoToFilter(escoId);
const next = await viewModel.loadInitialData();
setData(next);
} catch (mutationError) {
setError(mutationError instanceof Error ? mutationError.message : 'Could not add AI filter.');
} finally {
setIsMutating(false);
}
},
[viewModel],
);
const removeFilter = useCallback(
async (filterId: number) => {
setIsMutating(true);
setError(null);
try {
await viewModel.removeFilter(filterId);
const next = await viewModel.loadInitialData();
setData(next);
} catch (mutationError) {
setError(mutationError instanceof Error ? mutationError.message : 'Could not remove AI filter.');
} finally {
setIsMutating(false);
}
},
[viewModel],
);
const setFilterVisibility = useCallback(
async (filter: JobAgentFilterInterface, visible: boolean) => {
setIsMutating(true);
setError(null);
try {
await viewModel.setFilterVisibility(filter, visible);
setData((prev) => ({
...prev,
jobAgentFilters: prev.jobAgentFilters.map((existing) =>
existing.id === filter.id ? { ...existing, visible } : existing,
),
}));
} catch (mutationError) {
setError(mutationError instanceof Error ? mutationError.message : 'Could not update AI filter visibility.');
} finally {
setIsMutating(false);
}
},
[viewModel],
);
return {
...data,
isLoading,
isMutating,
error,
load,
addEscoToFilter,
removeFilter,
setFilterVisibility,
getEscoSuggestions: (query: string) => viewModel.getEscoSuggestions(query, data.escos, data.jobAgentFilters),
getSuggestionText: (value: number) => viewModel.getSuggestionText(value),
};
}

View File

@@ -0,0 +1,265 @@
import { useEffect, useMemo, useState } from 'react';
import type { ImprovementInterface } from '../../../mvvm/models/cv-suggestion.interface';
import { Sidebar } from '../../layout/components/Sidebar';
import { Topbar } from '../../layout/components/Topbar';
import { useAiAgentViewModel } from '../hooks/useAiAgentViewModel';
interface AiAgentPageProps {
onLogout: () => Promise<void>;
onNavigate: (key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') => void;
activeNavKey?: 'ai-jobagent' | 'ai-agent';
}
type ImprovementType = 'education' | 'language' | 'driversLicense' | 'qualification' | 'certificate';
function iconForImprovement(type: ImprovementType): string {
if (type === 'qualification') {
return '★';
}
if (type === 'driversLicense') {
return '↗';
}
if (type === 'certificate') {
return '✓';
}
if (type === 'education') {
return '▦';
}
return '◉';
}
function classForImprovement(type: ImprovementType): string {
if (type === 'qualification') {
return 'ai-notification-card qualification';
}
if (type === 'driversLicense') {
return 'ai-notification-card drivers';
}
if (type === 'certificate') {
return 'ai-notification-card certificate';
}
if (type === 'education') {
return 'ai-notification-card education';
}
return 'ai-notification-card language';
}
function withImprovementType(value: ImprovementInterface): value is ImprovementInterface & { improvementType: ImprovementType } {
return typeof (value as { improvementType?: string }).improvementType === 'string';
}
export function AiAgentPage({ onLogout, onNavigate, activeNavKey = 'ai-agent' }: AiAgentPageProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('arbejd.sidebar.collapsed') === '1');
const [showAddFilter, setShowAddFilter] = useState(false);
const [searchText, setSearchText] = useState('');
const [selectedSuggestionId, setSelectedSuggestionId] = useState<number | null>(null);
const [showAllNotifications, setShowAllNotifications] = useState(false);
const [expandedNotificationKey, setExpandedNotificationKey] = useState<string | null>(null);
const {
paymentOverview,
jobAgentFilters,
cvSuggestions,
isLoading,
isMutating,
error,
load,
addEscoToFilter,
getEscoSuggestions,
getSuggestionText,
} = useAiAgentViewModel();
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!selectedSuggestionId && cvSuggestions.length > 0) {
setSelectedSuggestionId(cvSuggestions[0].escoId);
return;
}
if (selectedSuggestionId && !cvSuggestions.some((entry) => entry.escoId === selectedSuggestionId)) {
setSelectedSuggestionId(cvSuggestions[0]?.escoId ?? null);
}
}, [selectedSuggestionId, cvSuggestions]);
const suggestions = useMemo(() => getEscoSuggestions(searchText), [getEscoSuggestions, searchText]);
const selectedSuggestion = useMemo(
() => cvSuggestions.find((entry) => entry.escoId === selectedSuggestionId) ?? cvSuggestions[0] ?? null,
[cvSuggestions, selectedSuggestionId],
);
const improvements = useMemo(() => {
const list = selectedSuggestion?.improvements ?? [];
const typed = list.filter(withImprovementType);
return showAllNotifications ? typed : typed.slice(0, 8);
}, [selectedSuggestion, showAllNotifications]);
const hasMoreNotifications = (selectedSuggestion?.improvements?.length ?? 0) > 8;
const careerAgentEnabled = Boolean(paymentOverview?.careerAgent);
async function handleAddFilter(escoId: number) {
await addEscoToFilter(escoId);
setSearchText('');
}
return (
<section className="dashboard-layout">
<Sidebar
collapsed={sidebarCollapsed}
activeKey={activeNavKey}
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 Agent" userName="Anders Jensen" planLabel="Jobseeker Pro" onLogout={onLogout} />
<div className="dashboard-scroll">
<article className="glass-panel dash-card ai-agent-hero">
<h3>Din AI Agent</h3>
<p>
Din AI Agent analyserer dit CV og giver dig anbefalinger til, hvordan du kan forbedre
dit CV og styrke dine jobmuligheder.
</p>
</article>
{error ? <p className="status error">{error}</p> : null}
<article className="glass-panel dash-card ai-notification-section">
<div className="ai-notification-head">
<h4>Karriereagent</h4>
<strong className="ai-notification-kicker">DIN KARRIEREAGENT FORESLÅR</strong>
<p>Boost din profil ved hjælp af kunstig intelligens. Forslagene er udvalgt til din profil, ud fra 100.000+ jobopslag</p>
</div>
<div className="ai-inline-controls">
<button
type="button"
className="primary-btn ai-add-filter-btn"
onClick={() => setShowAddFilter((prev) => !prev)}
disabled={isMutating}
>
{showAddFilter ? 'Luk filter' : '+ Tilføj filter'}
</button>
</div>
{showAddFilter ? (
<div className="ai-filter-search-wrap">
<input
className="field-input ai-filter-search"
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
placeholder="Søg stilling (ESCO)..."
/>
{searchText.trim().length > 0 && suggestions.length > 0 ? (
<div className="ai-filter-suggestions glass-panel">
{suggestions.map((esco) => (
<button
key={esco.id}
type="button"
className="ai-filter-suggestion-item"
onClick={() => void handleAddFilter(esco.id)}
disabled={isMutating}
>
{esco.preferedLabelDa}
</button>
))}
</div>
) : null}
</div>
) : null}
{isLoading ? <p>Indlæser AI filtre...</p> : null}
{!isLoading && jobAgentFilters.length === 0 ? (
<p className="helper-text">Ingen aktive AI filtre endnu. Tilføj en stilling for at starte.</p>
) : null}
{!careerAgentEnabled ? (
<p className="helper-text">Denne funktion kræver et aktivt abonnement med Karriereagent.</p>
) : null}
{careerAgentEnabled && cvSuggestions.length > 0 ? (
<div className="ai-notification-source-tabs">
{cvSuggestions.map((suggestion) => (
<button
key={suggestion.escoId}
type="button"
className={selectedSuggestion?.escoId === suggestion.escoId ? 'tab-btn active' : 'tab-btn'}
onClick={() => {
setSelectedSuggestionId(suggestion.escoId);
setShowAllNotifications(false);
}}
>
{suggestion.escoName}
</button>
))}
</div>
) : null}
{careerAgentEnabled && cvSuggestions.length === 0 && !isLoading ? (
<p className="helper-text">Systemet beregner stadig dine AI filtre. Kom tilbage om lidt.</p>
) : null}
<div className="ai-notification-grid">
{improvements.map((improvement) => {
const notificationKey = `${improvement.escoId}-${improvement.improvementType}-${improvement.name}`;
const expanded = expandedNotificationKey === notificationKey;
return (
<article
key={notificationKey}
className={expanded ? `${classForImprovement(improvement.improvementType)} expanded` : classForImprovement(improvement.improvementType)}
role="button"
tabIndex={0}
onClick={() =>
setExpandedNotificationKey((current) => (current === notificationKey ? null : notificationKey))
}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setExpandedNotificationKey((current) => (current === notificationKey ? null : notificationKey));
}
}}
>
<div className="ai-notification-icon" aria-hidden>
{iconForImprovement(improvement.improvementType)}
</div>
<div>
<strong>{improvement.name}</strong>
<p>{getSuggestionText(improvement.jobChanceIncrease)}</p>
<span>+{improvement.jobChanceIncrease}% relevans</span>
<div className={expanded ? 'ai-notification-extra-wrap expanded' : 'ai-notification-extra-wrap'}>
<div className="ai-notification-extra">
<p>{improvement.description || 'Ingen ekstra beskrivelse tilgængelig endnu.'}</p>
{typeof improvement.estimatedDurationMonths === 'number' ? (
<small>Estimeret varighed: {improvement.estimatedDurationMonths} måneder</small>
) : null}
</div>
</div>
</div>
</article>
);
})}
</div>
{hasMoreNotifications ? (
<button
type="button"
className="secondary-btn ai-show-more-btn"
onClick={() => setShowAllNotifications((prev) => !prev)}
>
{showAllNotifications ? 'Vis færre' : 'Vis flere'}
</button>
) : null}
</article>
</div>
</main>
</section>
);
}

View 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 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>
);
}

View File

@@ -0,0 +1,47 @@
import { useMemo, useState } from 'react';
import { AuthViewModel, type AuthActionResult, type RegisterInput } from '../../../mvvm/viewmodels/AuthViewModel';
export function useAuthViewModel() {
const viewModel = useMemo(() => new AuthViewModel(), []);
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<AuthActionResult | null>(null);
async function runAction(action: () => Promise<AuthActionResult>) {
setIsLoading(true);
setResult(null);
try {
const next = await action();
setResult(next);
return next;
} catch (error) {
const failed: AuthActionResult = {
ok: false,
message: error instanceof Error ? error.message : 'Noget gik galt.',
};
setResult(failed);
return failed;
} finally {
setIsLoading(false);
}
}
function login(email: string, password: string, rememberMe: boolean) {
return runAction(() => viewModel.login(email, password, rememberMe));
}
function register(input: RegisterInput) {
return runAction(() => viewModel.register(input));
}
function forgotPassword(email: string) {
return runAction(() => viewModel.forgotPassword(email));
}
return {
isLoading,
result,
login,
register,
forgotPassword,
};
}

View File

@@ -0,0 +1,37 @@
import { useState } from 'react';
interface ForgotPasswordPageProps {
isLoading: boolean;
onSubmit: (email: string) => Promise<void>;
}
export function ForgotPasswordPage({ isLoading, onSubmit }: ForgotPasswordPageProps) {
const [email, setEmail] = useState('');
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
await onSubmit(email);
}
return (
<form className="auth-form" onSubmit={handleSubmit}>
<p className="helper-text">
Indtast din email, sender vi en anmodning om nulstilling af kodeord.
</p>
<label className="field-label" htmlFor="forgot-email">Email</label>
<input
id="forgot-email"
className="field-input"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="you@arbejd.com"
required
/>
<button className="primary-btn" type="submit" disabled={isLoading}>
{isLoading ? 'Sender...' : 'Send anmodning'}
</button>
</form>
);
}

View File

@@ -0,0 +1,56 @@
import { useState } from 'react';
interface LoginPageProps {
isLoading: boolean;
onSubmit: (email: string, password: string, rememberMe: boolean) => Promise<void>;
}
export function LoginPage({ isLoading, onSubmit }: LoginPageProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(true);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
await onSubmit(email, password, rememberMe);
}
return (
<form className="auth-form" onSubmit={handleSubmit}>
<label className="field-label" htmlFor="login-email">Email</label>
<input
id="login-email"
className="field-input"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="you@arbejd.com"
required
/>
<label className="field-label" htmlFor="login-password">Adgangskode</label>
<input
id="login-password"
className="field-input"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="••••••••"
required
/>
<label className="check-row">
<input
type="checkbox"
checked={rememberMe}
onChange={(event) => setRememberMe(event.target.checked)}
/>
<span>Husk mig</span>
</label>
<button className="primary-btn" type="submit" disabled={isLoading}>
{isLoading ? 'Logger ind...' : 'Log ind'}
</button>
</form>
);
}

View File

@@ -0,0 +1,111 @@
import { useState } from 'react';
import type { RegisterInput } from '../../../mvvm/viewmodels/AuthViewModel';
interface RegisterPageProps {
isLoading: boolean;
onSubmit: (payload: RegisterInput) => Promise<void>;
}
export function RegisterPage({ isLoading, onSubmit }: RegisterPageProps) {
const [form, setForm] = useState<RegisterInput>({
firstName: '',
lastName: '',
email: '',
password: '',
zip: '',
zipName: '',
subscribe: true,
});
function update<K extends keyof RegisterInput>(key: K, value: RegisterInput[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
await onSubmit(form);
}
return (
<form className="auth-form" onSubmit={handleSubmit}>
<div className="field-grid">
<div>
<label className="field-label" htmlFor="register-first-name">Fornavn</label>
<input
id="register-first-name"
className="field-input"
value={form.firstName}
onChange={(event) => update('firstName', event.target.value)}
required
/>
</div>
<div>
<label className="field-label" htmlFor="register-last-name">Efternavn</label>
<input
id="register-last-name"
className="field-input"
value={form.lastName}
onChange={(event) => update('lastName', event.target.value)}
required
/>
</div>
</div>
<label className="field-label" htmlFor="register-email">Email</label>
<input
id="register-email"
className="field-input"
type="email"
value={form.email}
onChange={(event) => update('email', event.target.value)}
required
/>
<label className="field-label" htmlFor="register-password">Adgangskode</label>
<input
id="register-password"
className="field-input"
type="password"
value={form.password}
onChange={(event) => update('password', event.target.value)}
required
/>
<div className="field-grid">
<div>
<label className="field-label" htmlFor="register-zip">Postnummer</label>
<input
id="register-zip"
className="field-input"
value={form.zip}
onChange={(event) => update('zip', event.target.value)}
required
/>
</div>
<div>
<label className="field-label" htmlFor="register-zip-name">By</label>
<input
id="register-zip-name"
className="field-input"
value={form.zipName}
onChange={(event) => update('zipName', event.target.value)}
required
/>
</div>
</div>
<label className="check-row">
<input
type="checkbox"
checked={form.subscribe}
onChange={(event) => update('subscribe', event.target.checked)}
/>
<span>Modtag opdateringer</span>
</label>
<button className="primary-btn" type="submit" disabled={isLoading}>
{isLoading ? 'Opretter konto...' : 'Opret konto'}
</button>
</form>
);
}

View File

@@ -0,0 +1,459 @@
import { useCallback, useMemo, useState } from 'react';
import type { PlainLanguageInterface } from '../../../mvvm/models/all-language.interface';
import type { AiGeneratedCVDescription } from '../../../mvvm/models/ai-generated-cv-description.interface';
import type { CandidateInterface, CertificationInterface, DriversLicenseInterface, EducationInterface, ExperienceInterface, LanguageInterface, SkillInterface } from '../../../mvvm/models/candidate.interface';
import type { CvUploadDataInterface } from '../../../mvvm/models/cv-upload-data.interface';
import type { DriverLicenseTypeInterface } from '../../../mvvm/models/driver-license-type.interface';
import type { EducationSearchInterface } from '../../../mvvm/models/education-search.interface';
import type { EscoInterface } from '../../../mvvm/models/esco.interface';
import type { PaymentOverview } from '../../../mvvm/models/payment-overview.interface';
import type { QualificationSearchInterface } from '../../../mvvm/models/qualification-search.interface';
import type { SchoolInterface } from '../../../mvvm/models/school.interface';
import type { SearchedCertificationInterface } from '../../../mvvm/models/searched-certification.interface';
import { CvPageViewModel } from '../../../mvvm/viewmodels/CvPageViewModel';
type ActionKey =
| 'generate'
| 'download'
| 'upload'
| 'optimize'
| 'toggle-active'
| 'update-profile'
| 'remove-entry'
| 'update-entry'
| 'create-entry';
export function useCvPageViewModel() {
const viewModel = useMemo(() => new CvPageViewModel(), []);
const [candidate, setCandidate] = useState<CandidateInterface | null>(null);
const [experiences, setExperiences] = useState<ExperienceInterface[]>([]);
const [educations, setEducations] = useState<EducationInterface[]>([]);
const [skills, setSkills] = useState<SkillInterface[]>([]);
const [certifications, setCertifications] = useState<CertificationInterface[]>([]);
const [languages, setLanguages] = useState<LanguageInterface[]>([]);
const [driverLicenses, setDriverLicenses] = useState<DriversLicenseInterface[]>([]);
const [paymentOverview, setPaymentOverview] = useState<PaymentOverview | null>(null);
const [cvUploadData, setCvUploadData] = useState<CvUploadDataInterface | null>(null);
const [aiGeneratedCVDescription, setAiGeneratedCVDescription] = useState<AiGeneratedCVDescription | null>(null);
const [languageOptions, setLanguageOptions] = useState<PlainLanguageInterface[]>([]);
const [driverLicenseOptions, setDriverLicenseOptions] = useState<DriverLicenseTypeInterface[]>([]);
const [escoSuggestions, setEscoSuggestions] = useState<EscoInterface[]>([]);
const [qualificationSuggestions, setQualificationSuggestions] = useState<QualificationSearchInterface[]>([]);
const [educationSuggestions, setEducationSuggestions] = useState<EducationSearchInterface[]>([]);
const [schoolSuggestions, setSchoolSuggestions] = useState<SchoolInterface[]>([]);
const [certificationSuggestions, setCertificationSuggestions] = useState<SearchedCertificationInterface[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [actionLoading, setActionLoading] = useState<Record<ActionKey, boolean>>({
generate: false,
download: false,
upload: false,
optimize: false,
'toggle-active': false,
'update-profile': false,
'remove-entry': false,
'update-entry': false,
'create-entry': false,
});
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const load = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const snapshot = await viewModel.getSnapshot();
setCandidate(snapshot.candidate);
setExperiences(snapshot.experiences);
setEducations(snapshot.educations);
setSkills(snapshot.skills);
setCertifications(snapshot.certifications);
setLanguages(snapshot.languages);
setDriverLicenses(snapshot.driverLicenses);
setPaymentOverview(snapshot.paymentOverview);
setCvUploadData(snapshot.cvUploadData);
setAiGeneratedCVDescription(snapshot.aiGeneratedCVDescription);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Could not load CV data.');
} finally {
setIsLoading(false);
}
}, [viewModel]);
async function withAction<T>(key: ActionKey, fn: () => Promise<T>): Promise<T> {
setActionLoading((prev) => ({ ...prev, [key]: true }));
setError(null);
setInfo(null);
try {
return await fn();
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : 'An action failed.');
throw actionError;
} finally {
setActionLoading((prev) => ({ ...prev, [key]: false }));
}
}
const setActiveSeeker = useCallback(
async (isActive: boolean, language: string) => {
if (!candidate) {
return;
}
await withAction('toggle-active', async () => {
const updated = await viewModel.setActiveSeeker(candidate, isActive, language);
setCandidate(updated);
});
},
[candidate, viewModel],
);
const generateCv = useCallback(
async (language: string) => {
await withAction('generate', async () => {
await viewModel.generateCv(language);
setInfo('CV-generering er startet.');
await load();
});
},
[load, viewModel],
);
const downloadCv = useCallback(
async (language: string) => {
await withAction('download', async () => {
const url = await viewModel.getCvDownloadUrl(language);
window.open(url, '_blank', 'noopener,noreferrer');
});
},
[viewModel],
);
const uploadCv = useCallback(
async (base64File: string, fileType: 'pdf' | 'docx') => {
await withAction('upload', async () => {
await viewModel.uploadCv(base64File, fileType);
setInfo('CV er uploadet og behandles nu.');
await load();
});
},
[load, viewModel],
);
const optimizeCv = useCallback(
async (language: string) => {
await withAction('optimize', async () => {
await viewModel.optimizeCv(language);
setInfo('CV-optimering er sat i gang.');
await load();
});
},
[load, viewModel],
);
const updateCandidate = useCallback(
async (nextCandidate: CandidateInterface, language: string) => {
await withAction('update-profile', async () => {
const updated = await viewModel.updateCandidate(nextCandidate, language);
setCandidate(updated);
setInfo('Profiloplysninger er opdateret.');
});
},
[viewModel],
);
const updateExperience = useCallback(
async (experience: ExperienceInterface, language: string) => {
await withAction('update-entry', async () => {
await viewModel.updateExperience(experience, language);
await load();
});
},
[load, viewModel],
);
const updateEducation = useCallback(
async (education: EducationInterface, language: string) => {
await withAction('update-entry', async () => {
await viewModel.updateEducation(education, language);
await load();
});
},
[load, viewModel],
);
const updateCertification = useCallback(
async (certification: CertificationInterface) => {
await withAction('update-entry', async () => {
await viewModel.updateCertification(certification);
await load();
});
},
[load, viewModel],
);
const updateLanguage = useCallback(
async (languageItem: LanguageInterface) => {
await withAction('update-entry', async () => {
await viewModel.updateLanguage(languageItem);
await load();
});
},
[load, viewModel],
);
const removeExperience = useCallback(
async (experienceId: string) => {
await withAction('remove-entry', async () => {
await viewModel.removeExperience(experienceId);
await load();
});
},
[load, viewModel],
);
const removeEducation = useCallback(
async (educationId: string) => {
await withAction('remove-entry', async () => {
await viewModel.removeEducation(educationId);
await load();
});
},
[load, viewModel],
);
const removeQualification = useCallback(
async (skillId: string) => {
await withAction('remove-entry', async () => {
await viewModel.removeQualification(skillId);
await load();
});
},
[load, viewModel],
);
const removeCertification = useCallback(
async (certificationId: string) => {
await withAction('remove-entry', async () => {
await viewModel.removeCertification(certificationId);
await load();
});
},
[load, viewModel],
);
const removeLanguage = useCallback(
async (languageId: string) => {
await withAction('remove-entry', async () => {
await viewModel.removeLanguage(languageId);
await load();
});
},
[load, viewModel],
);
const removeDriverLicense = useCallback(
async (driverLicenseId: string) => {
await withAction('remove-entry', async () => {
await viewModel.removeDriverLicense(driverLicenseId);
await load();
});
},
[load, viewModel],
);
const searchEscoSuggestions = useCallback(
async (query: string) => {
try {
const list = await viewModel.getEscoSuggestions(query);
setEscoSuggestions(list);
} catch {
setEscoSuggestions([]);
}
},
[viewModel],
);
const searchQualificationSuggestions = useCallback(
async (query: string) => {
try {
const list = await viewModel.getQualificationSuggestions(query);
setQualificationSuggestions(list);
} catch {
setQualificationSuggestions([]);
}
},
[viewModel],
);
const searchEducationSuggestions = useCallback(
async (query: string) => {
try {
const list = await viewModel.getEducationSuggestions(query);
setEducationSuggestions(list);
} catch {
setEducationSuggestions([]);
}
},
[viewModel],
);
const searchSchoolSuggestions = useCallback(
async (query: string) => {
try {
const list = await viewModel.getSchoolSuggestions(query);
setSchoolSuggestions(list);
} catch {
setSchoolSuggestions([]);
}
},
[viewModel],
);
const searchCertificationSuggestions = useCallback(
async (query: string) => {
try {
const list = await viewModel.getCertificationSuggestions(query);
setCertificationSuggestions(list);
} catch {
setCertificationSuggestions([]);
}
},
[viewModel],
);
const loadCreateOptions = useCallback(async () => {
try {
const [languagesData, driverLicensesData] = await Promise.all([
viewModel.getLanguageOptions(),
viewModel.getDriverLicenseOptions(),
]);
setLanguageOptions(languagesData);
setDriverLicenseOptions(driverLicensesData);
} catch {
setLanguageOptions([]);
setDriverLicenseOptions([]);
}
}, [viewModel]);
const createExperience = useCallback(
async (payload: { companyName: string; comments: string; fromDate: Date | null; toDate: Date | null; isCurrent: boolean; escoId?: number | null; occupationName?: string }, language: string) => {
await withAction('create-entry', async () => {
await viewModel.createExperience(payload, language);
setInfo('Erfaring er tilføjet.');
await load();
});
},
[load, viewModel],
);
const createEducation = useCallback(
async (payload: { comments: string; fromDate: Date | null; toDate: Date | null; isCurrent: boolean; educationName?: string; educationDisced15?: number | null; institutionName?: string; institutionNumber: number | null }, language: string) => {
await withAction('create-entry', async () => {
await viewModel.createEducation(payload, language);
setInfo('Uddannelse er tilføjet.');
await load();
});
},
[load, viewModel],
);
const createCertification = useCallback(
async (payload: { certificateId?: string | null; certificateName?: string }) => {
await withAction('create-entry', async () => {
await viewModel.createCertification(payload);
setInfo('Certifikat er tilføjet.');
await load();
});
},
[load, viewModel],
);
const createLanguage = useCallback(
async (languageId: string, level: number) => {
await withAction('create-entry', async () => {
await viewModel.createLanguage(languageId, level);
setInfo('Sprog er tilføjet.');
await load();
});
},
[load, viewModel],
);
const createQualification = useCallback(
async (payload: { qualificationId?: string; qualificationName?: string; level: number }) => {
await withAction('create-entry', async () => {
await viewModel.createQualification(payload);
setInfo('Kvalifikation er tilføjet.');
await load();
});
},
[load, viewModel],
);
const createDriverLicense = useCallback(
async (driversLicenseId: string, level: number) => {
await withAction('create-entry', async () => {
await viewModel.createDriverLicense(driversLicenseId, level);
setInfo('Kørekort er tilføjet.');
await load();
});
},
[load, viewModel],
);
return {
candidate,
experiences,
educations,
skills,
certifications,
languages,
driverLicenses,
paymentOverview,
cvUploadData,
aiGeneratedCVDescription,
languageOptions,
driverLicenseOptions,
escoSuggestions,
qualificationSuggestions,
educationSuggestions,
schoolSuggestions,
certificationSuggestions,
isLoading,
actionLoading,
error,
info,
load,
setActiveSeeker,
updateCandidate,
updateExperience,
updateEducation,
updateCertification,
updateLanguage,
generateCv,
downloadCv,
uploadCv,
optimizeCv,
removeExperience,
removeEducation,
removeQualification,
removeCertification,
removeLanguage,
removeDriverLicense,
searchEscoSuggestions,
searchQualificationSuggestions,
searchEducationSuggestions,
searchSchoolSuggestions,
searchCertificationSuggestions,
loadCreateOptions,
createExperience,
createEducation,
createCertification,
createLanguage,
createQualification,
createDriverLicense,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
import { useCallback, useMemo, useState } from 'react';
import { DashboardViewModel, type DashboardInitialData } from '../../../mvvm/viewmodels/DashboardViewModel';
const INITIAL_DATA: DashboardInitialData = {
candidate: null,
notifications: [],
messages: [],
bestJobs: [],
subscription: null,
evaluations: [],
};
export function useDashboardViewModel() {
const viewModel = useMemo(() => new DashboardViewModel(), []);
const [data, setData] = useState<DashboardInitialData>(INITIAL_DATA);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const next = await viewModel.loadInitialData();
setData(next);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Could not load dashboard data.');
} finally {
setIsLoading(false);
}
}, [viewModel]);
return {
...data,
isLoading,
error,
load,
};
}

View File

@@ -0,0 +1,251 @@
import { useEffect, useMemo, useState } from 'react';
import { Sidebar } from '../../layout/components/Sidebar';
import { Topbar } from '../../layout/components/Topbar';
import { useDashboardViewModel } from '../hooks/useDashboardViewModel';
interface DashboardPageProps {
onLogout: () => Promise<void>;
onNavigate: (key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') => void;
onOpenJob: (jobId: string, fromJobnet: boolean) => void;
}
function formatDate(value?: string | Date | null): string {
if (!value) {
return 'Ingen dato';
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return 'Ingen dato';
}
return date.toLocaleDateString('da-DK', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
function companyInitial(value: string): string {
return value.trim().slice(0, 1).toUpperCase() || 'A';
}
function getRecommendationText(value: string | null): string {
const labelMap: Record<string, string> = {
proceed_to_second_interview: 'Anbefalet til 2. samtale',
consider: 'Har potentiale',
reject: 'Afvist',
needs_followup: 'Kræver opfølgning',
hire: 'Stærk kandidat',
};
if (!value) {
return 'Afventer evaluering';
}
return labelMap[value] ?? value;
}
const DAILY_TIPS = [
'Tilpas de første 3 linjer i dit CV til stillingsopslaget for højere svarrate.',
'Skriv en kort motivation med konkrete resultater fra dit seneste job.',
'Gem interessante jobs med det samme, så du kan sammenligne dem senere.',
'Hold din profil opdateret med nye certificeringer og erfaringer.',
'Brug 10 minutter dagligt på at svare hurtigt på nye beskeder.',
'Prioriter jobs med tydelig rollebeskrivelse og realistisk afstand.',
'Gennemgå dine seneste evalueringer og anvend ét konkret forbedringspunkt.',
];
export function DashboardPage({ onLogout, onNavigate, onOpenJob }: DashboardPageProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('arbejd.sidebar.collapsed') === '1');
const { candidate, notifications, messages, bestJobs, subscription, evaluations, isLoading, error, load } = useDashboardViewModel();
const candidateName = candidate?.firstName?.trim() || candidate?.name?.trim() || 'Anders';
const newJobsCount = notifications.length > 0 ? notifications.length : 12;
const dailyTip = useMemo(() => {
const dayIndex = Math.floor(Date.now() / (1000 * 60 * 60 * 24));
return DAILY_TIPS[dayIndex % DAILY_TIPS.length];
}, []);
useEffect(() => {
void load();
}, [load]);
return (
<section className="dashboard-layout">
<Sidebar
collapsed={sidebarCollapsed}
activeKey="dashboard"
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="Oversigt"
userName={candidateName}
planLabel={subscription?.productTypeName || 'Jobseeker Pro'}
onLogout={onLogout}
/>
<div className="dashboard-scroll">
{error ? <p className="status error">{error}</p> : null}
<section className="dashboard-hero-grid">
<article className="glass-panel dash-card hero dashboard-main-hero">
<h3>Velkommen tilbage, {candidateName}</h3>
<p>
Din AI Agent har arbejdet i baggrunden. Vi har fundet
<strong> {newJobsCount} nye jobs</strong>, der matcher din profil, og optimeret dit CV.
</p>
<div className="dashboard-hero-status">
<div className="dashboard-hero-progress">
<span style={{ width: `${Math.min(100, Math.max(24, newJobsCount * 8))}%` }} />
</div>
<small>AI Agent: Aktiv</small>
</div>
<p className="dashboard-tip">Dagens tip: {dailyTip}</p>
<button className="glass-button hero-cta" type="button" onClick={() => onNavigate('jobs')}>
til jobs
</button>
</article>
<article className="glass-panel dash-card dashboard-quick-actions-card">
<h4>Quick Actions</h4>
<div className="dashboard-quick-actions-grid">
<button type="button" className="primary-btn dashboard-quick-btn" onClick={() => onNavigate('simulator')}>
Start Interview Simulator
</button>
<button type="button" className="primary-btn dashboard-quick-btn" onClick={() => onNavigate('beskeder')}>
Send a message
</button>
<button type="button" className="primary-btn dashboard-quick-btn" onClick={() => onNavigate('ai-agent')}>
Set AI Agent
</button>
<button type="button" className="primary-btn dashboard-quick-btn" onClick={() => onNavigate('abonnement')}>
Check Abonnement
</button>
</div>
</article>
</section>
<div className="dashboard-overview-grid">
<article className="glass-panel dash-card dashboard-feed-card">
<div className="dash-header">
<h4>5 nyeste notifikationer</h4>
<span className="dashboard-count-pill">{notifications.length}</span>
</div>
{isLoading ? <p>Indlæser...</p> : null}
{!isLoading && notifications.length === 0 ? <p>Ingen notifikationer endnu.</p> : null}
<ul className="dashboard-feed-list">
{notifications.map((item) => (
<li key={item.id}>
<button
type="button"
className="dashboard-feed-item"
onClick={() => {
const fromJobnet = Boolean(item.jobnetPostingId);
const jobId = fromJobnet ? item.jobnetPostingId : item.jobPostingId;
if (jobId) {
onOpenJob(jobId, fromJobnet);
}
}}
>
<strong>{item.jobTitle || 'Jobagent match'}</strong>
<span>{item.companyName || 'Ukendt virksomhed'}</span>
</button>
</li>
))}
</ul>
</article>
<article className="glass-panel dash-card dashboard-feed-card dashboard-evaluations-card">
<div className="dash-header">
<h4>Seneste evalueringer</h4>
<span className="dashboard-count-pill">{evaluations.length}</span>
</div>
{isLoading ? <p>Indlæser...</p> : null}
{!isLoading && evaluations.length === 0 ? <p>Ingen evalueringer endnu.</p> : null}
<ul className="dashboard-feed-list">
{evaluations.map((evaluation) => (
<li key={evaluation.id}>
<button type="button" className="dashboard-feed-item" onClick={() => onNavigate('ai-agent')}>
<strong>{evaluation.jobName}</strong>
<span>
{evaluation.companyName || 'Ukendt virksomhed'} {getRecommendationText(evaluation.recommendation)}
</span>
</button>
</li>
))}
</ul>
</article>
<article className="glass-panel dash-card dashboard-feed-card dashboard-messages-card">
<div className="dash-header">
<h4>Seneste 5 beskeder</h4>
<button className="primary-btn jobs-apply-btn" type="button" onClick={() => onNavigate('beskeder')}>
Åbn
</button>
</div>
{isLoading ? <p>Indlæser...</p> : null}
{!isLoading && messages.length === 0 ? <p>Ingen beskeder endnu.</p> : null}
<ul className="dashboard-message-list">
{messages.map((thread) => (
<li key={thread.id}>
<button type="button" className="dashboard-message-item" onClick={() => onNavigate('beskeder')}>
<span className="dashboard-message-avatar">
{(thread.companyName || 'S').trim().slice(0, 1).toUpperCase()}
</span>
<span className="dashboard-message-main">
<strong>{thread.companyName || 'Samtale'}</strong>
<span>{thread.latestMessage?.text || 'Ingen besked'}</span>
</span>
</button>
</li>
))}
</ul>
</article>
<article className="glass-panel dash-card dashboard-feed-card dashboard-best-card">
<div className="dash-header">
<h4>Seneste 5 bedste jobs</h4>
<button className="primary-btn jobs-apply-btn" type="button" onClick={() => onNavigate('jobs')}>
Alle jobs
</button>
</div>
{isLoading ? <p>Indlæser...</p> : null}
{!isLoading && bestJobs.length === 0 ? <p>Ingen jobforslag endnu.</p> : null}
<div className="dashboard-best-list">
{bestJobs.map((job) => (
<button
type="button"
key={job.id}
className="dashboard-best-item"
onClick={() => onOpenJob(job.id, job.fromJobnet)}
>
<div className="dashboard-best-brand">
{job.logoUrl || job.companyLogoImage ? (
<img src={job.logoUrl || job.companyLogoImage} alt={job.companyName} className="jobs-result-logo-img" />
) : (
<div className="jobs-result-logo">{companyInitial(job.companyName)}</div>
)}
<div>
<strong>{job.title || 'Stilling'}</strong>
<span>{job.companyName || 'Ukendt virksomhed'}</span>
</div>
</div>
<div className="dashboard-best-meta">
<span>{job.candidateDistance != null ? `${job.candidateDistance.toFixed(1)} km` : 'Distance ukendt'}</span>
<span>Frist: {formatDate(job.applicationDeadline)}</span>
</div>
</button>
))}
</div>
</article>
</div>
</div>
</main>
</section>
);
}

View File

@@ -0,0 +1,170 @@
import { useCallback, useMemo, useState } from 'react';
import {
JobsPageViewModel,
type JobsFilterDraft,
type JobsListItem,
type OccupationOption,
type PlaceSuggestion,
type JobsTabKey,
} from '../../../mvvm/viewmodels/JobsPageViewModel';
export function useJobsPageViewModel() {
const viewModel = useMemo(() => new JobsPageViewModel(), []);
const [isLoading, setIsLoading] = useState(false);
const [isSavingFilter, setIsSavingFilter] = useState(false);
const [items, setItems] = useState<JobsListItem[]>([]);
const [bookmarkingIds, setBookmarkingIds] = useState<Record<string, boolean>>({});
const [occupationOptions, setOccupationOptions] = useState<OccupationOption[]>([]);
const [jobSearchWords, setJobSearchWords] = useState<string[]>([]);
const [placeSuggestions, setPlaceSuggestions] = useState<PlaceSuggestion[]>([]);
const [filter, setFilter] = useState<JobsFilterDraft | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(
async (tab: JobsTabKey, currentFilter?: JobsFilterDraft, searchTerm?: string) => {
setIsLoading(true);
setError(null);
try {
const nextFilter = currentFilter ?? (await viewModel.getSavedFilter());
const [nextOccupationOptions, nextSearchWords] = await Promise.all([
viewModel.getOccupationOptions(),
viewModel.getJobSearchWords(),
]);
setFilter(nextFilter);
setOccupationOptions(nextOccupationOptions);
setJobSearchWords(nextSearchWords);
try {
const nextItems = await viewModel.getTabItems(tab, searchTerm);
setItems(nextItems);
} catch (itemsError) {
setItems([]);
setError(itemsError instanceof Error ? itemsError.message : 'Could not load jobs list.');
}
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Could not load jobs data.');
} finally {
setIsLoading(false);
}
},
[viewModel],
);
const applyFilter = useCallback(
async (tab: JobsTabKey, nextFilter: JobsFilterDraft, searchTerm?: string) => {
setIsSavingFilter(true);
setError(null);
try {
await viewModel.saveFilter(nextFilter);
setFilter(nextFilter);
const nextItems = await viewModel.getTabItems(tab, searchTerm);
setItems(nextItems);
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : 'Could not save filter.');
} finally {
setIsSavingFilter(false);
}
},
[viewModel],
);
const resetFilter = useCallback(
async (tab: JobsTabKey, searchTerm?: string) => {
setIsSavingFilter(true);
setError(null);
try {
const reset = await viewModel.resetFilter();
setFilter(reset);
const nextItems = await viewModel.getTabItems(tab, searchTerm);
setItems(nextItems);
} catch (resetError) {
setError(resetError instanceof Error ? resetError.message : 'Could not reset filter.');
} finally {
setIsSavingFilter(false);
}
},
[viewModel],
);
const searchPlaceSuggestions = useCallback(
async (query: string) => {
try {
const next = await viewModel.searchPlaceSuggestions(query);
setPlaceSuggestions(next);
} catch {
setPlaceSuggestions([]);
}
},
[viewModel],
);
const choosePlaceSuggestion = useCallback(
async (placeId: string) => {
const place = await viewModel.getPlaceSelection(placeId);
if (!place) {
return;
}
setFilter((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
distanceCenterName: place.description,
latitude: place.latitude,
longitude: place.longitude,
};
});
setPlaceSuggestions([]);
},
[viewModel],
);
const toggleBookmark = useCallback(
async (item: JobsListItem, save: boolean, removeFromList = false) => {
setBookmarkingIds((prev) => ({ ...prev, [item.id]: true }));
setError(null);
try {
await viewModel.toggleBookmark(item, save);
setItems((prev) =>
prev
.map((existing) =>
existing.id === item.id ? { ...existing, isSaved: save } : existing,
)
.filter((existing) => !(removeFromList && existing.id === item.id)),
);
} catch (bookmarkError) {
setError(bookmarkError instanceof Error ? bookmarkError.message : 'Could not update bookmark.');
} finally {
setBookmarkingIds((prev) => {
const next = { ...prev };
delete next[item.id];
return next;
});
}
},
[viewModel],
);
return {
isLoading,
isSavingFilter,
items,
occupationOptions,
jobSearchWords,
placeSuggestions,
filter,
error,
load,
applyFilter,
resetFilter,
searchPlaceSuggestions,
choosePlaceSuggestion,
toggleBookmark,
bookmarkingIds,
setFilter,
};
}

View File

@@ -0,0 +1,279 @@
import { useEffect, useMemo, useState } from 'react';
import { Sidebar } from '../../layout/components/Sidebar';
import { Topbar } from '../../layout/components/Topbar';
import { JobDetailViewModel, type JobDetailData } from '../../../mvvm/viewmodels/JobDetailViewModel';
interface JobDetailPageProps {
jobId: string;
fromJobnet: boolean;
onLogout: () => Promise<void>;
onNavigate: (key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') => void;
}
function formatDate(value: string): string {
if (!value) {
return 'Ingen frist';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleDateString('da-DK', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
function looksLikeHtml(value: string): boolean {
return /<[^>]+>/.test(value);
}
function workTimeString(detail: JobDetailData): string {
if (detail.workTimes.length === 0) {
return 'Ikke opgivet';
}
const labels: Record<number, string> = {
1: 'Dag',
2: 'Aften',
3: 'Nat',
4: 'Weekend',
};
return detail.workTimes
.map((value) => labels[value] ?? `Type ${value}`)
.join(', ');
}
function employmentDateString(detail: JobDetailData): string {
if (detail.startAsSoonAsPossible) {
return 'Snarest muligt';
}
if (!detail.employmentDate) {
return 'Ikke opgivet';
}
return formatDate(detail.employmentDate);
}
function infoValue(value: string): string {
const normalized = value.trim();
return normalized.length > 0 ? normalized : 'Ikke opgivet';
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="job-info-row">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
export function JobDetailPage({ jobId, fromJobnet, onLogout, onNavigate }: JobDetailPageProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('arbejd.sidebar.collapsed') === '1');
const [detail, setDetail] = useState<JobDetailData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const viewModel = useMemo(() => new JobDetailViewModel(), []);
useEffect(() => {
let active = true;
setIsLoading(true);
setError(null);
void viewModel
.getDetail(jobId, fromJobnet)
.then((result) => {
if (!active) {
return;
}
setDetail(result);
})
.catch((loadError) => {
if (!active) {
return;
}
setError(loadError instanceof Error ? loadError.message : 'Could not load job detail.');
})
.finally(() => {
if (active) {
setIsLoading(false);
}
});
return () => {
active = false;
};
}, [jobId, fromJobnet, viewModel]);
async function handleToggleSave() {
if (!detail) {
return;
}
setIsSaving(true);
setError(null);
try {
await viewModel.toggleBookmark(detail.id, detail.fromJobnet, !detail.isSaved);
setDetail({ ...detail, isSaved: !detail.isSaved });
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : 'Could not update saved state.');
} finally {
setIsSaving(false);
}
}
async function handleMarkAsApplied() {
if (!detail) {
return;
}
setIsApplying(true);
setError(null);
try {
await viewModel.markAsApplied(detail.id, detail.fromJobnet);
setDetail({ ...detail, isApplied: true });
} catch (applyError) {
setError(applyError instanceof Error ? applyError.message : 'Could not mark as applied.');
} finally {
setIsApplying(false);
}
}
return (
<section className="dashboard-layout">
<Sidebar
collapsed={sidebarCollapsed}
activeKey="jobs"
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="Jobdetaljer" userName="Anders Jensen" planLabel="Jobseeker Pro" onLogout={onLogout} />
<div className="dashboard-scroll">
<section className="job-detail-layout">
<article className="glass-panel dash-card job-detail-main">
{isLoading ? <p>Indlæser jobdetaljer...</p> : null}
{error ? <p className="status error">{error}</p> : null}
{!isLoading && !error && detail ? (
<>
<div className="job-detail-head">
<div className="job-detail-logo-wrap">
{detail.logoUrl ? (
<img src={detail.logoUrl} alt={detail.companyName} className="job-detail-logo" />
) : (
<div className="job-detail-logo-fallback">
{detail.companyName.slice(0, 1).toUpperCase() || 'A'}
</div>
)}
</div>
<div>
<h3>{detail.title}</h3>
<p className="job-detail-company">{detail.companyName}</p>
{detail.occupationName ? <p className="job-detail-meta">{detail.occupationName}</p> : null}
{detail.address ? <p className="job-detail-meta">{detail.address}</p> : null}
<p className="job-detail-meta">Ansøgningsfrist: {formatDate(detail.applicationDeadline)}</p>
</div>
</div>
<div className="job-detail-description">
<h4>Om jobbet</h4>
{detail.description ? (
looksLikeHtml(detail.description) ? (
<div
className="job-detail-richtext"
dangerouslySetInnerHTML={{ __html: detail.description }}
/>
) : (
<p>{detail.description}</p>
)
) : (
<p>Ingen beskrivelse tilgængelig.</p>
)}
</div>
</>
) : null}
</article>
<aside className="job-detail-side">
{detail ? (
<article className="glass-panel dash-card job-detail-info-card">
<h4>Info</h4>
<div className="job-info-grid">
<InfoRow label="Arbejdstype" value={detail.isFullTime == null ? 'Ikke opgivet' : detail.isFullTime ? 'Fuldtid' : 'Deltid'} />
<InfoRow label="Arbejdstid" value={workTimeString(detail)} />
<InfoRow label="Kontaktperson" value={infoValue(detail.contactName)} />
<InfoRow label="Arbejdsgiver" value={detail.hiringCompanyName?.trim() ? detail.hiringCompanyName : 'Anonym'} />
<InfoRow label="Oprettet" value={detail.datePosted ? formatDate(detail.datePosted) : 'Ikke opgivet'} />
<InfoRow label="Ansøgningsfrist" value={detail.applicationDeadline ? formatDate(detail.applicationDeadline) : 'Ikke opgivet'} />
<InfoRow label="Ansættelsesdato" value={employmentDateString(detail)} />
<InfoRow label="Antal stillinger" value={detail.numberOfPositions == null ? 'Ikke opgivet' : String(detail.numberOfPositions)} />
</div>
</article>
) : null}
<article className="glass-panel dash-card job-detail-actions">
<h4>Handlinger</h4>
<button
type="button"
className={detail?.isSaved ? 'job-action-btn save active' : 'job-action-btn save'}
onClick={() => void handleToggleSave()}
disabled={!detail || isSaving}
>
<span className="job-action-icon" aria-hidden></span>
<span>{isSaving ? 'Gemmer...' : detail?.isSaved ? 'Fjern gemt job' : 'Gem job'}</span>
</button>
<button
type="button"
className="job-action-btn website"
onClick={() => {
if (detail?.websiteUrl) {
window.open(detail.websiteUrl, '_blank', 'noopener,noreferrer');
}
}}
disabled={!detail?.websiteUrl}
>
<span className="job-action-icon" aria-hidden></span>
<span>Åbn hjemmeside</span>
</button>
<button
type="button"
className={detail?.isApplied ? 'job-action-btn applied active' : 'job-action-btn applied'}
onClick={() => void handleMarkAsApplied()}
disabled={!detail || isApplying || Boolean(detail?.isApplied)}
>
<span className="job-action-icon" aria-hidden></span>
<span>{isApplying ? 'Opdaterer...' : detail?.isApplied ? 'Markeret som søgt' : 'Marker som søgt'}</span>
</button>
<button
type="button"
className="job-action-btn simulator"
onClick={() => window.alert('Interview-træning kobles på i næste step.')}
>
<span className="job-action-icon" aria-hidden></span>
<span>Træn jobsamtale</span>
</button>
<button
type="button"
className="job-action-btn application"
onClick={() => window.alert('Ansøgningsgenerator kobles på i næste step.')}
>
<span className="job-action-icon" aria-hidden></span>
<span>Generer ansøgning</span>
</button>
</article>
</aside>
</section>
</div>
</main>
</section>
);
}

View File

@@ -0,0 +1,552 @@
import { useEffect, useMemo, useState } from 'react';
import { Topbar } from '../../layout/components/Topbar';
import { Sidebar } from '../../layout/components/Sidebar';
import { useJobsPageViewModel } from '../hooks/useJobsPageViewModel';
import type { JobsFilterDraft, JobsTabKey } from '../../../mvvm/viewmodels/JobsPageViewModel';
interface JobsPageProps {
onLogout: () => Promise<void>;
onNavigate: (key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') => void;
onOpenJob: (jobId: string, fromJobnet: boolean) => void;
}
type BoolFilterKey =
| 'workTypePermanent'
| 'workTypePartTime';
const WORK_TYPE_FIELDS: Array<{ key: BoolFilterKey; label: string }> = [
{ key: 'workTypePermanent', label: 'Fast' },
{ key: 'workTypePartTime', label: 'Deltid' },
];
function tabKeyToSidebar(tab: JobsTabKey): string {
void tab;
return 'jobs';
}
export function JobsPage({ onLogout, onNavigate, onOpenJob }: JobsPageProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('arbejd.sidebar.collapsed') === '1');
const [activeTab, setActiveTab] = useState<JobsTabKey>('jobs');
const [searchInput, setSearchInput] = useState('');
const [showSuggestions, setShowSuggestions] = useState(false);
const [showAllPills, setShowAllPills] = useState(false);
const [filterTab, setFilterTab] = useState<'areas' | 'settings'>('areas');
const [showPlaceSuggestions, setShowPlaceSuggestions] = useState(false);
const [jobsSearchText, setJobsSearchText] = useState('');
const {
isLoading,
isSavingFilter,
items,
occupationOptions,
jobSearchWords,
placeSuggestions,
filter,
error,
load,
applyFilter,
resetFilter,
searchPlaceSuggestions,
choosePlaceSuggestion,
toggleBookmark,
bookmarkingIds,
setFilter,
} = useJobsPageViewModel();
useEffect(() => {
void load(activeTab, undefined, activeTab === 'jobs' ? jobsSearchText : undefined);
// intentionally only on tab switch to avoid network calls on each keypress
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, load]);
useEffect(() => {
const query = filter?.distanceCenterName ?? '';
if (filterTab !== 'settings' || !showPlaceSuggestions || query.trim().length < 3) {
return;
}
const timeoutId = window.setTimeout(() => {
void searchPlaceSuggestions(query);
}, 300);
return () => window.clearTimeout(timeoutId);
}, [filter?.distanceCenterName, filterTab, showPlaceSuggestions, searchPlaceSuggestions]);
const filteredSuggestions = useMemo(() => {
const query = searchInput.trim().toLowerCase();
const selectedIds = new Set(filter?.escoIds ?? []);
return occupationOptions
.filter((item) => !selectedIds.has(item.id))
.filter((item) => (query ? item.name.toLowerCase().includes(query) : true))
.slice(0, 8);
}, [searchInput, occupationOptions, filter]);
function updateFilter(patch: Partial<JobsFilterDraft>) {
if (!filter) {
return;
}
setFilter({ ...filter, ...patch });
}
function toggleBoolFilter(key: BoolFilterKey) {
if (!filter) {
return;
}
updateFilter({ [key]: !filter[key] } as Partial<JobsFilterDraft>);
}
function addOccupation(occupationId: number) {
if (!filter) {
return;
}
if (filter.escoIds.includes(occupationId)) {
return;
}
setFilter({ ...filter, escoIds: [...filter.escoIds, occupationId] });
setSearchInput('');
setShowSuggestions(false);
}
function removeOccupation(occupationId: number) {
if (!filter) {
return;
}
setFilter({ ...filter, escoIds: filter.escoIds.filter((existingId) => existingId !== occupationId) });
}
async function handleApplyFilter() {
if (!filter) {
return;
}
await applyFilter(activeTab, filter, activeTab === 'jobs' ? jobsSearchText : undefined);
}
const selectedOccupations = useMemo(() => {
const ids = filter?.escoIds ?? [];
const optionsById = new Map(occupationOptions.map((option) => [option.id, option.name]));
return ids.map((id) => ({
id,
name: optionsById.get(id) ?? `ESCO #${id}`,
}));
}, [occupationOptions, filter]);
const visiblePills = useMemo(
() => (showAllPills ? selectedOccupations : selectedOccupations.slice(0, 12)),
[showAllPills, selectedOccupations],
);
const hiddenPillCount = Math.max(0, selectedOccupations.length - visiblePills.length);
function companyInitial(name: string): string {
const trimmed = name.trim();
return trimmed.length > 0 ? trimmed.charAt(0).toUpperCase() : 'A';
}
function formatDeadline(value: string): string {
if (!value) {
return 'Ingen frist';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleDateString('da-DK', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
async function handleResetFilter() {
await resetFilter(activeTab, activeTab === 'jobs' ? jobsSearchText : undefined);
setSearchInput('');
setShowSuggestions(false);
}
function triggerJobsSearch() {
if (activeTab !== 'jobs') {
return;
}
void load('jobs', filter ?? undefined, jobsSearchText);
}
const jobSearchSuggestions = useMemo(() => {
const query = jobsSearchText.trim().toLowerCase();
if (!query) {
return jobSearchWords.slice(0, 8);
}
return jobSearchWords.filter((word) => word.toLowerCase().includes(query)).slice(0, 8);
}, [jobSearchWords, jobsSearchText]);
return (
<section className="dashboard-layout">
<Sidebar
collapsed={sidebarCollapsed}
activeKey={tabKeyToSidebar(activeTab)}
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="Jobs" userName="Anders Jensen" planLabel="Jobseeker Pro" onLogout={onLogout} />
<div className="dashboard-scroll">
<article className="glass-panel dash-card jobs-filter-card">
<div className="jobs-filter-top">
<div>
<h4>Filtre</h4>
<p className="jobs-filter-subtitle">Tilpas visningen med arbejdsområde, jobtype og afstand.</p>
</div>
<div className="jobs-filter-actions">
<button className="secondary-btn" type="button" onClick={() => void handleResetFilter()} disabled={isSavingFilter}>
Nulstil
</button>
<button className="primary-btn jobs-apply-btn" type="button" onClick={() => void handleApplyFilter()} disabled={isSavingFilter || isLoading || !filter}>
{isSavingFilter ? 'Gemmer...' : 'Anvend filtre'}
</button>
</div>
</div>
<div className="jobs-filter-tab-nav">
<button
type="button"
className={filterTab === 'areas' ? 'tab-btn active' : 'tab-btn'}
onClick={() => setFilterTab('areas')}
>
Arbejdsområde
</button>
<button
type="button"
className={filterTab === 'settings' ? 'tab-btn active' : 'tab-btn'}
onClick={() => setFilterTab('settings')}
>
Indstillinger
</button>
</div>
{filterTab === 'areas' ? (
<div className="jobs-filter-section">
<div className="jobs-search-box">
<label className="field-label" htmlFor="jobs-term-search">Søg og vælg jobs/fagområder</label>
<input
id="jobs-term-search"
className="field-input"
value={searchInput}
onFocus={() => setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 120)}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="Søg arbejdsområde"
/>
{showSuggestions && filteredSuggestions.length > 0 && (
<div className="jobs-suggestions glass-panel">
{filteredSuggestions.map((option) => (
<button key={option.id} type="button" className="jobs-suggestion-item" onMouseDown={() => addOccupation(option.id)}>
{option.name}
</button>
))}
</div>
)}
</div>
<div className="jobs-pill-meta">
<span className="chip">{selectedOccupations.length} valgte</span>
{selectedOccupations.length > 12 ? (
<button
type="button"
className="jobs-pill-toggle"
onClick={() => setShowAllPills((prev) => !prev)}
>
{showAllPills ? 'Vis færre' : `Vis flere${hiddenPillCount > 0 ? ` (+${hiddenPillCount})` : ''}`}
</button>
) : null}
</div>
<div className={showAllPills ? 'jobs-pill-row expanded' : 'jobs-pill-row'}>
{visiblePills.map((option) => (
<button key={option.id} type="button" className="jobs-pill" onClick={() => removeOccupation(option.id)}>
{option.name} <span aria-hidden>×</span>
</button>
))}
</div>
</div>
) : (
<div className="jobs-filter-section">
<div className="jobs-filter-groups">
<div className="jobs-filter-group">
<p>Jobtype</p>
<div className="jobs-switch-grid">
{WORK_TYPE_FIELDS.map((item) => (
<button
key={item.key}
type="button"
className={filter?.[item.key] ? 'jobs-switch is-on' : 'jobs-switch'}
onClick={() => toggleBoolFilter(item.key)}
aria-pressed={Boolean(filter?.[item.key])}
>
<span className="jobs-switch-track">
<span className="jobs-switch-thumb" />
</span>
<span className="jobs-switch-label">{item.label}</span>
</button>
))}
</div>
</div>
<div className="jobs-filter-group">
<p>Arbejdssted (center)</p>
<div className="jobs-address-box">
<input
className="field-input jobs-inline-input"
type="text"
value={filter?.distanceCenterName ?? ''}
onFocus={() => setShowPlaceSuggestions(true)}
onBlur={() => setTimeout(() => setShowPlaceSuggestions(false), 120)}
onChange={(event) => {
updateFilter({
distanceCenterName: event.target.value,
latitude: null,
longitude: null,
});
}}
placeholder="Søg adresse"
/>
{showPlaceSuggestions && placeSuggestions.length > 0 ? (
<div className="jobs-place-suggestions glass-panel">
{placeSuggestions.map((suggestion) => (
<button
key={suggestion.placeId}
type="button"
className="jobs-suggestion-item"
onMouseDown={() => void choosePlaceSuggestion(suggestion.placeId)}
>
{suggestion.description}
</button>
))}
</div>
) : null}
</div>
</div>
<div className="jobs-filter-group">
<p>Afstand fra center ({Math.round(filter?.workDistance ?? 0)} km)</p>
<input
className="jobs-distance-slider"
type="range"
min={0}
max={500}
value={filter?.workDistance ?? ''}
onChange={(event) => {
const value = event.target.value;
updateFilter({ workDistance: value ? Number(value) : null });
}}
/>
</div>
{filter?.workTypePartTime ? (
<div className="jobs-filter-group">
<p>Deltidstimer pr. uge</p>
<input
className="field-input jobs-inline-input"
type="number"
min={1}
max={37}
value={filter?.partTimeHours ?? ''}
onChange={(event) => {
const value = event.target.value;
updateFilter({ partTimeHours: value ? Number(value) : null });
}}
placeholder="Fx 20"
/>
</div>
) : null}
</div>
</div>
)}
</article>
<article className="glass-panel dash-card jobs-tabs-card">
<div className="mode-tabs jobs-tabs">
<button
type="button"
className={activeTab === 'jobs' ? 'tab-btn active' : 'tab-btn'}
onClick={() => setActiveTab('jobs')}
>
Jobs
</button>
<button
type="button"
className={activeTab === 'gemte' ? 'tab-btn active' : 'tab-btn'}
onClick={() => setActiveTab('gemte')}
>
Gemte
</button>
<button
type="button"
className={activeTab === 'sogte' ? 'tab-btn active' : 'tab-btn'}
onClick={() => setActiveTab('sogte')}
>
Søgte jobs
</button>
</div>
</article>
<section className="jobs-results-section">
<div className="dash-header">
<h4>
{activeTab === 'jobs'
? 'Jobs'
: activeTab === 'gemte'
? 'Gemte jobs'
: 'Søgte jobs'}
</h4>
<div className="jobs-results-tools">
{activeTab === 'jobs' ? (
<div className="jobs-top-search">
<input
className="field-input jobs-top-search-input"
list="jobs-search-words"
value={jobsSearchText}
onChange={(event) => setJobsSearchText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
triggerJobsSearch();
}
}}
placeholder="Søg jobtitel eller nøgleord"
/>
<datalist id="jobs-search-words">
{jobSearchSuggestions.map((word) => (
<option key={word} value={word} />
))}
</datalist>
<button type="button" className="secondary-btn" onClick={triggerJobsSearch}>
Søg
</button>
</div>
) : null}
<span className="chip">{items.length} resultater</span>
</div>
</div>
{isLoading ? <p>Indlæser jobs...</p> : null}
{error ? <p className="status error">{error}</p> : null}
{isLoading ? (
<div className="jobs-results-grid" aria-hidden>
{Array.from({ length: 6 }).map((_, index) => (
<article className="glass-panel jobs-result-card jobs-skeleton-card" key={`skeleton-${index}`}>
<div className="jobs-skeleton-line w-55" />
<div className="jobs-skeleton-line w-35" />
<div className="jobs-skeleton-line w-70" />
<div className="jobs-skeleton-line w-90" />
<div className="jobs-skeleton-line w-80" />
<div className="jobs-skeleton-row">
<div className="jobs-skeleton-pill" />
<div className="jobs-skeleton-pill" />
</div>
</article>
))}
</div>
) : null}
{!isLoading && !error && items.length === 0 ? (
<p>Ingen jobs fundet endnu.</p>
) : null}
{!isLoading && !error && items.length > 0 ? (
<div className="jobs-results-grid">
{items.map((item) => (
<article
className="glass-panel jobs-result-card"
key={item.id}
role="button"
tabIndex={0}
onClick={() => onOpenJob(item.id, item.fromJobnet)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpenJob(item.id, item.fromJobnet);
}
}}
>
<div className="jobs-result-top">
<div className="jobs-result-brand">
{item.logoUrl || item.companyLogoImage ? (
<img
className="jobs-result-logo-img"
src={item.logoUrl || item.companyLogoImage}
alt={item.companyName}
/>
) : (
<div className="jobs-result-logo">{companyInitial(item.companyName)}</div>
)}
<div>
<p className="jobs-result-company">{item.companyName}</p>
<p className="jobs-result-address">{item.address || 'Ukendt lokation'}</p>
</div>
</div>
<button
type="button"
className={item.isSaved ? 'jobs-bookmark-icon active' : 'jobs-bookmark-icon'}
disabled={Boolean(bookmarkingIds[item.id])}
onClick={() =>
void toggleBookmark(
item,
!item.isSaved,
activeTab === 'gemte' && item.isSaved,
)
}
onMouseDown={(event) => event.stopPropagation()}
onClickCapture={(event) => event.stopPropagation()}
aria-label={item.isSaved ? 'Fjern gemt job' : 'Gem job'}
title={item.isSaved ? 'Fjern gemt' : 'Gem job'}
>
{bookmarkingIds[item.id] ? '…' : item.isSaved ? '★' : '☆'}
</button>
</div>
<h5 className="jobs-result-title">{item.title}</h5>
<p className="jobs-result-occupation">{item.occupationName || 'Ikke angivet'}</p>
<p className="jobs-result-description">
{item.descriptionIntro || 'Ingen beskrivelse.'}
</p>
<div className="jobs-result-tags">
<span className="chip">Frist: {formatDeadline(item.applicationDeadline)}</span>
{typeof item.candidateDistance === 'number' ? (
<span className="chip">{item.candidateDistance.toFixed(1)} km</span>
) : null}
<span className="chip">
{activeTab === 'jobs' ? 'Nyt match' : activeTab === 'gemte' ? 'Favorit' : 'Ansøgt'}
</span>
</div>
<div className="jobs-result-footer">
<button
type="button"
className="primary-btn jobs-card-primary-btn"
onClick={(event) => {
event.stopPropagation();
onOpenJob(item.id, item.fromJobnet);
}}
>
{activeTab === 'sogte' ? 'Se ansøgning' : 'Åbn job'}
</button>
</div>
</article>
))}
</div>
) : null}
</section>
</div>
</main>
</section>
);
}

View File

@@ -0,0 +1,126 @@
import { useMemo } from 'react';
interface SidebarItem {
key: string;
label: string;
description: string;
badge?: boolean;
accent?: boolean;
}
interface SidebarProps {
collapsed: boolean;
activeKey: string;
onToggle: () => void;
onSelect?: (key: string) => void;
}
function NavIcon({ itemKey }: { itemKey: string }) {
if (itemKey === 'dashboard') {
return <svg viewBox="0 0 24 24" aria-hidden><rect x="3" y="3" width="8" height="8" rx="2" /><rect x="13" y="3" width="8" height="5" rx="2" /><rect x="13" y="10" width="8" height="11" rx="2" /><rect x="3" y="13" width="8" height="8" rx="2" /></svg>;
}
if (itemKey === 'cv') {
return <svg viewBox="0 0 24 24" aria-hidden><path d="M8 3h8l5 5v13H8z" /><path d="M16 3v5h5" /><path d="M11 13h7" /><path d="M11 17h7" /><circle cx="7" cy="16" r="3" /></svg>;
}
if (itemKey === 'jobs') {
return <svg viewBox="0 0 24 24" aria-hidden><rect x="3" y="7" width="18" height="13" rx="3" /><path d="M9 7V5a3 3 0 0 1 6 0v2" /><path d="M3 12h18" /></svg>;
}
if (itemKey === 'beskeder') {
return <svg viewBox="0 0 24 24" aria-hidden><path d="M4 5h16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H9l-5 4v-4H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z" /></svg>;
}
if (itemKey === 'ai-jobagent') {
return (
<svg viewBox="0 0 24 24" aria-hidden>
<path d="M10 6l1.8-3L13.6 6l3.4 1.4-3.4 1.4-1.8 3-1.8-3L6.6 7.4z" />
<path d="M16 12l1-1.8 1 1.8 1.8 1-1.8 1-1 1.8-1-1.8-1.8-1z" />
<path d="M4 18h8" />
<path d="M8 14v8" />
</svg>
);
}
if (itemKey === 'ai-agent') {
return (
<svg viewBox="0 0 24 24" aria-hidden>
<rect x="5" y="7" width="14" height="10" rx="4" />
<path d="M12 3v4" />
<circle cx="10" cy="12" r="1" />
<circle cx="14" cy="12" r="1" />
<path d="M9 15h6" />
</svg>
);
}
if (itemKey === 'simulator') {
return <svg viewBox="0 0 24 24" aria-hidden><rect x="3" y="8" width="18" height="10" rx="4" /><circle cx="8" cy="13" r="1.5" /><path d="M8 11.5v3" /><path d="M6.5 13h3" /><circle cx="16.5" cy="12" r="1.2" /><circle cx="18.8" cy="14.3" r="1.2" /></svg>;
}
return <svg viewBox="0 0 24 24" aria-hidden><rect x="3" y="5" width="18" height="14" rx="3" /><path d="M3 10h18" /><path d="M7 15h4" /></svg>;
}
export function Sidebar({ collapsed, activeKey, onToggle, onSelect }: SidebarProps) {
const items = useMemo<SidebarItem[]>(
() => [
{ key: 'dashboard', label: 'Dashboard', description: 'Oversigt over aktivitet, jobs, beskeder og evalueringer.' },
{ key: 'cv', label: 'CV', description: 'Administrer profil, erfaring, uddannelse og kvalifikationer.' },
{ key: 'jobs', label: 'Jobs', description: 'Find nye job, filtrer resultater og gem relevante stillinger.' },
{ key: 'beskeder', label: 'Beskeder', description: 'Læs og send beskeder med virksomheder og support.', badge: true },
{ key: 'ai-jobagent', label: 'AI JobAgent', description: 'Opsæt jobagent og få AI-baserede jobnotifikationer.', accent: true },
{ key: 'ai-agent', label: 'AI Agent', description: 'Få forslag til at forbedre dit CV og profil-match.', accent: true },
{ key: 'simulator', label: 'Simulator', description: 'Træn jobsamtaler og se interviewforløb.', },
{ key: 'abonnement', label: 'Abonnement', description: 'Se plan, funktioner og status for dit abonnement.' },
],
[],
);
return (
<aside id="sidebar" className={collapsed ? 'dashboard-sidebar collapsed glass-panel' : 'dashboard-sidebar glass-panel'}>
<div className="sidebar-header">
<div className="brand-chip">
<span>Ar</span>
</div>
<span className="sidebar-logo-text">Arbejd</span>
</div>
<nav className="sidebar-nav">
{items.map((item) => {
const active = item.key === activeKey;
const isAiItem = item.key === 'ai-jobagent' || item.key === 'ai-agent';
return (
<button
key={item.key}
type="button"
className={active ? 'sidebar-item nav-item active' : 'sidebar-item nav-item'}
title={item.label}
onClick={() => onSelect?.(item.key)}
>
<span
className={[
'sidebar-icon',
item.accent ? 'accent' : '',
isAiItem ? 'ai-spark' : '',
].join(' ').trim()}
>
<NavIcon itemKey={item.key} />
</span>
<span className="sidebar-label nav-text">{item.label}</span>
{item.badge && <span className="sidebar-badge" />}
<span className="sidebar-tooltip" aria-hidden>
<strong>
<span className={['sidebar-tooltip-icon', item.accent ? 'accent' : '', isAiItem ? 'ai-spark' : ''].join(' ').trim()}>
<NavIcon itemKey={item.key} />
</span>
{item.label}
</strong>
<small>{item.description}</small>
</span>
</button>
);
})}
</nav>
<div className="sidebar-footer">
<button type="button" className="sidebar-toggle" onClick={onToggle}>
{collapsed ? '→' : '←'}
</button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react';
interface ThemeToggleProps {
className?: string;
}
export function ThemeToggle({ className }: ThemeToggleProps) {
const [isDark, setIsDark] = useState<boolean>(() => {
if (typeof window === 'undefined') {
return false;
}
return window.localStorage.getItem('ui-theme') === 'dark';
});
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const { body } = window.document;
body.classList.toggle('theme-dark', isDark);
window.localStorage.setItem('ui-theme', isDark ? 'dark' : 'light');
}, [isDark]);
return (
<button
className={className ? `topbar-theme-toggle ${className}` : 'topbar-theme-toggle'}
type="button"
aria-label={isDark ? 'Skift til lyst tema' : 'Skift til mørkt tema'}
onClick={() => setIsDark((prev) => !prev)}
>
<span className={!isDark ? 'is-active' : undefined}></span>
<span className={isDark ? 'is-active' : undefined}></span>
</button>
);
}

View File

@@ -0,0 +1,107 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { ThemeToggle } from './ThemeToggle';
import { CandidateService } from '../../../mvvm/services/candidate.service';
import type { CandidateInterface } from '../../../mvvm/models/candidate.interface';
interface TopbarProps {
title: string;
userName: string;
planLabel: string;
onLogout: () => Promise<void>;
actions?: ReactNode;
}
let cachedCandidate: CandidateInterface | null = null;
function buildFullName(candidate: CandidateInterface): string {
const first = candidate.firstName?.trim() ?? '';
const last = candidate.lastName?.trim() ?? '';
const combined = `${first} ${last}`.trim();
if (combined) {
return combined;
}
return candidate.name?.trim() || '';
}
function resolveImage(candidate: CandidateInterface): string | null {
const imageUrl = candidate.imageUrl?.trim();
if (imageUrl) {
return imageUrl;
}
const image = candidate.image?.trim();
return image || null;
}
export function Topbar({ title, userName, planLabel, onLogout, actions }: TopbarProps) {
const [candidate, setCandidate] = useState<CandidateInterface | null>(cachedCandidate);
const candidateService = useMemo(() => new CandidateService(), []);
useEffect(() => {
let active = true;
if (cachedCandidate) {
setCandidate(cachedCandidate);
return () => {
active = false;
};
}
void candidateService.getCandidate()
.then((response) => {
if (!active || !response) {
return;
}
cachedCandidate = response;
setCandidate(response);
})
.catch(() => {
// Keep existing fallback display values.
});
return () => {
active = false;
};
}, [candidateService]);
const fullName = candidate ? buildFullName(candidate) : userName;
const avatarSrc = candidate ? resolveImage(candidate) : null;
return (
<header className="dashboard-topbar">
<div className="topbar-left">
<div className="topbar-home-dot"></div>
<h2>{title}</h2>
</div>
<div className="topbar-right">
{actions}
<ThemeToggle />
<div className="topbar-profile-wrap">
<button className="topbar-profile glass-button" type="button">
{avatarSrc ? (
<img className="avatar-img" src={avatarSrc} alt={fullName} />
) : (
<div className="avatar">{fullName.trim().slice(0, 1).toUpperCase() || 'A'}</div>
)}
<div className="profile-text">
<span>{fullName}</span>
<small>{planLabel}</small>
</div>
<span className="profile-caret"></span>
</button>
<div className="topbar-dropdown">
<button className="dropdown-item" type="button">Profile</button>
<button className="dropdown-item" type="button">Settings</button>
<button className="dropdown-item" type="button">Notifications</button>
<div className="dropdown-divider" />
<button className="dropdown-item danger" type="button" onClick={() => void onLogout()}>
Log ud
</button>
</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,89 @@
import { useCallback, useMemo, useState } from 'react';
import { MessagesViewModel, type MessageThreadItem } from '../../../mvvm/viewmodels/MessagesViewModel';
export function useMessagesViewModel() {
const viewModel = useMemo(() => new MessagesViewModel(), []);
const [threads, setThreads] = useState<MessageThreadItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSending, setIsSending] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const [nextThreads, nextUnreadCount] = await Promise.all([
viewModel.getThreads(),
viewModel.getUnreadCount().catch(() => 0),
]);
setThreads(nextThreads);
setUnreadCount(nextUnreadCount);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Could not load messages.');
} finally {
setIsLoading(false);
}
}, [viewModel]);
const sendMessage = useCallback(
async (threadId: string, text: string) => {
if (!text.trim()) {
return;
}
setIsSending(true);
setError(null);
try {
await viewModel.sendMessage(threadId, text);
await load();
} catch (sendError) {
setError(sendError instanceof Error ? sendError.message : 'Could not send message.');
} finally {
setIsSending(false);
}
},
[load, viewModel],
);
const markThreadRead = useCallback(
async (messageId?: string) => {
try {
await viewModel.markThreadReadByMessageId(messageId);
setThreads((prev) =>
prev.map((thread) => {
if (!messageId) {
return thread;
}
const updatedMessages = thread.allMessages.map((message) =>
message.id === messageId || (!message.fromCandidate && !message.seen)
? { ...message, seen: new Date() }
: message,
);
return {
...thread,
allMessages: updatedMessages,
latestMessage:
thread.latestMessage?.id === messageId
? { ...thread.latestMessage, seen: new Date() }
: thread.latestMessage,
};
}),
);
} catch {
// no-op: read receipt should not block UI
}
},
[viewModel],
);
return {
threads,
unreadCount,
isLoading,
isSending,
error,
load,
sendMessage,
markThreadRead,
};
}

View File

@@ -0,0 +1,202 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { Sidebar } from '../../layout/components/Sidebar';
import { Topbar } from '../../layout/components/Topbar';
import { useMessagesViewModel } from '../hooks/useMessagesViewModel';
interface BeskederPageProps {
onLogout: () => Promise<void>;
onNavigate: (key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') => void;
}
function formatTime(value?: Date | string): string {
if (!value) {
return '';
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleString('da-DK', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
function companyInitial(name?: string): string {
const trimmed = (name ?? '').trim();
return trimmed.length > 0 ? trimmed.slice(0, 1).toUpperCase() : 'A';
}
export function BeskederPage({ onLogout, onNavigate }: BeskederPageProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('arbejd.sidebar.collapsed') === '1');
const [selectedThreadId, setSelectedThreadId] = useState<string>('');
const [draftMessage, setDraftMessage] = useState('');
const { threads, unreadCount, isLoading, isSending, error, load, sendMessage, markThreadRead } = useMessagesViewModel();
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (threads.length === 0) {
setSelectedThreadId('');
return;
}
setSelectedThreadId((current) => {
if (current && threads.some((item) => item.id === current)) {
return current;
}
return threads[0].id;
});
}, [threads]);
const selectedThread = useMemo(
() => threads.find((item) => item.id === selectedThreadId) ?? null,
[threads, selectedThreadId],
);
const hasMessages = (selectedThread?.allMessages?.length ?? 0) > 0;
async function handleSelectThread(threadId: string) {
setSelectedThreadId(threadId);
const thread = threads.find((item) => item.id === threadId);
const firstUnreadIncoming = thread?.allMessages.find((msg) => !msg.fromCandidate && !msg.seen && msg.id);
if (firstUnreadIncoming?.id) {
await markThreadRead(firstUnreadIncoming.id);
}
}
async function handleSubmitMessage(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedThread || !draftMessage.trim() || isSending) {
return;
}
const threadId = selectedThread.id;
const text = draftMessage;
setDraftMessage('');
await sendMessage(threadId, text);
}
return (
<section className="dashboard-layout">
<Sidebar
collapsed={sidebarCollapsed}
activeKey="beskeder"
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="Beskeder" userName="Anders Jensen" planLabel="Jobseeker Pro" onLogout={onLogout} />
<div className="dashboard-scroll">
<section className="messages-layout">
<aside className="glass-panel dash-card messages-threads-card">
<div className="messages-list-head">
<h4>Samtaler</h4>
<span className="messages-unread-chip">Ulæste: {unreadCount}</span>
</div>
{isLoading ? <p>Indlæser beskeder...</p> : null}
{error ? <p className="status error">{error}</p> : null}
{!isLoading && threads.length === 0 ? <p>Ingen beskeder endnu.</p> : null}
<div className="messages-thread-list">
{threads.map((thread) => {
const active = thread.id === selectedThreadId;
const unseenIncoming = thread.allMessages.some((message) => !message.fromCandidate && !message.seen);
return (
<button
key={thread.id}
type="button"
className={active ? 'messages-thread-item active' : 'messages-thread-item'}
onClick={() => void handleSelectThread(thread.id)}
>
<div className="messages-thread-avatar-wrap">
{thread.companyLogoUrl ? (
<img src={thread.companyLogoUrl} alt={thread.companyName} className="messages-thread-avatar" />
) : (
<div className="messages-thread-avatar messages-thread-avatar-fallback">
{companyInitial(thread.companyName)}
</div>
)}
{unseenIncoming ? <span className="messages-thread-dot" /> : null}
</div>
<div className="messages-thread-content">
<div className="messages-thread-row">
<strong>{thread.companyName || 'Firma'}</strong>
<span>{formatTime(thread.latestMessage?.timeSent)}</span>
</div>
<p className="messages-thread-title">{thread.title || thread.jobPosting?.title || 'Samtale'}</p>
<p className="messages-thread-preview">{thread.latestMessage?.text || 'Ingen besked'}</p>
</div>
</button>
);
})}
</div>
</aside>
<article className="glass-panel dash-card messages-chat-card">
{selectedThread ? (
<>
<header className="messages-chat-head">
<div>
<h4>{selectedThread.companyName}</h4>
<p>{selectedThread.title || selectedThread.jobPosting?.title || 'Samtale om job'}</p>
</div>
<button className="secondary-btn" type="button" onClick={() => void load()}>
Opdater
</button>
</header>
<div className="messages-chat-scroll">
{hasMessages ? (
selectedThread.allMessages.map((message, index) => {
const own = message.fromCandidate;
return (
<div
key={message.id ?? `${selectedThread.id}-${index}`}
className={own ? 'message-bubble message-bubble-own' : 'message-bubble'}
>
<p>{message.text}</p>
<span>{formatTime(message.timeSent)}</span>
</div>
);
})
) : (
<p>Ingen beskeder i denne tråd endnu.</p>
)}
</div>
<form className="messages-composer" onSubmit={(event) => void handleSubmitMessage(event)}>
<input
className="field-input messages-composer-input"
value={draftMessage}
onChange={(event) => setDraftMessage(event.target.value)}
placeholder="Skriv en besked..."
/>
<button className="primary-btn" type="submit" disabled={isSending || !draftMessage.trim()}>
{isSending ? 'Sender...' : 'Send'}
</button>
</form>
</>
) : (
<p>Vælg en samtale for at se beskeder.</p>
)}
</article>
</section>
</div>
</main>
</section>
);
}

View File

@@ -0,0 +1,37 @@
import { useCallback, useEffect, useState } from 'react';
function normalizePath(pathname: string): string {
if (!pathname || pathname === '/') {
return '/login';
}
if (pathname.endsWith('/') && pathname.length > 1) {
return pathname.slice(0, -1);
}
return pathname;
}
export function useBrowserRoute() {
const [path, setPath] = useState(() => normalizePath(window.location.pathname));
useEffect(() => {
const onPopState = () => {
setPath(normalizePath(window.location.pathname));
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
const navigate = useCallback((nextPath: string, replace = false) => {
const normalized = normalizePath(nextPath);
if (replace) {
window.history.replaceState({}, '', normalized);
} else {
window.history.pushState({}, '', normalized);
}
setPath(normalized);
}, []);
return { path, navigate };
}

View File

@@ -0,0 +1,133 @@
import { useEffect, useMemo, useState } from 'react';
import { Sidebar } from '../../layout/components/Sidebar';
import { Topbar } from '../../layout/components/Topbar';
import { SimulationService } from '../../../mvvm/services/simulation.service';
interface JobSimulatorPageProps {
onLogout: () => Promise<void>;
onNavigate: (key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') => void;
}
interface InterviewItem {
id: string;
job_name: string;
company_name: string | null;
interview_date: string | null;
is_completed: boolean;
}
function asInterviews(value: unknown): InterviewItem[] {
if (!value || typeof value !== 'object') {
return [];
}
const root = value as { interviews?: unknown[] };
if (!Array.isArray(root.interviews)) {
return [];
}
return root.interviews
.map((item) => {
if (!item || typeof item !== 'object') {
return null;
}
const source = item as Record<string, unknown>;
const id = typeof source.id === 'string' ? source.id : '';
if (!id) {
return null;
}
return {
id,
job_name: typeof source.job_name === 'string' ? source.job_name : 'Interview',
company_name: typeof source.company_name === 'string' ? source.company_name : null,
interview_date: typeof source.interview_date === 'string' ? source.interview_date : null,
is_completed: Boolean(source.is_completed),
};
})
.filter((item): item is InterviewItem => Boolean(item))
.slice(0, 6);
}
function formatDate(value: string | null): string {
if (!value) {
return 'Ingen dato';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return 'Ingen dato';
}
return date.toLocaleDateString('da-DK', { day: '2-digit', month: 'short', year: 'numeric' });
}
export function JobSimulatorPage({ onLogout, onNavigate }: JobSimulatorPageProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('arbejd.sidebar.collapsed') === '1');
const [items, setItems] = useState<InterviewItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const simulationService = useMemo(() => new SimulationService(), []);
useEffect(() => {
let active = true;
setIsLoading(true);
void simulationService
.listInterviews(10, 0)
.then((response) => {
if (!active) {
return;
}
setItems(asInterviews(response));
})
.catch(() => {
if (!active) {
return;
}
setItems([]);
})
.finally(() => {
if (active) {
setIsLoading(false);
}
});
return () => {
active = false;
};
}, [simulationService]);
return (
<section className="dashboard-layout">
<Sidebar
collapsed={sidebarCollapsed}
activeKey="simulator"
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="Interview Simulator" userName="Anders Jensen" planLabel="Jobseeker Pro" onLogout={onLogout} />
<div className="dashboard-scroll">
<article className="glass-panel dash-card">
<div className="dash-header">
<h4>Seneste interviews</h4>
<button type="button" className="primary-btn jobs-apply-btn">Start nyt interview</button>
</div>
{isLoading ? <p>Indlæser interviews...</p> : null}
{!isLoading && items.length === 0 ? <p>Ingen interviews endnu.</p> : null}
<ul className="dashboard-feed-list">
{items.map((item) => (
<li key={item.id}>
<div className="dashboard-feed-item">
<strong>{item.job_name}</strong>
<span>{item.company_name || 'Ukendt virksomhed'} {formatDate(item.interview_date)} {item.is_completed ? 'Gennemført' : 'Ikke færdig'}</span>
</div>
</li>
))}
</ul>
</article>
</div>
</main>
</section>
);
}

View File

@@ -0,0 +1,95 @@
import { useEffect, useMemo, useState } from 'react';
import type { PaymentOverview } from '../../../mvvm/models/payment-overview.interface';
import { SubscriptionService } from '../../../mvvm/services/subscription.service';
import { Sidebar } from '../../layout/components/Sidebar';
import { Topbar } from '../../layout/components/Topbar';
interface SubscriptionPageProps {
onLogout: () => Promise<void>;
onNavigate: (key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') => void;
}
function formatDate(value: string | Date | undefined): string {
if (!value) {
return 'Ikke tilgængelig';
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return 'Ikke tilgængelig';
}
return date.toLocaleDateString('da-DK', { day: '2-digit', month: 'short', year: 'numeric' });
}
export function SubscriptionPage({ onLogout, onNavigate }: SubscriptionPageProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('arbejd.sidebar.collapsed') === '1');
const [paymentOverview, setPaymentOverview] = useState<PaymentOverview | null>(null);
const [isLoading, setIsLoading] = useState(true);
const subscriptionService = useMemo(() => new SubscriptionService(), []);
useEffect(() => {
let active = true;
setIsLoading(true);
void subscriptionService
.getPaymentOverview()
.then((response) => {
if (!active) {
return;
}
setPaymentOverview(response);
})
.catch(() => {
if (!active) {
return;
}
setPaymentOverview(null);
})
.finally(() => {
if (active) {
setIsLoading(false);
}
});
return () => {
active = false;
};
}, [subscriptionService]);
return (
<section className="dashboard-layout">
<Sidebar
collapsed={sidebarCollapsed}
activeKey="abonnement"
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="Abonnement" userName="Anders Jensen" planLabel="Jobseeker Pro" onLogout={onLogout} />
<div className="dashboard-scroll">
<article className="glass-panel dash-card">
<h4>Din plan</h4>
{isLoading ? <p>Indlæser abonnement...</p> : null}
{!isLoading && !paymentOverview ? <p>Kunne ikke hente abonnement.</p> : null}
{paymentOverview ? (
<div className="dashboard-subscription-content">
<p>Produkt: {paymentOverview.productTypeName || paymentOverview.productType || 'Ukendt'}</p>
<p>Fornyes: {formatDate(paymentOverview.renewDate)}</p>
<p>Aktiv til: {formatDate(paymentOverview.activeToDate)}</p>
<div className="dashboard-feature-pills">
{paymentOverview.generateApplication ? <span className="chip">Ansøgninger</span> : null}
{paymentOverview.careerAgent ? <span className="chip">Karriereagent</span> : null}
{paymentOverview.downloadCv ? <span className="chip">CV download</span> : null}
{paymentOverview.jobInterviewSimulation ? <span className="chip">Simulator</span> : null}
</div>
</div>
) : null}
</article>
</div>
</main>
</section>
);
}