diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dfc23d3..2257b68 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -158,6 +158,20 @@ Hermes Athena ist die einzige API-Oberflaeche fuer den Browser. +## Dashboard + +Das Dashboard liegt in Athena unter `/dashboard` und bezieht seine Daten ueber die BFF-Route `GET /api/dashboard/summary`. + +Hermes stellt dafuer `GET /dashboard/summary` bereit. Der Endpunkt ist mit `dashboard.read` geschuetzt und liefert nur Datenbloecke, fuer die der aktuelle Benutzer weitere Berechtigungen besitzt. + +Beispiele: + +- Kundenstatistiken nur mit `customers.read` +- Benutzerstatistiken nur mit `users.read` +- Rollenstatus nur mit `roles.read` + +Nicht vorhandene Module wie Aufgaben, Tickets oder Projekte werden nicht mit Fake-Daten gefuellt. Stattdessen liefert das Dashboard leere Widgets mit klarer Meldung. + ## RBAC: Rollen und Berechtigungen Olympus verwendet ein serverseitiges RBAC-System als Grundlage fuer alle CRM-Module. @@ -309,6 +323,57 @@ Der aktuell angemeldete Benutzer darf sich nicht selbst loeschen. Hermes verhind Benutzer besitzen eine primaere RBAC-Rolle ueber `role_id`. Die Rolle bestimmt die serverseitigen Berechtigungen. +## Kundenmodul + +Das Kundenmodul ist das zweite Enterprise-Modul und folgt den gleichen Grundsaetzen wie die Benutzerverwaltung: + +- Hermes stellt REST-Endpunkte bereit. +- Athena proxyt diese Endpunkte ueber `/api/customers`. +- Der Browser spricht nicht direkt mit Hermes. +- Berechtigungen werden serverseitig in Hermes geprueft. +- Die UI nutzt Permissions nur fuer Sichtbarkeit und Bedienkomfort. + +Hermes-Endpunkte: + +- `GET /customers` +- `GET /customers/{id}` +- `POST /customers` +- `PUT /customers/{id}` +- `DELETE /customers/{id}` +- `GET /customers/{id}/contacts` +- `POST /customers/{id}/contacts` +- `PUT /customers/{id}/contacts/{contact_id}` +- `DELETE /customers/{id}/contacts/{contact_id}` + +Athena-BFF-Routen: + +- `GET /api/customers` +- `POST /api/customers` +- `GET /api/customers/[id]` +- `PUT /api/customers/[id]` +- `DELETE /api/customers/[id]` +- `GET /api/customers/[id]/contacts` +- `POST /api/customers/[id]/contacts` +- `PUT /api/customers/[id]/contacts/[contactId]` +- `DELETE /api/customers/[id]/contacts/[contactId]` + +Tabellen: + +- `customers` +- `customer_addresses` +- `customer_contacts` + +Kunden-Permissions: + +- `customers.read` +- `customers.create` +- `customers.update` +- `customers.delete` + +Kontakte und Adressen gehoeren fachlich zum Kunden und werden aktuell ueber `customers.read` beziehungsweise `customers.update` gesteuert. + +Ein Kunden-Delete entfernt aktuell den Kunden inklusive Adressen und Ansprechpartnern. Fuer spaetere Projekte oder Tickets ist ein fachlicher Loeschschutz vorzubereiten, sobald diese Module existieren. + ## Verzeichnisstruktur ```text @@ -328,6 +393,7 @@ backend/hermes frontend/athena app/ api/ + customers/ roles/ users/ components/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 20f012e..5027b0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -164,6 +164,23 @@ Neue Module sollen mindestens diese UI-Zustaende unterstuetzen: - Validierung - Erfolg ohne Page Reload +### CRM-Modulstandard + +Ein neues CRM-Modul soll sich am Kundenmodul orientieren: + +- SQLAlchemy-Modell in `backend/hermes/app/models` +- Pydantic-Schemas in `backend/hermes/app/schemas` +- Repository in `backend/hermes/app/repositories` +- FastAPI-Router in `backend/hermes/app/api` +- Alembic-Migration fuer Schemaaenderungen +- Athena-BFF-Routen unter `frontend/athena/app/api` +- Frontend-Seiten unter `frontend/athena/app/` +- wiederverwendbare UI-Komponenten unter `frontend/athena/components` +- RBAC-Permissions vor der UI-Integration definieren +- keine Fake-Daten fuer noch nicht existierende Unterbereiche + +Dashboard-Widgets fuer noch nicht implementierte Module muessen Empty States anzeigen statt hart codierter Beispieldaten. + ### Neue Permissions Neue Module muessen eigene stabile Permission-Strings erhalten. diff --git a/backend/hermes/alembic/versions/9c1d8a2f6b44_create_customers.py b/backend/hermes/alembic/versions/9c1d8a2f6b44_create_customers.py new file mode 100644 index 0000000..cb483a4 --- /dev/null +++ b/backend/hermes/alembic/versions/9c1d8a2f6b44_create_customers.py @@ -0,0 +1,94 @@ +"""create customers + +Revision ID: 9c1d8a2f6b44 +Revises: 7b2c9f4a0d31 +Create Date: 2026-07-02 14:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "9c1d8a2f6b44" +down_revision: Union[str, Sequence[str], None] = "7b2c9f4a0d31" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "customers", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("customer_number", sa.String(length=50), nullable=False), + sa.Column("company_name", sa.String(length=255), nullable=False), + sa.Column("legal_name", sa.String(length=255), server_default="", nullable=False), + sa.Column("customer_type", sa.String(length=50), nullable=False), + sa.Column("status", sa.String(length=50), nullable=False), + sa.Column("industry", sa.String(length=120), server_default="", nullable=False), + sa.Column("website", sa.String(length=255), server_default="", nullable=False), + sa.Column("email", sa.String(length=255), server_default="", nullable=False), + sa.Column("phone", sa.String(length=80), server_default="", nullable=False), + sa.Column("tax_number", sa.String(length=120), server_default="", nullable=False), + sa.Column("vat_id", sa.String(length=120), server_default="", nullable=False), + sa.Column("notes", sa.Text(), server_default="", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_customers_company_name"), "customers", ["company_name"], unique=False) + op.create_index(op.f("ix_customers_customer_number"), "customers", ["customer_number"], unique=True) + op.create_index(op.f("ix_customers_customer_type"), "customers", ["customer_type"], unique=False) + op.create_index(op.f("ix_customers_status"), "customers", ["status"], unique=False) + + op.create_table( + "customer_addresses", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("customer_id", sa.Integer(), nullable=False), + sa.Column("type", sa.String(length=50), nullable=False), + sa.Column("street", sa.String(length=255), server_default="", nullable=False), + sa.Column("postal_code", sa.String(length=30), server_default="", nullable=False), + sa.Column("city", sa.String(length=120), server_default="", nullable=False), + sa.Column("state", sa.String(length=120), server_default="", nullable=False), + sa.Column("country", sa.String(length=120), server_default="Deutschland", nullable=False), + sa.Column("is_primary", sa.Boolean(), server_default=sa.false(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["customer_id"], ["customers.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_customer_addresses_customer_id"), "customer_addresses", ["customer_id"], unique=False) + op.create_index(op.f("ix_customer_addresses_type"), "customer_addresses", ["type"], unique=False) + + op.create_table( + "customer_contacts", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("customer_id", sa.Integer(), nullable=False), + sa.Column("first_name", sa.String(length=100), server_default="", nullable=False), + sa.Column("last_name", sa.String(length=100), server_default="", nullable=False), + sa.Column("position", sa.String(length=120), server_default="", nullable=False), + sa.Column("email", sa.String(length=255), server_default="", nullable=False), + sa.Column("phone", sa.String(length=80), server_default="", nullable=False), + sa.Column("mobile", sa.String(length=80), server_default="", nullable=False), + sa.Column("is_primary", sa.Boolean(), server_default=sa.false(), nullable=False), + sa.Column("notes", sa.Text(), server_default="", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["customer_id"], ["customers.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_customer_contacts_customer_id"), "customer_contacts", ["customer_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_customer_contacts_customer_id"), table_name="customer_contacts") + op.drop_table("customer_contacts") + op.drop_index(op.f("ix_customer_addresses_type"), table_name="customer_addresses") + op.drop_index(op.f("ix_customer_addresses_customer_id"), table_name="customer_addresses") + op.drop_table("customer_addresses") + op.drop_index(op.f("ix_customers_status"), table_name="customers") + op.drop_index(op.f("ix_customers_customer_type"), table_name="customers") + op.drop_index(op.f("ix_customers_customer_number"), table_name="customers") + op.drop_index(op.f("ix_customers_company_name"), table_name="customers") + op.drop_table("customers") diff --git a/backend/hermes/app/api/customers.py b/backend/hermes/app/api/customers.py new file mode 100644 index 0000000..3cc9339 --- /dev/null +++ b/backend/hermes/app/api/customers.py @@ -0,0 +1,208 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy.orm import Session + +from app.core.rbac import require_permission +from app.db.database import get_db +from app.models.customer import Customer, CustomerContact +from app.models.user import User +from app.repositories.customer_repository import CustomerRepository +from app.schemas.customer import ( + CustomerContactCreate, + CustomerContactResponse, + CustomerContactUpdate, + CustomerCreate, + CustomerResponse, + CustomerUpdate, +) + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/customers", + tags=["Customers"], +) + + +def get_customer_or_404(db: Session, customer_id: int) -> Customer: + customer = CustomerRepository.get_by_id(db, customer_id) + if customer is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Kunde nicht gefunden", + ) + return customer + + +def get_contact_or_404(db: Session, customer_id: int, contact_id: int) -> CustomerContact: + contact = CustomerRepository.get_contact_by_id(db, customer_id, contact_id) + if contact is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Ansprechpartner nicht gefunden", + ) + return contact + + +def ensure_unique_customer_number( + db: Session, + customer_number: str, + customer_id: int | None = None, +) -> None: + conflict = CustomerRepository.find_number_conflict( + db, + customer_number=customer_number, + exclude_customer_id=customer_id, + ) + if conflict is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Kundennummer ist bereits vergeben", + ) + + +@router.get("", response_model=list[CustomerResponse]) +@router.get("/", response_model=list[CustomerResponse], include_in_schema=False) +def get_customers( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("customers.read")), +): + logger.info("customers.list", extra={"actor_user_id": current_user.id}) + return CustomerRepository.get_all(db) + + +@router.get("/{customer_id}", response_model=CustomerResponse) +def get_customer( + customer_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("customers.read")), +): + logger.info( + "customers.detail", + extra={"actor_user_id": current_user.id, "target_customer_id": customer_id}, + ) + return get_customer_or_404(db, customer_id) + + +@router.post("", response_model=CustomerResponse, status_code=status.HTTP_201_CREATED) +@router.post( + "/", + response_model=CustomerResponse, + status_code=status.HTTP_201_CREATED, + include_in_schema=False, +) +def create_customer( + customer: CustomerCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("customers.create")), +): + ensure_unique_customer_number(db, customer.customer_number) + logger.info("customers.create", extra={"actor_user_id": current_user.id}) + return CustomerRepository.create(db, customer) + + +@router.put("/{customer_id}", response_model=CustomerResponse) +def update_customer( + customer_id: int, + customer: CustomerUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("customers.update")), +): + db_customer = get_customer_or_404(db, customer_id) + ensure_unique_customer_number(db, customer.customer_number, customer_id) + logger.info( + "customers.update", + extra={"actor_user_id": current_user.id, "target_customer_id": customer_id}, + ) + return CustomerRepository.update(db, db_customer, customer) + + +@router.delete("/{customer_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_customer( + customer_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("customers.delete")), +): + db_customer = get_customer_or_404(db, customer_id) + logger.info( + "customers.delete", + extra={"actor_user_id": current_user.id, "target_customer_id": customer_id}, + ) + CustomerRepository.delete(db, db_customer) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/{customer_id}/contacts", response_model=list[CustomerContactResponse]) +def get_customer_contacts( + customer_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("customers.read")), +): + get_customer_or_404(db, customer_id) + logger.info( + "customers.contacts.list", + extra={"actor_user_id": current_user.id, "target_customer_id": customer_id}, + ) + return CustomerRepository.get_contacts(db, customer_id) + + +@router.post( + "/{customer_id}/contacts", + response_model=CustomerContactResponse, + status_code=status.HTTP_201_CREATED, +) +def create_customer_contact( + customer_id: int, + contact: CustomerContactCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("customers.update")), +): + get_customer_or_404(db, customer_id) + logger.info( + "customers.contacts.create", + extra={"actor_user_id": current_user.id, "target_customer_id": customer_id}, + ) + return CustomerRepository.create_contact(db, customer_id, contact) + + +@router.put("/{customer_id}/contacts/{contact_id}", response_model=CustomerContactResponse) +def update_customer_contact( + customer_id: int, + contact_id: int, + contact: CustomerContactUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("customers.update")), +): + get_customer_or_404(db, customer_id) + db_contact = get_contact_or_404(db, customer_id, contact_id) + logger.info( + "customers.contacts.update", + extra={ + "actor_user_id": current_user.id, + "target_customer_id": customer_id, + "target_contact_id": contact_id, + }, + ) + return CustomerRepository.update_contact(db, db_contact, contact) + + +@router.delete("/{customer_id}/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_customer_contact( + customer_id: int, + contact_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("customers.update")), +): + get_customer_or_404(db, customer_id) + db_contact = get_contact_or_404(db, customer_id, contact_id) + logger.info( + "customers.contacts.delete", + extra={ + "actor_user_id": current_user.id, + "target_customer_id": customer_id, + "target_contact_id": contact_id, + }, + ) + CustomerRepository.delete_contact(db, db_contact) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py new file mode 100644 index 0000000..4b1a644 --- /dev/null +++ b/backend/hermes/app/api/dashboard.py @@ -0,0 +1,80 @@ +import logging + +from fastapi import APIRouter, Depends +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core.rbac import get_user_permission_names, require_permission +from app.db.database import get_db +from app.models.rbac import Role +from app.models.user import User +from app.repositories.customer_repository import CustomerRepository +from app.repositories.user_repository import UserRepository +from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/dashboard", + tags=["Dashboard"], +) + + +@router.get("/summary", response_model=DashboardSummary) +def get_dashboard_summary( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("dashboard.read")), +): + permissions = get_user_permission_names(current_user) + customers: list[MetricCard] = [] + latest_customers = [] + users: list[MetricCard] = [] + roles: list[MetricCard] = [] + + if "customers.read" in permissions: + customers = [ + MetricCard(label="Kunden", value=CustomerRepository.count_all(db)), + MetricCard(label="Aktive Kunden", value=CustomerRepository.count_active(db)), + MetricCard(label="Neue Kunden", value=CustomerRepository.count_recent(db)), + ] + latest_customers = CustomerRepository.get_latest(db) + + if "users.read" in permissions: + all_users = UserRepository.get_all(db) + users = [ + MetricCard(label="Benutzer", value=len(all_users)), + MetricCard(label="Aktive Benutzer", value=len([user for user in all_users if user.is_active])), + ] + + if "roles.read" in permissions: + roles = [ + MetricCard( + label="Rollen", + value=db.scalar(select(func.count(Role.id))) or 0, + ) + ] + + logger.info("dashboard.summary", extra={"actor_user_id": current_user.id}) + + return DashboardSummary( + customers=customers, + latest_customers=latest_customers, + users=users, + roles=roles, + activities=EmptyWidget( + title="Letzte Aktivitäten", + message="Noch keine Aktivitäten vorhanden.", + ), + tasks=EmptyWidget( + title="Offene Aufgaben", + message="Noch keine Aufgaben vorhanden.", + ), + tickets=EmptyWidget( + title="Offene Tickets", + message="Noch keine Tickets vorhanden.", + ), + projects=EmptyWidget( + title="Projekte", + message="Noch keine Projektdaten vorhanden.", + ), + ) diff --git a/backend/hermes/app/db/database.py b/backend/hermes/app/db/database.py index 59350fa..dce9d72 100644 --- a/backend/hermes/app/db/database.py +++ b/backend/hermes/app/db/database.py @@ -21,6 +21,7 @@ class Base(DeclarativeBase): # <<< HIER IMPORTIEREN >>> import app.models.rbac +import app.models.customer import app.models.user diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index 3556490..a5de34d 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -9,6 +9,8 @@ from starlette import status from starlette.requests import Request from app.api.auth import router as auth_router +from app.api.customers import router as customers_router +from app.api.dashboard import router as dashboard_router from app.api.permissions import router as permissions_router from app.api.roles import router as roles_router from app.api.users import router as users_router @@ -26,6 +28,8 @@ app.include_router(auth_router) app.include_router(users_router) app.include_router(roles_router) app.include_router(permissions_router) +app.include_router(customers_router) +app.include_router(dashboard_router) logger = logging.getLogger(__name__) diff --git a/backend/hermes/app/models/customer.py b/backend/hermes/app/models/customer.py new file mode 100644 index 0000000..461d127 --- /dev/null +++ b/backend/hermes/app/models/customer.py @@ -0,0 +1,92 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.database import Base + + +class Customer(Base): + __tablename__ = "customers" + + id: Mapped[int] = mapped_column(primary_key=True) + customer_number: Mapped[str] = mapped_column(String(50), unique=True, index=True) + company_name: Mapped[str] = mapped_column(String(255), index=True) + legal_name: Mapped[str] = mapped_column(String(255), default="", server_default="") + customer_type: Mapped[str] = mapped_column(String(50), index=True) + status: Mapped[str] = mapped_column(String(50), index=True) + industry: Mapped[str] = mapped_column(String(120), default="", server_default="") + website: Mapped[str] = mapped_column(String(255), default="", server_default="") + email: Mapped[str] = mapped_column(String(255), default="", server_default="") + phone: Mapped[str] = mapped_column(String(80), default="", server_default="") + tax_number: Mapped[str] = mapped_column(String(120), default="", server_default="") + vat_id: Mapped[str] = mapped_column(String(120), default="", server_default="") + notes: Mapped[str] = mapped_column(Text, default="", server_default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) + + addresses: Mapped[list["CustomerAddress"]] = relationship( + back_populates="customer", + cascade="all, delete-orphan", + lazy="selectin", + ) + contacts: Mapped[list["CustomerContact"]] = relationship( + back_populates="customer", + cascade="all, delete-orphan", + lazy="selectin", + ) + + +class CustomerAddress(Base): + __tablename__ = "customer_addresses" + + id: Mapped[int] = mapped_column(primary_key=True) + customer_id: Mapped[int] = mapped_column( + ForeignKey("customers.id", ondelete="CASCADE"), + index=True, + ) + type: Mapped[str] = mapped_column(String(50), index=True) + street: Mapped[str] = mapped_column(String(255), default="", server_default="") + postal_code: Mapped[str] = mapped_column(String(30), default="", server_default="") + city: Mapped[str] = mapped_column(String(120), default="", server_default="") + state: Mapped[str] = mapped_column(String(120), default="", server_default="") + country: Mapped[str] = mapped_column(String(120), default="Deutschland", server_default="Deutschland") + is_primary: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) + + customer: Mapped[Customer] = relationship(back_populates="addresses") + + +class CustomerContact(Base): + __tablename__ = "customer_contacts" + + id: Mapped[int] = mapped_column(primary_key=True) + customer_id: Mapped[int] = mapped_column( + ForeignKey("customers.id", ondelete="CASCADE"), + index=True, + ) + first_name: Mapped[str] = mapped_column(String(100), default="", server_default="") + last_name: Mapped[str] = mapped_column(String(100), default="", server_default="") + position: Mapped[str] = mapped_column(String(120), default="", server_default="") + email: Mapped[str] = mapped_column(String(255), default="", server_default="") + phone: Mapped[str] = mapped_column(String(80), default="", server_default="") + mobile: Mapped[str] = mapped_column(String(80), default="", server_default="") + is_primary: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + notes: Mapped[str] = mapped_column(Text, default="", server_default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) + + customer: Mapped[Customer] = relationship(back_populates="contacts") diff --git a/backend/hermes/app/repositories/customer_repository.py b/backend/hermes/app/repositories/customer_repository.py new file mode 100644 index 0000000..94ec8dc --- /dev/null +++ b/backend/hermes/app/repositories/customer_repository.py @@ -0,0 +1,269 @@ +from datetime import UTC, datetime, timedelta + +from sqlalchemy import func, select +from sqlalchemy.orm import Session, selectinload + +from app.models.customer import Customer, CustomerAddress, CustomerContact +from app.schemas.customer import ( + CustomerAddressCreate, + CustomerAddressUpdate, + CustomerContactCreate, + CustomerContactUpdate, + CustomerCreate, + CustomerUpdate, +) + + +def _email(value: object) -> str: + return "" if value is None else str(value) + + +def _url(value: object) -> str: + return "" if value is None else str(value) + + +class CustomerRepository: + @staticmethod + def get_all(db: Session) -> list[Customer]: + return list( + db.scalars( + select(Customer) + .options( + selectinload(Customer.addresses), + selectinload(Customer.contacts), + ) + .order_by(Customer.created_at.desc()) + ) + ) + + @staticmethod + def get_by_id(db: Session, customer_id: int) -> Customer | None: + return db.scalar( + select(Customer) + .where(Customer.id == customer_id) + .options( + selectinload(Customer.addresses), + selectinload(Customer.contacts), + ) + ) + + @staticmethod + def get_by_number(db: Session, customer_number: str) -> Customer | None: + return db.scalar(select(Customer).where(Customer.customer_number == customer_number)) + + @staticmethod + def find_number_conflict( + db: Session, + *, + customer_number: str, + exclude_customer_id: int | None = None, + ) -> Customer | None: + query = select(Customer).where(Customer.customer_number == customer_number) + if exclude_customer_id is not None: + query = query.where(Customer.id != exclude_customer_id) + return db.scalar(query) + + @staticmethod + def count_all(db: Session) -> int: + return db.scalar(select(func.count(Customer.id))) or 0 + + @staticmethod + def count_active(db: Session) -> int: + return db.scalar(select(func.count(Customer.id)).where(Customer.status == "active")) or 0 + + @staticmethod + def count_recent(db: Session, days: int = 30) -> int: + since = datetime.now(UTC) - timedelta(days=days) + return db.scalar( + select(func.count(Customer.id)) + .where(Customer.created_at >= since) + ) or 0 + + @staticmethod + def get_latest(db: Session, limit: int = 5) -> list[Customer]: + return list( + db.scalars( + select(Customer) + .options(selectinload(Customer.addresses), selectinload(Customer.contacts)) + .order_by(Customer.created_at.desc()) + .limit(limit) + ) + ) + + @staticmethod + def create(db: Session, customer: CustomerCreate) -> Customer: + db_customer = Customer( + customer_number=customer.customer_number, + company_name=customer.company_name, + legal_name=customer.legal_name, + customer_type=customer.customer_type, + status=customer.status, + industry=customer.industry, + website=_url(customer.website), + email=_email(customer.email), + phone=customer.phone, + tax_number=customer.tax_number, + vat_id=customer.vat_id, + notes=customer.notes, + ) + db_customer.addresses = [ + CustomerRepository._address_from_payload(address) + for address in customer.addresses + ] + db_customer.contacts = [ + CustomerRepository._contact_from_payload(contact) + for contact in customer.contacts + ] + CustomerRepository._normalize_primary(db_customer.addresses) + CustomerRepository._normalize_primary(db_customer.contacts) + + db.add(db_customer) + db.commit() + db.refresh(db_customer) + + return CustomerRepository.get_by_id(db, db_customer.id) or db_customer + + @staticmethod + def update(db: Session, db_customer: Customer, customer: CustomerUpdate) -> Customer: + db_customer.customer_number = customer.customer_number + db_customer.company_name = customer.company_name + db_customer.legal_name = customer.legal_name + db_customer.customer_type = customer.customer_type + db_customer.status = customer.status + db_customer.industry = customer.industry + db_customer.website = _url(customer.website) + db_customer.email = _email(customer.email) + db_customer.phone = customer.phone + db_customer.tax_number = customer.tax_number + db_customer.vat_id = customer.vat_id + db_customer.notes = customer.notes + + db_customer.addresses = [ + CustomerRepository._address_from_payload(address) + for address in customer.addresses + ] + CustomerRepository._normalize_primary(db_customer.addresses) + + db.commit() + db.refresh(db_customer) + + return CustomerRepository.get_by_id(db, db_customer.id) or db_customer + + @staticmethod + def delete(db: Session, db_customer: Customer) -> None: + db.delete(db_customer) + db.commit() + + @staticmethod + def get_contacts(db: Session, customer_id: int) -> list[CustomerContact]: + return list( + db.scalars( + select(CustomerContact) + .where(CustomerContact.customer_id == customer_id) + .order_by(CustomerContact.is_primary.desc(), CustomerContact.last_name) + ) + ) + + @staticmethod + def get_contact_by_id( + db: Session, + customer_id: int, + contact_id: int, + ) -> CustomerContact | None: + return db.scalar( + select(CustomerContact) + .where(CustomerContact.customer_id == customer_id) + .where(CustomerContact.id == contact_id) + ) + + @staticmethod + def create_contact( + db: Session, + customer_id: int, + contact: CustomerContactCreate, + ) -> CustomerContact: + db_contact = CustomerRepository._contact_from_payload(contact) + db_contact.customer_id = customer_id + db.add(db_contact) + db.flush() + + if db_contact.is_primary: + CustomerRepository._clear_other_primary_contacts(db, customer_id, db_contact.id) + + db.commit() + db.refresh(db_contact) + return db_contact + + @staticmethod + def update_contact( + db: Session, + db_contact: CustomerContact, + contact: CustomerContactUpdate, + ) -> CustomerContact: + db_contact.first_name = contact.first_name + db_contact.last_name = contact.last_name + db_contact.position = contact.position + db_contact.email = _email(contact.email) + db_contact.phone = contact.phone + db_contact.mobile = contact.mobile + db_contact.is_primary = contact.is_primary + db_contact.notes = contact.notes + + if db_contact.is_primary: + CustomerRepository._clear_other_primary_contacts(db, db_contact.customer_id, db_contact.id) + + db.commit() + db.refresh(db_contact) + return db_contact + + @staticmethod + def delete_contact(db: Session, db_contact: CustomerContact) -> None: + db.delete(db_contact) + db.commit() + + @staticmethod + def _address_from_payload(address: CustomerAddressCreate | CustomerAddressUpdate) -> CustomerAddress: + return CustomerAddress( + type=address.type, + street=address.street, + postal_code=address.postal_code, + city=address.city, + state=address.state, + country=address.country, + is_primary=address.is_primary, + ) + + @staticmethod + def _contact_from_payload(contact: CustomerContactCreate | CustomerContactUpdate) -> CustomerContact: + return CustomerContact( + first_name=contact.first_name, + last_name=contact.last_name, + position=contact.position, + email=_email(contact.email), + phone=contact.phone, + mobile=contact.mobile, + is_primary=contact.is_primary, + notes=contact.notes, + ) + + @staticmethod + def _normalize_primary(items: list[CustomerAddress] | list[CustomerContact]) -> None: + primary_seen = False + for item in items: + if item.is_primary and not primary_seen: + primary_seen = True + elif item.is_primary: + item.is_primary = False + + if items and not primary_seen: + items[0].is_primary = True + + @staticmethod + def _clear_other_primary_contacts(db: Session, customer_id: int, contact_id: int) -> None: + contacts = db.scalars( + select(CustomerContact) + .where(CustomerContact.customer_id == customer_id) + .where(CustomerContact.id != contact_id) + ) + for contact in contacts: + contact.is_primary = False diff --git a/backend/hermes/app/schemas/customer.py b/backend/hermes/app/schemas/customer.py new file mode 100644 index 0000000..f7eca6e --- /dev/null +++ b/backend/hermes/app/schemas/customer.py @@ -0,0 +1,131 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, EmailStr, Field, HttpUrl, field_validator + +CustomerStatus = Literal["lead", "active", "inactive", "blocked", "archived"] +CustomerType = Literal["company", "private", "public_sector", "partner", "supplier"] +AddressType = Literal["billing", "shipping", "primary", "other"] + + +def normalize_optional_text(value: object) -> str: + if value is None: + return "" + return str(value).strip() + + +class CustomerAddressBase(BaseModel): + type: AddressType = "primary" + street: str = Field(default="", max_length=255) + postal_code: str = Field(default="", max_length=30) + city: str = Field(default="", max_length=120) + state: str = Field(default="", max_length=120) + country: str = Field(default="Deutschland", max_length=120) + is_primary: bool = False + + @field_validator("street", "postal_code", "city", "state", "country", mode="before") + @classmethod + def normalize_text(cls, value: object) -> str: + return normalize_optional_text(value) + + +class CustomerAddressCreate(CustomerAddressBase): + pass + + +class CustomerAddressUpdate(CustomerAddressBase): + pass + + +class CustomerAddressResponse(CustomerAddressBase): + id: int + customer_id: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class CustomerContactBase(BaseModel): + first_name: str = Field(default="", max_length=100) + last_name: str = Field(default="", max_length=100) + position: str = Field(default="", max_length=120) + email: EmailStr | None = None + phone: str = Field(default="", max_length=80) + mobile: str = Field(default="", max_length=80) + is_primary: bool = False + notes: str = "" + + @field_validator("first_name", "last_name", "position", "phone", "mobile", "notes", mode="before") + @classmethod + def normalize_text(cls, value: object) -> str: + return normalize_optional_text(value) + + +class CustomerContactCreate(CustomerContactBase): + pass + + +class CustomerContactUpdate(CustomerContactBase): + pass + + +class CustomerContactResponse(CustomerContactBase): + id: int + customer_id: int + email: str + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class CustomerBase(BaseModel): + customer_number: str = Field(min_length=1, max_length=50) + company_name: str = Field(min_length=1, max_length=255) + legal_name: str = Field(default="", max_length=255) + customer_type: CustomerType + status: CustomerStatus + industry: str = Field(default="", max_length=120) + website: HttpUrl | None = None + email: EmailStr | None = None + phone: str = Field(default="", max_length=80) + tax_number: str = Field(default="", max_length=120) + vat_id: str = Field(default="", max_length=120) + notes: str = "" + + @field_validator( + "customer_number", + "company_name", + "legal_name", + "industry", + "phone", + "tax_number", + "vat_id", + "notes", + mode="before", + ) + @classmethod + def normalize_text(cls, value: object) -> str: + return normalize_optional_text(value) + + +class CustomerCreate(CustomerBase): + addresses: list[CustomerAddressCreate] = Field(default_factory=list) + contacts: list[CustomerContactCreate] = Field(default_factory=list) + + +class CustomerUpdate(CustomerBase): + addresses: list[CustomerAddressUpdate] = Field(default_factory=list) + + +class CustomerResponse(CustomerBase): + id: int + website: str + email: str + addresses: list[CustomerAddressResponse] + contacts: list[CustomerContactResponse] + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/hermes/app/schemas/dashboard.py b/backend/hermes/app/schemas/dashboard.py new file mode 100644 index 0000000..89dedcc --- /dev/null +++ b/backend/hermes/app/schemas/dashboard.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel + +from app.schemas.customer import CustomerResponse + + +class MetricCard(BaseModel): + label: str + value: int + + +class EmptyWidget(BaseModel): + title: str + message: str + + +class DashboardSummary(BaseModel): + customers: list[MetricCard] = [] + latest_customers: list[CustomerResponse] = [] + users: list[MetricCard] = [] + roles: list[MetricCard] = [] + activities: EmptyWidget + tasks: EmptyWidget + tickets: EmptyWidget + projects: EmptyWidget diff --git a/frontend/athena/app/api/customers/[id]/contacts/[contactId]/route.ts b/frontend/athena/app/api/customers/[id]/contacts/[contactId]/route.ts new file mode 100644 index 0000000..623885b --- /dev/null +++ b/frontend/athena/app/api/customers/[id]/contacts/[contactId]/route.ts @@ -0,0 +1,36 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ + id: string; + contactId: string; + }>; +}; + +async function proxyContactRequest(request: NextRequest, { params }: Params) { + const { id, contactId } = await params; + return proxyHermesRequest(request, `/customers/${id}/contacts/${contactId}`); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyContactRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyContactRequest(request, context); +} diff --git a/frontend/athena/app/api/customers/[id]/contacts/route.ts b/frontend/athena/app/api/customers/[id]/contacts/route.ts new file mode 100644 index 0000000..83c8554 --- /dev/null +++ b/frontend/athena/app/api/customers/[id]/contacts/route.ts @@ -0,0 +1,29 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ + id: string; + }>; +}; + +async function proxyContactsRequest(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/customers/${id}/contacts`); +} + +export async function GET(request: NextRequest, context: Params) { + return proxyContactsRequest(request, context); +} + +export async function POST(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyContactsRequest(request, context); +} diff --git a/frontend/athena/app/api/customers/[id]/route.ts b/frontend/athena/app/api/customers/[id]/route.ts new file mode 100644 index 0000000..77d153a --- /dev/null +++ b/frontend/athena/app/api/customers/[id]/route.ts @@ -0,0 +1,39 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ + id: string; + }>; +}; + +async function proxyCustomerRequest(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/customers/${id}`); +} + +export async function GET(request: NextRequest, context: Params) { + return proxyCustomerRequest(request, context); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyCustomerRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyCustomerRequest(request, context); +} diff --git a/frontend/athena/app/api/customers/route.ts b/frontend/athena/app/api/customers/route.ts new file mode 100644 index 0000000..89a13a2 --- /dev/null +++ b/frontend/athena/app/api/customers/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, `/customers${request.nextUrl.search}`); +} + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/customers"); +} diff --git a/frontend/athena/app/api/dashboard/summary/route.ts b/frontend/athena/app/api/dashboard/summary/route.ts new file mode 100644 index 0000000..3f681e6 --- /dev/null +++ b/frontend/athena/app/api/dashboard/summary/route.ts @@ -0,0 +1,7 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, "/dashboard/summary"); +} diff --git a/frontend/athena/app/customers/[id]/page.tsx b/frontend/athena/app/customers/[id]/page.tsx new file mode 100644 index 0000000..7ef74f0 --- /dev/null +++ b/frontend/athena/app/customers/[id]/page.tsx @@ -0,0 +1,345 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { ArrowLeft, Edit, Plus, Trash2 } from "lucide-react"; + +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import DetailSection from "@/components/common/DetailSection"; +import { Button } from "@/components/ui/button"; +import CustomerContactFormDialog from "@/components/customers/CustomerContactFormDialog"; +import CustomerFormDialog from "@/components/customers/CustomerFormDialog"; +import CustomerStatusBadge from "@/components/customers/CustomerStatusBadge"; +import { api } from "@/lib/api"; +import { hasPermission } from "@/lib/permissions"; +import type { CurrentUser } from "@/types/rbac"; +import type { Customer, CustomerContact, CustomerContactPayload, CustomerPayload } from "@/types/customer"; + +function getErrorMessage(error: unknown) { + if (typeof error === "object" && error !== null && "response" in error) { + const response = (error as { response?: { data?: { detail?: string } } }).response; + return response?.data?.detail ?? "Aktion konnte nicht abgeschlossen werden"; + } + return "Aktion konnte nicht abgeschlossen werden"; +} + +function formatDateTime(value: string) { + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); +} + +export default function CustomerDetailPage() { + const params = useParams<{ id: string }>(); + const router = useRouter(); + const [customer, setCustomer] = useState(null); + const [currentUser, setCurrentUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + const [formOpen, setFormOpen] = useState(false); + const [formError, setFormError] = useState(""); + const [contactOpen, setContactOpen] = useState(false); + const [contactError, setContactError] = useState(""); + const [selectedContact, setSelectedContact] = useState(null); + const [deleteContact, setDeleteContact] = useState(null); + const [deleteCustomerOpen, setDeleteCustomerOpen] = useState(false); + const [deleteError, setDeleteError] = useState(""); + + const loadCustomer = useCallback(async () => { + try { + const [customerResponse, meResponse] = await Promise.all([ + api.get(`/customers/${params.id}`), + api.get("/me"), + ]); + setCustomer(customerResponse.data); + setCurrentUser(meResponse.data); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }, [params.id]); + + useEffect(() => { + queueMicrotask(() => { + void loadCustomer(); + }); + }, [loadCustomer]); + + async function saveCustomer(payload: CustomerPayload) { + if (!customer) { + return; + } + setPending(true); + setFormError(""); + try { + const response = await api.put(`/customers/${customer.id}`, payload); + setCustomer(response.data); + setFormOpen(false); + } catch (err) { + setFormError(getErrorMessage(err)); + } finally { + setPending(false); + } + } + + function openContactDialog(contact: CustomerContact | null) { + setSelectedContact(contact); + setContactError(""); + setContactOpen(true); + } + + async function saveContact(payload: CustomerContactPayload) { + if (!customer) { + return; + } + setPending(true); + setContactError(""); + try { + if (selectedContact) { + await api.put(`/customers/${customer.id}/contacts/${selectedContact.id}`, payload); + } else { + await api.post(`/customers/${customer.id}/contacts`, payload); + } + const response = await api.get(`/customers/${customer.id}`); + setCustomer(response.data); + setContactOpen(false); + } catch (err) { + setContactError(getErrorMessage(err)); + } finally { + setPending(false); + } + } + + async function confirmContactDelete() { + if (!customer || !deleteContact) { + return; + } + setPending(true); + setDeleteError(""); + try { + await api.delete(`/customers/${customer.id}/contacts/${deleteContact.id}`); + const response = await api.get(`/customers/${customer.id}`); + setCustomer(response.data); + setDeleteContact(null); + } catch (err) { + setDeleteError(getErrorMessage(err)); + } finally { + setPending(false); + } + } + + async function confirmCustomerDelete() { + if (!customer) { + return; + } + setPending(true); + setDeleteError(""); + try { + await api.delete(`/customers/${customer.id}`); + router.push("/customers"); + } catch (err) { + setDeleteError(getErrorMessage(err)); + } finally { + setPending(false); + } + } + + if (loading) { + return
Kunde wird geladen...
; + } + + if (error || !customer) { + return ( +
+ + + Zurück zur Kundenliste + +
{error || "Kunde nicht gefunden"}
+
+ ); + } + + return ( +
+
+
+ + + Zurück zur Kundenliste + +
+

{customer.company_name}

+ +
+

{customer.customer_number}

+
+ +
+ {hasPermission(currentUser, "customers.update") && ( + + )} + {hasPermission(currentUser, "customers.delete") && ( + + )} +
+
+ + +
+ + + + + + + + +
+
+ + + {customer.addresses.length === 0 ? ( +

Noch keine Adressen vorhanden.

+ ) : ( +
+ {customer.addresses.map((address) => ( +
+
+ {address.type} + {address.is_primary && Primär} +
+

{address.street || "-"}

+

{address.postal_code} {address.city}

+

{address.state}

+

{address.country}

+
+ ))} +
+ )} +
+ + openContactDialog(null)}> + + Ansprechpartner + + )} + > + {customer.contacts.length === 0 ? ( +

Noch keine Ansprechpartner vorhanden.

+ ) : ( +
+ {customer.contacts.map((contact) => ( +
+
+
+

{contact.first_name} {contact.last_name}

+

{contact.position || "-"}

+
+ {contact.is_primary && Primär} +
+

{contact.email || "-"}

+

{contact.phone || contact.mobile || "-"}

+ {hasPermission(currentUser, "customers.update") && ( +
+ + +
+ )} +
+ ))} +
+ )} +
+ + +

{customer.notes || "Noch keine Notizen vorhanden."}

+
+ + +
+ + +
+
+ + + + + + { + if (!open) { + setDeleteContact(null); + setDeleteError(""); + } + }} + onConfirm={confirmContactDelete} + > + {deleteContact &&

{deleteContact.first_name} {deleteContact.last_name}

} + {deleteError &&

{deleteError}

} +
+ + { + setDeleteCustomerOpen(open); + if (!open) { + setDeleteError(""); + } + }} + onConfirm={confirmCustomerDelete} + > +

{customer.customer_number} · {customer.company_name}

+ {deleteError &&

{deleteError}

} +
+
+ ); +} + +function Detail({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/frontend/athena/app/customers/page.tsx b/frontend/athena/app/customers/page.tsx new file mode 100644 index 0000000..ee72ae6 --- /dev/null +++ b/frontend/athena/app/customers/page.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { Edit, Eye, Plus, Trash2 } from "lucide-react"; + +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import DataTable, { type DataTableColumn } from "@/components/common/DataTable"; +import SearchInput from "@/components/common/SearchInput"; +import { Button, buttonVariants } from "@/components/ui/button"; +import CustomerFormDialog from "@/components/customers/CustomerFormDialog"; +import CustomerStatusBadge from "@/components/customers/CustomerStatusBadge"; +import { api } from "@/lib/api"; +import { hasPermission } from "@/lib/permissions"; +import type { CurrentUser } from "@/types/rbac"; +import type { Customer, CustomerPayload, CustomerStatus, CustomerType } from "@/types/customer"; + +const pageSize = 10; + +function getErrorMessage(error: unknown) { + if (typeof error === "object" && error !== null && "response" in error) { + const response = (error as { response?: { data?: { detail?: string } } }).response; + return response?.data?.detail ?? "Aktion konnte nicht abgeschlossen werden"; + } + return "Aktion konnte nicht abgeschlossen werden"; +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }).format(new Date(value)); +} + +function getPrimaryCity(customer: Customer) { + return customer.addresses.find((address) => address.is_primary)?.city + || customer.addresses[0]?.city + || "-"; +} + +function getSortableValue(customer: Customer, key: string) { + if (key === "city") { + return getPrimaryCity(customer).toLowerCase(); + } + const value = customer[key as keyof Customer]; + return typeof value === "string" ? value.toLowerCase() : value; +} + +export default function CustomersPage() { + const [customers, setCustomers] = useState([]); + const [currentUser, setCurrentUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [formError, setFormError] = useState(""); + const [deleteError, setDeleteError] = useState(""); + const [pending, setPending] = useState(false); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + const [sortKey, setSortKey] = useState("created_at"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); + const [page, setPage] = useState(1); + const [formOpen, setFormOpen] = useState(false); + const [selectedCustomer, setSelectedCustomer] = useState(null); + const [deleteCustomer, setDeleteCustomer] = useState(null); + + const loadCustomers = useCallback(async () => { + try { + const [customersResponse, meResponse] = await Promise.all([ + api.get("/customers"), + api.get("/me"), + ]); + setCustomers(customersResponse.data); + setCurrentUser(meResponse.data); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + queueMicrotask(() => { + void loadCustomers(); + }); + }, [loadCustomers]); + + const filteredCustomers = useMemo(() => { + const term = search.trim().toLowerCase(); + + return customers + .filter((customer) => { + if (!term) { + return true; + } + return [ + customer.customer_number, + customer.company_name, + customer.legal_name, + customer.industry, + customer.email, + customer.phone, + getPrimaryCity(customer), + ].some((value) => value.toLowerCase().includes(term)); + }) + .filter((customer) => statusFilter === "all" || customer.status === statusFilter) + .filter((customer) => typeFilter === "all" || customer.customer_type === typeFilter) + .sort((first, second) => { + const firstValue = getSortableValue(first, sortKey); + const secondValue = getSortableValue(second, sortKey); + if (firstValue === secondValue) { + return 0; + } + const result = firstValue > secondValue ? 1 : -1; + return sortDirection === "asc" ? result : -result; + }); + }, [customers, search, sortDirection, sortKey, statusFilter, typeFilter]); + + const pageCount = Math.max(1, Math.ceil(filteredCustomers.length / pageSize)); + const currentPage = Math.min(page, pageCount); + const pageCustomers = filteredCustomers.slice((currentPage - 1) * pageSize, currentPage * pageSize); + + const columns: DataTableColumn[] = [ + { key: "customer_number", label: "Kundennummer", sortable: true, render: (customer) => customer.customer_number }, + { key: "company_name", label: "Firmenname", sortable: true, render: (customer) => {customer.company_name} }, + { key: "customer_type", label: "Typ", sortable: true, render: (customer) => customer.customer_type }, + { key: "status", label: "Status", sortable: true, render: (customer) => }, + { key: "industry", label: "Branche", sortable: true, render: (customer) => customer.industry || "-" }, + { key: "email", label: "E-Mail", sortable: true, render: (customer) => customer.email || "-" }, + { key: "phone", label: "Telefon", sortable: true, render: (customer) => customer.phone || "-" }, + { key: "city", label: "Ort", sortable: true, render: (customer) => getPrimaryCity(customer) }, + { key: "created_at", label: "Erstellt", sortable: true, render: (customer) => formatDate(customer.created_at) }, + { + key: "actions", + label: "Aktionen", + className: "px-4 py-3 text-right", + render: (customer) => ( +
+ + + + {hasPermission(currentUser, "customers.update") && ( + + )} + {hasPermission(currentUser, "customers.delete") && ( + + )} +
+ ), + }, + ]; + + function handleSort(key: string) { + if (sortKey === key) { + setSortDirection((current) => (current === "asc" ? "desc" : "asc")); + return; + } + setSortKey(key); + setSortDirection("asc"); + } + + function openCreateDialog() { + setSelectedCustomer(null); + setFormError(""); + setFormOpen(true); + } + + function openEditDialog(customer: Customer) { + setSelectedCustomer(customer); + setFormError(""); + setFormOpen(true); + } + + async function saveCustomer(payload: CustomerPayload) { + setPending(true); + setFormError(""); + try { + if (selectedCustomer) { + const response = await api.put(`/customers/${selectedCustomer.id}`, payload); + setCustomers((current) => current.map((customer) => customer.id === selectedCustomer.id ? response.data : customer)); + } else { + const response = await api.post("/customers", payload); + setCustomers((current) => [response.data, ...current]); + } + setFormOpen(false); + } catch (err) { + setFormError(getErrorMessage(err)); + } finally { + setPending(false); + } + } + + async function confirmDelete() { + if (!deleteCustomer) { + return; + } + setPending(true); + setDeleteError(""); + try { + await api.delete(`/customers/${deleteCustomer.id}`); + setCustomers((current) => current.filter((customer) => customer.id !== deleteCustomer.id)); + setDeleteCustomer(null); + } catch (err) { + setDeleteError(getErrorMessage(err)); + } finally { + setPending(false); + } + } + + return ( +
+
+
+

Kunden

+

{filteredCustomers.length} von {customers.length} Kunden

+
+ {hasPermission(currentUser, "customers.create") && ( + + )} +
+ +
+ { setSearch(value); setPage(1); }} placeholder="Kunden suchen" /> + + +
+ + customer.id} + sortKey={sortKey} + sortDirection={sortDirection} + loading={loading} + error={error} + emptyTitle="Keine Kunden gefunden" + emptyDescription="Passe Suche oder Filter an oder erstelle einen neuen Kunden." + onSort={handleSort} + /> + +
+ Seite {currentPage} von {pageCount} +
+ + +
+
+ + + + { + if (!open) { + setDeleteCustomer(null); + setDeleteError(""); + } + }} + onConfirm={confirmDelete} + > + {deleteCustomer && ( +
+
+
Kundennummer
+
{deleteCustomer.customer_number}
+
+
+
Firma
+
{deleteCustomer.company_name}
+
+ {deleteError &&

{deleteError}

} +
+ )} +
+
+ ); +} diff --git a/frontend/athena/app/dashboard/page.tsx b/frontend/athena/app/dashboard/page.tsx index fb4dde8..bb1d1b2 100644 --- a/frontend/athena/app/dashboard/page.tsx +++ b/frontend/athena/app/dashboard/page.tsx @@ -1,15 +1,111 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; + +import SummaryCard from "@/components/common/SummaryCard"; +import { api } from "@/lib/api"; +import type { DashboardSummary, EmptyWidget } from "@/types/dashboard"; + +function getErrorMessage(error: unknown) { + if (typeof error === "object" && error !== null && "response" in error) { + const response = (error as { response?: { data?: { detail?: string } } }).response; + return response?.data?.detail ?? "Dashboard konnte nicht geladen werden"; + } + return "Dashboard konnte nicht geladen werden"; +} + export default function DashboardPage() { - return ( -
-
-

- Olympus Dashboard -

- -

- Willkommen bei Funktechnik Schubert. -

+ const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const loadSummary = useCallback(async () => { + try { + const response = await api.get("/dashboard/summary"); + setSummary(response.data); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + queueMicrotask(() => { + void loadSummary(); + }); + }, [loadSummary]); + + if (loading) { + return
Dashboard wird geladen...
; + } + + if (error || !summary) { + return
{error || "Keine Dashboarddaten verfügbar"}
; + } + + const cards = [...summary.customers, ...summary.users, ...summary.roles]; + + return ( +
+
+

Dashboard

+

Operative Übersicht für Olympus CRM

+
+ + {cards.length > 0 ? ( +
+ {cards.map((card) => ( + + ))}
-
- ); - } \ No newline at end of file + ) : ( + + )} + +
+
+

Letzte Kunden

+ {summary.latest_customers.length > 0 && ( + + Alle Kunden + + )} +
+ + {summary.latest_customers.length === 0 ? ( +

Noch keine Kundendaten vorhanden.

+ ) : ( +
+ {summary.latest_customers.map((customer) => ( + +
+

{customer.company_name}

+

{customer.customer_number}

+
+ {customer.status} + + ))} +
+ )} +
+ +
+ + + + +
+ + ); +} + +function EmptyPanel({ widget }: { widget: EmptyWidget }) { + return ( +
+

{widget.title}

+

{widget.message}

+
+ ); +} diff --git a/frontend/athena/components/Sidebar.tsx b/frontend/athena/components/Sidebar.tsx index 6c8cc97..6b93c90 100644 --- a/frontend/athena/components/Sidebar.tsx +++ b/frontend/athena/components/Sidebar.tsx @@ -22,6 +22,7 @@ const menu = [ icon: LayoutDashboard, name: "Dashboard", href: "/dashboard", + permission: "dashboard.read", }, { icon: Users, @@ -39,6 +40,7 @@ const menu = [ icon: Users, name: "Kunden", href: "/customers", + permission: "customers.read", }, { icon: Wrench, diff --git a/frontend/athena/components/common/DetailSection.tsx b/frontend/athena/components/common/DetailSection.tsx new file mode 100644 index 0000000..f1998fe --- /dev/null +++ b/frontend/athena/components/common/DetailSection.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; + +type Props = { + title: string; + actions?: ReactNode; + children: ReactNode; +}; + +export default function DetailSection({ title, actions, children }: Props) { + return ( +
+
+

{title}

+ {actions} +
+ {children} +
+ ); +} diff --git a/frontend/athena/components/common/SummaryCard.tsx b/frontend/athena/components/common/SummaryCard.tsx new file mode 100644 index 0000000..ad64d3f --- /dev/null +++ b/frontend/athena/components/common/SummaryCard.tsx @@ -0,0 +1,13 @@ +type Props = { + label: string; + value: number | string; +}; + +export default function SummaryCard({ label, value }: Props) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/frontend/athena/components/customers/CustomerContactFormDialog.tsx b/frontend/athena/components/customers/CustomerContactFormDialog.tsx new file mode 100644 index 0000000..a5b602b --- /dev/null +++ b/frontend/athena/components/customers/CustomerContactFormDialog.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { FormEvent, ReactNode } from "react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { CustomerContact, CustomerContactPayload } from "@/types/customer"; + +const emptyContact: CustomerContactPayload = { + first_name: "", + last_name: "", + position: "", + email: "", + phone: "", + mobile: "", + is_primary: false, + notes: "", +}; + +type Props = { + open: boolean; + contact?: CustomerContact | null; + pending?: boolean; + serverError?: string; + onOpenChange: (open: boolean) => void; + onSubmit: (payload: CustomerContactPayload) => Promise; +}; + +export default function CustomerContactFormDialog({ + open, + contact, + pending = false, + serverError, + onOpenChange, + onSubmit, +}: Props) { + const initialForm = contact + ? { + first_name: contact.first_name, + last_name: contact.last_name, + position: contact.position, + email: contact.email, + phone: contact.phone, + mobile: contact.mobile, + is_primary: contact.is_primary, + notes: contact.notes, + } + : emptyContact; + + return ( + + + {open && ( + onOpenChange(false)} + onSubmit={onSubmit} + /> + )} + + + ); +} + +function ContactForm({ + initialForm, + pending, + serverError, + onCancel, + onSubmit, +}: { + initialForm: CustomerContactPayload; + pending: boolean; + serverError?: string; + onCancel: () => void; + onSubmit: (payload: CustomerContactPayload) => Promise; +}) { + const [form, setForm] = useState(initialForm); + const errors = useMemo(() => ({ + name: form.first_name.trim() || form.last_name.trim() + ? "" + : "Vorname oder Nachname erforderlich", + email: !form.email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email) + ? "" + : "Gültige E-Mail erforderlich", + }), [form.email, form.first_name, form.last_name]); + const valid = Object.values(errors).every((error) => !error); + + function update(key: K, value: CustomerContactPayload[K]) { + setForm((current) => ({ ...current, [key]: value })); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!valid) { + return; + } + + await onSubmit({ + ...form, + email: form.email?.trim() || null, + }); + } + + return ( +
+ + Ansprechpartner {initialForm.first_name || initialForm.last_name ? "bearbeiten" : "hinzufügen"} + Ansprechpartnerdaten und Primärkennzeichnung pflegen. + + +
+ + update("first_name", event.target.value)} /> + + + update("last_name", event.target.value)} /> + + + update("position", event.target.value)} /> + + + update("email", event.target.value)} /> + + + update("phone", event.target.value)} /> + + + update("mobile", event.target.value)} /> + + +
+ + + update("notes", event.target.value)} /> + + + {serverError &&

{serverError}

} + + + + + +
+ ); +} + +function Field({ + label, + error, + children, +}: { + label: string; + error?: string; + children: ReactNode; +}) { + return ( +
+ + {children} + {error &&

{error}

} +
+ ); +} diff --git a/frontend/athena/components/customers/CustomerFormDialog.tsx b/frontend/athena/components/customers/CustomerFormDialog.tsx new file mode 100644 index 0000000..559de7a --- /dev/null +++ b/frontend/athena/components/customers/CustomerFormDialog.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { FormEvent } from "react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { Customer, CustomerPayload, CustomerStatus, CustomerType } from "@/types/customer"; + +const customerTypes: Array<{ value: CustomerType; label: string }> = [ + { value: "company", label: "Unternehmen" }, + { value: "private", label: "Privat" }, + { value: "public_sector", label: "Öffentlicher Sektor" }, + { value: "partner", label: "Partner" }, + { value: "supplier", label: "Lieferant" }, +]; + +const statuses: Array<{ value: CustomerStatus; label: string }> = [ + { value: "lead", label: "Lead" }, + { value: "active", label: "Aktiv" }, + { value: "inactive", label: "Inaktiv" }, + { value: "blocked", label: "Gesperrt" }, + { value: "archived", label: "Archiviert" }, +]; + +const emptyCustomer: CustomerPayload = { + customer_number: "", + company_name: "", + legal_name: "", + customer_type: "company", + status: "lead", + industry: "", + website: "", + email: "", + phone: "", + tax_number: "", + vat_id: "", + notes: "", + addresses: [ + { + type: "primary", + street: "", + postal_code: "", + city: "", + state: "", + country: "Deutschland", + is_primary: true, + }, + ], +}; + +type Props = { + open: boolean; + customer?: Customer | null; + pending?: boolean; + serverError?: string; + onOpenChange: (open: boolean) => void; + onSubmit: (payload: CustomerPayload) => Promise; +}; + +export default function CustomerFormDialog({ + open, + customer, + pending = false, + serverError, + onOpenChange, + onSubmit, +}: Props) { + const initialForm = customer + ? { + customer_number: customer.customer_number, + company_name: customer.company_name, + legal_name: customer.legal_name, + customer_type: customer.customer_type, + status: customer.status, + industry: customer.industry, + website: customer.website, + email: customer.email, + phone: customer.phone, + tax_number: customer.tax_number, + vat_id: customer.vat_id, + notes: customer.notes, + addresses: customer.addresses.length > 0 + ? customer.addresses.map((address) => ({ + type: address.type, + street: address.street, + postal_code: address.postal_code, + city: address.city, + state: address.state, + country: address.country, + is_primary: address.is_primary, + })) + : emptyCustomer.addresses, + } + : emptyCustomer; + + return ( + + + {open && ( + onOpenChange(false)} + onSubmit={onSubmit} + /> + )} + + + ); +} + +function CustomerForm({ + initialForm, + pending, + serverError, + onCancel, + onSubmit, +}: { + initialForm: CustomerPayload; + pending: boolean; + serverError?: string; + onCancel: () => void; + onSubmit: (payload: CustomerPayload) => Promise; +}) { + const [form, setForm] = useState(initialForm); + + const errors = useMemo(() => ({ + customer_number: form.customer_number.trim() ? "" : "Kundennummer erforderlich", + company_name: form.company_name.trim() ? "" : "Firmenname erforderlich", + email: !form.email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email) + ? "" + : "Gültige E-Mail erforderlich", + }), [form.company_name, form.customer_number, form.email]); + + const valid = Object.values(errors).every((error) => !error); + + function update(key: K, value: CustomerPayload[K]) { + setForm((current) => ({ ...current, [key]: value })); + } + + function updateAddress(index: number, key: keyof CustomerPayload["addresses"][number], value: string | boolean) { + setForm((current) => ({ + ...current, + addresses: current.addresses.map((address, itemIndex) => ( + itemIndex === index + ? { ...address, [key]: value } + : key === "is_primary" && value === true + ? { ...address, is_primary: false } + : address + )), + })); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!valid) { + return; + } + + await onSubmit({ + ...form, + website: form.website?.trim() || null, + email: form.email?.trim() || null, + addresses: form.addresses.map((address, index) => ({ + ...address, + is_primary: address.is_primary || index === 0, + })), + }); + } + + return ( +
+ + Kunde {initialForm.customer_number ? "bearbeiten" : "erstellen"} + + Stammdaten und primäre Adresse des Kunden verwalten. + + + +
+ + update("customer_number", event.target.value)} /> + + + update("company_name", event.target.value)} /> + + + update("legal_name", event.target.value)} /> + + + update("industry", event.target.value)} /> + + + + + + + + + update("email", event.target.value)} /> + + + update("phone", event.target.value)} /> + + + update("website", event.target.value)} /> + + + update("vat_id", event.target.value)} /> + +
+ +
+

Primäradresse

+ {form.addresses.slice(0, 1).map((address, index) => ( +
+ + updateAddress(index, "street", event.target.value)} /> + + + updateAddress(index, "postal_code", event.target.value)} /> + + + updateAddress(index, "city", event.target.value)} /> + + + updateAddress(index, "country", event.target.value)} /> + +
+ ))} +
+ + + update("notes", event.target.value)} /> + + + {serverError &&

{serverError}

} + + + + + +
+ ); +} + +function Field({ + label, + error, + children, +}: { + label: string; + error?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {error &&

{error}

} +
+ ); +} diff --git a/frontend/athena/components/customers/CustomerStatusBadge.tsx b/frontend/athena/components/customers/CustomerStatusBadge.tsx new file mode 100644 index 0000000..c98fd4e --- /dev/null +++ b/frontend/athena/components/customers/CustomerStatusBadge.tsx @@ -0,0 +1,26 @@ +import { cn } from "@/lib/utils"; +import type { CustomerStatus } from "@/types/customer"; + +const labels: Record = { + lead: "Lead", + active: "Aktiv", + inactive: "Inaktiv", + blocked: "Gesperrt", + archived: "Archiviert", +}; + +const styles: Record = { + lead: "bg-sky-50 text-sky-700 ring-sky-600/20", + active: "bg-emerald-50 text-emerald-700 ring-emerald-600/20", + inactive: "bg-slate-100 text-slate-600 ring-slate-500/20", + blocked: "bg-red-50 text-red-700 ring-red-600/20", + archived: "bg-zinc-100 text-zinc-600 ring-zinc-500/20", +}; + +export default function CustomerStatusBadge({ status }: { status: CustomerStatus }) { + return ( + + {labels[status]} + + ); +} diff --git a/frontend/athena/proxy.ts b/frontend/athena/proxy.ts index 4e9dc49..f173a75 100644 --- a/frontend/athena/proxy.ts +++ b/frontend/athena/proxy.ts @@ -21,5 +21,6 @@ export const config = { "/dashboard/:path*", "/users/:path*", "/roles/:path*", + "/customers/:path*", ], }; diff --git a/frontend/athena/types/customer.ts b/frontend/athena/types/customer.ts new file mode 100644 index 0000000..df840a8 --- /dev/null +++ b/frontend/athena/types/customer.ts @@ -0,0 +1,89 @@ +export type CustomerStatus = "lead" | "active" | "inactive" | "blocked" | "archived"; +export type CustomerType = "company" | "private" | "public_sector" | "partner" | "supplier"; +export type AddressType = "billing" | "shipping" | "primary" | "other"; + +export interface CustomerAddress { + id: number; + customer_id: number; + type: AddressType; + street: string; + postal_code: string; + city: string; + state: string; + country: string; + is_primary: boolean; + created_at: string; + updated_at: string; +} + +export interface CustomerContact { + id: number; + customer_id: number; + first_name: string; + last_name: string; + position: string; + email: string; + phone: string; + mobile: string; + is_primary: boolean; + notes: string; + created_at: string; + updated_at: string; +} + +export interface Customer { + id: number; + customer_number: string; + company_name: string; + legal_name: string; + customer_type: CustomerType; + status: CustomerStatus; + industry: string; + website: string; + email: string; + phone: string; + tax_number: string; + vat_id: string; + notes: string; + addresses: CustomerAddress[]; + contacts: CustomerContact[]; + created_at: string; + updated_at: string; +} + +export type CustomerPayload = { + customer_number: string; + company_name: string; + legal_name: string; + customer_type: CustomerType; + status: CustomerStatus; + industry: string; + website?: string | null; + email?: string | null; + phone: string; + tax_number: string; + vat_id: string; + notes: string; + addresses: CustomerAddressPayload[]; +}; + +export type CustomerAddressPayload = { + type: AddressType; + street: string; + postal_code: string; + city: string; + state: string; + country: string; + is_primary: boolean; +}; + +export type CustomerContactPayload = { + first_name: string; + last_name: string; + position: string; + email?: string | null; + phone: string; + mobile: string; + is_primary: boolean; + notes: string; +}; diff --git a/frontend/athena/types/dashboard.ts b/frontend/athena/types/dashboard.ts new file mode 100644 index 0000000..b188679 --- /dev/null +++ b/frontend/athena/types/dashboard.ts @@ -0,0 +1,22 @@ +import type { Customer } from "@/types/customer"; + +export interface MetricCard { + label: string; + value: number; +} + +export interface EmptyWidget { + title: string; + message: string; +} + +export interface DashboardSummary { + customers: MetricCard[]; + latest_customers: Customer[]; + users: MetricCard[]; + roles: MetricCard[]; + activities: EmptyWidget; + tasks: EmptyWidget; + tickets: EmptyWidget; + projects: EmptyWidget; +}