@@ -25169,9 +25170,6 @@
${formatProfileOwnerBadgeMarkup(format)}
${escapeHtml(format.formatType || "Format")}
- ${canEditFormat(format)
- ? ``
- : formatParticipationStatusBadgeMarkup(participant.invitationStatus)}
@@ -25255,12 +25253,6 @@
-
@@ -25316,20 +25308,15 @@
if (!canEditCareObject(contact)) return;
const data = new FormData(event.currentTarget);
const formatId = String(data.get("formatId") || "");
- const invitationStatus = String(data.get("invitationStatus") || "Kandidat");
if (!formatId || !contact.id) return;
const format = formats.find((item) => item.id === formatId);
if (!canEditFormat(format)) {
setProfileFormatFeedback("Das Format ist nicht mehr bearbeitbar. Lade die Ansicht neu und prüfe den Archivstatus.", "error");
return;
}
- if (["Eingeladen", "Zugesagt", "Teilgenommen"].includes(invitationStatus) && mitmachenConsentAvailability(contact).key !== "available") {
- setProfileFormatFeedback("Der Kontakt kann als Kandidat verknüpft werden. Für eine Einladung fehlt eine wirksame #Mitmachen-Einwilligung.", "error");
- return;
- }
try {
event.currentTarget.setAttribute("aria-busy", "true");
- const updated = await window.dataService.addFormatParticipant(formatId, contact.id, { invitationStatus });
+ const updated = await window.dataService.addFormatParticipant(formatId, contact.id, { invitationStatus: "Kandidat" });
replaceFormat(updated);
setStorageStatus("Kontakt zum Format hinzugefügt");
detailFormatLinkOpen = false;
@@ -25340,35 +25327,6 @@
setProfileFormatFeedback(`Format konnte nicht verknüpft werden: ${error?.message || "Bitte prüfe Berechtigung und Verbindung."}`, "error");
}
});
- root.querySelectorAll("[data-format-profile-status]").forEach((select) => {
- select.addEventListener("change", async () => {
- const format = formats.find((item) => item.id === select.dataset.formatProfileStatus);
- if (!canEditFormat(format)) return;
- const participant = (format?.participants || []).find((item) => (item.contactId || item.contact_id) === select.dataset.contactId);
- if (["Eingeladen", "Zugesagt", "Teilgenommen"].includes(select.value) && mitmachenConsentAvailability(contact).key !== "available") {
- select.value = participant?.invitationStatus || "Kandidat";
- setProfileFormatFeedback("Status nicht geändert: Vor einer Einladung muss eine wirksame #Mitmachen-Einwilligung dokumentiert sein.", "error");
- return;
- }
- select.disabled = true;
- try {
- const updated = await window.dataService.updateFormatParticipant(
- select.dataset.formatProfileStatus,
- select.dataset.contactId,
- { invitationStatus: select.value },
- participant?.updatedAt || participant?.updated_at || ""
- );
- replaceFormat(updated);
- setStorageStatus("Beteiligungsstatus aktualisiert");
- rerender();
- } catch (error) {
- console.error("Beteiligungsstatus konnte nicht gespeichert werden.", error);
- select.disabled = false;
- select.value = participant?.invitationStatus || "Kandidat";
- setProfileFormatFeedback(`Beteiligungsstatus nicht gespeichert: ${error?.message || "Bitte versuche es erneut."}`, "error");
- }
- });
- });
root.querySelectorAll("[data-format-profile-action]").forEach((button) => {
button.addEventListener("click", async () => {
const action = button.dataset.formatProfileAction;
@@ -25885,18 +25843,6 @@
${canEditFormat(format) ? `` : ""}
-
`;
}
@@ -32917,7 +32863,7 @@
return `${categoryButtons}
${ownerSelect}`;
}
- function renderHistoryTimeline(contactId, changes = []) {
+ function renderHistoryTimeline(contactId, changes = [], root = document) {
const activeFilter = contactHistoryFilters.get(contactId) || "all";
const activeOwner = contactHistoryOwnerFilters.get(contactId) || "all";
const filteredActivities = changes.filter((change) => {
@@ -32928,19 +32874,19 @@
return matchesCategory && matchesOwner;
});
const groups = groupHistoryChanges(filteredActivities);
- const timeline = document.getElementById("history-timeline");
- const filters = document.getElementById("history-filters");
+ const timeline = root.querySelector("#history-timeline");
+ const filters = root.querySelector("#history-filters");
if (filters) filters.innerHTML = renderHistoryFilters(contactId, changes);
const bindHistoryControls = () => {
filters?.querySelectorAll("[data-history-filter]").forEach((button) => {
button.addEventListener("click", () => {
contactHistoryFilters.set(contactId, button.dataset.historyFilter || "all");
- renderHistoryTimeline(contactId, changes);
+ renderHistoryTimeline(contactId, changes, root);
});
});
filters?.querySelector("#history-owner-filter")?.addEventListener("change", (event) => {
contactHistoryOwnerFilters.set(contactId, event.target.value || "all");
- renderHistoryTimeline(contactId, changes);
+ renderHistoryTimeline(contactId, changes, root);
});
timeline?.querySelectorAll("[data-activity-details-toggle]").forEach((button) => {
button.addEventListener("click", () => toggleActivityDetails(button));
@@ -32962,25 +32908,33 @@
bindHistoryControls();
}
- async function loadContactHistory(contactId) {
- const timeline = document.getElementById("history-timeline");
+ async function loadContactHistory(contactId, root = document) {
+ const timeline = root.querySelector("#history-timeline");
if (!timeline) return;
+ const requestToken = {};
+ contactHistoryRequests.set(root, requestToken);
+ const requestIsCurrent = () => contactHistoryRequests.get(root) === requestToken;
if (!window.dataService?.getContactChanges) {
- renderHistoryTimeline(contactId, []);
+ renderHistoryTimeline(contactId, [], root);
return;
}
if (contactHistoryCache.has(contactId)) {
- renderHistoryTimeline(contactId, contactHistoryCache.get(contactId));
+ renderHistoryTimeline(contactId, contactHistoryCache.get(contactId), root);
return;
}
timeline.innerHTML = `Verlauf wird geladen...
`;
try {
const changes = await window.dataService.getContactChanges(contactId);
contactHistoryCache.set(contactId, changes);
- renderHistoryTimeline(contactId, changes);
+ if (!requestIsCurrent()) return;
+ renderHistoryTimeline(contactId, changes, root);
} catch (error) {
console.error("Änderungsverlauf konnte nicht geladen werden.", error);
- timeline.innerHTML = `Der Änderungsverlauf konnte nicht geladen werden. Bitte prüfe Anmeldung, Rolle und Verbindung.
`;
+ if (!requestIsCurrent()) return;
+ const currentTimeline = root.querySelector("#history-timeline");
+ if (currentTimeline) {
+ currentTimeline.innerHTML = `Der Änderungsverlauf konnte nicht geladen werden. Bitte prüfe Anmeldung, Rolle und Verbindung.
`;
+ }
}
}
@@ -35351,6 +35305,24 @@
if (isMobileLayout()) setMobileSidebarExpanded(false);
}
+ function openSidebarForHomeDestination() {
+ if (!isMobileLayout()) {
+ setSidebarCollapsed(false, { persist: false });
+ return;
+ }
+ window.setTimeout(() => {
+ if (!isMobileLayout()) {
+ setSidebarCollapsed(false, { persist: false });
+ return;
+ }
+ setMobileSidebarExpanded(true);
+ window.requestAnimationFrame(() => {
+ const activeItem = document.querySelector(".sidebar-nav .primary-tab.is-active");
+ activeItem?.focus({ preventScroll: true });
+ });
+ }, 0);
+ }
+
function routeTokenForView(view) {
const routeView = onboardingActive && view !== "onboarding" ? "onboarding" : view || activeView || "home";
if (routeView === "personProfile") return personProfileRoute();
@@ -36825,7 +36797,7 @@
}
if (!expertScope) bindFavoriteContactButtons(targetPanel);
refreshCustomSelects(targetPanel);
- if (!expertScope) loadContactHistory(contact.id);
+ if (!expertScope) loadContactHistory(contact.id, targetPanel);
window.requestAnimationFrame(() => {
targetPanel.querySelector('.detail-tabs [role="tab"].is-active')?.scrollIntoView({ block: "nearest", inline: "nearest" });
});
@@ -38269,6 +38241,7 @@
});
routeLinks.forEach((link) => {
const routeToken = link.dataset.routeLink || "home";
+ const opensSidebarForOrientation = link.matches(".home-destination-link[data-home-module]");
if (APP_ROUTES?.urlForRouteToken) {
link.setAttribute("href", APP_ROUTES.urlForRouteToken(routeToken, { search: window.location.search }));
}
@@ -38290,6 +38263,7 @@
setActiveView(targetView);
updateRouteHash(routeToken);
updateView();
+ if (opensSidebarForOrientation) openSidebarForHomeDestination();
});
});
viewTabs.forEach((tab) => {
diff --git a/frontend/data/demo-api.js b/frontend/data/demo-api.js
index 0805844..241b3f5 100644
--- a/frontend/data/demo-api.js
+++ b/frontend/data/demo-api.js
@@ -13,6 +13,7 @@
if (CONFIG.dataMode !== "demo" || CONFIG.authMode !== "anonymous-demo") return;
const OWNER_ONLY_CONTACT_CHANNELS = CONFIG.capabilities?.ownerOnlyContactChannels === true;
+ const ALL_DEMO_CONTACTS_INVITABLE = CONFIG.capabilities?.allDemoContactsInvitable === true;
const NOW = "2026-07-19T12:00:00.000Z";
const DEMO_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
const SENSITIVE_CONTACT_FIELDS = new Set(["email", "phone"]);
@@ -27,24 +28,7 @@
]);
const FORMAT_PARTICIPANT_BATCH_LIMIT = 500;
const FORMAT_IDEMPOTENCY_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
- const DEMO_NOTICE_DATA_VIEWS = new Set([
- "map",
- "contacts",
- "organizations",
- "activities",
- "analytics",
- "quality",
- "experts",
- "patients",
- "stakeholders",
- "framework",
- "hospitations",
- "questionnaire",
- "formats",
- "team",
- "personProfile",
- "organizationProfile"
- ]);
+ const DIRECTLY_INVITABLE_MITMACHEN_SOURCES = new Set(["online_form", "email", "written"]);
const baseline = window.VERSORGUNGS_COMPASS_DEMO_DATA || {};
const originalFetch = window.fetch.bind(window);
let idCounter = 0;
@@ -70,6 +54,47 @@
|| activeProfiles[0]?.id
|| "demo-profile-admin";
state.contacts ||= [];
+ if (ALL_DEMO_CONTACTS_INVITABLE) {
+ const profileIds = new Set(state.profiles.map((profile) => profile.id).filter(Boolean));
+ state.contacts = state.contacts.map((contact) => {
+ const effectiveAt = String(contact.mitmachenConsentEffectiveAt || "").trim();
+ const effectiveTime = new Date(effectiveAt).getTime();
+ const source = String(contact.mitmachenConsentSource || "").trim();
+ const recordedByCandidates = [
+ contact.mitmachenConsentRecordedBy,
+ contact.ownerId,
+ ...(Array.isArray(contact.ownerIds) ? contact.ownerIds : []),
+ "demo-profile-admin"
+ ];
+ const recordedBy = recordedByCandidates.find((candidate) => profileIds.has(candidate))
+ || state.profiles[0]?.id
+ || "demo-profile-admin";
+ const alreadyDirectlyInvitable =
+ contact.mitmachenConsentStatus === "granted"
+ && DIRECTLY_INVITABLE_MITMACHEN_SOURCES.has(source)
+ && Number.isFinite(effectiveTime)
+ && effectiveTime <= new Date(NOW).getTime()
+ && profileIds.has(contact.mitmachenConsentRecordedBy);
+ const wasArchived = ["archived", "Archiviert"].includes(contact.status);
+ return {
+ ...contact,
+ status: wasArchived ? "active" : contact.status,
+ note: wasArchived
+ ? "Synthetischer Demo-Kontakt für öffentliche Format-Einladungen."
+ : contact.note,
+ mitmachenConsentStatus: "granted",
+ mitmachenConsentEffectiveAt: Number.isFinite(effectiveTime) && effectiveTime <= new Date(NOW).getTime()
+ ? effectiveAt
+ : NOW,
+ mitmachenConsentSource: DIRECTLY_INVITABLE_MITMACHEN_SOURCES.has(source) ? source : "written",
+ mitmachenConsentTextVersion: contact.mitmachenConsentTextVersion || "mitmachen-kontakt-v2",
+ mitmachenConsentRecordedBy: recordedBy,
+ mitmachenConsentNote: alreadyDirectlyInvitable && contact.mitmachenConsentNote
+ ? contact.mitmachenConsentNote
+ : "Vollständig dokumentierte, rein synthetische #Mitmachen-Einwilligung für Einladungen zu Formaten."
+ };
+ });
+ }
state.organizations ||= [];
state.organizationPrimarySystems = state.organizations.flatMap((organization) => organization.primarySystems || []);
state.expertGroups ||= [];
@@ -1735,119 +1760,6 @@
return handleDemoApi(url, method, body, ifMatch);
};
- function installDemoNotice() {
- if (!document.body || document.getElementById("vk-public-demo-notice")) return;
- const style = document.createElement("style");
- style.textContent = `
- #vk-public-demo-notice {
- position: fixed; z-index: 70;
- top: calc(14px + env(safe-area-inset-top, 0px)); right: calc(16px + env(safe-area-inset-right, 0px));
- min-height: 38px; box-sizing: border-box;
- display: flex; gap: 10px; align-items: center;
- padding: 4px 5px 4px 11px; border: 1px solid #d8e1ef; border-radius: 11px;
- color: #64748b; background: rgba(255,255,255,.96); box-shadow: 0 7px 18px rgba(16,35,110,.08);
- font: 620 11px/1.25 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- backdrop-filter: blur(12px);
- }
- #vk-public-demo-notice[hidden], #vk-public-demo-trigger[hidden] { display: none; }
- #vk-public-demo-notice .vk-demo-copy { min-width: 0; display: flex; gap: 4px; align-items: center; }
- #vk-public-demo-notice strong { color: #475569; font-size: 11px; font-weight: 760; }
- #vk-public-demo-notice button {
- min-width: 38px; min-height: 30px; padding: 0 9px; border: 0; border-radius: 8px;
- color: #17275f; background: #eef3fb; font: inherit; font-weight: 760; cursor: pointer;
- }
- #vk-public-demo-notice button:hover, #vk-public-demo-notice button:focus-visible {
- background: #e2eafb; outline: 3px solid #155fe4; outline-offset: 2px;
- }
- #vk-public-demo-trigger {
- position: fixed; z-index: 70;
- top: calc(12px + env(safe-area-inset-top, 0px)); right: calc(12px + env(safe-area-inset-right, 0px));
- width: 38px; height: 38px; display: inline-grid; place-items: center;
- padding: 0; border: 1px solid #c9d8ef; border-radius: 11px;
- color: #1555a5; background: #eaf2ff; box-shadow: 0 7px 18px rgba(16,35,110,.08);
- backdrop-filter: blur(12px); cursor: pointer;
- }
- #vk-public-demo-trigger:hover, #vk-public-demo-trigger:focus-visible {
- background: #dceaff; outline: 3px solid #155fe4; outline-offset: 2px;
- }
- #vk-public-demo-trigger .vk-demo-trigger-mark {
- display: grid; place-items: center; width: 100%; height: 100%;
- color: inherit; background: transparent;
- }
- #vk-public-demo-trigger svg {
- width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2;
- stroke-linecap: round; stroke-linejoin: round;
- }
- @media (max-width: 620px) {
- #vk-public-demo-notice {
- top: calc(10px + env(safe-area-inset-top, 0px)); right: calc(10px + env(safe-area-inset-right, 0px));
- }
- }
- `;
- document.head.appendChild(style);
- const notice = document.createElement("aside");
- notice.id = "vk-public-demo-notice";
- notice.setAttribute("role", "note");
- notice.setAttribute("aria-label", "Hinweis zur öffentlichen Demo");
- notice.hidden = true;
- notice.innerHTML = `
- Hinweis: Öffentliche Demo
-
- `;
- const closeButton = notice.querySelector("[data-demo-notice-close]");
- const trigger = document.createElement("button");
- trigger.id = "vk-public-demo-trigger";
- trigger.type = "button";
- trigger.hidden = true;
- trigger.setAttribute("aria-label", "Hinweis zur öffentlichen Demo anzeigen");
- trigger.setAttribute("aria-controls", notice.id);
- trigger.setAttribute("aria-expanded", "false");
- trigger.innerHTML = `
-
-
-
- `;
- let noticeCollapsed = false;
- const appShell = document.querySelector(".app-shell");
- const mobileViewport = window.matchMedia("(max-width: 760px)");
- const syncNoticeVisibility = () => {
- const activeView = appShell?.dataset.activeView || "";
- const mobileNavigationOpen =
- mobileViewport.matches &&
- appShell?.classList.contains("is-mobile-sidebar-expanded");
- const eligible =
- DEMO_NOTICE_DATA_VIEWS.has(activeView) &&
- !mobileNavigationOpen;
- notice.hidden = !eligible || noticeCollapsed;
- trigger.hidden = !eligible || !noticeCollapsed;
- trigger.setAttribute("aria-expanded", eligible && !noticeCollapsed ? "true" : "false");
- };
- closeButton.addEventListener("click", () => {
- noticeCollapsed = true;
- syncNoticeVisibility();
- if (!trigger.hidden) trigger.focus({ preventScroll: true });
- });
- trigger.addEventListener("click", () => {
- noticeCollapsed = false;
- syncNoticeVisibility();
- if (!notice.hidden) closeButton.focus({ preventScroll: true });
- });
- (document.querySelector(".app-main") || document.body).prepend(notice);
- document.body.appendChild(trigger);
- if (appShell) {
- new MutationObserver(syncNoticeVisibility).observe(appShell, {
- attributes: true,
- attributeFilter: ["data-active-view", "class"]
- });
- }
- mobileViewport.addEventListener?.("change", syncNoticeVisibility);
- syncNoticeVisibility();
- }
-
window.VERSORGUNGS_COMPASS_DEMO_RUNTIME = Object.freeze({
publicDemo: true,
persistence: "memory-only",
@@ -1866,6 +1778,4 @@
}
});
- if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", installDemoNotice, { once: true });
- else installDemoNotice();
})();
diff --git a/scripts/audit_public_assets.mjs b/scripts/audit_public_assets.mjs
index 0c2fab1..a309263 100644
--- a/scripts/audit_public_assets.mjs
+++ b/scripts/audit_public_assets.mjs
@@ -177,6 +177,7 @@ if (existsSync(runtimeConfigPath)) {
assert(/requireApiGateway:\s*false/.test(runtimeConfig), `${artifactLabel}/data/runtime-config.js fordert unerwartet ein API-Gateway`);
assert(/cleanUrls:\s*false/.test(runtimeConfig), `${artifactLabel}/data/runtime-config.js darf ohne Rewrite-Server keine Clean URLs aktivieren`);
assert(/ownerOnlyContactChannels:\s*true/.test(runtimeConfig), `${artifactLabel}/data/runtime-config.js aktiviert den Owner-Schutz fuer Kontaktkanaele nicht`);
+ assert(/allDemoContactsInvitable:\s*true/.test(runtimeConfig), `${artifactLabel}/data/runtime-config.js gibt nicht alle Demo-Bestandskontakte fuer Einladungen frei`);
assert(!/apiBaseUrl:\s*["']https?:/i.test(runtimeConfig), `${artifactLabel}/data/runtime-config.js konfiguriert einen externen API-Zugriff`);
}
diff --git a/scripts/audit_target_assets.mjs b/scripts/audit_target_assets.mjs
index 12d57bb..b9e82d5 100644
--- a/scripts/audit_target_assets.mjs
+++ b/scripts/audit_target_assets.mjs
@@ -103,6 +103,7 @@ if (existsSync(configPath)) {
assert(/apiCredentials:\s*"include"/.test(config), `${artifactLabel}/data/runtime-config.js sendet keine geschuetzte Sitzung`);
assert(/authMode:\s*"(?:iap|oidc)"/.test(config), `${artifactLabel}/data/runtime-config.js verwendet keinen erlaubten signierten Auth-Modus`);
assert(!/ownerOnlyContactChannels:\s*true/.test(config), `${artifactLabel}/data/runtime-config.js darf den Pages-spezifischen Owner-Schutz nicht aktivieren`);
+ assert(!/allDemoContactsInvitable:\s*true/.test(config), `${artifactLabel}/data/runtime-config.js darf keine synthetische Demo-Einladungsfreigabe aktivieren`);
assert(!/supabaseUrl|supabaseAnonKey|registrationEndpoint/.test(config), `${artifactLabel}/data/runtime-config.js enthaelt direkte Supabase-Browserkonfiguration`);
}
diff --git a/scripts/build_static_frontend.sh b/scripts/build_static_frontend.sh
index d3c8680..2fb90cc 100755
--- a/scripts/build_static_frontend.sh
+++ b/scripts/build_static_frontend.sh
@@ -211,6 +211,7 @@ window.VERSORGUNGS_COMPASS_CONFIG = {
capabilities: {
contactRole: true,
contactConsent: true,
+ allDemoContactsInvitable: true,
ownerOnlyContactChannels: true,
organizationPrimarySystems: true,
registrationIntake: true,
diff --git a/scripts/test_demo_api.mjs b/scripts/test_demo_api.mjs
index cf9cde7..7d6c949 100644
--- a/scripts/test_demo_api.mjs
+++ b/scripts/test_demo_api.mjs
@@ -24,17 +24,13 @@ assert.match(demoApiSource, /resetOnReload:\s*true/, "Die Demo-Runtime muss den
assert.match(demoApiSource, /const\s+baseline\s*=\s*window\.VERSORGUNGS_COMPASS_DEMO_DATA/, "Die Demo-API muss ausschließlich den synthetischen Datensatz als Baseline verwenden.");
assert.match(demoApiSource, /const\s+state\s*=\s*clone\(baseline\)|const\s+state\s*=\s*clone\s*\(\s*baseline\s*\)/, "Die Demo-API muss ihre Baseline tief kopieren.");
assert.match(demoApiSource, /ownerOnlyContactChannels\s*===\s*true/, "Die Owner-Projektion muss explizit durch die Pages-Capability aktiviert werden.");
+assert.match(demoApiSource, /allDemoContactsInvitable\s*===\s*true/, "Die vollständige Demo-Einladungsfreigabe muss explizit durch die Pages-Capability aktiviert werden.");
assert.match(demoApiSource, /contactChannelAccess:\s*hasAccess\s*\?\s*["']owner["']\s*:\s*["']restricted["']/, "Der Demo-Adapter muss den Kontaktkanal-Zugriff explizit ausweisen.");
-assert.match(demoApiSource, /Hinweis:<\/strong>\s*Öffentliche Demo<\/span>/, "Der Demo-Hinweis muss die öffentliche Demo knapp als Hinweis benennen.");
-assert.doesNotMatch(demoApiSource, /Bitte keine echten Angaben eingeben/, "Der subtile Demo-Hinweis darf keinen zusätzlichen Warnsatz anzeigen.");
-assert.match(demoApiSource, /data-demo-notice-close>OK<\/button>/, "Der Demo-Hinweis muss sich mit einer knappen Bestätigung schließen lassen.");
-assert.match(demoApiSource, /id\s*=\s*["']vk-public-demo-trigger["']/, "Die geschlossene Demo-Leiste muss über einen schwebenden Trigger wieder erreichbar sein.");
-assert.match(demoApiSource, /