Compare commits
18
Commits
1ba78992a6
...
4922984b92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4922984b92 | ||
|
|
28f858f6da | ||
|
|
3accb8f690 | ||
|
|
16eaab8b64 | ||
|
|
1c99dcf326 | ||
|
|
ba6c0c50d1 | ||
|
|
de7e6948f8 | ||
|
|
d4319de445 | ||
|
|
385892ac15 | ||
|
|
87f8d9e395 | ||
|
|
77bf53423c | ||
|
|
ada80d0938 | ||
|
|
e0a87f48db | ||
|
|
58e3691ce1 | ||
|
|
46ce79c551 | ||
|
|
6f62965750 | ||
|
|
72055840f7 | ||
|
|
9d91915916 |
No files matched your search
@@ -0,0 +1,44 @@
|
|||||||
|
# Architektur der Anwendung
|
||||||
|
Beachte die strukturellen Vorgaben, die in ai-context/AI-operative-Vorgaben.md definiert sind.
|
||||||
|
In den Programmen sollen aussagekräftige Kommentare in deutsch erstellt werden.
|
||||||
|
|
||||||
|
## Vorgaben für die Typescript und mithriljs Entwicklung
|
||||||
|
Verwende bei der GUI primar die widgets, die es in client/Components/widgets gibt. Fehler widgets, gib eines Hinweis und erstelle einen Vorschlag für ein entsprechendes neues Widget.
|
||||||
|
Es soll immer ein responsive Design implmentiert werden, da die App sowohl auf PCs als auch auf mobilen Geräten laufen soll.
|
||||||
|
Verwende für Attribute der GUI-Oberfläche nach Möglichkeit Klassen aus NGINX_Server/html/static/w3.css, insbesondere wenn es darum geht, eine Respnosive Design zu implentieren.
|
||||||
|
View-Container werden mit `w3-container.w3-auto` gewrappt, damit sie auf kleinen Bildschirmen 100% und auf großen bis max. 1140px (bzw. 95%) zentriert nutzen.
|
||||||
|
|
||||||
|
Bei Mithril `m(...)`-Aufrufen mit längeren Attributlisten (z.B. mehrere `style`-Eigenschaften oder mehrere Attribute) schreibe die Attribute untereinander, sobald sie in einer Zeile nicht mehr gut lesbar sind (Faustregel: mehr als 2–3 Attribute oder ein mehrzeiliges `style`-Objekt). Kurze Attributlisten (1–2 Attribute, einzeiliges `style`) dürfen in einer Zeile bleiben. Formatbeispiel:
|
||||||
|
```typescript
|
||||||
|
m("div.w3-bar-block.w3-border", {
|
||||||
|
style: {
|
||||||
|
width: '95%',
|
||||||
|
'max-height': '220px',
|
||||||
|
'overflow-y': 'auto',
|
||||||
|
position: 'absolute',
|
||||||
|
'z-index': '1000'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Für Symbole auf Buttons und ähnlichen Steuerelementen verwende Symbole aus den Google Material Icons.
|
||||||
|
Erstelle die Datenmodelle für die Typescript-Entwicklung im Verzeichnis Client/Models. Erstelle für die verschiedenen Datenmodelle (Kunde, Projekt usw.) eigene .ts-Dateien. Erstelle in der jeweiligen Datei auch den Aufruf der REST-Api für die Server-Seite.
|
||||||
|
|
||||||
|
Bei `m.ClosureComponent` den Closure-Parameter weglassen (`() =>`) und Attribute ausschließlich über das `vnode` des jeweiligen Lifecycle-Hooks (`view`, `oninit`, `onupdate` etc.) lesen. Das `vnode` der Closure-Funktion ist ein Snapshot zum Erzeugungszeitpunkt und wird bei Redraws nicht aktualisiert — daraus gelesene `attrs` sind veraltet. Initialisierung, die `attrs` braucht, gehört nach `oninit`. Beispiel:
|
||||||
|
```typescript
|
||||||
|
export const Select: m.ClosureComponent<iSelect> = () => {
|
||||||
|
// closure-State ohne Referenz auf attrs
|
||||||
|
let search = ""
|
||||||
|
return {
|
||||||
|
oninit: (vnode) => {
|
||||||
|
// vnode.attrs hier initial lesen
|
||||||
|
search = vnode.attrs.value
|
||||||
|
},
|
||||||
|
view: (vnode) => {
|
||||||
|
// vnode.attrs enthält die aktuellen Werte je Render-Durchlauf
|
||||||
|
const options = vnode.attrs.options
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
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 {iCallLog, getCallLogsByProjektId, getNewCallLogId, addCallLogData, updateCallLogData} from "../models/call_log"
|
||||||
|
import {iKunde, getKunden} from "../models/kunde"
|
||||||
|
import {getProjekteByKundenId, getProjektById} from "../models/projekt"
|
||||||
|
|
||||||
|
// Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden.
|
||||||
|
// 1000 = jede Sekunde, 60000 = jede Minute.
|
||||||
|
const DAUER_UPDATE_INTERVALL = 1000
|
||||||
|
|
||||||
|
function createCallLogTreenodes(projektId: number | null = null): iTreenode[] {
|
||||||
|
let filteredLogs = getCallLogsByProjektId(projektId)
|
||||||
|
|
||||||
|
return filteredLogs.map((log: iCallLog) => {
|
||||||
|
return new Treenode(log, true, [])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const call_log_liste: m.ClosureComponent = (vnode) => {
|
||||||
|
let treenodes: iTreenode[] = createCallLogTreenodes()
|
||||||
|
let selectedNode: iTreenode | null = null
|
||||||
|
let filterProjektId: number | null = null
|
||||||
|
|
||||||
|
const handleRowMarked = (node: iTreenode) => {
|
||||||
|
selectedNode = node
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCallLogRoute = () => {
|
||||||
|
const now = new Date()
|
||||||
|
const datum = now.toISOString().split('T')[0]
|
||||||
|
const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1]
|
||||||
|
const params: any = {
|
||||||
|
id: -1,
|
||||||
|
datum: datum,
|
||||||
|
uhrzeitBeginn: uhrzeit,
|
||||||
|
dauer: "",
|
||||||
|
betreff: "",
|
||||||
|
nameAnsprechpartner: "",
|
||||||
|
telefon: "",
|
||||||
|
notiz: "",
|
||||||
|
wiedervorlageDatum: "",
|
||||||
|
erledigt: false
|
||||||
|
}
|
||||||
|
// Bei projektgefilterter Liste Kunde und Projekt vorbelegen.
|
||||||
|
if (filterProjektId !== null) {
|
||||||
|
const projekt = getProjektById(filterProjektId)
|
||||||
|
if (projekt) {
|
||||||
|
params.kundenName = projekt.kundenName
|
||||||
|
params.projektId = projekt.id
|
||||||
|
params.projektName = projekt.projektname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.route.set("/call_log", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
const editCallLog = (log: iCallLog, e: Event) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
m.route.set("/call_log", {
|
||||||
|
id: log.id,
|
||||||
|
kundenName: log.kundenName,
|
||||||
|
projektId: log.projektId,
|
||||||
|
projektName: log.projektName,
|
||||||
|
datum: log.datum,
|
||||||
|
uhrzeitBeginn: log.uhrzeitBeginn,
|
||||||
|
dauer: log.dauer,
|
||||||
|
betreff: log.betreff,
|
||||||
|
nameAnsprechpartner: log.nameAnsprechpartner,
|
||||||
|
telefon: log.telefon,
|
||||||
|
notiz: log.notiz,
|
||||||
|
wiedervorlageDatum: log.wiedervorlageDatum,
|
||||||
|
erledigt: log.erledigt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
|
||||||
|
let log: iCallLog = node.nodevalue
|
||||||
|
const isSelected = selectedNode === node
|
||||||
|
const cellStyle = isSelected ? {backgroundColor: '#e6f7ff'} : {}
|
||||||
|
|
||||||
|
return [
|
||||||
|
m("div", {style: cellStyle}, m("strong", log.kundenName + ": " + log.projektName)),
|
||||||
|
m("span", {style: cellStyle}, log.datum),
|
||||||
|
m("span", {style: cellStyle}, log.uhrzeitBeginn),
|
||||||
|
m("span", {style: cellStyle}, log.dauer + " min"),
|
||||||
|
m("span", {style: cellStyle}, log.betreff),
|
||||||
|
m("span", {style: cellStyle}, log.wiedervorlageDatum),
|
||||||
|
m("span", {style: cellStyle}, log.erledigt ? "Erledigt" : "Offen"),
|
||||||
|
m("button.w3-button.w3-small", {style: cellStyle, onclick: (e: Event) => editCallLog(log, e)}, m("i.material-icons", "edit"))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
|
||||||
|
let log: iCallLog = node.nodevalue
|
||||||
|
let searchLower = searchvalue.toLowerCase()
|
||||||
|
return log.kundenName.toLowerCase().includes(searchLower) ||
|
||||||
|
log.projektName.toLowerCase().includes(searchLower) ||
|
||||||
|
log.datum.includes(searchLower) ||
|
||||||
|
log.uhrzeitBeginn.toLowerCase().includes(searchLower) ||
|
||||||
|
log.betreff.toLowerCase().includes(searchLower) ||
|
||||||
|
log.nameAnsprechpartner.toLowerCase().includes(searchLower) ||
|
||||||
|
log.notiz.toLowerCase().includes(searchLower) ||
|
||||||
|
log.wiedervorlageDatum.includes(searchLower)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
oninit: (vnode) => {
|
||||||
|
const routeParams = m.route.param()
|
||||||
|
if (routeParams && routeParams.projektId) {
|
||||||
|
filterProjektId = parseInt(routeParams.projektId)
|
||||||
|
} else {
|
||||||
|
filterProjektId = null
|
||||||
|
}
|
||||||
|
treenodes = createCallLogTreenodes(filterProjektId)
|
||||||
|
},
|
||||||
|
view: (vnode) => {
|
||||||
|
return m("div.w3-container.w3-auto", {
|
||||||
|
style: {
|
||||||
|
display: 'flex',
|
||||||
|
'flex-direction': 'column',
|
||||||
|
height: 'inherit',
|
||||||
|
'overflow-y': 'inherit'}},
|
||||||
|
m("h1", "Gesprächsnotizen"),
|
||||||
|
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
||||||
|
m(Treetable,
|
||||||
|
{
|
||||||
|
treenodes: treenodes,
|
||||||
|
columnmapper: columnmapper,
|
||||||
|
filter: filterFunction,
|
||||||
|
onRowMarked: handleRowMarked
|
||||||
|
} as iTreetable,
|
||||||
|
m(TopbarButton, {onclick: addCallLogRoute, tooltip: "Neue Gesprächsnotiz anlegen"} as iTopbarButton,
|
||||||
|
m("i.material-icons", "add_call")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||||
|
// currentLog hält den Formularzustand. kundenId ist intern und wird über
|
||||||
|
// das Projekt aufgelöst; kundenName/projektName dienen der Anzeige.
|
||||||
|
let currentLog: {
|
||||||
|
id: number
|
||||||
|
kundenId: number
|
||||||
|
kundenName: string
|
||||||
|
projektId: number
|
||||||
|
projektName: string
|
||||||
|
datum: string
|
||||||
|
uhrzeitBeginn: string
|
||||||
|
dauer: string
|
||||||
|
betreff: string
|
||||||
|
nameAnsprechpartner: string
|
||||||
|
telefon: string
|
||||||
|
notiz: string
|
||||||
|
wiedervorlageDatum: string
|
||||||
|
erledigt: boolean
|
||||||
|
} = {
|
||||||
|
id: -1,
|
||||||
|
kundenId: -1,
|
||||||
|
kundenName: "",
|
||||||
|
projektId: -1,
|
||||||
|
projektName: "",
|
||||||
|
datum: "",
|
||||||
|
uhrzeitBeginn: "",
|
||||||
|
dauer: "",
|
||||||
|
betreff: "",
|
||||||
|
nameAnsprechpartner: "",
|
||||||
|
telefon: "",
|
||||||
|
notiz: "",
|
||||||
|
wiedervorlageDatum: "",
|
||||||
|
erledigt: false
|
||||||
|
}
|
||||||
|
// Sind Kunde und Projekt voreingestellt (Liste wurde projektgefiltert
|
||||||
|
// oder aus der Projektliste aufgerufen)?
|
||||||
|
let kundeVoreingestellt: boolean = false
|
||||||
|
// Startzeit für die Dauer-Messung (nur bei Neuanlage gesetzt).
|
||||||
|
let startZeit: Date | null = null
|
||||||
|
// Timer-Handle für die Dauer-Aktualisierung.
|
||||||
|
let timerId: number | null = null
|
||||||
|
// Direkte DOM-Referenz auf das Dauer-Feld, damit der Timer den Wert
|
||||||
|
// ohne Redraw aktualisieren kann (ein Redraw würde die übrigen Felder
|
||||||
|
// mit den veralteten currentLog-Werten überschreiben).
|
||||||
|
let dauerInput: HTMLInputElement | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatiert verstrichene Sekunden als mm:ss (Minuten:Sekunden).
|
||||||
|
*/
|
||||||
|
const formatDauer = (sekunden: number): string => {
|
||||||
|
const m = Math.floor(sekunden / 60)
|
||||||
|
const s = sekunden % 60
|
||||||
|
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
oninit: (vnode) => {
|
||||||
|
const routeParams = m.route.param()
|
||||||
|
if (routeParams) {
|
||||||
|
const projektId = routeParams.projektId ? parseInt(routeParams.projektId) : -1
|
||||||
|
kundeVoreingestellt = projektId !== -1
|
||||||
|
// kundenId aus dem Projekt ableiten, falls nicht direkt gesetzt.
|
||||||
|
let kundenId = -1
|
||||||
|
if (kundeVoreingestellt) {
|
||||||
|
const projekt = getProjektById(projektId)
|
||||||
|
if (projekt) { kundenId = projekt.kundenId }
|
||||||
|
}
|
||||||
|
currentLog = {
|
||||||
|
id: routeParams.id ? parseInt(routeParams.id) : -1,
|
||||||
|
kundenId: kundenId,
|
||||||
|
kundenName: routeParams.kundenName || "",
|
||||||
|
projektId: projektId,
|
||||||
|
projektName: routeParams.projektName || "",
|
||||||
|
datum: routeParams.datum || "",
|
||||||
|
uhrzeitBeginn: routeParams.uhrzeitBeginn || "",
|
||||||
|
dauer: routeParams.dauer || "",
|
||||||
|
betreff: routeParams.betreff || "",
|
||||||
|
nameAnsprechpartner: routeParams.nameAnsprechpartner || "",
|
||||||
|
telefon: routeParams.telefon || "",
|
||||||
|
notiz: routeParams.notiz || "",
|
||||||
|
wiedervorlageDatum: routeParams.wiedervorlageDatum || "",
|
||||||
|
erledigt: routeParams.erledigt === true || routeParams.erledigt === "true"
|
||||||
|
}
|
||||||
|
// Bei Neuanlage Startzeit merken und Dauer-Timer starten.
|
||||||
|
if (currentLog.id === -1) {
|
||||||
|
startZeit = new Date()
|
||||||
|
timerId = window.setInterval(() => {
|
||||||
|
if (startZeit) {
|
||||||
|
// Verstrichene Sekunden berechnen; das Intervall
|
||||||
|
// steuert nur die Update-Frequenz.
|
||||||
|
const sekunden = Math.floor((Date.now() - startZeit.getTime()) / 1000)
|
||||||
|
const dauer = formatDauer(sekunden)
|
||||||
|
currentLog.dauer = dauer
|
||||||
|
// Nur das Dauer-Feld direkt im DOM aktualisieren,
|
||||||
|
// ohne redraw (sonst werden andere Felder zurückgesetzt).
|
||||||
|
if (dauerInput) { dauerInput.value = dauer }
|
||||||
|
}
|
||||||
|
}, DAUER_UPDATE_INTERVALL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onremove: (vnode) => {
|
||||||
|
// Timer stoppen, damit er nach Verlassen des Views nicht weiterläuft.
|
||||||
|
if (timerId != null) { window.clearInterval(timerId) }
|
||||||
|
},
|
||||||
|
view: (vnode) => {
|
||||||
|
// Select-Optionen aus Kunden- und Projektliste aufbauen.
|
||||||
|
const kundenOptionen: iSelectOption[] = getKunden().map((k: iKunde) => {
|
||||||
|
return {value: k.id, label: k.name}
|
||||||
|
})
|
||||||
|
// Projekt-Optionen sind vom gewählten Kunden abhängig.
|
||||||
|
const projektOptionen: iSelectOption[] = getProjekteByKundenId(currentLog.kundenId).map((p) => {
|
||||||
|
return {value: p.id, label: p.projektname}
|
||||||
|
})
|
||||||
|
// Einheit der Dauer-Anzeige im Format mm:ss.
|
||||||
|
return m("div.w3-container.w3-auto.form-view-container",
|
||||||
|
m("h1", currentLog.id === -1 ? "Neue Gesprächsnotiz anlegen" : "Gesprächsnotiz"),
|
||||||
|
// Form füllt den verbleibenden Platz; das Form-Widget selbst
|
||||||
|
// sorgt (mit stickyButtons, Default) dafür, dass nur der
|
||||||
|
// Inhalt scrollt und die Buttons fixiert bleiben.
|
||||||
|
m("div", {style: {flex: '1', 'min-height': '0'}},
|
||||||
|
m(Form, {showButtons: true, onsubmit: (null), oncancel: (null)} as iForm, [
|
||||||
|
m("label", "Kunde",
|
||||||
|
m(Select, {
|
||||||
|
name: "kundenId",
|
||||||
|
value: currentLog.kundenId,
|
||||||
|
options: kundenOptionen,
|
||||||
|
readonly: kundeVoreingestellt,
|
||||||
|
required: true,
|
||||||
|
onchanged: (kundenId: number) => {
|
||||||
|
// Bei Kundenwechsel die kundenId übernehmen
|
||||||
|
// und projektId zurücksetzen, da die Projekte
|
||||||
|
// kundenabhängig sind.
|
||||||
|
currentLog.kundenId = kundenId
|
||||||
|
currentLog.projektId = -1
|
||||||
|
}
|
||||||
|
} as iSelect)),
|
||||||
|
m("label", "Projekt",
|
||||||
|
m(Select, {
|
||||||
|
name: "projektId",
|
||||||
|
value: currentLog.projektId,
|
||||||
|
options: projektOptionen,
|
||||||
|
readonly: kundeVoreingestellt,
|
||||||
|
required: true
|
||||||
|
} as iSelect)),
|
||||||
|
// Datum, Startzeit und Dauer in einer Zeile.
|
||||||
|
m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}},
|
||||||
|
m("label", {style: {flex: '1'}}, "Datum",
|
||||||
|
m(Input, {name: "datum", value: currentLog.datum, type: "date"} as iInput)),
|
||||||
|
m("label", {style: {flex: '1'}}, "Uhrzeit Beginn",
|
||||||
|
m(Input, {name: "uhrzeitBeginn", value: currentLog.uhrzeitBeginn, type: "time"} as iInput)),
|
||||||
|
m("label", {style: {flex: '1'}}, "Dauer (mm:ss)",
|
||||||
|
// Raw input statt Input-Widget, damit oncreate die
|
||||||
|
// DOM-Referenz für den Timer greifen kann. Text-Typ,
|
||||||
|
// da das Format mm:ss keine Zahl ist.
|
||||||
|
m("input.w3-input.w3-border", {
|
||||||
|
name: "dauer",
|
||||||
|
type: "text",
|
||||||
|
value: currentLog.dauer,
|
||||||
|
oncreate: (vnode: m.VnodeDOM) => {
|
||||||
|
dauerInput = vnode.dom as HTMLInputElement
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
m("label", "Betreff", m(Input, {name: "betreff", value: currentLog.betreff, type: "text"} as iInput)),
|
||||||
|
// Ansprechpartner und Telefon in einer Zeile.
|
||||||
|
m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}},
|
||||||
|
m("label", {style: {flex: '1'}}, "Name Ansprechpartner",
|
||||||
|
m(Input, {name: "nameAnsprechpartner", value: currentLog.nameAnsprechpartner, type: "text"} as iInput)),
|
||||||
|
m("label", {style: {flex: '1'}}, "Telefon",
|
||||||
|
m(Input, {name: "telefon", value: currentLog.telefon, type: "tel"} as iInput))
|
||||||
|
),
|
||||||
|
m("label", "Notiz", m(Textarea, {name: "notiz", value: currentLog.notiz, rows: 4} as iTextarea)),
|
||||||
|
m("label", "Wiedervorlage Datum", m(Input, {name: "wiedervorlageDatum", value: currentLog.wiedervorlageDatum, type: "date"} as iInput)),
|
||||||
|
m("label", m(Input, {name: "erledigt", type: "checkbox", value: currentLog.erledigt} as iInput), "Erledigt")
|
||||||
|
])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ export var headerdata = {
|
|||||||
version: "",
|
version: "",
|
||||||
username: "Nicht angemeldet",
|
username: "Nicht angemeldet",
|
||||||
userid: "Nicht angemeldet",
|
userid: "Nicht angemeldet",
|
||||||
appheader: "Der gläserne Kunde"
|
appheader: "Kundeninformationssystem"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const header: m.Component = {
|
export const header: m.Component = {
|
||||||
|
|||||||
+146
-9
@@ -1,18 +1,155 @@
|
|||||||
import m from "mithril"
|
import m from "mithril"
|
||||||
import {Form, iForm} from "./widgets/form"
|
import {Form, iForm} from "./widgets/form"
|
||||||
import { Input, iInput} from "./widgets/inputs"
|
import { Input, iInput} from "./widgets/inputs"
|
||||||
|
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
||||||
|
import {iKunde, kundenDaten, getNewKundeId, addKunde, updateKunde} from "../models/kunde"
|
||||||
|
|
||||||
export const kunde: m.Component = {
|
export const kunde: m.ClosureComponent = (vnode) => {
|
||||||
|
let currentKunde: iKunde = {
|
||||||
|
id: -1,
|
||||||
|
name: "",
|
||||||
|
strasse: "",
|
||||||
|
plz: "",
|
||||||
|
ort: "",
|
||||||
|
land: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
oninit: (vnode) => {
|
||||||
|
const routeParams = m.route.param()
|
||||||
|
if (routeParams) {
|
||||||
|
currentKunde = {
|
||||||
|
id: routeParams.id ? parseInt(routeParams.id) : -1,
|
||||||
|
name: routeParams.name || "",
|
||||||
|
strasse: routeParams.strasse || "",
|
||||||
|
plz: routeParams.plz || "",
|
||||||
|
ort: routeParams.ort || "",
|
||||||
|
land: routeParams.land || ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
view: (vnode) => {
|
view: (vnode) => {
|
||||||
return m("div.w3-container.w3-twothird",
|
const submit = (form: HTMLFormElement): boolean => {
|
||||||
m("h1", "Kundenstammblatt"),
|
const formData = new FormData(form)
|
||||||
m(Form, {showButtons: true, onsubmit: (null), oncancel: (null)} as iForm, [
|
const gespeicherterKunde: iKunde = {
|
||||||
m("label", "Kundenname (ganz neu)", m(Input, {name: "kundenname", value: "Musterfirma GmbH", type: "text"} as iInput)),
|
id: currentKunde.id,
|
||||||
m("label", "Straße", m(Input, {name: "strasse", value: "Musterstraße 1", type: "text"} as iInput)) ,
|
name: (formData.get("kundenname") as string) || "",
|
||||||
m("label", "PLZ", m(Input, {name: "plz", value: "12345", type: "text"} as iInput)),
|
strasse: (formData.get("strasse") as string) || "",
|
||||||
m("label", "Ort", m(Input, {name: "ort", value: "Musterstadt", type: "text"} as iInput)),
|
plz: (formData.get("plz") as string) || "",
|
||||||
m("label", "Land", m(Input, {name: "land", value: "Deutschland", type: "text"} as iInput))
|
ort: (formData.get("ort") as string) || "",
|
||||||
|
land: (formData.get("land") as string) || ""
|
||||||
|
}
|
||||||
|
if (currentKunde.id === -1) {
|
||||||
|
gespeicherterKunde.id = getNewKundeId()
|
||||||
|
addKunde(gespeicherterKunde)
|
||||||
|
} else {
|
||||||
|
updateKunde(gespeicherterKunde)
|
||||||
|
}
|
||||||
|
m.route.set("/kundenliste")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const cancel = (e: Event) => {
|
||||||
|
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: {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)),
|
||||||
|
m("label", "Straße", m(Input, {name: "strasse", value: currentKunde.strasse, type: "text"} as iInput)),
|
||||||
|
m("label", "PLZ", m(Input, {name: "plz", value: currentKunde.plz, type: "text"} as iInput)),
|
||||||
|
m("label", "Ort", m(Input, {name: "ort", value: currentKunde.ort, type: "text"} as iInput)),
|
||||||
|
m("label", "Land", m(Input, {name: "land", value: currentKunde.land, type: "text"} as iInput))
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt aus der Kundendatenliste die Treenode-Struktur für die Treetable der
|
||||||
|
* Kundenliste. Jeder Kunde wird zu einem expandierten Treenode ohne Kinder
|
||||||
|
* (expand=true, children=[]) - d.h. ein Blattknoten, der sofort in der
|
||||||
|
* Tabelle sichtbar ist und sich nicht weiter aufklappen lässt.
|
||||||
|
*/
|
||||||
|
function createKundenTreenodes(): iTreenode[] {
|
||||||
|
return kundenDaten.map((kunde: iKunde) => {
|
||||||
|
return new Treenode(kunde, true, [])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const kundenliste: m.ClosureComponent = (vnode) => {
|
||||||
|
let treenodes: iTreenode[] = createKundenTreenodes()
|
||||||
|
let selectedNode: iTreenode | null = null
|
||||||
|
|
||||||
|
const handleRowMarked = (node: iTreenode) => {
|
||||||
|
selectedNode = node
|
||||||
|
}
|
||||||
|
|
||||||
|
const editKunde = (kunde: iKunde, e: Event) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
m.route.set("/kunde", {id: kunde.id, name: kunde.name, strasse: kunde.strasse, plz: kunde.plz, ort: kunde.ort, land: kunde.land})
|
||||||
|
}
|
||||||
|
|
||||||
|
const showProjekte = (kunde: iKunde, e: Event) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
m.route.set("/projektliste", {kundenId: kunde.id})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addKundeRoute = () => {
|
||||||
|
m.route.set("/kunde", {id: -1, name: "", strasse: "", plz: "", ort: "", land: ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
|
||||||
|
let kunde: iKunde = node.nodevalue
|
||||||
|
const isSelected = selectedNode === node
|
||||||
|
const cellStyle = isSelected ? {backgroundColor: 'lightgray'} : {}
|
||||||
|
|
||||||
|
return [
|
||||||
|
m("div", {style: cellStyle}, m("strong", kunde.name)),
|
||||||
|
m("span", {style: cellStyle}, kunde.strasse),
|
||||||
|
m("span", {style: cellStyle}, kunde.plz),
|
||||||
|
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"))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
|
||||||
|
let kunde: iKunde = node.nodevalue
|
||||||
|
let searchLower = searchvalue.toLowerCase()
|
||||||
|
return kunde.name.toLowerCase().includes(searchLower) ||
|
||||||
|
kunde.strasse.toLowerCase().includes(searchLower) ||
|
||||||
|
kunde.plz.includes(searchLower) ||
|
||||||
|
kunde.ort.toLowerCase().includes(searchLower) ||
|
||||||
|
kunde.land.toLowerCase().includes(searchLower)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
view: (vnode) => {
|
||||||
|
return m("div.w3-container.w3-auto", {
|
||||||
|
style: {
|
||||||
|
display: 'flex',
|
||||||
|
'flex-direction': 'column',
|
||||||
|
height: 'inherit',
|
||||||
|
'overflow-y': 'inherit'}},
|
||||||
|
m("h1", "Kundenliste"),
|
||||||
|
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
||||||
|
m(Treetable,
|
||||||
|
{
|
||||||
|
treenodes: treenodes,
|
||||||
|
columnmapper: columnmapper,
|
||||||
|
filter: filterFunction,
|
||||||
|
onRowMarked: handleRowMarked
|
||||||
|
} as iTreetable,
|
||||||
|
m(TopbarButton, {onclick: addKundeRoute, tooltip: "Neuen Kunden anlegen"} as iTopbarButton,
|
||||||
|
m("i.material-icons", "person_add")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+233
-10
@@ -1,18 +1,241 @@
|
|||||||
import m from "mithril"
|
import m from "mithril"
|
||||||
import {Form, iForm} from "./widgets/form"
|
import {Form, iForm} from "./widgets/form"
|
||||||
import { Input, iInput} from "./widgets/inputs"
|
import { Input, iInput} from "./widgets/inputs"
|
||||||
|
import {Select, iSelect, iSelectOption} from "./widgets/select"
|
||||||
|
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
||||||
|
import {iProjekt, getProjekteByKundenId, getNewProjektId, addProjekt, updateProjekt} from "../models/projekt"
|
||||||
|
import {iKunde, getKunden} from "../models/kunde"
|
||||||
|
|
||||||
export const Projekt: m.Component = {
|
function createProjektTreenodes(kundenId: number | null = null): iTreenode[] {
|
||||||
|
let filteredProjekte = getProjekteByKundenId(kundenId)
|
||||||
|
|
||||||
|
return filteredProjekte.map((projekt: iProjekt) => {
|
||||||
|
return new Treenode(projekt, true, [])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const projektliste: m.ClosureComponent = (vnode) => {
|
||||||
|
let treenodes: iTreenode[] = createProjektTreenodes()
|
||||||
|
let selectedNode: iTreenode | null = null
|
||||||
|
let filterKundenId: number | null = null
|
||||||
|
|
||||||
|
const handleRowMarked = (node: iTreenode) => {
|
||||||
|
selectedNode = node
|
||||||
|
}
|
||||||
|
|
||||||
|
const addProjektRoute = () => {
|
||||||
|
const params: any = {
|
||||||
|
id: -1,
|
||||||
|
kundenname: "",
|
||||||
|
projektname: "",
|
||||||
|
zweck: "",
|
||||||
|
verantwortlicher: "",
|
||||||
|
startdatum: ""
|
||||||
|
}
|
||||||
|
// 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("/projekt", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
const editProjekt = (projekt: iProjekt, e: Event) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
m.route.set("/projekt", {
|
||||||
|
id: projekt.id,
|
||||||
|
kundenId: projekt.kundenId,
|
||||||
|
kundenname: projekt.kundenName,
|
||||||
|
projektname: projekt.projektname,
|
||||||
|
zweck: projekt.zweck,
|
||||||
|
verantwortlicher: projekt.verantwortlicher,
|
||||||
|
startdatum: projekt.startdatum
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCallLogs = (projekt: iProjekt, e: Event) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
m.route.set("/calllogliste", {projektId: projekt.id})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCallLog = (projekt: iProjekt, e: Event) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const now = new Date()
|
||||||
|
const datum = now.toISOString().split('T')[0]
|
||||||
|
const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1]
|
||||||
|
m.route.set("/call_log", {
|
||||||
|
id: -1,
|
||||||
|
kundenName: projekt.kundenName,
|
||||||
|
projektId: projekt.id,
|
||||||
|
projektName: projekt.projektname,
|
||||||
|
datum: datum,
|
||||||
|
uhrzeitBeginn: uhrzeit,
|
||||||
|
dauer: "",
|
||||||
|
betreff: "",
|
||||||
|
nameAnsprechpartner: "",
|
||||||
|
telefon: "",
|
||||||
|
notiz: "",
|
||||||
|
wiedervorlageDatum: "",
|
||||||
|
erledigt: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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"))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
|
||||||
|
let projekt: iProjekt = node.nodevalue
|
||||||
|
let searchLower = searchvalue.toLowerCase()
|
||||||
|
return projekt.kundenName.toLowerCase().includes(searchLower) ||
|
||||||
|
projekt.projektname.toLowerCase().includes(searchLower) ||
|
||||||
|
projekt.zweck.toLowerCase().includes(searchLower) ||
|
||||||
|
projekt.verantwortlicher.toLowerCase().includes(searchLower) ||
|
||||||
|
projekt.startdatum.includes(searchLower)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
oninit: (vnode) => {
|
||||||
|
const routeParams = m.route.param()
|
||||||
|
if (routeParams && routeParams.kundenId) {
|
||||||
|
filterKundenId = parseInt(routeParams.kundenId)
|
||||||
|
} else {
|
||||||
|
filterKundenId = null
|
||||||
|
}
|
||||||
|
treenodes = createProjektTreenodes(filterKundenId)
|
||||||
|
},
|
||||||
view: (vnode) => {
|
view: (vnode) => {
|
||||||
return m("div.w3-container.w3-twothird",
|
return m("div.w3-container.w3-auto", {
|
||||||
m("h1", "Projekt-Stammblatt"),
|
style: {
|
||||||
m(Form, {showButtons: true, onsubmit: (null), oncancel: (null)} as iForm, [
|
display: 'flex',
|
||||||
m("label", "Kundenname", m(Input, {name: "kundenname", value: "Musterfirma GmbH", type: "text"} as iInput)),
|
'flex-direction': 'column',
|
||||||
m("label", "Projektname", m(Input, {name: "projektname", value: "Musterprojekt", type: "text"} as iInput)) ,
|
height: 'inherit',
|
||||||
m("label", "Zweck", m(Input, {name: "zweck", value: "Musterzweck", type: "text"} as iInput)),
|
'overflow-y': 'inherit'}},
|
||||||
m("label", "Verantwortlicher", m(Input, {name: "verantwortlicher", value: "König Dickbauch", type: "text"} as iInput)),
|
m("h1", "Projektliste"),
|
||||||
m("label", "Startdatum", m(Input, {name: "startdatum", value: "2023-01-01", type: "date"} as iInput))
|
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
||||||
])
|
m(Treetable,
|
||||||
|
{
|
||||||
|
treenodes: treenodes,
|
||||||
|
columnmapper: columnmapper,
|
||||||
|
filter: filterFunction,
|
||||||
|
onRowMarked: handleRowMarked
|
||||||
|
} as iTreetable,
|
||||||
|
m(TopbarButton, {onclick: addProjektRoute, tooltip: "Neues Projekt anlegen"} as iTopbarButton,
|
||||||
|
m("i.material-icons", "post_add")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Projekt: m.ClosureComponent = (vnode) => {
|
||||||
|
// currentProjekt hält den Formularzustand. kundenId referenziert den
|
||||||
|
// Kunden eindeutig, kundenname dient der Anzeige.
|
||||||
|
let currentProjekt: {
|
||||||
|
id: number
|
||||||
|
kundenId: number
|
||||||
|
kundenname: string
|
||||||
|
projektname: string
|
||||||
|
zweck: string
|
||||||
|
verantwortlicher: string
|
||||||
|
startdatum: string
|
||||||
|
} = {
|
||||||
|
id: -1,
|
||||||
|
kundenId: -1,
|
||||||
|
kundenname: "",
|
||||||
|
projektname: "",
|
||||||
|
zweck: "",
|
||||||
|
verantwortlicher: "",
|
||||||
|
startdatum: ""
|
||||||
|
}
|
||||||
|
// Ist der Kunde voreingestellt (gefilterte Projektliste)?
|
||||||
|
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
|
||||||
|
currentProjekt = {
|
||||||
|
id: routeParams.id ? parseInt(routeParams.id) : -1,
|
||||||
|
kundenId: kundenId,
|
||||||
|
kundenname: routeParams.kundenname || "",
|
||||||
|
projektname: routeParams.projektname || "",
|
||||||
|
zweck: routeParams.zweck || "",
|
||||||
|
verantwortlicher: routeParams.verantwortlicher || "",
|
||||||
|
startdatum: routeParams.startdatum || ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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 gespeichertesProjekt: iProjekt = {
|
||||||
|
id: currentProjekt.id,
|
||||||
|
kundenId: kundenId,
|
||||||
|
kundenName: kunde ? kunde.name : "",
|
||||||
|
projektname: (formData.get("projektname") as string) || "",
|
||||||
|
zweck: (formData.get("zweck") as string) || "",
|
||||||
|
verantwortlicher: (formData.get("verantwortlicher") as string) || "",
|
||||||
|
startdatum: (formData.get("startdatum") as string) || ""
|
||||||
|
}
|
||||||
|
if (currentProjekt.id === -1) {
|
||||||
|
gespeichertesProjekt.id = getNewProjektId()
|
||||||
|
addProjekt(gespeichertesProjekt)
|
||||||
|
} else {
|
||||||
|
updateProjekt(gespeichertesProjekt)
|
||||||
|
}
|
||||||
|
m.route.set("/projektliste")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const cancel = (e: Event) => {
|
||||||
|
m.route.set("/projektliste")
|
||||||
|
}
|
||||||
|
// 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", currentProjekt.id === -1 ? "Neues Projekt anlegen" : "Projekt-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: currentProjekt.kundenId,
|
||||||
|
options: kundenOptionen,
|
||||||
|
readonly: kundeVoreingestellt,
|
||||||
|
required: true
|
||||||
|
} as iSelect)),
|
||||||
|
m("label", "Projektname", m(Input, {name: "projektname", value: currentProjekt.projektname, type: "text", required: true} as iInput)),
|
||||||
|
m("label", "Zweck", m(Input, {name: "zweck", value: currentProjekt.zweck, type: "text"} as iInput)),
|
||||||
|
m("label", "Verantwortlicher", m(Input, {name: "verantwortlicher", value: currentProjekt.verantwortlicher, type: "text"} as iInput)),
|
||||||
|
m("label", "Startdatum", m(Input, {name: "startdatum", value: currentProjekt.startdatum, type: "date"} as iInput))
|
||||||
|
])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
import m from "mithril"
|
||||||
|
import {Form, iForm} from "./widgets/form"
|
||||||
|
import { Input, iInput} from "./widgets/inputs"
|
||||||
|
import {Treenode, iTreenode, Treetable, iTreetable} from "./widgets/Treetable_V2"
|
||||||
|
|
||||||
|
const colorPalette: string[] = [
|
||||||
|
"#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff",
|
||||||
|
"#ff8000", "#80ff00", "#8000ff", "#ff8080", "#80ff80", "#8080ff",
|
||||||
|
"#ffa500", "#008000", "#000080", "#800000", "#808000", "#008080"
|
||||||
|
]
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INTERFACES FÜR HIERARCHISCHE TAGS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basis-Tag-Interface für die hierarchische Struktur.
|
||||||
|
*/
|
||||||
|
export interface iTag {
|
||||||
|
id: number; // Numerische ID (für Kompatibilität mit bestehendem Code)
|
||||||
|
name: string; // Anzeigename des Tags
|
||||||
|
description: string; // Beschreibung
|
||||||
|
color: string; // Farbcode (z. B. "#ff0000")
|
||||||
|
parentId: number | null; // Referenz auf den Eltern-Tag (null = Wurzelebene)
|
||||||
|
icon?: string; // Optional: Icon-Name oder URL
|
||||||
|
order?: number; // Optional: Sortierreihenfolge innerhalb der Ebene
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag mit verschachtelten Kindern für die Hierarchie-Darstellung.
|
||||||
|
*/
|
||||||
|
interface iTagWithChildren extends iTag {
|
||||||
|
children: iTagWithChildren[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// BEISPIELDATEN (HIERARCHISCH)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flache Liste aller Tags (für Datenbank-ähnliche Speicherung).
|
||||||
|
* Hierarchie wird über parentId abgebildet.
|
||||||
|
*/
|
||||||
|
const tagDaten: iTag[] = [
|
||||||
|
// ========== WURZEL-TAGS (Top-Level) ==========
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Wichtig",
|
||||||
|
description: "Hochpriorisierte Aufgaben",
|
||||||
|
color: "#ff0000",
|
||||||
|
parentId: null,
|
||||||
|
icon: "priority_high",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Kunde",
|
||||||
|
description: "Kundenbezogene Tags",
|
||||||
|
color: "#00ff00",
|
||||||
|
parentId: null,
|
||||||
|
icon: "person",
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Projekt",
|
||||||
|
description: "Projektbezogene Tags",
|
||||||
|
color: "#0000ff",
|
||||||
|
parentId: null,
|
||||||
|
icon: "folder",
|
||||||
|
order: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: "Follow-up",
|
||||||
|
description: "Nachverfolgung erforderlich",
|
||||||
|
color: "#ffff00",
|
||||||
|
parentId: null,
|
||||||
|
icon: "schedule",
|
||||||
|
order: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: "Archiv",
|
||||||
|
description: "Archivierte Einträge",
|
||||||
|
color: "#808080",
|
||||||
|
parentId: null,
|
||||||
|
icon: "archive",
|
||||||
|
order: 5,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== TECHNISCHE KATEGORIEN (neu) ==========
|
||||||
|
{
|
||||||
|
id: 100,
|
||||||
|
name: "Backend",
|
||||||
|
description: "Server-seitige Technologien und Frameworks",
|
||||||
|
color: "#4285F4",
|
||||||
|
parentId: null,
|
||||||
|
icon: "server",
|
||||||
|
order: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
name: "Frontend",
|
||||||
|
description: "Client-seitige Technologien und Frameworks",
|
||||||
|
color: "#34A853",
|
||||||
|
parentId: null,
|
||||||
|
icon: "browser",
|
||||||
|
order: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 102,
|
||||||
|
name: "DevOps",
|
||||||
|
description: "Infrastruktur, Deployment und CI/CD",
|
||||||
|
color: "#EA4335",
|
||||||
|
parentId: null,
|
||||||
|
icon: "cog",
|
||||||
|
order: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 103,
|
||||||
|
name: "Daten",
|
||||||
|
description: "Datenbanken, Analyse und KI",
|
||||||
|
color: "#FBBC04",
|
||||||
|
parentId: null,
|
||||||
|
icon: "database",
|
||||||
|
order: 9,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Backend -> Programmiersprachen
|
||||||
|
{
|
||||||
|
id: 200,
|
||||||
|
name: "Python",
|
||||||
|
description: "Allgemeine Python-Entwicklung",
|
||||||
|
color: "#3776AB",
|
||||||
|
parentId: 100,
|
||||||
|
icon: "python",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 201,
|
||||||
|
name: "Java",
|
||||||
|
description: "Java und JVM-Ökosystem",
|
||||||
|
color: "#007396",
|
||||||
|
parentId: 100,
|
||||||
|
icon: "java",
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 202,
|
||||||
|
name: "Node.js",
|
||||||
|
description: "JavaScript auf dem Server",
|
||||||
|
color: "#339933",
|
||||||
|
parentId: 100,
|
||||||
|
icon: "nodejs",
|
||||||
|
order: 3,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Backend -> Python -> Frameworks
|
||||||
|
{
|
||||||
|
id: 300,
|
||||||
|
name: "Flask",
|
||||||
|
description: "Mikro-Webframework für Python",
|
||||||
|
color: "#000000",
|
||||||
|
parentId: 200,
|
||||||
|
icon: "flask",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 301,
|
||||||
|
name: "Django",
|
||||||
|
description: "Full-Stack Webframework für Python",
|
||||||
|
color: "#092E20",
|
||||||
|
parentId: 200,
|
||||||
|
icon: "django",
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 302,
|
||||||
|
name: "FastAPI",
|
||||||
|
description: "Moderne API-Entwicklung mit Python",
|
||||||
|
color: "#005577",
|
||||||
|
parentId: 200,
|
||||||
|
icon: "fastapi",
|
||||||
|
order: 3,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Backend -> Node.js -> Frameworks
|
||||||
|
{
|
||||||
|
id: 310,
|
||||||
|
name: "Express",
|
||||||
|
description: "Minimalistisches Webframework für Node.js",
|
||||||
|
color: "#000000",
|
||||||
|
parentId: 202,
|
||||||
|
icon: "express",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 311,
|
||||||
|
name: "NestJS",
|
||||||
|
description: "Progressives Node.js-Framework für Enterprise-Anwendungen",
|
||||||
|
color: "#E0234E",
|
||||||
|
parentId: 202,
|
||||||
|
icon: "nestjs",
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Backend -> Datenbanken
|
||||||
|
{
|
||||||
|
id: 210,
|
||||||
|
name: "Datenbanken",
|
||||||
|
description: "Backend-Datenbank-Systeme",
|
||||||
|
color: "#336791",
|
||||||
|
parentId: 100,
|
||||||
|
icon: "database",
|
||||||
|
order: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 211,
|
||||||
|
name: "PostgreSQL",
|
||||||
|
description: "Relationale Datenbank mit erweiterten Features",
|
||||||
|
color: "#336791",
|
||||||
|
parentId: 210,
|
||||||
|
icon: "postgres",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 212,
|
||||||
|
name: "MongoDB",
|
||||||
|
description: "Dokumentenorientierte NoSQL-Datenbank",
|
||||||
|
color: "#47A248",
|
||||||
|
parentId: 210,
|
||||||
|
icon: "mongodb",
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Frontend -> JavaScript
|
||||||
|
{
|
||||||
|
id: 220,
|
||||||
|
name: "JavaScript",
|
||||||
|
description: "Die Hauptsprache des Web",
|
||||||
|
color: "#F7DF1E",
|
||||||
|
parentId: 101,
|
||||||
|
icon: "js",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 221,
|
||||||
|
name: "TypeScript",
|
||||||
|
description: "Typsicheres JavaScript",
|
||||||
|
color: "#3178C6",
|
||||||
|
parentId: 101,
|
||||||
|
icon: "ts",
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Frontend -> JavaScript -> Frameworks
|
||||||
|
{
|
||||||
|
id: 320,
|
||||||
|
name: "React",
|
||||||
|
description: "Komponentenbasiertes UI-Framework",
|
||||||
|
color: "#61DAFB",
|
||||||
|
parentId: 220,
|
||||||
|
icon: "react",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 321,
|
||||||
|
name: "Vue.js",
|
||||||
|
description: "Progressives JavaScript-Framework",
|
||||||
|
color: "#4FC08D",
|
||||||
|
parentId: 220,
|
||||||
|
icon: "vue",
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 322,
|
||||||
|
name: "Angular",
|
||||||
|
description: "Full-Stack Framework von Google",
|
||||||
|
color: "#DD1B16",
|
||||||
|
parentId: 220,
|
||||||
|
icon: "angular",
|
||||||
|
order: 3,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Frontend -> CSS
|
||||||
|
{
|
||||||
|
id: 230,
|
||||||
|
name: "CSS",
|
||||||
|
description: "Stildefinition für Webseiten",
|
||||||
|
color: "#1572B6",
|
||||||
|
parentId: 101,
|
||||||
|
icon: "css",
|
||||||
|
order: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 330,
|
||||||
|
name: "Tailwind CSS",
|
||||||
|
description: "Utility-first CSS-Framework",
|
||||||
|
color: "#06B6D4",
|
||||||
|
parentId: 230,
|
||||||
|
icon: "tailwind",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// DevOps
|
||||||
|
{
|
||||||
|
id: 240,
|
||||||
|
name: "Docker",
|
||||||
|
description: "Containerisierung von Anwendungen",
|
||||||
|
color: "#2496ED",
|
||||||
|
parentId: 102,
|
||||||
|
icon: "docker",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 241,
|
||||||
|
name: "Kubernetes",
|
||||||
|
description: "Orchestrierung von Containern",
|
||||||
|
color: "#326CE5",
|
||||||
|
parentId: 102,
|
||||||
|
icon: "kubernetes",
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 242,
|
||||||
|
name: "CI/CD",
|
||||||
|
description: "Continuous Integration und Deployment",
|
||||||
|
color: "#FFA500",
|
||||||
|
parentId: 102,
|
||||||
|
icon: "cicd",
|
||||||
|
order: 3,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Daten
|
||||||
|
{
|
||||||
|
id: 250,
|
||||||
|
name: "Künstliche Intelligenz",
|
||||||
|
description: "Machine Learning und KI-Technologien",
|
||||||
|
color: "#8B00FF",
|
||||||
|
parentId: 103,
|
||||||
|
icon: "ai",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 251,
|
||||||
|
name: "Big Data",
|
||||||
|
description: "Verarbeitung großer Datenmengen",
|
||||||
|
color: "#FF6B6B",
|
||||||
|
parentId: 103,
|
||||||
|
icon: "bigdata",
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HILFSFUNKTIONEN FÜR HIERARCHISCHE TAGS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt alle Wurzel-Tags (ohne Eltern) zurück.
|
||||||
|
*/
|
||||||
|
function getRootTags(): iTag[] {
|
||||||
|
return tagDaten.filter((tag) => tag.parentId === null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut aus der flachen Liste eine hierarchische Struktur mit Kindern.
|
||||||
|
* @param parentId - ID des Eltern-Tags (null für Wurzelebene)
|
||||||
|
* @returns Array von Tags mit verschachtelten Kindern
|
||||||
|
*/
|
||||||
|
function buildHierarchy(parentId: number | null = null): iTagWithChildren[] {
|
||||||
|
const children = tagDaten.filter((tag) => tag.parentId === parentId);
|
||||||
|
// Sortieren nach order-Feld (falls vorhanden)
|
||||||
|
children.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||||
|
return children.map((tag) => ({
|
||||||
|
...tag,
|
||||||
|
children: buildHierarchy(tag.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die vollständige Hierarchie als verschachteltes Objekt zurück.
|
||||||
|
*/
|
||||||
|
function getFullHierarchy(): iTagWithChildren[] {
|
||||||
|
return buildHierarchy(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht einen Tag nach ID.
|
||||||
|
*/
|
||||||
|
function findTagById(id: number): iTag | undefined {
|
||||||
|
return tagDaten.find((tag) => tag.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den vollständigen Pfad eines Tags als String zurück (z. B. "/Backend/Python/Flask").
|
||||||
|
*/
|
||||||
|
function getTagPath(tagId: number): string {
|
||||||
|
const tag = findTagById(tagId);
|
||||||
|
if (!tag) return "";
|
||||||
|
if (tag.parentId === null) return `/${tag.name}`;
|
||||||
|
const parentPath = getTagPath(tag.parentId);
|
||||||
|
return `${parentPath}/${tag.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt alle Nachfahren (Kinder, Enkel, ...) eines Tags zurück (flache Liste).
|
||||||
|
*/
|
||||||
|
function getDescendants(tagId: number): iTag[] {
|
||||||
|
const descendants: iTag[] = [];
|
||||||
|
const children = tagDaten.filter((tag) => tag.parentId === tagId);
|
||||||
|
for (const child of children) {
|
||||||
|
descendants.push(child);
|
||||||
|
descendants.push(...getDescendants(child.id));
|
||||||
|
}
|
||||||
|
return descendants;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt alle Vorfahren (Eltern, Großeltern, ...) eines Tags zurück.
|
||||||
|
*/
|
||||||
|
function getAncestors(tagId: number): iTag[] {
|
||||||
|
const ancestors: iTag[] = [];
|
||||||
|
const tag = findTagById(tagId);
|
||||||
|
if (!tag || tag.parentId === null) return ancestors;
|
||||||
|
|
||||||
|
const parent = findTagById(tag.parentId);
|
||||||
|
if (parent) {
|
||||||
|
ancestors.push(parent);
|
||||||
|
ancestors.push(...getAncestors(parent.id));
|
||||||
|
}
|
||||||
|
return ancestors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht Tags nach Namen (case-insensitive, partiell).
|
||||||
|
*/
|
||||||
|
function searchTagsByName(query: string): iTag[] {
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
return tagDaten.filter((tag) => tag.name.toLowerCase().includes(lowerQuery));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fügt einen neuen Tag hinzu (mit automatischer ID-Vergabe).
|
||||||
|
*/
|
||||||
|
function addTag(name: string, parentId: number | null = null, description: string = "", color: string = "#808080", icon?: string, order?: number): iTag {
|
||||||
|
const newId = tagDaten.length > 0 ? Math.max(...tagDaten.map(t => t.id)) + 1 : 1;
|
||||||
|
const tag: iTag = {
|
||||||
|
id: newId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
parentId,
|
||||||
|
icon,
|
||||||
|
order,
|
||||||
|
};
|
||||||
|
tagDaten.push(tag);
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ersetzt einen bestehenden Tag (z. B. nach Bearbeitung).
|
||||||
|
*/
|
||||||
|
function updateTag(updatedTag: iTag): void {
|
||||||
|
const index = tagDaten.findIndex(t => t.id === updatedTag.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
tagDaten[index] = updatedTag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TREENODE-ERZEUGUNG (FÜR DIE TREETABLE)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt Treenodes aus der hierarchischen Tag-Struktur.
|
||||||
|
* Berücksichtigt die Verschachtelung der Tags.
|
||||||
|
*/
|
||||||
|
function createTagTreenodes(): iTreenode[] {
|
||||||
|
const hierarchy = getFullHierarchy();
|
||||||
|
return hierarchy.map((tag) => createTreenodeFromTag(tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rekursive Funktion zum Erstellen eines Treenode aus einem Tag mit Kindern.
|
||||||
|
*/
|
||||||
|
function createTreenodeFromTag(tag: iTagWithChildren): iTreenode {
|
||||||
|
const children = tag.children.map(child => createTreenodeFromTag(child));
|
||||||
|
return new Treenode(tag, true, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// KOMPONENTEN
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const Tag: m.ClosureComponent = (vnode) => {
|
||||||
|
let currentTag: iTag = {
|
||||||
|
id: -1,
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
color: "",
|
||||||
|
parentId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const setColor = (color: string) => {
|
||||||
|
currentTag.color = color;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Liste aller Tags für die Eltern-Auswahl
|
||||||
|
const allTags = tagDaten.filter(t => t.id !== currentTag.id); // Ausschließen des aktuellen Tags
|
||||||
|
|
||||||
|
return {
|
||||||
|
oninit: (vnode) => {
|
||||||
|
const routeParams = m.route.param();
|
||||||
|
if (routeParams) {
|
||||||
|
currentTag = {
|
||||||
|
id: routeParams.id ? parseInt(routeParams.id) : -1,
|
||||||
|
name: routeParams.name || "",
|
||||||
|
description: routeParams.description || "",
|
||||||
|
color: routeParams.color || "",
|
||||||
|
parentId: routeParams.parentId ? parseInt(routeParams.parentId) : null,
|
||||||
|
icon: routeParams.icon || undefined,
|
||||||
|
order: routeParams.order ? parseInt(routeParams.order) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
view: (vnode) => {
|
||||||
|
return m("div.w3-container.w3-auto.form-view-container",
|
||||||
|
m("h1", currentTag.id === -1 ? "Neuen Tag erstellen" : "Tag bearbeiten"),
|
||||||
|
m("div", {style: {flex: '1', 'min-height': '0'}},
|
||||||
|
m(Form, {showButtons: true, onsubmit: (null), oncancel: (null)} as iForm, [
|
||||||
|
m("label", "Tag-Name", m(Input, {name: "name", value: currentTag.name, type: "text"} as iInput)),
|
||||||
|
m("label", "Beschreibung", m(Input, {name: "description", value: currentTag.description, type: "text"} as iInput)),
|
||||||
|
|
||||||
|
// Eltern-Tag-Auswahl
|
||||||
|
m("label", "Übergeordneter Tag (optional)",
|
||||||
|
m("select.w3-select", {name: "parentId", value: currentTag.parentId || ""},
|
||||||
|
m("option", {value: ""}, "-- Kein übergeordneter Tag --"),
|
||||||
|
allTags.map(tag =>
|
||||||
|
m("option", {value: tag.id, selected: currentTag.parentId === tag.id}, tag.name)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
m("label", "Farbe"),
|
||||||
|
m("div", {style: {display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px'}},
|
||||||
|
colorPalette.map(color =>
|
||||||
|
m("button", {
|
||||||
|
style: {
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
backgroundColor: color,
|
||||||
|
border: currentTag.color === color ? '2px solid black' : '1px solid #ccc',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
},
|
||||||
|
onclick: () => setColor(color)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
m(Input, {name: "color", value: currentTag.color, type: "text"} as iInput),
|
||||||
|
m("label", "Icon (optional)", m(Input, {name: "icon", value: currentTag.icon || "", type: "text"} as iInput)),
|
||||||
|
m("label", "Sortierreihenfolge (optional)", m(Input, {name: "order", value: currentTag.order?.toString() || "", type: "number"} as iInput)),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TagList: m.ClosureComponent = (vnode) => {
|
||||||
|
let treenodes: iTreenode[] = createTagTreenodes();
|
||||||
|
let selectedNode: iTreenode | null = null;
|
||||||
|
|
||||||
|
// Aktualisiere treenodes, wenn sich die Daten ändern
|
||||||
|
const updateTreenodes = () => {
|
||||||
|
treenodes = createTagTreenodes();
|
||||||
|
selectedNode = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowMarked = (node: iTreenode) => {
|
||||||
|
selectedNode = node;
|
||||||
|
};
|
||||||
|
|
||||||
|
const editTag = (tag: iTag, e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
m.route.set("/tag", {
|
||||||
|
id: tag.id,
|
||||||
|
name: tag.name,
|
||||||
|
description: tag.description,
|
||||||
|
color: tag.color,
|
||||||
|
parentId: tag.parentId,
|
||||||
|
icon: tag.icon,
|
||||||
|
order: tag.order
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
|
||||||
|
let tag: iTag = node.nodevalue;
|
||||||
|
const isSelected = selectedNode === node;
|
||||||
|
const cellStyle = isSelected ? {backgroundColor: 'lightgray'} : {};
|
||||||
|
|
||||||
|
return [
|
||||||
|
m("span", {style: cellStyle},
|
||||||
|
m("span", {
|
||||||
|
style: {
|
||||||
|
backgroundColor: tag.color,
|
||||||
|
color: 'white',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
display: 'inline-block',
|
||||||
|
marginRight: '8px'
|
||||||
|
}
|
||||||
|
}, tag.name)
|
||||||
|
),
|
||||||
|
m("span", {style: cellStyle}, tag.description),
|
||||||
|
m("div", {style: cellStyle},
|
||||||
|
m("button.w3-button.w3-small", {onclick: (e: Event) => editTag(tag, e)},
|
||||||
|
m("i.material-icons", "edit")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
|
||||||
|
let tag: iTag = node.nodevalue;
|
||||||
|
let searchLower = searchvalue.toLowerCase();
|
||||||
|
return tag.name.toLowerCase().includes(searchLower) ||
|
||||||
|
tag.description.toLowerCase().includes(searchLower) ||
|
||||||
|
tag.color.toLowerCase().includes(searchLower) ||
|
||||||
|
(tag.icon && tag.icon.toLowerCase().includes(searchLower));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
view: (vnode) => {
|
||||||
|
return m("div.w3-container", {
|
||||||
|
style: {
|
||||||
|
'max-width': '80%',
|
||||||
|
'margin-left': 'auto',
|
||||||
|
'margin-right': 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
'flex-direction': 'column',
|
||||||
|
height: 'inherit',
|
||||||
|
'overflow-y': 'inherit'}},
|
||||||
|
m("h1", "Tags"),
|
||||||
|
m("p.w3-text-grey", "Hierarchie: Tags können beliebig verschachtelt werden (z. B. Backend → Python → Flask)."),
|
||||||
|
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
||||||
|
m(Treetable,
|
||||||
|
{
|
||||||
|
treenodes: treenodes,
|
||||||
|
columnmapper: columnmapper,
|
||||||
|
filter: filterFunction,
|
||||||
|
onRowMarked: handleRowMarked
|
||||||
|
} as iTreetable
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXPORTE FÜR EXTERNE NUTZUNG
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
tagDaten as tags, // Flache Liste aller Tags
|
||||||
|
getFullHierarchy as hierarchy, // Vollständige hierarchische Struktur
|
||||||
|
findTagById,
|
||||||
|
getTagPath,
|
||||||
|
getDescendants,
|
||||||
|
getAncestors,
|
||||||
|
searchTagsByName,
|
||||||
|
addTag,
|
||||||
|
updateTag,
|
||||||
|
getRootTags,
|
||||||
|
};
|
||||||
@@ -6,6 +6,10 @@ export interface iForm {
|
|||||||
onsubmit: (form:HTMLFormElement) => boolean
|
onsubmit: (form:HTMLFormElement) => boolean
|
||||||
oncancel: (e:Event) => void
|
oncancel: (e:Event) => void
|
||||||
showbuttons?: boolean
|
showbuttons?: boolean
|
||||||
|
/** Wenn true (Default), bleiben die Buttons am unteren Rand fixiert,
|
||||||
|
* während der Formularinhalt scrollt. Bei false scrollen die Buttons
|
||||||
|
* mit dem Formular. */
|
||||||
|
stickyButtons?: boolean
|
||||||
/** Die Funktion gibt das HTMLFormElement zurück, welches in der Form-Componente angelegt wird */
|
/** Die Funktion gibt das HTMLFormElement zurück, welches in der Form-Componente angelegt wird */
|
||||||
onformcreate?: (form:HTMLFormElement) => void
|
onformcreate?: (form:HTMLFormElement) => void
|
||||||
}
|
}
|
||||||
@@ -14,6 +18,8 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
|||||||
let form: HTMLFormElement
|
let form: HTMLFormElement
|
||||||
let isDirty: boolean = false
|
let isDirty: boolean = false
|
||||||
let showButtons: boolean = vnode.attrs.showbuttons == undefined ? true : vnode.attrs.showbuttons
|
let showButtons: boolean = vnode.attrs.showbuttons == undefined ? true : vnode.attrs.showbuttons
|
||||||
|
// Default: Buttons bleiben fixiert am unteren Rand (stickyButtons = true).
|
||||||
|
let stickyButtons: boolean = vnode.attrs.stickyButtons == undefined ? true : vnode.attrs.stickyButtons
|
||||||
function canCloseForm(typ: string = "") {
|
function canCloseForm(typ: string = "") {
|
||||||
if (!isDirty) {
|
if (!isDirty) {
|
||||||
return true
|
return true
|
||||||
@@ -74,6 +80,48 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
|||||||
console.log(message)
|
console.log(message)
|
||||||
},
|
},
|
||||||
view: (vnode) => {
|
view: (vnode) => {
|
||||||
|
const buttons = showButtons == true
|
||||||
|
? m("div.w3-bar.w3-padding-16",
|
||||||
|
{
|
||||||
|
style: stickyButtons
|
||||||
|
? {flex: '0 0 auto', position: 'sticky', bottom: '0', 'background-color': 'white'}
|
||||||
|
: {}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
m("button.w3-button.w3-theme.w3-right.w3-mobile", {style: {margin: '0px 0px 2px 4px'}, type: "submit"}, "Speichern"),
|
||||||
|
m("button.w3-button.w3-theme.w3-right.w3-mobile", {style: {margin: '0px 0px 2px 4px'}, onclick: cancel, type: "button"}, "Abbrechen"),
|
||||||
|
m("button.w3-button.w3-theme.w3-right.w3-mobile", {onclick: reset, type: "button"}, "Zurücksetzen")
|
||||||
|
])
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Bei stickyButtons liegt der Formularinhalt in einem scrollbaren
|
||||||
|
// Container, die Buttons bleiben am unteren Rand fixiert. Das
|
||||||
|
// umschließende form-Element wird zum flex-Container, damit das
|
||||||
|
// Layout auch bei Fenstergrößenänderung stabil bleibt.
|
||||||
|
if (stickyButtons) {
|
||||||
|
return m("form", {
|
||||||
|
style: {display: 'flex', 'flex-direction': 'column', height: '100%'},
|
||||||
|
onreset: () => {console.log("reset")},
|
||||||
|
onsubmit: submit,
|
||||||
|
oncreate: (vnode) => {
|
||||||
|
form = vnode.dom as HTMLFormElement
|
||||||
|
let elements = form.querySelectorAll("input,textarea")
|
||||||
|
for (let index = 0; index < elements.length; index++) {
|
||||||
|
const element = elements[index];
|
||||||
|
element.addEventListener("change", onchanged)
|
||||||
|
}
|
||||||
|
registerFormElement(form)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Scrollbarer Formular-Inhalt.
|
||||||
|
m("div", {style: {flex: '1', 'overflow-y': 'auto', 'padding-right': '8px'}},
|
||||||
|
vnode.children
|
||||||
|
),
|
||||||
|
// Fixierter Button-Bereich.
|
||||||
|
buttons
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Ohne stickyButtons: bisheriges Verhalten (Buttons scrollen mit).
|
||||||
return m("form", {
|
return m("form", {
|
||||||
onreset: () => {console.log("reset")},
|
onreset: () => {console.log("reset")},
|
||||||
onsubmit: submit,
|
onsubmit: submit,
|
||||||
@@ -88,13 +136,7 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
vnode.children,
|
vnode.children,
|
||||||
showButtons == true ?
|
buttons
|
||||||
m("div.w3-bar.w3-padding-16",[
|
|
||||||
m("button.w3-button.w3-theme.w3-right.w3-mobile", {style: {margin: '0px 0px 2px 4px'}, type: "submit"}, "Speichern"),
|
|
||||||
m("button.w3-button.w3-theme.w3-right.w3-mobile", {style: {margin: '0px 0px 2px 4px'}, onclick: cancel, type: "button"}, "Abbrechen"),
|
|
||||||
m("button.w3-button.w3-theme.w3-right.w3-mobile", {onclick: reset, type: "button"}, "Zurücksetzen")
|
|
||||||
])
|
|
||||||
: null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,10 +117,12 @@ export const Textarea:m.Component<iTextarea> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toDateInputValue(date: Date): string {
|
export function toDateInputValue(date: Date|string): string {
|
||||||
console.log(date, typeof(date), (date instanceof Date))
|
console.log(date, typeof(date), (date instanceof Date))
|
||||||
let result: string = null
|
let result: string = null
|
||||||
if (date instanceof Date && date != null) {
|
if (typeof date === "string") {
|
||||||
|
result = date
|
||||||
|
} else if (date instanceof Date && date != null) {
|
||||||
result = [date.getFullYear(), (date.getMonth() + 1).toString().padStart(2, "0"), date.getDate().toString().padStart(2, "0")].join("-")
|
result = [date.getFullYear(), (date.getMonth() + 1).toString().padStart(2, "0"), date.getDate().toString().padStart(2, "0")].join("-")
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import m from "mithril"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Option für das Select-Widget.
|
||||||
|
*/
|
||||||
|
export interface iSelectOption {
|
||||||
|
/** Wert, der im Formular übermittelt wird (z.B. kundenId). */
|
||||||
|
value: any
|
||||||
|
/** Anzeige im Feld bzw. in der Dropdown-Liste (z.B. Kundenname). */
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Vnode der Komponente 'Select' akzeptiert die folgenden Parameter:
|
||||||
|
* @property {string} name - Der Name des Feldes (für FormData)
|
||||||
|
* @property {any} value - Der aktuell gewählte Wert (value der Option)
|
||||||
|
* @property {iSelectOption[]} options - Die zur Auswahl stehenden Optionen
|
||||||
|
* @property {boolean} readonly (optional) - Auswahl gesperrt
|
||||||
|
* @property {boolean} required (optional) - Pflichtfeld
|
||||||
|
* @property {Function} onchanged (optional) - Callback bei Änderung
|
||||||
|
*/
|
||||||
|
export interface iSelect {
|
||||||
|
name: string
|
||||||
|
value: any
|
||||||
|
options: iSelectOption[]
|
||||||
|
readonly?: boolean
|
||||||
|
required?: boolean
|
||||||
|
onchanged?: Function
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suchbares Select-Widget (Combobox). Ein sichtbares Texteingabefeld dient
|
||||||
|
* gleichzeitig als Suchfeld: während der Eingabe wird die aufklappbare
|
||||||
|
* Optionliste gefiltert — nur Optionen, deren label die eingegebene
|
||||||
|
* Zeichenfolge (case-insensitiv, als Teilstring) enthält, bleiben sichtbar.
|
||||||
|
* Wählt der Benutzer einen Eintrag, wird der interne Wert auf dessen
|
||||||
|
* option.value gesetzt und das Eingabefeld zeigt option.label.
|
||||||
|
*
|
||||||
|
* Für die Formularübergabe wird zusätzlich ein verstecktes input-Element
|
||||||
|
* gerendert, das den echten value trägt und über FormData ausgelesen werden
|
||||||
|
* kann. So funktioniert die Suche unabhängig vom übergebenen Wert.
|
||||||
|
*/
|
||||||
|
export const Select: m.ClosureComponent<iSelect> = () => {
|
||||||
|
// Interner Zustand: Suchtext, Dropdown geöffnet?, markierter Eintrag.
|
||||||
|
let search: string = ""
|
||||||
|
let isOpen: boolean = false
|
||||||
|
let highlightIndex: number = -1
|
||||||
|
// Referenz auf das Eingabefeld für Fokus nach Clear.
|
||||||
|
let inputfield: HTMLInputElement | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die gefilterten Optionen abhängig vom aktuellen Suchtext.
|
||||||
|
*/
|
||||||
|
const getFilteredOptions = (options: iSelectOption[]): iSelectOption[] => {
|
||||||
|
const searchLower = search.toLowerCase()
|
||||||
|
if (searchLower === "") {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
return options.filter((o: iSelectOption) => o.label.toLowerCase().includes(searchLower))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wählt eine Option aus: value setzen, Suchtext = label, Dropdown zu.
|
||||||
|
*/
|
||||||
|
const selectOption = (option: iSelectOption, vnode: m.Vnode<iSelect>) => {
|
||||||
|
vnode.attrs.value = option.value
|
||||||
|
search = option.label
|
||||||
|
isOpen = false
|
||||||
|
highlightIndex = -1
|
||||||
|
if (vnode.attrs.onchanged != undefined) {
|
||||||
|
vnode.attrs.onchanged(option.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Markierung auf den ersten Eintrag, wenn das Dropdown
|
||||||
|
* geöffnet wird.
|
||||||
|
*/
|
||||||
|
const openDropdown = (options: iSelectOption[], isReadonly: boolean) => {
|
||||||
|
if (!isReadonly) {
|
||||||
|
isOpen = true
|
||||||
|
highlightIndex = getFilteredOptions(options).length > 0 ? 0 : -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
oninit: (vnode) => {
|
||||||
|
// Aktuelles label zum value ermitteln (für die Anzeige im Feld).
|
||||||
|
const initialOption = vnode.attrs.options.find((o: iSelectOption) => o.value === vnode.attrs.value)
|
||||||
|
search = initialOption ? initialOption.label : ""
|
||||||
|
},
|
||||||
|
view: (vnode) => {
|
||||||
|
const isReadonly = vnode.attrs.readonly === true
|
||||||
|
const filteredOptions = getFilteredOptions(vnode.attrs.options)
|
||||||
|
const inputAttrs: any = {
|
||||||
|
type: "text",
|
||||||
|
value: search,
|
||||||
|
style: {'margin-bottom': '5px', width: '100%', 'padding-right': '32px'},
|
||||||
|
oncreate: (vnode: m.VnodeDOM) => {
|
||||||
|
inputfield = vnode.dom as HTMLInputElement
|
||||||
|
},
|
||||||
|
oninput: (e: Event) => {
|
||||||
|
if (isReadonly) { return }
|
||||||
|
search = (e.target as HTMLInputElement).value
|
||||||
|
isOpen = true
|
||||||
|
highlightIndex = filteredOptions.length > 0 ? 0 : -1
|
||||||
|
},
|
||||||
|
onfocus: (e: Event) => {
|
||||||
|
openDropdown(vnode.attrs.options, isReadonly)
|
||||||
|
},
|
||||||
|
onblur: (e: Event) => {
|
||||||
|
// Verzögert schließen, damit ein Klick auf einen Eintrag
|
||||||
|
// vorher registriert wird.
|
||||||
|
setTimeout(() => { isOpen = false }, 150)
|
||||||
|
},
|
||||||
|
onkeydown: (e: KeyboardEvent) => {
|
||||||
|
if (isReadonly) { return }
|
||||||
|
if (filteredOptions.length === 0) { return }
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
|
||||||
|
highlightIndex = (highlightIndex + 1) % filteredOptions.length
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
|
||||||
|
highlightIndex = (highlightIndex - 1 + filteredOptions.length) % filteredOptions.length
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
if (isOpen && highlightIndex >= 0 && highlightIndex < filteredOptions.length) {
|
||||||
|
e.preventDefault()
|
||||||
|
selectOption(filteredOptions[highlightIndex], vnode)
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
isOpen = false
|
||||||
|
highlightIndex = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isReadonly) {
|
||||||
|
inputAttrs.readonly = "true"
|
||||||
|
}
|
||||||
|
if (vnode.attrs.required === true) {
|
||||||
|
inputAttrs.required = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return m("div", {style: {width: '100%', position: 'relative'}},
|
||||||
|
// Verstecktes Feld für die Formularübergabe (echter value).
|
||||||
|
m("input", {type: "hidden", name: vnode.attrs.name, value: vnode.attrs.value}),
|
||||||
|
// Sichtbares Such-/Anzeigefeld mit Clear-Button darin.
|
||||||
|
m("div", {style: {position: 'relative', width: '100%'}},
|
||||||
|
m("input.w3-input.w3-border", inputAttrs),
|
||||||
|
// Clear-Button: leert das Suchfeld (nur wenn nicht readonly).
|
||||||
|
// Absolut positioniert, sodass der Button optisch im
|
||||||
|
// Eingabefeld liegt und vertikal zentriert ist.
|
||||||
|
!isReadonly
|
||||||
|
? m("button.w3-button", {
|
||||||
|
style: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: '4px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
'min-width': '24px',
|
||||||
|
height: '24px',
|
||||||
|
padding: '2px',
|
||||||
|
'line-height': '1'
|
||||||
|
},
|
||||||
|
onclick: () => {
|
||||||
|
search = ""
|
||||||
|
isOpen = true
|
||||||
|
highlightIndex = filteredOptions.length > 0 ? 0 : -1
|
||||||
|
if (inputfield) { inputfield.focus() }
|
||||||
|
}
|
||||||
|
}, m("i.material-icons", {style: {'font-size': '20px'}}, "clear"))
|
||||||
|
: null
|
||||||
|
),
|
||||||
|
// Gefilterte, aufklappbare Optionliste. Eigene Positionierung,
|
||||||
|
// da die w3-dropdown-content-Klasse das Element per CSS
|
||||||
|
// ausblendet (display:none) und nur bei :hover anzeigt.
|
||||||
|
isOpen && !isReadonly
|
||||||
|
? m("div.w3-bar-block.w3-border.w3-theme-l4", {
|
||||||
|
style: {
|
||||||
|
width: '95%',
|
||||||
|
'max-height': '220px',
|
||||||
|
'overflow-y': 'auto',
|
||||||
|
position: 'absolute',
|
||||||
|
'z-index': '1000',
|
||||||
|
'box-shadow': '2px 2px 4px darkgrey'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filteredOptions.length === 0
|
||||||
|
? m("div.w3-padding-small", "Keine Treffer")
|
||||||
|
: filteredOptions.map((option: iSelectOption, index: number) =>
|
||||||
|
m("a.w3-bar-item.w3-button.w3-text-left", {
|
||||||
|
style: {whiteSpace: 'normal'},
|
||||||
|
class: index === highlightIndex ? "w3-theme" : "w3-hover-light-grey",
|
||||||
|
// mousedown statt click: verhindert, dass das
|
||||||
|
// Eingabefeld den Fokus verliert, und schließt
|
||||||
|
// das Dropdown zuverlässig nach der Auswahl.
|
||||||
|
onmousedown: (e: Event) => {
|
||||||
|
e.preventDefault()
|
||||||
|
selectOption(option, vnode)
|
||||||
|
}
|
||||||
|
}, option.label)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-7
@@ -3,8 +3,10 @@ import {gridMountpoints, maingrid} from "./Components/mainlayout"
|
|||||||
import {header, menubar, init_menustack, get_menustack, headerdata} from "./Components/header"
|
import {header, menubar, init_menustack, get_menustack, headerdata} from "./Components/header"
|
||||||
import { appmanagementpage } from "./Components/auth/appmanagementpage"
|
import { appmanagementpage } from "./Components/auth/appmanagementpage"
|
||||||
import { changepasswordscreen, loginscreen } from "./Components/auth/login"
|
import { changepasswordscreen, loginscreen } from "./Components/auth/login"
|
||||||
import {kunde} from "./Components/kunde"
|
import {kunde, kundenliste} from "./Components/kunde"
|
||||||
import { Projekt } from "./Components/projekt"
|
import { Projekt, projektliste } from "./Components/projekt"
|
||||||
|
import { Call_Log, call_log_liste } from "./Components/call_log"
|
||||||
|
import { Tag, TagList } from "./Components/tags"
|
||||||
|
|
||||||
|
|
||||||
function healthcheck () {
|
function healthcheck () {
|
||||||
@@ -99,14 +101,14 @@ const knopf: m.Component = {
|
|||||||
const menu: m.Component = {
|
const menu: m.Component = {
|
||||||
view: (vnode) => {
|
view: (vnode) => {
|
||||||
return m(menubar, [
|
return m(menubar, [
|
||||||
m("a.w3-button.w3-bar-item", {href: "#!/kunde"}, "Kunden"),
|
m("a.w3-button.w3-bar-item", {href: "#!/kundenliste"}, "Kunden"),
|
||||||
m("a.w3-button.w3-bar-item", {href: "#!/projekt"}, "Projekte"),
|
m("a.w3-button.w3-bar-item", {href: "#!/projektliste"}, "Projekte"),
|
||||||
m("a.w3-button.w3-bar-item", {href: "#!/routename"}, "Gesprächsnotizen"),
|
m("a.w3-button.w3-bar-item", {href: "#!/calllogliste"}, "Gesprächsnotizen"),
|
||||||
// m("a.w3-button.w3-bar-item", {onclick: healthcheck}, "Aktualisieren"),
|
// m("a.w3-button.w3-bar-item", {onclick: healthcheck}, "Aktualisieren"),
|
||||||
m("div.w3-dropdown-hover",
|
m("div.w3-dropdown-hover",
|
||||||
m("button.w3-button", "Optionen"),
|
m("button.w3-button", "Optionen"),
|
||||||
m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
|
m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
|
||||||
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Tags"),
|
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"}, "Datenexport"),
|
||||||
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenimport"),
|
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenimport"),
|
||||||
)
|
)
|
||||||
@@ -144,6 +146,13 @@ m.route(gridMountpoints.main, "/blankpage", {
|
|||||||
"/usermanagement": appmanagementpage,
|
"/usermanagement": appmanagementpage,
|
||||||
"/changepassword": changepasswordscreen,
|
"/changepassword": changepasswordscreen,
|
||||||
"/login": loginscreen,
|
"/login": loginscreen,
|
||||||
|
"/kundenliste": kundenliste,
|
||||||
"/kunde": kunde,
|
"/kunde": kunde,
|
||||||
"/projekt": Projekt
|
"/projektliste": projektliste,
|
||||||
|
"/projekt": Projekt,
|
||||||
|
"/calllogliste": call_log_liste,
|
||||||
|
"/call_log": Call_Log,
|
||||||
|
"/taglist": TagList,
|
||||||
|
"/tag": Tag
|
||||||
|
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import m from "mithril"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datenmodell für eine Gesprächsnotiz (Call-Log).
|
||||||
|
*/
|
||||||
|
export interface iCallLog {
|
||||||
|
id: number
|
||||||
|
kundenName: string
|
||||||
|
projektId: number
|
||||||
|
projektName: string
|
||||||
|
datum: string
|
||||||
|
uhrzeitBeginn: string
|
||||||
|
dauer: string
|
||||||
|
betreff: string
|
||||||
|
nameAnsprechpartner: string
|
||||||
|
telefon: string
|
||||||
|
notiz: string
|
||||||
|
wiedervorlageDatum: string
|
||||||
|
erledigt: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-Memory-Datenspeicher für Gesprächsnotizen. Wird als Fallback solange
|
||||||
|
* verwendet, bis die serverseitigen REST-Endpunkte verfügbar sind.
|
||||||
|
*/
|
||||||
|
export const callLogDaten: iCallLog[] = [
|
||||||
|
{id: 1, kundenName: "Musterfirma GmbH", projektId: 1, projektName: "Website-Relaunch", datum: "2024-01-20", uhrzeitBeginn: "09:30", dauer: "45", betreff: "Anforderungen klären", nameAnsprechpartner: "Herr Schmidt", telefon: "+49 123 45678", notiz: "Kunde möchte responsive Design und neue Farbgestaltung. Budget noch offen.", wiedervorlageDatum: "2024-01-27", erledigt: false},
|
||||||
|
{id: 2, kundenName: "Musterfirma GmbH", projektId: 1, projektName: "Website-Relaunch", datum: "2024-01-25", uhrzeitBeginn: "14:00", dauer: "30", betreff: "Feedback zum Design", nameAnsprechpartner: "Frau Müller", telefon: "+49 123 45679", notiz: "Design-Entwürfe wurden positiv aufgenommen. Kleine Änderungen an der Navigation nötig.", wiedervorlageDatum: "2024-02-01", erledigt: false},
|
||||||
|
{id: 3, kundenName: "Beispiel AG", projektId: 2, projektName: "E-Commerce Plattform", datum: "2024-02-10", uhrzeitBeginn: "10:00", dauer: "60", betreff: "Technische Anforderungen", nameAnsprechpartner: "Herr Weber", telefon: "+49 123 45680", notiz: "Payment-Gateway und Versand-Schnittstellen müssen integriert werden. Zeitplan besprochen.", wiedervorlageDatum: "2024-02-15", erledigt: true},
|
||||||
|
{id: 4, kundenName: "Beispiel AG", projektId: 3, projektName: "CRM-System", datum: "2024-02-18", uhrzeitBeginn: "11:15", dauer: "20", betreff: "Schulungstermin", nameAnsprechpartner: "Frau Bauer", telefon: "+49 123 45681", notiz: "Schulung für 10 Mitarbeiter geplant. Raum noch zu buchen.", wiedervorlageDatum: "2024-02-20", erledigt: false},
|
||||||
|
{id: 5, kundenName: "Test Unternehmen", projektId: 4, projektName: "Mobile App", datum: "2024-03-05", uhrzeitBeginn: "15:45", dauer: "35", betreff: "Feature-Request", nameAnsprechpartner: "Herr König", telefon: "+49 123 45682", notiz: "Kunde wünscht Push-Benachrichtigungen und Offline-Modus.", wiedervorlageDatum: "2024-03-12", erledigt: false}
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die nächste freie Call-Log-ID auf Basis des In-Memory-Speichers.
|
||||||
|
* Sobald die serverseitige ID-Vergabe verfügbar ist, entfällt diese Funktion.
|
||||||
|
*/
|
||||||
|
export function getNewCallLogId(): number {
|
||||||
|
if (callLogDaten.length === 0) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return Math.max(...callLogDaten.map((log: iCallLog) => log.id)) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fügt eine neue Gesprächsnotiz dem In-Memory-Speicher hinzu.
|
||||||
|
*/
|
||||||
|
export function addCallLogData(log: iCallLog): void {
|
||||||
|
callLogDaten.push(log)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert eine bestehende Gesprächsnotiz im In-Memory-Speicher.
|
||||||
|
*/
|
||||||
|
export function updateCallLogData(log: iCallLog): void {
|
||||||
|
const index = callLogDaten.findIndex((l: iCallLog) => l.id === log.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
callLogDaten[index] = log
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert eine Kopie aller Gesprächsnotizen aus dem In-Memory-Speicher.
|
||||||
|
*/
|
||||||
|
export function getCallLogs(): iCallLog[] {
|
||||||
|
return [...callLogDaten]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die Gesprächsnotizen eines bestimmten Projekts. Ist projektId null,
|
||||||
|
* werden alle Gesprächsnotizen zurückgegeben.
|
||||||
|
*/
|
||||||
|
export function getCallLogsByProjektId(projektId: number | null): iCallLog[] {
|
||||||
|
return projektId
|
||||||
|
? callLogDaten.filter((l: iCallLog) => l.projektId === projektId)
|
||||||
|
: [...callLogDaten]
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 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 Call-Log-REST-Endpunkte.
|
||||||
|
*/
|
||||||
|
const CALLLOG_REST_BASE = "/xhr/calllog"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die Liste der Gesprächsnotizen vom Server.
|
||||||
|
*/
|
||||||
|
export function fetchCallLogs(): Promise<iCallLog[]> {
|
||||||
|
return m.request({
|
||||||
|
method: "GET",
|
||||||
|
url: CALLLOG_REST_BASE + "/list"
|
||||||
|
}).then((result) => result as iCallLog[])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt eine einzelne Gesprächsnotiz anhand ihrer ID vom Server.
|
||||||
|
*/
|
||||||
|
export function fetchCallLogById(id: number): Promise<iCallLog> {
|
||||||
|
return m.request({
|
||||||
|
method: "GET",
|
||||||
|
url: CALLLOG_REST_BASE + "/get",
|
||||||
|
params: {id: id}
|
||||||
|
}).then((result) => result as iCallLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt eine neue Gesprächsnotiz serverseitig an.
|
||||||
|
*/
|
||||||
|
export function createCallLogREST(log: iCallLog): Promise<iCallLog> {
|
||||||
|
return m.request({
|
||||||
|
method: "POST",
|
||||||
|
url: CALLLOG_REST_BASE + "/create",
|
||||||
|
body: log
|
||||||
|
}).then((result) => result as iCallLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert eine bestehende Gesprächsnotiz serverseitig.
|
||||||
|
*/
|
||||||
|
export function updateCallLogREST(log: iCallLog): Promise<iCallLog> {
|
||||||
|
return m.request({
|
||||||
|
method: "PUT",
|
||||||
|
url: CALLLOG_REST_BASE + "/update",
|
||||||
|
body: log
|
||||||
|
}).then((result) => result as iCallLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht eine Gesprächsnotiz serverseitig anhand ihrer ID.
|
||||||
|
*/
|
||||||
|
export function deleteCallLogREST(id: number): Promise<void> {
|
||||||
|
return m.request({
|
||||||
|
method: "DELETE",
|
||||||
|
url: CALLLOG_REST_BASE + "/delete",
|
||||||
|
params: {id: id}
|
||||||
|
}).then(() => undefined)
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import m from "mithril"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datenmodell für einen Kunden.
|
||||||
|
*/
|
||||||
|
export interface iKunde {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
strasse: string
|
||||||
|
plz: string
|
||||||
|
ort: string
|
||||||
|
land: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-Memory-Datenspeicher für Kunden. Wird als Fallback solange verwendet,
|
||||||
|
* bis die serverseitigen REST-Endpunkte verfügbar sind.
|
||||||
|
*/
|
||||||
|
export const kundenDaten: iKunde[] = [
|
||||||
|
{id: 1, name: "Musterfirma GmbH", strasse: "Musterstraße 1", plz: "12345", ort: "Musterstadt", land: "Deutschland"},
|
||||||
|
{id: 2, name: "Beispiel AG", strasse: "Beispielweg 2", plz: "54321", ort: "Beispielstadt", land: "Deutschland"},
|
||||||
|
{id: 3, name: "Test Unternehmen", strasse: "Testallee 3", plz: "67890", ort: "Testdorf", land: "Österreich"}
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die nächste freie Kunden-ID auf Basis des In-Memory-Speichers.
|
||||||
|
* Sobald die serverseitige ID-Vergabe verfügbar ist, entfällt diese Funktion.
|
||||||
|
*/
|
||||||
|
export function getNewKundeId(): number {
|
||||||
|
if (kundenDaten.length === 0) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return Math.max(...kundenDaten.map((kunde: iKunde) => kunde.id)) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fügt einen neuen Kunden dem In-Memory-Speicher hinzu.
|
||||||
|
*/
|
||||||
|
export function addKunde(kunde: iKunde): void {
|
||||||
|
kundenDaten.push(kunde)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert einen bestehenden Kunden im In-Memory-Speicher.
|
||||||
|
*/
|
||||||
|
export function updateKunde(kunde: iKunde): void {
|
||||||
|
const index = kundenDaten.findIndex((k: iKunde) => k.id === kunde.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
kundenDaten[index] = kunde
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert eine Kopie aller Kunden aus dem In-Memory-Speicher.
|
||||||
|
*/
|
||||||
|
export function getKunden(): iKunde[] {
|
||||||
|
return [...kundenDaten]
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 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 Kunden-REST-Endpunkte.
|
||||||
|
*/
|
||||||
|
const KUNDE_REST_BASE = "/xhr/kunde"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die Kundenliste vom Server.
|
||||||
|
*/
|
||||||
|
export function fetchKunden(): Promise<iKunde[]> {
|
||||||
|
return m.request({
|
||||||
|
method: "GET",
|
||||||
|
url: KUNDE_REST_BASE + "/list"
|
||||||
|
}).then((result) => result as iKunde[])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt einen einzelnen Kunden anhand seiner ID vom Server.
|
||||||
|
*/
|
||||||
|
export function fetchKundeById(id: number): Promise<iKunde> {
|
||||||
|
return m.request({
|
||||||
|
method: "GET",
|
||||||
|
url: KUNDE_REST_BASE + "/get",
|
||||||
|
params: {id: id}
|
||||||
|
}).then((result) => result as iKunde)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt einen neuen Kunden serverseitig an.
|
||||||
|
*/
|
||||||
|
export function createKundeREST(kunde: iKunde): Promise<iKunde> {
|
||||||
|
return m.request({
|
||||||
|
method: "POST",
|
||||||
|
url: KUNDE_REST_BASE + "/create",
|
||||||
|
body: kunde
|
||||||
|
}).then((result) => result as iKunde)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert einen bestehenden Kunden serverseitig.
|
||||||
|
*/
|
||||||
|
export function updateKundeREST(kunde: iKunde): Promise<iKunde> {
|
||||||
|
return m.request({
|
||||||
|
method: "PUT",
|
||||||
|
url: KUNDE_REST_BASE + "/update",
|
||||||
|
body: kunde
|
||||||
|
}).then((result) => result as iKunde)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht einen Kunden serverseitig anhand seiner ID.
|
||||||
|
*/
|
||||||
|
export function deleteKundeREST(id: number): Promise<void> {
|
||||||
|
return m.request({
|
||||||
|
method: "DELETE",
|
||||||
|
url: KUNDE_REST_BASE + "/delete",
|
||||||
|
params: {id: id}
|
||||||
|
}).then(() => undefined)
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import m from "mithril"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datenmodell für ein Projekt.
|
||||||
|
*/
|
||||||
|
export interface iProjekt {
|
||||||
|
id: number
|
||||||
|
kundenId: number
|
||||||
|
kundenName: string
|
||||||
|
projektname: string
|
||||||
|
zweck: string
|
||||||
|
verantwortlicher: string
|
||||||
|
startdatum: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-Memory-Datenspeicher für Projekte. Wird als Fallback solange verwendet,
|
||||||
|
* bis die serverseitigen REST-Endpunkte verfügbar sind.
|
||||||
|
*/
|
||||||
|
export const projektDaten: iProjekt[] = [
|
||||||
|
{id: 1, kundenId: 1, kundenName: "Musterfirma GmbH", projektname: "Website-Relaunch", zweck: "Neugestaltung der Unternehmenswebsite", verantwortlicher: "Max Mustermann", startdatum: "2024-01-15"},
|
||||||
|
{id: 2, kundenId: 1, kundenName: "Musterfirma GmbH", projektname: "E-Commerce Plattform", zweck: "Online-Shop Entwicklung", verantwortlicher: "Anna Schmidt", startdatum: "2024-03-01"},
|
||||||
|
{id: 3, kundenId: 2, kundenName: "Beispiel AG", projektname: "CRM-System", zweck: "Kundenbeziehungsmanagement", verantwortlicher: "Peter Müller", startdatum: "2024-02-20"},
|
||||||
|
{id: 4, kundenId: 3, kundenName: "Test Unternehmen", projektname: "Mobile App", zweck: "iOS und Android Entwicklung", verantwortlicher: "Lisa Bauer", startdatum: "2024-04-10"}
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die nächste freie Projekt-ID auf Basis des In-Memory-Speichers.
|
||||||
|
* Sobald die serverseitige ID-Vergabe verfügbar ist, entfällt diese Funktion.
|
||||||
|
*/
|
||||||
|
export function getNewProjektId(): number {
|
||||||
|
if (projektDaten.length === 0) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return Math.max(...projektDaten.map((projekt: iProjekt) => projekt.id)) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fügt ein neues Projekt dem In-Memory-Speicher hinzu.
|
||||||
|
*/
|
||||||
|
export function addProjekt(projekt: iProjekt): void {
|
||||||
|
projektDaten.push(projekt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert ein bestehendes Projekt im In-Memory-Speicher.
|
||||||
|
*/
|
||||||
|
export function updateProjekt(projekt: iProjekt): void {
|
||||||
|
const index = projektDaten.findIndex((p: iProjekt) => p.id === projekt.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
projektDaten[index] = projekt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert eine Kopie aller Projekte aus dem In-Memory-Speicher.
|
||||||
|
*/
|
||||||
|
export function getProjekte(): iProjekt[] {
|
||||||
|
return [...projektDaten]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die Projekte eines bestimmten Kunden. Ist kundenId null, werden
|
||||||
|
* alle Projekte zurückgegeben.
|
||||||
|
*/
|
||||||
|
export function getProjekteByKundenId(kundenId: number | null): iProjekt[] {
|
||||||
|
return kundenId
|
||||||
|
? projektDaten.filter((p: iProjekt) => p.kundenId === kundenId)
|
||||||
|
: [...projektDaten]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert ein einzelnes Projekt anhand seiner ID oder undefined.
|
||||||
|
*/
|
||||||
|
export function getProjektById(id: number): iProjekt | undefined {
|
||||||
|
return projektDaten.find((p: iProjekt) => p.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 Projekt-REST-Endpunkte.
|
||||||
|
*/
|
||||||
|
const PROJEKT_REST_BASE = "/xhr/projekt"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die Projektliste vom Server.
|
||||||
|
*/
|
||||||
|
export function fetchProjekte(): Promise<iProjekt[]> {
|
||||||
|
return m.request({
|
||||||
|
method: "GET",
|
||||||
|
url: PROJEKT_REST_BASE + "/list"
|
||||||
|
}).then((result) => result as iProjekt[])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt ein einzelnes Projekt anhand seiner ID vom Server.
|
||||||
|
*/
|
||||||
|
export function fetchProjektById(id: number): Promise<iProjekt> {
|
||||||
|
return m.request({
|
||||||
|
method: "GET",
|
||||||
|
url: PROJEKT_REST_BASE + "/get",
|
||||||
|
params: {id: id}
|
||||||
|
}).then((result) => result as iProjekt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt ein neues Projekt serverseitig an.
|
||||||
|
*/
|
||||||
|
export function createProjektREST(projekt: iProjekt): Promise<iProjekt> {
|
||||||
|
return m.request({
|
||||||
|
method: "POST",
|
||||||
|
url: PROJEKT_REST_BASE + "/create",
|
||||||
|
body: projekt
|
||||||
|
}).then((result) => result as iProjekt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert ein bestehendes Projekt serverseitig.
|
||||||
|
*/
|
||||||
|
export function updateProjektREST(projekt: iProjekt): Promise<iProjekt> {
|
||||||
|
return m.request({
|
||||||
|
method: "PUT",
|
||||||
|
url: PROJEKT_REST_BASE + "/update",
|
||||||
|
body: projekt
|
||||||
|
}).then((result) => result as iProjekt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht ein Projekt serverseitig anhand seiner ID.
|
||||||
|
*/
|
||||||
|
export function deleteProjektREST(id: number): Promise<void> {
|
||||||
|
return m.request({
|
||||||
|
method: "DELETE",
|
||||||
|
url: PROJEKT_REST_BASE + "/delete",
|
||||||
|
params: {id: id}
|
||||||
|
}).then(() => undefined)
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
@@ -20,3 +20,12 @@
|
|||||||
.w3-hover-theme:hover {color:#fff !important; background-color:#9e9e9e !important}
|
.w3-hover-theme:hover {color:#fff !important; background-color:#9e9e9e !important}
|
||||||
.w3-hover-text-theme:hover {color:#9e9e9e !important}
|
.w3-hover-text-theme:hover {color:#9e9e9e !important}
|
||||||
.w3-hover-border-theme:hover {border-color:#9e9e9e !important}
|
.w3-hover-border-theme:hover {border-color:#9e9e9e !important}
|
||||||
|
|
||||||
|
/* Container für Views, die ein Form-Widget mit stickyButtons enthalten:
|
||||||
|
voller Höhe, flex-Column, damit das Form-Widget den Inhalt scrollbar
|
||||||
|
hält und die Buttons fixiert am unteren Rand bleiben. */
|
||||||
|
.form-view-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -32,3 +32,4 @@ cp .env.example .env # Zitadel-Credentials eintragen
|
|||||||
flask db-init # Schema initialisieren
|
flask db-init # Schema initialisieren
|
||||||
flask seed-db # Testdaten einspielen
|
flask seed-db # Testdaten einspielen
|
||||||
flask run # App startet auf http://localhost:5000
|
flask run # App startet auf http://localhost:5000
|
||||||
|
```
|
||||||
@@ -23,20 +23,42 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
.
|
.
|
||||||
├── .gitignore
|
├── ai-context/ # Anweisungen und Information für Mistral AI
|
||||||
├── .env
|
├── App_Server/ # Enthält Alles, was auf den Server (Backend) gehört
|
||||||
├── .env.example
|
│ ├── data/ # enthält die SQLite-Datenbanken
|
||||||
├── requirements.txt
|
│ ├── Dockerfile
|
||||||
├── docker-compose.yml
|
│ ├── root/ # Enthält alle Python-Programme
|
||||||
├── Dockerfile
|
│ │ ├── approot.py
|
||||||
├── wsgi.py
|
│ │ ├── auth # Enthält Programme zu Benutzerverwaltung
|
||||||
├── data/ ← Datenbankverzeichnis (persistente SQLite-Datei, nicht im Image)
|
│ │ │ ├── model/ # Enthält die Datenmodelle der Benutzerverwaltung
|
||||||
└── app/
|
│ │ │ └── routes.py
|
||||||
├── __init__.py
|
│ │ ├── config.ini
|
||||||
├── config.py
|
│ │ ├── db.py
|
||||||
├── database.py
|
│ │ ├── requirements.txt
|
||||||
├── models.py
|
│ │ ├── routes/ # Enthält die Route-Definitionen für Flask
|
||||||
├── auth.py
|
│ │ ├── runserver.py
|
||||||
├── routes.py
|
│ │ ├── settings.py
|
||||||
├── templates/
|
│ │ └── templates/ # enthält die Vorlagen für Jinja2
|
||||||
└── static/
|
│ └── scripts/ # enthält Scripts, die im Dockjer-Container ausgeführt werden
|
||||||
|
├── Client/ # Enthält alles, was für die Browser-basierte GUI erforderlich ist
|
||||||
|
│ ├── Components/ # enthält die mit mithril.js entwickelten GUI-Komponenten
|
||||||
|
│ │ ├── auth/ # enthält die GUI-Komponenten für die Benutzer-Verwaltung
|
||||||
|
│ │ │ └── models/ # enthält die Datenmodelle für die Benutzerverwaltung
|
||||||
|
│ │ └── widgets # enthält einzelne GUI-Elemente wie Eingabefelder u.ä., die dann in den Komponenten verwendet werden
|
||||||
|
│ ├── index.ts
|
||||||
|
│ ├── inireader.mjs
|
||||||
|
│ ├── node_modules/
|
||||||
|
├── docker-compose.base.yaml
|
||||||
|
├── docker-compose.dev.yaml
|
||||||
|
├── docker-compose.prod.yaml
|
||||||
|
├── docker-compose.save.yaml
|
||||||
|
├── Dokumentationen/ # enthält Dokumente, die für die Entwicklung wichtig sind
|
||||||
|
├── mistral-context.md
|
||||||
|
├── NGINX_Server/ # enthält die Konfiguration für den im Docker-Container laufenden NGINX-Server
|
||||||
|
│ ├── certs/ # enthält die Zertifikatsdateien für die SSL-Verschlüsselung
|
||||||
|
│ ├── configtemplates
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── html/
|
||||||
|
│ └── static/ # enthält die statischen Elemente der GUI, wie css-Dateien und Javascript-Dateien
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
+54
-16
@@ -22,18 +22,53 @@ cd docs/ && make html # Ergebnis: docs/_build/html/index.html
|
|||||||
|
|
||||||
## Architektur
|
## Architektur
|
||||||
|
|
||||||
|
Das Projekt besteht aus 2 Teilen:
|
||||||
|
- Die GUI, die im Browser läuft und mit Typescript und mithril.js erstellt wird
|
||||||
|
- Das Backend, welches im wesentlichen mit Python, flask und sqlalchemy entwickelt wird
|
||||||
|
Daraus ergibt sich die folgende Struktur:
|
||||||
|
|
||||||
```
|
```
|
||||||
app/
|
.
|
||||||
├── __init__.py # App-Factory, Flask-Initialisierung, CLI-Kommandos (db-init, seed-db)
|
├── ai-context/ # Anweisungen und Information für Mistral AI
|
||||||
├── config.py # Konfiguration aus .env (12-Factor)
|
├── App_Server/ # Enthält Alles, was auf den Server (Backend) gehört
|
||||||
├── database.py # SQLAlchemy-Instanz und Session-Setup
|
│ ├── data/ # enthält die SQLite-Datenbanken
|
||||||
├── models.py # Alle ORM-Modelle (siehe Datenmodell unten)
|
│ ├── Dockerfile
|
||||||
├── auth.py # Zitadel-OIDC-Flow via Authlib; Rollen aus Token-Claims
|
│ ├── root/ # Enthält alle Python-Programme
|
||||||
├── routes.py # Flask-Blueprints und View-Funktionen
|
│ │ ├── approot.py
|
||||||
├── templates/ # Jinja2-Templates
|
│ │ ├── auth # Enthält Programme zu Benutzerverwaltung
|
||||||
└── static/ # CSS, JS, Assets
|
│ │ │ ├── model/ # Enthält die Datenmodelle der Benutzerverwaltung
|
||||||
data/ # SQLite-Datenbankdatei (nicht im Docker-Image, persistiert als Volume)
|
│ │ │ └── routes.py
|
||||||
wsgi.py # Waitress-Einstiegspunkt
|
│ │ ├── config.ini
|
||||||
|
│ │ ├── db.py
|
||||||
|
│ │ ├── requirements.txt
|
||||||
|
│ │ ├── routes/ # Enthält die Route-Definitionen für Flask
|
||||||
|
│ │ ├── runserver.py
|
||||||
|
│ │ ├── settings.py
|
||||||
|
│ │ └── templates/ # enthält die Vorlagen für Jinja2
|
||||||
|
│ └── scripts/ # enthält Scripts, die im Dockjer-Container ausgeführt werden
|
||||||
|
├── Client/ # Enthält alles, was für die Browser-basierte GUI erforderlich ist
|
||||||
|
│ ├── Components/ # enthält die mit mithril.js entwickelten GUI-Komponenten
|
||||||
|
│ │ ├── auth/ # enthält die GUI-Komponenten für die Benutzer-Verwaltung
|
||||||
|
│ │ │ └── models/ # enthält die Datenmodelle für die Benutzerverwaltung
|
||||||
|
│ │ └── widgets # enthält einzelne GUI-Elemente wie Eingabefelder u.ä.,
|
||||||
|
│ │ # die dann in den Komponenten verwendet werden
|
||||||
|
│ ├── index.ts
|
||||||
|
│ ├── inireader.mjs
|
||||||
|
│ ├── node_modules/
|
||||||
|
├── docker-compose.base.yaml
|
||||||
|
├── docker-compose.dev.yaml
|
||||||
|
├── docker-compose.prod.yaml
|
||||||
|
├── docker-compose.save.yaml
|
||||||
|
├── Dokumentationen/ # enthält Dokumente, die für die Entwicklung wichtig sind
|
||||||
|
├── mistral-context.md
|
||||||
|
├── NGINX_Server/ # enthält die Konfiguration für den im Docker-Container laufenden NGINX-Server
|
||||||
|
│ ├── certs/ # enthält die Zertifikatsdateien für die SSL-Verschlüsselung
|
||||||
|
│ ├── configtemplates
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── html/
|
||||||
|
│ └── static/ # enthält die statischen Elemente der GUI, wie css-Dateien und Javascript-Dateien
|
||||||
|
└── README.md
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Datenmodell
|
## Datenmodell
|
||||||
@@ -42,12 +77,12 @@ wsgi.py # Waitress-Einstiegspunkt
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `Benutzer` | Anwendungsnutzer; Primärschlüssel ist der Zitadel-`sub` |
|
| `Benutzer` | Anwendungsnutzer; Primärschlüssel ist der Zitadel-`sub` |
|
||||||
| `Kunden` | Kundenstamm (Firmen oder Privatpersonen) |
|
| `Kunden` | Kundenstamm (Firmen oder Privatpersonen) |
|
||||||
| `Kontakte` | Ansprechpartner bei einem Kunden (FK → Kunden) |
|
| `Kontakte` | Ansprechpartner bei einem Kunden (ForeignKey → Kunden) |
|
||||||
| `Projekte` | Akquise-Projekte, einem Kunden zugeordnet |
|
| `Projekte` | Akquise-Projekte, einem Kunden zugeordnet (ForeignKey → Kunden) |
|
||||||
| `Benutzer_Projekt` | n:m-Zuordnung Benutzer ↔ Projekte (Zugriffskontrolle) |
|
| `Benutzer_Projekt` | n:m-Zuordnung Benutzer ↔ Projekte (Zugriffskontrolle) |
|
||||||
| `Kontaktadressen` | Anrufziele innerhalb eines Projekts; importierbar via CSV/Excel (FK → Projekte) |
|
| `Kontaktadressen` | Anrufziele innerhalb eines Projekts; importierbar via CSV/Excel (ForeignKey → Projekte) |
|
||||||
| `Gesprächsnotizen` | Anrufdokumentation mit Metadaten, Freitext und Tags (FK → Kontaktadressen) |
|
| `Gesprächsnotizen` | Anrufdokumentation mit Metadaten, Freitext und Tags (ForeignKey → Projekte, Kontaktadressen) |
|
||||||
| `Tags` | Benutzerdefinierte Kategorien für Gesprächsnotizen |
|
| `Tags` | Benutzerdefinierte hierarchische Kategorien für Gesprächsnotizen |
|
||||||
| `Gesprächsnotizen_Tags` | n:m-Verknüpfung Gesprächsnotizen ↔ Tags |
|
| `Gesprächsnotizen_Tags` | n:m-Verknüpfung Gesprächsnotizen ↔ Tags |
|
||||||
|
|
||||||
## Authentifizierung & Autorisierung
|
## Authentifizierung & Autorisierung
|
||||||
@@ -100,6 +135,9 @@ SECRET_KEY=...
|
|||||||
|
|
||||||
## Weitere Vorgabedokumente
|
## Weitere Vorgabedokumente
|
||||||
|
|
||||||
|
Alle Dateien, die sich im Verzeichnis ai-context befinden, enthalten für das Projekt wichtige Information.
|
||||||
|
Die wichtigsten sind dabei die folgenden:
|
||||||
|
|
||||||
| Datei | Inhalt |
|
| Datei | Inhalt |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ai-context/AI-operative_Vorgaben.md` | Tech-Stack, Verzeichnisstruktur, Docker-Setup |
|
| `ai-context/AI-operative_Vorgaben.md` | Tech-Stack, Verzeichnisstruktur, Docker-Setup |
|
||||||
|
|||||||
Reference in new issue
Block a user