Initial React project
This commit is contained in:
@@ -1,264 +1,306 @@
|
||||
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';
|
||||
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: () => Promise<void>;
|
||||
onNavigate: (key: 'dashboard' | 'cv' | 'jobs' | 'beskeder' | 'ai-jobagent' | 'ai-agent' | 'simulator' | 'abonnement') => void;
|
||||
activeNavKey?: 'ai-jobagent' | 'ai-agent';
|
||||
onLogout: () => void;
|
||||
onNavigate: (target: DashboardNavKey) => void;
|
||||
onOpenJobDetail: (jobId: string, fromJobnet: boolean, returnPage?: DashboardNavKey) => void;
|
||||
onToggleTheme: () => void;
|
||||
theme: 'light' | 'dark';
|
||||
}
|
||||
|
||||
type ImprovementType = 'education' | 'language' | 'driversLicense' | 'qualification' | 'certificate';
|
||||
const EMPTY_DATA: AiAgentInitialData = {
|
||||
paymentOverview: null,
|
||||
jobAgentFilters: [],
|
||||
cvSuggestions: [],
|
||||
escos: [],
|
||||
};
|
||||
|
||||
function iconForImprovement(type: ImprovementType): string {
|
||||
if (type === 'qualification') {
|
||||
return '★';
|
||||
}
|
||||
if (type === 'driversLicense') {
|
||||
return '↗';
|
||||
}
|
||||
if (type === 'certificate') {
|
||||
return '✓';
|
||||
}
|
||||
if (type === 'education') {
|
||||
return '▦';
|
||||
}
|
||||
return '◉';
|
||||
function initials(value: string): string {
|
||||
return value.trim().slice(0, 1).toUpperCase() || 'A';
|
||||
}
|
||||
|
||||
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 deriveMatch(index: number): number {
|
||||
return Math.max(68, 98 - (index * 4));
|
||||
}
|
||||
|
||||
function withImprovementType(value: ImprovementInterface): value is ImprovementInterface & { improvementType: ImprovementType } {
|
||||
return typeof (value as { improvementType?: string }).improvementType === 'string';
|
||||
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, 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();
|
||||
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();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSuggestionId && cvSuggestions.length > 0) {
|
||||
setSelectedSuggestionId(cvSuggestions[0].escoId);
|
||||
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;
|
||||
}
|
||||
|
||||
if (selectedSuggestionId && !cvSuggestions.some((entry) => entry.escoId === selectedSuggestionId)) {
|
||||
setSelectedSuggestionId(cvSuggestions[0]?.escoId ?? null);
|
||||
}
|
||||
}, [selectedSuggestionId, cvSuggestions]);
|
||||
await aiViewModel.addEscoToFilter(suggestion.id);
|
||||
await refreshAgents();
|
||||
|
||||
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('');
|
||||
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="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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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" />
|
||||
|
||||
<main className="dashboard-main">
|
||||
<Topbar title="AI Agent" userName="Anders Jensen" planLabel="Jobseeker Pro" onLogout={onLogout} />
|
||||
<DashboardSidebar active="ai-agent" onNavigate={onNavigate} />
|
||||
|
||||
<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>
|
||||
<main className="dash-main custom-scrollbar ai-agent-main">
|
||||
<DashboardTopbar
|
||||
name={name}
|
||||
imageUrl={imageUrl}
|
||||
onLogout={onLogout}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
/>
|
||||
|
||||
{error ? <p className="status error">{error}</p> : null}
|
||||
<div className="ai-head">
|
||||
<h1>AI-agenter</h1>
|
||||
<p>Saet din jobsogning pa autopilot. Lad AI overvage og matche dig med de perfekte jobs.</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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 AI-agent</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-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 className="ai-field">
|
||||
<label>Sogetekst / Nogleord</label>
|
||||
<input value={keywords} onChange={(event) => setKeywords(event.target.value)} placeholder="F.eks. React, TypeScript, Tailwind" />
|
||||
</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 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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{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 className="ai-field ai-distance-field">
|
||||
<div className="ai-distance-head">
|
||||
<label>Maks. distance</label>
|
||||
<span>{distance} km</span>
|
||||
</div>
|
||||
) : null}
|
||||
<input type="range" min={0} max={100} value={distance} onChange={(event) => setDistance(Number(event.target.value))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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-create-actions">
|
||||
<button type="button" onClick={() => void handleSaveAgent()}><Save size={16} strokeWidth={1.8} /> Gem AI-agent</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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)}
|
||||
<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>
|
||||
<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>
|
||||
<h4>{filter.escoName}</h4>
|
||||
<p>{filter.isCalculated ? 'Aktiv siden i går' : 'Aktiv'}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
|
||||
{hasMoreNotifications ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-btn ai-show-more-btn"
|
||||
onClick={() => setShowAllNotifications((prev) => !prev)}
|
||||
<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, 'ai-agent')}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onOpenJobDetail(job.id, job.fromJobnet, 'ai-agent');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showAllNotifications ? 'Vis færre' : 'Vis flere'}
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
</div>
|
||||
<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 || 'AI-agent'}</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, 'ai-agent');
|
||||
}}
|
||||
>
|
||||
Læs mere <ArrowRight size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user