feat(repairs): add status timeline and public link preparation
This commit is contained in:
parent
e9ec207617
commit
09cce1f2b6
22 changed files with 1027 additions and 28 deletions
|
|
@ -204,9 +204,11 @@ Kernfunktionen:
|
|||
- Reparaturen manuell anlegen und bearbeiten
|
||||
- eindeutige Reparaturnummer im Format `R<jahr>-<laufende nummer sechsstellig>`, z. B. `R2026-000001`
|
||||
- Statusmodell von `new` bis `completed` oder `cancelled`
|
||||
- deutsche UI-Statuslabels in Athena, die internen API-Statuscodes bleiben englisch und stabil
|
||||
- Statushistorie in `repair_status_history`
|
||||
- Audit Logs fuer Erstellung, Bearbeitung, Statuswechsel, Storno und Intake
|
||||
- Dashboard-Kennzahlen fuer neue Reparaturen, Diagnose, wartende Kundenfreigaben und abgeschlossene Reparaturen
|
||||
- werkstattgerechte Status-Timeline mit Datum, Benutzer und Notiz
|
||||
- Audit Logs fuer Erstellung, Bearbeitung, Statuswechsel, Storno, Intake und Statuslink-Verwaltung
|
||||
- Dashboard-Kennzahlen fuer neue Reparaturen, Diagnose, wartende Kundenfreigaben, laufende Reparaturen, Endpruefung und abgeschlossene Reparaturen
|
||||
|
||||
Tabellen:
|
||||
|
||||
|
|
@ -214,6 +216,8 @@ Tabellen:
|
|||
- `repair_status_history`
|
||||
- `repair_intake_events`
|
||||
- `repair_documents`
|
||||
- `repair_public_access_tokens`
|
||||
- `repair_notification_events`
|
||||
|
||||
Hermes-Endpunkte:
|
||||
|
||||
|
|
@ -224,9 +228,66 @@ Hermes-Endpunkte:
|
|||
- `DELETE /repairs/{id}` als fachliches Storno
|
||||
- `PUT /repairs/{id}/status`
|
||||
- `GET /repairs/{id}/history`
|
||||
- `GET /repairs/{id}/public-link`
|
||||
- `POST /repairs/{id}/public-link`
|
||||
- `DELETE /repairs/{id}/public-link`
|
||||
- `GET /repairs/{id}/notifications`
|
||||
- `GET /public/repairs/status/{token}`
|
||||
|
||||
Athena-BFF-Routen spiegeln diese Endpunkte unter `/api/repairs...`. Mutierende Athena-Routen behalten die Same-Origin-Pruefung bei.
|
||||
|
||||
Statuslabels:
|
||||
|
||||
```text
|
||||
new = Neu
|
||||
accepted = Angenommen
|
||||
diagnosis = Diagnose
|
||||
estimate = Kostenvoranschlag
|
||||
waiting_for_customer = Wartet auf Kunde
|
||||
approved = Freigegeben
|
||||
repair = Reparatur
|
||||
final_test = Endprüfung
|
||||
ready_for_pickup = Abholbereit
|
||||
shipped = Versand
|
||||
completed = Abgeschlossen
|
||||
cancelled = Storniert
|
||||
```
|
||||
|
||||
### Sicherer Reparaturstatus-Link
|
||||
|
||||
v0.8.1 bereitet einen oeffentlichen Statuslink vor. Es wird noch kein Kundenportal und kein Kundenlogin gebaut.
|
||||
|
||||
Sicherheitsmodell:
|
||||
|
||||
- Klartexttoken wird lang und zufaellig erzeugt.
|
||||
- In PostgreSQL wird nur ein HMAC/SHA-256-Hash des Tokens gespeichert.
|
||||
- Der Klartexttoken wird nur einmal bei Erstellung an Athena zurueckgegeben.
|
||||
- Tokens werden niemals geloggt.
|
||||
- `repair_public_access_tokens` speichert Aktivstatus, Ablaufdatum, letzte Nutzung und Widerruf.
|
||||
- `GET /public/repairs/status/{token}` gibt nur kundenfreundliche Statusdaten aus.
|
||||
- Keine Kundendaten, internen Notizen oder Diagnosedetails werden oeffentlich ausgegeben.
|
||||
|
||||
Die spaetere Website kann daraus einen Pfad wie `/status/<token>` anbieten. Die eigentliche Website wird in Olympus nicht veraendert.
|
||||
|
||||
### Reparatur-Benachrichtigungen
|
||||
|
||||
`repair_notification_events` bereitet nachvollziehbare Kundenkommunikation vor. Der Versand wird noch nicht erzwungen.
|
||||
|
||||
Vorbereitete Ereignisse:
|
||||
|
||||
- Reparatur erfasst
|
||||
- Geraet angenommen
|
||||
- Diagnose laeuft
|
||||
- Kostenvoranschlag erstellt
|
||||
- Wartet auf Kundenfreigabe
|
||||
- Reparatur laeuft
|
||||
- Endpruefung
|
||||
- Abholbereit
|
||||
- Versand
|
||||
- Abgeschlossen
|
||||
|
||||
Vorlagen enthalten deutsche Betreffzeilen und Texte mit Platzhaltern fuer `repair_number`, `customer_name`, `device`, `status_label`, `public_status_url` und `company_name`.
|
||||
|
||||
### Website Repair Intake
|
||||
|
||||
Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit.
|
||||
|
|
@ -241,7 +302,7 @@ Sicherheitsmodell:
|
|||
|
||||
Der Intake speichert immer ein `repair_intake_events`-Ereignis und erzeugt bei erfolgreicher Verarbeitung einen Reparaturdatensatz mit `source = website` und `status = new`.
|
||||
|
||||
Eine spaetere Kundenportal-Statusseite ist architektonisch vorbereitet, aber noch nicht oeffentlich implementiert.
|
||||
Eine spaetere Statusseite ist architektonisch vorbereitet, aber noch nicht in der oeffentlichen Website implementiert.
|
||||
|
||||
## RBAC: Rollen und Berechtigungen
|
||||
|
||||
|
|
|
|||
|
|
@ -146,13 +146,31 @@ Athena erreicht Reparaturen ausschliesslich ueber BFF-Routen:
|
|||
- `/api/repairs/[id]`
|
||||
- `/api/repairs/[id]/status`
|
||||
- `/api/repairs/[id]/history`
|
||||
- `/api/repairs/[id]/public-link`
|
||||
- `/api/repairs/[id]/notifications`
|
||||
|
||||
Hermes stellt zusaetzlich `POST /public/repair-intake` fuer die spaetere Website-Anbindung bereit. Der Endpunkt ist fuer Server-zu-Server-Kommunikation gedacht und erwartet `X-Olympus-Intake-Token`. Der Token wird ueber `OLYMPUS_REPAIR_INTAKE_TOKEN` gesetzt und darf nicht im Browser verwendet werden.
|
||||
|
||||
Vorbereitete Kundenportal-Routen fuer spaeter:
|
||||
Ab v0.8.1 zeigt Athena deutsche Statuslabels, waehrend die API-Statuscodes englisch und stabil bleiben. Statuswechsel werden als Timeline mit Benutzer, Datum und Notiz angezeigt.
|
||||
|
||||
- `/status`
|
||||
- `/reparatur/status`
|
||||
Statuslink-Konzept:
|
||||
|
||||
- Interne Verwaltung ueber `GET|POST|DELETE /repairs/{id}/public-link`.
|
||||
- RBAC-Permission: `repairs.public_link.manage`.
|
||||
- Tokens sind lang, zufaellig und werden nur gehasht gespeichert.
|
||||
- Der Klartexttoken wird nur einmal bei Erstellung zurueckgegeben.
|
||||
- Oeffentliche Statusdaten kommen spaeter ueber `GET /public/repairs/status/{token}`.
|
||||
- Die Antwort enthaelt keine Kundendaten, keine internen Notizen und keine nicht freigegebenen Diagnosen.
|
||||
|
||||
Benachrichtigungen:
|
||||
|
||||
- Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`.
|
||||
- `repair_notification_events` bereitet spaeteren E-Mail-Versand nachvollziehbar vor.
|
||||
- Aktuell wird kein SMTP-Versand aus Olympus erzwungen.
|
||||
|
||||
Vorbereitete Website-/Portal-Routen fuer spaeter:
|
||||
|
||||
- `/status/<token>`
|
||||
- `/portal/login`
|
||||
|
||||
## Typische Fehler
|
||||
|
|
|
|||
24
ROADMAP.md
24
ROADMAP.md
|
|
@ -85,12 +85,28 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
|||
- Dashboard-Kennzahlen fuer Reparaturen
|
||||
- Athena-BFF-Routen und Reparatur-UI
|
||||
|
||||
## v0.8.1 - Kundenportal-Status, geplant
|
||||
## v0.8.1 - Reparaturstatus und Kundenkommunikation
|
||||
|
||||
- Deutsche Statuslabels in Athena bei stabilen englischen API-Statuscodes
|
||||
- Werkstattgerechte Status-Timeline auf der Reparaturdetailseite
|
||||
- Sicheres Public-Token-Modell fuer spaetere Statuslinks
|
||||
- Oeffentlicher Hermes-Endpunkt `GET /public/repairs/status/{token}` mit minimalen Kundendaten
|
||||
- Interne Statuslink-Verwaltung in Athena
|
||||
- E-Mail-Vorlagen und `repair_notification_events` vorbereitet
|
||||
- Keine Kundenlogin-Funktion und keine Aenderungen an der oeffentlichen Website
|
||||
|
||||
## v0.8.2 - Website-Statusintegration, geplant
|
||||
|
||||
- `/status/<token>` als spaeterer Einstieg fuer Statusabfragen auf der Website
|
||||
- Website ruft Statusdaten serverseitig oder ueber einen eigenen BFF ab
|
||||
- Kein Token im Log und keine unnoetigen Kundendaten in der oeffentlichen Antwort
|
||||
- Optionaler E-Mail-Versand mit sicherem Statuslink
|
||||
|
||||
## v0.8.3 - Kundenportal, geplant
|
||||
|
||||
- `/status` als Einstieg fuer Statusabfragen
|
||||
- `/reparatur/status` fuer Reparaturstatus
|
||||
- `/portal/login` fuer spaeteren Kundenlogin
|
||||
- Keine Browser-Secrets und keine direkten Hermes-Zugriffe
|
||||
- Separates Authentifizierungsmodell fuer Kunden
|
||||
- Keine Vermischung mit internen Olympus-Benutzern
|
||||
|
||||
## v0.9.0 - Tickets, geplant
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
"""add repair public links and notifications
|
||||
|
||||
Revision ID: d4f6a2b8c901
|
||||
Revises: c8e2f1a9b704
|
||||
Create Date: 2026-07-04 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "d4f6a2b8c901"
|
||||
down_revision: Union[str, Sequence[str], None] = "c8e2f1a9b704"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
PUBLIC_LINK_PERMISSION = (
|
||||
"repairs.public_link.manage",
|
||||
"Reparatur-Statuslinks verwalten",
|
||||
"Öffentliche Statuslinks für Reparaturen verwalten",
|
||||
"repairs",
|
||||
)
|
||||
|
||||
ROLE_PERMISSIONS = {
|
||||
"administrator": ["repairs.public_link.manage"],
|
||||
"management": ["repairs.public_link.manage"],
|
||||
"support": ["repairs.public_link.manage"],
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"repair_public_access_tokens",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("repair_id", sa.Integer(), nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("token_hint", sa.String(length=16), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), server_default="true", nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("token_hash"),
|
||||
)
|
||||
op.create_index(op.f("ix_repair_public_access_tokens_is_active"), "repair_public_access_tokens", ["is_active"], unique=False)
|
||||
op.create_index(op.f("ix_repair_public_access_tokens_repair_id"), "repair_public_access_tokens", ["repair_id"], unique=False)
|
||||
op.create_index(op.f("ix_repair_public_access_tokens_token_hash"), "repair_public_access_tokens", ["token_hash"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"repair_notification_events",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("repair_id", sa.Integer(), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=80), nullable=False),
|
||||
sa.Column("channel", sa.String(length=40), nullable=False),
|
||||
sa.Column("recipient", sa.String(length=255), server_default="", nullable=False),
|
||||
sa.Column("subject", sa.String(length=255), nullable=False),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.CheckConstraint("status in ('pending','sent','failed','skipped')", name="ck_repair_notification_events_status"),
|
||||
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_repair_notification_events_channel"), "repair_notification_events", ["channel"], unique=False)
|
||||
op.create_index(op.f("ix_repair_notification_events_event_type"), "repair_notification_events", ["event_type"], unique=False)
|
||||
op.create_index(op.f("ix_repair_notification_events_repair_id"), "repair_notification_events", ["repair_id"], unique=False)
|
||||
op.create_index(op.f("ix_repair_notification_events_status"), "repair_notification_events", ["status"], unique=False)
|
||||
|
||||
name, display_name, description, module = PUBLIC_LINK_PERMISSION
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
insert into permissions (name, display_name, description, module)
|
||||
values (:name, :display_name, :description, :module)
|
||||
on conflict (name) do update set
|
||||
display_name = excluded.display_name,
|
||||
description = excluded.description,
|
||||
module = excluded.module
|
||||
"""
|
||||
).bindparams(name=name, display_name=display_name, description=description, module=module)
|
||||
)
|
||||
|
||||
for role_name, permissions in ROLE_PERMISSIONS.items():
|
||||
for permission_name in permissions:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
insert into role_permissions (role_id, permission_id)
|
||||
select roles.id, permissions.id
|
||||
from roles, permissions
|
||||
where roles.name = :role_name and permissions.name = :permission_name
|
||||
on conflict do nothing
|
||||
"""
|
||||
).bindparams(role_name=role_name, permission_name=permission_name)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
delete from role_permissions
|
||||
using permissions
|
||||
where role_permissions.permission_id = permissions.id
|
||||
and permissions.name = :permission_name
|
||||
"""
|
||||
).bindparams(permission_name=PUBLIC_LINK_PERMISSION[0])
|
||||
)
|
||||
op.execute(sa.text("delete from permissions where name = :name").bindparams(name=PUBLIC_LINK_PERMISSION[0]))
|
||||
|
||||
op.drop_index(op.f("ix_repair_notification_events_status"), table_name="repair_notification_events")
|
||||
op.drop_index(op.f("ix_repair_notification_events_repair_id"), table_name="repair_notification_events")
|
||||
op.drop_index(op.f("ix_repair_notification_events_event_type"), table_name="repair_notification_events")
|
||||
op.drop_index(op.f("ix_repair_notification_events_channel"), table_name="repair_notification_events")
|
||||
op.drop_table("repair_notification_events")
|
||||
|
||||
op.drop_index(op.f("ix_repair_public_access_tokens_token_hash"), table_name="repair_public_access_tokens")
|
||||
op.drop_index(op.f("ix_repair_public_access_tokens_repair_id"), table_name="repair_public_access_tokens")
|
||||
op.drop_index(op.f("ix_repair_public_access_tokens_is_active"), table_name="repair_public_access_tokens")
|
||||
op.drop_table("repair_public_access_tokens")
|
||||
|
|
@ -60,7 +60,9 @@ def get_dashboard_summary(
|
|||
repairs = [
|
||||
MetricCard(label="Neue Reparaturen", value=RepairRepository.count_by_status(db, "new")),
|
||||
MetricCard(label="In Diagnose", value=RepairRepository.count_by_status(db, "diagnosis")),
|
||||
MetricCard(label="Warten auf Kunde", value=RepairRepository.count_by_status(db, "waiting_for_customer")),
|
||||
MetricCard(label="Wartet auf Kunde", value=RepairRepository.count_by_status(db, "waiting_for_customer")),
|
||||
MetricCard(label="In Reparatur", value=RepairRepository.count_by_status(db, "repair")),
|
||||
MetricCard(label="Endprüfung", value=RepairRepository.count_by_status(db, "final_test")),
|
||||
MetricCard(label="Abgeschlossen", value=RepairRepository.count_by_status(db, "completed")),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -14,12 +14,18 @@ from app.schemas.repair import (
|
|||
RepairIntakePayload,
|
||||
RepairIntakeResponse,
|
||||
RepairListResponse,
|
||||
RepairNotificationOverviewResponse,
|
||||
RepairPublicLinkCreateResponse,
|
||||
RepairPublicLinkResponse,
|
||||
RepairPublicStatusResponse,
|
||||
RepairResponse,
|
||||
RepairStatus,
|
||||
RepairStatusHistoryResponse,
|
||||
RepairStatusUpdate,
|
||||
RepairUpdate,
|
||||
)
|
||||
from app.services.repair_notification_service import RepairNotificationService
|
||||
from app.services.repair_public_link_service import RepairPublicLinkService
|
||||
from app.services.repair_service import RepairService
|
||||
|
||||
router = APIRouter(tags=["Repairs"])
|
||||
|
|
@ -123,6 +129,48 @@ def get_repair_history(
|
|||
return RepairRepository.get_history(db, repair_id)
|
||||
|
||||
|
||||
@router.get("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse)
|
||||
def get_public_link(
|
||||
repair_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.public_link.manage")),
|
||||
):
|
||||
db_repair = get_repair_or_404(db, repair_id)
|
||||
return RepairPublicLinkService.get(db, db_repair)
|
||||
|
||||
|
||||
@router.post("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_public_link(
|
||||
repair_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.public_link.manage")),
|
||||
):
|
||||
db_repair = get_repair_or_404(db, repair_id)
|
||||
return RepairPublicLinkService.create(db, db_repair, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.delete("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse)
|
||||
def revoke_public_link(
|
||||
repair_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.public_link.manage")),
|
||||
):
|
||||
db_repair = get_repair_or_404(db, repair_id)
|
||||
return RepairPublicLinkService.revoke(db, db_repair, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.get("/repairs/{repair_id}/notifications", response_model=RepairNotificationOverviewResponse)
|
||||
def get_repair_notifications(
|
||||
repair_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("repairs.read")),
|
||||
):
|
||||
db_repair = get_repair_or_404(db, repair_id)
|
||||
return RepairNotificationService.overview(db, db_repair)
|
||||
|
||||
|
||||
@router.post("/public/repair-intake", response_model=RepairIntakeResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_repair_intake(
|
||||
payload: RepairIntakePayload,
|
||||
|
|
@ -140,3 +188,11 @@ def create_repair_intake(
|
|||
status=cast(RepairStatus, repair.status),
|
||||
message="Reparaturanfrage übernommen",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/public/repairs/status/{token}", response_model=RepairPublicStatusResponse)
|
||||
def get_public_repair_status(
|
||||
token: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return RepairPublicLinkService.public_status(db, token)
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ class Repair(Base):
|
|||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
history: Mapped[list["RepairStatusHistory"]] = relationship(back_populates="repair", cascade="all, delete-orphan", lazy="selectin")
|
||||
public_access_tokens: Mapped[list["RepairPublicAccessToken"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
|
||||
notification_events: Mapped[list["RepairNotificationEvent"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class RepairStatusHistory(Base):
|
||||
|
|
@ -53,6 +55,18 @@ class RepairStatusHistory(Base):
|
|||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
repair: Mapped[Repair] = relationship(back_populates="history")
|
||||
actor = relationship("User", lazy="joined")
|
||||
|
||||
@property
|
||||
def actor_username(self) -> str:
|
||||
return self.actor.username if self.actor is not None else ""
|
||||
|
||||
@property
|
||||
def actor_display_name(self) -> str:
|
||||
if self.actor is None:
|
||||
return ""
|
||||
display_name = f"{self.actor.first_name} {self.actor.last_name}".strip()
|
||||
return display_name or self.actor.username
|
||||
|
||||
|
||||
class RepairIntakeEvent(Base):
|
||||
|
|
@ -79,3 +93,36 @@ class RepairDocument(Base):
|
|||
title: Mapped[str] = mapped_column(String(255))
|
||||
document_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class RepairPublicAccessToken(Base):
|
||||
__tablename__ = "repair_public_access_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
token_hint: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
repair: Mapped[Repair] = relationship(back_populates="public_access_tokens")
|
||||
|
||||
|
||||
class RepairNotificationEvent(Base):
|
||||
__tablename__ = "repair_notification_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||
channel: Mapped[str] = mapped_column(String(40), index=True)
|
||||
recipient: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||
subject: Mapped[str] = mapped_column(String(255))
|
||||
status: Mapped[str] = mapped_column(String(40), index=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
repair: Mapped[Repair] = relationship(back_populates="notification_events")
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ STANDARD_PERMISSIONS = [
|
|||
("repairs.status.update", "Reparaturstatus ändern", "Status von Reparaturen ändern", "repairs"),
|
||||
("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"),
|
||||
("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"),
|
||||
("repairs.public_link.manage", "Reparatur-Statuslinks verwalten", "Öffentliche Statuslinks für Reparaturen verwalten", "repairs"),
|
||||
]
|
||||
|
||||
ROLE_PERMISSION_NAMES = {
|
||||
|
|
@ -99,6 +100,7 @@ ROLE_PERMISSION_NAMES = {
|
|||
"repairs.create",
|
||||
"repairs.update",
|
||||
"repairs.status.update",
|
||||
"repairs.public_link.manage",
|
||||
},
|
||||
"sales": {
|
||||
"dashboard.read",
|
||||
|
|
@ -137,6 +139,7 @@ ROLE_PERMISSION_NAMES = {
|
|||
"repairs.update",
|
||||
"repairs.status.update",
|
||||
"repairs.intake",
|
||||
"repairs.public_link.manage",
|
||||
},
|
||||
"warehouse": {
|
||||
"dashboard.read",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from datetime import UTC, datetime
|
|||
from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models.repair import Repair, RepairIntakeEvent, RepairStatusHistory
|
||||
from app.models.repair import Repair, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory
|
||||
from app.schemas.repair import RepairCreate, RepairStatusUpdate, RepairUpdate
|
||||
|
||||
|
||||
|
|
@ -158,11 +158,22 @@ class RepairRepository:
|
|||
return list(
|
||||
db.scalars(
|
||||
select(RepairStatusHistory)
|
||||
.options(selectinload(RepairStatusHistory.actor))
|
||||
.where(RepairStatusHistory.repair_id == repair_id)
|
||||
.order_by(RepairStatusHistory.created_at.desc(), RepairStatusHistory.id.desc())
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_history_public(db: Session, repair_id: int) -> list[RepairStatusHistory]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(RepairStatusHistory)
|
||||
.where(RepairStatusHistory.repair_id == repair_id)
|
||||
.order_by(RepairStatusHistory.created_at.asc(), RepairStatusHistory.id.asc())
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def count_by_status(db: Session, status: str) -> int:
|
||||
return db.scalar(select(func.count(Repair.id)).where(Repair.status == status)) or 0
|
||||
|
|
@ -203,6 +214,110 @@ class RepairRepository:
|
|||
db.refresh(event)
|
||||
return event
|
||||
|
||||
@staticmethod
|
||||
def get_active_public_link(db: Session, repair_id: int) -> RepairPublicAccessToken | None:
|
||||
now = datetime.now(UTC)
|
||||
return db.scalar(
|
||||
select(RepairPublicAccessToken)
|
||||
.where(RepairPublicAccessToken.repair_id == repair_id)
|
||||
.where(RepairPublicAccessToken.is_active.is_(True))
|
||||
.where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now))
|
||||
.order_by(RepairPublicAccessToken.created_at.desc(), RepairPublicAccessToken.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_public_link_by_hash(db: Session, token_hash: str) -> RepairPublicAccessToken | None:
|
||||
now = datetime.now(UTC)
|
||||
return db.scalar(
|
||||
select(RepairPublicAccessToken)
|
||||
.options(selectinload(RepairPublicAccessToken.repair))
|
||||
.where(RepairPublicAccessToken.token_hash == token_hash)
|
||||
.where(RepairPublicAccessToken.is_active.is_(True))
|
||||
.where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now))
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_public_link(
|
||||
db: Session,
|
||||
*,
|
||||
repair_id: int,
|
||||
token_hash: str,
|
||||
token_hint: str,
|
||||
expires_at: datetime | None = None,
|
||||
) -> RepairPublicAccessToken:
|
||||
RepairRepository.revoke_public_links(db, repair_id=repair_id, commit=False)
|
||||
public_link = RepairPublicAccessToken(
|
||||
repair_id=repair_id,
|
||||
token_hash=token_hash,
|
||||
token_hint=token_hint,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(public_link)
|
||||
db.commit()
|
||||
db.refresh(public_link)
|
||||
return public_link
|
||||
|
||||
@staticmethod
|
||||
def revoke_public_links(db: Session, *, repair_id: int, commit: bool = True) -> None:
|
||||
now = datetime.now(UTC)
|
||||
links = list(
|
||||
db.scalars(
|
||||
select(RepairPublicAccessToken)
|
||||
.where(RepairPublicAccessToken.repair_id == repair_id)
|
||||
.where(RepairPublicAccessToken.is_active.is_(True))
|
||||
)
|
||||
)
|
||||
for link in links:
|
||||
link.is_active = False
|
||||
link.revoked_at = now
|
||||
if commit:
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def mark_public_link_used(db: Session, public_link: RepairPublicAccessToken) -> None:
|
||||
public_link.last_used_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def list_notification_events(db: Session, repair_id: int) -> list[RepairNotificationEvent]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(RepairNotificationEvent)
|
||||
.where(RepairNotificationEvent.repair_id == repair_id)
|
||||
.order_by(RepairNotificationEvent.created_at.desc(), RepairNotificationEvent.id.desc())
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_notification_event(
|
||||
db: Session,
|
||||
*,
|
||||
repair_id: int,
|
||||
event_type: str,
|
||||
channel: str,
|
||||
recipient: str,
|
||||
subject: str,
|
||||
status: str,
|
||||
error_message: str | None = None,
|
||||
sent_at: datetime | None = None,
|
||||
) -> RepairNotificationEvent:
|
||||
event = RepairNotificationEvent(
|
||||
repair_id=repair_id,
|
||||
event_type=event_type,
|
||||
channel=channel,
|
||||
recipient=recipient,
|
||||
subject=subject,
|
||||
status=status,
|
||||
error_message=error_message,
|
||||
sent_at=sent_at,
|
||||
)
|
||||
db.add(event)
|
||||
db.commit()
|
||||
db.refresh(event)
|
||||
return event
|
||||
|
||||
@staticmethod
|
||||
def _payload_data(payload: RepairCreate | RepairUpdate) -> dict:
|
||||
data = payload.model_dump()
|
||||
|
|
|
|||
|
|
@ -129,6 +129,8 @@ class RepairStatusHistoryResponse(BaseModel):
|
|||
new_status: str
|
||||
note: str | None
|
||||
actor_user_id: int | None
|
||||
actor_username: str = ""
|
||||
actor_display_name: str = ""
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
|
@ -160,3 +162,61 @@ class RepairIntakeResponse(BaseModel):
|
|||
repair_number: str
|
||||
status: RepairStatus
|
||||
message: str
|
||||
|
||||
|
||||
class RepairPublicLinkResponse(BaseModel):
|
||||
is_active: bool
|
||||
token_hint: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
last_used_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
public_status_path: str | None = None
|
||||
|
||||
|
||||
class RepairPublicLinkCreateResponse(RepairPublicLinkResponse):
|
||||
token: str
|
||||
public_status_path: str
|
||||
|
||||
|
||||
class RepairPublicStatusHistoryItem(BaseModel):
|
||||
status: RepairStatus
|
||||
status_label: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RepairPublicStatusResponse(BaseModel):
|
||||
repair_number: str
|
||||
public_status_label: str
|
||||
device_manufacturer: str
|
||||
device_model: str
|
||||
status_history_public: list[RepairPublicStatusHistoryItem]
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class RepairNotificationTemplateResponse(BaseModel):
|
||||
event_type: str
|
||||
status: RepairStatus | None = None
|
||||
subject: str
|
||||
text: str
|
||||
html: str | None = None
|
||||
|
||||
|
||||
class RepairNotificationEventResponse(BaseModel):
|
||||
id: int
|
||||
repair_id: int
|
||||
event_type: str
|
||||
channel: str
|
||||
recipient: str
|
||||
subject: str
|
||||
status: str
|
||||
error_message: str | None
|
||||
created_at: datetime
|
||||
sent_at: datetime | None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class RepairNotificationOverviewResponse(BaseModel):
|
||||
templates: list[RepairNotificationTemplateResponse]
|
||||
events: list[RepairNotificationEventResponse]
|
||||
|
|
|
|||
|
|
@ -163,6 +163,8 @@ def action_title(action: str) -> str:
|
|||
"repairs.cancel": "Reparatur storniert",
|
||||
"repairs.intake_processed": "Reparatur-Intake verarbeitet",
|
||||
"repairs.intake_failed": "Reparatur-Intake fehlgeschlagen",
|
||||
"repairs.public_link.create": "Reparatur-Statuslink erstellt",
|
||||
"repairs.public_link.revoke": "Reparatur-Statuslink deaktiviert",
|
||||
}
|
||||
return labels.get(action, action)
|
||||
|
||||
|
|
|
|||
126
backend/hermes/app/services/repair_notification_service.py
Normal file
126
backend/hermes/app/services/repair_notification_service.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.repair import Repair
|
||||
from app.repositories.repair_repository import RepairRepository
|
||||
from app.schemas.repair import RepairNotificationOverviewResponse, RepairNotificationTemplateResponse, RepairStatus
|
||||
|
||||
|
||||
STATUS_LABELS: dict[str, str] = {
|
||||
"new": "Neu",
|
||||
"accepted": "Angenommen",
|
||||
"diagnosis": "Diagnose",
|
||||
"estimate": "Kostenvoranschlag",
|
||||
"waiting_for_customer": "Wartet auf Kunde",
|
||||
"approved": "Freigegeben",
|
||||
"repair": "Reparatur",
|
||||
"final_test": "Endprüfung",
|
||||
"ready_for_pickup": "Abholbereit",
|
||||
"shipped": "Versand",
|
||||
"completed": "Abgeschlossen",
|
||||
"cancelled": "Storniert",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RepairNotificationTemplate:
|
||||
event_type: str
|
||||
status: RepairStatus | None
|
||||
subject: str
|
||||
text: str
|
||||
html: str | None = None
|
||||
|
||||
|
||||
TEMPLATES: tuple[RepairNotificationTemplate, ...] = (
|
||||
RepairNotificationTemplate(
|
||||
event_type="repair_created",
|
||||
status="new",
|
||||
subject="Reparatur {repair_number} wurde erfasst",
|
||||
text="Guten Tag {customer_name},\n\nIhre Reparatur {repair_number} für {device} wurde erfasst. Den aktuellen Status finden Sie später unter {public_status_url}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="device_accepted",
|
||||
status="accepted",
|
||||
subject="Gerät zu Reparatur {repair_number} angenommen",
|
||||
text="Guten Tag {customer_name},\n\nIhr Gerät {device} wurde angenommen. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="diagnosis_started",
|
||||
status="diagnosis",
|
||||
subject="Diagnose für Reparatur {repair_number} läuft",
|
||||
text="Guten Tag {customer_name},\n\nwir prüfen Ihr Gerät {device}. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="estimate_created",
|
||||
status="estimate",
|
||||
subject="Kostenvoranschlag zu Reparatur {repair_number}",
|
||||
text="Guten Tag {customer_name},\n\nfür Ihr Gerät {device} wurde ein Kostenvoranschlag vorbereitet. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="waiting_for_customer",
|
||||
status="waiting_for_customer",
|
||||
subject="Freigabe für Reparatur {repair_number} erforderlich",
|
||||
text="Guten Tag {customer_name},\n\nfür Ihre Reparatur {repair_number} warten wir auf Ihre Rückmeldung. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="repair_started",
|
||||
status="repair",
|
||||
subject="Reparatur {repair_number} läuft",
|
||||
text="Guten Tag {customer_name},\n\nwir bearbeiten Ihr Gerät {device}. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="final_test",
|
||||
status="final_test",
|
||||
subject="Endprüfung für Reparatur {repair_number}",
|
||||
text="Guten Tag {customer_name},\n\nIhr Gerät {device} befindet sich in der Endprüfung. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="ready_for_pickup",
|
||||
status="ready_for_pickup",
|
||||
subject="Reparatur {repair_number} ist abholbereit",
|
||||
text="Guten Tag {customer_name},\n\nIhr Gerät {device} ist abholbereit. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="shipped",
|
||||
status="shipped",
|
||||
subject="Reparatur {repair_number} wurde versendet",
|
||||
text="Guten Tag {customer_name},\n\nIhr Gerät {device} wurde versendet. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="completed",
|
||||
status="completed",
|
||||
subject="Reparatur {repair_number} abgeschlossen",
|
||||
text="Guten Tag {customer_name},\n\nIhre Reparatur {repair_number} wurde abgeschlossen. Vielen Dank.\n\n{company_name}",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RepairNotificationService:
|
||||
@staticmethod
|
||||
def render_templates(repair: Repair, *, public_status_url: str = "") -> list[RepairNotificationTemplateResponse]:
|
||||
placeholders = {
|
||||
"repair_number": repair.repair_number,
|
||||
"customer_name": repair.customer_name,
|
||||
"device": f"{repair.device_manufacturer} {repair.device_model}".strip(),
|
||||
"status_label": STATUS_LABELS.get(repair.status, repair.status),
|
||||
"public_status_url": public_status_url or "wird später bereitgestellt",
|
||||
"company_name": "Olympus CRM",
|
||||
}
|
||||
return [
|
||||
RepairNotificationTemplateResponse(
|
||||
event_type=template.event_type,
|
||||
status=template.status,
|
||||
subject=template.subject.format(**placeholders),
|
||||
text=template.text.format(**placeholders),
|
||||
html=template.html.format(**placeholders) if template.html else None,
|
||||
)
|
||||
for template in TEMPLATES
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def overview(db: Session, repair: Repair, *, public_status_url: str = "") -> RepairNotificationOverviewResponse:
|
||||
return RepairNotificationOverviewResponse(
|
||||
templates=RepairNotificationService.render_templates(repair, public_status_url=public_status_url),
|
||||
events=RepairRepository.list_notification_events(db, repair.id),
|
||||
)
|
||||
124
backend/hermes/app/services/repair_public_link_service.py
Normal file
124
backend/hermes/app/services/repair_public_link_service.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.repair import Repair, RepairPublicAccessToken
|
||||
from app.models.user import User
|
||||
from app.repositories.repair_repository import RepairRepository
|
||||
from app.schemas.repair import (
|
||||
RepairPublicLinkCreateResponse,
|
||||
RepairPublicLinkResponse,
|
||||
RepairPublicStatusHistoryItem,
|
||||
RepairPublicStatusResponse,
|
||||
)
|
||||
from app.services.audit_service import write_audit_log
|
||||
from app.services.repair_notification_service import STATUS_LABELS
|
||||
from app.services.repair_service import repair_label
|
||||
|
||||
|
||||
def public_status_path(token: str) -> str:
|
||||
return f"/status/{token}"
|
||||
|
||||
|
||||
class RepairPublicLinkService:
|
||||
@staticmethod
|
||||
def hash_token(token: str) -> str:
|
||||
return hmac.new(settings.secret_key.encode("utf-8"), token.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def create_token() -> str:
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
@staticmethod
|
||||
def response_from_link(public_link: RepairPublicAccessToken | None) -> RepairPublicLinkResponse:
|
||||
if public_link is None:
|
||||
return RepairPublicLinkResponse(is_active=False)
|
||||
return RepairPublicLinkResponse(
|
||||
is_active=public_link.is_active,
|
||||
token_hint=public_link.token_hint,
|
||||
expires_at=public_link.expires_at,
|
||||
last_used_at=public_link.last_used_at,
|
||||
created_at=public_link.created_at,
|
||||
revoked_at=public_link.revoked_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get(db: Session, repair: Repair) -> RepairPublicLinkResponse:
|
||||
return RepairPublicLinkService.response_from_link(RepairRepository.get_active_public_link(db, repair.id))
|
||||
|
||||
@staticmethod
|
||||
def create(db: Session, repair: Repair, *, actor: User, request: Request) -> RepairPublicLinkCreateResponse:
|
||||
token = RepairPublicLinkService.create_token()
|
||||
public_link = RepairRepository.create_public_link(
|
||||
db,
|
||||
repair_id=repair.id,
|
||||
token_hash=RepairPublicLinkService.hash_token(token),
|
||||
token_hint=token[-6:],
|
||||
)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repairs.public_link.create",
|
||||
entity_type="repairs",
|
||||
entity_id=repair.id,
|
||||
entity_label=repair_label(repair),
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"token_hint": public_link.token_hint},
|
||||
)
|
||||
return RepairPublicLinkCreateResponse(
|
||||
is_active=True,
|
||||
token=token,
|
||||
token_hint=public_link.token_hint,
|
||||
expires_at=public_link.expires_at,
|
||||
last_used_at=public_link.last_used_at,
|
||||
created_at=public_link.created_at,
|
||||
revoked_at=public_link.revoked_at,
|
||||
public_status_path=public_status_path(token),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def revoke(db: Session, repair: Repair, *, actor: User, request: Request) -> RepairPublicLinkResponse:
|
||||
active_link = RepairRepository.get_active_public_link(db, repair.id)
|
||||
RepairRepository.revoke_public_links(db, repair_id=repair.id)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="repairs.public_link.revoke",
|
||||
entity_type="repairs",
|
||||
entity_id=repair.id,
|
||||
entity_label=repair_label(repair),
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"token_hint": active_link.token_hint if active_link else None},
|
||||
)
|
||||
return RepairPublicLinkResponse(is_active=False, token_hint=active_link.token_hint if active_link else None, revoked_at=datetime.now(UTC))
|
||||
|
||||
@staticmethod
|
||||
def public_status(db: Session, token: str) -> RepairPublicStatusResponse:
|
||||
public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(token))
|
||||
if public_link is None or public_link.repair is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
|
||||
|
||||
RepairRepository.mark_public_link_used(db, public_link)
|
||||
repair = public_link.repair
|
||||
history = RepairRepository.get_history_public(db, repair.id)
|
||||
return RepairPublicStatusResponse(
|
||||
repair_number=repair.repair_number,
|
||||
public_status_label=STATUS_LABELS.get(repair.status, repair.status),
|
||||
device_manufacturer=repair.device_manufacturer,
|
||||
device_model=repair.device_model,
|
||||
status_history_public=[
|
||||
RepairPublicStatusHistoryItem(
|
||||
status=item.new_status,
|
||||
status_label=STATUS_LABELS.get(item.new_status, item.new_status),
|
||||
created_at=item.created_at,
|
||||
)
|
||||
for item in history
|
||||
],
|
||||
updated_at=repair.updated_at,
|
||||
)
|
||||
14
frontend/athena/app/api/repairs/[id]/notifications/route.ts
Normal file
14
frontend/athena/app/api/repairs/[id]/notifications/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/notifications`);
|
||||
}
|
||||
39
frontend/athena/app/api/repairs/[id]/public-link/route.ts
Normal file
39
frontend/athena/app/api/repairs/[id]/public-link/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 proxyPublicLinkRequest(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/public-link`);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, context: Params) {
|
||||
return proxyPublicLinkRequest(request, context);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyPublicLinkRequest(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyPublicLinkRequest(request, context);
|
||||
}
|
||||
|
|
@ -2,18 +2,26 @@
|
|||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Edit, Wrench } from "lucide-react";
|
||||
import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, ShieldCheck, Wrench, XCircle } from "lucide-react";
|
||||
|
||||
import DetailSection from "@/components/common/DetailSection";
|
||||
import { useToast } from "@/components/common/ToastProvider";
|
||||
import RepairFormDialog from "@/components/repairs/RepairFormDialog";
|
||||
import { RepairPriorityBadge, RepairStatusBadge } from "@/components/repairs/RepairStatusBadge";
|
||||
import { RepairPriorityBadge, RepairStatusBadge, statusLabels } from "@/components/repairs/RepairStatusBadge";
|
||||
import RepairStatusDialog from "@/components/repairs/RepairStatusDialog";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import type { CurrentUser } from "@/types/rbac";
|
||||
import type { Repair, RepairPayload, RepairStatus, RepairStatusHistory } from "@/types/repair";
|
||||
import type {
|
||||
Repair,
|
||||
RepairNotificationOverview,
|
||||
RepairPayload,
|
||||
RepairPublicLink,
|
||||
RepairPublicLinkCreated,
|
||||
RepairStatus,
|
||||
RepairStatusHistory,
|
||||
} from "@/types/repair";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
|
|
@ -43,11 +51,19 @@ function DetailItem({ label, value }: { label: string; value?: string | number |
|
|||
);
|
||||
}
|
||||
|
||||
function historyLabel(item: RepairStatusHistory) {
|
||||
const oldStatus = item.old_status ? statusLabels[item.old_status as RepairStatus] ?? item.old_status : "Start";
|
||||
return `${oldStatus} → ${statusLabels[item.new_status]}`;
|
||||
}
|
||||
|
||||
export default function RepairDetailPage({ params }: Params) {
|
||||
const { showToast } = useToast();
|
||||
const [repairId, setRepairId] = useState("");
|
||||
const [repair, setRepair] = useState<Repair | null>(null);
|
||||
const [history, setHistory] = useState<RepairStatusHistory[]>([]);
|
||||
const [publicLink, setPublicLink] = useState<RepairPublicLink | null>(null);
|
||||
const [lastCreatedStatusPath, setLastCreatedStatusPath] = useState("");
|
||||
const [notifications, setNotifications] = useState<RepairNotificationOverview | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
|
@ -56,6 +72,7 @@ export default function RepairDetailPage({ params }: Params) {
|
|||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [formError, setFormError] = useState("");
|
||||
const [statusError, setStatusError] = useState("");
|
||||
const [communicationPending, setCommunicationPending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
params.then((resolved) => setRepairId(resolved.id));
|
||||
|
|
@ -74,6 +91,15 @@ export default function RepairDetailPage({ params }: Params) {
|
|||
setRepair(repairResponse.data);
|
||||
setHistory(historyResponse.data);
|
||||
setCurrentUser(meResponse.data);
|
||||
const notificationResponse = await api.get<RepairNotificationOverview>(`/repairs/${repairId}/notifications`);
|
||||
setNotifications(notificationResponse.data);
|
||||
if (hasPermission(meResponse.data, "repairs.public_link.manage")) {
|
||||
const publicLinkResponse = await api.get<RepairPublicLink>(`/repairs/${repairId}/public-link`);
|
||||
setPublicLink(publicLinkResponse.data);
|
||||
} else {
|
||||
setPublicLink(null);
|
||||
}
|
||||
setLastCreatedStatusPath("");
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
|
|
@ -112,8 +138,10 @@ export default function RepairDetailPage({ params }: Params) {
|
|||
try {
|
||||
const response = await api.put<Repair>(`/repairs/${repair.id}/status`, { status, note });
|
||||
const historyResponse = await api.get<RepairStatusHistory[]>(`/repairs/${repair.id}/history`);
|
||||
const notificationResponse = await api.get<RepairNotificationOverview>(`/repairs/${repair.id}/notifications`);
|
||||
setRepair(response.data);
|
||||
setHistory(historyResponse.data);
|
||||
setNotifications(notificationResponse.data);
|
||||
setStatusOpen(false);
|
||||
showToast({ type: "success", title: "Status aktualisiert", description: response.data.repair_number });
|
||||
} catch (err) {
|
||||
|
|
@ -135,6 +163,53 @@ export default function RepairDetailPage({ params }: Params) {
|
|||
|
||||
const canUpdate = hasPermission(currentUser, "repairs.update");
|
||||
const canUpdateStatus = hasPermission(currentUser, "repairs.status.update");
|
||||
const canManagePublicLink = hasPermission(currentUser, "repairs.public_link.manage");
|
||||
|
||||
async function createPublicLink() {
|
||||
if (!repair || !canManagePublicLink) return;
|
||||
setCommunicationPending(true);
|
||||
try {
|
||||
const response = await api.post<RepairPublicLinkCreated>(`/repairs/${repair.id}/public-link`);
|
||||
setPublicLink(response.data);
|
||||
setLastCreatedStatusPath(response.data.public_status_path);
|
||||
showToast({ type: "success", title: "Statuslink erstellt", description: "Der Klartextlink wird nur jetzt angezeigt." });
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
showToast({ type: "error", title: "Statuslink konnte nicht erstellt werden", description: message });
|
||||
} finally {
|
||||
setCommunicationPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokePublicLink() {
|
||||
if (!repair || !canManagePublicLink) return;
|
||||
setCommunicationPending(true);
|
||||
try {
|
||||
const response = await api.delete<RepairPublicLink>(`/repairs/${repair.id}/public-link`);
|
||||
setPublicLink(response.data);
|
||||
setLastCreatedStatusPath("");
|
||||
showToast({ type: "success", title: "Statuslink deaktiviert", description: repair.repair_number });
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
showToast({ type: "error", title: "Statuslink konnte nicht deaktiviert werden", description: message });
|
||||
} finally {
|
||||
setCommunicationPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPublicLink() {
|
||||
if (!lastCreatedStatusPath) {
|
||||
showToast({ type: "error", title: "Kein Klartextlink verfügbar", description: "Der sichere Link wird nur direkt nach der Erstellung angezeigt." });
|
||||
return;
|
||||
}
|
||||
const absolutePath = new URL(lastCreatedStatusPath, window.location.origin).toString();
|
||||
try {
|
||||
await navigator.clipboard.writeText(absolutePath);
|
||||
showToast({ type: "success", title: "Link kopiert", description: lastCreatedStatusPath });
|
||||
} catch {
|
||||
showToast({ type: "error", title: "Link konnte nicht kopiert werden", description: "Bitte kopiere den angezeigten Link manuell." });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -208,20 +283,90 @@ export default function RepairDetailPage({ params }: Params) {
|
|||
{history.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Noch keine Statushistorie vorhanden.</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
<div className="space-y-0">
|
||||
{history.map((item) => (
|
||||
<div key={item.id} className="py-3">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="font-medium text-slate-950">{item.old_status || "Start"} → {item.new_status}</p>
|
||||
<div key={item.id} className="relative border-l border-slate-200 pb-5 pl-6 last:pb-0">
|
||||
<span className={`absolute -left-2 top-1 h-4 w-4 rounded-full ring-4 ring-white ${item.new_status === repair.status ? "bg-blue-600" : "bg-slate-300"}`} />
|
||||
<div className="flex flex-col gap-2 rounded-lg border bg-slate-50 p-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-medium text-slate-950">{historyLabel(item)}</p>
|
||||
{item.new_status === repair.status && <span className="rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-blue-600/20">Aktuell</span>}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{item.actor_display_name || item.actor_username || "System"}
|
||||
</p>
|
||||
{item.note && <p className="whitespace-pre-wrap text-sm text-slate-600">{item.note}</p>}
|
||||
</div>
|
||||
<time className="text-xs text-slate-500" dateTime={item.created_at}>{formatDate(item.created_at)}</time>
|
||||
</div>
|
||||
{item.note && <p className="mt-1 text-sm text-slate-500">{item.note}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Kundenkommunikation">
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_1.3fr]">
|
||||
<div className="space-y-4 rounded-lg border bg-slate-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{publicLink?.is_active ? <CheckCircle2 className="mt-0.5 text-emerald-600" size={20} /> : <XCircle className="mt-0.5 text-slate-400" size={20} />}
|
||||
<div>
|
||||
<p className="font-medium text-slate-950">Öffentlicher Statuslink {publicLink?.is_active ? "aktiv" : "inaktiv"}</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Kunden erhalten später Statusupdates per E-Mail mit sicherem Link. Ein Kundenlogin ist dafür nicht erforderlich.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="grid gap-3 text-sm">
|
||||
<DetailItem label="Token-Hinweis" value={publicLink?.token_hint ? `...${publicLink.token_hint}` : "-"} />
|
||||
<DetailItem label="Zuletzt verwendet" value={formatDate(publicLink?.last_used_at)} />
|
||||
<DetailItem label="Erstellt" value={formatDate(publicLink?.created_at)} />
|
||||
</dl>
|
||||
{lastCreatedStatusPath && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">
|
||||
<p className="font-medium">Einmaliger Klartextlink</p>
|
||||
<p className="mt-1 break-all">{lastCreatedStatusPath}</p>
|
||||
</div>
|
||||
)}
|
||||
{canManagePublicLink ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" onClick={createPublicLink} disabled={communicationPending}><Link2 size={16} />Statuslink erstellen</Button>
|
||||
<Button type="button" variant="outline" onClick={copyPublicLink} disabled={!lastCreatedStatusPath || communicationPending}><Copy size={16} />Link kopieren</Button>
|
||||
<Button type="button" variant="destructive" onClick={revokePublicLink} disabled={!publicLink?.is_active || communicationPending}>Statuslink deaktivieren</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">Für die Verwaltung des Statuslinks ist `repairs.public_link.manage` erforderlich.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-950">
|
||||
<Mail size={16} />
|
||||
Vorbereitete Benachrichtigungen
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{notifications?.templates.map((template) => (
|
||||
<div key={template.event_type} className="rounded-lg border bg-white p-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="font-medium text-slate-950">{template.subject}</p>
|
||||
{template.status && <RepairStatusBadge status={template.status} />}
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-sm text-slate-500">{template.text}</p>
|
||||
</div>
|
||||
))}
|
||||
{!notifications?.templates.length && <p className="text-sm text-slate-500">Noch keine Vorlagen vorbereitet.</p>}
|
||||
</div>
|
||||
<div className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-500">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldCheck className="mt-0.5 text-slate-500" size={16} />
|
||||
<p>Notification Events werden gespeichert, sobald später echter E-Mail-Versand angebunden wird. Interne Notizen werden nicht öffentlich ausgegeben.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Audit und Aktivität vorbereitet">
|
||||
<p className="text-sm text-slate-500">Reparaturaktionen werden in den Audit Logs erfasst und erscheinen im Activity Feed, wenn `repairs.read` vorhanden ist.</p>
|
||||
<Link href="/audit-logs" className={buttonVariants({ variant: "outline", size: "sm", className: "mt-4" })}>Audit Logs öffnen</Link>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import EmptyState from "@/components/common/EmptyState";
|
|||
import SearchInput from "@/components/common/SearchInput";
|
||||
import { useToast } from "@/components/common/ToastProvider";
|
||||
import RepairFormDialog from "@/components/repairs/RepairFormDialog";
|
||||
import { RepairPriorityBadge, RepairStatusBadge } from "@/components/repairs/RepairStatusBadge";
|
||||
import { RepairPriorityBadge, RepairStatusBadge, priorityLabels, statusLabels } from "@/components/repairs/RepairStatusBadge";
|
||||
import RepairStatusDialog from "@/components/repairs/RepairStatusDialog";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
|
|
@ -212,7 +212,7 @@ export default function RepairsPage() {
|
|||
<SearchInput value={search} onChange={(value) => { setSearch(value); setPage(1); }} placeholder="Reparaturen suchen" />
|
||||
<select value={statusFilter} onChange={(event) => { setStatusFilter(event.target.value as RepairStatus | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm">
|
||||
<option value="all">Alle Status</option>
|
||||
{["new", "accepted", "diagnosis", "estimate", "waiting_for_customer", "approved", "repair", "final_test", "ready_for_pickup", "shipped", "completed", "cancelled"].map((status) => <option key={status} value={status}>{status}</option>)}
|
||||
{(["new", "accepted", "diagnosis", "estimate", "waiting_for_customer", "approved", "repair", "final_test", "ready_for_pickup", "shipped", "completed", "cancelled"] as RepairStatus[]).map((status) => <option key={status} value={status}>{statusLabels[status]}</option>)}
|
||||
</select>
|
||||
<select value={sourceFilter} onChange={(event) => { setSourceFilter(event.target.value as RepairSource | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm">
|
||||
<option value="all">Alle Quellen</option>
|
||||
|
|
@ -220,7 +220,7 @@ export default function RepairsPage() {
|
|||
</select>
|
||||
<select value={priorityFilter} onChange={(event) => { setPriorityFilter(event.target.value as RepairPriority | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm">
|
||||
<option value="all">Alle Prioritäten</option>
|
||||
{["low", "normal", "high", "urgent"].map((priority) => <option key={priority} value={priority}>{priority}</option>)}
|
||||
{(["low", "normal", "high", "urgent"] as RepairPriority[]).map((priority) => <option key={priority} value={priority}>{priorityLabels[priority]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { priorityLabels, statusLabels } from "@/components/repairs/RepairStatusBadge";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Customer } from "@/types/customer";
|
||||
import type { Repair, RepairPayload, RepairPriority, RepairSource, RepairStatus } from "@/types/repair";
|
||||
|
|
@ -180,10 +181,10 @@ export default function RepairFormDialog({ open, repair, pending, serverError, o
|
|||
<h3 className="font-semibold text-slate-950">Vorgang</h3>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="grid gap-1 text-sm">Status<select className="h-8 rounded-lg border px-2 text-sm" value={payload.status} onChange={(event) => update("status", event.target.value as RepairStatus)}>
|
||||
{["new", "accepted", "diagnosis", "estimate", "waiting_for_customer", "approved", "repair", "final_test", "ready_for_pickup", "shipped", "completed", "cancelled"].map((status) => <option key={status} value={status}>{status}</option>)}
|
||||
{(["new", "accepted", "diagnosis", "estimate", "waiting_for_customer", "approved", "repair", "final_test", "ready_for_pickup", "shipped", "completed", "cancelled"] as RepairStatus[]).map((status) => <option key={status} value={status}>{statusLabels[status]}</option>)}
|
||||
</select></label>
|
||||
<label className="grid gap-1 text-sm">Priorität<select className="h-8 rounded-lg border px-2 text-sm" value={payload.priority} onChange={(event) => update("priority", event.target.value as RepairPriority)}>
|
||||
{["low", "normal", "high", "urgent"].map((priority) => <option key={priority} value={priority}>{priority}</option>)}
|
||||
{(["low", "normal", "high", "urgent"] as RepairPriority[]).map((priority) => <option key={priority} value={priority}>{priorityLabels[priority]}</option>)}
|
||||
</select></label>
|
||||
<label className="grid gap-1 text-sm">Quelle<select className="h-8 rounded-lg border px-2 text-sm" value={payload.source} onChange={(event) => update("source", event.target.value as RepairSource)}>
|
||||
{["manual", "website", "customer_portal", "email", "phone"].map((source) => <option key={source} value={source}>{source}</option>)}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const statusLabels: Record<RepairStatus, string> = {
|
|||
repair: "Reparatur",
|
||||
final_test: "Endprüfung",
|
||||
ready_for_pickup: "Abholbereit",
|
||||
shipped: "Versendet",
|
||||
shipped: "Versand",
|
||||
completed: "Abgeschlossen",
|
||||
cancelled: "Storniert",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState } from "react";
|
|||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { statusLabels } from "@/components/repairs/RepairStatusBadge";
|
||||
import type { Repair, RepairStatus } from "@/types/repair";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -18,7 +19,7 @@ type Props = {
|
|||
const statuses: RepairStatus[] = ["new", "accepted", "diagnosis", "estimate", "waiting_for_customer", "approved", "repair", "final_test", "ready_for_pickup", "shipped", "completed", "cancelled"];
|
||||
|
||||
export default function RepairStatusDialog({ open, repair, pending, serverError, onOpenChange, onSubmit }: Props) {
|
||||
const [status, setStatus] = useState<RepairStatus>("new");
|
||||
const [status, setStatus] = useState<RepairStatus>(repair?.status ?? "new");
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
return (
|
||||
|
|
@ -30,7 +31,7 @@ export default function RepairStatusDialog({ open, repair, pending, serverError,
|
|||
</DialogHeader>
|
||||
<div className="grid gap-3">
|
||||
<label className="grid gap-1 text-sm">Neuer Status<select className="h-8 rounded-lg border px-2 text-sm" value={status} onChange={(event) => setStatus(event.target.value as RepairStatus)}>
|
||||
{statuses.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
{statuses.map((item) => <option key={item} value={item}>{statusLabels[item]}</option>)}
|
||||
</select></label>
|
||||
<label className="grid gap-1 text-sm">Notiz<textarea className="min-h-24 rounded-lg border p-2 text-sm" value={note} onChange={(event) => setNote(event.target.value)} /></label>
|
||||
{serverError && <p className="text-sm text-red-600">{serverError}</p>}
|
||||
|
|
|
|||
|
|
@ -85,5 +85,48 @@ export interface RepairStatusHistory {
|
|||
new_status: RepairStatus;
|
||||
note: string | null;
|
||||
actor_user_id: number | null;
|
||||
actor_username: string;
|
||||
actor_display_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RepairPublicLink {
|
||||
is_active: boolean;
|
||||
token_hint: string | null;
|
||||
expires_at: string | null;
|
||||
last_used_at: string | null;
|
||||
created_at: string | null;
|
||||
revoked_at: string | null;
|
||||
public_status_path: string | null;
|
||||
}
|
||||
|
||||
export interface RepairPublicLinkCreated extends RepairPublicLink {
|
||||
token: string;
|
||||
public_status_path: string;
|
||||
}
|
||||
|
||||
export interface RepairNotificationTemplate {
|
||||
event_type: string;
|
||||
status: RepairStatus | null;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string | null;
|
||||
}
|
||||
|
||||
export interface RepairNotificationEvent {
|
||||
id: number;
|
||||
repair_id: number;
|
||||
event_type: string;
|
||||
channel: string;
|
||||
recipient: string;
|
||||
subject: string;
|
||||
status: "pending" | "sent" | "failed" | "skipped";
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
sent_at: string | null;
|
||||
}
|
||||
|
||||
export interface RepairNotificationOverview {
|
||||
templates: RepairNotificationTemplate[];
|
||||
events: RepairNotificationEvent[];
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue