Compare commits

...
4 Commits
Author SHA1 Message Date
gg bc9b337696 feat(crm): zentrale TagBadge-Komponente und Ergänzungen zur Workflow-Maske
- Position des Ansprechpartners in der Kopfzeile der Tagesworkflow-Maske anzeigen
- Neue TagBadge-Komponente (Farbchip, Name, Workflow-Icons: erledigend/
  Wiedervorlage) ersetzt die duplizierte Badge-Darstellung in Notiz-Liste,
  Workflow-Liste, Historie und Tag-Liste
- MultiSelect: optionale content-Option rendert ein fertiges Badge des
  Aufrufers statt Farbpunkt + Label im Auswahl-Modal; Suchfeld wird beim
  Öffnen des Modals fokussiert
- TagBadge-Attribut heißt tagData statt tag, da Mithril attrs-Objekte mit
  tag-Property als Vnode-Kinder behandelt (führte zu 'color' undefined)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-27 18:37:49 +02:00
ggandMistral Vibe 2ff4272ecd docs: TODO zur Klärung des Verantwortlichen-Felds im Projekt ergänzen
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-27 17:15:35 +02:00
gg 70f6101761 feat(cli): init-db setzt CRM-Geschäftsdaten zurück und legt neue Musterdaten an
init-db löscht vor dem Seeding sämtliche CRM-Geschäftsdaten (Kunden,
Kontakte, Projekte, Kontaktadressen, Gesprächsnotizen, Projekt-Zuordnungen)
in FK-sicherer Reihenfolge und legt danach die Musterdaten komplett neu an.
Die Tag-Taxonomie bleibt als Stammdaten erhalten.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-27 17:15:31 +02:00
gg f6588dec79 feat(tags): erledigende Tags mit rotem Stop-Hand-Symbol anzeigen
Statt des grünen Häkchens (check_circle) wird für erledigende Tags nun
die Stopp-Hand (back_hand) in Rot angezeigt.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-27 17:15:28 +02:00
7 changed files with 709 additions and 254 deletions

No files matched your search

+560 -158
View File
@@ -2,17 +2,23 @@
Flask CLI-Befehle zur Datenbank-Initialisierung und zum Reset. Flask CLI-Befehle zur Datenbank-Initialisierung und zum Reset.
Befehle (aufgerufen via Flask CLI): Befehle (aufgerufen via Flask CLI):
flask --app wsgi:app init-db -- Schema + Musterdaten anlegen flask --app wsgi:app init-db -- Schema anlegen/migrieren + Musterdaten
flask --app wsgi:app reset-db -- alles bis auf SysAdmin löschen flask --app wsgi:app reset-db -- alles bis auf SysAdmin löschen
init-db ist idempotent: bereits vorhandene Rollen/Kunden werden nicht init-db legt das Schema an bzw. migriert es und setzt danach die
doppelt angelegt. reset-db fragt standardmäßig zur Bestätigung; mit --yes CRM-Geschäftsdaten zurück: Sämtliche Kunden samt aller abhängigen Daten
wird die Abfrage übersprungen. (Kontakte, Projekte, Kontaktadressen, Gesprächsnotizen, Projekt-Zuordnungen)
werden gelöscht und die Musterdaten werden komplett neu angelegt. Die
Tag-Taxonomie (tags) bleibt erhalten, da es sich um Stammdaten handelt.
reset-db fragt standardmäßig zur Bestätigung; mit --yes wird die Abfrage
übersprungen.
:author: Mistral Vibe <vibe@mistral.ai> :author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-02 :date: 2026-09-02
:version: 1.0 :version: 1.1
""" """
from datetime import date, timedelta
import click import click
from flask import Flask from flask import Flask
@@ -33,10 +39,10 @@ def register_cli_commands(app: Flask):
click.echo("Initialisiere kbp_crm.db ...") click.echo("Initialisiere kbp_crm.db ...")
from crm.model import checkdb as crm_checkdb from crm.model import checkdb as crm_checkdb
crm_checkdb.checkupdates() crm_checkdb.checkupdates()
_seed_muster_kunden() click.echo("Lösche vorhandene CRM-Geschäftsdaten ...")
_seed_muster_projekte() _purge_crm_daten()
_seed_muster_kontaktadressen() click.echo("Lege Musterdaten an ...")
_seed_muster_calllogs() _seed_musterdaten()
click.echo("Fertig.") click.echo("Fertig.")
@@ -83,180 +89,576 @@ def _seed_app_roles():
session.commit() session.commit()
def _seed_muster_kunden(): def _purge_crm_daten():
""" """
Legt die drei Muster-Kunden an, falls die kunden-Tabelle noch leer ist. Löscht sämtliche CRM-Geschäftsdaten vor dem Neuanlegen der Musterdaten.
Die Löschreihenfolge beachtet die Fremdschlüsselbeziehungen: erst die
n:m-Verknüpfungen der Gesprächsnotizen, dann die Notizen selbst, danach
die Kontaktadressen, die Projekt-Nutzer-Zuordnungen, die Projekte, die
Kontakte und zuletzt die Kunden. Die Tag-Taxonomie (tags) bleibt
erhalten, da Tags Stammdaten und keine Musterdaten sind.
""" """
from crm.model import Kunde from crm.model import (
CallLogTag, CallLog, Kontaktadresse, ProjektUser,
Projekt, Kontakt, Kunde,
)
session = dbapp.session session = dbapp.session
if session.query(Kunde).count() > 0: session.query(CallLogTag).delete(synchronize_session=False)
return session.query(CallLog).delete(synchronize_session=False)
muster = [ session.query(Kontaktadresse).delete(synchronize_session=False)
Kunde(name="Musterfirma GmbH", strasse="Musterstraße 1", session.query(ProjektUser).delete(synchronize_session=False)
plz="12345", ort="Musterstadt", land="Deutschland"), session.query(Projekt).delete(synchronize_session=False)
Kunde(name="Beispiel AG", strasse="Beispielweg 2", session.query(Kontakt).delete(synchronize_session=False)
plz="54321", ort="Beispielstadt", land="Deutschland"), session.query(Kunde).delete(synchronize_session=False)
Kunde(name="Test Unternehmen", strasse="Testallee 3",
plz="67890", ort="Testdorf", land="Österreich"),
]
for kunde in muster:
session.add(kunde)
session.commit() session.commit()
def _seed_muster_projekte(): # ---------------------------------------------------------------------------
# Musterdaten: drei Kunden, jeder mit eigenen Kontakten und je drei
# Telefon-Akquise-Projekten. Die Daten sind bewusst so aufgebaut, dass
# jeder Kontakte- und Kontaktadressen-Name genau einmal in der gesamten
# Datenbank vorkommt und kein Projektname bei zwei Kunden existiert.
# Die Kontaktadressen sind die Akquise-Ziele (angerufene Firmen), die
# Gesprächsnotizen werden daraus automatisch abgeleitet.
# ---------------------------------------------------------------------------
_MUSTERDATEN = [
{
"name": "Nordwerk Metallbau GmbH & Co. KG",
"strasse": "Hamburger Str. 118",
"plz": "21079",
"ort": "Hamburg",
"land": "Deutschland",
"kontakte": [
{
"anrede": "Herr", "name": "Dipl.-Ing. Markus Haverkamp",
"position": "Einkaufsleiter",
"telefon": "+49 40 790 214 66",
"email": "haverkamp@nordwerk-metallbau.de",
"notiz": "Entscheider für Rahmenverträge; telefonisch am besten nach 15 Uhr erreichbar.",
},
{
"anrede": "Frau", "name": "Katrin Ohmsen",
"position": "Vertriebsleitung",
"telefon": "+49 40 790 214 12",
"email": "ohmsen@nordwerk-metallbau.de",
"notiz": "Steuert die laufenden Kampagnen mit uns; erwartet jeden Freitag ein kurzes Zwischen-Reporting.",
},
],
"projekte": [
{
"projektname": "Akquise Stahlträgerprogramm 2026 – Dachstühle und Hallenbau",
"zweck": "Telefonische Neukundenansprache bei Bauunternehmen in Norddeutschland, um das überarbeitete Stahlträgerprogramm zu positionieren. Ziel: 400 geführte Gespräche bis Jahresende, davon 60 Angebotsanfragen.",
"verantwortlicher": "Miriam Falkner",
"startdatum": date(2026, 8, 3),
"kontaktadressen": [
{
"anrede": "Herr", "name": "Torben Block",
"firma": "Block Bauunternehmung GmbH",
"position": "Einkaufsleiter",
"telefon": "+49 40 6899 2140",
"email": "t.block@block-bau-hamburg.de",
"strasse": "Wandsbeker Chaussee 210", "plz": "22089",
"ort": "Hamburg", "land": "Deutschland",
"notiz": "Tauscht die Rahmenverträge jährlich aus; entscheidet gemeinsam mit der Kalkulation.",
},
{
"anrede": "Frau", "name": "Silke Reimers",
"firma": "Reimers Dachbaustoffe Nord",
"position": "Leiterin Kalkulation",
"telefon": "+49 431 556 872",
"email": "s.reimers@reimers-dachbaustoffe.de",
"strasse": "Kieler Str. 31", "plz": "24116",
"ort": "Kiel", "land": "Deutschland",
"notiz": "Plant mehrere Hallenprojekte für das kommende Jahr.",
},
{
"anrede": "Herr", "name": "Gero Timmermann",
"firma": "Timmermann Hallenbau",
"position": "Geschäftsführer",
"telefon": "+49 511 987 4432",
"email": "g.timmermann@timmermann-hallenbau.de",
"strasse": "Am Lindener Hafen 12", "plz": "30453",
"ort": "Hannover", "land": "Deutschland",
"notiz": "Hat großes Interesse an Schnellmontage-Systemen signalisiert.",
},
],
},
{
"projektname": "Neukundengewinnung Zulieferer Ostösterreich",
"zweck": "Kaltakquise bei Stahl- und Metallzulieferern in Wien, Niederösterreich und Oberösterreich zur Erschließung neuer Lieferbeziehungen. Ziel: 250 Kontakte, davon 40 qualifizierte Terminvorschläge für den Außendienst.",
"verantwortlicher": "Dominik Reiss",
"startdatum": date(2026, 8, 17),
"kontaktadressen": [
{
"anrede": "Herr", "name": "Wolfgang Gruber",
"firma": "Gruber Stahlhandel",
"position": "Vertriebsleiter",
"telefon": "+43 1 982 76 11",
"email": "w.gruber@gruber-stahlhandel.at",
"strasse": "Laxenburger Str. 84", "plz": "1100",
"ort": "Wien", "land": "Österreich",
"notiz": "Bezieht Trägerprofile bislang aus Deutschland; offen für österreichische Bezugsquellen.",
},
{
"anrede": "Frau", "name": "Martina Ehgartner",
"firma": "Ehgartner Metallwerke",
"position": "Einkaufsleitung",
"telefon": "+43 732 455 210",
"email": "m.ehgartner@ehgartner-metallwerke.at",
"strasse": "Wiener Str. 145", "plz": "4020",
"ort": "Linz", "land": "Österreich",
"notiz": "Kauft halbjährlich größere Kontingente; Verträge laufen über die Zentrale.",
},
{
"anrede": "Herr", "name": "Kurt Strubreiter",
"firma": "Strubreiter Blechtechnik",
"position": "Inhaber",
"telefon": "+43 2628 741 33",
"email": "office@strubreiter-blechtechnik.at",
"strasse": "Betriebsstr. 7", "plz": "2700",
"ort": "Wiener Neustadt", "land": "Österreich",
"notiz": "Familienbetrieb; entscheidet kurzfristig, wenn die Konditionen stimmen.",
},
],
},
{
"projektname": "Cross-Selling Schutzgitter und Tore im Bestand",
"zweck": "Nachverkauf im Bestandskundenstamm: Kunden der Jahre 2022 bis 2025 werden telefonisch auf das neue Sortiment an Schutzgittern und Industrietoren angesprochen. Ziel: 300 Bestandskunden kontaktiert, Angebotsversand innerhalb von 48 Stunden.",
"verantwortlicher": "Aylin Kocabiyik",
"startdatum": date(2026, 9, 1),
"kontaktadressen": [
{
"anrede": "Frau", "name": "Petra Wilhelmi",
"firma": "Wilhelmi Logistikzentrum",
"position": "Technische Leiterin",
"telefon": "+49 421 338 7762",
"email": "p.wilhelmi@wilhelmi-logistik.de",
"strasse": "Universitätsallee 25", "plz": "28359",
"ort": "Bremen", "land": "Deutschland",
"notiz": "Sanierung der Tore für nächstes Jahr geplant; Budget muss noch beantragt werden.",
},
{
"anrede": "Herr", "name": "Heino Sievers",
"firma": "Sievers Agrartechnik",
"position": "Kundendienstleiter",
"telefon": "+49 441 763 519",
"email": "h.sievers@sievers-agrartechnik.de",
"strasse": "Boschdamm 18", "plz": "26122",
"ort": "Oldenburg", "land": "Deutschland",
"notiz": "Sucht Ersatzteile für ältere Hallentore; auch Interesse an Wartungsverträgen.",
},
{
"anrede": "Herr", "name": "Dietrich Voskamp",
"firma": "Voskamp Landbau",
"position": "Betriebsleiter",
"telefon": "+49 541 229 834",
"email": "d.voskamp@voskamp-landbau.de",
"strasse": "Natruper-Tor-Wall 70", "plz": "49074",
"ort": "Osnabrück", "land": "Deutschland",
"notiz": "Erweitert den Maschinenpark; über Neuanschaffungen entscheidet der Sohn mit.",
},
],
},
],
},
{
"name": "Vitalis Apothekenverbund eG",
"strasse": "Kaisersstr. 77",
"plz": "76133",
"ort": "Karlsruhe",
"land": "Deutschland",
"kontakte": [
{
"anrede": "Frau", "name": "Dr. Ingrid Halbach",
"position": "Mitglied der Geschäftsleitung",
"telefon": "+49 721 508 3390",
"email": "halbach@vitalis-apotheken.de",
"notiz": "Apothekerin; legt besonderen Wert auf seriös formulierte Gesprächsleitfäden.",
},
{
"anrede": "Herr", "name": "Jörn Kiebitz",
"position": "Leiter Filialmarketing",
"telefon": "+49 721 508 3377",
"email": "kiebitz@vitalis-apotheken.de",
"notiz": "Liefert die Filial- und Zielgruppenlisten für unsere Kampagnen; Ansprechpartner für Abstimmungstermine.",
},
],
"projekte": [
{
"projektname": "Terminierung Arzneimittelberatung – Vitalis Filialen Süd",
"zweck": "Telefonische Terminierung kostenpflichtiger Arzneimittelberatungen für Mitgliedsapotheken in Baden-Württemberg und Bayern. Ziel: 120 bestätigte Beratungstermine pro Monat bei maximal drei Absagen je Termin.",
"verantwortlicher": "Tobias Lenzen",
"startdatum": date(2026, 9, 7),
"kontaktadressen": [
{
"anrede": "Frau", "name": "Dr. Marlene Vogt",
"firma": "Vogt-Apotheke Stuttgart-West",
"position": "Inhaberin",
"telefon": "+49 711 563 448",
"email": "m.vogt@vogt-apotheke-stuttgart.de",
"strasse": "Kernerstr. 31", "plz": "70182",
"ort": "Stuttgart", "land": "Deutschland",
"notiz": "Führt die Beratungen selbst durch; Terminvorschläge möglichst vormittags.",
},
{
"anrede": "Herr", "name": "Ingo Pasternak",
"firma": "Pasternak Apotheke",
"position": "Filialleiter",
"telefon": "+49 89 448 2120",
"email": "i.pasternak@pasternak-apotheke.de",
"strasse": "Leopoldstr. 90", "plz": "80802",
"ort": "München", "land": "Deutschland",
"notiz": "Stellvertretung zu Servicezwecken; Zeitfenster dienstags und donnerstags.",
},
{
"anrede": "Frau", "name": "Regine Axt",
"firma": "Axt Rathaus-Apotheke",
"position": "Approbierte Apothekerin",
"telefon": "+49 721 976 512",
"email": "r.axt@axt-apotheke.de",
"strasse": "Am Stadtgarten 2", "plz": "76133",
"ort": "Karlsruhe", "land": "Deutschland",
"notiz": "Neu in der Mitgliedschaft; wünscht ein Einführungsgespräch.",
},
],
},
{
"projektname": "Rückgewinnung inaktiver Bonuskarten-Kunden",
"zweck": "Reaktivierung von rund 5.000 nicht mehr aktiven Bonuskarten-Kunden per Telefon mit anschließender Follow-up-Mail. Ziel: 8 Prozent Rückkehrquote im ersten Quartal, Nachverkauf über die Filialen.",
"verantwortlicher": "Camilla Brugger",
"startdatum": date(2026, 8, 24),
"kontaktadressen": [
{
"anrede": "Herr", "name": "Falk Rüdiger",
"firma": "Sonnen-Apotheke Freiburg",
"position": "Inhaber",
"telefon": "+49 761 388 224",
"email": "f.ruediger@sonnen-apotheke-freiburg.de",
"strasse": "Salzstr. 60", "plz": "79098",
"ort": "Freiburg", "land": "Deutschland",
"notiz": "Wertet die Rückkehrquote monatlich aus; erwartet Zahlen zum Quartalsende.",
},
{
"anrede": "Frau", "name": "Heidrun Clauß",
"firma": "Clauß Apotheke am Markt",
"position": "Inhaberin",
"telefon": "+49 351 469 887",
"email": "h.clauss@clauss-apotheke.de",
"strasse": "Wilsdruffer Str. 24", "plz": "01067",
"ort": "Dresden", "land": "Deutschland",
"notiz": "Ist mit der bisherigen Rücklaufquote unzufrieden und drängt auf eine neue Ansprache.",
},
{
"anrede": "Herr", "name": "Marek Sawatzki",
"firma": "Sawatzki Apotheke Leipzig-Süd",
"position": "Fachapotheker",
"telefon": "+49 341 225 904",
"email": "m.sawatzki@sawatzki-apotheke.de",
"strasse": "Karl-Liebknecht-Str. 105", "plz": "04275",
"ort": "Leipzig", "land": "Deutschland",
"notiz": "Möchte besonders die junge Zielgruppe wieder ansprechen.",
},
],
},
{
"projektname": "Up-Selling Homöopathie-Eigenmarke bei Mitgliedsapotheken",
"zweck": "Telefonische Empfehlung der Eigenmarke 'Vitalis Natur' an alle 180 Mitgliedsapotheken. Ziel: Listung in mindestens 60 Apotheken bis zum Ende des ersten Quartals, durchschnittliches Bestellvolumen von 1.200 Euro je Listung.",
"verantwortlicher": "Nils Gröber",
"startdatum": date(2026, 9, 14),
"kontaktadressen": [
{
"anrede": "Frau", "name": "Brunhilde Kessler",
"firma": "Kessler Engelpforte-Apotheke",
"position": "Inhaberin",
"telefon": "+49 611 344 210",
"email": "b.kessler@kessler-apotheke.de",
"strasse": "Kirchgasse 32", "plz": "65183",
"ort": "Wiesbaden", "land": "Deutschland",
"notiz": "Listet bislang nur zwei Eigenmarken-Produkte; offen für eine Sortimentserweiterung.",
},
{
"anrede": "Herr", "name": "Ali Demirci",
"firma": "Demirci Apotheke Köln",
"position": "Apotheker",
"telefon": "+49 221 559 731",
"email": "a.demirci@demirci-apotheke.de",
"strasse": "Neumarkt 3", "plz": "50667",
"ort": "Köln", "land": "Deutschland",
"notiz": "Fragt nach Verkaufsunterlagen für das Beratungsgespräch.",
},
{
"anrede": "Frau", "name": "Yasmin Trautmann",
"firma": "Trautmann Apotheke",
"position": "Fachapothekerin",
"telefon": "+49 69 664 128",
"email": "y.trautmann@trautmann-apotheke.de",
"strasse": "Berger Str. 175", "plz": "60385",
"ort": "Frankfurt", "land": "Deutschland",
"notiz": "Möchte erst die Ergebnisse einer Testlistung abwarten.",
},
],
},
],
},
{
"name": "Alpina Holzwerke Sepp Achmüller GmbH",
"strasse": "Sepp-Achmüller-Str. 4",
"plz": "6330",
"ort": "Kufstein",
"land": "Österreich",
"kontakte": [
{
"anrede": "Herr", "name": "Sepp Achmüller jun.",
"position": "Geschäftsführer",
"telefon": "+43 5372 6234 18",
"email": "achmiller@alpina-holzwerke.at",
"notiz": "Prüft jede Kampagne vor dem Start persönlich ab; Budgetfreigabe erst nach Vorlage einer Musterabrechnung.",
},
{
"anrede": "Frau", "name": "Elisabeth Steger",
"position": "Vertriebsleiterin",
"telefon": "+43 5372 6234 22",
"email": "steger@alpina-holzwerke.at",
"notiz": "Stimmt Zielgruppen und Anrufregionen mit uns ab; bevorzugt die Abstimmung per Mail.",
},
],
"projekte": [
{
"projektname": "Kampagne Thermofenster Linienserie Alpin",
"zweck": "Telefonische Kampagne bei Baufirmen, Architekten und Holzbaubetrieben für die neue Thermofenster-Serie 'Alpin'. Ziel: 300 geführte Gespräche und 45 Mess- beziehungsweise Angebotstermine.",
"verantwortlicher": "Sandra Wittke",
"startdatum": date(2026, 8, 10),
"kontaktadressen": [
{
"anrede": "Herr", "name": "Jakob Rainer",
"firma": "Rainer Holzbau",
"position": "Geschäftsführer",
"telefon": "+43 512 344 087",
"email": "j.rainer@rainer-holzbau.at",
"strasse": "Museumstr. 13", "plz": "6020",
"ort": "Innsbruck", "land": "Österreich",
"notiz": "Setzt auf Passivhaus-Komponenten; U-Werte müssen im Angebot ausgewiesen sein.",
},
{
"anrede": "Frau", "name": "Notburga Wörgötter",
"firma": "Wörgötter Zimmerei",
"position": "Kalkulation und Einkauf",
"telefon": "+43 662 834 556",
"email": "n.woergoetter@woergoetter-zimmerei.at",
"strasse": "Schallmooser Hauptstr. 44", "plz": "5020",
"ort": "Salzburg", "land": "Österreich",
"notiz": "Benötigt Muster für die Messepräsentation im November.",
},
{
"anrede": "Herr", "name": "Quirin Hofer",
"firma": "Hofer Bau & Planung",
"position": "Architekt",
"telefon": "+49 871 339 421",
"email": "q.hofer@hofer-bau-planung.de",
"strasse": "Ostengasse 15", "plz": "84028",
"ort": "Landshut", "land": "Deutschland",
"notiz": "Plant ein mehrgeschossiges Holzbauprojekt; Fensterelemente sind noch nicht vergeben.",
},
],
},
{
"projektname": "Wiedervorlage-Bestellungen Winterholz-Vorkauf",
"zweck": "Frühzeitige telefonische Vorkaufskampagne für Winterholz: Bestandskunden aus dem Bauhandel anrufen, Bedarf aufnehmen und Wiedervorlagen für die Bestellzeit setzen. Ziel: 65 Prozent Vorab-Buchung des Winterkontingents.",
"verantwortlicher": "Jonas Ade",
"startdatum": date(2026, 9, 21),
"kontaktadressen": [
{
"anrede": "Herr", "name": "Werner Prantner",
"firma": "Prantner Baustoffe",
"position": "Verkauf",
"telefon": "+49 8631 554 22",
"email": "w.prantner@prantner-baustoffe.de",
"strasse": "Simbacher Str. 14", "plz": "84137",
"ort": "Vilsbiburg", "land": "Deutschland",
"notiz": "Bestellt traditionell erst im November; testet dieses Jahr erstmals den Vorkauf.",
},
{
"anrede": "Frau", "name": "Cäcilia Pfeiff",
"firma": "Pfeiff Holzmarkt",
"position": "Inhaberin",
"telefon": "+43 732 915 447",
"email": "c.pfeiff@pfeiff-holzmarkt.at",
"strasse": "Rudolfstr. 12", "plz": "4020",
"ort": "Linz", "land": "Österreich",
"notiz": "Braucht eine verbindliche Lieferzusage bis Anfang Dezember.",
},
{
"anrede": "Herr", "name": "Ambros Hinterseer",
"firma": "Hinterseer Sägewerke",
"position": "Disposition",
"telefon": "+43 5356 622 18",
"email": "a.hinterseer@hinterseer-saegewerke.at",
"strasse": "Jochberger Str. 9", "plz": "6360",
"ort": "Kitzbühel", "land": "Österreich",
"notiz": "Koordiniert den Transport selbst; Übergabetermine müssen exakt stehen.",
},
],
},
{
"projektname": "Lead-Qualifizierung Sägewerksbetriebe Bayern/Tirol",
"zweck": "Anrufcampagne zur Lead-Qualifizierung bei mittelständischen Sägewerken in Bayern und Tirol: Recherche der Ansprechpartner und Bewertung nach Einkaufsvolumen. Ziel: 200 qualifizierte Leads bis Mitte des zweiten Quartals.",
"verantwortlicher": "Pia Merkert",
"startdatum": date(2026, 9, 28),
"kontaktadressen": [
{
"anrede": "Herr", "name": "Hubert Mühlbacher",
"firma": "Mühlbacher Holz",
"position": "Einkaufsleiter",
"telefon": "+49 851 987 350",
"email": "h.muehlbacher@muehlbacher-holz.de",
"strasse": "Innstr. 40", "plz": "94032",
"ort": "Passau", "land": "Deutschland",
"notiz": "Fragt nach Zertifikaten und Lieferkonditionen bereits im Erstgespräch.",
},
{
"anrede": "Frau", "name": "Resi Peringer",
"firma": "Peringer Säge",
"position": "Disposition",
"telefon": "+43 5285 644 21",
"email": "r.peringer@peringer-saege.at",
"strasse": "Dorfstr. 22", "plz": "6290",
"ort": "Mayrhofen", "land": "Österreich",
"notiz": "Familienbetrieb in dritter Generation; die Entscheidung liegt beim Senior.",
},
{
"anrede": "Herr", "name": "Konrad Eder",
"firma": "Eder Holzwerk",
"position": "Betriebsleiter",
"telefon": "+49 8031 554 67",
"email": "k.eder@eder-holzwerk.de",
"strasse": "Chiemseestr. 28", "plz": "83022",
"ort": "Rosenheim", "land": "Deutschland",
"notiz": "Erweitert die Produktionskapazität; sucht Lieferanten mit hoher Flexibilität.",
},
],
},
],
},
]
# Betreff-Vorlagen für die automatisch erzeugten Gesprächsnotizen. Passen
# zum Ablauf einer Telefon-Akquise (Erstkontakt -> Termin -> Angebot).
_BETREFFVORLAGEN = [
"Erstkontakt und Bedarfsaufnahme",
"Produktvorstellung am Telefon",
"Terminvorschlag Außendienst",
"Angebot nachreichen",
"Nachfassung zum Angebot",
"Wiedervorlage nach Sommerpause",
"Rückruf vereinbart",
]
# Notiz-Vorlagen für die automatisch erzeugten Gesprächsnotizen. Formuliert
# wie typische Ergebnisse eines Akquise-Telefonats; ein leerer Eintrag
# steht für ein Telefonat ohne Notiz.
_NOTIZVORLAGEN = [
"Gesprächspartner zeigt grundsätzliches Interesse und bittet um die Preisliste als PDF. Das Angebot geht per Mail raus.",
"Kein akuter Bedarf, aber Erweiterungsplanung für das kommende Jahr. Wiedervorlage im Frühjahr gesetzt.",
"Vor-Ort-Termin zur Besichtigung vereinbart; Unterlagen werden vorab per Mail gesendet.",
"Zentrale verweist auf die Beschaffungsabteilung, der konkrete Ansprechpartner wird noch erfragt.",
"Muster werden angefragt; Rückruf zugesagt, sobald die Kollegin aus dem Urlaub zurück ist.",
"Angebot wurde durchgesprochen, das Budget ist noch nicht freigegeben. Nächster Anruf nach der Freigabe.",
"",
]
def _seed_musterdaten():
""" """
Legt für jeden Muster-Kunden 1 bis 3 Muster-Projekte an, falls die Legt die vollständigen Musterdaten an: drei Kunden mit kundenspezifischen
projekte-Tabelle noch leer ist. Kontakten, je drei Telefon-Akquise-Projekte pro Kunde, projektbezogene
Kontaktadressen (Akquise-Ziele) und daraus abgeleitete Gesprächsnotizen.
Aufruf nur nach _purge_crm_daten(), da die Funktion die Daten ohne
Existenzprüfung neu anlegt. Die Gesprächsnotizen verweisen auf die
Kontaktadresse, zu der sie gehören (kontaktadresse_id ist Pflichtfeld),
und übernehmen Name und Telefonnummer des Ziels.
""" """
import random import random
from crm.model import Kunde, Projekt from crm.model import Kunde, Kontakt, Projekt, Kontaktadresse, CallLog
session = dbapp.session session = dbapp.session
if session.query(Projekt).count() > 0: # Fester Seed für reproduzierbare Musterdaten (Datum, Uhrzeit, Dauer).
return rng = random.Random(456)
kunden = session.query(Kunde).order_by(Kunde.id).all() for kundendaten in _MUSTERDATEN:
vorlagen = [ kunde = Kunde(
("Website-Relaunch", "Neugestaltung der Unternehmenswebsite"), name=kundendaten["name"],
("E-Commerce Plattform", "Online-Shop Entwicklung"), strasse=kundendaten["strasse"],
("CRM-System", "Kundenbeziehungsmanagement"), plz=kundendaten["plz"],
("Mobile App", "iOS- und Android-Entwicklung"), ort=kundendaten["ort"],
("SEO-Optimierung", "Suchmaschinenoptimierung"), land=kundendaten["land"],
] )
verantwortliche = [ session.add(kunde)
"Max Mustermann", "Anna Schmidt", "Peter Müller", # flush liefert die Auto-Increment-ID für die abhängigen Sätze.
"Lisa Bauer", "Thomas Becker", session.flush()
]
rng = random.Random(42) for kontaktdaten in kundendaten["kontakte"]:
vorlagen_idx = 0 kontakt = Kontakt(
for kunde in kunden: kunden_id=kunde.id,
anzahl = rng.randint(1, 3) anrede=kontaktdaten["anrede"],
for _ in range(anzahl): name=kontaktdaten["name"],
name, zweck = vorlagen[vorlagen_idx % len(vorlagen)] position=kontaktdaten["position"],
vorlagen_idx += 1 telefon=kontaktdaten["telefon"],
projekt = Projekt() email=kontaktdaten["email"],
projekt.kunden_id = kunde.id notiz=kontaktdaten["notiz"],
projekt.projektname = name )
projekt.zweck = zweck session.add(kontakt)
projekt.verantwortlicher = rng.choice(verantwortliche)
projekt.startdatum = "2024-0{monat}-1{tag}".format( for projektdaten in kundendaten["projekte"]:
monat=rng.randint(1, 9), tag=rng.randint(0, 9) projekt = Projekt(
kunden_id=kunde.id,
projektname=projektdaten["projektname"],
zweck=projektdaten["zweck"],
verantwortlicher=projektdaten["verantwortlicher"],
startdatum=projektdaten["startdatum"],
) )
session.add(projekt) session.add(projekt)
session.commit() session.flush()
for adressdaten in projektdaten["kontaktadressen"]:
def _seed_muster_kontaktadressen(): adresse = Kontaktadresse(
""" projekt_id=projekt.id,
Legt für jedes Muster-Projekt 1 bis 3 Kontaktadressen an, falls die anrede=adressdaten["anrede"],
kontaktadressen-Tabelle noch leer ist. name=adressdaten["name"],
""" firma=adressdaten["firma"],
import random position=adressdaten["position"],
telefon=adressdaten["telefon"],
from crm.model import Projekt, Kontaktadresse email=adressdaten["email"],
strasse=adressdaten["strasse"],
session = dbapp.session plz=adressdaten["plz"],
if session.query(Kontaktadresse).count() > 0: ort=adressdaten["ort"],
return land=adressdaten["land"],
notiz=adressdaten["notiz"],
projekte = session.query(Projekt).order_by(Projekt.id).all()
vorlagen = [
("Herr", "Thomas Becker", "Becker Webdesign GmbH", "Geschäftsführer"),
("Frau", "Sandra Klein", "Klein Marketing", "Marketingleiterin"),
("Herr", "Michael Wagner", "Wagner Online Shop", "IT-Leiter"),
("Frau", "Julia Hoffmann", "Hoffmann Consulting", "Beraterin"),
("Herr", "Stefan Roth", "Roth IT Solutions", "Technischer Leiter"),
]
orte = [
("Industriestr. 12", "45131", "Essen", "Deutschland"),
("Königsallee 5", "40212", "Düsseldorf", "Deutschland"),
("Hauptstr. 88", "50667", "Köln", "Deutschland"),
("Marienplatz 3", "80331", "München", "Deutschland"),
]
rng = random.Random(123)
vorlagen_idx = 0
for projekt in projekte:
anzahl = rng.randint(1, 3)
for _ in range(anzahl):
anrede, name, firma, position = vorlagen[vorlagen_idx % len(vorlagen)]
vorlagen_idx += 1
strasse, plz, ort, land = rng.choice(orte)
ka = Kontaktadresse()
ka.projekt_id = projekt.id
ka.anrede = anrede
ka.name = name
ka.firma = firma
ka.position = position
ka.telefon = "+49 {vorwahl} {nummer}".format(
vorwahl=rng.randint(100, 999),
nummer=rng.randint(100000, 999999)
) )
ka.email = name.lower().replace(" ", ".") + "@example.de" session.add(adresse)
ka.strasse = strasse session.flush()
ka.plz = plz
ka.ort = ort
ka.land = land
ka.notiz = ""
session.add(ka)
session.commit()
# Je Kontaktadresse ein bis zwei Gesprächsnotizen, die das
def _seed_muster_calllogs(): # Akquise-Telefonat mit diesem Ziel dokumentieren.
""" for _ in range(rng.randint(1, 2)):
Legt für jedes Muster-Projekt 2 bis 5 Gesprächsnotizen an, falls die
call_logs-Tabelle noch leer ist.
"""
import random
from crm.model import Projekt, CallLog
session = dbapp.session
if session.query(CallLog).count() > 0:
return
projekte = session.query(Projekt).order_by(Projekt.id).all()
betreffvorlagen = [
"Anforderungen klären",
"Feedback zum Design",
"Technische Anforderungen",
"Schulungstermin",
"Feature-Request",
"Budgetgespräch",
"Projekt-Review",
]
notizvorlagen = [
"Kunde möchte responsive Design und neue Farbgestaltung. Budget noch offen.",
"Design-Entwürfe wurden positiv aufgenommen. Kleine Änderungen an der Navigation nötig.",
"Payment-Gateway und Versand-Schnittstellen müssen integriert werden.",
"Schulung für 10 Mitarbeiter geplant. Raum noch zu buchen.",
"Kunde wünscht Push-Benachrichtigungen und Offline-Modus.",
"",
]
ansprechpartner = ["Herr Schmidt", "Frau Müller", "Herr Weber", "Frau Bauer", "Herr König"]
rng = random.Random(456)
for projekt in projekte:
anzahl = rng.randint(2, 5)
for _ in range(anzahl):
log = CallLog() log = CallLog()
log.projekt_id = projekt.id log.projekt_id = projekt.id
monat = rng.randint(1, 9) log.kontaktadresse_id = adresse.id
tag = rng.randint(1, 28) log.datum = date(2026, 9, rng.randint(1, 26))
log.datum = "2024-0{m}-{t}".format(m=monat, t=tag) if monat < 10 else "2024-{m}-{t}".format(m=monat, t=tag)
log.uhrzeit_beginn = "{h:02d}:{min:02d}".format( log.uhrzeit_beginn = "{h:02d}:{min:02d}".format(
h=rng.randint(8, 17), min=rng.randint(0, 59) h=rng.randint(8, 17), min=rng.randint(0, 59)
) )
log.dauer = str(rng.randint(15, 90)) log.dauer = str(rng.randint(5, 45))
log.betreff = rng.choice(betreffvorlagen) log.betreff = rng.choice(_BETREFFVORLAGEN)
log.name_ansprechpartner = rng.choice(ansprechpartner) log.name_ansprechpartner = adresse.name
log.telefon = "+49 {v} {n}".format( log.telefon = adresse.telefon
v=rng.randint(100, 999), n=rng.randint(100000, 999999) log.notiz = rng.choice(_NOTIZVORLAGEN)
log.wiedervorlage_datum = log.datum + timedelta(
days=rng.randint(7, 28)
) )
log.notiz = rng.choice(notizvorlagen)
wiedervorlage_monat = monat + 1 if monat < 12 else 1
log.wiedervorlage_datum = "2024-0{m}-{t}".format(m=wiedervorlage_monat, t=tag) if wiedervorlage_monat < 10 else "2024-{m}-{t}".format(m=wiedervorlage_monat, t=tag)
session.add(log) session.add(log)
session.commit() session.commit()
+6 -14
View File
@@ -9,6 +9,7 @@ import {iKunde, fetchKunden} from "../models/kunde"
import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt" import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse" import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse"
import {iTag, fetchTags, berechneWiedervorlageDatum} from "../models/tag" import {iTag, fetchTags, berechneWiedervorlageDatum} from "../models/tag"
import {TagBadge} from "./tagbadge"
import {showServerError} from "./auth/authmessages" import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard" import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
@@ -83,18 +84,7 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
// Zugewiesene Tags als farbige Badges hinter dem Betreff darstellen. // Zugewiesene Tags als farbige Badges hinter dem Betreff darstellen.
const tagBadges = (log.tags || []).map((t: iTag) => const tagBadges = (log.tags || []).map((t: iTag) =>
m("span", { m(TagBadge, {tagData: t, compact: true})
style: {
backgroundColor: t.color || '#999',
color: 'white',
padding: '1px 5px',
'border-radius': '3px',
'font-size': '11px',
'margin-left': '4px',
display: 'inline-block'
},
title: t.name
}, t.name)
) )
return [ return [
@@ -352,9 +342,11 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
return {value: ka.id, label: ka.name + (ka.firma ? " (" + ka.firma + ")" : "")} return {value: ka.id, label: ka.name + (ka.firma ? " (" + ka.firma + ")" : "")}
}) })
: [] : []
// Tag-Optionen und Farb-Map für die Mehrfachauswahl. // Tag-Optionen und Farb-Map für die Mehrfachauswahl. Als content
// wird das fertige TagBadge übergeben — das Auswahl-Modal zeigt
// damit den Farbchip mit Namen und die Workflow-Icons.
const tagOptionen: iSelectOption[] = tagListe.map((t: iTag) => { const tagOptionen: iSelectOption[] = tagListe.map((t: iTag) => {
return {value: t.id, label: t.name} return {value: t.id, label: t.name, content: m(TagBadge, {tagData: t})}
}) })
const tagColors: Map<number, string> = new Map(tagListe.map((t: iTag) => [t.id, t.color] as [number, string])) const tagColors: Map<number, string> = new Map(tagListe.map((t: iTag) => [t.id, t.color] as [number, string]))
+97
View File
@@ -0,0 +1,97 @@
import m from "mithril"
import {iTag} from "../models/tag"
/**
* Der Vnode der Komponente 'TagBadge' akzeptiert die folgenden Parameter:
* @property {iTag} tagData - Der darzustellende Tag. Achtung: Das Attribut
* darf nicht 'tag' heißen — Mithril behandelt ein attrs-
* Objekt mit einer 'tag'-Property als Vnode und verschiebt
* es in die children (siehe hyperscriptVnode.js).
* @property {boolean} compact (optional) - kompakte Darstellung für dichte
* Listen/Tabellen (kleinere Schrift, engeres Padding);
* Default ist die Standarddarstellung (fett)
* @property {string} title (optional) - Tooltip des Farbchips
* (Default: Tag-Name)
*/
export interface iTagBadge {
tagData: iTag
compact?: boolean
title?: string
}
/**
* Farbige Badge-Komponente für einen Tag: rendert den Tag-Namen als Chip in
* der Tag-Farbe und dahinter automatisch die Workflow-Zusatzicons —
* back_hand (rot) für erledigende Tags, schedule + Tagesabstand (blau) für
* Tags, die eine Wiedervorlage auslösen (jeweils mit Tooltip). Parent-Tags
* erhalten keine Zusatzicons.
*
* Die Komponente kapselt die Darstellung zentral, damit alle Stellen
* (Notiz-Listen, Workflow-Liste, Historie, Auswahl-Modals, Tag-Liste)
* einheitlich aussehen. Aufrufer geben nur den Tag an; Name, Farbe und
* Zusatzicons ergeben sich aus den Tag-Daten.
*/
export const TagBadge: m.ClosureComponent<iTagBadge> = () => {
return {
view: (vnode) => {
const tag = vnode.attrs.tagData
const compact = vnode.attrs.compact === true
// Farbiger Chip mit dem Tag-Namen. Bei fehlender Farbe grau.
const chip = m("span", {
style: {
backgroundColor: tag.color || '#999',
color: 'white',
padding: compact ? '1px 5px' : '2px 6px',
'border-radius': '3px',
'font-size': compact ? '11px' : undefined,
fontWeight: compact ? undefined : 'bold',
display: 'inline-block',
marginRight: '4px'
},
title: vnode.attrs.title || tag.name
}, tag.name)
// Workflow-Zusatzicons hinter dem Chip — fest programmiert aus
// den Tag-Flags, nicht per Attribut steuerbar. Parent-Tags sind
// nicht zuordenbar und erhalten daher keine Icons.
const icons: m.Child[] = []
if (!tag.is_parent) {
if (tag.is_erledigend) {
icons.push(m("i.material-icons", {
title: "Erledigend: Kontakt gilt als erledigt",
style: {
verticalAlign: 'middle',
fontSize: '16px',
color: '#d32f2f'
}
}, "back_hand"))
}
if (tag.loest_wiedervorlage_aus) {
icons.push(m("span", {
title: "Löst Wiedervorlage aus: " + (tag.wiedervorlage_abstand_tage ?? 0) + " Tage",
style: {
verticalAlign: 'middle',
fontSize: '12px',
color: '#1565c0'
}
},
m("i.material-icons", {
style: {
verticalAlign: 'middle',
marginRight: '2px',
fontSize: '16px'
}
}, "schedule"),
tag.wiedervorlage_abstand_tage != null ? tag.wiedervorlage_abstand_tage + "d" : ""))
}
}
return m("span", {
style: {
display: 'inline-flex',
alignItems: 'center',
marginLeft: '4px',
whiteSpace: 'nowrap'
}
}, chip, icons)
}
}
}
+13 -26
View File
@@ -14,6 +14,7 @@ import {iProjekt, fetchProjektById} from "../models/projekt"
import {iCallLog, fetchCallLogsByKontaktadresse, createCallLogREST} from "../models/call_log" import {iCallLog, fetchCallLogsByKontaktadresse, createCallLogREST} from "../models/call_log"
import {iTag, fetchTags, berechneWiedervorlageDatum} from "../models/tag" import {iTag, fetchTags, berechneWiedervorlageDatum} from "../models/tag"
import {iTagesWorkflowEntry, iTagesWorkflowFilter, fetchTagesWorkflowList} from "../models/tages_workflow" import {iTagesWorkflowEntry, iTagesWorkflowFilter, fetchTagesWorkflowList} from "../models/tages_workflow"
import {TagBadge} from "./tagbadge"
// ============================================================================ // ============================================================================
// MODUL-LEVEL WORKFLOW-KONTEXT // MODUL-LEVEL WORKFLOW-KONTEXT
@@ -148,18 +149,7 @@ export const TagesWorkflowListe: m.ClosureComponent = () => {
// Badges der Tags des letzten Gesprächs (gleicher Stil wie in der // Badges der Tags des letzten Gesprächs (gleicher Stil wie in der
// Gesprächsnotizen-Liste). // Gesprächsnotizen-Liste).
const tagBadges = (eintrag.letzte_notiz_tags || []).map((t: iTag) => const tagBadges = (eintrag.letzte_notiz_tags || []).map((t: iTag) =>
m("span", { m(TagBadge, {tagData: t, compact: true})
style: {
backgroundColor: t.color || '#999',
color: 'white',
padding: '1px 5px',
'border-radius': '3px',
'font-size': '11px',
'margin-left': '4px',
display: 'inline-block'
},
title: t.name
}, t.name)
) )
return [ return [
m("div", m("div",
@@ -675,9 +665,13 @@ export const TagesWorkflowMaske: m.ClosureComponent = () => {
// selbst scrollbar. // selbst scrollbar.
formAttrs.flexContent = true formAttrs.flexContent = true
// Tag-Optionen und Farb-Map für die Mehrfachauswahl. // Tag-Optionen und Farb-Map für die Mehrfachauswahl. Als content
// wird das fertige TagBadge übergeben — das Auswahl-Modal zeigt
// damit den Farbchip mit Namen und automatisch die Workflow-Icons
// (back_hand = erledigend, schedule + Tagesabstand = löst
// Wiedervorlage aus). label bleibt als Suchbasis gesetzt.
const tagOptionen: iSelectOption[] = tagListe.map((t: iTag) => { const tagOptionen: iSelectOption[] = tagListe.map((t: iTag) => {
return {value: t.id, label: t.name} return {value: t.id, label: t.name, content: m(TagBadge, {tagData: t})}
}) })
const tagColors: Map<number, string> = new Map(tagListe.map((t: iTag) => [t.id, t.color] as [number, string])) const tagColors: Map<number, string> = new Map(tagListe.map((t: iTag) => [t.id, t.color] as [number, string]))
@@ -795,6 +789,10 @@ export const TagesWorkflowMaske: m.ClosureComponent = () => {
m("p", {style: {margin: '4px 0'}}, m("p", {style: {margin: '4px 0'}},
m("strong", "Kontakt: "), m("strong", "Kontakt: "),
kontaktadresse ? kontaktadresse.name : "", kontaktadresse ? kontaktadresse.name : "",
// Position des Ansprechpartners in Klammern
// hinter dem Namen (nur wenn gesetzt).
kontaktadresse && kontaktadresse.position
? " (" + kontaktadresse.position + ")" : "",
kontaktadresse && kontaktadresse.telefon ? " | " + kontaktadresse.telefon : "", kontaktadresse && kontaktadresse.telefon ? " | " + kontaktadresse.telefon : "",
kontaktadresse && kontaktadresse.email ? " | " + kontaktadresse.email : ""), kontaktadresse && kontaktadresse.email ? " | " + kontaktadresse.email : ""),
m("p", {style: {margin: '4px 0'}}, m("p", {style: {margin: '4px 0'}},
@@ -948,18 +946,7 @@ export const TagesWorkflowMaske: m.ClosureComponent = () => {
m("td", log.betreff || ""), m("td", log.betreff || ""),
m("td", log.notiz || ""), m("td", log.notiz || ""),
m("td", (log.tags || []).map((t: iTag) => m("td", (log.tags || []).map((t: iTag) =>
m("span", { m(TagBadge, {tagData: t, compact: true}))),
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 || "")) m("td", {style: {'white-space': 'nowrap'}}, log.wiedervorlage_datum || ""))
) )
) )
+4 -55
View File
@@ -7,6 +7,7 @@ import {gridMountpoints} from "./mainlayout"
import {showServerError} from "./auth/authmessages" import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard" import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
import {iTag, iTagWithChildren, fetchTags, fetchTagHierarchy, fetchTagById, createTagREST, updateTagREST, deleteTagREST} from "../models/tag" import {iTag, iTagWithChildren, fetchTags, fetchTagHierarchy, fetchTagById, createTagREST, updateTagREST, deleteTagREST} from "../models/tag"
import {TagBadge} from "./tagbadge"
const colorPalette: string[] = [ const colorPalette: string[] = [
"#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff", "#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff",
@@ -348,47 +349,6 @@ export const TagList: m.ClosureComponent = () => {
const tagIcon = tag.is_parent ? "folder" : "label" const tagIcon = tag.is_parent ? "folder" : "label"
const tagTitle = tag.is_parent ? "Parent-Tag (nicht zuordenbar)" : "" const tagTitle = tag.is_parent ? "Parent-Tag (nicht zuordenbar)" : ""
// Workflow-Badges für echte Tags (nicht für Parent-Tags)
let workflowBadges: any[] = []
if (!tag.is_parent) {
workflowBadges = []
if (tag.is_erledigend) {
workflowBadges.push(
m("i.material-icons", {
title: "Erledigend: Kontakt gilt als erledigt",
style: {
verticalAlign: 'middle',
marginRight: '4px',
fontSize: '16px',
color: '#2e7d32'
}
}, "check_circle")
)
}
if (tag.loest_wiedervorlage_aus) {
workflowBadges.push(
m("span", {
title: "Löst Wiedervorlage aus: " + (tag.wiedervorlage_abstand_tage ?? 0) + " Tage",
style: {
verticalAlign: 'middle',
marginRight: '4px',
fontSize: '12px',
color: '#1565c0'
}
},
m("i.material-icons", {
style: {
verticalAlign: 'middle',
marginRight: '2px',
fontSize: '16px'
}
}, "schedule"),
tag.wiedervorlage_abstand_tage != null ? tag.wiedervorlage_abstand_tage + "d" : ""
)
)
}
}
return [ return [
m("span", m("span",
m("i.material-icons", { m("i.material-icons", {
@@ -399,20 +359,9 @@ export const TagList: m.ClosureComponent = () => {
}, },
title: tagTitle title: tagTitle
}, tagIcon), }, tagIcon),
m("span", { // Farbchip mit Name und Workflow-Icons (erledigend /
style: { // Wiedervorlage) aus der zentralen TagBadge-Komponente.
backgroundColor: tag.color || '#9e9e9e', m(TagBadge, {tagData: tag})),
color: 'white',
padding: '2px 6px',
borderRadius: '3px',
fontWeight: 'bold',
display: 'inline-block',
marginRight: '8px'
},
title: tagTitle
}, tag.name),
workflowBadges
),
m("span", tag.description), m("span", tag.description),
m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap', 'justify-content': 'flex-end'}}, 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("button.w3-button.w3-small", {title: "Tag bearbeiten", style: {padding: '0 2px'}, onclick: (e: Event) => editTag(tag, e)},
+26 -1
View File
@@ -8,6 +8,11 @@ export interface iSelectOption {
value: any value: any
/** Anzeige im Feld bzw. in der Dropdown-Liste (z.B. Kundenname). */ /** Anzeige im Feld bzw. in der Dropdown-Liste (z.B. Kundenname). */
label: string label: string
/** Optionaler fertiger Darstellungs-Content (z.B. eine Badge-Komponente),
* der im Auswahl-Modal des MultiSelect statt Farbpunkt und Label
* angezeigt wird. Das label bleibt davon unberührt — es ist weiterhin
* die Basis für die Suche im Modal. */
content?: m.Child
} }
/** /**
@@ -491,12 +496,18 @@ export const MultiSelect: m.ClosureComponent<iMultiSelect> = () => {
m("h2", modalTitle) m("h2", modalTitle)
), ),
m("div.w3-container.w3-theme-l1", {style: {padding: '16px'}}, m("div.w3-container.w3-theme-l1", {style: {padding: '16px'}},
// Suchfeld. // Suchfeld. Der oncreate-Hook fokussiert das
// Feld direkt beim Öffnen des Modals — das
// Input wird ja erst mit dem Modal erzeugt,
// sodass der Nutzer sofort tippen kann.
m("input.w3-input.w3-border", { m("input.w3-input.w3-border", {
type: "text", type: "text",
value: search, value: search,
placeholder: "Suchbegriff …", placeholder: "Suchbegriff …",
style: {'margin-bottom': '10px', width: '100%'}, style: {'margin-bottom': '10px', width: '100%'},
oncreate: (vnode: m.VnodeDOM) => {
(vnode.dom as HTMLInputElement).focus()
},
oninput: (e: Event) => { oninput: (e: Event) => {
search = (e.target as HTMLInputElement).value search = (e.target as HTMLInputElement).value
} }
@@ -526,6 +537,19 @@ export const MultiSelect: m.ClosureComponent<iMultiSelect> = () => {
toggleDraft(option) toggleDraft(option)
} }
}), }),
// Ist ein fertiger content gesetzt (z.B. eine
// Badge-Komponente des Aufrufers), wird dieser
// statt Farbpunkt und Label angezeigt — das
// Widget selbst bleibt frei von Tag-Wissen.
option.content
? m("span", {
style: {
display: 'inline-flex',
alignItems: 'center',
flex: '1 1 auto'
}
}, option.content)
: [
color color
? m("span", { ? m("span", {
style: { style: {
@@ -539,6 +563,7 @@ export const MultiSelect: m.ClosureComponent<iMultiSelect> = () => {
}) })
: null, : null,
m("span", option.label) m("span", option.label)
]
) )
}) })
), ),
+3
View File
@@ -0,0 +1,3 @@
# TODOs
- [ ] Mit KBP klären, ob der Verantwortliche im Projekt ein freier Name ist oder ob das ein Kontakt aus der Kontaktliste des Kunden sein muss.