feat(customers): add dashboard and customer management
This commit is contained in:
parent
694b7bd09a
commit
92cb8d1286
28 changed files with 2521 additions and 13 deletions
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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/<module>`
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
208
backend/hermes/app/api/customers.py
Normal file
208
backend/hermes/app/api/customers.py
Normal file
|
|
@ -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)
|
||||
80
backend/hermes/app/api/dashboard.py
Normal file
80
backend/hermes/app/api/dashboard.py
Normal file
|
|
@ -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.",
|
||||
),
|
||||
)
|
||||
|
|
@ -21,6 +21,7 @@ class Base(DeclarativeBase):
|
|||
|
||||
# <<< HIER IMPORTIEREN >>>
|
||||
import app.models.rbac
|
||||
import app.models.customer
|
||||
import app.models.user
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
92
backend/hermes/app/models/customer.py
Normal file
92
backend/hermes/app/models/customer.py
Normal file
|
|
@ -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")
|
||||
269
backend/hermes/app/repositories/customer_repository.py
Normal file
269
backend/hermes/app/repositories/customer_repository.py
Normal file
|
|
@ -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
|
||||
131
backend/hermes/app/schemas/customer.py
Normal file
131
backend/hermes/app/schemas/customer.py
Normal file
|
|
@ -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)
|
||||
24
backend/hermes/app/schemas/dashboard.py
Normal file
24
backend/hermes/app/schemas/dashboard.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
29
frontend/athena/app/api/customers/[id]/contacts/route.ts
Normal file
29
frontend/athena/app/api/customers/[id]/contacts/route.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
39
frontend/athena/app/api/customers/[id]/route.ts
Normal file
39
frontend/athena/app/api/customers/[id]/route.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
18
frontend/athena/app/api/customers/route.ts
Normal file
18
frontend/athena/app/api/customers/route.ts
Normal file
|
|
@ -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");
|
||||
}
|
||||
7
frontend/athena/app/api/dashboard/summary/route.ts
Normal file
7
frontend/athena/app/api/dashboard/summary/route.ts
Normal file
|
|
@ -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");
|
||||
}
|
||||
345
frontend/athena/app/customers/[id]/page.tsx
Normal file
345
frontend/athena/app/customers/[id]/page.tsx
Normal file
|
|
@ -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<Customer | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(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<CustomerContact | null>(null);
|
||||
const [deleteContact, setDeleteContact] = useState<CustomerContact | null>(null);
|
||||
const [deleteCustomerOpen, setDeleteCustomerOpen] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
|
||||
const loadCustomer = useCallback(async () => {
|
||||
try {
|
||||
const [customerResponse, meResponse] = await Promise.all([
|
||||
api.get<Customer>(`/customers/${params.id}`),
|
||||
api.get<CurrentUser>("/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<Customer>(`/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<CustomerContact>(`/customers/${customer.id}/contacts/${selectedContact.id}`, payload);
|
||||
} else {
|
||||
await api.post<CustomerContact>(`/customers/${customer.id}/contacts`, payload);
|
||||
}
|
||||
const response = await api.get<Customer>(`/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<Customer>(`/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 <div className="rounded-lg border bg-white p-8 text-slate-500">Kunde wird geladen...</div>;
|
||||
}
|
||||
|
||||
if (error || !customer) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link href="/customers" className="inline-flex items-center gap-2 text-sm text-slate-500 hover:text-slate-900">
|
||||
<ArrowLeft size={16} />
|
||||
Zurück zur Kundenliste
|
||||
</Link>
|
||||
<div className="rounded-lg border bg-white p-8 text-red-600">{error || "Kunde nicht gefunden"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<Link href="/customers" className="inline-flex items-center gap-2 text-sm text-slate-500 hover:text-slate-900">
|
||||
<ArrowLeft size={16} />
|
||||
Zurück zur Kundenliste
|
||||
</Link>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-slate-950">{customer.company_name}</h1>
|
||||
<CustomerStatusBadge status={customer.status} />
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">{customer.customer_number}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{hasPermission(currentUser, "customers.update") && (
|
||||
<Button type="button" onClick={() => setFormOpen(true)}>
|
||||
<Edit size={16} />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission(currentUser, "customers.delete") && (
|
||||
<Button type="button" variant="destructive" onClick={() => setDeleteCustomerOpen(true)}>
|
||||
<Trash2 size={16} />
|
||||
Löschen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetailSection title="Stammdaten">
|
||||
<dl className="grid gap-5 md:grid-cols-2">
|
||||
<Detail label="Kundennummer" value={customer.customer_number} />
|
||||
<Detail label="Typ" value={customer.customer_type} />
|
||||
<Detail label="Rechtlicher Name" value={customer.legal_name || "-"} />
|
||||
<Detail label="Branche" value={customer.industry || "-"} />
|
||||
<Detail label="E-Mail" value={customer.email || "-"} />
|
||||
<Detail label="Telefon" value={customer.phone || "-"} />
|
||||
<Detail label="Website" value={customer.website || "-"} />
|
||||
<Detail label="USt-ID" value={customer.vat_id || "-"} />
|
||||
</dl>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Adressen">
|
||||
{customer.addresses.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Noch keine Adressen vorhanden.</p>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{customer.addresses.map((address) => (
|
||||
<div key={address.id} className="rounded-lg border p-4 text-sm">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="font-medium capitalize">{address.type}</span>
|
||||
{address.is_primary && <span className="rounded-full bg-slate-100 px-2 py-1 text-xs">Primär</span>}
|
||||
</div>
|
||||
<p>{address.street || "-"}</p>
|
||||
<p>{address.postal_code} {address.city}</p>
|
||||
<p>{address.state}</p>
|
||||
<p>{address.country}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection
|
||||
title="Ansprechpartner"
|
||||
actions={hasPermission(currentUser, "customers.update") && (
|
||||
<Button type="button" size="sm" onClick={() => openContactDialog(null)}>
|
||||
<Plus size={16} />
|
||||
Ansprechpartner
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{customer.contacts.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Noch keine Ansprechpartner vorhanden.</p>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{customer.contacts.map((contact) => (
|
||||
<div key={contact.id} className="rounded-lg border p-4 text-sm">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-slate-950">{contact.first_name} {contact.last_name}</p>
|
||||
<p className="text-slate-500">{contact.position || "-"}</p>
|
||||
</div>
|
||||
{contact.is_primary && <span className="rounded-full bg-slate-100 px-2 py-1 text-xs">Primär</span>}
|
||||
</div>
|
||||
<p>{contact.email || "-"}</p>
|
||||
<p>{contact.phone || contact.mobile || "-"}</p>
|
||||
{hasPermission(currentUser, "customers.update") && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => openContactDialog(contact)}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" size="sm" onClick={() => setDeleteContact(contact)}>
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Notizen">
|
||||
<p className="whitespace-pre-wrap text-sm text-slate-700">{customer.notes || "Noch keine Notizen vorhanden."}</p>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Metadaten">
|
||||
<dl className="grid gap-5 md:grid-cols-2">
|
||||
<Detail label="Erstellt" value={formatDateTime(customer.created_at)} />
|
||||
<Detail label="Aktualisiert" value={formatDateTime(customer.updated_at)} />
|
||||
</dl>
|
||||
</DetailSection>
|
||||
|
||||
<CustomerFormDialog
|
||||
open={formOpen}
|
||||
customer={customer}
|
||||
pending={pending}
|
||||
serverError={formError}
|
||||
onOpenChange={setFormOpen}
|
||||
onSubmit={saveCustomer}
|
||||
/>
|
||||
|
||||
<CustomerContactFormDialog
|
||||
open={contactOpen}
|
||||
contact={selectedContact}
|
||||
pending={pending}
|
||||
serverError={contactError}
|
||||
onOpenChange={setContactOpen}
|
||||
onSubmit={saveContact}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteContact)}
|
||||
title="Ansprechpartner löschen"
|
||||
description="Möchten Sie den Ansprechpartner wirklich löschen?"
|
||||
pending={pending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleteContact(null);
|
||||
setDeleteError("");
|
||||
}
|
||||
}}
|
||||
onConfirm={confirmContactDelete}
|
||||
>
|
||||
{deleteContact && <p className="text-sm font-medium">{deleteContact.first_name} {deleteContact.last_name}</p>}
|
||||
{deleteError && <p className="mt-2 text-sm text-red-600">{deleteError}</p>}
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteCustomerOpen}
|
||||
title="Kunde löschen"
|
||||
description="Möchten Sie den Kunden wirklich löschen?"
|
||||
pending={pending}
|
||||
onOpenChange={(open) => {
|
||||
setDeleteCustomerOpen(open);
|
||||
if (!open) {
|
||||
setDeleteError("");
|
||||
}
|
||||
}}
|
||||
onConfirm={confirmCustomerDelete}
|
||||
>
|
||||
<p className="text-sm font-medium">{customer.customer_number} · {customer.company_name}</p>
|
||||
{deleteError && <p className="mt-2 text-sm text-red-600">{deleteError}</p>}
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-sm text-slate-500">{label}</dt>
|
||||
<dd className="mt-1 font-medium text-slate-950">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
309
frontend/athena/app/customers/page.tsx
Normal file
309
frontend/athena/app/customers/page.tsx
Normal file
|
|
@ -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<Customer[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(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<CustomerStatus | "all">("all");
|
||||
const [typeFilter, setTypeFilter] = useState<CustomerType | "all">("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<Customer | null>(null);
|
||||
const [deleteCustomer, setDeleteCustomer] = useState<Customer | null>(null);
|
||||
|
||||
const loadCustomers = useCallback(async () => {
|
||||
try {
|
||||
const [customersResponse, meResponse] = await Promise.all([
|
||||
api.get<Customer[]>("/customers"),
|
||||
api.get<CurrentUser>("/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<Customer>[] = [
|
||||
{ key: "customer_number", label: "Kundennummer", sortable: true, render: (customer) => customer.customer_number },
|
||||
{ key: "company_name", label: "Firmenname", sortable: true, render: (customer) => <span className="font-medium text-slate-950">{customer.company_name}</span> },
|
||||
{ key: "customer_type", label: "Typ", sortable: true, render: (customer) => customer.customer_type },
|
||||
{ key: "status", label: "Status", sortable: true, render: (customer) => <CustomerStatusBadge status={customer.status} /> },
|
||||
{ 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) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link href={`/customers/${customer.id}`} className={buttonVariants({ variant: "ghost", size: "icon-sm" })} title="Details">
|
||||
<Eye size={16} />
|
||||
</Link>
|
||||
{hasPermission(currentUser, "customers.update") && (
|
||||
<Button type="button" variant="ghost" size="icon-sm" title="Bearbeiten" onClick={() => openEditDialog(customer)}>
|
||||
<Edit size={16} />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission(currentUser, "customers.delete") && (
|
||||
<Button type="button" variant="destructive" size="icon-sm" title="Löschen" onClick={() => setDeleteCustomer(customer)}>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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<Customer>(`/customers/${selectedCustomer.id}`, payload);
|
||||
setCustomers((current) => current.map((customer) => customer.id === selectedCustomer.id ? response.data : customer));
|
||||
} else {
|
||||
const response = await api.post<Customer>("/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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-950">Kunden</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">{filteredCustomers.length} von {customers.length} Kunden</p>
|
||||
</div>
|
||||
{hasPermission(currentUser, "customers.create") && (
|
||||
<Button type="button" onClick={openCreateDialog}>
|
||||
<Plus size={16} />
|
||||
Neuer Kunde
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row lg:items-center">
|
||||
<SearchInput value={search} onChange={(value) => { setSearch(value); setPage(1); }} placeholder="Kunden suchen" />
|
||||
<select value={statusFilter} onChange={(event) => { setStatusFilter(event.target.value as CustomerStatus | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm">
|
||||
<option value="all">Alle Status</option>
|
||||
<option value="lead">Lead</option>
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="inactive">Inaktiv</option>
|
||||
<option value="blocked">Gesperrt</option>
|
||||
<option value="archived">Archiviert</option>
|
||||
</select>
|
||||
<select value={typeFilter} onChange={(event) => { setTypeFilter(event.target.value as CustomerType | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm">
|
||||
<option value="all">Alle Typen</option>
|
||||
<option value="company">Unternehmen</option>
|
||||
<option value="private">Privat</option>
|
||||
<option value="public_sector">Öffentlicher Sektor</option>
|
||||
<option value="partner">Partner</option>
|
||||
<option value="supplier">Lieferant</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={pageCustomers}
|
||||
rowKey={(customer) => 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}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-slate-600">
|
||||
<span>Seite {currentPage} von {pageCount}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" disabled={currentPage === 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>Zurück</Button>
|
||||
<Button type="button" variant="outline" disabled={currentPage === pageCount} onClick={() => setPage((current) => Math.min(pageCount, current + 1))}>Weiter</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CustomerFormDialog
|
||||
open={formOpen}
|
||||
customer={selectedCustomer}
|
||||
pending={pending}
|
||||
serverError={formError}
|
||||
onOpenChange={setFormOpen}
|
||||
onSubmit={saveCustomer}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteCustomer)}
|
||||
title="Kunde löschen"
|
||||
description="Möchten Sie den Kunden wirklich löschen?"
|
||||
pending={pending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleteCustomer(null);
|
||||
setDeleteError("");
|
||||
}
|
||||
}}
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
{deleteCustomer && (
|
||||
<dl className="grid gap-2 text-sm">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-slate-500">Kundennummer</dt>
|
||||
<dd className="font-medium">{deleteCustomer.customer_number}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-slate-500">Firma</dt>
|
||||
<dd className="font-medium">{deleteCustomer.company_name}</dd>
|
||||
</div>
|
||||
{deleteError && <p className="text-red-600">{deleteError}</p>}
|
||||
</dl>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +1,111 @@
|
|||
export default function DashboardPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-100">
|
||||
<div className="mx-auto max-w-7xl p-8">
|
||||
<h1 className="text-4xl font-bold">
|
||||
Olympus Dashboard
|
||||
</h1>
|
||||
"use client";
|
||||
|
||||
<p className="mt-4 text-slate-600">
|
||||
Willkommen bei Funktechnik Schubert.
|
||||
</p>
|
||||
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() {
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const loadSummary = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get<DashboardSummary>("/dashboard/summary");
|
||||
setSummary(response.data);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadSummary();
|
||||
});
|
||||
}, [loadSummary]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="rounded-lg border bg-white p-8 text-slate-500">Dashboard wird geladen...</div>;
|
||||
}
|
||||
|
||||
if (error || !summary) {
|
||||
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Keine Dashboarddaten verfügbar"}</div>;
|
||||
}
|
||||
|
||||
const cards = [...summary.customers, ...summary.users, ...summary.roles];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-950">Dashboard</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">Operative Übersicht für Olympus CRM</p>
|
||||
</div>
|
||||
|
||||
{cards.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{cards.map((card) => (
|
||||
<SummaryCard key={card.label} label={card.label} value={card.value} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyPanel widget={{ title: "Keine Kennzahlen", message: "Für deine Berechtigungen sind aktuell keine Kennzahlen verfügbar." }} />
|
||||
)}
|
||||
|
||||
<section className="rounded-lg border bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-950">Letzte Kunden</h2>
|
||||
{summary.latest_customers.length > 0 && (
|
||||
<Link href="/customers" className="text-sm font-medium text-slate-600 hover:text-slate-950">
|
||||
Alle Kunden
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{summary.latest_customers.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Noch keine Kundendaten vorhanden.</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{summary.latest_customers.map((customer) => (
|
||||
<Link key={customer.id} href={`/customers/${customer.id}`} className="flex items-center justify-between py-3 hover:bg-slate-50">
|
||||
<div>
|
||||
<p className="font-medium text-slate-950">{customer.company_name}</p>
|
||||
<p className="text-sm text-slate-500">{customer.customer_number}</p>
|
||||
</div>
|
||||
<span className="text-sm text-slate-500">{customer.status}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<EmptyPanel widget={summary.activities} />
|
||||
<EmptyPanel widget={summary.tasks} />
|
||||
<EmptyPanel widget={summary.tickets} />
|
||||
<EmptyPanel widget={summary.projects} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPanel({ widget }: { widget: EmptyWidget }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-5">
|
||||
<h2 className="font-semibold text-slate-950">{widget.title}</h2>
|
||||
<p className="mt-2 text-sm text-slate-500">{widget.message}</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
19
frontend/athena/components/common/DetailSection.tsx
Normal file
19
frontend/athena/components/common/DetailSection.tsx
Normal file
|
|
@ -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 (
|
||||
<section className="rounded-lg border bg-white p-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold text-slate-950">{title}</h2>
|
||||
{actions}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
13
frontend/athena/components/common/SummaryCard.tsx
Normal file
13
frontend/athena/components/common/SummaryCard.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
type Props = {
|
||||
label: string;
|
||||
value: number | string;
|
||||
};
|
||||
|
||||
export default function SummaryCard({ label, value }: Props) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-5">
|
||||
<p className="text-sm text-slate-500">{label}</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-slate-950">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<void>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
{open && (
|
||||
<ContactForm
|
||||
key={contact?.id ?? "new"}
|
||||
initialForm={initialForm}
|
||||
pending={pending}
|
||||
serverError={serverError}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactForm({
|
||||
initialForm,
|
||||
pending,
|
||||
serverError,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
initialForm: CustomerContactPayload;
|
||||
pending: boolean;
|
||||
serverError?: string;
|
||||
onCancel: () => void;
|
||||
onSubmit: (payload: CustomerContactPayload) => Promise<void>;
|
||||
}) {
|
||||
const [form, setForm] = useState<CustomerContactPayload>(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<K extends keyof CustomerContactPayload>(key: K, value: CustomerContactPayload[K]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit({
|
||||
...form,
|
||||
email: form.email?.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ansprechpartner {initialForm.first_name || initialForm.last_name ? "bearbeiten" : "hinzufügen"}</DialogTitle>
|
||||
<DialogDescription>Ansprechpartnerdaten und Primärkennzeichnung pflegen.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Vorname" error={errors.name}>
|
||||
<Input value={form.first_name} onChange={(event) => update("first_name", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="Nachname">
|
||||
<Input value={form.last_name} onChange={(event) => update("last_name", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="Position">
|
||||
<Input value={form.position} onChange={(event) => update("position", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="E-Mail" error={errors.email}>
|
||||
<Input type="email" value={form.email ?? ""} onChange={(event) => update("email", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="Telefon">
|
||||
<Input value={form.phone} onChange={(event) => update("phone", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="Mobil">
|
||||
<Input value={form.mobile} onChange={(event) => update("mobile", event.target.value)} />
|
||||
</Field>
|
||||
<label className="flex h-8 items-center gap-2 rounded-lg border px-2.5 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_primary}
|
||||
onChange={(event) => update("is_primary", event.target.checked)}
|
||||
/>
|
||||
Primärer Ansprechpartner
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Field label="Notizen">
|
||||
<Input value={form.notes} onChange={(event) => update("notes", event.target.value)} />
|
||||
</Field>
|
||||
|
||||
{serverError && <p className="text-sm text-red-600">{serverError}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" disabled={pending} onClick={onCancel}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={!valid || pending}>
|
||||
{pending ? "Speichern..." : "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
error?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
283
frontend/athena/components/customers/CustomerFormDialog.tsx
Normal file
283
frontend/athena/components/customers/CustomerFormDialog.tsx
Normal file
|
|
@ -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<void>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
{open && (
|
||||
<CustomerForm
|
||||
key={customer?.id ?? "new"}
|
||||
initialForm={initialForm}
|
||||
pending={pending}
|
||||
serverError={serverError}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerForm({
|
||||
initialForm,
|
||||
pending,
|
||||
serverError,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
initialForm: CustomerPayload;
|
||||
pending: boolean;
|
||||
serverError?: string;
|
||||
onCancel: () => void;
|
||||
onSubmit: (payload: CustomerPayload) => Promise<void>;
|
||||
}) {
|
||||
const [form, setForm] = useState<CustomerPayload>(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<K extends keyof CustomerPayload>(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<HTMLFormElement>) {
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kunde {initialForm.customer_number ? "bearbeiten" : "erstellen"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Stammdaten und primäre Adresse des Kunden verwalten.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Kundennummer" error={errors.customer_number}>
|
||||
<Input value={form.customer_number} onChange={(event) => update("customer_number", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="Firmenname" error={errors.company_name}>
|
||||
<Input value={form.company_name} onChange={(event) => update("company_name", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="Rechtlicher Name">
|
||||
<Input value={form.legal_name} onChange={(event) => update("legal_name", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="Branche">
|
||||
<Input value={form.industry} onChange={(event) => update("industry", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="Typ">
|
||||
<select value={form.customer_type} onChange={(event) => update("customer_type", event.target.value as CustomerType)} className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm">
|
||||
{customerTypes.map((type) => <option key={type.value} value={type.value}>{type.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Status">
|
||||
<select value={form.status} onChange={(event) => update("status", event.target.value as CustomerStatus)} className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm">
|
||||
{statuses.map((status) => <option key={status.value} value={status.value}>{status.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="E-Mail" error={errors.email}>
|
||||
<Input type="email" value={form.email ?? ""} onChange={(event) => update("email", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="Telefon">
|
||||
<Input value={form.phone} onChange={(event) => update("phone", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="Website">
|
||||
<Input value={form.website ?? ""} onChange={(event) => update("website", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="USt-ID">
|
||||
<Input value={form.vat_id} onChange={(event) => update("vat_id", event.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-4 text-sm font-semibold">Primäradresse</h3>
|
||||
{form.addresses.slice(0, 1).map((address, index) => (
|
||||
<div key={index} className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Straße">
|
||||
<Input value={address.street} onChange={(event) => updateAddress(index, "street", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="PLZ">
|
||||
<Input value={address.postal_code} onChange={(event) => updateAddress(index, "postal_code", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="Ort">
|
||||
<Input value={address.city} onChange={(event) => updateAddress(index, "city", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="Land">
|
||||
<Input value={address.country} onChange={(event) => updateAddress(index, "country", event.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Field label="Notizen">
|
||||
<Input value={form.notes} onChange={(event) => update("notes", event.target.value)} />
|
||||
</Field>
|
||||
|
||||
{serverError && <p className="text-sm text-red-600">{serverError}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" disabled={pending} onClick={onCancel}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={!valid || pending}>
|
||||
{pending ? "Speichern..." : "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
error?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
frontend/athena/components/customers/CustomerStatusBadge.tsx
Normal file
26
frontend/athena/components/customers/CustomerStatusBadge.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import type { CustomerStatus } from "@/types/customer";
|
||||
|
||||
const labels: Record<CustomerStatus, string> = {
|
||||
lead: "Lead",
|
||||
active: "Aktiv",
|
||||
inactive: "Inaktiv",
|
||||
blocked: "Gesperrt",
|
||||
archived: "Archiviert",
|
||||
};
|
||||
|
||||
const styles: Record<CustomerStatus, string> = {
|
||||
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 (
|
||||
<span className={cn("inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ring-1", styles[status])}>
|
||||
{labels[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,5 +21,6 @@ export const config = {
|
|||
"/dashboard/:path*",
|
||||
"/users/:path*",
|
||||
"/roles/:path*",
|
||||
"/customers/:path*",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
89
frontend/athena/types/customer.ts
Normal file
89
frontend/athena/types/customer.ts
Normal file
|
|
@ -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;
|
||||
};
|
||||
22
frontend/athena/types/dashboard.ts
Normal file
22
frontend/athena/types/dashboard.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue