diff --git a/App_Server/root/crm/model/__init__.py b/App_Server/root/crm/model/__init__.py index 3c53156..f1aff32 100644 --- a/App_Server/root/crm/model/__init__.py +++ b/App_Server/root/crm/model/__init__.py @@ -263,12 +263,14 @@ class Kontaktadresse(dbapp.Model): def to_dict(self): """ Liefert die Kontaktdaten als Dictionary. Die Tabellenspalten werden - über columns2dict serialisiert; projekt_name wird zusätzlich aus der - Projekt-Relationship eingefügt. is_erledigt wird dynamisch aus den - Gesprächsnotizen des Kontakts berechnet. + über columns2dict serialisiert; projekt_name und kunden_name werden + zusätzlich aus der Projekt- bzw. Kunde-Relationship eingefügt. + is_erledigt wird dynamisch aus den Gesprächsnotizen des Kontakts + berechnet. """ result = columns2dict(self) result['projekt_name'] = self.projekt.projektname if self.projekt else None + result['kunden_name'] = self.projekt.kunde.name if self.projekt and self.projekt.kunde else None result['is_erledigt'] = self.is_erledigt() return result @@ -296,6 +298,14 @@ class CallLog(dbapp.Model): # NOT NULL) — die Pflicht wird über die Server-Validierung in # crm.model.call_log durchgesetzt. kontaktadresse_id = Column(Integer, ForeignKey('kontaktadressen.id'), nullable=False) + # Ersteller der Gesprächsnotiz (Mitarbeiter, der den Anruf getätigt + # hat). Referenziert logisch die 'id' in der Auth-Datenbanktabelle + # 'users'; ein physischer Fremdschlüssel ist aufgrund der zwei + # getrennten SQLite-Datenbanken nicht möglich (Muster wie + # projekt_users.user_id). nullable=True, da Alt-Notizen (angelegt + # vor der Spalte) keinen Ersteller erfassen konnten; die Auflösung + # auf den username erfolgt zur Laufzeit über eine separate Query. + user_id = Column(Integer, nullable=True) datum = Column(Date, nullable=True) uhrzeit_beginn = Column(String(10), nullable=True) dauer = Column(String(10), nullable=True) @@ -319,8 +329,10 @@ class CallLog(dbapp.Model): """ 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. Die zugeordneten - Tags werden als Liste von Tag-Dictionaries unter 'tags' ergänzt. + zusätzlich aus der Projekt-Relationship eingefügt. mitarbeiter_name + löst die user_id gegen die Auth-Datenbank auf (None bei Alt-Notizen + ohne erfassten Ersteller). Die zugeordneten Tags werden als Liste + von Tag-Dictionaries unter 'tags' ergänzt. """ result = columns2dict(self) if self.projekt: @@ -329,6 +341,14 @@ class CallLog(dbapp.Model): else: result['projekt_name'] = None result['kunden_name'] = None + # Ersteller gegen die Auth-Datenbank auflösen (Muster wie + # crm.model.projekt_user). Alt-Notizen ohne user_id erhalten None. + result['mitarbeiter_name'] = None + if self.user_id is not None: + from auth.model import User + user = User.query.get(self.user_id) + if user is not None: + result['mitarbeiter_name'] = user.username # Zugeordnete Tags serialisieren (nur is_parent=False sind zuordenbar). result['tags'] = [clt.tag.to_dict() for clt in self.tags_rel] return result diff --git a/App_Server/root/crm/model/call_log.py b/App_Server/root/crm/model/call_log.py index 712df8a..57cc3d2 100644 --- a/App_Server/root/crm/model/call_log.py +++ b/App_Server/root/crm/model/call_log.py @@ -94,13 +94,17 @@ def _get_validierte_kontaktadresse_id(data: dict, projekt_id) -> int: return kontaktadresse.id -def get_call_logs(projekt_id: int | None = None, user_id: int | None = None): +def get_call_logs(projekt_id: int | None = None, + user_id: int | None = None, + kontaktadresse_id: int | None = None): """ Liefert Gesprächsnotizen, sortiert nach Datum absteigend. Ist projekt_id gesetzt, werden nur die Notizen dieses Projekts - zurückgegeben (serverseitige Filterung). Ohne projekt_id werden - alle Notizen geliefert. + zurückgegeben (serverseitige Filterung). Ist kontaktadresse_id + gesetzt, werden nur die Notizen dieses Kontakts zurückgegeben — + beide Filter können kombiniert werden. Ohne Filter werden alle + Notizen geliefert. Ist user_id gesetzt, werden nur Notizen von Projekten geliefert, denen der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits-Filterung). @@ -109,6 +113,8 @@ def get_call_logs(projekt_id: int | None = None, user_id: int | None = None): query = CallLog.query if projekt_id is not None: query = query.filter(CallLog.projekt_id == projekt_id) + if kontaktadresse_id is not None: + query = query.filter(CallLog.kontaktadresse_id == kontaktadresse_id) if user_id is not None: # Nur Notizen von Projekten, denen der Nutzer zugeordnet ist. query = query.filter( @@ -148,7 +154,7 @@ def get_call_log(call_log_id: int, user_id: int | None = None) -> CallLog: return call_log -def create_call_log(data: dict) -> CallLog: +def create_call_log(data: dict, user_id: int | None = None) -> CallLog: """ Legt eine neue Gesprächsnotiz an. Die vom Client übergebene ID wird ignoriert; die ID wird serverseitig per Auto-Increment vergeben. @@ -156,6 +162,11 @@ def create_call_log(data: dict) -> CallLog: - data: dict mit den Feldern projekt_id, kontaktadresse_id, datum, uhrzeit_beginn, dauer, betreff, name_ansprechpartner, telefon, notiz, wiedervorlage_datum, erledigt, tag_ids + - user_id: ID des angemeldeten Mitarbeiters, der die Notiz erfasst + (Ersteller). Wird von der Route übergeben (current_user.id), + damit diese Funktion frei vom Flask-Request-Kontext bleibt. + Der Ersteller wird beim Anlegen gesetzt und beim späteren + Update nicht mehr verändert. """ projekt_id = data.get("projekt_id") if projekt_id is None: @@ -173,6 +184,12 @@ def create_call_log(data: dict) -> CallLog: call_log = CallLog() call_log.projekt_id = int(projekt_id) call_log.kontaktadresse_id = kontaktadresse_id + # Ersteller erfassen: welcher Mitarbeiter den Anruf getätigt hat. + # Alt-Notizen (vor der user_id-Spalte angelegt) haben NULL; neue + # Notizen ohne Angabe erhalten ebenfalls NULL, bleiben aber die + # Ausnahme, da die Route immer current_user.id übergibt. + if user_id is not None: + call_log.user_id = int(user_id) 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 diff --git a/App_Server/root/crm/model/checkdb.py b/App_Server/root/crm/model/checkdb.py index da736de..0273987 100644 --- a/App_Server/root/crm/model/checkdb.py +++ b/App_Server/root/crm/model/checkdb.py @@ -325,6 +325,47 @@ def _db_V01_13(): session.commit() +def _db_V01_14(): + """ + Fügt der Tabelle 'call_logs' die Spalte 'user_id' hinzu: Jede + Gesprächsnotiz erfasst, welcher Mitarbeiter den Anruf getätigt hat. + Die Spalte referenziert logisch die 'id' in der Auth-Datenbanktabelle + 'users'; ein physischer Fremdschlüssel ist aufgrund der zwei + getrennten SQLite-Datenbanken nicht möglich (Muster wie + projekt_users.user_id). + + Kein Backfill: Für bestehende CallLogs ist der Ersteller nicht mehr + ermittelbar — die Spalte bleibt dort NULL (Anzeige 'unbekannt'). + Neue Notizen erhalten die user_id serverseitig über current_user.id + (siehe crm.routes.calllog_create). + + Idempotent: Bei Neuinstallation hat create_all die Spalte bereits + angelegt — in diesem Fall wird das ALTER TABLE übersprungen. Der + Index ist über IF NOT EXISTS ebenfalls idempotent. + """ + from sqlalchemy import text + + result = session.execute(text("PRAGMA table_info(call_logs)")) + spalten = {row[1] for row in result} + if 'user_id' not in spalten: + session.execute(text( + "ALTER TABLE call_logs ADD COLUMN user_id INTEGER" + )) + session.execute(text( + "CREATE INDEX IF NOT EXISTS ix_call_logs_user_id" + " ON call_logs (user_id)" + )) + # Roh-SQL-Änderungen committen, BEVOR die Systeminfo aktualisiert wird + # (dasselbe Muster wie _db_V01_08 und _db_V01_13): Die DDL-Anweisung + # läuft über die Default-Bind-Verbindung — SQLALCHEMY_DATABASE_URI + # zeigt auf dieselbe Datei wie der appdb-Bind. Das ORM-Update der + # Systeminfo läuft über die appdb-Bind-Verbindung; ohne dieses Commit + # blockiert die offene Transaktion das UPDATE ('database is locked'). + session.commit() + _updatesysteminfo('01.14') + session.commit() + + def checkupdates(): """ Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle diff --git a/App_Server/root/crm/model/tages_workflow.py b/App_Server/root/crm/model/tages_workflow.py new file mode 100644 index 0000000..a611dea --- /dev/null +++ b/App_Server/root/crm/model/tages_workflow.py @@ -0,0 +1,203 @@ +""" +Geschäftslogik für den Tages-Workflow (Telefon-Akquise). + +Aggregiert pro Kontaktadresse die für den Tages-Workflow relevanten +Informationen: letzte Gesprächsnotiz, aktuell gültiges Wiedervorlage-Datum +und dynamisch berechneter Erledigt-Status. Filter (Kunde, Status, +Wiedervorlage-Zeitraum) und die priorisierte Sortierung sind Teil der +Aggregation — der Client erhält eine fertige, gefilterte Liste. + +Aufgerufen werden die Funktionen aus crm/routes.py; die Autorisierungs- +prüfung erfolgt dort über den Decorator authorisation_required. + +:author: Mistral Vibe +:date: 2026-09-23 +:version: 1.0 +""" +from datetime import date, timedelta + +from db import dbapp +from crm.model import Kontaktadresse, Projekt, ProjektUser + +session = dbapp.session + + +def _naechste_woche_start_und_ende(heute: date) -> tuple[date, date]: + """ + Liefert Montag und Sonntag der ISO-Kalenderwoche, in die der + übergebene Tag fällt. + """ + jahr, woche, _ = heute.isocalendar() + montag = date.fromisocalendar(jahr, woche, 1) + sonntag = montag + timedelta(days=6) + return montag, sonntag + + +def _ist_im_wiedervorlage_zeitraum(wiedervorlage_datum: date | None, + zeitraum: str | None, + heute: date) -> bool: + """ + Prüft, ob das Wiedervorlage-Datum in den angeforderten Zeitraum + fällt. Ohne Zeitraum (None) ist die Prüfung immer wahr. + + Kontakte ohne wiedervorlage_datum fallen bei gesetztem Zeitraum + heraus — auch Kontakte ohne Gesprächsnotizen, da für sie kein + Wiedervorlage-Datum existiert. + """ + if not zeitraum: + return True + if wiedervorlage_datum is None: + return False + if zeitraum == "heute": + return wiedervorlage_datum == heute + if zeitraum == "3tage": + return heute <= wiedervorlage_datum <= heute + timedelta(days=3) + if zeitraum == "ueberfaellig": + return wiedervorlage_datum < heute + if zeitraum == "woche": + montag, sonntag = _naechste_woche_start_und_ende(heute) + return montag <= wiedervorlage_datum <= sonntag + # Unbekannte Zeitraum-Angaben wirken wie kein Filter. + return True + + +def _sortierschlüssel(eintrag: dict, heute: date) -> tuple: + """ + Priorisierte Sortierung der Tages-Workflow-Liste (Konzept 2.2): + + - Prio 0: überfällige Wiedervorlagen (wiedervorlage < heute), + aufsteigend nach Datum + - Prio 1: Kontakte ohne Gesprächsnotizen + - Prio 2: Rest (heute/zukünftig), aufsteigend nach Wiedervorlage; + Notizen ohne Wiedervorlage ans Ende von Prio 2 + + Innerhalb gleicher Priorität/Datum entscheidet der Kontaktname. + """ + wiedervorlage = eintrag["wiedervorlage_datum"] + hat_notizen = eintrag["letzte_notiz"] is not None + # wiedervorlage ist ein ISO-String (yyyy-MM-dd) und sortiert lexikalisch + # genauso wie das zugehörige date — daher String-Vergleich mit dem + # isoformat() des heutigen Tages. + heute_str = heute.isoformat() + if wiedervorlage is not None and wiedervorlage < heute_str: + prio = 0 + elif not hat_notizen: + prio = 1 + else: + prio = 2 + # Ohne Wiedervorlage ans Ende der Prioritätsgruppe sortieren. + datum_key = wiedervorlage if wiedervorlage is not None else "9999-12-31" + return (prio, datum_key, eintrag["name"]) + + +def get_tages_workflow_list(user_id: int | None = None, + kunden_id: int | None = None, + status: str | None = None, + wiedervorlage: str | None = None) -> list[dict]: + """ + Liefert die Tages-Workflow-Liste: pro Kontaktadresse ein Dictionary + mit den Kontakt- und Projektdaten, der letzten Gesprächsnotiz, dem + aktuell gültigen Wiedervorlage-Datum und dem Erledigt-Status. + + Das aktuell gültige Wiedervorlage-Datum ist das wiedervorlage_datum + der jüngsten Gesprächsnotiz, die eines gesetzt hat (Reihenfolge nach + datum/uhrzeit_beginn absteigend). Spätere Notizen ohne Wiedervorlage + heben eine früher gesetzte nicht auf. + + Ist user_id gesetzt, werden nur Kontaktadressen von Projekten geliefert, + denen der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits- + Filterung). SysAdmin und AppAdmin übergeben user_id=None und sehen alle. + + Filter (alle optional): + - kunden_id: nur Kontaktadressen von Projekten dieses Kunden + - status: 'offen' (nicht erledigte) oder 'erledigt'; alles andere + wirkt wie kein Filter + - wiedervorlage: 'heute', '3tage' (bis heute+3), 'ueberfaellig' + (< heute) oder 'woche' (ISO-Kalenderwoche des heutigen Tages) + """ + heute = date.today() + query = Kontaktadresse.query + if kunden_id is not None: + # Nur Kontaktadressen von Projekten des ausgewählten Kunden. + query = query.filter( + Kontaktadresse.projekt_id.in_( + dbapp.session.query(Projekt.id).filter( + Projekt.kunden_id == kunden_id + ) + ) + ) + if user_id is not None: + # Nur Kontaktadressen von Projekten, denen der Nutzer zugeordnet ist. + query = query.filter( + Kontaktadresse.projekt_id.in_( + dbapp.session.query(ProjektUser.projekt_id).filter( + ProjektUser.user_id == user_id + ) + ) + ) + kontaktadressen = query.all() + + eintraege = [] + for kontaktadresse in kontaktadressen: + # Gesprächsnotizen des Kontakts, jüngste zuerst. uhrzeit_beginn + # ist ein 'HH:MM'-String und sortiert lexikalisch korrekt. + logs = sorted( + kontaktadresse.call_logs, + key=lambda log: (log.datum or date.min, log.uhrzeit_beginn or ""), + reverse=True, + ) + letzte_notiz = logs[0] if logs else None + # Aktuell gültiges Wiedervorlage-Datum: jüngste Notiz, die + # eines gesetzt hat. + wiedervorlage_datum = next( + (log.wiedervorlage_datum for log in logs + if log.wiedervorlage_datum is not None), + None, + ) + ist_erledigt = kontaktadresse.is_erledigt() + + # Status-Filter: 'offen' bzw. 'erledigt'; alles andere wirkt + # wie kein Filter. + if status == "offen" and ist_erledigt: + continue + if status == "erledigt" and not ist_erledigt: + continue + # Wiedervorlage-Zeitraum-Filter. + if not _ist_im_wiedervorlage_zeitraum(wiedervorlage_datum, + wiedervorlage, heute): + continue + + projekt = kontaktadresse.projekt + eintrag = { + 'kontaktadresse_id': kontaktadresse.id, + 'projekt_id': kontaktadresse.projekt_id, + 'projekt_name': projekt.projektname if projekt else None, + 'kunden_id': projekt.kunden_id if projekt else None, + 'kunden_name': projekt.kunde.name if projekt and projekt.kunde else None, + 'anrede': kontaktadresse.anrede, + 'name': kontaktadresse.name, + 'firma': kontaktadresse.firma, + 'position': kontaktadresse.position, + 'telefon': kontaktadresse.telefon, + 'email': kontaktadresse.email, + 'is_erledigt': ist_erledigt, + # Text der letzten Gesprächsnotiz (leerer String, falls der + # jüngste Log keinen Notiztext enthält) bzw. None, wenn der + # Kontakt noch keine Gesprächsnotiz hat. + 'letzte_notiz': (letzte_notiz.notiz or "") if letzte_notiz else None, + 'letzte_notiz_datum': letzte_notiz.datum.isoformat() if letzte_notiz and letzte_notiz.datum else None, + 'letzte_notiz_uhrzeit': letzte_notiz.uhrzeit_beginn if letzte_notiz else None, + # Tags der letzten Gesprächsnotiz (für die Listen-Anzeige); + # leere Liste, wenn der Kontakt noch keine Notiz hat. + 'letzte_notiz_tags': ( + [clt.tag.to_dict() for clt in letzte_notiz.tags_rel] + if letzte_notiz else [] + ), + 'wiedervorlage_datum': wiedervorlage_datum.isoformat() if wiedervorlage_datum else None, + } + eintraege.append(eintrag) + + # Priorisierte Sortierung: überfällige Wiedervorlagen zuerst, dann + # Kontakte ohne Gesprächsnotizen, dann der Rest nach Wiedervorlage. + eintraege.sort(key=lambda eintrag: _sortierschlüssel(eintrag, heute)) + return eintraege diff --git a/App_Server/root/crm/routes.py b/App_Server/root/crm/routes.py index f721c04..cae3826 100644 --- a/App_Server/root/crm/routes.py +++ b/App_Server/root/crm/routes.py @@ -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 tages_workflow from crm.model import tag as tags print("crm-routes") @@ -332,11 +333,15 @@ def kontaktadresse_delete(): @app.get("/xhr/calllog/list") @authorisation_required([MITARBEITER, KUNDEN_MANAGER]) def calllog_list(): - """Liefert Gesprächsnotizen als Liste. Optional nach projekt_id gefiltert. - Für Nicht-SysAdmin/Nicht-AppAdmin nur Notizen sichtbarer Projekte.""" + """Liefert Gesprächsnotizen als Liste. Optional nach projekt_id und/oder + kontaktadresse_id gefiltert. Für Nicht-SysAdmin/Nicht-AppAdmin nur + Notizen sichtbarer Projekte.""" projekt_id = request.args.get("projekt_id", type=int) + kontaktadresse_id = request.args.get("kontaktadresse_id", type=int) user_id = _get_visible_user_id() - result = [l.to_dict() for l in call_logs.get_call_logs(projekt_id, user_id)] + result = [l.to_dict() for l in call_logs.get_call_logs( + projekt_id, user_id, kontaktadresse_id + )] return jsonify(result), 200 @@ -354,9 +359,10 @@ def calllog_get(): @app.post("/xhr/calllog/create") @authorisation_required([KUNDEN_MANAGER]) def calllog_create(): - """Legt eine neue Gesprächsnotiz an. ID wird serverseitig vergeben.""" + """Legt eine neue Gesprächsnotiz an. ID wird serverseitig vergeben. + current_user.id wird als Ersteller (user_id) erfasst.""" _require_projekt_access(int(request.json["projekt_id"])) - call_log = call_logs.create_call_log(request.json) + call_log = call_logs.create_call_log(request.json, current_user.id) return jsonify(call_log.to_dict()), 200 @@ -381,6 +387,30 @@ def calllog_delete(): return jsonify({}), 200 +# --------------------------------------------------------------------------- # +# Tages-Workflow # +# --------------------------------------------------------------------------- # + + +@app.get("/xhr/workflow/list") +@authorisation_required([MITARBEITER, KUNDEN_MANAGER]) +def workflow_list(): + """Tages-Workflow-Liste: Kontaktadressen mit letzter Notiz, Wiedervorlage + und dynamisch berechnetem Status. Optional gefiltert nach kunden_id, + status ('offen'/'erledigt') und wiedervorlage ('heute'/'3tage'/ + 'ueberfaellig'/'woche'). Für Nicht-SysAdmin/Nicht-AppAdmin nur + Kontaktadressen sichtbarer Projekte. Die Sortierung ist priorisiert: + überfällige Wiedervorlagen, dann Kontakte ohne Notizen, dann der Rest.""" + user_id = _get_visible_user_id() + result = tages_workflow.get_tages_workflow_list( + user_id, + request.args.get("kunden_id", type=int), + request.args.get("status"), + request.args.get("wiedervorlage"), + ) + return jsonify(result), 200 + + # --------------------------------------------------------------------------- # # Tag (hierarchische Taxonomie) # # --------------------------------------------------------------------------- # diff --git a/Client/Components/call_log.ts b/Client/Components/call_log.ts index b078cac..2d5d13c 100644 --- a/Client/Components/call_log.ts +++ b/Client/Components/call_log.ts @@ -8,7 +8,7 @@ import {iCallLog, fetchCallLogs, fetchCallLogById, createCallLogREST, updateCall 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 {iTag, fetchTags, berechneWiedervorlageDatum} from "../models/tag" import {showServerError} from "./auth/authmessages" import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard" @@ -277,27 +277,6 @@ export const Call_Log: m.ClosureComponent = (vnode) => { return min.toString().padStart(2, '0') + ':' + sek.toString().padStart(2, '0') } - /** - * Berechnet das Wiedervorlage-Datum automatisch aus den ausgewählten - * Tags. Basis ist das Datum der Notiz (currentLog.datum) oder heute, - * falls dieses leer ist. Aus allen ausgewählten Tags mit - * loest_wiedervorlage_aus=True wird das Minimum von - * wiedervorlage_abstand_tage ermittelt und zum Basisdatum addiert. - * Liefert einen leeren String, wenn kein Tag mit - * loest_wiedervorlage_aus=True ausgewählt ist. - */ - const berechneWiedervorlageDatum = (tagIds: number[]): string => { - const basis = currentLog.datum || new Date().toISOString().split('T')[0] - const wvTags = tagListe.filter((t: iTag) => - tagIds.includes(t.id) && t.loest_wiedervorlage_aus && t.wiedervorlage_abstand_tage != null - ) - if (wvTags.length === 0) return "" - const minAbstand = Math.min(...wvTags.map(t => t.wiedervorlage_abstand_tage as number)) - const basisDate = new Date(basis) - basisDate.setDate(basisDate.getDate() + minAbstand) - return basisDate.toISOString().split('T')[0] - } - return { oninit: (vnode) => { const routeParams = m.route.param() @@ -397,6 +376,11 @@ export const Call_Log: m.ClosureComponent = (vnode) => { kontaktadresse_id: ansprechpartnerVoreingestellt ? currentLog.kontaktadresse_id : parseInt((formData.get("kontaktadresse_id") as string) || "-1"), + // Ersteller wird serverseitig aus current_user.id + // gesetzt — hier nur Platzhalter, mitarbeiter_name + // wird ausschließlich vom Server geliefert. + user_id: null, + mitarbeiter_name: null, projekt_name: "", kunden_name: "", datum: currentLog.datum || "", @@ -537,7 +521,11 @@ export const Call_Log: m.ClosureComponent = (vnode) => { onchanged: (newSelection: number[]) => { selectedTagIds.splice(0, selectedTagIds.length) Array.prototype.push.apply(selectedTagIds, newSelection) - const vorschlag = berechneWiedervorlageDatum(newSelection) + const vorschlag = berechneWiedervorlageDatum( + newSelection, + tagListe, + currentLog.datum || new Date().toISOString().split('T')[0] + ) if (vorschlag) { currentLog.wiedervorlage_datum = vorschlag } diff --git a/Client/Components/kontaktadresse.ts b/Client/Components/kontaktadresse.ts index 274e896..6848570 100644 --- a/Client/Components/kontaktadresse.ts +++ b/Client/Components/kontaktadresse.ts @@ -238,6 +238,9 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => { id: currentKontaktadresse.id, projekt_id: currentKontaktadresse.projekt_id, projekt_name: "", + // Server-seitig aus den Relationships abgeleitet; + // Platzhalter beim Senden. + kunden_name: "", anrede: (formData.get("anrede") as string) || "", name: (formData.get("name") as string) || "", firma: (formData.get("firma") as string) || "", diff --git a/Client/Components/tages_workflow.ts b/Client/Components/tages_workflow.ts new file mode 100644 index 0000000..74ff196 --- /dev/null +++ b/Client/Components/tages_workflow.ts @@ -0,0 +1,761 @@ +import m from "mithril" +import {Form, iForm} from "./widgets/form" +import {Input, iInput} from "./widgets/inputs" +import {Select, iSelect, MultiSelect, iMultiSelect, iSelectOption} from "./widgets/select" +import {Treenode, iTreenode, Treetable, iTreetable} from "./widgets/Treetable_V2" +import {ModalType, ModalView, iModalView} from "./widgets/Modal" +import {gridMountpoints} from "./mainlayout" +import {showServerError} from "./auth/authmessages" +import {DirtyMaskHelper} from "./widgets/dirtyguard" +import {serializeBc, iBreadcrumb} from "./widgets/breadcrumb" +import {iKunde, fetchKunden} from "../models/kunde" +import {iKontaktadresse, fetchKontaktadresseById} from "../models/kontaktadresse" +import {iCallLog, fetchCallLogsByKontaktadresse, createCallLogREST} from "../models/call_log" +import {iTag, fetchTags, berechneWiedervorlageDatum} from "../models/tag" +import {iTagesWorkflowEntry, iTagesWorkflowFilter, fetchTagesWorkflowList} from "../models/tages_workflow" + +// ============================================================================ +// MODUL-LEVEL WORKFLOW-KONTEXT +// ============================================================================ + +// Die aktuellen Filter-Einstellungen werden von der Liste gesetzt und von +// der Maske gelesen: Für "Nächster Kontakt" bzw. "Speichern und weiter" +// wird die Workflow-Liste mit denselben Filtern frisch vom Server geladen, +// um Filter-Prüfung und Reihenfolge serverseitig neu zu berechnen. Im +// Kontext liegen nur einfache Werte (IDs/Strings) — die Maske lädt ihre +// Daten frisch vom Server (AGENTS.md: nur IDs in Route-Params). +let workflowFilter: iTagesWorkflowFilter = {status: "offen"} + +// Hinweis, der beim Weiterschalten an die nächste Maske übergeben wird, +// wenn der aktuelle Kontakt die Filterkriterien nicht mehr erfüllt +// (Konzept 2.5). Wird von der Maske beim Laden konsumiert. +let workflowHinweis: string | null = null + +// In der aktuellen Workflow-Sitzung bereits bearbeitete Kontaktadressen. +// Wird beim "Speichern und weiter"/"Speichern" gefüllt, damit der nächste +// Kontakt nicht bereits Abgearbeitetes erneut anbietet. Zurücksetzen bei +// jedem (erneuten) Laden der Liste — die Sitzung endet mit der Rückkehr +// zur Liste. +let bearbeiteteIds: Set = new Set() + +/** + * Heutiges Datum als ISO-String (yyyy-MM-dd) für das Notiz-Datum. + */ +function heuteIso(): string { + return new Date().toISOString().split('T')[0] +} + +/** + * Aktuelle Uhrzeit als HH:MM für den Begin der neuen Notiz. + */ +function jetztUhrzeit(): string { + const now = new Date() + return now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1] +} + +// ============================================================================ +// TAGES-WORKFLOW-LISTE +// ============================================================================ + +export const TagesWorkflowListe: m.ClosureComponent = () => { + let eintraege: iTagesWorkflowEntry[] = [] + let treenodes: iTreenode[] = [] + let selectedNode: iTreenode | null = null + // Filterleisten-Zustand (Closure). kunden_id: -1 = alle Kunden. + let filterKundenId: number = -1 + let filterStatus: string = "offen" + let filterWiedervorlage: string = "alle" + // Kundenliste für das Kunden-Select, asynchron geladen. + let kundenListe: iKunde[] = [] + + /** + * Lädt die Workflow-Liste mit den aktuellen Filter-Einstellungen vom + * Server und erzeugt die flachen Treenodes für die Treetable. + */ + const listeLaden = () => { + // Filter-Zustand in den Modul-Kontext schreiben — die Maske lädt + // mit denselben Filtern, wenn der nächste Kontakt bestimmt wird. + workflowFilter = { + kunden_id: filterKundenId >= 0 ? filterKundenId : undefined, + status: filterStatus !== "alle" ? filterStatus : undefined, + wiedervorlage: filterWiedervorlage !== "alle" ? filterWiedervorlage : undefined, + } + // Mit dem Laden der Liste beginnt eine neue Workflow-Sitzung — + // die bisher als bearbeitet gemerkten Kontakte verfallen. + bearbeiteteIds = new Set() + fetchTagesWorkflowList(workflowFilter).then((result) => { + eintraege = result + treenodes = result.map((eintrag: iTagesWorkflowEntry) => new Treenode(eintrag, true, [])) + selectedNode = null + }).catch((e) => showServerError(e)) + } + + /** + * Übernimmt einen geänderten Filterwert in den Zustand und lädt die + * Liste sofort neu — jeder Filterwechsel löst genau einen GET-Request + * aus, ein separates "Filter anwenden" ist daher nicht nötig. + */ + const filterGeaendert = (setter: () => void) => { + setter() + listeLaden() + } + + /** + * Setzt alle Filter auf die Defaults zurück und lädt die Liste neu. + */ + const filterZuruecksetzen = () => { + filterGeaendert(() => { + filterKundenId = -1 + filterStatus = "offen" + filterWiedervorlage = "alle" + }) + } + + /** + * Öffnet die Bearbeitungsmaske für einen Workflow-Eintrag. + */ + const openMaske = (eintrag: iTagesWorkflowEntry) => { + m.route.set("/tagesworkflowkontakt", {kontaktadresse_id: eintrag.kontaktadresse_id}) + } + + /** + * "Workflow beginnen": öffnet den ersten Eintrag der gefilterten Liste. + */ + const workflowBeginnen = () => { + if (eintraege.length > 0) { + openMaske(eintraege[0]) + } + } + + const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { + const eintrag: iTagesWorkflowEntry = node.nodevalue + // Badges der Tags des letzten Gesprächs (gleicher Stil wie in der + // Gesprächsnotizen-Liste). + const tagBadges = (eintrag.letzte_notiz_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", eintrag.kunden_name || ""), + m("div", {style: {fontSize: '12px', color: '#666'}}, eintrag.projekt_name || "")), + // Kontakt: Name (+Firma), darunter die Telefonnummer in fett — + // so bleibt die Nummer ohne eigene Spalte ohne Umbruch lesbar. + m("div", + m("div", eintrag.name + (eintrag.firma ? " (" + eintrag.firma + ")" : "")), + eintrag.telefon + ? m("div", m("strong", eintrag.telefon)) + : null), + // Letztes Gespräch: Datum (+Uhrzeit) und darunter die Tags der + // letzten Notiz (eigene Zeile — mehr Platz für die Badges). + // Der Notiztext steht als Tooltip auf der Spalte. + m("div", + {title: eintrag.letzte_notiz || undefined}, + m("div", {style: {'white-space': 'nowrap'}}, + eintrag.letzte_notiz_datum + ? eintrag.letzte_notiz_datum + + (eintrag.letzte_notiz_uhrzeit ? ", " + eintrag.letzte_notiz_uhrzeit : "") + : "—"), + m("div", tagBadges)), + // Wiedervorlage: Liegt das Datum in der Vergangenheit, wird es + // rot und fett hervorgehoben (Überfällig). ISO-Strings + // (yyyy-MM-dd) vergleichen lexikalisch korrekt. + m("span", { + class: eintrag.wiedervorlage_datum && eintrag.wiedervorlage_datum < heuteIso() + ? "w3-text-red" + : "", + style: { + 'white-space': 'nowrap', + fontWeight: eintrag.wiedervorlage_datum && eintrag.wiedervorlage_datum < heuteIso() + ? 'bold' + : 'normal' + }, + title: eintrag.wiedervorlage_datum && eintrag.wiedervorlage_datum < heuteIso() + ? "Überfällig" + : undefined + }, eintrag.wiedervorlage_datum || ""), + m("span.w3-tag" + (eintrag.is_erledigt ? ".w3-green" : ".w3-grey"), + eintrag.is_erledigt ? "Erledigt" : "Offen"), + // Expliziter Button statt Zeilen-Klick: Die Select-Optionen der + // Filterleiste reagieren auf mousedown und schließen das + // Dropdown vor dem mouseup — der mouseup landet dann auf der + // Zeile darunter und würde eine Navigation auslösen. Die + // Bearbeitung startet daher ausschließlich über bewusste + // Klicks (Button je Zeile oder "Workflow beginnen"). + m("button.w3-button.w3-small", + { + title: "Kontakt im Workflow bearbeiten", + style: {padding: '0 2px'}, + onclick: (e: Event) => { + e.stopPropagation() + openMaske(eintrag) + } + }, + m("i.material-icons", "call")) + ] + } + + return { + oninit: (vnode) => { + // Filter-Zustand aus dem Modul-Kontext übernehmen, damit nach + // Rückkehr aus der Maske die zuletzt gewählten Filter aktiv sind. + filterKundenId = workflowFilter.kunden_id !== undefined ? workflowFilter.kunden_id : -1 + filterStatus = workflowFilter.status || "offen" + filterWiedervorlage = workflowFilter.wiedervorlage || "alle" + fetchKunden().then((result) => { kundenListe = result }).catch((e) => showServerError(e)) + listeLaden() + }, + view: (vnode) => { + // Optionen für die Filter-Selects. "Alle"-Optionen erhalten + // den Wert -1 (Kunde) bzw. "alle" (Status/Wiedervorlage). + const kundenOptionen: iSelectOption[] = [ + {value: -1, label: "Alle Kunden"}, + ...kundenListe.map((k: iKunde) => { + return {value: k.id, label: k.name} + }) + ] + const statusOptionen: iSelectOption[] = [ + {value: "alle", label: "Alle"}, + {value: "offen", label: "Offen"}, + {value: "erledigt", label: "Erledigt"} + ] + const wiedervorlageOptionen: iSelectOption[] = [ + {value: "alle", label: "Alle"}, + {value: "ueberfaellig", label: "Überfällig"}, + {value: "heute", label: "Heute"}, + {value: "3tage", label: "Nächste 3 Tage"}, + {value: "woche", label: "Aktuelle Woche"} + ] + return m("div.w3-container.w3-auto", { + style: { + display: 'flex', + 'flex-direction': 'column', + height: 'inherit', + 'overflow-y': 'inherit'}}, + m("h1", "Tages-Workflow"), + // Filterleiste: drei Selects nebeneinander, auf kleinen + // Bildschirmen untereinander (w3-third ist responsive). + m("div.w3-row-padding", {style: {'margin-bottom': '8px'}}, + m("div.w3-third", + m("label", "Kunde", + m(Select, { + name: "kunden_id", + value: filterKundenId, + options: kundenOptionen, + onchanged: (kundenId: number) => { + filterGeaendert(() => { filterKundenId = kundenId }) + } + } as iSelect))), + m("div.w3-third", + m("label", "Kontakt-Status", + m(Select, { + name: "status", + value: filterStatus, + options: statusOptionen, + onchanged: (status: string) => { + filterGeaendert(() => { filterStatus = status }) + } + } as iSelect))), + m("div.w3-third", + m("label", "Wiedervorlage", + m(Select, { + name: "wiedervorlage", + value: filterWiedervorlage, + options: wiedervorlageOptionen, + onchanged: (zeitraum: string) => { + filterGeaendert(() => { filterWiedervorlage = zeitraum }) + } + } as iSelect))) + ), + // Aktionsbuttons: Filter wirken sofort, daher kein + // "Filter anwenden" mehr — nur Zurücksetzen und Start. + m("div.w3-bar", + m("button.w3-button.w3-theme-l2", + {onclick: filterZuruecksetzen}, + m("i.material-icons", {style: {verticalAlign: 'middle', marginRight: '4px'}}, "restart_alt"), + "Zurücksetzen"), + m("button.w3-button.w3-theme", + { + onclick: workflowBeginnen, + style: {'margin-left': '4px'}, + disabled: eintraege.length === 0 + }, + m("i.material-icons", {style: {verticalAlign: 'middle', marginRight: '4px'}}, "play_arrow"), + "Workflow beginnen"), + // Info-Button: erklärt in einem Modal die priorisierte + // Sortierung und die aktuell aktiven Filter. Die Label + // werden aus denselben Optionen abgeleitet, die auch die + // Filter-Selects anzeigen. + m("button.w3-button.w3-theme-l4.w3-right", + { + type: "button", + title: "Sortierung und Filter erklärt", + onclick: () => { + const label = (optionen: iSelectOption[], value: any): string => { + const o = optionen.find((op: iSelectOption) => op.value === value) + return o ? o.label : String(value) + } + m.mount(gridMountpoints.modalhook, { + view: () => { + return m(ModalView, + { + title: "Sortierung & Filter", + type: ModalType.MESSAGE, + width: '50%' + } as iModalView, + m("div.w3-panel", + m("p", m("strong", "Die Liste ist nach Priorität sortiert:")), + m("ol", + m("li", "Überfällige Wiedervorlagen (rot und fett dargestellt), älteste zuerst"), + m("li", "Kontakte ohne Gesprächsnotizen"), + m("li", "Übrige Kontakte, aufsteigend nach Wiedervorlage-Datum")), + m("p", m("strong", "Aktuell aktive Filter:")), + m("ul", + m("li", "Kunde: " + label(kundenOptionen, filterKundenId)), + m("li", "Kontakt-Status: " + label(statusOptionen, filterStatus)), + m("li", "Wiedervorlage: " + label(wiedervorlageOptionen, filterWiedervorlage)), + // Sichtbarkeits-Hinweis: die Projekt-Zuordnung + // greift serverseitig immer, unabhängig von den + // gewählten Filtern. + m("li", "Sichtbarkeit: Es werden nur Kontaktadressen von Projekten angezeigt, die Ihnen zugeordnet sind (AppAdmin/SysAdmin sehen alle).")), + m("p", {style: {color: '#666', 'font-size': '13px'}}, + "Filter wirken sofort bei Änderung. Beim Weiterschalten in der Bearbeitungsmaske werden bereits bearbeitete Kontakte und solche, die nicht mehr den Filterkriterien entsprechen, automatisch übersprungen.") + ), + m("div.w3-bar.w3-padding-16", + m("button.w3-button.w3-theme.w3-right", + { + onclick: () => { + m.mount(gridMountpoints.modalhook, null) + } + }, + "Schließen") + ) + ) + } + }) + } + }, + m("i.material-icons", "info")) + ), + m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, + m(Treetable, + { + treenodes: treenodes, + columnmapper: columnmapper, + // Spaltenüberschriften; letzte Spalte ist der + // Bearbeiten-Button (ohne Titel). + header: ["Kunde", "Kontakt", "Letztes Gespräch", "Wiedervorlage", "Status", ""], + // Ohne filter-Attribut rendert das Treetable + // keine Suchleiste — die Server-Filter der + // Leiste oben sind das einzige Eingrenzungskriterium. + // Die Liste ist flach (keine Hierarchie), daher + // auch keine Ein-/Ausklappen-Buttons. + showExpandButtons: false, + // Zeilen-Klick markiert nur (wie in den übrigen + // Listen). Die Bearbeitungsmaske wird über den + // Telefon-Button je Zeile oder "Workflow + // beginnen" geöffnet — nicht über die + // Zeilen-Markierung, da der mouseup nach dem + // Auswählen einer Filter-Option fälschlich auf + // der Zeile darunter landen würde. + onRowMarked: (node: iTreenode) => { + selectedNode = node + }, + isSelected: (node: iTreenode) => node === selectedNode + } as iTreetable + ) + ) + ) + } + } +} + +// ============================================================================ +// TAGES-WORKFLOW-MASKE +// ============================================================================ + +export const TagesWorkflowMaske: m.ClosureComponent = () => { + // Kontaktadresse, Historie und Formularzustand werden beim Laden frisch + // vom Server geholt bzw. zurückgesetzt. + let kontaktadresse: iKontaktadresse | null = null + let historie: iCallLog[] = [] + let geladeneId: number = -1 + // Formularzustand der neuen Gesprächsnotiz (live gebunden, damit ein + // Redraw die Eingaben nicht verwirft). + let notizText: string = "" + let wiedervorlageDatum: string = "" + let selectedTagIds: number[] = [] + // Asynchron geladene Tag-Liste (nur zuordenbare, nicht is_parent). + let tagListe: iTag[] = [] + // Hinweis aus der Navigation (Kontakt übersprungen), siehe workflowHinweis. + let hinweis: string | null = null + // Formular-Referenz für das programmgesteuerte Submit ("Speichern und + // weiter" löst dasselbe Submit aus wie der Standard-Speichern-Button). + let formRef: HTMLFormElement | null = null + // Nach erfolgreichem Speichern automatisch zum nächsten Kontakt + // weitergeschaltet werden? + let weiterNachSpeichern: boolean = false + // Eigener Dirty-Merkstand: "Nächster Kontakt" soll ungespeicherte + // Eingaben nicht kommentarlos verwerfen. + let formDirty: boolean = false + // DirtyMaskHelper: Standard-Ziel nach Speichern/Abbrechen ist die + // Workflow-Liste (entspricht dem Konzept-Button "Zurück zur Liste"). + const guard = new DirtyMaskHelper("/tagesworkflow") + + /** + * Lädt Kontaktadresse und Historie frisch vom Server und setzt den + * Formularzustand zurück. Wird im oninit und beim Wechsel des + * Route-Params (Nächster Kontakt) aufgerufen. + */ + const laden = (kontaktadresseId: number) => { + geladeneId = kontaktadresseId + kontaktadresse = null + historie = [] + notizText = "" + wiedervorlageDatum = "" + selectedTagIds = [] + formDirty = false + // Hinweis der vorherigen Navigation übernehmen und konsumieren. + hinweis = workflowHinweis + workflowHinweis = null + fetchKontaktadresseById(kontaktadresseId).then((result) => { + kontaktadresse = result + }).catch((e) => showServerError(e)) + fetchCallLogsByKontaktadresse(kontaktadresseId).then((result) => { + historie = result + }).catch((e) => showServerError(e)) + } + + /** + * Bestimmt den nächsten Kontakt der Workflow-Sitzung: Die Liste wird + * mit den aktiven Filtern frisch vom Server geladen — der Server + * berechnet Status und Wiedervorlage neu, sodass inzwischen erledigte + * oder herausgefallene Kontakte automatisch übersprungen werden + * (Konzept 2.5/3.4). Bereits bearbeitete Kontakte dieser Sitzung + * werden ebenfalls übersprungen. + */ + const naechsterKontakt = () => { + const aktuelleId = geladeneId + const aktuellerName = kontaktadresse ? kontaktadresse.name : "" + fetchTagesWorkflowList(workflowFilter).then((liste) => { + // Position des aktuellen Kontakts in der frischen Liste suchen. + const idx = liste.findIndex((e: iTagesWorkflowEntry) => + e.kontaktadresse_id === aktuelleId + ) + // Kandidaten: ab dem Nachfolger des aktuellen Kontakts; ist + // der aktuelle selbst herausgefallen (z.B. gerade als erledigt + // markiert), die gesamte Liste. + const kandidaten = idx === -1 ? liste : liste.slice(idx + 1) + // Erster Kandidat, der in dieser Sitzung noch nicht bearbeitet + // wurde. Ist der aktuelle Kontakt herausgefallen, wird der + // Grund im Hinweis erläutert. + const ziel = kandidaten.find((e: iTagesWorkflowEntry) => + !bearbeiteteIds.has(e.kontaktadresse_id) + ) + if (ziel === undefined) { + // Kein Kontakt mehr, der den Filterkriterien entspricht. + m.mount(gridMountpoints.modalhook, { + view: () => { + return m(ModalView, + { + title: "Tages-Workflow", + type: ModalType.MESSAGE, + width: '40%' + } as iModalView, + m("div.w3-panel", + m("p", "Keine weiteren Kontakte entsprechen den Filterkriterien.")), + m("div.w3-bar.w3-padding-16", + m("button.w3-button.w3-theme.w3-right", + { + onclick: () => { + m.mount(gridMountpoints.modalhook, null) + m.route.set("/tagesworkflow") + } + }, + "Zurück zur Liste") + ) + ) + } + }) + return + } + if (idx === -1) { + // Grund für das Herausfallen erläutern (beste Schätzung + // anhand des geladenen Erledigt-Status). + const grund = kontaktadresse && kontaktadresse.is_erledigt + ? "er wurde als erledigt markiert" + : "sein Wiedervorlage-Datum liegt außerhalb des gefilterten Zeitraums" + workflowHinweis = "Kontakt " + aktuellerName + " erfüllt die Filterkriterien nicht mehr (" + grund + ") — es wird der nächste passende Kontakt angezeigt." + } + m.route.set("/tagesworkflowkontakt", {kontaktadresse_id: ziel.kontaktadresse_id}) + }).catch((e) => showServerError(e)) + } + + return { + oninit: (vnode) => { + const routeParams = m.route.param() + const kontaktadresseId = routeParams && routeParams.kontaktadresse_id + ? parseInt(routeParams.kontaktadresse_id) : -1 + guard.registerGuard() + laden(kontaktadresseId) + // Tags laden — nur zuordenbare (is_parent=false) Tags. + fetchTags().then((result) => { + tagListe = result.filter((t: iTag) => !t.is_parent) + }).catch((e) => showServerError(e)) + }, + onbeforeupdate: (vnode) => { + // "Nächster Kontakt" navigiert auf derselben Route mit neuem + // kontaktadresse_id-Param — Mithril mountet die Komponente + // dabei nicht neu, daher hier auf Parameterwechsel reagieren. + const routeParams = m.route.param() + const neueId = routeParams && routeParams.kontaktadresse_id + ? parseInt(routeParams.kontaktadresse_id) : -1 + if (neueId !== geladeneId) { + laden(neueId) + } + return true + }, + onremove: (vnode) => { + guard.cleanup() + }, + view: (vnode) => { + const submit = (form: HTMLFormElement): boolean => { + if (!kontaktadresse) return false + const formData = new FormData(form) + const log: iCallLog = { + id: -1, + projekt_id: kontaktadresse.projekt_id, + kontaktadresse_id: kontaktadresse.id, + // Wird serverseitig aus current_user.id gesetzt. + user_id: null, + projekt_name: "", + kunden_name: "", + mitarbeiter_name: null, + datum: heuteIso(), + uhrzeit_beginn: jetztUhrzeit(), + dauer: "", + betreff: "", + name_ansprechpartner: kontaktadresse.name, + telefon: kontaktadresse.telefon || "", + notiz: ((formData.get("notiz") as string) || "").trim(), + wiedervorlage_datum: wiedervorlageDatum || "", + erledigt: false, + tag_ids: selectedTagIds.slice() + } + createCallLogREST(log).then(() => { + // Kontakt als bearbeitet merken, damit "Nächster + // Kontakt" ihn in dieser Sitzung nicht erneut anbietet. + bearbeiteteIds.add(geladeneId) + if (weiterNachSpeichern) { + weiterNachSpeichern = false + naechsterKontakt() + } else { + // Beim Kontakt bleiben: Historie und Erledigt- + // Status frisch laden (durch die neue Notiz können + // sich beide geändert haben), Formular zurücksetzen. + laden(geladeneId) + } + }).catch((e) => { showServerError(e); guard.markDirty() }) + return true + } + + // Form-Attribute des DirtyMaskHelper um die eigene Form- + // Referenz erweitern ("Speichern und weiter" triggert das + // Submit programmgesteuert) und den Dirty-Zustand mitverfolgen. + const formAttrs: any = guard.formAttrs(submit) as iForm + const origOnformcreate = formAttrs.onformcreate + formAttrs.onformcreate = (f: HTMLFormElement) => { + formRef = f + origOnformcreate(f) + } + const origOnDirtyChange = formAttrs.onDirtyChange + formAttrs.onDirtyChange = (dirty: boolean) => { + formDirty = dirty + origOnDirtyChange(dirty) + } + + // 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 = new Map(tagListe.map((t: iTag) => [t.id, t.color] as [number, string])) + + // Historie client-seitig stabil nach Datum/Uhrzeit absteigend + // sortieren (neueste zuerst; Server sortiert nur nach Datum). + const historieSortiert = historie.slice().sort((a: iCallLog, b: iCallLog) => { + const da = a.datum || "" + const db = b.datum || "" + if (db !== da) return db.localeCompare(da) + return (b.uhrzeit_beginn || "").localeCompare(a.uhrzeit_beginn || "") + }) + + const kontaktBearbeiten = () => { + // Breadcrumb-Kette mit der Workflow-Liste mitgeben, damit + // die Kontaktadresse-Maske per Breadcrumb zurückführt. + const bc = serializeBc([{ + route: "/tagesworkflow", + params: {}, + label: "Tages-Workflow" + } as iBreadcrumb]) + m.route.set("/kontaktadresse", {id: geladeneId, bc: bc}) + } + + return m("div.w3-container.w3-auto.form-view-container", + m("h1", "Tages-Workflow" + (kontaktadresse ? ": " + kontaktadresse.name : "")), + // Hinweis, falls beim Weiterschalten ein Kontakt übersprungen wurde. + hinweis + ? m("div.w3-panel.w3-pale-yellow.w3-leftbar", + {style: {'border-left': '6px solid #ffc107'}}, + m("p", hinweis)) + : null, + m("div", {style: {flex: '1', 'min-height': '0'}}, + m(Form, formAttrs, [ + // ---- Kopfzeile: Projekt- und Kontaktinformationen ---- + m("div.w3-panel.w3-theme-l4.w3-round-large", {style: {'margin-bottom': '12px'}}, + m("div.w3-row", + m("div.w3-half", + m("p", {style: {margin: '4px 0'}}, + m("strong", "Kunde: "), kontaktadresse ? kontaktadresse.kunden_name : ""), + m("p", {style: {margin: '4px 0'}}, + m("strong", "Projekt: "), kontaktadresse ? kontaktadresse.projekt_name : "")), + m("div.w3-half", + m("p", {style: {margin: '4px 0'}}, + m("strong", "Kontakt: "), + kontaktadresse ? kontaktadresse.name : "", + kontaktadresse && kontaktadresse.telefon ? " | " + kontaktadresse.telefon : "", + kontaktadresse && kontaktadresse.email ? " | " + kontaktadresse.email : ""), + m("p", {style: {margin: '4px 0'}}, + m("strong", "Status: "), + m("span.w3-tag" + (kontaktadresse && kontaktadresse.is_erledigt ? ".w3-green" : ".w3-grey"), + kontaktadresse && kontaktadresse.is_erledigt ? "Erledigt" : "Offen"), + m("button.w3-button.w3-theme.w3-right", + { + type: "button", + style: {'margin-top': '-4px'}, + onclick: kontaktBearbeiten + }, + m("i.material-icons", {style: {verticalAlign: 'middle', marginRight: '4px'}}, "edit"), + "Kontakt bearbeiten"))) + ) + ), + // ---- Historie der bisherigen Gesprächsnotizen ---- + m("h2", {style: {fontSize: '20px'}}, "Bisherige Gesprächsnotizen"), + m("div", {style: {'overflow-x': 'auto', 'margin-bottom': '16px'}}, + m("table.w3-table.w3-bordered.w3-striped", + m("thead", + m("tr.w3-theme", + m("th", "Datum"), + m("th", "Mitarbeiter"), + m("th", "Notiz"), + m("th", "Tags"), + m("th", "Wiedervorlage"))), + historieSortiert.map((log: iCallLog) => + m("tr", + m("td", {style: {'white-space': 'nowrap'}}, + log.datum + (log.uhrzeit_beginn ? ", " + log.uhrzeit_beginn : "")), + m("td", {style: {'white-space': 'nowrap'}}, + log.mitarbeiter_name || "unbekannt"), + m("td", log.notiz || ""), + m("td", (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))), + m("td", {style: {'white-space': 'nowrap'}}, log.wiedervorlage_datum || "")) + ) + ) + ), + // ---- Formular für die neue Gesprächsnotiz ---- + m("h2", {style: {fontSize: '20px'}}, "Neue Gesprächsnotiz"), + m("label", "Notiz", + m("textarea.w3-input.w3-border", { + name: "notiz", + value: notizText, + rows: 4, + required: true, + style: {'margin-bottom': '8px'}, + oninput: (e: Event) => { + notizText = (e.target as HTMLTextAreaElement).value + } + })), + 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: (newSelection: number[]) => { + selectedTagIds = newSelection.slice() + // Wiedervorlage-Datum automatisch aus den Tags + // berechnen (Basis heute), falls ein Tag mit + // loest_wiedervorlage_aus gewählt wurde. + const vorschlag = berechneWiedervorlageDatum( + newSelection, tagListe, heuteIso()) + if (vorschlag) { + wiedervorlageDatum = vorschlag + } + } + } as iMultiSelect)), + m("label", "Wiedervorlage Datum", m(Input, { + name: "wiedervorlage_datum", + value: wiedervorlageDatum, + type: "date", + oninput: (e: Event) => { + wiedervorlageDatum = (e.target as HTMLInputElement).value + } + } as iInput)), + // ---- Workflow-Navigation ---- + // "Speichern" und "Abbrechen" (= Zurück zur Liste) + // liefert der Form-Standard-Button-Bereich. + m("div.w3-bar.w3-padding-16", + m("button.w3-button.w3-theme.w3-right", { + type: "button", + onclick: () => { + weiterNachSpeichern = true + if (formRef && typeof formRef.requestSubmit === "function") { + formRef.requestSubmit() + } + } + }, + m("i.material-icons", {style: {verticalAlign: 'middle', marginRight: '4px'}}, "skip_next"), + "Speichern und weiter"), + m("button.w3-button.w3-theme-l2.w3-right", { + type: "button", + style: {'margin-right': '4px'}, + onclick: () => { + if (formDirty && + !window.confirm("Es gibt ungespeicherte Eingaben. Ohne Speichern zum nächsten Kontakt wechseln?")) { + return + } + naechsterKontakt() + } + }, + m("i.material-icons", {style: {verticalAlign: 'middle', marginRight: '4px'}}, "arrow_forward"), + "Nächster Kontakt") + ) + ]) + ) + ) + } + } +} diff --git a/Client/index.ts b/Client/index.ts index 2862a3f..bb1b2a4 100644 --- a/Client/index.ts +++ b/Client/index.ts @@ -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 { TagesWorkflowListe, TagesWorkflowMaske } from "./Components/tages_workflow" import { guardedNavigate } from "./Components/widgets/dirtyguard" @@ -133,6 +134,7 @@ const menu: m.ClosureComponent = () => { }, view: (vnode) => { return m(menubar, [ + menuItem("/tagesworkflow", "Tages-Workflow"), menuItem("/kundenliste", "Kunden"), menuItem("/projektliste", "Projekte"), menuItem("/calllogliste", "Gesprächsnotizen"), @@ -214,6 +216,8 @@ m.route(gridMountpoints.main, "/kundenliste", { "/kontaktadresseliste": kontaktadresseliste, "/kontaktadresse": Kontaktadresse, "/taglist": TagList, - "/tag": Tag - + "/tag": Tag, + "/tagesworkflow": TagesWorkflowListe, + "/tagesworkflowkontakt": TagesWorkflowMaske + }) \ No newline at end of file diff --git a/Client/models/call_log.ts b/Client/models/call_log.ts index 70f5cb3..92a03d5 100644 --- a/Client/models/call_log.ts +++ b/Client/models/call_log.ts @@ -8,6 +8,11 @@ import {iTag} from "./tag" * serverseitig aus der Projekt-Relationship abgeleitet und im JSON ergänzt. * kontaktadresse_id ist Pflichtfeld: Jede Notiz gehört zu genau einer * Kontaktadresse (Fremdschlüssel auf kontaktadressen.id). + * user_id erfasst den Ersteller (Mitarbeiter, der den Anruf getätigt hat); + * sie wird serverseitig beim Anlegen gesetzt und nie vom Client gesendet. + * Alt-Notizen ohne erfassten Ersteller haben null. + * mitarbeiter_name ist keine Spalte, sondern wird serverseitig aus der + * user_id gegen die Auth-Datenbank aufgelöst (null ohne user_id). * tags (vom Server beim Lesen) und tag_ids (vom Client beim Schreiben) * steuern die n:m-Beziehung zu den Tags. */ @@ -15,8 +20,12 @@ export interface iCallLog { id: number projekt_id: number kontaktadresse_id: number + /** Ersteller (Mitarbeiter), serverseitig gesetzt; null bei Alt-Notizen. */ + user_id: number | null projekt_name: string kunden_name: string + /** Aufgelöster Username des Erstellers; null bei Alt-Notizen. */ + mitarbeiter_name: string | null datum: string uhrzeit_beginn: string dauer: string @@ -73,6 +82,19 @@ export function fetchCallLogById(id: number): Promise { }).then((result) => result as iCallLog) } +/** + * Lädt die Gesprächsnotizen eines einzelnen Kontakts (Kontaktadresse) + * vom Server — serverseitig nach kontaktadresse_id gefiltert. Wird für + * die Historie in der Tages-Workflow-Maske genutzt. + */ +export function fetchCallLogsByKontaktadresse(kontaktadresseId: number): Promise { + return m.request({ + method: "GET", + url: CALLLOG_REST_BASE + "/list", + params: {kontaktadresse_id: kontaktadresseId} + }).then((result) => result as iCallLog[]) +} + /** * Legt eine neue Gesprächsnotiz serverseitig an. */ diff --git a/Client/models/kontaktadresse.ts b/Client/models/kontaktadresse.ts index 3bc54e8..c7c43a3 100644 --- a/Client/models/kontaktadresse.ts +++ b/Client/models/kontaktadresse.ts @@ -4,16 +4,17 @@ import m from "mithril" * Datenmodell für eine Kontaktadresse (Akquise-Ziel innerhalb eines Projekts). * Jede Kontaktadresse ist genau einem Projekt zugeordnet (FK projekt_id). * Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case). - * projekt_name ist keine ORM-Spalte, sondern wird serverseitig aus der - * Projekt-Relationship abgeleitet und im JSON ergänzt. is_erledigt ist - * ebenfalls keine Spalte, sondern wird serverseitig dynamisch berechnet: - * True, sobald mindestens eine Gesprächsnotiz des Kontakts einen Tag mit - * is_erledigend=True hat. + * projekt_name und kunden_name sind keine ORM-Spalten, sondern werden + * serverseitig aus den Projekt-/Kunde-Relationships abgeleitet und im + * JSON ergänzt. is_erledigt ist ebenfalls keine Spalte, sondern wird + * serverseitig dynamisch berechnet: True, sobald mindestens eine + * Gesprächsnotiz des Kontakts einen Tag mit is_erledigend=True hat. */ export interface iKontaktadresse { id: number projekt_id: number projekt_name: string + kunden_name: string anrede: string name: string firma: string diff --git a/Client/models/tag.ts b/Client/models/tag.ts index c3cbbcb..7ab8e6b 100644 --- a/Client/models/tag.ts +++ b/Client/models/tag.ts @@ -112,3 +112,32 @@ export function deleteTagREST(id: number): Promise { params: {id: id} }).then(() => undefined) } + +/* ------------------------------------------------------------------ */ +/* Fachliche Hilfsfunktionen */ +/* ------------------------------------------------------------------ */ + +/** + * Berechnet das Wiedervorlage-Datum automatisch aus den ausgewählten + * Tags. Basis ist das übergebene Basisdatum (z.B. das Datum der Notiz + * oder heute). Aus allen ausgewählten Tags mit + * loest_wiedervorlage_aus=True wird das Minimum von + * wiedervorlage_abstand_tage ermittelt und zum Basisdatum addiert. + * Liefert einen leeren String, wenn kein Tag mit + * loest_wiedervorlage_aus=True ausgewählt ist. + * + * Wird von der Call_Log-Maske und der Tages-Workflow-Maske genutzt, + * damit die Berechnungslogik nur an einer Stelle gepflegt wird. + */ +export function berechneWiedervorlageDatum(tagIds: number[], + tagListe: iTag[], + basisdatum: string): string { + const wvTags = tagListe.filter((t: iTag) => + tagIds.includes(t.id) && t.loest_wiedervorlage_aus && t.wiedervorlage_abstand_tage != null + ) + if (wvTags.length === 0) return "" + const minAbstand = Math.min(...wvTags.map(t => t.wiedervorlage_abstand_tage as number)) + const basisDate = new Date(basisdatum) + basisDate.setDate(basisDate.getDate() + minAbstand) + return basisDate.toISOString().split('T')[0] +} diff --git a/Client/models/tages_workflow.ts b/Client/models/tages_workflow.ts new file mode 100644 index 0000000..9ef2b73 --- /dev/null +++ b/Client/models/tages_workflow.ts @@ -0,0 +1,84 @@ +import m from "mithril" +import {iTag} from "./tag" + +/** + * Datenmodell für einen Eintrag der Tages-Workflow-Liste. Der Server + * aggregiert pro Kontaktadresse: die letzte Gesprächsnotiz, das aktuell + * gültige Wiedervorlage-Datum (jüngste Notiz, die eines gesetzt hat) + * und den dynamisch berechneten Erledigt-Status. Die Sortierung erfolgt + * serverseitig priorisiert (überfällige Wiedervorlagen, dann Kontakte + * ohne Notizen, dann der Rest nach Wiedervorlage). + */ +export interface iTagesWorkflowEntry { + kontaktadresse_id: number + projekt_id: number + projekt_name: string + kunden_id: number + kunden_name: string + anrede: string + name: string + firma: string + position: string + telefon: string + email: string + /** Dynamisch berechneter Erledigt-Status (keine ORM-Spalte). */ + is_erledigt: boolean + /** Text der letzten Gesprächsnotiz; null, wenn der Kontakt keine Notiz hat. */ + letzte_notiz: string | null + /** Datum der letzten Gesprächsnotiz (ISO yyyy-MM-dd); null ohne Notizen. */ + letzte_notiz_datum: string | null + /** Uhrzeit Beginn der letzten Gesprächsnotiz; null ohne Notizen. */ + letzte_notiz_uhrzeit: string | null + /** Tags der letzten Gesprächsnotiz; leere Liste ohne Notizen. */ + letzte_notiz_tags: iTag[] + /** Aktuell gültiges Wiedervorlage-Datum (ISO yyyy-MM-dd) oder null. */ + wiedervorlage_datum: string | null +} + +/** + * Filter-Einstellungen der Tages-Workflow-Liste. Alle Felder optional; + * nicht gesetzte Felder wirken wie kein Filter. Die Filter werden + * serverseitig angewendet. + */ +export interface iTagesWorkflowFilter { + /** Nur Kontaktadressen von Projekten dieses Kunden. */ + kunden_id?: number + /** 'offen' = nur nicht erledigte, 'erledigt' = nur erledigte Kontakte. */ + status?: string + /** 'heute', '3tage', 'ueberfaellig' oder 'woche' (ISO-Kalenderwoche). */ + wiedervorlage?: string +} + +/* ------------------------------------------------------------------ */ +/* REST-API-Aufrufe (Client-Seite) */ +/* ------------------------------------------------------------------ */ + +/** + * Basis-Pfad der Tages-Workflow-REST-Endpunkte. + */ +const WORKFLOW_REST_BASE = "/xhr/workflow" + +/** + * Lädt die Tages-Workflow-Liste vom Server: pro Kontaktadresse ein + * aggregierter Eintrag (letzte Notiz, Wiedervorlage, Status), gefiltert + * und priorisiert sortiert. + */ +export function fetchTagesWorkflowList(filter?: iTagesWorkflowFilter): Promise { + const params: Record = {} + if (filter) { + if (filter.kunden_id !== undefined && filter.kunden_id >= 0) { + params.kunden_id = filter.kunden_id + } + if (filter.status) { + params.status = filter.status + } + if (filter.wiedervorlage) { + params.wiedervorlage = filter.wiedervorlage + } + } + return m.request({ + method: "GET", + url: WORKFLOW_REST_BASE + "/list", + params: params + }).then((result) => result as iTagesWorkflowEntry[]) +}