From 3e1791d31612c168915906530a3ba490fe148e2b Mon Sep 17 00:00:00 2001 From: Gerd Date: Tue, 1 Sep 2026 10:31:25 +0200 Subject: [PATCH] =?UTF-8?q?Kontaktadressen:=20Modell,=20Liste=20und=20Form?= =?UTF-8?q?ular=20f=C3=BCr=20Akquise-Ziele=20auf=20Projektebene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Entität "Kontaktadresse" (Akquise-Ziel, FK projektId) analog zu Kontakten: - Client/models/kontaktadresse.ts: Interface, In-Memory-Mock, CRUD- und REST-Stubs - Client/Components/kontaktadresse.ts: Kontaktadressenliste (Treetable, projektgefiltert) und Stammblatt-Formular - index.ts: Routen /kontaktadresseliste und /kontaktadresse (kein Hauptmenü-Eintrag) - projekt.ts: Kontaktadressen-Button in Projektliste (kompakt in einer Zelle) und im Projekt-Formular (nur bei bestehendem Projekt) Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- Client/Components/kontaktadresse.ts | 260 ++++++++++++++++++++++++++++ Client/Components/projekt.ts | 42 +++-- Client/index.ts | 3 + Client/models/kontaktadresse.ts | 151 ++++++++++++++++ 4 files changed, 442 insertions(+), 14 deletions(-) create mode 100644 Client/Components/kontaktadresse.ts create mode 100644 Client/models/kontaktadresse.ts diff --git a/Client/Components/kontaktadresse.ts b/Client/Components/kontaktadresse.ts new file mode 100644 index 0000000..5640e87 --- /dev/null +++ b/Client/Components/kontaktadresse.ts @@ -0,0 +1,260 @@ +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 {iKontaktadresse, getKontaktadressenByProjektId, getNewKontaktadresseId, addKontaktadresse, updateKontaktadresse} from "../models/kontaktadresse" +import {iProjekt, getProjekte} from "../models/projekt" + +function createKontaktadresseTreenodes(projektId: number | null = null): iTreenode[] { + let filteredKontaktadressen = getKontaktadressenByProjektId(projektId) + + return filteredKontaktadressen.map((kontaktadresse: iKontaktadresse) => { + return new Treenode(kontaktadresse, true, []) + }) +} + +export const kontaktadresseliste: m.ClosureComponent = (vnode) => { + let treenodes: iTreenode[] = createKontaktadresseTreenodes() + let selectedNode: iTreenode | null = null + let filterProjektId: number | null = null + + const handleRowMarked = (node: iTreenode) => { + selectedNode = node + } + + const addKontaktadresseRoute = () => { + const params: any = { + id: -1, + anrede: "", + name: "", + firma: "", + telefon: "", + email: "", + strasse: "", + plz: "", + ort: "", + land: "", + notiz: "" + } + // Nur bei projektgefilterter Liste das Projekt vorbelegen. + if (filterProjektId !== null) { + const projekt = getProjekte().find((p: iProjekt) => p.id === filterProjektId) + if (projekt) { + params.projektId = projekt.id + params.projektname = projekt.projektname + } + } + m.route.set("/kontaktadresse", params) + } + + const editKontaktadresse = (kontaktadresse: iKontaktadresse, e: Event) => { + e.stopPropagation() + m.route.set("/kontaktadresse", { + id: kontaktadresse.id, + projektId: kontaktadresse.projektId, + projektname: kontaktadresse.projektName, + anrede: kontaktadresse.anrede, + name: kontaktadresse.name, + firma: kontaktadresse.firma, + telefon: kontaktadresse.telefon, + email: kontaktadresse.email, + strasse: kontaktadresse.strasse, + plz: kontaktadresse.plz, + ort: kontaktadresse.ort, + land: kontaktadresse.land, + notiz: kontaktadresse.notiz + }) + } + + const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { + let kontaktadresse: iKontaktadresse = node.nodevalue + const isSelected = selectedNode === node + const cellStyle = isSelected ? {backgroundColor: '#e6f7ff'} : {} + + return [ + m("div", {style: cellStyle}, m("strong", kontaktadresse.name)), + m("span", {style: cellStyle}, kontaktadresse.firma), + m("span", {style: cellStyle}, kontaktadresse.telefon), + m("span", {style: cellStyle}, kontaktadresse.email), + m("span", {style: cellStyle}, kontaktadresse.ort), + m("span", {style: cellStyle}, kontaktadresse.projektName), + m("button.w3-button.w3-small", {style: cellStyle, onclick: (e: Event) => editKontaktadresse(kontaktadresse, e)}, m("i.material-icons", "edit")) + ] + } + + const filterFunction = (node: iTreenode, searchvalue: string): boolean => { + let kontaktadresse: iKontaktadresse = node.nodevalue + let searchLower = searchvalue.toLowerCase() + return kontaktadresse.name.toLowerCase().includes(searchLower) || + kontaktadresse.firma.toLowerCase().includes(searchLower) || + kontaktadresse.telefon.toLowerCase().includes(searchLower) || + kontaktadresse.email.toLowerCase().includes(searchLower) || + kontaktadresse.ort.toLowerCase().includes(searchLower) || + kontaktadresse.projektName.toLowerCase().includes(searchLower) + } + + return { + oninit: (vnode) => { + const routeParams = m.route.param() + if (routeParams && routeParams.projektId) { + filterProjektId = parseInt(routeParams.projektId) + } else { + filterProjektId = null + } + treenodes = createKontaktadresseTreenodes(filterProjektId) + }, + view: (vnode) => { + return m("div.w3-container.w3-auto", { + style: { + display: 'flex', + 'flex-direction': 'column', + height: 'inherit', + 'overflow-y': 'inherit'}}, + m("h1", "Kontaktadressen"), + m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, + m(Treetable, + { + treenodes: treenodes, + columnmapper: columnmapper, + filter: filterFunction, + onRowMarked: handleRowMarked + } as iTreetable, + m(TopbarButton, {onclick: addKontaktadresseRoute, tooltip: "Neue Kontaktadresse anlegen"} as iTopbarButton, + m("i.material-icons", "person_add") + ) + ) + ) + ) + } + } +} + +export const Kontaktadresse: m.ClosureComponent = (vnode) => { + // currentKontaktadresse hält den Formularzustand. projektId referenziert das + // Projekt eindeutig, projektname dient der Anzeige. + let currentKontaktadresse: { + id: number + projektId: number + projektname: string + anrede: string + name: string + firma: string + telefon: string + email: string + strasse: string + plz: string + ort: string + land: string + notiz: string + } = { + id: -1, + projektId: -1, + projektname: "", + anrede: "", + name: "", + firma: "", + telefon: "", + email: "", + strasse: "", + plz: "", + ort: "", + land: "", + notiz: "" + } + // Ist das Projekt voreingestellt (gefilterte Kontaktadressenliste)? + let projektVoreingestellt: boolean = false + + return { + oninit: (vnode) => { + const routeParams = m.route.param() + if (routeParams) { + const projektId = routeParams.projektId ? parseInt(routeParams.projektId) : -1 + projektVoreingestellt = projektId !== -1 + currentKontaktadresse = { + id: routeParams.id ? parseInt(routeParams.id) : -1, + projektId: projektId, + projektname: routeParams.projektname || "", + anrede: routeParams.anrede || "", + name: routeParams.name || "", + firma: routeParams.firma || "", + telefon: routeParams.telefon || "", + email: routeParams.email || "", + strasse: routeParams.strasse || "", + plz: routeParams.plz || "", + ort: routeParams.ort || "", + land: routeParams.land || "", + notiz: routeParams.notiz || "" + } + } + }, + view: (vnode) => { + const submit = (form: HTMLFormElement): boolean => { + const formData = new FormData(form) + const projektId = parseInt(formData.get("projektId") as string) + // Projektnamen zur gewählten projektId auflösen. + const projekt = getProjekte().find((p: iProjekt) => p.id === projektId) + const gespeicherteKontaktadresse: iKontaktadresse = { + id: currentKontaktadresse.id, + projektId: projektId, + projektName: projekt ? projekt.projektname : "", + anrede: (formData.get("anrede") as string) || "", + name: (formData.get("name") as string) || "", + firma: (formData.get("firma") as string) || "", + telefon: (formData.get("telefon") as string) || "", + email: (formData.get("email") as string) || "", + strasse: (formData.get("strasse") as string) || "", + plz: (formData.get("plz") as string) || "", + ort: (formData.get("ort") as string) || "", + land: (formData.get("land") as string) || "", + notiz: (formData.get("notiz") as string) || "" + } + if (currentKontaktadresse.id === -1) { + gespeicherteKontaktadresse.id = getNewKontaktadresseId() + addKontaktadresse(gespeicherteKontaktadresse) + } else { + updateKontaktadresse(gespeicherteKontaktadresse) + } + m.route.set("/kontaktadresseliste") + return true + } + const cancel = (e: Event) => { + m.route.set("/kontaktadresseliste") + } + // Select-Optionen aus der Projektliste aufbauen. + const projektOptionen: iSelectOption[] = getProjekte().map((p: iProjekt) => { + return {value: p.id, label: p.projektname} + }) + return m("div.w3-container.w3-auto.form-view-container", + m("h1", currentKontaktadresse.id === -1 ? "Neue Kontaktadresse anlegen" : "Kontaktadresse-Stammblatt"), + m("div", {style: {flex: '1', 'min-height': '0'}}, + m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [ + m("label", "Projekt", + m(Select, { + name: "projektId", + value: currentKontaktadresse.projektId, + options: projektOptionen, + readonly: projektVoreingestellt, + required: true + } as iSelect)), + 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)), + m("label", "Firma", m(Input, {name: "firma", value: currentKontaktadresse.firma, type: "text"} as iInput)), + m("label", "Telefon", m(Input, {name: "telefon", value: currentKontaktadresse.telefon, type: "tel"} as iInput)), + m("label", "E-Mail", m(Input, {name: "email", value: currentKontaktadresse.email, type: "email"} as iInput)), + m("label", "Straße", m(Input, {name: "strasse", value: currentKontaktadresse.strasse, type: "text"} as iInput)), + // PLZ und Ort in einer Zeile. + m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}}, + m("label", {style: {flex: '0 0 120px'}}, "PLZ", + m(Input, {name: "plz", value: currentKontaktadresse.plz, type: "text"} as iInput)), + m("label", {style: {flex: '1'}}, "Ort", + m(Input, {name: "ort", value: currentKontaktadresse.ort, type: "text"} as iInput)) + ), + m("label", "Land", m(Input, {name: "land", value: currentKontaktadresse.land, type: "text"} as iInput)), + m("label", "Notiz", m(Textarea, {name: "notiz", value: currentKontaktadresse.notiz, rows: 4} as iTextarea)) + ]) + ) + ) + } + } +} diff --git a/Client/Components/projekt.ts b/Client/Components/projekt.ts index ea1c964..a0b97d5 100644 --- a/Client/Components/projekt.ts +++ b/Client/Components/projekt.ts @@ -60,6 +60,11 @@ export const projektliste: m.ClosureComponent = (vnode) => { e.stopPropagation() m.route.set("/calllogliste", {projektId: projekt.id}) } + + const showKontaktadressen = (projekt: iProjekt, e: Event) => { + e.stopPropagation() + m.route.set("/kontaktadresseliste", {projektId: projekt.id}) + } const addCallLog = (projekt: iProjekt, e: Event) => { e.stopPropagation() @@ -85,18 +90,19 @@ export const projektliste: m.ClosureComponent = (vnode) => { const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { let projekt: iProjekt = node.nodevalue - const isSelected = selectedNode === node - const cellStyle = isSelected ? {backgroundColor: '#e6f7ff'} : {} - + return [ - m("div", {style: cellStyle}, m("strong", projekt.kundenName)), - m("span", {style: cellStyle}, projekt.projektname), - m("span", {style: cellStyle}, projekt.zweck), - m("span", {style: cellStyle}, projekt.verantwortlicher), - m("span", {style: cellStyle}, projekt.startdatum), - m("button.w3-button.w3-small", {style: cellStyle, onclick: (e: Event) => editProjekt(projekt, e)}, m("i.material-icons", "edit")), - m("button.w3-button.w3-small", {title: "Gesprächsnotizen anzeigen", style: cellStyle, onclick: (e: Event) => showCallLogs(projekt, e)}, m("i.material-icons", "call_log")), - m("button.w3-button.w3-small", {title: "Gesprächsnotiz hinzufügen", style: cellStyle, onclick: (e: Event) => addCallLog(projekt, e)}, m("i.material-icons", "add_call")) + m("div", m("strong", projekt.kundenName)), + m("span", projekt.projektname), + m("span", projekt.zweck), + m("span", projekt.verantwortlicher), + m("span", projekt.startdatum), + m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap'}}, + m("button.w3-button.w3-small", {style: {padding: '0 2px'}, onclick: (e: Event) => editProjekt(projekt, e)}, m("i.material-icons", "edit")), + m("button.w3-button.w3-small", {title: "Kontaktadressen anzeigen", style: {padding: '0 2px'}, onclick: (e: Event) => showKontaktadressen(projekt, e)}, m("i.material-icons", "contact_page")), + m("button.w3-button.w3-small", {title: "Gesprächsnotizen anzeigen", style: {padding: '0 2px'}, onclick: (e: Event) => showCallLogs(projekt, e)}, m("i.material-icons", "call_log")), + m("button.w3-button.w3-small", {title: "Gesprächsnotiz hinzufügen", style: {padding: '0 2px'}, onclick: (e: Event) => addCallLog(projekt, e)}, m("i.material-icons", "add_call")) + ) ] } @@ -129,12 +135,13 @@ export const projektliste: m.ClosureComponent = (vnode) => { 'overflow-y': 'inherit'}}, m("h1", "Projektliste"), m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, - m(Treetable, + m(Treetable, { treenodes: treenodes, columnmapper: columnmapper, filter: filterFunction, - onRowMarked: handleRowMarked + onRowMarked: handleRowMarked, + isSelected: (node: iTreenode) => node === selectedNode } as iTreetable, m(TopbarButton, {onclick: addProjektRoute, tooltip: "Neues Projekt anlegen"} as iTopbarButton, m("i.material-icons", "post_add") @@ -218,7 +225,14 @@ export const Projekt: m.ClosureComponent = (vnode) => { return {value: k.id, label: k.name} }) return m("div.w3-container.w3-auto.form-view-container", - m("h1", currentProjekt.id === -1 ? "Neues Projekt anlegen" : "Projekt-Stammblatt"), + m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}}, + m("h1", currentProjekt.id === -1 ? "Neues Projekt anlegen" : "Projekt-Stammblatt"), + currentProjekt.id !== -1 + ? m("button.w3-button.w3-theme", + {onclick: () => m.route.set("/kontaktadresseliste", {projektId: currentProjekt.id})}, + m("i.material-icons", "contact_page"), " Kontaktadressen") + : null + ), m("div", {style: {flex: '1', 'min-height': '0'}}, m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [ m("label", "Kunde", diff --git a/Client/index.ts b/Client/index.ts index dd6859c..d99df99 100644 --- a/Client/index.ts +++ b/Client/index.ts @@ -7,6 +7,7 @@ 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 { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse" import { Tag, TagList } from "./Components/tags" @@ -155,6 +156,8 @@ m.route(gridMountpoints.main, "/blankpage", { "/call_log": Call_Log, "/kontaktliste": kontaktliste, "/kontakt": Kontakt, + "/kontaktadresseliste": kontaktadresseliste, + "/kontaktadresse": Kontaktadresse, "/taglist": TagList, "/tag": Tag diff --git a/Client/models/kontaktadresse.ts b/Client/models/kontaktadresse.ts new file mode 100644 index 0000000..3f2b6b2 --- /dev/null +++ b/Client/models/kontaktadresse.ts @@ -0,0 +1,151 @@ +import m from "mithril" + +/** + * Datenmodell für eine Kontaktadresse (Akquise-Ziel innerhalb eines Projekts). + * Jede Kontaktadresse ist genau einem Projekt zugeordnet (FK projektId). + */ +export interface iKontaktadresse { + id: number + projektId: number + projektName: string + anrede: string + name: string + firma: string + telefon: string + email: string + strasse: string + plz: string + ort: string + land: string + notiz: string +} + +/** + * In-Memory-Datenspeicher für Kontaktadressen. Wird als Fallback solange verwendet, + * bis die serverseitigen REST-Endpunkte verfügbar sind. + */ +export const kontaktadresseDaten: iKontaktadresse[] = [ + {id: 1, projektId: 1, projektName: "Website-Relaunch", anrede: "Herr", name: "Thomas Becker", firma: "Becker Webdesign GmbH", telefon: "+49 201 123456", email: "t.becker@becker-webdesign.de", strasse: "Industriestr. 12", plz: "45131", ort: "Essen", land: "Deutschland", notiz: "Interessent für Website-Relaunch, Erstkontakt auf Messe."}, + {id: 2, projektId: 1, projektName: "Website-Relaunch", anrede: "Frau", name: "Sandra Klein", firma: "Klein Marketing", telefon: "+49 211 987654", email: "s.klein@klein-marketing.de", strasse: "Königsallee 5", plz: "40212", ort: "Düsseldorf", land: "Deutschland", notiz: ""}, + {id: 3, projektId: 2, projektName: "E-Commerce Plattform", anrede: "Herr", name: "Michael Wagner", firma: "Wagner Online Shop", telefon: "+49 221 555123", email: "m.wagner@wagner-shop.de", strasse: "Hauptstr. 88", plz: "50667", ort: "Köln", land: "Deutschland", notiz: "Sucht E-Commerce-Lösung, Budget geklärt."}, + {id: 4, projektId: 3, projektName: "CRM-System", anrede: "Frau", name: "Julia Hoffmann", firma: "Hoffmann Consulting", telefon: "+49 89 444333", email: "j.hoffmann@hoffmann-consulting.de", strasse: "Marienplatz 3", plz: "80331", ort: "München", land: "Deutschland", notiz: "Wiedervorlage im nächsten Quartal."} +] + +/** + * Liefert die nächste freie Kontaktadressen-ID auf Basis des In-Memory-Speichers. + * Sobald die serverseitige ID-Vergabe verfügbar ist, entfällt diese Funktion. + */ +export function getNewKontaktadresseId(): number { + if (kontaktadresseDaten.length === 0) { + return 1 + } + return Math.max(...kontaktadresseDaten.map((ka: iKontaktadresse) => ka.id)) + 1 +} + +/** + * Fügt eine neue Kontaktadresse dem In-Memory-Speicher hinzu. + */ +export function addKontaktadresse(kontaktadresse: iKontaktadresse): void { + kontaktadresseDaten.push(kontaktadresse) +} + +/** + * Aktualisiert eine bestehende Kontaktadresse im In-Memory-Speicher. + */ +export function updateKontaktadresse(kontaktadresse: iKontaktadresse): void { + const index = kontaktadresseDaten.findIndex((k: iKontaktadresse) => k.id === kontaktadresse.id) + if (index !== -1) { + kontaktadresseDaten[index] = kontaktadresse + } +} + +/** + * Liefert eine Kopie aller Kontaktadressen aus dem In-Memory-Speicher. + */ +export function getKontaktadressen(): iKontaktadresse[] { + return [...kontaktadresseDaten] +} + +/** + * Liefert die Kontaktadressen eines bestimmten Projekts. Ist projektId null, + * werden alle Kontaktadressen zurückgegeben. + */ +export function getKontaktadressenByProjektId(projektId: number | null): iKontaktadresse[] { + return projektId + ? kontaktadresseDaten.filter((k: iKontaktadresse) => k.projektId === projektId) + : [...kontaktadresseDaten] +} + +/** + * Liefert eine einzelne Kontaktadresse anhand ihrer ID oder undefined. + */ +export function getKontaktadresseById(id: number): iKontaktadresse | undefined { + return kontaktadresseDaten.find((k: iKontaktadresse) => 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 Kontaktadressen-REST-Endpunkte. + */ +const KONTAKTADRESSE_REST_BASE = "/xhr/kontaktadresse" + +/** + * Lädt die Kontaktadressenliste vom Server. + */ +export function fetchKontaktadressen(): Promise { + return m.request({ + method: "GET", + url: KONTAKTADRESSE_REST_BASE + "/list" + }).then((result) => result as iKontaktadresse[]) +} + +/** + * Lädt eine einzelne Kontaktadresse anhand ihrer ID vom Server. + */ +export function fetchKontaktadresseById(id: number): Promise { + return m.request({ + method: "GET", + url: KONTAKTADRESSE_REST_BASE + "/get", + params: {id: id} + }).then((result) => result as iKontaktadresse) +} + +/** + * Legt eine neue Kontaktadresse serverseitig an. + */ +export function createKontaktadresseREST(kontaktadresse: iKontaktadresse): Promise { + return m.request({ + method: "POST", + url: KONTAKTADRESSE_REST_BASE + "/create", + body: kontaktadresse + }).then((result) => result as iKontaktadresse) +} + +/** + * Aktualisiert eine bestehende Kontaktadresse serverseitig. + */ +export function updateKontaktadresseREST(kontaktadresse: iKontaktadresse): Promise { + return m.request({ + method: "PUT", + url: KONTAKTADRESSE_REST_BASE + "/update", + body: kontaktadresse + }).then((result) => result as iKontaktadresse) +} + +/** + * Löscht eine Kontaktadresse serverseitig anhand ihrer ID. + */ +export function deleteKontaktadresseREST(id: number): Promise { + return m.request({ + method: "DELETE", + url: KONTAKTADRESSE_REST_BASE + "/delete", + params: {id: id} + }).then(() => undefined) +}