2510 lines
97 KiB
TypeScript
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;
|