Initial React project
This commit is contained in:
109
src/presentation/ai-agent/hooks/useAiAgentViewModel.ts
Normal file
109
src/presentation/ai-agent/hooks/useAiAgentViewModel.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
265
src/presentation/ai-agent/pages/AiAgentPage.tsx
Normal file
265
src/presentation/ai-agent/pages/AiAgentPage.tsx
Normal 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 på 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>
|
||||
);
|
||||
}
|
||||
909
src/presentation/ai-jobagent/pages/AiJobAgentPage.tsx
Normal file
909
src/presentation/ai-jobagent/pages/AiJobAgentPage.tsx
Normal file
@@ -0,0 +1,909 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Sidebar } from '../../layout/components/Sidebar';
|
||||
import { Topbar } from '../../layout/components/Topbar';
|
||||
import type { NotificationInterface } from '../../../mvvm/models/notification.interface';
|
||||
import type { NotificationSettingInterface } from '../../../mvvm/models/notification-setting.interface';
|
||||
import type { OccupationCategorizationInterface, SubAreaInterface } from '../../../mvvm/models/occupation-categorization.interface';
|
||||
import type { OccupationInterface } from '../../../mvvm/models/occupation.interface';
|
||||
import { AiJobAgentViewModel } from '../../../mvvm/viewmodels/AiJobAgentViewModel';
|
||||
|
||||
interface AiJobAgentPageProps {
|
||||
onLogout: () => Promise<void>;
|
||||
onNavigate: (key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') => void;
|
||||
onOpenJob: (jobId: string, fromJobnet: boolean) => void;
|
||||
}
|
||||
|
||||
type OccupationTree = OccupationCategorizationInterface[];
|
||||
|
||||
const PAGE_LIMIT = 20;
|
||||
|
||||
function createEmptySetting(): NotificationSettingInterface {
|
||||
return {
|
||||
id: null,
|
||||
jobAgentName: '',
|
||||
workTimeDay: false,
|
||||
workTimeEvening: false,
|
||||
workTimeNight: false,
|
||||
workTimeWeekend: false,
|
||||
workTypePermanent: false,
|
||||
workTypeFreelance: false,
|
||||
workTypePartTime: false,
|
||||
workTypeSubstitute: false,
|
||||
workTypeTemporary: false,
|
||||
workDistance: 50,
|
||||
distanceCenterName: '',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
partTimeHours: null,
|
||||
notifyOnPush: false,
|
||||
notifyOnSms: false,
|
||||
searchText: '',
|
||||
escoIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSetting(setting: NotificationSettingInterface): NotificationSettingInterface {
|
||||
return {
|
||||
...createEmptySetting(),
|
||||
...setting,
|
||||
id: setting.id ?? null,
|
||||
jobAgentName: setting.jobAgentName ?? '',
|
||||
distanceCenterName: setting.distanceCenterName ?? '',
|
||||
searchText: setting.searchText ?? '',
|
||||
escoIds: Array.isArray(setting.escoIds) ? setting.escoIds : [],
|
||||
workDistance: typeof setting.workDistance === 'number' ? setting.workDistance : 50,
|
||||
};
|
||||
}
|
||||
|
||||
function mapTree(source: OccupationCategorizationInterface[]): OccupationTree {
|
||||
return source.map((area) => ({
|
||||
...area,
|
||||
expanded: Boolean(area.expanded),
|
||||
activated: Boolean(area.activated),
|
||||
someIsActive: Boolean(area.someIsActive),
|
||||
subAreas: area.subAreas.map((subArea) => ({
|
||||
...subArea,
|
||||
expanded: Boolean(subArea.expanded),
|
||||
activated: Boolean(subArea.activated),
|
||||
someIsActive: Boolean(subArea.someIsActive),
|
||||
occupations: subArea.occupations.map((occupation) => ({
|
||||
...occupation,
|
||||
activated: Boolean(occupation.activated),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function applySelectedEscos(tree: OccupationTree, escoIds: number[]): OccupationTree {
|
||||
const selected = new Set(escoIds);
|
||||
return tree.map((area) => {
|
||||
const subAreas = area.subAreas.map((subArea) => {
|
||||
const occupations = subArea.occupations.map((occupation) => ({
|
||||
...occupation,
|
||||
activated: selected.has(occupation.id),
|
||||
}));
|
||||
const activeCount = occupations.filter((occ) => occ.activated).length;
|
||||
return {
|
||||
...subArea,
|
||||
occupations,
|
||||
activated: activeCount > 0 && activeCount === occupations.length,
|
||||
someIsActive: activeCount > 0 && activeCount < occupations.length,
|
||||
};
|
||||
});
|
||||
|
||||
const allActive = subAreas.length > 0 && subAreas.every((item) => item.activated);
|
||||
const someActive = subAreas.some((item) => item.activated || item.someIsActive);
|
||||
return {
|
||||
...area,
|
||||
subAreas,
|
||||
activated: allActive,
|
||||
someIsActive: someActive && !allActive,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function collectSelectedEscos(tree: OccupationTree): number[] {
|
||||
const ids: number[] = [];
|
||||
for (const area of tree) {
|
||||
for (const subArea of area.subAreas) {
|
||||
for (const occupation of subArea.occupations) {
|
||||
if (occupation.activated) {
|
||||
ids.push(occupation.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function formatDate(value: Date | string): string {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return date.toLocaleDateString('da-DK', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function companyInitial(value: string | null | undefined): string {
|
||||
if (!value?.trim()) {
|
||||
return 'Ar';
|
||||
}
|
||||
return value.trim().slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function hasAnyActiveJobAgent(settings: NotificationSettingInterface[]): boolean {
|
||||
return settings.some((setting) => (setting.escoIds?.length ?? 0) > 0);
|
||||
}
|
||||
|
||||
export function AiJobAgentPage({ onLogout, onNavigate, onOpenJob }: AiJobAgentPageProps) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('arbejd.sidebar.collapsed') === '1');
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showWorkAreas, setShowWorkAreas] = useState(false);
|
||||
const [isLoadingSettings, setIsLoadingSettings] = useState(true);
|
||||
const [isLoadingNotifications, setIsLoadingNotifications] = useState(true);
|
||||
const [isLoadingMoreNotifications, setIsLoadingMoreNotifications] = useState(false);
|
||||
const [isSavingSetting, setIsSavingSetting] = useState(false);
|
||||
const [isSearchingPlaces, setIsSearchingPlaces] = useState(false);
|
||||
const [hasMoreNotifications, setHasMoreNotifications] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notificationSettings, setNotificationSettings] = useState<NotificationSettingInterface[]>([]);
|
||||
const [notifications, setNotifications] = useState<NotificationInterface[]>([]);
|
||||
const [selectedJobAgentId, setSelectedJobAgentId] = useState<number | 'new'>('new');
|
||||
const [editingSetting, setEditingSetting] = useState<NotificationSettingInterface>(createEmptySetting());
|
||||
const [occupationTree, setOccupationTree] = useState<OccupationTree>([]);
|
||||
const [searchOccupationWord, setSearchOccupationWord] = useState('');
|
||||
const [placeSuggestions, setPlaceSuggestions] = useState<Array<{ place_id?: string; description?: string }>>([]);
|
||||
const viewModel = useMemo(() => new AiJobAgentViewModel(), []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function bootstrap() {
|
||||
setError(null);
|
||||
setIsLoadingSettings(true);
|
||||
setIsLoadingNotifications(true);
|
||||
try {
|
||||
const [settings, rawTree, firstNotifications] = await Promise.all([
|
||||
viewModel.getNotificationSettings(),
|
||||
viewModel.getOccupationTree(),
|
||||
viewModel.getNotifications(0, PAGE_LIMIT),
|
||||
]);
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSettings = settings.map(normalizeSetting);
|
||||
const mappedTree = mapTree(rawTree);
|
||||
const initialSetting = normalizedSettings[0] ?? createEmptySetting();
|
||||
const initialId = normalizedSettings[0]?.id ?? 'new';
|
||||
|
||||
setNotificationSettings(normalizedSettings);
|
||||
setSelectedJobAgentId(initialId);
|
||||
setEditingSetting(initialSetting);
|
||||
setOccupationTree(applySelectedEscos(mappedTree, initialSetting.escoIds));
|
||||
setNotifications(firstNotifications);
|
||||
setHasMoreNotifications(firstNotifications.length === PAGE_LIMIT);
|
||||
} catch (loadError) {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setError(loadError instanceof Error ? loadError.message : 'Kunne ikke indlæse AI JobAgent.');
|
||||
} finally {
|
||||
if (active) {
|
||||
setIsLoadingSettings(false);
|
||||
setIsLoadingNotifications(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [viewModel]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = editingSetting.distanceCenterName?.trim() ?? '';
|
||||
if (!showSettings || query.length < 3) {
|
||||
setPlaceSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setIsSearchingPlaces(true);
|
||||
void viewModel
|
||||
.searchPlaces(query)
|
||||
.then((suggestions) => {
|
||||
setPlaceSuggestions(suggestions);
|
||||
})
|
||||
.catch(() => {
|
||||
setPlaceSuggestions([]);
|
||||
})
|
||||
.finally(() => setIsSearchingPlaces(false));
|
||||
}, 350);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [editingSetting.distanceCenterName, showSettings, viewModel]);
|
||||
|
||||
const filteredOccupations = useMemo(() => {
|
||||
const query = searchOccupationWord.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return [];
|
||||
}
|
||||
const list: Array<{ areaCode: number; subAreaCode: number; occupation: OccupationInterface }> = [];
|
||||
for (const area of occupationTree) {
|
||||
for (const subArea of area.subAreas) {
|
||||
for (const occupation of subArea.occupations) {
|
||||
if (occupation.name.toLowerCase().includes(query)) {
|
||||
list.push({ areaCode: area.areaCode, subAreaCode: subArea.subAreaCode, occupation });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return list.slice(0, 40);
|
||||
}, [occupationTree, searchOccupationWord]);
|
||||
|
||||
const selectedEscoCount = useMemo(() => collectSelectedEscos(occupationTree).length, [occupationTree]);
|
||||
const unseenCount = useMemo(
|
||||
() => notifications.filter((notification) => !notification.seenByUser).length,
|
||||
[notifications],
|
||||
);
|
||||
|
||||
function selectJobAgent(value: number | 'new') {
|
||||
setSelectedJobAgentId(value);
|
||||
setShowWorkAreas(false);
|
||||
setSearchOccupationWord('');
|
||||
|
||||
if (value === 'new') {
|
||||
const empty = createEmptySetting();
|
||||
setEditingSetting(empty);
|
||||
setOccupationTree((prev) => applySelectedEscos(prev, []));
|
||||
return;
|
||||
}
|
||||
|
||||
const setting = notificationSettings.find((item) => item.id === value);
|
||||
const normalized = normalizeSetting(setting ?? createEmptySetting());
|
||||
setEditingSetting(normalized);
|
||||
setOccupationTree((prev) => applySelectedEscos(prev, normalized.escoIds));
|
||||
}
|
||||
|
||||
function updateTreeByArea(areaCode: number, active: boolean) {
|
||||
setOccupationTree((prev) =>
|
||||
prev.map((area) => {
|
||||
if (area.areaCode !== areaCode) {
|
||||
return area;
|
||||
}
|
||||
const subAreas = area.subAreas.map((subArea) => ({
|
||||
...subArea,
|
||||
activated: active,
|
||||
someIsActive: false,
|
||||
occupations: subArea.occupations.map((occupation) => ({
|
||||
...occupation,
|
||||
activated: active,
|
||||
})),
|
||||
}));
|
||||
return {
|
||||
...area,
|
||||
subAreas,
|
||||
activated: active,
|
||||
someIsActive: false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function updateTreeBySubArea(areaCode: number, subAreaCode: number, active: boolean) {
|
||||
setOccupationTree((prev) =>
|
||||
prev.map((area) => {
|
||||
if (area.areaCode !== areaCode) {
|
||||
return area;
|
||||
}
|
||||
|
||||
const subAreas = area.subAreas.map((subArea) => {
|
||||
if (subArea.subAreaCode !== subAreaCode) {
|
||||
return subArea;
|
||||
}
|
||||
return {
|
||||
...subArea,
|
||||
activated: active,
|
||||
someIsActive: false,
|
||||
occupations: subArea.occupations.map((occupation) => ({
|
||||
...occupation,
|
||||
activated: active,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const allActive = subAreas.length > 0 && subAreas.every((item) => item.activated);
|
||||
const someActive = subAreas.some((item) => item.activated || item.someIsActive);
|
||||
|
||||
return {
|
||||
...area,
|
||||
subAreas,
|
||||
activated: allActive,
|
||||
someIsActive: someActive && !allActive,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function updateTreeByOccupation(areaCode: number, subAreaCode: number, occupationId: number) {
|
||||
setOccupationTree((prev) =>
|
||||
prev.map((area) => {
|
||||
if (area.areaCode !== areaCode) {
|
||||
return area;
|
||||
}
|
||||
|
||||
const subAreas = area.subAreas.map((subArea) => {
|
||||
if (subArea.subAreaCode !== subAreaCode) {
|
||||
return subArea;
|
||||
}
|
||||
|
||||
const occupations = subArea.occupations.map((occupation) =>
|
||||
occupation.id === occupationId
|
||||
? { ...occupation, activated: !occupation.activated }
|
||||
: occupation,
|
||||
);
|
||||
|
||||
const activeCount = occupations.filter((occupation) => occupation.activated).length;
|
||||
return {
|
||||
...subArea,
|
||||
occupations,
|
||||
activated: activeCount > 0 && activeCount === occupations.length,
|
||||
someIsActive: activeCount > 0 && activeCount < occupations.length,
|
||||
};
|
||||
});
|
||||
|
||||
const allActive = subAreas.length > 0 && subAreas.every((item) => item.activated);
|
||||
const someActive = subAreas.some((item) => item.activated || item.someIsActive);
|
||||
return {
|
||||
...area,
|
||||
subAreas,
|
||||
activated: allActive,
|
||||
someIsActive: someActive && !allActive,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function saveSetting() {
|
||||
if (!editingSetting.jobAgentName?.trim()) {
|
||||
return;
|
||||
}
|
||||
setIsSavingSetting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload: NotificationSettingInterface = {
|
||||
...editingSetting,
|
||||
jobAgentName: editingSetting.jobAgentName.trim(),
|
||||
searchText: editingSetting.searchText?.trim() || null,
|
||||
distanceCenterName: editingSetting.distanceCenterName?.trim() || null,
|
||||
escoIds: collectSelectedEscos(occupationTree),
|
||||
};
|
||||
|
||||
await viewModel.saveNotificationSetting(selectedJobAgentId, payload);
|
||||
const settings = (await viewModel.getNotificationSettings()).map(normalizeSetting);
|
||||
setNotificationSettings(settings);
|
||||
|
||||
if (selectedJobAgentId === 'new') {
|
||||
const created = settings.find((item) => item.jobAgentName === payload.jobAgentName) ?? settings[0];
|
||||
if (created?.id != null) {
|
||||
setSelectedJobAgentId(created.id);
|
||||
setEditingSetting(created);
|
||||
setOccupationTree((prev) => applySelectedEscos(prev, created.escoIds));
|
||||
}
|
||||
} else {
|
||||
const updated = settings.find((item) => item.id === selectedJobAgentId);
|
||||
if (updated) {
|
||||
setEditingSetting(updated);
|
||||
setOccupationTree((prev) => applySelectedEscos(prev, updated.escoIds));
|
||||
}
|
||||
}
|
||||
|
||||
setShowSettings(false);
|
||||
setShowWorkAreas(false);
|
||||
} catch (saveError) {
|
||||
setError(saveError instanceof Error ? saveError.message : 'Kunne ikke gemme jobagent indstillinger.');
|
||||
} finally {
|
||||
setIsSavingSetting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCurrentSetting() {
|
||||
if (selectedJobAgentId === 'new') {
|
||||
return;
|
||||
}
|
||||
setIsSavingSetting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await viewModel.deleteNotificationSetting(selectedJobAgentId);
|
||||
const settings = (await viewModel.getNotificationSettings()).map(normalizeSetting);
|
||||
setNotificationSettings(settings);
|
||||
const next = settings[0] ?? createEmptySetting();
|
||||
setSelectedJobAgentId(settings[0]?.id ?? 'new');
|
||||
setEditingSetting(next);
|
||||
setOccupationTree((prev) => applySelectedEscos(prev, next.escoIds));
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : 'Kunne ikke slette jobagent.');
|
||||
} finally {
|
||||
setIsSavingSetting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectPlaceSuggestion(placeId: string) {
|
||||
const details = await viewModel.getPlaceDetails(placeId);
|
||||
if (!details) {
|
||||
return;
|
||||
}
|
||||
setEditingSetting((prev) => ({
|
||||
...prev,
|
||||
distanceCenterName: details.address,
|
||||
latitude: details.latitude,
|
||||
longitude: details.longitude,
|
||||
}));
|
||||
setPlaceSuggestions([]);
|
||||
}
|
||||
|
||||
async function openNotificationJob(notification: NotificationInterface) {
|
||||
if (!notification.seenByUser) {
|
||||
void viewModel.markNotificationSeen(notification.id);
|
||||
setNotifications((prev) =>
|
||||
prev.map((item) => (item.id === notification.id ? { ...item, seenByUser: true } : item)),
|
||||
);
|
||||
}
|
||||
|
||||
const fromJobnet = Boolean(notification.jobnetPostingId);
|
||||
const jobId = fromJobnet ? notification.jobnetPostingId : notification.jobPostingId;
|
||||
if (jobId) {
|
||||
onOpenJob(jobId, fromJobnet);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleNotificationBookmark(notification: NotificationInterface) {
|
||||
const save = !notification.saved;
|
||||
try {
|
||||
await viewModel.toggleNotificationBookmark(notification, save);
|
||||
setNotifications((prev) =>
|
||||
prev.map((item) => (item.id === notification.id ? { ...item, saved: save } : item)),
|
||||
);
|
||||
} catch {
|
||||
setError('Kunne ikke opdatere gemt status på notifikation.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreNotifications() {
|
||||
if (!hasMoreNotifications || isLoadingMoreNotifications) {
|
||||
return;
|
||||
}
|
||||
setIsLoadingMoreNotifications(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await viewModel.getNotifications(notifications.length, PAGE_LIMIT);
|
||||
setNotifications((prev) => [...prev, ...next]);
|
||||
setHasMoreNotifications(next.length === PAGE_LIMIT);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : 'Kunne ikke indlæse flere notifikationer.');
|
||||
} finally {
|
||||
setIsLoadingMoreNotifications(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="dashboard-layout">
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
activeKey="ai-jobagent"
|
||||
onToggle={() => setSidebarCollapsed((prev) => { const next = !prev; window.localStorage.setItem('arbejd.sidebar.collapsed', next ? '1' : '0'); return next; })}
|
||||
onSelect={(key) => {
|
||||
if (key === 'dashboard' || key === 'cv' || key === 'jobs' || key === 'beskeder' || key === 'ai-jobagent' || key === 'ai-agent' || key === 'simulator' || key === 'abonnement') {
|
||||
onNavigate(key);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="dashboard-main">
|
||||
<Topbar title="AI JobAgent" userName="Anders Jensen" planLabel="Jobseeker Pro" onLogout={onLogout} />
|
||||
|
||||
<div className="dashboard-scroll">
|
||||
{error ? <p className="status error">{error}</p> : null}
|
||||
|
||||
<article className="glass-panel dash-card jobagent-hero-card">
|
||||
<div className="jobagent-hero-top">
|
||||
<div>
|
||||
<span className="jobagent-kicker">AI JobAgent</span>
|
||||
<h3>Automatisk jobmatch med dit CV</h3>
|
||||
<p>
|
||||
Jobagenten følger nye opslag og fremhæver relevante job baseret på dine valgte områder,
|
||||
arbejdstype og afstand.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-btn"
|
||||
onClick={() => setShowSettings((prev) => !prev)}
|
||||
disabled={isLoadingSettings}
|
||||
>
|
||||
{showSettings ? 'Luk indstillinger' : 'Åbn indstillinger'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isLoadingSettings ? (
|
||||
<div className="jobagent-hero-metrics">
|
||||
<div className="jobagent-metric-pill">
|
||||
<span>Jobagenter</span>
|
||||
<strong>{notificationSettings.length}</strong>
|
||||
</div>
|
||||
<div className="jobagent-metric-pill">
|
||||
<span>Aktive filtre</span>
|
||||
<strong>{selectedEscoCount}</strong>
|
||||
</div>
|
||||
<div className="jobagent-metric-pill">
|
||||
<span>Nye notifikationer</span>
|
||||
<strong>{unseenCount}</strong>
|
||||
</div>
|
||||
<p className="jobagent-summary-note">
|
||||
{hasAnyActiveJobAgent(notificationSettings)
|
||||
? 'Mindst én jobagent er aktiv.'
|
||||
: 'Vælg stillingstyper og områder for at aktivere en jobagent.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="jobagent-summary-note">Indlæser jobagenter...</p>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<section className="jobagent-layout-grid">
|
||||
{showSettings ? (
|
||||
<article className="glass-panel dash-card jobagent-settings-card">
|
||||
<div className="jobagent-settings-top">
|
||||
<div>
|
||||
<span className="jobagent-kicker">Opsaetning</span>
|
||||
<h4>Jobagent indstillinger</h4>
|
||||
</div>
|
||||
<div className="jobagent-settings-actions">
|
||||
{selectedJobAgentId !== 'new' ? (
|
||||
<button type="button" className="secondary-btn danger" onClick={() => void deleteCurrentSetting()} disabled={isSavingSetting}>
|
||||
Slet
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="primary-btn" onClick={() => void saveSetting()} disabled={isSavingSetting || !editingSetting.jobAgentName?.trim()}>
|
||||
{isSavingSetting ? 'Gemmer...' : 'Gem'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="jobagent-settings-grid">
|
||||
<label className="jobagent-field">
|
||||
<span>Vælg jobagent</span>
|
||||
<select
|
||||
className="field-input"
|
||||
value={selectedJobAgentId === 'new' ? 'new' : String(selectedJobAgentId)}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
selectJobAgent(value === 'new' ? 'new' : Number(value));
|
||||
}}
|
||||
>
|
||||
{notificationSettings.map((setting) => (
|
||||
<option key={setting.id ?? Math.random()} value={String(setting.id)}>
|
||||
{setting.jobAgentName?.trim() || 'Uden navn'}
|
||||
</option>
|
||||
))}
|
||||
<option value="new">Opret ny jobagent</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="jobagent-field">
|
||||
<span>Jobagent navn</span>
|
||||
<input
|
||||
className="field-input"
|
||||
value={editingSetting.jobAgentName ?? ''}
|
||||
onChange={(event) => setEditingSetting((prev) => ({ ...prev, jobAgentName: event.target.value }))}
|
||||
placeholder="Fx. Min jobagent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="jobagent-field">
|
||||
<span>Søgetekst</span>
|
||||
<input
|
||||
className="field-input"
|
||||
value={editingSetting.searchText ?? ''}
|
||||
onChange={(event) => setEditingSetting((prev) => ({ ...prev, searchText: event.target.value }))}
|
||||
placeholder="Fritekst til søgning"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="jobagent-field">
|
||||
<span>Arbejdsområder</span>
|
||||
<button type="button" className="secondary-btn" onClick={() => setShowWorkAreas((prev) => !prev)}>
|
||||
{showWorkAreas ? 'Luk arbejdsområder' : 'Åbn arbejdsområder'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="jobagent-field">
|
||||
<span>Arbejdstype</span>
|
||||
<div className="jobagent-toggle-row">
|
||||
<button
|
||||
type="button"
|
||||
className={editingSetting.workTypePermanent ? 'tab-btn active' : 'tab-btn'}
|
||||
onClick={() => setEditingSetting((prev) => ({ ...prev, workTypePermanent: !prev.workTypePermanent }))}
|
||||
>
|
||||
Fast
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={editingSetting.workTypePartTime ? 'tab-btn active' : 'tab-btn'}
|
||||
onClick={() => setEditingSetting((prev) => ({ ...prev, workTypePartTime: !prev.workTypePartTime }))}
|
||||
>
|
||||
Deltid
|
||||
</button>
|
||||
</div>
|
||||
{editingSetting.workTypePartTime ? (
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={37}
|
||||
className="field-input"
|
||||
value={editingSetting.partTimeHours ?? ''}
|
||||
onChange={(event) =>
|
||||
setEditingSetting((prev) => ({
|
||||
...prev,
|
||||
partTimeHours: event.target.value ? Number(event.target.value) : null,
|
||||
}))
|
||||
}
|
||||
placeholder="Timer pr. uge"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="jobagent-field jobagent-location-field">
|
||||
<span>Center for afstand</span>
|
||||
<input
|
||||
className="field-input"
|
||||
value={editingSetting.distanceCenterName ?? ''}
|
||||
onChange={(event) =>
|
||||
setEditingSetting((prev) => ({
|
||||
...prev,
|
||||
distanceCenterName: event.target.value,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
}))
|
||||
}
|
||||
placeholder="Søg adresse"
|
||||
/>
|
||||
{isSearchingPlaces ? <small>Søger adresser...</small> : null}
|
||||
{placeSuggestions.length > 0 ? (
|
||||
<div className="jobagent-place-suggestions glass-panel">
|
||||
{placeSuggestions.map((suggestion) => (
|
||||
<button
|
||||
key={`${suggestion.place_id}-${suggestion.description}`}
|
||||
type="button"
|
||||
onClick={() => suggestion.place_id && void selectPlaceSuggestion(suggestion.place_id)}
|
||||
>
|
||||
{suggestion.description}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="jobagent-field jobagent-distance-field">
|
||||
<span>Arbejdsafstand: {editingSetting.workDistance ?? 50} km</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={500}
|
||||
value={editingSetting.workDistance ?? 50}
|
||||
onChange={(event) => setEditingSetting((prev) => ({ ...prev, workDistance: Number(event.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showWorkAreas ? (
|
||||
<section className="jobagent-workareas-card">
|
||||
<div className="jobagent-workareas-top">
|
||||
<h5>Arbejdsområder</h5>
|
||||
<span>{selectedEscoCount} valgt</span>
|
||||
</div>
|
||||
<div className="jobagent-workareas">
|
||||
<input
|
||||
className="field-input"
|
||||
value={searchOccupationWord}
|
||||
onChange={(event) => setSearchOccupationWord(event.target.value)}
|
||||
placeholder="Søg arbejdsområde"
|
||||
/>
|
||||
|
||||
{searchOccupationWord.trim() && filteredOccupations.length > 0 ? (
|
||||
<div className="jobagent-workareas-search-list">
|
||||
{filteredOccupations.map((entry) => (
|
||||
<label key={`${entry.areaCode}-${entry.subAreaCode}-${entry.occupation.id}`} className="jobagent-checkline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(entry.occupation.activated)}
|
||||
onChange={() => updateTreeByOccupation(entry.areaCode, entry.subAreaCode, entry.occupation.id)}
|
||||
/>
|
||||
<span>{entry.occupation.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="jobagent-workareas-tree">
|
||||
{occupationTree.map((area) => (
|
||||
<div key={area.areaCode} className="jobagent-workarea-node">
|
||||
<div className="jobagent-workarea-row">
|
||||
<button type="button" className="jobagent-expand-btn" onClick={() =>
|
||||
setOccupationTree((prev) => prev.map((item) => item.areaCode === area.areaCode ? { ...item, expanded: !item.expanded } : item))
|
||||
}>
|
||||
{area.expanded ? '▾' : '▸'}
|
||||
</button>
|
||||
<label className="jobagent-checkline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={area.activated || area.someIsActive}
|
||||
onChange={() => updateTreeByArea(area.areaCode, !(area.activated || area.someIsActive))}
|
||||
/>
|
||||
<span>{area.areaName}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{area.expanded ? (
|
||||
<div className="jobagent-workarea-children">
|
||||
{area.subAreas.map((subArea: SubAreaInterface) => (
|
||||
<div key={`${area.areaCode}-${subArea.subAreaCode}`} className="jobagent-workarea-node">
|
||||
<div className="jobagent-workarea-row">
|
||||
<button type="button" className="jobagent-expand-btn" onClick={() =>
|
||||
setOccupationTree((prev) =>
|
||||
prev.map((item) =>
|
||||
item.areaCode === area.areaCode
|
||||
? {
|
||||
...item,
|
||||
subAreas: item.subAreas.map((child) =>
|
||||
child.subAreaCode === subArea.subAreaCode ? { ...child, expanded: !child.expanded } : child,
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}>
|
||||
{subArea.expanded ? '▾' : '▸'}
|
||||
</button>
|
||||
<label className="jobagent-checkline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subArea.activated || subArea.someIsActive}
|
||||
onChange={() => updateTreeBySubArea(area.areaCode, subArea.subAreaCode, !(subArea.activated || subArea.someIsActive))}
|
||||
/>
|
||||
<span>{subArea.subAreaName}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{subArea.expanded ? (
|
||||
<div className="jobagent-workarea-children">
|
||||
{subArea.occupations.map((occupation) => (
|
||||
<label key={occupation.id} className="jobagent-checkline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(occupation.activated)}
|
||||
onChange={() => updateTreeByOccupation(area.areaCode, subArea.subAreaCode, occupation.id)}
|
||||
/>
|
||||
<span>{occupation.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
<article className="glass-panel dash-card jobagent-notifications-card">
|
||||
<div className="jobagent-notifications-top">
|
||||
<div>
|
||||
<span className="jobagent-kicker">Live feed</span>
|
||||
<h4>Notifikationer</h4>
|
||||
</div>
|
||||
<span className="jobagent-notification-count">{notifications.length}</span>
|
||||
</div>
|
||||
{isLoadingNotifications ? <p>Indlæser notifikationer...</p> : null}
|
||||
|
||||
{!isLoadingNotifications && notifications.length === 0 ? (
|
||||
<p>Ingen notifikationer endnu.</p>
|
||||
) : null}
|
||||
|
||||
<div className="jobagent-notification-list">
|
||||
<div className="jobs-results-grid jobagent-results-grid">
|
||||
{notifications.map((notification) => (
|
||||
<article
|
||||
key={notification.id}
|
||||
className={notification.seenByUser ? 'glass-panel jobs-result-card jobagent-result-card' : 'glass-panel jobs-result-card jobagent-result-card unseen'}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => void openNotificationJob(notification)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
void openNotificationJob(notification);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="jobs-result-top">
|
||||
<div className="jobs-result-brand">
|
||||
{notification.logoUrl ? (
|
||||
<img className="jobs-result-logo-img" src={notification.logoUrl} alt={notification.companyName || 'Virksomhed'} />
|
||||
) : (
|
||||
<div className="jobs-result-logo">{companyInitial(notification.companyName)}</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="jobs-result-company">{notification.companyName || 'Ukendt virksomhed'}</p>
|
||||
<p className="jobs-result-address">
|
||||
{notification.city ? `${notification.city} ${notification.zip || ''}` : 'Ukendt lokation'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={notification.saved ? 'jobs-bookmark-icon active' : 'jobs-bookmark-icon'}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClickCapture={(event) => event.stopPropagation()}
|
||||
onClick={() => void toggleNotificationBookmark(notification)}
|
||||
aria-label={notification.saved ? 'Fjern gemt job' : 'Gem job'}
|
||||
title={notification.saved ? 'Fjern gemt' : 'Gem job'}
|
||||
>
|
||||
{notification.saved ? '★' : '☆'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h5 className="jobs-result-title">{notification.jobTitle || 'Jobagent match'}</h5>
|
||||
<p className="jobs-result-occupation">{notification.escoTitle || 'AI JobAgent forslag'}</p>
|
||||
<p className="jobs-result-description">
|
||||
{notification.seenByUser ? 'Match fra din jobagent baseret på dine valgte filtre.' : 'Nyt match fra din jobagent.'}
|
||||
</p>
|
||||
|
||||
<div className="jobs-result-tags">
|
||||
<span className="chip">{formatDate(notification.notificationDate)}</span>
|
||||
{notification.distance ? <span className="chip">{Number(notification.distance).toFixed(1)} km</span> : null}
|
||||
<span className="chip">{notification.seenByUser ? 'Set' : 'Ny'}</span>
|
||||
</div>
|
||||
|
||||
<div className="jobs-result-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="primary-btn jobs-card-primary-btn"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void openNotificationJob(notification);
|
||||
}}
|
||||
>
|
||||
Åbn job
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasMoreNotifications ? (
|
||||
<button type="button" className="secondary-btn jobagent-load-more" onClick={() => void loadMoreNotifications()} disabled={isLoadingMoreNotifications}>
|
||||
{isLoadingMoreNotifications ? 'Indlæser...' : 'Indlæs flere'}
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
47
src/presentation/auth/hooks/useAuthViewModel.ts
Normal file
47
src/presentation/auth/hooks/useAuthViewModel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
37
src/presentation/auth/pages/ForgotPasswordPage.tsx
Normal file
37
src/presentation/auth/pages/ForgotPasswordPage.tsx
Normal 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, så 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>
|
||||
);
|
||||
}
|
||||
56
src/presentation/auth/pages/LoginPage.tsx
Normal file
56
src/presentation/auth/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/presentation/auth/pages/RegisterPage.tsx
Normal file
111
src/presentation/auth/pages/RegisterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
459
src/presentation/cv/hooks/useCvPageViewModel.ts
Normal file
459
src/presentation/cv/hooks/useCvPageViewModel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
1482
src/presentation/cv/pages/CvPage.tsx
Normal file
1482
src/presentation/cv/pages/CvPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
39
src/presentation/dashboard/hooks/useDashboardViewModel.ts
Normal file
39
src/presentation/dashboard/hooks/useDashboardViewModel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
251
src/presentation/dashboard/pages/DashboardPage.tsx
Normal file
251
src/presentation/dashboard/pages/DashboardPage.tsx
Normal 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')}>
|
||||
Gå 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>
|
||||
);
|
||||
}
|
||||
170
src/presentation/jobs/hooks/useJobsPageViewModel.ts
Normal file
170
src/presentation/jobs/hooks/useJobsPageViewModel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
279
src/presentation/jobs/pages/JobDetailPage.tsx
Normal file
279
src/presentation/jobs/pages/JobDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
552
src/presentation/jobs/pages/JobsPage.tsx
Normal file
552
src/presentation/jobs/pages/JobsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
src/presentation/layout/components/Sidebar.tsx
Normal file
126
src/presentation/layout/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/presentation/layout/components/ThemeToggle.tsx
Normal file
35
src/presentation/layout/components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
src/presentation/layout/components/Topbar.tsx
Normal file
107
src/presentation/layout/components/Topbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/presentation/messages/hooks/useMessagesViewModel.ts
Normal file
89
src/presentation/messages/hooks/useMessagesViewModel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
202
src/presentation/messages/pages/BeskederPage.tsx
Normal file
202
src/presentation/messages/pages/BeskederPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/presentation/router/useBrowserRoute.ts
Normal file
37
src/presentation/router/useBrowserRoute.ts
Normal 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 };
|
||||
}
|
||||
|
||||
133
src/presentation/simulator/pages/JobSimulatorPage.tsx
Normal file
133
src/presentation/simulator/pages/JobSimulatorPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
95
src/presentation/subscription/pages/SubscriptionPage.tsx
Normal file
95
src/presentation/subscription/pages/SubscriptionPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user