3390 lines
131 KiB
TypeScript
3390 lines
131 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,
|
|
ensureDemoEvaluatorProfiles,
|
|
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 {
|
|
confirmSecureShareImport,
|
|
createSecureShare,
|
|
decryptSecureSharePayload,
|
|
fetchSecureShare,
|
|
parseShareRequestFromLocation
|
|
} from "./secureShare";
|
|
import {
|
|
createFollowup,
|
|
createReport,
|
|
deleteAllReports,
|
|
deleteReport,
|
|
assertValidExportRange,
|
|
buildExportFilename,
|
|
ensureDemoData,
|
|
exportReports,
|
|
getLatestWorkReport,
|
|
getLastWorkReport,
|
|
importReportsFromJson,
|
|
isEditableWithin24Hours,
|
|
isDemoModeEnabled,
|
|
listReports,
|
|
previewImportReports,
|
|
setDemoModeEnabled,
|
|
updateFollowup,
|
|
updateReport
|
|
} from "./storage";
|
|
import type { ExportRange } 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.2.1";
|
|
|
|
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("/s")) {
|
|
return <SecureSharePage />;
|
|
}
|
|
if (window.location.pathname.startsWith("/evaluator") || window.location.pathname.startsWith("/veileder")) {
|
|
return <EvaluatorApp />;
|
|
}
|
|
|
|
const [demoMode, setDemoMode] = useState(() => isDemoModeEnabled());
|
|
const [view, setView] = useState<View>("registrer");
|
|
const [entries, setEntries] = useState<StoredEntry[]>(() => listReports(isDemoModeEnabled()));
|
|
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
|
const [deleteRequest, setDeleteRequest] = useState<{ id: string; name: string } | null>(null);
|
|
const [editEntry, setEditEntry] = useState<StoredEntry | null>(null);
|
|
|
|
const refresh = () => {
|
|
const next = listReports(demoMode);
|
|
setEntries(next);
|
|
};
|
|
const selectedEntry = selectedEntryId ? entries.find((entry) => entry.id === selectedEntryId) : undefined;
|
|
const changeDemoMode = (enabled: boolean) => {
|
|
setDemoModeEnabled(enabled);
|
|
if (enabled) ensureDemoData();
|
|
setDemoMode(enabled);
|
|
setSelectedEntryId(null);
|
|
setDeleteRequest(null);
|
|
setEditEntry(null);
|
|
setEntries(listReports(enabled));
|
|
};
|
|
|
|
useEffect(() => {
|
|
setEntries(listReports(demoMode));
|
|
}, [demoMode]);
|
|
|
|
return (
|
|
<div className={demoMode ? "app-shell demo-active" : "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>
|
|
{demoMode && (
|
|
<div className="notice warning">
|
|
<ShieldAlert aria-hidden="true" />
|
|
<span>{t("demo.notice")}</span>
|
|
</div>
|
|
)}
|
|
{view === "registrer" && (
|
|
<ReportForm
|
|
demoMode={demoMode}
|
|
editReport={editEntry?.type === "work_report" ? editEntry : null}
|
|
onCancelEdit={() => setEditEntry(null)}
|
|
onSaved={() => {
|
|
setEditEntry(null);
|
|
refresh();
|
|
}}
|
|
/>
|
|
)}
|
|
{view === "oppfolging" && (
|
|
<FollowupForm
|
|
demoMode={demoMode}
|
|
editFollowup={editEntry?.type === "delayed_followup" ? editEntry : null}
|
|
reports={entries}
|
|
onCancelEdit={() => setEditEntry(null)}
|
|
onSaved={() => {
|
|
setEditEntry(null);
|
|
refresh();
|
|
}}
|
|
/>
|
|
)}
|
|
{view === "oversikt" && (
|
|
<Overview
|
|
entries={entries}
|
|
selectedEntry={selectedEntry}
|
|
onSelect={setSelectedEntryId}
|
|
onClose={() => setSelectedEntryId(null)}
|
|
onRequestEdit={(entry) => {
|
|
setEditEntry(entry);
|
|
setView(entry.type === "work_report" ? "registrer" : "oppfolging");
|
|
}}
|
|
onRequestDelete={(entry) => setDeleteRequest({ id: entry.id, name: entry.type === "work_report" ? entry.workplace || t("danger.reportName") : t("danger.followupName") })}
|
|
/>
|
|
)}
|
|
{view === "personvern" && <PrivacyPanel demoMode={demoMode} entries={entries} onDeleted={refresh} />}
|
|
</main>
|
|
<AppFooter demoMode={demoMode} onDemoModeChange={changeDemoMode} />
|
|
<ConfirmDeleteModal
|
|
itemName={deleteRequest?.name ?? ""}
|
|
open={Boolean(deleteRequest)}
|
|
onCancel={() => setDeleteRequest(null)}
|
|
onConfirm={() => {
|
|
if (!deleteRequest) return;
|
|
deleteReport(deleteRequest.id, demoMode);
|
|
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({
|
|
demoMode,
|
|
onDemoModeChange,
|
|
showDemoToggle = true
|
|
}: {
|
|
demoMode: boolean;
|
|
onDemoModeChange: (enabled: boolean) => void;
|
|
showDemoToggle?: boolean;
|
|
}) {
|
|
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>
|
|
{showDemoToggle && (
|
|
<button className={demoMode ? "footer-link active" : "footer-link"} onClick={() => onDemoModeChange(!demoMode)} type="button">
|
|
{demoMode ? t("demo.exit") : t("demo.enter")}
|
|
</button>
|
|
)}
|
|
</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(demoMode = false): ReportDraft {
|
|
const last = getLastWorkReport(demoMode);
|
|
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 createReportDraftFromReport(report: Report): ReportDraft {
|
|
return {
|
|
workplace: report.workplace ?? "",
|
|
work_start_time: report.work_start_time ?? "",
|
|
work_end_time: report.work_end_time ?? "",
|
|
work_ability: report.work_ability,
|
|
energy_level: report.energy_level,
|
|
mental_clarity: report.mental_clarity,
|
|
symptom_burden: report.symptom_burden,
|
|
effort_strain: report.effort_strain,
|
|
status: report.status,
|
|
physical_energy_detail: report.physical_energy,
|
|
mental_energy_detail: report.mental_energy,
|
|
perceived_productivity: report.perceived_productivity,
|
|
task_completion: report.task_completion,
|
|
task_types: report.task_types,
|
|
main_limitations: report.main_limitations,
|
|
helpful_accommodations: report.helpful_accommodations,
|
|
note: report.note ?? ""
|
|
};
|
|
}
|
|
|
|
function ReportForm({
|
|
demoMode,
|
|
editReport,
|
|
onCancelEdit,
|
|
onSaved
|
|
}: {
|
|
demoMode: boolean;
|
|
editReport?: Report | null;
|
|
onCancelEdit?: () => void;
|
|
onSaved: () => void;
|
|
}) {
|
|
const { labels, t } = useI18n();
|
|
const editing = Boolean(editReport);
|
|
const editAllowed = !editReport || isEditableWithin24Hours(editReport);
|
|
const [draft, setDraft] = useState<ReportDraft>(() => editReport ? createReportDraftFromReport(editReport) : createInitialReportDraft(demoMode));
|
|
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;
|
|
|
|
useEffect(() => {
|
|
setDraft(editReport ? createReportDraftFromReport(editReport) : createInitialReportDraft(demoMode));
|
|
setAttempted(false);
|
|
setSavedText("");
|
|
}, [demoMode, editReport?.id]);
|
|
|
|
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 || !editAllowed) return;
|
|
|
|
if (editReport) updateReport(editReport.id, draft, demoMode);
|
|
else createReport(draft, demoMode);
|
|
setDraft(createInitialReportDraft(demoMode));
|
|
setAttempted(false);
|
|
setSavedText(editReport ? t("edit.saved") : t("register.saved"));
|
|
onSaved();
|
|
window.setTimeout(() => setSavedText(""), 2400);
|
|
};
|
|
|
|
return (
|
|
<section className="flow">
|
|
<div className="notice">
|
|
<Activity aria-hidden="true" />
|
|
<span>{editing ? t("edit.reportIntro") : t("register.intro")}</span>
|
|
</div>
|
|
{editing && !editAllowed && <p className="error-text">{t("edit.expired")}</p>}
|
|
|
|
<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" disabled={!editAllowed} onClick={submit} type="button">
|
|
<Save aria-hidden="true" />
|
|
<span>{editing ? t("edit.saveChanges") : t("register.saveReport")}</span>
|
|
</button>
|
|
{editing && onCancelEdit && (
|
|
<button className="secondary-action" onClick={onCancelEdit} type="button">
|
|
{t("edit.cancelEdit")}
|
|
</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 createFollowupDraftFromFollowup(followup: DelayedFollowup): FollowupDraft {
|
|
return {
|
|
parent_report_id: findParentId(followup),
|
|
related_report_id: findParentId(followup),
|
|
worse_than_before: followup.worse_than_before,
|
|
delayed_symptoms: followup.delayed_symptoms,
|
|
recovery_status: followup.recovery_status,
|
|
note: followup.note ?? ""
|
|
};
|
|
}
|
|
|
|
function FollowupForm({
|
|
demoMode,
|
|
editFollowup,
|
|
reports,
|
|
onCancelEdit,
|
|
onSaved
|
|
}: {
|
|
demoMode: boolean;
|
|
editFollowup?: DelayedFollowup | null;
|
|
reports: StoredEntry[];
|
|
onCancelEdit?: () => void;
|
|
onSaved: () => void;
|
|
}) {
|
|
const { labels, t } = useI18n();
|
|
const editing = Boolean(editFollowup);
|
|
const editAllowed = !editFollowup || isEditableWithin24Hours(editFollowup);
|
|
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 latestReport = workReports[0] ?? getLatestWorkReport(demoMode);
|
|
const editParent = editFollowup ? workReports.find((report) => report.id === findParentId(editFollowup)) : undefined;
|
|
const parentOptions = Object.fromEntries(
|
|
[latestReport, editParent]
|
|
.filter((report): report is Report => Boolean(report))
|
|
.filter((report, index, array) => array.findIndex((candidate) => candidate.id === report.id) === index)
|
|
.map((report) => [report.id, formatReportPickerLabel(report, labels, t)])
|
|
);
|
|
const [draft, setDraft] = useState<FollowupDraft>(() =>
|
|
editFollowup
|
|
? createFollowupDraftFromFollowup(editFollowup)
|
|
: { parent_report_id: latestReport?.id, related_report_id: latestReport?.id, delayed_symptoms: [], note: "" }
|
|
);
|
|
const selectedParentIsLatest =
|
|
!draft.parent_report_id ||
|
|
!latestReport ||
|
|
draft.parent_report_id === latestReport.id ||
|
|
Boolean(editFollowup && draft.parent_report_id === findParentId(editFollowup));
|
|
|
|
useEffect(() => {
|
|
setDraft(
|
|
editFollowup
|
|
? createFollowupDraftFromFollowup(editFollowup)
|
|
: { parent_report_id: latestReport?.id, related_report_id: latestReport?.id, delayed_symptoms: [], note: "" }
|
|
);
|
|
setAttempted(false);
|
|
setSavedText("");
|
|
}, [demoMode, editFollowup?.id, latestReport?.id]);
|
|
|
|
const submit = () => {
|
|
setAttempted(true);
|
|
if (!draft.worse_than_before || !selectedParentIsLatest || !editAllowed) return;
|
|
|
|
if (editFollowup) updateFollowup(editFollowup.id, draft, demoMode);
|
|
else createFollowup(draft, demoMode);
|
|
setDraft({ parent_report_id: latestReport?.id, related_report_id: latestReport?.id, delayed_symptoms: [], note: "" });
|
|
setAttempted(false);
|
|
setSavedText(editFollowup ? t("edit.saved") : 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>{editing ? t("edit.followupIntro") : t("followup.intro")}</span>
|
|
</div>
|
|
{editing && !editAllowed && <p className="error-text">{t("edit.expired")}</p>}
|
|
|
|
<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={parentOptions}
|
|
onChange={(value) => setDraft((current) => ({ ...current, parent_report_id: value, related_report_id: value }))}
|
|
/>
|
|
{attempted && !selectedParentIsLatest && <p className="error-text">{t("followup.latestOnly")}</p>}
|
|
</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" disabled={!editAllowed} onClick={submit} type="button">
|
|
<Send aria-hidden="true" />
|
|
<span>{editing ? t("edit.saveChanges") : t("followup.saveFollowup")}</span>
|
|
</button>
|
|
{editing && onCancelEdit && (
|
|
<button className="secondary-action" onClick={onCancelEdit} type="button">
|
|
{t("edit.cancelEdit")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Overview({
|
|
entries,
|
|
selectedEntry,
|
|
onSelect,
|
|
onClose,
|
|
onRequestEdit,
|
|
onRequestDelete
|
|
}: {
|
|
entries: StoredEntry[];
|
|
selectedEntry?: StoredEntry;
|
|
onSelect: (id: string) => void;
|
|
onClose: () => void;
|
|
onRequestEdit: (entry: StoredEntry) => 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} onRequestEdit={onRequestEdit} onRequestDelete={onRequestDelete} />}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ReportModal({
|
|
entry,
|
|
onClose,
|
|
onRequestEdit,
|
|
onRequestDelete
|
|
}: {
|
|
entry: StoredEntry;
|
|
onClose: () => void;
|
|
onRequestEdit: (entry: StoredEntry) => 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} onRequestEdit={onRequestEdit} 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 datePlaceholder = t("overview.datePlaceholder");
|
|
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.fromDate")}</span>
|
|
<input
|
|
type="date"
|
|
value={period.startDate}
|
|
placeholder={datePlaceholder}
|
|
aria-label={`${t("overview.fromDate")} ${datePlaceholder}`}
|
|
onChange={(event) => onChange({ ...period, preset: "custom", startDate: event.target.value })}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span>{t("overview.toDate")}</span>
|
|
<input
|
|
type="date"
|
|
value={period.endDate}
|
|
placeholder={datePlaceholder}
|
|
aria-label={`${t("overview.toDate")} ${datePlaceholder}`}
|
|
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",
|
|
followup_response: "normal"
|
|
});
|
|
const [fullscreenOpen, setFullscreenOpen] = useState(false);
|
|
const reports = useMemo(() => {
|
|
return filterReportsByPeriod(entries, period);
|
|
}, [entries, period]);
|
|
const followupResponses = useMemo(
|
|
() => showScoreBands ? filterFollowupResponsesByPeriod(buildFollowupResponsePoints(entries), period) : [],
|
|
[entries, period, showScoreBands]
|
|
);
|
|
const workTimeBands = useMemo(() => filterWorkTimeBandsByPeriod(buildWorkTimeBands(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 responseState = visibility[followupResponseMetric.key] ?? "normal";
|
|
const visibleFollowupResponses = showScoreBands && responseState !== "hidden" ? followupResponses : [];
|
|
const times = [
|
|
...reports.map(getEntryTime),
|
|
...visibleFollowupResponses.map((point) => getEntryTime(point.followup)),
|
|
...workTimeBands.flatMap((band) => [band.startAt, band.endAt])
|
|
];
|
|
const [periodStart, periodEnd] = periodBounds(period);
|
|
const minTime = graphDomainStart(times, periodStart);
|
|
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")}>
|
|
{workTimeBands.map((band) => (
|
|
<rect
|
|
aria-label={t("graphModal.workTimeTooltip")}
|
|
className="work-time-band"
|
|
key={`work-time-${band.reportId}-${band.startAt}`}
|
|
x={x(band.startAt)}
|
|
y={padding.top}
|
|
width={Math.max(1, x(band.endAt) - x(band.startAt))}
|
|
height={height - padding.top - padding.bottom}
|
|
/>
|
|
))}
|
|
{[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}
|
|
/>
|
|
));
|
|
})}
|
|
{visibleFollowupResponses.map((point) => {
|
|
const parentTime = getEntryTime(point.parent);
|
|
const followupTime = getEntryTime(point.followup);
|
|
const opacity = responseState === "dim" ? 0.42 : 0.9;
|
|
return (
|
|
<g key={`followup-response-${point.followup.id}`} opacity={opacity}>
|
|
<line className="followup-link" x1={x(parentTime)} x2={x(followupTime)} y1={y(point.parentScore)} y2={y(point.effectScore)} />
|
|
<line className="followup-effect-bar" x1={x(followupTime)} x2={x(followupTime)} y1={y(point.parentScore)} y2={y(point.effectScore)} />
|
|
<circle className="followup-response-point" cx={x(followupTime)} cy={y(point.effectScore)} r="5" />
|
|
</g>
|
|
);
|
|
})}
|
|
</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>
|
|
);
|
|
})}
|
|
{showScoreBands && (
|
|
<button
|
|
className={`legend-chip ${responseState}`}
|
|
onClick={() => cycleVisibility(followupResponseMetric.key)}
|
|
type="button"
|
|
title={t("graphModal.followupResponseHelp")}
|
|
>
|
|
<i style={{ background: followupResponseMetric.color }} /> {followupResponseMetric.short} {t(followupResponseMetric.labelKey)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p className="empty-text">{t("overview.dashedLine")}</p>
|
|
<p className="empty-text">{t("graphModal.workTimeLegend")}</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", followup_response: "normal" });
|
|
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 followupsByParent = useMemo(() => groupFollowups(entries), [entries]);
|
|
const followupResponses = useMemo(() => filterFollowupResponsesByPeriod(buildFollowupResponsePoints(entries), period), [entries, period]);
|
|
const workTimeBands = useMemo(() => filterWorkTimeBandsByPeriod(buildWorkTimeBands(entries), period), [entries, period]);
|
|
const width = 1120;
|
|
const height = 520;
|
|
const padding = { left: 44, right: 24, top: 26, bottom: 58 };
|
|
const responseState = visibility[followupResponseMetric.key] ?? "normal";
|
|
const visibleFollowupResponses = responseState !== "hidden" ? followupResponses : [];
|
|
const times = [
|
|
...reports.map(getEntryTime),
|
|
...visibleFollowupResponses.map((point) => getEntryTime(point.followup)),
|
|
...workTimeBands.flatMap((band) => [band.startAt, band.endAt])
|
|
];
|
|
const [periodStart, periodEnd] = periodBounds(period);
|
|
const minTime = times.length ? graphDomainStart(times, periodStart) : graphDomainStart([Date.now()], periodStart);
|
|
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>
|
|
);
|
|
})}
|
|
<button className={`legend-chip ${responseState}`} onClick={() => cycleVisibility(followupResponseMetric.key)} type="button" title={t("graphModal.followupResponseHelp")}>
|
|
<i style={{ background: followupResponseMetric.color }} /> {followupResponseMetric.short} {t(followupResponseMetric.labelKey)}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div className="graph-modal-body fullscreen-graph-body">
|
|
<p className="empty-text fullscreen-graph-help">{t("graphModal.snapHelp")}</p>
|
|
{reports.length < 1 ? (
|
|
<p className="empty-text">{t("graphModal.noReports")}</p>
|
|
) : (
|
|
<div className="graph-inspection-layout">
|
|
<div className="graph-workspace">
|
|
<div className="fullscreen-graph-chart-shell">
|
|
<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)}
|
|
>
|
|
{workTimeBands.map((band) => (
|
|
<rect
|
|
aria-label={t("graphModal.workTimeTooltip")}
|
|
className="work-time-band"
|
|
key={`fullscreen-work-time-${band.reportId}-${band.startAt}`}
|
|
x={x(band.startAt)}
|
|
y={padding.top}
|
|
width={Math.max(1, x(band.endAt) - x(band.startAt))}
|
|
height={height - padding.top - padding.bottom}
|
|
/>
|
|
))}
|
|
{[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}
|
|
/>
|
|
));
|
|
})}
|
|
{visibleFollowupResponses.map((point) => {
|
|
const parentTime = getEntryTime(point.parent);
|
|
const followupTime = getEntryTime(point.followup);
|
|
const opacity = responseState === "dim" ? 0.42 : 0.9;
|
|
return (
|
|
<g key={`fullscreen-followup-response-${point.followup.id}`} opacity={opacity}>
|
|
<line className="followup-link" x1={x(parentTime)} x2={x(followupTime)} y1={y(point.parentScore)} y2={y(point.effectScore)} />
|
|
<line className="followup-effect-bar" x1={x(followupTime)} x2={x(followupTime)} y1={y(point.parentScore)} y2={y(point.effectScore)} />
|
|
<circle className="followup-response-point" cx={x(followupTime)} cy={y(point.effectScore)} r="6" />
|
|
</g>
|
|
);
|
|
})}
|
|
{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>
|
|
</div>
|
|
<GraphSelectedPills report={inspect?.report ?? null} />
|
|
</div>
|
|
<GraphInspectTooltip
|
|
followups={inspect?.report ? followupsByParent.get(inspect.report.id) ?? [] : []}
|
|
report={inspect?.report ?? null}
|
|
responsePoints={inspect?.report ? followupResponses.filter((point) => point.parent.id === inspect.report?.id) : []}
|
|
workTimeBand={inspect?.report ? workTimeBands.find((band) => band.reportId === inspect.report?.id) ?? null : null}
|
|
visibility={visibility}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GraphSelectedPills({ report }: { report: Report | null }) {
|
|
const { labels, t } = useI18n();
|
|
if (!report) {
|
|
return <div className="graph-selected-pills fullscreen-graph-below" aria-hidden="true" />;
|
|
}
|
|
return (
|
|
<div className="graph-selected-pills fullscreen-graph-below">
|
|
<PillGroup title={t("details.taskTypes")} values={report.task_types} labels={labels.taskTypeLabels} />
|
|
<PillGroup title={t("details.accommodations")} values={report.helpful_accommodations} labels={labels.accommodationLabels} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PillGroup<T extends string>({ title, values, labels }: { title: string; values: T[]; labels: Record<T, string> }) {
|
|
if (values.length === 0) return null;
|
|
return (
|
|
<div>
|
|
<strong>{title}</strong>
|
|
<span className="graph-status-pill-row">
|
|
{values.map((value) => (
|
|
<i className="graph-status-pill" key={value}>{labels[value]}</i>
|
|
))}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GraphInspectTooltip({
|
|
followups,
|
|
report,
|
|
responsePoints,
|
|
workTimeBand,
|
|
visibility
|
|
}: {
|
|
followups: DelayedFollowup[];
|
|
report: Report | null;
|
|
responsePoints: FollowupResponsePoint[];
|
|
workTimeBand: WorkTimeBand | null;
|
|
visibility: Record<string, LineVisibility>;
|
|
}) {
|
|
const { labels, 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>
|
|
{workTimeBand && (
|
|
<p>
|
|
<strong>{t("graphModal.workTime")}:</strong> {formatTimeRange(workTimeBand.startAt, workTimeBand.endAt)}
|
|
</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>
|
|
<section className="graph-followups">
|
|
<h4>{t("details.followups")}</h4>
|
|
{followups.length === 0 && <p>{t("graphModal.noFollowups")}</p>}
|
|
{followups
|
|
.slice()
|
|
.sort((a, b) => getEntryTime(a) - getEntryTime(b))
|
|
.map((followup) => {
|
|
const response = responsePoints.find((point) => point.followup.id === followup.id);
|
|
return (
|
|
<div key={followup.id}>
|
|
<strong>{formatDateTime(followup)}</strong>
|
|
<span>{t("details.worseValue", { value: labels.worseLabels[followup.worse_than_before] })}</span>
|
|
{followup.recovery_status && <span>{t("details.recoveryValue", { value: labels.recoveryLabels[followup.recovery_status] })}</span>}
|
|
{response && <span>{t("graphModal.followupDelay", { hours: response.offsetHours })}</span>}
|
|
{response && <span>{t("graphModal.followupEffect", { score: response.effectScore, parent: response.parentScore })}</span>}
|
|
{followup.note && <p>{followup.note}</p>}
|
|
</div>
|
|
);
|
|
})}
|
|
</section>
|
|
{(visibility[followupResponseMetric.key] ?? "normal") !== "hidden" && responsePoints.length > 0 && (
|
|
<p className="empty-text">{t("graphModal.followupResponseDisclaimer")}</p>
|
|
)}
|
|
</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,
|
|
onRequestEdit,
|
|
onRequestDelete
|
|
}: {
|
|
entry: StoredEntry;
|
|
onRequestEdit?: (entry: StoredEntry) => void;
|
|
onRequestDelete?: (entry: StoredEntry) => void;
|
|
}) {
|
|
const { labels, t } = useI18n();
|
|
const related =
|
|
entry.type === "delayed_followup" && findParentId(entry) ? listReports().find((item) => item.id === findParentId(entry)) : undefined;
|
|
const editable = isEditableWithin24Hours(entry);
|
|
|
|
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>
|
|
)}
|
|
{onRequestEdit && (
|
|
<section className="edit-zone">
|
|
{editable ? (
|
|
<button className="secondary-action" onClick={() => onRequestEdit(entry)} type="button">
|
|
<Save aria-hidden="true" />
|
|
<span>{t("edit.edit")}</span>
|
|
</button>
|
|
) : (
|
|
<p className="empty-text">{t("edit.expired")}</p>
|
|
)}
|
|
</section>
|
|
)}
|
|
{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({ demoMode, entries, onDeleted }: { demoMode: boolean; 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 [exportDialog, setExportDialog] = useState<"json" | "email" | null>(null);
|
|
const [exportMessage, setExportMessage] = useState("");
|
|
|
|
const clear = () => {
|
|
deleteAllReports(demoMode);
|
|
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, demoMode);
|
|
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, demoMode);
|
|
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.${summary.demo_entries_detected ? " Demodata ble importert i demo-modus." : ""}${flagged > 0 ? " Noen rapporter kunne ikke verifiseres." : ""}`
|
|
);
|
|
onDeleted();
|
|
};
|
|
|
|
return (
|
|
<section className="privacy-panel">
|
|
<div className="notice warning">
|
|
<ShieldAlert aria-hidden="true" />
|
|
<span>{demoMode ? t("privacy.demoWarning") : t("privacy.warning")}</span>
|
|
</div>
|
|
|
|
<div className="action-grid">
|
|
<button className="secondary-action" onClick={() => setExportDialog("json")} type="button" disabled={entries.length === 0}>
|
|
<Download aria-hidden="true" />
|
|
<span>{t("privacy.exportJson")}</span>
|
|
</button>
|
|
<button className="secondary-action" onClick={() => setExportDialog("email")} type="button" disabled={entries.length === 0}>
|
|
<Send aria-hidden="true" />
|
|
<span>{t("privacy.exportEmail")}</span>
|
|
</button>
|
|
</div>
|
|
<div className="privacy-management-row">
|
|
<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>
|
|
<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>
|
|
{Boolean(pendingImport.summary.demo_entries_detected) && (
|
|
<p className="error-text">{t("privacy.demoImportWarning", { count: pendingImport.summary.demo_entries_detected ?? 0 })}</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>}
|
|
{exportMessage && <p className="success-text">{exportMessage}</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>
|
|
{exportDialog && (
|
|
<ExportModal
|
|
demoMode={demoMode}
|
|
mode={exportDialog}
|
|
onClose={() => setExportDialog(null)}
|
|
onMessage={setExportMessage}
|
|
/>
|
|
)}
|
|
<ConfirmDeleteModal open={confirmDeleteAll} itemName={t("privacy.deleteAllName")} onCancel={() => setConfirmDeleteAll(false)} onConfirm={clear} />
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ExportModal({
|
|
demoMode,
|
|
mode,
|
|
onClose,
|
|
onMessage
|
|
}: {
|
|
demoMode: boolean;
|
|
mode: "json" | "email";
|
|
onClose: () => void;
|
|
onMessage: (message: string) => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const [range, setRange] = useState<ExportRange>({});
|
|
const [recipient, setRecipient] = useState("");
|
|
const [rangeOpen, setRangeOpen] = useState(false);
|
|
const [serverConsent, setServerConsent] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [emailBackup, setEmailBackup] = useState<{ recipient: string; subject: string; body: string } | null>(null);
|
|
const isEmail = mode === "email";
|
|
|
|
const validate = () => {
|
|
try {
|
|
assertValidExportRange(range);
|
|
} catch {
|
|
setError(t("privacy.exportInvalidRange"));
|
|
return false;
|
|
}
|
|
if (isEmail && !isProbablyEmail(recipient)) {
|
|
setError(t("privacy.invalidEmail"));
|
|
return false;
|
|
}
|
|
if (isEmail && !serverConsent) {
|
|
setError(t("privacy.serverStorageConsentRequired"));
|
|
return false;
|
|
}
|
|
setError("");
|
|
return true;
|
|
};
|
|
|
|
const createFile = () => {
|
|
const filename = buildExportFilename(demoMode, range);
|
|
const json = exportReports(demoMode, range);
|
|
return new File([json], filename, { type: "application/json" });
|
|
};
|
|
|
|
const submit = async () => {
|
|
if (!validate()) return;
|
|
setBusy(true);
|
|
if (!isEmail) {
|
|
const file = createFile();
|
|
downloadFile(file);
|
|
setBusy(false);
|
|
onClose();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const share = await createSecureShare(exportReports(demoMode, range));
|
|
const subject = t("privacy.emailSubject");
|
|
const appUrl = window.location.origin;
|
|
const body = t("privacy.secureEmailBody", { appUrl, shareUrl: share.shareUrl });
|
|
const nextBackup = { recipient: recipient.trim(), subject, body };
|
|
setEmailBackup(nextBackup);
|
|
window.location.href = `mailto:${encodeURIComponent(nextBackup.recipient)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
|
onMessage(t("privacy.emailLinkCreated"));
|
|
} catch (error) {
|
|
setError(error instanceof Error ? error.message : t("privacy.emailLinkFailed"));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const submitDisabled = busy || (isEmail && (!isProbablyEmail(recipient) || !serverConsent));
|
|
|
|
return (
|
|
<div className="modal-overlay report-overlay" role="presentation" onMouseDown={onClose}>
|
|
<section className="export-modal" role="dialog" aria-modal="true" aria-labelledby="export-modal-title" onMouseDown={(event) => event.stopPropagation()}>
|
|
<button className="modal-close" onClick={onClose} type="button" aria-label={t("common.close")}>
|
|
<X aria-hidden="true" />
|
|
</button>
|
|
<div className="field-head">
|
|
<h2 id="export-modal-title">{isEmail ? t("privacy.emailExportTitle") : t("privacy.exportRangeTitle")}</h2>
|
|
<p>{isEmail ? t("privacy.emailClientNotice") : t("privacy.exportRangeIntro")}</p>
|
|
</div>
|
|
<p className="notice warning">{t("privacy.exportSensitiveNotice")}</p>
|
|
{isEmail && (
|
|
<label className="text-field">
|
|
<span>{t("privacy.recipientEmail")}</span>
|
|
<input
|
|
inputMode="email"
|
|
onChange={(event) => setRecipient(event.target.value)}
|
|
placeholder={t("privacy.recipientPlaceholder")}
|
|
type="email"
|
|
value={recipient}
|
|
/>
|
|
</label>
|
|
)}
|
|
{isEmail ? (
|
|
<>
|
|
<button className="secondary-action compact-toggle" onClick={() => setRangeOpen((open) => !open)} type="button">
|
|
<FileClock aria-hidden="true" />
|
|
<span>{t("privacy.limitPeriod")}</span>
|
|
</button>
|
|
{rangeOpen && <ExportRangeFields range={range} onChange={setRange} />}
|
|
<p className="empty-text">{t("privacy.allDataSelected")}</p>
|
|
<label className="consent-check">
|
|
<input checked={serverConsent} onChange={(event) => setServerConsent(event.target.checked)} type="checkbox" />
|
|
<span>{t("privacy.serverStorageConsent")}</span>
|
|
</label>
|
|
</>
|
|
) : (
|
|
<>
|
|
<ExportRangeFields range={range} onChange={setRange} />
|
|
<p className="empty-text">{t("privacy.allDataDefault")}</p>
|
|
</>
|
|
)}
|
|
{error && <p className="error-text">{error}</p>}
|
|
<div className="action-grid">
|
|
<button className="secondary-action" onClick={onClose} type="button">
|
|
{t("common.cancel")}
|
|
</button>
|
|
<button className="primary-action" disabled={submitDisabled} onClick={submit} type="button">
|
|
{isEmail ? t("privacy.createEmail") : t("privacy.exportJson")}
|
|
</button>
|
|
</div>
|
|
{emailBackup && (
|
|
<section className="copy-backup">
|
|
<h3>{t("privacy.copyPasteBackupTitle")}</h3>
|
|
<p>{t("privacy.copyPasteBackupIntro")}</p>
|
|
<label className="text-field">
|
|
<span>{t("privacy.recipientEmail")}</span>
|
|
<input readOnly value={emailBackup.recipient} />
|
|
</label>
|
|
<label className="text-field">
|
|
<span>{t("privacy.emailSubjectLabel")}</span>
|
|
<input readOnly value={emailBackup.subject} />
|
|
</label>
|
|
<label className="text-field">
|
|
<span>{t("privacy.emailBodyLabel")}</span>
|
|
<textarea readOnly rows={10} value={emailBackup.body} />
|
|
</label>
|
|
</section>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ExportRangeFields({ range, onChange }: { range: ExportRange; onChange: (range: ExportRange) => void }) {
|
|
const { t } = useI18n();
|
|
const datePlaceholder = t("overview.datePlaceholder");
|
|
return (
|
|
<div className="date-range export-date-range">
|
|
<label>
|
|
<span>{t("privacy.fromDate")}</span>
|
|
<input
|
|
aria-label={`${t("privacy.fromDate")} ${datePlaceholder}`}
|
|
onChange={(event) => onChange({ ...range, fromDate: event.target.value || undefined })}
|
|
placeholder={datePlaceholder}
|
|
type="date"
|
|
value={range.fromDate ?? ""}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span>{t("privacy.toDate")}</span>
|
|
<input
|
|
aria-label={`${t("privacy.toDate")} ${datePlaceholder}`}
|
|
onChange={(event) => onChange({ ...range, toDate: event.target.value || undefined })}
|
|
placeholder={datePlaceholder}
|
|
type="date"
|
|
value={range.toDate ?? ""}
|
|
/>
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function downloadFile(file: File) {
|
|
const url = URL.createObjectURL(file);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = file.name;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function isProbablyEmail(value: string) {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
|
}
|
|
|
|
function SecureSharePage() {
|
|
const { t } = useI18n();
|
|
const request = useMemo(() => parseShareRequestFromLocation(window.location), []);
|
|
const [status, setStatus] = useState<"idle" | "loading" | "done" | "error">("idle");
|
|
const [message, setMessage] = useState("");
|
|
|
|
const importShare = async () => {
|
|
if (!request.exportId) {
|
|
setStatus("error");
|
|
setMessage(t("share.notFound"));
|
|
return;
|
|
}
|
|
if (!request.key) {
|
|
setStatus("error");
|
|
setMessage(t("share.missingKey"));
|
|
return;
|
|
}
|
|
setStatus("loading");
|
|
setMessage("");
|
|
try {
|
|
const shared = await fetchSecureShare(request.exportId);
|
|
if (shared.status !== "available") {
|
|
if (shared.status === "deleted") throw new Error(t("share.alreadyDeleted"));
|
|
if (shared.status === "expired") throw new Error(t("share.expired"));
|
|
throw new Error(t("share.notFound"));
|
|
}
|
|
const payload = await decryptSecureSharePayload(shared, request.key);
|
|
const result = importExportPayload(JSON.stringify(payload), "", undefined, false);
|
|
if (!request.confirmToken) throw new Error(t("share.missingConfirmToken"));
|
|
await confirmSecureShareImport(request.exportId, request.confirmToken);
|
|
setStatus("done");
|
|
setMessage(
|
|
t("share.imported", {
|
|
reports: result.added,
|
|
skipped: result.skipped
|
|
})
|
|
);
|
|
} catch (error) {
|
|
setStatus("error");
|
|
setMessage(error instanceof Error ? error.message : t("share.decryptFailed"));
|
|
}
|
|
};
|
|
|
|
const initialError = !request.key ? t("share.missingKey") : !request.exportId ? t("share.notFound") : "";
|
|
|
|
return (
|
|
<div className="app-shell share-shell">
|
|
<header className="topbar">
|
|
<div>
|
|
<p className="eyebrow">{t("app.eyebrow")}</p>
|
|
<h1>{t("share.title")}</h1>
|
|
</div>
|
|
<a className="text-link" href="/veileder">
|
|
{t("app.evaluator")}
|
|
</a>
|
|
</header>
|
|
<main>
|
|
<section className="share-card">
|
|
<div className="notice">
|
|
<ShieldAlert aria-hidden="true" />
|
|
<span>{t("share.intro")}</span>
|
|
</div>
|
|
<p>{t("share.activeImportNotice")}</p>
|
|
{initialError && <p className="error-text">{initialError}</p>}
|
|
{message && <p className={status === "done" ? "success-text" : "error-text"}>{message}</p>}
|
|
<div className="action-grid">
|
|
<button className="primary-action" disabled={Boolean(initialError) || status === "loading" || status === "done"} onClick={importShare} type="button">
|
|
<Download aria-hidden="true" />
|
|
<span>{status === "loading" ? t("share.fetching") : t("share.fetchAndImport")}</span>
|
|
</button>
|
|
<a className="secondary-action" href="/veileder">
|
|
<UserRound aria-hidden="true" />
|
|
<span>{t("share.openAdvisor")}</span>
|
|
</a>
|
|
</div>
|
|
{status === "error" && <p className="empty-text">{t("share.requestNewShare")}</p>}
|
|
</section>
|
|
</main>
|
|
<AppFooter demoMode={false} onDemoModeChange={() => undefined} showDemoToggle={false} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EvaluatorApp() {
|
|
const { labels, t } = useI18n();
|
|
const [demoMode, setDemoMode] = useState(() => isDemoModeEnabled());
|
|
const [profiles, setProfiles] = useState<EvaluatorProfile[]>(() => listEvaluatorProfiles(isDemoModeEnabled()));
|
|
const [selectedId, setSelectedId] = useState<string | null>(() => listEvaluatorProfiles(isDemoModeEnabled())[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]);
|
|
|
|
useEffect(() => {
|
|
const next = listEvaluatorProfiles(demoMode);
|
|
setProfiles(next);
|
|
setSelectedId((current) => (current && next.some((profile) => profile.id === current) ? current : next[0]?.id ?? null));
|
|
setSelectedEntryId(null);
|
|
}, [demoMode]);
|
|
|
|
const refresh = (nextSelectedId?: string) => {
|
|
const next = listEvaluatorProfiles(demoMode);
|
|
setProfiles(next);
|
|
setSelectedId((current) => nextSelectedId ?? (current && next.some((profile) => profile.id === current) ? current : next[0]?.id ?? null));
|
|
};
|
|
|
|
const changeDemoMode = (enabled: boolean) => {
|
|
setDemoModeEnabled(enabled);
|
|
if (enabled) {
|
|
ensureDemoData();
|
|
ensureDemoEvaluatorProfiles();
|
|
}
|
|
setDemoMode(enabled);
|
|
setMessage(enabled ? t("demo.notice") : "");
|
|
};
|
|
|
|
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, demoMode);
|
|
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(
|
|
`${actionText} ${result.added} nye rapporter, ${result.skipped} duplikater hoppet over.${result.demo_entries_detected > 0 ? ` ADVARSEL: Importen inneholder ${result.demo_entries_detected} demodata-oppføringer.` : ""}${flagged > 0 ? " Noen rapporter kunne ikke verifiseres." : ""}`
|
|
);
|
|
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 {
|
|
const text = await file.text();
|
|
const demoEntries = countEvaluatorBackupDemoEntries(text);
|
|
importEvaluatorBackup(text, demoMode);
|
|
setMessage(`${t("evaluator.importedBackup")}${demoEntries > 0 ? ` ADVARSEL: Backupen inneholder ${demoEntries} demodata-oppføringer.` : ""}`);
|
|
refresh();
|
|
} catch (error) {
|
|
setMessage(error instanceof Error ? error.message : "Backup kunne ikke importeres.");
|
|
} finally {
|
|
event.target.value = "";
|
|
}
|
|
};
|
|
|
|
const downloadBackup = () => {
|
|
const blob = new Blob([exportEvaluatorBackup(demoMode)], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `${demoMode ? "arbeidspuls-demo" : "arbeidspuls"}-veilederdata-${new Date().toISOString().slice(0, 10)}.json`;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const saveName = () => {
|
|
if (!selected) return;
|
|
renameEvaluatorProfile(selected.id, selected.display_name, demoMode);
|
|
refresh(selected.id);
|
|
};
|
|
|
|
const savePersonNote = () => {
|
|
if (!selected) return;
|
|
upsertEvaluatorNote(selected.id, personNote, { note_type: "person" }, demoMode);
|
|
refresh(selected.id);
|
|
};
|
|
|
|
const deleteSelectedPerson = () => {
|
|
if (!deletePersonRequest) return;
|
|
deleteEvaluatorProfile(deletePersonRequest.id, demoMode);
|
|
const remaining = listEvaluatorProfiles(demoMode);
|
|
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={demoMode ? "app-shell evaluator-shell demo-active" : "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>
|
|
|
|
{demoMode && (
|
|
<div className="notice warning">
|
|
<ShieldAlert aria-hidden="true" />
|
|
<span>{t("demo.notice")}</span>
|
|
</div>
|
|
)}
|
|
|
|
<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">
|
|
{selected.contains_demo_data && (
|
|
<div className="notice warning">
|
|
<ShieldAlert aria-hidden="true" />
|
|
<span>{t("evaluator.demoDataWarning")}</span>
|
|
</div>
|
|
)}
|
|
<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}
|
|
demoMode={demoMode}
|
|
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 demoMode={demoMode} onDemoModeChange={changeDemoMode} />
|
|
<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 countEvaluatorBackupDemoEntries(jsonText: string) {
|
|
try {
|
|
const parsed = JSON.parse(jsonText) as { data_mode?: string; profiles?: EvaluatorProfile[] };
|
|
if (!Array.isArray(parsed.profiles)) return 0;
|
|
const count = parsed.profiles.reduce(
|
|
(total, profile) => total + (profile.contains_demo_data ? 1 : 0) + profile.reports.filter((entry) => entry.demo || entry.id.startsWith("demo-arbeidspuls")).length,
|
|
0
|
|
);
|
|
return parsed.data_mode === "demo" && count === 0 ? 1 : count;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
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.demo && <span className="demo-meta-pill" title={t("evaluator.demoDataWarning")}>{t("demo.label")}</span>}
|
|
{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>
|
|
{t("followup.worsePrefix")}: {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,
|
|
demoMode,
|
|
onSaved,
|
|
onClose
|
|
}: {
|
|
entry: StoredEntry;
|
|
followups: DelayedFollowup[];
|
|
notes: EvaluatorProfile["evaluator_notes"];
|
|
profileId: string;
|
|
demoMode: boolean;
|
|
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 }, demoMode);
|
|
} else {
|
|
upsertEvaluatorNote(profileId, noteText, {
|
|
note_type: "followup",
|
|
related_followup_id: entry.id,
|
|
parent_report_id: findParentId(entry)
|
|
}, demoMode);
|
|
}
|
|
onSaved();
|
|
};
|
|
|
|
const remove = () => {
|
|
if (entry.type === "work_report") {
|
|
deleteEvaluatorNote(profileId, { note_type: "report", related_report_id: entry.id }, demoMode);
|
|
} else {
|
|
deleteEvaluatorNote(profileId, { note_type: "followup", related_followup_id: entry.id }, demoMode);
|
|
}
|
|
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);
|
|
}}
|
|
/>
|
|
{entry.type === "work_report" ? <ReportTechnicalDetails report={entry} /> : <FollowupTechnicalDetails followup={entry} />}
|
|
</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")}>
|
|
{report.demo && <Detail label={t("details.dataMode")} value={t("demo.label")} />}
|
|
{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>
|
|
)}
|
|
</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")}>
|
|
{followup.demo && <Detail label={t("details.dataMode")} value={t("demo.label")} />}
|
|
{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>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ReportTechnicalDetails({ report }: { report: Report }) {
|
|
const { t } = useI18n();
|
|
return (
|
|
<>
|
|
<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)} />
|
|
{report.updated_at && <Detail label={t("details.updated")} value={formatIsoDateTime(report.updated_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} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function FollowupTechnicalDetails({ followup }: { followup: DelayedFollowup }) {
|
|
const { t } = useI18n();
|
|
return (
|
|
<>
|
|
<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)} />
|
|
{followup.updated_at && <Detail label={t("details.updated")} value={formatIsoDateTime(followup.updated_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} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 currentDateLocale() {
|
|
return localStorage.getItem("arbeidspuls.language") === "en" ? "en-GB" : "nb-NO";
|
|
}
|
|
|
|
export 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 locale = currentDateLocale();
|
|
const weekday = parsed.toLocaleDateString(locale, { weekday: "long" });
|
|
const date = parsed.toLocaleDateString(locale, { 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 locale = currentDateLocale();
|
|
const weekday = parsed.toLocaleDateString(locale, { weekday: "long" });
|
|
const date = parsed.toLocaleDateString(locale, { day: "2-digit", month: "2-digit", year: "2-digit" });
|
|
return `${capitalize(weekday)} ${date}`;
|
|
}
|
|
|
|
function formatReportPickerLabel(report: Report, labels: ReturnType<typeof useI18n>["labels"], t: ReturnType<typeof useI18n>["t"]) {
|
|
const time = report.report_time.slice(0, 5);
|
|
const workplace = report.workplace?.trim();
|
|
const context = `${t("details.workAbility").toLowerCase()} ${report.work_ability}, ${labels.statusLabels[report.status].toLowerCase()}`;
|
|
return workplace
|
|
? `${formatShortDate(report)} ${time} - ${workplace} (${context})`
|
|
: `${formatShortDate(report)} ${time} - ${t("common.unknownWorkplace")} (${context})`;
|
|
}
|
|
|
|
function formatIsoDateTime(value: string) {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
const locale = currentDateLocale();
|
|
const weekday = date.toLocaleDateString(locale, { weekday: "long" });
|
|
const formatted = date.toLocaleDateString(locale, { day: "2-digit", month: "2-digit", year: "numeric" });
|
|
const time = date.toLocaleTimeString(locale, { 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 filterFollowupResponsesByPeriod(points: FollowupResponsePoint[], period: TrendPeriod) {
|
|
const [start, end] = periodBounds(period);
|
|
return points.filter((point) => {
|
|
const time = getEntryTime(point.followup);
|
|
if (start && time < start) return false;
|
|
if (end && time > end) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
export function graphDomainStart(times: number[], selectedStart: number | null = null) {
|
|
const fallback = times.length ? Math.min(...times) : Date.now();
|
|
return startOfDayTime(selectedStart ?? fallback);
|
|
}
|
|
|
|
function startOfDayTime(time: number) {
|
|
const date = new Date(time);
|
|
date.setHours(0, 0, 0, 0);
|
|
return date.getTime();
|
|
}
|
|
|
|
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) });
|
|
}
|
|
if (!markers.some((marker) => marker.time === minTime)) {
|
|
markers.unshift({ time: minTime, label: formatMarker(minTime, "day") });
|
|
}
|
|
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);
|
|
const locale = currentDateLocale();
|
|
if (unit === "time") return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
|
|
if (unit === "day" || unit === "week") return date.toLocaleDateString(locale, { day: "2-digit", month: "2-digit" });
|
|
if (unit === "month") return date.toLocaleDateString(locale, { month: "short", year: "2-digit" });
|
|
return date.toLocaleDateString(locale, { year: "numeric" });
|
|
}
|
|
|
|
function formatTimeRange(startAt: number, endAt: number) {
|
|
const locale = currentDateLocale();
|
|
const options: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" };
|
|
return `${new Date(startAt).toLocaleTimeString(locale, options)}-${new Date(endAt).toLocaleTimeString(locale, options)}`;
|
|
}
|
|
|
|
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 };
|
|
type WorkTimeBand = {
|
|
reportId: string;
|
|
startAt: number;
|
|
endAt: number;
|
|
workplace?: string;
|
|
};
|
|
type FollowupResponsePoint = {
|
|
followup: DelayedFollowup;
|
|
parent: Report;
|
|
parentScore: number;
|
|
effectScore: number;
|
|
impact: number;
|
|
offsetHours: number;
|
|
};
|
|
|
|
const followupResponseMetric = {
|
|
key: "followup_response",
|
|
labelKey: "graphModal.followupResponse",
|
|
short: "O",
|
|
color: "#b2642a"
|
|
};
|
|
|
|
function isPoint(value: ChartPoint | null): value is ChartPoint {
|
|
return Boolean(value);
|
|
}
|
|
|
|
export function calculateFollowupEffectScore(
|
|
parentScore: number,
|
|
worseThanBefore: string,
|
|
recoveryStatus?: string
|
|
) {
|
|
const worseImpact: Record<string, number> = {
|
|
bedre: 10,
|
|
nei: 0,
|
|
ikke_verre: 0,
|
|
litt: -10,
|
|
litt_verre: -10,
|
|
moderat: -25,
|
|
moderat_verre: -25,
|
|
mye: -40,
|
|
mye_verre: -40,
|
|
svært_mye: -60,
|
|
svaert_mye: -60,
|
|
svært_mye_verre: -60,
|
|
svaert_mye_verre: -60
|
|
};
|
|
const recoveryAdjustment: Record<string, number> = {
|
|
fullt_restituert: 5,
|
|
tilbake_til_baseline: 5,
|
|
samme_dag: 5,
|
|
delvis_restituert: 0,
|
|
en_dag: 0,
|
|
to_tre_dager: -5,
|
|
ikke_restituert: -10,
|
|
ikke_tilbake_til_baseline: -10,
|
|
fire_pluss_dager: -10,
|
|
forverret: -15
|
|
};
|
|
const impact = (worseImpact[worseThanBefore] ?? 0) + (recoveryStatus ? recoveryAdjustment[recoveryStatus] ?? 0 : 0);
|
|
return Math.max(0, Math.min(100, parentScore + impact));
|
|
}
|
|
|
|
export function buildFollowupResponsePoints(entries: StoredEntry[]) {
|
|
const reports = new Map(entries.filter((entry): entry is Report => entry.type === "work_report").map((report) => [report.id, report]));
|
|
return entries
|
|
.filter((entry): entry is DelayedFollowup => entry.type === "delayed_followup")
|
|
.map((followup): FollowupResponsePoint | null => {
|
|
const parentId = findParentId(followup);
|
|
const parent = parentId ? reports.get(parentId) : undefined;
|
|
if (!parent) return null;
|
|
const parentScore = getReportScore(parent) ?? calculateReportScore(parent);
|
|
if (parentScore === null) return null;
|
|
const effectScore = calculateFollowupEffectScore(parentScore, followup.worse_than_before, followup.recovery_status);
|
|
return {
|
|
followup,
|
|
parent,
|
|
parentScore,
|
|
effectScore,
|
|
impact: effectScore - parentScore,
|
|
offsetHours: Math.round(((getEntryTime(followup) - getEntryTime(parent)) / (60 * 60 * 1000)) * 10) / 10
|
|
};
|
|
})
|
|
.filter((point): point is FollowupResponsePoint => Boolean(point))
|
|
.sort((a, b) => getEntryTime(a.followup) - getEntryTime(b.followup));
|
|
}
|
|
|
|
export function buildWorkTimeBands(entries: StoredEntry[]) {
|
|
return entries
|
|
.filter((entry): entry is Report => entry.type === "work_report")
|
|
.map((report): WorkTimeBand | null => {
|
|
if (!report.work_start_time || !report.work_end_time) return null;
|
|
const startAt = new Date(`${report.report_date}T${report.work_start_time}`).getTime();
|
|
let endAt = new Date(`${report.report_date}T${report.work_end_time}`).getTime();
|
|
if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) return null;
|
|
if (endAt <= startAt) endAt += 24 * 60 * 60 * 1000;
|
|
return {
|
|
reportId: report.id,
|
|
startAt,
|
|
endAt,
|
|
workplace: report.workplace
|
|
};
|
|
})
|
|
.filter((band): band is WorkTimeBand => Boolean(band))
|
|
.sort((a, b) => a.startAt - b.startAt);
|
|
}
|
|
|
|
function filterWorkTimeBandsByPeriod(bands: WorkTimeBand[], period: TrendPeriod) {
|
|
const [start, end] = periodBounds(period);
|
|
return bands.filter((band) => {
|
|
if (start && band.endAt < start) return false;
|
|
if (end && band.startAt > end) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
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;
|