From 9af789b9967fe692b20d692a53246c43e2457574 Mon Sep 17 00:00:00 2001 From: Gerd Date: Tue, 1 Sep 2026 09:40:44 +0200 Subject: [PATCH] =?UTF-8?q?Kontakte:=20Modell,=20Liste=20und=20Formular=20?= =?UTF-8?q?f=C3=BCr=20Ansprechpartner=20beim=20Kunden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Entität "Kontakt" (Ansprechpartner, FK kundenId) analog zu Projekte: - Client/models/kontakt.ts: Interface, In-Memory-Mock, CRUD- und REST-Stubs - Client/Components/kontakt.ts: Kontaktliste (Treetable, kundengefiltert) und Stammblatt-Formular - index.ts: Routen /kontaktliste und /kontakt (kein Hauptmenü-Eintrag) - kunde.ts: Kontakte-Button in Kundenliste und im Kunden-Formular (nur bei bestehendem Kunden) Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- Client/Components/kontakt.ts | 225 +++++++++++++++++++++++++++++++++++ Client/Components/kunde.ts | 17 ++- Client/index.ts | 3 + Client/models/kontakt.ts | 147 +++++++++++++++++++++++ 4 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 Client/Components/kontakt.ts create mode 100644 Client/models/kontakt.ts diff --git a/Client/Components/kontakt.ts b/Client/Components/kontakt.ts new file mode 100644 index 0000000..cfff427 --- /dev/null +++ b/Client/Components/kontakt.ts @@ -0,0 +1,225 @@ +import m from "mithril" +import {Form, iForm} from "./widgets/form" +import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs" +import {Select, iSelect, iSelectOption} from "./widgets/select" +import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2" +import {iKontakt, getKontakteByKundenId, getNewKontaktId, addKontakt, updateKontakt} from "../models/kontakt" +import {iKunde, getKunden} from "../models/kunde" + +function createKontaktTreenodes(kundenId: number | null = null): iTreenode[] { + let filteredKontakte = getKontakteByKundenId(kundenId) + + return filteredKontakte.map((kontakt: iKontakt) => { + return new Treenode(kontakt, true, []) + }) +} + +export const kontaktliste: m.ClosureComponent = (vnode) => { + let treenodes: iTreenode[] = createKontaktTreenodes() + let selectedNode: iTreenode | null = null + let filterKundenId: number | null = null + + const handleRowMarked = (node: iTreenode) => { + selectedNode = node + } + + const addKontaktRoute = () => { + const params: any = { + id: -1, + anrede: "", + name: "", + position: "", + telefon: "", + email: "", + notiz: "" + } + // Nur bei kundengefilterter Liste den Kunden vorbelegen. + if (filterKundenId !== null) { + const kunde = getKunden().find((k: iKunde) => k.id === filterKundenId) + if (kunde) { + params.kundenId = kunde.id + params.kundenname = kunde.name + } + } + m.route.set("/kontakt", params) + } + + const editKontakt = (kontakt: iKontakt, e: Event) => { + e.stopPropagation() + m.route.set("/kontakt", { + id: kontakt.id, + kundenId: kontakt.kundenId, + kundenname: kontakt.kundenName, + anrede: kontakt.anrede, + name: kontakt.name, + position: kontakt.position, + telefon: kontakt.telefon, + email: kontakt.email, + notiz: kontakt.notiz + }) + } + + const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { + let kontakt: iKontakt = node.nodevalue + const isSelected = selectedNode === node + const cellStyle = isSelected ? {backgroundColor: '#e6f7ff'} : {} + + return [ + m("div", {style: cellStyle}, m("strong", kontakt.name)), + m("span", {style: cellStyle}, kontakt.position), + m("span", {style: cellStyle}, kontakt.telefon), + m("span", {style: cellStyle}, kontakt.email), + m("span", {style: cellStyle}, kontakt.kundenName), + m("button.w3-button.w3-small", {style: cellStyle, onclick: (e: Event) => editKontakt(kontakt, e)}, m("i.material-icons", "edit")) + ] + } + + const filterFunction = (node: iTreenode, searchvalue: string): boolean => { + let kontakt: iKontakt = node.nodevalue + let searchLower = searchvalue.toLowerCase() + return kontakt.name.toLowerCase().includes(searchLower) || + kontakt.position.toLowerCase().includes(searchLower) || + kontakt.telefon.toLowerCase().includes(searchLower) || + kontakt.email.toLowerCase().includes(searchLower) || + kontakt.kundenName.toLowerCase().includes(searchLower) + } + + return { + oninit: (vnode) => { + const routeParams = m.route.param() + if (routeParams && routeParams.kundenId) { + filterKundenId = parseInt(routeParams.kundenId) + } else { + filterKundenId = null + } + treenodes = createKontaktTreenodes(filterKundenId) + }, + view: (vnode) => { + return m("div.w3-container.w3-auto", { + style: { + display: 'flex', + 'flex-direction': 'column', + height: 'inherit', + 'overflow-y': 'inherit'}}, + m("h1", "Kontaktliste"), + m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, + m(Treetable, + { + treenodes: treenodes, + columnmapper: columnmapper, + filter: filterFunction, + onRowMarked: handleRowMarked + } as iTreetable, + m(TopbarButton, {onclick: addKontaktRoute, tooltip: "Neuen Kontakt anlegen"} as iTopbarButton, + m("i.material-icons", "person_add") + ) + ) + ) + ) + } + } +} + +export const Kontakt: m.ClosureComponent = (vnode) => { + // currentKontakt hält den Formularzustand. kundenId referenziert den + // Kunden eindeutig, kundenname dient der Anzeige. + let currentKontakt: { + id: number + kundenId: number + kundenname: string + anrede: string + name: string + position: string + telefon: string + email: string + notiz: string + } = { + id: -1, + kundenId: -1, + kundenname: "", + anrede: "", + name: "", + position: "", + telefon: "", + email: "", + notiz: "" + } + // Ist der Kunde voreingestellt (gefilterte Kontaktliste)? + let kundeVoreingestellt: boolean = false + + return { + oninit: (vnode) => { + const routeParams = m.route.param() + if (routeParams) { + const kundenId = routeParams.kundenId ? parseInt(routeParams.kundenId) : -1 + kundeVoreingestellt = kundenId !== -1 + currentKontakt = { + id: routeParams.id ? parseInt(routeParams.id) : -1, + kundenId: kundenId, + kundenname: routeParams.kundenname || "", + anrede: routeParams.anrede || "", + name: routeParams.name || "", + position: routeParams.position || "", + telefon: routeParams.telefon || "", + email: routeParams.email || "", + notiz: routeParams.notiz || "" + } + } + }, + view: (vnode) => { + const submit = (form: HTMLFormElement): boolean => { + const formData = new FormData(form) + const kundenId = parseInt(formData.get("kundenId") as string) + // Kundennamen zur gewählten kundenId auflösen. + const kunde = getKunden().find((k: iKunde) => k.id === kundenId) + const gespeicherterKontakt: iKontakt = { + id: currentKontakt.id, + kundenId: kundenId, + kundenName: kunde ? kunde.name : "", + anrede: (formData.get("anrede") as string) || "", + name: (formData.get("name") as string) || "", + position: (formData.get("position") as string) || "", + telefon: (formData.get("telefon") as string) || "", + email: (formData.get("email") as string) || "", + notiz: (formData.get("notiz") as string) || "" + } + if (currentKontakt.id === -1) { + gespeicherterKontakt.id = getNewKontaktId() + addKontakt(gespeicherterKontakt) + } else { + updateKontakt(gespeicherterKontakt) + } + m.route.set("/kontaktliste") + return true + } + const cancel = (e: Event) => { + m.route.set("/kontaktliste") + } + // Select-Optionen aus der Kundenliste aufbauen. + const kundenOptionen: iSelectOption[] = getKunden().map((k: iKunde) => { + return {value: k.id, label: k.name} + }) + return m("div.w3-container.w3-auto.form-view-container", + m("h1", currentKontakt.id === -1 ? "Neuen Kontakt anlegen" : "Kontakt-Stammblatt"), + m("div", {style: {flex: '1', 'min-height': '0'}}, + m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [ + m("label", "Kunde", + m(Select, { + name: "kundenId", + value: currentKontakt.kundenId, + options: kundenOptionen, + readonly: kundeVoreingestellt, + required: true + } as iSelect)), + 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)), + m("label", "Position", m(Input, {name: "position", value: currentKontakt.position, type: "text"} as iInput)), + m("label", "Telefon", m(Input, {name: "telefon", value: currentKontakt.telefon, type: "tel"} as iInput)), + m("label", "E-Mail", m(Input, {name: "email", value: currentKontakt.email, type: "email"} as iInput)), + m("label", "Notiz", m(Textarea, {name: "notiz", value: currentKontakt.notiz, rows: 4} as iTextarea)) + ]) + ) + ) + } + } +} diff --git a/Client/Components/kunde.ts b/Client/Components/kunde.ts index 238cf1d..c5c0f2d 100644 --- a/Client/Components/kunde.ts +++ b/Client/Components/kunde.ts @@ -52,7 +52,14 @@ export const kunde: m.ClosureComponent = (vnode) => { m.route.set("/kundenliste") } return m("div.w3-container.w3-auto.form-view-container", - m("h1", currentKunde.id === -1 ? "Neuen Kunden anlegen" : "Kundenstammblatt"), + 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", {kundenId: currentKunde.id})}, + m("i.material-icons", "contact_page"), " Kontakte") + : null + ), 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)), @@ -96,6 +103,11 @@ export const kundenliste: m.ClosureComponent = (vnode) => { e.stopPropagation() m.route.set("/projektliste", {kundenId: kunde.id}) } + + const showKontakte = (kunde: iKunde, e: Event) => { + e.stopPropagation() + m.route.set("/kontaktliste", {kundenId: kunde.id}) + } const addKundeRoute = () => { m.route.set("/kunde", {id: -1, name: "", strasse: "", plz: "", ort: "", land: ""}) @@ -113,7 +125,8 @@ export const kundenliste: m.ClosureComponent = (vnode) => { m("span", {style: cellStyle}, kunde.ort), m("span", {style: cellStyle}, kunde.land), m("button.w3-button.w3-small", {style: cellStyle, onclick: (e: Event) => editKunde(kunde, e)}, m("i.material-icons", "edit")), - m("button.w3-button.w3-small", {style: cellStyle, onclick: (e: Event) => showProjekte(kunde, e)}, m("i.material-icons", "library_books")) + m("button.w3-button.w3-small", {style: cellStyle, onclick: (e: Event) => showProjekte(kunde, e)}, m("i.material-icons", "library_books")), + m("button.w3-button.w3-small", {title: "Kontakte anzeigen", style: cellStyle, onclick: (e: Event) => showKontakte(kunde, e)}, m("i.material-icons", "contact_page")) ] } diff --git a/Client/index.ts b/Client/index.ts index f7fc73b..dd6859c 100644 --- a/Client/index.ts +++ b/Client/index.ts @@ -6,6 +6,7 @@ import { changepasswordscreen, loginscreen } from "./Components/auth/login" import {kunde, kundenliste} from "./Components/kunde" import { Projekt, projektliste } from "./Components/projekt" import { Call_Log, call_log_liste } from "./Components/call_log" +import { Kontakt, kontaktliste } from "./Components/kontakt" import { Tag, TagList } from "./Components/tags" @@ -152,6 +153,8 @@ m.route(gridMountpoints.main, "/blankpage", { "/projekt": Projekt, "/calllogliste": call_log_liste, "/call_log": Call_Log, + "/kontaktliste": kontaktliste, + "/kontakt": Kontakt, "/taglist": TagList, "/tag": Tag diff --git a/Client/models/kontakt.ts b/Client/models/kontakt.ts new file mode 100644 index 0000000..a198e6a --- /dev/null +++ b/Client/models/kontakt.ts @@ -0,0 +1,147 @@ +import m from "mithril" + +/** + * Datenmodell für einen Kontakt (Ansprechpartner beim Kunden). + * Jeder Kontakt ist genau einem Kunden zugeordnet (FK kundenId). + */ +export interface iKontakt { + id: number + kundenId: number + kundenName: string + anrede: string + name: string + position: string + telefon: string + email: string + notiz: string +} + +/** + * In-Memory-Datenspeicher für Kontakte. Wird als Fallback solange verwendet, + * bis die serverseitigen REST-Endpunkte verfügbar sind. + */ +export const kontaktDaten: iKontakt[] = [ + {id: 1, kundenId: 1, kundenName: "Musterfirma GmbH", anrede: "Herr", name: "Max Mustermann", position: "Geschäftsführer", telefon: "+49 123 45678", email: "m.mustermann@musterfirma.de", notiz: "Erster Ansprechpartner für das Website-Projekt."}, + {id: 2, kundenId: 1, kundenName: "Musterfirma GmbH", anrede: "Frau", name: "Anna Schmidt", position: "Marketingleiterin", telefon: "+49 123 45679", email: "a.schmidt@musterfirma.de", notiz: "Zuständig für Design-Fragen."}, + {id: 3, kundenId: 2, kundenName: "Beispiel AG", anrede: "Herr", name: "Peter Müller", position: "IT-Leiter", telefon: "+49 543 21012", email: "p.mueller@beispiel-ag.de", notiz: "Technischer Ansprechpartner."}, + {id: 4, kundenId: 3, kundenName: "Test Unternehmen", anrede: "Frau", name: "Lisa Bauer", position: "Projektmanagerin", telefon: "+49 678 90123", email: "l.bauer@test-unternehmen.com", notiz: ""} +] + +/** + * Liefert die nächste freie Kontakt-ID auf Basis des In-Memory-Speichers. + * Sobald die serverseitige ID-Vergabe verfügbar ist, entfällt diese Funktion. + */ +export function getNewKontaktId(): number { + if (kontaktDaten.length === 0) { + return 1 + } + return Math.max(...kontaktDaten.map((kontakt: iKontakt) => kontakt.id)) + 1 +} + +/** + * Fügt einen neuen Kontakt dem In-Memory-Speicher hinzu. + */ +export function addKontakt(kontakt: iKontakt): void { + kontaktDaten.push(kontakt) +} + +/** + * Aktualisiert einen bestehenden Kontakt im In-Memory-Speicher. + */ +export function updateKontakt(kontakt: iKontakt): void { + const index = kontaktDaten.findIndex((k: iKontakt) => k.id === kontakt.id) + if (index !== -1) { + kontaktDaten[index] = kontakt + } +} + +/** + * Liefert eine Kopie aller Kontakte aus dem In-Memory-Speicher. + */ +export function getKontakte(): iKontakt[] { + return [...kontaktDaten] +} + +/** + * Liefert die Kontakte eines bestimmten Kunden. Ist kundenId null, werden + * alle Kontakte zurückgegeben. + */ +export function getKontakteByKundenId(kundenId: number | null): iKontakt[] { + return kundenId + ? kontaktDaten.filter((k: iKontakt) => k.kundenId === kundenId) + : [...kontaktDaten] +} + +/** + * Liefert einen einzelnen Kontakt anhand seiner ID oder undefined. + */ +export function getKontaktById(id: number): iKontakt | undefined { + return kontaktDaten.find((k: iKontakt) => k.id === id) +} + +/* ------------------------------------------------------------------ */ +/* REST-API-Aufrufe (Client-Seite) */ +/* ------------------------------------------------------------------ */ +/* Die folgenden Funktionen kapseln die Kommunikation mit den */ +/* serverseitigen REST-Endpunkten. Die Endpunkte sind noch nicht */ +/* implementiert und werden später nachgereicht. */ +/* ------------------------------------------------------------------ */ + +/** + * Basis-Pfad der Kontakt-REST-Endpunkte. + */ +const KONTAKT_REST_BASE = "/xhr/kontakt" + +/** + * Lädt die Kontaktliste vom Server. + */ +export function fetchKontakte(): Promise { + return m.request({ + method: "GET", + url: KONTAKT_REST_BASE + "/list" + }).then((result) => result as iKontakt[]) +} + +/** + * Lädt einen einzelnen Kontakt anhand seiner ID vom Server. + */ +export function fetchKontaktById(id: number): Promise { + return m.request({ + method: "GET", + url: KONTAKT_REST_BASE + "/get", + params: {id: id} + }).then((result) => result as iKontakt) +} + +/** + * Legt einen neuen Kontakt serverseitig an. + */ +export function createKontaktREST(kontakt: iKontakt): Promise { + return m.request({ + method: "POST", + url: KONTAKT_REST_BASE + "/create", + body: kontakt + }).then((result) => result as iKontakt) +} + +/** + * Aktualisiert einen bestehenden Kontakt serverseitig. + */ +export function updateKontaktREST(kontakt: iKontakt): Promise { + return m.request({ + method: "PUT", + url: KONTAKT_REST_BASE + "/update", + body: kontakt + }).then((result) => result as iKontakt) +} + +/** + * Löscht einen Kontakt serverseitig anhand seiner ID. + */ +export function deleteKontaktREST(id: number): Promise { + return m.request({ + method: "DELETE", + url: KONTAKT_REST_BASE + "/delete", + params: {id: id} + }).then(() => undefined) +}