Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d8201f46a | ||
|
|
d7e5c014a8 | ||
|
|
a67921ec74 | ||
|
|
fd30049a32 | ||
|
|
f1e5938ae1 | ||
|
|
d30adf1579 | ||
|
|
5abcb2d96c | ||
|
|
675d7775c1 | ||
|
|
b44ea806ef | ||
|
|
3361ef16dd |
No files matched your search
+1
-1
@@ -5,7 +5,7 @@ node_modules/
|
||||
tsout/
|
||||
kbp_crm*.js
|
||||
kbcrm/App_Server/data
|
||||
#App_Server/data/
|
||||
App_Server/data/
|
||||
oidc-test/
|
||||
.vibe/
|
||||
.vibe-global/
|
||||
@@ -259,12 +259,19 @@ class CallLog(dbapp.Model):
|
||||
erledigt = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
projekt = relationship('Projekt')
|
||||
# n:m-Beziehung zu Tags über die Verknüpfungstabelle call_logs_tags.
|
||||
# cascade='all, delete-orphan' stellt sicher, dass die Zuordnungen
|
||||
# automatisch gelöscht werden, wenn die Gesprächsnotiz gelöscht wird.
|
||||
tags_rel = relationship(
|
||||
'CallLogTag', cascade='all, delete-orphan', back_populates='call_log'
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
Liefert die Gesprächsnotiz als Dictionary. Die Tabellenspalten werden
|
||||
über columns2dict serialisiert; projekt_name und kunden_name werden
|
||||
zusätzlich aus der Projekt-Relationship eingefügt.
|
||||
zusätzlich aus der Projekt-Relationship eingefügt. Die zugeordneten
|
||||
Tags werden als Liste von Tag-Dictionaries unter 'tags' ergänzt.
|
||||
"""
|
||||
result = columns2dict(self)
|
||||
if self.projekt:
|
||||
@@ -273,7 +280,65 @@ class CallLog(dbapp.Model):
|
||||
else:
|
||||
result['projekt_name'] = None
|
||||
result['kunden_name'] = None
|
||||
# Zugeordnete Tags serialisieren (nur is_parent=False sind zuordenbar).
|
||||
result['tags'] = [clt.tag.to_dict() for clt in self.tags_rel]
|
||||
return result
|
||||
|
||||
|
||||
class CallLogTag(dbapp.Model):
|
||||
"""
|
||||
Verknüpfungstabelle für die n:m-Beziehung zwischen Gesprächsnotizen
|
||||
(CallLog) und Tags. Jede Zeile verknüpft eine Gesprächsnotiz mit einem
|
||||
Tag. Der UniqueConstraint stellt sicher, dass jeder Tag nur einmal pro
|
||||
Gesprächsnotiz zugeordnet werden kann.
|
||||
"""
|
||||
__tablename__ = 'call_logs_tags'
|
||||
__bind_key__ = BINDKEY
|
||||
__table_args__ = (
|
||||
UniqueConstraint('call_log_id', 'tag_id', name='uq_calllog_tag'),
|
||||
Index('ix_calllogtag_call_log_id', 'call_log_id'),
|
||||
Index('ix_calllogtag_tag_id', 'tag_id'),
|
||||
)
|
||||
id = Column(Integer, primary_key=True)
|
||||
call_log_id = Column(Integer, ForeignKey('call_logs.id'), nullable=False)
|
||||
tag_id = Column(Integer, ForeignKey('tags.id'), nullable=False)
|
||||
|
||||
call_log = relationship('CallLog', back_populates='tags_rel')
|
||||
tag = relationship('Tag')
|
||||
|
||||
|
||||
class Tag(dbapp.Model):
|
||||
"""
|
||||
Hierarchischer Tag (Taxonomie). Tags koennen beliebig verschachtelt
|
||||
werden ueber die Selbst-Referenz parent_id -> tags.id. Ein Tag mit
|
||||
is_parent=True ist ein reiner Gruppierungsknoten (nicht zuordenbar);
|
||||
is_parent=False kennzeichnet einen echten, zuordenbaren Tag.
|
||||
"""
|
||||
__tablename__ = 'tags'
|
||||
__bind_key__ = BINDKEY
|
||||
__table_args__ = (
|
||||
Index('ix_tags_parent_id', 'parent_id'),
|
||||
)
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(String(2000), nullable=True)
|
||||
color = Column(String(20), nullable=True)
|
||||
parent_id = Column(Integer, ForeignKey('tags.id'), nullable=True)
|
||||
is_parent = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Selbst-Referenzierende Relationship: parent verweist auf den
|
||||
# uebergeordneten Tag, children auf alle direkten Kinder. Die
|
||||
# Sortierung der Kinder erfolgt alphabetisch nach name.
|
||||
parent = relationship(
|
||||
'Tag', remote_side='Tag.id', back_populates='children'
|
||||
)
|
||||
children = relationship(
|
||||
'Tag', back_populates='parent', order_by='Tag.name'
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""Liefert die Tag-Daten als Dictionary (fuer JSON-Serialisierung)."""
|
||||
return columns2dict(self)
|
||||
|
||||
|
||||
print("########## crm/model importiert")
|
||||
@@ -9,14 +9,68 @@ dort über den Decorator authorisation_required.
|
||||
:date: 2026-09-04
|
||||
:version: 1.0
|
||||
"""
|
||||
from datetime import date
|
||||
|
||||
from db import dbapp
|
||||
from crm.model import CallLog, Projekt, ProjektUser
|
||||
from crm.model import CallLog, CallLogTag, Projekt, ProjektUser, Tag
|
||||
|
||||
from auth.model.errors import ApplicationError, HtmlStatusCode
|
||||
|
||||
session = dbapp.session
|
||||
|
||||
|
||||
def _parse_date(value: str | None) -> date | None:
|
||||
"""
|
||||
Wandelt einen ISO-Datum-String (yyyy-MM-dd) in ein date-Objekt um.
|
||||
Leere Strings und None werden zu None konvertiert (Spalte ist nullable).
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def _set_call_log_tags(call_log: CallLog, tag_ids: list) -> None:
|
||||
"""
|
||||
Ersetzt die Tag-Zuordnungen einer Gesprächsnotiz durch die übergebene
|
||||
Liste von Tag-IDs. Bestehende Zuordnungen werden gelöscht, danach werden
|
||||
die neuen Zuordnungen angelegt.
|
||||
|
||||
Nur Tags, die existieren und nicht reine Gruppierungsknoten
|
||||
(is_parent=True) sind, werden zugeordnet. Duplikate in der Liste
|
||||
werden durch ein Set eliminiert.
|
||||
"""
|
||||
# Bestehende Zuordnungen entfernen.
|
||||
for clt in list(call_log.tags_rel):
|
||||
session.delete(clt)
|
||||
call_log.tags_rel = []
|
||||
# Neue Zuordnungen anlegen.
|
||||
seen = set()
|
||||
for tag_id in tag_ids or []:
|
||||
try:
|
||||
tag_id = int(tag_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if tag_id in seen:
|
||||
continue
|
||||
seen.add(tag_id)
|
||||
tag = Tag.query.get(tag_id)
|
||||
if tag is None or tag.is_parent:
|
||||
# Nicht zuordenbar — überspringen.
|
||||
continue
|
||||
clt = CallLogTag()
|
||||
clt.call_log = call_log
|
||||
clt.tag = tag
|
||||
session.add(clt)
|
||||
|
||||
|
||||
|
||||
def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
|
||||
"""
|
||||
Liefert Gesprächsnotizen, sortiert nach Datum absteigend.
|
||||
@@ -94,15 +148,16 @@ def create_call_log(data: dict) -> CallLog:
|
||||
)
|
||||
call_log = CallLog()
|
||||
call_log.projekt_id = int(projekt_id)
|
||||
call_log.datum = (data.get("datum") or "").strip() or None
|
||||
call_log.datum = _parse_date(data.get("datum"))
|
||||
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
|
||||
call_log.dauer = (data.get("dauer") or "").strip() or None
|
||||
call_log.betreff = (data.get("betreff") or "").strip() or None
|
||||
call_log.name_ansprechpartner = (data.get("name_ansprechpartner") or "").strip() or None
|
||||
call_log.telefon = (data.get("telefon") or "").strip() or None
|
||||
call_log.notiz = (data.get("notiz") or "").strip() or None
|
||||
call_log.wiedervorlage_datum = (data.get("wiedervorlage_datum") or "").strip() or None
|
||||
call_log.wiedervorlage_datum = _parse_date(data.get("wiedervorlage_datum"))
|
||||
call_log.erledigt = bool(data.get("erledigt", False))
|
||||
_set_call_log_tags(call_log, data.get("tag_ids"))
|
||||
session.add(call_log)
|
||||
session.commit()
|
||||
return call_log
|
||||
@@ -127,15 +182,16 @@ def update_call_log(data: dict) -> CallLog:
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
call_log.projekt_id = int(projekt_id)
|
||||
call_log.datum = (data.get("datum") or "").strip() or None
|
||||
call_log.datum = _parse_date(data.get("datum"))
|
||||
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
|
||||
call_log.dauer = (data.get("dauer") or "").strip() or None
|
||||
call_log.betreff = (data.get("betreff") or "").strip() or None
|
||||
call_log.name_ansprechpartner = (data.get("name_ansprechpartner") or "").strip() or None
|
||||
call_log.telefon = (data.get("telefon") or "").strip() or None
|
||||
call_log.notiz = (data.get("notiz") or "").strip() or None
|
||||
call_log.wiedervorlage_datum = (data.get("wiedervorlage_datum") or "").strip() or None
|
||||
call_log.wiedervorlage_datum = _parse_date(data.get("wiedervorlage_datum"))
|
||||
call_log.erledigt = bool(data.get("erledigt", False))
|
||||
_set_call_log_tags(call_log, data.get("tag_ids"))
|
||||
session.commit()
|
||||
return call_log
|
||||
|
||||
|
||||
@@ -221,6 +221,28 @@ def _db_V01_09():
|
||||
session.commit()
|
||||
|
||||
|
||||
def _db_V01_10():
|
||||
"""
|
||||
Legt die Tabelle 'tags' an. Tags sind eine hierarchische Taxonomie
|
||||
mit Selbst-Referenz (parent_id -> tags.id). create_all erzeugt nur
|
||||
fehlende Tabellen, bestehende bleiben unangetastet.
|
||||
"""
|
||||
dbapp.create_all(bind_key=BINDKEY)
|
||||
_updatesysteminfo('01.10')
|
||||
session.commit()
|
||||
|
||||
|
||||
def _db_V01_11():
|
||||
"""
|
||||
Legt die Verknüpfungstabelle 'call_logs_tags' für die n:m-Beziehung
|
||||
zwischen Gesprächsnotizen und Tags an. create_all erzeugt nur fehlende
|
||||
Tabellen, bestehende bleiben unangetastet (idempotent).
|
||||
"""
|
||||
dbapp.create_all(bind_key=BINDKEY)
|
||||
_updatesysteminfo('01.11')
|
||||
session.commit()
|
||||
|
||||
|
||||
def checkupdates():
|
||||
"""
|
||||
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
|
||||
|
||||
@@ -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
|
||||
@@ -27,6 +27,7 @@ from crm.model import projekt as projekte
|
||||
from crm.model import projekt_user as projekt_users
|
||||
from crm.model import kontaktadresse as kontaktadressen
|
||||
from crm.model import call_log as call_logs
|
||||
from crm.model import tag as tags
|
||||
|
||||
print("crm-routes")
|
||||
|
||||
@@ -380,4 +381,64 @@ def calllog_delete():
|
||||
return jsonify({}), 200
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tag (hierarchische Taxonomie) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tags sind nicht projektbezogen — die projektbezogene Sichtbarkeits-
|
||||
# Filterung entfaellt. Lesen: alle Mitarbeiter. Schreiben: nur AppAdmin/SysAdmin.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@app.get("/xhr/tag/list")
|
||||
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
|
||||
def tag_list():
|
||||
"""Liefert alle Tags als flache Liste. Optional nach parent_id gefiltert."""
|
||||
parent_id = request.args.get("parent_id", type=int)
|
||||
result = [t.to_dict() for t in tags.get_tags(parent_id)]
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@app.get("/xhr/tag/hierarchy")
|
||||
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
|
||||
def tag_hierarchy():
|
||||
"""Liefert die vollstaendige Tag-Hierarchie als verschachtelte Struktur."""
|
||||
result = tags.get_tag_hierarchy()
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@app.get("/xhr/tag/get")
|
||||
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
|
||||
def tag_get():
|
||||
"""Liefert einen einzelnen Tag anhand seiner ID (Query-Param id)."""
|
||||
tag = tags.get_tag(int(request.args.get("id")))
|
||||
return jsonify(tag.to_dict()), 200
|
||||
|
||||
|
||||
@app.post("/xhr/tag/create")
|
||||
# Leeres allowed_roles-Array: Nur AppAdmin/SysAdmin (vom Decorator
|
||||
# implizit ergaenzt). Tags sind eine globale Taxonomie — die Anlage
|
||||
# und Aenderung ist ausschliesslich den Administratoren vorbehalten.
|
||||
@authorisation_required([])
|
||||
def tag_create():
|
||||
"""Legt einen neuen Tag an. ID wird serverseitig vergeben."""
|
||||
tag = tags.create_tag(request.json)
|
||||
return jsonify(tag.to_dict()), 200
|
||||
|
||||
|
||||
@app.put("/xhr/tag/update")
|
||||
@authorisation_required([])
|
||||
def tag_update():
|
||||
"""Aktualisiert einen bestehenden Tag."""
|
||||
tag = tags.update_tag(request.json)
|
||||
return jsonify(tag.to_dict()), 200
|
||||
|
||||
|
||||
@app.delete("/xhr/tag/delete")
|
||||
@authorisation_required([])
|
||||
def tag_delete():
|
||||
"""Loescht einen Tag inkl. aller Nachfahren (kaskadierend) anhand seiner ID."""
|
||||
tags.delete_tag(int(request.args.get("id")))
|
||||
return jsonify({}), 200
|
||||
|
||||
|
||||
print("########## crm.routes importiert")
|
||||
+123
-11
@@ -1,13 +1,16 @@
|
||||
import m from "mithril"
|
||||
import {Form, iForm} from "./widgets/form"
|
||||
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
|
||||
import {Select, iSelect, iSelectOption} from "./widgets/select"
|
||||
import {Select, iSelect, MultiSelect, iMultiSelect, iSelectOption} from "./widgets/select"
|
||||
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
||||
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
|
||||
import {iCallLog, fetchCallLogs, fetchCallLogById, createCallLogREST, updateCallLogREST} from "../models/call_log"
|
||||
import {iKunde, fetchKunden} from "../models/kunde"
|
||||
import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
|
||||
import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse"
|
||||
import {iTag, fetchTags} from "../models/tag"
|
||||
import {showServerError} from "./auth/authmessages"
|
||||
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||
|
||||
// Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden.
|
||||
const DAUER_UPDATE_INTERVALL = 1000
|
||||
@@ -21,11 +24,20 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
|
||||
// da es nicht in den Route-Params enthalten ist.
|
||||
let projektName: string = ""
|
||||
let kundenName: string = ""
|
||||
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||
let breadcrumbs: iBreadcrumb[] = []
|
||||
|
||||
const handleRowMarked = (node: iTreenode) => {
|
||||
selectedNode = node
|
||||
}
|
||||
|
||||
// Eigener Listen-Crumb für die Breadcrumb-Kette.
|
||||
const ownCrumb = (): iBreadcrumb => ({
|
||||
route: "/calllogliste",
|
||||
params: filterProjektId !== null ? {projekt_id: filterProjektId} : {},
|
||||
label: filterProjektId !== null ? "Notizen: " + projektName : "Gesprächsnotizen"
|
||||
})
|
||||
|
||||
const addCallLogRoute = () => {
|
||||
const now = new Date()
|
||||
const datum = now.toISOString().split('T')[0]
|
||||
@@ -40,30 +52,58 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
|
||||
telefon: "",
|
||||
notiz: "",
|
||||
wiedervorlage_datum: "",
|
||||
erledigt: false
|
||||
erledigt: false,
|
||||
from_liste: "1"
|
||||
}
|
||||
if (filterProjektId !== null) {
|
||||
params.projekt_id = filterProjektId
|
||||
params.projekt_name = projektName
|
||||
params.kunden_name = kundenName
|
||||
params.return_projekt_id = filterProjektId
|
||||
params.return_projekt_name = projektName
|
||||
}
|
||||
// Breadcrumb-Kette um eigene Position erweitern.
|
||||
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
|
||||
m.route.set("/call_log", params)
|
||||
}
|
||||
|
||||
const editCallLog = (log: iCallLog, e: Event) => {
|
||||
e.stopPropagation()
|
||||
m.route.set("/call_log", {id: log.id})
|
||||
const params: any = {id: log.id, from_liste: "1"}
|
||||
if (filterProjektId !== null) {
|
||||
params.return_projekt_id = filterProjektId
|
||||
params.return_projekt_name = projektName
|
||||
}
|
||||
// Breadcrumb-Kette um eigene Position erweitern.
|
||||
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
|
||||
m.route.set("/call_log", params)
|
||||
}
|
||||
|
||||
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
|
||||
let log: iCallLog = node.nodevalue
|
||||
|
||||
// Zugewiesene Tags als farbige Badges hinter dem Betreff darstellen.
|
||||
const tagBadges = (log.tags || []).map((t: iTag) =>
|
||||
m("span", {
|
||||
style: {
|
||||
backgroundColor: t.color || '#999',
|
||||
color: 'white',
|
||||
padding: '1px 5px',
|
||||
'border-radius': '3px',
|
||||
'font-size': '11px',
|
||||
'margin-left': '4px',
|
||||
display: 'inline-block'
|
||||
},
|
||||
title: t.name
|
||||
}, t.name)
|
||||
)
|
||||
|
||||
return [
|
||||
m("div", m("strong", log.kunden_name + ": " + log.projekt_name)),
|
||||
m("span", log.datum),
|
||||
m("span", log.uhrzeit_beginn),
|
||||
m("span", log.dauer + " min"),
|
||||
m("span", log.betreff),
|
||||
m("span", log.betreff, ...tagBadges),
|
||||
m("span", log.wiedervorlage_datum),
|
||||
m("span", log.erledigt ? "Erledigt" : "Offen"),
|
||||
m("button.w3-button.w3-small", {title: "Gesprächsnotiz bearbeiten", style: {padding: '0 2px'}, onclick: (e: Event) => editCallLog(log, e)}, m("i.material-icons", "edit"))
|
||||
@@ -93,6 +133,8 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
|
||||
filterProjektId = null
|
||||
projektName = ""
|
||||
}
|
||||
// Breadcrumb-Kette aus Route-Param parsen.
|
||||
breadcrumbs = parseBc(routeParams && routeParams.bc)
|
||||
// Gesprächsnotizen asynchron vom Server laden. Die Filterung
|
||||
// nach projekt_id erfolgt serverseitig, sofern ein Filter
|
||||
// gesetzt ist.
|
||||
@@ -118,6 +160,10 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
|
||||
height: 'inherit',
|
||||
'overflow-y': 'inherit'}},
|
||||
m("h1", projektName ? "Gesprächsnotizen für " + projektName : "Gesprächsnotizen"),
|
||||
m(BreadcrumbBar, {
|
||||
breadcrumbs: breadcrumbs,
|
||||
current: filterProjektId !== null ? "Notizen: " + projektName : "Gesprächsnotizen"
|
||||
} as iBreadcrumbBar),
|
||||
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
||||
m(Treetable,
|
||||
{
|
||||
@@ -183,6 +229,39 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
let kundenListe: iKunde[] = []
|
||||
let projektListe: iProjekt[] = []
|
||||
let kontaktadressenListe: iKontaktadresse[] = []
|
||||
// Asynchron geladene Tag-Liste (nur zuordenbare, nicht is_parent).
|
||||
let tagListe: iTag[] = []
|
||||
// Aktuell ausgewählte Tag-IDs im Formular.
|
||||
let selectedTagIds: number[] = []
|
||||
// Wurde die Maske aus der Gesprächsnotizenliste geöffnet? Wenn ja,
|
||||
// erfolgt die Rückkehr dorthin mit dem ursprünglichen Listenfilter.
|
||||
let fromListe: boolean = false
|
||||
// Filter-Kontext der Liste, aus der die Maske geöffnet wurde.
|
||||
// null bedeutet ungefiltert; ein Wert bedeutet projektgefiltert.
|
||||
let returnProjektId: number | null = null
|
||||
let returnProjektName: string = ""
|
||||
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||
let breadcrumbs: iBreadcrumb[] = []
|
||||
// Route-Params für die Rückkehr zur Notizliste; als Closure an den
|
||||
// DirtyMaskHelper übergeben (greift auf fromListe, returnProjektId,
|
||||
// currentLog, breadcrumbs zu).
|
||||
const backParams = (): any => {
|
||||
const params: any = {}
|
||||
if (fromListe) {
|
||||
if (returnProjektId !== null) {
|
||||
params.projekt_id = returnProjektId
|
||||
params.projekt_name = returnProjektName
|
||||
}
|
||||
} else if (currentLog.projekt_id !== -1) {
|
||||
params.projekt_id = currentLog.projekt_id
|
||||
params.projekt_name = currentLog.projekt_name
|
||||
}
|
||||
const listBc = serializeBc(breadcrumbs.slice(0, -1))
|
||||
if (listBc) params.bc = listBc
|
||||
return params
|
||||
}
|
||||
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
|
||||
const guard = new DirtyMaskHelper("/calllogliste", backParams)
|
||||
|
||||
/**
|
||||
* Formatiert verstrichene Sekunden als mm:ss.
|
||||
@@ -197,12 +276,23 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
oninit: (vnode) => {
|
||||
const routeParams = m.route.param()
|
||||
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
|
||||
// Rückkehr-Kontext: wurde die Maske aus der Liste geöffnet und
|
||||
// mit welchem Filter soll zurückgekehrt werden?
|
||||
fromListe = !!(routeParams && routeParams.from_liste)
|
||||
if (routeParams && routeParams.return_projekt_id) {
|
||||
returnProjektId = parseInt(routeParams.return_projekt_id)
|
||||
returnProjektName = routeParams.return_projekt_name || ""
|
||||
}
|
||||
// Breadcrumb-Kette aus Route-Param parsen.
|
||||
breadcrumbs = parseBc(routeParams && routeParams.bc)
|
||||
guard.registerGuard()
|
||||
if (id !== -1) {
|
||||
// Gesprächsnotiz frisch vom Server holen.
|
||||
fetchCallLogById(id).then((result) => {
|
||||
currentLog = result
|
||||
projektVoreingestellt = true
|
||||
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
|
||||
selectedTagIds = (result.tags || []).map((t: iTag) => t.id)
|
||||
}).catch((e) => showServerError(e))
|
||||
} else {
|
||||
projektVoreingestellt = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) !== -1 : false
|
||||
@@ -234,9 +324,12 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
fetchKunden().then((result) => { kundenListe = result }).catch((e) => showServerError(e))
|
||||
fetchProjekte().then((result) => { projektListe = result }).catch((e) => showServerError(e))
|
||||
fetchKontaktadressen().then((result) => { kontaktadressenListe = result }).catch((e) => showServerError(e))
|
||||
// Tags laden — nur zuordenbare (is_parent=false) Tags.
|
||||
fetchTags().then((result) => { tagListe = result.filter((t: iTag) => !t.is_parent) }).catch((e) => showServerError(e))
|
||||
},
|
||||
onremove: (vnode) => {
|
||||
if (timerId != null) { window.clearInterval(timerId) }
|
||||
guard.cleanup()
|
||||
},
|
||||
view: (vnode) => {
|
||||
// kunden_id aus dem geladenen Projekt ableiten (fuer abhaengiges
|
||||
@@ -267,6 +360,11 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
return {value: ka.id, label: ka.name + (ka.firma ? " (" + ka.firma + ")" : "")}
|
||||
})
|
||||
: []
|
||||
// Tag-Optionen und Farb-Map für die Mehrfachauswahl.
|
||||
const tagOptionen: iSelectOption[] = tagListe.map((t: iTag) => {
|
||||
return {value: t.id, label: t.name}
|
||||
})
|
||||
const tagColors: Map<number, string> = new Map(tagListe.map((t: iTag) => [t.id, t.color] as [number, string]))
|
||||
|
||||
const submit = (form: HTMLFormElement): boolean => {
|
||||
const formData = new FormData(form)
|
||||
@@ -283,22 +381,25 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
telefon: (formData.get("telefon") as string) || "",
|
||||
notiz: (formData.get("notiz") as string) || "",
|
||||
wiedervorlage_datum: (formData.get("wiedervorlage_datum") as string) || "",
|
||||
erledigt: formData.get("erledigt") === "on"
|
||||
erledigt: formData.get("erledigt") === "on",
|
||||
tag_ids: formData.getAll("tag_ids").map((v) => parseInt(v as string)).filter((n) => !isNaN(n))
|
||||
}
|
||||
if (currentLog.id === -1) {
|
||||
createCallLogREST(gespeichertesLog).then(() => m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name})).catch((e) => showServerError(e))
|
||||
createCallLogREST(gespeichertesLog).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
} else {
|
||||
updateCallLogREST(gespeichertesLog).then(() => m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name})).catch((e) => showServerError(e))
|
||||
updateCallLogREST(gespeichertesLog).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
}
|
||||
return true
|
||||
}
|
||||
const cancel = (e: Event) => {
|
||||
m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name})
|
||||
}
|
||||
return m("div.w3-container.w3-auto.form-view-container",
|
||||
m("h1", currentLog.id === -1 ? "Neue Gesprächsnotiz anlegen" : "Gesprächsnotiz"),
|
||||
m(BreadcrumbBar, {
|
||||
breadcrumbs: breadcrumbs,
|
||||
current: currentLog.id === -1 ? "Neue Gesprächsnotiz" : "Gesprächsnotiz",
|
||||
onnavigate: guardedNavigate
|
||||
} as iBreadcrumbBar),
|
||||
m("div", {style: {flex: '1', 'min-height': '0'}},
|
||||
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
|
||||
m(Form, guard.formAttrs(submit) as iForm, [
|
||||
m("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id}),
|
||||
// Kunde und Projekt als Read-Only-Text, da voreingestellt.
|
||||
m("label", "Kunde",
|
||||
@@ -346,6 +447,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
if (ka) {
|
||||
currentLog.name_ansprechpartner = ka.name
|
||||
currentLog.telefon = ka.telefon
|
||||
guard.markDirty()
|
||||
}
|
||||
}
|
||||
} as iSelect)),
|
||||
@@ -353,6 +455,16 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
m(Input, {name: "telefon", value: currentLog.telefon, type: "tel"} as iInput))
|
||||
),
|
||||
m("label", "Notiz", m(Textarea, {name: "notiz", value: currentLog.notiz, rows: 4} as iTextarea)),
|
||||
m("label", "Tags", m(MultiSelect, {
|
||||
name: "tag_ids",
|
||||
selected: selectedTagIds,
|
||||
options: tagOptionen,
|
||||
colors: tagColors,
|
||||
placeholder: "kein Tag",
|
||||
modalTitle: "Tags auswählen",
|
||||
editTooltip: "Tags bearbeiten",
|
||||
onchanged: () => { guard.markDirty() }
|
||||
} as iMultiSelect)),
|
||||
m("label", "Wiedervorlage Datum", m(Input, {name: "wiedervorlage_datum", value: currentLog.wiedervorlage_datum, type: "date"} as iInput)),
|
||||
m("label", m(Input, {name: "erledigt", type: "checkbox", value: currentLog.erledigt} as iInput), "Erledigt")
|
||||
])
|
||||
|
||||
@@ -2,9 +2,11 @@ import m from "mithril"
|
||||
import {Form, iForm} from "./widgets/form"
|
||||
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
|
||||
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
||||
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
|
||||
import {iKontakt, fetchKontakte, fetchKontaktById, createKontaktREST, updateKontaktREST} from "../models/kontakt"
|
||||
import {iKunde, fetchKundeById} from "../models/kunde"
|
||||
import {showServerError} from "./auth/authmessages"
|
||||
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||
|
||||
export const kontaktliste: m.ClosureComponent = (vnode) => {
|
||||
let treenodes: iTreenode[] = []
|
||||
@@ -13,11 +15,20 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
|
||||
// Kundenname für die Überschrift, asynchron per fetchKundeById
|
||||
// aufgelöst, falls nicht in den Route-Params enthalten.
|
||||
let kundenName: string = ""
|
||||
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||
let breadcrumbs: iBreadcrumb[] = []
|
||||
|
||||
const handleRowMarked = (node: iTreenode) => {
|
||||
selectedNode = node
|
||||
}
|
||||
|
||||
// Eigener Listen-Crumb für die Breadcrumb-Kette.
|
||||
const ownCrumb = (): iBreadcrumb => ({
|
||||
route: "/kontaktliste",
|
||||
params: filterKundenId !== null ? {kunden_id: filterKundenId} : {},
|
||||
label: filterKundenId !== null ? "Kontakte: " + kundenName : "Kontakte"
|
||||
})
|
||||
|
||||
const addKontaktRoute = () => {
|
||||
const params: any = {id: -1}
|
||||
// Nur bei kundengefilterter Liste den Kunden vorbelegen.
|
||||
@@ -25,12 +36,15 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
|
||||
params.kunden_id = filterKundenId
|
||||
params.kunden_name = kundenName
|
||||
}
|
||||
// Breadcrumb-Kette um eigene Position erweitern.
|
||||
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
|
||||
m.route.set("/kontakt", params)
|
||||
}
|
||||
|
||||
const editKontakt = (kontakt: iKontakt, e: Event) => {
|
||||
e.stopPropagation()
|
||||
m.route.set("/kontakt", {id: kontakt.id})
|
||||
// Breadcrumb-Kette um eigene Position erweitern.
|
||||
m.route.set("/kontakt", {id: kontakt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
|
||||
}
|
||||
|
||||
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
|
||||
@@ -66,6 +80,8 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
|
||||
filterKundenId = null
|
||||
kundenName = ""
|
||||
}
|
||||
// Breadcrumb-Kette aus Route-Param parsen.
|
||||
breadcrumbs = parseBc(routeParams && routeParams.bc)
|
||||
// Kontakte asynchron vom Server laden. Die Filterung nach
|
||||
// kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist.
|
||||
fetchKontakte(filterKundenId !== null ? filterKundenId : undefined).then((result) => {
|
||||
@@ -86,6 +102,10 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
|
||||
height: 'inherit',
|
||||
'overflow-y': 'inherit'}},
|
||||
m("h1", kundenName ? "Kontaktliste für " + kundenName : "Kontaktliste"),
|
||||
m(BreadcrumbBar, {
|
||||
breadcrumbs: breadcrumbs,
|
||||
current: filterKundenId !== null ? "Kontakte: " + kundenName : "Kontakte"
|
||||
} as iBreadcrumbBar),
|
||||
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
||||
m(Treetable,
|
||||
{
|
||||
@@ -129,11 +149,27 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
|
||||
email: "",
|
||||
notiz: ""
|
||||
}
|
||||
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||
let breadcrumbs: iBreadcrumb[] = []
|
||||
// Route-Params für die Rückkehr zur Kontaktliste; als Closure an den
|
||||
// DirtyMaskHelper übergeben, da dieser die maskenspezifischen Variablen
|
||||
// (currentKontakt, breadcrumbs) nicht kennt.
|
||||
const backParams = (): any => {
|
||||
const listBc = serializeBc(breadcrumbs.slice(0, -1))
|
||||
const params: any = {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name}
|
||||
if (listBc) params.bc = listBc
|
||||
return params
|
||||
}
|
||||
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
|
||||
const guard = new DirtyMaskHelper("/kontaktliste", backParams)
|
||||
|
||||
return {
|
||||
oninit: (vnode) => {
|
||||
const routeParams = m.route.param()
|
||||
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
|
||||
// Breadcrumb-Kette aus Route-Param parsen.
|
||||
breadcrumbs = parseBc(routeParams && routeParams.bc)
|
||||
guard.registerGuard()
|
||||
if (id !== -1) {
|
||||
// Kontaktdaten frisch vom Server holen, um zwischenzeitliche
|
||||
// Änderungen anderer Nutzer nicht zu überschreiben.
|
||||
@@ -147,6 +183,9 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
|
||||
currentKontakt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
|
||||
}
|
||||
},
|
||||
onremove: (vnode) => {
|
||||
guard.cleanup()
|
||||
},
|
||||
view: (vnode) => {
|
||||
const submit = (form: HTMLFormElement): boolean => {
|
||||
const formData = new FormData(form)
|
||||
@@ -162,21 +201,23 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
|
||||
notiz: (formData.get("notiz") as string) || ""
|
||||
}
|
||||
if (currentKontakt.id === -1) {
|
||||
createKontaktREST(gespeicherterKontakt).then(() => m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name})).catch((e) => showServerError(e))
|
||||
createKontaktREST(gespeicherterKontakt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
} else {
|
||||
updateKontaktREST(gespeicherterKontakt).then(() => m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name})).catch((e) => showServerError(e))
|
||||
updateKontaktREST(gespeicherterKontakt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
}
|
||||
return true
|
||||
}
|
||||
const cancel = (e: Event) => {
|
||||
m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name})
|
||||
}
|
||||
return m("div.w3-container.w3-auto.form-view-container",
|
||||
m("h1", currentKontakt.id === -1
|
||||
? "Neuer Kontakt für " + currentKontakt.kunden_name
|
||||
: "Kontakt-Stammblatt"),
|
||||
m(BreadcrumbBar, {
|
||||
breadcrumbs: breadcrumbs,
|
||||
current: currentKontakt.id === -1 ? "Neuer Kontakt" : "Kontakt-Stammblatt",
|
||||
onnavigate: guardedNavigate
|
||||
} as iBreadcrumbBar),
|
||||
m("div", {style: {flex: '1', 'min-height': '0'}},
|
||||
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
|
||||
m(Form, guard.formAttrs(submit) as iForm, [
|
||||
m("input", {type: "hidden", name: "kunden_id", value: currentKontakt.kunden_id}),
|
||||
m("label", "Anrede", m(Input, {name: "anrede", value: currentKontakt.anrede, type: "text"} as iInput)),
|
||||
m("label", "Name", m(Input, {name: "name", value: currentKontakt.name, type: "text", required: true} as iInput)),
|
||||
|
||||
@@ -2,9 +2,11 @@ import m from "mithril"
|
||||
import {Form, iForm} from "./widgets/form"
|
||||
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
|
||||
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
||||
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
|
||||
import {iKontaktadresse, fetchKontaktadressen, fetchKontaktadresseById, createKontaktadresseREST, updateKontaktadresseREST} from "../models/kontaktadresse"
|
||||
import {iProjekt, fetchProjektById} from "../models/projekt"
|
||||
import {showServerError} from "./auth/authmessages"
|
||||
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||
|
||||
export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
|
||||
let treenodes: iTreenode[] = []
|
||||
@@ -13,23 +15,35 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
|
||||
// Projektname für die Überschrift, asynchron per fetchProjektById
|
||||
// aufgelöst, falls nicht in den Route-Params enthalten.
|
||||
let projektName: string = ""
|
||||
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||
let breadcrumbs: iBreadcrumb[] = []
|
||||
|
||||
const handleRowMarked = (node: iTreenode) => {
|
||||
selectedNode = node
|
||||
}
|
||||
|
||||
// Eigener Listen-Crumb für die Breadcrumb-Kette.
|
||||
const ownCrumb = (): iBreadcrumb => ({
|
||||
route: "/kontaktadresseliste",
|
||||
params: filterProjektId !== null ? {projekt_id: filterProjektId} : {},
|
||||
label: filterProjektId !== null ? "Kontaktadressen: " + projektName : "Kontaktadressen"
|
||||
})
|
||||
|
||||
const addKontaktadresseRoute = () => {
|
||||
const params: any = {id: -1}
|
||||
if (filterProjektId !== null) {
|
||||
params.projekt_id = filterProjektId
|
||||
params.projekt_name = projektName
|
||||
}
|
||||
// Breadcrumb-Kette um eigene Position erweitern.
|
||||
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
|
||||
m.route.set("/kontaktadresse", params)
|
||||
}
|
||||
|
||||
const editKontaktadresse = (kontaktadresse: iKontaktadresse, e: Event) => {
|
||||
e.stopPropagation()
|
||||
m.route.set("/kontaktadresse", {id: kontaktadresse.id})
|
||||
// Breadcrumb-Kette um eigene Position erweitern.
|
||||
m.route.set("/kontaktadresse", {id: kontaktadresse.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
|
||||
}
|
||||
|
||||
const addCallLog = (kontaktadresse: iKontaktadresse, e: Event) => {
|
||||
@@ -37,6 +51,14 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
|
||||
const now = new Date()
|
||||
const datum = now.toISOString().split('T')[0]
|
||||
const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1]
|
||||
// Breadcrumb-Kette um Kontaktadressenliste und Notizliste
|
||||
// erweitern, da die Maske nach Speichern/Abbrechen in die
|
||||
// projektgefilterte Notizliste zurückkehrt.
|
||||
const chain = [...breadcrumbs, ownCrumb(), {
|
||||
route: "/calllogliste",
|
||||
params: {projekt_id: kontaktadresse.projekt_id},
|
||||
label: "Notizen: " + kontaktadresse.projekt_name
|
||||
} as iBreadcrumb]
|
||||
m.route.set("/call_log", {
|
||||
id: -1,
|
||||
projekt_id: kontaktadresse.projekt_id,
|
||||
@@ -49,7 +71,8 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
|
||||
telefon: kontaktadresse.telefon,
|
||||
notiz: "",
|
||||
wiedervorlage_datum: "",
|
||||
erledigt: false
|
||||
erledigt: false,
|
||||
bc: serializeBc(chain)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -92,6 +115,8 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
|
||||
filterProjektId = null
|
||||
projektName = ""
|
||||
}
|
||||
// Breadcrumb-Kette aus Route-Param parsen.
|
||||
breadcrumbs = parseBc(routeParams && routeParams.bc)
|
||||
// Kontaktadressen asynchron vom Server laden. Die Filterung
|
||||
// nach projekt_id erfolgt serverseitig, sofern ein Filter
|
||||
// gesetzt ist.
|
||||
@@ -113,6 +138,10 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
|
||||
height: 'inherit',
|
||||
'overflow-y': 'inherit'}},
|
||||
m("h1", projektName ? "Kontaktadressen für " + projektName : "Kontaktadressen"),
|
||||
m(BreadcrumbBar, {
|
||||
breadcrumbs: breadcrumbs,
|
||||
current: filterProjektId !== null ? "Kontaktadressen: " + projektName : "Kontaktadressen"
|
||||
} as iBreadcrumbBar),
|
||||
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
||||
m(Treetable,
|
||||
{
|
||||
@@ -166,11 +195,26 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
|
||||
land: "",
|
||||
notiz: ""
|
||||
}
|
||||
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||
let breadcrumbs: iBreadcrumb[] = []
|
||||
// Route-Params für die Rückkehr zur Kontaktadressenliste; als Closure
|
||||
// an den DirtyMaskHelper übergeben.
|
||||
const backParams = (): any => {
|
||||
const listBc = serializeBc(breadcrumbs.slice(0, -1))
|
||||
const params: any = {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name}
|
||||
if (listBc) params.bc = listBc
|
||||
return params
|
||||
}
|
||||
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
|
||||
const guard = new DirtyMaskHelper("/kontaktadresseliste", backParams)
|
||||
|
||||
return {
|
||||
oninit: (vnode) => {
|
||||
const routeParams = m.route.param()
|
||||
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
|
||||
// Breadcrumb-Kette aus Route-Param parsen.
|
||||
breadcrumbs = parseBc(routeParams && routeParams.bc)
|
||||
guard.registerGuard()
|
||||
if (id !== -1) {
|
||||
// Kontaktadresse frisch vom Server holen.
|
||||
fetchKontaktadresseById(id).then((result) => {
|
||||
@@ -182,6 +226,9 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
|
||||
currentKontaktadresse.projekt_name = routeParams && routeParams.projekt_name ? routeParams.projekt_name : ""
|
||||
}
|
||||
},
|
||||
onremove: (vnode) => {
|
||||
guard.cleanup()
|
||||
},
|
||||
view: (vnode) => {
|
||||
const submit = (form: HTMLFormElement): boolean => {
|
||||
const formData = new FormData(form)
|
||||
@@ -202,21 +249,25 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
|
||||
notiz: (formData.get("notiz") as string) || ""
|
||||
}
|
||||
if (currentKontaktadresse.id === -1) {
|
||||
createKontaktadresseREST(gespeicherteKontaktadresse).then(() => m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name})).catch((e) => showServerError(e))
|
||||
createKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
} else {
|
||||
updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name})).catch((e) => showServerError(e))
|
||||
updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
}
|
||||
return true
|
||||
}
|
||||
const cancel = (e: Event) => {
|
||||
m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name})
|
||||
}
|
||||
return m("div.w3-container.w3-auto.form-view-container",
|
||||
m("h1", currentKontaktadresse.id === -1
|
||||
? "Neue Kontaktadresse für " + currentKontaktadresse.projekt_name
|
||||
: "Kontaktadresse-Stammblatt"),
|
||||
m(BreadcrumbBar, {
|
||||
breadcrumbs: breadcrumbs,
|
||||
current: currentKontaktadresse.id === -1
|
||||
? "Neue Kontaktadresse"
|
||||
: "Kontaktadresse-Stammblatt",
|
||||
onnavigate: guardedNavigate
|
||||
} as iBreadcrumbBar),
|
||||
m("div", {style: {flex: '1', 'min-height': '0'}},
|
||||
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
|
||||
m(Form, guard.formAttrs(submit) as iForm, [
|
||||
m("input", {type: "hidden", name: "projekt_id", value: currentKontaktadresse.projekt_id}),
|
||||
m("label", "Anrede", m(Input, {name: "anrede", value: currentKontaktadresse.anrede, type: "text"} as iInput)),
|
||||
m("label", "Name", m(Input, {name: "name", value: currentKontaktadresse.name, type: "text", required: true} as iInput)),
|
||||
|
||||
+46
-11
@@ -2,8 +2,10 @@ import m from "mithril"
|
||||
import {Form, iForm} from "./widgets/form"
|
||||
import { Input, iInput} from "./widgets/inputs"
|
||||
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
||||
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
|
||||
import {iKunde, fetchKunden, fetchKundeById, createKundeREST, updateKundeREST} from "../models/kunde"
|
||||
import {showServerError} from "./auth/authmessages"
|
||||
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||
|
||||
export const kunde: m.ClosureComponent = (vnode) => {
|
||||
let currentKunde: iKunde = {
|
||||
@@ -14,11 +16,18 @@ export const kunde: m.ClosureComponent = (vnode) => {
|
||||
ort: "",
|
||||
land: ""
|
||||
}
|
||||
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||
let breadcrumbs: iBreadcrumb[] = []
|
||||
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
|
||||
const guard = new DirtyMaskHelper("/kundenliste")
|
||||
|
||||
return {
|
||||
oninit: (vnode) => {
|
||||
const routeParams = m.route.param()
|
||||
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
|
||||
// Breadcrumb-Kette aus Route-Param parsen.
|
||||
breadcrumbs = parseBc(routeParams && routeParams.bc)
|
||||
guard.registerGuard()
|
||||
if (id !== -1) {
|
||||
// Kundendaten frisch vom Server holen, um zwischenzeitliche
|
||||
// Änderungen anderer Nutzer nicht zu überschreiben.
|
||||
@@ -27,6 +36,9 @@ export const kunde: m.ClosureComponent = (vnode) => {
|
||||
}).catch((e) => showServerError(e))
|
||||
}
|
||||
},
|
||||
onremove: (vnode) => {
|
||||
guard.cleanup()
|
||||
},
|
||||
view: (vnode) => {
|
||||
const submit = (form: HTMLFormElement): boolean => {
|
||||
const formData = new FormData(form)
|
||||
@@ -39,26 +51,28 @@ export const kunde: m.ClosureComponent = (vnode) => {
|
||||
land: (formData.get("land") as string) || ""
|
||||
}
|
||||
if (currentKunde.id === -1) {
|
||||
createKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste")).catch((e) => showServerError(e))
|
||||
createKundeREST(gespeicherterKunde).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
} else {
|
||||
updateKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste")).catch((e) => showServerError(e))
|
||||
updateKundeREST(gespeicherterKunde).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
}
|
||||
return true
|
||||
}
|
||||
const cancel = (e: Event) => {
|
||||
m.route.set("/kundenliste")
|
||||
}
|
||||
return m("div.w3-container.w3-auto.form-view-container",
|
||||
m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}},
|
||||
m("h1", currentKunde.id === -1 ? "Neuen Kunden anlegen" : "Kundenstammblatt"),
|
||||
currentKunde.id !== -1
|
||||
? m("button.w3-button.w3-theme",
|
||||
{onclick: () => m.route.set("/kontaktliste", {kunden_id: currentKunde.id})},
|
||||
{onclick: (e: MouseEvent) => guardedNavigate("/kontaktliste", {kunden_id: currentKunde.id}, e)},
|
||||
m("i.material-icons", "contact_page"), " Kontakte")
|
||||
: null
|
||||
),
|
||||
m(BreadcrumbBar, {
|
||||
breadcrumbs: breadcrumbs,
|
||||
current: currentKunde.id === -1 ? "Neuer Kunde" : "Kundenstammblatt",
|
||||
onnavigate: guardedNavigate
|
||||
} as iBreadcrumbBar),
|
||||
m("div", {style: {flex: '1', 'min-height': '0'}},
|
||||
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
|
||||
m(Form, guard.formAttrs(submit) as iForm, [
|
||||
m("label", "Kundenname", m(Input, {name: "kundenname", value: currentKunde.name, type: "text", required: true} as iInput)),
|
||||
m("label", "Straße", m(Input, {name: "strasse", value: currentKunde.strasse, type: "text"} as iInput)),
|
||||
m("label", "PLZ", m(Input, {name: "plz", value: currentKunde.plz, type: "text"} as iInput)),
|
||||
@@ -74,28 +88,43 @@ export const kunde: m.ClosureComponent = (vnode) => {
|
||||
export const kundenliste: m.ClosureComponent = (vnode) => {
|
||||
let treenodes: iTreenode[] = []
|
||||
let selectedNode: iTreenode | null = null
|
||||
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||
let breadcrumbs: iBreadcrumb[] = []
|
||||
|
||||
const handleRowMarked = (node: iTreenode) => {
|
||||
selectedNode = node
|
||||
}
|
||||
|
||||
// Eigener Listen-Crumb für die Breadcrumb-Kette.
|
||||
const ownCrumb = (): iBreadcrumb => ({
|
||||
route: "/kundenliste",
|
||||
params: {},
|
||||
label: "Kunden"
|
||||
})
|
||||
|
||||
const editKunde = (kunde: iKunde, e: Event) => {
|
||||
e.stopPropagation()
|
||||
m.route.set("/kunde", {id: kunde.id})
|
||||
// Breadcrumb-Kette um eigene Position erweitern.
|
||||
m.route.set("/kunde", {id: kunde.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
|
||||
}
|
||||
|
||||
const showProjekte = (kunde: iKunde, e: Event) => {
|
||||
e.stopPropagation()
|
||||
m.route.set("/projektliste", {kunden_id: kunde.id})
|
||||
// Breadcrumb-Kette: Kundenliste als Vorgänger der Projektliste.
|
||||
const bc = serializeBc([...breadcrumbs, ownCrumb()])
|
||||
m.route.set("/projektliste", {kunden_id: kunde.id, bc})
|
||||
}
|
||||
|
||||
const showKontakte = (kunde: iKunde, e: Event) => {
|
||||
e.stopPropagation()
|
||||
m.route.set("/kontaktliste", {kunden_id: kunde.id})
|
||||
// Breadcrumb-Kette: Kundenliste als Vorgänger der Kontaktliste.
|
||||
const bc = serializeBc([...breadcrumbs, ownCrumb()])
|
||||
m.route.set("/kontaktliste", {kunden_id: kunde.id, bc})
|
||||
}
|
||||
|
||||
const addKundeRoute = () => {
|
||||
m.route.set("/kunde", {id: -1})
|
||||
// Breadcrumb-Kette um eigene Position erweitern.
|
||||
m.route.set("/kunde", {id: -1, bc: serializeBc([...breadcrumbs, ownCrumb()])})
|
||||
}
|
||||
|
||||
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
|
||||
@@ -127,6 +156,8 @@ export const kundenliste: m.ClosureComponent = (vnode) => {
|
||||
|
||||
return {
|
||||
oninit: (vnode) => {
|
||||
// Breadcrumb-Kette aus Route-Param parsen (bei Top-Level leer).
|
||||
breadcrumbs = parseBc(m.route.param() && m.route.param().bc)
|
||||
// Kundenliste asynchron vom Server laden. m.request löst nach
|
||||
// Promise-Auflösung automatisch ein Redraw aus.
|
||||
fetchKunden().then((kunden: iKunde[]) => {
|
||||
@@ -141,6 +172,10 @@ export const kundenliste: m.ClosureComponent = (vnode) => {
|
||||
height: 'inherit',
|
||||
'overflow-y': 'inherit'}},
|
||||
m("h1", "Kundenliste"),
|
||||
m(BreadcrumbBar, {
|
||||
breadcrumbs: breadcrumbs,
|
||||
current: "Kunden"
|
||||
} as iBreadcrumbBar),
|
||||
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
||||
m(Treetable,
|
||||
{
|
||||
|
||||
@@ -2,9 +2,11 @@ import m from "mithril"
|
||||
import {Form, iForm} from "./widgets/form"
|
||||
import { Input, iInput} from "./widgets/inputs"
|
||||
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
||||
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
|
||||
import {iProjekt, fetchProjekte, fetchProjektById, createProjektREST, updateProjektREST} from "../models/projekt"
|
||||
import {iKunde, fetchKundeById} from "../models/kunde"
|
||||
import {showServerError} from "./auth/authmessages"
|
||||
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||
|
||||
export const projektliste: m.ClosureComponent = (vnode) => {
|
||||
let treenodes: iTreenode[] = []
|
||||
@@ -13,11 +15,20 @@ export const projektliste: m.ClosureComponent = (vnode) => {
|
||||
// Kundenname für die Überschrift, asynchron per fetchKundeById
|
||||
// aufgelöst, falls nicht in den Route-Params enthalten.
|
||||
let kundenName: string = ""
|
||||
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||
let breadcrumbs: iBreadcrumb[] = []
|
||||
|
||||
const handleRowMarked = (node: iTreenode) => {
|
||||
selectedNode = node
|
||||
}
|
||||
|
||||
// Eigener Listen-Crumb für die Breadcrumb-Kette.
|
||||
const ownCrumb = (): iBreadcrumb => ({
|
||||
route: "/projektliste",
|
||||
params: filterKundenId !== null ? {kunden_id: filterKundenId, kunden_name: kundenName} : {},
|
||||
label: filterKundenId !== null ? "Projekte: " + kundenName : "Projekte"
|
||||
})
|
||||
|
||||
const addProjektRoute = () => {
|
||||
const params: any = {id: -1}
|
||||
// Nur bei kundengefilterter Liste den Kunden vorbelegen.
|
||||
@@ -25,22 +36,27 @@ export const projektliste: m.ClosureComponent = (vnode) => {
|
||||
params.kunden_id = filterKundenId
|
||||
params.kunden_name = kundenName
|
||||
}
|
||||
// Breadcrumb-Kette um eigene Position erweitern.
|
||||
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
|
||||
m.route.set("/projekt", params)
|
||||
}
|
||||
|
||||
const editProjekt = (projekt: iProjekt, e: Event) => {
|
||||
e.stopPropagation()
|
||||
m.route.set("/projekt", {id: projekt.id})
|
||||
// Breadcrumb-Kette um eigene Position erweitern.
|
||||
m.route.set("/projekt", {id: projekt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
|
||||
}
|
||||
|
||||
const showCallLogs = (projekt: iProjekt, e: Event) => {
|
||||
e.stopPropagation()
|
||||
m.route.set("/calllogliste", {projekt_id: projekt.id})
|
||||
// Breadcrumb-Kette um eigene Position erweitern.
|
||||
m.route.set("/calllogliste", {projekt_id: projekt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
|
||||
}
|
||||
|
||||
const showKontaktadressen = (projekt: iProjekt, e: Event) => {
|
||||
e.stopPropagation()
|
||||
m.route.set("/kontaktadresseliste", {projekt_id: projekt.id})
|
||||
// Breadcrumb-Kette um eigene Position erweitern.
|
||||
m.route.set("/kontaktadresseliste", {projekt_id: projekt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
|
||||
}
|
||||
|
||||
const addCallLog = (projekt: iProjekt, e: Event) => {
|
||||
@@ -48,6 +64,14 @@ export const projektliste: m.ClosureComponent = (vnode) => {
|
||||
const now = new Date()
|
||||
const datum = now.toISOString().split('T')[0]
|
||||
const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1]
|
||||
// Breadcrumb-Kette um Projektliste und Notizliste erweitern, da
|
||||
// die Maske nach Speichern/Abbrechen in die projektgefilterte
|
||||
// Notizliste zurückkehrt.
|
||||
const chain = [...breadcrumbs, ownCrumb(), {
|
||||
route: "/calllogliste",
|
||||
params: {projekt_id: projekt.id},
|
||||
label: "Notizen: " + projekt.projektname
|
||||
} as iBreadcrumb]
|
||||
m.route.set("/call_log", {
|
||||
id: -1,
|
||||
kunden_name: projekt.kunden_name,
|
||||
@@ -61,7 +85,8 @@ export const projektliste: m.ClosureComponent = (vnode) => {
|
||||
telefon: "",
|
||||
notiz: "",
|
||||
wiedervorlage_datum: "",
|
||||
erledigt: false
|
||||
erledigt: false,
|
||||
bc: serializeBc(chain)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -103,6 +128,8 @@ export const projektliste: m.ClosureComponent = (vnode) => {
|
||||
filterKundenId = null
|
||||
kundenName = ""
|
||||
}
|
||||
// Breadcrumb-Kette aus Route-Param parsen.
|
||||
breadcrumbs = parseBc(routeParams && routeParams.bc)
|
||||
// Projekte asynchron vom Server laden. Die Filterung nach
|
||||
// kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist.
|
||||
fetchProjekte(filterKundenId !== null ? filterKundenId : undefined).then((result) => {
|
||||
@@ -123,6 +150,10 @@ export const projektliste: m.ClosureComponent = (vnode) => {
|
||||
height: 'inherit',
|
||||
'overflow-y': 'inherit'}},
|
||||
m("h1", kundenName ? "Projektliste für " + kundenName : "Projektliste"),
|
||||
m(BreadcrumbBar, {
|
||||
breadcrumbs: breadcrumbs,
|
||||
current: kundenName ? "Projekte: " + kundenName : "Projekte"
|
||||
} as iBreadcrumbBar),
|
||||
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
||||
m(Treetable,
|
||||
{
|
||||
@@ -162,11 +193,26 @@ export const Projekt: m.ClosureComponent = (vnode) => {
|
||||
verantwortlicher: "",
|
||||
startdatum: ""
|
||||
}
|
||||
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||
let breadcrumbs: iBreadcrumb[] = []
|
||||
// Route-Params für die Rückkehr zur Projektliste; als Closure an den
|
||||
// DirtyMaskHelper übergeben.
|
||||
const backParams = (): any => {
|
||||
const listBc = serializeBc(breadcrumbs.slice(0, -1))
|
||||
const params: any = {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name}
|
||||
if (listBc) params.bc = listBc
|
||||
return params
|
||||
}
|
||||
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
|
||||
const guard = new DirtyMaskHelper("/projektliste", backParams)
|
||||
|
||||
return {
|
||||
oninit: (vnode) => {
|
||||
const routeParams = m.route.param()
|
||||
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
|
||||
// Breadcrumb-Kette aus Route-Param parsen.
|
||||
breadcrumbs = parseBc(routeParams && routeParams.bc)
|
||||
guard.registerGuard()
|
||||
if (id !== -1) {
|
||||
// Projektdaten frisch vom Server holen, um zwischenzeitliche
|
||||
// Änderungen anderer Nutzer nicht zu überschreiben.
|
||||
@@ -180,6 +226,9 @@ export const Projekt: m.ClosureComponent = (vnode) => {
|
||||
currentProjekt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
|
||||
}
|
||||
},
|
||||
onremove: (vnode) => {
|
||||
guard.cleanup()
|
||||
},
|
||||
view: (vnode) => {
|
||||
const submit = (form: HTMLFormElement): boolean => {
|
||||
const formData = new FormData(form)
|
||||
@@ -193,15 +242,12 @@ export const Projekt: m.ClosureComponent = (vnode) => {
|
||||
startdatum: (formData.get("startdatum") as string) || ""
|
||||
}
|
||||
if (currentProjekt.id === -1) {
|
||||
createProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name})).catch((e) => showServerError(e))
|
||||
createProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
} else {
|
||||
updateProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name})).catch((e) => showServerError(e))
|
||||
updateProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
}
|
||||
return true
|
||||
}
|
||||
const cancel = (e: Event) => {
|
||||
m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name})
|
||||
}
|
||||
return m("div.w3-container.w3-auto.form-view-container",
|
||||
m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}},
|
||||
m("h1", currentProjekt.id === -1
|
||||
@@ -210,17 +256,22 @@ export const Projekt: m.ClosureComponent = (vnode) => {
|
||||
currentProjekt.id !== -1
|
||||
? [
|
||||
m("button.w3-button.w3-theme",
|
||||
{onclick: () => m.route.set("/kontaktadresseliste", {projekt_id: currentProjekt.id})},
|
||||
{onclick: (e: MouseEvent) => guardedNavigate("/kontaktadresseliste", {projekt_id: currentProjekt.id}, e)},
|
||||
m("i.material-icons", "contact_page"), " Kontaktadressen"),
|
||||
m("button.w3-button.w3-theme",
|
||||
{style: {'margin-left': '4px'},
|
||||
onclick: () => m.route.set("/projektuserliste", {projekt_id: currentProjekt.id})},
|
||||
onclick: (e: MouseEvent) => guardedNavigate("/projektuserliste", {projekt_id: currentProjekt.id}, e)},
|
||||
m("i.material-icons", "group"), " Nutzerberechtigungen")
|
||||
]
|
||||
: null
|
||||
),
|
||||
m(BreadcrumbBar, {
|
||||
breadcrumbs: breadcrumbs,
|
||||
current: currentProjekt.id === -1 ? "Neues Projekt" : "Projekt-Stammblatt",
|
||||
onnavigate: guardedNavigate
|
||||
} as iBreadcrumbBar),
|
||||
m("div", {style: {flex: '1', 'min-height': '0'}},
|
||||
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
|
||||
m(Form, guard.formAttrs(submit) as iForm, [
|
||||
m("input", {type: "hidden", name: "kunden_id", value: currentProjekt.kunden_id}),
|
||||
m("label", "Projektname", m(Input, {name: "projektname", value: currentProjekt.projektname, type: "text", required: true} as iInput)),
|
||||
m("label", "Zweck", m(Input, {name: "zweck", value: currentProjekt.zweck, type: "text"} as iInput)),
|
||||
|
||||
+243
-559
@@ -1,7 +1,12 @@
|
||||
import m from "mithril"
|
||||
import {Form, iForm} from "./widgets/form"
|
||||
import { Input, iInput} from "./widgets/inputs"
|
||||
import {Treenode, iTreenode, Treetable, iTreetable} from "./widgets/Treetable_V2"
|
||||
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
||||
import {ModalType, ModalView, iModalView} from "./widgets/Modal"
|
||||
import {gridMountpoints} from "./mainlayout"
|
||||
import {showServerError} from "./auth/authmessages"
|
||||
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||
import {iTag, iTagWithChildren, fetchTags, fetchTagHierarchy, fetchTagById, createTagREST, updateTagREST, deleteTagREST} from "../models/tag"
|
||||
|
||||
const colorPalette: string[] = [
|
||||
"#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff",
|
||||
@@ -10,536 +15,164 @@ const colorPalette: string[] = [
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// INTERFACES FÜR HIERARCHISCHE TAGS
|
||||
// HILFSFUNKTIONEN
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Basis-Tag-Interface für die hierarchische Struktur.
|
||||
* Erzeugt Treenodes aus der vom Server geladenen Hierarchie.
|
||||
* Beruecksichtigt die Verschachtelung der Tags.
|
||||
*/
|
||||
export interface iTag {
|
||||
id: number; // Numerische ID (für Kompatibilität mit bestehendem Code)
|
||||
name: string; // Anzeigename des Tags
|
||||
description: string; // Beschreibung
|
||||
color: string; // Farbcode (z. B. "#ff0000")
|
||||
parentId: number | null; // Referenz auf den Eltern-Tag (null = Wurzelebene)
|
||||
icon?: string; // Optional: Icon-Name oder URL
|
||||
order?: number; // Optional: Sortierreihenfolge innerhalb der Ebene
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag mit verschachtelten Kindern für die Hierarchie-Darstellung.
|
||||
*/
|
||||
interface iTagWithChildren extends iTag {
|
||||
children: iTagWithChildren[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BEISPIELDATEN (HIERARCHISCH)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Flache Liste aller Tags (für Datenbank-ähnliche Speicherung).
|
||||
* Hierarchie wird über parentId abgebildet.
|
||||
*/
|
||||
const tagDaten: iTag[] = [
|
||||
// ========== WURZEL-TAGS (Top-Level) ==========
|
||||
{
|
||||
id: 1,
|
||||
name: "Wichtig",
|
||||
description: "Hochpriorisierte Aufgaben",
|
||||
color: "#ff0000",
|
||||
parentId: null,
|
||||
icon: "priority_high",
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Kunde",
|
||||
description: "Kundenbezogene Tags",
|
||||
color: "#00ff00",
|
||||
parentId: null,
|
||||
icon: "person",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Projekt",
|
||||
description: "Projektbezogene Tags",
|
||||
color: "#0000ff",
|
||||
parentId: null,
|
||||
icon: "folder",
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Follow-up",
|
||||
description: "Nachverfolgung erforderlich",
|
||||
color: "#ffff00",
|
||||
parentId: null,
|
||||
icon: "schedule",
|
||||
order: 4,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Archiv",
|
||||
description: "Archivierte Einträge",
|
||||
color: "#808080",
|
||||
parentId: null,
|
||||
icon: "archive",
|
||||
order: 5,
|
||||
},
|
||||
|
||||
// ========== TECHNISCHE KATEGORIEN (neu) ==========
|
||||
{
|
||||
id: 100,
|
||||
name: "Backend",
|
||||
description: "Server-seitige Technologien und Frameworks",
|
||||
color: "#4285F4",
|
||||
parentId: null,
|
||||
icon: "server",
|
||||
order: 6,
|
||||
},
|
||||
{
|
||||
id: 101,
|
||||
name: "Frontend",
|
||||
description: "Client-seitige Technologien und Frameworks",
|
||||
color: "#34A853",
|
||||
parentId: null,
|
||||
icon: "browser",
|
||||
order: 7,
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
name: "DevOps",
|
||||
description: "Infrastruktur, Deployment und CI/CD",
|
||||
color: "#EA4335",
|
||||
parentId: null,
|
||||
icon: "cog",
|
||||
order: 8,
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
name: "Daten",
|
||||
description: "Datenbanken, Analyse und KI",
|
||||
color: "#FBBC04",
|
||||
parentId: null,
|
||||
icon: "database",
|
||||
order: 9,
|
||||
},
|
||||
|
||||
// Backend -> Programmiersprachen
|
||||
{
|
||||
id: 200,
|
||||
name: "Python",
|
||||
description: "Allgemeine Python-Entwicklung",
|
||||
color: "#3776AB",
|
||||
parentId: 100,
|
||||
icon: "python",
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 201,
|
||||
name: "Java",
|
||||
description: "Java und JVM-Ökosystem",
|
||||
color: "#007396",
|
||||
parentId: 100,
|
||||
icon: "java",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 202,
|
||||
name: "Node.js",
|
||||
description: "JavaScript auf dem Server",
|
||||
color: "#339933",
|
||||
parentId: 100,
|
||||
icon: "nodejs",
|
||||
order: 3,
|
||||
},
|
||||
|
||||
// Backend -> Python -> Frameworks
|
||||
{
|
||||
id: 300,
|
||||
name: "Flask",
|
||||
description: "Mikro-Webframework für Python",
|
||||
color: "#000000",
|
||||
parentId: 200,
|
||||
icon: "flask",
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 301,
|
||||
name: "Django",
|
||||
description: "Full-Stack Webframework für Python",
|
||||
color: "#092E20",
|
||||
parentId: 200,
|
||||
icon: "django",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 302,
|
||||
name: "FastAPI",
|
||||
description: "Moderne API-Entwicklung mit Python",
|
||||
color: "#005577",
|
||||
parentId: 200,
|
||||
icon: "fastapi",
|
||||
order: 3,
|
||||
},
|
||||
|
||||
// Backend -> Node.js -> Frameworks
|
||||
{
|
||||
id: 310,
|
||||
name: "Express",
|
||||
description: "Minimalistisches Webframework für Node.js",
|
||||
color: "#000000",
|
||||
parentId: 202,
|
||||
icon: "express",
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 311,
|
||||
name: "NestJS",
|
||||
description: "Progressives Node.js-Framework für Enterprise-Anwendungen",
|
||||
color: "#E0234E",
|
||||
parentId: 202,
|
||||
icon: "nestjs",
|
||||
order: 2,
|
||||
},
|
||||
|
||||
// Backend -> Datenbanken
|
||||
{
|
||||
id: 210,
|
||||
name: "Datenbanken",
|
||||
description: "Backend-Datenbank-Systeme",
|
||||
color: "#336791",
|
||||
parentId: 100,
|
||||
icon: "database",
|
||||
order: 4,
|
||||
},
|
||||
{
|
||||
id: 211,
|
||||
name: "PostgreSQL",
|
||||
description: "Relationale Datenbank mit erweiterten Features",
|
||||
color: "#336791",
|
||||
parentId: 210,
|
||||
icon: "postgres",
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 212,
|
||||
name: "MongoDB",
|
||||
description: "Dokumentenorientierte NoSQL-Datenbank",
|
||||
color: "#47A248",
|
||||
parentId: 210,
|
||||
icon: "mongodb",
|
||||
order: 2,
|
||||
},
|
||||
|
||||
// Frontend -> JavaScript
|
||||
{
|
||||
id: 220,
|
||||
name: "JavaScript",
|
||||
description: "Die Hauptsprache des Web",
|
||||
color: "#F7DF1E",
|
||||
parentId: 101,
|
||||
icon: "js",
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 221,
|
||||
name: "TypeScript",
|
||||
description: "Typsicheres JavaScript",
|
||||
color: "#3178C6",
|
||||
parentId: 101,
|
||||
icon: "ts",
|
||||
order: 2,
|
||||
},
|
||||
|
||||
// Frontend -> JavaScript -> Frameworks
|
||||
{
|
||||
id: 320,
|
||||
name: "React",
|
||||
description: "Komponentenbasiertes UI-Framework",
|
||||
color: "#61DAFB",
|
||||
parentId: 220,
|
||||
icon: "react",
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 321,
|
||||
name: "Vue.js",
|
||||
description: "Progressives JavaScript-Framework",
|
||||
color: "#4FC08D",
|
||||
parentId: 220,
|
||||
icon: "vue",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 322,
|
||||
name: "Angular",
|
||||
description: "Full-Stack Framework von Google",
|
||||
color: "#DD1B16",
|
||||
parentId: 220,
|
||||
icon: "angular",
|
||||
order: 3,
|
||||
},
|
||||
|
||||
// Frontend -> CSS
|
||||
{
|
||||
id: 230,
|
||||
name: "CSS",
|
||||
description: "Stildefinition für Webseiten",
|
||||
color: "#1572B6",
|
||||
parentId: 101,
|
||||
icon: "css",
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
id: 330,
|
||||
name: "Tailwind CSS",
|
||||
description: "Utility-first CSS-Framework",
|
||||
color: "#06B6D4",
|
||||
parentId: 230,
|
||||
icon: "tailwind",
|
||||
order: 1,
|
||||
},
|
||||
|
||||
// DevOps
|
||||
{
|
||||
id: 240,
|
||||
name: "Docker",
|
||||
description: "Containerisierung von Anwendungen",
|
||||
color: "#2496ED",
|
||||
parentId: 102,
|
||||
icon: "docker",
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 241,
|
||||
name: "Kubernetes",
|
||||
description: "Orchestrierung von Containern",
|
||||
color: "#326CE5",
|
||||
parentId: 102,
|
||||
icon: "kubernetes",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 242,
|
||||
name: "CI/CD",
|
||||
description: "Continuous Integration und Deployment",
|
||||
color: "#FFA500",
|
||||
parentId: 102,
|
||||
icon: "cicd",
|
||||
order: 3,
|
||||
},
|
||||
|
||||
// Daten
|
||||
{
|
||||
id: 250,
|
||||
name: "Künstliche Intelligenz",
|
||||
description: "Machine Learning und KI-Technologien",
|
||||
color: "#8B00FF",
|
||||
parentId: 103,
|
||||
icon: "ai",
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 251,
|
||||
name: "Big Data",
|
||||
description: "Verarbeitung großer Datenmengen",
|
||||
color: "#FF6B6B",
|
||||
parentId: 103,
|
||||
icon: "bigdata",
|
||||
order: 2,
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// HILFSFUNKTIONEN FÜR HIERARCHISCHE TAGS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Gibt alle Wurzel-Tags (ohne Eltern) zurück.
|
||||
*/
|
||||
function getRootTags(): iTag[] {
|
||||
return tagDaten.filter((tag) => tag.parentId === null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut aus der flachen Liste eine hierarchische Struktur mit Kindern.
|
||||
* @param parentId - ID des Eltern-Tags (null für Wurzelebene)
|
||||
* @returns Array von Tags mit verschachtelten Kindern
|
||||
*/
|
||||
function buildHierarchy(parentId: number | null = null): iTagWithChildren[] {
|
||||
const children = tagDaten.filter((tag) => tag.parentId === parentId);
|
||||
// Sortieren nach order-Feld (falls vorhanden)
|
||||
children.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
return children.map((tag) => ({
|
||||
...tag,
|
||||
children: buildHierarchy(tag.id),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die vollständige Hierarchie als verschachteltes Objekt zurück.
|
||||
*/
|
||||
function getFullHierarchy(): iTagWithChildren[] {
|
||||
return buildHierarchy(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht einen Tag nach ID.
|
||||
*/
|
||||
function findTagById(id: number): iTag | undefined {
|
||||
return tagDaten.find((tag) => tag.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den vollständigen Pfad eines Tags als String zurück (z. B. "/Backend/Python/Flask").
|
||||
*/
|
||||
function getTagPath(tagId: number): string {
|
||||
const tag = findTagById(tagId);
|
||||
if (!tag) return "";
|
||||
if (tag.parentId === null) return `/${tag.name}`;
|
||||
const parentPath = getTagPath(tag.parentId);
|
||||
return `${parentPath}/${tag.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle Nachfahren (Kinder, Enkel, ...) eines Tags zurück (flache Liste).
|
||||
*/
|
||||
function getDescendants(tagId: number): iTag[] {
|
||||
const descendants: iTag[] = [];
|
||||
const children = tagDaten.filter((tag) => tag.parentId === tagId);
|
||||
for (const child of children) {
|
||||
descendants.push(child);
|
||||
descendants.push(...getDescendants(child.id));
|
||||
}
|
||||
return descendants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle Vorfahren (Eltern, Großeltern, ...) eines Tags zurück.
|
||||
*/
|
||||
function getAncestors(tagId: number): iTag[] {
|
||||
const ancestors: iTag[] = [];
|
||||
const tag = findTagById(tagId);
|
||||
if (!tag || tag.parentId === null) return ancestors;
|
||||
|
||||
const parent = findTagById(tag.parentId);
|
||||
if (parent) {
|
||||
ancestors.push(parent);
|
||||
ancestors.push(...getAncestors(parent.id));
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht Tags nach Namen (case-insensitive, partiell).
|
||||
*/
|
||||
function searchTagsByName(query: string): iTag[] {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return tagDaten.filter((tag) => tag.name.toLowerCase().includes(lowerQuery));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt einen neuen Tag hinzu (mit automatischer ID-Vergabe).
|
||||
*/
|
||||
function addTag(name: string, parentId: number | null = null, description: string = "", color: string = "#808080", icon?: string, order?: number): iTag {
|
||||
const newId = tagDaten.length > 0 ? Math.max(...tagDaten.map(t => t.id)) + 1 : 1;
|
||||
const tag: iTag = {
|
||||
id: newId,
|
||||
name,
|
||||
description,
|
||||
color,
|
||||
parentId,
|
||||
icon,
|
||||
order,
|
||||
};
|
||||
tagDaten.push(tag);
|
||||
return tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt einen bestehenden Tag (z. B. nach Bearbeitung).
|
||||
*/
|
||||
function updateTag(updatedTag: iTag): void {
|
||||
const index = tagDaten.findIndex(t => t.id === updatedTag.id);
|
||||
if (index !== -1) {
|
||||
tagDaten[index] = updatedTag;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TREENODE-ERZEUGUNG (FÜR DIE TREETABLE)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Erzeugt Treenodes aus der hierarchischen Tag-Struktur.
|
||||
* Berücksichtigt die Verschachtelung der Tags.
|
||||
*/
|
||||
function createTagTreenodes(): iTreenode[] {
|
||||
const hierarchy = getFullHierarchy();
|
||||
return hierarchy.map((tag) => createTreenodeFromTag(tag));
|
||||
function createTagTreenodes(hierarchy: iTagWithChildren[]): iTreenode[] {
|
||||
return hierarchy.map((tag) => createTreenodeFromTag(tag))
|
||||
}
|
||||
|
||||
/**
|
||||
* Rekursive Funktion zum Erstellen eines Treenode aus einem Tag mit Kindern.
|
||||
*/
|
||||
function createTreenodeFromTag(tag: iTagWithChildren): iTreenode {
|
||||
const children = tag.children.map(child => createTreenodeFromTag(child));
|
||||
return new Treenode(tag, true, children);
|
||||
const children = tag.children.map(child => createTreenodeFromTag(child))
|
||||
return new Treenode(tag, true, children)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zaehlt alle Nachfahren eines Treenodes rekursiv (fuer die
|
||||
* Loesch-Bestaetigungsmeldung).
|
||||
*/
|
||||
function countDescendants(node: iTreenode): number {
|
||||
let count = 0
|
||||
for (const child of node.children) {
|
||||
count++
|
||||
count += countDescendants(child)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft rekursiv, ob irgendein Nachfahre eines Treenodes auf den
|
||||
* Suchbegriff passt (Name oder Beschreibung, case-insensitive).
|
||||
*/
|
||||
function descendantMatches(node: iTreenode, searchLower: string): boolean {
|
||||
for (const child of node.children) {
|
||||
const tag: iTag = child.nodevalue
|
||||
if (tag.name.toLowerCase().includes(searchLower) ||
|
||||
(tag.description || "").toLowerCase().includes(searchLower)) {
|
||||
return true
|
||||
}
|
||||
if (descendantMatches(child, searchLower)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// KOMPONENTEN
|
||||
// ============================================================================
|
||||
|
||||
export const Tag: m.ClosureComponent = (vnode) => {
|
||||
export const Tag: m.ClosureComponent = () => {
|
||||
let currentTag: iTag = {
|
||||
id: -1,
|
||||
name: "",
|
||||
description: "",
|
||||
color: "",
|
||||
parentId: null,
|
||||
};
|
||||
parent_id: null,
|
||||
is_parent: false,
|
||||
}
|
||||
|
||||
// Liste aller Parent-Tags fuer die Eltern-Auswahl (nur Parent-Tags
|
||||
// koennen uebergeordnet sein)
|
||||
let allParentTags: iTag[] = []
|
||||
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
|
||||
const guard = new DirtyMaskHelper("/taglist")
|
||||
|
||||
const setColor = (color: string) => {
|
||||
currentTag.color = color;
|
||||
};
|
||||
|
||||
// Liste aller Tags für die Eltern-Auswahl
|
||||
const allTags = tagDaten.filter(t => t.id !== currentTag.id); // Ausschließen des aktuellen Tags
|
||||
currentTag.color = color
|
||||
guard.markDirty()
|
||||
}
|
||||
|
||||
return {
|
||||
oninit: (vnode) => {
|
||||
const routeParams = m.route.param();
|
||||
if (routeParams) {
|
||||
const routeParams = m.route.param()
|
||||
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
|
||||
guard.registerGuard()
|
||||
if (id !== -1) {
|
||||
// Bestehenden Tag frisch vom Server laden
|
||||
fetchTagById(id).then((result) => {
|
||||
currentTag = result
|
||||
}).catch((e) => showServerError(e))
|
||||
} else if (routeParams) {
|
||||
// Neuer Tag: vorbelegte Werte aus Route-Params uebernehmen
|
||||
currentTag = {
|
||||
id: routeParams.id ? parseInt(routeParams.id) : -1,
|
||||
id: -1,
|
||||
name: routeParams.name || "",
|
||||
description: routeParams.description || "",
|
||||
color: routeParams.color || "",
|
||||
parentId: routeParams.parentId ? parseInt(routeParams.parentId) : null,
|
||||
icon: routeParams.icon || undefined,
|
||||
order: routeParams.order ? parseInt(routeParams.order) : undefined,
|
||||
};
|
||||
parent_id: routeParams.parent_id ? parseInt(routeParams.parent_id) : null,
|
||||
is_parent: routeParams.is_parent ? routeParams.is_parent === "true" : false,
|
||||
}
|
||||
}
|
||||
// Parent-Tag-Liste vom Server laden (nur is_parent-Tags sind
|
||||
// als uebergeordnete Tags zulaessig). Der aktuelle Tag wird
|
||||
// ausgeschlossen, damit er nicht sein eigener Parent wird.
|
||||
fetchTags().then((result) => {
|
||||
allParentTags = result.filter(t => t.id !== currentTag.id && t.is_parent)
|
||||
}).catch((e) => showServerError(e))
|
||||
},
|
||||
onremove: (vnode) => {
|
||||
guard.cleanup()
|
||||
},
|
||||
view: (vnode) => {
|
||||
const submit = (form: HTMLFormElement): boolean => {
|
||||
const formData = new FormData(form)
|
||||
const gespeicherterTag: iTag = {
|
||||
id: currentTag.id,
|
||||
name: (formData.get("name") as string) || "",
|
||||
description: (formData.get("description") as string) || "",
|
||||
color: currentTag.color || "",
|
||||
parent_id: formData.get("parent_id") ? parseInt(formData.get("parent_id") as string) : null,
|
||||
is_parent: currentTag.is_parent,
|
||||
}
|
||||
if (currentTag.id === -1) {
|
||||
createTagREST(gespeicherterTag).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
} else {
|
||||
updateTagREST(gespeicherterTag).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
}
|
||||
return true
|
||||
}
|
||||
return m("div.w3-container.w3-auto.form-view-container",
|
||||
m("h1", currentTag.id === -1 ? "Neuen Tag erstellen" : "Tag bearbeiten"),
|
||||
m("div", {style: {flex: '1', 'min-height': '0'}},
|
||||
m(Form, {showButtons: true, onsubmit: (null), oncancel: (null)} as iForm, [
|
||||
m("label", "Tag-Name", m(Input, {name: "name", value: currentTag.name, type: "text"} as iInput)),
|
||||
m("label", "Beschreibung", m(Input, {name: "description", value: currentTag.description, type: "text"} as iInput)),
|
||||
m(Form, guard.formAttrs(submit) as iForm, [
|
||||
m("label", "Tag-Name", m(Input, {name: "name", value: currentTag.name, type: "text", required: true, oninput: (e: Event) => { currentTag.name = (e.target as HTMLInputElement).value }} as iInput)),
|
||||
m("label", "Beschreibung", m(Input, {name: "description", value: currentTag.description, type: "text", oninput: (e: Event) => { currentTag.description = (e.target as HTMLInputElement).value }} as iInput)),
|
||||
|
||||
// Parent-Tag-Umschalter: true = Gruppierungs-Knoten (nicht zuordenbar)
|
||||
m("div", {style: {'margin-bottom': '5px'}},
|
||||
m("label",
|
||||
m("input.w3-check", {
|
||||
type: "checkbox",
|
||||
name: "is_parent",
|
||||
checked: currentTag.is_parent,
|
||||
style: {'margin-right': '8px'},
|
||||
onclick: (e: Event) => {
|
||||
currentTag.is_parent = (e.target as HTMLInputElement).checked
|
||||
guard.markDirty()
|
||||
}
|
||||
}),
|
||||
"Nur zur Gruppierung, kann nicht zugeordnet werden"
|
||||
)
|
||||
),
|
||||
|
||||
// Eltern-Tag-Auswahl
|
||||
m("label", "Übergeordneter Tag (optional)",
|
||||
m("select.w3-select", {name: "parentId", value: currentTag.parentId || ""},
|
||||
m("option", {value: ""}, "-- Kein übergeordneter Tag --"),
|
||||
allTags.map(tag =>
|
||||
m("option", {value: tag.id, selected: currentTag.parentId === tag.id}, tag.name)
|
||||
)
|
||||
m("label", "Übergeordneter Tag (optional)"),
|
||||
m("select.w3-select", {
|
||||
name: "parent_id",
|
||||
value: currentTag.parent_id || "",
|
||||
onchange: () => { guard.markDirty() }
|
||||
},
|
||||
m("option", {value: ""}, "-- Kein übergeordneter Tag --"),
|
||||
allParentTags.map(tag =>
|
||||
m("option", {value: tag.id, selected: currentTag.parent_id === tag.id}, tag.name)
|
||||
)
|
||||
),
|
||||
|
||||
@@ -547,6 +180,7 @@ export const Tag: m.ClosureComponent = (vnode) => {
|
||||
m("div", {style: {display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px'}},
|
||||
colorPalette.map(color =>
|
||||
m("button", {
|
||||
type: "button",
|
||||
style: {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
@@ -559,48 +193,102 @@ export const Tag: m.ClosureComponent = (vnode) => {
|
||||
})
|
||||
)
|
||||
),
|
||||
m(Input, {name: "color", value: currentTag.color, type: "text"} as iInput),
|
||||
m("label", "Icon (optional)", m(Input, {name: "icon", value: currentTag.icon || "", type: "text"} as iInput)),
|
||||
m("label", "Sortierreihenfolge (optional)", m(Input, {name: "order", value: currentTag.order?.toString() || "", type: "number"} as iInput)),
|
||||
])
|
||||
)
|
||||
);
|
||||
)
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const TagList: m.ClosureComponent = (vnode) => {
|
||||
let treenodes: iTreenode[] = createTagTreenodes();
|
||||
let selectedNode: iTreenode | null = null;
|
||||
export const TagList: m.ClosureComponent = () => {
|
||||
let treenodes: iTreenode[] = []
|
||||
let selectedNode: iTreenode | null = null
|
||||
|
||||
// Aktualisiere treenodes, wenn sich die Daten ändern
|
||||
const updateTreenodes = () => {
|
||||
treenodes = createTagTreenodes();
|
||||
selectedNode = null;
|
||||
};
|
||||
// Laedt die Hierarchie vom Server und erzeugt Treenodes
|
||||
const loadTreenodes = () => {
|
||||
fetchTagHierarchy().then((hierarchy) => {
|
||||
treenodes = createTagTreenodes(hierarchy)
|
||||
selectedNode = null
|
||||
}).catch((e) => showServerError(e))
|
||||
}
|
||||
|
||||
const handleRowMarked = (node: iTreenode) => {
|
||||
selectedNode = node;
|
||||
};
|
||||
selectedNode = node
|
||||
}
|
||||
|
||||
const editTag = (tag: iTag, e: Event) => {
|
||||
e.stopPropagation();
|
||||
m.route.set("/tag", {
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
description: tag.description,
|
||||
color: tag.color,
|
||||
parentId: tag.parentId,
|
||||
icon: tag.icon,
|
||||
order: tag.order
|
||||
});
|
||||
};
|
||||
e.stopPropagation()
|
||||
m.route.set("/tag", {id: tag.id})
|
||||
}
|
||||
|
||||
const deleteTag = (tag: iTag, node: iTreenode, e: Event) => {
|
||||
e.stopPropagation()
|
||||
const childCount = countDescendants(node)
|
||||
if (childCount === 0) {
|
||||
// Keine Kinder: einfache Bestaetigung
|
||||
if (window.confirm("Tag '" + tag.name + "' wirklich löschen?")) {
|
||||
deleteTagREST(tag.id).then(() => {
|
||||
loadTreenodes()
|
||||
}).catch((e) => showServerError(e))
|
||||
}
|
||||
} else {
|
||||
// Mit Kindern: Modal mit Hinweis auf kaskadierende Loeschung
|
||||
m.mount(gridMountpoints.modalhook, {
|
||||
view: () => {
|
||||
return m(ModalView,
|
||||
{
|
||||
title: "Tag löschen",
|
||||
type: ModalType.MESSAGE,
|
||||
width: '40%'
|
||||
} as iModalView,
|
||||
m("div.w3-panel",
|
||||
m("p", "Der Tag '" + tag.name + "' hat " + childCount + " untergeordnete Tags."),
|
||||
m("p", "Soll er inkl. aller untergeordneten Tags gelöscht werden?")
|
||||
),
|
||||
m("div.w3-bar.w3-padding-16",
|
||||
m("button.w3-button.w3-red.w3-left",
|
||||
{
|
||||
onclick: () => {
|
||||
deleteTagREST(tag.id).then(() => {
|
||||
m.mount(gridMountpoints.modalhook, null)
|
||||
loadTreenodes()
|
||||
}).catch((e) => showServerError(e))
|
||||
}
|
||||
},
|
||||
"Inkl. aller löschen"
|
||||
),
|
||||
m("button.w3-button.w3-theme.w3-right",
|
||||
{onclick: () => {m.mount(gridMountpoints.modalhook, null)}},
|
||||
"Abbrechen"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addTagRoute = () => {
|
||||
m.route.set("/tag", {id: -1})
|
||||
}
|
||||
|
||||
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
|
||||
let tag: iTag = node.nodevalue;
|
||||
let tag: iTag = node.nodevalue
|
||||
|
||||
// Parent-Tags erhalten ein "folder"-Icon, echte Tags ein "label"-Icon
|
||||
const tagIcon = tag.is_parent ? "folder" : "label"
|
||||
const tagTitle = tag.is_parent ? "Parent-Tag (nicht zuordenbar)" : ""
|
||||
|
||||
return [
|
||||
m("span",
|
||||
m("i.material-icons", {
|
||||
style: {
|
||||
verticalAlign: 'middle',
|
||||
marginRight: '6px',
|
||||
fontSize: '18px'
|
||||
},
|
||||
title: tagTitle
|
||||
}, tagIcon),
|
||||
m("span", {
|
||||
style: {
|
||||
backgroundColor: tag.color,
|
||||
@@ -610,40 +298,50 @@ export const TagList: m.ClosureComponent = (vnode) => {
|
||||
fontWeight: 'bold',
|
||||
display: 'inline-block',
|
||||
marginRight: '8px'
|
||||
}
|
||||
},
|
||||
title: tagTitle
|
||||
}, tag.name)
|
||||
),
|
||||
m("span", tag.description),
|
||||
m("div",
|
||||
m("button.w3-button.w3-small", {style: {padding: '0 2px'}, onclick: (e: Event) => editTag(tag, e)},
|
||||
m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap', 'justify-content': 'flex-end'}},
|
||||
m("button.w3-button.w3-small", {title: "Tag bearbeiten", style: {padding: '0 2px'}, onclick: (e: Event) => editTag(tag, e)},
|
||||
m("i.material-icons", "edit")
|
||||
),
|
||||
m("button.w3-button.w3-small", {title: "Tag löschen", style: {padding: '0 2px'}, onclick: (e: Event) => deleteTag(tag, node, e)},
|
||||
m("i.material-icons", "delete")
|
||||
)
|
||||
)
|
||||
];
|
||||
};
|
||||
]
|
||||
}
|
||||
|
||||
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
|
||||
let tag: iTag = node.nodevalue;
|
||||
let searchLower = searchvalue.toLowerCase();
|
||||
return tag.name.toLowerCase().includes(searchLower) ||
|
||||
tag.description.toLowerCase().includes(searchLower) ||
|
||||
tag.color.toLowerCase().includes(searchLower) ||
|
||||
(tag.icon && tag.icon.toLowerCase().includes(searchLower));
|
||||
};
|
||||
let tag: iTag = node.nodevalue
|
||||
let searchLower = searchvalue.toLowerCase()
|
||||
|
||||
// Prueft, ob der Tag selbst auf den Suchbegriff passt (nur Name und Beschreibung)
|
||||
const selfMatch = tag.name.toLowerCase().includes(searchLower) ||
|
||||
(tag.description || "").toLowerCase().includes(searchLower)
|
||||
|
||||
// Wenn der Tag selbst passt, anzeigen
|
||||
if (selfMatch) return true
|
||||
|
||||
// Wenn der Tag Kinder hat, pruefen, ob ein Nachfahre passt
|
||||
if (searchLower === "") return true
|
||||
return descendantMatches(node, searchLower)
|
||||
}
|
||||
|
||||
return {
|
||||
oninit: (vnode) => {
|
||||
loadTreenodes()
|
||||
},
|
||||
view: (vnode) => {
|
||||
return m("div.w3-container", {
|
||||
return m("div.w3-container.w3-auto", {
|
||||
style: {
|
||||
'max-width': '80%',
|
||||
'margin-left': 'auto',
|
||||
'margin-right': 'auto',
|
||||
display: 'flex',
|
||||
'flex-direction': 'column',
|
||||
height: 'inherit',
|
||||
'overflow-y': 'inherit'}},
|
||||
m("h1", "Tags"),
|
||||
m("p.w3-text-grey", "Hierarchie: Tags können beliebig verschachtelt werden (z. B. Backend → Python → Flask)."),
|
||||
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
||||
m(Treetable,
|
||||
{
|
||||
@@ -652,27 +350,13 @@ export const TagList: m.ClosureComponent = (vnode) => {
|
||||
filter: filterFunction,
|
||||
onRowMarked: handleRowMarked,
|
||||
isSelected: (node: iTreenode) => node === selectedNode
|
||||
} as iTreetable
|
||||
} as iTreetable,
|
||||
m(TopbarButton, {onclick: addTagRoute, tooltip: "Neuen Tag anlegen"} as iTopbarButton,
|
||||
m("i.material-icons", "add")
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
)
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// EXPORTE FÜR EXTERNE NUTZUNG
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
tagDaten as tags, // Flache Liste aller Tags
|
||||
getFullHierarchy as hierarchy, // Vollständige hierarchische Struktur
|
||||
findTagById,
|
||||
getTagPath,
|
||||
getDescendants,
|
||||
getAncestors,
|
||||
searchTagsByName,
|
||||
addTag,
|
||||
updateTag,
|
||||
getRootTags,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,9 @@ export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
|
||||
onRowMarked: onRowMarked,
|
||||
isSelected: isSelected,
|
||||
isEven: ((z % 2) == 0),
|
||||
hasSiblingsWithChildren: topHasSiblingsWithChildren} as iTreerow)
|
||||
hasSiblingsWithChildren: topHasSiblingsWithChildren,
|
||||
filter: vnode.attrs.filter,
|
||||
searchvalue: searchvalue} as iTreerow)
|
||||
}
|
||||
else {
|
||||
return null
|
||||
@@ -114,6 +116,12 @@ interface iTreerow {
|
||||
* den Platzhalter-Pfeil nur dann zu rendern, wenn es Nodes mit
|
||||
* Pfeil gibt, an denen der Text ausgerichtet werden kann. */
|
||||
hasSiblingsWithChildren?: boolean
|
||||
/** Optional: Filter-Funktion, die auch auf Kind-Knoten angewendet wird.
|
||||
* Wenn gesetzt, werden Kind-Knoten nur gerendert, wenn der Filter
|
||||
* für sie true zurückgibt. */
|
||||
filter?: (row: iTreenode, searchvalue: string) => boolean
|
||||
/** Aktueller Suchwert, der an den Filter weitergereicht wird. */
|
||||
searchvalue?: string
|
||||
}
|
||||
let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
|
||||
|
||||
@@ -178,7 +186,9 @@ let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
|
||||
// Falls der Trennode Kinder hat und expandiert werden soll, wird rekursiv die Trennode-Komponente für
|
||||
// die Child-Nodes aufgerufen, ansonsten wir null zurückgegeben und die Einrückung hört auf
|
||||
vnode.attrs.treenode.hasChildren && vnode.attrs.treenode.expand
|
||||
? vnode.attrs.treenode.children.map((tn: iTreenode, z) => {
|
||||
? vnode.attrs.treenode.children
|
||||
.filter(tn => !vnode.attrs.filter || vnode.attrs.filter(tn, vnode.attrs.searchvalue || ""))
|
||||
.map((tn: iTreenode, z) => {
|
||||
return m(Treerow, {
|
||||
treenode: tn,
|
||||
level: vnode.attrs.level + 1,
|
||||
@@ -186,7 +196,9 @@ let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
|
||||
onRowMarked: vnode.attrs.onRowMarked,
|
||||
isSelected: vnode.attrs.isSelected,
|
||||
isEven: ((z % 2) == 0),
|
||||
hasSiblingsWithChildren: childHasSiblingsWithChildren
|
||||
hasSiblingsWithChildren: childHasSiblingsWithChildren,
|
||||
filter: vnode.attrs.filter,
|
||||
searchvalue: vnode.attrs.searchvalue
|
||||
} as iTreerow)
|
||||
})
|
||||
: null
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,13 @@ export interface iForm {
|
||||
stickyButtons?: boolean
|
||||
/** Die Funktion gibt das HTMLFormElement zurück, welches in der Form-Componente angelegt wird */
|
||||
onformcreate?: (form:HTMLFormElement) => void
|
||||
/** Callback bei Änderung des Dirty-State. Wird mit true aufgerufen,
|
||||
* sobald der Nutzer Eingaben vornimmt, und mit false, nachdem das
|
||||
* Formular erfolgreich gespeichert oder zurückgesetzt wurde. Die
|
||||
* umgebende Maske nutzt dies, um ihren eigenen isDirty-State mit dem
|
||||
* der Form synchron zu halten und so den Navigations-Guard
|
||||
* (dirtyguard.ts) zu speisen. */
|
||||
onDirtyChange?: (dirty: boolean) => void
|
||||
}
|
||||
|
||||
export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
@@ -29,6 +36,9 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
}
|
||||
function onchanged (e:Event) {
|
||||
isDirty = true
|
||||
if (vnode.attrs.onDirtyChange != undefined) {
|
||||
vnode.attrs.onDirtyChange(true)
|
||||
}
|
||||
if (vnode.attrs.onchanged != undefined) {
|
||||
vnode.attrs.onchanged(e)
|
||||
}
|
||||
@@ -37,6 +47,9 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
e.preventDefault()
|
||||
if (vnode.attrs.onsubmit(form) == true) {
|
||||
isDirty = false
|
||||
if (vnode.attrs.onDirtyChange != undefined) {
|
||||
vnode.attrs.onDirtyChange(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
function registerFormElement (f:HTMLFormElement) {
|
||||
@@ -46,22 +59,24 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
}
|
||||
function cancel(e:Event) {
|
||||
e.preventDefault()
|
||||
if (canCloseForm() == true) {
|
||||
isDirty = false
|
||||
if (vnode.attrs.oncancel != undefined) {
|
||||
vnode.attrs.oncancel(e)
|
||||
}
|
||||
else {
|
||||
history.back()
|
||||
}
|
||||
// Der Dirty-Check beim Abbrechen erfolgt jetzt durch den
|
||||
// Navigations-Guard der Maske (dirtyguard.ts), da oncancel die
|
||||
// Navigation auslöst. Die Form selbst fragt hier nicht mehr nach,
|
||||
// um eine doppelte Bestätigung zu vermeiden. isDirty wird nicht
|
||||
// zurückgesetzt, damit der Guard den aktuellen State sieht.
|
||||
if (vnode.attrs.oncancel != undefined) {
|
||||
vnode.attrs.oncancel(e)
|
||||
}
|
||||
else {
|
||||
e['redraw'] = false
|
||||
history.back()
|
||||
}
|
||||
}
|
||||
function reset(e:Event) {
|
||||
if (canCloseForm("reset")) {
|
||||
isDirty = false
|
||||
if (vnode.attrs.onDirtyChange != undefined) {
|
||||
vnode.attrs.onDirtyChange(false)
|
||||
}
|
||||
// Hier passiert nichts.
|
||||
// Da mithril nach jedem User-Event (wie z.B. einem 'Click') ein redraw() ausführt, wird hier die Form
|
||||
// neu gezeichnet und zwar mit den ursprünglichen Daten aus den zu grundeliegenden Datenmodel, das ja
|
||||
@@ -109,6 +124,10 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
for (let index = 0; index < elements.length; index++) {
|
||||
const element = elements[index];
|
||||
element.addEventListener("change", onchanged)
|
||||
// 'input' feuert schon während des Tippens (vor
|
||||
// Blur), so dass die Maske dirty wird, auch wenn
|
||||
// der Nutzer sofort eine Breadcrumb klickt.
|
||||
element.addEventListener("input", onchanged)
|
||||
}
|
||||
registerFormElement(form)
|
||||
}
|
||||
@@ -131,6 +150,10 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
for (let index = 0; index < elements.length; index++) {
|
||||
const element = elements[index];
|
||||
element.addEventListener("change", onchanged)
|
||||
// 'input' feuert schon während des Tippens (vor
|
||||
// Blur), so dass die Maske dirty wird, auch wenn
|
||||
// der Nutzer sofort eine Breadcrumb klickt.
|
||||
element.addEventListener("input", onchanged)
|
||||
}
|
||||
registerFormElement(form)
|
||||
}
|
||||
|
||||
@@ -23,11 +23,13 @@ export interface iInput {
|
||||
readonly?: boolean
|
||||
required?:boolean
|
||||
onchanged?: Function
|
||||
oninput?: Function
|
||||
}
|
||||
/**
|
||||
* Diese Komponente stellt ein Html-Input Element dar. Der darzustellende Wert wird einfach mit dem Attribut
|
||||
* 'value' übergeben. Der eingegebene Wert muss dann später explizit aus Value abgefragt werden. Es findet also kein
|
||||
* sutomatisches Binding statt.
|
||||
* sutomatisches Binding statt. Über den oninput-Callback kann jedoch ein Live-Binding realisiert werden,
|
||||
* damit Eingaben bei einem Redraw (z.B. durch interaktive Elemente außerhalb des Inputs) nicht verloren gehen.
|
||||
*/
|
||||
export const Input:m.Component<iInput> = {
|
||||
onupdate: (vnode) => {
|
||||
@@ -71,6 +73,9 @@ export const Input:m.Component<iInput> = {
|
||||
if (vnode.attrs.onchanged != undefined) {
|
||||
attributes['onchange'] = vnode.attrs.onchanged
|
||||
}
|
||||
if (vnode.attrs.oninput != undefined) {
|
||||
attributes['oninput'] = vnode.attrs.oninput
|
||||
}
|
||||
|
||||
|
||||
return m("input.w3-input.w3-border", attributes)
|
||||
|
||||
@@ -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
@@ -11,6 +11,7 @@ import { Call_Log, call_log_liste } from "./Components/call_log"
|
||||
import { Kontakt, kontaktliste } from "./Components/kontakt"
|
||||
import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse"
|
||||
import { Tag, TagList } from "./Components/tags"
|
||||
import { guardedNavigate } from "./Components/widgets/dirtyguard"
|
||||
|
||||
|
||||
function healthcheck () {
|
||||
@@ -118,21 +119,29 @@ const knopf: m.Component = {
|
||||
}
|
||||
const menu: m.ClosureComponent = () => {
|
||||
let showProjektBerechtigungen = false
|
||||
// Menü-Eintrag, der die Navigation über den Dirty-Guard leitet, so dass
|
||||
// aus einer Bearbeitungsmaske heraus ungespeicherte Änderungen abgefragt
|
||||
// werden, bevor die Ansicht wechselt.
|
||||
const menuItem = (route: string, label: string) => {
|
||||
return m("a.w3-button.w3-bar-item",
|
||||
{href: "#", onclick: (e: Event) => {e.preventDefault(); guardedNavigate(route, undefined, e)}},
|
||||
label)
|
||||
}
|
||||
return {
|
||||
oninit: (vnode) => {
|
||||
showProjektBerechtigungen = false
|
||||
},
|
||||
view: (vnode) => {
|
||||
return m(menubar, [
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/kundenliste"}, "Kunden"),
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/projektliste"}, "Projekte"),
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/calllogliste"}, "Gesprächsnotizen"),
|
||||
menuItem("/kundenliste", "Kunden"),
|
||||
menuItem("/projektliste", "Projekte"),
|
||||
menuItem("/calllogliste", "Gesprächsnotizen"),
|
||||
m("div.w3-dropdown-hover",
|
||||
m("button.w3-button", "Optionen"),
|
||||
m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/taglist"}, "Tags"),
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenexport"),
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenimport"),
|
||||
menuItem("/taglist", "Tags"),
|
||||
menuItem("/default", "Datenexport"),
|
||||
menuItem("/default", "Datenimport"),
|
||||
// Projekt-Berechtigungen nur für SysAdmin/AppAdmin sichtbar.
|
||||
// Als Toggle-Button realisiert, da verschachtelte w3-dropdown-hover
|
||||
// innerhalb eines offenen Dropdowns nicht zuverlässig funktionieren.
|
||||
@@ -147,10 +156,10 @@ const menu: m.ClosureComponent = () => {
|
||||
showProjektBerechtigungen
|
||||
? m("div.w3-bar-block",
|
||||
m("a.w3-button.w3-bar-item",
|
||||
{href: "#!/projektuserliste", style: {'padding-left': '32px'}},
|
||||
{href: "#", style: {'padding-left': '32px'}, onclick: (e: Event) => {e.preventDefault(); guardedNavigate("/projektuserliste", undefined, e)}},
|
||||
"Projekt-Zugriffe"),
|
||||
m("a.w3-button.w3-bar-item",
|
||||
{href: "#!/userprojektliste", style: {'padding-left': '32px'}},
|
||||
{href: "#", style: {'padding-left': '32px'}, onclick: (e: Event) => {e.preventDefault(); guardedNavigate("/userprojektliste", undefined, e)}},
|
||||
"Nutzer-Zugriffe")
|
||||
)
|
||||
: null
|
||||
@@ -162,9 +171,9 @@ const menu: m.ClosureComponent = () => {
|
||||
m("div.w3-dropdown-hover.w3-right",
|
||||
m("button.w3-button", "System"),
|
||||
m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/login"}, "Login"),
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/changepassword"}, "Kennwort ändern"),
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/usermanagement"}, "Verwalten"),
|
||||
menuItem("/login", "Login"),
|
||||
menuItem("/changepassword", "Kennwort ändern"),
|
||||
menuItem("/usermanagement", "Verwalten"),
|
||||
)
|
||||
)
|
||||
])
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import m from "mithril"
|
||||
import {iTag} from "./tag"
|
||||
|
||||
/**
|
||||
* Datenmodell für eine Gesprächsnotiz (Call-Log).
|
||||
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
|
||||
* projekt_name und kunden_name sind keine ORM-Spalten, sondern werden
|
||||
* serverseitig aus der Projekt-Relationship abgeleitet und im JSON ergänzt.
|
||||
* tags (vom Server beim Lesen) und tag_ids (vom Client beim Schreiben)
|
||||
* steuern die n:m-Beziehung zu den Tags.
|
||||
*/
|
||||
export interface iCallLog {
|
||||
id: number
|
||||
@@ -20,6 +23,10 @@ export interface iCallLog {
|
||||
notiz: string
|
||||
wiedervorlage_datum: string
|
||||
erledigt: boolean
|
||||
/** Vom Server gelieferte zugeordnete Tags (nur beim Lesen). */
|
||||
tags?: iTag[]
|
||||
/** Vom Client zu übermittelnde Tag-IDs (nur beim Schreiben). */
|
||||
tag_ids?: number[]
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user