Compare commits

...

7 Commits
main ... dev

Author SHA1 Message Date
c3462d3d3a prepare better DB interaction with reload 2026-05-06 15:35:49 +02:00
3dbc9ecfcb dataclass-driven form generation 2026-04-30 14:51:35 +02:00
c5aadd502d further prototyping, added first DB interactions 2026-04-23 15:57:39 +02:00
e4ebb1ee7f track prototypes 2026-04-22 15:43:17 +02:00
5aa09727c4 add Qt (PySide) 2026-04-22 15:43:06 +02:00
425ebefff4 add nice-gui dep 2026-04-16 16:15:17 +02:00
1abff8f45a conceptual renaming 2026-04-16 14:33:26 +02:00
17 changed files with 5113 additions and 119 deletions

2
.gitignore vendored
View File

@ -1,5 +1,5 @@
# own
prototypes/
# prototypes/
data/
reports/
*.code-workspace

1867
pdm.lock generated

File diff suppressed because it is too large Load Diff

153
prototypes/db_access.py Normal file
View File

@ -0,0 +1,153 @@
# %%
import importlib
from pathlib import Path
import polars as pl
import sqlalchemy as sql
from wce_crm import db
importlib.reload(db)
# %%
PTH_DATA_DB = Path.cwd().parent / "data/db"
assert PTH_DATA_DB.exists()
assert PTH_DATA_DB.is_dir()
# %%
DB_KL = PTH_DATA_DB / "wce_kontaktliste.db"
DB_CRM = PTH_DATA_DB / "wce_crm.db"
assert DB_KL.exists()
assert DB_CRM.exists()
# %%
engine = sql.create_engine(f"sqlite:///{DB_CRM}")
# %%
db.df_crm_master
# %%
stmt = sql.select(db.ext_crm_master)
str(stmt.compile(engine))
df = pl.read_database(stmt, engine, schema_overrides=db.ext_crm_master_schema)
# df = pl.concat([df, df[:2]])
# %%
df.select("ma_unternehmensname").is_duplicated().sum()
# %%
q = df.lazy()
counter = pl.int_range(0, pl.len()).over(pl.col.ma_unternehmensname)
q = q.with_columns(
ma_unternehmensname_dedupl=pl.when(counter == 0)
.then(pl.col.ma_unternehmensname)
.otherwise(pl.format("{} ({})", pl.col.ma_unternehmensname, counter))
)
df = q.collect()
df.select("ma_unternehmensname_dedupl").is_duplicated().sum()
# %%
# mapping dedupl text to idx
df.head()
# dict(zip(df["ma_unternehmensname_dedupl"], df["ma_id"]))
# %%
sub = df[0]
sub
# sub.with_columns(
# # pl.when(pl.col(pl.Boolean)).then(pl.lit("Ja")).otherwise(pl.lit("Nein"))
# pl.when(pl.col(pl.Boolean)).then(pl.lit("Ja")).otherwise(pl.lit("Nein")).name.keep()
# )
# %%
q = (
sub.lazy()
.with_columns(
pl.col(pl.Datetime).dt.to_string("%d.%m.%Y"),
pl.col(pl.Date).dt.to_string("%d.%m.%Y"),
pl.when(pl.col(pl.Boolean)).then(pl.lit("Ja")).otherwise(pl.lit("Nein")).name.keep(),
)
.with_columns(pl.all().cast(pl.String))
)
sub = q.collect()
sub
# %%
df.row(0, named=True)
# %%
db.df_crm_master.estimated_size("mb")
# %%
# // CRM Ansprechpartner
df = db.df_contact_person.filter(pl.col.ma_id == 410)
df = df.with_columns(pl.col(pl.String).str.replace_all(r"[\r\t\n]", " ").str.strip_chars(" "))
df
# %%
tuple(zip(df["ma_id"], df["an_id"]))
# %%
db.df_crm_master
# %%
# // CRM Nutzer
stmt = sql.select(db.ext_crm_nutzer).limit(20)
str(stmt.compile(engine))
df = pl.read_database(stmt, engine, schema_overrides=db.ext_crm_nutzer_schema)
# %%
stmt = sql.text("""SELECT ma_unternehmensname, ma_ersteintrag_datum, ma_aktualisierung_datum
FROM Master
WHERE ma_ersteintrag_datum LIKE '%ff'
LIMIT 10;""")
with engine.connect() as con:
res = con.execute(stmt)
print(res.fetchall())
# %%
# ----------------------------------------------------------------
engine = sql.create_engine(f"sqlite:///{DB_KL}")
stmt = sql.select(db.ext_kl_unternehmen.c.u_firmenname).limit(20)
with engine.connect() as con:
res = con.execute(stmt)
res.scalars().all()
# %%
for _ in res.mappings():
print(_)
# %%
# %%
stmt = sql.select(db.ext_kl_unternehmen)
df = pl.read_database(stmt, engine, schema_overrides=db.ext_kl_unternehmen_schema)
# %%
df
# %%
df.estimated_size("mb")
# %%
df.height
# %%
db.df_kontaktliste
# %%
sub = db.df_kontaktliste.select(["u_id", "u_firmenname"]).lazy()
# %%
counter = pl.int_range(0, pl.len()).over(pl.col.u_firmenname)
sub = sub.with_columns(
t=pl.when(counter == 0)
.then(pl.col.u_firmenname)
.otherwise(pl.format("{} ({})", pl.col.u_firmenname, counter))
)
# %%
sub.collect()
# %%
# 1. Create a sample DataFrame
df = pl.DataFrame({"text_col": ["TEST", "APPLE", "TEST", "TEST", "BANANA", "APPLE"]})
# 2. Define the window function to count occurrences
# This generates a sequence [0, 1, 2...] for each unique string
counter = pl.int_range(0, pl.len()).over("text_col")
# 3. Apply the conditional formatting
df = df.with_columns(
updated_col=pl.when(counter == 0)
.then(pl.col("text_col")) # Keep original for the first occurrence
.otherwise(pl.format("{} ({})", pl.col("text_col"), counter)) # Format duplicates
)
# %%
df

View File

@ -0,0 +1,49 @@
from typing import Any
from nicegui import ui
nested_menu = {
"Notiz": None,
"Beratungsgespräch": None,
"Neu": {"Unternehmen": None, "Individualperson": None},
}
def build_menu(
title: str,
structure: dict[str, Any],
):
def _recursive(structure: dict[str, Any]):
with ui.menu():
for label, children in structure.items():
if children:
with ui.menu_item(label, auto_close=False).props("icon=arrow_right"):
with ui.menu().props('anchor="top end" self="top start"'):
build_menu(children) # Rekursion für Untermenüs
else:
ui.menu_item(label, on_click=lambda: ui.notify(f"{label} geklickt"))
with ui.button(title, icon="add"):
_recursive(structure)
# ui.menu_item("Direkte Aktion", on_click=lambda: ui.notify("Aktion 1"))
# ui.separator()
# # Der Trick: @click.stop verhindert, dass das Hauptmenü den Klick bemerkt
# with ui.menu_item("Untermenü öffnen...", auto_close=False).props("icon=arrow_right"):
# with ui.item_section().props("side"):
# ui.icon("keyboard_arrow_right")
# with ui.menu().props('anchor="top end" self="top start"'):
# ui.menu_item("Unterpunkt A", on_click=lambda: ui.notify("A"))
# ui.menu_item("Unterpunkt B", on_click=lambda: ui.notify("B"))
# def build_menu(data):
# for label, children in data.items():
# if children:
# with ui.menu_item(label):
# with ui.menu().props('anchor="top end" self="top start"'):
# build_menu(children) # Rekursion für Untermenüs
# else:
# ui.menu_item(label, on_click=lambda l=label: ui.notify(f"{l} geklickt"))

378
prototypes/t_nice_gui.py Normal file
View File

@ -0,0 +1,378 @@
from typing import Any
from nicegui import ui
def menu():
with ui.column().classes("w-full items-center"):
ui.label("Mein Dashboard 2").classes("text-h4")
# Eine Hauptspalte für die gesamte Seite
# @ui.page("/")
# def main_page():
# menu()
# # Erste Sektion: Zwei Karten nebeneinander
# with ui.row().classes("w-full justify-center"):
# with ui.card():
# ui.label("Statistik A")
# ui.number(value=42)
# with ui.card():
# ui.label("Statistik B")
# ui.switch("Aktivieren")
# # Zweite Sektion: Eine breite Spalte darunter
# with ui.card().classes("w-full items-left"):
# ui.label("Details")
# ui.markdown("Hier stehen weitere Informationen in einer eigenen Sektion.")
# with ui.expansion("Erweiterte Einstellungen", icon="settings").classes("w-full"):
# ui.label("Hier kannst du tiefergehende Konfigurationen vornehmen.")
# # ui.input("API Schlüssel")
# ui.link("Gehe zur Einstellungs-Seite", "/settings")
# @ui.page("/settings")
# def settings_page():
# menu()
# ui.label("Einstellungen").classes("text-h4")
# ui.button("Zurück", on_click=lambda: ui.navigate.to("/"))
# Beispieldaten für die Kacheln
menu_items = [
{"title": "Dashboard", "icon": "dashboard", "route": "/dashboard", "color": "#e3f2fd"},
{"title": "Benutzer", "icon": "people", "route": "/users", "color": "#f1f8e9"},
{"title": "Einstellungen", "icon": "settings", "route": "/settings", "color": "#fff3e0"},
{"title": "Berichte", "icon": "assessment", "route": "/reports", "color": "#fce4ec"},
{"title": "Hilfe", "icon": "help", "route": "/help", "color": "#f3e5f5"},
{"title": "Abmelden", "icon": "logout", "route": "/logout", "color": "#efebe9"},
]
# @ui.page("/")
# def main_page():
# ui.label("Hauptmenü").classes("text-h4 mb-4")
# # Erstellt ein Raster mit 3 Spalten (auf kleinen Bildschirmen 1, auf mittleren 2, auf großen 3)
# with ui.grid(columns="1fr 1fr 1fr").classes("w-full gap-4"):
# for item in menu_items:
# # Erstelle eine Karte als Kachel
# # 'cursor-pointer' macht den Mauszeiger zur Hand
# with (
# ui.card()
# .tight()
# .classes("cursor-pointer hover:shadow-lg transition-shadow")
# .style(f"background-color: {item['color']}; height: 150px;")
# .on("click", lambda i=item: ui.navigate.to(i["route"]))
# ):
# with ui.column().classes("w-full h-full items-center justify-center p-4"):
# ui.icon(item["icon"]).classes("text-5xl mb-2")
# ui.label(item["title"]).classes("text-lg font-bold")
# # Dummy-Zielseiten
# @ui.page("/dashboard")
# def dashboard():
# ui.label("Hier ist das Dashboard")
# ui.button("Zurück", on_click=lambda: ui.navigate.to("/"))
# Beispieldaten: Eine Liste von Einträgen
# data = [
# {"id": "001", "name": "Projekt Alpha", "status": "Aktiv", "date": "2023-10-01"},
# {"id": "002", "name": "Kundenmeeting", "status": "Geplant", "date": "2023-10-05"},
# {"id": "003", "name": "System-Update", "status": "Erledigt", "date": "2023-09-28"},
# {"id": "004", "name": "Budget-Review", "status": "Aktiv", "date": "2023-10-12"},
# ]
# @ui.page("/")
# def table_view():
# with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-2"):
# # --- TABELLEN-KOPF ---
# # Wir nutzen ein Grid, um die Spaltenbreiten festzulegen (z.B. 10% ID, 50% Name, 20% Status, 20% Datum)
# with ui.row().classes(
# "w-full bg-slate-200 p-4 rounded-t-lg shadow-sm font-bold text-lg items-center"
# ):
# ui.label("ID").classes("w-12")
# ui.label("Bezeichnung").classes("flex-grow")
# ui.label("Status").classes("w-24")
# ui.label("Datum").classes("w-24 text-right")
# # --- TABELLEN-EINTRÄGE ---
# for entry in data:
# # Jede Zeile ist eine klickbare Karte/Reihe
# with (
# ui.card()
# .tight()
# .classes(
# "w-full hover:bg-blue-50 cursor-pointer transition-all hover:scale-[1.01]"
# )
# .on("click", lambda e=entry: ui.notify(f"Gehe zu: {e['name']}"))
# ):
# with ui.row().classes("w-full p-4 items-center"):
# # Geringfügig kleinerer Text als im Header (text-base vs text-lg)
# ui.label(entry["id"]).classes("w-12 text-slate-500 font-mono")
# ui.label(entry["name"]).classes("flex-grow font-medium")
# # Ein kleiner Chip für den Status
# ui.badge(
# entry["status"],
# color="green" if entry["status"] == "Aktiv" else "grey",
# ).classes("w-20")
# ui.label(entry["date"]).classes("w-24 text-right text-sm text-slate-400")
# 1. Unsere Datenquelle (könnte später auch eine Datenbank sein)
data = [
{
"UN/ NWP/ Kontaktperson": "Unternehmen 1",
"Individualberatung": "Max Mustermann",
"Pauschalberatung": "",
"VerlaufsprotokollDatum": "30.04.2023",
},
{
"UN/ NWP/ Kontaktperson": "Maxi Musterfrau",
"Individualberatung": "",
"Pauschalberatung": "Ausbildung",
"Datum": "30.06.2023",
},
]
nested_menu = {
"Notiz": None,
"Beratungsgespräch": None,
"Neu": {"Unternehmen": None, "Individualperson": None},
}
# nested_menu = {
# "Notiz": None,
# "Beratungsgespräch": None,
# "Neu": {"Unternehmen": None, "Individualperson": None},
# "Weitere": {
# "Sub 1": {"Sub-1-1": None, "Sub-1-2": None},
# "Sub 2": {"Sub 2-1": None, "Sub-2-2": None},
# "Sub 3 deutlich länger als andere": {"Sub 3-1": None, "Sub-3-2": None},
# },
# }
def build_menu(
title: str,
icon: str,
structure: dict[str, Any],
):
# Diese Funktion baut NUR die Einträge (Items), nicht die Container (Menus)
def _recursive(current_structure: dict[str, Any]):
for label, children in current_structure.items():
if children:
# --- AST (Verzweigung) ---
with ui.menu_item(auto_close=False):
with ui.item_section():
ui.label(label)
with ui.item_section().props("side"):
ui.icon("keyboard_arrow_right") # Das korrekte Pfeil-Icon
# Hier bauen wir EINEN neuen Container für die nächste Ebene
with ui.menu().props('anchor="top end" self="top start"'):
_recursive(children)
else:
# --- BLATT (Endpunkt) ---
# Beachte das l=label für das Closure/Late-Binding-Problem
ui.menu_item(label, on_click=lambda l=label: ui.notify(f"{l} geklickt!"))
with ui.button(title, icon=icon):
with ui.menu():
_recursive(structure)
@ui.page("/")
def table_view():
# Header-Bereich
with ui.column().classes("w-full max-w-5xl mx-auto p-2 gap-2"):
ui.label("Projekt-Verwaltung").classes("text-h4 mb-2")
with ui.row().classes("w-full justify-left p-2"):
build_menu("Hinzufügen", "add", nested_menu)
# with ui.button("Neu", icon="add"):
# with ui.menu() as main_menu:
# ui.menu_item("Direkte Aktion", on_click=lambda: ui.notify("Aktion 1"))
# ui.separator()
# with ui.menu_item("Untermenü öffnen...", auto_close=False).props(
# "icon=arrow_right"
# ):
# with ui.item_section().props("side"):
# ui.icon("keyboard_arrow_right")
# with ui.menu().props('anchor="top end" self="top start"'):
# ui.menu_item("Unterpunkt A", on_click=lambda: ui.notify("A"))
# ui.menu_item("Unterpunkt B", on_click=lambda: ui.notify("B"))
# --- EINGABE-BEREICH ---
with ui.row().classes("w-full items-center mb-4 gap-2"):
name_input = ui.input(placeholder="Name des Eintrags").classes("flex-grow")
status_input = ui.select(["Aktiv", "Geplant", "Erledigt"], value="Aktiv").classes(
"w-40"
)
def add_entry():
if not name_input.value:
ui.notify("Bitte einen Namen eingeben!", type="negative")
return
# Neuen Eintrag zum Datenmodell hinzufügen
new_id = f"{len(data) + 1:03d}"
data.append(
{
"id": new_id,
"name": name_input.value,
"status": status_input.value,
"date": "Heute",
}
)
# UI aktualisieren
name_input.value = "" # Feld leeren
# render_table.refresh() # Die Tabelle neu zeichnen
ui.notify(f"Eintrag {new_id} hinzugefügt")
ui.button(on_click=add_entry, icon="add").classes("rounded-full")
# Beispieldaten: Bei Projekt Gamma fehlt 'c3' komplett, bei Delta ist es leer/None
data = [
{
"c1": "Projekt Alpha",
"c2": "Ganz normaler Eintrag mit allen Daten.",
"c3": "Teamleitung",
"c4": "Priorität Hoch",
"date": "17.04.2024",
},
{
"c1": "Projekt Gamma",
"c2": "Hier fehlt die Abteilung (c3) komplett im Datenmodell.",
# 'c3' existiert hier nicht!
"c4": "Wartend",
"date": "21.04.2024",
},
{
"c1": "Projekt Delta",
"c2": "Hier ist die Abteilung (c3) explizit None.",
"c3": None,
"c4": "Pausiert",
"date": "22.04.2024",
},
]
# Äußerer Container mit grauem Hintergrund, damit die weißen Zellen besser wirken
# KORREKTUR: 'min-h-screen' entfernt.
# NEU: 'rounded-xl' hinzugefügt, damit der Hintergrund-Kasten saubere Ecken hat.
with ui.column().classes("w-full max-w-6xl mx-auto p-4 gap-2 bg-slate-50 rounded-xl"):
# --- TABELLEN-HEADER ---
# Auch hier min-w-0 hinzufügen, damit der Header nicht vom Text weggedrückt wird
with ui.row().classes(
"w-full p-2 font-bold text-slate-500 items-center no-wrap gap-2"
):
ui.label("Name").classes("flex-1 min-w-0 text-center")
ui.label("Beschreibung").classes("flex-1 min-w-0 text-center")
ui.label("Abteilung").classes("flex-1 min-w-0 text-center")
ui.label("Status").classes("flex-1 min-w-0 text-center")
ui.label("Datum").classes("w-32 text-center")
# --- TABELLEN-ZEILEN ---
for entry in data:
with ui.row().classes("w-full items-stretch no-wrap gap-2 mb-2"):
# WICHTIG: 'min-w-0' zwingt die Box, in ihrem Flex-Anteil zu bleiben!
cell_classes = (
"p-3 bg-white border border-slate-200 rounded-lg shadow-sm "
"cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition-all "
"flex items-center justify-center text-center min-w-0"
)
text_logic = "whitespace-normal break-words"
with ui.element("div").classes(f"flex-1 {cell_classes}"):
ui.label(entry.get("c1", "")).classes(f"{text_logic} font-bold")
with ui.element("div").classes(f"flex-1 {cell_classes}"):
ui.label(entry.get("c2", "")).classes(
f"{text_logic} text-sm text-slate-600"
)
# --- ZELLE 3: PLATZHALTER ---
c3_value = entry.get("c3")
if c3_value:
with ui.element("div").classes(f"flex-1 {cell_classes}"):
ui.label(c3_value).classes(text_logic)
else:
# WICHTIG: Auch der unsichtbare Platzhalter braucht 'min-w-0'
ui.element("div").classes("flex-1 min-w-0")
with ui.element("div").classes(f"flex-1 {cell_classes}"):
ui.badge(entry.get("c4", "")).classes(text_logic)
with ui.element("div").classes(f"w-32 {cell_classes}"):
ui.label(entry.get("date", "")).classes(
f"{text_logic} text-slate-400 text-xs font-mono"
)
# # --- TABELLEN-HEADER ---
# with ui.row().classes("w-full bg-slate-200 p-4 rounded-t-lg font-bold items-center"):
# ui.label("UN/ NWP/ Kontaktperson").classes("w-12")
# ui.label("Individualberatung").classes("flex-grow")
# ui.label("Pauschalberatung").classes("w-24 text-center")
# ui.label("Aktion").classes("w-12 text-right")
# # --- ZEILEN MIT EINZELN KLICKBAREN SPALTEN ---
# for entry in data:
# with ui.card().tight().classes("w-full mb-1"):
# with ui.row().classes("w-full p-4 items-center no-wrap"):
# # 1. Spalte: ID (Klickbar)
# ui.label(entry["UN/ NWP/ Kontaktperson"]).classes(
# "flex-grow text-slate-500 font-mono cursor-pointer hover:text-blue-600"
# ).on("click", lambda e=entry: ui.notify(f"ID {e['id']} Details öffnen"))
# # 2. Spalte: Name (Klickbar, nimmt den meisten Platz ein)
# ui.label(entry["Individualberatung"]).classes(
# "flex-grow font-medium cursor-pointer hover:underline"
# ).on("click", lambda e=entry: ui.notify(f"Editor für {e['name']}"))
# # 3. Spalte: Status (Klickbar, als Badge)
# ui.label(entry["Pauschalberatung"]).classes(
# "flex-grow font-medium cursor-pointer hover:underline"
# ).on("click", lambda e=entry: ui.notify(f"Editor für {e['name']}"))
# # 4. Spalte: Einzelnes Icon (Klickbare Aktion am Rand)
# # 3. Spalte: Status (Klickbar, als Badge)
# ui.label(entry["Datum"]).classes(
# "flex-grow font-medium cursor-pointer hover:underline"
# ).on("click", lambda e=entry: ui.notify(f"Editor für {e['name']}"))
# --- DYNAMISCHER TABELLEN-INHALT ---
# Wir definieren eine Funktion mit dem @ui.refreshable Dekorator
# @ui.refreshable
# def render_table():
# if not data:
# ui.label("Keine Einträge vorhanden").classes("p-4 text-slate-400")
# return
# for entry in data:
# with ui.card().tight().classes("w-full hover:bg-blue-50 cursor-pointer mb-1"):
# with ui.row().classes("w-full p-4 items-center"):
# ui.label(entry["id"]).classes("w-12 text-slate-500 font-mono")
# ui.label(entry["name"]).classes("flex-grow font-medium")
# ui.badge(
# entry["status"],
# color="green" if entry["status"] == "Aktiv" else "grey",
# ).classes("w-20")
# ui.label(entry["date"]).classes(
# "w-24 text-right text-sm text-slate-400"
# )
# Initiales Rendern der Tabelle
# render_table()
ui.run(native=False)

384
prototypes/t_nice_gui_2.py Normal file
View File

@ -0,0 +1,384 @@
from typing import Any
from nicegui import ui
def menu():
with ui.column().classes("w-full items-center"):
ui.label("Mein Dashboard 2").classes("text-h4")
# Eine Hauptspalte für die gesamte Seite
# @ui.page("/")
# def main_page():
# menu()
# # Erste Sektion: Zwei Karten nebeneinander
# with ui.row().classes("w-full justify-center"):
# with ui.card():
# ui.label("Statistik A")
# ui.number(value=42)
# with ui.card():
# ui.label("Statistik B")
# ui.switch("Aktivieren")
# # Zweite Sektion: Eine breite Spalte darunter
# with ui.card().classes("w-full items-left"):
# ui.label("Details")
# ui.markdown("Hier stehen weitere Informationen in einer eigenen Sektion.")
# with ui.expansion("Erweiterte Einstellungen", icon="settings").classes("w-full"):
# ui.label("Hier kannst du tiefergehende Konfigurationen vornehmen.")
# # ui.input("API Schlüssel")
# ui.link("Gehe zur Einstellungs-Seite", "/settings")
# @ui.page("/settings")
# def settings_page():
# menu()
# ui.label("Einstellungen").classes("text-h4")
# ui.button("Zurück", on_click=lambda: ui.navigate.to("/"))
# Beispieldaten für die Kacheln
menu_items = [
{"title": "Dashboard", "icon": "dashboard", "route": "/dashboard", "color": "#e3f2fd"},
{"title": "Benutzer", "icon": "people", "route": "/users", "color": "#f1f8e9"},
{"title": "Einstellungen", "icon": "settings", "route": "/settings", "color": "#fff3e0"},
{"title": "Berichte", "icon": "assessment", "route": "/reports", "color": "#fce4ec"},
{"title": "Hilfe", "icon": "help", "route": "/help", "color": "#f3e5f5"},
{"title": "Abmelden", "icon": "logout", "route": "/logout", "color": "#efebe9"},
]
# @ui.page("/")
# def main_page():
# ui.label("Hauptmenü").classes("text-h4 mb-4")
# # Erstellt ein Raster mit 3 Spalten (auf kleinen Bildschirmen 1, auf mittleren 2, auf großen 3)
# with ui.grid(columns="1fr 1fr 1fr").classes("w-full gap-4"):
# for item in menu_items:
# # Erstelle eine Karte als Kachel
# # 'cursor-pointer' macht den Mauszeiger zur Hand
# with (
# ui.card()
# .tight()
# .classes("cursor-pointer hover:shadow-lg transition-shadow")
# .style(f"background-color: {item['color']}; height: 150px;")
# .on("click", lambda i=item: ui.navigate.to(i["route"]))
# ):
# with ui.column().classes("w-full h-full items-center justify-center p-4"):
# ui.icon(item["icon"]).classes("text-5xl mb-2")
# ui.label(item["title"]).classes("text-lg font-bold")
# # Dummy-Zielseiten
# @ui.page("/dashboard")
# def dashboard():
# ui.label("Hier ist das Dashboard")
# ui.button("Zurück", on_click=lambda: ui.navigate.to("/"))
# Beispieldaten: Eine Liste von Einträgen
# data = [
# {"id": "001", "name": "Projekt Alpha", "status": "Aktiv", "date": "2023-10-01"},
# {"id": "002", "name": "Kundenmeeting", "status": "Geplant", "date": "2023-10-05"},
# {"id": "003", "name": "System-Update", "status": "Erledigt", "date": "2023-09-28"},
# {"id": "004", "name": "Budget-Review", "status": "Aktiv", "date": "2023-10-12"},
# ]
# @ui.page("/")
# def table_view():
# with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-2"):
# # --- TABELLEN-KOPF ---
# # Wir nutzen ein Grid, um die Spaltenbreiten festzulegen (z.B. 10% ID, 50% Name, 20% Status, 20% Datum)
# with ui.row().classes(
# "w-full bg-slate-200 p-4 rounded-t-lg shadow-sm font-bold text-lg items-center"
# ):
# ui.label("ID").classes("w-12")
# ui.label("Bezeichnung").classes("flex-grow")
# ui.label("Status").classes("w-24")
# ui.label("Datum").classes("w-24 text-right")
# # --- TABELLEN-EINTRÄGE ---
# for entry in data:
# # Jede Zeile ist eine klickbare Karte/Reihe
# with (
# ui.card()
# .tight()
# .classes(
# "w-full hover:bg-blue-50 cursor-pointer transition-all hover:scale-[1.01]"
# )
# .on("click", lambda e=entry: ui.notify(f"Gehe zu: {e['name']}"))
# ):
# with ui.row().classes("w-full p-4 items-center"):
# # Geringfügig kleinerer Text als im Header (text-base vs text-lg)
# ui.label(entry["id"]).classes("w-12 text-slate-500 font-mono")
# ui.label(entry["name"]).classes("flex-grow font-medium")
# # Ein kleiner Chip für den Status
# ui.badge(
# entry["status"],
# color="green" if entry["status"] == "Aktiv" else "grey",
# ).classes("w-20")
# ui.label(entry["date"]).classes("w-24 text-right text-sm text-slate-400")
# 1. Unsere Datenquelle (könnte später auch eine Datenbank sein)
data = [
{
"UN/ NWP/ Kontaktperson": "Unternehmen 1",
"Individualberatung": "Max Mustermann",
"Pauschalberatung": "",
"VerlaufsprotokollDatum": "30.04.2023",
},
{
"UN/ NWP/ Kontaktperson": "Maxi Musterfrau",
"Individualberatung": "",
"Pauschalberatung": "Ausbildung",
"Datum": "30.06.2023",
},
]
nested_menu = {
"Notiz": None,
"Beratungsgespräch": None,
"Neu": {"Unternehmen": None, "Individualperson": None},
}
# nested_menu = {
# "Notiz": None,
# "Beratungsgespräch": None,
# "Neu": {"Unternehmen": None, "Individualperson": None},
# "Weitere": {
# "Sub 1": {"Sub-1-1": None, "Sub-1-2": None},
# "Sub 2": {"Sub 2-1": None, "Sub-2-2": None},
# "Sub 3 deutlich länger als andere": {"Sub 3-1": None, "Sub-3-2": None},
# },
# }
def build_menu(
title: str,
icon: str,
structure: dict[str, Any],
):
# Diese Funktion baut NUR die Einträge (Items), nicht die Container (Menus)
def _recursive(current_structure: dict[str, Any]):
for label, children in current_structure.items():
if children:
# --- AST (Verzweigung) ---
with ui.menu_item(auto_close=False):
with ui.item_section():
ui.label(label)
with ui.item_section().props("side"):
ui.icon("keyboard_arrow_right") # Das korrekte Pfeil-Icon
# Hier bauen wir EINEN neuen Container für die nächste Ebene
with ui.menu().props('anchor="top end" self="top start"'):
_recursive(children)
else:
# --- BLATT (Endpunkt) ---
# Beachte das l=label für das Closure/Late-Binding-Problem
ui.menu_item(label, on_click=lambda l=label: ui.notify(f"{l} geklickt!"))
with ui.button(title, icon=icon):
with ui.menu():
_recursive(structure)
@ui.page("/")
def table_view():
# Header-Bereich
with ui.column().classes("w-full max-w-5xl mx-auto p-2 gap-2"):
ui.label("Projekt-Verwaltung").classes("text-h4 mb-2")
with ui.row().classes("w-full justify-left p-2"):
build_menu("Hinzufügen", "add", nested_menu)
# with ui.button("Neu", icon="add"):
# with ui.menu() as main_menu:
# ui.menu_item("Direkte Aktion", on_click=lambda: ui.notify("Aktion 1"))
# ui.separator()
# with ui.menu_item("Untermenü öffnen...", auto_close=False).props(
# "icon=arrow_right"
# ):
# with ui.item_section().props("side"):
# ui.icon("keyboard_arrow_right")
# with ui.menu().props('anchor="top end" self="top start"'):
# ui.menu_item("Unterpunkt A", on_click=lambda: ui.notify("A"))
# ui.menu_item("Unterpunkt B", on_click=lambda: ui.notify("B"))
# --- EINGABE-BEREICH ---
with ui.row().classes("w-full items-center mb-4 gap-2"):
name_input = ui.input(placeholder="Name des Eintrags").classes("flex-grow")
status_input = ui.select(["Aktiv", "Geplant", "Erledigt"], value="Aktiv").classes(
"w-40"
)
def add_entry():
if not name_input.value:
ui.notify("Bitte einen Namen eingeben!", type="negative")
return
# Neuen Eintrag zum Datenmodell hinzufügen
new_id = f"{len(data) + 1:03d}"
data.append(
{
"id": new_id,
"name": name_input.value,
"status": status_input.value,
"date": "Heute",
}
)
# UI aktualisieren
name_input.value = "" # Feld leeren
# render_table.refresh() # Die Tabelle neu zeichnen
ui.notify(f"Eintrag {new_id} hinzugefügt")
ui.button(on_click=add_entry, icon="add").classes("rounded-full")
# Beispieldaten: Bei Projekt Gamma fehlt 'c3' komplett, bei Delta ist es leer/None
data = [
{
"c1": "Projekt Alpha",
"c2": "Ganz normaler Eintrag mit allen Daten.",
"c3": "Teamleitung",
"c4": "Priorität Hoch",
"date": "17.04.2024",
},
{
"c1": "Projekt Gamma",
"c2": "Hier fehlt die Abteilung (c3) komplett im Datenmodell.",
# 'c3' existiert hier nicht!
"c4": "Wartend",
"date": "21.04.2024",
},
{
"c1": "Projekt Delta",
"c2": "Hier ist die Abteilung (c3) explizit None.",
"c3": None,
"c4": "Pausiert",
"date": "22.04.2024",
},
]
# Äußerer Container mit grauem Hintergrund, damit die weißen Zellen besser wirken
# KORREKTUR: 'min-h-screen' entfernt.
# NEU: 'rounded-xl' hinzugefügt, damit der Hintergrund-Kasten saubere Ecken hat.
with ui.column().classes("w-full max-w-6xl mx-auto p-4 gap-2 bg-slate-50 rounded-xl"):
# --- DIE GRID-DEFINITION ---
# 4 gleich große Spalten (1fr) und eine feste (8rem = ca. 128px, entspricht w-32)
grid_classes = "w-full grid grid-cols-[1fr_1fr_1fr_1fr_8rem] gap-2"
# --- TABELLEN-HEADER ---
# Wir nutzen ein normales div mit unseren grid_classes statt ui.row()
with ui.element("div").classes(
f"{grid_classes} p-2 font-bold text-slate-500 items-center"
):
ui.label("Name").classes("text-center")
ui.label("Beschreibung").classes("text-center")
ui.label("Abteilung").classes("text-center")
ui.label("Status").classes("text-center")
ui.label("Datum").classes("text-center")
# --- TABELLEN-ZEILEN ---
for entry in data:
# Auch die Zeile wird zu einem Grid-Container. 'items-stretch' funktioniert hier ebenfalls!
with ui.element("div").classes(f"{grid_classes} items-stretch mb-2"):
# Wir brauchen hier KEIN flex-1 oder min-w-0 mehr! Grid regelt die Breite.
# Wir behalten flex nur im Inneren der Box, um den Text zu zentrieren.
cell_classes = (
"p-3 bg-white border border-slate-200 rounded-lg shadow-sm "
"cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition-all "
"flex items-center justify-center text-center"
)
text_logic = "whitespace-normal break-words"
with ui.element("div").classes(cell_classes):
ui.label(entry.get("c1", "")).classes(f"{text_logic} font-bold")
with ui.element("div").classes(cell_classes):
ui.label(entry.get("c2", "")).classes(
f"{text_logic} text-sm text-slate-600"
)
# --- ZELLE 3: PLATZHALTER ---
c3_value = entry.get("c3")
if c3_value:
with ui.element("div").classes(cell_classes):
ui.label(c3_value).classes(text_logic)
else:
# Der Platzhalter braucht gar nichts mehr. Grid reserviert den Platz ohnehin!
ui.element("div")
with ui.element("div").classes(cell_classes):
ui.badge(entry.get("c4", "")).classes(text_logic)
with ui.element("div").classes(cell_classes):
ui.label(entry.get("date", "")).classes(
f"{text_logic} text-slate-400 text-xs font-mono"
)
# # --- TABELLEN-HEADER ---
# with ui.row().classes("w-full bg-slate-200 p-4 rounded-t-lg font-bold items-center"):
# ui.label("UN/ NWP/ Kontaktperson").classes("w-12")
# ui.label("Individualberatung").classes("flex-grow")
# ui.label("Pauschalberatung").classes("w-24 text-center")
# ui.label("Aktion").classes("w-12 text-right")
# # --- ZEILEN MIT EINZELN KLICKBAREN SPALTEN ---
# for entry in data:
# with ui.card().tight().classes("w-full mb-1"):
# with ui.row().classes("w-full p-4 items-center no-wrap"):
# # 1. Spalte: ID (Klickbar)
# ui.label(entry["UN/ NWP/ Kontaktperson"]).classes(
# "flex-grow text-slate-500 font-mono cursor-pointer hover:text-blue-600"
# ).on("click", lambda e=entry: ui.notify(f"ID {e['id']} Details öffnen"))
# # 2. Spalte: Name (Klickbar, nimmt den meisten Platz ein)
# ui.label(entry["Individualberatung"]).classes(
# "flex-grow font-medium cursor-pointer hover:underline"
# ).on("click", lambda e=entry: ui.notify(f"Editor für {e['name']}"))
# # 3. Spalte: Status (Klickbar, als Badge)
# ui.label(entry["Pauschalberatung"]).classes(
# "flex-grow font-medium cursor-pointer hover:underline"
# ).on("click", lambda e=entry: ui.notify(f"Editor für {e['name']}"))
# # 4. Spalte: Einzelnes Icon (Klickbare Aktion am Rand)
# # 3. Spalte: Status (Klickbar, als Badge)
# ui.label(entry["Datum"]).classes(
# "flex-grow font-medium cursor-pointer hover:underline"
# ).on("click", lambda e=entry: ui.notify(f"Editor für {e['name']}"))
# --- DYNAMISCHER TABELLEN-INHALT ---
# Wir definieren eine Funktion mit dem @ui.refreshable Dekorator
# @ui.refreshable
# def render_table():
# if not data:
# ui.label("Keine Einträge vorhanden").classes("p-4 text-slate-400")
# return
# for entry in data:
# with ui.card().tight().classes("w-full hover:bg-blue-50 cursor-pointer mb-1"):
# with ui.row().classes("w-full p-4 items-center"):
# ui.label(entry["id"]).classes("w-12 text-slate-500 font-mono")
# ui.label(entry["name"]).classes("flex-grow font-medium")
# ui.badge(
# entry["status"],
# color="green" if entry["status"] == "Aktiv" else "grey",
# ).classes("w-20")
# ui.label(entry["date"]).classes(
# "w-24 text-right text-sm text-slate-400"
# )
# Initiales Rendern der Tabelle
# render_table()
ui.run(native=False)

285
prototypes/t_qt.py Normal file
View File

@ -0,0 +1,285 @@
import sys
from PySide6.QtCore import Qt
from PySide6.QtGui import QAction # WICHTIG: QAction wird für Menüeinträge gebraucht!
from PySide6.QtWidgets import (
QApplication,
QDialog,
QDialogButtonBox,
QFormLayout,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
# 1. Wir definieren unsere eigene klickbare "Zelle"
class ClickableCell(QFrame):
def __init__(self, text):
super().__init__()
# Qt Style Sheets (QSS) für das Design (Rahmen, Hintergrund, Hover)
self.setStyleSheet("""
ClickableCell {
background-color: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
ClickableCell:hover {
background-color: #eff6ff;
border: 1px solid #60a5fa;
}
""")
# Layout in der Zelle, um den Text zu zentrieren
layout = QVBoxLayout(self)
label = QLabel(text)
label.setWordWrap(True) # Textumbruch erzwingen!
label.setAlignment(Qt.AlignCenter)
label.setStyleSheet("border: none; background: transparent;")
layout.addWidget(label)
# Klick-Event abfangen
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
print("Zelle wurde geklickt!")
class HeaderCell(QLabel):
def __init__(self, text):
super().__init__(text)
# Textausrichtung zentrieren
self.setAlignment(Qt.AlignCenter)
# Styling: Fetter Text, grauer Hintergrund (entspricht slate-200), leicht abgerundet
self.setStyleSheet("""
HeaderCell {
background-color: #e2e8f0;
color: #475569;
font-weight: bold;
padding: 10px;
border-radius: 6px;
}
""")
class NewEntryDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Neuer Eintrag")
self.setMinimumWidth(350)
# Ein FormLayout richtet Labels und Eingabefelder automatisch sauber aus
layout = QFormLayout(self)
# Eingabefelder erstellen
self.input_c1 = QLineEdit()
self.input_c2 = QLineEdit()
self.input_c3 = QLineEdit()
self.input_c4 = QLineEdit()
self.input_date = QLineEdit()
# Felder zum Layout hinzufügen
layout.addRow("Name:", self.input_c1)
layout.addRow("Beschreibung:", self.input_c2)
layout.addRow("Abteilung (optional):", self.input_c3)
layout.addRow("Status:", self.input_c4)
layout.addRow("Datum:", self.input_date)
# Standard-Buttons (OK und Abbrechen)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept) # Schließt Dialog und meldet "Erfolg"
buttons.rejected.connect(self.reject) # Schließt Dialog und meldet "Abbruch"
layout.addWidget(buttons)
def get_data(self):
# Liest die Textfelder aus und gibt sie als Dictionary zurück
return {
"c1": self.input_c1.text(),
"c2": self.input_c2.text(),
# Wenn das Feld leer ist, speichern wir None (für unseren Platzhalter-Effekt)
"c3": self.input_c3.text() if self.input_c3.text().strip() else None,
"c4": self.input_c4.text(),
"date": self.input_date.text(),
}
# 2. Das Hauptfenster mit dem Grid-Layout
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Native PySide Tabelle")
self.resize(1800, 200)
# --- 1. DAS MENÜ ERSTELLEN ---
self.create_menu()
# --- 2. DAS ZENTRALE WIDGET ---
# Da das QMainWindow den Rahmen vorgibt, brauchen wir ein Container-Widget für die Mitte
central_widget = QWidget()
self.setCentralWidget(central_widget)
# Das Haupt-Layout des Fensters (Horizontal)
outer_layout = QHBoxLayout(central_widget)
# Ein Container-Widget für deine Tabelle/Grid
container = QWidget()
# 2. NEU: Dem Container sagen: "Dehne dich so weit aus, wie du darfst!"
# container.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
# --- NEU: DER SCROLL-BEREICH ---
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(
True
) # WICHTIG: Erlaubt dem Grid im Inneren, sich an die Breite anzupassen
scroll_area.setMinimumWidth(700)
scroll_area.setMaximumWidth(
1500
) # Die Breiten-Begrenzung wandert nun auf die ScrollArea
scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Optional: Rahmen der ScrollArea entfernen, damit es "flacher" und moderner aussieht
scroll_area.setFrameShape(QFrame.NoFrame)
# --- WICHTIG: VERKNÜPFUNG ---
# 1. Den Container in die ScrollArea stecken
scroll_area.setWidget(container)
# 3. Zentrierung durch "Stretches" (wie mx-auto)
# Wir fügen links und rechts vom Container Platzhalter ein
outer_layout.addStretch(1)
outer_layout.addWidget(scroll_area, stretch=100)
outer_layout.addStretch(1)
# Optional: Damit der Container oben am Rand klebt
outer_layout.setAlignment(Qt.AlignTop)
# Wir geben dem Container ein vertikales Layout
container_layout = QVBoxLayout(container)
container_layout.setContentsMargins(0, 0, 0, 0) # Entfernt unnötige Ränder
# Das Grid für unsere Tabelle
# Das Grid-Layout kommt in den Container
# self.grid = QGridLayout(container)
# Das Grid wird nun OHNE direkten Container erstellt
self.grid = QGridLayout()
self.grid.setSpacing(10) # Entspricht gap-2
self.grid.setColumnStretch(0, 1)
self.grid.setColumnStretch(1, 1)
self.grid.setColumnStretch(2, 1)
self.grid.setColumnStretch(3, 1)
# 3. Wir fügen das Grid oben in das vertikale Layout ein
container_layout.addLayout(self.grid)
# 4. DER ZAUBERTRICK: Wir setzen eine Feder unter das Grid.
# Diese Feder drückt das gesamte Grid nach oben und absorbiert den leeren Raum!
container_layout.addStretch()
# Zeilen-Zähler (0 ist für die Überschriften)
self.current_row = 0
# Beispieldaten (Projekt Gamma hat kein 'c3')
headers = ["Name", "Beschreibung", "Abteilung", "Status", "Datum"]
for col_idx, title in enumerate(headers):
self.grid.addWidget(HeaderCell(title), self.current_row, col_idx)
self.current_row += 1
data = [
{
"c1": "Projekt Alpha",
"c2": "Alles komplett.",
"c3": "Teamleitung",
"c4": "Hoch",
"date": "17.04.",
},
{
"c1": "Projekt Beta",
"c2": "Abteilung fehlt.",
"c4": "Wartend",
"date": "21.04.",
},
{
"c1": "Projekt Gamma",
"c2": "Alles komplett.",
"c3": "Teamleitung",
"c4": "Mittel",
"date": "30.04.",
},
{
"c1": "Projekt Delta",
"c2": "Abteilung fehlt.",
"c4": "Wartend",
"date": "05.05.",
},
]
# Wir gehen die Datenliste mit enumerate durch, um den row_index für das Grid zu bekommen
for entry in data:
self.add_row_to_grid(entry)
# --- HILFSMETHODE UM EINE ZEILE EINZUFÜGEN ---
def add_row_to_grid(self, entry):
row = self.current_row
self.grid.addWidget(ClickableCell(entry.get("c1", "")), row, 0)
self.grid.addWidget(ClickableCell(entry.get("c2", "")), row, 1)
c3_value = entry.get("c3")
if c3_value:
self.grid.addWidget(ClickableCell(c3_value), row, 2)
else:
empty_box = QFrame()
empty_box.setStyleSheet(
"QFrame { background-color: #f8fafc; border: 2px dashed #e2e8f0; border-radius: 8px; }"
)
self.grid.addWidget(empty_box, row, 2)
self.grid.addWidget(ClickableCell(entry.get("c4", "")), row, 3)
self.grid.addWidget(ClickableCell(entry.get("date", "")), row, 4)
self.current_row += 1 # Zähler für den nächsten Eintrag erhöhen
# --- MENÜ LOGIK ---
def create_menu(self):
menu_bar = self.menuBar()
file_menu = menu_bar.addMenu("Datei")
new_action = QAction("Neuer Eintrag...", self)
new_action.setShortcut("Ctrl+N")
# VERKNÜPFUNG: Wenn geklickt, rufe die Methode zum Öffnen des Dialogs auf
new_action.triggered.connect(self.open_new_entry_dialog)
file_menu.addAction(new_action)
file_menu.addSeparator()
exit_action = QAction("Beenden", self)
exit_action.setShortcut("Ctrl+Q")
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_action)
# 2. Hauptmenü-Punkt: "Hilfe"
help_menu = menu_bar.addMenu("Hilfe")
about_action = QAction("Über", self)
help_menu.addAction(about_action)
# --- DIALOG AUFRUFEN & DATEN ÜBERNEHMEN ---
def open_new_entry_dialog(self):
dialog = NewEntryDialog(self)
# .exec() pausiert das Programm, bis der Dialog geschlossen wird.
# Es gibt True zurück, wenn der Nutzer "OK" geklickt hat.
if dialog.exec():
new_data = dialog.get_data() # Dictionary aus dem Dialog holen
self.add_row_to_grid(new_data) # Ins Grid zeichnen
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())

1590
prototypes/t_qt_2.py Normal file

File diff suppressed because it is too large Load Diff

45
prototypes/tests.py Normal file
View File

@ -0,0 +1,45 @@
# %%
import dataclasses as dc
import enum
from PySide6.QtCore import QDate, Qt
class FormFieldType(enum.StrEnum):
TEXT = enum.auto()
LONGTEXT = enum.auto()
DATE = enum.auto()
DATETIME = enum.auto()
@dc.dataclass(slots=True)
class FormField:
key: str
label: str
type: FormFieldType
required: bool
def __post_init__(self) -> None:
self.label = self.label.strip()
if not self.label.endswith(":"):
self.label += ":"
if self.required:
self.label += "*"
# %%
FormField("name", "Projektbeschreibung", FormFieldType.LONGTEXT, required=True)
# %%
FormField("name", "Projektbeschreibung:", FormFieldType.LONGTEXT, required=True)
# %%
FormField("name", "Projektbeschreibung", FormFieldType.LONGTEXT, required=False)
# %%
FormField("name", "Projektbeschreibung:", FormFieldType.LONGTEXT, required=False)
# %%
addr.export()
# %%
set_date = QDate.fromString("26.07.2026", "dd.MM.yyyy")
# %%
Qt.Tet

View File

@ -1,12 +1,12 @@
[project]
name = "wce-gui"
name = "wce-crm"
version = "0.1.0"
description = "GUI for NAFKA project with WCE"
description = "GUI for CRM of NAFKA project with WCE"
authors = [
{name = "d-opt GmbH, resp. Florian Förster", email = "f.foerster@d-opt.com"},
]
dependencies = []
requires-python = ">=3.11"
dependencies = ["nicegui>=3.10.0", "pyside6>=6.11.0", "sqlalchemy>=2.0.49", "polars>=1.40.1", "dopt-basics>=0.2.4"]
requires-python = "<3.14,>=3.11"
readme = "README.md"
license = {text = "LicenseRef-Proprietary"}
@ -143,6 +143,8 @@ dev = [
"pdoc3>=0.11.5",
"bump-my-version>=1.1.1",
"nox>=2025.2.9",
"pywebview>=6.2.1",
"pythonnet==3.0.5",
]
nb = [
"jupyterlab>=4.3.5",

3
src/wce_crm/__init__.py Normal file
View File

@ -0,0 +1,3 @@
import wce_crm.env
wce_crm.env.setup()

View File

@ -0,0 +1,143 @@
from __future__ import annotations
from typing import TypedDict, cast
import polars as pl
from wce_crm import db
class CompanyInfo(TypedDict):
ma_id: str
wce_id: str
ma_unternehmensname: str
ma_branche: str
ma_strasse: str
ma_hausnummer: str
ma_plz: str
ma_ort: str
ma_plz_postfach: str
ma_postfach: str
ma_website: str
ma_mail: str
ma_telefonnummer: str
ma_faxnummer: str
ma_ersteintrag_datum: str
ma_aktualisierung_datum: str
ma_aktualisierung_nutzer: str
ma_sollprozess: str
ma_auslaendische_mitarbeiter: str
ma_quelle_information: str
ma_bemerkung: str
ma_kontakt: str
ma_schlagworte: str
ma_archiviert: str
class ContactPersonInfo(TypedDict):
an_id: str
ma_id: str
wce_id: str
st_id: str
an_sachgebiet: str
an_anrede: str
an_titel: str
an_nachname: str
an_vorname: str
an_position: str
an_mail: str
an_festnetz: str
an_mobil: str
an_faxnummer: str
an_hauptansprechpartner: str
an_anrede_anschrift: str
an_bemerkung: str
an_aktualisierung_datum: str
an_aktualisierung_nutzer: str
an_letztes_kontaktdatum: str
an_ersteintrag_datum: str
an_archiviert: str
def _transform_for_gui_output(
data: pl.DataFrame,
) -> pl.DataFrame:
q = (
data.lazy()
.with_columns(
pl.col(pl.Datetime).dt.to_string("%d.%m.%Y"),
pl.col(pl.Date).dt.to_string("%d.%m.%Y"),
pl.when(pl.col(pl.Boolean))
.then(pl.lit("Ja"))
.otherwise(pl.lit("Nein"))
.name.keep(),
)
.with_columns(pl.all().cast(pl.String))
)
return q.collect()
def comp_search_choices() -> tuple[tuple[str, int], ...]:
# TODO no reload functionality
q = db.df_crm_master.lazy()
counter = pl.int_range(0, pl.len()).over(pl.col.ma_unternehmensname)
q = q.with_columns(
dedupl=pl.when(counter == 0)
.then(pl.col.ma_unternehmensname)
.otherwise(pl.format("{} ({})", pl.col.ma_unternehmensname, counter))
)
df = q.collect()
# return dict(zip(df["dedupl"], df["ma_id"]))
return tuple(zip(df["dedupl"], df["ma_id"]))
def comp_search_get_info(
ma_id: int,
) -> CompanyInfo:
df = db.df_crm_master.filter(pl.col.ma_id == ma_id)
if df.height > 1 or df.height == 0:
raise ValueError(f"Größe des zurückgelieferten Datenpakets ungültig: {df.height}")
df = _transform_for_gui_output(df)
return cast(CompanyInfo, df.row(0, named=True))
def contact_person_search_choices(
ma_id: int | None,
use_both_names: bool,
) -> tuple[tuple[str, int], ...]:
# TODO no reload functionality
q = db.df_contact_person.lazy()
if ma_id is not None:
q = q.filter(pl.col.ma_id == ma_id)
dedupl_col = pl.col.an_nachname
if use_both_names:
q = q.with_columns(
name_search=(pl.format("{}, {}", pl.col.an_nachname, pl.col.an_vorname))
)
dedupl_col = pl.col.name_search
counter = pl.int_range(0, pl.len()).over(dedupl_col)
q = q.with_columns(
dedupl=pl.when(counter == 0)
.then(dedupl_col)
.otherwise(pl.format("{} ({})", dedupl_col, counter))
)
df = q.collect()
# return dict(zip(df["dedupl"], df["an_id"]))
return tuple(zip(df["dedupl"], df["an_id"]))
def contact_person_search_get_info(
an_id: int,
) -> ContactPersonInfo:
df = db.df_contact_person.filter(pl.col.an_id == an_id)
if df.height > 1 or df.height == 0:
raise ValueError(f"Größe des zurückgelieferten Datenpakets ungültig: {df.height}")
df = _transform_for_gui_output(df)
return cast(ContactPersonInfo, df.row(0, named=True))

297
src/wce_crm/db.py Normal file
View File

@ -0,0 +1,297 @@
from __future__ import annotations
import os
import re
from datetime import datetime
from pathlib import Path
import polars as pl
import sqlalchemy as sql
from sqlalchemy import Column, String, Table, TypeDecorator
from wce_crm import types as t
class SafeDateTime(TypeDecorator):
"""Cleans non-standard ISO strings before parsing."""
impl = String # We treat the underlying data as a String first
def process_result_value(self, value, dialect):
if value is None:
return None
# 1. Remove the trailing 'ff' (or any trailing letters)
# 2. Replace comma with dot (SQLAlchemy prefers . over ,)
clean_value = re.sub(r"[a-zA-Z]+$", "", value).replace(",", ".")
try:
return datetime.fromisoformat(clean_value)
except ValueError:
# Fallback if it's still weird
return None
md_crm = sql.MetaData()
# ---------- OLD "Kontaktliste" ----------
md_kontaktliste = sql.MetaData()
ext_kl_unternehmen: sql.Table = Table(
"Unternehmen",
md_kontaktliste,
Column("u_id", sql.Integer, nullable=False, unique=True),
Column("u_zeitstempel_eintrag", sql.DateTime, nullable=False),
Column("u_rechtsform", sql.Text, nullable=False),
Column("u_firmenname", sql.Text, nullable=False),
Column("u_strasse", sql.Text, nullable=False),
Column("u_hausnummer", sql.Text, nullable=False),
Column("u_adresszusatz", sql.Text, nullable=True),
Column("u_plz", sql.Text, nullable=False),
Column("u_ort", sql.Text, nullable=False),
Column("u_postfach", sql.Text, nullable=True),
Column("u_website", sql.Text, nullable=True),
Column("u_anrede", sql.Text, nullable=False),
Column("u_titel", sql.Text, nullable=True),
Column("u_vorname", sql.Text, nullable=False),
Column("u_nachname", sql.Text, nullable=False),
Column("u_funktion", sql.Text, nullable=False),
Column("u_mail", sql.Text, nullable=False),
Column("u_telefon", sql.Text, nullable=False),
Column("u_plz_postfach", sql.Text, nullable=True),
Column("u_einwilligung_inhaber", sql.Boolean, nullable=True),
Column("u_einwilligung_ansprechpartner", sql.Boolean, nullable=True),
Column("u_aktiv", sql.Boolean, nullable=False, default=1),
)
ext_kl_unternehmen_schema: t.PolarsSchema = {
"u_id": pl.UInt64,
"u_zeitstempel_eintrag": pl.Datetime,
"u_rechtsform": pl.String,
"u_firmenname": pl.String,
"u_strasse": pl.String,
"u_hausnummer": pl.String,
"u_adresszusatz": pl.String,
"u_plz": pl.String,
"u_ort": pl.String,
"u_postfach": pl.String,
"u_website": pl.String,
"u_anrede": pl.String,
"u_titel": pl.String,
"u_vorname": pl.String,
"u_nachname": pl.String,
"u_funktion": pl.String,
"u_mail": pl.String,
"u_telefon": pl.String,
"u_plz_postfach": pl.String,
"u_einwilligung_inhaber": pl.Boolean,
"u_einwilligung_ansprechpartner": pl.Boolean,
"u_aktiv": pl.Boolean,
}
def get_ext_kontaktliste(
db_path: Path | None,
) -> pl.DataFrame:
if db_path is None:
ENV_PTH = os.environ.get("DOPT_DB_KONTAKTLISTE", None)
if ENV_PTH is None:
raise ValueError("No database path provided or found as ENV var.")
db_path = Path(ENV_PTH)
if not db_path.exists():
raise FileNotFoundError(f"Database not found under >{db_path}<")
engine = sql.create_engine(f"sqlite:///{db_path}")
stmt = sql.select(ext_kl_unternehmen)
return pl.read_database(stmt, engine, schema_overrides=ext_kl_unternehmen_schema)
df_kontaktliste = get_ext_kontaktliste(None)
# ----------------------------------------------------
ext_crm_master: sql.Table = Table(
"Master",
md_crm,
Column("ma_id", sql.Integer, nullable=False, unique=True),
Column("wce_id", sql.ForeignKey("Nutzer.wce_id")),
Column("ma_unternehmensname", sql.Text, nullable=True),
Column("ma_branche", sql.Text, nullable=True),
Column("ma_strasse", sql.Text, nullable=True),
Column("ma_hausnummer", sql.Text, nullable=True),
Column("ma_plz", sql.Text, nullable=True),
Column("ma_ort", sql.Text, nullable=True),
Column("ma_plz_postfach", sql.Text, nullable=True),
Column("ma_postfach", sql.Text, nullable=True),
Column("ma_website", sql.Text, nullable=True),
Column("ma_mail", sql.Text, nullable=True),
Column("ma_telefonnummer", sql.Text, nullable=True),
Column("ma_faxnummer", sql.Text, nullable=True),
Column("ma_ersteintrag_datum", SafeDateTime, nullable=True),
Column("ma_aktualisierung_datum", SafeDateTime, nullable=True),
Column("ma_aktualisierung_nutzer", sql.Text, nullable=True),
Column("ma_sollprozess", sql.Text, nullable=True),
Column("ma_auslaendische_mitarbeiter", sql.Text, nullable=True),
Column("ma_quelle_information", sql.Text, nullable=True),
Column("ma_bemerkung", sql.Text, nullable=True),
Column("ma_kontakt", sql.Boolean, nullable=True),
Column("ma_schlagworte", sql.Text, nullable=True),
Column("ma_archiviert", sql.Boolean, nullable=True, default=False),
)
ext_crm_master_schema: t.PolarsSchema = {
"ma_id": pl.UInt64,
"wce_id": pl.UInt64,
"ma_unternehmensname": pl.String,
"ma_branche": pl.String,
"ma_strasse": pl.String,
"ma_hausnummer": pl.String,
"ma_plz": pl.String,
"ma_ort": pl.String,
"ma_plz_postfach": pl.String,
"ma_postfach": pl.String,
"ma_website": pl.String,
"ma_mail": pl.String,
"ma_telefonnummer": pl.String,
"ma_faxnummer": pl.String,
"ma_ersteintrag_datum": pl.Datetime,
"ma_aktualisierung_datum": pl.Datetime,
"ma_aktualisierung_nutzer": pl.String,
"ma_sollprozess": pl.String,
"ma_auslaendische_mitarbeiter": pl.String,
"ma_quelle_information": pl.String,
"ma_bemerkung": pl.String,
"ma_kontakt": pl.Boolean,
"ma_schlagworte": pl.String,
"ma_archiviert": pl.Boolean,
}
def get_ext_crm_master(
db_path: Path | None,
) -> pl.DataFrame:
if db_path is None:
ENV_PTH = os.environ.get("DOPT_DB_CRM", None)
if ENV_PTH is None:
raise ValueError("No database path provided or found as ENV var.")
db_path = Path(ENV_PTH)
if not db_path.exists():
raise FileNotFoundError(f"Database not found under >{db_path}<")
engine = sql.create_engine(f"sqlite:///{db_path}")
stmt = sql.select(ext_crm_master)
return pl.read_database(stmt, engine, schema_overrides=ext_crm_master_schema)
df_crm_master = get_ext_crm_master(None)
ext_crm_nutzer: sql.Table = Table(
"Nutzer",
md_crm,
Column("wce_id", sql.Integer, nullable=False, unique=True),
Column("wce_name", sql.Text, nullable=True),
Column("wce_vorname", sql.Text, nullable=True),
Column("wce_kuerzel", sql.Text, nullable=True),
Column("wce_passwort", sql.Text, nullable=True),
Column("wce_angelegt_am", sql.DateTime, nullable=True),
Column("wce_rolle", sql.Text, nullable=True),
Column("wce_angelegt_von", sql.Text, nullable=True),
Column("wce_aktiv", sql.Boolean, nullable=True),
Column("wce_letzter_login", sql.DateTime, nullable=True),
)
ext_crm_nutzer_schema: t.PolarsSchema = {
"wce_id": pl.UInt64,
"wce_name": pl.String,
"wce_vorname": pl.String,
"wce_kuerzel": pl.String,
"wce_passwort": pl.String,
"wce_angelegt_am": pl.Datetime,
"wce_rolle": pl.String,
"wce_angelegt_von": pl.String,
"wce_aktiv": pl.Boolean,
"wce_letzter_login": pl.Datetime,
}
ext_crm_contact_person: sql.Table = Table(
"Ansprechpartner",
md_crm,
Column("an_id", sql.Integer, nullable=False, unique=True),
Column("ma_id", sql.ForeignKey("Master.ma_id")),
Column("wce_id", sql.ForeignKey("Nutzer.wce_id")),
Column("st_id", sql.Integer, nullable=False),
Column("an_sachgebiet", sql.Text, nullable=True),
Column("an_anrede", sql.Text, nullable=True),
Column("an_titel", sql.Text, nullable=True),
Column("an_nachname", sql.Text, nullable=True),
Column("an_vorname", sql.Text, nullable=True),
Column("an_position", sql.Text, nullable=True),
Column("an_mail", sql.Text, nullable=True),
Column("an_festnetz", sql.Text, nullable=True),
Column("an_mobil", sql.Text, nullable=True),
Column("an_faxnummer", sql.Text, nullable=True),
Column("an_hauptansprechpartner", sql.Text, nullable=True),
Column("an_anrede_anschrift", sql.Text, nullable=True),
Column("an_bemerkung", sql.Text, nullable=True),
Column("an_aktualisierung_datum", SafeDateTime, nullable=True),
Column("an_aktualisierung_nutzer", sql.Text, nullable=True),
Column("an_letztes_kontaktdatum", SafeDateTime, nullable=True),
Column("an_ersteintrag_datum", SafeDateTime, nullable=True),
Column("an_archiviert", sql.Boolean, nullable=True, default=0),
)
ext_crm_contact_person_schema: t.PolarsSchema = {
"an_id": pl.UInt64,
"ma_id": pl.UInt64,
"wce_id": pl.UInt64,
"st_id": pl.UInt64,
"an_sachgebiet": pl.String,
"an_anrede": pl.String,
"an_titel": pl.String,
"an_nachname": pl.String,
"an_vorname": pl.String,
"an_position": pl.String,
"an_mail": pl.String,
"an_festnetz": pl.String,
"an_mobil": pl.String,
"an_faxnummer": pl.String,
"an_hauptansprechpartner": pl.String,
"an_anrede_anschrift": pl.String,
"an_bemerkung": pl.String,
"an_aktualisierung_datum": pl.Datetime,
"an_aktualisierung_nutzer": pl.String,
"an_letztes_kontaktdatum": pl.Datetime,
"an_ersteintrag_datum": pl.Datetime,
"an_archiviert": pl.Boolean,
}
def get_ext_crm_contact_person(
db_path: Path | None,
) -> pl.DataFrame:
if db_path is None:
ENV_PTH = os.environ.get("DOPT_DB_CRM", None)
if ENV_PTH is None:
raise ValueError("No database path provided or found as ENV var.")
db_path = Path(ENV_PTH)
if not db_path.exists():
raise FileNotFoundError(f"Database not found under >{db_path}<")
engine = sql.create_engine(f"sqlite:///{db_path}")
stmt = sql.select(ext_crm_contact_person)
df = pl.read_database(stmt, engine, schema_overrides=ext_crm_contact_person_schema)
# TODO Database seems to contain entries with invalid characters like \t or \n
df = df.with_columns(
pl.col(pl.String).str.replace_all(r"[\r\t\n]", " ").str.strip_chars(" ")
)
return df
df_contact_person = get_ext_crm_contact_person(None)

15
src/wce_crm/env.py Normal file
View File

@ -0,0 +1,15 @@
import os
from pathlib import Path
PROJECT_ROOT = Path(__file__).parents[2]
DB_PATH = PROJECT_ROOT / "data/db"
DB_KONTAKTLISTE = DB_PATH / "wce_kontaktliste.db"
assert DB_KONTAKTLISTE.exists()
DB_CRM = DB_PATH / "wce_crm.db"
assert DB_CRM.exists()
def setup():
os.environ["DOPT_DB_KONTAKTLISTE"] = str(DB_KONTAKTLISTE)
os.environ["DOPT_DB_CRM"] = str(DB_CRM)

2
src/wce_crm/env_vars.txt Normal file
View File

@ -0,0 +1,2 @@
DOPT_DB_KONTAKTLISTE: Pfad zur Datenbank der Kontaktliste, falls nicht direkt übergeben (Prototypenphase)
DOPT_DB_CRM: Pfad zur CRM-Datenbank, falls nicht direkt übergeben (Prototypenphase)

9
src/wce_crm/types.py Normal file
View File

@ -0,0 +1,9 @@
from __future__ import annotations
from typing import TYPE_CHECKING, TypeAlias
if TYPE_CHECKING:
import polars as pl
PolarsSchema: TypeAlias = dict[str, type["pl.DataType"]]