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/ tsout/
kbp_crm*.js kbp_crm*.js
kbcrm/App_Server/data kbcrm/App_Server/data
#App_Server/data/ App_Server/data/
oidc-test/ oidc-test/
.vibe/ .vibe/
.vibe-global/ .vibe-global/
+66 -1
View File
@@ -259,12 +259,19 @@ class CallLog(dbapp.Model):
erledigt = Column(Boolean, nullable=False, default=False) erledigt = Column(Boolean, nullable=False, default=False)
projekt = relationship('Projekt') 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): def to_dict(self):
""" """
Liefert die Gesprächsnotiz als Dictionary. Die Tabellenspalten werden Liefert die Gesprächsnotiz als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; projekt_name und kunden_name 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) result = columns2dict(self)
if self.projekt: if self.projekt:
@@ -273,7 +280,65 @@ class CallLog(dbapp.Model):
else: else:
result['projekt_name'] = None result['projekt_name'] = None
result['kunden_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 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") print("########## crm/model importiert")
+61 -5
View File
@@ -9,14 +9,68 @@ dort über den Decorator authorisation_required.
:date: 2026-09-04 :date: 2026-09-04
:version: 1.0 :version: 1.0
""" """
from datetime import date
from db import dbapp 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 from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session 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): def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
""" """
Liefert Gesprächsnotizen, sortiert nach Datum absteigend. Liefert Gesprächsnotizen, sortiert nach Datum absteigend.
@@ -94,15 +148,16 @@ def create_call_log(data: dict) -> CallLog:
) )
call_log = CallLog() call_log = CallLog()
call_log.projekt_id = int(projekt_id) 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.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
call_log.dauer = (data.get("dauer") 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.betreff = (data.get("betreff") or "").strip() or None
call_log.name_ansprechpartner = (data.get("name_ansprechpartner") 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.telefon = (data.get("telefon") or "").strip() or None
call_log.notiz = (data.get("notiz") 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)) call_log.erledigt = bool(data.get("erledigt", False))
_set_call_log_tags(call_log, data.get("tag_ids"))
session.add(call_log) session.add(call_log)
session.commit() session.commit()
return call_log return call_log
@@ -127,15 +182,16 @@ def update_call_log(data: dict) -> CallLog:
htmlstatus=HtmlStatusCode.Bad_Request, htmlstatus=HtmlStatusCode.Bad_Request,
) )
call_log.projekt_id = int(projekt_id) 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.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
call_log.dauer = (data.get("dauer") 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.betreff = (data.get("betreff") or "").strip() or None
call_log.name_ansprechpartner = (data.get("name_ansprechpartner") 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.telefon = (data.get("telefon") or "").strip() or None
call_log.notiz = (data.get("notiz") 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)) call_log.erledigt = bool(data.get("erledigt", False))
_set_call_log_tags(call_log, data.get("tag_ids"))
session.commit() session.commit()
return call_log return call_log
+22
View File
@@ -221,6 +221,28 @@ def _db_V01_09():
session.commit() 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(): def checkupdates():
""" """
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle 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 projekt_user as projekt_users
from crm.model import kontaktadresse as kontaktadressen from crm.model import kontaktadresse as kontaktadressen
from crm.model import call_log as call_logs from crm.model import call_log as call_logs
from crm.model import tag as tags
print("crm-routes") print("crm-routes")
@@ -380,4 +381,64 @@ def calllog_delete():
return jsonify({}), 200 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") print("########## crm.routes importiert")
+123 -11
View File
@@ -1,13 +1,16 @@
import m from "mithril" import m from "mithril"
import {Form, iForm} from "./widgets/form" import {Form, iForm} from "./widgets/form"
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs" 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 {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 {iCallLog, fetchCallLogs, fetchCallLogById, createCallLogREST, updateCallLogREST} from "../models/call_log"
import {iKunde, fetchKunden} from "../models/kunde" import {iKunde, fetchKunden} from "../models/kunde"
import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt" import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse" import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse"
import {iTag, fetchTags} from "../models/tag"
import {showServerError} from "./auth/authmessages" import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
// Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden. // Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden.
const DAUER_UPDATE_INTERVALL = 1000 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. // da es nicht in den Route-Params enthalten ist.
let projektName: string = "" let projektName: string = ""
let kundenName: string = "" let kundenName: string = ""
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
const handleRowMarked = (node: iTreenode) => { const handleRowMarked = (node: iTreenode) => {
selectedNode = node 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 addCallLogRoute = () => {
const now = new Date() const now = new Date()
const datum = now.toISOString().split('T')[0] const datum = now.toISOString().split('T')[0]
@@ -40,30 +52,58 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
telefon: "", telefon: "",
notiz: "", notiz: "",
wiedervorlage_datum: "", wiedervorlage_datum: "",
erledigt: false erledigt: false,
from_liste: "1"
} }
if (filterProjektId !== null) { if (filterProjektId !== null) {
params.projekt_id = filterProjektId params.projekt_id = filterProjektId
params.projekt_name = projektName params.projekt_name = projektName
params.kunden_name = kundenName 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) m.route.set("/call_log", params)
} }
const editCallLog = (log: iCallLog, e: Event) => { const editCallLog = (log: iCallLog, e: Event) => {
e.stopPropagation() 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) => { const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let log: iCallLog = node.nodevalue 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 [ return [
m("div", m("strong", log.kunden_name + ": " + log.projekt_name)), m("div", m("strong", log.kunden_name + ": " + log.projekt_name)),
m("span", log.datum), m("span", log.datum),
m("span", log.uhrzeit_beginn), m("span", log.uhrzeit_beginn),
m("span", log.dauer + " min"), m("span", log.dauer + " min"),
m("span", log.betreff), m("span", log.betreff, ...tagBadges),
m("span", log.wiedervorlage_datum), m("span", log.wiedervorlage_datum),
m("span", log.erledigt ? "Erledigt" : "Offen"), 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")) 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 filterProjektId = null
projektName = "" projektName = ""
} }
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
// Gesprächsnotizen asynchron vom Server laden. Die Filterung // Gesprächsnotizen asynchron vom Server laden. Die Filterung
// nach projekt_id erfolgt serverseitig, sofern ein Filter // nach projekt_id erfolgt serverseitig, sofern ein Filter
// gesetzt ist. // gesetzt ist.
@@ -118,6 +160,10 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
height: 'inherit', height: 'inherit',
'overflow-y': 'inherit'}}, 'overflow-y': 'inherit'}},
m("h1", projektName ? "Gesprächsnotizen für " + projektName : "Gesprächsnotizen"), 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("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable, m(Treetable,
{ {
@@ -183,6 +229,39 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
let kundenListe: iKunde[] = [] let kundenListe: iKunde[] = []
let projektListe: iProjekt[] = [] let projektListe: iProjekt[] = []
let kontaktadressenListe: iKontaktadresse[] = [] 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. * Formatiert verstrichene Sekunden als mm:ss.
@@ -197,12 +276,23 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
oninit: (vnode) => { oninit: (vnode) => {
const routeParams = m.route.param() const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 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) { if (id !== -1) {
// Gesprächsnotiz frisch vom Server holen. // Gesprächsnotiz frisch vom Server holen.
fetchCallLogById(id).then((result) => { fetchCallLogById(id).then((result) => {
currentLog = result currentLog = result
projektVoreingestellt = true projektVoreingestellt = true
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== "" ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
selectedTagIds = (result.tags || []).map((t: iTag) => t.id)
}).catch((e) => showServerError(e)) }).catch((e) => showServerError(e))
} else { } else {
projektVoreingestellt = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) !== -1 : false 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)) fetchKunden().then((result) => { kundenListe = result }).catch((e) => showServerError(e))
fetchProjekte().then((result) => { projektListe = result }).catch((e) => showServerError(e)) fetchProjekte().then((result) => { projektListe = result }).catch((e) => showServerError(e))
fetchKontaktadressen().then((result) => { kontaktadressenListe = 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) => { onremove: (vnode) => {
if (timerId != null) { window.clearInterval(timerId) } if (timerId != null) { window.clearInterval(timerId) }
guard.cleanup()
}, },
view: (vnode) => { view: (vnode) => {
// kunden_id aus dem geladenen Projekt ableiten (fuer abhaengiges // 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 + ")" : "")} 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 submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form) const formData = new FormData(form)
@@ -283,22 +381,25 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
telefon: (formData.get("telefon") as string) || "", telefon: (formData.get("telefon") as string) || "",
notiz: (formData.get("notiz") as string) || "", notiz: (formData.get("notiz") as string) || "",
wiedervorlage_datum: (formData.get("wiedervorlage_datum") 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) { 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 { } 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 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", return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentLog.id === -1 ? "Neue Gesprächsnotiz anlegen" : "Gesprächsnotiz"), 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("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}), m("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id}),
// Kunde und Projekt als Read-Only-Text, da voreingestellt. // Kunde und Projekt als Read-Only-Text, da voreingestellt.
m("label", "Kunde", m("label", "Kunde",
@@ -346,6 +447,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
if (ka) { if (ka) {
currentLog.name_ansprechpartner = ka.name currentLog.name_ansprechpartner = ka.name
currentLog.telefon = ka.telefon currentLog.telefon = ka.telefon
guard.markDirty()
} }
} }
} as iSelect)), } 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(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", "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", "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") 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 {Form, iForm} from "./widgets/form"
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs" import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2" 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 {iKontakt, fetchKontakte, fetchKontaktById, createKontaktREST, updateKontaktREST} from "../models/kontakt"
import {iKunde, fetchKundeById} from "../models/kunde" import {iKunde, fetchKundeById} from "../models/kunde"
import {showServerError} from "./auth/authmessages" import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
export const kontaktliste: m.ClosureComponent = (vnode) => { export const kontaktliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = [] let treenodes: iTreenode[] = []
@@ -13,11 +15,20 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
// Kundenname für die Überschrift, asynchron per fetchKundeById // Kundenname für die Überschrift, asynchron per fetchKundeById
// aufgelöst, falls nicht in den Route-Params enthalten. // aufgelöst, falls nicht in den Route-Params enthalten.
let kundenName: string = "" let kundenName: string = ""
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
const handleRowMarked = (node: iTreenode) => { const handleRowMarked = (node: iTreenode) => {
selectedNode = node 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 addKontaktRoute = () => {
const params: any = {id: -1} const params: any = {id: -1}
// Nur bei kundengefilterter Liste den Kunden vorbelegen. // Nur bei kundengefilterter Liste den Kunden vorbelegen.
@@ -25,12 +36,15 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
params.kunden_id = filterKundenId params.kunden_id = filterKundenId
params.kunden_name = kundenName params.kunden_name = kundenName
} }
// Breadcrumb-Kette um eigene Position erweitern.
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/kontakt", params) m.route.set("/kontakt", params)
} }
const editKontakt = (kontakt: iKontakt, e: Event) => { const editKontakt = (kontakt: iKontakt, e: Event) => {
e.stopPropagation() 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) => { const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
@@ -66,6 +80,8 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
filterKundenId = null filterKundenId = null
kundenName = "" kundenName = ""
} }
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
// Kontakte asynchron vom Server laden. Die Filterung nach // Kontakte asynchron vom Server laden. Die Filterung nach
// kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist. // kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist.
fetchKontakte(filterKundenId !== null ? filterKundenId : undefined).then((result) => { fetchKontakte(filterKundenId !== null ? filterKundenId : undefined).then((result) => {
@@ -86,6 +102,10 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
height: 'inherit', height: 'inherit',
'overflow-y': 'inherit'}}, 'overflow-y': 'inherit'}},
m("h1", kundenName ? "Kontaktliste für " + kundenName : "Kontaktliste"), 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("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable, m(Treetable,
{ {
@@ -129,11 +149,27 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
email: "", email: "",
notiz: "" 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 { return {
oninit: (vnode) => { oninit: (vnode) => {
const routeParams = m.route.param() const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 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) { if (id !== -1) {
// Kontaktdaten frisch vom Server holen, um zwischenzeitliche // Kontaktdaten frisch vom Server holen, um zwischenzeitliche
// Änderungen anderer Nutzer nicht zu überschreiben. // Ä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 : "" currentKontakt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
} }
}, },
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => { view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => { const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form) const formData = new FormData(form)
@@ -162,21 +201,23 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
notiz: (formData.get("notiz") as string) || "" notiz: (formData.get("notiz") as string) || ""
} }
if (currentKontakt.id === -1) { 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 { } 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 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", return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentKontakt.id === -1 m("h1", currentKontakt.id === -1
? "Neuer Kontakt für " + currentKontakt.kunden_name ? "Neuer Kontakt für " + currentKontakt.kunden_name
: "Kontakt-Stammblatt"), : "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("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("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", "Anrede", m(Input, {name: "anrede", value: currentKontakt.anrede, type: "text"} as iInput)),
m("label", "Name", m(Input, {name: "name", value: currentKontakt.name, type: "text", required: true} as iInput)), m("label", "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 {Form, iForm} from "./widgets/form"
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs" import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2" 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 {iKontaktadresse, fetchKontaktadressen, fetchKontaktadresseById, createKontaktadresseREST, updateKontaktadresseREST} from "../models/kontaktadresse"
import {iProjekt, fetchProjektById} from "../models/projekt" import {iProjekt, fetchProjektById} from "../models/projekt"
import {showServerError} from "./auth/authmessages" import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
export const kontaktadresseliste: m.ClosureComponent = (vnode) => { export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = [] let treenodes: iTreenode[] = []
@@ -13,23 +15,35 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
// Projektname für die Überschrift, asynchron per fetchProjektById // Projektname für die Überschrift, asynchron per fetchProjektById
// aufgelöst, falls nicht in den Route-Params enthalten. // aufgelöst, falls nicht in den Route-Params enthalten.
let projektName: string = "" let projektName: string = ""
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
const handleRowMarked = (node: iTreenode) => { const handleRowMarked = (node: iTreenode) => {
selectedNode = node 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 addKontaktadresseRoute = () => {
const params: any = {id: -1} const params: any = {id: -1}
if (filterProjektId !== null) { if (filterProjektId !== null) {
params.projekt_id = filterProjektId params.projekt_id = filterProjektId
params.projekt_name = projektName params.projekt_name = projektName
} }
// Breadcrumb-Kette um eigene Position erweitern.
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/kontaktadresse", params) m.route.set("/kontaktadresse", params)
} }
const editKontaktadresse = (kontaktadresse: iKontaktadresse, e: Event) => { const editKontaktadresse = (kontaktadresse: iKontaktadresse, e: Event) => {
e.stopPropagation() 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) => { const addCallLog = (kontaktadresse: iKontaktadresse, e: Event) => {
@@ -37,6 +51,14 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
const now = new Date() const now = new Date()
const datum = now.toISOString().split('T')[0] const datum = now.toISOString().split('T')[0]
const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1] 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", { m.route.set("/call_log", {
id: -1, id: -1,
projekt_id: kontaktadresse.projekt_id, projekt_id: kontaktadresse.projekt_id,
@@ -49,7 +71,8 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
telefon: kontaktadresse.telefon, telefon: kontaktadresse.telefon,
notiz: "", notiz: "",
wiedervorlage_datum: "", wiedervorlage_datum: "",
erledigt: false erledigt: false,
bc: serializeBc(chain)
}) })
} }
@@ -92,6 +115,8 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
filterProjektId = null filterProjektId = null
projektName = "" projektName = ""
} }
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
// Kontaktadressen asynchron vom Server laden. Die Filterung // Kontaktadressen asynchron vom Server laden. Die Filterung
// nach projekt_id erfolgt serverseitig, sofern ein Filter // nach projekt_id erfolgt serverseitig, sofern ein Filter
// gesetzt ist. // gesetzt ist.
@@ -113,6 +138,10 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
height: 'inherit', height: 'inherit',
'overflow-y': 'inherit'}}, 'overflow-y': 'inherit'}},
m("h1", projektName ? "Kontaktadressen für " + projektName : "Kontaktadressen"), 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("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable, m(Treetable,
{ {
@@ -166,11 +195,26 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
land: "", land: "",
notiz: "" 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 { return {
oninit: (vnode) => { oninit: (vnode) => {
const routeParams = m.route.param() const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 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) { if (id !== -1) {
// Kontaktadresse frisch vom Server holen. // Kontaktadresse frisch vom Server holen.
fetchKontaktadresseById(id).then((result) => { fetchKontaktadresseById(id).then((result) => {
@@ -182,6 +226,9 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
currentKontaktadresse.projekt_name = routeParams && routeParams.projekt_name ? routeParams.projekt_name : "" currentKontaktadresse.projekt_name = routeParams && routeParams.projekt_name ? routeParams.projekt_name : ""
} }
}, },
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => { view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => { const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form) const formData = new FormData(form)
@@ -202,21 +249,25 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
notiz: (formData.get("notiz") as string) || "" notiz: (formData.get("notiz") as string) || ""
} }
if (currentKontaktadresse.id === -1) { 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 { } 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 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", return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentKontaktadresse.id === -1 m("h1", currentKontaktadresse.id === -1
? "Neue Kontaktadresse für " + currentKontaktadresse.projekt_name ? "Neue Kontaktadresse für " + currentKontaktadresse.projekt_name
: "Kontaktadresse-Stammblatt"), : "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("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("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", "Anrede", m(Input, {name: "anrede", value: currentKontaktadresse.anrede, type: "text"} as iInput)),
m("label", "Name", m(Input, {name: "name", value: currentKontaktadresse.name, type: "text", required: true} as iInput)), m("label", "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 {Form, iForm} from "./widgets/form"
import { Input, iInput} from "./widgets/inputs" import { Input, iInput} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2" import {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 {iKunde, fetchKunden, fetchKundeById, createKundeREST, updateKundeREST} from "../models/kunde"
import {showServerError} from "./auth/authmessages" import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
export const kunde: m.ClosureComponent = (vnode) => { export const kunde: m.ClosureComponent = (vnode) => {
let currentKunde: iKunde = { let currentKunde: iKunde = {
@@ -14,11 +16,18 @@ export const kunde: m.ClosureComponent = (vnode) => {
ort: "", ort: "",
land: "" 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 { return {
oninit: (vnode) => { oninit: (vnode) => {
const routeParams = m.route.param() const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 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) { if (id !== -1) {
// Kundendaten frisch vom Server holen, um zwischenzeitliche // Kundendaten frisch vom Server holen, um zwischenzeitliche
// Änderungen anderer Nutzer nicht zu überschreiben. // Änderungen anderer Nutzer nicht zu überschreiben.
@@ -27,6 +36,9 @@ export const kunde: m.ClosureComponent = (vnode) => {
}).catch((e) => showServerError(e)) }).catch((e) => showServerError(e))
} }
}, },
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => { view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => { const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form) const formData = new FormData(form)
@@ -39,26 +51,28 @@ export const kunde: m.ClosureComponent = (vnode) => {
land: (formData.get("land") as string) || "" land: (formData.get("land") as string) || ""
} }
if (currentKunde.id === -1) { 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 { } 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 return true
} }
const cancel = (e: Event) => {
m.route.set("/kundenliste")
}
return m("div.w3-container.w3-auto.form-view-container", return m("div.w3-container.w3-auto.form-view-container",
m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}}, m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}},
m("h1", currentKunde.id === -1 ? "Neuen Kunden anlegen" : "Kundenstammblatt"), m("h1", currentKunde.id === -1 ? "Neuen Kunden anlegen" : "Kundenstammblatt"),
currentKunde.id !== -1 currentKunde.id !== -1
? m("button.w3-button.w3-theme", ? 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") m("i.material-icons", "contact_page"), " Kontakte")
: null : 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("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", "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", "Straße", m(Input, {name: "strasse", value: currentKunde.strasse, type: "text"} as iInput)),
m("label", "PLZ", m(Input, {name: "plz", value: currentKunde.plz, type: "text"} as iInput)), m("label", "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) => { export const kundenliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = [] let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null let selectedNode: iTreenode | null = null
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
const handleRowMarked = (node: iTreenode) => { const handleRowMarked = (node: iTreenode) => {
selectedNode = node selectedNode = node
} }
// Eigener Listen-Crumb für die Breadcrumb-Kette.
const ownCrumb = (): iBreadcrumb => ({
route: "/kundenliste",
params: {},
label: "Kunden"
})
const editKunde = (kunde: iKunde, e: Event) => { const editKunde = (kunde: iKunde, e: Event) => {
e.stopPropagation() 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) => { const showProjekte = (kunde: iKunde, e: Event) => {
e.stopPropagation() 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) => { const showKontakte = (kunde: iKunde, e: Event) => {
e.stopPropagation() 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 = () => { 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) => { const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
@@ -127,6 +156,8 @@ export const kundenliste: m.ClosureComponent = (vnode) => {
return { return {
oninit: (vnode) => { 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 // Kundenliste asynchron vom Server laden. m.request löst nach
// Promise-Auflösung automatisch ein Redraw aus. // Promise-Auflösung automatisch ein Redraw aus.
fetchKunden().then((kunden: iKunde[]) => { fetchKunden().then((kunden: iKunde[]) => {
@@ -141,6 +172,10 @@ export const kundenliste: m.ClosureComponent = (vnode) => {
height: 'inherit', height: 'inherit',
'overflow-y': 'inherit'}}, 'overflow-y': 'inherit'}},
m("h1", "Kundenliste"), m("h1", "Kundenliste"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: "Kunden"
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable, m(Treetable,
{ {
+63 -12
View File
@@ -2,9 +2,11 @@ import m from "mithril"
import {Form, iForm} from "./widgets/form" import {Form, iForm} from "./widgets/form"
import { Input, iInput} from "./widgets/inputs" import { Input, iInput} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2" import {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 {iProjekt, fetchProjekte, fetchProjektById, createProjektREST, updateProjektREST} from "../models/projekt"
import {iKunde, fetchKundeById} from "../models/kunde" import {iKunde, fetchKundeById} from "../models/kunde"
import {showServerError} from "./auth/authmessages" import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
export const projektliste: m.ClosureComponent = (vnode) => { export const projektliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = [] let treenodes: iTreenode[] = []
@@ -13,11 +15,20 @@ export const projektliste: m.ClosureComponent = (vnode) => {
// Kundenname für die Überschrift, asynchron per fetchKundeById // Kundenname für die Überschrift, asynchron per fetchKundeById
// aufgelöst, falls nicht in den Route-Params enthalten. // aufgelöst, falls nicht in den Route-Params enthalten.
let kundenName: string = "" let kundenName: string = ""
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
const handleRowMarked = (node: iTreenode) => { const handleRowMarked = (node: iTreenode) => {
selectedNode = node 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 addProjektRoute = () => {
const params: any = {id: -1} const params: any = {id: -1}
// Nur bei kundengefilterter Liste den Kunden vorbelegen. // Nur bei kundengefilterter Liste den Kunden vorbelegen.
@@ -25,22 +36,27 @@ export const projektliste: m.ClosureComponent = (vnode) => {
params.kunden_id = filterKundenId params.kunden_id = filterKundenId
params.kunden_name = kundenName params.kunden_name = kundenName
} }
// Breadcrumb-Kette um eigene Position erweitern.
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/projekt", params) m.route.set("/projekt", params)
} }
const editProjekt = (projekt: iProjekt, e: Event) => { const editProjekt = (projekt: iProjekt, e: Event) => {
e.stopPropagation() 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) => { const showCallLogs = (projekt: iProjekt, e: Event) => {
e.stopPropagation() 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) => { const showKontaktadressen = (projekt: iProjekt, e: Event) => {
e.stopPropagation() 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) => { const addCallLog = (projekt: iProjekt, e: Event) => {
@@ -48,6 +64,14 @@ export const projektliste: m.ClosureComponent = (vnode) => {
const now = new Date() const now = new Date()
const datum = now.toISOString().split('T')[0] const datum = now.toISOString().split('T')[0]
const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1] 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", { m.route.set("/call_log", {
id: -1, id: -1,
kunden_name: projekt.kunden_name, kunden_name: projekt.kunden_name,
@@ -61,7 +85,8 @@ export const projektliste: m.ClosureComponent = (vnode) => {
telefon: "", telefon: "",
notiz: "", notiz: "",
wiedervorlage_datum: "", wiedervorlage_datum: "",
erledigt: false erledigt: false,
bc: serializeBc(chain)
}) })
} }
@@ -103,6 +128,8 @@ export const projektliste: m.ClosureComponent = (vnode) => {
filterKundenId = null filterKundenId = null
kundenName = "" kundenName = ""
} }
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
// Projekte asynchron vom Server laden. Die Filterung nach // Projekte asynchron vom Server laden. Die Filterung nach
// kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist. // kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist.
fetchProjekte(filterKundenId !== null ? filterKundenId : undefined).then((result) => { fetchProjekte(filterKundenId !== null ? filterKundenId : undefined).then((result) => {
@@ -123,6 +150,10 @@ export const projektliste: m.ClosureComponent = (vnode) => {
height: 'inherit', height: 'inherit',
'overflow-y': 'inherit'}}, 'overflow-y': 'inherit'}},
m("h1", kundenName ? "Projektliste für " + kundenName : "Projektliste"), 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("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable, m(Treetable,
{ {
@@ -162,11 +193,26 @@ export const Projekt: m.ClosureComponent = (vnode) => {
verantwortlicher: "", verantwortlicher: "",
startdatum: "" 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 { return {
oninit: (vnode) => { oninit: (vnode) => {
const routeParams = m.route.param() const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 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) { if (id !== -1) {
// Projektdaten frisch vom Server holen, um zwischenzeitliche // Projektdaten frisch vom Server holen, um zwischenzeitliche
// Änderungen anderer Nutzer nicht zu überschreiben. // Ä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 : "" currentProjekt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
} }
}, },
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => { view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => { const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form) const formData = new FormData(form)
@@ -193,15 +242,12 @@ export const Projekt: m.ClosureComponent = (vnode) => {
startdatum: (formData.get("startdatum") as string) || "" startdatum: (formData.get("startdatum") as string) || ""
} }
if (currentProjekt.id === -1) { 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 { } 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 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", return m("div.w3-container.w3-auto.form-view-container",
m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}}, m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}},
m("h1", currentProjekt.id === -1 m("h1", currentProjekt.id === -1
@@ -210,17 +256,22 @@ export const Projekt: m.ClosureComponent = (vnode) => {
currentProjekt.id !== -1 currentProjekt.id !== -1
? [ ? [
m("button.w3-button.w3-theme", 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("i.material-icons", "contact_page"), " Kontaktadressen"),
m("button.w3-button.w3-theme", m("button.w3-button.w3-theme",
{style: {'margin-left': '4px'}, {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") m("i.material-icons", "group"), " Nutzerberechtigungen")
] ]
: null : 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("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("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", "Projektname", m(Input, {name: "projektname", value: currentProjekt.projektname, type: "text", required: true} as iInput)),
m("label", "Zweck", m(Input, {name: "zweck", value: currentProjekt.zweck, type: "text"} as iInput)), m("label", "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 m from "mithril"
import {Form, iForm} from "./widgets/form" import {Form, iForm} from "./widgets/form"
import { Input, iInput} from "./widgets/inputs" import { Input, iInput} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable} 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[] = [ const colorPalette: string[] = [
"#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff", "#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 { function createTagTreenodes(hierarchy: iTagWithChildren[]): iTreenode[] {
id: number; // Numerische ID (für Kompatibilität mit bestehendem Code) return hierarchy.map((tag) => createTreenodeFromTag(tag))
name: string; // Anzeigename des Tags
description: string; // Beschreibung
color: string; // Farbcode (z. B. "#ff0000")
parentId: number | null; // Referenz auf den Eltern-Tag (null = Wurzelebene)
icon?: string; // Optional: Icon-Name oder URL
order?: number; // Optional: Sortierreihenfolge innerhalb der Ebene
}
/**
* Tag mit verschachtelten Kindern für die Hierarchie-Darstellung.
*/
interface iTagWithChildren extends iTag {
children: iTagWithChildren[];
}
// ============================================================================
// BEISPIELDATEN (HIERARCHISCH)
// ============================================================================
/**
* Flache Liste aller Tags (für Datenbank-ähnliche Speicherung).
* Hierarchie wird über parentId abgebildet.
*/
const tagDaten: iTag[] = [
// ========== WURZEL-TAGS (Top-Level) ==========
{
id: 1,
name: "Wichtig",
description: "Hochpriorisierte Aufgaben",
color: "#ff0000",
parentId: null,
icon: "priority_high",
order: 1,
},
{
id: 2,
name: "Kunde",
description: "Kundenbezogene Tags",
color: "#00ff00",
parentId: null,
icon: "person",
order: 2,
},
{
id: 3,
name: "Projekt",
description: "Projektbezogene Tags",
color: "#0000ff",
parentId: null,
icon: "folder",
order: 3,
},
{
id: 4,
name: "Follow-up",
description: "Nachverfolgung erforderlich",
color: "#ffff00",
parentId: null,
icon: "schedule",
order: 4,
},
{
id: 5,
name: "Archiv",
description: "Archivierte Einträge",
color: "#808080",
parentId: null,
icon: "archive",
order: 5,
},
// ========== TECHNISCHE KATEGORIEN (neu) ==========
{
id: 100,
name: "Backend",
description: "Server-seitige Technologien und Frameworks",
color: "#4285F4",
parentId: null,
icon: "server",
order: 6,
},
{
id: 101,
name: "Frontend",
description: "Client-seitige Technologien und Frameworks",
color: "#34A853",
parentId: null,
icon: "browser",
order: 7,
},
{
id: 102,
name: "DevOps",
description: "Infrastruktur, Deployment und CI/CD",
color: "#EA4335",
parentId: null,
icon: "cog",
order: 8,
},
{
id: 103,
name: "Daten",
description: "Datenbanken, Analyse und KI",
color: "#FBBC04",
parentId: null,
icon: "database",
order: 9,
},
// Backend -> Programmiersprachen
{
id: 200,
name: "Python",
description: "Allgemeine Python-Entwicklung",
color: "#3776AB",
parentId: 100,
icon: "python",
order: 1,
},
{
id: 201,
name: "Java",
description: "Java und JVM-Ökosystem",
color: "#007396",
parentId: 100,
icon: "java",
order: 2,
},
{
id: 202,
name: "Node.js",
description: "JavaScript auf dem Server",
color: "#339933",
parentId: 100,
icon: "nodejs",
order: 3,
},
// Backend -> Python -> Frameworks
{
id: 300,
name: "Flask",
description: "Mikro-Webframework für Python",
color: "#000000",
parentId: 200,
icon: "flask",
order: 1,
},
{
id: 301,
name: "Django",
description: "Full-Stack Webframework für Python",
color: "#092E20",
parentId: 200,
icon: "django",
order: 2,
},
{
id: 302,
name: "FastAPI",
description: "Moderne API-Entwicklung mit Python",
color: "#005577",
parentId: 200,
icon: "fastapi",
order: 3,
},
// Backend -> Node.js -> Frameworks
{
id: 310,
name: "Express",
description: "Minimalistisches Webframework für Node.js",
color: "#000000",
parentId: 202,
icon: "express",
order: 1,
},
{
id: 311,
name: "NestJS",
description: "Progressives Node.js-Framework für Enterprise-Anwendungen",
color: "#E0234E",
parentId: 202,
icon: "nestjs",
order: 2,
},
// Backend -> Datenbanken
{
id: 210,
name: "Datenbanken",
description: "Backend-Datenbank-Systeme",
color: "#336791",
parentId: 100,
icon: "database",
order: 4,
},
{
id: 211,
name: "PostgreSQL",
description: "Relationale Datenbank mit erweiterten Features",
color: "#336791",
parentId: 210,
icon: "postgres",
order: 1,
},
{
id: 212,
name: "MongoDB",
description: "Dokumentenorientierte NoSQL-Datenbank",
color: "#47A248",
parentId: 210,
icon: "mongodb",
order: 2,
},
// Frontend -> JavaScript
{
id: 220,
name: "JavaScript",
description: "Die Hauptsprache des Web",
color: "#F7DF1E",
parentId: 101,
icon: "js",
order: 1,
},
{
id: 221,
name: "TypeScript",
description: "Typsicheres JavaScript",
color: "#3178C6",
parentId: 101,
icon: "ts",
order: 2,
},
// Frontend -> JavaScript -> Frameworks
{
id: 320,
name: "React",
description: "Komponentenbasiertes UI-Framework",
color: "#61DAFB",
parentId: 220,
icon: "react",
order: 1,
},
{
id: 321,
name: "Vue.js",
description: "Progressives JavaScript-Framework",
color: "#4FC08D",
parentId: 220,
icon: "vue",
order: 2,
},
{
id: 322,
name: "Angular",
description: "Full-Stack Framework von Google",
color: "#DD1B16",
parentId: 220,
icon: "angular",
order: 3,
},
// Frontend -> CSS
{
id: 230,
name: "CSS",
description: "Stildefinition für Webseiten",
color: "#1572B6",
parentId: 101,
icon: "css",
order: 3,
},
{
id: 330,
name: "Tailwind CSS",
description: "Utility-first CSS-Framework",
color: "#06B6D4",
parentId: 230,
icon: "tailwind",
order: 1,
},
// DevOps
{
id: 240,
name: "Docker",
description: "Containerisierung von Anwendungen",
color: "#2496ED",
parentId: 102,
icon: "docker",
order: 1,
},
{
id: 241,
name: "Kubernetes",
description: "Orchestrierung von Containern",
color: "#326CE5",
parentId: 102,
icon: "kubernetes",
order: 2,
},
{
id: 242,
name: "CI/CD",
description: "Continuous Integration und Deployment",
color: "#FFA500",
parentId: 102,
icon: "cicd",
order: 3,
},
// Daten
{
id: 250,
name: "Künstliche Intelligenz",
description: "Machine Learning und KI-Technologien",
color: "#8B00FF",
parentId: 103,
icon: "ai",
order: 1,
},
{
id: 251,
name: "Big Data",
description: "Verarbeitung großer Datenmengen",
color: "#FF6B6B",
parentId: 103,
icon: "bigdata",
order: 2,
},
];
// ============================================================================
// HILFSFUNKTIONEN FÜR HIERARCHISCHE TAGS
// ============================================================================
/**
* Gibt alle Wurzel-Tags (ohne Eltern) zurück.
*/
function getRootTags(): iTag[] {
return tagDaten.filter((tag) => tag.parentId === null);
}
/**
* Baut aus der flachen Liste eine hierarchische Struktur mit Kindern.
* @param parentId - ID des Eltern-Tags (null für Wurzelebene)
* @returns Array von Tags mit verschachtelten Kindern
*/
function buildHierarchy(parentId: number | null = null): iTagWithChildren[] {
const children = tagDaten.filter((tag) => tag.parentId === parentId);
// Sortieren nach order-Feld (falls vorhanden)
children.sort((a, b) => (a.order || 0) - (b.order || 0));
return children.map((tag) => ({
...tag,
children: buildHierarchy(tag.id),
}));
}
/**
* Gibt die vollständige Hierarchie als verschachteltes Objekt zurück.
*/
function getFullHierarchy(): iTagWithChildren[] {
return buildHierarchy(null);
}
/**
* Sucht einen Tag nach ID.
*/
function findTagById(id: number): iTag | undefined {
return tagDaten.find((tag) => tag.id === id);
}
/**
* Gibt den vollständigen Pfad eines Tags als String zurück (z. B. "/Backend/Python/Flask").
*/
function getTagPath(tagId: number): string {
const tag = findTagById(tagId);
if (!tag) return "";
if (tag.parentId === null) return `/${tag.name}`;
const parentPath = getTagPath(tag.parentId);
return `${parentPath}/${tag.name}`;
}
/**
* Gibt alle Nachfahren (Kinder, Enkel, ...) eines Tags zurück (flache Liste).
*/
function getDescendants(tagId: number): iTag[] {
const descendants: iTag[] = [];
const children = tagDaten.filter((tag) => tag.parentId === tagId);
for (const child of children) {
descendants.push(child);
descendants.push(...getDescendants(child.id));
}
return descendants;
}
/**
* Gibt alle Vorfahren (Eltern, Großeltern, ...) eines Tags zurück.
*/
function getAncestors(tagId: number): iTag[] {
const ancestors: iTag[] = [];
const tag = findTagById(tagId);
if (!tag || tag.parentId === null) return ancestors;
const parent = findTagById(tag.parentId);
if (parent) {
ancestors.push(parent);
ancestors.push(...getAncestors(parent.id));
}
return ancestors;
}
/**
* Sucht Tags nach Namen (case-insensitive, partiell).
*/
function searchTagsByName(query: string): iTag[] {
const lowerQuery = query.toLowerCase();
return tagDaten.filter((tag) => tag.name.toLowerCase().includes(lowerQuery));
}
/**
* Fügt einen neuen Tag hinzu (mit automatischer ID-Vergabe).
*/
function addTag(name: string, parentId: number | null = null, description: string = "", color: string = "#808080", icon?: string, order?: number): iTag {
const newId = tagDaten.length > 0 ? Math.max(...tagDaten.map(t => t.id)) + 1 : 1;
const tag: iTag = {
id: newId,
name,
description,
color,
parentId,
icon,
order,
};
tagDaten.push(tag);
return tag;
}
/**
* Ersetzt einen bestehenden Tag (z. B. nach Bearbeitung).
*/
function updateTag(updatedTag: iTag): void {
const index = tagDaten.findIndex(t => t.id === updatedTag.id);
if (index !== -1) {
tagDaten[index] = updatedTag;
}
}
// ============================================================================
// TREENODE-ERZEUGUNG (FÜR DIE TREETABLE)
// ============================================================================
/**
* Erzeugt Treenodes aus der hierarchischen Tag-Struktur.
* Berücksichtigt die Verschachtelung der Tags.
*/
function createTagTreenodes(): iTreenode[] {
const hierarchy = getFullHierarchy();
return hierarchy.map((tag) => createTreenodeFromTag(tag));
} }
/** /**
* Rekursive Funktion zum Erstellen eines Treenode aus einem Tag mit Kindern. * Rekursive Funktion zum Erstellen eines Treenode aus einem Tag mit Kindern.
*/ */
function createTreenodeFromTag(tag: iTagWithChildren): iTreenode { function createTreenodeFromTag(tag: iTagWithChildren): iTreenode {
const children = tag.children.map(child => createTreenodeFromTag(child)); const children = tag.children.map(child => createTreenodeFromTag(child))
return new Treenode(tag, true, children); 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 // KOMPONENTEN
// ============================================================================ // ============================================================================
export const Tag: m.ClosureComponent = (vnode) => { export const Tag: m.ClosureComponent = () => {
let currentTag: iTag = { let currentTag: iTag = {
id: -1, id: -1,
name: "", name: "",
description: "", description: "",
color: "", 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) => { const setColor = (color: string) => {
currentTag.color = color; currentTag.color = color
}; guard.markDirty()
}
// Liste aller Tags für die Eltern-Auswahl
const allTags = tagDaten.filter(t => t.id !== currentTag.id); // Ausschließen des aktuellen Tags
return { return {
oninit: (vnode) => { oninit: (vnode) => {
const routeParams = m.route.param(); const routeParams = m.route.param()
if (routeParams) { 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 = { currentTag = {
id: routeParams.id ? parseInt(routeParams.id) : -1, id: -1,
name: routeParams.name || "", name: routeParams.name || "",
description: routeParams.description || "", description: routeParams.description || "",
color: routeParams.color || "", color: routeParams.color || "",
parentId: routeParams.parentId ? parseInt(routeParams.parentId) : null, parent_id: routeParams.parent_id ? parseInt(routeParams.parent_id) : null,
icon: routeParams.icon || undefined, is_parent: routeParams.is_parent ? routeParams.is_parent === "true" : false,
order: routeParams.order ? parseInt(routeParams.order) : undefined, }
};
} }
// 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) => { 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", return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentTag.id === -1 ? "Neuen Tag erstellen" : "Tag bearbeiten"), m("h1", currentTag.id === -1 ? "Neuen Tag erstellen" : "Tag bearbeiten"),
m("div", {style: {flex: '1', 'min-height': '0'}}, m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, {showButtons: true, onsubmit: (null), oncancel: (null)} as iForm, [ m(Form, guard.formAttrs(submit) as iForm, [
m("label", "Tag-Name", m(Input, {name: "name", value: currentTag.name, type: "text"} as iInput)), 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"} 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 // Eltern-Tag-Auswahl
m("label", "Übergeordneter Tag (optional)", m("label", "Übergeordneter Tag (optional)"),
m("select.w3-select", {name: "parentId", value: currentTag.parentId || ""}, m("select.w3-select", {
m("option", {value: ""}, "-- Kein übergeordneter Tag --"), name: "parent_id",
allTags.map(tag => value: currentTag.parent_id || "",
m("option", {value: tag.id, selected: currentTag.parentId === tag.id}, tag.name) 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'}}, m("div", {style: {display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px'}},
colorPalette.map(color => colorPalette.map(color =>
m("button", { m("button", {
type: "button",
style: { style: {
width: '32px', width: '32px',
height: '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) => { export const TagList: m.ClosureComponent = () => {
let treenodes: iTreenode[] = createTagTreenodes(); let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null; let selectedNode: iTreenode | null = null
// Aktualisiere treenodes, wenn sich die Daten ändern // Laedt die Hierarchie vom Server und erzeugt Treenodes
const updateTreenodes = () => { const loadTreenodes = () => {
treenodes = createTagTreenodes(); fetchTagHierarchy().then((hierarchy) => {
selectedNode = null; treenodes = createTagTreenodes(hierarchy)
}; selectedNode = null
}).catch((e) => showServerError(e))
}
const handleRowMarked = (node: iTreenode) => { const handleRowMarked = (node: iTreenode) => {
selectedNode = node; selectedNode = node
}; }
const editTag = (tag: iTag, e: Event) => { const editTag = (tag: iTag, e: Event) => {
e.stopPropagation(); e.stopPropagation()
m.route.set("/tag", { m.route.set("/tag", {id: tag.id})
id: tag.id, }
name: tag.name,
description: tag.description, const deleteTag = (tag: iTag, node: iTreenode, e: Event) => {
color: tag.color, e.stopPropagation()
parentId: tag.parentId, const childCount = countDescendants(node)
icon: tag.icon, if (childCount === 0) {
order: tag.order // 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) => { 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 [ return [
m("span", m("span",
m("i.material-icons", {
style: {
verticalAlign: 'middle',
marginRight: '6px',
fontSize: '18px'
},
title: tagTitle
}, tagIcon),
m("span", { m("span", {
style: { style: {
backgroundColor: tag.color, backgroundColor: tag.color,
@@ -610,40 +298,50 @@ export const TagList: m.ClosureComponent = (vnode) => {
fontWeight: 'bold', fontWeight: 'bold',
display: 'inline-block', display: 'inline-block',
marginRight: '8px' marginRight: '8px'
} },
title: tagTitle
}, tag.name) }, tag.name)
), ),
m("span", tag.description), m("span", tag.description),
m("div", m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap', 'justify-content': 'flex-end'}},
m("button.w3-button.w3-small", {style: {padding: '0 2px'}, onclick: (e: Event) => editTag(tag, e)}, 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("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 => { const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let tag: iTag = node.nodevalue; let tag: iTag = node.nodevalue
let searchLower = searchvalue.toLowerCase(); let searchLower = searchvalue.toLowerCase()
return tag.name.toLowerCase().includes(searchLower) ||
tag.description.toLowerCase().includes(searchLower) || // Prueft, ob der Tag selbst auf den Suchbegriff passt (nur Name und Beschreibung)
tag.color.toLowerCase().includes(searchLower) || const selfMatch = tag.name.toLowerCase().includes(searchLower) ||
(tag.icon && tag.icon.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 { return {
oninit: (vnode) => {
loadTreenodes()
},
view: (vnode) => { view: (vnode) => {
return m("div.w3-container", { return m("div.w3-container.w3-auto", {
style: { style: {
'max-width': '80%',
'margin-left': 'auto',
'margin-right': 'auto',
display: 'flex', display: 'flex',
'flex-direction': 'column', 'flex-direction': 'column',
height: 'inherit', height: 'inherit',
'overflow-y': 'inherit'}}, 'overflow-y': 'inherit'}},
m("h1", "Tags"), 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("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable, m(Treetable,
{ {
@@ -652,27 +350,13 @@ export const TagList: m.ClosureComponent = (vnode) => {
filter: filterFunction, filter: filterFunction,
onRowMarked: handleRowMarked, onRowMarked: handleRowMarked,
isSelected: (node: iTreenode) => node === selectedNode 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, onRowMarked: onRowMarked,
isSelected: isSelected, isSelected: isSelected,
isEven: ((z % 2) == 0), isEven: ((z % 2) == 0),
hasSiblingsWithChildren: topHasSiblingsWithChildren} as iTreerow) hasSiblingsWithChildren: topHasSiblingsWithChildren,
filter: vnode.attrs.filter,
searchvalue: searchvalue} as iTreerow)
} }
else { else {
return null return null
@@ -114,6 +116,12 @@ interface iTreerow {
* den Platzhalter-Pfeil nur dann zu rendern, wenn es Nodes mit * den Platzhalter-Pfeil nur dann zu rendern, wenn es Nodes mit
* Pfeil gibt, an denen der Text ausgerichtet werden kann. */ * Pfeil gibt, an denen der Text ausgerichtet werden kann. */
hasSiblingsWithChildren?: boolean 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) => { 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 // 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 // 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.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, { return m(Treerow, {
treenode: tn, treenode: tn,
level: vnode.attrs.level + 1, level: vnode.attrs.level + 1,
@@ -186,7 +196,9 @@ let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
onRowMarked: vnode.attrs.onRowMarked, onRowMarked: vnode.attrs.onRowMarked,
isSelected: vnode.attrs.isSelected, isSelected: vnode.attrs.isSelected,
isEven: ((z % 2) == 0), isEven: ((z % 2) == 0),
hasSiblingsWithChildren: childHasSiblingsWithChildren hasSiblingsWithChildren: childHasSiblingsWithChildren,
filter: vnode.attrs.filter,
searchvalue: vnode.attrs.searchvalue
} as iTreerow) } as iTreerow)
}) })
: null : 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 stickyButtons?: boolean
/** Die Funktion gibt das HTMLFormElement zurück, welches in der Form-Componente angelegt wird */ /** Die Funktion gibt das HTMLFormElement zurück, welches in der Form-Componente angelegt wird */
onformcreate?: (form:HTMLFormElement) => void onformcreate?: (form:HTMLFormElement) => void
/** 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) => { export const Form:m.ClosureComponent<iForm> = (vnode) => {
@@ -29,6 +36,9 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
} }
function onchanged (e:Event) { function onchanged (e:Event) {
isDirty = true isDirty = true
if (vnode.attrs.onDirtyChange != undefined) {
vnode.attrs.onDirtyChange(true)
}
if (vnode.attrs.onchanged != undefined) { if (vnode.attrs.onchanged != undefined) {
vnode.attrs.onchanged(e) vnode.attrs.onchanged(e)
} }
@@ -37,6 +47,9 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
e.preventDefault() e.preventDefault()
if (vnode.attrs.onsubmit(form) == true) { if (vnode.attrs.onsubmit(form) == true) {
isDirty = false isDirty = false
if (vnode.attrs.onDirtyChange != undefined) {
vnode.attrs.onDirtyChange(false)
}
} }
} }
function registerFormElement (f:HTMLFormElement) { function registerFormElement (f:HTMLFormElement) {
@@ -46,22 +59,24 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
} }
function cancel(e:Event) { function cancel(e:Event) {
e.preventDefault() e.preventDefault()
if (canCloseForm() == true) { // Der Dirty-Check beim Abbrechen erfolgt jetzt durch den
isDirty = false // Navigations-Guard der Maske (dirtyguard.ts), da oncancel die
if (vnode.attrs.oncancel != undefined) { // Navigation auslöst. Die Form selbst fragt hier nicht mehr nach,
vnode.attrs.oncancel(e) // um eine doppelte Bestätigung zu vermeiden. isDirty wird nicht
} // zurückgesetzt, damit der Guard den aktuellen State sieht.
else { if (vnode.attrs.oncancel != undefined) {
history.back() vnode.attrs.oncancel(e)
}
} }
else { else {
e['redraw'] = false history.back()
} }
} }
function reset(e:Event) { function reset(e:Event) {
if (canCloseForm("reset")) { if (canCloseForm("reset")) {
isDirty = false isDirty = false
if (vnode.attrs.onDirtyChange != undefined) {
vnode.attrs.onDirtyChange(false)
}
// Hier passiert nichts. // Hier passiert nichts.
// Da mithril nach jedem User-Event (wie z.B. einem 'Click') ein redraw() ausführt, wird hier die Form // 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 // 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++) { for (let index = 0; index < elements.length; index++) {
const element = elements[index]; const element = elements[index];
element.addEventListener("change", onchanged) 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) registerFormElement(form)
} }
@@ -131,6 +150,10 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
for (let index = 0; index < elements.length; index++) { for (let index = 0; index < elements.length; index++) {
const element = elements[index]; const element = elements[index];
element.addEventListener("change", onchanged) 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) registerFormElement(form)
} }
+6 -1
View File
@@ -23,11 +23,13 @@ export interface iInput {
readonly?: boolean readonly?: boolean
required?:boolean required?:boolean
onchanged?: Function onchanged?: Function
oninput?: Function
} }
/** /**
* Diese Komponente stellt ein Html-Input Element dar. Der darzustellende Wert wird einfach mit dem Attribut * 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 * '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> = { export const Input:m.Component<iInput> = {
onupdate: (vnode) => { onupdate: (vnode) => {
@@ -71,6 +73,9 @@ export const Input:m.Component<iInput> = {
if (vnode.attrs.onchanged != undefined) { if (vnode.attrs.onchanged != undefined) {
attributes['onchange'] = vnode.attrs.onchanged attributes['onchange'] = vnode.attrs.onchanged
} }
if (vnode.attrs.oninput != undefined) {
attributes['oninput'] = vnode.attrs.oninput
}
return m("input.w3-input.w3-border", attributes) 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 { Kontakt, kontaktliste } from "./Components/kontakt"
import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse" import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse"
import { Tag, TagList } from "./Components/tags" import { Tag, TagList } from "./Components/tags"
import { guardedNavigate } from "./Components/widgets/dirtyguard"
function healthcheck () { function healthcheck () {
@@ -118,21 +119,29 @@ const knopf: m.Component = {
} }
const menu: m.ClosureComponent = () => { const menu: m.ClosureComponent = () => {
let showProjektBerechtigungen = false 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 { return {
oninit: (vnode) => { oninit: (vnode) => {
showProjektBerechtigungen = false showProjektBerechtigungen = false
}, },
view: (vnode) => { view: (vnode) => {
return m(menubar, [ return m(menubar, [
m("a.w3-button.w3-bar-item", {href: "#!/kundenliste"}, "Kunden"), menuItem("/kundenliste", "Kunden"),
m("a.w3-button.w3-bar-item", {href: "#!/projektliste"}, "Projekte"), menuItem("/projektliste", "Projekte"),
m("a.w3-button.w3-bar-item", {href: "#!/calllogliste"}, "Gesprächsnotizen"), menuItem("/calllogliste", "Gesprächsnotizen"),
m("div.w3-dropdown-hover", m("div.w3-dropdown-hover",
m("button.w3-button", "Optionen"), m("button.w3-button", "Optionen"),
m("div.w3-dropdown-content.w3-bar-block.w3-card-4", m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
m("a.w3-button.w3-bar-item", {href: "#!/taglist"}, "Tags"), menuItem("/taglist", "Tags"),
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenexport"), menuItem("/default", "Datenexport"),
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenimport"), menuItem("/default", "Datenimport"),
// Projekt-Berechtigungen nur für SysAdmin/AppAdmin sichtbar. // Projekt-Berechtigungen nur für SysAdmin/AppAdmin sichtbar.
// Als Toggle-Button realisiert, da verschachtelte w3-dropdown-hover // Als Toggle-Button realisiert, da verschachtelte w3-dropdown-hover
// innerhalb eines offenen Dropdowns nicht zuverlässig funktionieren. // innerhalb eines offenen Dropdowns nicht zuverlässig funktionieren.
@@ -147,10 +156,10 @@ const menu: m.ClosureComponent = () => {
showProjektBerechtigungen showProjektBerechtigungen
? m("div.w3-bar-block", ? m("div.w3-bar-block",
m("a.w3-button.w3-bar-item", 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"), "Projekt-Zugriffe"),
m("a.w3-button.w3-bar-item", 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") "Nutzer-Zugriffe")
) )
: null : null
@@ -162,9 +171,9 @@ const menu: m.ClosureComponent = () => {
m("div.w3-dropdown-hover.w3-right", m("div.w3-dropdown-hover.w3-right",
m("button.w3-button", "System"), m("button.w3-button", "System"),
m("div.w3-dropdown-content.w3-bar-block.w3-card-4", m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
m("a.w3-button.w3-bar-item", {href: "#!/login"}, "Login"), menuItem("/login", "Login"),
m("a.w3-button.w3-bar-item", {href: "#!/changepassword"}, "Kennwort ändern"), menuItem("/changepassword", "Kennwort ändern"),
m("a.w3-button.w3-bar-item", {href: "#!/usermanagement"}, "Verwalten"), menuItem("/usermanagement", "Verwalten"),
) )
) )
]) ])
+7
View File
@@ -1,10 +1,13 @@
import m from "mithril" import m from "mithril"
import {iTag} from "./tag"
/** /**
* Datenmodell für eine Gesprächsnotiz (Call-Log). * Datenmodell für eine Gesprächsnotiz (Call-Log).
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case). * Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* projekt_name und kunden_name sind keine ORM-Spalten, sondern werden * projekt_name und kunden_name sind keine ORM-Spalten, sondern werden
* serverseitig aus der Projekt-Relationship abgeleitet und im JSON ergänzt. * 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 { export interface iCallLog {
id: number id: number
@@ -20,6 +23,10 @@ export interface iCallLog {
notiz: string notiz: string
wiedervorlage_datum: string wiedervorlage_datum: string
erledigt: boolean 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)
}