Compare commits
18
Commits
| 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: "",
|
||||
username: "Nicht angemeldet",
|
||||
userid: "Nicht angemeldet",
|
||||
appheader: "Der gläserne Kunde"
|
||||
appheader: "Kundeninformationssystem"
|
||||
}
|
||||
|
||||
export const header: m.Component = {
|
||||
|
||||
+146
-9
@@ -1,18 +1,155 @@
|
||||
import m from "mithril"
|
||||
import {Form, iForm} from "./widgets/form"
|
||||
import { Input, iInput} from "./widgets/inputs"
|
||||
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
||||
import {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) => {
|
||||
return m("div.w3-container.w3-twothird",
|
||||
m("h1", "Kundenstammblatt"),
|
||||
m(Form, {showButtons: true, onsubmit: (null), oncancel: (null)} as iForm, [
|
||||
m("label", "Kundenname (ganz neu)", m(Input, {name: "kundenname", value: "Musterfirma GmbH", type: "text"} as iInput)),
|
||||
m("label", "Straße", m(Input, {name: "strasse", value: "Musterstraße 1", type: "text"} as iInput)) ,
|
||||
m("label", "PLZ", m(Input, {name: "plz", value: "12345", type: "text"} as iInput)),
|
||||
m("label", "Ort", m(Input, {name: "ort", value: "Musterstadt", type: "text"} as iInput)),
|
||||
m("label", "Land", m(Input, {name: "land", value: "Deutschland", type: "text"} as iInput))
|
||||
const submit = (form: HTMLFormElement): boolean => {
|
||||
const formData = new FormData(form)
|
||||
const gespeicherterKunde: iKunde = {
|
||||
id: currentKunde.id,
|
||||
name: (formData.get("kundenname") as string) || "",
|
||||
strasse: (formData.get("strasse") as string) || "",
|
||||
plz: (formData.get("plz") as string) || "",
|
||||
ort: (formData.get("ort") as string) || "",
|
||||
land: (formData.get("land") as string) || ""
|
||||
}
|
||||
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 {Form, iForm} from "./widgets/form"
|
||||
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) => {
|
||||
return m("div.w3-container.w3-twothird",
|
||||
m("h1", "Projekt-Stammblatt"),
|
||||
m(Form, {showButtons: true, onsubmit: (null), oncancel: (null)} as iForm, [
|
||||
m("label", "Kundenname", m(Input, {name: "kundenname", value: "Musterfirma GmbH", type: "text"} as iInput)),
|
||||
m("label", "Projektname", m(Input, {name: "projektname", value: "Musterprojekt", type: "text"} as iInput)) ,
|
||||
m("label", "Zweck", m(Input, {name: "zweck", value: "Musterzweck", type: "text"} as iInput)),
|
||||
m("label", "Verantwortlicher", m(Input, {name: "verantwortlicher", value: "König Dickbauch", type: "text"} as iInput)),
|
||||
m("label", "Startdatum", m(Input, {name: "startdatum", value: "2023-01-01", type: "date"} as iInput))
|
||||
])
|
||||
return m("div.w3-container.w3-auto", {
|
||||
style: {
|
||||
display: 'flex',
|
||||
'flex-direction': 'column',
|
||||
height: 'inherit',
|
||||
'overflow-y': 'inherit'}},
|
||||
m("h1", "Projektliste"),
|
||||
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
|
||||
oncancel: (e:Event) => void
|
||||
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 */
|
||||
onformcreate?: (form:HTMLFormElement) => void
|
||||
}
|
||||
@@ -14,6 +18,8 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
let form: HTMLFormElement
|
||||
let isDirty: boolean = false
|
||||
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 = "") {
|
||||
if (!isDirty) {
|
||||
return true
|
||||
@@ -74,6 +80,48 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
console.log(message)
|
||||
},
|
||||
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", {
|
||||
onreset: () => {console.log("reset")},
|
||||
onsubmit: submit,
|
||||
@@ -88,13 +136,7 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
}
|
||||
},
|
||||
vnode.children,
|
||||
showButtons == true ?
|
||||
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
|
||||
buttons
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
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("-")
|
||||
}
|
||||
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 { appmanagementpage } from "./Components/auth/appmanagementpage"
|
||||
import { changepasswordscreen, loginscreen } from "./Components/auth/login"
|
||||
import {kunde} from "./Components/kunde"
|
||||
import { Projekt } from "./Components/projekt"
|
||||
import {kunde, kundenliste} from "./Components/kunde"
|
||||
import { Projekt, projektliste } from "./Components/projekt"
|
||||
import { Call_Log, call_log_liste } from "./Components/call_log"
|
||||
import { Tag, TagList } from "./Components/tags"
|
||||
|
||||
|
||||
function healthcheck () {
|
||||
@@ -99,14 +101,14 @@ const knopf: m.Component = {
|
||||
const menu: m.Component = {
|
||||
view: (vnode) => {
|
||||
return m(menubar, [
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/kunde"}, "Kunden"),
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/projekt"}, "Projekte"),
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/routename"}, "Gesprächsnotizen"),
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/kundenliste"}, "Kunden"),
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/projektliste"}, "Projekte"),
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/calllogliste"}, "Gesprächsnotizen"),
|
||||
// m("a.w3-button.w3-bar-item", {onclick: healthcheck}, "Aktualisieren"),
|
||||
m("div.w3-dropdown-hover",
|
||||
m("button.w3-button", "Optionen"),
|
||||
m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/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"}, "Datenimport"),
|
||||
)
|
||||
@@ -144,6 +146,13 @@ m.route(gridMountpoints.main, "/blankpage", {
|
||||
"/usermanagement": appmanagementpage,
|
||||
"/changepassword": changepasswordscreen,
|
||||
"/login": loginscreen,
|
||||
"/kundenliste": kundenliste,
|
||||
"/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-text-theme:hover {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 seed-db # Testdaten einspielen
|
||||
flask run # App startet auf http://localhost:5000
|
||||
```
|
||||
@@ -23,20 +23,42 @@
|
||||
|
||||
```text
|
||||
.
|
||||
├── .gitignore
|
||||
├── .env
|
||||
├── .env.example
|
||||
├── requirements.txt
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── wsgi.py
|
||||
├── data/ ← Datenbankverzeichnis (persistente SQLite-Datei, nicht im Image)
|
||||
└── app/
|
||||
├── __init__.py
|
||||
├── config.py
|
||||
├── database.py
|
||||
├── models.py
|
||||
├── auth.py
|
||||
├── routes.py
|
||||
├── templates/
|
||||
└── static/
|
||||
├── ai-context/ # Anweisungen und Information für Mistral AI
|
||||
├── App_Server/ # Enthält Alles, was auf den Server (Backend) gehört
|
||||
│ ├── data/ # enthält die SQLite-Datenbanken
|
||||
│ ├── Dockerfile
|
||||
│ ├── root/ # Enthält alle Python-Programme
|
||||
│ │ ├── approot.py
|
||||
│ │ ├── auth # Enthält Programme zu Benutzerverwaltung
|
||||
│ │ │ ├── model/ # Enthält die Datenmodelle der Benutzerverwaltung
|
||||
│ │ │ └── routes.py
|
||||
│ │ ├── 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
|
||||
```
|
||||
+54
-16
@@ -22,18 +22,53 @@ cd docs/ && make html # Ergebnis: docs/_build/html/index.html
|
||||
|
||||
## 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)
|
||||
├── config.py # Konfiguration aus .env (12-Factor)
|
||||
├── database.py # SQLAlchemy-Instanz und Session-Setup
|
||||
├── models.py # Alle ORM-Modelle (siehe Datenmodell unten)
|
||||
├── auth.py # Zitadel-OIDC-Flow via Authlib; Rollen aus Token-Claims
|
||||
├── routes.py # Flask-Blueprints und View-Funktionen
|
||||
├── templates/ # Jinja2-Templates
|
||||
└── static/ # CSS, JS, Assets
|
||||
data/ # SQLite-Datenbankdatei (nicht im Docker-Image, persistiert als Volume)
|
||||
wsgi.py # Waitress-Einstiegspunkt
|
||||
.
|
||||
├── ai-context/ # Anweisungen und Information für Mistral AI
|
||||
├── App_Server/ # Enthält Alles, was auf den Server (Backend) gehört
|
||||
│ ├── data/ # enthält die SQLite-Datenbanken
|
||||
│ ├── Dockerfile
|
||||
│ ├── root/ # Enthält alle Python-Programme
|
||||
│ │ ├── approot.py
|
||||
│ │ ├── auth # Enthält Programme zu Benutzerverwaltung
|
||||
│ │ │ ├── model/ # Enthält die Datenmodelle der Benutzerverwaltung
|
||||
│ │ │ └── routes.py
|
||||
│ │ ├── 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
|
||||
@@ -42,12 +77,12 @@ wsgi.py # Waitress-Einstiegspunkt
|
||||
|---|---|
|
||||
| `Benutzer` | Anwendungsnutzer; Primärschlüssel ist der Zitadel-`sub` |
|
||||
| `Kunden` | Kundenstamm (Firmen oder Privatpersonen) |
|
||||
| `Kontakte` | Ansprechpartner bei einem Kunden (FK → Kunden) |
|
||||
| `Projekte` | Akquise-Projekte, einem Kunden zugeordnet |
|
||||
| `Kontakte` | Ansprechpartner bei einem Kunden (ForeignKey → Kunden) |
|
||||
| `Projekte` | Akquise-Projekte, einem Kunden zugeordnet (ForeignKey → Kunden) |
|
||||
| `Benutzer_Projekt` | n:m-Zuordnung Benutzer ↔ Projekte (Zugriffskontrolle) |
|
||||
| `Kontaktadressen` | Anrufziele innerhalb eines Projekts; importierbar via CSV/Excel (FK → Projekte) |
|
||||
| `Gesprächsnotizen` | Anrufdokumentation mit Metadaten, Freitext und Tags (FK → Kontaktadressen) |
|
||||
| `Tags` | Benutzerdefinierte Kategorien für Gesprächsnotizen |
|
||||
| `Kontaktadressen` | Anrufziele innerhalb eines Projekts; importierbar via CSV/Excel (ForeignKey → Projekte) |
|
||||
| `Gesprächsnotizen` | Anrufdokumentation mit Metadaten, Freitext und Tags (ForeignKey → Projekte, Kontaktadressen) |
|
||||
| `Tags` | Benutzerdefinierte hierarchische Kategorien für Gesprächsnotizen |
|
||||
| `Gesprächsnotizen_Tags` | n:m-Verknüpfung Gesprächsnotizen ↔ Tags |
|
||||
|
||||
## Authentifizierung & Autorisierung
|
||||
@@ -100,6 +135,9 @@ SECRET_KEY=...
|
||||
|
||||
## 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 |
|
||||
|---|---|
|
||||
| `ai-context/AI-operative_Vorgaben.md` | Tech-Stack, Verzeichnisstruktur, Docker-Setup |
|
||||
|
||||
Reference in new issue
Block a user