Arbeidspuls/src/App.tsx
2026-05-28 10:21:59 +02:00

2510 lines
97 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import type React from "react";
import {
Activity,
BarChart3,
Download,
FileClock,
FileJson,
Github,
HelpCircle,
Heart,
Search,
Plus,
RotateCcw,
Save,
Send,
ShieldAlert,
Trash2,
Upload,
X,
UserRound
} from "lucide-react";
import {
accommodationLabels,
categoryTooltipTexts,
delayedSymptomLabels,
limitationLabels,
negativeScale,
positiveScale,
recoveryLabels,
statusLabels,
taskCompletionLabels,
taskTypeLabels,
tooltipTexts,
worseLabels
} from "./labels";
import {
deleteEvaluatorNote,
deleteEvaluatorProfile,
exportEvaluatorBackup,
importEvaluatorBackup,
importExportPayload,
listEvaluatorProfiles,
renameEvaluatorProfile,
upsertEvaluatorNote
} from "./evaluatorStorage";
import {
calculatePeriodWorkAbilityAssessment,
calculateReportScore,
getEntryTime,
getReportScore,
getScoreBand,
normalizeReportMetric,
scoreBandDisclaimer
} from "./scoring";
import { verificationLabel, verificationTooltip } from "./integrity";
import {
I18nProvider,
translateScoreBand,
translateScoreBandDescription,
translateVerificationStatus,
useI18n
} from "./i18n";
import {
createFollowup,
createReport,
deleteAllReports,
deleteReport,
exportReports,
getLastWorkReport,
importReportsFromJson,
listReports,
previewImportReports
} from "./storage";
import type {
Accommodation,
DelayedFollowup,
DelayedSymptom,
EvaluatorProfile,
FollowupDraft,
ImportSummary,
Limitation,
RecoveryStatus,
Report,
ReportDraft,
ReportStatus,
ScaleValue,
StoredEntry,
TaskCompletion,
TaskType,
WorseThanBefore
} from "./types";
type View = "registrer" | "oppfolging" | "oversikt" | "personvern";
type PresetRange = "7d" | "1m" | "3m" | "6m" | "1y" | "3y" | "5y" | "all" | "custom";
type LineVisibility = "normal" | "dim" | "hidden";
type TrendMetric = { key: keyof Report | "total_score_percent"; label: string; short: string; color: string };
const PERIOD_KEY = "arbeidsevne-egenvurdering:trend-period";
const APP_VERSION = "1.0";
const taskTypeOptions = Object.keys(taskTypeLabels) as TaskType[];
const limitationOptions = Object.keys(limitationLabels) as Limitation[];
const accommodationOptions = Object.keys(accommodationLabels) as Accommodation[];
const delayedSymptomOptions = Object.keys(delayedSymptomLabels) as DelayedSymptom[];
const visibleStatusOptions: ReportStatus[] = ["kan_fortsette", "trenger_pause", "trenger_enklere_oppgave", "bør_stoppe"];
const trendMetrics: TrendMetric[] = [
{ key: "total_score_percent", label: "Totalscore", short: "T", color: "#235b5e" },
{ key: "work_ability", label: "Arbeidsevne", short: "A", color: "#307f78" },
{ key: "energy_level", label: "Energi", short: "E", color: "#b2642a" },
{ key: "mental_clarity", label: "Klarhet", short: "K", color: "#4f5c9c" },
{ key: "symptom_burden", label: "Symptomer", short: "S", color: "#b23b4a" },
{ key: "effort_strain", label: "Belastning", short: "B", color: "#6b5a3a" }
];
function App() {
return (
<I18nProvider>
<AppRoutes />
</I18nProvider>
);
}
function AppRoutes() {
const { t } = useI18n();
if (window.location.pathname.startsWith("/evaluator") || window.location.pathname.startsWith("/veileder")) {
return <EvaluatorApp />;
}
const [view, setView] = useState<View>("registrer");
const [entries, setEntries] = useState<StoredEntry[]>(() => listReports());
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
const [deleteRequest, setDeleteRequest] = useState<{ id: string; name: string } | null>(null);
const refresh = () => setEntries(listReports());
const selectedEntry = selectedEntryId ? entries.find((entry) => entry.id === selectedEntryId) : undefined;
return (
<div className="app-shell">
<header className="topbar">
<div>
<p className="eyebrow">{t("app.eyebrow")}</p>
<h1>{t("app.title")}</h1>
</div>
<a className="text-link" href="/veileder">
{t("app.evaluator")}
</a>
</header>
<nav className="main-nav" aria-label={t("app.navLabel")}>
<NavButton icon={<Plus />} label={t("nav.register")} active={view === "registrer"} onClick={() => setView("registrer")} />
<NavButton
icon={<FileClock />}
label={t("nav.followup")}
active={view === "oppfolging"}
onClick={() => setView("oppfolging")}
/>
<NavButton icon={<BarChart3 />} label={t("nav.overview")} active={view === "oversikt"} onClick={() => setView("oversikt")} />
<NavButton
icon={<ShieldAlert />}
label={t("nav.privacy")}
active={view === "personvern"}
onClick={() => setView("personvern")}
/>
</nav>
<main>
{view === "registrer" && <ReportForm onSaved={refresh} />}
{view === "oppfolging" && <FollowupForm reports={entries} onSaved={refresh} />}
{view === "oversikt" && (
<Overview
entries={entries}
selectedEntry={selectedEntry}
onSelect={setSelectedEntryId}
onClose={() => setSelectedEntryId(null)}
onRequestDelete={(entry) => setDeleteRequest({ id: entry.id, name: entry.type === "work_report" ? entry.workplace || t("danger.reportName") : t("danger.followupName") })}
/>
)}
{view === "personvern" && <PrivacyPanel entries={entries} onDeleted={refresh} />}
</main>
<AppFooter />
<ConfirmDeleteModal
itemName={deleteRequest?.name ?? ""}
open={Boolean(deleteRequest)}
onCancel={() => setDeleteRequest(null)}
onConfirm={() => {
if (!deleteRequest) return;
deleteReport(deleteRequest.id);
if (selectedEntryId === deleteRequest.id) setSelectedEntryId(null);
setDeleteRequest(null);
refresh();
}}
/>
</div>
);
}
function NavButton({
icon,
label,
active,
onClick
}: {
icon: React.ReactNode;
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button className={active ? "nav-button active" : "nav-button"} onClick={onClick} type="button">
{icon}
<span>{label}</span>
</button>
);
}
function AppFooter() {
const { language, setLanguage, t } = useI18n();
const baseYear = 2026;
const currentYear = new Date().getFullYear();
const yearLabel = currentYear > baseYear ? `${baseYear}-${currentYear}` : `${baseYear}`;
return (
<footer className="app-footer">
<div className="footer-language-switcher" aria-label={t("footer.languageChoice")}>
<button
aria-current={language === "nb-NO"}
aria-label={t("footer.switchToNorwegian")}
className="footer-language-button"
onClick={() => setLanguage("nb-NO")}
type="button"
>
{t("footer.norwegian")} 🇳🇴
</button>
<span className="footer-language-separator">|</span>
<button
aria-current={language === "en"}
aria-label={t("footer.switchToEnglish")}
className="footer-language-button"
onClick={() => setLanguage("en")}
type="button"
>
{t("footer.english")} 🇬🇧
</button>
</div>
<div className="footer-credit">
© {yearLabel} {t("footer.developedBy")} <Heart aria-label={t("footer.heart")} className="footer-heart" />
</div>
<div className="footer-links">
<a className="footer-link" href="https://git.rolfsvaag.no/frarol96/Arbeidspuls" rel="noreferrer" target="_blank">
<Github aria-hidden="true" />
<span>{t("footer.sourceCode", { version: APP_VERSION })}</span>
</a>
</div>
</footer>
);
}
function ConfirmDeleteModal({
open,
itemName,
onCancel,
onConfirm
}: {
open: boolean;
itemName: string;
onCancel: () => void;
onConfirm: () => void;
}) {
const { t } = useI18n();
const [secondsLeft, setSecondsLeft] = useState(1);
useEffect(() => {
if (!open) return;
setSecondsLeft(1);
const timer = window.setTimeout(() => setSecondsLeft(0), 1000);
return () => window.clearTimeout(timer);
}, [open, itemName]);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onCancel();
if (event.key === "Enter" && secondsLeft > 0) event.preventDefault();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onCancel, open, secondsLeft]);
if (!open) return null;
return (
<div className="modal-overlay" role="presentation" onMouseDown={onCancel}>
<section className="confirm-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title" onMouseDown={(event) => event.stopPropagation()}>
<h2 id="confirm-title">{t("deleteModal.title")}</h2>
<p>{t("deleteModal.body", { itemName })}</p>
<div className="action-grid">
<button className="secondary-action" onClick={onCancel} type="button">
{t("common.cancel")}
</button>
<button className="secondary-action danger" disabled={secondsLeft > 0} onClick={onConfirm} type="button">
{secondsLeft > 0 ? t("deleteModal.waiting", { secondsLeft }) : t("deleteModal.ready")}
</button>
</div>
</section>
</div>
);
}
function createInitialReportDraft(): ReportDraft {
const last = getLastWorkReport();
return {
workplace: last?.workplace ?? "",
work_start_time: last?.work_start_time ?? "",
work_end_time: last?.work_end_time ?? "",
task_types: [],
main_limitations: [],
helpful_accommodations: [],
note: ""
};
}
function ReportForm({ onSaved }: { onSaved: () => void }) {
const { labels, t } = useI18n();
const [draft, setDraft] = useState<ReportDraft>(() => createInitialReportDraft());
const [attempted, setAttempted] = useState(false);
const [savedText, setSavedText] = useState("");
const reportFields = requiredReportFields.map((field) => ({ ...field, label: reportFieldLabel(field.key, t) }));
const missing = reportFields.filter((field) => !draft[field.key]);
const canSubmit = missing.length === 0;
const setScale = (key: keyof ReportDraft, value: ScaleValue) => {
setDraft((current) => ({ ...current, [key]: value }));
};
const toggle = <T extends string>(key: keyof ReportDraft, value: T) => {
setDraft((current) => {
const values = current[key] as T[];
return {
...current,
[key]: values.includes(value) ? values.filter((item) => item !== value) : [...values, value]
};
});
};
const submit = () => {
setAttempted(true);
if (!canSubmit) return;
createReport(draft);
setDraft(createInitialReportDraft());
setAttempted(false);
setSavedText(t("register.saved"));
onSaved();
window.setTimeout(() => setSavedText(""), 2400);
};
return (
<section className="flow">
<div className="notice">
<Activity aria-hidden="true" />
<span>{t("register.intro")}</span>
</div>
<section className="field-card compact-session">
<div className="field-head inline-head">
<h2>{t("register.workSession")}</h2>
<Tooltip text={`${labels.tooltipTexts.workplace} ${labels.tooltipTexts.work_time}`} />
</div>
<div className="session-grid">
<label className="text-field">
<span>{t("register.workplace")}</span>
<input
value={draft.workplace ?? ""}
onChange={(event) => setDraft((current) => ({ ...current, workplace: event.target.value }))}
placeholder={t("register.optional")}
/>
</label>
<label className="text-field">
<span>{t("register.startTime")}</span>
<input
type="time"
value={draft.work_start_time ?? ""}
onChange={(event) => setDraft((current) => ({ ...current, work_start_time: event.target.value }))}
/>
</label>
<label className="text-field">
<span>{t("register.endTime")}</span>
<input
type="time"
value={draft.work_end_time ?? ""}
onChange={(event) => setDraft((current) => ({ ...current, work_end_time: event.target.value }))}
/>
</label>
</div>
</section>
<div className="form-stack">
{reportFields.map((field) => (
<ScaleField
key={field.key}
label={field.label}
tooltip={labels.tooltipTexts[field.key]}
value={draft[field.key] as ScaleValue | undefined}
guidance={field.negative ? labels.negativeScale : labels.positiveScale}
invalid={attempted && !draft[field.key]}
onChange={(value) => setScale(field.key, value)}
/>
))}
<section className={attempted && !draft.status ? "field-card invalid" : "field-card"}>
<div className="field-head">
<h2>{t("register.continueQuestion")}</h2>
<p>{t("register.chooseBestNow")}</p>
</div>
<div className="status-grid">
{visibleStatusOptions.map((status) => (
<button
className={draft.status === status ? "status-button selected" : "status-button"}
key={status}
onClick={() => setDraft((current) => ({ ...current, status }))}
type="button"
>
{labels.statusLabels[status]}
</button>
))}
</div>
</section>
<details className="details-panel">
<summary>{t("register.optionalDetails")}</summary>
<div className="details-content">
<ScaleField
label={t("register.physicalEnergy")}
tooltip={labels.tooltipTexts.physical_energy}
value={draft.physical_energy_detail}
guidance={labels.positiveScale}
compact
onChange={(value) => setScale("physical_energy_detail", value)}
/>
<ScaleField
label={t("register.mentalEnergy")}
tooltip={labels.tooltipTexts.mental_energy}
value={draft.mental_energy_detail}
guidance={labels.positiveScale}
compact
onChange={(value) => setScale("mental_energy_detail", value)}
/>
<ScaleField
label={t("register.productivity")}
value={draft.perceived_productivity}
guidance={labels.positiveScale}
compact
onChange={(value) => setScale("perceived_productivity", value)}
/>
<SelectField
label={t("register.taskCompletion")}
value={draft.task_completion}
options={labels.taskCompletionLabels}
onChange={(value) => setDraft((current) => ({ ...current, task_completion: value }))}
/>
<ChipGroup
label={t("register.taskTypes")}
options={taskTypeOptions}
labels={labels.taskTypeLabels}
values={draft.task_types}
onToggle={(value) => toggle("task_types", value)}
/>
<ChipGroup
label={t("register.limitations")}
options={limitationOptions}
labels={labels.limitationLabels}
values={draft.main_limitations}
onToggle={(value) => toggle("main_limitations", value)}
/>
<ChipGroup
label={t("register.accommodations")}
options={accommodationOptions}
labels={labels.accommodationLabels}
values={draft.helpful_accommodations}
onToggle={(value) => toggle("helpful_accommodations", value)}
/>
<label className="text-field">
<span>{t("register.note")}</span>
<textarea
maxLength={1000}
value={draft.note}
onChange={(event) => setDraft((current) => ({ ...current, note: event.target.value }))}
rows={4}
/>
</label>
</div>
</details>
</div>
<div className="sticky-actions">
{attempted && !canSubmit && <p className="error-text">{t("register.missing", { fields: missing.map((field) => field.label).join(", ") })}</p>}
{savedText && <p className="success-text">{savedText}</p>}
<button className="primary-action" onClick={submit} type="button">
<Save aria-hidden="true" />
<span>{t("register.saveReport")}</span>
</button>
</div>
</section>
);
}
const requiredReportFields: Array<{
key: "work_ability" | "energy_level" | "mental_clarity" | "symptom_burden" | "effort_strain";
label: string;
negative?: boolean;
}> = [
{ key: "work_ability", label: "Arbeidsevne akkurat nå" },
{ key: "energy_level", label: "Energinivå" },
{ key: "mental_clarity", label: "Mental klarhet / konsentrasjon" },
{ key: "symptom_burden", label: "Symptombelastning", negative: true },
{ key: "effort_strain", label: "Anstrengelse / belastning", negative: true }
];
function reportFieldLabel(key: (typeof requiredReportFields)[number]["key"], t: ReturnType<typeof useI18n>["t"]) {
const map: Record<typeof key, string> = {
work_ability: t("register.workAbility"),
energy_level: t("register.energyLevel"),
mental_clarity: t("register.mentalClarity"),
symptom_burden: t("register.symptomBurden"),
effort_strain: t("register.effortStrain")
};
return map[key];
}
function ScaleField({
label,
value,
guidance,
tooltip,
invalid,
compact,
onChange
}: {
label: string;
value?: ScaleValue;
guidance: Record<ScaleValue, string>;
tooltip?: string;
invalid?: boolean;
compact?: boolean;
onChange: (value: ScaleValue) => void;
}) {
const { t } = useI18n();
return (
<section className={`${compact ? "field-card compact" : "field-card"} ${invalid ? "invalid" : ""}`}>
<div className="field-head">
<div className="inline-head">
<h2>{label}</h2>
{tooltip && <Tooltip text={tooltip} />}
</div>
<p>
{t("register.oneEqualsFiveEquals", { low: guidance[1].toLowerCase(), high: guidance[5].toLowerCase() })}
</p>
</div>
<div className="scale-grid" role="group" aria-label={label}>
{([1, 2, 3, 4, 5] as ScaleValue[]).map((number) => (
<button
className={value === number ? "scale-button selected" : "scale-button"}
key={number}
onClick={() => onChange(number)}
type="button"
aria-pressed={value === number}
title={guidance[number]}
>
<strong>{number}</strong>
<span>{guidance[number]}</span>
</button>
))}
</div>
</section>
);
}
function Tooltip({ text, label }: { text: string; label?: string }) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open]);
return (
<span className={open ? "tooltip open" : "tooltip"} onMouseLeave={() => setOpen(false)}>
<button
className="tooltip-button"
type="button"
aria-label={label ?? t("overview.explainTrend")}
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
onFocus={() => setOpen(true)}
>
<HelpCircle aria-hidden="true" />
</button>
<span role="tooltip">{text}</span>
</span>
);
}
function SelectField<T extends string>({
label,
value,
options,
onChange
}: {
label: string;
value?: T;
options: Record<T, string>;
onChange: (value: T | undefined) => void;
}) {
const { t } = useI18n();
return (
<label className="select-field">
<span>{label}</span>
<select value={value ?? ""} onChange={(event) => onChange((event.target.value || undefined) as T | undefined)}>
<option value="">{t("common.notSelected")}</option>
{(Object.keys(options) as T[]).map((key) => (
<option key={key} value={key}>
{options[key]}
</option>
))}
</select>
</label>
);
}
function ChipGroup<T extends string>({
label,
options,
labels,
values,
onToggle
}: {
label: string;
options: T[];
labels: Record<T, string>;
values: T[];
onToggle: (value: T) => void;
}) {
return (
<section className="chip-section">
<h3>{label}</h3>
<div className="chips">
{options.map((option) => (
<button
className={values.includes(option) ? "chip selected" : "chip"}
key={option}
onClick={() => onToggle(option)}
type="button"
aria-pressed={values.includes(option)}
>
{labels[option]}
</button>
))}
</div>
</section>
);
}
function FollowupForm({ reports, onSaved }: { reports: StoredEntry[]; onSaved: () => void }) {
const { labels, t } = useI18n();
const [draft, setDraft] = useState<FollowupDraft>({ delayed_symptoms: [], note: "" });
const [attempted, setAttempted] = useState(false);
const [savedText, setSavedText] = useState("");
const workReports = reports
.filter((entry): entry is Report => entry.type === "work_report")
.sort((a, b) => getEntryTime(b) - getEntryTime(a));
const submit = () => {
setAttempted(true);
if (!draft.worse_than_before) return;
createFollowup(draft);
setDraft({ delayed_symptoms: [], note: "" });
setAttempted(false);
setSavedText(t("followup.saved"));
onSaved();
window.setTimeout(() => setSavedText(""), 2400);
};
const toggleSymptom = (value: DelayedSymptom) => {
setDraft((current) => ({
...current,
delayed_symptoms: current.delayed_symptoms.includes(value)
? current.delayed_symptoms.filter((item) => item !== value)
: [...current.delayed_symptoms, value]
}));
};
return (
<section className="flow">
<div className="notice">
<FileClock aria-hidden="true" />
<span>{t("followup.intro")}</span>
</div>
<section className={attempted && !draft.worse_than_before ? "field-card invalid" : "field-card"}>
<div className="field-head">
<h2>{t("followup.worseQuestion")}</h2>
<p>{t("followup.chooseOne")}</p>
</div>
<div className="status-grid four">
{(Object.keys(worseLabels) as WorseThanBefore[]).map((value) => (
<button
className={draft.worse_than_before === value ? "status-button selected" : "status-button"}
key={value}
onClick={() => setDraft((current) => ({ ...current, worse_than_before: value }))}
type="button"
>
{labels.worseLabels[value]}
</button>
))}
</div>
</section>
<section className="field-card">
<SelectField
label={t("followup.connectReport")}
value={draft.parent_report_id}
options={Object.fromEntries(workReports.map((report) => [report.id, formatReportPickerLabel(report)]))}
onChange={(value) => setDraft((current) => ({ ...current, parent_report_id: value, related_report_id: value }))}
/>
</section>
<details className="details-panel">
<summary>{t("register.optionalDetails")}</summary>
<div className="details-content">
<ChipGroup
label={t("followup.delayedSymptoms")}
options={delayedSymptomOptions}
labels={labels.delayedSymptomLabels}
values={draft.delayed_symptoms}
onToggle={toggleSymptom}
/>
<SelectField
label={t("followup.recovery")}
value={draft.recovery_status}
options={labels.recoveryLabels}
onChange={(value) => setDraft((current) => ({ ...current, recovery_status: value as RecoveryStatus | undefined }))}
/>
<label className="text-field">
<span>{t("register.note")}</span>
<textarea
maxLength={1000}
value={draft.note}
onChange={(event) => setDraft((current) => ({ ...current, note: event.target.value }))}
rows={4}
/>
</label>
</div>
</details>
<div className="sticky-actions">
{attempted && !draft.worse_than_before && <p className="error-text">{t("followup.missingWorse")}</p>}
{savedText && <p className="success-text">{savedText}</p>}
<button className="primary-action" onClick={submit} type="button">
<Send aria-hidden="true" />
<span>{t("followup.saveFollowup")}</span>
</button>
</div>
</section>
);
}
function Overview({
entries,
selectedEntry,
onSelect,
onClose,
onRequestDelete
}: {
entries: StoredEntry[];
selectedEntry?: StoredEntry;
onSelect: (id: string) => void;
onClose: () => void;
onRequestDelete: (entry: StoredEntry) => void;
}) {
const { labels, t } = useI18n();
const [period, setPeriod] = useState<TrendPeriod>(() => readPeriod());
const followupsByParent = useMemo(() => groupFollowups(entries), [entries]);
const orphanFollowups = useMemo(
() => entries.filter((entry): entry is DelayedFollowup => entry.type === "delayed_followup" && !findParentId(entry)),
[entries]
);
const reports = useMemo(() => entries.filter((entry): entry is Report => entry.type === "work_report"), [entries]);
const setStoredPeriod = (next: TrendPeriod) => {
setPeriod(next);
localStorage.setItem(PERIOD_KEY, JSON.stringify(next));
};
return (
<section className="overview-layout">
<div className="overview-main">
<PeriodControls period={period} onChange={setStoredPeriod} />
<Trend entries={entries} period={period} />
<div className="entry-list">
{reports.length === 0 && orphanFollowups.length === 0 && <p className="empty-text">{t("overview.noReports")}</p>}
{reports.map((entry) => (
<ReportListItem
key={entry.id}
report={entry}
followups={followupsByParent.get(entry.id) ?? []}
onSelect={onSelect}
/>
))}
{orphanFollowups.map((entry) => (
<button className="entry-row" key={entry.id} onClick={() => onSelect(entry.id)} type="button">
<div>
<strong>{t("followup.orphan")}</strong>
<span>{formatDateTime(entry)}</span>
</div>
<span className="followup-worse">{t("followup.worsePrefix")}: <strong>{labels.worseLabels[entry.worse_than_before]}</strong></span>
</button>
))}
</div>
</div>
{selectedEntry && <ReportModal entry={selectedEntry} onClose={onClose} onRequestDelete={onRequestDelete} />}
</section>
);
}
function ReportModal({
entry,
onClose,
onRequestDelete
}: {
entry: StoredEntry;
onClose: () => void;
onRequestDelete: (entry: StoredEntry) => void;
}) {
const { t } = useI18n();
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
return (
<div className="modal-overlay report-overlay" role="presentation" onMouseDown={onClose}>
<section className="report-modal" role="dialog" aria-modal="true" onMouseDown={(event) => event.stopPropagation()}>
<button className="modal-close" onClick={onClose} type="button" aria-label={t("overview.closeReport")}>
<X aria-hidden="true" />
</button>
<EntryDetails entry={entry} onRequestDelete={onRequestDelete} />
</section>
</div>
);
}
function ReportListItem({
report,
followups,
onSelect
}: {
report: Report;
followups: DelayedFollowup[];
onSelect: (id: string) => void;
}) {
const { labels, t } = useI18n();
const score = getReportScore(report);
return (
<div className="report-group">
<button className="entry-row" onClick={() => onSelect(report.id)} type="button">
<div>
<strong>{report.workplace || t("overview.report")}</strong>
<span>
{formatDateTime(report)}
{report.work_start_time || report.work_end_time ? ` · ${report.work_start_time || "?"}-${report.work_end_time || "?"}` : ""}
</span>
</div>
<div className="metrics-line">
<MetricTag code="A" value={report.work_ability} />
<MetricTag code="E" value={report.energy_level} />
<MetricTag code="K" value={report.mental_clarity} />
<MetricTag code="S" value={report.symptom_burden} />
<MetricTag code="B" value={report.effort_strain} />
{score !== null && <MetricTag code="T" value={`${score}%`} />}
</div>
</button>
{followups.length > 0 && (
<div className="followup-list">
{followups.map((followup) => (
<button className="followup-row" key={followup.id} onClick={() => onSelect(followup.id)} type="button">
<span>{t("overview.followup")} {formatDateTime(followup)}</span>
<span className="followup-worse">{t("followup.worsePrefix")}: <strong>{labels.worseLabels[followup.worse_than_before]}</strong></span>
</button>
))}
</div>
)}
</div>
);
}
function MetricTag({ code, value }: { code: keyof typeof categoryTooltipTexts; value: React.ReactNode }) {
const { labels } = useI18n();
return (
<span title={`${labels.categoryTooltipTexts[code]}: ${value}`}>
{code} {value}
</span>
);
}
function VerificationIndicator({ entry }: { entry: StoredEntry }) {
const { t } = useI18n();
const status = entry.integrity?.verification_status;
if (!status || status === "valid") return null;
return (
<button
className="verification-warning"
type="button"
title={verificationTooltip(status)}
aria-label={t("verification.invalidLabel")}
onClick={(event) => event.stopPropagation()}
>
!
</button>
);
}
type TrendPeriod = {
preset: PresetRange;
startDate: string;
endDate: string;
};
function PeriodControls({ period, onChange }: { period: TrendPeriod; onChange: (period: TrendPeriod) => void }) {
const { t } = useI18n();
const applyPreset = (preset: PresetRange) => {
const today = new Date();
const endDate = toDateInput(today);
if (preset === "all") {
onChange({ preset, startDate: "", endDate: "" });
return;
}
if (preset === "custom") {
onChange({ ...period, preset });
return;
}
const start = new Date(today);
const days = { "7d": 7, "1m": 31, "3m": 92, "6m": 183, "1y": 365, "3y": 1095, "5y": 1825 }[preset];
start.setDate(start.getDate() - days);
onChange({ preset, startDate: toDateInput(start), endDate });
};
return (
<section className="period-panel">
<div className="period-presets">
{[
["7d", t("overview.week")],
["1m", t("overview.month")],
["3m", t("overview.threeMonths")],
["6m", t("overview.sixMonths")],
["1y", t("overview.year")],
["3y", t("overview.threeYears")],
["5y", t("overview.fiveYears")],
["all", t("overview.all")]
].map(([preset, label]) => (
<button
className={period.preset === preset ? "chip selected" : "chip"}
key={preset}
onClick={() => applyPreset(preset as PresetRange)}
type="button"
>
{label}
</button>
))}
</div>
<div className="date-range">
<label>
<span>{t("overview.from")}</span>
<input
type="date"
value={period.startDate}
onChange={(event) => onChange({ ...period, preset: "custom", startDate: event.target.value })}
/>
</label>
<label>
<span>{t("overview.to")}</span>
<input
type="date"
value={period.endDate}
onChange={(event) => onChange({ ...period, preset: "custom", endDate: event.target.value })}
/>
</label>
</div>
</section>
);
}
function Trend({ entries, period, showScoreBands = false }: { entries: StoredEntry[]; period: TrendPeriod; showScoreBands?: boolean }) {
const { labels, language, t } = useI18n();
const [visibility, setVisibility] = useState<Record<string, LineVisibility>>({
total_score_percent: "dim"
});
const [fullscreenOpen, setFullscreenOpen] = useState(false);
const reports = useMemo(() => {
return filterReportsByPeriod(entries, period);
}, [entries, period]);
const periodAssessment = showScoreBands ? calculatePeriodWorkAbilityAssessment(reports) : null;
const metrics = trendMetrics;
if (reports.length < 2) {
return (
<section className="trend-panel">
<div className="trend-head">
<div className="inline-head">
<h2>{t("overview.trend")}</h2>
<Tooltip text={labels.tooltipTexts.total_score_percent} label={t("overview.explainTrend")} />
</div>
{showScoreBands && (
<button className="graph-zoom-button" onClick={() => setFullscreenOpen(true)} type="button" aria-label={t("graphModal.open")}>
<Search aria-hidden="true" />
</button>
)}
</div>
<p className="empty-text">{t("overview.minimumTwo")}</p>
{showScoreBands && <PeriodAssessment assessment={periodAssessment} />}
{showScoreBands && <FullscreenGraphModal entries={entries} initialPeriod={period} open={fullscreenOpen} onClose={() => setFullscreenOpen(false)} />}
</section>
);
}
const width = 760;
const height = 260;
const padding = { left: 36, right: 12, top: 18, bottom: 44 };
const times = reports.map(getEntryTime);
const [periodStart, periodEnd] = periodBounds(period);
const minTime = periodStart ?? Math.min(...times);
const maxTime = periodEnd ?? Math.max(...times);
const range = Math.max(1, maxTime - minTime);
const x = (time: number) => padding.left + ((time - minTime) / range) * (width - padding.left - padding.right);
const y = (value: number) => padding.top + (1 - value / 100) * (height - padding.top - padding.bottom);
const markers = createTimeMarkers(minTime, maxTime);
const averages = metrics
.map((metric) => ({
...metric,
average: average(reports.map((report) => normalizeReportMetric(report, metric.key)).filter(isNumber))
}))
.filter((metric) => metric.average !== null);
const cycleVisibility = (key: string) => {
setVisibility((current) => {
const next = current[key] === "dim" ? "hidden" : current[key] === "hidden" ? "normal" : "dim";
return { ...current, [key]: next };
});
};
return (
<section className="trend-panel">
<div className="trend-head">
<div className="inline-head">
<h2>{t("overview.trend")}</h2>
<Tooltip text={t("overview.trendTooltip")} label={t("overview.explainTrend")} />
</div>
<div className="trend-actions">
<p>{t("overview.average")}: {Math.round(averages.find((item) => item.key === "total_score_percent")?.average ?? 0)}%</p>
{showScoreBands && (
<button className="graph-zoom-button" onClick={() => setFullscreenOpen(true)} type="button" aria-label={t("graphModal.open")}>
<Search aria-hidden="true" />
</button>
)}
</div>
</div>
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={t("overview.chartLabel")}>
{[0, 25, 50, 75, 100].map((line) => (
<g key={line}>
<line className="grid-line" x1={padding.left} x2={width - padding.right} y1={y(line)} y2={y(line)} />
<text className="axis-label" x="0" y={y(line) + 4}>
{line}
</text>
</g>
))}
{markers.map((marker) => (
<g key={`${marker.time}-${marker.label}`}>
<line className="time-marker" x1={x(marker.time)} x2={x(marker.time)} y1={padding.top} y2={height - padding.bottom} />
<text className="axis-label" x={x(marker.time)} y={height - 18} textAnchor="middle">
{marker.label}
</text>
</g>
))}
{metrics.flatMap((metric) => {
const state = visibility[metric.key] ?? "normal";
if (state === "hidden") return [];
const points = reports
.map((report) => {
const value = normalizeReportMetric(report, metric.key);
const time = getEntryTime(report);
return value === null ? null : { x: x(time), y: y(value), time };
})
.filter(isPoint);
return createLineSegments(points, padding.left).map((segment, index) => (
<line
key={`${metric.key}-${index}`}
x1={segment.x1}
x2={segment.x2}
y1={segment.y1}
y2={segment.y2}
stroke={metric.color}
strokeWidth={metric.key === "total_score_percent" ? "2.1" : "1.75"}
strokeLinecap="round"
opacity={state === "dim" ? 0.4 : 1}
strokeDasharray={segment.dashed ? "8 8" : undefined}
/>
));
})}
{metrics.flatMap((metric) => {
const state = visibility[metric.key] ?? "normal";
if (state === "hidden") return [];
return reports
.map((report) => {
const value = normalizeReportMetric(report, metric.key);
const time = getEntryTime(report);
return value === null ? null : { x: x(time), y: y(value), time };
})
.filter(isPoint)
.map((point) => (
<circle
key={`${metric.key}-point-${point.time}`}
cx={point.x}
cy={point.y}
r={metric.key === "total_score_percent" ? 4.5 : 4}
fill={metric.color}
opacity={state === "dim" ? 0.45 : 0.92}
/>
));
})}
</svg>
<div className="legend interactive">
{metrics.map((metric) => {
const state = visibility[metric.key] ?? "normal";
const metricAverage = averages.find((item) => item.key === metric.key)?.average ?? null;
const band = metric.key === "total_score_percent" && showScoreBands ? getScoreBand(metricAverage) : null;
return (
<button
className={`legend-chip ${state}`}
key={metric.key}
onClick={() => cycleVisibility(metric.key)}
type="button"
title={band ? `${metric.label}: ${Math.round(metricAverage ?? 0)}% · ${translateScoreBand(band, t)}. ${t("scoring.disclaimer")}` : metric.label}
>
<i style={{ background: metric.color }} /> {metric.short} {metric.label}
{band && <span className="legend-band">· {translateScoreBand(band, t, "short")}</span>}
</button>
);
})}
</div>
<p className="empty-text">{t("overview.dashedLine")}</p>
<div className="average-grid">
{averages.map((metric) => (
<span key={metric.key}>
{metric.short}: {Math.round(metric.average ?? 0)}%
</span>
))}
</div>
{showScoreBands && <PeriodAssessment assessment={periodAssessment} />}
{showScoreBands && <FullscreenGraphModal entries={entries} initialPeriod={period} open={fullscreenOpen} onClose={() => setFullscreenOpen(false)} />}
</section>
);
}
function FullscreenGraphModal({
entries,
initialPeriod,
open,
onClose
}: {
entries: StoredEntry[];
initialPeriod: TrendPeriod;
open: boolean;
onClose: () => void;
}) {
const { t } = useI18n();
const [period, setPeriod] = useState(initialPeriod);
const [visibility, setVisibility] = useState<Record<string, LineVisibility>>({ total_score_percent: "dim" });
const [inspect, setInspect] = useState<{ x: number; report: Report | null } | null>(null);
const closeRef = useRef<HTMLButtonElement | null>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
const modalRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
setPeriod(initialPeriod);
window.setTimeout(() => closeRef.current?.focus(), 0);
}, [initialPeriod, open]);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
if (event.key !== "Tab" || !modalRef.current) return;
const focusable = Array.from(
modalRef.current.querySelectorAll<HTMLElement>("button, input, select, textarea, [href], [tabindex]:not([tabindex='-1'])")
).filter((element) => !element.hasAttribute("disabled"));
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose, open]);
const reports = useMemo(() => filterReportsByPeriod(entries, period), [entries, period]);
const width = 1120;
const height = 520;
const padding = { left: 44, right: 24, top: 26, bottom: 58 };
const times = reports.map(getEntryTime);
const [periodStart, periodEnd] = periodBounds(period);
const minTime = periodStart ?? (times.length ? Math.min(...times) : Date.now());
const maxTime = periodEnd ?? (times.length ? Math.max(...times) : minTime + 1);
const range = Math.max(1, maxTime - minTime);
const x = (time: number) => padding.left + ((time - minTime) / range) * (width - padding.left - padding.right);
const y = (value: number) => padding.top + (1 - value / 100) * (height - padding.top - padding.bottom);
const markers = createTimeMarkers(minTime, maxTime);
const cycleVisibility = (key: string) => {
setVisibility((current) => {
const next = current[key] === "dim" ? "hidden" : current[key] === "hidden" ? "normal" : "dim";
return { ...current, [key]: next };
});
};
const inspectAt = (clientX: number) => {
if (!svgRef.current || reports.length === 0) return;
const rect = svgRef.current.getBoundingClientRect();
const localX = ((clientX - rect.left) / rect.width) * width;
const nearest = reports
.map((report) => ({ report, distance: Math.abs(x(getEntryTime(report)) - localX) }))
.sort((a, b) => a.distance - b.distance)[0];
setInspect({
x: Math.min(width - padding.right, Math.max(padding.left, localX)),
report: nearest && nearest.distance <= 16 ? nearest.report : null
});
};
if (!open) return null;
return (
<div className="modal-overlay graph-modal-overlay" role="presentation" onMouseDown={onClose}>
<section ref={modalRef} className="graph-modal" role="dialog" aria-modal="true" aria-labelledby="graph-modal-title" onMouseDown={(event) => event.stopPropagation()}>
<div className="graph-modal-head">
<div>
<p className="eyebrow">{t("evaluator.eyebrow")}</p>
<h2 id="graph-modal-title">{t("graphModal.title")}</h2>
</div>
<button ref={closeRef} className="modal-close" onClick={onClose} type="button" aria-label={t("graphModal.close")}>
<X aria-hidden="true" />
</button>
</div>
<div className="graph-modal-controls">
<PeriodControls period={period} onChange={setPeriod} />
<section className="period-panel">
<div className="field-head">
<h2>{t("graphModal.visibleLines")}</h2>
<p>{t("graphModal.visibleHelp")}</p>
</div>
<div className="legend interactive">
{trendMetrics.map((metric) => {
const state = visibility[metric.key] ?? "normal";
return (
<button className={`legend-chip ${state}`} key={metric.key} onClick={() => cycleVisibility(metric.key)} type="button">
<i style={{ background: metric.color }} /> {metric.short} {metric.label}
</button>
);
})}
</div>
</section>
</div>
<div className="graph-modal-body">
<p className="empty-text">{t("graphModal.snapHelp")}</p>
{reports.length < 1 ? (
<p className="empty-text">{t("graphModal.noReports")}</p>
) : (
<div className="graph-inspection-layout">
<svg
ref={svgRef}
className="fullscreen-chart"
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label={t("graphModal.chartLabel")}
onPointerLeave={() => setInspect(null)}
onPointerMove={(event) => inspectAt(event.clientX)}
>
{[0, 25, 50, 75, 100].map((line) => (
<g key={line}>
<line className="grid-line" x1={padding.left} x2={width - padding.right} y1={y(line)} y2={y(line)} />
<text className="axis-label" x="0" y={y(line) + 4}>
{line}
</text>
</g>
))}
{markers.map((marker) => (
<g key={`${marker.time}-${marker.label}`}>
<line className="time-marker" x1={x(marker.time)} x2={x(marker.time)} y1={padding.top} y2={height - padding.bottom} />
<text className="axis-label" x={x(marker.time)} y={height - 22} textAnchor="middle">
{marker.label}
</text>
</g>
))}
{trendMetrics.flatMap((metric) => {
const state = visibility[metric.key] ?? "normal";
if (state === "hidden") return [];
const points = reports
.map((report) => {
const value = normalizeReportMetric(report, metric.key);
const time = getEntryTime(report);
return value === null ? null : { x: x(time), y: y(value), time };
})
.filter(isPoint);
return createLineSegments(points, padding.left).map((segment, index) => (
<line
key={`${metric.key}-fullscreen-${index}`}
x1={segment.x1}
x2={segment.x2}
y1={segment.y1}
y2={segment.y2}
stroke={metric.color}
strokeWidth="1.75"
strokeLinecap="round"
opacity={state === "dim" ? 0.4 : 1}
strokeDasharray={segment.dashed ? "8 8" : undefined}
/>
));
})}
{trendMetrics.flatMap((metric) => {
const state = visibility[metric.key] ?? "normal";
if (state === "hidden") return [];
return reports
.map((report) => {
const value = normalizeReportMetric(report, metric.key);
const time = getEntryTime(report);
return value === null ? null : { x: x(time), y: y(value), time, reportId: report.id };
})
.filter((point): point is ChartPoint & { reportId: string } => Boolean(point))
.map((point) => (
<circle
key={`${metric.key}-fullscreen-point-${point.reportId}`}
className={inspect?.report?.id === point.reportId ? "chart-point active" : "chart-point"}
cx={point.x}
cy={point.y}
r={inspect?.report?.id === point.reportId ? 6.5 : 4.5}
fill={metric.color}
opacity={state === "dim" ? 0.45 : 0.95}
/>
));
})}
{inspect && <line className="inspect-line" x1={inspect.report ? x(getEntryTime(inspect.report)) : inspect.x} x2={inspect.report ? x(getEntryTime(inspect.report)) : inspect.x} y1={padding.top} y2={height - padding.bottom} />}
</svg>
<GraphInspectTooltip report={inspect?.report ?? null} visibility={visibility} />
</div>
)}
</div>
</section>
</div>
);
}
function GraphInspectTooltip({ report, visibility }: { report: Report | null; visibility: Record<string, LineVisibility> }) {
const { t } = useI18n();
if (!report) {
return (
<aside className="graph-inspect-panel">
<h3>{t("graphModal.noPoint")}</h3>
<p>{t("graphModal.noPointHelp")}</p>
</aside>
);
}
return (
<aside className="graph-inspect-panel">
<h3>{t("graphModal.nearestReport")}</h3>
<p>
{formatDateTime(report)}
<br />
{report.workplace || t("common.unknownWorkplace")}
</p>
<dl>
{trendMetrics
.filter((metric) => (visibility[metric.key] ?? "normal") !== "hidden")
.map((metric) => {
const value = normalizeReportMetric(report, metric.key);
return (
<div key={metric.key}>
<dt>{metric.short}</dt>
<dd>{value === null ? "-" : `${Math.round(value)}%`}</dd>
</div>
);
})}
</dl>
</aside>
);
}
function PeriodAssessment({ assessment }: { assessment: ReturnType<typeof calculatePeriodWorkAbilityAssessment> | null }) {
const { t } = useI18n();
return (
<div className="period-assessment">
<div>
<strong>{t("scoring.periodLabel")}</strong>
<span>
{assessment?.score_percent === null || !assessment
? t("scoring.periodEmpty")
: `${assessment.score_percent} % · ${translateScoreBand(assessment.score_band, t)}`}
</span>
</div>
<Tooltip
text={t("scoring.periodTooltip")}
label={t("scoring.periodLabel")}
/>
</div>
);
}
function EntryDetails({ entry, onRequestDelete }: { entry: StoredEntry; onRequestDelete?: (entry: StoredEntry) => void }) {
const { labels, t } = useI18n();
const related =
entry.type === "delayed_followup" && findParentId(entry) ? listReports().find((item) => item.id === findParentId(entry)) : undefined;
return (
<div className="details-view">
<div className="detail-head">
<div>
<p className="eyebrow">{entry.type === "work_report" ? t("overview.report") : t("overview.followup")}</p>
<h2>{entry.type === "work_report" ? entry.workplace || t("common.unknownWorkplace") : formatDateTime(entry)}</h2>
</div>
</div>
{entry.type === "work_report" ? (
<dl className="detail-grid">
<Detail label={t("details.time")} value={formatDateTime(entry)} />
<Detail label={t("register.workplace")} value={entry.workplace || t("common.unknownWorkplace")} />
<Detail label={t("details.workTime")} value={`${entry.work_start_time || t("common.notSelected")} - ${entry.work_end_time || t("common.notSelected")}`} />
<Detail label={t("details.totalScore")} value={`${getReportScore(entry) ?? calculateReportScore(entry) ?? t("common.notCalculated")}%`} />
<Detail label={t("details.workAbility")} value={entry.work_ability} />
<Detail label={t("details.energy")} value={entry.energy_level} />
<Detail label={t("details.physicalEnergy")} value={entry.physical_energy} />
<Detail label={t("details.mentalEnergy")} value={entry.mental_energy} />
<Detail label={t("details.mentalClarity")} value={entry.mental_clarity} />
<Detail label={t("details.symptoms")} value={entry.symptom_burden} />
<Detail label={t("details.strain")} value={entry.effort_strain} />
<Detail label={t("details.status")} value={labels.statusLabels[entry.status]} />
<Detail label={t("details.productivity")} value={entry.perceived_productivity ?? t("common.notSelected")} />
<Detail label={t("details.taskCompletion")} value={entry.task_completion ? labels.taskCompletionLabels[entry.task_completion] : t("common.notSelected")} />
<Detail label={t("details.taskTypes")} value={labelsFor(entry.task_types, labels.taskTypeLabels, t)} />
<Detail label={t("details.limitations")} value={labelsFor(entry.main_limitations, labels.limitationLabels, t)} />
<Detail label={t("details.accommodations")} value={labelsFor(entry.helpful_accommodations, labels.accommodationLabels, t)} />
<Detail label={t("details.comment")} value={entry.note ?? t("details.noComment")} />
</dl>
) : (
<dl className="detail-grid">
<Detail label={t("details.worse")} value={labels.worseLabels[entry.worse_than_before]} />
<Detail label={t("details.delayedSymptoms")} value={labelsFor(entry.delayed_symptoms, labels.delayedSymptomLabels, t)} />
<Detail label={t("details.recovery")} value={entry.recovery_status ? labels.recoveryLabels[entry.recovery_status] : t("common.notSelected")} />
<Detail label={t("details.connectedReport")} value={related ? formatDateTime(related) : t("common.notSelected")} />
<Detail label={t("details.comment")} value={entry.note ?? t("details.noComment")} />
</dl>
)}
{onRequestDelete && (
<section className="danger-zone">
<h3>{t("danger.title")}</h3>
<p>{t("danger.deleteEntryHelp")}</p>
<button className="secondary-action danger" onClick={() => onRequestDelete(entry)} type="button">
<Trash2 aria-hidden="true" />
<span>{entry.type === "work_report" ? t("danger.deleteReport") : t("danger.deleteFollowup")}</span>
</button>
</section>
)}
</div>
);
}
function Detail({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
);
}
function PrivacyPanel({ entries, onDeleted }: { entries: StoredEntry[]; onDeleted: () => void }) {
const { t } = useI18n();
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false);
const [pendingImport, setPendingImport] = useState<{ text: string; summary: ImportSummary; fileName: string } | null>(null);
const [importMessage, setImportMessage] = useState("");
const download = () => {
const blob = new Blob([exportReports()], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `arbeidsevne-rapporter-${new Date().toISOString().slice(0, 10)}.json`;
link.click();
URL.revokeObjectURL(url);
};
const clear = () => {
deleteAllReports();
setConfirmDeleteAll(false);
onDeleted();
};
const chooseImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const summary = previewImportReports(text);
setPendingImport({ text, summary, fileName: file.name });
setImportMessage("");
} catch (error) {
setPendingImport(null);
setImportMessage(error instanceof Error ? error.message : "Importfilen kunne ikke leses.");
} finally {
event.target.value = "";
}
};
const confirmImport = () => {
if (!pendingImport) return;
const summary = importReportsFromJson(pendingImport.text);
const flagged = summary.verification
? summary.verification.modified + summary.verification.missing + summary.verification.unsupported + summary.verification.error
: 0;
setPendingImport(null);
setImportMessage(
`Import fullført: ${summary.reports_added} nye rapporter, ${summary.followups_added} nye oppfølginger, ${summary.duplicates_skipped} duplikater hoppet over.${flagged > 0 ? " Noen rapporter kunne ikke verifiseres." : ""}`
);
onDeleted();
};
return (
<section className="privacy-panel">
<div className="notice warning">
<ShieldAlert aria-hidden="true" />
<span>{t("privacy.warning")}</span>
</div>
<div className="action-grid">
<button className="secondary-action" onClick={download} type="button" disabled={entries.length === 0}>
<Download aria-hidden="true" />
<span>{t("privacy.exportJson")}</span>
</button>
<label className="secondary-action file-inline">
<Upload aria-hidden="true" />
<span>{t("privacy.importBackup")}</span>
<input accept="application/json,.json" onChange={chooseImport} type="file" />
</label>
</div>
<div className="single-danger-action-row">
<button className="secondary-action danger" onClick={() => setConfirmDeleteAll(true)} type="button" disabled={entries.length === 0}>
<Trash2 aria-hidden="true" />
<span>{t("privacy.deleteAll")}</span>
</button>
</div>
<div className="notice">
<FileJson aria-hidden="true" />
<span>{t("privacy.importHelp")}</span>
</div>
{pendingImport && (
<section className="import-review">
<h2>{t("privacy.importFrom", { fileName: pendingImport.fileName })}</h2>
<p>
{t("privacy.importStats", {
reports: pendingImport.summary.reports_added,
followups: pendingImport.summary.followups_added,
duplicates: pendingImport.summary.duplicates_skipped,
conflicts: pendingImport.summary.conflicts_detected
})}
</p>
<div className="action-grid">
<button className="secondary-action" onClick={() => setPendingImport(null)} type="button">
{t("privacy.cancelImport")}
</button>
<button className="primary-action" onClick={confirmImport} type="button">
{t("privacy.importData")}
</button>
</div>
</section>
)}
{importMessage && <p className="success-text">{importMessage}</p>}
<p className="privacy-copy">
{t("privacy.baseCopy")}
</p>
<div className="privacy-copy privacy-sections">
<section>
<h2>{t("privacy.storageTitle")}</h2>
<p>{t("privacy.storageBody")}</p>
</section>
<section>
<h2>{t("privacy.backupTitle")}</h2>
<p>{t("privacy.backupBody")}</p>
</section>
<section>
<h2>{t("privacy.sharingTitle")}</h2>
<p>{t("privacy.sharingBody")}</p>
</section>
<section>
<h2>{t("privacy.sensitiveTitle")}</h2>
<p>{t("privacy.sensitiveBody")}</p>
</section>
</div>
<p className="success-text">{t("privacy.exportIntegrity")}</p>
<ConfirmDeleteModal open={confirmDeleteAll} itemName={t("privacy.deleteAllName")} onCancel={() => setConfirmDeleteAll(false)} onConfirm={clear} />
</section>
);
}
function EvaluatorApp() {
const { labels, t } = useI18n();
const [profiles, setProfiles] = useState<EvaluatorProfile[]>(() => listEvaluatorProfiles());
const [selectedId, setSelectedId] = useState<string | null>(() => listEvaluatorProfiles()[0]?.id ?? null);
const [displayName, setDisplayName] = useState("");
const [targetId, setTargetId] = useState("");
const [message, setMessage] = useState("");
const [personNote, setPersonNote] = useState("");
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [sortDir, setSortDir] = useState<"newest" | "oldest">("newest");
const [deletePersonRequest, setDeletePersonRequest] = useState<EvaluatorProfile | null>(null);
const selected = profiles.find((profile) => profile.id === selectedId);
const selectedEntry = selectedEntryId ? selected?.reports.find((entry) => entry.id === selectedEntryId) : undefined;
const period = readPeriod();
useEffect(() => {
if (selected) setPersonNote(getPersonNote(selected)?.text ?? "");
}, [selected?.id]);
const refresh = (nextSelectedId?: string) => {
const next = listEvaluatorProfiles();
setProfiles(next);
if (nextSelectedId) setSelectedId(nextSelectedId);
};
const importFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const result = importExportPayload(await file.text(), displayName, targetId || undefined);
const flagged = result.verification.modified + result.verification.missing + result.verification.unsupported + result.verification.error;
const actionText =
result.target_resolution === "matched_existing_person"
? "Importen ble knyttet til eksisterende bruker."
: "Opprettet ny bruker.";
setMessage(
flagged > 0
? `${actionText} ${result.added} nye rapporter, ${result.skipped} duplikater hoppet over. Noen rapporter kunne ikke verifiseres.`
: `${actionText} ${result.added} nye rapporter, ${result.skipped} duplikater hoppet over.`
);
setDisplayName("");
setTargetId("");
refresh(result.profile.id);
} catch (error) {
setMessage(error instanceof Error ? error.message : "Import feilet.");
} finally {
event.target.value = "";
}
};
const importBackup = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
importEvaluatorBackup(await file.text());
setMessage(t("evaluator.importedBackup"));
refresh();
} catch (error) {
setMessage(error instanceof Error ? error.message : "Backup kunne ikke importeres.");
} finally {
event.target.value = "";
}
};
const downloadBackup = () => {
const blob = new Blob([exportEvaluatorBackup()], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `arbeidsevne-veilederdata-${new Date().toISOString().slice(0, 10)}.json`;
link.click();
URL.revokeObjectURL(url);
};
const saveName = () => {
if (!selected) return;
renameEvaluatorProfile(selected.id, selected.display_name);
refresh(selected.id);
};
const savePersonNote = () => {
if (!selected) return;
upsertEvaluatorNote(selected.id, personNote, { note_type: "person" });
setPersonNote("");
refresh(selected.id);
};
const deleteSelectedPerson = () => {
if (!deletePersonRequest) return;
deleteEvaluatorProfile(deletePersonRequest.id);
const remaining = listEvaluatorProfiles();
setProfiles(remaining);
setSelectedId(remaining[0]?.id ?? null);
setSelectedEntryId(null);
setDeletePersonRequest(null);
};
const reports = useMemo(() => {
if (!selected) return [];
const lowered = search.trim().toLowerCase();
return selected.reports
.filter((entry): entry is Report => entry.type === "work_report")
.filter((report) => {
if (!lowered) return true;
return [report.workplace, report.note, report.report_date, labels.statusLabels[report.status]]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(lowered);
})
.sort((a, b) => (sortDir === "newest" ? getEntryTime(b) - getEntryTime(a) : getEntryTime(a) - getEntryTime(b)));
}, [labels.statusLabels, search, selected, sortDir]);
const followupsByParent = useMemo(() => (selected ? groupFollowups(selected.reports) : new Map<string, DelayedFollowup[]>()), [selected]);
return (
<div className="app-shell evaluator-shell">
<header className="topbar">
<div>
<p className="eyebrow">{t("evaluator.eyebrow")}</p>
<h1>{t("evaluator.title")}</h1>
</div>
<a className="text-link" href="/">
{t("evaluator.backToApp")}
</a>
</header>
<main className="evaluator-layout">
<section className="evaluator-sidebar">
<div className="field-card">
<div className="field-head">
<h2>{t("evaluator.importJson")}</h2>
<p>{t("evaluator.importHelp")}</p>
</div>
<label className="text-field">
<span>{t("evaluator.displayName")}</span>
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder={t("evaluator.optionalImportName")} />
</label>
<label className="select-field">
<span>{t("evaluator.connectExisting")}</span>
<select value={targetId} onChange={(event) => setTargetId(event.target.value)}>
<option value="">{t("evaluator.autoMatch")}</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.display_name}
</option>
))}
</select>
</label>
<label className="file-button">
<Upload aria-hidden="true" />
<span>{t("evaluator.chooseJson")}</span>
<input accept="application/json,.json" onChange={importFile} type="file" />
</label>
<div className="evaluator-backup-actions">
<button className="secondary-action" onClick={downloadBackup} type="button" disabled={profiles.length === 0}>
<Download aria-hidden="true" />
<span>{t("evaluator.exportBackup")}</span>
</button>
<label className="secondary-action file-inline">
<Upload aria-hidden="true" />
<span>{t("evaluator.importBackup")}</span>
<input accept="application/json,.json" onChange={importBackup} type="file" />
</label>
</div>
{message && <p className="success-text">{message}</p>}
</div>
<div className="profile-list">
{profiles.map((profile) => (
<button
className={selectedId === profile.id ? "profile-row active" : "profile-row"}
key={profile.id}
onClick={() => {
setSelectedId(profile.id);
setSelectedEntryId(null);
}}
type="button"
>
<UserRound aria-hidden="true" />
<span>{profile.display_name}</span>
<small>{t("evaluator.reportCount", { count: profile.reports.length })}</small>
</button>
))}
</div>
</section>
<section className="evaluator-main">
{!selected && (
<div className="notice">
<FileJson aria-hidden="true" />
<span>{t("evaluator.importStart")}</span>
</div>
)}
{selected && (
<div className="evaluator-main-grid">
<div className="evaluator-left-workspace">
<section className="field-card evaluator-name-card">
<label className="text-field">
<span>{t("evaluator.displayName")}</span>
<input
value={selected.display_name}
onChange={(event) => {
const value = event.target.value;
setProfiles((current) =>
current.map((profile) => (profile.id === selected.id ? { ...profile, display_name: value } : profile))
);
}}
/>
</label>
<div className="evaluator-name-actions">
<button className="secondary-action" onClick={saveName} type="button">
<Save aria-hidden="true" />
<span>{t("evaluator.saveName")}</span>
</button>
<button
className="secondary-action danger"
onClick={() => setDeletePersonRequest(selected)}
title={t("evaluator.deletePersonTitle")}
type="button"
>
<Trash2 aria-hidden="true" />
<span>{t("evaluator.deletePerson")}</span>
</button>
</div>
</section>
<Trend entries={selected.reports} period={period} showScoreBands />
<section className="field-card">
<div className="field-head">
<h2>{t("evaluator.personNote")}</h2>
<p>{t("evaluator.personNoteHelp")}</p>
</div>
<textarea
value={personNote}
onChange={(event) => setPersonNote(event.target.value)}
rows={4}
/>
<button className="primary-action" onClick={savePersonNote} type="button">
<Save aria-hidden="true" />
<span>{t("evaluator.saveNote")}</span>
</button>
{getPersonNote(selected) && <p className="empty-text">{t("evaluator.changedAt", { time: formatIsoDateTime(getPersonNote(selected)!.updated_at) })}</p>}
</section>
<div className="review-tools">
<label className="search-field">
<Search aria-hidden="true" />
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t("evaluator.searchReports")} />
</label>
<select value={sortDir} onChange={(event) => setSortDir(event.target.value as "newest" | "oldest")}>
<option value="newest">{t("evaluator.newest")}</option>
<option value="oldest">{t("evaluator.oldest")}</option>
</select>
</div>
<div className="entry-list">
{reports.map((report) => (
<ReviewReportCard
followups={followupsByParent.get(report.id) ?? []}
key={report.id}
notes={selected.evaluator_notes}
onSelect={setSelectedEntryId}
report={report}
selectedId={selectedEntryId}
/>
))}
</div>
</div>
<aside className="detail-pane evaluator-detail-pane">
{selectedEntry ? (
<ReviewDetailPane
entry={selectedEntry}
followups={selectedEntry.type === "work_report" ? followupsByParent.get(selectedEntry.id) ?? [] : []}
notes={selected.evaluator_notes}
profileId={selected.id}
onSaved={() => refresh(selected.id)}
onClose={() => setSelectedEntryId(null)}
/>
) : (
<div className="detail-empty-state">
<h2>{t("evaluator.noSelectedTitle")}</h2>
<p>{t("evaluator.noSelectedBody")}</p>
</div>
)}
</aside>
</div>
)}
</section>
</main>
<AppFooter />
<ConfirmDeleteModal
open={Boolean(deletePersonRequest)}
itemName={
deletePersonRequest
? `${deletePersonRequest.display_name}, alle importerte rapporter, oppfølginger og veiledernotater knyttet til denne brukeren`
: ""
}
onCancel={() => setDeletePersonRequest(null)}
onConfirm={deleteSelectedPerson}
/>
</div>
);
}
function ReviewReportCard({
report,
followups,
notes,
selectedId,
onSelect
}: {
report: Report;
followups: DelayedFollowup[];
notes: EvaluatorProfile["evaluator_notes"];
selectedId: string | null;
onSelect: (id: string) => void;
}) {
const { labels, t } = useI18n();
const reportNote = getReportNote(notes, report.id);
const hasFollowupNote = followups.some((followup) => getFollowupNote(notes, followup.id));
const selected = selectedId === report.id || followups.some((followup) => followup.id === selectedId);
const score = getReportScore(report) ?? calculateReportScore(report);
const band = getScoreBand(score);
const scoreItems: ScorePillProps[] = [
{
code: "T",
value: score === null ? null : `${score}%`,
score,
title: score === null ? `${t("details.totalScore")}: ${t("common.notCalculated")}` : `${t("details.totalScore")}: ${score}%${band ? ` · ${translateScoreBand(band, t)}` : ""}`
},
{
code: "A",
value: report.work_ability ?? null,
score: normalizeReportMetric(report, "work_ability"),
title: `${labels.categoryTooltipTexts.A}: ${report.work_ability ?? t("common.notProvided")}`
},
{
code: "E",
value: report.energy_level ?? null,
score: normalizeReportMetric(report, "energy_level"),
title: `${labels.categoryTooltipTexts.E}: ${report.energy_level ?? t("common.notProvided")}`
},
{
code: "K",
value: report.mental_clarity ?? null,
score: normalizeReportMetric(report, "mental_clarity"),
title: `${labels.categoryTooltipTexts.K}: ${report.mental_clarity ?? t("common.notProvided")}`
},
{
code: "S",
value: report.symptom_burden ?? null,
score: normalizeReportMetric(report, "symptom_burden"),
title: `${labels.categoryTooltipTexts.S}: ${report.symptom_burden ?? t("common.notProvided")} / 5`
},
{
code: "B",
value: report.effort_strain ?? null,
score: normalizeReportMetric(report, "effort_strain"),
title: `${labels.categoryTooltipTexts.B}: ${report.effort_strain ?? t("common.notProvided")} / 5`
}
];
return (
<div className={selected ? "report-group selected-review" : "report-group"}>
<button className="entry-row review-card-main" onClick={() => onSelect(report.id)} type="button">
<div className="report-primary-info">
<strong>{report.workplace || t("common.unknownWorkplace")}</strong>
<span>{formatDateTime(report)}</span>
</div>
<div className="report-score-area">
<div className="report-score-grid" aria-label={t("evaluator.scoreValues")}>
{scoreItems.map((item) => (
<ScorePill key={item.code} {...item} />
))}
</div>
<div className="report-meta-pill-row">
{report.integrity?.verification_status && report.integrity.verification_status !== "valid" && (
<span className="verification-warning meta-pill" title={verificationTooltip(report.integrity.verification_status)}>
! <span>{t("evaluator.validationError")}</span>
</span>
)}
{followups.length > 0 && <span title={t("evaluator.hasFollowup")}>{t("overview.followup")} {followups.length}</span>}
{report.note && <span title={t("evaluator.hasComment")}>{t("evaluator.comment")}</span>}
{reportNote && <span title={t("evaluator.hasEvaluatorNote")}>{t("evaluator.evaluatorNote")}</span>}
{hasFollowupNote && <span title={t("evaluator.followupNoteBadge")}>{t("evaluator.followupNoteBadge")}</span>}
</div>
</div>
</button>
<button className="review-open" onClick={() => onSelect(report.id)} type="button">
{t("evaluator.details")}
</button>
{followups.length > 0 && (
<div className="followup-list">
{followups.map((followup) => (
<button className="followup-row" key={followup.id} onClick={() => onSelect(followup.id)} type="button">
<span>{formatDateTime(followup)}</span>
<strong>
{labels.worseLabels[followup.worse_than_before]} {getFollowupNote(notes, followup.id) ? `· ${t("evaluator.evaluatorNote")}` : ""}
</strong>
</button>
))}
</div>
)}
</div>
);
}
type ScorePillProps = {
code: keyof typeof categoryTooltipTexts;
value: React.ReactNode | null;
score: number | null;
title: string;
};
function ScorePill({ code, value, score, title }: ScorePillProps) {
return (
<span className={`report-score-pill ${scorePillClass(score)}`} title={title}>
{code} {value ?? "-"}
</span>
);
}
function scorePillClass(score: number | null | undefined) {
if (score === null || score === undefined || Number.isNaN(score)) return "score-missing";
const value = Math.max(0, Math.min(100, score));
if (value >= 90) return "score-high";
if (value >= 70) return "score-good";
if (value >= 50) return "score-moderate";
if (value >= 30) return "score-low";
return "score-very-low";
}
function ReviewDetailPane({
entry,
followups,
notes,
profileId,
onSaved,
onClose
}: {
entry: StoredEntry;
followups: DelayedFollowup[];
notes: EvaluatorProfile["evaluator_notes"];
profileId: string;
onSaved: () => void;
onClose: () => void;
}) {
const { t } = useI18n();
const currentNote = entry.type === "work_report" ? getReportNote(notes, entry.id) : getFollowupNote(notes, entry.id);
const [noteText, setNoteText] = useState(currentNote?.text ?? "");
const [confirmDeleteNote, setConfirmDeleteNote] = useState(false);
useEffect(() => {
setNoteText(currentNote?.text ?? "");
}, [entry.id, currentNote?.id, currentNote?.updated_at]);
const save = () => {
if (entry.type === "work_report") {
upsertEvaluatorNote(profileId, noteText, { note_type: "report", related_report_id: entry.id });
} else {
upsertEvaluatorNote(profileId, noteText, {
note_type: "followup",
related_followup_id: entry.id,
parent_report_id: findParentId(entry)
});
}
onSaved();
};
const remove = () => {
if (entry.type === "work_report") {
deleteEvaluatorNote(profileId, { note_type: "report", related_report_id: entry.id });
} else {
deleteEvaluatorNote(profileId, { note_type: "followup", related_followup_id: entry.id });
}
setNoteText("");
onSaved();
};
return (
<div className="review-detail">
<div className="detail-head">
<div>
<p className="eyebrow">{entry.type === "work_report" ? t("evaluator.reportDetails") : t("evaluator.followupDetails")}</p>
<h2>{entry.type === "work_report" ? entry.workplace || t("common.unknownWorkplace") : formatDateTime(entry)}</h2>
</div>
<button className="secondary-action slim" onClick={onClose} type="button" aria-label={t("evaluator.closeDetails")}>
{t("evaluator.closeDetails")}
</button>
</div>
{entry.type === "work_report" ? <ReportDetailSections report={entry} followups={followups} /> : <FollowupDetailSections followup={entry} />}
<section className="field-card compact">
<div className="field-head">
<h2>{entry.type === "work_report" ? t("evaluator.reportNote") : t("evaluator.followupNote")}</h2>
<p>{t("evaluator.noteHelp")}</p>
</div>
<textarea value={noteText} onChange={(event) => setNoteText(event.target.value)} rows={4} />
<div className="action-grid">
<button className="primary-action" onClick={save} type="button">
{t("evaluator.saveNote")}
</button>
<button className="secondary-action danger" onClick={() => setConfirmDeleteNote(true)} type="button" disabled={!currentNote && !noteText}>
{t("evaluator.deleteNote")}
</button>
</div>
</section>
<ConfirmDeleteModal
open={confirmDeleteNote}
itemName={entry.type === "work_report" ? t("evaluator.reportNote") : t("evaluator.followupNote")}
onCancel={() => setConfirmDeleteNote(false)}
onConfirm={() => {
remove();
setConfirmDeleteNote(false);
}}
/>
</div>
);
}
function ReportDetailSections({ report, followups }: { report: Report; followups: DelayedFollowup[] }) {
const { labels, language, t } = useI18n();
const score = getReportScore(report) ?? calculateReportScore(report);
const band = getScoreBand(score);
const verificationStatus = report.integrity?.verification_status;
const hasVerificationWarning = verificationStatus && verificationStatus !== "valid";
return (
<div className="detail-sections">
<DetailSection title={t("details.summary")}>
{hasVerificationWarning && (
<Detail
label={t("details.integrity")}
value={<span className="verification-warning meta-pill" title={verificationTooltip(verificationStatus)}>! <span>{translateVerificationStatus(verificationStatus, t)}</span></span>}
/>
)}
<Detail label={t("details.time")} value={formatDateTime(report)} />
<Detail label={t("register.workplace")} value={report.workplace || t("common.unknownWorkplace")} />
<Detail label={t("details.workTime")} value={`${report.work_start_time || t("common.notProvided")} - ${report.work_end_time || t("common.notProvided")}`} />
<Detail label={t("details.status")} value={labels.statusLabels[report.status]} />
<Detail label={t("details.totalScore")} value={score === null ? t("common.notCalculated") : `${score}%`} />
<Detail label={t("details.functionLevel")} value={translateScoreBand(band, t)} />
<Detail label={t("details.description")} value={translateScoreBandDescription(band, language) ?? "Totalscore mangler eller kan ikke beregnes."} />
<Detail label={t("details.important")} value={t("scoring.disclaimer")} />
</DetailSection>
<DetailSection title={t("details.coreValues")}>
<Detail label={t("details.workAbility")} value={t("details.outOfFive", { value: report.work_ability })} />
<Detail label={t("details.energy")} value={t("details.outOfFive", { value: report.energy_level })} />
<Detail label={t("details.mentalClarity")} value={t("details.outOfFive", { value: report.mental_clarity })} />
<Detail label={t("details.symptoms")} value={t("details.outOfFive", { value: report.symptom_burden })} />
<Detail label={t("details.strain")} value={t("details.outOfFive", { value: report.effort_strain })} />
</DetailSection>
{hasReportDetails(report) ? (
<DetailSection title={t("details.details")}>
<Detail label={t("details.physicalEnergy")} value={t("details.outOfFive", { value: report.physical_energy })} />
<Detail label={t("details.mentalEnergy")} value={t("details.outOfFive", { value: report.mental_energy })} />
{report.perceived_productivity && <Detail label={t("details.productivity")} value={t("details.outOfFive", { value: report.perceived_productivity })} />}
{report.task_completion && <Detail label={t("details.taskCompletion")} value={labels.taskCompletionLabels[report.task_completion]} />}
{report.task_types.length > 0 && <Detail label={t("details.taskTypes")} value={labelsFor(report.task_types, labels.taskTypeLabels, t)} />}
{report.main_limitations.length > 0 && <Detail label={t("details.limitations")} value={labelsFor(report.main_limitations, labels.limitationLabels, t)} />}
{report.helpful_accommodations.length > 0 && <Detail label={t("details.accommodations")} value={labelsFor(report.helpful_accommodations, labels.accommodationLabels, t)} />}
</DetailSection>
) : (
<p className="empty-text">{t("details.noOptionalDetails")}</p>
)}
{report.note && (
<DetailSection title={t("details.userComment")}>
<Detail label={t("details.comment")} value={report.note} />
</DetailSection>
)}
{followups.length > 0 && (
<DetailSection title={t("details.followups")}>
{followups.map((followup) => (
<Detail
key={followup.id}
label={formatDateTime(followup)}
value={`${t("details.worseValue", { value: labels.worseLabels[followup.worse_than_before] })}${followup.recovery_status ? ` · ${t("details.recoveryValue", { value: labels.recoveryLabels[followup.recovery_status] })}` : ""}`}
/>
))}
</DetailSection>
)}
<TechnicalDetails>
<p>{t("details.technicalDescription")}</p>
<DetailSection title={t("details.technical")}>
<Detail label={t("details.reportId")} value={report.id} />
<Detail label="Type" value={report.type} />
<Detail label={t("details.created")} value={formatIsoDateTime(report.created_at)} />
<Detail label={t("details.reportDate")} value={report.report_date} />
<Detail label={t("details.reportTime")} value={report.report_time} />
<Detail label={t("details.integrityStatus")} value={translateVerificationStatus(report.integrity?.verification_status, t)} />
<Detail label={t("details.explanation")} value={report.integrity?.verification_reason || verificationTooltip(report.integrity?.verification_status)} />
<Detail label={t("details.algorithm")} value={report.integrity?.algorithm || t("common.notProvided")} />
<Detail label="Canonicalization" value={report.integrity?.canonicalization || t("common.notProvided")} />
<Detail label={t("details.signed")} value={report.integrity?.signed_at ? formatIsoDateTime(report.integrity.signed_at) : t("common.notProvided")} />
<Detail label={t("details.payloadHash")} value={report.integrity?.payload_hash_sha256 || t("common.notProvided")} />
</DetailSection>
</TechnicalDetails>
<RawDataDetails data={report} />
</div>
);
}
function FollowupDetailSections({ followup }: { followup: DelayedFollowup }) {
const { labels, t } = useI18n();
const verificationStatus = followup.integrity?.verification_status;
const hasVerificationWarning = verificationStatus && verificationStatus !== "valid";
return (
<div className="detail-sections">
<DetailSection title={t("details.summary")}>
{hasVerificationWarning && (
<Detail
label={t("details.integrity")}
value={<span className="verification-warning meta-pill" title={verificationTooltip(verificationStatus)}>! <span>{translateVerificationStatus(verificationStatus, t)}</span></span>}
/>
)}
<Detail label={t("details.time")} value={formatDateTime(followup)} />
<Detail label={t("details.worse")} value={labels.worseLabels[followup.worse_than_before]} />
{followup.recovery_status && <Detail label={t("details.recovery")} value={labels.recoveryLabels[followup.recovery_status]} />}
</DetailSection>
{(followup.delayed_symptoms.length > 0 || followup.note) && (
<DetailSection title={t("details.details")}>
{followup.delayed_symptoms.length > 0 && <Detail label={t("details.delayedSymptoms")} value={labelsFor(followup.delayed_symptoms, labels.delayedSymptomLabels, t)} />}
{followup.note && <Detail label={t("details.userComment")} value={followup.note} />}
</DetailSection>
)}
<TechnicalDetails>
<p>{t("details.technicalDescription")}</p>
<DetailSection title={t("details.technical")}>
<Detail label={t("details.followupId")} value={followup.id} />
<Detail label={t("details.connectedReport")} value={findParentId(followup) || t("common.notSelected")} />
<Detail label="Type" value={followup.type} />
<Detail label={t("details.created")} value={formatIsoDateTime(followup.created_at)} />
<Detail label={t("details.reportDate")} value={followup.report_date} />
<Detail label={t("details.reportTime")} value={followup.report_time} />
<Detail label={t("details.integrityStatus")} value={translateVerificationStatus(followup.integrity?.verification_status, t)} />
<Detail label={t("details.explanation")} value={followup.integrity?.verification_reason || verificationTooltip(followup.integrity?.verification_status)} />
<Detail label={t("details.algorithm")} value={followup.integrity?.algorithm || t("common.notProvided")} />
<Detail label={t("details.payloadHash")} value={followup.integrity?.payload_hash_sha256 || t("common.notProvided")} />
</DetailSection>
</TechnicalDetails>
<RawDataDetails data={followup} />
</div>
);
}
function TechnicalDetails({ children }: { children: React.ReactNode }) {
const { t } = useI18n();
return (
<details className="technical-details">
<summary>{t("details.technical")}</summary>
{children}
</details>
);
}
function RawDataDetails({ data }: { data: StoredEntry }) {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
const raw = JSON.stringify(data, null, 2);
const copy = async () => {
await navigator.clipboard?.writeText(raw);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
};
return (
<details className="raw-details">
<summary>{t("details.rawData")}</summary>
<p>{t("details.rawDescription")}</p>
<button className="secondary-action slim" onClick={copy} type="button">
{copied ? t("common.copied") : t("details.copyRaw")}
</button>
<pre>{raw}</pre>
</details>
);
}
function hasReportDetails(report: Report) {
return Boolean(
report.physical_energy ||
report.mental_energy ||
report.perceived_productivity ||
report.task_completion ||
report.task_types.length ||
report.main_limitations.length ||
report.helpful_accommodations.length
);
}
function DetailSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="detail-section">
<h3>{title}</h3>
<dl className="detail-grid">{children}</dl>
</section>
);
}
function labelsFor<T extends string>(values: T[] | undefined, labels: Record<T, string>, t?: ReturnType<typeof useI18n>["t"]) {
return values?.length ? values.map((value) => labels[value]).join(", ") : t ? t("common.notSelected") : "Ikke valgt";
}
function getPersonNote(profile: EvaluatorProfile) {
return profile.evaluator_notes.find((note) => (note.note_type ?? "person") === "person" && !note.related_report_id && !note.related_followup_id);
}
function getReportNote(notes: EvaluatorProfile["evaluator_notes"], reportId: string) {
return notes.find((note) => (note.note_type === "report" || (!note.note_type && note.related_report_id)) && note.related_report_id === reportId);
}
function getFollowupNote(notes: EvaluatorProfile["evaluator_notes"], followupId: string) {
return notes.find((note) => note.note_type === "followup" && note.related_followup_id === followupId);
}
function formatDateTime(entry: StoredEntry) {
const parsed = new Date(`${entry.report_date}T${entry.report_time || "00:00:00"}`);
if (Number.isNaN(parsed.getTime())) return `${entry.report_date} ${entry.report_time}`;
const weekday = parsed.toLocaleDateString("nb-NO", { weekday: "long" });
const date = parsed.toLocaleDateString("nb-NO", { day: "2-digit", month: "2-digit", year: "numeric" });
return `${capitalize(weekday)} ${date} ${entry.report_time}`;
}
function formatShortDate(entry: StoredEntry) {
const parsed = new Date(`${entry.report_date}T${entry.report_time || "00:00:00"}`);
if (Number.isNaN(parsed.getTime())) return entry.report_date;
const weekday = parsed.toLocaleDateString("nb-NO", { weekday: "long" });
const date = parsed.toLocaleDateString("nb-NO", { day: "2-digit", month: "2-digit", year: "2-digit" });
return `${capitalize(weekday)} ${date}`;
}
function formatReportPickerLabel(report: Report) {
const time = report.report_time.slice(0, 5);
const workplace = report.workplace?.trim();
const context = `arbeidsevne ${report.work_ability}, ${statusLabels[report.status].toLowerCase()}`;
return workplace ? `${formatShortDate(report)} ${time} - ${workplace} (${context})` : `${formatShortDate(report)} ${time} - Ukjent arbeidssted (${context})`;
}
function formatIsoDateTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const weekday = date.toLocaleDateString("nb-NO", { weekday: "long" });
const formatted = date.toLocaleDateString("nb-NO", { day: "2-digit", month: "2-digit", year: "numeric" });
const time = date.toLocaleTimeString("nb-NO", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
return `${capitalize(weekday)} ${formatted} ${time}`;
}
function capitalize(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1);
}
function findParentId(followup: DelayedFollowup) {
return followup.parent_report_id ?? followup.related_report_id;
}
function groupFollowups(entries: StoredEntry[]) {
const groups = new Map<string, DelayedFollowup[]>();
entries
.filter((entry): entry is DelayedFollowup => entry.type === "delayed_followup" && Boolean(findParentId(entry)))
.forEach((followup) => {
const parentId = findParentId(followup);
if (!parentId) return;
groups.set(parentId, [...(groups.get(parentId) ?? []), followup].sort((a, b) => getEntryTime(b) - getEntryTime(a)));
});
return groups;
}
function filterReportsByPeriod(entries: StoredEntry[], period: TrendPeriod) {
const [start, end] = periodBounds(period);
return entries
.filter((entry): entry is Report => entry.type === "work_report")
.filter((report) => {
const time = getEntryTime(report);
if (start && time < start) return false;
if (end && time > end) return false;
return true;
})
.sort((a, b) => getEntryTime(a) - getEntryTime(b));
}
function readPeriod(): TrendPeriod {
const fallback = { preset: "all" as PresetRange, startDate: "", endDate: "" };
try {
const parsed = JSON.parse(localStorage.getItem(PERIOD_KEY) ?? "");
return parsed?.preset ? parsed : fallback;
} catch {
return fallback;
}
}
function periodBounds(period: TrendPeriod): [number | null, number | null] {
const start = period.startDate ? new Date(`${period.startDate}T00:00:00`).getTime() : null;
const end = period.endDate ? new Date(`${period.endDate}T23:59:59`).getTime() : null;
return [start, end];
}
function toDateInput(date: Date) {
return [date.getFullYear(), String(date.getMonth() + 1).padStart(2, "0"), String(date.getDate()).padStart(2, "0")].join("-");
}
function createTimeMarkers(minTime: number, maxTime: number) {
const span = maxTime - minTime;
const candidates = [
{ unit: "time", step: 60 * 60 * 1000 },
{ unit: "day", step: 24 * 60 * 60 * 1000 },
{ unit: "week", step: 7 * 24 * 60 * 60 * 1000 },
{ unit: "month", step: 31 * 24 * 60 * 60 * 1000 },
{ unit: "year", step: 365 * 24 * 60 * 60 * 1000 }
];
const selected = candidates.find((candidate) => span / candidate.step <= 8) ?? candidates[candidates.length - 1];
const start = floorToInterval(minTime, selected.step);
const markers: Array<{ time: number; label: string }> = [];
for (let time = start; time <= maxTime; time += selected.step) {
if (time >= minTime) markers.push({ time, label: formatMarker(time, selected.unit) });
}
return markers.slice(0, 10);
}
function floorToInterval(time: number, interval: number) {
return Math.floor(time / interval) * interval;
}
function formatMarker(time: number, unit: string) {
const date = new Date(time);
if (unit === "time") return date.toLocaleTimeString("nb-NO", { hour: "2-digit", minute: "2-digit" });
if (unit === "day" || unit === "week") return date.toLocaleDateString("nb-NO", { day: "2-digit", month: "2-digit" });
if (unit === "month") return date.toLocaleDateString("nb-NO", { month: "short", year: "2-digit" });
return date.toLocaleDateString("nb-NO", { year: "numeric" });
}
function average(values: number[]) {
if (values.length === 0) return null;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
type ChartPoint = { x: number; y: number; time: number };
function isPoint(value: ChartPoint | null): value is ChartPoint {
return Boolean(value);
}
export function createLineSegments(points: ChartPoint[], chartLeft: number) {
const gapThresholdMs = 3 * 24 * 60 * 60 * 1000;
if (points.length === 0) return [];
const segments = [
{
x1: chartLeft,
x2: points[0].x,
y1: points[0].y,
y2: points[0].y,
dashed: true
}
];
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const current = points[index];
segments.push({
x1: previous.x,
y1: previous.y,
x2: current.x,
y2: current.y,
dashed: current.time - previous.time >= gapThresholdMs
});
}
return segments;
}
function isNumber(value: number | null): value is number {
return typeof value === "number" && Number.isFinite(value);
}
export default App;