Initial import
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import type { EmployerResponse, PersonResponse, Guid, AttestSummary } from './types'
|
||||
|
||||
async function apiGet<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
const res = await fetch(path, { headers: { 'Accept': 'application/json' }, signal })
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`API GET ${path} failed: ${res.status} ${res.statusText} ${text}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const Api = {
|
||||
getPerson: (id: Guid, signal?: AbortSignal) => apiGet<PersonResponse>(`/api/v1/persons/${id}`, signal),
|
||||
listPersonAttests: (id: Guid, signal?: AbortSignal) => apiGet<AttestSummary[]>(`/api/v1/persons/${id}/attests`, signal),
|
||||
getEmployer: (id: Guid, signal?: AbortSignal) => apiGet<EmployerResponse>(`/api/v1/employers/${id}`, signal),
|
||||
listEmployerAttests: (id: Guid, signal?: AbortSignal) => apiGet<AttestSummary[]>(`/api/v1/employers/${id}/attests`, signal),
|
||||
personAttestDownloadUrl: (personId: Guid, attestId: Guid) => `/api/v1/persons/${personId}/attests/${attestId}/download`,
|
||||
employerAttestDownloadUrl: (employerId: Guid, attestId: Guid) => `/api/v1/employers/${employerId}/attests/${attestId}/download`,
|
||||
personAttestPreviewUrl: (personId: Guid, attestId: Guid) => `/api/v1/persons/${personId}/attests/${attestId}/download?inline=true`,
|
||||
employerAttestPreviewUrl: (employerId: Guid, attestId: Guid) => `/api/v1/employers/${employerId}/attests/${attestId}/download?inline=true`,
|
||||
uploadPersonAttest: async (
|
||||
personId: Guid,
|
||||
req: {
|
||||
title: string
|
||||
from: string // YYYY-MM-DD
|
||||
to: string // YYYY-MM-DD
|
||||
summary?: string | null
|
||||
contentBase64: string
|
||||
contentType: string
|
||||
}
|
||||
): Promise<{ attestId: Guid }> => {
|
||||
const body = {
|
||||
title: req.title,
|
||||
from: req.from,
|
||||
to: req.to,
|
||||
summary: req.summary ?? null,
|
||||
blobPath: '',
|
||||
blobHash: null,
|
||||
contentBase64: req.contentBase64,
|
||||
contentType: req.contentType,
|
||||
}
|
||||
const path = `/api/v1/persons/${personId}/attests`
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`API POST ${path} failed: ${res.status} ${res.statusText} ${text}`)
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
uploadEmployerAttest: async (
|
||||
employerId: Guid,
|
||||
req: {
|
||||
personId: Guid
|
||||
title: string
|
||||
from: string // YYYY-MM-DD
|
||||
to: string // YYYY-MM-DD
|
||||
summary?: string | null
|
||||
contentBase64: string
|
||||
contentType: string
|
||||
}
|
||||
): Promise<{ attestId: Guid }> => {
|
||||
const body = {
|
||||
personId: req.personId,
|
||||
title: req.title,
|
||||
from: req.from,
|
||||
to: req.to,
|
||||
summary: req.summary ?? null,
|
||||
blobPath: '',
|
||||
blobHash: null,
|
||||
contentBase64: req.contentBase64,
|
||||
contentType: req.contentType,
|
||||
}
|
||||
const path = `/api/v1/employers/${employerId}/attests`
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`API POST ${path} failed: ${res.status} ${res.statusText} ${text}`)
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { App } from './ui/App'
|
||||
import './styles.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(<App />)
|
||||
@@ -0,0 +1,120 @@
|
||||
:root {
|
||||
--bg: #f7f7f8;
|
||||
--panel: #ffffff;
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
--brand: #c3002f; /* NAV red */
|
||||
--border: #e5e7eb;
|
||||
--success: #16a34a;
|
||||
--danger: #dc2626;
|
||||
--warning: #d97706;
|
||||
}
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Apple Color Emoji", "Segoe UI Emoji"; }
|
||||
|
||||
.container { max-width: 1100px; margin: 0 auto; padding: 0 16px; }
|
||||
|
||||
.app-header {
|
||||
position: sticky; top: 0; z-index: 10; background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.app-header-inner { display: flex; align-items: center; gap: 16px; height: 64px; }
|
||||
.logo { display: flex; align-items: center; gap: 10px; }
|
||||
.logo img { height: 36px; width: auto; display: block; }
|
||||
.logo strong { font-size: 18px; color: var(--brand); letter-spacing: 0.2px; }
|
||||
|
||||
.role-toggle { display: inline-flex; background: #f1f5f9; border-radius: 9999px; padding: 4px; }
|
||||
.role-toggle button { border: none; background: transparent; padding: 6px 12px; border-radius: 9999px; cursor: pointer; color: var(--muted); font-weight: 600; }
|
||||
.role-toggle button[aria-pressed="true"] { background: var(--panel); color: var(--text); box-shadow: 0 1px 2px rgba(16,24,40,0.06); }
|
||||
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.login-area { display: flex; align-items: center; gap: 8px; }
|
||||
.login-area input[type="text"] { height: 34px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 8px; min-width: 340px; }
|
||||
.btn { height: 34px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--border); background: #fff; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
|
||||
.btn.primary { background: var(--brand); color: #fff; border-color: var(--brand); }
|
||||
.btn.ghost { background: transparent; }
|
||||
.btn.secondary { background: #fff; border-color: #cbd5e1; }
|
||||
.btn.danger { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
.btn.sm { height: 28px; padding: 0 10px; border-radius: 6px; font-size: 14px; }
|
||||
.btn:disabled { opacity: .6; cursor: default; }
|
||||
.btn .icon { width: 14px; height: 14px; margin-right: 6px; vertical-align: -2px; }
|
||||
|
||||
/* Icon-only buttons/links */
|
||||
.icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--border); background: #fff; color: var(--text); cursor: pointer; text-decoration: none; }
|
||||
.icon-btn:hover { background: #f3f4f6; }
|
||||
.icon-btn .icon { width: 16px; height: 16px; margin: 0; }
|
||||
|
||||
.app-main { padding: 16px 0 40px; }
|
||||
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; }
|
||||
.cards { margin-top: 8px; }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; }
|
||||
|
||||
.section { margin-bottom: 16px; }
|
||||
.section-title { display: flex; align-items: center; justify-content: space-between; margin: 0 0 8px; }
|
||||
.toolbar { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.tag { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: 9999px; padding: 2px 10px; font-size: 12px; color: var(--muted); background: #f8fafc; }
|
||||
.tag.success { color: var(--success); border-color: #bbf7d0; background: #ecfdf5; }
|
||||
.tag.muted { color: var(--muted); }
|
||||
|
||||
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-top: 8px; }
|
||||
.kpi { background: #fafafa; border: 1px dashed var(--border); border-radius: 12px; padding: 12px; }
|
||||
.kpi strong { display: block; font-size: 20px; }
|
||||
.kpi span { color: var(--muted); font-size: 12px; }
|
||||
|
||||
.alert { padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border); background: #fff7ed; color: #7c2d12; }
|
||||
.alert.error { background: #fef2f2; color: #991b1b; }
|
||||
.alert.success { background: #f0fdf4; color: #14532d; }
|
||||
|
||||
/* Modal */
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(17,24,39,.5); display: flex; align-items: center; justify-content: center; padding: 24px; z-index: 50; }
|
||||
.modal { width: min(1100px, 96vw); background: var(--panel); border-radius: 12px; border: 1px solid var(--border); overflow: hidden; display: flex; flex-direction: column; max-height: 92vh; }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||
.modal-body { padding: 0; }
|
||||
.modal-body iframe { display: block; width: 100%; height: 70vh; border: none; }
|
||||
|
||||
/* Forms */
|
||||
.form-grid { display: grid; gap: 10px; grid-template-columns: 1fr; }
|
||||
@media (min-width: 720px) { .form-grid.cols-2 { grid-template-columns: 1fr 1fr; } }
|
||||
.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||
.field label.btn { display: inline-flex; align-items: center; justify-content: center; margin-bottom: 0; font-size: 14px; color: var(--text); }
|
||||
.field input[type="text"], .field input[type="date"], .field textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; }
|
||||
.help { color: var(--muted); font-size: 12px; }
|
||||
|
||||
/* Accessible visually hidden input (screen-reader only) */
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
.file-row { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* Loader */
|
||||
.spinner { width: 16px; height: 16px; border: 2px solid #fff; border-top-color: rgba(255,255,255,.2); border-radius: 9999px; display: inline-block; animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg) } }
|
||||
|
||||
/* Date picker */
|
||||
.date-input { position: relative; display: inline-block; }
|
||||
.date-field { width: 150px; height: 36px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: #fff; display: inline-flex; align-items: center; justify-content: space-between; cursor: pointer; }
|
||||
.date-field:hover { background: #f9fafb; }
|
||||
.date-field .value { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.date-field .placeholder { color: var(--muted); }
|
||||
.date-field .icon { width: 16px; height: 16px; opacity: .7; }
|
||||
.calendar-popover { position: absolute; top: calc(100% + 6px); left: 0; background: #fff; border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 10px 25px rgba(16,24,40,0.1); width: 280px; padding: 8px; z-index: 40; }
|
||||
.cal-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 6px; }
|
||||
.cal-title { font-weight: 600; text-transform: capitalize; }
|
||||
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-top: 6px; }
|
||||
.cal-cell { text-align: center; padding: 6px 0; font-size: 13px; color: var(--text); }
|
||||
.cal-wd { color: var(--muted); text-transform: lowercase; }
|
||||
.cal-day { border: 1px solid transparent; background: transparent; border-radius: 8px; cursor: pointer; }
|
||||
.cal-day:hover { background: #f3f4f6; }
|
||||
.cal-day.selected { background: #eef2ff; border-color: #c7d2fe; }
|
||||
|
||||
/* Inline pair for date fields */
|
||||
.field-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: end; width: fit-content; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 600; }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
.success { color: #16a34a; }
|
||||
.error { color: #dc2626; }
|
||||
@@ -0,0 +1,27 @@
|
||||
export type Guid = string
|
||||
|
||||
export interface AttestSummary {
|
||||
id: Guid
|
||||
employer: string
|
||||
title: string
|
||||
from: string // DateOnly as ISO string
|
||||
to: string // DateOnly as ISO string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
export interface PersonResponse {
|
||||
id: Guid
|
||||
nationalIdHash: string
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
attests: AttestSummary[]
|
||||
}
|
||||
|
||||
export interface EmployerResponse {
|
||||
id: Guid
|
||||
orgNumber: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type Role = 'privat' | 'bedrift'
|
||||
|
||||
@@ -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 e‑post')
|
||||
} else {
|
||||
const e = await Api.getEmployer(session.id, ac.signal)
|
||||
setIdentity(e.name || 'Ukjent bedrift')
|
||||
}
|
||||
} catch {
|
||||
setIdentity(session.role === 'privat' ? 'Ukjent e‑post' : '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>Person‑ID (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>PDF‑fil</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>E‑post</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>PDF‑fil</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export function isAbortError(err: unknown): boolean {
|
||||
if (!err) return false
|
||||
// DOMException AbortError in browsers
|
||||
if (typeof DOMException !== 'undefined' && err instanceof DOMException && err.name === 'AbortError') return true
|
||||
// Fallback check
|
||||
return typeof err === 'object' && 'name' in (err as any) && (err as any).name === 'AbortError'
|
||||
}
|
||||
|
||||
export function isPdfFile(file: File): boolean {
|
||||
if (file.type === 'application/pdf') return true
|
||||
return file.name.toLowerCase().endsWith('.pdf')
|
||||
}
|
||||
|
||||
export async function fileToBase64(file: File): Promise<string> {
|
||||
// Read as DataURL, then strip the prefix
|
||||
const dataUrl: string = await new Promise((resolve, reject) => {
|
||||
const fr = new FileReader()
|
||||
fr.onerror = () => reject(fr.error)
|
||||
fr.onload = () => resolve(String(fr.result))
|
||||
fr.readAsDataURL(file)
|
||||
})
|
||||
const comma = dataUrl.indexOf(',')
|
||||
return comma >= 0 ? dataUrl.substring(comma + 1) : dataUrl
|
||||
}
|
||||
|
||||
export function toDateOnlyString(d: string | Date): string {
|
||||
const date = typeof d === 'string' ? new Date(d) : d
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
export 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 formatDateOnly(d: string): string {
|
||||
// Expect YYYY-MM-DD; fallback to original string
|
||||
if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(d)) return d
|
||||
const [y, m, day] = d.split('-')
|
||||
return `${day}.${m}.${y}`
|
||||
}
|
||||
|
||||
export function formatRange(from: string, to: string): string {
|
||||
return `${formatDateOnly(from)} – ${formatDateOnly(to)}`
|
||||
}
|
||||
|
||||
export function toFileName(s: string): string {
|
||||
try {
|
||||
return s
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
} catch {
|
||||
return s.replace(/\W+/g, '-').toLowerCase()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user