Release v1.1.1 demo advisor mode
This commit is contained in:
parent
82da79cc4a
commit
76ec48bfef
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "arbeidspuls",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "arbeidspuls",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.475.0",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "arbeidspuls",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = "arbeidspuls-v5";
|
||||
const CACHE_NAME = "arbeidspuls-v6";
|
||||
const ASSETS = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
|
||||
112
src/App.tsx
112
src/App.tsx
@ -37,6 +37,7 @@ import {
|
||||
import {
|
||||
deleteEvaluatorNote,
|
||||
deleteEvaluatorProfile,
|
||||
ensureDemoEvaluatorProfiles,
|
||||
exportEvaluatorBackup,
|
||||
importEvaluatorBackup,
|
||||
importExportPayload,
|
||||
@ -153,20 +154,14 @@ function AppRoutes() {
|
||||
|
||||
return (
|
||||
<div className={demoMode ? "app-shell demo-active" : "app-shell"}>
|
||||
{demoMode && <DemoBanner onExit={() => changeDemoMode(false)} />}
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<p className="eyebrow">{t("app.eyebrow")}</p>
|
||||
<h1>{t("app.title")}</h1>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<button className={demoMode ? "text-link active" : "text-link"} onClick={() => changeDemoMode(!demoMode)} type="button">
|
||||
{demoMode ? t("demo.exit") : t("demo.enter")}
|
||||
</button>
|
||||
<a className="text-link" href="/veileder">
|
||||
{t("app.evaluator")}
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="main-nav" aria-label={t("app.navLabel")}>
|
||||
@ -206,7 +201,7 @@ function AppRoutes() {
|
||||
)}
|
||||
{view === "personvern" && <PrivacyPanel demoMode={demoMode} entries={entries} onDeleted={refresh} />}
|
||||
</main>
|
||||
<AppFooter />
|
||||
<AppFooter demoMode={demoMode} onDemoModeChange={changeDemoMode} />
|
||||
<ConfirmDeleteModal
|
||||
itemName={deleteRequest?.name ?? ""}
|
||||
open={Boolean(deleteRequest)}
|
||||
@ -242,21 +237,7 @@ function NavButton({
|
||||
);
|
||||
}
|
||||
|
||||
function DemoBanner({ onExit }: { onExit: () => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="demo-banner" role="status">
|
||||
<span>DEMO</span>
|
||||
<span aria-hidden="true">[</span>
|
||||
<button aria-label={t("demo.exit")} onClick={onExit} type="button">
|
||||
x
|
||||
</button>
|
||||
<span aria-hidden="true">]</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppFooter() {
|
||||
function AppFooter({ demoMode, onDemoModeChange }: { demoMode: boolean; onDemoModeChange: (enabled: boolean) => void }) {
|
||||
const { language, setLanguage, t } = useI18n();
|
||||
const baseYear = 2026;
|
||||
const currentYear = new Date().getFullYear();
|
||||
@ -292,6 +273,9 @@ function AppFooter() {
|
||||
<Github aria-hidden="true" />
|
||||
<span>{t("footer.sourceCode", { version: APP_VERSION })}</span>
|
||||
</a>
|
||||
<button className={demoMode ? "footer-link active" : "footer-link"} onClick={() => onDemoModeChange(!demoMode)} type="button">
|
||||
{demoMode ? t("demo.exit") : t("demo.enter")}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
@ -1687,8 +1671,9 @@ function PrivacyPanel({ demoMode, entries, onDeleted }: { demoMode: boolean; ent
|
||||
|
||||
function EvaluatorApp() {
|
||||
const { labels, t } = useI18n();
|
||||
const [profiles, setProfiles] = useState<EvaluatorProfile[]>(() => listEvaluatorProfiles());
|
||||
const [selectedId, setSelectedId] = useState<string | null>(() => listEvaluatorProfiles()[0]?.id ?? null);
|
||||
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("");
|
||||
@ -1705,10 +1690,27 @@ function EvaluatorApp() {
|
||||
if (selected) setPersonNote(getPersonNote(selected)?.text ?? "");
|
||||
}, [selected?.id]);
|
||||
|
||||
const refresh = (nextSelectedId?: string) => {
|
||||
const next = listEvaluatorProfiles();
|
||||
useEffect(() => {
|
||||
const next = listEvaluatorProfiles(demoMode);
|
||||
setProfiles(next);
|
||||
if (nextSelectedId) setSelectedId(nextSelectedId);
|
||||
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>) => {
|
||||
@ -1716,7 +1718,7 @@ function EvaluatorApp() {
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const result = importExportPayload(await file.text(), displayName, targetId || undefined);
|
||||
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"
|
||||
@ -1739,8 +1741,10 @@ function EvaluatorApp() {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
importEvaluatorBackup(await file.text());
|
||||
setMessage(t("evaluator.importedBackup"));
|
||||
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.");
|
||||
@ -1750,32 +1754,32 @@ function EvaluatorApp() {
|
||||
};
|
||||
|
||||
const downloadBackup = () => {
|
||||
const blob = new Blob([exportEvaluatorBackup()], { type: "application/json" });
|
||||
const blob = new Blob([exportEvaluatorBackup(demoMode)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `arbeidsevne-veilederdata-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
link.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);
|
||||
renameEvaluatorProfile(selected.id, selected.display_name, demoMode);
|
||||
refresh(selected.id);
|
||||
};
|
||||
|
||||
const savePersonNote = () => {
|
||||
if (!selected) return;
|
||||
upsertEvaluatorNote(selected.id, personNote, { note_type: "person" });
|
||||
upsertEvaluatorNote(selected.id, personNote, { note_type: "person" }, demoMode);
|
||||
setPersonNote("");
|
||||
refresh(selected.id);
|
||||
};
|
||||
|
||||
const deleteSelectedPerson = () => {
|
||||
if (!deletePersonRequest) return;
|
||||
deleteEvaluatorProfile(deletePersonRequest.id);
|
||||
const remaining = listEvaluatorProfiles();
|
||||
deleteEvaluatorProfile(deletePersonRequest.id, demoMode);
|
||||
const remaining = listEvaluatorProfiles(demoMode);
|
||||
setProfiles(remaining);
|
||||
setSelectedId(remaining[0]?.id ?? null);
|
||||
setSelectedEntryId(null);
|
||||
@ -1801,7 +1805,7 @@ function EvaluatorApp() {
|
||||
const followupsByParent = useMemo(() => (selected ? groupFollowups(selected.reports) : new Map<string, DelayedFollowup[]>()), [selected]);
|
||||
|
||||
return (
|
||||
<div className="app-shell evaluator-shell">
|
||||
<div className={demoMode ? "app-shell evaluator-shell demo-active" : "app-shell evaluator-shell"}>
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<p className="eyebrow">{t("evaluator.eyebrow")}</p>
|
||||
@ -1812,6 +1816,13 @@ function EvaluatorApp() {
|
||||
</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">
|
||||
@ -1968,6 +1979,7 @@ function EvaluatorApp() {
|
||||
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)}
|
||||
/>
|
||||
@ -1982,7 +1994,7 @@ function EvaluatorApp() {
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
<AppFooter />
|
||||
<AppFooter demoMode={demoMode} onDemoModeChange={changeDemoMode} />
|
||||
<ConfirmDeleteModal
|
||||
open={Boolean(deletePersonRequest)}
|
||||
itemName={
|
||||
@ -1997,6 +2009,20 @@ function EvaluatorApp() {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@ -2130,6 +2156,7 @@ function ReviewDetailPane({
|
||||
followups,
|
||||
notes,
|
||||
profileId,
|
||||
demoMode,
|
||||
onSaved,
|
||||
onClose
|
||||
}: {
|
||||
@ -2137,6 +2164,7 @@ function ReviewDetailPane({
|
||||
followups: DelayedFollowup[];
|
||||
notes: EvaluatorProfile["evaluator_notes"];
|
||||
profileId: string;
|
||||
demoMode: boolean;
|
||||
onSaved: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
@ -2151,22 +2179,22 @@ function ReviewDetailPane({
|
||||
|
||||
const save = () => {
|
||||
if (entry.type === "work_report") {
|
||||
upsertEvaluatorNote(profileId, noteText, { note_type: "report", related_report_id: entry.id });
|
||||
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 });
|
||||
deleteEvaluatorNote(profileId, { note_type: "report", related_report_id: entry.id }, demoMode);
|
||||
} else {
|
||||
deleteEvaluatorNote(profileId, { note_type: "followup", related_followup_id: entry.id });
|
||||
deleteEvaluatorNote(profileId, { note_type: "followup", related_followup_id: entry.id }, demoMode);
|
||||
}
|
||||
setNoteText("");
|
||||
onSaved();
|
||||
|
||||
153
src/demoData.ts
153
src/demoData.ts
@ -1,4 +1,4 @@
|
||||
import type { StoredEntry } from "./types";
|
||||
import type { EvaluatorProfile, StoredEntry } from "./types";
|
||||
|
||||
export const demoEntries: StoredEntry[] = [
|
||||
{
|
||||
@ -182,3 +182,154 @@ export const demoEntries: StoredEntry[] = [
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const sharedDemoProfiles = {
|
||||
created_at: "2026-05-28T08:00:00.000Z",
|
||||
updated_at: "2026-05-28T08:00:00.000Z",
|
||||
contains_demo_data: true,
|
||||
evaluator_notes: []
|
||||
};
|
||||
|
||||
export const demoEvaluatorProfiles: EvaluatorProfile[] = [
|
||||
{
|
||||
...sharedDemoProfiles,
|
||||
id: "demo-advisor-profile-01",
|
||||
display_name: "Demo: Nora Hjemmekontor",
|
||||
source_fingerprint: "demo-nora",
|
||||
reports: demoEntries
|
||||
},
|
||||
{
|
||||
...sharedDemoProfiles,
|
||||
id: "demo-advisor-profile-02",
|
||||
display_name: "Demo: Amir Kontor",
|
||||
source_fingerprint: "demo-amir",
|
||||
reports: [
|
||||
{
|
||||
id: "demo-arbeidspuls-amir-001",
|
||||
created_at: "2026-05-18T07:20:00.000Z",
|
||||
report_date: "2026-05-18",
|
||||
report_time: "09:20:00",
|
||||
type: "work_report",
|
||||
workplace: "Kontor",
|
||||
work_start_time: "08:30",
|
||||
work_end_time: "11:30",
|
||||
work_ability: 3,
|
||||
energy_level: 3,
|
||||
mental_clarity: 4,
|
||||
symptom_burden: 2,
|
||||
effort_strain: 3,
|
||||
status: "kan_fortsette",
|
||||
physical_energy: 3,
|
||||
mental_energy: 4,
|
||||
perceived_productivity: 3,
|
||||
task_completion: "delvis",
|
||||
task_types: ["sitting", "computer_work", "time_pressure"],
|
||||
main_limitations: ["fatigue"],
|
||||
helpful_accommodations: ["extra_breaks"],
|
||||
note: "Testdata: moderat dag med noen pauser.",
|
||||
total_score_percent: 68,
|
||||
demo: true
|
||||
},
|
||||
{
|
||||
id: "demo-arbeidspuls-amir-002",
|
||||
created_at: "2026-05-23T10:45:00.000Z",
|
||||
report_date: "2026-05-23",
|
||||
report_time: "12:45:00",
|
||||
type: "work_report",
|
||||
workplace: "Kontor",
|
||||
work_start_time: "10:00",
|
||||
work_end_time: "12:30",
|
||||
work_ability: 2,
|
||||
energy_level: 2,
|
||||
mental_clarity: 2,
|
||||
symptom_burden: 4,
|
||||
effort_strain: 5,
|
||||
status: "trenger_pause",
|
||||
physical_energy: 2,
|
||||
mental_energy: 2,
|
||||
perceived_productivity: 2,
|
||||
task_completion: "litt",
|
||||
task_types: ["sitting", "talking_social", "noise"],
|
||||
main_limitations: ["fatigue", "sensory_overload", "brain_fog"],
|
||||
helpful_accommodations: ["quiet_room", "shorter_task"],
|
||||
note: "Testdata: støy og sosial belastning ga tydelig fall.",
|
||||
total_score_percent: 24,
|
||||
demo: true
|
||||
},
|
||||
{
|
||||
id: "demo-arbeidspuls-amir-followup-002",
|
||||
created_at: "2026-05-24T08:30:00.000Z",
|
||||
report_date: "2026-05-24",
|
||||
report_time: "10:30:00",
|
||||
type: "delayed_followup",
|
||||
parent_report_id: "demo-arbeidspuls-amir-002",
|
||||
related_report_id: "demo-arbeidspuls-amir-002",
|
||||
worse_than_before: "moderat",
|
||||
delayed_symptoms: ["fatigue", "brain_fog", "headache"],
|
||||
recovery_status: "to_tre_dager",
|
||||
note: "Testdata: oppfølging etter krevende kontordag.",
|
||||
demo: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
...sharedDemoProfiles,
|
||||
id: "demo-advisor-profile-03",
|
||||
display_name: "Demo: Liv Butikk",
|
||||
source_fingerprint: "demo-liv",
|
||||
reports: [
|
||||
{
|
||||
id: "demo-arbeidspuls-liv-001",
|
||||
created_at: "2026-05-19T12:15:00.000Z",
|
||||
report_date: "2026-05-19",
|
||||
report_time: "14:15:00",
|
||||
type: "work_report",
|
||||
workplace: "Butikk",
|
||||
work_start_time: "12:00",
|
||||
work_end_time: "14:00",
|
||||
work_ability: 3,
|
||||
energy_level: 3,
|
||||
mental_clarity: 3,
|
||||
symptom_burden: 3,
|
||||
effort_strain: 3,
|
||||
status: "trenger_enklere_oppgave",
|
||||
physical_energy: 3,
|
||||
mental_energy: 3,
|
||||
perceived_productivity: 3,
|
||||
task_completion: "delvis",
|
||||
task_types: ["standing", "walking", "talking_social"],
|
||||
main_limitations: ["pain", "fatigue"],
|
||||
helpful_accommodations: ["sitting_down", "help_from_others"],
|
||||
note: "Testdata: fysisk aktivitet krevde tilrettelegging.",
|
||||
total_score_percent: 50,
|
||||
demo: true
|
||||
},
|
||||
{
|
||||
id: "demo-arbeidspuls-liv-002",
|
||||
created_at: "2026-05-26T07:50:00.000Z",
|
||||
report_date: "2026-05-26",
|
||||
report_time: "09:50:00",
|
||||
type: "work_report",
|
||||
workplace: "Butikk",
|
||||
work_start_time: "09:00",
|
||||
work_end_time: "09:45",
|
||||
work_ability: 4,
|
||||
energy_level: 4,
|
||||
mental_clarity: 4,
|
||||
symptom_burden: 2,
|
||||
effort_strain: 2,
|
||||
status: "kan_fortsette",
|
||||
physical_energy: 4,
|
||||
mental_energy: 4,
|
||||
perceived_productivity: 4,
|
||||
task_completion: "som_forventet",
|
||||
task_types: ["standing", "walking"],
|
||||
main_limitations: [],
|
||||
helpful_accommodations: ["shorter_task", "sitting_down"],
|
||||
note: "Testdata: kortere oppgave fungerte bedre.",
|
||||
total_score_percent: 78,
|
||||
demo: true
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@ -123,4 +123,12 @@ describe("evaluator-import", () => {
|
||||
expect(result.profile.contains_demo_data).toBe(true);
|
||||
expect(listEvaluatorProfiles()[0].contains_demo_data).toBe(true);
|
||||
});
|
||||
|
||||
it("holder veilederens demobrukere separat fra normale brukere", () => {
|
||||
const demoProfiles = listEvaluatorProfiles(true);
|
||||
|
||||
expect(demoProfiles.length).toBeGreaterThanOrEqual(3);
|
||||
expect(demoProfiles.every((profile) => profile.contains_demo_data)).toBe(true);
|
||||
expect(listEvaluatorProfiles()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import { calculateReportScore, getEntryTime } from "./scoring";
|
||||
import { summarizeVerification, verifyEntryIntegrity } from "./integrity";
|
||||
import { demoEvaluatorProfiles } from "./demoData";
|
||||
import type { EvaluatorNote, EvaluatorProfile, ExportPayload, ImportTargetResolution, Report, StoredEntry } from "./types";
|
||||
|
||||
const EVALUATOR_KEY = "arbeidsevne-egenvurdering:evaluator-profiles";
|
||||
const DEMO_EVALUATOR_KEY = "arbeidspuls:demo-evaluator-profiles";
|
||||
const DEMO_EVALUATOR_SEEDED_KEY = "arbeidspuls:demo-evaluator-seeded";
|
||||
|
||||
export type ImportResult = {
|
||||
profile: EvaluatorProfile;
|
||||
@ -15,8 +18,22 @@ export type ImportResult = {
|
||||
matched_report_ids: string[];
|
||||
};
|
||||
|
||||
function readProfiles(): EvaluatorProfile[] {
|
||||
const raw = localStorage.getItem(EVALUATOR_KEY);
|
||||
function evaluatorKey(demoMode = false) {
|
||||
return demoMode ? DEMO_EVALUATOR_KEY : EVALUATOR_KEY;
|
||||
}
|
||||
|
||||
export function ensureDemoEvaluatorProfiles() {
|
||||
const existing = readProfiles(true);
|
||||
if (existing.length > 0) return existing;
|
||||
if (localStorage.getItem(DEMO_EVALUATOR_SEEDED_KEY) === "true") return [];
|
||||
const seeded = demoEvaluatorProfiles.map(normalizeProfile);
|
||||
writeProfiles(seeded, true);
|
||||
localStorage.setItem(DEMO_EVALUATOR_SEEDED_KEY, "true");
|
||||
return seeded;
|
||||
}
|
||||
|
||||
function readProfiles(demoMode = false): EvaluatorProfile[] {
|
||||
const raw = localStorage.getItem(evaluatorKey(demoMode));
|
||||
if (!raw) return [];
|
||||
|
||||
try {
|
||||
@ -27,32 +44,33 @@ function readProfiles(): EvaluatorProfile[] {
|
||||
}
|
||||
}
|
||||
|
||||
function writeProfiles(profiles: EvaluatorProfile[]) {
|
||||
localStorage.setItem(EVALUATOR_KEY, JSON.stringify(profiles));
|
||||
function writeProfiles(profiles: EvaluatorProfile[], demoMode = false) {
|
||||
localStorage.setItem(evaluatorKey(demoMode), JSON.stringify(profiles));
|
||||
}
|
||||
|
||||
export function listEvaluatorProfiles() {
|
||||
return readProfiles().map(normalizeProfile).sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
||||
export function listEvaluatorProfiles(demoMode = false) {
|
||||
if (demoMode) ensureDemoEvaluatorProfiles();
|
||||
return readProfiles(demoMode).map(normalizeProfile).sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
||||
}
|
||||
|
||||
export function updateEvaluatorProfile(profile: EvaluatorProfile) {
|
||||
const profiles = readProfiles();
|
||||
export function updateEvaluatorProfile(profile: EvaluatorProfile, demoMode = false) {
|
||||
const profiles = readProfiles(demoMode);
|
||||
const next = profiles.some((item) => item.id === profile.id)
|
||||
? profiles.map((item) => (item.id === profile.id ? profile : item))
|
||||
: [profile, ...profiles];
|
||||
writeProfiles(next);
|
||||
writeProfiles(next, demoMode);
|
||||
}
|
||||
|
||||
export function renameEvaluatorProfile(profileId: string, displayName: string) {
|
||||
export function renameEvaluatorProfile(profileId: string, displayName: string, demoMode = false) {
|
||||
const now = new Date().toISOString();
|
||||
const profiles = readProfiles().map((profile) =>
|
||||
const profiles = readProfiles(demoMode).map((profile) =>
|
||||
profile.id === profileId ? { ...profile, display_name: displayName.trim() || profile.display_name, updated_at: now } : profile
|
||||
);
|
||||
writeProfiles(profiles);
|
||||
writeProfiles(profiles, demoMode);
|
||||
}
|
||||
|
||||
export function deleteEvaluatorProfile(profileId: string) {
|
||||
writeProfiles(readProfiles().filter((profile) => profile.id !== profileId));
|
||||
export function deleteEvaluatorProfile(profileId: string, demoMode = false) {
|
||||
writeProfiles(readProfiles(demoMode).filter((profile) => profile.id !== profileId), demoMode);
|
||||
}
|
||||
|
||||
export function addEvaluatorNote(profileId: string, text: string, relatedReportId?: string) {
|
||||
@ -62,22 +80,24 @@ export function addEvaluatorNote(profileId: string, text: string, relatedReportI
|
||||
export function upsertEvaluatorNote(
|
||||
profileId: string,
|
||||
text: string,
|
||||
noteTarget: Pick<EvaluatorNote, "note_type" | "related_report_id" | "related_followup_id" | "parent_report_id">
|
||||
noteTarget: Pick<EvaluatorNote, "note_type" | "related_report_id" | "related_followup_id" | "parent_report_id">,
|
||||
demoMode = false
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
const trimmed = text.trim();
|
||||
const profiles = readProfiles().map((profile) =>
|
||||
const profiles = readProfiles(demoMode).map((profile) =>
|
||||
profile.id === profileId ? upsertNoteInProfile(normalizeProfile(profile), trimmed, noteTarget, now) : profile
|
||||
);
|
||||
writeProfiles(profiles);
|
||||
writeProfiles(profiles, demoMode);
|
||||
}
|
||||
|
||||
export function deleteEvaluatorNote(
|
||||
profileId: string,
|
||||
noteTarget: Pick<EvaluatorNote, "note_type" | "related_report_id" | "related_followup_id">
|
||||
noteTarget: Pick<EvaluatorNote, "note_type" | "related_report_id" | "related_followup_id">,
|
||||
demoMode = false
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
const profiles = readProfiles().map((profile) =>
|
||||
const profiles = readProfiles(demoMode).map((profile) =>
|
||||
profile.id === profileId
|
||||
? {
|
||||
...normalizeProfile(profile),
|
||||
@ -86,14 +106,14 @@ export function deleteEvaluatorNote(
|
||||
}
|
||||
: profile
|
||||
);
|
||||
writeProfiles(profiles);
|
||||
writeProfiles(profiles, demoMode);
|
||||
}
|
||||
|
||||
export function importExportPayload(jsonText: string, displayName: string, targetProfileId?: string): ImportResult {
|
||||
export function importExportPayload(jsonText: string, displayName: string, targetProfileId?: string, demoMode = false): ImportResult {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
const reports = extractReports(parsed).map(verifyEntryIntegrity);
|
||||
const demoEntriesDetected = countDemoEntries(reports, parsed);
|
||||
const profiles = readProfiles().map(normalizeProfile);
|
||||
const profiles = readProfiles(demoMode).map(normalizeProfile);
|
||||
const fingerprint = makeFingerprint(reports);
|
||||
const target = resolveImportTarget(reports, profiles, targetProfileId);
|
||||
if (target.action === "conflict_requires_manual_resolution") {
|
||||
@ -141,7 +161,7 @@ export function importExportPayload(jsonText: string, displayName: string, targe
|
||||
evaluator_notes: []
|
||||
};
|
||||
|
||||
updateEvaluatorProfile(profile);
|
||||
updateEvaluatorProfile(profile, demoMode);
|
||||
return {
|
||||
profile,
|
||||
added,
|
||||
@ -154,25 +174,26 @@ export function importExportPayload(jsonText: string, displayName: string, targe
|
||||
};
|
||||
}
|
||||
|
||||
export function exportEvaluatorBackup() {
|
||||
export function exportEvaluatorBackup(demoMode = false) {
|
||||
return JSON.stringify(
|
||||
{
|
||||
backup_type: "arbeidsevne-veilederdata",
|
||||
schema_version: 1,
|
||||
exported_at: new Date().toISOString(),
|
||||
profiles: listEvaluatorProfiles()
|
||||
data_mode: demoMode ? "demo" : "normal",
|
||||
profiles: listEvaluatorProfiles(demoMode)
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
export function importEvaluatorBackup(jsonText: string) {
|
||||
export function importEvaluatorBackup(jsonText: string, demoMode = false) {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
if (parsed?.backup_type !== "arbeidsevne-veilederdata" || !Array.isArray(parsed.profiles)) {
|
||||
throw new Error("Filen er ikke en gyldig veilederbackup.");
|
||||
}
|
||||
const existing = readProfiles();
|
||||
const existing = readProfiles(demoMode);
|
||||
const merged = [...existing];
|
||||
for (const profile of parsed.profiles as EvaluatorProfile[]) {
|
||||
const index = merged.findIndex((item) => item.id === profile.id);
|
||||
@ -186,7 +207,7 @@ export function importEvaluatorBackup(jsonText: string) {
|
||||
merged.push(normalizeProfile(profile));
|
||||
}
|
||||
}
|
||||
writeProfiles(merged);
|
||||
writeProfiles(merged, demoMode);
|
||||
}
|
||||
|
||||
export function extractReports(payload: ExportPayload | StoredEntry[] | { reports?: StoredEntry[] }) {
|
||||
|
||||
24
src/i18n.tsx
24
src/i18n.tsx
@ -334,7 +334,7 @@ const en: TranslationTree = {
|
||||
notCalculated: "Not calculated",
|
||||
unknownWorkplace: "Unknown workplace"
|
||||
},
|
||||
app: { eyebrow: "Local self-assessment", title: "Arbeidspuls", evaluator: "Guide", navLabel: "Main navigation" },
|
||||
app: { eyebrow: "Local self-assessment", title: "Arbeidspuls", evaluator: "Advisor", navLabel: "Main navigation" },
|
||||
nav: { register: "Register", followup: "Follow-up", overview: "Overview", privacy: "Privacy" },
|
||||
demo: {
|
||||
enter: "Demo",
|
||||
@ -442,9 +442,9 @@ const en: TranslationTree = {
|
||||
backupTitle: "Backup and device changes",
|
||||
backupBody:
|
||||
"Because the data is stored locally, it may be lost if you change device, delete browser data, use another browser or reset the device. Export a JSON file regularly if you want to keep the reports.",
|
||||
sharingTitle: "Sharing with a guide",
|
||||
sharingTitle: "Sharing with an advisor",
|
||||
sharingBody:
|
||||
"If a guide or another person will review the reports, export a JSON file and share it with them, for example by email. The recipient can import the JSON file in the guide view.",
|
||||
"If an advisor or another person will review the reports, export a JSON file and share it with them, for example by email. The recipient can import the JSON file in the advisor view.",
|
||||
sensitiveTitle: "Sensitive information",
|
||||
sensitiveBody:
|
||||
"Reports may contain health and function-related information. Share the JSON file in a way suitable for sensitive information, and only with people who should have access.",
|
||||
@ -452,8 +452,8 @@ const en: TranslationTree = {
|
||||
deleteAllName: "all local reports"
|
||||
},
|
||||
evaluator: {
|
||||
eyebrow: "Guide",
|
||||
title: "Guide review",
|
||||
eyebrow: "Advisor",
|
||||
title: "Advisor review",
|
||||
backToApp: "Back to app",
|
||||
importJson: "Import JSON",
|
||||
importHelp: "Choose an export from the app. Data is stored locally in this browser.",
|
||||
@ -462,14 +462,14 @@ const en: TranslationTree = {
|
||||
connectExisting: "Connect to existing person",
|
||||
autoMatch: "Create new or use automatic match",
|
||||
chooseJson: "Choose JSON file",
|
||||
exportBackup: "Export guide data",
|
||||
importBackup: "Import guide backup",
|
||||
importedBackup: "Guide backup has been imported.",
|
||||
exportBackup: "Export advisor data",
|
||||
importBackup: "Import advisor backup",
|
||||
importedBackup: "Advisor backup has been imported.",
|
||||
demoDataWarning: "This import contains demo data. Do not use it as real user or health information.",
|
||||
importStart: "Import a JSON file to start reviewing.",
|
||||
saveName: "Save name",
|
||||
deletePerson: "Delete user and reports",
|
||||
deletePersonTitle: "Deletes this user, imported reports, follow-ups and guide notes after confirmation.",
|
||||
deletePersonTitle: "Deletes this user, imported reports, follow-ups and advisor notes after confirmation.",
|
||||
reportCount: "{count} reports",
|
||||
personNote: "General note for user",
|
||||
personNoteHelp: "This note applies to the person as a whole, not one specific report.",
|
||||
@ -486,15 +486,15 @@ const en: TranslationTree = {
|
||||
closeDetails: "Close details",
|
||||
reportNote: "Report note",
|
||||
followupNote: "Follow-up note",
|
||||
noteHelp: "The guide note is stored locally and does not change imported report data.",
|
||||
noteHelp: "The advisor note is stored locally and does not change imported report data.",
|
||||
deleteNote: "Delete note",
|
||||
validationError: "Validation error",
|
||||
comment: "Comment",
|
||||
evaluatorNote: "Guide note",
|
||||
evaluatorNote: "Advisor note",
|
||||
followupNoteBadge: "Follow-up note",
|
||||
hasFollowup: "Has follow-up",
|
||||
hasComment: "Has user comment",
|
||||
hasEvaluatorNote: "Has guide note",
|
||||
hasEvaluatorNote: "Has advisor note",
|
||||
scoreValues: "Score values"
|
||||
},
|
||||
details: {
|
||||
|
||||
@ -51,14 +51,6 @@ button {
|
||||
padding: 10px 2px 14px;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.topbar h1,
|
||||
.field-head h2,
|
||||
.filters h2,
|
||||
@ -688,47 +680,6 @@ textarea {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-link.active {
|
||||
background: #235b5e;
|
||||
border-color: #235b5e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.demo-banner {
|
||||
align-items: center;
|
||||
background: #1f4f51;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 12px 30px rgba(31, 79, 81, 0.22);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 950;
|
||||
gap: 0.5rem;
|
||||
letter-spacing: 0;
|
||||
padding: 0.42rem 0.46rem 0.42rem 0.74rem;
|
||||
position: fixed;
|
||||
right: max(12px, env(safe-area-inset-right));
|
||||
top: max(12px, env(safe-area-inset-top));
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.demo-banner button {
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 1px solid rgba(255, 255, 255, 0.42);
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font: inherit;
|
||||
height: 1.35rem;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
width: 1.35rem;
|
||||
}
|
||||
|
||||
.demo-meta-pill {
|
||||
background: #fbecd7;
|
||||
color: #7d4118;
|
||||
@ -1633,7 +1584,10 @@ textarea {
|
||||
border: 1px solid #ccd7d4;
|
||||
border-radius: 999px;
|
||||
color: #235b5e;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font: inherit;
|
||||
font-weight: 850;
|
||||
gap: 0.4rem;
|
||||
justify-content: center;
|
||||
padding: 0.44rem 0.7rem;
|
||||
@ -1653,6 +1607,19 @@ textarea {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.footer-link.active {
|
||||
background: #235b5e;
|
||||
border-color: #235b5e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.footer-link.active:hover,
|
||||
.footer-link.active:focus-visible {
|
||||
background: #1f4f51;
|
||||
border-color: #1f4f51;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-footer svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user