308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
ArrowRight,
|
|
Bot,
|
|
MapPin,
|
|
Monitor,
|
|
PenSquare,
|
|
Save,
|
|
Sparkles,
|
|
Target,
|
|
} from 'lucide-react';
|
|
import type { EscoInterface } from '../../../mvvm/models/esco.interface';
|
|
import type { JobAgentFilterInterface } from '../../../mvvm/models/job-agent-filter.interface';
|
|
import { AiAgentViewModel, type AiAgentInitialData } from '../../../mvvm/viewmodels/AiAgentViewModel';
|
|
import { JobsPageViewModel, type JobsListItem } from '../../../mvvm/viewmodels/JobsPageViewModel';
|
|
import { DashboardSidebar, type DashboardNavKey } from '../../dashboard/components/DashboardSidebar';
|
|
import { DashboardTopbar } from '../../dashboard/components/DashboardTopbar';
|
|
import '../../dashboard/pages/dashboard.css';
|
|
import './ai-agent.css';
|
|
|
|
interface AiAgentPageProps {
|
|
onLogout: () => void;
|
|
onNavigate: (target: DashboardNavKey) => void;
|
|
onOpenJobDetail: (jobId: string, fromJobnet: boolean, returnPage?: DashboardNavKey) => void;
|
|
onToggleTheme: () => void;
|
|
theme: 'light' | 'dark';
|
|
}
|
|
|
|
const EMPTY_DATA: AiAgentInitialData = {
|
|
paymentOverview: null,
|
|
jobAgentFilters: [],
|
|
cvSuggestions: [],
|
|
escos: [],
|
|
};
|
|
|
|
function initials(value: string): string {
|
|
return value.trim().slice(0, 1).toUpperCase() || 'A';
|
|
}
|
|
|
|
function deriveMatch(index: number): number {
|
|
return Math.max(68, 98 - (index * 4));
|
|
}
|
|
|
|
function byLabelEscos(data: EscoInterface[], query: string): EscoInterface[] {
|
|
const trimmed = query.trim().toLowerCase();
|
|
if (!trimmed) {
|
|
return [];
|
|
}
|
|
return data.filter((item) => item.preferedLabelDa.toLowerCase().includes(trimmed)).slice(0, 8);
|
|
}
|
|
|
|
export function AiAgentPage({ onLogout, onNavigate, onOpenJobDetail, onToggleTheme, theme }: AiAgentPageProps) {
|
|
const aiViewModel = useMemo(() => new AiAgentViewModel(), []);
|
|
const jobsViewModel = useMemo(() => new JobsPageViewModel(), []);
|
|
|
|
const [name, setName] = useState('Lasse');
|
|
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
|
const [data, setData] = useState<AiAgentInitialData>(EMPTY_DATA);
|
|
const [jobs, setJobs] = useState<JobsListItem[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
const [agentName, setAgentName] = useState('');
|
|
const [keywords, setKeywords] = useState('');
|
|
const [workArea, setWorkArea] = useState('');
|
|
const [workType, setWorkType] = useState('');
|
|
const [workLocation, setWorkLocation] = useState('');
|
|
const [distance, setDistance] = useState(25);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
|
|
async function load() {
|
|
setIsLoading(true);
|
|
|
|
const [profile, initialData, jobsData] = await Promise.all([
|
|
aiViewModel.getCandidateProfile(),
|
|
aiViewModel.loadInitialData(),
|
|
jobsViewModel.getTabItems('jobs'),
|
|
]);
|
|
|
|
if (!active) {
|
|
return;
|
|
}
|
|
|
|
setName(profile.name);
|
|
setImageUrl(profile.imageUrl);
|
|
setData(initialData);
|
|
setJobs(jobsData);
|
|
setIsLoading(false);
|
|
}
|
|
|
|
void load();
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [aiViewModel, jobsViewModel]);
|
|
|
|
async function refreshAgents() {
|
|
const next = await aiViewModel.loadInitialData();
|
|
setData(next);
|
|
}
|
|
|
|
async function handleSaveAgent() {
|
|
const query = keywords.trim() || agentName.trim() || workArea.trim();
|
|
const suggestion = aiViewModel.getEscoSuggestions(query, data.escos, data.jobAgentFilters)[0] || byLabelEscos(data.escos, query)[0];
|
|
if (!suggestion) {
|
|
return;
|
|
}
|
|
|
|
await aiViewModel.addEscoToFilter(suggestion.id);
|
|
await refreshAgents();
|
|
|
|
setAgentName('');
|
|
setKeywords('');
|
|
setWorkArea('');
|
|
setWorkType('');
|
|
setWorkLocation('');
|
|
setDistance(25);
|
|
}
|
|
|
|
async function handleToggleVisibility(filter: JobAgentFilterInterface) {
|
|
await aiViewModel.setFilterVisibility(filter, !filter.visible);
|
|
await refreshAgents();
|
|
}
|
|
|
|
const activeFilters = data.jobAgentFilters;
|
|
const recommendedJobs = (jobs.length > 0 ? jobs : []).slice(0, 6);
|
|
|
|
return (
|
|
<section className={`dash-root ${theme === 'dark' ? 'theme-dark' : ''}`}>
|
|
<div className="dash-orb dash-orb-1" />
|
|
<div className="dash-orb dash-orb-2" />
|
|
<div className="dash-orb dash-orb-3" />
|
|
|
|
<DashboardSidebar active="agents" onNavigate={onNavigate} />
|
|
|
|
<main className="dash-main custom-scrollbar ai-agent-main">
|
|
<DashboardTopbar
|
|
name={name}
|
|
imageUrl={imageUrl}
|
|
onLogout={onLogout}
|
|
theme={theme}
|
|
onToggleTheme={onToggleTheme}
|
|
/>
|
|
|
|
<div className="ai-head">
|
|
<h1>Jobagenter</h1>
|
|
<p>Saet din jobsogning pa autopilot. Lad agenter overvage og matche dig med de perfekte jobs.</p>
|
|
</div>
|
|
|
|
<section className="ai-create-card">
|
|
<div className="ai-create-title">
|
|
<div className="ai-create-icon"><Bot size={20} strokeWidth={1.8} /></div>
|
|
<h2>Opret ny jobagent</h2>
|
|
</div>
|
|
|
|
<div className="ai-form-grid">
|
|
<div className="ai-field">
|
|
<label>Agentens navn</label>
|
|
<input value={agentName} onChange={(event) => setAgentName(event.target.value)} placeholder="F.eks. Frontend Udvikler CPH" />
|
|
</div>
|
|
|
|
<div className="ai-field">
|
|
<label>Sogetekst / Nogleord</label>
|
|
<input value={keywords} onChange={(event) => setKeywords(event.target.value)} placeholder="F.eks. React, TypeScript, Tailwind" />
|
|
</div>
|
|
|
|
<div className="ai-field">
|
|
<label>Arbejdsomrade</label>
|
|
<select value={workArea} onChange={(event) => setWorkArea(event.target.value)}>
|
|
<option value="">Vaelg branche</option>
|
|
<option value="IT & Udvikling">IT & Udvikling</option>
|
|
<option value="Design & UX">Design & UX</option>
|
|
<option value="Salg & Marketing">Salg & Marketing</option>
|
|
<option value="HR & Ledelse">HR & Ledelse</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="ai-field">
|
|
<label>Arbejdstype</label>
|
|
<select value={workType} onChange={(event) => setWorkType(event.target.value)}>
|
|
<option value="">Vaelg type</option>
|
|
<option value="Fuldtid">Fuldtid</option>
|
|
<option value="Deltid">Deltid</option>
|
|
<option value="Freelance">Freelance / Konsulent</option>
|
|
<option value="Studiejob">Studiejob</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="ai-field">
|
|
<label>Arbejdssted</label>
|
|
<div className="ai-location-wrap">
|
|
<MapPin size={16} strokeWidth={1.8} />
|
|
<input value={workLocation} onChange={(event) => setWorkLocation(event.target.value)} placeholder="By eller postnummer" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ai-field ai-distance-field">
|
|
<div className="ai-distance-head">
|
|
<label>Maks. distance</label>
|
|
<span>{distance} km</span>
|
|
</div>
|
|
<input type="range" min={0} max={100} value={distance} onChange={(event) => setDistance(Number(event.target.value))} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ai-create-actions">
|
|
<button type="button" onClick={() => void handleSaveAgent()}><Save size={16} strokeWidth={1.8} /> Gem jobagent</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="ai-agents-section">
|
|
<h3>Dine aktive agenter</h3>
|
|
<div className="ai-agents-row custom-scrollbar">
|
|
{activeFilters.length === 0 ? <p className="dash-loading">Ingen aktive agenter endnu.</p> : null}
|
|
{activeFilters.map((filter, index) => (
|
|
<article key={filter.id} className="ai-agent-chip-card">
|
|
<div className="ai-agent-card-head">
|
|
<div className="ai-agent-chip-left">
|
|
<div className={`ai-agent-mini-icon ${index % 2 === 0 ? 'teal' : 'indigo'}`}>
|
|
{index % 2 === 0 ? <Monitor size={16} strokeWidth={1.8} /> : <PenSquare size={16} strokeWidth={1.8} />}
|
|
</div>
|
|
<div>
|
|
<h4>{filter.escoName}</h4>
|
|
<p>{filter.isCalculated ? 'Aktiv siden i går' : 'Aktiv'}</p>
|
|
</div>
|
|
</div>
|
|
<button type="button" className={filter.visible ? 'ai-toggle on' : 'ai-toggle'} onClick={() => void handleToggleVisibility(filter)}>
|
|
<span />
|
|
</button>
|
|
</div>
|
|
<div className="ai-tags">
|
|
<span>{filter.escoName}</span>
|
|
<span>{workLocation || 'København'}</span>
|
|
<span>{distance} km</span>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="ai-jobs-section">
|
|
<div className="ai-jobs-head">
|
|
<h3><Sparkles size={16} strokeWidth={1.8} /> Anbefalede jobs til dig</h3>
|
|
<span>Opdateret for 5 min siden</span>
|
|
</div>
|
|
|
|
<div className="ai-jobs-grid">
|
|
{isLoading ? <p className="dash-loading">Indlaeser anbefalinger...</p> : null}
|
|
{!isLoading && recommendedJobs.length === 0 ? <p className="dash-loading">Ingen jobanbefalinger fundet endnu.</p> : null}
|
|
{recommendedJobs.map((job, index) => (
|
|
<article
|
|
key={job.id}
|
|
className="ai-job-card"
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => onOpenJobDetail(job.id, job.fromJobnet, 'agents')}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
onOpenJobDetail(job.id, job.fromJobnet, 'agents');
|
|
}
|
|
}}
|
|
>
|
|
<div className={`ai-job-rail ${index % 3 === 2 ? 'indigo' : 'teal'}`} />
|
|
<div className="ai-job-top">
|
|
{job.companyLogoImage || job.logoUrl
|
|
? <img src={job.companyLogoImage || job.logoUrl} alt={job.companyName} className="ai-company-logo" />
|
|
: <div className="ai-company-logo-fallback">{initials(job.companyName)}</div>}
|
|
<div className="ai-match-col">
|
|
<div className="ai-match-pill"><Target size={13} strokeWidth={1.8} /> {deriveMatch(index)}% Match</div>
|
|
<small>Via: {activeFilters[0]?.escoName || 'Jobagent'}</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ai-job-title-wrap">
|
|
<h4>{job.title}</h4>
|
|
<p>{job.companyName} • {job.address || 'Lokation'}</p>
|
|
</div>
|
|
|
|
<div className="ai-job-tags">
|
|
<span>{job.occupationName || 'Frontend'}</span>
|
|
<span>{job.fromJobnet ? 'Jobnet' : 'Arbejd.com'}</span>
|
|
<span>{job.candidateDistance != null ? `${Math.round(job.candidateDistance)} km` : 'Remote'}</span>
|
|
</div>
|
|
|
|
<div className="ai-job-bottom">
|
|
<span>Slået op for nyligt</span>
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onOpenJobDetail(job.id, job.fromJobnet, 'agents');
|
|
}}
|
|
>
|
|
Læs mere <ArrowRight size={14} strokeWidth={1.8} />
|
|
</button>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</section>
|
|
);
|
|
}
|