Initial import

This commit is contained in:
Stein Helge Riise
2025-11-17 08:32:46 +01:00
commit ede31fbb7e
129 changed files with 9514 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useMemo, useState } from 'react'
import { EmployerView } from './EmployerView'
import { PersonView } from './PersonView'
import { HomeLanding } from './HomeLanding'
import type { Guid, Role } from '../types'
import { isGuidLike } from '../utils'
import { Api } from '../api'
type Session = { role: Role; id: Guid } | null
function loadSession(): Session {
try {
const text = localStorage.getItem('minattest.session')
if (!text) return null
const obj = JSON.parse(text)
if ((obj.role === 'privat' || obj.role === 'bedrift') && typeof obj.id === 'string') return obj
} catch {
// ignore
}
return null
}
function saveSession(s: Session) {
if (s) localStorage.setItem('minattest.session', JSON.stringify(s))
else localStorage.removeItem('minattest.session')
}
export function App() {
const [session, setSession] = useState<Session>(() => loadSession())
const [selectedRole, setSelectedRole] = useState<Role>('privat')
const [pendingId, setPendingId] = useState('')
const [loginErr, setLoginErr] = useState<string | null>(null)
const [identity, setIdentity] = useState<string | null>(null)
const performLogin = () => {
setLoginErr(null)
if (!isGuidLike(pendingId)) { setLoginErr('Ugyldig GUID'); return }
const s = { role: selectedRole, id: pendingId as Guid }
setSession(s); saveSession(s)
}
const onLogout = () => {
setSession(null)
saveSession(null)
}
// Load identity label for header (email for privat, company name for bedrift)
useEffect(() => {
if (!session) { setIdentity(null); return }
const ac = new AbortController()
;(async () => {
try {
if (session.role === 'privat') {
const p = await Api.getPerson(session.id, ac.signal)
setIdentity(p.email ?? 'Ukjent epost')
} else {
const e = await Api.getEmployer(session.id, ac.signal)
setIdentity(e.name || 'Ukjent bedrift')
}
} catch {
setIdentity(session.role === 'privat' ? 'Ukjent epost' : 'Ukjent bedrift')
}
})()
return () => ac.abort()
}, [session])
const body = useMemo(() => {
if (!session) return <HomeLanding />
if (session.role === 'privat') return <PersonView personId={session.id} />
return <EmployerView employerId={session.id} />
}, [session])
return (
<div>
<div className="app-header">
<div className="container app-header-inner">
<div className="logo">
<strong>MinAttest</strong>
</div>
<div className="role-toggle" role="group" aria-label="Velg rolle">
<button aria-pressed={selectedRole === 'privat'} onClick={() => setSelectedRole('privat')}>Privat</button>
<button aria-pressed={selectedRole === 'bedrift'} onClick={() => setSelectedRole('bedrift')}>Bedrift</button>
</div>
<div className="spacer" />
<div className="login-area">
{!session ? (
<>
<input
type="text"
placeholder={selectedRole === 'privat' ? 'Person-ID (GUID)' : 'Arbeidsgiver-ID (GUID)'}
value={pendingId}
onChange={(e) => setPendingId(e.target.value)}
/>
<button className="btn primary" onClick={performLogin}>Logg inn</button>
{loginErr && <span className="error" style={{ marginLeft: 8 }}>{loginErr}</span>}
</>
) : (
<>
<span className="muted">Innlogget som: {identity ?? ''}</span>
<button className="btn" onClick={onLogout}>Logg ut</button>
</>
)}
</div>
</div>
</div>
<main className="container app-main">{body}</main>
</div>
)
}
@@ -0,0 +1,127 @@
import { useEffect, useMemo, useRef, useState } from 'react'
const nbMonths = ['januar','februar','mars','april','mai','juni','juli','august','september','oktober','november','desember']
const nbWeekdays = ['man','tir','ons','tor','fre','lør','søn']
function clampMonth(year: number, month: number) {
if (month < 0) { year -= 1; month = 11 }
if (month > 11) { year += 1; month = 0 }
return { year, month }
}
function parseDateOnly(value?: string): Date | null {
if (!value) return null
const m = value.match(/^([0-9]{4})-([0-9]{2})-([0-9]{2})$/)
if (!m) return null
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
return isNaN(d.getTime()) ? null : d
}
function toDateOnly(d: Date): string {
const y = d.getFullYear(); const m = String(d.getMonth()+1).padStart(2,'0'); const day = String(d.getDate()).padStart(2,'0')
return `${y}-${m}-${day}`
}
export function DatePicker({ value, onChange, disabled }: { value: string; onChange: (v: string) => void; disabled?: boolean }) {
const selected = parseDateOnly(value) ?? new Date()
const [open, setOpen] = useState(false)
const [viewYear, setViewYear] = useState(selected.getFullYear())
const [viewMonth, setViewMonth] = useState(selected.getMonth())
const wrapperRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function onDocClick(e: MouseEvent) {
if (!wrapperRef.current) return
if (!wrapperRef.current.contains(e.target as Node)) setOpen(false)
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onDocClick)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDocClick)
document.removeEventListener('keydown', onKey)
}
}, [])
useEffect(() => {
// When value changes externally, sync calendar view
const d = parseDateOnly(value)
if (d) { setViewYear(d.getFullYear()); setViewMonth(d.getMonth()) }
}, [value])
const days = useMemo(() => {
const first = new Date(viewYear, viewMonth, 1)
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate()
const weekStartMondayIndex = (first.getDay() + 6) % 7 // 0=Mon ... 6=Sun
const cells: Array<{ d: number | null; date?: Date }> = []
for (let i=0;i<weekStartMondayIndex;i++) cells.push({ d: null })
for (let day=1; day<=daysInMonth; day++) {
const date = new Date(viewYear, viewMonth, day)
cells.push({ d: day, date })
}
// pad to full weeks (6 rows max for stability)
while (cells.length % 7 !== 0) cells.push({ d: null })
return cells
}, [viewYear, viewMonth])
const display = useMemo(() => {
const d = parseDateOnly(value)
if (!d) return '—'
const dd = String(d.getDate()).padStart(2,'0')
const mm = String(d.getMonth()+1).padStart(2,'0')
const yyyy = d.getFullYear()
return `${dd}.${mm}.${yyyy}`
}, [value])
return (
<div className="date-input" ref={wrapperRef}>
<button
type="button"
className="date-field"
disabled={disabled}
onClick={() => setOpen(v => !v)}
aria-haspopup="dialog"
aria-expanded={open}
aria-label="Velg dato"
>
<span className={value ? 'value' : 'placeholder'}>{value ? display : 'dd.mm.åååå'}</span>
<svg className="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/>
<path d="M16 2v4M8 2v4M3 10h18"/>
</svg>
</button>
{open && (
<div className="calendar-popover" role="dialog" aria-label="Kalender">
<div className="cal-header">
<button className="icon-btn" onClick={() => { const {year, month} = clampMonth(viewYear, viewMonth - 1); setViewYear(year); setViewMonth(month) }} aria-label="Forrige måned" title="Forrige måned">
<svg className="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<div className="cal-title">{nbMonths[viewMonth]} {viewYear}</div>
<button className="icon-btn" onClick={() => { const {year, month} = clampMonth(viewYear, viewMonth + 1); setViewYear(year); setViewMonth(month) }} aria-label="Neste måned" title="Neste måned">
<svg className="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 6l6 6-6 6"/></svg>
</button>
</div>
<div className="cal-grid cal-weekdays">
{nbWeekdays.map((w) => <div key={w} className="cal-cell cal-wd">{w}</div>)}
</div>
<div className="cal-grid cal-days">
{days.map((c, i) => {
if (c.d === null) return <div key={i} className="cal-cell" />
const isSelected = value && c.date && toDateOnly(c.date) === value
return (
<button
type="button"
key={i}
className={"cal-cell cal-day" + (isSelected ? ' selected' : '')}
onClick={() => { onChange(toDateOnly(c.date!)); setOpen(false) }}
>{c.d}</button>
)
})}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,254 @@
import { useEffect, useMemo, useState } from 'react'
import { Api } from '../api'
import { fileToBase64, isAbortError, isPdfFile, toDateOnlyString, formatRange, isGuidLike, toFileName } from '../utils'
import { DatePicker } from './DatePicker'
import type { AttestSummary, EmployerResponse, Guid } from '../types'
export function EmployerView({ employerId }: { employerId: Guid }) {
const [employer, setEmployer] = useState<EmployerResponse | null>(null)
const [attests, setAttests] = useState<AttestSummary[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [preview, setPreview] = useState<Guid | null>(null)
const [uploading, setUploading] = useState(false)
const [success, setSuccess] = useState<string | null>(null)
useEffect(() => {
const ac = new AbortController()
setError(null)
Promise.all([
Api.getEmployer(employerId, ac.signal),
Api.listEmployerAttests(employerId, ac.signal)
]).then(([e, a]) => { setEmployer(e); setAttests(a) }).catch(err => {
if (isAbortError(err)) return
const msg = err instanceof Error ? err.message : String(err)
setError(msg)
})
return () => ac.abort()
}, [employerId])
const previewUrl = useMemo(() => preview ? Api.employerAttestPreviewUrl(employerId, preview) : null, [preview, employerId])
return (
<div>
<section className="panel section">
<div className="section-title">
<h2 style={{ margin: 0 }}>Bedrift</h2>
<div className="toolbar">
<button className="btn secondary sm" onClick={() => {
setError(null)
const ac = new AbortController()
Promise.all([
Api.getEmployer(employerId, ac.signal),
Api.listEmployerAttests(employerId, ac.signal)
])
.then(([e, a]) => { setEmployer(e); setAttests(a) })
.catch(e => { if (!isAbortError(e)) setError((e as Error).message) })
}}>Oppdater</button>
</div>
</div>
{!employer && !error && <p className="muted">Laster arbeidsgiver</p>}
{error && <p className="alert error">{error}</p>}
{employer && (
<div className="kpis">
<div className="kpi"><strong>{employer.name}</strong><span>Navn</span></div>
<div className="kpi"><strong>{employer.orgNumber}</strong><span>Organisasjonsnummer</span></div>
<div className="kpi"><strong>{attests?.length ?? 0}</strong><span>Attester</span></div>
</div>
)}
</section>
<section className="panel section" style={{ marginBottom: 16 }}>
<div className="section-title">
<h3 style={{ margin: 0 }}>Utsted attest (PDF)</h3>
</div>
<UploadEmployerAttestForm
disabled={uploading}
onSubmit={async (data) => {
setError(null); setSuccess(null); setUploading(true)
try {
if (!data.file || !isPdfFile(data.file)) throw new Error('Kun PDF er tillatt.')
const contentBase64 = await fileToBase64(data.file)
const resp = await Api.uploadEmployerAttest(employerId, {
personId: data.personId,
title: data.title,
from: toDateOnlyString(data.from),
to: toDateOnlyString(data.to),
summary: data.summary || null,
contentBase64,
contentType: 'application/pdf',
})
setSuccess('Attest utstedt.')
setPreview(resp.attestId)
const a = await Api.listEmployerAttests(employerId)
setAttests(a)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
setError(msg)
} finally {
setUploading(false)
}
}}
/>
{success && <p className="alert success">{success}</p>}
</section>
<section className="panel section">
<div className="section-title">
<h3 style={{ margin: 0 }}>Bedriftens attester</h3>
<div className="toolbar">
<button className="btn secondary sm" onClick={async () => {
setError(null)
try { setAttests(await Api.listEmployerAttests(employerId)) } catch (e) { setError((e as Error).message) }
}}>Oppdater liste</button>
</div>
</div>
{!attests && !error && <p className="muted">Laster attester</p>}
{attests && attests.length === 0 && <p className="muted">Ingen attester.</p>}
{attests && attests.length > 0 && (
<table>
<thead>
<tr>
<th>Tittel</th>
<th>Arbeidsgiver</th>
<th>Periode</th>
<th>Status</th>
<th style={{ width: 300 }}>Handlinger</th>
</tr>
</thead>
<tbody>
{attests.map(a => (
<tr key={a.id}>
<td>{a.title}</td>
<td>{a.employer}</td>
<td>{formatRange(a.from, a.to)}</td>
<td>
<span className={a.verified ? 'tag success' : 'tag muted'}>
{a.verified ? 'Verifisert' : 'Ubekreftet'}
</span>
</td>
<td>
<div className="toolbar">
<button
className="icon-btn"
onClick={() => setPreview(a.id)}
aria-label={`Vis ${a.title}`}
title="Vis"
>
<svg className="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
<a
className="icon-btn"
href={Api.employerAttestDownloadUrl(employerId, a.id)}
download={`attest-${toFileName(a.title)}-${a.from}-${a.to}.pdf`}
aria-label={`Last ned ${a.title}`}
title="Last ned PDF"
>
<svg className="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v10"/>
<path d="M8 9l4 4 4-4"/>
<path d="M4 21h16"/>
</svg>
</a>
<button className="btn sm danger" onClick={() => {
if (confirm('Er du sikker på at du vil slette denne attesten?')) alert('Sletting ikke implementert i API ennå.')
}}>Slett</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{previewUrl && (
<div className="modal-backdrop" onClick={() => setPreview(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<strong>Forhåndsvisning</strong>
<button className="btn" onClick={() => setPreview(null)}>Lukk</button>
</div>
<div className="modal-body">
<iframe src={previewUrl} title="Forhåndsvisning" />
</div>
</div>
</div>
)}
</div>
)
}
function UploadEmployerAttestForm({
onSubmit,
disabled,
}: {
disabled?: boolean
onSubmit: (data: { personId: Guid; title: string; from: string; to: string; summary?: string; file?: File | null }) => void
}) {
const [personId, setPersonId] = useState('')
const [title, setTitle] = useState('')
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const [summary, setSummary] = useState('')
const [file, setFile] = useState<File | null>(null)
const [localError, setLocalError] = useState<string | null>(null)
const submit = (e: React.FormEvent) => {
e.preventDefault()
setLocalError(null)
if (!isGuidLike(personId)) return setLocalError('Ugyldig person GUID')
if (!title.trim()) return setLocalError('Tittel er påkrevd')
if (!from || !to) return setLocalError('Fra/til-dato er påkrevd')
if (!file) return setLocalError('Velg en PDF-fil')
if (!isPdfFile(file)) return setLocalError('Kun PDF er tillatt')
onSubmit({ personId: personId as Guid, title: title.trim(), from, to, summary: summary.trim() || undefined, file })
// reset
setPersonId(''); setTitle(''); setFrom(''); setTo(''); setSummary('')
const input = document.getElementById('employer-file') as HTMLInputElement | null
if (input) input.value = ''
setFile(null)
}
return (
<form onSubmit={submit} className="form-grid cols-2" style={{ maxWidth: 900 }}>
<div className="field" style={{ gridColumn: '1 / -1' }}>
<label>PersonID (GUID)</label>
<input value={personId} onChange={e => setPersonId(e.target.value)} disabled={disabled} placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
</div>
<div className="field" style={{ gridColumn: '1 / -1' }}>
<label>Tittel</label>
<input value={title} onChange={e => setTitle(e.target.value)} disabled={disabled} />
</div>
<div className="field-row" style={{ gridColumn: '1 / -1' }}>
<div className="field">
<label>Fra</label>
<DatePicker value={from} onChange={setFrom} disabled={disabled} />
</div>
<div className="field">
<label>Til</label>
<DatePicker value={to} onChange={setTo} disabled={disabled} />
</div>
</div>
<div className="field">
<label>Sammendrag (valgfritt)</label>
<textarea value={summary} onChange={e => setSummary(e.target.value)} disabled={disabled} rows={5} />
</div>
<div className="field" style={{ gridColumn: '1 / -1' }}>
<label>PDFfil</label>
<div className="file-row">
<input id="employer-file" className="sr-only" type="file" accept="application/pdf,.pdf" onChange={e => setFile(e.target.files?.[0] ?? null)} disabled={disabled} />
<label htmlFor="employer-file" className="btn secondary">Velg PDF</label>
<span className="muted">{file ? file.name : 'Ingen fil valgt'}</span>
</div>
<div className="help">Kun PDF er tillatt.</div>
</div>
<div className="toolbar" style={{ gridColumn: '1 / -1' }}>
<button className="btn primary" type="submit" disabled={disabled}>{disabled ? <span className="spinner" /> : 'Utsted'}</button>
{localError && <span className="alert error" style={{ padding: '6px 8px' }}>{localError}</span>}
</div>
</form>
)
}
@@ -0,0 +1,58 @@
export function HomeLanding() {
return (
<div className="panel" style={{ padding: 20 }}>
<h2 style={{ marginTop: 0 }}>Hva er MinAttest?</h2>
<p>
MinAttest er en digital plattform for utstedelse, lagring, deling og verifisering av
arbeidsattester. Målet er å erstatte papir/PDF-attester med en standardisert, sikker og
brukervennlig løsning som gir verdi både for arbeidsgivere, ansatte og rekrutterere.
</p>
<div className="cards" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', marginTop: 12 }}>
<div className="card" style={card}>
<h3 style={h3}>For privatpersoner</h3>
<ul>
<li>Trygg tilgang til attester hele livet</li>
<li>Se og last ned attester</li>
<li>Last opp tidligere attester (markeres som ikke verifisert)</li>
</ul>
</div>
<div className="card" style={card}>
<h3 style={h3}>For bedrifter</h3>
<ul>
<li>Standardisert og sikker attesthåndtering</li>
<li>Utsted attester til ansatte</li>
<li>Oversikt over bedriftens attester</li>
</ul>
</div>
<div className="card" style={card}>
<h3 style={h3}>For rekrutterere</h3>
<ul>
<li>Verifiser attester via delte lenker</li>
<li>Rask innsikt i arbeidshistorikk</li>
</ul>
</div>
</div>
<h2 style={{ marginTop: 24 }}>Hva kan løsningen gjøre i dag (MVP)?</h2>
<ul>
<li>Pålogging via enkel demo (BFF) uten ekte autentisering</li>
<li>Privatperson: se og laste opp attester, forhåndsvise og laste ned</li>
<li>Bedrift: utstede attester til en person, forhåndsvise og laste ned</li>
<li>API med struktur for videre sikkerhet og deling</li>
</ul>
<h2 style={{ marginTop: 24 }}>Kommer snart</h2>
<ul>
<li>Ekte autentisering: BankID (privat) og Entra ID (bedrift)</li>
<li>Deling med verifiseringslenker</li>
<li>Signering og malbasert attestgenerering</li>
<li>Varslinger og admin-dashboard</li>
</ul>
</div>
)
}
const card: React.CSSProperties = { background: '#fff', border: '1px solid #e5e7eb', borderRadius: 12, padding: 16 }
const h3: React.CSSProperties = { marginTop: 0, marginBottom: 8 }
@@ -0,0 +1,49 @@
import { useState } from 'react'
import type { Guid, Role } from '../types'
function isGuidLike(s: string): boolean {
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/.test(s)
}
export function LoginGate({ onLogin }: { onLogin: (role: Role, id: Guid) => void }) {
const [mode, setMode] = useState<Role | null>(null)
const [id, setId] = useState('')
const [error, setError] = useState<string | null>(null)
const startPrivat = () => { setMode('privat'); setId(''); setError(null) }
const startBedrift = () => { setMode('bedrift'); setId(''); setError(null) }
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!mode) return
if (!isGuidLike(id)) { setError('Ugyldig GUID'); return }
onLogin(mode, id)
}
return (
<div>
<p>Velg innlogging for å fortsette.</p>
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<button onClick={startPrivat}>Logg inn privat</button>
<button onClick={startBedrift}>Logg inn bedrift</button>
</div>
{mode && (
<form onSubmit={submit} style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<label>
{mode === 'privat' ? 'Person-ID (GUID):' : 'Arbeidsgiver-ID (GUID):'}
<input
type="text"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
value={id}
onChange={(e) => setId(e.target.value.trim())}
style={{ marginLeft: 8, width: 360 }}
/>
</label>
<button type="submit">Fortsett</button>
{error && <span style={{ color: 'crimson', marginLeft: 8 }}>{error}</span>}
</form>
)}
</div>
)
}
@@ -0,0 +1,256 @@
import { useEffect, useMemo, useState } from 'react'
import { Api } from '../api'
import { isAbortError, fileToBase64, isPdfFile, toDateOnlyString, formatRange, toFileName } from '../utils'
import { DatePicker } from './DatePicker'
import type { AttestSummary, Guid, PersonResponse } from '../types'
export function PersonView({ personId }: { personId: Guid }) {
const [person, setPerson] = useState<PersonResponse | null>(null)
const [attests, setAttests] = useState<AttestSummary[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [preview, setPreview] = useState<Guid | null>(null)
const [uploading, setUploading] = useState(false)
const [success, setSuccess] = useState<string | null>(null)
useEffect(() => {
const ac = new AbortController()
setError(null)
Promise.all([
Api.getPerson(personId, ac.signal),
Api.listPersonAttests(personId, ac.signal)
]).then(([p, a]) => { setPerson(p); setAttests(a) }).catch(err => {
if (isAbortError(err)) return
const msg = err instanceof Error ? err.message : String(err)
setError(msg)
})
return () => ac.abort()
}, [personId])
const previewUrl = useMemo(() => preview ? Api.personAttestPreviewUrl(personId, preview) : null, [preview, personId])
return (
<div>
<section className="panel section">
<div className="section-title">
<h2 style={{ margin: 0 }}>Din profil</h2>
<div className="toolbar">
<button
className="btn secondary sm"
onClick={() => {
setError(null)
const ac = new AbortController()
Promise.all([
Api.getPerson(personId, ac.signal),
Api.listPersonAttests(personId, ac.signal)
])
.then(([p, a]) => { setPerson(p); setAttests(a) })
.catch(e => { if (!isAbortError(e)) setError((e as Error).message) })
}}
>Oppdater</button>
</div>
</div>
{!person && !error && <p className="muted">Laster person</p>}
{error && <p className="alert error">{error}</p>}
{person && (
<>
<div className="kpis">
<div className="kpi"><strong>{person.attests.length}</strong><span>Attester</span></div>
<div className="kpi"><strong>{person.email || '—'}</strong><span>Epost</span></div>
<div className="kpi"><strong>{person.phone || '—'}</strong><span>Telefon</span></div>
</div>
{/* ID intentionally hidden from UI */}
</>
)}
</section>
<section className="panel section" style={{ marginBottom: 16 }}>
<div className="section-title">
<h3 style={{ margin: 0 }}>Last opp attest (PDF)</h3>
</div>
<UploadPersonAttestForm
disabled={uploading}
onSubmit={async (data) => {
setError(null); setSuccess(null); setUploading(true)
try {
if (!data.file || !isPdfFile(data.file)) throw new Error('Kun PDF er tillatt.')
const contentBase64 = await fileToBase64(data.file)
const resp = await Api.uploadPersonAttest(personId, {
title: data.title,
from: toDateOnlyString(data.from),
to: toDateOnlyString(data.to),
summary: data.summary || null,
contentBase64,
contentType: 'application/pdf',
})
setSuccess('Attest lastet opp.')
setPreview(resp.attestId)
const [p, a] = await Promise.all([
Api.getPerson(personId),
Api.listPersonAttests(personId)
])
setPerson(p); setAttests(a)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
setError(msg)
} finally {
setUploading(false)
}
}}
/>
{success && <p className="alert success">{success}</p>}
</section>
<section className="panel section">
<div className="section-title">
<h3 style={{ margin: 0 }}>Attester</h3>
<div className="toolbar">
<button className="btn secondary sm" onClick={async () => {
setError(null)
try { setAttests(await Api.listPersonAttests(personId)) } catch (e) { setError((e as Error).message) }
}}>Oppdater liste</button>
</div>
</div>
{!attests && !error && <p className="muted">Laster attester</p>}
{attests && attests.length === 0 && <p className="muted">Ingen attester.</p>}
{attests && attests.length > 0 && (
<table>
<thead>
<tr>
<th>Tittel</th>
<th>Arbeidsgiver</th>
<th>Periode</th>
<th>Status</th>
<th style={{ width: 300 }}>Handlinger</th>
</tr>
</thead>
<tbody>
{attests.map(a => (
<tr key={a.id}>
<td>{a.title}</td>
<td>{a.employer}</td>
<td>{formatRange(a.from, a.to)}</td>
<td>
<span className={a.verified ? 'tag success' : 'tag muted'}>
{a.verified ? 'Verifisert' : 'Ubekreftet'}
</span>
</td>
<td>
<div className="toolbar">
<button
className="icon-btn"
onClick={() => setPreview(a.id)}
aria-label={`Vis ${a.title}`}
title="Vis"
>
<svg className="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
<a
className="icon-btn"
href={Api.personAttestDownloadUrl(personId, a.id)}
download={`attest-${toFileName(a.title)}-${a.from}-${a.to}.pdf`}
aria-label={`Last ned ${a.title}`}
title="Last ned PDF"
>
<svg className="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v10"/>
<path d="M8 9l4 4 4-4"/>
<path d="M4 21h16"/>
</svg>
</a>
<button className="btn sm danger" onClick={() => {
if (confirm('Er du sikker på at du vil slette denne attesten?')) alert('Sletting ikke implementert i API ennå.')
}}>Slett</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{previewUrl && (
<div className="modal-backdrop" onClick={() => setPreview(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<strong>Forhåndsvisning</strong>
<button className="btn" onClick={() => setPreview(null)}>Lukk</button>
</div>
<div className="modal-body">
<iframe src={previewUrl} title="Forhåndsvisning" />
</div>
</div>
</div>
)}
</div>
)
}
function UploadPersonAttestForm({
onSubmit,
disabled,
}: {
disabled?: boolean
onSubmit: (data: { title: string; from: string; to: string; summary?: string; file?: File | null }) => void
}) {
const [title, setTitle] = useState('')
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const [summary, setSummary] = useState('')
const [file, setFile] = useState<File | null>(null)
const [localError, setLocalError] = useState<string | null>(null)
const submit = (e: React.FormEvent) => {
e.preventDefault()
setLocalError(null)
if (!title.trim()) return setLocalError('Tittel er påkrevd')
if (!from || !to) return setLocalError('Fra/til-dato er påkrevd')
if (!file) return setLocalError('Velg en PDF-fil')
if (!isPdfFile(file)) return setLocalError('Kun PDF er tillatt')
onSubmit({ title: title.trim(), from, to, summary: summary.trim() || undefined, file })
// reset
setTitle(''); setFrom(''); setTo(''); setSummary('')
const input = document.getElementById('person-file') as HTMLInputElement | null
if (input) input.value = ''
setFile(null)
}
return (
<form onSubmit={submit} className="form-grid cols-2" style={{ maxWidth: 900 }}>
<div className="field" style={{ gridColumn: '1 / -1' }}>
<label>Tittel</label>
<input value={title} onChange={e => setTitle(e.target.value)} disabled={disabled} />
</div>
<div className="field-row" style={{ gridColumn: '1 / -1' }}>
<div className="field">
<label>Fra</label>
<DatePicker value={from} onChange={setFrom} disabled={disabled} />
</div>
<div className="field">
<label>Til</label>
<DatePicker value={to} onChange={setTo} disabled={disabled} />
</div>
</div>
<div className="field">
<label>Sammendrag (valgfritt)</label>
<textarea value={summary} onChange={e => setSummary(e.target.value)} disabled={disabled} rows={5} />
</div>
<div className="field" style={{ gridColumn: '1 / -1' }}>
<label>PDFfil</label>
<div className="file-row">
<input id="person-file" className="sr-only" type="file" accept="application/pdf,.pdf" onChange={e => setFile(e.target.files?.[0] ?? null)} disabled={disabled} />
<label htmlFor="person-file" className="btn secondary">Velg PDF</label>
<span className="muted">{file ? file.name : 'Ingen fil valgt'}</span>
</div>
<div className="help">Kun PDF er tillatt.</div>
</div>
<div className="toolbar" style={{ gridColumn: '1 / -1' }}>
<button className="btn primary" type="submit" disabled={disabled}>{disabled ? <span className="spinner" /> : 'Last opp'}</button>
{localError && <span className="alert error" style={{ padding: '6px 8px' }}>{localError}</span>}
</div>
</form>
)
}