From eb66b435a045eb5db2dd5a17e9956cb4918e5fa4 Mon Sep 17 00:00:00 2001 From: foefl Date: Mon, 13 Jul 2026 17:50:59 +0200 Subject: [PATCH] refactoring and basic loading of existing consultations on the company profile --- src/wce_crm/backend/backend.py | 68 +++- src/wce_crm/data_models.py | 218 ++++++++++++- src/wce_crm/gui.py | 552 +++++++++++++++++---------------- src/wce_crm/types.py | 20 ++ 4 files changed, 572 insertions(+), 286 deletions(-) diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index 970e4d1..c3f701d 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -10,9 +10,20 @@ from dopt_basics.result_pattern import wrap_result from wce_crm import db from wce_crm.constants import TIMEZONE_CEST -from wce_crm.data_models import Beratungsgespraech_Einzelgespraech, Beratungsgespraech_Vorgang +from wce_crm.data_models import ( + Beratungsgespraech_Einzelgespraech, + Beratungsgespraech_Vorgang, +) from wce_crm.logging import logger_back as logger -from wce_crm.types import CompanyInfo, ContactPersonInfo, InitRecType, MainPageEntry +from wce_crm.types import ( + CompanyInfo, + CompanyProfileConsultationEntry, + CompanyProfileConsultations, + ConsultingType, + ContactPersonInfo, + InitRecType, + MainPageEntry, +) if TYPE_CHECKING: from wce_crm.types import ConsId, ExtAnId, ExtMaId, RecId @@ -393,6 +404,59 @@ def page_consulting_from_db( return consultation_data +# // consulting page interaction +def companyprofile_page_get_consultations( + un_id: RecId, +) -> CompanyProfileConsultations: + logger.debug("[Call backend] _companyprofile_page_get_consultations") + stmt = sql.select( + db.beratung_vorgang.c.vorgang_id, + db.beratung_vorgang.c.aktualisiert, + db.beratung_vorgang.c.titel, + db.beratung_vorgang.c.beratungs_typ, + ).where(db.beratung_vorgang.c.un_id == un_id) + + with db.ENGINE.connect() as conn: + res = conn.execute(stmt) + + cons_entries_pauschal: list[CompanyProfileConsultationEntry] = [] + cons_entries_individual: list[CompanyProfileConsultationEntry] = [] + + for entry in res.mappings(): + cons_id = entry["vorgang_id"] + assert cons_id, "no VorgangID defined" + + datetime_updated = cast(datetime.datetime, entry["aktualisiert"]) + datetime_updated = datetime_updated.astimezone(TIMEZONE_CEST) + + base_title = entry["titel"] + + _cons_type = entry["beratungs_typ"] + cons_type = ConsultingType(_cons_type) + + con_entry = CompanyProfileConsultationEntry( + cons_id=cons_id, + title=base_title, + date_updated=datetime_updated, + cons_type=cons_type, + ) + + if cons_type is ConsultingType.PAUSCHAL: + cons_entries_pauschal.append(con_entry) + elif cons_type is ConsultingType.INDIVIDUAL: + cons_entries_individual.append(con_entry) + else: + raise TypeError(f"Unknown consulting type: {_cons_type}") + + cons_entries_pauschal.sort(key=lambda x: x.date_updated, reverse=True) + cons_entries_individual.sort(key=lambda x: x.date_updated, reverse=True) + + return CompanyProfileConsultations( + pauschal=cons_entries_pauschal, + individual=cons_entries_individual, + ) + + # // main page interaction def _main_page_get_company_list() -> list[MainPageEntry]: logger.debug("[Call backend] get_company_list") diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index 3c5772c..05bc1f1 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -1,9 +1,11 @@ from __future__ import annotations +import copy +import dataclasses as dc import datetime -import enum import json -from typing import Annotated, Any, Final, Literal +from collections.abc import Iterable, Sequence +from typing import TYPE_CHECKING, Annotated, Any, Final, Generic, Protocol, TypeVar from pydantic import ( AwareDatetime, @@ -16,8 +18,16 @@ from pydantic import ( ) from pydantic_core import ErrorDetails -ValidAge = Annotated[int, Field(ge=0, le=99)] +from wce_crm.form_defs import FormField +from wce_crm.types import ConsId, ConsultingType, RecId, UserId +if TYPE_CHECKING: + from wce_crm.gui import Page_Consulting_ConsultingSession # noqa: F401 + +ValidAge = Annotated[int, Field(ge=0, le=99)] +COLUMN_SEP: Final[str] = "__" +M = TypeVar("M", bound="Module") +S = TypeVar("S", bound="PageState") # // validation translation GERMAN_ERROR_MESSAGES: Final[dict[str, str]] = { @@ -64,9 +74,204 @@ def _parse_json(value: Any) -> str: raise TypeError -COLUMN_SEP: Final[str] = "__" +# // GUI states and types +def set_page_state( + target: S, + source: S, + deep: bool = False, +) -> None: + """ + transfer all field values generically from 'source' to 'target' + """ + if not (dc.is_dataclass(target) and dc.is_dataclass(source)): + raise TypeError("Both source and atrget must be dataclasses") + + if type(target) is not type(source): + raise TypeError("Both source and target must have the same type") + + for field in dc.fields(source): + if field.name == "child_modules": + continue + + new_value = getattr(source, field.name) + + if deep: + new_value = copy.deepcopy(new_value) + + setattr(target, field.name, new_value) +class WrapperModule(Protocol): + def _sync_state_to_GUI(self) -> None: + """build states for all child modules and use their `load_state` method + to initialise them with the new data + """ + ... + + def _sync_GUI_to_state(self) -> None: + """call `get_state` method on all child modules and assign respective + properties to state of parent module + """ + ... + + def load_state(self, new_state: Any) -> None: ... + + def get_state(self) -> Any: + """should always be a simple call to `_sync_GUI_to_state` and then returning + the module's state + + Returns + ------- + Any + state of the respective module + """ + ... + + +class Module(WrapperModule, Protocol): + def validate(self) -> list[str]: + """should be a list of field names which could not be validated successfully, + propagates through: parent components must call this on all child modules and extend + their own error list with the return value + + Returns + ------- + list[str] + list of error fields (form field label) + """ + ... + + def save_data(self) -> None: ... + + +class AutoFormInsert(Protocol): + def __call__( + self, + data: dict[str, Any], + ) -> RecId: ... + + +class AutoFormUpdate(Protocol): + def __call__( + self, + id_: int, + data: dict[str, Any], + ) -> None: ... + + +class AutoFormGet(Protocol): + def __call__( + self, + id_: int, + ) -> dict[str, Any]: ... + + +class AutoFormDelete(Protocol): + def __call__( + self, + id_: int, + ) -> None: ... + + +@dc.dataclass(slots=True) +class Session: + user_id: UserId + user_name: str + + +@dc.dataclass(slots=True) +class PageState(Generic[M]): + child_modules: list[M] = dc.field(default_factory=list) + + +@dc.dataclass(slots=True, kw_only=True) +class Page_MainPage_State(PageState[Module]): + session: Session + row_count: int = 0 + + +@dc.dataclass(slots=True, kw_only=True) +class Page_NewInitRec_State(PageState[Module]): + session: Session + + +@dc.dataclass(slots=True, kw_only=True) +class Page_InitRecCompany_State(PageState[Module]): + session: Session + un_id: RecId | None = None + + +@dc.dataclass(slots=True, kw_only=True) +class AutoFormConfig: + model: type[FlatBaseModel] + data_insert: AutoFormInsert + data_update: AutoFormUpdate + data_get: AutoFormGet + data_delete: AutoFormDelete + form_fields: Sequence[FormField] + ignored_keys: Iterable[str] = tuple() + add_buttons: bool = True + + +@dc.dataclass(slots=True, kw_only=True) +class AutoForm_State(PageState[Module]): + session: Session + cfg: AutoFormConfig + rec_id: RecId | None = None + form_data: dict[str, Any] | None = None + + +@dc.dataclass(slots=True, kw_only=True) +class Page_InitRecPerson_State(PageState[Module]): + session: Session + pers_id: RecId | None = None + + +@dc.dataclass(slots=True, kw_only=True) +class Page_Consulting_State(PageState[Module]): + session: Session + vorgang_id: ConsId | None + un_id: RecId | None = None + pers_id: RecId | None = None + titel: str = "TEST-TITEL" # add title later to GUI or build it in the background + beratungs_typ: ConsultingType + cons_sessions: list[Page_Consulting_ConsultingSession_State] = dc.field( + default_factory=list + ) + ist_geloescht: bool = False + + +@dc.dataclass(slots=True, kw_only=True) +class Page_Consulting_ConsultingSession_State(PageState[Module]): + session: Session + nutzer_id: UserId # must be set with the session (always known) + nutzer_name: str # must be set with the session (always known) + beratung_id: int | None # known and not known entries + zeitstempel: datetime.datetime | None = None + # dc.field(default_factory=lambda: datetime.datetime.now(tz=datetime.UTC)) + ansprechpartner: str | None = None + kommunikationsweg: str | None = None + thema_crm_matrix: str | None = ( + None # TODO should be linked to something, target unclear (need specification by customer) + ) + anmerkungen: str | None = None + rueckmeldung: str | None = None + ist_geloescht: bool = False + + +@dc.dataclass(slots=True, kw_only=True) +class Page_Consulting_Table_State(PageState["Page_Consulting_ConsultingSession"]): + session: Session + row_states: list[Page_Consulting_ConsultingSession_State] = dc.field(default_factory=list) + + +@dc.dataclass(slots=True, kw_only=True) +class Page_CompanyProfile_State(PageState[Module]): + session: Session + rec_id: RecId | None = None + + +# // Pydantic models class FlatBaseModel(BaseModel): """ Optimised Pydantic base class, which parses JSON strings and column @@ -347,11 +552,6 @@ class Grunderfassung_Sprachen(BaseModel): # ** Consulting -class ConsultingType(enum.StrEnum): - PAUSCHAL = enum.auto() - INDIVIDUAL = enum.auto() - - class Beratungsgespraech_Vorgang(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index d5ffdc3..1b279d9 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -7,13 +7,12 @@ import re import sys import traceback from collections import defaultdict -from collections.abc import Container, Iterable, Sequence +from collections.abc import Callable, Container, Iterable, Sequence from pprint import pformat from typing import ( TYPE_CHECKING, Any, Final, - Generic, Literal, Protocol, TypeAlias, @@ -80,12 +79,22 @@ from wce_crm.backend import backend from wce_crm.constants import TIMEZONE_CEST from wce_crm.data_models import ( COLUMN_SEP, + AutoForm_State, + AutoFormConfig, Beratungsgespraech_Einzelgespraech, Beratungsgespraech_Vorgang, - ConsultingType, - FlatBaseModel, Grunderfassung_Personen, Grunderfassung_Unternehmen, + Page_CompanyProfile_State, + Page_Consulting_ConsultingSession_State, + Page_Consulting_State, + Page_Consulting_Table_State, + Page_InitRecCompany_State, + Page_InitRecPerson_State, + Page_MainPage_State, + Page_NewInitRec_State, + Session, + set_page_state, translate_pydantic_errors, ) from wce_crm.form_defs import ( @@ -101,16 +110,17 @@ from wce_crm.logging import ( logger_gui, logger_page_consulting, ) +from wce_crm.types import CompanyProfileConsultationEntry, ConsultingType if TYPE_CHECKING: - from wce_crm.types import ConsId, ExtMaId, RecId, UserId + from wce_crm.data_models import AutoForm_State, Module, WrapperModule + from wce_crm.types import ConsId, ExtMaId K = TypeVar("K") V = TypeVar("V") T = TypeVar("T") W = TypeVar("W", bound="QWidget") -M = TypeVar("M", bound="Module") -S = TypeVar("S", bound="PageState") + DEBUG: bool = True DEBUG_SEARCH_WIDGET: bool = False @@ -137,6 +147,60 @@ QSS = """ *[styleClass="stempel"]:focus { border: 1px dashed #cbd5e1; } + +QPushButton[styleClass="addButton"] { + background-color: #007acc; + color: white; + border: none; + border-radius: 6px; + padding: 8px 15px; + font-weight: bold; +} + +QPushButton[styleClass="addButton"]:hover { + background-color: #0098ff; +} + +QPushButton[styleClass="outlineAddButton"] { + background-color: transparent; + color: #007acc; + border: 1px solid #007acc; + border-radius: 6px; + padding: 5px 12px; + font-weight: bold; +} + +QPushButton[styleClass="outlineAddButton"]:hover { + background-color: #007acc; + color: white; +} + +QPushButton[styleClass="softAddButton"] { + background-color: #e0f2fe; + color: #0369a1; + border: none; + border-radius: 6px; + padding: 6px 12px; + font-weight: bold; +} + +QPushButton[styleClass="softAddButton"]:hover { + background-color: #bae6fd; + color: #0369a1; +} + +QPushButton[styleClass="flatAddButton"] { + background-color: transparent; + color: #475569; + border: none; + padding: 6px 12px; + font-weight: bold; +} + +QPushButton[styleClass="flatAddButton"]:hover { + background-color: #f1f5f9; + color: #007acc; +} """ DROPDOWN_DEFAULT: Final[str] = "--- Bitte wählen ---" DYNAMIC_LIST_KEY_PATTERN: Final[re.Pattern] = re.compile(r"-\[(\d+)\]") @@ -199,78 +263,6 @@ class CustomForm(Protocol): def validate_form_data(self) -> list[str]: ... -class WrapperModule(Protocol): - def _sync_state_to_GUI(self) -> None: - """build states for all child modules and use their `load_state` method - to initialise them with the new data - """ - ... - - def _sync_GUI_to_state(self) -> None: - """call `get_state` method on all child modules and assign respective - properties to state of parent module - """ - ... - - def load_state(self, new_state: Any) -> None: ... - - def get_state(self) -> Any: - """should always be a simple call to `_sync_GUI_to_state` and then returning - the module's state - - Returns - ------- - Any - state of the respective module - """ - ... - - -class Module(WrapperModule, Protocol): - def validate(self) -> list[str]: - """should be a list of field names which could not be validated successfully, - propagates through: parent components must call this on all child modules and extend - their own error list with the return value - - Returns - ------- - list[str] - list of error fields (form field label) - """ - ... - - def save_data(self) -> None: ... - - -class AutoFormInsert(Protocol): - def __call__( - self, - data: dict[str, Any], - ) -> backend.RecId: ... - - -class AutoFormUpdate(Protocol): - def __call__( - self, - id_: int, - data: dict[str, Any], - ) -> None: ... - - -class AutoFormGet(Protocol): - def __call__( - self, - id_: int, - ) -> dict[str, Any]: ... - - -class AutoFormDelete(Protocol): - def __call__( - self, - id_: int, - ) -> None: ... - - class CustomWidget(QWidget): def __init__( self, @@ -292,104 +284,6 @@ class CustomWidget(QWidget): def validate_form_data(self) -> list[str]: ... -@dc.dataclass(slots=True) -class Session: - user_id: UserId - user_name: str - - -@dc.dataclass(slots=True) -class PageState(Generic[M]): - child_modules: list[M] = dc.field(default_factory=list) - - -@dc.dataclass(slots=True, kw_only=True) -class Page_MainPage_State(PageState[Module]): - session: Session - row_count: int = 0 - - -@dc.dataclass(slots=True, kw_only=True) -class Page_NewInitRec_State(PageState[Module]): - session: Session - - -@dc.dataclass(slots=True, kw_only=True) -class Page_InitRecCompany_State(PageState[Module]): - session: Session - un_id: RecId | None = None - - -@dc.dataclass(slots=True, kw_only=True) -class AutoFormConfig: - model: type[FlatBaseModel] - data_insert: AutoFormInsert - data_update: AutoFormUpdate - data_get: AutoFormGet - data_delete: AutoFormDelete - form_fields: Sequence[FormField] - ignored_keys: Iterable[str] = tuple() - add_buttons: bool = True - - -@dc.dataclass(slots=True, kw_only=True) -class AutoForm_State(PageState[Module]): - session: Session - cfg: AutoFormConfig - rec_id: RecId | None = None - form_data: dict[str, Any] | None = None - - -@dc.dataclass(slots=True, kw_only=True) -class Page_InitRecPerson_State(PageState[Module]): - session: Session - pers_id: RecId | None = None - - -@dc.dataclass(slots=True, kw_only=True) -class Page_Consulting_State(PageState[Module]): - session: Session - vorgang_id: ConsId | None - un_id: RecId | None = None - pers_id: RecId | None = None - titel: str = "TEST-TITEL" # add title later to GUI or build it in the background - beratungs_typ: ConsultingType - cons_sessions: list[Page_Consulting_ConsultingSession_State] = dc.field( - default_factory=list - ) - ist_geloescht: bool = False - - -@dc.dataclass(slots=True, kw_only=True) -class Page_Consulting_ConsultingSession_State(PageState[Module]): - session: Session - nutzer_id: UserId # must be set with the session (always known) - nutzer_name: str # must be set with the session (always known) - beratung_id: int | None # known and not known entries - zeitstempel: datetime.datetime | None = None - # dc.field(default_factory=lambda: datetime.datetime.now(tz=datetime.UTC)) - ansprechpartner: str | None = None - kommunikationsweg: str | None = None - thema_crm_matrix: str | None = ( - None # TODO should be linked to something, target unclear (need specification by customer) - ) - anmerkungen: str | None = None - rueckmeldung: str | None = None - ist_geloescht: bool = False - - -@dc.dataclass(slots=True, kw_only=True) -class Page_Consulting_Table_State(PageState["Page_Consulting_ConsultingSession"]): - session: Session - row_states: list[Page_Consulting_ConsultingSession_State] = dc.field(default_factory=list) - - -@dc.dataclass(slots=True, kw_only=True) -class Page_CompanyProfile_State(PageState[Module]): - session: Session - rec_id: RecId | None = None - - def _add_widget_to_layout( form_field: FormField[W], widget: QWidget, @@ -2581,25 +2475,43 @@ class NoScrollFilter(QObject): return super().eventFilter(obj, event) -class MainPage_ClickableCell(QFrame): - """cell in the table on the startup screen""" - - clicked = Signal(types.MainPageEntry) +class HeaderCell(QLabel): + """basic header for table like objects""" + + def __init__( + self, + text: str, + font_size: int = 12, + ): + super().__init__(text) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setStyleSheet(f""" + HeaderCell {{ + background-color: #e2e8f0; + color: #475569; + font-weight: bold; + padding: 10px; + border-radius: 6px; + font-size: {font_size}px; + }} + """) + + +class ClickableCell(QFrame): + """basic cell which can be clicked""" def __init__( self, text: str, - data_record: types.MainPageEntry, ): super().__init__() - self.data_record = data_record self.setStyleSheet(""" - MainPage_ClickableCell { + ClickableCell { background-color: white; border: 1px solid #e2e8f0; border-radius: 8px; } - MainPage_ClickableCell:hover { + ClickableCell:hover { background-color: #eff6ff; border: 1px solid #60a5fa; } @@ -2610,29 +2522,41 @@ class MainPage_ClickableCell(QFrame): label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(label) + +class CompanyProfile_Consultation_ClickableCell(ClickableCell): + """cell on the company profile for consultations""" + + clicked = Signal(CompanyProfileConsultationEntry) + + def __init__( + self, + text: str, + data_record: CompanyProfileConsultationEntry, + ): + super().__init__(text=text) + self.data_record = data_record + def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self.clicked.emit(self.data_record) -class MainPage_HeaderCell(QLabel): +class MainPage_ClickableCell(ClickableCell): + """cell in the table on the startup screen""" + + clicked = Signal(types.MainPageEntry) + def __init__( self, text: str, - font_size: int = 12, + data_record: types.MainPageEntry, ): - super().__init__(text) - self.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.setStyleSheet(f""" - MainPage_HeaderCell {{ - background-color: #e2e8f0; - color: #475569; - font-weight: bold; - padding: 10px; - border-radius: 6px; - font-size: {font_size}px; - }} - """) + super().__init__(text=text) + self.data_record = data_record + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit(self.data_record) class Page_NewInitRec(QWidget): @@ -2867,6 +2791,9 @@ class Page_InitRecCompany(QWidget): ) def _request_company_profile(self) -> None: + assert self.STATE.un_id is not None, ( + "requested company profile for a company which is not known (no ID)" + ) req_state = Page_CompanyProfile_State( session=self.STATE.session, rec_id=self.STATE.un_id, @@ -3148,6 +3075,25 @@ class Page_CompanyProfile(QWidget): change_title_btn.clicked.connect(self.set_company_name) container_layout.addLayout(layout_change_title) + btn_dummy_pauschal = QPushButton("Dummy Pauschalberatung hinzufügen") + btn_dummy_pauschal.clicked.connect( + lambda: self._debug_consulting_dummy(ConsultingType.PAUSCHAL) + ) + container_layout.addWidget(btn_dummy_pauschal) + btn_dummy_inidividual = QPushButton("Dummy Individualberatung hinzufügen") + btn_dummy_inidividual.clicked.connect( + lambda: self._debug_consulting_dummy(ConsultingType.INDIVIDUAL) + ) + container_layout.addWidget(btn_dummy_inidividual) + + btn_clear_cons = QPushButton("Beratungen leeren") + btn_clear_cons.clicked.connect(self._clear_consultations) + container_layout.addWidget(btn_clear_cons) + + btn_update_cons = QPushButton("Beratungen aktualisieren") + btn_update_cons.clicked.connect(self._update_consultations) + container_layout.addWidget(btn_update_cons) + separator2 = QFrame() separator2.setFrameShape(QFrame.Shape.HLine) separator2.setFrameShadow(QFrame.Shadow.Sunken) @@ -3155,7 +3101,7 @@ class Page_CompanyProfile(QWidget): container_layout.addSpacing(30) - self.title_company_name = MainPage_HeaderCell("Unternehmen: n.v.", font_size=16) + self.title_company_name = HeaderCell("Unternehmen: n.v.", font_size=16) container_layout.addWidget(self.title_company_name) container_layout.addSpacing(20) @@ -3171,7 +3117,7 @@ class Page_CompanyProfile(QWidget): left_frame.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) left_layout = QVBoxLayout(left_frame) main_hor_layout.addWidget(left_frame, stretch=1, alignment=Qt.AlignmentFlag.AlignTop) - master_data_label = MainPage_HeaderCell("Stammdaten & Vermerke") + master_data_label = HeaderCell("Stammdaten & Vermerke") master_data_label.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) @@ -3193,21 +3139,23 @@ class Page_CompanyProfile(QWidget): top_right_frame.setSizePolicy( QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum ) - top_right_layout = QVBoxLayout(top_right_frame) - common_consultancy_label = MainPage_HeaderCell("Pauschalberatung") + self.layout_cons_pauschal = QVBoxLayout(top_right_frame) + self.cons_pauschal: list[CompanyProfile_Consultation_ClickableCell] = [] + common_consultancy_label = HeaderCell("Pauschalberatung") common_consultancy_label.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) - top_right_layout.addWidget(common_consultancy_label) + self.layout_cons_pauschal.addWidget(common_consultancy_label) self.add_btn_pauschal = QPushButton("+ Hinzufügen") self.add_btn_pauschal.clicked.connect( - lambda: self._request_consulting(ConsultingType.PAUSCHAL) + lambda: self._request_new_consulting(ConsultingType.PAUSCHAL) ) self.add_btn_pauschal.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) self.add_btn_pauschal.setMinimumHeight(30) - top_right_layout.addWidget(self.add_btn_pauschal) + self.add_btn_pauschal.setProperty("styleClass", "flatAddButton") + self.layout_cons_pauschal.addWidget(self.add_btn_pauschal) # bottom right area bottom_right_frame = QFrame() @@ -3215,21 +3163,23 @@ class Page_CompanyProfile(QWidget): bottom_right_frame.setSizePolicy( QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum ) - bottom_right_layout = QVBoxLayout(bottom_right_frame) - individual_consultancy_label = MainPage_HeaderCell("Individualberatung") + self.layout_cons_indiv = QVBoxLayout(bottom_right_frame) + self.cons_indiv: list[CompanyProfile_Consultation_ClickableCell] = [] + individual_consultancy_label = HeaderCell("Individualberatung") individual_consultancy_label.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) - bottom_right_layout.addWidget(individual_consultancy_label) + self.layout_cons_indiv.addWidget(individual_consultancy_label) self.add_btn_individual = QPushButton("+ Hinzufügen") self.add_btn_individual.clicked.connect( - lambda: self._request_consulting(ConsultingType.INDIVIDUAL) + lambda: self._request_new_consulting(ConsultingType.INDIVIDUAL) ) self.add_btn_individual.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) self.add_btn_individual.setMinimumHeight(30) - bottom_right_layout.addWidget(self.add_btn_individual) + self.add_btn_individual.setProperty("styleClass", "flatAddButton") + self.layout_cons_indiv.addWidget(self.add_btn_individual) right_column_layout.addWidget(top_right_frame) right_column_layout.addWidget(bottom_right_frame) @@ -3270,15 +3220,37 @@ class Page_CompanyProfile(QWidget): # } # """) + def _debug_consulting_dummy( + self, + type: ConsultingType, + ): + if type is ConsultingType.PAUSCHAL: + dummy_click_cell = CompanyProfile_Consultation_ClickableCell( + "Dummy Pauschal", + None, # type: ignore + ) + target_layout = self.layout_cons_pauschal + self.cons_pauschal.append(dummy_click_cell) + else: + dummy_click_cell = CompanyProfile_Consultation_ClickableCell( + "Dummy Individual", + None, # type: ignore + ) + target_layout = self.layout_cons_indiv + self.cons_indiv.append(dummy_click_cell) + + target_idx = target_layout.count() - 1 + target_layout.insertWidget(target_idx, dummy_click_cell) + def _request_initrec_company(self) -> None: req_state = Page_InitRecCompany_State( session=self.STATE.session, un_id=self.STATE.rec_id, ) - logger_gui.debug("[Page -- InitRec Company] State to call: %s", req_state) + logger_gui.debug("[Page Company Profile] State to call: %s", req_state) self.back_requested.emit(req_state) - def _request_consulting( + def _request_new_consulting( self, cons_type: ConsultingType, ) -> None: @@ -3293,6 +3265,62 @@ class Page_CompanyProfile(QWidget): ) self.consulting_requested.emit(new_state) + def _request_existing_consulting( + self, + data: CompanyProfileConsultationEntry, + ) -> None: + assert self.STATE.rec_id is not None, ( + "tried to call consulting from comp profile without set ID" + ) + new_state = Page_Consulting_State( + session=self.STATE.session, + vorgang_id=data.cons_id, + un_id=self.STATE.rec_id, + beratungs_typ=data.cons_type, + ) + self.consulting_requested.emit(new_state) + + def _clear_consultations(self) -> None: + logger_gui.debug("[Page Company Profile] Clear consultations...") + + while self.cons_pauschal: + widget = self.cons_pauschal[0] + self.layout_cons_pauschal.removeWidget(widget) + self.cons_pauschal.remove(widget) + widget.deleteLater() + + while self.cons_indiv: + widget = self.cons_indiv[0] + self.layout_cons_indiv.removeWidget(widget) + self.cons_indiv.remove(widget) + widget.deleteLater() + + def _update_consultations(self) -> None: + assert self.STATE.rec_id is not None, ( + "cannot update consultations if RecID is not set" + ) + consultations = backend.companyprofile_page_get_consultations(un_id=self.STATE.rec_id) + + self._clear_consultations() + + for entry in consultations.pauschal: + date_formatted = entry.date_updated.strftime("%d.%m.%Y %H:%M") + display_title = f"Pauschalberatung ({date_formatted}): {entry.title}" + click_cell = CompanyProfile_Consultation_ClickableCell(display_title, entry) + click_cell.clicked.connect(self._request_existing_consulting) + target_idx = self.layout_cons_pauschal.count() - 1 + self.layout_cons_pauschal.insertWidget(target_idx, click_cell) + self.cons_pauschal.append(click_cell) + + for entry in consultations.individual: + date_formatted = entry.date_updated.strftime("%d.%m.%Y %H:%M") + display_title = f"Pauschalberatung ({date_formatted}): {entry.title}" + click_cell = CompanyProfile_Consultation_ClickableCell(display_title, entry) + click_cell.clicked.connect(self._request_existing_consulting) + target_idx = self.layout_cons_indiv.count() - 1 + self.layout_cons_indiv.insertWidget(target_idx, click_cell) + self.cons_indiv.append(click_cell) + def _disable_add_btns(self) -> None: self.add_btn_pauschal.setEnabled(False) self.add_btn_individual.setEnabled(False) @@ -3797,13 +3825,18 @@ class Page_Consulting(QWidget): self, text: str, button_text: str, + button_func: Callable[[], None] | None = None, ) -> None: + if button_text and button_func is None: + raise ValueError("Can not add button without calling function") + banner = InfoBanner( text=text, button_text=button_text, ) + if button_func is not None: + banner.btn_clicked.connect(button_func) self.info_banners.append(banner) - banner.link_clicked.connect(self._open_linking_dialogue) self.info_banners_layout.addWidget(banner) def _sync_state_to_GUI(self) -> None: @@ -3824,23 +3857,30 @@ class Page_Consulting(QWidget): # info banners needed self._clear_info_banners() - if self.STATE.un_id is None and self.STATE.pers_id is None: - # link missing + if self.STATE.vorgang_id is None: self._add_linking_banner( - text="⚠️ Dieser Eintrag ist noch nicht mit einer Entität verknüpft.", - button_text="Jetzt verknüpfen", - ) - elif ( - self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL - and self.STATE.pers_id is None - ): - self._add_linking_banner( - text=( - "⚠️ Dies ist eine Individualberatung, aber die Verknüpfgung zu " - "einer Person fehlt." - ), - button_text="Jetzt verknüpfen", + text="⚠️ Dieser Eintrag ist noch nicht gespeichert.", + button_text="", ) + else: + if self.STATE.un_id is None and self.STATE.pers_id is None: + self._add_linking_banner( + text="⚠️ Dieser Eintrag ist noch nicht mit einer Entität verknüpft.", + button_text="Jetzt verknüpfen", + button_func=self._open_linking_dialogue, + ) + elif ( + self.STATE.beratungs_typ is ConsultingType.INDIVIDUAL + and self.STATE.pers_id is None + ): + self._add_linking_banner( + text=( + "⚠️ Dies ist eine Individualberatung, aber die Verknüpfgung zu " + "einer Person fehlt." + ), + button_text="Jetzt verknüpfen", + button_func=self._open_linking_dialogue, + ) # child modules # call to child modules' syncing method not needed because these are initialised by @@ -4124,6 +4164,7 @@ class Page_Consulting_ConsultingSession(QWidget): layout, widget_registry=self.widget_registry, ) + self.timestamp.set_pydatetime(datetime.datetime.now(TIMEZONE_CEST)) contact_def = FormField[QLineEdit]( "Kontaktperson", @@ -4302,24 +4343,10 @@ class Page_Consulting_Table(QWidget): # --- BUTTON (ADD) --- self.btn_add = QPushButton("+ Neuen Eintrag hinzufügen") self.btn_add.setCursor(Qt.CursorShape.PointingHandCursor) - self.btn_add.setObjectName("BlueAddButton") # ID vergeben + self.btn_add.setProperty("styleClass", "addButton") main_layout.addWidget(self.btn_add) self.btn_add.clicked.connect(lambda: self.add_row(None, False)) - self.btn_add.setStyleSheet(""" - #BlueAddButton { - background-color: #007acc; - color: white; - border: none; - border-radius: 6px; - padding: 8px 15px; - font-weight: bold; - } - #BlueAddButton:hover { - background-color: #0098ff; - } - """) - # --- HEADER --- header_widget = QWidget() header_layout = QHBoxLayout(header_widget) @@ -4547,7 +4574,7 @@ class Page_Consulting_Table(QWidget): class InfoBanner(QFrame): - link_clicked = Signal() + btn_clicked = Signal() def __init__( self, @@ -4582,43 +4609,18 @@ class InfoBanner(QFrame): layout.setContentsMargins(15, 10, 15, 10) self.label = QLabel(text) - self.button = QPushButton(button_text) layout.addWidget(self.label) layout.addStretch() - layout.addWidget(self.button) - self.button.clicked.connect(self.link_clicked.emit) + if button_text: + self.button = QPushButton(button_text) + layout.addWidget(self.button) + self.button.clicked.connect(self.btn_clicked.emit) #################################################################### -def set_page_state( - target: S, - source: S, - deep: bool = False, -) -> None: - """ - transfer all field values generically from 'source' to 'target' - """ - if not (dc.is_dataclass(target) and dc.is_dataclass(source)): - raise TypeError("Both source and atrget must be dataclasses") - - if type(target) is not type(source): - raise TypeError("Both source and target must have the same type") - - for field in dc.fields(source): - if field.name == "child_modules": - continue - - new_value = getattr(source, field.name) - - if deep: - new_value = copy.deepcopy(new_value) - - setattr(target, field.name, new_value) - - def clear_layout( layout: QLayout | None, ) -> None: @@ -4782,7 +4784,7 @@ class MainWindow(QMainWindow): "Datum", ] for col_idx, title in enumerate(headers): - self.header_grid.addWidget(MainPage_HeaderCell(title), self.current_row, col_idx) + self.header_grid.addWidget(HeaderCell(title), self.current_row, col_idx) self.update_grid() diff --git a/src/wce_crm/types.py b/src/wce_crm/types.py index eb8e648..a4c0715 100644 --- a/src/wce_crm/types.py +++ b/src/wce_crm/types.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, TypeAlias, TypedDict if TYPE_CHECKING: import polars as pl + UserId: TypeAlias = int RecId: TypeAlias = int ConsId: TypeAlias = int @@ -22,6 +23,11 @@ class InitRecType(enum.IntEnum): PERSON = enum.auto() +class ConsultingType(enum.StrEnum): + PAUSCHAL = enum.auto() + INDIVIDUAL = enum.auto() + + # // database interaction data structures # ** externals class CompanyInfo(TypedDict): @@ -83,3 +89,17 @@ class MainPageEntry: display_name: str Metadaten_aktualisierung: datetime.datetime type: InitRecType + + +@dc.dataclass(slots=True) +class CompanyProfileConsultationEntry: + title: str + cons_id: ConsId + date_updated: datetime.datetime + cons_type: ConsultingType + + +@dc.dataclass(slots=True) +class CompanyProfileConsultations: + pauschal: list[CompanyProfileConsultationEntry] + individual: list[CompanyProfileConsultationEntry]