Compare commits

...
10 Commits
Author SHA1 Message Date
gg 1d8201f46a fix: Entferne Kommentar aus .gitignore für App_Server/data 2026-09-19 19:02:59 +02:00
gg d7e5c014a8 Merge branch 'main' of https://git.everdc.eu/chmln-app-dev/kbcrm 2026-09-19 18:59:14 +02:00
gg a67921ec74 feat(isdirty): Navigations-Guard gegen ungespeichertes Verlassen von Bearbeitungsmasken
Zentraler DirtyGuard (dirtyguard.ts) mit DirtyMaskHelper-Klasse und
3-Button-Bestätigungs-Modal (Speichern/Verwerden/Abbrechen). Form meldet
Dirty-State via onDirtyChange nach oben; input-Event ergänzt change für
frühzeitiges Dirty-Marking. Alle App-Navigationen (Breadcrumb, Menü,
In-Masken-Buttons, Abbrechen) laufen über guardedNavigate. Auto-Redraw
bei dirty per e.redraw=false unterbunden, damit DOM-Eingaben erhalten
bleiben (Input-Widget hat kein Live-Binding).

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-19 18:21:58 +02:00
gg fd30049a32 feat(nav): Breadcrumb-Navigation für Hierarchie-Aufrufkette
Neue BreadcrumbBar-Widget-Komponente (breadcrumb.ts) zeigt die
Aufrufkette als anklickbare Leiste. Der Route-Param bc (JSON-String)
transportiert die Vorgänger-Ansichten durch alle Listen und Masken.
Jede Ansicht hängt beim Weiternavigieren ihren eigenen Crumb an die
Kette an; Masken reichen bc beim Speichern/Abbrechen zurück.
Erfasst: kunde, projekt, kontaktadresse, call_log, kontakt.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-19 16:16:49 +02:00
gg f1e5938ae1 fix: Rückkehr-Kontext für Gesprächsnotizenliste erhalten
Die Bearbeitungsmaske kehrt beim Speichern/Abbrechen nicht mehr mit dem
Projekt der bearbeiteten Notiz zurück, sondern mit dem Filter der Liste,
aus der sie geöffnet wurde. Liste reicht from_liste/return_projekt_id an
die Maske durch; Maske wertet diesen Kontext in goBack() aus.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-19 15:38:23 +02:00
gg d30adf1579 feat(calllog): Tags einer Gesprächsnotiz zuordnen (n:m)
Backend:
- Neues CallLogTag-Model (Tabelle call_logs_tags) mit Relationship
  auf CallLog (cascade delete-orphan); to_dict liefert 'tags'.
- Migration _db_V01_11 (idempotent via create_all).
- _set_call_log_tags in create/update_call_log: ersetzt
  Tag-Zuordnungen, filtert is_parent-Tags, eliminiert Duplikate.
- _parse_date wandelt ISO-Strings in date-Objekte (fixt autoflush-
  Crash bei Date-Spalten).

Frontend:
- iCallLog um tags (Lesen) und tag_ids (Schreiben) ergänzt.
- Neues generisches MultiSelect-Widget (widgets/select.ts):
  Badges + Edit-Button, inline Modal mit Suchfeld, Checkbox-Liste,
  OK-Button; konfigurierbare Texte (placeholder, modalTitle,
  editTooltip).
- Call_Log-Formular: Tag-Auswahl via MultiSelect, Submit via
  FormData.getAll; Tag-Badges in der Listenansicht hinter dem Betreff.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-19 15:12:36 +02:00
gg 5abcb2d96c feat(tags): Backend mit REST-API und Client-Anbindung fuer hierarchische Tags
- SQLAlchemy-Modell Tag mit Selbst-Referenz (parent_id), is_parent-Flag
- DB-Migration V01.10 (idempotent via create_all)
- Geschaeftslogik tag.py: CRUD, Hierarchie-Aufbau, kaskadierendes Loeschen,
  Zirkelbezug-Pruefung
- REST-Endpunkte /xhr/tag/*: Lesen fuer Mitarbeiter, Schreiben fuer AppAdmin
- Client-Modell tag.ts mit REST-Funktionen
- Frontend tags.ts auf REST-API umgestellt, hartcodierte Daten entfernt,
  Loesch-Dialog mit kaskadierender Bestaetigung

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-19 14:08:34 +02:00
gg 675d7775c1 feat: oninput-Callback zum Input-Widget hinzugefügt
Ermöglicht ein Live-Binding der Eingabewerte, damit Eingaben bei einem
Redraw (z.B. durch interaktive Elemente außerhalb des Inputs) nicht
verloren gehen.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-19 14:07:30 +02:00
gg b44ea806ef feat(tags): is_parent-Flag zur Unterscheidung von Gruppierungs-Tags und zuordenbaren Tags
Parent-Tags (is_parent=true) dienen als Gruppierungsknoten und können nicht
Gesprächsnotizen zugeordnet werden. Echte Tags (is_parent=false) sind
zuordenbar. Die Bearbeitungsmaske bietet eine Checkbox für das Flag, die
Eltern-Auswahl ist auf Parent-Tags beschränkt. Die Tag-Liste zeigt
Parent-Tags mit Folder-Icon, echte Tags mit Label-Icon. Die Suche filtert
den gesamten Teilbaum — nur Knoten mit Self-Match oder einem matchenden
Nachfahren werden angezeigt.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-19 10:40:39 +02:00
ggandMistral Vibe 3361ef16dd feat: Treetable filter auf Kind-Knoten anwenden
Der filter-Callback wird jetzt auch auf Kind-Knoten angewendet, nicht nur auf Top-Level-Nodes. Dadurch lässt sich bei hierarchischen Daten (z.B. Tag-Baum) die Suche auf den gesamten Teilbaum verfeinern — nur Knoten, die selbst matchen oder einen matchenden Nachfahren haben, werden gerendert.

Abwärtskompatibel: Aufrufer ohne filter-Attribut sind unaffected.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-19 10:39:11 +02:00
21 changed files with 1808 additions and 639 deletions

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@ node_modules/
tsout/
kbp_crm*.js
kbcrm/App_Server/data
#App_Server/data/
App_Server/data/
oidc-test/
.vibe/
.vibe-global/
+66 -1
View File
@@ -259,12 +259,19 @@ class CallLog(dbapp.Model):
erledigt = Column(Boolean, nullable=False, default=False)
projekt = relationship('Projekt')
# n:m-Beziehung zu Tags über die Verknüpfungstabelle call_logs_tags.
# cascade='all, delete-orphan' stellt sicher, dass die Zuordnungen
# automatisch gelöscht werden, wenn die Gesprächsnotiz gelöscht wird.
tags_rel = relationship(
'CallLogTag', cascade='all, delete-orphan', back_populates='call_log'
)
def to_dict(self):
"""
Liefert die Gesprächsnotiz als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; projekt_name und kunden_name werden
zusätzlich aus der Projekt-Relationship eingefügt.
zusätzlich aus der Projekt-Relationship eingefügt. Die zugeordneten
Tags werden als Liste von Tag-Dictionaries unter 'tags' ergänzt.
"""
result = columns2dict(self)
if self.projekt:
@@ -273,7 +280,65 @@ class CallLog(dbapp.Model):
else:
result['projekt_name'] = None
result['kunden_name'] = None
# Zugeordnete Tags serialisieren (nur is_parent=False sind zuordenbar).
result['tags'] = [clt.tag.to_dict() for clt in self.tags_rel]
return result
class CallLogTag(dbapp.Model):
"""
Verknüpfungstabelle für die n:m-Beziehung zwischen Gesprächsnotizen
(CallLog) und Tags. Jede Zeile verknüpft eine Gesprächsnotiz mit einem
Tag. Der UniqueConstraint stellt sicher, dass jeder Tag nur einmal pro
Gesprächsnotiz zugeordnet werden kann.
"""
__tablename__ = 'call_logs_tags'
__bind_key__ = BINDKEY
__table_args__ = (
UniqueConstraint('call_log_id', 'tag_id', name='uq_calllog_tag'),
Index('ix_calllogtag_call_log_id', 'call_log_id'),
Index('ix_calllogtag_tag_id', 'tag_id'),
)
id = Column(Integer, primary_key=True)
call_log_id = Column(Integer, ForeignKey('call_logs.id'), nullable=False)
tag_id = Column(Integer, ForeignKey('tags.id'), nullable=False)
call_log = relationship('CallLog', back_populates='tags_rel')
tag = relationship('Tag')
class Tag(dbapp.Model):
"""
Hierarchischer Tag (Taxonomie). Tags koennen beliebig verschachtelt
werden ueber die Selbst-Referenz parent_id -> tags.id. Ein Tag mit
is_parent=True ist ein reiner Gruppierungsknoten (nicht zuordenbar);
is_parent=False kennzeichnet einen echten, zuordenbaren Tag.
"""
__tablename__ = 'tags'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_tags_parent_id', 'parent_id'),
)
id = Column(Integer, primary_key=True)
name = Column(String(200), nullable=False)
description = Column(String(2000), nullable=True)
color = Column(String(20), nullable=True)
parent_id = Column(Integer, ForeignKey('tags.id'), nullable=True)
is_parent = Column(Boolean, nullable=False, default=False)
# Selbst-Referenzierende Relationship: parent verweist auf den
# uebergeordneten Tag, children auf alle direkten Kinder. Die
# Sortierung der Kinder erfolgt alphabetisch nach name.
parent = relationship(
'Tag', remote_side='Tag.id', back_populates='children'
)
children = relationship(
'Tag', back_populates='parent', order_by='Tag.name'
)
def to_dict(self):
"""Liefert die Tag-Daten als Dictionary (fuer JSON-Serialisierung)."""
return columns2dict(self)
print("########## crm/model importiert")
+61 -5
View File
@@ -9,14 +9,68 @@ dort über den Decorator authorisation_required.
:date: 2026-09-04
:version: 1.0
"""
from datetime import date
from db import dbapp
from crm.model import CallLog, Projekt, ProjektUser
from crm.model import CallLog, CallLogTag, Projekt, ProjektUser, Tag
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def _parse_date(value: str | None) -> date | None:
"""
Wandelt einen ISO-Datum-String (yyyy-MM-dd) in ein date-Objekt um.
Leere Strings und None werden zu None konvertiert (Spalte ist nullable).
"""
if not value:
return None
value = value.strip()
if not value:
return None
try:
return date.fromisoformat(value)
except ValueError:
return None
def _set_call_log_tags(call_log: CallLog, tag_ids: list) -> None:
"""
Ersetzt die Tag-Zuordnungen einer Gesprächsnotiz durch die übergebene
Liste von Tag-IDs. Bestehende Zuordnungen werden gelöscht, danach werden
die neuen Zuordnungen angelegt.
Nur Tags, die existieren und nicht reine Gruppierungsknoten
(is_parent=True) sind, werden zugeordnet. Duplikate in der Liste
werden durch ein Set eliminiert.
"""
# Bestehende Zuordnungen entfernen.
for clt in list(call_log.tags_rel):
session.delete(clt)
call_log.tags_rel = []
# Neue Zuordnungen anlegen.
seen = set()
for tag_id in tag_ids or []:
try:
tag_id = int(tag_id)
except (TypeError, ValueError):
continue
if tag_id in seen:
continue
seen.add(tag_id)
tag = Tag.query.get(tag_id)
if tag is None or tag.is_parent:
# Nicht zuordenbar — überspringen.
continue
clt = CallLogTag()
clt.call_log = call_log
clt.tag = tag
session.add(clt)
def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
"""
Liefert Gesprächsnotizen, sortiert nach Datum absteigend.
@@ -94,15 +148,16 @@ def create_call_log(data: dict) -> CallLog:
)
call_log = CallLog()
call_log.projekt_id = int(projekt_id)
call_log.datum = (data.get("datum") or "").strip() or None
call_log.datum = _parse_date(data.get("datum"))
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
call_log.dauer = (data.get("dauer") or "").strip() or None
call_log.betreff = (data.get("betreff") or "").strip() or None
call_log.name_ansprechpartner = (data.get("name_ansprechpartner") or "").strip() or None
call_log.telefon = (data.get("telefon") or "").strip() or None
call_log.notiz = (data.get("notiz") or "").strip() or None
call_log.wiedervorlage_datum = (data.get("wiedervorlage_datum") or "").strip() or None
call_log.wiedervorlage_datum = _parse_date(data.get("wiedervorlage_datum"))
call_log.erledigt = bool(data.get("erledigt", False))
_set_call_log_tags(call_log, data.get("tag_ids"))
session.add(call_log)
session.commit()
return call_log
@@ -127,15 +182,16 @@ def update_call_log(data: dict) -> CallLog:
htmlstatus=HtmlStatusCode.Bad_Request,
)
call_log.projekt_id = int(projekt_id)
call_log.datum = (data.get("datum") or "").strip() or None
call_log.datum = _parse_date(data.get("datum"))
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
call_log.dauer = (data.get("dauer") or "").strip() or None
call_log.betreff = (data.get("betreff") or "").strip() or None
call_log.name_ansprechpartner = (data.get("name_ansprechpartner") or "").strip() or None
call_log.telefon = (data.get("telefon") or "").strip() or None
call_log.notiz = (data.get("notiz") or "").strip() or None
call_log.wiedervorlage_datum = (data.get("wiedervorlage_datum") or "").strip() or None
call_log.wiedervorlage_datum = _parse_date(data.get("wiedervorlage_datum"))
call_log.erledigt = bool(data.get("erledigt", False))
_set_call_log_tags(call_log, data.get("tag_ids"))
session.commit()
return call_log
+22
View File
@@ -221,6 +221,28 @@ def _db_V01_09():
session.commit()
def _db_V01_10():
"""
Legt die Tabelle 'tags' an. Tags sind eine hierarchische Taxonomie
mit Selbst-Referenz (parent_id -> tags.id). create_all erzeugt nur
fehlende Tabellen, bestehende bleiben unangetastet.
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.10')
session.commit()
def _db_V01_11():
"""
Legt die Verknüpfungstabelle 'call_logs_tags' für die n:m-Beziehung
zwischen Gesprächsnotizen und Tags an. create_all erzeugt nur fehlende
Tabellen, bestehende bleiben unangetastet (idempotent).
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.11')
session.commit()
def checkupdates():
"""
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
+181
View File
@@ -0,0 +1,181 @@
"""
Geschäftslogik für die Entität Tag (hierarchische Taxonomie).
Tags koennen beliebig verschachtelt werden (parent_id -> tags.id).
Ein Tag mit is_parent=True ist ein Gruppierungsknoten und nicht
zuordenbar; is_parent=False kennzeichnet einen echten, zuordenbaren Tag.
Tags sind nicht projektbezogen — die projektbezogene Sichtbarkeits-
Filterung entfällt hier vollständig.
:author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-19
:version: 1.0
"""
from db import dbapp
from crm.model import Tag
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def get_tags(parent_id: int | None = None) -> list[Tag]:
"""
Liefert Tags, sortiert nach name (alphabetisch).
Ist parent_id gesetzt, werden nur die direkten Kinder dieses
Tags geliefert. Ohne parent_id werden alle Tags geliefert.
"""
query = Tag.query
if parent_id is not None:
query = query.filter(Tag.parent_id == parent_id)
return query.order_by(Tag.name).all()
def get_tag(tag_id: int) -> Tag:
"""
Liefert einen einzelnen Tag anhand seiner ID.
Wirft ApplicationError (404), falls der Tag nicht existiert.
"""
tag = Tag.query.get(tag_id)
if tag is None:
raise ApplicationError(
"Tag mit der ID {} wurde nicht gefunden.".format(tag_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
return tag
def create_tag(data: dict) -> Tag:
"""
Legt einen neuen Tag an. Die vom Client übergebene ID wird ignoriert;
die ID wird serverseitig per Auto-Increment vergeben.
- data: dict mit den Feldern name, description, color, parent_id,
is_parent
"""
if not data.get("name") or not str(data["name"]).strip():
raise ApplicationError(
"Der Tag-Name darf nicht leer sein.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
parent_id = data.get("parent_id")
if parent_id is not None:
parent_id = int(parent_id)
# Prüfen, ob der Eltern-Tag existiert
parent = Tag.query.get(parent_id)
if parent is None:
raise ApplicationError(
"Der übergeordnete Tag existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
tag = Tag()
tag.name = data["name"].strip()
tag.description = (data.get("description") or "").strip() or None
tag.color = (data.get("color") or "").strip() or None
tag.parent_id = parent_id
tag.is_parent = bool(data.get("is_parent", False))
session.add(tag)
session.commit()
return tag
def update_tag(data: dict) -> Tag:
"""
Aktualisiert einen bestehenden Tag. Die ID im data-Dict bestimmt
den zu ändernden Datensatz.
Verhindert Zirkelbezüge: Ein Tag kann nicht sein eigener Vorfahr
als Eltern-Tag gesetzt werden.
"""
tag = get_tag(data["id"])
if not data.get("name") or not str(data["name"]).strip():
raise ApplicationError(
"Der Tag-Name darf nicht leer sein.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
parent_id = data.get("parent_id")
if parent_id is not None:
parent_id = int(parent_id)
# Prüfen, ob der Eltern-Tag existiert
parent = Tag.query.get(parent_id)
if parent is None:
raise ApplicationError(
"Der übergeordnete Tag existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
# Zirkelbezug-Prüfung: Der neue Eltern-Tag darf nicht ein
# Nachfahre des zu ändernden Tags sein (sonst entsteht ein Zyklus).
if parent_id == tag.id:
raise ApplicationError(
"Ein Tag kann nicht sich selbst als übergeordneten Tag haben.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
descendant_ids = {d.id for d in get_descendants(tag.id)}
if parent_id in descendant_ids:
raise ApplicationError(
"Der übergeordnete Tag ist ein Nachfahre dieses Tags. "
"Das würde einen Zirkelbezug erzeugen.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
tag.name = data["name"].strip()
tag.description = (data.get("description") or "").strip() or None
tag.color = (data.get("color") or "").strip() or None
tag.parent_id = parent_id
tag.is_parent = bool(data.get("is_parent", False))
session.commit()
return tag
def delete_tag(tag_id: int):
"""
Löscht einen Tag inkl. aller Nachfahren (kaskadierend).
Die Nachfahren werden zuerst (bottom-up) gelöscht, damit der
Fremdschlüssel auf parent_id nicht verletzt wird. Zuletzt wird
der Tag selbst gelöscht.
Wirft ApplicationError (404), falls der Tag nicht existiert.
"""
tag = get_tag(tag_id)
# Alle Nachfahren ermitteln und bottom-up löschen (Kinder vor Eltern)
descendants = get_descendants(tag_id)
for descendant in reversed(descendants):
session.delete(descendant)
session.delete(tag)
session.commit()
def get_descendants(tag_id: int) -> list[Tag]:
"""
Liefert alle Nachfahren (Kinder, Enkel, ...) eines Tags als flache
Liste. Die Reihenfolge ist top-down (Eltern vor Kindern), was für
die kaskadierende Löschung bottom-up (reversed) verwendet wird.
"""
descendants: list[Tag] = []
children = Tag.query.filter(Tag.parent_id == tag_id).order_by(Tag.name).all()
for child in children:
descendants.append(child)
descendants.extend(get_descendants(child.id))
return descendants
def get_tag_hierarchy() -> list[dict]:
"""
Liefert die vollständige Tag-Hierarchie als verschachtelte Struktur.
Wurzel-Tags (parent_id=None) bilden die oberste Ebene. Jeder Tag
erhält ein 'children'-Array mit seinen rekursiv eingebetteten Kindern.
Die Sortierung erfolgt alphabetisch nach name (durch die Relationship-
Definition order_by='Tag.name').
"""
roots = Tag.query.filter(Tag.parent_id.is_(None)).order_by(Tag.name).all()
return [_tag_to_dict_with_children(t) for t in roots]
def _tag_to_dict_with_children(tag: Tag) -> dict:
"""Wandelt einen Tag inkl. Kinder in ein verschachteltes Dict um."""
result = tag.to_dict()
result['children'] = [_tag_to_dict_with_children(c) for c in tag.children]
return result
+61
View File
@@ -27,6 +27,7 @@ from crm.model import projekt as projekte
from crm.model import projekt_user as projekt_users
from crm.model import kontaktadresse as kontaktadressen
from crm.model import call_log as call_logs
from crm.model import tag as tags
print("crm-routes")
@@ -380,4 +381,64 @@ def calllog_delete():
return jsonify({}), 200
# --------------------------------------------------------------------------- #
# Tag (hierarchische Taxonomie) #
# --------------------------------------------------------------------------- #
# Tags sind nicht projektbezogen — die projektbezogene Sichtbarkeits-
# Filterung entfaellt. Lesen: alle Mitarbeiter. Schreiben: nur AppAdmin/SysAdmin.
# --------------------------------------------------------------------------- #
@app.get("/xhr/tag/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def tag_list():
"""Liefert alle Tags als flache Liste. Optional nach parent_id gefiltert."""
parent_id = request.args.get("parent_id", type=int)
result = [t.to_dict() for t in tags.get_tags(parent_id)]
return jsonify(result), 200
@app.get("/xhr/tag/hierarchy")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def tag_hierarchy():
"""Liefert die vollstaendige Tag-Hierarchie als verschachtelte Struktur."""
result = tags.get_tag_hierarchy()
return jsonify(result), 200
@app.get("/xhr/tag/get")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def tag_get():
"""Liefert einen einzelnen Tag anhand seiner ID (Query-Param id)."""
tag = tags.get_tag(int(request.args.get("id")))
return jsonify(tag.to_dict()), 200
@app.post("/xhr/tag/create")
# Leeres allowed_roles-Array: Nur AppAdmin/SysAdmin (vom Decorator
# implizit ergaenzt). Tags sind eine globale Taxonomie — die Anlage
# und Aenderung ist ausschliesslich den Administratoren vorbehalten.
@authorisation_required([])
def tag_create():
"""Legt einen neuen Tag an. ID wird serverseitig vergeben."""
tag = tags.create_tag(request.json)
return jsonify(tag.to_dict()), 200
@app.put("/xhr/tag/update")
@authorisation_required([])
def tag_update():
"""Aktualisiert einen bestehenden Tag."""
tag = tags.update_tag(request.json)
return jsonify(tag.to_dict()), 200
@app.delete("/xhr/tag/delete")
@authorisation_required([])
def tag_delete():
"""Loescht einen Tag inkl. aller Nachfahren (kaskadierend) anhand seiner ID."""
tags.delete_tag(int(request.args.get("id")))
return jsonify({}), 200
print("########## crm.routes importiert")
+123 -11
View File
@@ -1,13 +1,16 @@
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 {Select, iSelect, MultiSelect, iMultiSelect, iSelectOption} from "./widgets/select"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
import {iCallLog, fetchCallLogs, fetchCallLogById, createCallLogREST, updateCallLogREST} from "../models/call_log"
import {iKunde, fetchKunden} from "../models/kunde"
import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse"
import {iTag, fetchTags} from "../models/tag"
import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
// Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden.
const DAUER_UPDATE_INTERVALL = 1000
@@ -21,11 +24,20 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
// da es nicht in den Route-Params enthalten ist.
let projektName: string = ""
let kundenName: string = ""
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
const handleRowMarked = (node: iTreenode) => {
selectedNode = node
}
// Eigener Listen-Crumb für die Breadcrumb-Kette.
const ownCrumb = (): iBreadcrumb => ({
route: "/calllogliste",
params: filterProjektId !== null ? {projekt_id: filterProjektId} : {},
label: filterProjektId !== null ? "Notizen: " + projektName : "Gesprächsnotizen"
})
const addCallLogRoute = () => {
const now = new Date()
const datum = now.toISOString().split('T')[0]
@@ -40,30 +52,58 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
telefon: "",
notiz: "",
wiedervorlage_datum: "",
erledigt: false
erledigt: false,
from_liste: "1"
}
if (filterProjektId !== null) {
params.projekt_id = filterProjektId
params.projekt_name = projektName
params.kunden_name = kundenName
params.return_projekt_id = filterProjektId
params.return_projekt_name = projektName
}
// Breadcrumb-Kette um eigene Position erweitern.
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/call_log", params)
}
const editCallLog = (log: iCallLog, e: Event) => {
e.stopPropagation()
m.route.set("/call_log", {id: log.id})
const params: any = {id: log.id, from_liste: "1"}
if (filterProjektId !== null) {
params.return_projekt_id = filterProjektId
params.return_projekt_name = projektName
}
// Breadcrumb-Kette um eigene Position erweitern.
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/call_log", params)
}
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let log: iCallLog = node.nodevalue
// Zugewiesene Tags als farbige Badges hinter dem Betreff darstellen.
const tagBadges = (log.tags || []).map((t: iTag) =>
m("span", {
style: {
backgroundColor: t.color || '#999',
color: 'white',
padding: '1px 5px',
'border-radius': '3px',
'font-size': '11px',
'margin-left': '4px',
display: 'inline-block'
},
title: t.name
}, t.name)
)
return [
m("div", m("strong", log.kunden_name + ": " + log.projekt_name)),
m("span", log.datum),
m("span", log.uhrzeit_beginn),
m("span", log.dauer + " min"),
m("span", log.betreff),
m("span", log.betreff, ...tagBadges),
m("span", log.wiedervorlage_datum),
m("span", log.erledigt ? "Erledigt" : "Offen"),
m("button.w3-button.w3-small", {title: "Gesprächsnotiz bearbeiten", style: {padding: '0 2px'}, onclick: (e: Event) => editCallLog(log, e)}, m("i.material-icons", "edit"))
@@ -93,6 +133,8 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
filterProjektId = null
projektName = ""
}
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
// Gesprächsnotizen asynchron vom Server laden. Die Filterung
// nach projekt_id erfolgt serverseitig, sofern ein Filter
// gesetzt ist.
@@ -118,6 +160,10 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", projektName ? "Gesprächsnotizen für " + projektName : "Gesprächsnotizen"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: filterProjektId !== null ? "Notizen: " + projektName : "Gesprächsnotizen"
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable,
{
@@ -183,6 +229,39 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
let kundenListe: iKunde[] = []
let projektListe: iProjekt[] = []
let kontaktadressenListe: iKontaktadresse[] = []
// Asynchron geladene Tag-Liste (nur zuordenbare, nicht is_parent).
let tagListe: iTag[] = []
// Aktuell ausgewählte Tag-IDs im Formular.
let selectedTagIds: number[] = []
// Wurde die Maske aus der Gesprächsnotizenliste geöffnet? Wenn ja,
// erfolgt die Rückkehr dorthin mit dem ursprünglichen Listenfilter.
let fromListe: boolean = false
// Filter-Kontext der Liste, aus der die Maske geöffnet wurde.
// null bedeutet ungefiltert; ein Wert bedeutet projektgefiltert.
let returnProjektId: number | null = null
let returnProjektName: string = ""
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
// Route-Params für die Rückkehr zur Notizliste; als Closure an den
// DirtyMaskHelper übergeben (greift auf fromListe, returnProjektId,
// currentLog, breadcrumbs zu).
const backParams = (): any => {
const params: any = {}
if (fromListe) {
if (returnProjektId !== null) {
params.projekt_id = returnProjektId
params.projekt_name = returnProjektName
}
} else if (currentLog.projekt_id !== -1) {
params.projekt_id = currentLog.projekt_id
params.projekt_name = currentLog.projekt_name
}
const listBc = serializeBc(breadcrumbs.slice(0, -1))
if (listBc) params.bc = listBc
return params
}
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/calllogliste", backParams)
/**
* Formatiert verstrichene Sekunden als mm:ss.
@@ -197,12 +276,23 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
oninit: (vnode) => {
const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
// Rückkehr-Kontext: wurde die Maske aus der Liste geöffnet und
// mit welchem Filter soll zurückgekehrt werden?
fromListe = !!(routeParams && routeParams.from_liste)
if (routeParams && routeParams.return_projekt_id) {
returnProjektId = parseInt(routeParams.return_projekt_id)
returnProjektName = routeParams.return_projekt_name || ""
}
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
guard.registerGuard()
if (id !== -1) {
// Gesprächsnotiz frisch vom Server holen.
fetchCallLogById(id).then((result) => {
currentLog = result
projektVoreingestellt = true
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
selectedTagIds = (result.tags || []).map((t: iTag) => t.id)
}).catch((e) => showServerError(e))
} else {
projektVoreingestellt = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) !== -1 : false
@@ -234,9 +324,12 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
fetchKunden().then((result) => { kundenListe = result }).catch((e) => showServerError(e))
fetchProjekte().then((result) => { projektListe = result }).catch((e) => showServerError(e))
fetchKontaktadressen().then((result) => { kontaktadressenListe = result }).catch((e) => showServerError(e))
// Tags laden — nur zuordenbare (is_parent=false) Tags.
fetchTags().then((result) => { tagListe = result.filter((t: iTag) => !t.is_parent) }).catch((e) => showServerError(e))
},
onremove: (vnode) => {
if (timerId != null) { window.clearInterval(timerId) }
guard.cleanup()
},
view: (vnode) => {
// kunden_id aus dem geladenen Projekt ableiten (fuer abhaengiges
@@ -267,6 +360,11 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
return {value: ka.id, label: ka.name + (ka.firma ? " (" + ka.firma + ")" : "")}
})
: []
// Tag-Optionen und Farb-Map für die Mehrfachauswahl.
const tagOptionen: iSelectOption[] = tagListe.map((t: iTag) => {
return {value: t.id, label: t.name}
})
const tagColors: Map<number, string> = new Map(tagListe.map((t: iTag) => [t.id, t.color] as [number, string]))
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
@@ -283,22 +381,25 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
telefon: (formData.get("telefon") as string) || "",
notiz: (formData.get("notiz") as string) || "",
wiedervorlage_datum: (formData.get("wiedervorlage_datum") as string) || "",
erledigt: formData.get("erledigt") === "on"
erledigt: formData.get("erledigt") === "on",
tag_ids: formData.getAll("tag_ids").map((v) => parseInt(v as string)).filter((n) => !isNaN(n))
}
if (currentLog.id === -1) {
createCallLogREST(gespeichertesLog).then(() => m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name})).catch((e) => showServerError(e))
createCallLogREST(gespeichertesLog).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateCallLogREST(gespeichertesLog).then(() => m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name})).catch((e) => showServerError(e))
updateCallLogREST(gespeichertesLog).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
return true
}
const cancel = (e: Event) => {
m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name})
}
return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentLog.id === -1 ? "Neue Gesprächsnotiz anlegen" : "Gesprächsnotiz"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: currentLog.id === -1 ? "Neue Gesprächsnotiz" : "Gesprächsnotiz",
onnavigate: guardedNavigate
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m(Form, guard.formAttrs(submit) as iForm, [
m("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id}),
// Kunde und Projekt als Read-Only-Text, da voreingestellt.
m("label", "Kunde",
@@ -346,6 +447,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
if (ka) {
currentLog.name_ansprechpartner = ka.name
currentLog.telefon = ka.telefon
guard.markDirty()
}
}
} as iSelect)),
@@ -353,6 +455,16 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
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", "Tags", m(MultiSelect, {
name: "tag_ids",
selected: selectedTagIds,
options: tagOptionen,
colors: tagColors,
placeholder: "kein Tag",
modalTitle: "Tags auswählen",
editTooltip: "Tags bearbeiten",
onchanged: () => { guard.markDirty() }
} as iMultiSelect)),
m("label", "Wiedervorlage Datum", m(Input, {name: "wiedervorlage_datum", value: currentLog.wiedervorlage_datum, type: "date"} as iInput)),
m("label", m(Input, {name: "erledigt", type: "checkbox", value: currentLog.erledigt} as iInput), "Erledigt")
])
+48 -7
View File
@@ -2,9 +2,11 @@ import m from "mithril"
import {Form, iForm} from "./widgets/form"
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
import {iKontakt, fetchKontakte, fetchKontaktById, createKontaktREST, updateKontaktREST} from "../models/kontakt"
import {iKunde, fetchKundeById} from "../models/kunde"
import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
export const kontaktliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = []
@@ -13,11 +15,20 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
// Kundenname für die Überschrift, asynchron per fetchKundeById
// aufgelöst, falls nicht in den Route-Params enthalten.
let kundenName: string = ""
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
const handleRowMarked = (node: iTreenode) => {
selectedNode = node
}
// Eigener Listen-Crumb für die Breadcrumb-Kette.
const ownCrumb = (): iBreadcrumb => ({
route: "/kontaktliste",
params: filterKundenId !== null ? {kunden_id: filterKundenId} : {},
label: filterKundenId !== null ? "Kontakte: " + kundenName : "Kontakte"
})
const addKontaktRoute = () => {
const params: any = {id: -1}
// Nur bei kundengefilterter Liste den Kunden vorbelegen.
@@ -25,12 +36,15 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
params.kunden_id = filterKundenId
params.kunden_name = kundenName
}
// Breadcrumb-Kette um eigene Position erweitern.
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/kontakt", params)
}
const editKontakt = (kontakt: iKontakt, e: Event) => {
e.stopPropagation()
m.route.set("/kontakt", {id: kontakt.id})
// Breadcrumb-Kette um eigene Position erweitern.
m.route.set("/kontakt", {id: kontakt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
}
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
@@ -66,6 +80,8 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
filterKundenId = null
kundenName = ""
}
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
// Kontakte asynchron vom Server laden. Die Filterung nach
// kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist.
fetchKontakte(filterKundenId !== null ? filterKundenId : undefined).then((result) => {
@@ -86,6 +102,10 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", kundenName ? "Kontaktliste für " + kundenName : "Kontaktliste"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: filterKundenId !== null ? "Kontakte: " + kundenName : "Kontakte"
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable,
{
@@ -129,11 +149,27 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
email: "",
notiz: ""
}
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
// Route-Params für die Rückkehr zur Kontaktliste; als Closure an den
// DirtyMaskHelper übergeben, da dieser die maskenspezifischen Variablen
// (currentKontakt, breadcrumbs) nicht kennt.
const backParams = (): any => {
const listBc = serializeBc(breadcrumbs.slice(0, -1))
const params: any = {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name}
if (listBc) params.bc = listBc
return params
}
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/kontaktliste", backParams)
return {
oninit: (vnode) => {
const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
guard.registerGuard()
if (id !== -1) {
// Kontaktdaten frisch vom Server holen, um zwischenzeitliche
// Änderungen anderer Nutzer nicht zu überschreiben.
@@ -147,6 +183,9 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
currentKontakt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
}
},
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
@@ -162,21 +201,23 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
notiz: (formData.get("notiz") as string) || ""
}
if (currentKontakt.id === -1) {
createKontaktREST(gespeicherterKontakt).then(() => m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name})).catch((e) => showServerError(e))
createKontaktREST(gespeicherterKontakt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateKontaktREST(gespeicherterKontakt).then(() => m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name})).catch((e) => showServerError(e))
updateKontaktREST(gespeicherterKontakt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
return true
}
const cancel = (e: Event) => {
m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name})
}
return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentKontakt.id === -1
? "Neuer Kontakt für " + currentKontakt.kunden_name
: "Kontakt-Stammblatt"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: currentKontakt.id === -1 ? "Neuer Kontakt" : "Kontakt-Stammblatt",
onnavigate: guardedNavigate
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m(Form, guard.formAttrs(submit) as iForm, [
m("input", {type: "hidden", name: "kunden_id", value: currentKontakt.kunden_id}),
m("label", "Anrede", m(Input, {name: "anrede", value: currentKontakt.anrede, type: "text"} as iInput)),
m("label", "Name", m(Input, {name: "name", value: currentKontakt.name, type: "text", required: true} as iInput)),
+59 -8
View File
@@ -2,9 +2,11 @@ import m from "mithril"
import {Form, iForm} from "./widgets/form"
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
import {iKontaktadresse, fetchKontaktadressen, fetchKontaktadresseById, createKontaktadresseREST, updateKontaktadresseREST} from "../models/kontaktadresse"
import {iProjekt, fetchProjektById} from "../models/projekt"
import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = []
@@ -13,23 +15,35 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
// Projektname für die Überschrift, asynchron per fetchProjektById
// aufgelöst, falls nicht in den Route-Params enthalten.
let projektName: string = ""
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
const handleRowMarked = (node: iTreenode) => {
selectedNode = node
}
// Eigener Listen-Crumb für die Breadcrumb-Kette.
const ownCrumb = (): iBreadcrumb => ({
route: "/kontaktadresseliste",
params: filterProjektId !== null ? {projekt_id: filterProjektId} : {},
label: filterProjektId !== null ? "Kontaktadressen: " + projektName : "Kontaktadressen"
})
const addKontaktadresseRoute = () => {
const params: any = {id: -1}
if (filterProjektId !== null) {
params.projekt_id = filterProjektId
params.projekt_name = projektName
}
// Breadcrumb-Kette um eigene Position erweitern.
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/kontaktadresse", params)
}
const editKontaktadresse = (kontaktadresse: iKontaktadresse, e: Event) => {
e.stopPropagation()
m.route.set("/kontaktadresse", {id: kontaktadresse.id})
// Breadcrumb-Kette um eigene Position erweitern.
m.route.set("/kontaktadresse", {id: kontaktadresse.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
}
const addCallLog = (kontaktadresse: iKontaktadresse, e: Event) => {
@@ -37,6 +51,14 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
const now = new Date()
const datum = now.toISOString().split('T')[0]
const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1]
// Breadcrumb-Kette um Kontaktadressenliste und Notizliste
// erweitern, da die Maske nach Speichern/Abbrechen in die
// projektgefilterte Notizliste zurückkehrt.
const chain = [...breadcrumbs, ownCrumb(), {
route: "/calllogliste",
params: {projekt_id: kontaktadresse.projekt_id},
label: "Notizen: " + kontaktadresse.projekt_name
} as iBreadcrumb]
m.route.set("/call_log", {
id: -1,
projekt_id: kontaktadresse.projekt_id,
@@ -49,7 +71,8 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
telefon: kontaktadresse.telefon,
notiz: "",
wiedervorlage_datum: "",
erledigt: false
erledigt: false,
bc: serializeBc(chain)
})
}
@@ -92,6 +115,8 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
filterProjektId = null
projektName = ""
}
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
// Kontaktadressen asynchron vom Server laden. Die Filterung
// nach projekt_id erfolgt serverseitig, sofern ein Filter
// gesetzt ist.
@@ -113,6 +138,10 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", projektName ? "Kontaktadressen für " + projektName : "Kontaktadressen"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: filterProjektId !== null ? "Kontaktadressen: " + projektName : "Kontaktadressen"
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable,
{
@@ -166,11 +195,26 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
land: "",
notiz: ""
}
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
// Route-Params für die Rückkehr zur Kontaktadressenliste; als Closure
// an den DirtyMaskHelper übergeben.
const backParams = (): any => {
const listBc = serializeBc(breadcrumbs.slice(0, -1))
const params: any = {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name}
if (listBc) params.bc = listBc
return params
}
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/kontaktadresseliste", backParams)
return {
oninit: (vnode) => {
const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
guard.registerGuard()
if (id !== -1) {
// Kontaktadresse frisch vom Server holen.
fetchKontaktadresseById(id).then((result) => {
@@ -182,6 +226,9 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
currentKontaktadresse.projekt_name = routeParams && routeParams.projekt_name ? routeParams.projekt_name : ""
}
},
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
@@ -202,21 +249,25 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
notiz: (formData.get("notiz") as string) || ""
}
if (currentKontaktadresse.id === -1) {
createKontaktadresseREST(gespeicherteKontaktadresse).then(() => m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name})).catch((e) => showServerError(e))
createKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name})).catch((e) => showServerError(e))
updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
return true
}
const cancel = (e: Event) => {
m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name})
}
return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentKontaktadresse.id === -1
? "Neue Kontaktadresse für " + currentKontaktadresse.projekt_name
: "Kontaktadresse-Stammblatt"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: currentKontaktadresse.id === -1
? "Neue Kontaktadresse"
: "Kontaktadresse-Stammblatt",
onnavigate: guardedNavigate
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m(Form, guard.formAttrs(submit) as iForm, [
m("input", {type: "hidden", name: "projekt_id", value: currentKontaktadresse.projekt_id}),
m("label", "Anrede", m(Input, {name: "anrede", value: currentKontaktadresse.anrede, type: "text"} as iInput)),
m("label", "Name", m(Input, {name: "name", value: currentKontaktadresse.name, type: "text", required: true} as iInput)),
+46 -11
View File
@@ -2,8 +2,10 @@ 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 {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
import {iKunde, fetchKunden, fetchKundeById, createKundeREST, updateKundeREST} from "../models/kunde"
import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
export const kunde: m.ClosureComponent = (vnode) => {
let currentKunde: iKunde = {
@@ -14,11 +16,18 @@ export const kunde: m.ClosureComponent = (vnode) => {
ort: "",
land: ""
}
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/kundenliste")
return {
oninit: (vnode) => {
const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
guard.registerGuard()
if (id !== -1) {
// Kundendaten frisch vom Server holen, um zwischenzeitliche
// Änderungen anderer Nutzer nicht zu überschreiben.
@@ -27,6 +36,9 @@ export const kunde: m.ClosureComponent = (vnode) => {
}).catch((e) => showServerError(e))
}
},
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
@@ -39,26 +51,28 @@ export const kunde: m.ClosureComponent = (vnode) => {
land: (formData.get("land") as string) || ""
}
if (currentKunde.id === -1) {
createKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste")).catch((e) => showServerError(e))
createKundeREST(gespeicherterKunde).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste")).catch((e) => showServerError(e))
updateKundeREST(gespeicherterKunde).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
return true
}
const cancel = (e: Event) => {
m.route.set("/kundenliste")
}
return m("div.w3-container.w3-auto.form-view-container",
m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}},
m("h1", currentKunde.id === -1 ? "Neuen Kunden anlegen" : "Kundenstammblatt"),
currentKunde.id !== -1
? m("button.w3-button.w3-theme",
{onclick: () => m.route.set("/kontaktliste", {kunden_id: currentKunde.id})},
{onclick: (e: MouseEvent) => guardedNavigate("/kontaktliste", {kunden_id: currentKunde.id}, e)},
m("i.material-icons", "contact_page"), " Kontakte")
: null
),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: currentKunde.id === -1 ? "Neuer Kunde" : "Kundenstammblatt",
onnavigate: guardedNavigate
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m(Form, guard.formAttrs(submit) 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)),
@@ -74,28 +88,43 @@ export const kunde: m.ClosureComponent = (vnode) => {
export const kundenliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
const handleRowMarked = (node: iTreenode) => {
selectedNode = node
}
// Eigener Listen-Crumb für die Breadcrumb-Kette.
const ownCrumb = (): iBreadcrumb => ({
route: "/kundenliste",
params: {},
label: "Kunden"
})
const editKunde = (kunde: iKunde, e: Event) => {
e.stopPropagation()
m.route.set("/kunde", {id: kunde.id})
// Breadcrumb-Kette um eigene Position erweitern.
m.route.set("/kunde", {id: kunde.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
}
const showProjekte = (kunde: iKunde, e: Event) => {
e.stopPropagation()
m.route.set("/projektliste", {kunden_id: kunde.id})
// Breadcrumb-Kette: Kundenliste als Vorgänger der Projektliste.
const bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/projektliste", {kunden_id: kunde.id, bc})
}
const showKontakte = (kunde: iKunde, e: Event) => {
e.stopPropagation()
m.route.set("/kontaktliste", {kunden_id: kunde.id})
// Breadcrumb-Kette: Kundenliste als Vorgänger der Kontaktliste.
const bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/kontaktliste", {kunden_id: kunde.id, bc})
}
const addKundeRoute = () => {
m.route.set("/kunde", {id: -1})
// Breadcrumb-Kette um eigene Position erweitern.
m.route.set("/kunde", {id: -1, bc: serializeBc([...breadcrumbs, ownCrumb()])})
}
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
@@ -127,6 +156,8 @@ export const kundenliste: m.ClosureComponent = (vnode) => {
return {
oninit: (vnode) => {
// Breadcrumb-Kette aus Route-Param parsen (bei Top-Level leer).
breadcrumbs = parseBc(m.route.param() && m.route.param().bc)
// Kundenliste asynchron vom Server laden. m.request löst nach
// Promise-Auflösung automatisch ein Redraw aus.
fetchKunden().then((kunden: iKunde[]) => {
@@ -141,6 +172,10 @@ export const kundenliste: m.ClosureComponent = (vnode) => {
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", "Kundenliste"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: "Kunden"
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable,
{
+63 -12
View File
@@ -2,9 +2,11 @@ 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 {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
import {iProjekt, fetchProjekte, fetchProjektById, createProjektREST, updateProjektREST} from "../models/projekt"
import {iKunde, fetchKundeById} from "../models/kunde"
import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
export const projektliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = []
@@ -13,11 +15,20 @@ export const projektliste: m.ClosureComponent = (vnode) => {
// Kundenname für die Überschrift, asynchron per fetchKundeById
// aufgelöst, falls nicht in den Route-Params enthalten.
let kundenName: string = ""
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
const handleRowMarked = (node: iTreenode) => {
selectedNode = node
}
// Eigener Listen-Crumb für die Breadcrumb-Kette.
const ownCrumb = (): iBreadcrumb => ({
route: "/projektliste",
params: filterKundenId !== null ? {kunden_id: filterKundenId, kunden_name: kundenName} : {},
label: filterKundenId !== null ? "Projekte: " + kundenName : "Projekte"
})
const addProjektRoute = () => {
const params: any = {id: -1}
// Nur bei kundengefilterter Liste den Kunden vorbelegen.
@@ -25,22 +36,27 @@ export const projektliste: m.ClosureComponent = (vnode) => {
params.kunden_id = filterKundenId
params.kunden_name = kundenName
}
// Breadcrumb-Kette um eigene Position erweitern.
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/projekt", params)
}
const editProjekt = (projekt: iProjekt, e: Event) => {
e.stopPropagation()
m.route.set("/projekt", {id: projekt.id})
// Breadcrumb-Kette um eigene Position erweitern.
m.route.set("/projekt", {id: projekt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
}
const showCallLogs = (projekt: iProjekt, e: Event) => {
e.stopPropagation()
m.route.set("/calllogliste", {projekt_id: projekt.id})
// Breadcrumb-Kette um eigene Position erweitern.
m.route.set("/calllogliste", {projekt_id: projekt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
}
const showKontaktadressen = (projekt: iProjekt, e: Event) => {
e.stopPropagation()
m.route.set("/kontaktadresseliste", {projekt_id: projekt.id})
// Breadcrumb-Kette um eigene Position erweitern.
m.route.set("/kontaktadresseliste", {projekt_id: projekt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
}
const addCallLog = (projekt: iProjekt, e: Event) => {
@@ -48,6 +64,14 @@ export const projektliste: m.ClosureComponent = (vnode) => {
const now = new Date()
const datum = now.toISOString().split('T')[0]
const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1]
// Breadcrumb-Kette um Projektliste und Notizliste erweitern, da
// die Maske nach Speichern/Abbrechen in die projektgefilterte
// Notizliste zurückkehrt.
const chain = [...breadcrumbs, ownCrumb(), {
route: "/calllogliste",
params: {projekt_id: projekt.id},
label: "Notizen: " + projekt.projektname
} as iBreadcrumb]
m.route.set("/call_log", {
id: -1,
kunden_name: projekt.kunden_name,
@@ -61,7 +85,8 @@ export const projektliste: m.ClosureComponent = (vnode) => {
telefon: "",
notiz: "",
wiedervorlage_datum: "",
erledigt: false
erledigt: false,
bc: serializeBc(chain)
})
}
@@ -103,6 +128,8 @@ export const projektliste: m.ClosureComponent = (vnode) => {
filterKundenId = null
kundenName = ""
}
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
// Projekte asynchron vom Server laden. Die Filterung nach
// kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist.
fetchProjekte(filterKundenId !== null ? filterKundenId : undefined).then((result) => {
@@ -123,6 +150,10 @@ export const projektliste: m.ClosureComponent = (vnode) => {
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", kundenName ? "Projektliste für " + kundenName : "Projektliste"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: kundenName ? "Projekte: " + kundenName : "Projekte"
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable,
{
@@ -162,11 +193,26 @@ export const Projekt: m.ClosureComponent = (vnode) => {
verantwortlicher: "",
startdatum: ""
}
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
// Route-Params für die Rückkehr zur Projektliste; als Closure an den
// DirtyMaskHelper übergeben.
const backParams = (): any => {
const listBc = serializeBc(breadcrumbs.slice(0, -1))
const params: any = {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name}
if (listBc) params.bc = listBc
return params
}
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/projektliste", backParams)
return {
oninit: (vnode) => {
const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
guard.registerGuard()
if (id !== -1) {
// Projektdaten frisch vom Server holen, um zwischenzeitliche
// Änderungen anderer Nutzer nicht zu überschreiben.
@@ -180,6 +226,9 @@ export const Projekt: m.ClosureComponent = (vnode) => {
currentProjekt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
}
},
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
@@ -193,15 +242,12 @@ export const Projekt: m.ClosureComponent = (vnode) => {
startdatum: (formData.get("startdatum") as string) || ""
}
if (currentProjekt.id === -1) {
createProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name})).catch((e) => showServerError(e))
createProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name})).catch((e) => showServerError(e))
updateProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
return true
}
const cancel = (e: Event) => {
m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name})
}
return m("div.w3-container.w3-auto.form-view-container",
m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}},
m("h1", currentProjekt.id === -1
@@ -210,17 +256,22 @@ export const Projekt: m.ClosureComponent = (vnode) => {
currentProjekt.id !== -1
? [
m("button.w3-button.w3-theme",
{onclick: () => m.route.set("/kontaktadresseliste", {projekt_id: currentProjekt.id})},
{onclick: (e: MouseEvent) => guardedNavigate("/kontaktadresseliste", {projekt_id: currentProjekt.id}, e)},
m("i.material-icons", "contact_page"), " Kontaktadressen"),
m("button.w3-button.w3-theme",
{style: {'margin-left': '4px'},
onclick: () => m.route.set("/projektuserliste", {projekt_id: currentProjekt.id})},
onclick: (e: MouseEvent) => guardedNavigate("/projektuserliste", {projekt_id: currentProjekt.id}, e)},
m("i.material-icons", "group"), " Nutzerberechtigungen")
]
: null
),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: currentProjekt.id === -1 ? "Neues Projekt" : "Projekt-Stammblatt",
onnavigate: guardedNavigate
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m(Form, guard.formAttrs(submit) as iForm, [
m("input", {type: "hidden", name: "kunden_id", value: currentProjekt.kunden_id}),
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)),
+243 -559
View File
@@ -1,7 +1,12 @@
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"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {ModalType, ModalView, iModalView} from "./widgets/Modal"
import {gridMountpoints} from "./mainlayout"
import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
import {iTag, iTagWithChildren, fetchTags, fetchTagHierarchy, fetchTagById, createTagREST, updateTagREST, deleteTagREST} from "../models/tag"
const colorPalette: string[] = [
"#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff",
@@ -10,536 +15,164 @@ const colorPalette: string[] = [
]
// ============================================================================
// INTERFACES FÜR HIERARCHISCHE TAGS
// HILFSFUNKTIONEN
// ============================================================================
/**
* Basis-Tag-Interface für die hierarchische Struktur.
* Erzeugt Treenodes aus der vom Server geladenen Hierarchie.
* Beruecksichtigt die Verschachtelung der Tags.
*/
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));
function createTagTreenodes(hierarchy: iTagWithChildren[]): iTreenode[] {
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);
const children = tag.children.map(child => createTreenodeFromTag(child))
return new Treenode(tag, true, children)
}
/**
* Zaehlt alle Nachfahren eines Treenodes rekursiv (fuer die
* Loesch-Bestaetigungsmeldung).
*/
function countDescendants(node: iTreenode): number {
let count = 0
for (const child of node.children) {
count++
count += countDescendants(child)
}
return count
}
/**
* Prueft rekursiv, ob irgendein Nachfahre eines Treenodes auf den
* Suchbegriff passt (Name oder Beschreibung, case-insensitive).
*/
function descendantMatches(node: iTreenode, searchLower: string): boolean {
for (const child of node.children) {
const tag: iTag = child.nodevalue
if (tag.name.toLowerCase().includes(searchLower) ||
(tag.description || "").toLowerCase().includes(searchLower)) {
return true
}
if (descendantMatches(child, searchLower)) {
return true
}
}
return false
}
// ============================================================================
// KOMPONENTEN
// ============================================================================
export const Tag: m.ClosureComponent = (vnode) => {
export const Tag: m.ClosureComponent = () => {
let currentTag: iTag = {
id: -1,
name: "",
description: "",
color: "",
parentId: null,
};
parent_id: null,
is_parent: false,
}
// Liste aller Parent-Tags fuer die Eltern-Auswahl (nur Parent-Tags
// koennen uebergeordnet sein)
let allParentTags: iTag[] = []
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/taglist")
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
currentTag.color = color
guard.markDirty()
}
return {
oninit: (vnode) => {
const routeParams = m.route.param();
if (routeParams) {
const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
guard.registerGuard()
if (id !== -1) {
// Bestehenden Tag frisch vom Server laden
fetchTagById(id).then((result) => {
currentTag = result
}).catch((e) => showServerError(e))
} else if (routeParams) {
// Neuer Tag: vorbelegte Werte aus Route-Params uebernehmen
currentTag = {
id: routeParams.id ? parseInt(routeParams.id) : -1,
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,
};
parent_id: routeParams.parent_id ? parseInt(routeParams.parent_id) : null,
is_parent: routeParams.is_parent ? routeParams.is_parent === "true" : false,
}
}
// Parent-Tag-Liste vom Server laden (nur is_parent-Tags sind
// als uebergeordnete Tags zulaessig). Der aktuelle Tag wird
// ausgeschlossen, damit er nicht sein eigener Parent wird.
fetchTags().then((result) => {
allParentTags = result.filter(t => t.id !== currentTag.id && t.is_parent)
}).catch((e) => showServerError(e))
},
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
const gespeicherterTag: iTag = {
id: currentTag.id,
name: (formData.get("name") as string) || "",
description: (formData.get("description") as string) || "",
color: currentTag.color || "",
parent_id: formData.get("parent_id") ? parseInt(formData.get("parent_id") as string) : null,
is_parent: currentTag.is_parent,
}
if (currentTag.id === -1) {
createTagREST(gespeicherterTag).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateTagREST(gespeicherterTag).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
return true
}
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)),
m(Form, guard.formAttrs(submit) as iForm, [
m("label", "Tag-Name", m(Input, {name: "name", value: currentTag.name, type: "text", required: true, oninput: (e: Event) => { currentTag.name = (e.target as HTMLInputElement).value }} as iInput)),
m("label", "Beschreibung", m(Input, {name: "description", value: currentTag.description, type: "text", oninput: (e: Event) => { currentTag.description = (e.target as HTMLInputElement).value }} as iInput)),
// Parent-Tag-Umschalter: true = Gruppierungs-Knoten (nicht zuordenbar)
m("div", {style: {'margin-bottom': '5px'}},
m("label",
m("input.w3-check", {
type: "checkbox",
name: "is_parent",
checked: currentTag.is_parent,
style: {'margin-right': '8px'},
onclick: (e: Event) => {
currentTag.is_parent = (e.target as HTMLInputElement).checked
guard.markDirty()
}
}),
"Nur zur Gruppierung, kann nicht zugeordnet werden"
)
),
// 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", "Übergeordneter Tag (optional)"),
m("select.w3-select", {
name: "parent_id",
value: currentTag.parent_id || "",
onchange: () => { guard.markDirty() }
},
m("option", {value: ""}, "-- Kein übergeordneter Tag --"),
allParentTags.map(tag =>
m("option", {value: tag.id, selected: currentTag.parent_id === tag.id}, tag.name)
)
),
@@ -547,6 +180,7 @@ export const Tag: m.ClosureComponent = (vnode) => {
m("div", {style: {display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px'}},
colorPalette.map(color =>
m("button", {
type: "button",
style: {
width: '32px',
height: '32px',
@@ -559,48 +193,102 @@ export const Tag: m.ClosureComponent = (vnode) => {
})
)
),
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;
export const TagList: m.ClosureComponent = () => {
let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null
// Aktualisiere treenodes, wenn sich die Daten ändern
const updateTreenodes = () => {
treenodes = createTagTreenodes();
selectedNode = null;
};
// Laedt die Hierarchie vom Server und erzeugt Treenodes
const loadTreenodes = () => {
fetchTagHierarchy().then((hierarchy) => {
treenodes = createTagTreenodes(hierarchy)
selectedNode = null
}).catch((e) => showServerError(e))
}
const handleRowMarked = (node: iTreenode) => {
selectedNode = node;
};
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
});
};
e.stopPropagation()
m.route.set("/tag", {id: tag.id})
}
const deleteTag = (tag: iTag, node: iTreenode, e: Event) => {
e.stopPropagation()
const childCount = countDescendants(node)
if (childCount === 0) {
// Keine Kinder: einfache Bestaetigung
if (window.confirm("Tag '" + tag.name + "' wirklich löschen?")) {
deleteTagREST(tag.id).then(() => {
loadTreenodes()
}).catch((e) => showServerError(e))
}
} else {
// Mit Kindern: Modal mit Hinweis auf kaskadierende Loeschung
m.mount(gridMountpoints.modalhook, {
view: () => {
return m(ModalView,
{
title: "Tag löschen",
type: ModalType.MESSAGE,
width: '40%'
} as iModalView,
m("div.w3-panel",
m("p", "Der Tag '" + tag.name + "' hat " + childCount + " untergeordnete Tags."),
m("p", "Soll er inkl. aller untergeordneten Tags gelöscht werden?")
),
m("div.w3-bar.w3-padding-16",
m("button.w3-button.w3-red.w3-left",
{
onclick: () => {
deleteTagREST(tag.id).then(() => {
m.mount(gridMountpoints.modalhook, null)
loadTreenodes()
}).catch((e) => showServerError(e))
}
},
"Inkl. aller löschen"
),
m("button.w3-button.w3-theme.w3-right",
{onclick: () => {m.mount(gridMountpoints.modalhook, null)}},
"Abbrechen"
)
)
)
}
})
}
}
const addTagRoute = () => {
m.route.set("/tag", {id: -1})
}
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let tag: iTag = node.nodevalue;
let tag: iTag = node.nodevalue
// Parent-Tags erhalten ein "folder"-Icon, echte Tags ein "label"-Icon
const tagIcon = tag.is_parent ? "folder" : "label"
const tagTitle = tag.is_parent ? "Parent-Tag (nicht zuordenbar)" : ""
return [
m("span",
m("i.material-icons", {
style: {
verticalAlign: 'middle',
marginRight: '6px',
fontSize: '18px'
},
title: tagTitle
}, tagIcon),
m("span", {
style: {
backgroundColor: tag.color,
@@ -610,40 +298,50 @@ export const TagList: m.ClosureComponent = (vnode) => {
fontWeight: 'bold',
display: 'inline-block',
marginRight: '8px'
}
},
title: tagTitle
}, tag.name)
),
m("span", tag.description),
m("div",
m("button.w3-button.w3-small", {style: {padding: '0 2px'}, onclick: (e: Event) => editTag(tag, e)},
m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap', 'justify-content': 'flex-end'}},
m("button.w3-button.w3-small", {title: "Tag bearbeiten", style: {padding: '0 2px'}, onclick: (e: Event) => editTag(tag, e)},
m("i.material-icons", "edit")
),
m("button.w3-button.w3-small", {title: "Tag löschen", style: {padding: '0 2px'}, onclick: (e: Event) => deleteTag(tag, node, e)},
m("i.material-icons", "delete")
)
)
];
};
]
}
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));
};
let tag: iTag = node.nodevalue
let searchLower = searchvalue.toLowerCase()
// Prueft, ob der Tag selbst auf den Suchbegriff passt (nur Name und Beschreibung)
const selfMatch = tag.name.toLowerCase().includes(searchLower) ||
(tag.description || "").toLowerCase().includes(searchLower)
// Wenn der Tag selbst passt, anzeigen
if (selfMatch) return true
// Wenn der Tag Kinder hat, pruefen, ob ein Nachfahre passt
if (searchLower === "") return true
return descendantMatches(node, searchLower)
}
return {
oninit: (vnode) => {
loadTreenodes()
},
view: (vnode) => {
return m("div.w3-container", {
return m("div.w3-container.w3-auto", {
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,
{
@@ -652,27 +350,13 @@ export const TagList: m.ClosureComponent = (vnode) => {
filter: filterFunction,
onRowMarked: handleRowMarked,
isSelected: (node: iTreenode) => node === selectedNode
} as iTreetable
} as iTreetable,
m(TopbarButton, {onclick: addTagRoute, tooltip: "Neuen Tag anlegen"} as iTopbarButton,
m("i.material-icons", "add")
)
)
)
);
)
}
};
};
// ============================================================================
// 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,
};
}
}
+15 -3
View File
@@ -88,7 +88,9 @@ export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
onRowMarked: onRowMarked,
isSelected: isSelected,
isEven: ((z % 2) == 0),
hasSiblingsWithChildren: topHasSiblingsWithChildren} as iTreerow)
hasSiblingsWithChildren: topHasSiblingsWithChildren,
filter: vnode.attrs.filter,
searchvalue: searchvalue} as iTreerow)
}
else {
return null
@@ -114,6 +116,12 @@ interface iTreerow {
* den Platzhalter-Pfeil nur dann zu rendern, wenn es Nodes mit
* Pfeil gibt, an denen der Text ausgerichtet werden kann. */
hasSiblingsWithChildren?: boolean
/** Optional: Filter-Funktion, die auch auf Kind-Knoten angewendet wird.
* Wenn gesetzt, werden Kind-Knoten nur gerendert, wenn der Filter
* für sie true zurückgibt. */
filter?: (row: iTreenode, searchvalue: string) => boolean
/** Aktueller Suchwert, der an den Filter weitergereicht wird. */
searchvalue?: string
}
let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
@@ -178,7 +186,9 @@ let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
// Falls der Trennode Kinder hat und expandiert werden soll, wird rekursiv die Trennode-Komponente für
// die Child-Nodes aufgerufen, ansonsten wir null zurückgegeben und die Einrückung hört auf
vnode.attrs.treenode.hasChildren && vnode.attrs.treenode.expand
? vnode.attrs.treenode.children.map((tn: iTreenode, z) => {
? vnode.attrs.treenode.children
.filter(tn => !vnode.attrs.filter || vnode.attrs.filter(tn, vnode.attrs.searchvalue || ""))
.map((tn: iTreenode, z) => {
return m(Treerow, {
treenode: tn,
level: vnode.attrs.level + 1,
@@ -186,7 +196,9 @@ let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
onRowMarked: vnode.attrs.onRowMarked,
isSelected: vnode.attrs.isSelected,
isEven: ((z % 2) == 0),
hasSiblingsWithChildren: childHasSiblingsWithChildren
hasSiblingsWithChildren: childHasSiblingsWithChildren,
filter: vnode.attrs.filter,
searchvalue: vnode.attrs.searchvalue
} as iTreerow)
})
: null
+111
View File
@@ -0,0 +1,111 @@
import m from "mithril"
/** Ein Breadcrumb-Glied: Route, Filter-Params und Anzeige-Label einer
* Vorgänger-Ansicht in der Aufrufkette. */
export interface iBreadcrumb {
route: string
params: any
label: string
}
/** Attribute der BreadcrumbBar: die Vorgänger-Kette (ancestors) und das
* Label der aktuellen Ansicht (wird als letztes, nicht klickbares Glied
* angezeigt). Ist breadcrumbs leer, wird keine Leiste gerendert.
* onnavigate (optional): Navigations-Callback, der statt m.route.set
* verwendet wird — typischerweise guardedNavigate aus dirtyguard.ts,
* so dass bei ungespeicherten Änderungen das Bestätigungs-Modal erscheint. */
export interface iBreadcrumbBar {
breadcrumbs: iBreadcrumb[]
current: string
onnavigate?: (route: string, params: any, e?: Event) => void
}
/** Parst den Route-Param `bc` (JSON-String) in ein Breadcrumb-Array.
* Liefert bei fehlendem/ungültigem `bc` ein leeres Array. */
export function parseBc(bc: string | undefined): iBreadcrumb[] {
if (!bc) return []
try { return JSON.parse(bc) } catch { return [] }
}
/** Serialisiert ein Breadcrumb-Array in den Route-Param `bc`.
* Liefert undefined bei leerer Kette (kein bc in der Route). */
export function serializeBc(crumbs: iBreadcrumb[]): string | undefined {
return crumbs.length > 0 ? JSON.stringify(crumbs) : undefined
}
export const BreadcrumbBar: m.ClosureComponent<iBreadcrumbBar> = () => {
return {
view: (vnode) => {
const crumbs = vnode.attrs.breadcrumbs || []
const current = vnode.attrs.current
// Ohne Vorgänger und ohne aktuelles Label keine Leiste rendern
// (Top-Level-Ansichten ohne Hierarchie-Kontext).
if (crumbs.length === 0 && !current) return null
const items: any[] = []
crumbs.forEach((crumb: iBreadcrumb, i: number) => {
if (i > 0) {
items.push(m("span.w3-small", {
style: {margin: '0 2px', opacity: '0.6'}
}, "›"))
}
// Vorgänger sind klickbar: Navigation zur jeweiligen Ansicht
// mit deren Filter-Params und der auf dieses Glied gekürzten
// Kette als neuem bc.
items.push(m("a.w3-button.w3-small.w3-theme-l2", {
style: {
padding: '2px 8px',
'border-radius': '4px',
'text-decoration': 'none',
'white-space': 'nowrap'
},
title: "Zurück zu " + crumb.label,
onclick: (e: MouseEvent) => {
const subChain = crumbs.slice(0, i)
const params: any = {...crumb.params}
const bc = serializeBc(subChain)
if (bc) params.bc = bc
// Navigation über den Guard, so dass bei dirty-Maske
// das Bestätigungs-Modal erscheint. Event wird
// durchgereicht, damit guardedNavigate den auto-redraw
// unterbinden kann.
if (vnode.attrs.onnavigate) {
vnode.attrs.onnavigate(crumb.route, params, e)
} else {
m.route.set(crumb.route, params)
}
}
}, crumb.label))
})
// Aktuelle Ansicht als letztes, nicht klickbares Glied (fett).
if (current) {
if (crumbs.length > 0) {
items.push(m("span.w3-small", {
style: {margin: '0 2px', opacity: '0.6'}
}, "›"))
}
items.push(m("span.w3-small.w3-theme-d3", {
style: {
padding: '2px 8px',
'border-radius': '4px',
'font-weight': 'bold',
'white-space': 'nowrap'
}
}, current))
}
return m("div.w3-theme-l1", {
style: {
display: 'flex',
'flex-wrap': 'nowrap',
'align-items': 'center',
padding: '4px 8px',
'border-radius': '4px',
'margin-bottom': '8px',
'overflow-x': 'auto',
'overflow-y': 'hidden'
}
}, items)
}
}
}
+267
View File
@@ -0,0 +1,267 @@
import m from "mithril"
import { gridMountpoints } from "../mainlayout"
import type { iForm } from "./form"
/** Registrierungsschnittstelle für eine Bearbeitungsmaske. Die Maske
* registriert in oninit einen Guard und entfernt ihn in onremove wieder.
* So weiß der zentrale Navigations-Guard, ob die aktuelle Ansicht ungespeicherte
* Änderungen enthält und wie das Speichern auszulösen ist. */
export interface DirtyGuard {
/** Liefert true, wenn die Maske ungespeicherte Änderungen enthält. */
isDirty: () => boolean
/** Triggert das Speichern des Formulars (form.requestSubmit()). Bei
* Angabe einer Ziel-Route navigiert die Maske nach erfolgreichem Speichern
* dorthin statt zum Standard-Ziel. Die Maske muss dafür die Ziel-Route
* zwischenspeichern und im Erfolgs-.then der REST-Aufrufs auswerten. */
save: (targetRoute?: string, targetParams?: any) => void
}
// Aktiv registrierter Guard (max. einer, da nur eine Maske sichtbar ist).
let guard: DirtyGuard | null = null
/** Wird von einer Bearbeitungsmaske in oninit aufgerufen, um den Guard
* zu aktivieren. */
export function registerDirtyGuard(opts: DirtyGuard): void {
guard = opts
}
/** Wird von einer Bearbeitungsmaske in onremove aufgerufen, um den Guard
* zu deaktivieren. Ein evtl. noch offenes Bestätigungs-Modal wird geschlossen. */
export function clearDirtyGuard(): void {
guard = null
m.mount(gridMountpoints.modalhook, null)
}
/** Liefert true, wenn aktuell ein Guard registriert ist. */
export function hasDirtyGuard(): boolean {
return guard != null
}
/** Zentraler Einstieg für alle App-internen Navigationen. Ist die aktive
* Maske dirty, wird das 3-Button-Bestätigungs-Modal geöffnet; andernfalls
* wird direkt navigiert. Alle m.route.set-Aufrufe, die aus einer
* Bearbeitungsmaske heraus führen, sollten diese Funktion verwenden.
*
* Der optionale Event-Parameter e wird bei dirty auf e.redraw = false
* gesetzt, damit Mithril keinen auto-redraw der Maske ausführt und die
* eingegebenen Formularwerte im DOM erhalten bleiben — das Input-Widget
* hat kein Live-Binding, ein Redraw würde die Felder mit den (noch
* unveränderten) Model-Werten überschreiben. */
export function guardedNavigate(route: string, params?: any, e?: Event): void {
if (guard && guard.isDirty()) {
if (e) e['redraw'] = false
showDirtyConfirmModal(route, params)
return
}
m.route.set(route, params)
}
/** Hilfsfunktion für Masken: triggert das Formular-Submit. Die Maske übergibt
* ihre Form-Referenz (über onformcreate) und einen Callback, der die
* Ziel-Route für die Navigation nach erfolgreichem Speichern zwischenspeichert. */
export function submitForm(form: HTMLFormElement | null): void {
if (!form) return
if (typeof form.requestSubmit === "function") {
form.requestSubmit()
} else {
// Fallback für ältere Browser.
form.dispatchEvent(new Event("submit", {cancelable: true, bubbles: true}))
}
}
// ---- 3-Button-Bestätigungs-Modal ----------------------------------------
interface iDirtyConfirmModal {
route: string
params: any
}
const DirtyConfirmModal: m.Component<iDirtyConfirmModal> = {
view: (vnode) => {
const close = () => { m.mount(gridMountpoints.modalhook, null) }
const onSave = (e: MouseEvent) => {
// Auto-Redraw unterbinden, damit die darunter liegende Maske
// nicht neu gerendert wird und die eingegebenen Formularwerte
// im DOM erhalten bleiben — das Input-Widget hat kein
// Live-Binding, ein Redraw würde die Felder mit den (noch
// unveränderten) Model-Werten überschreiben. Das Modal wird
// durch close() direkt per m.mount entfernt.
e['redraw'] = false
close()
if (guard) {
guard.save(vnode.attrs.route, vnode.attrs.params)
}
}
const onDiscard = (e: MouseEvent) => {
// Redraw unterbinden; m.route.set löst den Komponentenwechsel
// ohnehin aus.
e['redraw'] = false
close()
m.route.set(vnode.attrs.route, vnode.attrs.params)
}
const onCancel = (e: MouseEvent) => {
// Redraw unterbinden — die Maske bleibt unverändert, die
// Eingaben des Nutzers dürfen nicht überschrieben werden.
e['redraw'] = false
close()
}
return m("div.w3-modal", {style: {display: 'block'}},
m("div.w3-modal-content.w3-card-4", {style: {width: '440px', top: '20%'}},
m("header.w3-container.w3-theme",
m("h3", "Ungespeicherte Änderungen")),
m("div.w3-container.w3-theme-l1",
m("p", "Die Maske enthält ungespeicherte Änderungen."),
m("p", "Wie möchten Sie verfahren?")),
m("footer.w3-container.w3-theme-l1.w3-padding-16",
m("div.w3-bar",
m("button.w3-button.w3-theme.w3-right",
{style: {'margin-left': '4px'}, onclick: onDiscard},
"Verwerfen"),
m("button.w3-button.w3-theme.w3-right",
{style: {'margin-left': '4px'}, onclick: onSave},
"Speichern"),
m("button.w3-button.w3-theme-l2.w3-right",
{style: {'margin-left': '4px'}, onclick: onCancel},
"Abbrechen")
)
)
)
)
}
}
function showDirtyConfirmModal(route: string, params: any): void {
m.mount(gridMountpoints.modalhook, {
view: () => { return m(DirtyConfirmModal, {route: route, params: params} as iDirtyConfirmModal) }
})
}
// ---- DirtyMaskHelper: kapselt den isDirty/Guard-Boilerplate ---------------
/** Hilfsklasse für Bearbeitungsmasken. Kapselt den gesamten isDirty/Guard-
* Boilerplate, der sonst in jeder Maske identisch wiederkehrt: Dirty-State,
* Form-Referenz, pendingTarget (Ziel-Route nach Speichern aus dem Modal),
* Guard-Registrierung/-Cleanup, navigateAfterSave, cancel und die
* Form-Attribute.
*
* Maskenspezifisch bleiben: die DTO-Erzeugung und create/update-Aufrufe im
* submit (die Maske ruft danach guard.navigateAfterSave() bzw.
* guard.markDirty() auf), die backParams()-Closure, In-Masken-Buttons und
* der oninit-Daten-Fetch.
*
* Verwendung in einer ClosureComponent:
* ```ts
* export const maske: m.ClosureComponent = (vnode) => {
* const guard = new DirtyMaskHelper("/zielroute", () => ({...backParams}))
* return {
* oninit: (v) => { guard.registerGuard(); // fetch ...
* },
* onremove: (v) => { guard.cleanup() },
* view: (v) => {
* const submit = (form) => {
* // DTO bauen, createX, guard.navigateAfterSave, guard.markDirty
* return true
* }
* return m(Form, guard.formAttrs(submit) as iForm, [...])
* }
* }
* }
* ``` */
export class DirtyMaskHelper {
private isDirty = false
private formRef: HTMLFormElement | null = null
private pendingTarget: {route: string, params?: any} | null = null
private readonly defaultRoute: string
private readonly backParams: () => any
/** @param defaultRoute Standard-Ziel nach Save/Cancel (ohne pendingTarget).
* @param backParams liefert die Route-Params für das Standard-Ziel;
* optional, da einige Masken (z.B. kunde, tag) keine Params benötigen. */
constructor(defaultRoute: string, backParams?: () => any) {
this.defaultRoute = defaultRoute
this.backParams = backParams || (() => ({}))
}
/** In oninit aufrufen. Weist diese DirtyMaskHelper-Instanz der
* zentralen statischen Modul-Variablen `guard` (siehe
* registerDirtyGuard) zu, so dass alle Konsumenten — guardedNavigate,
* das 3-Button-Modal, hasDirtyGuard — über diesen einen Slot auf den
* Guard zugreifen, der zur gerade aktiven Maske "gehört". Die
* übergebenen Closures binden this lexikalisch an diese Instanz, so
* dass isDirty() und save() live auf den Instanz-State zugreifen.
*
* Registrierung bewusst nicht im Konstruktor: der Konstruktor läuft
* zum Erzeugungszeitpunkt der ClosureComponent, nicht zum
* Lebenszyklus-Start (oninit). Würde er registrieren, könnte eine
* neue Instanz den zentralen Slot überschreiben, bevor onremove der
* alten Maske aufgeräumt hat — die alte würde dann den Guard der
* neuen Instanz löschen. Mit registerGuard in oninit gilt:
* erst räumt die alte Maske auf (cleanup in onremove), dann
* registriert die neue. */
registerGuard(): void {
registerDirtyGuard({isDirty: () => this.isDirty, save: this.save})
}
/** In onremove aufrufen — deregistriert den Guard und schließt ein
* evtl. offenes Bestätigungs-Modal. */
cleanup(): void {
clearDirtyGuard()
}
/** Als onDirtyChange an die Form übergeben (wird von der Form bei
* Eingabe-Änderungen aufgerufen). */
onDirtyChange = (dirty: boolean) => { this.isDirty = dirty }
/** Bei Custom-Widget-Änderungen aufrufen (Select onchanged etc.), die
* kein change/input-Event auf der Form auslösen. */
markDirty = () => { this.isDirty = true }
/** Als onformcreate an die Form übergeben (speichert die Form-Referenz
* für das programmtische Submit aus dem Bestätigungs-Modal). */
setForm = (f: HTMLFormElement) => { this.formRef = f }
/** Nach erfolgreichem Speichern aufrufen (im .then der REST-Calls).
* Gibt es ein pendingTarget (Speichern aus dem Bestätigungs-Modal),
* wird dorthin navigiert; sonst zum Standard-Ziel. */
navigateAfterSave = () => {
if (this.pendingTarget) {
const t = this.pendingTarget
this.pendingTarget = null
m.route.set(t.route, t.params)
} else {
m.route.set(this.defaultRoute, this.backParams())
}
}
/** Als oncancel an die Form übergeben. Navigiert über den Guard,
* so dass bei dirty die Bestätigungs-Modal erscheint. Das Event
* wird durchgereicht, damit guardedNavigate den auto-redraw
* unterbinden kann. */
cancel = (e?: Event) => {
guardedNavigate(this.defaultRoute, this.backParams(), e)
}
/** Vom Navigations-Guard aufgerufen, wenn der Nutzer im Bestätigungs-
* Modal "Speichern" wählt. Speichert die Ziel-Route und triggert das
* Form-Submit; die Maske navigiert nach erfolgreichem Speichern über
* navigateAfterSave() dorthin. */
private save = (targetRoute?: string, targetParams?: any) => {
this.pendingTarget = targetRoute ? {route: targetRoute, params: targetParams} : null
submitForm(this.formRef)
}
/** Liefert das Form-Attribut-Objekt für m(Form, ...). Der onsubmit-
* Callback ist maskenspezifisch (DTO-Erzeugung + create/update) und
* muss von der Maske übergeben werden; alle anderen Attribute
* (showButtons, oncancel, onDirtyChange, onformcreate) werden von
* der Hilfsklasse gesetzt. */
formAttrs(onsubmit: (form: HTMLFormElement) => boolean): Partial<iForm> {
return {
showbuttons: true,
onsubmit: onsubmit,
oncancel: this.cancel,
onDirtyChange: this.onDirtyChange,
onformcreate: this.setForm
}
}
}
+32 -9
View File
@@ -12,6 +12,13 @@ export interface iForm {
stickyButtons?: boolean
/** Die Funktion gibt das HTMLFormElement zurück, welches in der Form-Componente angelegt wird */
onformcreate?: (form:HTMLFormElement) => void
/** Callback bei Änderung des Dirty-State. Wird mit true aufgerufen,
* sobald der Nutzer Eingaben vornimmt, und mit false, nachdem das
* Formular erfolgreich gespeichert oder zurückgesetzt wurde. Die
* umgebende Maske nutzt dies, um ihren eigenen isDirty-State mit dem
* der Form synchron zu halten und so den Navigations-Guard
* (dirtyguard.ts) zu speisen. */
onDirtyChange?: (dirty: boolean) => void
}
export const Form:m.ClosureComponent<iForm> = (vnode) => {
@@ -29,6 +36,9 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
}
function onchanged (e:Event) {
isDirty = true
if (vnode.attrs.onDirtyChange != undefined) {
vnode.attrs.onDirtyChange(true)
}
if (vnode.attrs.onchanged != undefined) {
vnode.attrs.onchanged(e)
}
@@ -37,6 +47,9 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
e.preventDefault()
if (vnode.attrs.onsubmit(form) == true) {
isDirty = false
if (vnode.attrs.onDirtyChange != undefined) {
vnode.attrs.onDirtyChange(false)
}
}
}
function registerFormElement (f:HTMLFormElement) {
@@ -46,22 +59,24 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
}
function cancel(e:Event) {
e.preventDefault()
if (canCloseForm() == true) {
isDirty = false
if (vnode.attrs.oncancel != undefined) {
vnode.attrs.oncancel(e)
}
else {
history.back()
}
// Der Dirty-Check beim Abbrechen erfolgt jetzt durch den
// Navigations-Guard der Maske (dirtyguard.ts), da oncancel die
// Navigation auslöst. Die Form selbst fragt hier nicht mehr nach,
// um eine doppelte Bestätigung zu vermeiden. isDirty wird nicht
// zurückgesetzt, damit der Guard den aktuellen State sieht.
if (vnode.attrs.oncancel != undefined) {
vnode.attrs.oncancel(e)
}
else {
e['redraw'] = false
history.back()
}
}
function reset(e:Event) {
if (canCloseForm("reset")) {
isDirty = false
if (vnode.attrs.onDirtyChange != undefined) {
vnode.attrs.onDirtyChange(false)
}
// Hier passiert nichts.
// Da mithril nach jedem User-Event (wie z.B. einem 'Click') ein redraw() ausführt, wird hier die Form
// neu gezeichnet und zwar mit den ursprünglichen Daten aus den zu grundeliegenden Datenmodel, das ja
@@ -109,6 +124,10 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
for (let index = 0; index < elements.length; index++) {
const element = elements[index];
element.addEventListener("change", onchanged)
// 'input' feuert schon während des Tippens (vor
// Blur), so dass die Maske dirty wird, auch wenn
// der Nutzer sofort eine Breadcrumb klickt.
element.addEventListener("input", onchanged)
}
registerFormElement(form)
}
@@ -131,6 +150,10 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
for (let index = 0; index < elements.length; index++) {
const element = elements[index];
element.addEventListener("change", onchanged)
// 'input' feuert schon während des Tippens (vor
// Blur), so dass die Maske dirty wird, auch wenn
// der Nutzer sofort eine Breadcrumb klickt.
element.addEventListener("input", onchanged)
}
registerFormElement(form)
}
+6 -1
View File
@@ -23,11 +23,13 @@ export interface iInput {
readonly?: boolean
required?:boolean
onchanged?: Function
oninput?: Function
}
/**
* Diese Komponente stellt ein Html-Input Element dar. Der darzustellende Wert wird einfach mit dem Attribut
* 'value' übergeben. Der eingegebene Wert muss dann später explizit aus Value abgefragt werden. Es findet also kein
* sutomatisches Binding statt.
* sutomatisches Binding statt. Über den oninput-Callback kann jedoch ein Live-Binding realisiert werden,
* damit Eingaben bei einem Redraw (z.B. durch interaktive Elemente außerhalb des Inputs) nicht verloren gehen.
*/
export const Input:m.Component<iInput> = {
onupdate: (vnode) => {
@@ -71,6 +73,9 @@ export const Input:m.Component<iInput> = {
if (vnode.attrs.onchanged != undefined) {
attributes['onchange'] = vnode.attrs.onchanged
}
if (vnode.attrs.oninput != undefined) {
attributes['oninput'] = vnode.attrs.oninput
}
return m("input.w3-input.w3-border", attributes)
+268
View File
@@ -207,3 +207,271 @@ export const Select: m.ClosureComponent<iSelect> = () => {
}
}
}
/**
* Der Vnode der Komponente 'MultiSelect' akzeptiert die folgenden Parameter:
* @property {string} name - Der Name des Feldes (für FormData; pro gewähltem
* Wert wird ein hidden input dieses Namens erzeugt)
* @property {any[]} selected - Die aktuell gewählten Werte (values der Optionen)
* @property {iSelectOption[]} options - Die zur Auswahl stehenden Optionen
* @property {boolean} readonly (optional) - Auswahl gesperrt
* @property {Map<any, string>} colors (optional) - Wert -> Hexfarbe für
* Farbpunkte/Chips (z.B. Tag-Farben)
* @property {string} placeholder (optional) - Platzhalter-Badge-Text bei
* leerer Auswahl (Default: "kein Wert")
* @property {string} modalTitle (optional) - Titel des Auswahl-Modals
* (Default: "Auswahl")
* @property {string} editTooltip (optional) - Tooltip des Edit-Buttons
* (Default: "Bearbeiten")
* @property {Function} onchanged (optional) - Callback bei Änderung der Auswahl
*/
export interface iMultiSelect {
name: string
selected: any[]
options: iSelectOption[]
readonly?: boolean
colors?: Map<any, string>
placeholder?: string
modalTitle?: string
editTooltip?: string
onchanged?: Function
}
/**
* Mehrfachauswahl-Widget mit Modal-Dialog. Im Formular werden nur die
* ausgewählten Werte als farbige Badges (bzw. ein grauer Platzhalter bei
* leerer Auswahl) zusammen mit einem Edit-Button angezeigt. Beim Klick
* auf den Edit-Button öffnet sich ein Modal mit einem Suchfeld, der
* durchsuchbaren Checkbox-Liste aller Optionen und einem OK-Button zum
* Schließen.
*
* Das Widget ist generisch und nicht auf Tags beschränkt — es arbeitet
* mit beliebigen iSelectOption-Listen. Tag-spezifische Texte (Platzhalter,
* Modal-Titel, Tooltip) sind über die entsprechenden Attribute
* konfigurierbar.
*
* Für die Formularübergabe wird pro gewähltem Wert ein verstecktes
* input-Element erzeugt, sodass FormData.getAll(name) alle IDs liefert.
*/
export const MultiSelect: m.ClosureComponent<iMultiSelect> = () => {
// Suchtext und Modal-Sichtbarkeit.
let search: string = ""
let isModalOpen: boolean = false
// Lokale Arbeitskopie der Auswahl während das Modal geöffnet ist.
let draft: any[] = []
const getFilteredOptions = (options: iSelectOption[], searchText: string): iSelectOption[] => {
const searchLower = searchText.toLowerCase()
if (searchLower === "") {
return options
}
return options.filter((o: iSelectOption) => o.label.toLowerCase().includes(searchLower))
}
return {
oninit: (vnode) => {
search = ""
isModalOpen = false
draft = []
},
view: (vnode) => {
const isReadonly = vnode.attrs.readonly === true
const colors = vnode.attrs.colors
const placeholder = vnode.attrs.placeholder || "kein Wert"
const modalTitle = vnode.attrs.modalTitle || "Auswahl"
const editTooltip = vnode.attrs.editTooltip || "Bearbeiten"
const selected: any[] = Array.isArray(vnode.attrs.selected) ? vnode.attrs.selected : []
const filteredOptions = getFilteredOptions(vnode.attrs.options, search)
const isDraftSelected = (value: any) => draft.includes(value)
const toggleDraft = (option: iSelectOption) => {
if (isDraftSelected(option.value)) {
const idx = draft.indexOf(option.value)
if (idx >= 0) { draft.splice(idx, 1) }
} else {
draft.push(option.value)
}
}
const removeChip = (value: any, e: Event) => {
e.stopPropagation()
e.preventDefault()
const idx = vnode.attrs.selected.indexOf(value)
if (idx >= 0) { vnode.attrs.selected.splice(idx, 1) }
if (vnode.attrs.onchanged != undefined) {
vnode.attrs.onchanged(vnode.attrs.selected.slice())
}
}
const openEditModal = (e: Event) => {
e.preventDefault()
// draft als Kopie der aktuellen Auswahl initialisieren.
draft = selected.slice()
search = ""
isModalOpen = true
}
const closeModal = () => {
// Auswahl aus draft in selected übernehmen.
vnode.attrs.selected.splice(0, vnode.attrs.selected.length)
Array.prototype.push.apply(vnode.attrs.selected, draft)
if (vnode.attrs.onchanged != undefined) {
vnode.attrs.onchanged(vnode.attrs.selected.slice())
}
isModalOpen = false
search = ""
}
// Badges für ausgewählte Werte. Bei leerer Auswahl wird ein
// graues Platzhalter-Badge "kein Tag" angezeigt.
const badges = m("div", {
style: {
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
'margin-bottom': '5px',
'min-height': '20px',
alignItems: 'center'
}
},
selected.length > 0
? selected.map((value: any) => {
const opt = vnode.attrs.options.find((o: iSelectOption) => o.value === value)
const label = opt ? opt.label : String(value)
const color = colors ? colors.get(value) : undefined
return m("span.w3-tag.w3-small", {
style: {
backgroundColor: color || '#999',
color: 'white',
borderRadius: '3px',
padding: '2px 4px',
display: 'inline-flex',
alignItems: 'center',
gap: '2px',
cursor: isReadonly ? 'default' : 'pointer'
}
},
m("span", label),
!isReadonly
? m("button.w3-button", {
style: {
'min-width': '16px',
height: '16px',
padding: '0',
'line-height': '1',
color: 'white',
background: 'transparent'
},
onclick: (e: Event) => removeChip(value, e)
}, "×")
: null
)
})
: m("span.w3-tag.w3-small", {
style: {
backgroundColor: '#bbb',
color: 'white',
borderRadius: '3px',
padding: '2px 4px',
display: 'inline-flex',
alignItems: 'center'
}
}, placeholder)
)
return m("div", {style: {width: '100%', position: 'relative'}},
// Versteckte Felder für die Formularübergabe (ein pro gewähltem Wert).
selected.map((value: any) =>
m("input", {type: "hidden", name: vnode.attrs.name, value: value})
),
m("div", {style: {display: 'flex', alignItems: 'center', gap: '8px'}},
badges,
!isReadonly
? m("button.w3-button.w3-small", {
title: editTooltip,
style: {
'min-width': '32px',
height: '32px',
padding: '0 4px',
'line-height': '1'
},
onclick: openEditModal
}, m("i.material-icons", {style: {'font-size': '20px'}}, "edit"))
: null
),
// Inline gerendertes Modal für die Tag-Auswahl.
isModalOpen
? m("div.w3-modal", {style: {display: 'block'}},
m("div.w3-modal-content.w3-card-4", {style: {width: '500px', top: '10%'}},
m("header.w3-container.w3-theme",
m("h2", modalTitle)
),
m("div.w3-container.w3-theme-l1", {style: {padding: '16px'}},
// Suchfeld.
m("input.w3-input.w3-border", {
type: "text",
value: search,
placeholder: "Suchbegriff …",
style: {'margin-bottom': '10px', width: '100%'},
oninput: (e: Event) => {
search = (e.target as HTMLInputElement).value
}
}),
// Checkbox-Liste der (gefilterten) Tags.
m("div.w3-bar-block.w3-border", {
style: {
'max-height': '300px',
'overflow-y': 'auto',
'margin-bottom': '10px'
}
},
filteredOptions.length === 0
? m("div.w3-padding-small", "Keine Treffer")
: filteredOptions.map((option: iSelectOption) => {
const color = colors ? colors.get(option.value) : undefined
return m("label.w3-bar-item.w3-button.w3-text-left", {
style: {whiteSpace: 'normal', display: 'flex', alignItems: 'center', gap: '6px'}
},
m("input.w3-check", {
type: "checkbox",
checked: isDraftSelected(option.value),
style: {'margin-right': '4px'},
onclick: (e: Event) => {
if (isReadonly) { return }
e.preventDefault()
toggleDraft(option)
}
}),
color
? m("span", {
style: {
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: color,
flex: '0 0 auto'
}
})
: null,
m("span", option.label)
)
})
),
// OK-Button schließt das Modal.
m("div.w3-bar",
m("button.w3-button.w3-theme.w3-right",
{type: "button", onclick: closeModal},
"OK"
)
)
)
)
)
: null
)
}
}
}
+20 -11
View File
@@ -11,6 +11,7 @@ import { Call_Log, call_log_liste } from "./Components/call_log"
import { Kontakt, kontaktliste } from "./Components/kontakt"
import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse"
import { Tag, TagList } from "./Components/tags"
import { guardedNavigate } from "./Components/widgets/dirtyguard"
function healthcheck () {
@@ -118,21 +119,29 @@ const knopf: m.Component = {
}
const menu: m.ClosureComponent = () => {
let showProjektBerechtigungen = false
// Menü-Eintrag, der die Navigation über den Dirty-Guard leitet, so dass
// aus einer Bearbeitungsmaske heraus ungespeicherte Änderungen abgefragt
// werden, bevor die Ansicht wechselt.
const menuItem = (route: string, label: string) => {
return m("a.w3-button.w3-bar-item",
{href: "#", onclick: (e: Event) => {e.preventDefault(); guardedNavigate(route, undefined, e)}},
label)
}
return {
oninit: (vnode) => {
showProjektBerechtigungen = false
},
view: (vnode) => {
return m(menubar, [
m("a.w3-button.w3-bar-item", {href: "#!/kundenliste"}, "Kunden"),
m("a.w3-button.w3-bar-item", {href: "#!/projektliste"}, "Projekte"),
m("a.w3-button.w3-bar-item", {href: "#!/calllogliste"}, "Gesprächsnotizen"),
menuItem("/kundenliste", "Kunden"),
menuItem("/projektliste", "Projekte"),
menuItem("/calllogliste", "Gesprächsnotizen"),
m("div.w3-dropdown-hover",
m("button.w3-button", "Optionen"),
m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
m("a.w3-button.w3-bar-item", {href: "#!/taglist"}, "Tags"),
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenexport"),
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenimport"),
menuItem("/taglist", "Tags"),
menuItem("/default", "Datenexport"),
menuItem("/default", "Datenimport"),
// Projekt-Berechtigungen nur für SysAdmin/AppAdmin sichtbar.
// Als Toggle-Button realisiert, da verschachtelte w3-dropdown-hover
// innerhalb eines offenen Dropdowns nicht zuverlässig funktionieren.
@@ -147,10 +156,10 @@ const menu: m.ClosureComponent = () => {
showProjektBerechtigungen
? m("div.w3-bar-block",
m("a.w3-button.w3-bar-item",
{href: "#!/projektuserliste", style: {'padding-left': '32px'}},
{href: "#", style: {'padding-left': '32px'}, onclick: (e: Event) => {e.preventDefault(); guardedNavigate("/projektuserliste", undefined, e)}},
"Projekt-Zugriffe"),
m("a.w3-button.w3-bar-item",
{href: "#!/userprojektliste", style: {'padding-left': '32px'}},
{href: "#", style: {'padding-left': '32px'}, onclick: (e: Event) => {e.preventDefault(); guardedNavigate("/userprojektliste", undefined, e)}},
"Nutzer-Zugriffe")
)
: null
@@ -162,9 +171,9 @@ const menu: m.ClosureComponent = () => {
m("div.w3-dropdown-hover.w3-right",
m("button.w3-button", "System"),
m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
m("a.w3-button.w3-bar-item", {href: "#!/login"}, "Login"),
m("a.w3-button.w3-bar-item", {href: "#!/changepassword"}, "Kennwort ändern"),
m("a.w3-button.w3-bar-item", {href: "#!/usermanagement"}, "Verwalten"),
menuItem("/login", "Login"),
menuItem("/changepassword", "Kennwort ändern"),
menuItem("/usermanagement", "Verwalten"),
)
)
])
+7
View File
@@ -1,10 +1,13 @@
import m from "mithril"
import {iTag} from "./tag"
/**
* Datenmodell für eine Gesprächsnotiz (Call-Log).
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* projekt_name und kunden_name sind keine ORM-Spalten, sondern werden
* serverseitig aus der Projekt-Relationship abgeleitet und im JSON ergänzt.
* tags (vom Server beim Lesen) und tag_ids (vom Client beim Schreiben)
* steuern die n:m-Beziehung zu den Tags.
*/
export interface iCallLog {
id: number
@@ -20,6 +23,10 @@ export interface iCallLog {
notiz: string
wiedervorlage_datum: string
erledigt: boolean
/** Vom Server gelieferte zugeordnete Tags (nur beim Lesen). */
tags?: iTag[]
/** Vom Client zu übermittelnde Tag-IDs (nur beim Schreiben). */
tag_ids?: number[]
}
/* ------------------------------------------------------------------ */
+108
View File
@@ -0,0 +1,108 @@
import m from "mithril"
/**
* Datenmodell für einen Tag (hierarchische Taxonomie).
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* Tags koennen beliebig verschachtelt werden (parent_id -> tags.id).
*/
export interface iTag {
id: number
name: string
description: string
color: string
parent_id: number | null
is_parent: boolean
}
/**
* Tag mit verschachtelten Kindern für die Hierarchie-Darstellung.
* Das children-Array wird serverseitig im Hierarchie-Endpunkt befüllt.
*/
export interface iTagWithChildren extends iTag {
children: iTagWithChildren[]
}
/* ------------------------------------------------------------------ */
/* REST-API-Aufrufe (Client-Seite) */
/* ------------------------------------------------------------------ */
/* Die folgenden Funktionen kapseln die Kommunikation mit den */
/* serverseitigen REST-Endpunkten (/xhr/tag/*). */
/* ------------------------------------------------------------------ */
/**
* Basis-Pfad der Tag-REST-Endpunkte.
*/
const TAG_REST_BASE = "/xhr/tag"
/**
* Lädt alle Tags als flache Liste vom Server.
* Ist parentId gesetzt, werden nur die direkten Kinder dieses Tags
* geliefert (serverseitige Filterung).
*/
export function fetchTags(parentId?: number): Promise<iTag[]> {
const params: Record<string, any> = {}
if (parentId !== undefined) {
params.parent_id = parentId
}
return m.request({
method: "GET",
url: TAG_REST_BASE + "/list",
params: params
}).then((result) => result as iTag[])
}
/**
* Lädt die vollständige Tag-Hierarchie als verschachtelte Struktur vom Server.
* Jeder Tag enthält ein children-Array mit seinen rekursiv eingebetteten Kindern.
*/
export function fetchTagHierarchy(): Promise<iTagWithChildren[]> {
return m.request({
method: "GET",
url: TAG_REST_BASE + "/hierarchy"
}).then((result) => result as iTagWithChildren[])
}
/**
* Lädt einen einzelnen Tag anhand seiner ID vom Server.
*/
export function fetchTagById(id: number): Promise<iTag> {
return m.request({
method: "GET",
url: TAG_REST_BASE + "/get",
params: {id: id}
}).then((result) => result as iTag)
}
/**
* Legt einen neuen Tag serverseitig an. Die ID wird vom Server
* vergeben und im zurückgegebenen Tag-Objekt gesetzt.
*/
export function createTagREST(tag: iTag): Promise<iTag> {
return m.request({
method: "POST",
url: TAG_REST_BASE + "/create",
body: tag
}).then((result) => result as iTag)
}
/**
* Aktualisiert einen bestehenden Tag serverseitig.
*/
export function updateTagREST(tag: iTag): Promise<iTag> {
return m.request({
method: "PUT",
url: TAG_REST_BASE + "/update",
body: tag
}).then((result) => result as iTag)
}
/**
* Löscht einen Tag inkl. aller Nachfahren serverseitig anhand seiner ID.
*/
export function deleteTagREST(id: number): Promise<void> {
return m.request({
method: "DELETE",
url: TAG_REST_BASE + "/delete",
params: {id: id}
}).then(() => undefined)
}