From fd30049a32f50fa1a6b305c9b25e158b28a1ce28 Mon Sep 17 00:00:00 2001 From: Gerd Date: Sat, 19 Sep 2026 16:16:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(nav):=20Breadcrumb-Navigation=20f=C3=BCr?= =?UTF-8?q?=20Hierarchie-Aufrufkette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue BreadcrumbBar-Widget-Komponente (breadcrumb.ts) zeigt die Aufrufkette als anklickbare Leiste. Der Route-Param bc (JSON-String) transportiert die Vorgänger-Ansichten durch alle Listen und Masken. Jede Ansicht hängt beim Weiternavigieren ihren eigenen Crumb an die Kette an; Masken reichen bc beim Speichern/Abbrechen zurück. Erfasst: kunde, projekt, kontaktadresse, call_log, kontakt. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- Client/Components/call_log.ts | 33 +++++++++ Client/Components/kontakt.ts | 43 ++++++++++- Client/Components/kontaktadresse.ts | 56 ++++++++++++-- Client/Components/kunde.ts | 52 ++++++++++--- Client/Components/projekt.ts | 60 +++++++++++++-- Client/Components/widgets/breadcrumb.ts | 99 +++++++++++++++++++++++++ 6 files changed, 316 insertions(+), 27 deletions(-) create mode 100644 Client/Components/widgets/breadcrumb.ts diff --git a/Client/Components/call_log.ts b/Client/Components/call_log.ts index 924b89d..4621dfc 100644 --- a/Client/Components/call_log.ts +++ b/Client/Components/call_log.ts @@ -3,6 +3,7 @@ import {Form, iForm} from "./widgets/form" import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs" import {Select, iSelect, MultiSelect, iMultiSelect, iSelectOption} from "./widgets/select" import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2" +import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb" import {iCallLog, fetchCallLogs, fetchCallLogById, createCallLogREST, updateCallLogREST} from "../models/call_log" import {iKunde, fetchKunden} from "../models/kunde" import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt" @@ -22,11 +23,20 @@ export const call_log_liste: m.ClosureComponent = (vnode) => { // da es nicht in den Route-Params enthalten ist. let projektName: string = "" let kundenName: string = "" + // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). + let breadcrumbs: iBreadcrumb[] = [] const handleRowMarked = (node: iTreenode) => { selectedNode = node } + // Eigener Listen-Crumb für die Breadcrumb-Kette. + const ownCrumb = (): iBreadcrumb => ({ + route: "/calllogliste", + params: filterProjektId !== null ? {projekt_id: filterProjektId} : {}, + label: filterProjektId !== null ? "Notizen: " + projektName : "Gesprächsnotizen" + }) + const addCallLogRoute = () => { const now = new Date() const datum = now.toISOString().split('T')[0] @@ -51,6 +61,8 @@ export const call_log_liste: m.ClosureComponent = (vnode) => { params.return_projekt_id = filterProjektId params.return_projekt_name = projektName } + // Breadcrumb-Kette um eigene Position erweitern. + params.bc = serializeBc([...breadcrumbs, ownCrumb()]) m.route.set("/call_log", params) } @@ -61,6 +73,8 @@ export const call_log_liste: m.ClosureComponent = (vnode) => { params.return_projekt_id = filterProjektId params.return_projekt_name = projektName } + // Breadcrumb-Kette um eigene Position erweitern. + params.bc = serializeBc([...breadcrumbs, ownCrumb()]) m.route.set("/call_log", params) } @@ -118,6 +132,8 @@ export const call_log_liste: m.ClosureComponent = (vnode) => { filterProjektId = null projektName = "" } + // Breadcrumb-Kette aus Route-Param parsen. + breadcrumbs = parseBc(routeParams && routeParams.bc) // Gesprächsnotizen asynchron vom Server laden. Die Filterung // nach projekt_id erfolgt serverseitig, sofern ein Filter // gesetzt ist. @@ -143,6 +159,10 @@ export const call_log_liste: m.ClosureComponent = (vnode) => { height: 'inherit', 'overflow-y': 'inherit'}}, m("h1", projektName ? "Gesprächsnotizen für " + projektName : "Gesprächsnotizen"), + m(BreadcrumbBar, { + breadcrumbs: breadcrumbs, + current: filterProjektId !== null ? "Notizen: " + projektName : "Gesprächsnotizen" + } as iBreadcrumbBar), m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, m(Treetable, { @@ -219,6 +239,8 @@ export const Call_Log: m.ClosureComponent = (vnode) => { // null bedeutet ungefiltert; ein Wert bedeutet projektgefiltert. let returnProjektId: number | null = null let returnProjektName: string = "" + // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). + let breadcrumbs: iBreadcrumb[] = [] /** * Formatiert verstrichene Sekunden als mm:ss. @@ -240,6 +262,8 @@ export const Call_Log: m.ClosureComponent = (vnode) => { returnProjektId = parseInt(routeParams.return_projekt_id) returnProjektName = routeParams.return_projekt_name || "" } + // Breadcrumb-Kette aus Route-Param parsen. + breadcrumbs = parseBc(routeParams && routeParams.bc) if (id !== -1) { // Gesprächsnotiz frisch vom Server holen. fetchCallLogById(id).then((result) => { @@ -334,6 +358,11 @@ export const Call_Log: m.ClosureComponent = (vnode) => { 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) } @@ -367,6 +396,10 @@ export const Call_Log: m.ClosureComponent = (vnode) => { } 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" + } as iBreadcrumbBar), m("div", {style: {flex: '1', 'min-height': '0'}}, m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [ m("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id}), diff --git a/Client/Components/kontakt.ts b/Client/Components/kontakt.ts index 0d87c6b..5524d9f 100644 --- a/Client/Components/kontakt.ts +++ b/Client/Components/kontakt.ts @@ -2,6 +2,7 @@ import m from "mithril" import {Form, iForm} from "./widgets/form" import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs" import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2" +import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb" import {iKontakt, fetchKontakte, fetchKontaktById, createKontaktREST, updateKontaktREST} from "../models/kontakt" import {iKunde, fetchKundeById} from "../models/kunde" import {showServerError} from "./auth/authmessages" @@ -13,11 +14,20 @@ export const kontaktliste: m.ClosureComponent = (vnode) => { // Kundenname für die Überschrift, asynchron per fetchKundeById // aufgelöst, falls nicht in den Route-Params enthalten. let kundenName: string = "" + // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). + let breadcrumbs: iBreadcrumb[] = [] const handleRowMarked = (node: iTreenode) => { selectedNode = node } + // Eigener Listen-Crumb für die Breadcrumb-Kette. + const ownCrumb = (): iBreadcrumb => ({ + route: "/kontaktliste", + params: filterKundenId !== null ? {kunden_id: filterKundenId} : {}, + label: filterKundenId !== null ? "Kontakte: " + kundenName : "Kontakte" + }) + const addKontaktRoute = () => { const params: any = {id: -1} // Nur bei kundengefilterter Liste den Kunden vorbelegen. @@ -25,12 +35,15 @@ export const kontaktliste: m.ClosureComponent = (vnode) => { params.kunden_id = filterKundenId params.kunden_name = kundenName } + // Breadcrumb-Kette um eigene Position erweitern. + params.bc = serializeBc([...breadcrumbs, ownCrumb()]) m.route.set("/kontakt", params) } const editKontakt = (kontakt: iKontakt, e: Event) => { e.stopPropagation() - m.route.set("/kontakt", {id: kontakt.id}) + // Breadcrumb-Kette um eigene Position erweitern. + m.route.set("/kontakt", {id: kontakt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])}) } const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { @@ -66,6 +79,8 @@ export const kontaktliste: m.ClosureComponent = (vnode) => { filterKundenId = null kundenName = "" } + // Breadcrumb-Kette aus Route-Param parsen. + breadcrumbs = parseBc(routeParams && routeParams.bc) // Kontakte asynchron vom Server laden. Die Filterung nach // kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist. fetchKontakte(filterKundenId !== null ? filterKundenId : undefined).then((result) => { @@ -86,6 +101,10 @@ export const kontaktliste: m.ClosureComponent = (vnode) => { height: 'inherit', 'overflow-y': 'inherit'}}, m("h1", kundenName ? "Kontaktliste für " + kundenName : "Kontaktliste"), + m(BreadcrumbBar, { + breadcrumbs: breadcrumbs, + current: filterKundenId !== null ? "Kontakte: " + kundenName : "Kontakte" + } as iBreadcrumbBar), m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, m(Treetable, { @@ -129,11 +148,15 @@ export const Kontakt: m.ClosureComponent = (vnode) => { email: "", notiz: "" } + // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). + let breadcrumbs: iBreadcrumb[] = [] return { oninit: (vnode) => { const routeParams = m.route.param() const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 + // Breadcrumb-Kette aus Route-Param parsen. + breadcrumbs = parseBc(routeParams && routeParams.bc) if (id !== -1) { // Kontaktdaten frisch vom Server holen, um zwischenzeitliche // Änderungen anderer Nutzer nicht zu überschreiben. @@ -148,6 +171,14 @@ export const Kontakt: m.ClosureComponent = (vnode) => { } }, 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 = { @@ -162,19 +193,23 @@ export const Kontakt: m.ClosureComponent = (vnode) => { notiz: (formData.get("notiz") as string) || "" } if (currentKontakt.id === -1) { - createKontaktREST(gespeicherterKontakt).then(() => m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name})).catch((e) => showServerError(e)) + createKontaktREST(gespeicherterKontakt).then(() => goBack()).catch((e) => showServerError(e)) } else { - updateKontaktREST(gespeicherterKontakt).then(() => m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name})).catch((e) => showServerError(e)) + updateKontaktREST(gespeicherterKontakt).then(() => goBack()).catch((e) => showServerError(e)) } return true } const cancel = (e: Event) => { - m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name}) + 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" + } as iBreadcrumbBar), m("div", {style: {flex: '1', 'min-height': '0'}}, m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [ m("input", {type: "hidden", name: "kunden_id", value: currentKontakt.kunden_id}), diff --git a/Client/Components/kontaktadresse.ts b/Client/Components/kontaktadresse.ts index 7946587..b55de44 100644 --- a/Client/Components/kontaktadresse.ts +++ b/Client/Components/kontaktadresse.ts @@ -2,6 +2,7 @@ import m from "mithril" import {Form, iForm} from "./widgets/form" import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs" import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2" +import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb" import {iKontaktadresse, fetchKontaktadressen, fetchKontaktadresseById, createKontaktadresseREST, updateKontaktadresseREST} from "../models/kontaktadresse" import {iProjekt, fetchProjektById} from "../models/projekt" import {showServerError} from "./auth/authmessages" @@ -13,23 +14,35 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => { // Projektname für die Überschrift, asynchron per fetchProjektById // aufgelöst, falls nicht in den Route-Params enthalten. let projektName: string = "" + // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). + let breadcrumbs: iBreadcrumb[] = [] const handleRowMarked = (node: iTreenode) => { selectedNode = node } + // Eigener Listen-Crumb für die Breadcrumb-Kette. + const ownCrumb = (): iBreadcrumb => ({ + route: "/kontaktadresseliste", + params: filterProjektId !== null ? {projekt_id: filterProjektId} : {}, + label: filterProjektId !== null ? "Kontaktadressen: " + projektName : "Kontaktadressen" + }) + const addKontaktadresseRoute = () => { const params: any = {id: -1} if (filterProjektId !== null) { params.projekt_id = filterProjektId params.projekt_name = projektName } + // Breadcrumb-Kette um eigene Position erweitern. + params.bc = serializeBc([...breadcrumbs, ownCrumb()]) m.route.set("/kontaktadresse", params) } const editKontaktadresse = (kontaktadresse: iKontaktadresse, e: Event) => { e.stopPropagation() - m.route.set("/kontaktadresse", {id: kontaktadresse.id}) + // Breadcrumb-Kette um eigene Position erweitern. + m.route.set("/kontaktadresse", {id: kontaktadresse.id, bc: serializeBc([...breadcrumbs, ownCrumb()])}) } const addCallLog = (kontaktadresse: iKontaktadresse, e: Event) => { @@ -37,6 +50,14 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => { const now = new Date() const datum = now.toISOString().split('T')[0] const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1] + // Breadcrumb-Kette um Kontaktadressenliste und Notizliste + // erweitern, da die Maske nach Speichern/Abbrechen in die + // projektgefilterte Notizliste zurückkehrt. + const chain = [...breadcrumbs, ownCrumb(), { + route: "/calllogliste", + params: {projekt_id: kontaktadresse.projekt_id}, + label: "Notizen: " + kontaktadresse.projekt_name + } as iBreadcrumb] m.route.set("/call_log", { id: -1, projekt_id: kontaktadresse.projekt_id, @@ -49,7 +70,8 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => { telefon: kontaktadresse.telefon, notiz: "", wiedervorlage_datum: "", - erledigt: false + erledigt: false, + bc: serializeBc(chain) }) } @@ -92,6 +114,8 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => { filterProjektId = null projektName = "" } + // Breadcrumb-Kette aus Route-Param parsen. + breadcrumbs = parseBc(routeParams && routeParams.bc) // Kontaktadressen asynchron vom Server laden. Die Filterung // nach projekt_id erfolgt serverseitig, sofern ein Filter // gesetzt ist. @@ -113,6 +137,10 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => { height: 'inherit', 'overflow-y': 'inherit'}}, m("h1", projektName ? "Kontaktadressen für " + projektName : "Kontaktadressen"), + m(BreadcrumbBar, { + breadcrumbs: breadcrumbs, + current: filterProjektId !== null ? "Kontaktadressen: " + projektName : "Kontaktadressen" + } as iBreadcrumbBar), m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, m(Treetable, { @@ -166,11 +194,15 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => { land: "", notiz: "" } + // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). + let breadcrumbs: iBreadcrumb[] = [] return { oninit: (vnode) => { const routeParams = m.route.param() const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 + // Breadcrumb-Kette aus Route-Param parsen. + breadcrumbs = parseBc(routeParams && routeParams.bc) if (id !== -1) { // Kontaktadresse frisch vom Server holen. fetchKontaktadresseById(id).then((result) => { @@ -183,6 +215,14 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => { } }, 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 = { @@ -202,19 +242,25 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => { notiz: (formData.get("notiz") as string) || "" } if (currentKontaktadresse.id === -1) { - createKontaktadresseREST(gespeicherteKontaktadresse).then(() => m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name})).catch((e) => showServerError(e)) + createKontaktadresseREST(gespeicherteKontaktadresse).then(() => goBack()).catch((e) => showServerError(e)) } else { - updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name})).catch((e) => showServerError(e)) + updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => goBack()).catch((e) => showServerError(e)) } return true } const cancel = (e: Event) => { - m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name}) + goBack() } return m("div.w3-container.w3-auto.form-view-container", m("h1", currentKontaktadresse.id === -1 ? "Neue Kontaktadresse für " + currentKontaktadresse.projekt_name : "Kontaktadresse-Stammblatt"), + m(BreadcrumbBar, { + breadcrumbs: breadcrumbs, + current: currentKontaktadresse.id === -1 + ? "Neue Kontaktadresse" + : "Kontaktadresse-Stammblatt" + } as iBreadcrumbBar), m("div", {style: {flex: '1', 'min-height': '0'}}, m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [ m("input", {type: "hidden", name: "projekt_id", value: currentKontaktadresse.projekt_id}), diff --git a/Client/Components/kunde.ts b/Client/Components/kunde.ts index f379632..26a94bb 100644 --- a/Client/Components/kunde.ts +++ b/Client/Components/kunde.ts @@ -2,6 +2,7 @@ import m from "mithril" import {Form, iForm} from "./widgets/form" import { Input, iInput} from "./widgets/inputs" import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2" +import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb" import {iKunde, fetchKunden, fetchKundeById, createKundeREST, updateKundeREST} from "../models/kunde" import {showServerError} from "./auth/authmessages" @@ -14,11 +15,15 @@ export const kunde: m.ClosureComponent = (vnode) => { ort: "", land: "" } - + // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). + let breadcrumbs: iBreadcrumb[] = [] + return { oninit: (vnode) => { const routeParams = m.route.param() const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 + // Breadcrumb-Kette aus Route-Param parsen. + breadcrumbs = parseBc(routeParams && routeParams.bc) if (id !== -1) { // Kundendaten frisch vom Server holen, um zwischenzeitliche // Änderungen anderer Nutzer nicht zu überschreiben. @@ -57,6 +62,10 @@ export const kunde: m.ClosureComponent = (vnode) => { m("i.material-icons", "contact_page"), " Kontakte") : null ), + m(BreadcrumbBar, { + breadcrumbs: breadcrumbs, + current: currentKunde.id === -1 ? "Neuer Kunde" : "Kundenstammblatt" + } as iBreadcrumbBar), m("div", {style: {flex: '1', 'min-height': '0'}}, m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [ m("label", "Kundenname", m(Input, {name: "kundenname", value: currentKunde.name, type: "text", required: true} as iInput)), @@ -74,28 +83,43 @@ export const kunde: m.ClosureComponent = (vnode) => { export const kundenliste: m.ClosureComponent = (vnode) => { let treenodes: iTreenode[] = [] let selectedNode: iTreenode | null = null + // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). + let breadcrumbs: iBreadcrumb[] = [] const handleRowMarked = (node: iTreenode) => { selectedNode = node } - + + // Eigener Listen-Crumb für die Breadcrumb-Kette. + const ownCrumb = (): iBreadcrumb => ({ + route: "/kundenliste", + params: {}, + label: "Kunden" + }) + const editKunde = (kunde: iKunde, e: Event) => { e.stopPropagation() - m.route.set("/kunde", {id: kunde.id}) + // Breadcrumb-Kette um eigene Position erweitern. + m.route.set("/kunde", {id: kunde.id, bc: serializeBc([...breadcrumbs, ownCrumb()])}) } - + const showProjekte = (kunde: iKunde, e: Event) => { e.stopPropagation() - m.route.set("/projektliste", {kunden_id: kunde.id}) + // Breadcrumb-Kette: Kundenliste als Vorgänger der Projektliste. + const bc = serializeBc([...breadcrumbs, ownCrumb()]) + m.route.set("/projektliste", {kunden_id: kunde.id, bc}) } const showKontakte = (kunde: iKunde, e: Event) => { e.stopPropagation() - m.route.set("/kontaktliste", {kunden_id: kunde.id}) + // Breadcrumb-Kette: Kundenliste als Vorgänger der Kontaktliste. + const bc = serializeBc([...breadcrumbs, ownCrumb()]) + m.route.set("/kontaktliste", {kunden_id: kunde.id, bc}) } - + const addKundeRoute = () => { - m.route.set("/kunde", {id: -1}) + // Breadcrumb-Kette um eigene Position erweitern. + m.route.set("/kunde", {id: -1, bc: serializeBc([...breadcrumbs, ownCrumb()])}) } const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { @@ -127,6 +151,8 @@ export const kundenliste: m.ClosureComponent = (vnode) => { return { oninit: (vnode) => { + // Breadcrumb-Kette aus Route-Param parsen (bei Top-Level leer). + breadcrumbs = parseBc(m.route.param() && m.route.param().bc) // Kundenliste asynchron vom Server laden. m.request löst nach // Promise-Auflösung automatisch ein Redraw aus. fetchKunden().then((kunden: iKunde[]) => { @@ -136,11 +162,15 @@ export const kundenliste: m.ClosureComponent = (vnode) => { view: (vnode) => { return m("div.w3-container.w3-auto", { style: { - display: 'flex', - 'flex-direction': 'column', - height: 'inherit', + display: 'flex', + 'flex-direction': 'column', + height: 'inherit', 'overflow-y': 'inherit'}}, m("h1", "Kundenliste"), + m(BreadcrumbBar, { + breadcrumbs: breadcrumbs, + current: "Kunden" + } as iBreadcrumbBar), m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, m(Treetable, { diff --git a/Client/Components/projekt.ts b/Client/Components/projekt.ts index 0ff49a6..cd14e35 100644 --- a/Client/Components/projekt.ts +++ b/Client/Components/projekt.ts @@ -2,6 +2,7 @@ import m from "mithril" import {Form, iForm} from "./widgets/form" import { Input, iInput} from "./widgets/inputs" import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2" +import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb" import {iProjekt, fetchProjekte, fetchProjektById, createProjektREST, updateProjektREST} from "../models/projekt" import {iKunde, fetchKundeById} from "../models/kunde" import {showServerError} from "./auth/authmessages" @@ -13,11 +14,20 @@ export const projektliste: m.ClosureComponent = (vnode) => { // Kundenname für die Überschrift, asynchron per fetchKundeById // aufgelöst, falls nicht in den Route-Params enthalten. let kundenName: string = "" + // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). + let breadcrumbs: iBreadcrumb[] = [] const handleRowMarked = (node: iTreenode) => { selectedNode = node } + // Eigener Listen-Crumb für die Breadcrumb-Kette. + const ownCrumb = (): iBreadcrumb => ({ + route: "/projektliste", + params: filterKundenId !== null ? {kunden_id: filterKundenId, kunden_name: kundenName} : {}, + label: filterKundenId !== null ? "Projekte: " + kundenName : "Projekte" + }) + const addProjektRoute = () => { const params: any = {id: -1} // Nur bei kundengefilterter Liste den Kunden vorbelegen. @@ -25,22 +35,27 @@ export const projektliste: m.ClosureComponent = (vnode) => { params.kunden_id = filterKundenId params.kunden_name = kundenName } + // Breadcrumb-Kette um eigene Position erweitern. + params.bc = serializeBc([...breadcrumbs, ownCrumb()]) m.route.set("/projekt", params) } const editProjekt = (projekt: iProjekt, e: Event) => { e.stopPropagation() - m.route.set("/projekt", {id: projekt.id}) + // Breadcrumb-Kette um eigene Position erweitern. + m.route.set("/projekt", {id: projekt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])}) } const showCallLogs = (projekt: iProjekt, e: Event) => { e.stopPropagation() - m.route.set("/calllogliste", {projekt_id: projekt.id}) + // Breadcrumb-Kette um eigene Position erweitern. + m.route.set("/calllogliste", {projekt_id: projekt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])}) } const showKontaktadressen = (projekt: iProjekt, e: Event) => { e.stopPropagation() - m.route.set("/kontaktadresseliste", {projekt_id: projekt.id}) + // Breadcrumb-Kette um eigene Position erweitern. + m.route.set("/kontaktadresseliste", {projekt_id: projekt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])}) } const addCallLog = (projekt: iProjekt, e: Event) => { @@ -48,6 +63,14 @@ export const projektliste: m.ClosureComponent = (vnode) => { const now = new Date() const datum = now.toISOString().split('T')[0] const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1] + // Breadcrumb-Kette um Projektliste und Notizliste erweitern, da + // die Maske nach Speichern/Abbrechen in die projektgefilterte + // Notizliste zurückkehrt. + const chain = [...breadcrumbs, ownCrumb(), { + route: "/calllogliste", + params: {projekt_id: projekt.id}, + label: "Notizen: " + projekt.projektname + } as iBreadcrumb] m.route.set("/call_log", { id: -1, kunden_name: projekt.kunden_name, @@ -61,7 +84,8 @@ export const projektliste: m.ClosureComponent = (vnode) => { telefon: "", notiz: "", wiedervorlage_datum: "", - erledigt: false + erledigt: false, + bc: serializeBc(chain) }) } @@ -103,6 +127,8 @@ export const projektliste: m.ClosureComponent = (vnode) => { filterKundenId = null kundenName = "" } + // Breadcrumb-Kette aus Route-Param parsen. + breadcrumbs = parseBc(routeParams && routeParams.bc) // Projekte asynchron vom Server laden. Die Filterung nach // kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist. fetchProjekte(filterKundenId !== null ? filterKundenId : undefined).then((result) => { @@ -123,6 +149,10 @@ export const projektliste: m.ClosureComponent = (vnode) => { height: 'inherit', 'overflow-y': 'inherit'}}, m("h1", kundenName ? "Projektliste für " + kundenName : "Projektliste"), + m(BreadcrumbBar, { + breadcrumbs: breadcrumbs, + current: kundenName ? "Projekte: " + kundenName : "Projekte" + } as iBreadcrumbBar), m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, m(Treetable, { @@ -162,11 +192,15 @@ export const Projekt: m.ClosureComponent = (vnode) => { verantwortlicher: "", startdatum: "" } + // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). + let breadcrumbs: iBreadcrumb[] = [] return { oninit: (vnode) => { const routeParams = m.route.param() const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 + // Breadcrumb-Kette aus Route-Param parsen. + breadcrumbs = parseBc(routeParams && routeParams.bc) if (id !== -1) { // Projektdaten frisch vom Server holen, um zwischenzeitliche // Änderungen anderer Nutzer nicht zu überschreiben. @@ -192,15 +226,23 @@ 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", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name})).catch((e) => showServerError(e)) + createProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", routeParams)).catch((e) => showServerError(e)) } else { - updateProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name})).catch((e) => showServerError(e)) + updateProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", routeParams)).catch((e) => showServerError(e)) } return true } const cancel = (e: Event) => { - m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name}) + 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'}}, @@ -219,6 +261,10 @@ export const Projekt: m.ClosureComponent = (vnode) => { ] : null ), + m(BreadcrumbBar, { + breadcrumbs: breadcrumbs, + current: currentProjekt.id === -1 ? "Neues Projekt" : "Projekt-Stammblatt" + } as iBreadcrumbBar), m("div", {style: {flex: '1', 'min-height': '0'}}, m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [ m("input", {type: "hidden", name: "kunden_id", value: currentProjekt.kunden_id}), diff --git a/Client/Components/widgets/breadcrumb.ts b/Client/Components/widgets/breadcrumb.ts new file mode 100644 index 0000000..1d69bf4 --- /dev/null +++ b/Client/Components/widgets/breadcrumb.ts @@ -0,0 +1,99 @@ +import m from "mithril" + +/** Ein Breadcrumb-Glied: Route, Filter-Params und Anzeige-Label einer + * Vorgänger-Ansicht in der Aufrufkette. */ +export interface iBreadcrumb { + route: string + params: any + label: string +} + +/** 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. */ +export interface iBreadcrumbBar { + breadcrumbs: iBreadcrumb[] + current: string +} + +/** Parst den Route-Param `bc` (JSON-String) in ein Breadcrumb-Array. + * Liefert bei fehlendem/ungültigem `bc` ein leeres Array. */ +export function parseBc(bc: string | undefined): iBreadcrumb[] { + if (!bc) return [] + try { return JSON.parse(bc) } catch { return [] } +} + +/** Serialisiert ein Breadcrumb-Array in den Route-Param `bc`. + * Liefert undefined bei leerer Kette (kein bc in der Route). */ +export function serializeBc(crumbs: iBreadcrumb[]): string | undefined { + return crumbs.length > 0 ? JSON.stringify(crumbs) : undefined +} + +export const BreadcrumbBar: m.ClosureComponent = () => { + return { + view: (vnode) => { + const crumbs = vnode.attrs.breadcrumbs || [] + const current = vnode.attrs.current + // Ohne Vorgänger und ohne aktuelles Label keine Leiste rendern + // (Top-Level-Ansichten ohne Hierarchie-Kontext). + if (crumbs.length === 0 && !current) return null + + const items: any[] = [] + crumbs.forEach((crumb: iBreadcrumb, i: number) => { + if (i > 0) { + items.push(m("span.w3-small", { + style: {margin: '0 2px', opacity: '0.6'} + }, "›")) + } + // Vorgänger sind klickbar: Navigation zur jeweiligen Ansicht + // mit deren Filter-Params und der auf dieses Glied gekürzten + // Kette als neuem bc. + items.push(m("a.w3-button.w3-small.w3-theme-l2", { + style: { + padding: '2px 8px', + 'border-radius': '4px', + 'text-decoration': 'none', + 'white-space': 'nowrap' + }, + title: "Zurück zu " + crumb.label, + onclick: () => { + 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) + } + }, crumb.label)) + }) + // Aktuelle Ansicht als letztes, nicht klickbares Glied (fett). + if (current) { + if (crumbs.length > 0) { + items.push(m("span.w3-small", { + style: {margin: '0 2px', opacity: '0.6'} + }, "›")) + } + items.push(m("span.w3-small.w3-theme-d3", { + style: { + padding: '2px 8px', + 'border-radius': '4px', + 'font-weight': 'bold', + 'white-space': 'nowrap' + } + }, current)) + } + + return m("div.w3-theme-l1", { + style: { + display: 'flex', + 'flex-wrap': 'nowrap', + 'align-items': 'center', + padding: '4px 8px', + 'border-radius': '4px', + 'margin-bottom': '8px', + 'overflow-x': 'auto', + 'overflow-y': 'hidden' + } + }, items) + } + } +}