8 Commits 535c49e3f1 ... b3a46dcaaf

Autor SHA1 Mensaje Fecha
  gc-server3 b3a46dcaaf Diverse UI Anpassungen hace 4 semanas
  gc-server3 aed738d76e Webservice über nssm hace 4 semanas
  gc-server3 ada592407d Filter in csv ausgelagert hace 4 semanas
  gc-server3 4974dc0a3b Docuware Versuch hace 4 semanas
  gc-server3 878e4b1e93 Redesign von Tina übernommen hace 1 mes
  gc-server3 4f56484eb8 Templates ausgelagert hace 2 meses
  gc-server3 fda10c18f3 Dashboard-Auswahl hace 2 meses
  gc-server3 54b02bb3d1 Auth - Erster Entwurf hace 2 meses
Se han modificado 42 ficheros con 2656 adiciones y 373 borrados
  1. 8 1
      README.md
  2. 17 0
      app/auth2.py
  3. 17 12
      app/cleanup_comments.py
  4. 20 0
      app/config.py
  5. 335 0
      app/docuware_search.py
  6. 56 0
      app/jinja_templates.py
  7. 0 0
      app/ldap.py
  8. 50 0
      app/main2.py
  9. 94 0
      app/main3.py
  10. 30 0
      app/oauth_token.py
  11. 90 0
      app/query_filter.py
  12. 16 121
      app/routes.py
  13. 21 0
      app/schemas.py
  14. 35 0
      app/users.py
  15. 27 0
      filter_export.py
  16. 6 3
      pyproject.toml
  17. 71 2
      static/assets/css/main.css
  18. 1181 0
      static/assets/css/redesign.css
  19. 20 19
      templates/base/base.html
  20. 47 5
      templates/base/chat_container.html
  21. 58 11
      templates/base/liste.html
  22. 1 1
      templates/base/liste_filter.html
  23. 3 3
      templates/base/login.html
  24. 82 34
      templates/forderungen/dashboard/dashboard.html
  25. 21 0
      templates/forderungen/dashboard/queries/dashboard_betriebe.sql
  26. 21 0
      templates/forderungen/dashboard/queries/dashboard_staffel.sql
  27. 18 0
      templates/forderungen/dashboard/queries/dashboard_verursacher.sql
  28. 36 89
      templates/forderungen/details/details.html
  29. 7 0
      templates/forderungen/details/details_buchungen.html
  30. 4 0
      templates/forderungen/details/details_formular.html
  31. 7 0
      templates/forderungen/details/details_mahnungen.html
  32. 8 8
      templates/forderungen/details/details_uebersicht.html
  33. 17 0
      templates/forderungen/liste/config/liste_filter.csv
  34. 83 47
      templates/forderungen/liste/liste_zeile.html
  35. 4 17
      templates/forderungen/liste/queries/forderungen_liste.sql
  36. 125 0
      uv.lock
  37. 5 0
      webservice/install_service.bat
  38. BIN
      webservice/nssm.exe
  39. 3 0
      webservice/start_service.bat
  40. 3 0
      webservice/stop_service.bat
  41. 4 0
      webservice/uninstall_service.bat
  42. 5 0
      webservice/webservice.bat

+ 8 - 1
README.md

@@ -1,4 +1,11 @@
-# Reisacher Forderungsmanagement 2.0
+# GlobalCube Enterprise Planning, Information & Controlling (gcepic)
+
+Das ultimative Tool für:
+
+- Forderungsmanagement
+- Offene Aufträge
+- Planung
+- Fahrzeug-Auftragseingang
 
 ## Installation
 

+ 17 - 0
app/auth2.py

@@ -0,0 +1,17 @@
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from .oauth_token import verify_token
+from .schemas import TokenData
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
+
+
+def get_current_user(token: str = Depends(oauth2_scheme)):
+    payload = verify_token(token)
+    username: str = payload.get("sub")
+    if username is None:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Invalid authentication credentials",
+        )
+    return TokenData(username=username)

+ 17 - 12
app/cleanup_comments.py

@@ -1,4 +1,4 @@
-from datetime import timedelta
+from datetime import timedelta, date
 
 from db import get_session
 import re
@@ -14,26 +14,31 @@ def main():
             comment_date = row.Timestamp + timedelta(minutes=i)
             comment_user = row.Benutzer_ID
             r = r.replace("'", "")
+            d, u = get_date_and_user_from_comment(r)
             db.execute(
                 text(
                     "INSERT INTO dbo.Forderungen_Kommentar "
                     f"VALUES ('{row.Client_DB}', '{row.Beleg_Nr}', '{comment_date}', '{comment_user}', '{r}')"
                 )
             )
-            # m = re.search(r"(\d\d\.\d\d?)\.?[\/\s](\w+)", r)
-            # if m:
-            #     comment_date = m[1]
-            #     comment_user = m[2]
-            # else:
-            #     if r.startswith("EWB"):
-            #         continue
-            #     az = re.search(r"^AZ\:?\s(\d+\/\d+)", r)
-            #     if az:
-            #         continue
-            #     print(r)
 
     db.commit()
 
 
+def get_date_and_user_from_comment(r: str) -> tuple[date, str]:
+    m = re.search(r"(\d\d\.\d\d?)\.?[\/\s](\w+)", r)
+    if m:
+        date_split = m[1].split(".")
+        return date(2026, date_split[1], date_split[0]), m[2]
+
+    if r.startswith("EWB"):
+        return None, "mde"
+
+    az = re.search(r"^AZ\:?\s(\d+\/\d+)", r)
+    if az:
+        return None, "mde"
+    return None, "mde"
+
+
 if __name__ == "__main__":
     main()

+ 20 - 0
app/config.py

@@ -0,0 +1,20 @@
+from pydantic_settings import BaseSettings
+
+
+class Settings(BaseSettings):
+    # auth
+    SECRET_KEY: str = "my_secret_key"
+    ALGORITHM: str = "HS256"
+    ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
+    # ldap
+    LDAP_URL: str
+    LDAP_USER_DN: str
+    LDAP_BASE_DN: str
+    # database
+    DATABASE_URL: str = "sqlite:///./dev.db"
+
+    class Config:
+        env_file = ".env"
+
+
+settings = Settings()

+ 335 - 0
app/docuware_search.py

@@ -0,0 +1,335 @@
+"""
+DocuWare: gueltige Suchfilter ermitteln und Dokumente abfragen.
+
+Voraussetzungen:
+  pip install requests
+
+Konfiguration per Umgebungsvariablen:
+  DOCUWARE_SERVER_URL     z.B. https://your-system.docuware.cloud
+  DOCUWARE_USERNAME       DocuWare Benutzername
+  DOCUWARE_PASSWORD       DocuWare Passwort
+  DOCUWARE_FILE_CABINET   optional: Name des File Cabinets
+  DOCUWARE_ORG_ID         optional: OrgId, falls bei On-Prem/Enterprise nötig
+
+Beispiele:
+  python docuware_search.py --list-filters
+  python docuware_search.py --list-documents --count 10 --fields DOCUMENT_TYPE,COMPANY_NAME
+  python docuware_search.py --search DOCUMENT_TYPE "Invoice" --count 10
+  python docuware_search.py --search DWSTOREDATETIME 2024-01-01 2024-12-31 --count 20
+"""
+
+import argparse
+import json
+import os
+import sys
+from typing import Any
+
+import requests
+from dotenv import load_dotenv
+
+load_dotenv()
+
+
+PLATFORM = "DocuWare/Platform"
+CLIENT_ID = "docuware.platform.net.client"
+SCOPE = "docuware.platform"
+
+
+class DocuWareClient:
+    def __init__(self, server_url: str, username: str, password: str, org_id: str | None = None) -> None:
+        self.server_url = server_url.rstrip("/")
+        self.username = username
+        self.password = password
+        self.org_id = org_id
+        self.session = requests.Session()
+        self.session.headers.update({"Accept": "application/json"})
+        self.access_token: str | None = None
+
+    def _url(self, path: str) -> str:
+        return f"{self.server_url}/{PLATFORM}/{path.lstrip('/')}"
+
+    def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response:
+        response = self.session.request(method, url, timeout=60, **kwargs)
+        try:
+            response.raise_for_status()
+        except requests.HTTPError as exc:
+            body = response.text[:2000]
+            raise RuntimeError(f"{method} {url} fehlgeschlagen: HTTP {response.status_code}\n{body}") from exc
+        return response
+
+    def authenticate_password_grant(self) -> None:
+        """
+        Entspricht in der Postman-Collection:
+        1. Home/IdentityServiceInfo
+        2. /.well-known/openid-configuration
+        3.a Request Token w/ Username & Password
+        """
+        identity_info = self._request(
+            "GET",
+            self._url("Home/IdentityServiceInfo"),
+        ).json()
+
+        identity_service_url = identity_info["IdentityServiceUrl"].rstrip("/")
+        openid_config = self._request(
+            "GET",
+            f"{identity_service_url}/.well-known/openid-configuration",
+        ).json()
+
+        token_endpoint = openid_config["token_endpoint"]
+        token_response = self._request(
+            "POST",
+            token_endpoint,
+            headers={"Accept": "application/json"},
+            data={
+                "grant_type": "password",
+                "scope": SCOPE,
+                "client_id": CLIENT_ID,
+                "username": self.username,
+                "password": self.password,
+            },
+        ).json()
+
+        self.access_token = token_response["access_token"]
+        self.session.headers.update({"Authorization": f"Bearer {self.access_token}"})
+
+    def get_file_cabinets(self) -> list[dict[str, Any]]:
+        params = {}
+        if self.org_id:
+            params["OrgId"] = self.org_id
+        data = self._request("GET", self._url("FileCabinets"), params=params).json()
+        return data.get("FileCabinet", [])
+
+    def select_file_cabinet(self, file_cabinet_name: str | None = None) -> dict[str, Any]:
+        cabinets = self.get_file_cabinets()
+        if not cabinets:
+            raise RuntimeError("Keine File Cabinets gefunden oder keine Berechtigung.")
+
+        if file_cabinet_name:
+            for cabinet in cabinets:
+                if cabinet.get("Name") == file_cabinet_name:
+                    return cabinet
+            names = ", ".join(c.get("Name", "<ohne Name>") for c in cabinets)
+            raise RuntimeError(f"File Cabinet '{file_cabinet_name}' nicht gefunden. Verfuegbar: {names}")
+
+        return cabinets[0]
+
+    def get_dialogs(self, file_cabinet_id: str, dialog_type: str | None = None) -> list[dict[str, Any]]:
+        params = {}
+        if dialog_type:
+            params["DialogType"] = dialog_type
+        data = self._request(
+            "GET",
+            self._url(f"FileCabinets/{file_cabinet_id}/Dialogs"),
+            params=params,
+        ).json()
+        return data.get("Dialog", [])
+
+    def select_search_dialog(self, file_cabinet_id: str) -> dict[str, Any]:
+        dialogs = self.get_dialogs(file_cabinet_id, dialog_type="Search")
+        if not dialogs:
+            # Fallback: alle Dialoge laden und nach Type suchen.
+            dialogs = [d for d in self.get_dialogs(file_cabinet_id) if d.get("Type") == "Search"]
+
+        if not dialogs:
+            raise RuntimeError("Kein Search-Dialog gefunden.")
+
+        for dialog in dialogs:
+            if dialog.get("IsDefault") is True:
+                return dialog
+
+        return dialogs[0]
+
+    def get_dialog_details(self, file_cabinet_id: str, search_dialog_id: str) -> dict[str, Any]:
+        return self._request(
+            "GET",
+            self._url(f"FileCabinets/{file_cabinet_id}/Dialogs/{search_dialog_id}"),
+        ).json()
+
+    def get_valid_search_filters(self, file_cabinet_id: str, search_dialog_id: str) -> list[dict[str, Any]]:
+        """
+        Die gueltigen Suchfilter sind die Felder des Search-Dialogs.
+        Fuer Abfragen wird typischerweise DBFieldName als Condition.DBName verwendet.
+        """
+        details = self.get_dialog_details(file_cabinet_id, search_dialog_id)
+        fields = details.get("Fields", [])
+
+        result = []
+        for field in fields:
+            if field.get("Visible", True):
+                result.append(
+                    {
+                        "db_name": field.get("DBFieldName"),
+                        "label": field.get("DlgLabel"),
+                        "type": field.get("DWFieldType"),
+                        "read_only": field.get("ReadOnly"),
+                        "not_empty": field.get("NotEmpty"),
+                        "allow_extended_search": field.get("AllowExtendedSearch"),
+                        "length": field.get("Length"),
+                        "precision": field.get("Precision"),
+                    }
+                )
+        return result
+
+    def list_documents(
+        self,
+        file_cabinet_id: str,
+        count: int = 10,
+        fields: list[str] | None = None,
+    ) -> dict[str, Any]:
+        """
+        Einfache Dokumentliste ohne Suchbedingung.
+        """
+        params: dict[str, Any] = {"Count": count}
+        if fields:
+            params["Fields"] = ",".join(fields)
+
+        return self._request(
+            "GET",
+            self._url(f"FileCabinets/{file_cabinet_id}/Documents"),
+            params=params,
+        ).json()
+
+    def search_documents(
+        self,
+        file_cabinet_id: str,
+        search_dialog_id: str,
+        conditions: list[dict[str, Any]],
+        operation: str = "And",
+        count: int = 10,
+        start: int = 0,
+        result_fields: list[str] | None = None,
+        sort_field: str | None = None,
+        sort_direction: str = "Asc",
+    ) -> dict[str, Any]:
+        """
+        Suche per DialogExpression.
+
+        conditions Beispiel:
+          [{"DBName": "DOCUMENT_TYPE", "Value": ["Invoice"]}]
+          [{"DBName": "DWSTOREDATETIME", "Value": ["2024-01-01", "2024-12-31"]}]
+
+        Mehrere Werte innerhalb einer Condition werden von DocuWare als OR interpretiert.
+        Mehrere Conditions werden ueber Operation ("And" / "Or") kombiniert.
+        """
+        body: dict[str, Any] = {
+            "Condition": conditions,
+            "Operation": operation,
+            "Start": start,
+            "Count": count,
+            "ForceRefresh": True,
+            "IncludeSuggestions": False,
+        }
+
+        if result_fields:
+            body["AdditionalResultFields"] = result_fields
+
+        if sort_field:
+            body["SortOrder"] = [{"Field": sort_field, "Direction": sort_direction}]
+
+        params = {"DialogId": search_dialog_id}
+
+        return self._request(
+            "POST",
+            self._url(f"FileCabinets/{file_cabinet_id}/Query/DialogExpression"),
+            params=params,
+            json=body,
+        ).json()
+
+
+def print_json(data: Any) -> None:
+    print(json.dumps(data, indent=2, ensure_ascii=False))
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="DocuWare Suchfilter und Dokumente per requests abfragen")
+    parser.add_argument(
+        "--file-cabinet", default=os.environ.get("DOCUWARE_FILE_CABINET"), help="Name des File Cabinets"
+    )
+    parser.add_argument("--count", type=int, default=10, help="Maximale Anzahl Dokumente")
+    parser.add_argument("--fields", default="", help="Kommagetrennte Ergebnisfelder, z.B. DOCUMENT_TYPE,COMPANY_NAME")
+    parser.add_argument("--list-filters", action="store_true", help="Gueltige Suchfelder des Search-Dialogs ausgeben")
+    parser.add_argument("--list-documents", action="store_true", help="Dokumente ohne Suchbedingung listen")
+    parser.add_argument(
+        "--search",
+        nargs="+",
+        metavar=("DB_FIELD", "VALUE"),
+        help="Suche: DB-Feld plus ein oder mehrere Werte, z.B. --search DOCUMENT_TYPE Invoice",
+    )
+    parser.add_argument("--operation", choices=["And", "Or"], default="And", help="Verknuepfung mehrerer Conditions")
+    parser.add_argument("--sort-field", help="DB-Feld zum Sortieren")
+    parser.add_argument("--sort-direction", choices=["Asc", "Desc"], default="Asc")
+    args = parser.parse_args()
+
+    server_url = os.environ.get("DOCUWARE_SERVER_URL")
+    username = os.environ.get("DOCUWARE_USERNAME")
+    password = os.environ.get("DOCUWARE_PASSWORD")
+    org_id = os.environ.get("DOCUWARE_ORG_ID")
+
+    missing = [
+        name
+        for name, value in {
+            "DOCUWARE_SERVER_URL": server_url,
+            "DOCUWARE_USERNAME": username,
+            "DOCUWARE_PASSWORD": password,
+        }.items()
+        if not value
+    ]
+    if missing:
+        print(f"Fehlende Umgebungsvariablen: {', '.join(missing)}", file=sys.stderr)
+        return 2
+
+    client = DocuWareClient(server_url=server_url, username=username, password=password, org_id=org_id)
+    client.authenticate_password_grant()
+
+    cabinet = client.select_file_cabinet(args.file_cabinet)
+    file_cabinet_id = cabinet["Id"]
+
+    search_dialog = client.select_search_dialog(file_cabinet_id)
+    search_dialog_id = search_dialog["Id"]
+
+    field_list = [f.strip() for f in args.fields.split(",") if f.strip()]
+
+    if args.list_filters:
+        filters = client.get_valid_search_filters(file_cabinet_id, search_dialog_id)
+        print_json(
+            {
+                "file_cabinet": {"id": file_cabinet_id, "name": cabinet.get("Name")},
+                "search_dialog": {"id": search_dialog_id, "name": search_dialog.get("DisplayName")},
+                "valid_filters": filters,
+            }
+        )
+        return 0
+
+    if args.list_documents:
+        print_json(client.list_documents(file_cabinet_id, count=args.count, fields=field_list or None))
+        return 0
+
+    if args.search:
+        if len(args.search) < 2:
+            print("--search erwartet DB_FIELD und mindestens einen VALUE", file=sys.stderr)
+            return 2
+
+        db_field = args.search[0]
+        values = args.search[1:]
+        conditions = [{"DBName": db_field, "Value": values}]
+
+        print_json(
+            client.search_documents(
+                file_cabinet_id=file_cabinet_id,
+                search_dialog_id=search_dialog_id,
+                conditions=conditions,
+                operation=args.operation,
+                count=args.count,
+                result_fields=field_list or None,
+                sort_field=args.sort_field,
+                sort_direction=args.sort_direction,
+            )
+        )
+        return 0
+
+    parser.print_help()
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 56 - 0
app/jinja_templates.py

@@ -0,0 +1,56 @@
+from datetime import datetime
+
+from fastapi.templating import Jinja2Templates
+
+templates = Jinja2Templates(directory="templates")
+
+
+def number_format(input: float) -> str:
+    return format(input, "0,.2f").replace(".", ":").replace(",", ".").replace(":", ",")
+
+
+def date_format(input: datetime) -> str:
+    if input is None:
+        return ""
+    return input.strftime("%d.%m.%Y")
+
+
+def date_format2(input: datetime) -> str:
+    if input is None:
+        return ""
+    if isinstance(input, str):
+        return input[:10]
+    return input.strftime("%Y-%m-%d")
+
+
+def checked(input: str) -> str:
+    if input in ("J", "1", 1, True):
+        return "checked"
+    return ""
+
+
+def selected(input: str) -> str:
+    if input in ("J", "1", 1, True):
+        return "selected"
+    return ""
+
+
+def truefalse(input: str) -> str:
+    if input in ("J", "1", 1, True):
+        return "true"
+    return "false"
+
+
+def show(input: str) -> str:
+    if input in ("J", "1", 1, True):
+        return "show"
+    return ""
+
+
+templates.env.filters["number_format"] = number_format
+templates.env.filters["date_format"] = date_format
+templates.env.filters["date_format2"] = date_format2
+templates.env.filters["checked"] = checked
+templates.env.filters["selected"] = selected
+templates.env.filters["truefalse"] = truefalse
+templates.env.filters["show"] = show

+ 0 - 0
app/auth.py → app/ldap.py


+ 50 - 0
app/main2.py

@@ -0,0 +1,50 @@
+from fastapi import FastAPI, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+from fastapi.security import OAuth2PasswordRequestForm
+from .oauth_token import create_access_token
+from .schemas import User, Token
+from .auth2 import get_current_user
+from .users import UserModel, get_password_hash, authenticate_user
+from .db import get_session
+
+app = FastAPI()
+
+
+@app.post("/token", response_model=Token)
+def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_session)):
+    user = authenticate_user(db, form_data.username, form_data.password)
+    print(user)
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Incorrect username or password",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+    access_token = create_access_token(data={"sub": user.username})
+    return {"access_token": access_token, "token_type": "bearer"}
+
+
+@app.get("/users/me")
+def read_users_me(current_user: User = Depends(get_current_user)):
+    return current_user
+
+
+@app.post("/users/createUsers")
+def create_user(user: User, db: Session = Depends(get_session), current_user: User = Depends(get_current_user)):
+    # Check if the username already exists
+    existing_user = db.query(UserModel).filter(UserModel.username == user.username).first()
+    if existing_user:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Username already exists",
+        )
+
+    hashed_password = get_password_hash(user.hashed_password)
+    # Create a new user object
+    new_user = UserModel(username=user.username, email=user.email, hashed_password=hashed_password)
+    # Add the new user to the database
+    db.add(new_user)
+    db.commit()
+    db.refresh(new_user)
+
+    return {"message": "User created successfully", "user_id": new_user.id}

+ 94 - 0
app/main3.py

@@ -0,0 +1,94 @@
+from typing import Annotated
+
+from fastapi import Depends, FastAPI, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
+from pydantic import BaseModel
+
+fake_users_db = {
+    "johndoe": {
+        "username": "johndoe",
+        "full_name": "John Doe",
+        "email": "johndoe@example.com",
+        "hashed_password": "fakehashedsecret",
+        "disabled": False,
+    },
+    "alice": {
+        "username": "alice",
+        "full_name": "Alice Wonderson",
+        "email": "alice@example.com",
+        "hashed_password": "fakehashedsecret2",
+        "disabled": True,
+    },
+}
+
+app = FastAPI()
+
+
+def fake_hash_password(password: str):
+    return "fakehashed" + password
+
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
+
+
+class User(BaseModel):
+    username: str
+    email: str | None = None
+    full_name: str | None = None
+    disabled: bool | None = None
+
+
+class UserInDB(User):
+    hashed_password: str
+
+
+def get_user(db, username: str):
+    if username in db:
+        user_dict = db[username]
+        return UserInDB(**user_dict)
+
+
+def fake_decode_token(token):
+    # This doesn't provide any security at all
+    # Check the next version
+    user = get_user(fake_users_db, token)
+    return user
+
+
+async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
+    user = fake_decode_token(token)
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Not authenticated",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+    return user
+
+
+async def get_current_active_user(
+    current_user: Annotated[User, Depends(get_current_user)],
+):
+    if current_user.disabled:
+        raise HTTPException(status_code=400, detail="Inactive user")
+    return current_user
+
+
+@app.post("/token")
+async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
+    user_dict = fake_users_db.get(form_data.username)
+    if not user_dict:
+        raise HTTPException(status_code=400, detail="Incorrect username or password")
+    user = UserInDB(**user_dict)
+    hashed_password = fake_hash_password(form_data.password)
+    if not hashed_password == user.hashed_password:
+        raise HTTPException(status_code=400, detail="Incorrect username or password")
+
+    return {"access_token": user.username, "token_type": "bearer"}
+
+
+@app.get("/users/me")
+async def read_users_me(
+    current_user: Annotated[User, Depends(get_current_active_user)],
+):
+    return current_user

+ 30 - 0
app/oauth_token.py

@@ -0,0 +1,30 @@
+from datetime import datetime, timedelta, timezone
+from jose import JWTError, jwt
+from fastapi import HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from .config import settings
+
+SECRET_KEY = settings.SECRET_KEY
+ALGORITHM = settings.ALGORITHM
+ACCESS_TOKEN_EXPIRE_MINUTES = settings.ACCESS_TOKEN_EXPIRE_MINUTES
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
+
+
+def create_access_token(data: dict):
+    to_encode = data.copy()
+    expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
+    to_encode.update({"exp": expire})
+    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
+
+
+def verify_token(token: str):
+    try:
+        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+        return payload
+    except JWTError:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Invalid or expired token",
+            headers={"WWW-Authenticate": "Bearer"},
+        )

+ 90 - 0
app/query_filter.py

@@ -0,0 +1,90 @@
+import csv
+from dataclasses import dataclass
+
+
+@dataclass
+class FilterConfig:
+    name: str
+    text: str
+    filter_type: str
+    width: str
+    key_column: str
+    value_column: str
+    sort_column: str
+    visible: bool = True
+    default_value: str = ""
+    options: list[dict[str, str]] | None = None
+    current_value: str | None = None
+
+    @property
+    def query_param(self) -> str:
+        if self.current_value is None or self.current_value == "":
+            if self.default_value is None or self.default_value == "":
+                return None
+            self.current_value = self.default_value
+
+        if self.filter_type == "date":
+            if "bis" in self.name.lower():
+                return f" [{self.key_column}] <= '{self.current_value}' "
+            return f" [{self.key_column}] >= '{self.current_value}' "
+        # if self.filter_type == "select":
+        #     return f"{self.key_column} IN ({self.current_value})"
+        return f" [{self.key_column}] LIKE '%{self.current_value}%' "
+
+    @staticmethod
+    def from_dict(
+        name: str,
+        filter_type: str,
+        width: str,
+        text: str,
+        key_column: str,
+        value_column: str,
+        sort_column: str,
+        default_value: str,
+    ) -> "FilterConfig":
+        if filter_type not in ["text", "select", "date", "hidden"]:
+            filter_type = "text"
+        if width not in ["1", "2", "3", "4"]:
+            width = "2"
+        if not text:
+            text = name
+        if not key_column:
+            key_column = name
+        if not value_column:
+            value_column = key_column
+        if not sort_column:
+            sort_column = key_column
+        if not default_value:
+            default_value = ""
+
+        return FilterConfig(
+            name=name,
+            text=text,
+            filter_type=filter_type,
+            width=width,
+            key_column=key_column,
+            value_column=value_column,
+            sort_column=sort_column,
+            default_value=default_value,
+        )
+
+
+filter_type_option = {
+    "text": "text",
+    "select": "selectedOptionValue",
+    "date": "selectedDate",
+    "hidden": "selectedOptionValue",
+}
+
+
+def import_filter_config() -> list[FilterConfig]:
+    res = []
+    with open(
+        "templates\\forderungen\\liste\\config\\liste_filter.csv", "r", newline="", encoding="latin-1"
+    ) as csvfile:
+        reader = csv.DictReader(csvfile, delimiter=";")
+        for row in reader:
+            config = FilterConfig.from_dict(*row.values())
+
+            res.append(config)
+    return res

+ 16 - 121
app/routes.py

@@ -1,6 +1,5 @@
 import io
 import re
-from dataclasses import dataclass
 from datetime import datetime, timedelta
 from pathlib import Path
 from urllib.parse import unquote, urlencode
@@ -15,17 +14,18 @@ from fastapi.responses import (
     RedirectResponse,
     StreamingResponse,
 )
-from fastapi.templating import Jinja2Templates
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 
-from .auth import ldap_authenticate
+from app.query_filter import filter_type_option, import_filter_config
+
 from .db import get_session
+from .jinja_templates import templates
+from .ldap import ldap_authenticate
 from .models import Bemerkung, Forderung
 from .schemas import BemerkungIn
 
 router = APIRouter()
-templates = Jinja2Templates(directory="templates")
 
 
 @router.get("/", response_class=HTMLResponse)
@@ -60,123 +60,11 @@ def forderungsliste(request: Request, db: Session = Depends(get_session), limit:
     return templates.TemplateResponse(request, "base/list.html", {"request": request, "forderungen": q})
 
 
-def number_format(input: float) -> str:
-    return format(input, "0,.2f").replace(".", ":").replace(",", ".").replace(":", ",")
-
-
-def date_format(input: datetime) -> str:
-    if input is None:
-        return ""
-    return input.strftime("%d.%m.%Y")
-
-
-def checked(input: str) -> str:
-    if input in ("J", "1", 1, True):
-        return "checked"
-    return ""
-
-
-def selected(input: str) -> str:
-    if input in ("J", "1", 1, True):
-        return "selected"
-    return ""
-
-
-def truefalse(input: str) -> str:
-    if input in ("J", "1", 1, True):
-        return "true"
-    return "false"
-
-
-def show(input: str) -> str:
-    if input in ("J", "1", 1, True):
-        return "show"
-    return ""
-
-
-templates.env.filters["number_format"] = number_format
-templates.env.filters["date_format"] = date_format
-templates.env.filters["checked"] = checked
-templates.env.filters["selected"] = selected
-templates.env.filters["truefalse"] = truefalse
-templates.env.filters["show"] = show
-
-
 def single_quote(text: str):
     return "'" + unquote(text) + "'"
 
 
-@dataclass
-class FilterConfig:
-    name: str
-    text: str
-    filter_type: str
-    width: str
-    key_column: str
-    value_column: str
-    visible: bool = True
-    default_value: str = ""
-    options: list[dict[str, str]] | None = None
-    current_value: str | None = None
-
-
-forderung_filter_config = [
-    FilterConfig("Hauptbetrieb", "Hauptbetrieb", "select", "2", "Client_DB", "Hauptbetrieb_Name"),
-    FilterConfig("Standort", "Standort", "select", "3", "Standort_ID", "Standort_Name"),
-    FilterConfig("Bereich", "Bereich", "select", "3", "Bereich", "Bereich"),
-    FilterConfig("Verursacher", "Verursacher", "select", "4", "Verursacher", "Verursacher"),
-    FilterConfig("Rechnungsnummer", "Rechnungsnummer", "text", "2", "Document_No", "Document_No"),
-    FilterConfig(
-        "RechnungsdatumVon",
-        "Rechnungsdatum von",
-        "date",
-        "2",
-        "Invoice_Date",
-        "Invoice_Date",
-        default_value="2000-01-01T00:00:00",
-    ),
-    FilterConfig(
-        "RechnungsdatumBis",
-        "Rechnungsdatum bis",
-        "date",
-        "2",
-        "Invoice_Date",
-        "Invoice_Date",
-        default_value="2027-01-01T00:00:00",
-    ),
-    FilterConfig("Kunde", "Kunde", "text", "4", "Kunde", "Kunde"),
-    FilterConfig("Abwarten", "Abwarten", "select", "2", "Abwarten", "Abwarten"),
-    FilterConfig(
-        "WiedervorlageVon",
-        "Wiedervorlage von",
-        "date",
-        "2",
-        "Wiedervorlage",
-        "Wiedervorlage",
-        default_value="2000-01-01T00:00:00",
-    ),
-    FilterConfig(
-        "WiedervorlageBis",
-        "Wiedervorlage bis",
-        "date",
-        "2",
-        "Wiedervorlage",
-        "Wiedervorlage",
-        default_value="2027-01-01T00:00:00",
-    ),
-    FilterConfig("Fahrzeug", "Fahrzeug", "select", "2", "VIN", "VIN"),
-    FilterConfig("Staffel", "Staffel", "select", "2", "Staffel", "Staffel"),
-    FilterConfig("Mahnstufe", "Mahnstufe", "select", "2", "Mahnstufe", "Mahnstufe"),
-    FilterConfig("Bearbeitet", "Bearbeitet", "select", "2", "Bearbeitet", "Bearbeitet"),
-    FilterConfig("BenutzerSelect", "Benutzer", "hidden", "2", "", "", visible=False, default_value="winter"),
-]
-
-filter_type_option = {
-    "text": "text",
-    "select": "selectedOptionValue",
-    "date": "selectedDate",
-    "hidden": "selectedOptionValue",
-}
+forderung_filter_config = import_filter_config()
 
 
 @router.get("/app/forderungen/liste", response_class=HTMLResponse)
@@ -196,14 +84,21 @@ def forderungen_liste(request: Request, db: Session = Depends(get_session), limi
     query = templates.TemplateResponse(request, "forderungen/liste/queries/forderungen_liste.sql", context).body.decode(
         "utf-8"
     )
-    # print(query)
+    query_params = [
+        f.query_param for f in forderung_filter_config if f.query_param is not None and f.filter_type != "hidden"
+    ]
+    if query_params:
+        query = query.replace("1 = 1", " AND ".join(query_params))
+    print(query)
+    print(query_params)
     # q = db.execute(text("SELECT * FROM [dbo].[Forderungen]"))
-    q = db.execute(text(query)).fetchall()
-    col_names = list(q[0]._asdict().keys())
+    q_res = db.execute(text(query))
+    col_names = list(q_res.keys())
+    q = q_res.fetchall()
 
     filters = {}
     for f in forderung_filter_config:
-        if f.key_column == "":
+        if f.key_column not in col_names:
             continue
         filters[f.name] = {row[col_names.index(f.key_column)]: row[col_names.index(f.value_column)] for row in q}
         f.options = filters[f.name]

+ 21 - 0
app/schemas.py

@@ -24,3 +24,24 @@ class ZahlungOut(BaseModel):
 class BemerkungIn(BaseModel):
     bemerkung: Optional[str]
     wiedervorlage_datum: Optional[date]
+
+
+class Token(BaseModel):
+    access_token: str
+    token_type: str
+
+
+class TokenData(BaseModel):
+    username: str | None = None
+
+
+class User(BaseModel):
+    username: str
+    email: str | None = None
+    hashed_password: str
+
+
+class UserInDB(User):
+    username: str
+    email: str | None = None
+    hashed_password: str

+ 35 - 0
app/users.py

@@ -0,0 +1,35 @@
+from sqlalchemy.orm import Session
+from .schemas import UserInDB
+from .db import Base
+from passlib.context import CryptContext
+from sqlalchemy import Column, Integer, String
+
+# Password hashing configuration
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+
+class UserModel(Base):
+    __tablename__ = "users"
+    id = Column(Integer, primary_key=True, index=True)
+    username = Column(String, unique=True, index=True)
+    email = Column(String, unique=True, index=True)
+    hashed_password = Column(String)
+
+
+def get_password_hash(password: str) -> str:
+    return pwd_context.hash(password)
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+    return pwd_context.verify(plain_password, hashed_password)
+
+
+def authenticate_user(db: Session, username: str, password: str) -> UserInDB | None:
+    user = get_by_username(db, username)
+    if not user or not verify_password(password, user.hashed_password):
+        return None
+    return user
+
+
+def get_by_username(db: Session, username: str) -> UserInDB | None:
+    return db.query(UserModel).filter(UserModel.username == username).first()

+ 27 - 0
filter_export.py

@@ -0,0 +1,27 @@
+import csv
+from dataclasses import asdict
+
+from app.routes import FilterConfig, forderung_filter_config
+
+
+def export_filter_config():
+    with open("filter_config.csv", "w", newline="", encoding="latin-1") as csvfile:
+        writer = csv.DictWriter(csvfile, fieldnames=asdict(forderung_filter_config[0]).keys(), delimiter=";")
+        writer.writeheader()
+        writer.writerows([asdict(config) for config in forderung_filter_config])
+
+
+# name	filter_type	width	text	key_column	value_column	sort_column	default_value
+
+
+def import_filter_config2():
+    with open("forderungen\\liste\\config\\liste_filter.csv", "r", newline="", encoding="latin-1") as csvfile:
+        reader = csv.DictReader(csvfile, delimiter=";")
+        for row in reader:
+            # Convert the row dictionary to a FilterConfig object
+            config = FilterConfig.from_dict(*row.values())
+            print(config)
+
+
+if __name__ == "__main__":
+    import_filter_config2()

+ 6 - 3
pyproject.toml

@@ -1,7 +1,7 @@
 [project]
-name = "reisacher-forderung"
-version = "0.1.0"
-description = "Reisacher Forderungsmanagement 2.0"
+name = "gcepic"
+version = "0.2.0"
+description = "GlobalCube Enterprise Planning, Information & Controlling"
 readme = "README.md"
 requires-python = ">=3.13"
 dependencies = [
@@ -20,4 +20,7 @@ dependencies = [
 	"python-dotenv>=1.2.1",
 	"docxtpl>=0.20.2",
 	"imap-tools>=1.12.1",
+	"passlib>=1.7.4",
+	"python-jose>=3.5.0",
+	"requests>=2.34.2",
 ]

+ 71 - 2
static/assets/css/main.css

@@ -36,7 +36,7 @@ body {
 */
 
 .chat-container {
-    width: 700px;
+    /* width: 700px; */
     height: 900px;
     display: flex;
     flex-direction: column;
@@ -816,4 +816,73 @@ a {
   position: sticky;
   top: 40px;
   background: white;
-}
+}
+
+
+      .chat-input {
+            padding: 15px 20px;
+            border-top: 1px solid var(--border);
+            display: flex;
+            align-items: center;
+            background-color: white;
+            position: relative;
+            z-index: 10;
+        }
+
+        .input-container {
+            flex: 1;
+            display: flex;
+            align-items: center;
+            background-color: var(--secondary);
+            border-radius: 20px;
+            padding: 0 15px;
+            position: relative;
+        }
+
+        .input-container input {
+            flex: 1;
+            border: none;
+            outline: none;
+            height: 40px;
+            background-color: transparent;
+            padding: 0 10px;
+            font-size: 15px;
+        }
+
+        .input-actions {
+            display: flex;
+            gap: 10px;
+        }
+
+        .input-btn {
+            background: none;
+            border: none;
+            color: #888;
+            cursor: pointer;
+            transition: var(--transition);
+            font-size: 18px;
+        }
+
+        .input-btn:hover {
+            color: var(--primary);
+        }
+
+        .send-btn {
+            background-color: var(--primary);
+            color: white;
+            width: 40px;
+            height: 40px;
+            border-radius: 50%;
+            border: none;
+            cursor: pointer;
+            margin-left: 10px;
+            transition: var(--transition);
+            display: flex;
+            align-items: center;
+            justify-content: center;
+        }
+
+        .send-btn:hover {
+            background-color: var(--primary-light);
+            transform: scale(1.05);
+        }

+ 1181 - 0
static/assets/css/redesign.css

@@ -0,0 +1,1181 @@
+:root {
+    --gc-bg: #f4f6fb;
+    --gc-surface: #ffffff;
+    --gc-surface-soft: #f8fafc;
+    --gc-border: #e5e7eb;
+    --gc-text: #111827;
+    --gc-muted: #6b7280;
+    --gc-primary: #475569;
+    --gc-primary-dark: #334155;
+    --gc-primary-soft: #eef2f7;
+    --gc-danger: #dc2626;
+    --gc-warning: #d97706;
+    --gc-success: #16a34a;
+    --gc-radius-sm: 10px;
+    --gc-radius-md: 16px;
+    --gc-radius-lg: 22px;
+    --gc-shadow-sm: 0 4px 14px rgba(15, 23, 42, .06);
+    --gc-shadow-md: 0 18px 45px rgba(15, 23, 42, .10);
+    --gc-topbar-height: 72px;
+  }
+  
+  /* Basis */
+  
+  html,
+  body {
+    min-height: 100%;
+    background: var(--gc-bg);
+    color: var(--gc-text);
+  }
+  
+  body {
+    overflow-y: auto;
+    overflow-x: auto;
+    font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+  }
+  
+  /* App Shell */
+  
+  .gc-shell {
+    height: 100vh;
+    display: flex;
+    flex-direction: column;
+    background:
+      radial-gradient(circle at top left, rgba(100, 116, 139, .10), transparent 34rem),
+      linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
+  }
+  
+  .gc-topbar {
+    height: var(--gc-topbar-height);
+    flex: 0 0 var(--gc-topbar-height);
+    display: flex;
+    align-items: center;
+    padding: 0 1.5rem;
+    background: rgba(255, 255, 255, .86);
+    backdrop-filter: blur(18px);
+    border-bottom: 1px solid rgba(226, 232, 240, .9);
+    z-index: 1000;
+  }
+  
+  .gc-logo {
+    height: 38px;
+    width: auto;
+    object-fit: contain;
+  }
+  
+  .gc-brand {
+    font-size: 1rem;
+    font-weight: 800;
+    letter-spacing: -.02em;
+    color: var(--gc-text);
+  }
+  
+  .gc-nav-link {
+    display: inline-flex;
+    align-items: center;
+    min-height: 38px;
+    padding: .45rem .8rem;
+    border-radius: 999px;
+    color: var(--gc-muted);
+    font-weight: 700;
+    text-decoration: none;
+    transition: all .18s ease;
+  }
+  
+  .gc-nav-link:hover {
+    color: #334155;
+    background: #eef2f7;
+    text-decoration: none;
+  }
+  
+  .gc-page {
+    flex: 1;
+    width: 100%;
+    max-width: 1840px;
+    margin: 0 auto;
+    padding: 1.25rem;
+    overflow: hidden;
+    display: flex;
+    flex-direction: column;
+    gap: 1rem;
+  }
+  
+  /* Cards */
+  
+  .gc-card {
+    background: rgba(255, 255, 255, .95);
+    border: 1px solid rgba(226, 232, 240, .95);
+    border-radius: var(--gc-radius-lg);
+    box-shadow: var(--gc-shadow-sm);
+  }
+  
+  /* Filter */
+  
+  .gc-filterbar {
+    padding: 1rem;
+  }
+  
+  .gc-filterbar .form-label {
+    font-size: .74rem;
+    font-weight: 800;
+    text-transform: uppercase;
+    color: var(--gc-muted);
+    letter-spacing: .04em;
+  }
+  
+  .gc-filterbar .form-control,
+  .gc-filterbar .form-select {
+    border-radius: 12px;
+    border-color: var(--gc-border);
+    font-weight: 600;
+  }
+  
+  .gc-filterbar .form-control:focus,
+  .gc-filterbar .form-select:focus {
+    border-color: #94a3b8;
+    box-shadow: 0 0 0 .2rem rgba(100, 116, 139, .12);
+  }
+  
+  /* Tabelle */
+  
+  .gc-table-wrap {
+    flex: 1;
+    overflow: auto;
+    border-radius: var(--gc-radius-lg);
+    padding-top: 0 !important;
+  }
+  
+  .gc-table {
+    width: 100%;
+    min-width: 1450px;
+    margin: 0 !important;
+    border-collapse: separate !important;
+    border-spacing: 0 !important;
+    table-layout: auto !important;
+    font-size: .88rem;
+  }
+  
+  .gc-table thead {
+    position: static !important;
+    z-index: auto !important;
+  }
+  
+  .gc-table thead th {
+    position: static !important;
+    top: auto !important;
+    z-index: auto !important;
+    background: #f3f4f6 !important;
+    color: #667085;
+    font-size: .7rem;
+    font-weight: 900;
+    text-transform: uppercase;
+    letter-spacing: .055em;
+    padding: .85rem .8rem !important;
+    border-top: 0 !important;
+    border-bottom: 1px solid #d7dde5 !important;
+    box-shadow: none !important;
+    white-space: nowrap;
+    vertical-align: bottom;
+  }
+  
+  .gc-table tbody td {
+    background: #ffffff;
+    padding: .85rem .8rem !important;
+    border-top: 1px solid #edf2f7;
+    vertical-align: top;
+  }
+  
+  .gc-table tbody tr {
+    transition: background .15s ease;
+  }
+  
+  .gc-table tbody tr:hover td {
+    background: #fafbfc;
+  }
+  
+  /* Spalten */
+  
+  .gc-table th:nth-child(2),
+  .gc-table td:nth-child(2) {
+    width: 120px;
+    white-space: normal !important;
+  }
+  
+  .gc-table th:nth-child(3),
+  .gc-table td:nth-child(3) {
+    padding-left: 14px !important;
+  }
+  
+  .gc-table th:nth-child(8),
+  .gc-table td:nth-child(8),
+  .gc-table td:nth-child(8) *,
+  .gc-table th:nth-child(9),
+  .gc-table td:nth-child(9),
+  .gc-table td:nth-child(9) * {
+    white-space: nowrap !important;
+  }
+  
+  /* Tabellenlinks neutral/grau */
+  
+  .gc-table a {
+    color: #4b5563 !important;
+    text-decoration: none;
+    font-weight: 600;
+  }
+  
+  .gc-table a:hover {
+    color: #1f2937 !important;
+    text-decoration: underline;
+  }
+  
+  /* Typografie */
+  
+  .gc-title {
+    color: var(--gc-text);
+    font-weight: 750;
+    line-height: 1.25;
+  }
+  
+  .gc-subtitle {
+    margin-top: .15rem;
+    color: var(--gc-muted);
+    font-size: .78rem;
+    line-height: 1.35;
+  }
+  
+  .gc-main-cell {
+    min-width: 290px;
+  }
+  
+  .gc-money {
+    font-variant-numeric: tabular-nums;
+    font-weight: 400;
+    white-space: nowrap;
+  }
+  /* Offen / Kunde ges. weiterhin fett */
+.gc-table td:nth-child(8) .gc-money {
+    font-weight: 800 !important;
+  }
+  
+  /* Buttons */
+  
+  .gc-action-btn {
+    border-radius: 10px;
+    font-weight: 700;
+    background: #7b8ea3 !important;
+    border-color: #7b8ea3 !important;
+    color: #ffffff !important;
+    box-shadow: none !important;
+  }
+  
+  .gc-action-btn:hover {
+    background: #66788c !important;
+    border-color: #66788c !important;
+    color: #ffffff !important;
+  }
+  
+  /* Badges */
+  
+  .gc-badge {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    gap: .3rem;
+    min-height: 24px;
+    padding: .22rem .55rem;
+    border-radius: 999px;
+    font-size: .7rem;
+    font-weight: 800;
+    border: 1px solid transparent;
+    white-space: nowrap;
+  }
+  
+  .gc-badge-info {
+    background: #edf2f7;
+    color: #475569;
+    border-color: #dbe3ee;
+  }
+  
+  .gc-badge-warning {
+    background: #f6ecd2;
+    color: #8a6a1f;
+    border-color: #ead9a7;
+  }
+  
+  .gc-badge-danger {
+    background: #fee2e2;
+    color: #991b1b;
+    border-color: #fecaca;
+  }
+  
+  .gc-badge-success {
+    background: #dcfce7;
+    color: #166534;
+    border-color: #bbf7d0;
+  }
+  
+  /* Mahnstufen */
+  
+  .gc-mahnstufe-1 {
+    background: #fee2e2 !important;
+    color: #991b1b !important;
+    border-color: #fecaca !important;
+  }
+  
+  .gc-mahnstufe-2 {
+    background: #fecaca !important;
+    color: #7f1d1d !important;
+    border-color: #fca5a5 !important;
+  }
+  
+  .gc-mahnstufe-3 {
+    background: #dc2626 !important;
+    color: #ffffff !important;
+    border-color: #991b1b !important;
+  }
+  
+  /* Wiedervorlage */
+  
+  .gc-wv-overdue {
+    background: #fee2e2 !important;
+    color: #991b1b !important;
+    border-color: #fecaca !important;
+  }
+  
+  .gc-wv-today {
+    background: #f6ecd2 !important;
+    color: #8a6a1f !important;
+    border-color: #ead9a7 !important;
+  }
+  
+  .gc-wv-future {
+    background: #dcfce7 !important;
+    color: #166534 !important;
+    border-color: #bbf7d0 !important;
+  }
+  
+  /* Infinite Scroll */
+  
+  .gc-load-sentinel,
+  .gc-load-sentinel td {
+    height: 1px !important;
+    padding: 0 !important;
+    border: 0 !important;
+    background: transparent !important;
+  }
+  
+  /* Detailseite Layout */
+  
+  .gc-case-header,
+  .gc-panel,
+  .gc-case-summary {
+    padding: 1.25rem;
+  }
+  
+  .gc-case-summary .gc-panel-title {
+    margin-bottom: 1rem;
+    font-size: .85rem;
+    font-weight: 800;
+    color: #334155;
+  }
+  
+  .gc-summary-section {
+    margin-bottom: 1rem;
+  }
+  
+  .gc-summary-label {
+    font-size: .72rem;
+    font-weight: 800;
+    color: #64748b;
+    text-transform: uppercase;
+    letter-spacing: .05em;
+    margin-bottom: .15rem;
+  }
+  
+  .gc-summary-main {
+    font-size: .98rem;
+    font-weight: 700;
+    color: #172033;
+    line-height: 1.25;
+  }
+  
+  .gc-summary-sub {
+    font-size: .85rem;
+    color: #64748b;
+    margin-top: .15rem;
+  }
+  
+  .gc-summary-grid {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: .75rem;
+    margin-bottom: 1rem;
+  }
+  
+  .gc-summary-value {
+    font-size: .98rem;
+    font-weight: 700;
+    color: #172033;
+  }
+  
+  .gc-case-summary hr {
+    margin: 1.25rem 0;
+    border-color: #e6eaf0;
+    opacity: 1;
+  }
+  
+  .gc-detail-grid {
+    display: grid !important;
+    grid-template-columns: 320px minmax(600px, 1fr) 360px !important;
+    gap: 1rem !important;
+    align-items: start !important;
+    width: 100% !important;
+  }
+  
+  .gc-detail-grid > .gc-case-summary {
+    grid-column: 1 !important;
+    width: 100% !important;
+  }
+  
+  .gc-detail-grid > .gc-detail-main {
+    grid-column: 2 !important;
+    min-width: 0 !important;
+    width: 100% !important;
+  }
+  
+  .gc-detail-grid > .gc-action-panel {
+    grid-column: 3 !important;
+    width: 100% !important;
+    position: sticky !important;
+    top: calc(var(--gc-topbar-height) + 1rem) !important;
+  }
+  
+  .gc-accordion-clean .accordion-item {
+    border: 1px solid var(--gc-border);
+    border-radius: var(--gc-radius-md) !important;
+    overflow: hidden;
+    margin-bottom: .75rem;
+  }
+  
+  /* Chat / Timeline */
+  
+  .gc-detail-main .chat-container2 {
+    height: calc(100vh - var(--gc-topbar-height) - 15rem);
+    max-height: 620px;
+    min-height: 420px;
+    display: flex;
+    flex-direction: column;
+    background: #ffffff;
+    border: 1px solid rgba(226, 232, 240, .95);
+    border-radius: var(--gc-radius-lg);
+    box-shadow: var(--gc-shadow-sm);
+    overflow: hidden;
+  }
+  
+  .chat-container2 .header {
+    flex: 0 0 auto;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: .85rem 1rem;
+    background: #ffffff;
+    border-bottom: 1px solid #e5e7eb;
+  }
+  
+  .chat-container2 .header-left {
+    display: flex;
+    align-items: center;
+    gap: .75rem;
+    min-width: 0;
+  }
+  
+  .chat-container2 .avatar {
+    width: 32px;
+    height: 32px;
+    flex: 0 0 32px;
+    border-radius: 999px;
+    background: #e5e7eb;
+    color: #ffffff;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    font-size: .72rem;
+    font-weight: 900;
+    overflow: hidden;
+  }
+  
+  .chat-container2 .course-title {
+    font-size: .95rem;
+    font-weight: 850;
+    color: #111827;
+    line-height: 1.25;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+  
+  .chat-container2 .course-participants {
+    margin-top: .1rem;
+    font-size: .76rem;
+    color: #64748b;
+  }
+  
+  .chat-container2 .header-actions {
+    display: flex;
+    align-items: center;
+    gap: .35rem;
+  }
+  
+  .chat-container2 .icon-button {
+    width: 32px;
+    height: 32px;
+    border: 1px solid #e5e7eb;
+    border-radius: 10px;
+    background: #f8fafc;
+    color: #64748b;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+  }
+  
+  .chat-container2 .icon-button:hover {
+    background: #eef2f7;
+    color: #334155;
+    border-color: #cbd5e1;
+  }
+  
+  .chat-container2 .tabs {
+    flex: 0 0 auto;
+    display: flex;
+    gap: .35rem;
+    padding: .65rem .85rem;
+    background: #f8fafc;
+    border-bottom: 1px solid #e5e7eb;
+    overflow-x: auto;
+  }
+  
+  .chat-container2 .tab {
+    padding: .38rem .65rem;
+    border-radius: 999px;
+    font-size: .75rem;
+    font-weight: 800;
+    color: #64748b;
+    white-space: nowrap;
+  }
+  
+  .chat-container2 .tab.active {
+    background: #64748b;
+    color: #ffffff;
+  }
+  
+  .chat-container2 .chat-content {
+    flex: 1;
+    min-height: 0;
+    overflow-y: auto;
+    padding: 1rem;
+    background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
+  }
+  
+  .chat-container2 .date-divider {
+    display: flex;
+    align-items: center;
+    gap: .75rem;
+    margin: .4rem 0 1rem;
+  }
+  
+  .chat-container2 .divider-line {
+    flex: 1;
+    height: 1px;
+    background: #e5e7eb;
+  }
+  
+  .chat-container2 .divider-text {
+    font-size: .7rem;
+    font-weight: 850;
+    color: #64748b;
+    text-transform: uppercase;
+    letter-spacing: .04em;
+  }
+  
+  .chat-container2 .message {
+    max-width: 78%;
+    margin-bottom: .85rem;
+  }
+  
+  .chat-container2 .message.teacher,
+  .chat-container2 .message.Fibu,
+  .chat-container2 .message.fibu {
+    margin-right: auto;
+  }
+  
+  .chat-container2 .message.student,
+  .chat-container2 .message.Abteilung,
+  .chat-container2 .message.abteilung {
+    margin-left: auto;
+  }
+  
+  .chat-container2 .message-header {
+    display: flex;
+    align-items: center;
+    gap: .45rem;
+    margin-bottom: .3rem;
+  }
+  
+  .chat-container2 .sender-name {
+    font-size: .76rem;
+    font-weight: 850;
+    color: #334155;
+  }
+  
+  .chat-container2 .timestamp {
+    font-size: .7rem;
+    color: #94a3b8;
+    margin-left: .25rem;
+  }
+  
+  .chat-container2 .message-bubble {
+    display: inline-block;
+    padding: .65rem .85rem;
+    border-radius: 15px;
+    font-size: .86rem;
+    line-height: 1.42;
+    background: #f1f5f9;
+    color: #172033;
+    border: 1px solid #e2e8f0;
+    box-shadow: 0 3px 10px rgba(15, 23, 42, .04);
+  }
+  
+  .chat-container2 .message.student .message-bubble,
+  .chat-container2 .message.Abteilung .message-bubble,
+  .chat-container2 .message.abteilung .message-bubble {
+    background: #f8fafc;
+    color: #172033;
+    border-color: #cbd5e1;
+  }
+  
+  .chat-container2 .message.teacher .message-bubble,
+  .chat-container2 .message.Fibu .message-bubble,
+  .chat-container2 .message.fibu .message-bubble {
+    background: #ffffff;
+    color: #172033;
+    border-color: #e2e8f0;
+  }
+  
+  .chat-container2 .input-area {
+    flex: 0 0 auto;
+    min-height: 46px;
+    padding: .6rem .85rem;
+    background: #ffffff;
+    border-top: 1px solid #e5e7eb;
+  }
+  
+  .chat-container2 .toolbar {
+    display: flex;
+    align-items: center;
+    gap: .5rem;
+  }
+  
+  .chat-container2 .tool-button {
+    width: 32px;
+    height: 32px;
+    border-radius: 10px;
+    border: 1px dashed #cbd5e1;
+    background: #f8fafc;
+  }
+  
+  /* Responsive */
+  
+  @media (max-width: 1200px) {
+    .gc-detail-grid {
+      grid-template-columns: 1fr !important;
+    }
+  
+    .gc-detail-grid > .gc-case-summary,
+    .gc-detail-grid > .gc-detail-main,
+    .gc-detail-grid > .gc-action-panel {
+      grid-column: auto !important;
+    }
+  
+    .gc-detail-grid > .gc-action-panel {
+      position: static !important;
+    }
+  
+    .gc-detail-main .chat-container2 {
+      height: auto;
+      min-height: 520px;
+      max-height: none;
+    }
+  }
+  
+  @media (max-width: 992px) {
+    body {
+      overflow: auto;
+    }
+  
+    .gc-shell {
+      min-height: 100vh;
+      height: auto;
+    }
+  
+    .gc-page {
+      overflow: visible;
+    }
+  
+    .gc-table-wrap {
+      overflow-x: auto;
+    }
+  }
+  /* =========================================================
+   HAUPTSEITE: SINNVOLLERE SPALTENBREITEN
+========================================================= */
+
+.gc-table {
+    min-width: 1380px;
+  }
+  
+  /* Aktion */
+  .gc-table th:nth-child(1),
+  .gc-table td:nth-child(1) {
+    width: 54px;
+  }
+  
+  /* Filiale / Bereich */
+  .gc-table th:nth-child(2),
+  .gc-table td:nth-child(2) {
+    width: 115px;
+    max-width: 115px;
+    white-space: normal !important;
+  }
+  
+  /* Kunde */
+  .gc-table th:nth-child(3),
+  .gc-table td:nth-child(3) {
+    width: 230px;
+    min-width: 230px;
+    padding-left: 8px !important;
+  }
+  
+  /* Verursacher */
+  .gc-table th:nth-child(4),
+  .gc-table td:nth-child(4) {
+    width: 145px;
+    min-width: 145px;
+    padding-left: 6px !important;
+  }
+  
+  /* RG-Nr. */
+  .gc-table th:nth-child(5),
+  .gc-table td:nth-child(5) {
+    width: 118px;
+  }
+  
+  /* RG-Datum / Fällig */
+  .gc-table th:nth-child(6),
+  .gc-table td:nth-child(6) {
+    width: 120px;
+  }
+  
+  /* RG-Betrag */
+  .gc-table th:nth-child(7),
+  .gc-table td:nth-child(7) {
+    width: 105px;
+  }
+  
+  /* offen / Kunde ges. */
+  .gc-table th:nth-child(8),
+  .gc-table td:nth-child(8) {
+    width: 155px;
+  }
+  
+  /* Mahnstufe / Staffel */
+  .gc-table th:nth-child(9),
+  .gc-table td:nth-child(9) {
+    width: 125px;
+  }
+  
+  /* Abw. */
+  .gc-table th:nth-child(10),
+  .gc-table td:nth-child(10) {
+    width: 80px;
+  }
+  
+  /* Kommentar Fibu */
+  .gc-table th:nth-child(11),
+  .gc-table td:nth-child(11) {
+    width: 165px;
+  }
+  
+  /* Kommentar Abteilung */
+  .gc-table th:nth-child(12),
+  .gc-table td:nth-child(12) {
+    width: 165px;
+  }
+  
+  /* Wiedervorlage */
+  .gc-table th:nth-child(13),
+  .gc-table td:nth-child(13) {
+    width: 120px;
+  }
+  
+  /* Abw. wieder im alten Gelb */
+  .gc-table td:nth-child(10) .gc-badge-warning {
+    background: #fef3c7 !important;
+    color: #92400e !important;
+    border-color: #fde68a !important;
+    text-align: left;
+  }
+  /* =========================================================
+   EDIT BUTTON LINKS HELLER
+========================================================= */
+
+.gc-table td:first-child button,
+.gc-table td:first-child .btn,
+.gc-table td:first-child a {
+  width: 34px;
+  height: 34px;
+  border-radius: 12px !important;
+  border: 1px solid #d6dee8 !important;
+
+  background: linear-gradient(
+    180deg,
+    #9db0c2 0%,
+    #879caf 100%
+  ) !important;
+
+  color: #ffffff !important;
+
+  box-shadow:
+    inset 0 1px 0 rgba(255,255,255,.28),
+    0 2px 6px rgba(15,23,42,.08);
+
+  transition: all .18s ease;
+}
+
+/* Hover */
+.gc-table td:first-child button:hover,
+.gc-table td:first-child .btn:hover,
+.gc-table td:first-child a:hover {
+  background: linear-gradient(
+    180deg,
+    #a8b9c9 0%,
+    #92a6b8 100%
+  ) !important;
+
+  transform: translateY(-1px);
+}
+
+/* Icon */
+.gc-table td:first-child i,
+.gc-table td:first-child svg {
+  color: #ffffff !important;
+  font-size: .9rem;
+}
+/* =========================================================
+   DETAILSEITE: CHATVERLAUF OPTISCH AUFWERTEN
+========================================================= */
+
+.chat-container2 {
+    background: #ffffff !important;
+    border-radius: 22px !important;
+    overflow: hidden;
+  }
+  
+  .chat-container2 .chat-content {
+    background:
+      radial-gradient(circle at top left, rgba(148, 163, 184, .16), transparent 22rem),
+      linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%) !important;
+    padding: 1.25rem 1.5rem !important;
+  }
+  
+  .chat-container2 .date-divider {
+    margin: 1.25rem 0 1.5rem !important;
+  }
+  
+  .chat-container2 .divider-line {
+    background: #cbd5e1 !important;
+  }
+  
+  .chat-container2 .divider-text {
+    color: #64748b !important;
+    font-weight: 900 !important;
+    font-size: .76rem !important;
+  }
+  
+  .chat-container2 .message {
+    margin-bottom: 1.15rem !important;
+  }
+  
+  .chat-container2 .message-header {
+    margin-bottom: .4rem !important;
+  }
+  
+  .chat-container2 .message-header .avatar {
+    width: 34px !important;
+    height: 34px !important;
+    min-width: 34px !important;
+    background: linear-gradient(180deg, #cbd5e1 0%, #94a3b8 100%) !important;
+    color: #ffffff !important;
+    border: 2px solid #ffffff !important;
+    box-shadow: 0 3px 10px rgba(15, 23, 42, .10) !important;
+    font-size: .68rem !important;
+    text-transform: uppercase;
+  }
+  
+  .chat-container2 .sender-name {
+    color: #172033 !important;
+    font-weight: 850 !important;
+    font-size: .86rem !important;
+  }
+  
+  .chat-container2 .timestamp {
+    color: #94a3b8 !important;
+    font-size: .72rem !important;
+  }
+  
+  .chat-container2 .message-bubble {
+    background: #ffffff !important;
+    color: #1e293b !important;
+    border: 1px solid #dbe3ee !important;
+    border-radius: 16px 16px 16px 6px !important;
+    padding: .72rem .95rem !important;
+    box-shadow: 0 4px 14px rgba(15, 23, 42, .06) !important;
+    line-height: 1.45 !important;
+  }
+  
+  .chat-container2 .message-bubble:hover {
+    border-color: #cbd5e1 !important;
+    box-shadow: 0 8px 22px rgba(15, 23, 42, .09) !important;
+  }
+  
+  /* Nachrichten rechts / Abteilung etwas anders einfärben */
+  .chat-container2 .message.student .message-bubble,
+  .chat-container2 .message.Abteilung .message-bubble,
+  .chat-container2 .message.abteilung .message-bubble {
+    background: #f8fafc;
+    border-color: #e2e8f0;
+    border-radius: 16px 16px 6px 16px !important;
+  }
+  
+  /* Fibu / Standard links */
+  .chat-container2 .message.teacher .message-bubble,
+  .chat-container2 .message.Fibu .message-bubble,
+  .chat-container2 .message.fibu .message-bubble {
+    background: #ffffff !important;
+  }
+  
+  /* Tabs schöner */
+  .chat-container2 .tabs {
+    background: #f8fafc !important;
+    border-bottom: 1px solid #e2e8f0 !important;
+  }
+  
+  .chat-container2 .tab {
+    color: #64748b !important;
+    font-weight: 850 !important;
+  }
+  
+  .chat-container2 .tab.active {
+    background: #64748b !important;
+    color: #ffffff !important;
+    box-shadow: 0 4px 10px rgba(15, 23, 42, .10) !important;
+  }
+  /* =========================================================
+   BUTTONS: KEIN ZEILENUMBRUCH
+========================================================= */
+
+.gc-action-btn,
+.gc-action-btn span,
+.gc-action-btn button,
+.btn {
+  white-space: nowrap !important;
+}
+/* =========================================================
+   RECHTE WORKFLOW-SPALTE:
+   KEINE ZEILENUMBRÜCHE
+========================================================= */
+
+.gc-action-panel button,
+.gc-action-panel .btn,
+.gc-action-panel .gc-badge,
+.gc-action-panel label,
+.gc-action-panel .form-label,
+.gc-action-panel .form-check-label,
+.gc-action-panel select,
+.gc-action-panel option {
+  white-space: nowrap !important;
+}
+
+/* Buttons breiter */
+.gc-action-panel .btn {
+  min-width: 150px;
+}
+
+/* Labels links etwas breiter */
+.gc-action-panel .row > div:first-child,
+.gc-action-panel .col-form-label {
+  min-width: 125px;
+}
+
+/* Eingabefelder sauber ausrichten */
+.gc-action-panel .form-control,
+.gc-action-panel .form-select {
+  min-width: 110px;
+}
+.gc-action-panel .btn:last-child {
+    min-width: auto !important;
+    width: auto !important;
+    padding: .45rem .8rem !important;
+    font-size: .92rem !important;
+  }
+
+  /* =========================================================
+   BETRAGS-BUTTON RECHTS – EDLER
+========================================================= */
+
+.gc-action-panel .btn-primary,
+.gc-action-panel .btn-success,
+.gc-action-panel .btn-purple {
+  background: linear-gradient(
+    180deg,
+    #64748b 0%,
+    #475569 100%
+  ) !important;
+
+  border: 1px solid #475569 !important;
+  color: #ffffff !important;
+
+  box-shadow:
+    0 4px 12px rgba(15,23,42,.12),
+    inset 0 1px 0 rgba(255,255,255,.12);
+
+  min-width: auto !important;
+  width: auto !important;
+
+  padding: .45rem .8rem !important;
+  font-size: .92rem !important;
+  border-radius: 10px !important;
+}
+
+/* Hover */
+.gc-action-panel .btn-primary:hover,
+.gc-action-panel .btn-success:hover,
+.gc-action-panel .btn-purple:hover {
+  background: linear-gradient(
+    180deg,
+    #718197 0%,
+    #526174 100%
+  ) !important;
+
+  transform: translateY(-1px);
+}
+
+/* =========================================================
+   DETAILSEITE: SEITE BEI AUFGEKLAPPTEN BOXEN SCROLLBAR
+========================================================= */
+
+body {
+    overflow-y: auto !important;
+    overflow-x: hidden !important;
+  }
+  
+  .gc-shell {
+    min-height: 100vh !important;
+    height: auto !important;
+  }
+  
+  .gc-page {
+    min-height: calc(100vh - var(--gc-topbar-height)) !important;
+    height: auto !important;
+    overflow: visible !important;
+  }
+  
+  .gc-detail-grid {
+    align-items: start !important;
+    overflow: visible !important;
+  }
+  
+  .gc-detail-main,
+  .gc-case-summary,
+  .gc-action-panel {
+    overflow: visible !important;
+  }
+  
+  /* Falls Accordion/Boxen innen abgeschnitten werden */
+  .accordion,
+  .accordion-item,
+  .accordion-collapse,
+  .accordion-body,
+  .collapse,
+  .show {
+    overflow: visible !important;
+  }
+  
+  /* Chat bleibt trotzdem intern scrollbar */
+  .gc-detail-main .chat-container2 {
+    overflow: hidden !important;
+  }
+  
+  .gc-detail-main .chat-content {
+    overflow-y: auto !important;
+  }
+
+/* =========================================================
+   DETAILSEITE – ALLE AUFKLAPPBAREN BOXEN WIE "MAHNUNGEN"
+   Inhalt bleibt in der Box, horizontaler Scroll erlaubt
+========================================================= */
+
+.gc-detail-main .accordion-item,
+.gc-detail-main .accordion-collapse,
+.gc-detail-main .accordion-body {
+  max-width: 100% !important;
+  overflow: hidden !important;
+}
+
+.gc-detail-main .accordion-body {
+  overflow-x: auto !important;
+  overflow-y: visible !important;
+  padding: 1rem !important;
+}
+
+/* Tabellen dürfen breiter sein, aber sprengen nicht mehr die Box */
+.gc-detail-main .accordion-body table {
+  width: max-content !important;
+  min-width: 100% !important;
+  max-width: none !important;
+  table-layout: auto !important;
+  white-space: nowrap;
+}
+
+/* Zellen kompakt */
+.gc-detail-main .accordion-body table th,
+.gc-detail-main .accordion-body table td {
+  padding: .55rem .65rem !important;
+  vertical-align: top !important;
+  white-space: nowrap;
+}
+
+/* Lange Beschreibungen dürfen umbrechen */
+.gc-detail-main .accordion-body table td:nth-child(4) {
+  white-space: normal !important;
+  min-width: 180px;
+  max-width: 260px;
+}
+
+/* Horizontaler Scroll optisch sauber */
+.gc-detail-main .accordion-body::-webkit-scrollbar {
+  height: 10px;
+}
+
+.gc-detail-main .accordion-body::-webkit-scrollbar-track {
+  background: #eef2f7;
+  border-radius: 999px;
+}
+
+.gc-detail-main .accordion-body::-webkit-scrollbar-thumb {
+  background: #cbd5e1;
+  border-radius: 999px;
+}
+
+.gc-detail-main .accordion-body::-webkit-scrollbar-thumb:hover {
+  background: #94a3b8;
+}

+ 20 - 19
templates/base/base.html

@@ -19,6 +19,7 @@
   <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"></script>
 
   <link href="/static/assets/css/main.css" rel="stylesheet">
+  <link href="/static/assets/css/redesign.css" rel="stylesheet">
 
   <!-- Favicon icon -->
   <link rel="icon" href="/static/assets/images/favicon.ico" type="image/x-icon">
@@ -58,23 +59,23 @@
           <i class="icon icon-lg cil-menu"></i>
         </button>
         <ul class="header-nav d-none d-lg-flex">
-          <li class="nav-item"><a class="nav-link" href="/select">Übersicht</a></li>
           <li class="nav-item"><a class="nav-link" href="/app/forderungen/liste">Liste</a></li>
           <li class="nav-item"><a class="nav-link" href="/app/forderungen/dashboard">Dashboard</a></li>
-          <li class="nav-item"><a class="nav-link" href="#">Einstellungen</a></li>
         </ul>
         <ul class="header-nav ms-auto">
-          <li class="nav-item"><a class="nav-link" href="#">
-              <i class="icon icon-lg cil-bell"></i></a></li>
-          <li class="nav-item"><a class="nav-link" href="#">
-              <i class="icon icon-lg cil-list-rich"></i></a></li>
-          <li class="nav-item"><a class="nav-link" href="#">
-              <i class="icon icon-lg cil-envelope-open"></i></a></li>
+          <li class="nav-item"><a class="nav-link" href="/select">
+              <i class="icon icon-lg cil-home"></i></a></li>
+          <li class="nav-item"><a class="nav-link" href="/config">
+              <i class="icon icon-lg cil-cog"></i></a></li>
+          <li class="nav-item"><a class="nav-link" href="/logout">
+              <i class="icon icon-lg cil-user"></i></a></li>
         </ul>
+        <!--
         <ul class="header-nav">
           <li class="nav-item py-1">
             <div class="vr h-100 mx-2 text-body text-opacity-75"></div>
           </li>
+
           <li class="nav-item dropdown">
             <button class="btn btn-link nav-link py-2 px-2 d-flex align-items-center" type="button"
               aria-expanded="false" data-coreui-toggle="dropdown">
@@ -133,16 +134,16 @@
             </div>
           </li>
         </ul>
+        -->
       </div>
     </header>
-    <main role="main" class="container-xxl">
+    <main role="main" class="gc-page">
       <div class="container-fluid px-6">
         <img src="/static/assets/images/Reisacher.png">
         <!--<strong>Forderungsmanagement</strong>-->
       </div>
-      <div class="container-fluid px-6">
-        {% block content %}{% endblock %}
-      </div>
+
+      {% block content %}{% endblock %}
     </main>
   </div>
 
@@ -154,14 +155,14 @@
           Datensätze: {{ summary.Anzahl }}
         </div>
         <div class="col-sm-6">
-          Filter: 
-           {% for f in filter_config  %}
-  
-           {% if f.filter_type != 'hidden' and f.current_value != f.default_value %}
-            {{ f.text }} = '{{ f.current_value }}' |
-           {% endif %} 
+          Filter:
+          {% for f in filter_config %}
+
+          {% if f.filter_type != 'hidden' and f.current_value != f.default_value %}
+          {{ f.text }} = '{{ f.current_value }}' |
+          {% endif %}
 
-           {% endfor %}
+          {% endfor %}
         </div>
         <div class="col-sm-3 text-end">
           Gesamt: <strong>{{ summary.offen|number_format }}</strong>

+ 47 - 5
templates/base/chat_container.html

@@ -44,7 +44,7 @@
         <div class="tab active">Diese Rechnung</div>
         <div class="tab">Alle offenen Rechnungen</div>
         <div class="tab">Dieses Fahrzeug</div>
-        <div class="tab">Gesamte Historie</div>
+        <div class="tab">Debitor Historie</div>
     </div>
     <div class="chat-content" id="chatContent">
         <div class="date-divider">
@@ -52,7 +52,7 @@
             <div class="divider-text">{{ forderung_kopf[0].Invoice_Date|date_format }}</div>
             <div class="divider-line"></div>
         </div>
-<!--
+        <!--
         <div class="message teacher">
             <div class="message-header">
                 <div class="avatar">DKI</div>
@@ -108,7 +108,7 @@
             </div>
         </div>
         {% endfor %}
-<!--
+        <!--
         <div class="message student">
             <div class="message-header">
                 <div class="avatar">CVE</div>
@@ -415,8 +415,50 @@
     </div>
     <div class="input-area">
         <div class="toolbar">
-            <button class="tool-button tooltip" data-tooltip="Attach File">
+            
+            <button class="btn btn-primary">
+                <i class="icon icon-lg cil-paperclip"></i>
             </button>
+            <input type="text" class="message-input" id="comment" name="comment" placeholder="Kommentieren ..." autofocus="autofocus"></textarea>
+            <button class="btn btn-primary">
+                <i class="icon icon-lg cil-chevron-right"></i>
+            </button>
+
+
+
+
+            <div class="chat-input" style="display: none;">
+                <div class="input-container">
+                    <div class="input-actions">
+                        <button class="btn input-btn" id="attachBtn" title="Attach file">
+                            <i class="icon icon-lg cil-paperclip"></i>
+                        </button>
+                    </div>
+                    <input type="text" id="messageInput" placeholder="Type a message, use # for tasks, @ for people...">
+                </div>
+                <button class="send-btn" id="sendBtn">
+                    <i class="icon icon-lg cil-chevron-right"></i>
+                </button>
+
+                <!-- File Upload Preview -->
+                <div class="file-preview" id="filePreview" style="display:none;">
+                    <div class="file-preview-header">
+                        <span>File to upload</span>
+                        <button class="file-preview-close" onclick="closeFilePreview()">
+                            <i class="fas fa-times"></i>
+                        </button>
+                    </div>
+                    <div class="file-preview-content">
+                        <div class="file-preview-icon">
+                            <i class="fas fa-file"></i>
+                        </div>
+                        <div class="file-preview-details">
+                            <div class="file-preview-name">document.pdf</div>
+                            <div class="file-preview-size">3.2 MB</div>
+                        </div>
+                    </div>
+                </div>
+            </div>
         </div>
     </div>
-</div>
+</div>

+ 58 - 11
templates/base/liste.html

@@ -1,17 +1,64 @@
 {% extends "base/base.html" %}
+
 {% block content %}
 
-{%include 'base/liste_filter.html' %}
-
-<div  id="liste-content">
-<table class="table table-striped table-bordered table-sm">
-  <thead>
-    {%include 'forderungen/liste/liste_kopfzeile.html' %}    
-  </thead>
-  <tbody>
-    {%include 'base/liste_tabelle.html' %}
-  </tbody>
-</table>
+
+
+{# =========================================================
+FILTERBEREICH
+---------------------------------------------------------
+Oberer Bereich mit:
+- Dropdowns
+- Suchfeldern
+- Datumsfiltern
+- Bereichsfiltern
+========================================================= #}
+<div class="gc-card gc-filterbar">
+
+  {% include 'base/liste_filter.html' %}
+
+</div>
+
+
+
+{# =========================================================
+TABELLEN-CONTAINER
+---------------------------------------------------------
+Enthält die komplette Forderungsliste.
+
+Klassen:
+- gc-card → Kartenlayout
+- gc-table-wrap → Scroll-/Tabellencontainer
+========================================================= #}
+<div class="gc-card gc-table-wrap">
+
+  <table class="table gc-table table-hover align-middle">
+
+    {# =====================================================
+    TABELLENKOPF
+    -----------------------------------------------------
+    Enthält:
+    - Spaltenüberschriften
+    - Sticky Header
+    ====================================================== #}
+    <thead>
+
+      {% include 'forderungen/liste/liste_kopfzeile.html' %}
+
+    </thead>
+
+
+    {# =====================================================
+    TABELLENINHALT
+    -----------------------------------------------------
+    Enthält alle Datensätze / Tabellenzeilen
+    ====================================================== #}
+    <tbody>
+
+      {% include 'base/liste_tabelle.html' %}
+
+    </tbody>
+  </table>
 </div>
 
 {% endblock %}

+ 1 - 1
templates/base/liste_filter.html

@@ -23,7 +23,7 @@
       {% endfor %}
     </select>
     {% elif f.filter_type == 'date' %}
-    <input type="date" class="form-control" id="{{ f.name }}" name="{{ f.name }}">
+    <input type="date" class="form-control" id="{{ f.name }}" name="{{ f.name }}" value="{{ f.current_value|date_format2 }}">
     {% else %}
     <input type="text" class="form-control" id="{{ f.name }}" name="{{ f.name }}" value="{{ f.current_value }}">
     {% endif %}

+ 3 - 3
templates/base/login.html

@@ -41,7 +41,7 @@
 			<div class="row align-items-center">
 				<div class="col-md-6">
 					<div class="card-body">
-						<h2 class="mb-4">Global Cube<br> <span class="text-c-blue">Datenverfeinerungsplattform</span></h2>
+						<h2 class="mb-4">Reisacher<br> <span class="text-c-blue">Forderungsmanagement 2.0</span></h2>
 						<p>Aus Freude am Zahlen.</p>
 						<div class="toggle-block">
 							<ol class="position-relative carousel-indicators justify-content-start">
@@ -68,8 +68,8 @@
 								</div>
 							</div> -->
 							<a href="/select"><button class="btn btn-primary mb-4">Anmelden</button></a>
-							<button class="btn btn-primary btn-outline-primary mb-4 toggle-btn">Registrieren</button>
-							<p class="mb-2 text-muted">Passwort vergessen? <a href="reset-password.html" class="f-w-400">Hier zur&uuml;cksetzen</a></p>
+							
+							
 						</div>
 						<div class="toggle-block collapse">
 							<ol class="position-relative carousel-indicators justify-content-start">

+ 82 - 34
templates/forderungen/dashboard/dashboard.html

@@ -1,14 +1,39 @@
 {% extends "base/base.html" %}
 {% block content %}
 
-<div>
+<br>
+<br>
+
+<h3>Forderungen</h3>
+
+<br>
+
+<ul class="nav nav-pills">
+  <li class="nav-item">
+    <a class="nav-link" aria-current="page" href="?page=Altersstaffel">nach Altersstaffel</a>
+  </li>
+  <li class="nav-item">
+    <a class="nav-link active" href="?page=Standort">nach Standort</a>
+  </li>
+  <li class="nav-item">
+    <a class="nav-link" href="?page=Verursacher">nach Verursacher</a>
+  </li>
+  <li class="nav-item">
+    <a class="nav-link disabled" aria-disabled="true">nach Wiedervorlage</a>
+  </li>
+</ul>
+
+<br>
+<br>
+
+<div class="col-8">
   <canvas id="myChart"></canvas>
 </div>
 
 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
 
 <script>
-  var CHART_COLORS = {
+  let CHART_COLORS = {
     red: 'rgb(255, 99, 132)',
     orange: 'rgb(255, 159, 64)',
     yellow: 'rgb(255, 205, 86)',
@@ -18,45 +43,52 @@
     grey: 'rgb(201, 203, 207)'
   };
 
-  var ctx = document.getElementById('myChart');
+  let data = {
+    labels: [
+      'MM',
+      'KRU',
+      'ULM',
+      'LL',
+      'GZ',
+      'AAM',
+    ],
+    datasets: [{
+      label: 'Verkauf',
+      data: [12, 19, 3, 5, 2, 3],
+      borderWidth: 1,
+      backgroundColor: CHART_COLORS.red
+    },
+    {
+      label: 'Service',
+      data: [12, 19, 3, 5, 2, 3],
+      borderWidth: 1,
+      backgroundColor: CHART_COLORS.blue
+    },
+    {
+      label: 'TZ',
+      data: [12, 19, 3, 5, 2, 3],
+      borderWidth: 1,
+      backgroundColor: CHART_COLORS.green
+    }, {
+      label: 'Sonstige',
+      data: [12, 19, 3, 5, 2, 3],
+      borderWidth: 1,
+      backgroundColor: CHART_COLORS.yellow
+    }]
+  };
 
-  new Chart(ctx, {
+  let myChart = document.getElementById('myChart')
+
+  let myBarChart = new Chart(myChart, {
     type: 'bar',
     data: {
-      labels: ['  < 2 Wochen',
-        ' 2 - 4 Wochen',
-        ' 4 - 6 Wochen',
-        ' 6 - 12 Wochen',
-        ' noch nicht fällig',
-        '> 12 Wochen'],
-      datasets: [{
-        label: 'Verkauf',
-        data: [12, 19, 3, 5, 2, 3],
-        borderWidth: 1,
-        backgroundColor: CHART_COLORS.red
-      },
-    {
-        label: 'Service',
-        data: [12, 19, 3, 5, 2, 3],
-        borderWidth: 1,
-        backgroundColor: CHART_COLORS.blue
-      },
-      {
-        label: 'TZ',
-        data: [12, 19, 3, 5, 2, 3],
-        borderWidth: 1,
-        backgroundColor: CHART_COLORS.green
-      },{
-        label: 'Sonstige',
-        data: [12, 19, 3, 5, 2, 3],
-        borderWidth: 1,
-        backgroundColor: CHART_COLORS.yellow
-      }]
+      labels: data.labels,
+      datasets: data.datasets
     },
     options: {
       plugins: {
         title: {
-          display: true,
+          display: false,
           text: 'Chart.js Bar Chart - Stacked'
         },
       },
@@ -71,6 +103,22 @@
       }
     }
   });
+
+  myChart.onclick = function (e) {
+    var slice = myBarChart.getElementsAtEventForMode(e, 'nearest', { intersect: true }, true);
+    if (!slice.length) return; // return if not clicked on slice
+    console.log(slice);
+    var label = myBarChart.data.labels[slice[0].index];
+    var ids = {
+      'MM': '10',
+      'KRU': '30',
+      'ULM': '40',
+      'LL': '50',
+      'GZ': '55',
+      'AAM': '60',
+    };
+    window.open('/app/forderungen/liste/?Standort=' + ids[label]);
+  }
 </script>
 
 {% endblock %}

+ 21 - 0
templates/forderungen/dashboard/queries/dashboard_betriebe.sql

@@ -0,0 +1,21 @@
+-- Nach Betrieben
+SELECT [Dim1_Sortierung]
+     , [Dim2_Sortierung]
+     , [Dim1]
+     , [Dim2]
+     , sum([Kennzahl1]) AS [Kennzahl1]
+     , sum([Kennzahl2]) AS [Kennzahl2]
+FROM (SELECT [Standort_ID] AS [Dim1_Sortierung]
+           , CASE
+                 WHEN [Bereich] = 'Verkauf' THEN 1
+                 WHEN [Bereich] = 'Service' THEN 2
+                 ELSE 9
+             END AS [Dim2_Sortierung]
+           , [Standort_Name] AS [Dim1]
+           , isnull([Bereich], 'Sonstige') AS [Dim2]
+           , [offen] AS [Kennzahl1]
+           , 1 AS [Kennzahl2]
+      FROM [Forderungen]
+      WHERE [Rechnung_Gutschrift] = 'Rechnung') [V1]
+GROUP BY [Dim1_Sortierung], [Dim2_Sortierung], [Dim1], [Dim2]
+ORDER BY 1, 2

+ 21 - 0
templates/forderungen/dashboard/queries/dashboard_staffel.sql

@@ -0,0 +1,21 @@
+-- Nach Altersstaffel
+SELECT [Dim1_Sortierung]
+     , [Dim2_Sortierung]
+     , [Dim1]
+     , [Dim2]
+     , sum([Kennzahl1]) AS [Kennzahl1]
+     , sum([Kennzahl2]) AS [Kennzahl2]
+FROM (SELECT [Staffel] AS [Dim1_Sortierung]
+           , CASE
+                 WHEN [Bereich] = 'Verkauf' THEN 1
+                 WHEN [Bereich] = 'Service' THEN 2
+                 ELSE 9
+             END AS [Dim2_Sortierung]
+           , [Staffel] AS [Dim1]
+           , isnull([Bereich], 'Sonstige') AS [Dim2]
+           , [offen] AS [Kennzahl1]
+           , 1 AS [Kennzahl2]
+      FROM [Forderungen]
+      WHERE [Rechnung_Gutschrift] = 'Rechnung') [V1]
+GROUP BY [Dim1_Sortierung], [Dim2_Sortierung], [Dim1], [Dim2]
+ORDER BY 1, 2

+ 18 - 0
templates/forderungen/dashboard/queries/dashboard_verursacher.sql

@@ -0,0 +1,18 @@
+-- Nach Verursacher
+SELECT [Dim1_Sortierung]
+     , [Dim2_Sortierung]
+     , [Dim1]
+     , [Dim2]
+     , sum([Kennzahl1]) AS [Kennzahl1]
+     , sum([Kennzahl2]) AS [Kennzahl2]
+FROM (SELECT [Standort_ID] AS [Dim1_Sortierung]
+           , [Verursacher] AS [Dim2_Sortierung]
+           , [Standort_Name] AS [Dim1]
+           , [Verursacher] AS [Dim2]
+           , [offen] AS [Kennzahl1]
+           , 1 AS [Kennzahl2]
+      FROM [Forderungen]
+      WHERE [Rechnung_Gutschrift] = 'Rechnung') [V1]
+GROUP BY [Dim1_Sortierung], [Dim2_Sortierung], [Dim1], [Dim2]
+HAVING sum([Kennzahl2]) >= 10
+ORDER BY 1, 2

+ 36 - 89
templates/forderungen/details/details.html

@@ -5,115 +5,62 @@
 <h2>{{forderung_kopf[0].Beleg }}</h2>
 <br>
 
-{%include 'forderungen/details/details_uebersicht.html' %}
+<div class="row g-3">
 
-{%include 'forderungen/details/details_kommentare.html' %}
+<div class="col-3">
+
+  {%include 'forderungen/details/details_uebersicht.html' %}
+
+</div>
 
-<br>
-<br>
 
-<div class="row g-3">
 <div class="col-5">
 
   {%include 'base/chat_container.html' %}
 
-</div>
-<div class="col-1">
-  &nbsp;
+  <br>
+  <br>
+
+  <h3>Dateien</h3>
+
+  {%include 'forderungen/details/details_dateien.html' %}
+
 </div>
 
-<div class="col-6">
+<div class="col-4">
 
 {%include 'forderungen/details/details_formular.html' %}
 
+<br>
+<br>
+
+<h3>Auftragspositionen</h3>
+
+{%include 'forderungen/details/details_positionen.html' %}
+
 </div>
 </div>
 <br>
 <br>
 
-<div class="accordion accordion-flush" id="accordionFlushExample">
-  <div class="accordion-item">
-    <h2 class="accordion-header">
-      <button class="accordion-button collapsed" type="button" data-coreui-toggle="collapse"
-        data-coreui-target="#flush-collapseOne" aria-expanded="false" aria-controls="flush-collapseOne">
-        <h3>Dateien</h3>
-      </button>
-    </h2>
-    <div id="flush-collapseOne" class="accordion-collapse collapse" data-coreui-parent="#accordionFlushExample">
-      <div class="accordion-body">
-
-        {%include 'forderungen/details/details_dateien.html' %}
-
-      </div>
-    </div>
-  </div>
-
-  <div class="accordion-item">
-    <h2 class="accordion-header">
-      <button class="accordion-button collapsed" type="button" data-coreui-toggle="collapse"
-        data-coreui-target="#flush-collapseTwo" aria-expanded="false" aria-controls="flush-collapseTwo">
-        <h3>Forderung Details</h3>
-      </button>
-    </h2>
-    <div id="flush-collapseTwo" class="accordion-collapse collapse" data-coreui-parent="#accordionFlushExample">
-      <div class="accordion-body">
-
-        {%include 'forderungen/details/details_rohdaten.html' %}
-
-      </div>
-    </div>
-  </div>
-  <div class="accordion-item">
-    <h2 class="accordion-header">
-      <button class="accordion-button collapsed" type="button" data-coreui-toggle="collapse"
-        data-coreui-target="#flush-collapseThree" aria-expanded="false" aria-controls="flush-collapseThree">
-        <h3>Mahnungen</h3>
-      </button>
-    </h2>
-    <div id="flush-collapseThree" class="accordion-collapse collapse" data-coreui-parent="#accordionFlushExample">
-      <div class="accordion-body">
-
-        {%include 'forderungen/details/details_mahnungen.html' %}
-
-      </div>
-    </div>
-  </div>
-
-
-  <div class="accordion-item">
-    <h2 class="accordion-header">
-      <button class="accordion-button collapsed" type="button" data-coreui-toggle="collapse"
-        data-coreui-target="#flush-collapseFour" aria-expanded="false" aria-controls="flush-collapseFour">
-        <h3>Auftragspositionen</h3>
-      </button>
-    </h2>
-    <div id="flush-collapseFour" class="accordion-collapse collapse" data-coreui-parent="#accordionFlushExample">
-      <div class="accordion-body">
-
-        {%include 'forderungen/details/details_positionen.html' %}
-        
-      </div>
-    </div>
-  </div>
-
-
-  <div class="accordion-item">
-    <h2 class="accordion-header">
-      <button class="accordion-button collapsed" type="button" data-coreui-toggle="collapse"
-        data-coreui-target="#flush-collapseFive" aria-expanded="false" aria-controls="flush-collapseFive">
-        <h3>Buchungsbelege</h3>
-      </button>
-    </h2>
-    <div id="flush-collapseFive" class="accordion-collapse collapse" data-coreui-parent="#accordionFlushExample">
-      <div class="accordion-body">
+{%include 'forderungen/details/details_kommentare.html' %}
 
-        {%include 'forderungen/details/details_buchungen.html' %}
+<br>
+<br>
 
-      </div>
-    </div>
-  </div>
 
-</div>
+<h3>Mahnungen</h3>
+
+{%include 'forderungen/details/details_mahnungen.html' %}
+
+<br>
+<br>
+
+
+<h3>Buchungsbelege</h3>
+
+        {%include 'forderungen/details/details_buchungen.html' %}
+
 
 
 {% endblock %}

+ 7 - 0
templates/forderungen/details/details_buchungen.html

@@ -1,3 +1,10 @@
+<div class="tabs">
+    <div class="tab">Diese Rechnung</div>
+    <div class="tab">Alle offenen Rechnungen</div>
+    <div class="tab">Dieses Fahrzeug</div>
+    <div class="tab active">Debitor Historie</div>
+</div>
+
 <table class="table table-striped table-bordered">
     <thead>
         <tr>

+ 4 - 0
templates/forderungen/details/details_formular.html

@@ -162,6 +162,10 @@
   <div class="collapse {{ forderung_kopf[0].Mahnen_aussetzen|show }}" id="MahnenCollapse">
     <div class="card card-body">
       <div class="row mb-3">
+        <p>
+          Dieser Beleg wird beim nächsten Mahnlauf nicht angemahnt.
+        </p>
+
         <label for="Mahnen_Begründung" class="col-sm-3 col-form-label">Begründung</label>
         <div class="col-sm-5">
 

+ 7 - 0
templates/forderungen/details/details_mahnungen.html

@@ -1,3 +1,10 @@
+<div class="tabs">
+    <div class="tab">Diese Rechnung</div>
+    <div class="tab">Alle offenen Rechnungen</div>
+    <div class="tab">Dieses Fahrzeug</div>
+    <div class="tab active">Debitor Historie</div>
+</div>
+
 <table class="table table-striped table-bordered">
     <thead>
         <tr>

+ 8 - 8
templates/forderungen/details/details_uebersicht.html

@@ -1,6 +1,6 @@
 
 <div class="row mb-3">
-  <div class="col-4">
+  <div>
     <table class="table table-sm">
       <tr>
         <th colspan="2">Grunddaten</th>
@@ -31,7 +31,7 @@
 
     </table>
   </div>
-  <div class="col-4">
+  <div>
     <table class="table table-sm">
       <tr>
         <th colspan="2">Beleg</th>
@@ -65,7 +65,7 @@
 
     </table>
   </div>
-  <div class="col-4">
+  <div>
     <table class="table table-sm">
       <tr>
         <th colspan="2">Forderung</th>
@@ -98,7 +98,7 @@
   </div>
 
 
-  <div class="col-4">
+  <div>
     <table class="table table-sm">
       <tr>
         <th colspan="2">Kunde</th>
@@ -152,7 +152,7 @@
     </table>
   </div>
 {% if forderung_kopf[0].Fahrzeug_Leasing == 'J' %}
-<div class="col-4">
+<div>
     <table class="table table-sm">
       <tr>
         <th colspan="2">Leasing-Kunde</th>
@@ -198,7 +198,7 @@
 
 
 {% if forderung_kopf[0].Versicherung == 'J' and forderung_kopf[0].Vers_Adresse_ID != '' %}
-  <div class="col-4">
+  <div>
     <table class="table table-sm">
       <tr>
         <th colspan="2">Versicherung</th>
@@ -242,7 +242,7 @@
 {% endif %}
 
 
-  <div class="col-4">
+  <div>
     <table class="table table-sm">
       <tr>
         <th colspan="2">Fahrzeug</th>
@@ -303,7 +303,7 @@
       </tr>
 
       <tr>
-        <td><strong>Fahrzeug_Vorbesitzer_Anzahl:</strong></td>
+        <td><strong>Anzahl Vorbesitzer:</strong></td>
         <td>{{forderung_kopf[0].Fahrzeug_Vorbesitzer_Anzahl }}</td>
       </tr>
 

+ 17 - 0
templates/forderungen/liste/config/liste_filter.csv

@@ -0,0 +1,17 @@
+Name;Filter_Typ;Breite;Beschriftung;Tabellenfeld;Anzeigewert;Sortierung;Standardwert
+Hauptbetrieb;select;2;;Client_DB;Hauptbetrieb_Name;;
+Standort;select;3;;Standort_ID;Standort_Name;;
+Bereich;select;3;;;;;
+Verursacher;select;4;;;;;
+Rechnungsnummer;text;2;;Document_No;;;
+RechnungsdatumVon;date;2;Rechnungsdatum von;Invoice_Date;;;
+RechnungsdatumBis;date;2;Rechnungsdatum bis;Invoice_Date;;;
+Kunde;text;4;;;;;
+Abwarten;select;2;;;;;
+WiedervorlageVon;date;2;Wiedervorlage von;Wiedervorlage;;;
+WiedervorlageBis;date;2;Wiedervorlage bis;Wiedervorlage;;;
+Fahrzeug;select;2;;VIN;;;
+Staffel;select;2;;;;;
+Mahnstufe;select;2;;;;;
+Bearbeitet;select;2;;;;;
+BenutzerSelect;hidden;2;Benutzer;;;;winter

+ 83 - 47
templates/forderungen/liste/liste_zeile.html

@@ -1,48 +1,84 @@
+<tr>
+  <td>
+    <a href="/app/forderungen/details/{{ row.Client_DB }}_{{ row.Document_No }}"
+      class="btn btn-sm btn-primary gc-action-btn">
+      <i class="icon cil-pencil"></i>
+    </a>
+  </td>
 
-    <tr>
-      <td>
-        <a href="/app/forderungen/details/{{ row.Client_DB }}_{{ row.Document_No }}" class="btn btn-sm btn-primary"><i class="icon cil-pencil"></i></a>
-      </td>
-      <td>
-        {{ row.Standort_Name }}<br>
-        {{ row.Bereich }}
-      </td>
-      <td>
-        <a href="/app/forderungen/liste?Kunde={{ row.Kunde|urlencode }}">{{ row.Kunde }}</a>
-        {% if row.Kunde_Email %}
-        <a href="mailto:{{ row.Kunde_Email }}?subject={{ row.Document_No }}" class="btn btn-sm btn-outline-secondary" hx-disable="true">
-            <i class="cil-at"></i>
-        </a>
-        {% endif %}
-      </td>
-      <td>
-        <a href="/app/forderungen/liste?Verursacher={{ row.Verursacher|urlencode }}">{{ row.Verursacher }}</a>
-        {% if row.Verursacher == 'N.N.' %}
-        <a class="btn btn-sm btn-outline-secondary">
-            <i class="cil-user-follow"></i>
-        </a>
-        {% endif %}
-      </td>
-      <td>{{ row.Document_No }}</td>
-      <td>
-        {{ row.Invoice_Date|date_format }}<br>
-        {{ row.Fällig_Datum|date_format }}
-      </td>
-      <td class="text-end">{{ row.offen|number_format }}</td>
-      <td class="text-end">
-        {{ row.offen|number_format }}<br>
-        {{ row.offen_Kunde_gesamt|number_format }}
-      </td>
-      <td class="text-end">
-        {{ row.Mahnstufe }}<br>
-        {{ row.Staffel }}
-      </td>
-      <td class="text-end">{{ row.Abwarten }}</td>
-      <td>
-        {{ row.Kommentar_Fibu or '' }}
-      </td>
-      <td>
-        {{ row.Kommentar_Abteilung or '' }}
-      </td>
-      <td class="text-end">{{ row.Wiedervorlage|date_format }}</td>
-    </tr>
+  <td>
+    <div class="gc-title">{{ row.Standort_Name }}</div>
+    <div class="gc-subtitle">{{ row.Bereich }}</div>
+  </td>
+
+  <td class="gc-main-cell">
+    <a class="gc-title text-decoration-none" href="/app/forderungen/liste?Kunde={{ row.Kunde|urlencode }}">
+      {{ row.Kunde }}
+    </a>
+    <div class="gc-subtitle">
+      {{ row.Kunde2 or '' }}
+      {% if row.Kunde_Email %}
+      · <a href="mailto:{{ row.Kunde_Email }}?subject={{ row.Document_No }}" hx-disable="true">E-Mail</a>
+      {% endif %}
+    </div>
+  </td>
+
+  <td>
+    <a href="/app/forderungen/liste?Verursacher={{ row.Verursacher|urlencode }}"
+      class="text-decoration-none fw-semibold">
+      {{ row.Verursacher }}
+    </a>
+    {% if row.Verursacher == 'N.N.' %}
+    <span class="gc-badge gc-badge-warning ms-1">offen</span>
+    {% endif %}
+  </td>
+
+  <td>
+    <span class="gc-badge gc-badge-info">{{ row.Document_No }}</span>
+  </td>
+
+  <td>
+    <div>{{ row.Invoice_Date|date_format }}</div>
+    <div class="gc-subtitle">fällig {{ row.Fällig_Datum|date_format }}</div>
+  </td>
+
+  <td class="text-end align-top">
+    <div class="gc-money">{{ row.Beleg_Betrag|number_format }} €</div>
+  </td>
+
+  <td class="text-end">
+    <div class="gc-money">{{ row.offen|number_format }} €</div>
+    <div class="gc-subtitle">{{ row.Kunde_gesamt_Bezug }} {{ row.offen_Kunde_gesamt|number_format }} €</div>
+  </td>
+
+  <td class="text-end">
+    {% set mahnstufe = row.Mahnstufe or row.Stufe %}
+
+    {% if mahnstufe and mahnstufe|string != '0' %}
+    <span class="gc-badge gc-badge-danger gc-mahnstufe-{{ mahnstufe }}">
+      M{{ mahnstufe }}
+    </span>
+    {% endif %}
+    <div class="gc-subtitle">{{ row.Tage }} Tage</div>
+  </td>
+
+  <td class="text-end">
+    {% if row.Abwarten %}
+    <span class="gc-badge gc-badge-warning">{{ row.Abwarten }}</span>
+    {% endif %}
+  </td>
+
+  <td>
+    <div class="gc-subtitle">{{ row.Kommentar_Fibu or '' }}</div>
+  </td>
+
+  <td>
+    <div class="gc-subtitle">{{ row.Kommentar_Abteilung or '' }}</div>
+  </td>
+
+  <td class="text-end">
+    {% if row.Wiedervorlage %}
+    <span class="gc-badge gc-badge-success">{{ row.Wiedervorlage|date_format }}</span>
+    {% endif %}
+  </td>
+</tr>

+ 4 - 17
templates/forderungen/liste/queries/forderungen_liste.sql

@@ -29,6 +29,10 @@ SELECT [F].[Client_DB]
      , [F].[Forderungsart]
      --, [F].[Abwarten]
      , [F].[Verursacher_Benutzer_ID]
+     , F.Kunde2
+     , F.Beleg_Betrag
+     , F.Kunde_gesamt_Bezug
+
      , left(isnull([FK].[Kommentar_Fibu], ''), 50) AS [Kommentar_Fibu]
      , left(isnull([FK].[Kommentar_Abteilung], ''), 50) AS [Kommentar_Abteilung]
      , [K].[Kunde_Email] AS [Kunde_Email]
@@ -56,21 +60,4 @@ AND [F].[Standort_ID] = [BR].[Standort_ID]
 AND ([BR].[Rolle] = 'Buchhaltung' OR [BR].[Benutzer_ID] = [F].[Verursacher_Benutzer_ID])
 
 WHERE 1 = 1
-AND F.[Invoice_Date] >= {{ RechnungsdatumVon.selectedDate }}
-AND F.[Invoice_Date] <= {{ RechnungsdatumBis.selectedDate }}
-AND F.[Client_DB] LIKE '%' + {{ Hauptbetrieb.selectedOptionValue }}
-AND F.[Standort_ID] LIKE '%' + {{ Standort.selectedOptionValue }}
-AND F.[Bereich] LIKE '%' + {{ Bereich.selectedOptionValue }}
-AND F.[Document_No] LIKE '%' + {{ Rechnungsnummer.text }} + '%'
-AND F.[Kunde] LIKE '%' + {{ Kunde.text }} + '%'
-AND F.[Verursacher] LIKE '%' + {{ Verursacher.selectedOptionValue }}
-AND F.[VIN] LIKE '%' + {{ Fahrzeug.selectedOptionValue }}
-AND F.[Staffel] LIKE '%' + {{ Staffel.selectedOptionValue }}
-AND F.[Mahnstufe] LIKE '%' + {{ Mahnstufe.selectedOptionValue }}
-AND F.[Bearbeitet] LIKE '%' + {{ Bearbeitet.selectedOptionValue }}
-AND F.[Abwarten] LIKE '%' + {{ Abwarten.selectedOptionValue }}
-AND (
-	F.Wiedervorlage >= {{ WiedervorlageVon.selectedDate }}
-	AND F.Wiedervorlage <= {{ WiedervorlageBis.selectedDate }}
-)
 ORDER BY F.[Wiedervorlage] ASC, F.[Tage] DESC

+ 125 - 0
uv.lock

@@ -72,6 +72,63 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
 ]
 
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
+    { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
+    { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
+    { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
+    { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
+    { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
+    { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
+    { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
+    { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
+    { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
+    { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
+    { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
+    { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
+    { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
+    { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
+    { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
+    { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
+    { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
+    { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
+    { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
+]
+
 [[package]]
 name = "click"
 version = "8.3.1"
@@ -116,6 +173,18 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/a4/ad/e07939d8e020e513d3860400413ba1e0e06102c469639b440d921337efef/docxtpl-0.20.2-py3-none-any.whl", hash = "sha256:626d5c570a46a62b2ca73b4d08f1c240fa031a5bc45371e1466a4fe184923d10", size = 17881, upload-time = "2025-11-13T12:47:13.704Z" },
 ]
 
+[[package]]
+name = "ecdsa"
+version = "0.19.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" },
+]
+
 [[package]]
 name = "email-validator"
 version = "2.3.0"
@@ -646,6 +715,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/68/b0/34937815889fa982613775e4b97fddd13250f11012d769949c5465af2150/pandas-3.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:108dd1790337a494aa80e38def654ca3f0968cf4f362c85f44c15e471667102d", size = 9452085, upload-time = "2026-02-17T22:20:14.331Z" },
 ]
 
+[[package]]
+name = "passlib"
+version = "1.7.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
+]
+
 [[package]]
 name = "pyasn1"
 version = "0.6.3"
@@ -833,6 +911,20 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
 ]
 
+[[package]]
+name = "python-jose"
+version = "3.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "ecdsa" },
+    { name = "pyasn1" },
+    { name = "rsa" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
+]
+
 [[package]]
 name = "python-multipart"
 version = "0.0.22"
@@ -892,10 +984,13 @@ dependencies = [
     { name = "ldap3" },
     { name = "openpyxl" },
     { name = "pandas" },
+    { name = "passlib" },
     { name = "pyodbc" },
     { name = "python-docx" },
     { name = "python-dotenv" },
+    { name = "python-jose" },
     { name = "python-multipart" },
+    { name = "requests" },
     { name = "sqlalchemy" },
     { name = "uvicorn", extra = ["standard"] },
 ]
@@ -911,14 +1006,32 @@ requires-dist = [
     { name = "ldap3", specifier = ">=2.9" },
     { name = "openpyxl", specifier = ">=3.1" },
     { name = "pandas", specifier = ">=2.0" },
+    { name = "passlib", specifier = ">=1.7.4" },
     { name = "pyodbc", specifier = ">=4.0" },
     { name = "python-docx", specifier = ">=0.8" },
     { name = "python-dotenv", specifier = ">=1.2.1" },
+    { name = "python-jose", specifier = ">=3.5.0" },
     { name = "python-multipart", specifier = ">=0.0.6" },
+    { name = "requests", specifier = ">=2.34.2" },
     { name = "sqlalchemy", specifier = ">=2.0" },
     { name = "uvicorn", extras = ["standard"], specifier = ">=0.22" },
 ]
 
+[[package]]
+name = "requests"
+version = "2.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "certifi" },
+    { name = "charset-normalizer" },
+    { name = "idna" },
+    { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
+]
+
 [[package]]
 name = "rich"
 version = "14.3.3"
@@ -999,6 +1112,18 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" },
 ]
 
+[[package]]
+name = "rsa"
+version = "4.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pyasn1" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
+]
+
 [[package]]
 name = "sentry-sdk"
 version = "2.56.0"

+ 5 - 0
webservice/install_service.bat

@@ -0,0 +1,5 @@
+@echo off
+cd /d %~dp0
+nssm.exe install "GlobalCube EPIC Webservice" "%~dp0webservice.bat"
+net start "GlobalCube EPIC Webservice"
+pause

BIN
webservice/nssm.exe


+ 3 - 0
webservice/start_service.bat

@@ -0,0 +1,3 @@
+@echo off
+net start "GlobalCube EPIC Webservice"
+pause

+ 3 - 0
webservice/stop_service.bat

@@ -0,0 +1,3 @@
+@echo off
+net stop "GlobalCube EPIC Webservice"
+pause

+ 4 - 0
webservice/uninstall_service.bat

@@ -0,0 +1,4 @@
+@echo off
+cd /d %~dp0
+nssm.exe remove "GlobalCube EPIC Webservice"
+pause

+ 5 - 0
webservice/webservice.bat

@@ -0,0 +1,5 @@
+cd %~dp0..
+call .venv\Scripts\activate.bat
+
+uvicorn app.main:app --reload --host 0.0.0.0 --port 8093
+rem pause