8 Commits 535c49e3f1 ... b3a46dcaaf

Autor SHA1 Mensagem Data
  gc-server3 b3a46dcaaf Diverse UI Anpassungen 4 semanas atrás
  gc-server3 aed738d76e Webservice über nssm 4 semanas atrás
  gc-server3 ada592407d Filter in csv ausgelagert 4 semanas atrás
  gc-server3 4974dc0a3b Docuware Versuch 4 semanas atrás
  gc-server3 878e4b1e93 Redesign von Tina übernommen 1 mês atrás
  gc-server3 4f56484eb8 Templates ausgelagert 2 meses atrás
  gc-server3 fda10c18f3 Dashboard-Auswahl 2 meses atrás
  gc-server3 54b02bb3d1 Auth - Erster Entwurf 2 meses atrás
42 arquivos alterados com 2656 adições e 373 exclusões
  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
 ## 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
 from db import get_session
 import re
 import re
@@ -14,26 +14,31 @@ def main():
             comment_date = row.Timestamp + timedelta(minutes=i)
             comment_date = row.Timestamp + timedelta(minutes=i)
             comment_user = row.Benutzer_ID
             comment_user = row.Benutzer_ID
             r = r.replace("'", "")
             r = r.replace("'", "")
+            d, u = get_date_and_user_from_comment(r)
             db.execute(
             db.execute(
                 text(
                 text(
                     "INSERT INTO dbo.Forderungen_Kommentar "
                     "INSERT INTO dbo.Forderungen_Kommentar "
                     f"VALUES ('{row.Client_DB}', '{row.Beleg_Nr}', '{comment_date}', '{comment_user}', '{r}')"
                     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()
     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__":
 if __name__ == "__main__":
     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 io
 import re
 import re
-from dataclasses import dataclass
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
 from pathlib import Path
 from pathlib import Path
 from urllib.parse import unquote, urlencode
 from urllib.parse import unquote, urlencode
@@ -15,17 +14,18 @@ from fastapi.responses import (
     RedirectResponse,
     RedirectResponse,
     StreamingResponse,
     StreamingResponse,
 )
 )
-from fastapi.templating import Jinja2Templates
 from sqlalchemy import text
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 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 .db import get_session
+from .jinja_templates import templates
+from .ldap import ldap_authenticate
 from .models import Bemerkung, Forderung
 from .models import Bemerkung, Forderung
 from .schemas import BemerkungIn
 from .schemas import BemerkungIn
 
 
 router = APIRouter()
 router = APIRouter()
-templates = Jinja2Templates(directory="templates")
 
 
 
 
 @router.get("/", response_class=HTMLResponse)
 @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})
     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):
 def single_quote(text: str):
     return "'" + unquote(text) + "'"
     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)
 @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(
     query = templates.TemplateResponse(request, "forderungen/liste/queries/forderungen_liste.sql", context).body.decode(
         "utf-8"
         "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("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 = {}
     filters = {}
     for f in forderung_filter_config:
     for f in forderung_filter_config:
-        if f.key_column == "":
+        if f.key_column not in col_names:
             continue
             continue
         filters[f.name] = {row[col_names.index(f.key_column)]: row[col_names.index(f.value_column)] for row in q}
         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]
         f.options = filters[f.name]

+ 21 - 0
app/schemas.py

@@ -24,3 +24,24 @@ class ZahlungOut(BaseModel):
 class BemerkungIn(BaseModel):
 class BemerkungIn(BaseModel):
     bemerkung: Optional[str]
     bemerkung: Optional[str]
     wiedervorlage_datum: Optional[date]
     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]
 [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"
 readme = "README.md"
 requires-python = ">=3.13"
 requires-python = ">=3.13"
 dependencies = [
 dependencies = [
@@ -20,4 +20,7 @@ dependencies = [
 	"python-dotenv>=1.2.1",
 	"python-dotenv>=1.2.1",
 	"docxtpl>=0.20.2",
 	"docxtpl>=0.20.2",
 	"imap-tools>=1.12.1",
 	"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 {
 .chat-container {
-    width: 700px;
+    /* width: 700px; */
     height: 900px;
     height: 900px;
     display: flex;
     display: flex;
     flex-direction: column;
     flex-direction: column;
@@ -816,4 +816,73 @@ a {
   position: sticky;
   position: sticky;
   top: 40px;
   top: 40px;
   background: white;
   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>
   <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/main.css" rel="stylesheet">
+  <link href="/static/assets/css/redesign.css" rel="stylesheet">
 
 
   <!-- Favicon icon -->
   <!-- Favicon icon -->
   <link rel="icon" href="/static/assets/images/favicon.ico" type="image/x-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>
           <i class="icon icon-lg cil-menu"></i>
         </button>
         </button>
         <ul class="header-nav d-none d-lg-flex">
         <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/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="/app/forderungen/dashboard">Dashboard</a></li>
-          <li class="nav-item"><a class="nav-link" href="#">Einstellungen</a></li>
         </ul>
         </ul>
         <ul class="header-nav ms-auto">
         <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>
+        <!--
         <ul class="header-nav">
         <ul class="header-nav">
           <li class="nav-item py-1">
           <li class="nav-item py-1">
             <div class="vr h-100 mx-2 text-body text-opacity-75"></div>
             <div class="vr h-100 mx-2 text-body text-opacity-75"></div>
           </li>
           </li>
+
           <li class="nav-item dropdown">
           <li class="nav-item dropdown">
             <button class="btn btn-link nav-link py-2 px-2 d-flex align-items-center" type="button"
             <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">
               aria-expanded="false" data-coreui-toggle="dropdown">
@@ -133,16 +134,16 @@
             </div>
             </div>
           </li>
           </li>
         </ul>
         </ul>
+        -->
       </div>
       </div>
     </header>
     </header>
-    <main role="main" class="container-xxl">
+    <main role="main" class="gc-page">
       <div class="container-fluid px-6">
       <div class="container-fluid px-6">
         <img src="/static/assets/images/Reisacher.png">
         <img src="/static/assets/images/Reisacher.png">
         <!--<strong>Forderungsmanagement</strong>-->
         <!--<strong>Forderungsmanagement</strong>-->
       </div>
       </div>
-      <div class="container-fluid px-6">
-        {% block content %}{% endblock %}
-      </div>
+
+      {% block content %}{% endblock %}
     </main>
     </main>
   </div>
   </div>
 
 
@@ -154,14 +155,14 @@
           Datensätze: {{ summary.Anzahl }}
           Datensätze: {{ summary.Anzahl }}
         </div>
         </div>
         <div class="col-sm-6">
         <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>
         <div class="col-sm-3 text-end">
         <div class="col-sm-3 text-end">
           Gesamt: <strong>{{ summary.offen|number_format }}</strong>
           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 active">Diese Rechnung</div>
         <div class="tab">Alle offenen Rechnungen</div>
         <div class="tab">Alle offenen Rechnungen</div>
         <div class="tab">Dieses Fahrzeug</div>
         <div class="tab">Dieses Fahrzeug</div>
-        <div class="tab">Gesamte Historie</div>
+        <div class="tab">Debitor Historie</div>
     </div>
     </div>
     <div class="chat-content" id="chatContent">
     <div class="chat-content" id="chatContent">
         <div class="date-divider">
         <div class="date-divider">
@@ -52,7 +52,7 @@
             <div class="divider-text">{{ forderung_kopf[0].Invoice_Date|date_format }}</div>
             <div class="divider-text">{{ forderung_kopf[0].Invoice_Date|date_format }}</div>
             <div class="divider-line"></div>
             <div class="divider-line"></div>
         </div>
         </div>
-<!--
+        <!--
         <div class="message teacher">
         <div class="message teacher">
             <div class="message-header">
             <div class="message-header">
                 <div class="avatar">DKI</div>
                 <div class="avatar">DKI</div>
@@ -108,7 +108,7 @@
             </div>
             </div>
         </div>
         </div>
         {% endfor %}
         {% endfor %}
-<!--
+        <!--
         <div class="message student">
         <div class="message student">
             <div class="message-header">
             <div class="message-header">
                 <div class="avatar">CVE</div>
                 <div class="avatar">CVE</div>
@@ -415,8 +415,50 @@
     </div>
     </div>
     <div class="input-area">
     <div class="input-area">
         <div class="toolbar">
         <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>
             </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>
-</div>
+</div>

+ 58 - 11
templates/base/liste.html

@@ -1,17 +1,64 @@
 {% extends "base/base.html" %}
 {% extends "base/base.html" %}
+
 {% block content %}
 {% 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>
 </div>
 
 
 {% endblock %}
 {% endblock %}

+ 1 - 1
templates/base/liste_filter.html

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

+ 3 - 3
templates/base/login.html

@@ -41,7 +41,7 @@
 			<div class="row align-items-center">
 			<div class="row align-items-center">
 				<div class="col-md-6">
 				<div class="col-md-6">
 					<div class="card-body">
 					<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>
 						<p>Aus Freude am Zahlen.</p>
 						<div class="toggle-block">
 						<div class="toggle-block">
 							<ol class="position-relative carousel-indicators justify-content-start">
 							<ol class="position-relative carousel-indicators justify-content-start">
@@ -68,8 +68,8 @@
 								</div>
 								</div>
 							</div> -->
 							</div> -->
 							<a href="/select"><button class="btn btn-primary mb-4">Anmelden</button></a>
 							<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>
 						<div class="toggle-block collapse">
 						<div class="toggle-block collapse">
 							<ol class="position-relative carousel-indicators justify-content-start">
 							<ol class="position-relative carousel-indicators justify-content-start">

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

@@ -1,14 +1,39 @@
 {% extends "base/base.html" %}
 {% extends "base/base.html" %}
 {% block content %}
 {% 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>
   <canvas id="myChart"></canvas>
 </div>
 </div>
 
 
 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
 
 
 <script>
 <script>
-  var CHART_COLORS = {
+  let CHART_COLORS = {
     red: 'rgb(255, 99, 132)',
     red: 'rgb(255, 99, 132)',
     orange: 'rgb(255, 159, 64)',
     orange: 'rgb(255, 159, 64)',
     yellow: 'rgb(255, 205, 86)',
     yellow: 'rgb(255, 205, 86)',
@@ -18,45 +43,52 @@
     grey: 'rgb(201, 203, 207)'
     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',
     type: 'bar',
     data: {
     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: {
     options: {
       plugins: {
       plugins: {
         title: {
         title: {
-          display: true,
+          display: false,
           text: 'Chart.js Bar Chart - Stacked'
           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>
 </script>
 
 
 {% endblock %}
 {% 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>
 <h2>{{forderung_kopf[0].Beleg }}</h2>
 <br>
 <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">
 <div class="col-5">
 
 
   {%include 'base/chat_container.html' %}
   {%include 'base/chat_container.html' %}
 
 
-</div>
-<div class="col-1">
-  &nbsp;
+  <br>
+  <br>
+
+  <h3>Dateien</h3>
+
+  {%include 'forderungen/details/details_dateien.html' %}
+
 </div>
 </div>
 
 
-<div class="col-6">
+<div class="col-4">
 
 
 {%include 'forderungen/details/details_formular.html' %}
 {%include 'forderungen/details/details_formular.html' %}
 
 
+<br>
+<br>
+
+<h3>Auftragspositionen</h3>
+
+{%include 'forderungen/details/details_positionen.html' %}
+
 </div>
 </div>
 </div>
 </div>
 <br>
 <br>
 <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 %}
 {% 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">
 <table class="table table-striped table-bordered">
     <thead>
     <thead>
         <tr>
         <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="collapse {{ forderung_kopf[0].Mahnen_aussetzen|show }}" id="MahnenCollapse">
     <div class="card card-body">
     <div class="card card-body">
       <div class="row mb-3">
       <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>
         <label for="Mahnen_Begründung" class="col-sm-3 col-form-label">Begründung</label>
         <div class="col-sm-5">
         <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">
 <table class="table table-striped table-bordered">
     <thead>
     <thead>
         <tr>
         <tr>

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

@@ -1,6 +1,6 @@
 
 
 <div class="row mb-3">
 <div class="row mb-3">
-  <div class="col-4">
+  <div>
     <table class="table table-sm">
     <table class="table table-sm">
       <tr>
       <tr>
         <th colspan="2">Grunddaten</th>
         <th colspan="2">Grunddaten</th>
@@ -31,7 +31,7 @@
 
 
     </table>
     </table>
   </div>
   </div>
-  <div class="col-4">
+  <div>
     <table class="table table-sm">
     <table class="table table-sm">
       <tr>
       <tr>
         <th colspan="2">Beleg</th>
         <th colspan="2">Beleg</th>
@@ -65,7 +65,7 @@
 
 
     </table>
     </table>
   </div>
   </div>
-  <div class="col-4">
+  <div>
     <table class="table table-sm">
     <table class="table table-sm">
       <tr>
       <tr>
         <th colspan="2">Forderung</th>
         <th colspan="2">Forderung</th>
@@ -98,7 +98,7 @@
   </div>
   </div>
 
 
 
 
-  <div class="col-4">
+  <div>
     <table class="table table-sm">
     <table class="table table-sm">
       <tr>
       <tr>
         <th colspan="2">Kunde</th>
         <th colspan="2">Kunde</th>
@@ -152,7 +152,7 @@
     </table>
     </table>
   </div>
   </div>
 {% if forderung_kopf[0].Fahrzeug_Leasing == 'J' %}
 {% if forderung_kopf[0].Fahrzeug_Leasing == 'J' %}
-<div class="col-4">
+<div>
     <table class="table table-sm">
     <table class="table table-sm">
       <tr>
       <tr>
         <th colspan="2">Leasing-Kunde</th>
         <th colspan="2">Leasing-Kunde</th>
@@ -198,7 +198,7 @@
 
 
 
 
 {% if forderung_kopf[0].Versicherung == 'J' and forderung_kopf[0].Vers_Adresse_ID != '' %}
 {% if forderung_kopf[0].Versicherung == 'J' and forderung_kopf[0].Vers_Adresse_ID != '' %}
-  <div class="col-4">
+  <div>
     <table class="table table-sm">
     <table class="table table-sm">
       <tr>
       <tr>
         <th colspan="2">Versicherung</th>
         <th colspan="2">Versicherung</th>
@@ -242,7 +242,7 @@
 {% endif %}
 {% endif %}
 
 
 
 
-  <div class="col-4">
+  <div>
     <table class="table table-sm">
     <table class="table table-sm">
       <tr>
       <tr>
         <th colspan="2">Fahrzeug</th>
         <th colspan="2">Fahrzeug</th>
@@ -303,7 +303,7 @@
       </tr>
       </tr>
 
 
       <tr>
       <tr>
-        <td><strong>Fahrzeug_Vorbesitzer_Anzahl:</strong></td>
+        <td><strong>Anzahl Vorbesitzer:</strong></td>
         <td>{{forderung_kopf[0].Fahrzeug_Vorbesitzer_Anzahl }}</td>
         <td>{{forderung_kopf[0].Fahrzeug_Vorbesitzer_Anzahl }}</td>
       </tr>
       </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].[Forderungsart]
      --, [F].[Abwarten]
      --, [F].[Abwarten]
      , [F].[Verursacher_Benutzer_ID]
      , [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_Fibu], ''), 50) AS [Kommentar_Fibu]
      , left(isnull([FK].[Kommentar_Abteilung], ''), 50) AS [Kommentar_Abteilung]
      , left(isnull([FK].[Kommentar_Abteilung], ''), 50) AS [Kommentar_Abteilung]
      , [K].[Kunde_Email] AS [Kunde_Email]
      , [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])
 AND ([BR].[Rolle] = 'Buchhaltung' OR [BR].[Benutzer_ID] = [F].[Verursacher_Benutzer_ID])
 
 
 WHERE 1 = 1
 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
 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" },
     { 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]]
 [[package]]
 name = "click"
 name = "click"
 version = "8.3.1"
 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" },
     { 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]]
 [[package]]
 name = "email-validator"
 name = "email-validator"
 version = "2.3.0"
 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" },
     { 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]]
 [[package]]
 name = "pyasn1"
 name = "pyasn1"
 version = "0.6.3"
 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" },
     { 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]]
 [[package]]
 name = "python-multipart"
 name = "python-multipart"
 version = "0.0.22"
 version = "0.0.22"
@@ -892,10 +984,13 @@ dependencies = [
     { name = "ldap3" },
     { name = "ldap3" },
     { name = "openpyxl" },
     { name = "openpyxl" },
     { name = "pandas" },
     { name = "pandas" },
+    { name = "passlib" },
     { name = "pyodbc" },
     { name = "pyodbc" },
     { name = "python-docx" },
     { name = "python-docx" },
     { name = "python-dotenv" },
     { name = "python-dotenv" },
+    { name = "python-jose" },
     { name = "python-multipart" },
     { name = "python-multipart" },
+    { name = "requests" },
     { name = "sqlalchemy" },
     { name = "sqlalchemy" },
     { name = "uvicorn", extra = ["standard"] },
     { name = "uvicorn", extra = ["standard"] },
 ]
 ]
@@ -911,14 +1006,32 @@ requires-dist = [
     { name = "ldap3", specifier = ">=2.9" },
     { name = "ldap3", specifier = ">=2.9" },
     { name = "openpyxl", specifier = ">=3.1" },
     { name = "openpyxl", specifier = ">=3.1" },
     { name = "pandas", specifier = ">=2.0" },
     { name = "pandas", specifier = ">=2.0" },
+    { name = "passlib", specifier = ">=1.7.4" },
     { name = "pyodbc", specifier = ">=4.0" },
     { name = "pyodbc", specifier = ">=4.0" },
     { name = "python-docx", specifier = ">=0.8" },
     { name = "python-docx", specifier = ">=0.8" },
     { name = "python-dotenv", specifier = ">=1.2.1" },
     { name = "python-dotenv", specifier = ">=1.2.1" },
+    { name = "python-jose", specifier = ">=3.5.0" },
     { name = "python-multipart", specifier = ">=0.0.6" },
     { name = "python-multipart", specifier = ">=0.0.6" },
+    { name = "requests", specifier = ">=2.34.2" },
     { name = "sqlalchemy", specifier = ">=2.0" },
     { name = "sqlalchemy", specifier = ">=2.0" },
     { name = "uvicorn", extras = ["standard"], specifier = ">=0.22" },
     { 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]]
 [[package]]
 name = "rich"
 name = "rich"
 version = "14.3.3"
 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" },
     { 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]]
 [[package]]
 name = "sentry-sdk"
 name = "sentry-sdk"
 version = "2.56.0"
 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