From 9ed5e88e013c671871b0701ab20d197e203691b7 Mon Sep 17 00:00:00 2001 From: Gerd Date: Mon, 7 Sep 2026 16:04:06 +0200 Subject: [PATCH] =?UTF-8?q?Men=C3=BC=20Projekt-Berechtigungen,=20=C3=9Cber?= =?UTF-8?q?sichtssichten=20und=20AppAdmin-Erkennung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Menü: Dropdown "Projekt-Berechtigungen" unter Optionen mit Unterpunkten "Projekt-Zugriffe" und "Nutzer-Zugriffe" (nur SysAdmin/AppAdmin) - AppAdmin-Erkennung: Server liefert isAppAdmin-Flag im Login- und getusers-Response (applikationsspezifisch via has_permission) - Login: CurrentUserIsAppAdmin in sessionStorage speichern/entfernen - Projekt-Zugriffe: geschachtelter Treetable (Projekt → Nutzer) als reine Anzeige, Detail-Sicht aus Projekt-Maske mit Bearbeitung - Nutzer-Zugriffe: neue Komponente user_projektliste.ts, geschachtelter Treetable (Nutzer → Projekte), reine Anzeige - Server: neuer Endpunkt GET /xhr/projektuser/listall (nur AppAdmin/SysAdmin) - Server: get_all_projekt_users() mit Batch-Query für Projekte und User - Treetable-Widget: Platzhalter-Pfeil bei kinderlosen Nodes nur wenn Geschwister mit Kindern existieren (hasSiblingsWithChildren) - Datei projekt_user.ts umbenannt in projekt_userliste.ts (konsistent mit user_projektliste.ts) Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- App_Server/root/auth/routes.py | 10 +- App_Server/root/crm/model/projekt_user.py | 42 +++ App_Server/root/crm/routes.py | 9 + Client/Components/auth/login.ts | 8 + Client/Components/auth/models/users.ts | 1 + .../{projekt_user.ts => projekt_userliste.ts} | 248 +++++++++++--- Client/Components/user_projektliste.ts | 313 ++++++++++++++++++ Client/Components/widgets/Treetable_V2.ts | 43 ++- Client/index.ts | 81 +++-- Client/models/projekt_user.ts | 20 ++ 10 files changed, 693 insertions(+), 82 deletions(-) rename Client/Components/{projekt_user.ts => projekt_userliste.ts} (54%) create mode 100644 Client/Components/user_projektliste.ts diff --git a/App_Server/root/auth/routes.py b/App_Server/root/auth/routes.py index b9c2492..21cc7cf 100644 --- a/App_Server/root/auth/routes.py +++ b/App_Server/root/auth/routes.py @@ -52,6 +52,7 @@ def checklogin(): login_user(user) result = user.to_dict() result["isSysAdmin"] = user.is_SysAdmin() + result["isAppAdmin"] = user.has_permission(app.config["APPID"], [APP_ADMIN]) return jsonify(result), 200 @@ -86,13 +87,18 @@ def logout(): def getusers(): """ Die Rückgabe wird in 'users.get_users()' (s.u.) anhand er App-Berechtigung und der - Rollen gefiltert. + Rollen gefiltert. Pro User wird zusätzlich isSysAdmin und das app-spezifische + isAppAdmin Flag ergänzt, damit der Client keine app-unspezifische Rollenprüfung + durchführen muss. """ userquery = users.get_users() result = [] user: User for user in userquery: - result.append(user.to_relationaldict()) + eintrag = user.to_relationaldict() + eintrag["isSysAdmin"] = user.is_SysAdmin() + eintrag["isAppAdmin"] = user.has_permission(app.config["APPID"], [APP_ADMIN]) + result.append(eintrag) return jsonify(result), 200 diff --git a/App_Server/root/crm/model/projekt_user.py b/App_Server/root/crm/model/projekt_user.py index 1608181..5cf5ec2 100644 --- a/App_Server/root/crm/model/projekt_user.py +++ b/App_Server/root/crm/model/projekt_user.py @@ -165,3 +165,45 @@ def is_user_assigned(projekt_id: int, user_id: int) -> bool: ProjektUser.user_id == user_id, ).first() return zuordnung is not None + + +def get_all_projekt_users(): + """ + Liefert alle Projekt-User-Zuordnungen, angereichert mit projektname + (aus Projekt-Relationship) sowie userid, username und email (aus + Auth-DB via Batch-Query). + + Wird für die Übersichtssicht "Nutzer-Zugriffe" verwendet, in der alle + Zuordnungen in einem Aufruf geladen werden. + """ + zuordnungen = ProjektUser.query.all() + + if not zuordnungen: + return [] + + # Projekte und Nutzer in jeweils einer Batch-Query auflösen. + projekt_ids = list({z.projekt_id for z in zuordnungen}) + user_ids = list({z.user_id for z in zuordnungen}) + + projekte = Projekt.query.filter(Projekt.id.in_(projekt_ids)).all() + projekt_map = {p.id: p for p in projekte} + + users = User.query.filter(User.id.in_(user_ids)).all() + user_map = {u.id: u for u in users} + + result = [] + for z in zuordnungen: + eintrag = z.to_dict() + projekt = projekt_map.get(z.projekt_id) + eintrag['projektname'] = projekt.projektname if projekt else "" + user = user_map.get(z.user_id) + if user is not None: + eintrag['userid'] = user.userid + eintrag['username'] = user.username + eintrag['email'] = user.email or "" + else: + eintrag['userid'] = "" + eintrag['username'] = "" + eintrag['email'] = "" + result.append(eintrag) + return result diff --git a/App_Server/root/crm/routes.py b/App_Server/root/crm/routes.py index e950259..1f18b15 100644 --- a/App_Server/root/crm/routes.py +++ b/App_Server/root/crm/routes.py @@ -233,6 +233,15 @@ def projektuser_list(): return jsonify(result), 200 +@app.get("/xhr/projektuser/listall") +# Leeres allowed_roles-Array als Erweiterungspunkt (nur AppAdmin/SysAdmin). +@authorisation_required([]) +def projektuser_listall(): + """Liefert alle Projekt-User-Zuordnungen inkl. projektname und Nutzerdaten.""" + result = projekt_users.get_all_projekt_users() + return jsonify(result), 200 + + @app.post("/xhr/projektuser/add") # Leeres allowed_roles-Array als Erweiterungspunkt: Aktuell passieren nur # AppAdmin und SysAdmin (vom Decorator implizit ergänzt). Sobald später eine diff --git a/Client/Components/auth/login.ts b/Client/Components/auth/login.ts index a379855..840fea2 100644 --- a/Client/Components/auth/login.ts +++ b/Client/Components/auth/login.ts @@ -34,11 +34,18 @@ export const loginscreen: m.ClosureComponent = (vnode) => { if (user.isSysAdmin) { sessionStorage.setItem("CurrentUserIsSysAdmin", "1"); } + // AppAdmin: der Server prüft applikationsspezifisch via + // has_permission(appid, [APP_ADMIN]) und liefert isAppAdmin + // im Login-Response. Der Client wertet nur das Flag aus. + if (user.isAppAdmin) { + sessionStorage.setItem("CurrentUserIsAppAdmin", "1"); + } }) .then(() => {m.route.set(vnode.attrs.setnextpage)}) .catch((error) => { sessionStorage.removeItem("CurrentUser"); sessionStorage.removeItem("CurrentUserIsSysAdmin"); + sessionStorage.removeItem("CurrentUserIsAppAdmin"); showServerError(error) }) @@ -52,6 +59,7 @@ export const loginscreen: m.ClosureComponent = (vnode) => { .then (() => { sessionStorage.removeItem("CurrentUser"); sessionStorage.removeItem("CurrentUserIsSysAdmin"); + sessionStorage.removeItem("CurrentUserIsAppAdmin"); // global.appglobals.currentuser = "nicht angemeldet" // global.appglobals.currentuserid = "" }) diff --git a/Client/Components/auth/models/users.ts b/Client/Components/auth/models/users.ts index c92aad6..47a164e 100644 --- a/Client/Components/auth/models/users.ts +++ b/Client/Components/auth/models/users.ts @@ -9,6 +9,7 @@ export interface iUser { email: string telefon: string isSysAdmin: boolean + isAppAdmin: boolean userapps: iUserApp[] } diff --git a/Client/Components/projekt_user.ts b/Client/Components/projekt_userliste.ts similarity index 54% rename from Client/Components/projekt_user.ts rename to Client/Components/projekt_userliste.ts index 8d3ba85..b153529 100644 --- a/Client/Components/projekt_user.ts +++ b/Client/Components/projekt_userliste.ts @@ -2,18 +2,19 @@ import m from "mithril" import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2" import {ModalView, ModalType, iModalView} from "./widgets/Modal" import {gridMountpoints} from "./mainlayout" -import {iProjektUser, fetchProjektUsers, addProjektUserREST, removeProjektUserREST} from "../models/projekt_user" -import {iProjekt, fetchProjektById} from "../models/projekt" +import {iProjektUser, iProjektUserWithProjekt, fetchProjektUsers, fetchAllProjektUsers, addProjektUserREST, removeProjektUserREST} from "../models/projekt_user" +import {iProjekt, fetchProjektById, fetchProjekte} from "../models/projekt" import {iUser} from "./auth/models/users" import {showServerError} from "./auth/authmessages" /** - * Liste der einem Projekt zugeordneten Nutzer (Sichtbarkeit). + * Übersicht "Projekt-Zugriffe": Zeigt alle Projekte als Top-Level-Knoten + * mit ihren jeweils zugeordneten Nutzern als Child-Knoten. Der SysAdmin und der AppAdmin + * können Nutzer zuordnen und entfernen. * - * Der Liste wird die projekt_id über die Route-Params übergeben. Sie zeigt - * alle Nutzer, die das Projekt sehen dürfen. Der AppAdmin (und SysAdmin) - * kann Nutzer über ein Modal mit Select-Widget hinzufügen und über einen - * Entfernen-Button pro Zeile wieder wegnehmen. + * Wird die Komponente mit einer projekt_id in den Route-Params aufgerufen, + * zeigt sie nur die Nutzer-Liste für dieses eine Projekt (Detail-Sicht, + * aufgerufen aus der Projekt-Maske heraus). */ export const projektuserliste: m.ClosureComponent = () => { let treenodes: iTreenode[] = [] @@ -21,6 +22,12 @@ export const projektuserliste: m.ClosureComponent = () => { let projektId: number = -1 let projektName: string = "" let projektUsers: iProjektUser[] = [] + // Alle Projekte für den Übersichtsmodus. + let projekte: iProjekt[] = [] + // Alle Projekt-User-Zuordnungen (inkl. projektname) für den Übersichtsmodus. + let allZuordnungen: iProjektUserWithProjekt[] = [] + // True, wenn die Detail-Sicht (Nutzer-Liste für ein Projekt) aktiv ist. + let detailMode: boolean = false // Alle Nutzer aus der Auth-DB, für das Hinzufügen-Modal. let allUsers: iUser[] = [] @@ -64,7 +71,7 @@ export const projektuserliste: m.ClosureComponent = () => { const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { let pu: iProjektUser = node.nodevalue return [ - m("div", m("strong", pu.userid)), + m("span", m("strong", pu.userid)), m("span", pu.username), m("span", pu.email), m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap'}}, @@ -88,17 +95,147 @@ export const projektuserliste: m.ClosureComponent = () => { } /** - * Öffnet das Modal zum Hinzufügen eines Nutzers. Die verfügbaren - * Nutzer werden clientseitig gegen die bereits zugeordneten gefiltert. + * Entfernt einen Nutzer aus einem Projekt (Übersichtsmodus). + */ + const removeUserOverview = (zuordnung: iProjektUserWithProjekt, e: Event) => { + e.stopPropagation() + removeProjektUserREST(zuordnung.projekt_id, zuordnung.user_id).then(() => { + loadOverviewData() + }).catch((e) => showServerError(e)) + } + + /** + * Columnmapper für Top-Level (Projekt-Zeilen im Übersichtsmodus). + */ + const overviewProjektColumnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { + let projekt: iProjekt = node.nodevalue + return [ + m("span", m("strong", projekt.projektname)), + m("span", projekt.kunden_name), + m("span.w3-small.w3-opacity", node.children.length + " Nutzer") + ] + } + + /** + * Columnmapper für Child-Level (Nutzer-Zeilen im Übersichtsmodus). + * Nur Anzeige — keine Buttons. + */ + const overviewNutzerColumnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { + let z: iProjektUserWithProjekt = node.nodevalue + return [ + m("span", m("strong", z.userid)), + m("span", z.username + " — " + z.email), + m("span", "") + ] + } + + // Der Treetable verwendet denselben columnmapper für alle Levels. + // Über die nodevalue-Eigenschaft wird zwischen Projekt- und Nutzer- + // Zeilen unterschieden. + const overviewColumnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { + if (node.nodevalue && node.nodevalue.user_id !== undefined) { + return overviewNutzerColumnmapper(node) + } + return overviewProjektColumnmapper(node) + } + + const overviewFilterFunction = (node: iTreenode, searchvalue: string): boolean => { + let searchLower = searchvalue.toLowerCase() + let nv = node.nodevalue + if (nv && nv.user_id !== undefined) { + // Nutzer-Zeile: nach userid, username filtern. + return (nv.userid || "").toLowerCase().includes(searchLower) || + (nv.username || "").toLowerCase().includes(searchLower) + } + // Projekt-Zeile: nach projektname, kunden_name filtern. + // Bei Treffer in Child-Nutzern das Projekt ebenfalls anzeigen. + if ((nv.projektname || "").toLowerCase().includes(searchLower) || + (nv.kunden_name || "").toLowerCase().includes(searchLower)) { + return true + } + return node.children.some((child) => { + let z = child.nodevalue + return (z.userid || "").toLowerCase().includes(searchLower) || + (z.username || "").toLowerCase().includes(searchLower) + }) + } + + /** + * Lädt alle Projekte und alle Zuordnungen und baut den + * Treenode-Baum (Projekt → Nutzer) auf. + */ + const loadOverviewData = () => { + Promise.all([ + fetchProjekte(), + fetchAllProjektUsers(), + m.request({method: "GET", url: "/auth/getusers"}) + ]).then(([projekteResult, zuordnungenResult, usersResult]) => { + projekte = projekteResult as iProjekt[] + allZuordnungen = zuordnungenResult as iProjektUserWithProjekt[] + allUsers = usersResult as iUser[] + buildOverviewTreenodes() + }).catch((e) => showServerError(e)) + } + + /** + * Baut den Treenode-Baum für den Übersichtsmodus: + * Top-Level = alle Projekte, Child-Nodes = zugeordnete Nutzer. + */ + const buildOverviewTreenodes = () => { + treenodes = projekte.map((p: iProjekt) => { + const nutzerNodes = allZuordnungen + .filter((z) => z.projekt_id === p.id) + .map((z) => new Treenode(z, true, [])) + return new Treenode(p, true, nutzerNodes) + }) + } + + /** + * Öffnet das Modal zum Hinzufügen von Nutzern. + * Im Detail-Modus für das aktuelle Projekt, im Übersichts-Modus + * für das selektierte Projekt. */ const openAddModal = () => { - // Alle Nutzer laden, falls noch nicht geschehen. if (allUsers.length === 0) { loadAllUsers() } m.mount(gridMountpoints.modalhook, addUserModal) } + /** + * Liefert die projekt_id, für die das Add-Modal aktuell gilt: + * im Detail-Modus die Route-Param projektId, im Übersichts-Modus + * die projekt_id der selektierten Projekt-Zeile. + */ + const getModalProjektId = (): number => { + if (detailMode) { + return projektId + } + // Übersichtsmodus: selektierte Projekt-Zeile. + if (selectedNode && selectedNode.nodevalue && + selectedNode.nodevalue.user_id === undefined) { + return (selectedNode.nodevalue as iProjekt).id + } + return -1 + } + + /** + * Liefert die bereits zugeordneten Nutzer für das Modal: + * im Detail-Modus projektUsers, im Übersichts-Modus aus + * allZuordnungen gefiltert. + */ + const getModalAssignedIds = (): Set => { + if (detailMode) { + return new Set(projektUsers.map((pu) => pu.user_id)) + } + const pid = getModalProjektId() + return new Set( + allZuordnungen + .filter((z) => z.projekt_id === pid) + .map((z) => z.user_id) + ) + } + /** * Modal-Komponente zur Auswahl mehrerer Nutzer, die dem Projekt * zugeordnet werden sollen. Zeigt eine durchsuchbare Liste mit @@ -115,15 +252,13 @@ export const projektuserliste: m.ClosureComponent = () => { // SysAdmin oder AppAdmin sind (diese haben ohnehin immer Zugriff) // und deren Suchtext-Filter entsprechen. Sortiert nach username. const getAvailableUsers = (): iUser[] => { - const assignedIds = new Set(projektUsers.map((pu) => pu.user_id)) + const assignedIds = getModalAssignedIds() const searchLower = search.toLowerCase() return allUsers.filter((u: iUser) => { // SysAdmin und AppAdmin haben immer Zugriff, // sie brauchen keine projektbezogene Zuordnung. if (u.isSysAdmin) return false - if (u.userapps && u.userapps.some((ua) => - ua.userapp_roles && ua.userapp_roles.some((r) => r.roleid === "AppAdmin") - )) return false + if (u.isAppAdmin) return false if (assignedIds.has(u.id)) return false if (searchLower === "") return true return (u.userid || "").toLowerCase().includes(searchLower) || @@ -148,16 +283,25 @@ export const projektuserliste: m.ClosureComponent = () => { closeModal() return } + const modalProjektId = getModalProjektId() const promises: Promise[] = [] selectedIds.forEach((uid: number) => { - promises.push(addProjektUserREST(projektId, uid)) + promises.push(addProjektUserREST(modalProjektId, uid)) }) Promise.all(promises).then(() => { closeModal() - loadProjektUsers() + if (detailMode) { + loadProjektUsers() + } else { + loadOverviewData() + } }).catch((e) => { showServerError(e) - loadProjektUsers() + if (detailMode) { + loadProjektUsers() + } else { + loadOverviewData() + } }) } @@ -251,16 +395,20 @@ export const projektuserliste: m.ClosureComponent = () => { const routeParams = m.route.param() projektId = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) : -1 projektName = routeParams && routeParams.projekt_name ? routeParams.projekt_name : "" - // Projektname asynchron auflösen, falls nicht in Route-Params. - if (projektId !== -1 && projektName === "") { - fetchProjektById(projektId).then((projekt: iProjekt) => { - projektName = projekt.projektname - }).catch((e) => showServerError(e)) + detailMode = projektId !== -1 + if (detailMode) { + // Detail-Sicht: Nutzer-Liste für ein konkretes Projekt. + if (projektName === "") { + fetchProjektById(projektId).then((projekt: iProjekt) => { + projektName = projekt.projektname + }).catch((e) => showServerError(e)) + } + loadAllUsers() + loadProjektUsers() + } else { + // Übersichts-Sicht: alle Projekte + Zuordnungen + Nutzer laden. + loadOverviewData() } - // Alle Nutzer für das Hinzufügen-Modal vorab laden. - loadAllUsers() - // Zugeordnete Nutzer laden. - loadProjektUsers() }, view: (vnode) => { return m("div.w3-container.w3-auto", @@ -272,26 +420,38 @@ export const projektuserliste: m.ClosureComponent = () => { 'overflow-y': 'inherit' } }, - m("h1", projektName - ? "Nutzerberechtigungen für Projekt: " + projektName - : "Nutzerberechtigungen"), + m("h1", detailMode + ? (projektName + ? "Nutzerberechtigungen für Projekt: " + projektName + : "Nutzerberechtigungen") + : "Projekt-Zugriffe — Übersicht"), m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, - m(Treetable, - { - treenodes: treenodes, - columnmapper: columnmapper, - filter: filterFunction, - onRowMarked: handleRowMarked, - isSelected: (node: iTreenode) => node === selectedNode - } as iTreetable, - m(TopbarButton, + detailMode + ? m(Treetable, { - onclick: openAddModal, - tooltip: "Nutzer hinzufügen" - } as iTopbarButton, - m("i.material-icons", "person_add") + treenodes: treenodes, + columnmapper: columnmapper, + filter: filterFunction, + onRowMarked: handleRowMarked, + isSelected: (node: iTreenode) => node === selectedNode + } as iTreetable, + m(TopbarButton, + { + onclick: openAddModal, + tooltip: "Nutzer hinzufügen" + } as iTopbarButton, + m("i.material-icons", "person_add") + ) + ) + : m(Treetable, + { + treenodes: treenodes, + columnmapper: overviewColumnmapper, + filter: overviewFilterFunction, + onRowMarked: handleRowMarked, + isSelected: (node: iTreenode) => node === selectedNode + } as iTreetable ) - ) ) ) } diff --git a/Client/Components/user_projektliste.ts b/Client/Components/user_projektliste.ts new file mode 100644 index 0000000..8deb9b1 --- /dev/null +++ b/Client/Components/user_projektliste.ts @@ -0,0 +1,313 @@ +import m from "mithril" +import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2" +import {ModalView, ModalType, iModalView} from "./widgets/Modal" +import {gridMountpoints} from "./mainlayout" +import {iProjektUserWithProjekt, fetchAllProjektUsers, addProjektUserREST, removeProjektUserREST} from "../models/projekt_user" +import {iProjekt, fetchProjekte} from "../models/projekt" +import {iUser} from "./auth/models/users" +import {showServerError} from "./auth/authmessages" + +/** + * Übersicht "Nutzer-Zugriffe": Zeigt alle Nutzer (ohne SysAdmin/AppAdmin) + * als Top-Level-Knoten mit ihren jeweils zugeordneten Projekten als + * Child-Knoten. Der AppAdmin kann Projekte zuordnen und entfernen. + */ +export const userprojektliste: m.ClosureComponent = () => { + let treenodes: iTreenode[] = [] + let selectedNode: iTreenode | null = null + // Alle Nutzer aus der Auth-DB. + let allUsers: iUser[] = [] + // Alle Projekt-User-Zuordnungen (inkl. projektname). + let allZuordnungen: iProjektUserWithProjekt[] = [] + // Alle Projekte (für das Hinzufügen-Modal). + let allProjekte: iProjekt[] = [] + + const handleRowMarked = (node: iTreenode) => { + selectedNode = node + } + + /** + * Lädt alle Nutzer, alle Projekte und alle Zuordnungen und baut + * den Treenode-Baum (Nutzer → Projekte) auf. + */ + const loadData = () => { + Promise.all([ + m.request({method: "GET", url: "/auth/getusers"}), + fetchProjekte(), + fetchAllProjektUsers() + ]).then(([usersResult, projekteResult, zuordnungenResult]) => { + allUsers = usersResult as iUser[] + allProjekte = projekteResult as iProjekt[] + allZuordnungen = zuordnungenResult as iProjektUserWithProjekt[] + buildTreenodes() + }).catch((e) => showServerError(e)) + } + + /** + * Baut den Treenode-Baum: Top-Level = Nutzer (ohne SysAdmin/AppAdmin), + * Child-Nodes = zugeordnete Projekte. + */ + const buildTreenodes = () => { + // Nur Nutzer ohne SysAdmin/AppAdmin-Rolle anzeigen. + const nutzer = allUsers.filter((u: iUser) => { + if (u.isSysAdmin) return false + if (u.isAppAdmin) return false + return true + }).sort((a: iUser, b: iUser) => { + return (a.username || "").localeCompare(b.username || "") + }) + + treenodes = nutzer.map((u: iUser) => { + // Zugeordnete Projekte für diesen Nutzer. + const projektNodes = allZuordnungen + .filter((z) => z.user_id === u.id) + .map((z) => new Treenode(z, true, [])) + return new Treenode(u, true, projektNodes) + }) + } + + /** + * Entfernt ein Projekt von einem Nutzer. + */ + const removeProjekt = (zuordnung: iProjektUserWithProjekt, e: Event) => { + e.stopPropagation() + removeProjektUserREST(zuordnung.projekt_id, zuordnung.user_id).then(() => { + loadData() + }).catch((e) => showServerError(e)) + } + + /** + * Columnmapper für Top-Level (Nutzer-Zeilen). + */ + const nutzerColumnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { + let u: iUser = node.nodevalue + return [ + m("span", m("strong", u.userid)), + m("span", u.username), + m("span", u.email), + m("span.w3-small.w3-opacity", node.children.length + " Projekt(e)") + ] + } + + /** + * Columnmapper für Child-Level (Projekt-Zeilen). + * Nur Anzeige — keine Buttons. + */ + const projektColumnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { + let z: iProjektUserWithProjekt = node.nodevalue + return [ + m("span", m("strong", z.projektname)), + m("span", ""), + m("span", ""), + m("span", "") + ] + } + + // Der Treetable verwendet pro Level denselben columnmapper. Da Nutzer- + // und Projekt-Zeilen unterschiedlich aussehen, wird über die + // nodevalue-Eigenschaft zwischen ihnen unterschieden. + const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { + // Wenn die nodevalue eine projekt_id-Eigenschaft hat, ist es + // eine Projekt-Zuordnung (Child-Knoten), sonst ein Nutzer (Top-Level). + if (node.nodevalue && node.nodevalue.projekt_id !== undefined) { + return projektColumnmapper(node) + } + return nutzerColumnmapper(node) + } + + const filterFunction = (node: iTreenode, searchvalue: string): boolean => { + let searchLower = searchvalue.toLowerCase() + let nv = node.nodevalue + if (nv && nv.projekt_id !== undefined) { + // Projekt-Zeile: nach projektname filtern. + return (nv.projektname || "").toLowerCase().includes(searchLower) + } + // Nutzer-Zeile: nach userid, username, email filtern. + // Bei Suche auch Treffer in den Child-Projekten anzeigen. + if ((nv.userid || "").toLowerCase().includes(searchLower) || + (nv.username || "").toLowerCase().includes(searchLower) || + (nv.email || "").toLowerCase().includes(searchLower)) { + return true + } + // Prüfen, ob ein Child-Projekt den Suchtext enthält. + return node.children.some((child) => { + let z = child.nodevalue + return (z.projektname || "").toLowerCase().includes(searchLower) + }) + } + + /** + * Öffnet das Modal zum Hinzufügen von Projekten zu einem Nutzer. + */ + const openAddModal = (user: iUser) => { + m.mount(gridMountpoints.modalhook, { + view: () => m(addProjektModal, {user: user}) + }) + } + + /** + * Modal zum Hinzufügen mehrerer Projekte zu einem Nutzer. + * Zeigt eine durchsuchbare Checkbox-Liste aller Projekte, die dem + * Nutzer noch nicht zugeordnet sind. + */ + const addProjektModal: m.ClosureComponent<{user: iUser}> = () => { + let selectedIds: Set = new Set() + let search: string = "" + + const getAvailableProjekte = (): iProjekt[] => { + const assignedIds = new Set( + allZuordnungen + .filter((z) => z.user_id === vnode_userId) + .map((z) => z.projekt_id) + ) + const searchLower = search.toLowerCase() + return allProjekte.filter((p: iProjekt) => { + if (assignedIds.has(p.id)) return false + if (searchLower === "") return true + return (p.projektname || "").toLowerCase().includes(searchLower) || + (p.kunden_name || "").toLowerCase().includes(searchLower) + }).sort((a: iProjekt, b: iProjekt) => { + return (a.projektname || "").localeCompare(b.projektname || "") + }) + } + + // Die user_id wird in der Closure gehalten, da der Closure- + // Parameter vom Typ m.ClosureComponent ist und attrs nur im + // view/oninit verfügbar sind. + let vnode_userId: number = -1 + + const closeModal = () => { + m.mount(gridMountpoints.modalhook, null) + } + + const confirmAdd = () => { + if (selectedIds.size === 0) { + closeModal() + return + } + const promises: Promise[] = [] + selectedIds.forEach((pid: number) => { + promises.push(addProjektUserREST(pid, vnode_userId)) + }) + Promise.all(promises).then(() => { + closeModal() + loadData() + }).catch((e) => { + showServerError(e) + loadData() + }) + } + + const toggleProjekt = (projektId: number) => { + if (selectedIds.has(projektId)) { + selectedIds.delete(projektId) + } else { + selectedIds.add(projektId) + } + } + + return { + oninit: (vnode) => { + vnode_userId = vnode.attrs.user.id + }, + view: (vnode) => { + const user = vnode.attrs.user + const availableProjekte = getAvailableProjekte() + return m(ModalView, + { + title: "Projekt zuordnen", + type: ModalType.MESSAGE, + width: '500px' + } as iModalView, + m("div.w3-padding-16", + m("p", "Wählen Sie die Projekte aus, die dem Nutzer \"" + user.username + "\" zugeordnet werden sollen:"), + m("label", "Suche"), + m("input.w3-input.w3-border", + { + type: "text", + value: search, + placeholder: "Nach Projektname oder Kunde suchen", + style: {'margin-bottom': '8px'}, + oninput: (e: Event) => { + search = (e.target as HTMLInputElement).value + } + } + ), + m("div", + { + style: { + 'max-height': '300px', + 'overflow-y': 'auto', + 'margin-top': '8px', + border: '1px solid #ccc' + } + }, + availableProjekte.length === 0 + ? m("div.w3-padding-small", "Keine weiteren Projekte verfügbar") + : availableProjekte.map((p: iProjekt) => + m("label.w3-block.w3-hover-light-grey.w3-text-left", + { + style: {'border-bottom': '1px solid #eee', padding: '4px 8px'}, + onclick: () => toggleProjekt(p.id) + }, + m("input", + { + type: "checkbox", + checked: selectedIds.has(p.id), + style: {'margin-right': '8px'}, + onclick: (e: Event) => e.stopPropagation(), + onchange: (e: Event) => toggleProjekt(p.id) + } + ), + m("strong", p.projektname), + " (" + p.kunden_name + ")" + ) + ) + ), + m("div.w3-bar.w3-padding-16", + m("span.w3-small.w3-left", + {style: {'line-height': '36px', 'margin-right': '8px'}}, + selectedIds.size + " ausgewählt"), + m("button.w3-button.w3-theme.w3-right", + {style: {'margin-left': '4px'}, onclick: confirmAdd}, + "Zuordnen"), + m("button.w3-button.w3-theme.w3-right", + {style: {'margin-left': '4px'}, onclick: closeModal}, + "Abbrechen") + ) + ) + ) + } + } + } + + return { + oninit: (vnode) => { + loadData() + }, + view: (vnode) => { + return m("div.w3-container.w3-auto", + { + style: { + display: 'flex', + 'flex-direction': 'column', + height: 'inherit', + 'overflow-y': 'inherit' + } + }, + m("h1", "Nutzer-Zugriffe — Übersicht"), + m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, + m(Treetable, + { + treenodes: treenodes, + columnmapper: columnmapper, + filter: filterFunction, + onRowMarked: handleRowMarked, + isSelected: (node: iTreenode) => node === selectedNode + } as iTreetable + ) + ) + ) + } + } +} diff --git a/Client/Components/widgets/Treetable_V2.ts b/Client/Components/widgets/Treetable_V2.ts index 49ff947..265089d 100644 --- a/Client/Components/widgets/Treetable_V2.ts +++ b/Client/Components/widgets/Treetable_V2.ts @@ -51,28 +51,35 @@ export const Treetable: m.ClosureComponent = (vnode) => { } return { view: (vnode) => { + // Prüfen, ob mindestens ein Top-Level-Node Kinder hat. + // Wird benötigt, um bei kinderlosen Top-Level-Nodes den + // Platzhalter-Pfeil nur dann zu rendern, wenn es Geschwister + // mit Kindern (und damit Pfeil) gibt. + const topHasSiblingsWithChildren = vnode.attrs.treenodes.some( + (tn: iTreenode) => tn.hasChildren + ) return m("div.w3-border-theme", {style: {height: '99%', overflow: 'hidden', 'border-style': 'solid', 'border-width': '1px'}}, m("div.w3-bar.w3-theme", m(TopbarButton, { onclick: () => { expandtoggle = true; changeTreenodes(vnode.attrs.treenodes, expandtoggle) - }}, + }}, m("i.material-icons", "unfold_more") ), m(TopbarButton, { onclick: () => { expandtoggle = false; changeTreenodes(vnode.attrs.treenodes, expandtoggle) - }}, + }}, m("i.material-icons", "unfold_less") ), vnode.children, - (vnode.attrs.filter != undefined) ? m(SearchField, {setSearchvalue: setSearchvalue} as iSearchfield) : null + (vnode.attrs.filter != undefined) ? m(SearchField, {setSearchvalue: setSearchvalue} as iSearchfield) : null ), - m("div", {style: {height: '100%'}}, + m("div", {style: {height: '100%'}}, m("div", {style: {height: 'calc(100% - 32px', overflow: 'auto', width: '100%'}}, m("table.w3-table", {style: {'overflow-y': 'auto'}}, vnode.attrs.treenodes.map((tn:iTreenode, z) => { - if (vnode.attrs.filter == undefined || (vnode.attrs.filter != undefined && vnode.attrs.filter(tn, searchvalue) == true)) { + if (vnode.attrs.filter == undefined || (vnode.attrs.filter != undefined && vnode.attrs.filter(tn, searchvalue) == true)) { return m(Treerow, { // ACHTUNG: Änderungen hier müssen unten beim rekursiven Aufruf der Treerow-Funktion ebenfalls eingebaut werden treenode: tn, @@ -80,7 +87,8 @@ export const Treetable: m.ClosureComponent = (vnode) => { columnmapper1: vnode.attrs.columnmapper, onRowMarked: onRowMarked, isSelected: isSelected, - isEven: ((z % 2) == 0)} as iTreerow) + isEven: ((z % 2) == 0), + hasSiblingsWithChildren: topHasSiblingsWithChildren} as iTreerow) } else { return null @@ -101,6 +109,11 @@ interface iTreerow { onRowMarked: (node: iTreenode) => void isSelected?: (node: iTreenode) => boolean isEven?: boolean + /** True, wenn mindestens ein Geschwister-Node auf demselben Level + * Kinder hat. Wird verwendet, um bei kinderlosen Top-Level-Nodes + * den Platzhalter-Pfeil nur dann zu rendern, wenn es Nodes mit + * Pfeil gibt, an denen der Text ausgerichtet werden kann. */ + hasSiblingsWithChildren?: boolean } let Treerow: m.ClosureComponent = (vnode) => { @@ -110,6 +123,10 @@ let Treerow: m.ClosureComponent = (vnode) => { // Zeile selektiert ist; in diesem Fall wird der Hintergrund der // gesamten -Zeile eingefärbt. const rowSelected = vnode.attrs.isSelected ? vnode.attrs.isSelected(vnode.attrs.treenode) : false + // Prüfen, ob mindestens ein Child-Node selbst Kinder hat. + const childHasSiblingsWithChildren = vnode.attrs.treenode.children.some( + (tn: iTreenode) => tn.hasChildren + ) return [ m("tr.w3-border-theme", {onmouseup: () => vnode.attrs.onRowMarked(vnode.attrs.treenode), onkeyup: () => vnode.attrs.onRowMarked(vnode.attrs.treenode), @@ -135,10 +152,13 @@ let Treerow: m.ClosureComponent = (vnode) => { // angezeigt werden, allerdings soll durch 'margin-left' der entsprechende Platz des // Buttons eingerückt werden, damit die Einrückungs-Level auch bei "kinderlosen" Treenodes // denen der Trennodes mit Kindern entsprechen - : vnode.attrs.level == 0 - ? null // Wenn der kinderlos Treenode allerdings sirekt auf Level 0 ist, soll nocht - // eingerückt werden, da es bei Level 0 keine Eltern-Nodes mit Kindern und - // demzufolge mit "expand_more/expand_less"-Button gibt. + : vnode.attrs.level == 0 + ? (vnode.attrs.hasSiblingsWithChildren + ? m("span", { + style: {'margin-left': '0px', + 'margin-right': '32px'} + }) + : null) : m( "span", { style: {'margin-left': '24px', @@ -165,7 +185,8 @@ let Treerow: m.ClosureComponent = (vnode) => { columnmapper1: vnode.attrs.columnmapper1, onRowMarked: vnode.attrs.onRowMarked, isSelected: vnode.attrs.isSelected, - isEven: ((z % 2) == 0) + isEven: ((z % 2) == 0), + hasSiblingsWithChildren: childHasSiblingsWithChildren } as iTreerow) }) : null diff --git a/Client/index.ts b/Client/index.ts index 0dc110a..bbd326e 100644 --- a/Client/index.ts +++ b/Client/index.ts @@ -5,7 +5,8 @@ import { appmanagementpage } from "./Components/auth/appmanagementpage" import { changepasswordscreen, loginscreen } from "./Components/auth/login" import {kunde, kundenliste} from "./Components/kunde" import { Projekt, projektliste } from "./Components/projekt" -import { projektuserliste } from "./Components/projekt_user" +import { projektuserliste } from "./Components/projekt_userliste" +import { userprojektliste } from "./Components/user_projektliste" import { Call_Log, call_log_liste } from "./Components/call_log" import { Kontakt, kontaktliste } from "./Components/kontakt" import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse" @@ -63,6 +64,7 @@ function logout() { console.log(result) sessionStorage.removeItem("CurrentUser"); sessionStorage.removeItem("CurrentUserIsSysAdmin"); + sessionStorage.removeItem("CurrentUserIsAppAdmin"); headerdata.username = "Nicht angemeldet" if (result['logout'] == true) {m.route.set("/blankpage")} }) @@ -101,31 +103,59 @@ const knopf: m.Component = { return m("button", {onclick: healthcheck}, "Aktualisieren") } } -const menu: m.Component = { - 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"), - // m("a.w3-button.w3-bar-item", {onclick: healthcheck}, "Aktualisieren"), - 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"), +const menu: m.ClosureComponent = () => { + let showProjektBerechtigungen = false + 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"), + 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"), + // 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. + (sessionStorage.getItem("CurrentUserIsSysAdmin") || sessionStorage.getItem("CurrentUserIsAppAdmin")) + ? [ + m("button.w3-button.w3-bar-item", + { + onclick: () => {showProjektBerechtigungen = !showProjektBerechtigungen} + }, + m("span", showProjektBerechtigungen ? "Projekt-Berechtigungen \u25B2" : "Projekt-Berechtigungen \u25BC") + ), + showProjektBerechtigungen + ? m("div.w3-bar-block", + m("a.w3-button.w3-bar-item", + {href: "#!/projektuserliste", style: {'padding-left': '32px'}}, + "Projekt-Zugriffe"), + m("a.w3-button.w3-bar-item", + {href: "#!/userprojektliste", style: {'padding-left': '32px'}}, + "Nutzer-Zugriffe") + ) + : null + ] + : null, + ) + ), + m("div.w3-button.w3-right", {onclick: logout}, "Logout"), + 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"), + ) ) - ), - m("div.w3-button.w3-right", {onclick: logout}, "Logout"), - 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"), - ) - ) - ]) + ]) + } } } @@ -154,6 +184,7 @@ m.route(gridMountpoints.main, "/blankpage", { "/projektliste": projektliste, "/projekt": Projekt, "/projektuserliste": projektuserliste, + "/userprojektliste": userprojektliste, "/calllogliste": call_log_liste, "/call_log": Call_Log, "/kontaktliste": kontaktliste, diff --git a/Client/models/projekt_user.ts b/Client/models/projekt_user.ts index 6697189..88afd5e 100644 --- a/Client/models/projekt_user.ts +++ b/Client/models/projekt_user.ts @@ -17,6 +17,15 @@ export interface iProjektUser { email: string } +/** + * Erweitertes Interface für die Übersichtssicht "Nutzer-Zugriffe". + * Enthält zusätzlich den Projektnamen, der serverseitig aus der + * Projekt-Relationship aufgelöst wird. + */ +export interface iProjektUserWithProjekt extends iProjektUser { + projektname: string +} + /* ------------------------------------------------------------------ */ /* REST-API-Aufrufe (Client-Seite) */ /* ------------------------------------------------------------------ */ @@ -61,3 +70,14 @@ export function removeProjektUserREST(projektId: number, userId: number): Promis params: {projekt_id: projektId, user_id: userId} }).then(() => undefined) } + +/** + * Lädt alle Projekt-User-Zuordnungen inkl. projektname und Nutzerdaten. + * Nur für AppAdmin/SysAdmin. + */ +export function fetchAllProjektUsers(): Promise { + return m.request({ + method: "GET", + url: PROJEKTUSER_REST_BASE + "/listall" + }).then((result) => result as iProjektUserWithProjekt[]) +}