diff --git a/Client/Components/call_log.ts b/Client/Components/call_log.ts index 4621dfc..d8a46f6 100644 --- a/Client/Components/call_log.ts +++ b/Client/Components/call_log.ts @@ -10,6 +10,7 @@ import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt" import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse" import {iTag, fetchTags} from "../models/tag" import {showServerError} from "./auth/authmessages" +import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard" // Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden. const DAUER_UPDATE_INTERVALL = 1000 @@ -241,6 +242,26 @@ export const Call_Log: m.ClosureComponent = (vnode) => { let returnProjektName: string = "" // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). let breadcrumbs: iBreadcrumb[] = [] + // Route-Params für die Rückkehr zur Notizliste; als Closure an den + // DirtyMaskHelper übergeben (greift auf fromListe, returnProjektId, + // currentLog, breadcrumbs zu). + const backParams = (): any => { + const params: any = {} + if (fromListe) { + if (returnProjektId !== null) { + params.projekt_id = returnProjektId + params.projekt_name = returnProjektName + } + } else if (currentLog.projekt_id !== -1) { + params.projekt_id = currentLog.projekt_id + params.projekt_name = currentLog.projekt_name + } + const listBc = serializeBc(breadcrumbs.slice(0, -1)) + if (listBc) params.bc = listBc + return params + } + // DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate. + const guard = new DirtyMaskHelper("/calllogliste", backParams) /** * Formatiert verstrichene Sekunden als mm:ss. @@ -264,6 +285,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => { } // Breadcrumb-Kette aus Route-Param parsen. breadcrumbs = parseBc(routeParams && routeParams.bc) + guard.registerGuard() if (id !== -1) { // Gesprächsnotiz frisch vom Server holen. fetchCallLogById(id).then((result) => { @@ -307,6 +329,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => { }, onremove: (vnode) => { if (timerId != null) { window.clearInterval(timerId) } + guard.cleanup() }, view: (vnode) => { // kunden_id aus dem geladenen Projekt ableiten (fuer abhaengiges @@ -343,29 +366,6 @@ export const Call_Log: m.ClosureComponent = (vnode) => { }) const tagColors: Map = new Map(tagListe.map((t: iTag) => [t.id, t.color] as [number, string])) - const goBack = () => { - const params: any = {} - if (fromListe) { - // Rückkehr in den Listenkontext, aus dem die Maske - // geöffnet wurde. Ohne return_projekt_id ungefiltert. - if (returnProjektId !== null) { - params.projekt_id = returnProjektId - params.projekt_name = returnProjektName - } - } else if (currentLog.projekt_id !== -1) { - // Fallback: Maske wurde nicht aus der Liste geöffnet - // (z.B. addCallLog aus projekt.ts/kontaktadresse.ts). - params.projekt_id = currentLog.projekt_id - params.projekt_name = currentLog.projekt_name - } - // Breadcrumb-Kette ohne das letzte Glied (die Notizliste - // selbst) an die Liste durchreichen, damit diese die - // Vorgänger-Ansichten wieder als Leiste anzeigen kann. - const listBc = serializeBc(breadcrumbs.slice(0, -1)) - if (listBc) params.bc = listBc - m.route.set("/calllogliste", params) - } - const submit = (form: HTMLFormElement): boolean => { const formData = new FormData(form) const gespeichertesLog: iCallLog = { @@ -385,23 +385,21 @@ export const Call_Log: m.ClosureComponent = (vnode) => { tag_ids: formData.getAll("tag_ids").map((v) => parseInt(v as string)).filter((n) => !isNaN(n)) } if (currentLog.id === -1) { - createCallLogREST(gespeichertesLog).then(() => goBack()).catch((e) => showServerError(e)) + createCallLogREST(gespeichertesLog).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() }) } else { - updateCallLogREST(gespeichertesLog).then(() => goBack()).catch((e) => showServerError(e)) + updateCallLogREST(gespeichertesLog).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() }) } return true } - const cancel = (e: Event) => { - goBack() - } return m("div.w3-container.w3-auto.form-view-container", m("h1", currentLog.id === -1 ? "Neue Gesprächsnotiz anlegen" : "Gesprächsnotiz"), m(BreadcrumbBar, { breadcrumbs: breadcrumbs, - current: currentLog.id === -1 ? "Neue Gesprächsnotiz" : "Gesprächsnotiz" + current: currentLog.id === -1 ? "Neue Gesprächsnotiz" : "Gesprächsnotiz", + onnavigate: guardedNavigate } as iBreadcrumbBar), m("div", {style: {flex: '1', 'min-height': '0'}}, - m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [ + m(Form, guard.formAttrs(submit) as iForm, [ m("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id}), // Kunde und Projekt als Read-Only-Text, da voreingestellt. m("label", "Kunde", @@ -449,6 +447,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => { if (ka) { currentLog.name_ansprechpartner = ka.name currentLog.telefon = ka.telefon + guard.markDirty() } } } as iSelect)), @@ -463,7 +462,8 @@ export const Call_Log: m.ClosureComponent = (vnode) => { colors: tagColors, placeholder: "kein Tag", modalTitle: "Tags auswählen", - editTooltip: "Tags bearbeiten" + editTooltip: "Tags bearbeiten", + onchanged: () => { guard.markDirty() } } as iMultiSelect)), m("label", "Wiedervorlage Datum", m(Input, {name: "wiedervorlage_datum", value: currentLog.wiedervorlage_datum, type: "date"} as iInput)), m("label", m(Input, {name: "erledigt", type: "checkbox", value: currentLog.erledigt} as iInput), "Erledigt") diff --git a/Client/Components/kontakt.ts b/Client/Components/kontakt.ts index 5524d9f..d61ec53 100644 --- a/Client/Components/kontakt.ts +++ b/Client/Components/kontakt.ts @@ -6,6 +6,7 @@ import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from " import {iKontakt, fetchKontakte, fetchKontaktById, createKontaktREST, updateKontaktREST} from "../models/kontakt" import {iKunde, fetchKundeById} from "../models/kunde" import {showServerError} from "./auth/authmessages" +import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard" export const kontaktliste: m.ClosureComponent = (vnode) => { let treenodes: iTreenode[] = [] @@ -150,6 +151,17 @@ export const Kontakt: m.ClosureComponent = (vnode) => { } // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). let breadcrumbs: iBreadcrumb[] = [] + // Route-Params für die Rückkehr zur Kontaktliste; als Closure an den + // DirtyMaskHelper übergeben, da dieser die maskenspezifischen Variablen + // (currentKontakt, breadcrumbs) nicht kennt. + const backParams = (): any => { + const listBc = serializeBc(breadcrumbs.slice(0, -1)) + const params: any = {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name} + if (listBc) params.bc = listBc + return params + } + // DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate. + const guard = new DirtyMaskHelper("/kontaktliste", backParams) return { oninit: (vnode) => { @@ -157,6 +169,7 @@ export const Kontakt: m.ClosureComponent = (vnode) => { const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 // Breadcrumb-Kette aus Route-Param parsen. breadcrumbs = parseBc(routeParams && routeParams.bc) + guard.registerGuard() if (id !== -1) { // Kontaktdaten frisch vom Server holen, um zwischenzeitliche // Änderungen anderer Nutzer nicht zu überschreiben. @@ -170,15 +183,10 @@ export const Kontakt: m.ClosureComponent = (vnode) => { currentKontakt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : "" } }, + onremove: (vnode) => { + guard.cleanup() + }, view: (vnode) => { - // Rückkehr zur Kontaktliste; das bc ohne das letzte Glied (die - // Kontaktliste selbst) durchreichen. - const goBack = () => { - const listBc = serializeBc(breadcrumbs.slice(0, -1)) - const routeParams: any = {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name} - if (listBc) routeParams.bc = listBc - m.route.set("/kontaktliste", routeParams) - } const submit = (form: HTMLFormElement): boolean => { const formData = new FormData(form) const gespeicherterKontakt: iKontakt = { @@ -193,25 +201,23 @@ export const Kontakt: m.ClosureComponent = (vnode) => { notiz: (formData.get("notiz") as string) || "" } if (currentKontakt.id === -1) { - createKontaktREST(gespeicherterKontakt).then(() => goBack()).catch((e) => showServerError(e)) + createKontaktREST(gespeicherterKontakt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() }) } else { - updateKontaktREST(gespeicherterKontakt).then(() => goBack()).catch((e) => showServerError(e)) + updateKontaktREST(gespeicherterKontakt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() }) } return true } - const cancel = (e: Event) => { - goBack() - } return m("div.w3-container.w3-auto.form-view-container", m("h1", currentKontakt.id === -1 ? "Neuer Kontakt für " + currentKontakt.kunden_name : "Kontakt-Stammblatt"), m(BreadcrumbBar, { breadcrumbs: breadcrumbs, - current: currentKontakt.id === -1 ? "Neuer Kontakt" : "Kontakt-Stammblatt" + current: currentKontakt.id === -1 ? "Neuer Kontakt" : "Kontakt-Stammblatt", + onnavigate: guardedNavigate } as iBreadcrumbBar), m("div", {style: {flex: '1', 'min-height': '0'}}, - m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [ + m(Form, guard.formAttrs(submit) as iForm, [ m("input", {type: "hidden", name: "kunden_id", value: currentKontakt.kunden_id}), m("label", "Anrede", m(Input, {name: "anrede", value: currentKontakt.anrede, type: "text"} as iInput)), m("label", "Name", m(Input, {name: "name", value: currentKontakt.name, type: "text", required: true} as iInput)), diff --git a/Client/Components/kontaktadresse.ts b/Client/Components/kontaktadresse.ts index b55de44..35b52ff 100644 --- a/Client/Components/kontaktadresse.ts +++ b/Client/Components/kontaktadresse.ts @@ -6,6 +6,7 @@ import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from " import {iKontaktadresse, fetchKontaktadressen, fetchKontaktadresseById, createKontaktadresseREST, updateKontaktadresseREST} from "../models/kontaktadresse" import {iProjekt, fetchProjektById} from "../models/projekt" import {showServerError} from "./auth/authmessages" +import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard" export const kontaktadresseliste: m.ClosureComponent = (vnode) => { let treenodes: iTreenode[] = [] @@ -196,6 +197,16 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => { } // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). let breadcrumbs: iBreadcrumb[] = [] + // Route-Params für die Rückkehr zur Kontaktadressenliste; als Closure + // an den DirtyMaskHelper übergeben. + const backParams = (): any => { + const listBc = serializeBc(breadcrumbs.slice(0, -1)) + const params: any = {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name} + if (listBc) params.bc = listBc + return params + } + // DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate. + const guard = new DirtyMaskHelper("/kontaktadresseliste", backParams) return { oninit: (vnode) => { @@ -203,6 +214,7 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => { const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 // Breadcrumb-Kette aus Route-Param parsen. breadcrumbs = parseBc(routeParams && routeParams.bc) + guard.registerGuard() if (id !== -1) { // Kontaktadresse frisch vom Server holen. fetchKontaktadresseById(id).then((result) => { @@ -214,15 +226,10 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => { currentKontaktadresse.projekt_name = routeParams && routeParams.projekt_name ? routeParams.projekt_name : "" } }, + onremove: (vnode) => { + guard.cleanup() + }, view: (vnode) => { - // Rückkehr zur Kontaktadressenliste; das bc ohne das letzte - // Glied (die Kontaktadressenliste selbst) durchreichen. - const goBack = () => { - const listBc = serializeBc(breadcrumbs.slice(0, -1)) - const routeParams: any = {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name} - if (listBc) routeParams.bc = listBc - m.route.set("/kontaktadresseliste", routeParams) - } const submit = (form: HTMLFormElement): boolean => { const formData = new FormData(form) const gespeicherteKontaktadresse: iKontaktadresse = { @@ -242,15 +249,12 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => { notiz: (formData.get("notiz") as string) || "" } if (currentKontaktadresse.id === -1) { - createKontaktadresseREST(gespeicherteKontaktadresse).then(() => goBack()).catch((e) => showServerError(e)) + createKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() }) } else { - updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => goBack()).catch((e) => showServerError(e)) + updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() }) } return true } - const cancel = (e: Event) => { - goBack() - } return m("div.w3-container.w3-auto.form-view-container", m("h1", currentKontaktadresse.id === -1 ? "Neue Kontaktadresse für " + currentKontaktadresse.projekt_name @@ -259,10 +263,11 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => { breadcrumbs: breadcrumbs, current: currentKontaktadresse.id === -1 ? "Neue Kontaktadresse" - : "Kontaktadresse-Stammblatt" + : "Kontaktadresse-Stammblatt", + onnavigate: guardedNavigate } as iBreadcrumbBar), m("div", {style: {flex: '1', 'min-height': '0'}}, - m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [ + m(Form, guard.formAttrs(submit) as iForm, [ m("input", {type: "hidden", name: "projekt_id", value: currentKontaktadresse.projekt_id}), m("label", "Anrede", m(Input, {name: "anrede", value: currentKontaktadresse.anrede, type: "text"} as iInput)), m("label", "Name", m(Input, {name: "name", value: currentKontaktadresse.name, type: "text", required: true} as iInput)), diff --git a/Client/Components/kunde.ts b/Client/Components/kunde.ts index 26a94bb..f4e218c 100644 --- a/Client/Components/kunde.ts +++ b/Client/Components/kunde.ts @@ -5,6 +5,7 @@ import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb" import {iKunde, fetchKunden, fetchKundeById, createKundeREST, updateKundeREST} from "../models/kunde" import {showServerError} from "./auth/authmessages" +import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard" export const kunde: m.ClosureComponent = (vnode) => { let currentKunde: iKunde = { @@ -17,6 +18,8 @@ export const kunde: m.ClosureComponent = (vnode) => { } // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). let breadcrumbs: iBreadcrumb[] = [] + // DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate. + const guard = new DirtyMaskHelper("/kundenliste") return { oninit: (vnode) => { @@ -24,6 +27,7 @@ export const kunde: m.ClosureComponent = (vnode) => { const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 // Breadcrumb-Kette aus Route-Param parsen. breadcrumbs = parseBc(routeParams && routeParams.bc) + guard.registerGuard() if (id !== -1) { // Kundendaten frisch vom Server holen, um zwischenzeitliche // Änderungen anderer Nutzer nicht zu überschreiben. @@ -32,6 +36,9 @@ export const kunde: m.ClosureComponent = (vnode) => { }).catch((e) => showServerError(e)) } }, + onremove: (vnode) => { + guard.cleanup() + }, view: (vnode) => { const submit = (form: HTMLFormElement): boolean => { const formData = new FormData(form) @@ -44,30 +51,28 @@ export const kunde: m.ClosureComponent = (vnode) => { land: (formData.get("land") as string) || "" } if (currentKunde.id === -1) { - createKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste")).catch((e) => showServerError(e)) + createKundeREST(gespeicherterKunde).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() }) } else { - updateKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste")).catch((e) => showServerError(e)) + updateKundeREST(gespeicherterKunde).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() }) } return true } - const cancel = (e: Event) => { - m.route.set("/kundenliste") - } return m("div.w3-container.w3-auto.form-view-container", m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}}, m("h1", currentKunde.id === -1 ? "Neuen Kunden anlegen" : "Kundenstammblatt"), currentKunde.id !== -1 ? m("button.w3-button.w3-theme", - {onclick: () => m.route.set("/kontaktliste", {kunden_id: currentKunde.id})}, + {onclick: (e: MouseEvent) => guardedNavigate("/kontaktliste", {kunden_id: currentKunde.id}, e)}, m("i.material-icons", "contact_page"), " Kontakte") : null ), m(BreadcrumbBar, { breadcrumbs: breadcrumbs, - current: currentKunde.id === -1 ? "Neuer Kunde" : "Kundenstammblatt" + current: currentKunde.id === -1 ? "Neuer Kunde" : "Kundenstammblatt", + onnavigate: guardedNavigate } as iBreadcrumbBar), m("div", {style: {flex: '1', 'min-height': '0'}}, - m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [ + m(Form, guard.formAttrs(submit) as iForm, [ m("label", "Kundenname", m(Input, {name: "kundenname", value: currentKunde.name, type: "text", required: true} as iInput)), m("label", "Straße", m(Input, {name: "strasse", value: currentKunde.strasse, type: "text"} as iInput)), m("label", "PLZ", m(Input, {name: "plz", value: currentKunde.plz, type: "text"} as iInput)), diff --git a/Client/Components/projekt.ts b/Client/Components/projekt.ts index cd14e35..865de5a 100644 --- a/Client/Components/projekt.ts +++ b/Client/Components/projekt.ts @@ -6,6 +6,7 @@ import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from " import {iProjekt, fetchProjekte, fetchProjektById, createProjektREST, updateProjektREST} from "../models/projekt" import {iKunde, fetchKundeById} from "../models/kunde" import {showServerError} from "./auth/authmessages" +import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard" export const projektliste: m.ClosureComponent = (vnode) => { let treenodes: iTreenode[] = [] @@ -194,6 +195,16 @@ export const Projekt: m.ClosureComponent = (vnode) => { } // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). let breadcrumbs: iBreadcrumb[] = [] + // Route-Params für die Rückkehr zur Projektliste; als Closure an den + // DirtyMaskHelper übergeben. + const backParams = (): any => { + const listBc = serializeBc(breadcrumbs.slice(0, -1)) + const params: any = {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name} + if (listBc) params.bc = listBc + return params + } + // DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate. + const guard = new DirtyMaskHelper("/projektliste", backParams) return { oninit: (vnode) => { @@ -201,6 +212,7 @@ export const Projekt: m.ClosureComponent = (vnode) => { const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 // Breadcrumb-Kette aus Route-Param parsen. breadcrumbs = parseBc(routeParams && routeParams.bc) + guard.registerGuard() if (id !== -1) { // Projektdaten frisch vom Server holen, um zwischenzeitliche // Änderungen anderer Nutzer nicht zu überschreiben. @@ -214,6 +226,9 @@ export const Projekt: m.ClosureComponent = (vnode) => { currentProjekt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : "" } }, + onremove: (vnode) => { + guard.cleanup() + }, view: (vnode) => { const submit = (form: HTMLFormElement): boolean => { const formData = new FormData(form) @@ -226,24 +241,13 @@ export const Projekt: m.ClosureComponent = (vnode) => { verantwortlicher: (formData.get("verantwortlicher") as string) || "", startdatum: (formData.get("startdatum") as string) || "" } - // Bei Rückkehr zur Projektliste das bc ohne das letzte - // Glied (die Projektliste selbst) durchreichen. - const listBc = serializeBc(breadcrumbs.slice(0, -1)) - const routeParams: any = {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name} - if (listBc) routeParams.bc = listBc if (currentProjekt.id === -1) { - createProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", routeParams)).catch((e) => showServerError(e)) + createProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() }) } else { - updateProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", routeParams)).catch((e) => showServerError(e)) + updateProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() }) } return true } - const cancel = (e: Event) => { - const listBc = serializeBc(breadcrumbs.slice(0, -1)) - const routeParams: any = {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name} - if (listBc) routeParams.bc = listBc - m.route.set("/projektliste", routeParams) - } return m("div.w3-container.w3-auto.form-view-container", m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}}, m("h1", currentProjekt.id === -1 @@ -252,21 +256,22 @@ export const Projekt: m.ClosureComponent = (vnode) => { currentProjekt.id !== -1 ? [ m("button.w3-button.w3-theme", - {onclick: () => m.route.set("/kontaktadresseliste", {projekt_id: currentProjekt.id})}, + {onclick: (e: MouseEvent) => guardedNavigate("/kontaktadresseliste", {projekt_id: currentProjekt.id}, e)}, m("i.material-icons", "contact_page"), " Kontaktadressen"), m("button.w3-button.w3-theme", {style: {'margin-left': '4px'}, - onclick: () => m.route.set("/projektuserliste", {projekt_id: currentProjekt.id})}, + onclick: (e: MouseEvent) => guardedNavigate("/projektuserliste", {projekt_id: currentProjekt.id}, e)}, m("i.material-icons", "group"), " Nutzerberechtigungen") ] : null ), m(BreadcrumbBar, { breadcrumbs: breadcrumbs, - current: currentProjekt.id === -1 ? "Neues Projekt" : "Projekt-Stammblatt" + current: currentProjekt.id === -1 ? "Neues Projekt" : "Projekt-Stammblatt", + onnavigate: guardedNavigate } as iBreadcrumbBar), m("div", {style: {flex: '1', 'min-height': '0'}}, - m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [ + m(Form, guard.formAttrs(submit) as iForm, [ m("input", {type: "hidden", name: "kunden_id", value: currentProjekt.kunden_id}), m("label", "Projektname", m(Input, {name: "projektname", value: currentProjekt.projektname, type: "text", required: true} as iInput)), m("label", "Zweck", m(Input, {name: "zweck", value: currentProjekt.zweck, type: "text"} as iInput)), diff --git a/Client/Components/tags.ts b/Client/Components/tags.ts index 540eb81..1a40cc9 100644 --- a/Client/Components/tags.ts +++ b/Client/Components/tags.ts @@ -5,6 +5,7 @@ import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} import {ModalType, ModalView, iModalView} from "./widgets/Modal" import {gridMountpoints} from "./mainlayout" import {showServerError} from "./auth/authmessages" +import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard" import {iTag, iTagWithChildren, fetchTags, fetchTagHierarchy, fetchTagById, createTagREST, updateTagREST, deleteTagREST} from "../models/tag" const colorPalette: string[] = [ @@ -78,18 +79,22 @@ export const Tag: m.ClosureComponent = () => { is_parent: false, } - const setColor = (color: string) => { - currentTag.color = color - } - // Liste aller Parent-Tags fuer die Eltern-Auswahl (nur Parent-Tags // koennen uebergeordnet sein) let allParentTags: iTag[] = [] + // DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate. + const guard = new DirtyMaskHelper("/taglist") + + const setColor = (color: string) => { + currentTag.color = color + guard.markDirty() + } return { oninit: (vnode) => { const routeParams = m.route.param() const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 + guard.registerGuard() if (id !== -1) { // Bestehenden Tag frisch vom Server laden fetchTagById(id).then((result) => { @@ -113,6 +118,9 @@ export const Tag: m.ClosureComponent = () => { allParentTags = result.filter(t => t.id !== currentTag.id && t.is_parent) }).catch((e) => showServerError(e)) }, + onremove: (vnode) => { + guard.cleanup() + }, view: (vnode) => { const submit = (form: HTMLFormElement): boolean => { const formData = new FormData(form) @@ -125,19 +133,16 @@ export const Tag: m.ClosureComponent = () => { is_parent: currentTag.is_parent, } if (currentTag.id === -1) { - createTagREST(gespeicherterTag).then(() => m.route.set("/taglist")).catch((e) => showServerError(e)) + createTagREST(gespeicherterTag).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() }) } else { - updateTagREST(gespeicherterTag).then(() => m.route.set("/taglist")).catch((e) => showServerError(e)) + updateTagREST(gespeicherterTag).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() }) } return true } - const cancel = (e: Event) => { - m.route.set("/taglist") - } return m("div.w3-container.w3-auto.form-view-container", m("h1", currentTag.id === -1 ? "Neuen Tag erstellen" : "Tag bearbeiten"), m("div", {style: {flex: '1', 'min-height': '0'}}, - m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [ + m(Form, guard.formAttrs(submit) as iForm, [ m("label", "Tag-Name", m(Input, {name: "name", value: currentTag.name, type: "text", required: true, oninput: (e: Event) => { currentTag.name = (e.target as HTMLInputElement).value }} as iInput)), m("label", "Beschreibung", m(Input, {name: "description", value: currentTag.description, type: "text", oninput: (e: Event) => { currentTag.description = (e.target as HTMLInputElement).value }} as iInput)), @@ -151,6 +156,7 @@ export const Tag: m.ClosureComponent = () => { style: {'margin-right': '8px'}, onclick: (e: Event) => { currentTag.is_parent = (e.target as HTMLInputElement).checked + guard.markDirty() } }), "Nur zur Gruppierung, kann nicht zugeordnet werden" @@ -159,7 +165,11 @@ export const Tag: m.ClosureComponent = () => { // Eltern-Tag-Auswahl m("label", "Übergeordneter Tag (optional)"), - m("select.w3-select", {name: "parent_id", value: currentTag.parent_id || ""}, + m("select.w3-select", { + name: "parent_id", + value: currentTag.parent_id || "", + onchange: () => { guard.markDirty() } + }, m("option", {value: ""}, "-- Kein übergeordneter Tag --"), allParentTags.map(tag => m("option", {value: tag.id, selected: currentTag.parent_id === tag.id}, tag.name) diff --git a/Client/Components/widgets/breadcrumb.ts b/Client/Components/widgets/breadcrumb.ts index 1d69bf4..635a494 100644 --- a/Client/Components/widgets/breadcrumb.ts +++ b/Client/Components/widgets/breadcrumb.ts @@ -10,10 +10,14 @@ export interface iBreadcrumb { /** Attribute der BreadcrumbBar: die Vorgänger-Kette (ancestors) und das * Label der aktuellen Ansicht (wird als letztes, nicht klickbares Glied - * angezeigt). Ist breadcrumbs leer, wird keine Leiste gerendert. */ + * angezeigt). Ist breadcrumbs leer, wird keine Leiste gerendert. + * onnavigate (optional): Navigations-Callback, der statt m.route.set + * verwendet wird — typischerweise guardedNavigate aus dirtyguard.ts, + * so dass bei ungespeicherten Änderungen das Bestätigungs-Modal erscheint. */ export interface iBreadcrumbBar { breadcrumbs: iBreadcrumb[] current: string + onnavigate?: (route: string, params: any, e?: Event) => void } /** Parst den Route-Param `bc` (JSON-String) in ein Breadcrumb-Array. @@ -56,12 +60,20 @@ export const BreadcrumbBar: m.ClosureComponent = () => { 'white-space': 'nowrap' }, title: "Zurück zu " + crumb.label, - onclick: () => { + onclick: (e: MouseEvent) => { const subChain = crumbs.slice(0, i) const params: any = {...crumb.params} const bc = serializeBc(subChain) if (bc) params.bc = bc - m.route.set(crumb.route, params) + // Navigation über den Guard, so dass bei dirty-Maske + // das Bestätigungs-Modal erscheint. Event wird + // durchgereicht, damit guardedNavigate den auto-redraw + // unterbinden kann. + if (vnode.attrs.onnavigate) { + vnode.attrs.onnavigate(crumb.route, params, e) + } else { + m.route.set(crumb.route, params) + } } }, crumb.label)) }) diff --git a/Client/Components/widgets/dirtyguard.ts b/Client/Components/widgets/dirtyguard.ts new file mode 100644 index 0000000..5e2c4bb --- /dev/null +++ b/Client/Components/widgets/dirtyguard.ts @@ -0,0 +1,267 @@ +import m from "mithril" +import { gridMountpoints } from "../mainlayout" +import type { iForm } from "./form" + +/** Registrierungsschnittstelle für eine Bearbeitungsmaske. Die Maske + * registriert in oninit einen Guard und entfernt ihn in onremove wieder. + * So weiß der zentrale Navigations-Guard, ob die aktuelle Ansicht ungespeicherte + * Änderungen enthält und wie das Speichern auszulösen ist. */ +export interface DirtyGuard { + /** Liefert true, wenn die Maske ungespeicherte Änderungen enthält. */ + isDirty: () => boolean + /** Triggert das Speichern des Formulars (form.requestSubmit()). Bei + * Angabe einer Ziel-Route navigiert die Maske nach erfolgreichem Speichern + * dorthin statt zum Standard-Ziel. Die Maske muss dafür die Ziel-Route + * zwischenspeichern und im Erfolgs-.then der REST-Aufrufs auswerten. */ + save: (targetRoute?: string, targetParams?: any) => void +} + +// Aktiv registrierter Guard (max. einer, da nur eine Maske sichtbar ist). +let guard: DirtyGuard | null = null + +/** Wird von einer Bearbeitungsmaske in oninit aufgerufen, um den Guard + * zu aktivieren. */ +export function registerDirtyGuard(opts: DirtyGuard): void { + guard = opts +} + +/** Wird von einer Bearbeitungsmaske in onremove aufgerufen, um den Guard + * zu deaktivieren. Ein evtl. noch offenes Bestätigungs-Modal wird geschlossen. */ +export function clearDirtyGuard(): void { + guard = null + m.mount(gridMountpoints.modalhook, null) +} + +/** Liefert true, wenn aktuell ein Guard registriert ist. */ +export function hasDirtyGuard(): boolean { + return guard != null +} + +/** Zentraler Einstieg für alle App-internen Navigationen. Ist die aktive + * Maske dirty, wird das 3-Button-Bestätigungs-Modal geöffnet; andernfalls + * wird direkt navigiert. Alle m.route.set-Aufrufe, die aus einer + * Bearbeitungsmaske heraus führen, sollten diese Funktion verwenden. + * + * Der optionale Event-Parameter e wird bei dirty auf e.redraw = false + * gesetzt, damit Mithril keinen auto-redraw der Maske ausführt und die + * eingegebenen Formularwerte im DOM erhalten bleiben — das Input-Widget + * hat kein Live-Binding, ein Redraw würde die Felder mit den (noch + * unveränderten) Model-Werten überschreiben. */ +export function guardedNavigate(route: string, params?: any, e?: Event): void { + if (guard && guard.isDirty()) { + if (e) e['redraw'] = false + showDirtyConfirmModal(route, params) + return + } + m.route.set(route, params) +} + +/** Hilfsfunktion für Masken: triggert das Formular-Submit. Die Maske übergibt + * ihre Form-Referenz (über onformcreate) und einen Callback, der die + * Ziel-Route für die Navigation nach erfolgreichem Speichern zwischenspeichert. */ +export function submitForm(form: HTMLFormElement | null): void { + if (!form) return + if (typeof form.requestSubmit === "function") { + form.requestSubmit() + } else { + // Fallback für ältere Browser. + form.dispatchEvent(new Event("submit", {cancelable: true, bubbles: true})) + } +} + +// ---- 3-Button-Bestätigungs-Modal ---------------------------------------- + +interface iDirtyConfirmModal { + route: string + params: any +} + +const DirtyConfirmModal: m.Component = { + view: (vnode) => { + const close = () => { m.mount(gridMountpoints.modalhook, null) } + const onSave = (e: MouseEvent) => { + // Auto-Redraw unterbinden, damit die darunter liegende Maske + // nicht neu gerendert wird und die eingegebenen Formularwerte + // im DOM erhalten bleiben — das Input-Widget hat kein + // Live-Binding, ein Redraw würde die Felder mit den (noch + // unveränderten) Model-Werten überschreiben. Das Modal wird + // durch close() direkt per m.mount entfernt. + e['redraw'] = false + close() + if (guard) { + guard.save(vnode.attrs.route, vnode.attrs.params) + } + } + const onDiscard = (e: MouseEvent) => { + // Redraw unterbinden; m.route.set löst den Komponentenwechsel + // ohnehin aus. + e['redraw'] = false + close() + m.route.set(vnode.attrs.route, vnode.attrs.params) + } + const onCancel = (e: MouseEvent) => { + // Redraw unterbinden — die Maske bleibt unverändert, die + // Eingaben des Nutzers dürfen nicht überschrieben werden. + e['redraw'] = false + close() + } + return m("div.w3-modal", {style: {display: 'block'}}, + m("div.w3-modal-content.w3-card-4", {style: {width: '440px', top: '20%'}}, + m("header.w3-container.w3-theme", + m("h3", "Ungespeicherte Änderungen")), + m("div.w3-container.w3-theme-l1", + m("p", "Die Maske enthält ungespeicherte Änderungen."), + m("p", "Wie möchten Sie verfahren?")), + m("footer.w3-container.w3-theme-l1.w3-padding-16", + m("div.w3-bar", + m("button.w3-button.w3-theme.w3-right", + {style: {'margin-left': '4px'}, onclick: onDiscard}, + "Verwerfen"), + m("button.w3-button.w3-theme.w3-right", + {style: {'margin-left': '4px'}, onclick: onSave}, + "Speichern"), + m("button.w3-button.w3-theme-l2.w3-right", + {style: {'margin-left': '4px'}, onclick: onCancel}, + "Abbrechen") + ) + ) + ) + ) + } +} + +function showDirtyConfirmModal(route: string, params: any): void { + m.mount(gridMountpoints.modalhook, { + view: () => { return m(DirtyConfirmModal, {route: route, params: params} as iDirtyConfirmModal) } + }) +} + +// ---- DirtyMaskHelper: kapselt den isDirty/Guard-Boilerplate --------------- + +/** Hilfsklasse für Bearbeitungsmasken. Kapselt den gesamten isDirty/Guard- + * Boilerplate, der sonst in jeder Maske identisch wiederkehrt: Dirty-State, + * Form-Referenz, pendingTarget (Ziel-Route nach Speichern aus dem Modal), + * Guard-Registrierung/-Cleanup, navigateAfterSave, cancel und die + * Form-Attribute. + * + * Maskenspezifisch bleiben: die DTO-Erzeugung und create/update-Aufrufe im + * submit (die Maske ruft danach guard.navigateAfterSave() bzw. + * guard.markDirty() auf), die backParams()-Closure, In-Masken-Buttons und + * der oninit-Daten-Fetch. + * + * Verwendung in einer ClosureComponent: + * ```ts + * export const maske: m.ClosureComponent = (vnode) => { + * const guard = new DirtyMaskHelper("/zielroute", () => ({...backParams})) + * return { + * oninit: (v) => { guard.registerGuard(); // fetch ... + * }, + * onremove: (v) => { guard.cleanup() }, + * view: (v) => { + * const submit = (form) => { + * // DTO bauen, createX, guard.navigateAfterSave, guard.markDirty + * return true + * } + * return m(Form, guard.formAttrs(submit) as iForm, [...]) + * } + * } + * } + * ``` */ +export class DirtyMaskHelper { + private isDirty = false + private formRef: HTMLFormElement | null = null + private pendingTarget: {route: string, params?: any} | null = null + private readonly defaultRoute: string + private readonly backParams: () => any + + /** @param defaultRoute Standard-Ziel nach Save/Cancel (ohne pendingTarget). + * @param backParams liefert die Route-Params für das Standard-Ziel; + * optional, da einige Masken (z.B. kunde, tag) keine Params benötigen. */ + constructor(defaultRoute: string, backParams?: () => any) { + this.defaultRoute = defaultRoute + this.backParams = backParams || (() => ({})) + } + + /** In oninit aufrufen. Weist diese DirtyMaskHelper-Instanz der + * zentralen statischen Modul-Variablen `guard` (siehe + * registerDirtyGuard) zu, so dass alle Konsumenten — guardedNavigate, + * das 3-Button-Modal, hasDirtyGuard — über diesen einen Slot auf den + * Guard zugreifen, der zur gerade aktiven Maske "gehört". Die + * übergebenen Closures binden this lexikalisch an diese Instanz, so + * dass isDirty() und save() live auf den Instanz-State zugreifen. + * + * Registrierung bewusst nicht im Konstruktor: der Konstruktor läuft + * zum Erzeugungszeitpunkt der ClosureComponent, nicht zum + * Lebenszyklus-Start (oninit). Würde er registrieren, könnte eine + * neue Instanz den zentralen Slot überschreiben, bevor onremove der + * alten Maske aufgeräumt hat — die alte würde dann den Guard der + * neuen Instanz löschen. Mit registerGuard in oninit gilt: + * erst räumt die alte Maske auf (cleanup in onremove), dann + * registriert die neue. */ + registerGuard(): void { + registerDirtyGuard({isDirty: () => this.isDirty, save: this.save}) + } + + /** In onremove aufrufen — deregistriert den Guard und schließt ein + * evtl. offenes Bestätigungs-Modal. */ + cleanup(): void { + clearDirtyGuard() + } + + /** Als onDirtyChange an die Form übergeben (wird von der Form bei + * Eingabe-Änderungen aufgerufen). */ + onDirtyChange = (dirty: boolean) => { this.isDirty = dirty } + + /** Bei Custom-Widget-Änderungen aufrufen (Select onchanged etc.), die + * kein change/input-Event auf der Form auslösen. */ + markDirty = () => { this.isDirty = true } + + /** Als onformcreate an die Form übergeben (speichert die Form-Referenz + * für das programmtische Submit aus dem Bestätigungs-Modal). */ + setForm = (f: HTMLFormElement) => { this.formRef = f } + + /** Nach erfolgreichem Speichern aufrufen (im .then der REST-Calls). + * Gibt es ein pendingTarget (Speichern aus dem Bestätigungs-Modal), + * wird dorthin navigiert; sonst zum Standard-Ziel. */ + navigateAfterSave = () => { + if (this.pendingTarget) { + const t = this.pendingTarget + this.pendingTarget = null + m.route.set(t.route, t.params) + } else { + m.route.set(this.defaultRoute, this.backParams()) + } + } + + /** Als oncancel an die Form übergeben. Navigiert über den Guard, + * so dass bei dirty die Bestätigungs-Modal erscheint. Das Event + * wird durchgereicht, damit guardedNavigate den auto-redraw + * unterbinden kann. */ + cancel = (e?: Event) => { + guardedNavigate(this.defaultRoute, this.backParams(), e) + } + + /** Vom Navigations-Guard aufgerufen, wenn der Nutzer im Bestätigungs- + * Modal "Speichern" wählt. Speichert die Ziel-Route und triggert das + * Form-Submit; die Maske navigiert nach erfolgreichem Speichern über + * navigateAfterSave() dorthin. */ + private save = (targetRoute?: string, targetParams?: any) => { + this.pendingTarget = targetRoute ? {route: targetRoute, params: targetParams} : null + submitForm(this.formRef) + } + + /** Liefert das Form-Attribut-Objekt für m(Form, ...). Der onsubmit- + * Callback ist maskenspezifisch (DTO-Erzeugung + create/update) und + * muss von der Maske übergeben werden; alle anderen Attribute + * (showButtons, oncancel, onDirtyChange, onformcreate) werden von + * der Hilfsklasse gesetzt. */ + formAttrs(onsubmit: (form: HTMLFormElement) => boolean): Partial { + return { + showbuttons: true, + onsubmit: onsubmit, + oncancel: this.cancel, + onDirtyChange: this.onDirtyChange, + onformcreate: this.setForm + } + } +} diff --git a/Client/Components/widgets/form.ts b/Client/Components/widgets/form.ts index 5b701f8..b08036b 100644 --- a/Client/Components/widgets/form.ts +++ b/Client/Components/widgets/form.ts @@ -12,6 +12,13 @@ export interface iForm { stickyButtons?: boolean /** Die Funktion gibt das HTMLFormElement zurück, welches in der Form-Componente angelegt wird */ onformcreate?: (form:HTMLFormElement) => void + /** Callback bei Änderung des Dirty-State. Wird mit true aufgerufen, + * sobald der Nutzer Eingaben vornimmt, und mit false, nachdem das + * Formular erfolgreich gespeichert oder zurückgesetzt wurde. Die + * umgebende Maske nutzt dies, um ihren eigenen isDirty-State mit dem + * der Form synchron zu halten und so den Navigations-Guard + * (dirtyguard.ts) zu speisen. */ + onDirtyChange?: (dirty: boolean) => void } export const Form:m.ClosureComponent = (vnode) => { @@ -29,6 +36,9 @@ export const Form:m.ClosureComponent = (vnode) => { } function onchanged (e:Event) { isDirty = true + if (vnode.attrs.onDirtyChange != undefined) { + vnode.attrs.onDirtyChange(true) + } if (vnode.attrs.onchanged != undefined) { vnode.attrs.onchanged(e) } @@ -37,6 +47,9 @@ export const Form:m.ClosureComponent = (vnode) => { e.preventDefault() if (vnode.attrs.onsubmit(form) == true) { isDirty = false + if (vnode.attrs.onDirtyChange != undefined) { + vnode.attrs.onDirtyChange(false) + } } } function registerFormElement (f:HTMLFormElement) { @@ -46,22 +59,24 @@ export const Form:m.ClosureComponent = (vnode) => { } function cancel(e:Event) { e.preventDefault() - if (canCloseForm() == true) { - isDirty = false - if (vnode.attrs.oncancel != undefined) { - vnode.attrs.oncancel(e) - } - else { - history.back() - } + // Der Dirty-Check beim Abbrechen erfolgt jetzt durch den + // Navigations-Guard der Maske (dirtyguard.ts), da oncancel die + // Navigation auslöst. Die Form selbst fragt hier nicht mehr nach, + // um eine doppelte Bestätigung zu vermeiden. isDirty wird nicht + // zurückgesetzt, damit der Guard den aktuellen State sieht. + if (vnode.attrs.oncancel != undefined) { + vnode.attrs.oncancel(e) } else { - e['redraw'] = false + history.back() } } function reset(e:Event) { if (canCloseForm("reset")) { isDirty = false + if (vnode.attrs.onDirtyChange != undefined) { + vnode.attrs.onDirtyChange(false) + } // Hier passiert nichts. // Da mithril nach jedem User-Event (wie z.B. einem 'Click') ein redraw() ausführt, wird hier die Form // neu gezeichnet und zwar mit den ursprünglichen Daten aus den zu grundeliegenden Datenmodel, das ja @@ -109,6 +124,10 @@ export const Form:m.ClosureComponent = (vnode) => { for (let index = 0; index < elements.length; index++) { const element = elements[index]; element.addEventListener("change", onchanged) + // 'input' feuert schon während des Tippens (vor + // Blur), so dass die Maske dirty wird, auch wenn + // der Nutzer sofort eine Breadcrumb klickt. + element.addEventListener("input", onchanged) } registerFormElement(form) } @@ -131,6 +150,10 @@ export const Form:m.ClosureComponent = (vnode) => { for (let index = 0; index < elements.length; index++) { const element = elements[index]; element.addEventListener("change", onchanged) + // 'input' feuert schon während des Tippens (vor + // Blur), so dass die Maske dirty wird, auch wenn + // der Nutzer sofort eine Breadcrumb klickt. + element.addEventListener("input", onchanged) } registerFormElement(form) } diff --git a/Client/index.ts b/Client/index.ts index 7bd9272..2862a3f 100644 --- a/Client/index.ts +++ b/Client/index.ts @@ -11,6 +11,7 @@ import { Call_Log, call_log_liste } from "./Components/call_log" import { Kontakt, kontaktliste } from "./Components/kontakt" import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse" import { Tag, TagList } from "./Components/tags" +import { guardedNavigate } from "./Components/widgets/dirtyguard" function healthcheck () { @@ -118,21 +119,29 @@ const knopf: m.Component = { } const menu: m.ClosureComponent = () => { let showProjektBerechtigungen = false + // Menü-Eintrag, der die Navigation über den Dirty-Guard leitet, so dass + // aus einer Bearbeitungsmaske heraus ungespeicherte Änderungen abgefragt + // werden, bevor die Ansicht wechselt. + const menuItem = (route: string, label: string) => { + return m("a.w3-button.w3-bar-item", + {href: "#", onclick: (e: Event) => {e.preventDefault(); guardedNavigate(route, undefined, e)}}, + label) + } return { oninit: (vnode) => { showProjektBerechtigungen = false }, view: (vnode) => { return m(menubar, [ - m("a.w3-button.w3-bar-item", {href: "#!/kundenliste"}, "Kunden"), - m("a.w3-button.w3-bar-item", {href: "#!/projektliste"}, "Projekte"), - m("a.w3-button.w3-bar-item", {href: "#!/calllogliste"}, "Gesprächsnotizen"), + menuItem("/kundenliste", "Kunden"), + menuItem("/projektliste", "Projekte"), + menuItem("/calllogliste", "Gesprächsnotizen"), m("div.w3-dropdown-hover", m("button.w3-button", "Optionen"), m("div.w3-dropdown-content.w3-bar-block.w3-card-4", - m("a.w3-button.w3-bar-item", {href: "#!/taglist"}, "Tags"), - m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenexport"), - m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenimport"), + menuItem("/taglist", "Tags"), + menuItem("/default", "Datenexport"), + menuItem("/default", "Datenimport"), // Projekt-Berechtigungen nur für SysAdmin/AppAdmin sichtbar. // Als Toggle-Button realisiert, da verschachtelte w3-dropdown-hover // innerhalb eines offenen Dropdowns nicht zuverlässig funktionieren. @@ -147,10 +156,10 @@ const menu: m.ClosureComponent = () => { showProjektBerechtigungen ? m("div.w3-bar-block", m("a.w3-button.w3-bar-item", - {href: "#!/projektuserliste", style: {'padding-left': '32px'}}, + {href: "#", style: {'padding-left': '32px'}, onclick: (e: Event) => {e.preventDefault(); guardedNavigate("/projektuserliste", undefined, e)}}, "Projekt-Zugriffe"), m("a.w3-button.w3-bar-item", - {href: "#!/userprojektliste", style: {'padding-left': '32px'}}, + {href: "#", style: {'padding-left': '32px'}, onclick: (e: Event) => {e.preventDefault(); guardedNavigate("/userprojektliste", undefined, e)}}, "Nutzer-Zugriffe") ) : null @@ -162,9 +171,9 @@ const menu: m.ClosureComponent = () => { m("div.w3-dropdown-hover.w3-right", m("button.w3-button", "System"), m("div.w3-dropdown-content.w3-bar-block.w3-card-4", - m("a.w3-button.w3-bar-item", {href: "#!/login"}, "Login"), - m("a.w3-button.w3-bar-item", {href: "#!/changepassword"}, "Kennwort ändern"), - m("a.w3-button.w3-bar-item", {href: "#!/usermanagement"}, "Verwalten"), + menuItem("/login", "Login"), + menuItem("/changepassword", "Kennwort ändern"), + menuItem("/usermanagement", "Verwalten"), ) ) ])