Compare commits

...

8 Commits

Author SHA1 Message Date
m
fdef5af32e feat: reporting dashboard — case stats, deadline compliance, workload, billing (P1)
Backend:
- ReportingService with aggregation queries (CTEs, FILTER clauses)
- 4 API endpoints: /api/reports/{cases,deadlines,workload,billing}
- Date range filtering via ?from=&to= query params

Frontend:
- /berichte page with 4 tabs: Akten, Fristen, Auslastung, Abrechnung
- recharts: bar/pie/line charts for all report types
- Date range picker, CSV export, print-friendly view
- Sidebar nav entry with BarChart3 icon

Also resolves merge conflicts between role-based, notification, and
audit trail branches, and adds missing TS types (AuditLogResponse,
Notification, NotificationPreferences).
2026-03-30 11:24:45 +02:00
m
8e65463130 feat: role-based permissions — owner/partner/associate/paralegal/secretary (P0) 2026-03-30 11:09:05 +02:00
m
a307b29db8 feat: email notifications + deadline reminder system (P0) 2026-03-30 11:08:53 +02:00
m
5e88384fab feat: append-only audit trail for all mutations (P0) 2026-03-30 11:08:41 +02:00
m
0a0ec016d8 feat: role-based permissions (owner/partner/associate/paralegal/secretary)
Backend:
- auth/permissions.go: full permission matrix with RequirePermission/RequireRole
  middleware, CanEditCase, CanDeleteDocument helpers
- auth/context.go: add user role to request context
- auth/middleware.go: resolve role alongside tenant in auth flow
- auth/tenant_resolver.go: verify membership + resolve role for X-Tenant-ID
- handlers/case_assignments.go: CRUD for case-level user assignments
- handlers/tenant_handler.go: UpdateMemberRole, GetMe (/api/me) endpoints
- handlers/documents.go: permission-based delete (own vs all)
- router/router.go: permission-wrapped routes for all endpoints
- services/case_assignment_service.go: assign/unassign with tenant validation
- services/tenant_service.go: UpdateMemberRole with owner protection
- models/case_assignment.go: CaseAssignment model

Database:
- user_tenants.role: CHECK constraint (owner/partner/associate/paralegal/secretary)
- case_assignments table: case_id, user_id, role (lead/team/viewer)
- Migrated existing admin->partner, member->associate

Frontend:
- usePermissions hook: fetches /api/me, provides can() helper
- TeamSettings: 5-role dropdown, role change, permission-gated invite
- CaseAssignments: new component for case-level team management
- Sidebar: conditionally hides AI/Settings based on permissions
- Cases page: hides "Neue Akte" button for non-authorized roles
- Case detail: new "Mitarbeiter" tab for assignment management
2026-03-30 11:04:57 +02:00
m
ac20c03f01 feat: email notifications + deadline reminder system
Database:
- notification_preferences table (user_id, tenant_id, reminder days, email/digest toggles)
- notifications table (type, entity link, read/sent tracking, dedup index)

Backend:
- NotificationService with background goroutine checking reminders hourly
- CheckDeadlineReminders: finds deadlines due in N days per user prefs, creates notifications
- Overdue deadline detection and notification
- Daily digest at 8am: compiles pending notifications into one email
- SendEmail via `m mail send` CLI command
- Deduplication: same notification type + entity + day = skip
- API: GET/PATCH notifications, unread count, mark read/all-read
- API: GET/PUT notification-preferences with upsert

Frontend:
- NotificationBell in header with unread count badge (polls every 30s)
- Dropdown panel with notification list, type-colored dots, time-ago, entity links
- Mark individual/all as read
- NotificationSettings in Einstellungen page: reminder day toggles, email toggle, digest toggle
2026-03-30 11:03:17 +02:00
m
c324a2b5c7 fix: critical security hardening — tenant isolation, CORS, error masking, input validation 2026-03-30 11:02:52 +02:00
m
b36247dfb9 feat: append-only audit trail for all mutations (P0)
- Database: kanzlai.audit_log table with RLS, append-only policies
  (no UPDATE/DELETE), indexes for entity, user, and time queries
- Backend: AuditService.Log() with context-based tenant/user/IP/UA
  extraction, wired into all 7 services (case, deadline, appointment,
  document, note, party, tenant)
- API: GET /api/audit-log with entity_type, entity_id, user_id,
  from/to date, and pagination filters
- Frontend: Protokoll tab on case detail page with chronological
  audit entries, diff preview, and pagination

Required by § 50 BRAO and DSGVO Art. 5(2).
2026-03-30 11:02:28 +02:00
49 changed files with 4477 additions and 145 deletions

View File

@@ -36,7 +36,12 @@ func main() {
calDAVSvc.Start()
defer calDAVSvc.Stop()
handler := router.New(database, authMW, cfg, calDAVSvc)
// Start notification reminder service
notifSvc := services.NewNotificationService(database)
notifSvc.Start()
defer notifSvc.Stop()
handler := router.New(database, authMW, cfg, calDAVSvc, notifSvc)
slog.Info("starting KanzlAI API server", "port", cfg.Port)
if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil {

View File

@@ -9,8 +9,11 @@ import (
type contextKey string
const (
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
userRoleKey contextKey = "user_role"
ipKey contextKey = "ip_address"
userAgentKey contextKey = "user_agent"
)
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
@@ -30,3 +33,32 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(tenantIDKey).(uuid.UUID)
return id, ok
}
func ContextWithUserRole(ctx context.Context, role string) context.Context {
return context.WithValue(ctx, userRoleKey, role)
}
func UserRoleFromContext(ctx context.Context) string {
role, _ := ctx.Value(userRoleKey).(string)
return role
}
func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context {
ctx = context.WithValue(ctx, ipKey, ip)
ctx = context.WithValue(ctx, userAgentKey, userAgent)
return ctx
}
func IPFromContext(ctx context.Context) *string {
if v, ok := ctx.Value(ipKey).(string); ok && v != "" {
return &v
}
return nil
}
func UserAgentFromContext(ctx context.Context) *string {
if v, ok := ctx.Value(userAgentKey).(string); ok && v != "" {
return &v
}
return nil
}

View File

@@ -37,6 +37,14 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
ctx := ContextWithUserID(r.Context(), userID)
// Tenant resolution is handled by TenantResolver middleware for scoped routes.
// Tenant management routes handle their own access control.
// Capture IP and user-agent for audit logging
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent())
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -0,0 +1,213 @@
package auth
import (
"context"
"net/http"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// Valid roles ordered by privilege level (highest first).
var ValidRoles = []string{"owner", "partner", "associate", "paralegal", "secretary"}
// IsValidRole checks if a role string is one of the defined roles.
func IsValidRole(role string) bool {
for _, r := range ValidRoles {
if r == role {
return true
}
}
return false
}
// Permission represents an action that can be checked against roles.
type Permission int
const (
PermManageTeam Permission = iota
PermManageBilling
PermCreateCase
PermEditAllCases
PermEditAssignedCase
PermViewAllCases
PermManageDeadlines
PermManageAppointments
PermUploadDocuments
PermDeleteDocuments
PermDeleteOwnDocuments
PermViewAuditLog
PermManageSettings
PermAIExtraction
)
// rolePermissions maps each role to its set of permissions.
var rolePermissions = map[string]map[Permission]bool{
"owner": {
PermManageTeam: true,
PermManageBilling: true,
PermCreateCase: true,
PermEditAllCases: true,
PermEditAssignedCase: true,
PermViewAllCases: true,
PermManageDeadlines: true,
PermManageAppointments: true,
PermUploadDocuments: true,
PermDeleteDocuments: true,
PermDeleteOwnDocuments: true,
PermViewAuditLog: true,
PermManageSettings: true,
PermAIExtraction: true,
},
"partner": {
PermManageTeam: true,
PermManageBilling: true,
PermCreateCase: true,
PermEditAllCases: true,
PermEditAssignedCase: true,
PermViewAllCases: true,
PermManageDeadlines: true,
PermManageAppointments: true,
PermUploadDocuments: true,
PermDeleteDocuments: true,
PermDeleteOwnDocuments: true,
PermViewAuditLog: true,
PermManageSettings: true,
PermAIExtraction: true,
},
"associate": {
PermCreateCase: true,
PermEditAssignedCase: true,
PermViewAllCases: true,
PermManageDeadlines: true,
PermManageAppointments: true,
PermUploadDocuments: true,
PermDeleteOwnDocuments: true,
PermAIExtraction: true,
},
"paralegal": {
PermEditAssignedCase: true,
PermViewAllCases: true,
PermManageDeadlines: true,
PermManageAppointments: true,
PermUploadDocuments: true,
},
"secretary": {
PermViewAllCases: true,
PermManageAppointments: true,
PermUploadDocuments: true,
},
}
// HasPermission checks if the given role has the specified permission.
func HasPermission(role string, perm Permission) bool {
perms, ok := rolePermissions[role]
if !ok {
return false
}
return perms[perm]
}
// RequirePermission returns middleware that checks if the user's role has the given permission.
func RequirePermission(perm Permission) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role := UserRoleFromContext(r.Context())
if role == "" || !HasPermission(role, perm) {
writeJSONError(w, "insufficient permissions", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireRole returns middleware that checks if the user has one of the specified roles.
func RequireRole(roles ...string) func(http.Handler) http.Handler {
allowed := make(map[string]bool, len(roles))
for _, r := range roles {
allowed[r] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role := UserRoleFromContext(r.Context())
if !allowed[role] {
writeJSONError(w, "insufficient permissions", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// IsAssignedToCase checks if a user is assigned to a specific case.
func IsAssignedToCase(ctx context.Context, db *sqlx.DB, userID, caseID uuid.UUID) (bool, error) {
var exists bool
err := db.GetContext(ctx, &exists,
`SELECT EXISTS(SELECT 1 FROM case_assignments WHERE user_id = $1 AND case_id = $2)`,
userID, caseID)
return exists, err
}
// CanEditCase checks if a user can edit a specific case based on role and assignment.
func CanEditCase(ctx context.Context, db *sqlx.DB, userID, caseID uuid.UUID, role string) (bool, error) {
// Owner and partner can edit all cases
if HasPermission(role, PermEditAllCases) {
return true, nil
}
// Others need to be assigned
if !HasPermission(role, PermEditAssignedCase) {
return false, nil
}
return IsAssignedToCase(ctx, db, userID, caseID)
}
// CanDeleteDocument checks if a user can delete a specific document.
func CanDeleteDocument(role string, docUploaderID, userID uuid.UUID) bool {
if HasPermission(role, PermDeleteDocuments) {
return true
}
if HasPermission(role, PermDeleteOwnDocuments) {
return docUploaderID == userID
}
return false
}
// permissionNames maps Permission constants to their string names for frontend use.
var permissionNames = map[Permission]string{
PermManageTeam: "manage_team",
PermManageBilling: "manage_billing",
PermCreateCase: "create_case",
PermEditAllCases: "edit_all_cases",
PermEditAssignedCase: "edit_assigned_case",
PermViewAllCases: "view_all_cases",
PermManageDeadlines: "manage_deadlines",
PermManageAppointments: "manage_appointments",
PermUploadDocuments: "upload_documents",
PermDeleteDocuments: "delete_documents",
PermDeleteOwnDocuments: "delete_own_documents",
PermViewAuditLog: "view_audit_log",
PermManageSettings: "manage_settings",
PermAIExtraction: "ai_extraction",
}
// GetRolePermissions returns a list of permission name strings for the given role.
func GetRolePermissions(role string) []string {
perms, ok := rolePermissions[role]
if !ok {
return nil
}
var names []string
for p := range perms {
if name, ok := permissionNames[p]; ok {
names = append(names, name)
}
}
return names
}
func writeJSONError(w http.ResponseWriter, msg string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write([]byte(`{"error":"` + msg + `"}`))
}

View File

@@ -13,6 +13,7 @@ import (
type TenantLookup interface {
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error)
GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error)
}
// TenantResolver is middleware that resolves the tenant from X-Tenant-ID header
@@ -42,21 +43,23 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
return
}
// Verify user has access to this tenant
hasAccess, err := tr.lookup.VerifyAccess(r.Context(), userID, parsed)
// Verify user has access and get their role
role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed)
if err != nil {
slog.Error("tenant access check failed", "error", err, "user_id", userID, "tenant_id", parsed)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if !hasAccess {
if role == "" {
http.Error(w, `{"error":"no access to tenant"}`, http.StatusForbidden)
return
}
tenantID = parsed
// Override the role from middleware with the correct one for this tenant
r = r.WithContext(ContextWithUserRole(r.Context(), role))
} else {
// Default to user's first tenant
// Default to user's first tenant (role already set by middleware)
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
if err != nil {
slog.Error("failed to resolve default tenant", "error", err, "user_id", userID)

View File

@@ -14,6 +14,7 @@ type mockTenantLookup struct {
err error
hasAccess bool
accessErr error
role string
}
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
@@ -24,9 +25,19 @@ func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uu
return m.hasAccess, m.accessErr
}
func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
if m.role != "" {
return m.role, m.err
}
if m.hasAccess {
return "associate", m.err
}
return "", m.err
}
func TestTenantResolver_FromHeader(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{hasAccess: true})
tr := NewTenantResolver(&mockTenantLookup{hasAccess: true, role: "partner"})
var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -0,0 +1,63 @@
package handlers
import (
"net/http"
"strconv"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type AuditLogHandler struct {
svc *services.AuditService
}
func NewAuditLogHandler(svc *services.AuditService) *AuditLogHandler {
return &AuditLogHandler{svc: svc}
}
func (h *AuditLogHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
q := r.URL.Query()
page, _ := strconv.Atoi(q.Get("page"))
limit, _ := strconv.Atoi(q.Get("limit"))
filter := services.AuditFilter{
EntityType: q.Get("entity_type"),
From: q.Get("from"),
To: q.Get("to"),
Page: page,
Limit: limit,
}
if idStr := q.Get("entity_id"); idStr != "" {
if id, err := uuid.Parse(idStr); err == nil {
filter.EntityID = &id
}
}
if idStr := q.Get("user_id"); idStr != "" {
if id, err := uuid.Parse(idStr); err == nil {
filter.UserID = &id
}
}
entries, total, err := h.svc.List(r.Context(), tenantID, filter)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch audit log")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"entries": entries,
"total": total,
"page": filter.Page,
"limit": filter.Limit,
})
}

View File

@@ -0,0 +1,119 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type CaseAssignmentHandler struct {
svc *services.CaseAssignmentService
}
func NewCaseAssignmentHandler(svc *services.CaseAssignmentService) *CaseAssignmentHandler {
return &CaseAssignmentHandler{svc: svc}
}
// List handles GET /api/cases/{id}/assignments
func (h *CaseAssignmentHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
assignments, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"assignments": assignments,
"total": len(assignments),
})
}
// Assign handles POST /api/cases/{id}/assignments
func (h *CaseAssignmentHandler) Assign(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
var req struct {
UserID string `json:"user_id"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
userID, err := uuid.Parse(req.UserID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid user_id")
return
}
if req.Role == "" {
req.Role = "team"
}
if req.Role != "lead" && req.Role != "team" && req.Role != "viewer" {
writeError(w, http.StatusBadRequest, "role must be lead, team, or viewer")
return
}
assignment, err := h.svc.Assign(r.Context(), tenantID, caseID, userID, req.Role)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, assignment)
}
// Unassign handles DELETE /api/cases/{id}/assignments/{uid}
func (h *CaseAssignmentHandler) Unassign(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
userID, err := uuid.Parse(r.PathValue("uid"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid user ID")
return
}
if err := h.svc.Unassign(r.Context(), tenantID, caseID, userID); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
}

View File

@@ -115,7 +115,7 @@ func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) {
return
}
deadline, err := h.deadlines.Create(tenantID, input)
deadline, err := h.deadlines.Create(r.Context(), tenantID, input)
if err != nil {
internalError(w, "failed to create deadline", err)
return
@@ -144,7 +144,7 @@ func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) {
return
}
deadline, err := h.deadlines.Update(tenantID, deadlineID, input)
deadline, err := h.deadlines.Update(r.Context(), tenantID, deadlineID, input)
if err != nil {
internalError(w, "failed to update deadline", err)
return
@@ -171,7 +171,7 @@ func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) {
return
}
deadline, err := h.deadlines.Complete(tenantID, deadlineID)
deadline, err := h.deadlines.Complete(r.Context(), tenantID, deadlineID)
if err != nil {
internalError(w, "failed to complete deadline", err)
return
@@ -198,7 +198,7 @@ func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.deadlines.Delete(tenantID, deadlineID); err != nil {
if err := h.deadlines.Delete(r.Context(), tenantID, deadlineID); err != nil {
writeError(w, http.StatusNotFound, "deadline not found")
return
}

View File

@@ -167,6 +167,7 @@ func (h *DocumentHandler) Delete(w http.ResponseWriter, r *http.Request) {
return
}
userID, _ := auth.UserFromContext(r.Context())
role := auth.UserRoleFromContext(r.Context())
docID, err := uuid.Parse(r.PathValue("docId"))
if err != nil {
@@ -174,6 +175,26 @@ func (h *DocumentHandler) Delete(w http.ResponseWriter, r *http.Request) {
return
}
// Check permission: owner/partner can delete any, associate can delete own
doc, err := h.svc.GetByID(r.Context(), tenantID, docID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if doc == nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
uploaderID := uuid.Nil
if doc.UploadedBy != nil {
uploaderID = *doc.UploadedBy
}
if !auth.CanDeleteDocument(role, uploaderID, userID) {
writeError(w, http.StatusForbidden, "insufficient permissions to delete this document")
return
}
if err := h.svc.Delete(r.Context(), tenantID, docID, userID); err != nil {
writeError(w, http.StatusNotFound, "document not found")
return

View File

@@ -0,0 +1,171 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// NotificationHandler handles notification API endpoints.
type NotificationHandler struct {
svc *services.NotificationService
db *sqlx.DB
}
// NewNotificationHandler creates a new notification handler.
func NewNotificationHandler(svc *services.NotificationService, db *sqlx.DB) *NotificationHandler {
return &NotificationHandler{svc: svc, db: db}
}
// List returns paginated notifications for the authenticated user.
func (h *NotificationHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
notifications, total, err := h.svc.ListForUser(r.Context(), tenantID, userID, limit, offset)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list notifications")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"data": notifications,
"total": total,
})
}
// UnreadCount returns the count of unread notifications.
func (h *NotificationHandler) UnreadCount(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
count, err := h.svc.UnreadCount(r.Context(), tenantID, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to count notifications")
return
}
writeJSON(w, http.StatusOK, map[string]int{"unread_count": count})
}
// MarkRead marks a single notification as read.
func (h *NotificationHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
notifID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid notification ID")
return
}
if err := h.svc.MarkRead(r.Context(), tenantID, userID, notifID); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// MarkAllRead marks all notifications as read.
func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
if err := h.svc.MarkAllRead(r.Context(), tenantID, userID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to mark all read")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// GetPreferences returns notification preferences for the authenticated user.
func (h *NotificationHandler) GetPreferences(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
pref, err := h.svc.GetPreferences(r.Context(), tenantID, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get preferences")
return
}
writeJSON(w, http.StatusOK, pref)
}
// UpdatePreferences updates notification preferences for the authenticated user.
func (h *NotificationHandler) UpdatePreferences(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
var input services.UpdatePreferencesInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
pref, err := h.svc.UpdatePreferences(r.Context(), tenantID, userID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update preferences")
return
}
writeJSON(w, http.StatusOK, pref)
}

View File

@@ -0,0 +1,109 @@
package handlers
import (
"net/http"
"time"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type ReportHandler struct {
svc *services.ReportingService
}
func NewReportHandler(svc *services.ReportingService) *ReportHandler {
return &ReportHandler{svc: svc}
}
// parseDateRange extracts from/to query params, defaulting to last 12 months.
func parseDateRange(r *http.Request) (time.Time, time.Time) {
now := time.Now()
from := time.Date(now.Year()-1, now.Month(), 1, 0, 0, 0, 0, time.UTC)
to := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, time.UTC)
if v := r.URL.Query().Get("from"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
from = t
}
}
if v := r.URL.Query().Get("to"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
to = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, time.UTC)
}
}
return from, to
}
func (h *ReportHandler) Cases(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
from, to := parseDateRange(r)
data, err := h.svc.CaseReport(r.Context(), tenantID, from, to)
if err != nil {
internalError(w, "failed to generate case report", err)
return
}
writeJSON(w, http.StatusOK, data)
}
func (h *ReportHandler) Deadlines(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
from, to := parseDateRange(r)
data, err := h.svc.DeadlineReport(r.Context(), tenantID, from, to)
if err != nil {
internalError(w, "failed to generate deadline report", err)
return
}
writeJSON(w, http.StatusOK, data)
}
func (h *ReportHandler) Workload(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
from, to := parseDateRange(r)
data, err := h.svc.WorkloadReport(r.Context(), tenantID, from, to)
if err != nil {
internalError(w, "failed to generate workload report", err)
return
}
writeJSON(w, http.StatusOK, data)
}
func (h *ReportHandler) Billing(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
from, to := parseDateRange(r)
data, err := h.svc.BillingReport(r.Context(), tenantID, from, to)
if err != nil {
internalError(w, "failed to generate billing report", err)
return
}
writeJSON(w, http.StatusOK, data)
}

View File

@@ -130,15 +130,15 @@ func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) {
return
}
// Only owners and admins can invite
// Only owners and partners can invite
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
if role != "owner" && role != "admin" {
jsonError(w, "only owners and admins can invite users", http.StatusForbidden)
if role != "owner" && role != "partner" {
jsonError(w, "only owners and partners can invite users", http.StatusForbidden)
return
}
@@ -155,10 +155,15 @@ func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) {
return
}
if req.Role == "" {
req.Role = "member"
req.Role = "associate"
}
if req.Role != "member" && req.Role != "admin" {
jsonError(w, "role must be member or admin", http.StatusBadRequest)
if !auth.IsValidRole(req.Role) {
jsonError(w, "invalid role", http.StatusBadRequest)
return
}
// Non-owners cannot invite as owner
if role != "owner" && req.Role == "owner" {
jsonError(w, "only owners can invite as owner", http.StatusForbidden)
return
}
@@ -192,14 +197,14 @@ func (h *TenantHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
return
}
// Only owners and admins can remove members (or user removing themselves)
// Only owners and partners can remove members (or user removing themselves)
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
if role != "owner" && role != "admin" && userID != memberID {
if role != "owner" && role != "partner" && userID != memberID {
jsonError(w, "insufficient permissions", http.StatusForbidden)
return
}
@@ -227,15 +232,15 @@ func (h *TenantHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
return
}
// Only owners and admins can update settings
// Only owners and partners can update settings
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
if role != "owner" && role != "admin" {
jsonError(w, "only owners and admins can update settings", http.StatusForbidden)
if role != "owner" && role != "partner" {
jsonError(w, "only owners and partners can update settings", http.StatusForbidden)
return
}
@@ -294,6 +299,85 @@ func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, members, http.StatusOK)
}
// UpdateMemberRole handles PUT /api/tenants/{id}/members/{uid}/role
func (h *TenantHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
return
}
memberID, err := uuid.Parse(r.PathValue("uid"))
if err != nil {
jsonError(w, "invalid member ID", http.StatusBadRequest)
return
}
// Only owners and partners can change roles
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if role != "owner" && role != "partner" {
jsonError(w, "only owners and partners can change roles", http.StatusForbidden)
return
}
var req struct {
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if !auth.IsValidRole(req.Role) {
jsonError(w, "invalid role", http.StatusBadRequest)
return
}
// Non-owners cannot promote to owner
if role != "owner" && req.Role == "owner" {
jsonError(w, "only owners can promote to owner", http.StatusForbidden)
return
}
if err := h.svc.UpdateMemberRole(r.Context(), tenantID, memberID, req.Role); err != nil {
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
jsonResponse(w, map[string]string{"status": "updated"}, http.StatusOK)
}
// GetMe handles GET /api/me — returns the current user's ID and role in the active tenant.
func (h *TenantHandler) GetMe(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
role := auth.UserRoleFromContext(r.Context())
tenantID, _ := auth.TenantFromContext(r.Context())
// Get user's permissions for frontend UI
perms := auth.GetRolePermissions(role)
jsonResponse(w, map[string]any{
"user_id": userID,
"tenant_id": tenantID,
"role": role,
"permissions": perms,
}, http.StatusOK)
}
func jsonResponse(w http.ResponseWriter, data interface{}, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)

View File

@@ -46,7 +46,7 @@ func testServer(t *testing.T) (http.Handler, func()) {
}
authMW := auth.NewMiddleware(jwtSecret, database)
handler := router.New(database, authMW, cfg, nil)
handler := router.New(database, authMW, cfg, nil, nil)
return handler, func() { database.Close() }
}

View File

@@ -0,0 +1,22 @@
package models
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
type AuditLog struct {
ID int64 `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
UserID *uuid.UUID `db:"user_id" json:"user_id,omitempty"`
Action string `db:"action" json:"action"`
EntityType string `db:"entity_type" json:"entity_type"`
EntityID *uuid.UUID `db:"entity_id" json:"entity_id,omitempty"`
OldValues *json.RawMessage `db:"old_values" json:"old_values,omitempty"`
NewValues *json.RawMessage `db:"new_values" json:"new_values,omitempty"`
IPAddress *string `db:"ip_address" json:"ip_address,omitempty"`
UserAgent *string `db:"user_agent" json:"user_agent,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}

View File

@@ -0,0 +1,15 @@
package models
import (
"time"
"github.com/google/uuid"
)
type CaseAssignment struct {
ID uuid.UUID `db:"id" json:"id"`
CaseID uuid.UUID `db:"case_id" json:"case_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Role string `db:"role" json:"role"`
AssignedAt time.Time `db:"assigned_at" json:"assigned_at"`
}

View File

@@ -0,0 +1,32 @@
package models
import (
"time"
"github.com/google/uuid"
"github.com/lib/pq"
)
type Notification struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Type string `db:"type" json:"type"`
EntityType *string `db:"entity_type" json:"entity_type,omitempty"`
EntityID *uuid.UUID `db:"entity_id" json:"entity_id,omitempty"`
Title string `db:"title" json:"title"`
Body *string `db:"body" json:"body,omitempty"`
SentAt *time.Time `db:"sent_at" json:"sent_at,omitempty"`
ReadAt *time.Time `db:"read_at" json:"read_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
type NotificationPreferences struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
DeadlineReminderDays pq.Int64Array `db:"deadline_reminder_days" json:"deadline_reminder_days"`
EmailEnabled bool `db:"email_enabled" json:"email_enabled"`
DailyDigest bool `db:"daily_digest" json:"daily_digest"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -15,20 +15,22 @@ import (
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *services.CalDAVService) http.Handler {
func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *services.CalDAVService, notifSvc *services.NotificationService) http.Handler {
mux := http.NewServeMux()
// Services
tenantSvc := services.NewTenantService(db)
caseSvc := services.NewCaseService(db)
partySvc := services.NewPartyService(db)
appointmentSvc := services.NewAppointmentService(db)
auditSvc := services.NewAuditService(db)
tenantSvc := services.NewTenantService(db, auditSvc)
caseSvc := services.NewCaseService(db, auditSvc)
partySvc := services.NewPartyService(db, auditSvc)
appointmentSvc := services.NewAppointmentService(db, auditSvc)
holidaySvc := services.NewHolidayService(db)
deadlineSvc := services.NewDeadlineService(db)
deadlineSvc := services.NewDeadlineService(db, auditSvc)
deadlineRuleSvc := services.NewDeadlineRuleService(db)
calculator := services.NewDeadlineCalculator(holidaySvc)
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
documentSvc := services.NewDocumentService(db, storageCli)
documentSvc := services.NewDocumentService(db, storageCli, auditSvc)
assignmentSvc := services.NewCaseAssignmentService(db)
// AI service (optional — only if API key is configured)
var aiH *handlers.AIHandler
@@ -40,10 +42,18 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
// Middleware
tenantResolver := auth.NewTenantResolver(tenantSvc)
noteSvc := services.NewNoteService(db)
noteSvc := services.NewNoteService(db, auditSvc)
dashboardSvc := services.NewDashboardService(db)
reportingSvc := services.NewReportingService(db)
// Notification handler (optional — nil in tests)
var notifH *handlers.NotificationHandler
if notifSvc != nil {
notifH = handlers.NewNotificationHandler(notifSvc, db)
}
// Handlers
auditH := handlers.NewAuditLogHandler(auditSvc)
tenantH := handlers.NewTenantHandler(tenantSvc)
caseH := handlers.NewCaseHandler(caseSvc)
partyH := handlers.NewPartyHandler(partySvc)
@@ -52,9 +62,11 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
dashboardH := handlers.NewDashboardHandler(dashboardSvc)
reportH := handlers.NewReportHandler(reportingSvc)
noteH := handlers.NewNoteHandler(noteSvc)
eventH := handlers.NewCaseEventHandler(db)
docH := handlers.NewDocumentHandler(documentSvc)
assignmentH := handlers.NewCaseAssignmentHandler(assignmentSvc)
// Public routes
mux.HandleFunc("GET /health", handleHealth(db))
@@ -70,77 +82,119 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
api.HandleFunc("POST /api/tenants/{id}/invite", tenantH.InviteUser)
api.HandleFunc("DELETE /api/tenants/{id}/members/{uid}", tenantH.RemoveMember)
api.HandleFunc("GET /api/tenants/{id}/members", tenantH.ListMembers)
api.HandleFunc("PUT /api/tenants/{id}/members/{uid}/role", tenantH.UpdateMemberRole)
// Permission-wrapping helper: wraps a HandlerFunc with a permission check
perm := func(p auth.Permission, fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
role := auth.UserRoleFromContext(r.Context())
if !auth.HasPermission(role, p) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":"insufficient permissions"}`))
return
}
fn(w, r)
}
}
// Tenant-scoped routes (require tenant context)
scoped := http.NewServeMux()
// Cases
scoped.HandleFunc("GET /api/cases", caseH.List)
scoped.HandleFunc("POST /api/cases", caseH.Create)
scoped.HandleFunc("GET /api/cases/{id}", caseH.Get)
scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update)
scoped.HandleFunc("DELETE /api/cases/{id}", caseH.Delete)
// Current user info (role, permissions) — all authenticated users
scoped.HandleFunc("GET /api/me", tenantH.GetMe)
// Parties
// Cases — all can view, create needs PermCreateCase, archive needs PermCreateCase
scoped.HandleFunc("GET /api/cases", caseH.List)
scoped.HandleFunc("POST /api/cases", perm(auth.PermCreateCase, caseH.Create))
scoped.HandleFunc("GET /api/cases/{id}", caseH.Get)
scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update) // case-level access checked in handler
scoped.HandleFunc("DELETE /api/cases/{id}", perm(auth.PermCreateCase, caseH.Delete))
// Parties — same access as case editing
scoped.HandleFunc("GET /api/cases/{id}/parties", partyH.List)
scoped.HandleFunc("POST /api/cases/{id}/parties", partyH.Create)
scoped.HandleFunc("PUT /api/parties/{partyId}", partyH.Update)
scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete)
// Deadlines
// Deadlines — manage needs PermManageDeadlines, view is open
scoped.HandleFunc("GET /api/deadlines/{deadlineID}", deadlineH.Get)
scoped.HandleFunc("GET /api/deadlines", deadlineH.ListAll)
scoped.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase)
scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", deadlineH.Create)
scoped.HandleFunc("PUT /api/deadlines/{deadlineID}", deadlineH.Update)
scoped.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", deadlineH.Complete)
scoped.HandleFunc("DELETE /api/deadlines/{deadlineID}", deadlineH.Delete)
scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", perm(auth.PermManageDeadlines, deadlineH.Create))
scoped.HandleFunc("PUT /api/deadlines/{deadlineID}", perm(auth.PermManageDeadlines, deadlineH.Update))
scoped.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", perm(auth.PermManageDeadlines, deadlineH.Complete))
scoped.HandleFunc("DELETE /api/deadlines/{deadlineID}", perm(auth.PermManageDeadlines, deadlineH.Delete))
// Deadline rules (reference data)
// Deadline rules (reference data) — all can read
scoped.HandleFunc("GET /api/deadline-rules", ruleH.List)
scoped.HandleFunc("GET /api/deadline-rules/{type}", ruleH.GetRuleTree)
scoped.HandleFunc("GET /api/proceeding-types", ruleH.ListProceedingTypes)
// Deadline calculator
// Deadline calculator — all can use
scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate)
// Appointments
// Appointments — all can manage (PermManageAppointments granted to all)
scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get)
scoped.HandleFunc("GET /api/appointments", apptH.List)
scoped.HandleFunc("POST /api/appointments", apptH.Create)
scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update)
scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete)
scoped.HandleFunc("POST /api/appointments", perm(auth.PermManageAppointments, apptH.Create))
scoped.HandleFunc("PUT /api/appointments/{id}", perm(auth.PermManageAppointments, apptH.Update))
scoped.HandleFunc("DELETE /api/appointments/{id}", perm(auth.PermManageAppointments, apptH.Delete))
// Case events
// Case assignments — manage team required for assign/unassign
scoped.HandleFunc("GET /api/cases/{id}/assignments", assignmentH.List)
scoped.HandleFunc("POST /api/cases/{id}/assignments", perm(auth.PermManageTeam, assignmentH.Assign))
scoped.HandleFunc("DELETE /api/cases/{id}/assignments/{uid}", perm(auth.PermManageTeam, assignmentH.Unassign))
// Case events — all can view
scoped.HandleFunc("GET /api/case-events/{id}", eventH.Get)
// Notes
// Notes — all can manage
scoped.HandleFunc("GET /api/notes", noteH.List)
scoped.HandleFunc("POST /api/notes", noteH.Create)
scoped.HandleFunc("PUT /api/notes/{id}", noteH.Update)
scoped.HandleFunc("DELETE /api/notes/{id}", noteH.Delete)
// Dashboard
// Dashboard — all can view
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
// Documents
// Reports — all can view
scoped.HandleFunc("GET /api/reports/cases", reportH.Cases)
scoped.HandleFunc("GET /api/reports/deadlines", reportH.Deadlines)
scoped.HandleFunc("GET /api/reports/workload", reportH.Workload)
scoped.HandleFunc("GET /api/reports/billing", reportH.Billing)
// Audit log
scoped.HandleFunc("GET /api/audit-log", auditH.List)
// Documents — all can upload, delete checked in handler (own vs all)
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
scoped.HandleFunc("POST /api/cases/{id}/documents", docH.Upload)
scoped.HandleFunc("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, docH.Upload))
scoped.HandleFunc("GET /api/documents/{docId}", docH.Download)
scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta)
scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete)
scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) // permission check inside handler
// AI endpoints (rate limited: 5 req/min burst 10 per IP)
if aiH != nil {
aiLimiter := middleware.NewTokenBucket(5.0/60.0, 10)
scoped.HandleFunc("POST /api/ai/extract-deadlines", aiLimiter.LimitFunc(aiH.ExtractDeadlines))
scoped.HandleFunc("POST /api/ai/summarize-case", aiLimiter.LimitFunc(aiH.SummarizeCase))
scoped.HandleFunc("POST /api/ai/extract-deadlines", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.ExtractDeadlines)))
scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase)))
}
// CalDAV sync endpoints
// Notifications
if notifH != nil {
scoped.HandleFunc("GET /api/notifications", notifH.List)
scoped.HandleFunc("GET /api/notifications/unread-count", notifH.UnreadCount)
scoped.HandleFunc("PATCH /api/notifications/{id}/read", notifH.MarkRead)
scoped.HandleFunc("PATCH /api/notifications/read-all", notifH.MarkAllRead)
scoped.HandleFunc("GET /api/notification-preferences", notifH.GetPreferences)
scoped.HandleFunc("PUT /api/notification-preferences", notifH.UpdatePreferences)
}
// CalDAV sync endpoints — settings permission required
if calDAVSvc != nil {
calDAVH := handlers.NewCalDAVHandler(calDAVSvc)
scoped.HandleFunc("POST /api/caldav/sync", calDAVH.TriggerSync)
scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync))
scoped.HandleFunc("GET /api/caldav/status", calDAVH.GetStatus)
}

View File

@@ -12,11 +12,12 @@ import (
)
type AppointmentService struct {
db *sqlx.DB
db *sqlx.DB
audit *AuditService
}
func NewAppointmentService(db *sqlx.DB) *AppointmentService {
return &AppointmentService{db: db}
func NewAppointmentService(db *sqlx.DB, audit *AuditService) *AppointmentService {
return &AppointmentService{db: db, audit: audit}
}
type AppointmentFilter struct {
@@ -86,6 +87,7 @@ func (s *AppointmentService) Create(ctx context.Context, a *models.Appointment)
if err != nil {
return fmt.Errorf("creating appointment: %w", err)
}
s.audit.Log(ctx, "create", "appointment", &a.ID, nil, a)
return nil
}
@@ -116,6 +118,7 @@ func (s *AppointmentService) Update(ctx context.Context, a *models.Appointment)
if rows == 0 {
return fmt.Errorf("appointment not found")
}
s.audit.Log(ctx, "update", "appointment", &a.ID, nil, a)
return nil
}
@@ -131,5 +134,6 @@ func (s *AppointmentService) Delete(ctx context.Context, tenantID, id uuid.UUID)
if rows == 0 {
return fmt.Errorf("appointment not found")
}
s.audit.Log(ctx, "delete", "appointment", &id, nil, nil)
return nil
}

View File

@@ -0,0 +1,141 @@
package services
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
type AuditService struct {
db *sqlx.DB
}
func NewAuditService(db *sqlx.DB) *AuditService {
return &AuditService{db: db}
}
// Log records an audit entry. It extracts tenant, user, IP, and user-agent from context.
// Errors are logged but not returned — audit logging must not break business operations.
func (s *AuditService) Log(ctx context.Context, action, entityType string, entityID *uuid.UUID, oldValues, newValues any) {
tenantID, ok := auth.TenantFromContext(ctx)
if !ok {
slog.Warn("audit: missing tenant_id in context", "action", action, "entity_type", entityType)
return
}
var userID *uuid.UUID
if uid, ok := auth.UserFromContext(ctx); ok {
userID = &uid
}
var oldJSON, newJSON *json.RawMessage
if oldValues != nil {
if b, err := json.Marshal(oldValues); err == nil {
raw := json.RawMessage(b)
oldJSON = &raw
}
}
if newValues != nil {
if b, err := json.Marshal(newValues); err == nil {
raw := json.RawMessage(b)
newJSON = &raw
}
}
ip := auth.IPFromContext(ctx)
ua := auth.UserAgentFromContext(ctx)
_, err := s.db.ExecContext(ctx,
`INSERT INTO audit_log (tenant_id, user_id, action, entity_type, entity_id, old_values, new_values, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
tenantID, userID, action, entityType, entityID, oldJSON, newJSON, ip, ua)
if err != nil {
slog.Error("audit: failed to write log entry",
"error", err,
"action", action,
"entity_type", entityType,
"entity_id", entityID,
)
}
}
// AuditFilter holds query parameters for listing audit log entries.
type AuditFilter struct {
EntityType string
EntityID *uuid.UUID
UserID *uuid.UUID
From string // RFC3339 date
To string // RFC3339 date
Page int
Limit int
}
// List returns paginated audit log entries for a tenant.
func (s *AuditService) List(ctx context.Context, tenantID uuid.UUID, filter AuditFilter) ([]models.AuditLog, int, error) {
if filter.Limit <= 0 {
filter.Limit = 50
}
if filter.Limit > 200 {
filter.Limit = 200
}
if filter.Page <= 0 {
filter.Page = 1
}
offset := (filter.Page - 1) * filter.Limit
where := "WHERE tenant_id = $1"
args := []any{tenantID}
argIdx := 2
if filter.EntityType != "" {
where += fmt.Sprintf(" AND entity_type = $%d", argIdx)
args = append(args, filter.EntityType)
argIdx++
}
if filter.EntityID != nil {
where += fmt.Sprintf(" AND entity_id = $%d", argIdx)
args = append(args, *filter.EntityID)
argIdx++
}
if filter.UserID != nil {
where += fmt.Sprintf(" AND user_id = $%d", argIdx)
args = append(args, *filter.UserID)
argIdx++
}
if filter.From != "" {
where += fmt.Sprintf(" AND created_at >= $%d", argIdx)
args = append(args, filter.From)
argIdx++
}
if filter.To != "" {
where += fmt.Sprintf(" AND created_at <= $%d", argIdx)
args = append(args, filter.To)
argIdx++
}
var total int
if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM audit_log "+where, args...); err != nil {
return nil, 0, fmt.Errorf("counting audit entries: %w", err)
}
query := fmt.Sprintf("SELECT * FROM audit_log %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d",
where, argIdx, argIdx+1)
args = append(args, filter.Limit, offset)
var entries []models.AuditLog
if err := s.db.SelectContext(ctx, &entries, query, args...); err != nil {
return nil, 0, fmt.Errorf("listing audit entries: %w", err)
}
if entries == nil {
entries = []models.AuditLog{}
}
return entries, total, nil
}

View File

@@ -0,0 +1,92 @@
package services
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
type CaseAssignmentService struct {
db *sqlx.DB
}
func NewCaseAssignmentService(db *sqlx.DB) *CaseAssignmentService {
return &CaseAssignmentService{db: db}
}
// ListByCase returns all assignments for a case.
func (s *CaseAssignmentService) ListByCase(ctx context.Context, tenantID, caseID uuid.UUID) ([]models.CaseAssignment, error) {
var assignments []models.CaseAssignment
err := s.db.SelectContext(ctx, &assignments,
`SELECT ca.id, ca.case_id, ca.user_id, ca.role, ca.assigned_at
FROM case_assignments ca
JOIN cases c ON c.id = ca.case_id
WHERE ca.case_id = $1 AND c.tenant_id = $2
ORDER BY ca.assigned_at`,
caseID, tenantID)
if err != nil {
return nil, fmt.Errorf("list case assignments: %w", err)
}
return assignments, nil
}
// Assign adds a user to a case with the given role.
func (s *CaseAssignmentService) Assign(ctx context.Context, tenantID, caseID, userID uuid.UUID, role string) (*models.CaseAssignment, error) {
// Verify user is a member of this tenant
var memberExists bool
err := s.db.GetContext(ctx, &memberExists,
`SELECT EXISTS(SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2)`,
userID, tenantID)
if err != nil {
return nil, fmt.Errorf("check membership: %w", err)
}
if !memberExists {
return nil, fmt.Errorf("user is not a member of this tenant")
}
// Verify case belongs to tenant
var caseExists bool
err = s.db.GetContext(ctx, &caseExists,
`SELECT EXISTS(SELECT 1 FROM cases WHERE id = $1 AND tenant_id = $2)`,
caseID, tenantID)
if err != nil {
return nil, fmt.Errorf("check case: %w", err)
}
if !caseExists {
return nil, fmt.Errorf("case not found")
}
var assignment models.CaseAssignment
err = s.db.QueryRowxContext(ctx,
`INSERT INTO case_assignments (case_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (case_id, user_id) DO UPDATE SET role = EXCLUDED.role
RETURNING id, case_id, user_id, role, assigned_at`,
caseID, userID, role,
).StructScan(&assignment)
if err != nil {
return nil, fmt.Errorf("assign user to case: %w", err)
}
return &assignment, nil
}
// Unassign removes a user from a case.
func (s *CaseAssignmentService) Unassign(ctx context.Context, tenantID, caseID, userID uuid.UUID) error {
result, err := s.db.ExecContext(ctx,
`DELETE FROM case_assignments ca
USING cases c
WHERE ca.case_id = c.id AND ca.case_id = $1 AND ca.user_id = $2 AND c.tenant_id = $3`,
caseID, userID, tenantID)
if err != nil {
return fmt.Errorf("unassign: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("assignment not found")
}
return nil
}

View File

@@ -13,11 +13,12 @@ import (
)
type CaseService struct {
db *sqlx.DB
db *sqlx.DB
audit *AuditService
}
func NewCaseService(db *sqlx.DB) *CaseService {
return &CaseService{db: db}
func NewCaseService(db *sqlx.DB, audit *AuditService) *CaseService {
return &CaseService{db: db, audit: audit}
}
type CaseFilter struct {
@@ -162,6 +163,9 @@ func (s *CaseService) Create(ctx context.Context, tenantID uuid.UUID, userID uui
if err := s.db.GetContext(ctx, &c, "SELECT * FROM cases WHERE id = $1", id); err != nil {
return nil, fmt.Errorf("fetching created case: %w", err)
}
s.audit.Log(ctx, "create", "case", &id, nil, c)
return &c, nil
}
@@ -239,6 +243,9 @@ func (s *CaseService) Update(ctx context.Context, tenantID, caseID uuid.UUID, us
if err := s.db.GetContext(ctx, &updated, "SELECT * FROM cases WHERE id = $1", caseID); err != nil {
return nil, fmt.Errorf("fetching updated case: %w", err)
}
s.audit.Log(ctx, "update", "case", &caseID, current, updated)
return &updated, nil
}
@@ -254,6 +261,7 @@ func (s *CaseService) Delete(ctx context.Context, tenantID, caseID uuid.UUID, us
return sql.ErrNoRows
}
createEvent(ctx, s.db, tenantID, caseID, userID, "case_archived", "Case archived", nil)
s.audit.Log(ctx, "delete", "case", &caseID, map[string]string{"status": "active"}, map[string]string{"status": "archived"})
return nil
}

View File

@@ -1,6 +1,7 @@
package services
import (
"context"
"database/sql"
"fmt"
"time"
@@ -13,12 +14,13 @@ import (
// DeadlineService handles CRUD operations for case deadlines
type DeadlineService struct {
db *sqlx.DB
db *sqlx.DB
audit *AuditService
}
// NewDeadlineService creates a new deadline service
func NewDeadlineService(db *sqlx.DB) *DeadlineService {
return &DeadlineService{db: db}
func NewDeadlineService(db *sqlx.DB, audit *AuditService) *DeadlineService {
return &DeadlineService{db: db, audit: audit}
}
// ListAll returns all deadlines for a tenant, ordered by due_date
@@ -87,7 +89,7 @@ type CreateDeadlineInput struct {
}
// Create inserts a new deadline
func (s *DeadlineService) Create(tenantID uuid.UUID, input CreateDeadlineInput) (*models.Deadline, error) {
func (s *DeadlineService) Create(ctx context.Context, tenantID uuid.UUID, input CreateDeadlineInput) (*models.Deadline, error) {
id := uuid.New()
source := input.Source
if source == "" {
@@ -108,6 +110,7 @@ func (s *DeadlineService) Create(tenantID uuid.UUID, input CreateDeadlineInput)
if err != nil {
return nil, fmt.Errorf("creating deadline: %w", err)
}
s.audit.Log(ctx, "create", "deadline", &id, nil, d)
return &d, nil
}
@@ -123,7 +126,7 @@ type UpdateDeadlineInput struct {
}
// Update modifies an existing deadline
func (s *DeadlineService) Update(tenantID, deadlineID uuid.UUID, input UpdateDeadlineInput) (*models.Deadline, error) {
func (s *DeadlineService) Update(ctx context.Context, tenantID, deadlineID uuid.UUID, input UpdateDeadlineInput) (*models.Deadline, error) {
// First check it exists and belongs to tenant
existing, err := s.GetByID(tenantID, deadlineID)
if err != nil {
@@ -154,11 +157,12 @@ func (s *DeadlineService) Update(tenantID, deadlineID uuid.UUID, input UpdateDea
if err != nil {
return nil, fmt.Errorf("updating deadline: %w", err)
}
s.audit.Log(ctx, "update", "deadline", &deadlineID, existing, d)
return &d, nil
}
// Complete marks a deadline as completed
func (s *DeadlineService) Complete(tenantID, deadlineID uuid.UUID) (*models.Deadline, error) {
func (s *DeadlineService) Complete(ctx context.Context, tenantID, deadlineID uuid.UUID) (*models.Deadline, error) {
query := `UPDATE deadlines SET
status = 'completed',
completed_at = $1,
@@ -176,11 +180,12 @@ func (s *DeadlineService) Complete(tenantID, deadlineID uuid.UUID) (*models.Dead
}
return nil, fmt.Errorf("completing deadline: %w", err)
}
s.audit.Log(ctx, "update", "deadline", &deadlineID, map[string]string{"status": "pending"}, map[string]string{"status": "completed"})
return &d, nil
}
// Delete removes a deadline
func (s *DeadlineService) Delete(tenantID, deadlineID uuid.UUID) error {
func (s *DeadlineService) Delete(ctx context.Context, tenantID, deadlineID uuid.UUID) error {
query := `DELETE FROM deadlines WHERE id = $1 AND tenant_id = $2`
result, err := s.db.Exec(query, deadlineID, tenantID)
if err != nil {
@@ -193,5 +198,6 @@ func (s *DeadlineService) Delete(tenantID, deadlineID uuid.UUID) error {
if rows == 0 {
return fmt.Errorf("deadline not found")
}
s.audit.Log(ctx, "delete", "deadline", &deadlineID, nil, nil)
return nil
}

View File

@@ -18,10 +18,11 @@ const documentBucket = "kanzlai-documents"
type DocumentService struct {
db *sqlx.DB
storage *StorageClient
audit *AuditService
}
func NewDocumentService(db *sqlx.DB, storage *StorageClient) *DocumentService {
return &DocumentService{db: db, storage: storage}
func NewDocumentService(db *sqlx.DB, storage *StorageClient, audit *AuditService) *DocumentService {
return &DocumentService{db: db, storage: storage, audit: audit}
}
type CreateDocumentInput struct {
@@ -97,6 +98,7 @@ func (s *DocumentService) Create(ctx context.Context, tenantID, caseID, userID u
if err := s.db.GetContext(ctx, &doc, "SELECT * FROM documents WHERE id = $1", id); err != nil {
return nil, fmt.Errorf("fetching created document: %w", err)
}
s.audit.Log(ctx, "create", "document", &id, nil, doc)
return &doc, nil
}
@@ -151,6 +153,7 @@ func (s *DocumentService) Delete(ctx context.Context, tenantID, docID, userID uu
// Log case event
createEvent(ctx, s.db, tenantID, doc.CaseID, userID, "document_deleted",
fmt.Sprintf("Document deleted: %s", doc.Title), nil)
s.audit.Log(ctx, "delete", "document", &docID, doc, nil)
return nil
}

View File

@@ -13,11 +13,12 @@ import (
)
type NoteService struct {
db *sqlx.DB
db *sqlx.DB
audit *AuditService
}
func NewNoteService(db *sqlx.DB) *NoteService {
return &NoteService{db: db}
func NewNoteService(db *sqlx.DB, audit *AuditService) *NoteService {
return &NoteService{db: db, audit: audit}
}
// ListByParent returns all notes for a given parent entity, scoped to tenant.
@@ -68,6 +69,7 @@ func (s *NoteService) Create(ctx context.Context, tenantID uuid.UUID, createdBy
if err != nil {
return nil, fmt.Errorf("creating note: %w", err)
}
s.audit.Log(ctx, "create", "note", &id, nil, n)
return &n, nil
}
@@ -85,6 +87,7 @@ func (s *NoteService) Update(ctx context.Context, tenantID, noteID uuid.UUID, co
}
return nil, fmt.Errorf("updating note: %w", err)
}
s.audit.Log(ctx, "update", "note", &noteID, nil, n)
return &n, nil
}
@@ -101,6 +104,7 @@ func (s *NoteService) Delete(ctx context.Context, tenantID, noteID uuid.UUID) er
if rows == 0 {
return fmt.Errorf("note not found")
}
s.audit.Log(ctx, "delete", "note", &noteID, nil, nil)
return nil
}

View File

@@ -0,0 +1,501 @@
package services
import (
"context"
"fmt"
"log/slog"
"os/exec"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
// NotificationService handles notification CRUD, deadline reminders, and email sending.
type NotificationService struct {
db *sqlx.DB
stopCh chan struct{}
wg sync.WaitGroup
}
// NewNotificationService creates a new notification service.
func NewNotificationService(db *sqlx.DB) *NotificationService {
return &NotificationService{
db: db,
stopCh: make(chan struct{}),
}
}
// Start launches the background reminder checker (every hour) and daily digest (8am).
func (s *NotificationService) Start() {
s.wg.Add(1)
go s.backgroundLoop()
}
// Stop gracefully shuts down background workers.
func (s *NotificationService) Stop() {
close(s.stopCh)
s.wg.Wait()
}
func (s *NotificationService) backgroundLoop() {
defer s.wg.Done()
// Check reminders on startup
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
s.CheckDeadlineReminders(ctx)
cancel()
reminderTicker := time.NewTicker(1 * time.Hour)
defer reminderTicker.Stop()
// Digest ticker: check every 15 minutes, send at 8am
digestTicker := time.NewTicker(15 * time.Minute)
defer digestTicker.Stop()
var lastDigestDate string
for {
select {
case <-s.stopCh:
return
case <-reminderTicker.C:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
s.CheckDeadlineReminders(ctx)
cancel()
case now := <-digestTicker.C:
today := now.Format("2006-01-02")
hour := now.Hour()
if hour >= 8 && lastDigestDate != today {
lastDigestDate = today
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
s.SendDailyDigests(ctx)
cancel()
}
}
}
}
// CheckDeadlineReminders finds deadlines due in N days matching user preferences and creates notifications.
func (s *NotificationService) CheckDeadlineReminders(ctx context.Context) {
slog.Info("checking deadline reminders")
// Get all user preferences with email enabled
var prefs []models.NotificationPreferences
err := s.db.SelectContext(ctx, &prefs,
`SELECT user_id, tenant_id, deadline_reminder_days, email_enabled, daily_digest, created_at, updated_at
FROM notification_preferences`)
if err != nil {
slog.Error("failed to load notification preferences", "error", err)
return
}
if len(prefs) == 0 {
return
}
// Collect all unique reminder day values across all users
daySet := make(map[int64]bool)
for _, p := range prefs {
for _, d := range p.DeadlineReminderDays {
daySet[d] = true
}
}
if len(daySet) == 0 {
return
}
// Build array of target dates
today := time.Now().Truncate(24 * time.Hour)
var targetDates []string
dayToDate := make(map[string]int64)
for d := range daySet {
target := today.AddDate(0, 0, int(d))
dateStr := target.Format("2006-01-02")
targetDates = append(targetDates, dateStr)
dayToDate[dateStr] = d
}
// Also check overdue deadlines
todayStr := today.Format("2006-01-02")
// Find pending deadlines matching target dates
type deadlineRow struct {
models.Deadline
CaseTitle string `db:"case_title"`
CaseNumber string `db:"case_number"`
}
// Reminder deadlines (due in N days)
var reminderDeadlines []deadlineRow
query, args, err := sqlx.In(
`SELECT d.*, c.title AS case_title, c.case_number
FROM deadlines d
JOIN cases c ON c.id = d.case_id
WHERE d.status = 'pending' AND d.due_date IN (?)`,
targetDates)
if err == nil {
query = s.db.Rebind(query)
err = s.db.SelectContext(ctx, &reminderDeadlines, query, args...)
}
if err != nil {
slog.Error("failed to query reminder deadlines", "error", err)
}
// Overdue deadlines
var overdueDeadlines []deadlineRow
err = s.db.SelectContext(ctx, &overdueDeadlines,
`SELECT d.*, c.title AS case_title, c.case_number
FROM deadlines d
JOIN cases c ON c.id = d.case_id
WHERE d.status = 'pending' AND d.due_date < $1`, todayStr)
if err != nil {
slog.Error("failed to query overdue deadlines", "error", err)
}
// Create notifications for each user based on their tenant and preferences
for _, pref := range prefs {
// Reminder notifications
for _, dl := range reminderDeadlines {
if dl.TenantID != pref.TenantID {
continue
}
daysUntil := dayToDate[dl.DueDate]
// Check if this user cares about this many days
if !containsDay(pref.DeadlineReminderDays, daysUntil) {
continue
}
title := fmt.Sprintf("Frist in %d Tagen: %s", daysUntil, dl.Title)
body := fmt.Sprintf("Akte %s — %s\nFällig am %s", dl.CaseNumber, dl.CaseTitle, dl.DueDate)
entityType := "deadline"
s.CreateNotification(ctx, CreateNotificationInput{
TenantID: pref.TenantID,
UserID: pref.UserID,
Type: "deadline_reminder",
EntityType: &entityType,
EntityID: &dl.ID,
Title: title,
Body: &body,
SendEmail: pref.EmailEnabled && !pref.DailyDigest,
})
}
// Overdue notifications
for _, dl := range overdueDeadlines {
if dl.TenantID != pref.TenantID {
continue
}
title := fmt.Sprintf("Frist überfällig: %s", dl.Title)
body := fmt.Sprintf("Akte %s — %s\nFällig seit %s", dl.CaseNumber, dl.CaseTitle, dl.DueDate)
entityType := "deadline"
s.CreateNotification(ctx, CreateNotificationInput{
TenantID: pref.TenantID,
UserID: pref.UserID,
Type: "deadline_overdue",
EntityType: &entityType,
EntityID: &dl.ID,
Title: title,
Body: &body,
SendEmail: pref.EmailEnabled && !pref.DailyDigest,
})
}
}
}
// SendDailyDigests compiles pending notifications into one email per user.
func (s *NotificationService) SendDailyDigests(ctx context.Context) {
slog.Info("sending daily digests")
// Find users with daily_digest enabled
var prefs []models.NotificationPreferences
err := s.db.SelectContext(ctx, &prefs,
`SELECT user_id, tenant_id, deadline_reminder_days, email_enabled, daily_digest, created_at, updated_at
FROM notification_preferences
WHERE daily_digest = true AND email_enabled = true`)
if err != nil {
slog.Error("failed to load digest preferences", "error", err)
return
}
for _, pref := range prefs {
// Get unsent notifications for this user from the last 24 hours
var notifications []models.Notification
err := s.db.SelectContext(ctx, &notifications,
`SELECT id, tenant_id, user_id, type, entity_type, entity_id, title, body, sent_at, read_at, created_at
FROM notifications
WHERE user_id = $1 AND tenant_id = $2 AND sent_at IS NULL
AND created_at > now() - interval '24 hours'
ORDER BY created_at DESC`,
pref.UserID, pref.TenantID)
if err != nil {
slog.Error("failed to load unsent notifications", "error", err, "user_id", pref.UserID)
continue
}
if len(notifications) == 0 {
continue
}
// Get user email
email := s.getUserEmail(ctx, pref.UserID)
if email == "" {
continue
}
// Build digest
var lines []string
lines = append(lines, fmt.Sprintf("Guten Morgen! Hier ist Ihre Tagesübersicht mit %d Benachrichtigungen:\n", len(notifications)))
for _, n := range notifications {
body := ""
if n.Body != nil {
body = " — " + *n.Body
}
lines = append(lines, fmt.Sprintf("• %s%s", n.Title, body))
}
lines = append(lines, "\n---\nKanzlAI Kanzleimanagement")
subject := fmt.Sprintf("KanzlAI Tagesübersicht — %d Benachrichtigungen", len(notifications))
bodyText := strings.Join(lines, "\n")
if err := SendEmail(email, subject, bodyText); err != nil {
slog.Error("failed to send digest email", "error", err, "user_id", pref.UserID)
continue
}
// Mark all as sent
ids := make([]uuid.UUID, len(notifications))
for i, n := range notifications {
ids[i] = n.ID
}
query, args, err := sqlx.In(
`UPDATE notifications SET sent_at = now() WHERE id IN (?)`, ids)
if err == nil {
query = s.db.Rebind(query)
_, err = s.db.ExecContext(ctx, query, args...)
}
if err != nil {
slog.Error("failed to mark digest notifications sent", "error", err)
}
slog.Info("sent daily digest", "user_id", pref.UserID, "count", len(notifications))
}
}
// CreateNotificationInput holds the data for creating a notification.
type CreateNotificationInput struct {
TenantID uuid.UUID
UserID uuid.UUID
Type string
EntityType *string
EntityID *uuid.UUID
Title string
Body *string
SendEmail bool
}
// CreateNotification stores a notification in the DB and optionally sends an email.
func (s *NotificationService) CreateNotification(ctx context.Context, input CreateNotificationInput) (*models.Notification, error) {
// Dedup: check if we already sent this notification today
if input.EntityID != nil {
var count int
err := s.db.GetContext(ctx, &count,
`SELECT COUNT(*) FROM notifications
WHERE user_id = $1 AND entity_id = $2 AND type = $3
AND created_at::date = CURRENT_DATE`,
input.UserID, input.EntityID, input.Type)
if err == nil && count > 0 {
return nil, nil // Already notified today
}
}
var n models.Notification
err := s.db.QueryRowxContext(ctx,
`INSERT INTO notifications (tenant_id, user_id, type, entity_type, entity_id, title, body)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, tenant_id, user_id, type, entity_type, entity_id, title, body, sent_at, read_at, created_at`,
input.TenantID, input.UserID, input.Type, input.EntityType, input.EntityID,
input.Title, input.Body).StructScan(&n)
if err != nil {
slog.Error("failed to create notification", "error", err)
return nil, fmt.Errorf("create notification: %w", err)
}
// Send email immediately if requested (non-digest users)
if input.SendEmail {
email := s.getUserEmail(ctx, input.UserID)
if email != "" {
go func() {
if err := SendEmail(email, input.Title, derefStr(input.Body)); err != nil {
slog.Error("failed to send notification email", "error", err, "user_id", input.UserID)
} else {
// Mark as sent
_, _ = s.db.Exec(`UPDATE notifications SET sent_at = now() WHERE id = $1`, n.ID)
}
}()
}
}
return &n, nil
}
// ListForUser returns notifications for a user in a tenant, paginated.
func (s *NotificationService) ListForUser(ctx context.Context, tenantID, userID uuid.UUID, limit, offset int) ([]models.Notification, int, error) {
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
var total int
err := s.db.GetContext(ctx, &total,
`SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND tenant_id = $2`,
userID, tenantID)
if err != nil {
return nil, 0, fmt.Errorf("count notifications: %w", err)
}
var notifications []models.Notification
err = s.db.SelectContext(ctx, &notifications,
`SELECT id, tenant_id, user_id, type, entity_type, entity_id, title, body, sent_at, read_at, created_at
FROM notifications
WHERE user_id = $1 AND tenant_id = $2
ORDER BY created_at DESC
LIMIT $3 OFFSET $4`,
userID, tenantID, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("list notifications: %w", err)
}
return notifications, total, nil
}
// UnreadCount returns the number of unread notifications for a user.
func (s *NotificationService) UnreadCount(ctx context.Context, tenantID, userID uuid.UUID) (int, error) {
var count int
err := s.db.GetContext(ctx, &count,
`SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND tenant_id = $2 AND read_at IS NULL`,
userID, tenantID)
return count, err
}
// MarkRead marks a single notification as read.
func (s *NotificationService) MarkRead(ctx context.Context, tenantID, userID, notificationID uuid.UUID) error {
result, err := s.db.ExecContext(ctx,
`UPDATE notifications SET read_at = now()
WHERE id = $1 AND user_id = $2 AND tenant_id = $3 AND read_at IS NULL`,
notificationID, userID, tenantID)
if err != nil {
return fmt.Errorf("mark notification read: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("notification not found or already read")
}
return nil
}
// MarkAllRead marks all notifications as read for a user.
func (s *NotificationService) MarkAllRead(ctx context.Context, tenantID, userID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`UPDATE notifications SET read_at = now()
WHERE user_id = $1 AND tenant_id = $2 AND read_at IS NULL`,
userID, tenantID)
return err
}
// GetPreferences returns notification preferences for a user, creating defaults if needed.
func (s *NotificationService) GetPreferences(ctx context.Context, tenantID, userID uuid.UUID) (*models.NotificationPreferences, error) {
var pref models.NotificationPreferences
err := s.db.GetContext(ctx, &pref,
`SELECT user_id, tenant_id, deadline_reminder_days, email_enabled, daily_digest, created_at, updated_at
FROM notification_preferences
WHERE user_id = $1 AND tenant_id = $2`,
userID, tenantID)
if err != nil {
// Return defaults if no preferences set
return &models.NotificationPreferences{
UserID: userID,
TenantID: tenantID,
DeadlineReminderDays: pq.Int64Array{7, 3, 1},
EmailEnabled: true,
DailyDigest: false,
}, nil
}
return &pref, nil
}
// UpdatePreferences upserts notification preferences for a user.
func (s *NotificationService) UpdatePreferences(ctx context.Context, tenantID, userID uuid.UUID, input UpdatePreferencesInput) (*models.NotificationPreferences, error) {
var pref models.NotificationPreferences
err := s.db.QueryRowxContext(ctx,
`INSERT INTO notification_preferences (user_id, tenant_id, deadline_reminder_days, email_enabled, daily_digest)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, tenant_id)
DO UPDATE SET deadline_reminder_days = $3, email_enabled = $4, daily_digest = $5, updated_at = now()
RETURNING user_id, tenant_id, deadline_reminder_days, email_enabled, daily_digest, created_at, updated_at`,
userID, tenantID, pq.Int64Array(input.DeadlineReminderDays), input.EmailEnabled, input.DailyDigest).StructScan(&pref)
if err != nil {
return nil, fmt.Errorf("update preferences: %w", err)
}
return &pref, nil
}
// UpdatePreferencesInput holds the data for updating notification preferences.
type UpdatePreferencesInput struct {
DeadlineReminderDays []int64 `json:"deadline_reminder_days"`
EmailEnabled bool `json:"email_enabled"`
DailyDigest bool `json:"daily_digest"`
}
// SendEmail sends an email using the `m mail send` CLI command.
func SendEmail(to, subject, body string) error {
cmd := exec.Command("m", "mail", "send",
"--to", to,
"--subject", subject,
"--body", body,
"--yes")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("m mail send failed: %w (output: %s)", err, string(output))
}
slog.Info("email sent", "to", to, "subject", subject)
return nil
}
// getUserEmail looks up the email for a user from Supabase auth.users.
func (s *NotificationService) getUserEmail(ctx context.Context, userID uuid.UUID) string {
var email string
err := s.db.GetContext(ctx, &email,
`SELECT email FROM auth.users WHERE id = $1`, userID)
if err != nil {
slog.Error("failed to get user email", "error", err, "user_id", userID)
return ""
}
return email
}
func containsDay(arr pq.Int64Array, day int64) bool {
for _, d := range arr {
if d == day {
return true
}
}
return false
}
func derefStr(s *string) string {
if s == nil {
return ""
}
return *s
}

View File

@@ -13,11 +13,12 @@ import (
)
type PartyService struct {
db *sqlx.DB
db *sqlx.DB
audit *AuditService
}
func NewPartyService(db *sqlx.DB) *PartyService {
return &PartyService{db: db}
func NewPartyService(db *sqlx.DB, audit *AuditService) *PartyService {
return &PartyService{db: db, audit: audit}
}
type CreatePartyInput struct {
@@ -79,6 +80,7 @@ func (s *PartyService) Create(ctx context.Context, tenantID, caseID uuid.UUID, u
if err := s.db.GetContext(ctx, &party, "SELECT * FROM parties WHERE id = $1", id); err != nil {
return nil, fmt.Errorf("fetching created party: %w", err)
}
s.audit.Log(ctx, "create", "party", &id, nil, party)
return &party, nil
}
@@ -135,6 +137,7 @@ func (s *PartyService) Update(ctx context.Context, tenantID, partyID uuid.UUID,
if err := s.db.GetContext(ctx, &updated, "SELECT * FROM parties WHERE id = $1", partyID); err != nil {
return nil, fmt.Errorf("fetching updated party: %w", err)
}
s.audit.Log(ctx, "update", "party", &partyID, current, updated)
return &updated, nil
}
@@ -148,5 +151,6 @@ func (s *PartyService) Delete(ctx context.Context, tenantID, partyID uuid.UUID)
if rows == 0 {
return sql.ErrNoRows
}
s.audit.Log(ctx, "delete", "party", &partyID, nil, nil)
return nil
}

View File

@@ -0,0 +1,329 @@
package services
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type ReportingService struct {
db *sqlx.DB
}
func NewReportingService(db *sqlx.DB) *ReportingService {
return &ReportingService{db: db}
}
// --- Case Statistics ---
type CaseStats struct {
Period string `json:"period" db:"period"`
Opened int `json:"opened" db:"opened"`
Closed int `json:"closed" db:"closed"`
Active int `json:"active" db:"active"`
}
type CasesByType struct {
CaseType string `json:"case_type" db:"case_type"`
Count int `json:"count" db:"count"`
}
type CasesByCourt struct {
Court string `json:"court" db:"court"`
Count int `json:"count" db:"count"`
}
type CaseReport struct {
Monthly []CaseStats `json:"monthly"`
ByType []CasesByType `json:"by_type"`
ByCourt []CasesByCourt `json:"by_court"`
Total CaseReportTotals `json:"total"`
}
type CaseReportTotals struct {
Opened int `json:"opened"`
Closed int `json:"closed"`
Active int `json:"active"`
}
func (s *ReportingService) CaseReport(ctx context.Context, tenantID uuid.UUID, from, to time.Time) (*CaseReport, error) {
report := &CaseReport{}
// Monthly breakdown
monthlyQuery := `
SELECT
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS period,
COUNT(*) AS opened,
COUNT(*) FILTER (WHERE status IN ('closed', 'archived')) AS closed,
COUNT(*) FILTER (WHERE status = 'active') AS active
FROM cases
WHERE tenant_id = $1 AND created_at >= $2 AND created_at <= $3
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY DATE_TRUNC('month', created_at)`
report.Monthly = []CaseStats{}
if err := s.db.SelectContext(ctx, &report.Monthly, monthlyQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("case report monthly: %w", err)
}
// By type
typeQuery := `
SELECT COALESCE(case_type, 'Sonstiges') AS case_type, COUNT(*) AS count
FROM cases
WHERE tenant_id = $1 AND created_at >= $2 AND created_at <= $3
GROUP BY case_type
ORDER BY count DESC`
report.ByType = []CasesByType{}
if err := s.db.SelectContext(ctx, &report.ByType, typeQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("case report by type: %w", err)
}
// By court
courtQuery := `
SELECT COALESCE(court, 'Ohne Gericht') AS court, COUNT(*) AS count
FROM cases
WHERE tenant_id = $1 AND created_at >= $2 AND created_at <= $3
GROUP BY court
ORDER BY count DESC`
report.ByCourt = []CasesByCourt{}
if err := s.db.SelectContext(ctx, &report.ByCourt, courtQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("case report by court: %w", err)
}
// Totals
totalsQuery := `
SELECT
COUNT(*) AS opened,
COUNT(*) FILTER (WHERE status IN ('closed', 'archived')) AS closed,
COUNT(*) FILTER (WHERE status = 'active') AS active
FROM cases
WHERE tenant_id = $1 AND created_at >= $2 AND created_at <= $3`
if err := s.db.GetContext(ctx, &report.Total, totalsQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("case report totals: %w", err)
}
return report, nil
}
// --- Deadline Compliance ---
type DeadlineCompliance struct {
Period string `json:"period" db:"period"`
Total int `json:"total" db:"total"`
Met int `json:"met" db:"met"`
Missed int `json:"missed" db:"missed"`
Pending int `json:"pending" db:"pending"`
ComplianceRate float64 `json:"compliance_rate"`
}
type MissedDeadline struct {
ID uuid.UUID `json:"id" db:"id"`
Title string `json:"title" db:"title"`
DueDate string `json:"due_date" db:"due_date"`
CaseID uuid.UUID `json:"case_id" db:"case_id"`
CaseNumber string `json:"case_number" db:"case_number"`
CaseTitle string `json:"case_title" db:"case_title"`
DaysOverdue int `json:"days_overdue" db:"days_overdue"`
}
type DeadlineReport struct {
Monthly []DeadlineCompliance `json:"monthly"`
Missed []MissedDeadline `json:"missed"`
Total DeadlineReportTotals `json:"total"`
}
type DeadlineReportTotals struct {
Total int `json:"total"`
Met int `json:"met"`
Missed int `json:"missed"`
Pending int `json:"pending"`
ComplianceRate float64 `json:"compliance_rate"`
}
func (s *ReportingService) DeadlineReport(ctx context.Context, tenantID uuid.UUID, from, to time.Time) (*DeadlineReport, error) {
report := &DeadlineReport{}
// Monthly compliance
monthlyQuery := `
SELECT
TO_CHAR(DATE_TRUNC('month', due_date), 'YYYY-MM') AS period,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'completed' AND (completed_at IS NULL OR completed_at::date <= due_date)) AS met,
COUNT(*) FILTER (WHERE (status = 'completed' AND completed_at::date > due_date) OR (status = 'pending' AND due_date < CURRENT_DATE)) AS missed,
COUNT(*) FILTER (WHERE status = 'pending' AND due_date >= CURRENT_DATE) AS pending
FROM deadlines
WHERE tenant_id = $1 AND due_date >= $2 AND due_date <= $3
GROUP BY DATE_TRUNC('month', due_date)
ORDER BY DATE_TRUNC('month', due_date)`
report.Monthly = []DeadlineCompliance{}
if err := s.db.SelectContext(ctx, &report.Monthly, monthlyQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("deadline report monthly: %w", err)
}
// Calculate compliance rates
for i := range report.Monthly {
completed := report.Monthly[i].Met + report.Monthly[i].Missed
if completed > 0 {
report.Monthly[i].ComplianceRate = float64(report.Monthly[i].Met) / float64(completed) * 100
}
}
// Missed deadlines list
missedQuery := `
SELECT d.id, d.title, d.due_date, d.case_id, c.case_number, c.title AS case_title,
(CURRENT_DATE - d.due_date::date) AS days_overdue
FROM deadlines d
JOIN cases c ON c.id = d.case_id AND c.tenant_id = d.tenant_id
WHERE d.tenant_id = $1 AND d.due_date >= $2 AND d.due_date <= $3
AND ((d.status = 'pending' AND d.due_date < CURRENT_DATE)
OR (d.status = 'completed' AND d.completed_at::date > d.due_date))
ORDER BY d.due_date ASC
LIMIT 50`
report.Missed = []MissedDeadline{}
if err := s.db.SelectContext(ctx, &report.Missed, missedQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("deadline report missed: %w", err)
}
// Totals
totalsQuery := `
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'completed' AND (completed_at IS NULL OR completed_at::date <= due_date)) AS met,
COUNT(*) FILTER (WHERE (status = 'completed' AND completed_at::date > due_date) OR (status = 'pending' AND due_date < CURRENT_DATE)) AS missed,
COUNT(*) FILTER (WHERE status = 'pending' AND due_date >= CURRENT_DATE) AS pending
FROM deadlines
WHERE tenant_id = $1 AND due_date >= $2 AND due_date <= $3`
if err := s.db.GetContext(ctx, &report.Total, totalsQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("deadline report totals: %w", err)
}
completed := report.Total.Met + report.Total.Missed
if completed > 0 {
report.Total.ComplianceRate = float64(report.Total.Met) / float64(completed) * 100
}
return report, nil
}
// --- Workload ---
type UserWorkload struct {
UserID uuid.UUID `json:"user_id" db:"user_id"`
ActiveCases int `json:"active_cases" db:"active_cases"`
Deadlines int `json:"deadlines" db:"deadlines"`
Overdue int `json:"overdue" db:"overdue"`
Completed int `json:"completed" db:"completed"`
}
type WorkloadReport struct {
Users []UserWorkload `json:"users"`
}
func (s *ReportingService) WorkloadReport(ctx context.Context, tenantID uuid.UUID, from, to time.Time) (*WorkloadReport, error) {
report := &WorkloadReport{}
query := `
WITH user_cases AS (
SELECT ca.user_id, COUNT(DISTINCT ca.case_id) AS active_cases
FROM case_assignments ca
JOIN cases c ON c.id = ca.case_id AND c.tenant_id = $1
WHERE c.status = 'active'
GROUP BY ca.user_id
),
user_deadlines AS (
SELECT ca.user_id,
COUNT(*) AS deadlines,
COUNT(*) FILTER (WHERE d.status = 'pending' AND d.due_date < CURRENT_DATE) AS overdue,
COUNT(*) FILTER (WHERE d.status = 'completed' AND d.completed_at >= $2 AND d.completed_at <= $3) AS completed
FROM case_assignments ca
JOIN deadlines d ON d.case_id = ca.case_id AND d.tenant_id = $1
WHERE d.due_date >= $2 AND d.due_date <= $3
GROUP BY ca.user_id
)
SELECT
COALESCE(uc.user_id, ud.user_id) AS user_id,
COALESCE(uc.active_cases, 0) AS active_cases,
COALESCE(ud.deadlines, 0) AS deadlines,
COALESCE(ud.overdue, 0) AS overdue,
COALESCE(ud.completed, 0) AS completed
FROM user_cases uc
FULL OUTER JOIN user_deadlines ud ON uc.user_id = ud.user_id
ORDER BY active_cases DESC`
report.Users = []UserWorkload{}
if err := s.db.SelectContext(ctx, &report.Users, query, tenantID, from, to); err != nil {
return nil, fmt.Errorf("workload report: %w", err)
}
return report, nil
}
// --- Billing (summary from case data) ---
type BillingByMonth struct {
Period string `json:"period" db:"period"`
CasesActive int `json:"cases_active" db:"cases_active"`
CasesClosed int `json:"cases_closed" db:"cases_closed"`
CasesNew int `json:"cases_new" db:"cases_new"`
}
type BillingByType struct {
CaseType string `json:"case_type" db:"case_type"`
Active int `json:"active" db:"active"`
Closed int `json:"closed" db:"closed"`
Total int `json:"total" db:"total"`
}
type BillingReport struct {
Monthly []BillingByMonth `json:"monthly"`
ByType []BillingByType `json:"by_type"`
}
func (s *ReportingService) BillingReport(ctx context.Context, tenantID uuid.UUID, from, to time.Time) (*BillingReport, error) {
report := &BillingReport{}
// Monthly activity for billing overview
monthlyQuery := `
SELECT
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS period,
COUNT(*) FILTER (WHERE status = 'active') AS cases_active,
COUNT(*) FILTER (WHERE status IN ('closed', 'archived')) AS cases_closed,
COUNT(*) AS cases_new
FROM cases
WHERE tenant_id = $1 AND created_at >= $2 AND created_at <= $3
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY DATE_TRUNC('month', created_at)`
report.Monthly = []BillingByMonth{}
if err := s.db.SelectContext(ctx, &report.Monthly, monthlyQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("billing report monthly: %w", err)
}
// By case type
typeQuery := `
SELECT
COALESCE(case_type, 'Sonstiges') AS case_type,
COUNT(*) FILTER (WHERE status = 'active') AS active,
COUNT(*) FILTER (WHERE status IN ('closed', 'archived')) AS closed,
COUNT(*) AS total
FROM cases
WHERE tenant_id = $1 AND created_at >= $2 AND created_at <= $3
GROUP BY case_type
ORDER BY total DESC`
report.ByType = []BillingByType{}
if err := s.db.SelectContext(ctx, &report.ByType, typeQuery, tenantID, from, to); err != nil {
return nil, fmt.Errorf("billing report by type: %w", err)
}
return report, nil
}

View File

@@ -13,11 +13,12 @@ import (
)
type TenantService struct {
db *sqlx.DB
db *sqlx.DB
audit *AuditService
}
func NewTenantService(db *sqlx.DB) *TenantService {
return &TenantService{db: db}
func NewTenantService(db *sqlx.DB, audit *AuditService) *TenantService {
return &TenantService{db: db, audit: audit}
}
// Create creates a new tenant and assigns the creator as owner.
@@ -49,6 +50,7 @@ func (s *TenantService) Create(ctx context.Context, userID uuid.UUID, name, slug
return nil, fmt.Errorf("commit: %w", err)
}
s.audit.Log(ctx, "create", "tenant", &tenant.ID, nil, tenant)
return &tenant, nil
}
@@ -184,6 +186,7 @@ func (s *TenantService) InviteByEmail(ctx context.Context, tenantID uuid.UUID, e
return nil, fmt.Errorf("invite user: %w", err)
}
s.audit.Log(ctx, "create", "membership", &tenantID, nil, ut)
return &ut, nil
}
@@ -199,9 +202,44 @@ func (s *TenantService) UpdateSettings(ctx context.Context, tenantID uuid.UUID,
if err != nil {
return nil, fmt.Errorf("update settings: %w", err)
}
s.audit.Log(ctx, "update", "settings", &tenantID, nil, settings)
return &tenant, nil
}
// UpdateMemberRole changes a member's role in a tenant.
func (s *TenantService) UpdateMemberRole(ctx context.Context, tenantID, userID uuid.UUID, newRole string) error {
// Get current role
currentRole, err := s.GetUserRole(ctx, userID, tenantID)
if err != nil {
return fmt.Errorf("get current role: %w", err)
}
if currentRole == "" {
return fmt.Errorf("user is not a member of this tenant")
}
// If demoting the last owner, block it
if currentRole == "owner" && newRole != "owner" {
var ownerCount int
err := s.db.GetContext(ctx, &ownerCount,
`SELECT COUNT(*) FROM user_tenants WHERE tenant_id = $1 AND role = 'owner'`,
tenantID)
if err != nil {
return fmt.Errorf("count owners: %w", err)
}
if ownerCount <= 1 {
return fmt.Errorf("cannot demote the last owner")
}
}
_, err = s.db.ExecContext(ctx,
`UPDATE user_tenants SET role = $1 WHERE user_id = $2 AND tenant_id = $3`,
newRole, userID, tenantID)
if err != nil {
return fmt.Errorf("update role: %w", err)
}
return nil
}
// RemoveMember removes a user from a tenant. Cannot remove the last owner.
func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error {
// Check if the user being removed is an owner
@@ -236,5 +274,6 @@ func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.
return fmt.Errorf("remove member: %w", err)
}
s.audit.Log(ctx, "delete", "membership", &tenantID, map[string]any{"user_id": userID, "role": role}, nil)
return nil
}

View File

@@ -14,6 +14,7 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-dropzone": "^15.0.0",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
},
"devDependencies": {
@@ -244,6 +245,8 @@
"@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw=="],
@@ -298,6 +301,10 @@
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.16.1", "", {}, "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@supabase/auth-js": ["@supabase/auth-js@2.100.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-pdT3ye3UVRN1Cg0wom6BmyY+XTtp5DiJaYnPi6j8ht5i8Lq8kfqxJMJz9GI9YDKk3w1nhGOPnh6Qz5qpyYm+1w=="],
"@supabase/functions-js": ["@supabase/functions-js@2.100.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-keLg79RPwP+uiwHuxFPTFgDRxPV46LM4j/swjyR2GKJgWniTVSsgiBHfbIBDcrQwehLepy09b/9QSHUywtKRWQ=="],
@@ -362,6 +369,24 @@
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -376,6 +401,8 @@
"@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/type-utils": "8.57.2", "@typescript-eslint/utils": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w=="],
@@ -528,6 +555,8 @@
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -546,6 +575,28 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
"data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="],
@@ -562,6 +613,8 @@
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@@ -606,6 +659,8 @@
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -646,6 +701,8 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -736,6 +793,8 @@
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
@@ -744,6 +803,8 @@
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
@@ -978,10 +1039,18 @@
"react-dropzone": ["react-dropzone@15.0.0", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"recharts": ["recharts@3.8.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
@@ -990,6 +1059,8 @@
"requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@@ -1096,6 +1167,8 @@
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
@@ -1152,6 +1225,10 @@
"url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"vite-node": ["vite-node@2.1.8", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg=="],
@@ -1202,6 +1279,8 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
@@ -1254,7 +1333,7 @@
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],

View File

@@ -20,6 +20,7 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-dropzone": "^15.0.0",
"recharts": "^3.8.1",
"sonner": "^2.0.7"
},
"devDependencies": {

View File

@@ -0,0 +1,262 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type {
CaseReport,
DeadlineReport,
WorkloadReport,
BillingReport,
} from "@/lib/types";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
import { Skeleton } from "@/components/ui/Skeleton";
import {
AlertTriangle,
RefreshCw,
Download,
Printer,
FolderOpen,
Clock,
Users,
Receipt,
} from "lucide-react";
import { CasesTab } from "@/components/reports/CasesTab";
import { DeadlinesTab } from "@/components/reports/DeadlinesTab";
import { WorkloadTab } from "@/components/reports/WorkloadTab";
import { BillingTab } from "@/components/reports/BillingTab";
type TabKey = "cases" | "deadlines" | "workload" | "billing";
const TABS: { key: TabKey; label: string; icon: typeof FolderOpen }[] = [
{ key: "cases", label: "Akten", icon: FolderOpen },
{ key: "deadlines", label: "Fristen", icon: Clock },
{ key: "workload", label: "Auslastung", icon: Users },
{ key: "billing", label: "Abrechnung", icon: Receipt },
];
function getDefaultDateRange(): { from: string; to: string } {
const now = new Date();
const from = new Date(now.getFullYear() - 1, now.getMonth(), 1);
return {
from: from.toISOString().split("T")[0],
to: now.toISOString().split("T")[0],
};
}
function ReportSkeleton() {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
<Skeleton className="h-72 rounded-xl" />
<Skeleton className="h-48 rounded-xl" />
</div>
);
}
export default function BerichtePage() {
const [activeTab, setActiveTab] = useState<TabKey>("cases");
const defaults = getDefaultDateRange();
const [from, setFrom] = useState(defaults.from);
const [to, setTo] = useState(defaults.to);
const queryParams = `?from=${from}&to=${to}`;
const casesQuery = useQuery({
queryKey: ["reports", "cases", from, to],
queryFn: () => api.get<CaseReport>(`/reports/cases${queryParams}`),
enabled: activeTab === "cases",
});
const deadlinesQuery = useQuery({
queryKey: ["reports", "deadlines", from, to],
queryFn: () => api.get<DeadlineReport>(`/reports/deadlines${queryParams}`),
enabled: activeTab === "deadlines",
});
const workloadQuery = useQuery({
queryKey: ["reports", "workload", from, to],
queryFn: () => api.get<WorkloadReport>(`/reports/workload${queryParams}`),
enabled: activeTab === "workload",
});
const billingQuery = useQuery({
queryKey: ["reports", "billing", from, to],
queryFn: () => api.get<BillingReport>(`/reports/billing${queryParams}`),
enabled: activeTab === "billing",
});
const currentQuery = {
cases: casesQuery,
deadlines: deadlinesQuery,
workload: workloadQuery,
billing: billingQuery,
}[activeTab];
function exportCSV() {
if (!currentQuery.data) return;
let csv = "";
const data = currentQuery.data;
if (activeTab === "cases") {
const d = data as CaseReport;
csv = "Monat,Eroeffnet,Geschlossen,Aktiv\n";
csv += d.monthly
.map((r) => `${r.period},${r.opened},${r.closed},${r.active}`)
.join("\n");
} else if (activeTab === "deadlines") {
const d = data as DeadlineReport;
csv = "Monat,Gesamt,Eingehalten,Versaeumt,Ausstehend,Quote (%)\n";
csv += d.monthly
.map(
(r) =>
`${r.period},${r.total},${r.met},${r.missed},${r.pending},${r.compliance_rate.toFixed(1)}`,
)
.join("\n");
} else if (activeTab === "workload") {
const d = data as WorkloadReport;
csv = "Benutzer-ID,Aktive Akten,Fristen,Ueberfaellig,Erledigt\n";
csv += d.users
.map(
(r) =>
`${r.user_id},${r.active_cases},${r.deadlines},${r.overdue},${r.completed}`,
)
.join("\n");
} else if (activeTab === "billing") {
const d = data as BillingReport;
csv = "Monat,Aktiv,Geschlossen,Neu\n";
csv += d.monthly
.map(
(r) =>
`${r.period},${r.cases_active},${r.cases_closed},${r.cases_new}`,
)
.join("\n");
}
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `bericht-${activeTab}-${from}-${to}.csv`;
link.click();
URL.revokeObjectURL(url);
}
return (
<div className="animate-fade-in mx-auto max-w-6xl space-y-6 print:max-w-none">
<div className="print:hidden">
<Breadcrumb items={[{ label: "Berichte" }]} />
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-lg font-semibold text-neutral-900">Berichte</h1>
<p className="mt-0.5 text-sm text-neutral-500">
Statistiken und Auswertungen
</p>
</div>
<div className="flex items-center gap-3 print:hidden">
<div className="flex items-center gap-2 text-sm">
<label className="text-neutral-500">Von</label>
<input
type="date"
value={from}
onChange={(e) => setFrom(e.target.value)}
className="rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-900 outline-none focus:border-neutral-400"
/>
<label className="text-neutral-500">Bis</label>
<input
type="date"
value={to}
onChange={(e) => setTo(e.target.value)}
className="rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-900 outline-none focus:border-neutral-400"
/>
</div>
<button
onClick={exportCSV}
disabled={!currentQuery.data}
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 disabled:opacity-50"
>
<Download className="h-3.5 w-3.5" />
CSV
</button>
<button
onClick={() => window.print()}
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50"
>
<Printer className="h-3.5 w-3.5" />
Drucken
</button>
</div>
</div>
{/* Tabs */}
<div className="border-b border-neutral-200 print:hidden">
<nav className="-mb-px flex gap-6">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-1.5 border-b-2 py-2.5 text-sm font-medium transition-colors ${
activeTab === tab.key
? "border-neutral-900 text-neutral-900"
: "border-transparent text-neutral-500 hover:border-neutral-300 hover:text-neutral-700"
}`}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab content */}
{currentQuery.isLoading && <ReportSkeleton />}
{currentQuery.error && (
<div className="py-12 text-center">
<div className="mx-auto mb-3 w-fit rounded-xl bg-red-50 p-3">
<AlertTriangle className="h-6 w-6 text-red-500" />
</div>
<h2 className="text-sm font-medium text-neutral-900">
Bericht konnte nicht geladen werden
</h2>
<p className="mt-1 text-sm text-neutral-500">
Bitte versuchen Sie es erneut.
</p>
<button
onClick={() => currentQuery.refetch()}
className="mt-4 inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
<RefreshCw className="h-3.5 w-3.5" />
Erneut laden
</button>
</div>
)}
{!currentQuery.isLoading && !currentQuery.error && currentQuery.data && (
<>
{activeTab === "cases" && (
<CasesTab data={currentQuery.data as CaseReport} />
)}
{activeTab === "deadlines" && (
<DeadlinesTab data={currentQuery.data as DeadlineReport} />
)}
{activeTab === "workload" && (
<WorkloadTab data={currentQuery.data as WorkloadReport} />
)}
{activeTab === "billing" && (
<BillingTab data={currentQuery.data as BillingReport} />
)}
</>
)}
</div>
);
}

View File

@@ -13,8 +13,10 @@ import {
Clock,
FileText,
Users,
UserCheck,
StickyNote,
AlertTriangle,
ScrollText,
} from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
@@ -43,7 +45,9 @@ const TABS = [
{ segment: "fristen", label: "Fristen", icon: Clock },
{ segment: "dokumente", label: "Dokumente", icon: FileText },
{ segment: "parteien", label: "Parteien", icon: Users },
{ segment: "mitarbeiter", label: "Mitarbeiter", icon: UserCheck },
{ segment: "notizen", label: "Notizen", icon: StickyNote },
{ segment: "protokoll", label: "Protokoll", icon: ScrollText },
] as const;
const TAB_LABELS: Record<string, string> = {
@@ -51,7 +55,9 @@ const TAB_LABELS: Record<string, string> = {
fristen: "Fristen",
dokumente: "Dokumente",
parteien: "Parteien",
mitarbeiter: "Mitarbeiter",
notizen: "Notizen",
protokoll: "Protokoll",
};
function CaseDetailSkeleton() {

View File

@@ -0,0 +1,9 @@
"use client";
import { useParams } from "next/navigation";
import { CaseAssignments } from "@/components/cases/CaseAssignments";
export default function CaseMitarbeiterPage() {
const { id } = useParams<{ id: string }>();
return <CaseAssignments caseId={id} />;
}

View File

@@ -0,0 +1,178 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useParams, useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
import type { AuditLogResponse } from "@/lib/types";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Loader2, ChevronLeft, ChevronRight } from "lucide-react";
const ACTION_LABELS: Record<string, string> = {
create: "Erstellt",
update: "Aktualisiert",
delete: "Geloescht",
};
const ACTION_COLORS: Record<string, string> = {
create: "bg-emerald-50 text-emerald-700",
update: "bg-blue-50 text-blue-700",
delete: "bg-red-50 text-red-700",
};
const ENTITY_LABELS: Record<string, string> = {
case: "Akte",
deadline: "Frist",
appointment: "Termin",
document: "Dokument",
party: "Partei",
note: "Notiz",
settings: "Einstellungen",
membership: "Mitgliedschaft",
};
function DiffPreview({
oldValues,
newValues,
}: {
oldValues?: Record<string, unknown>;
newValues?: Record<string, unknown>;
}) {
if (!oldValues && !newValues) return null;
const allKeys = new Set([
...Object.keys(oldValues ?? {}),
...Object.keys(newValues ?? {}),
]);
const changes: { key: string; from?: unknown; to?: unknown }[] = [];
for (const key of allKeys) {
const oldVal = oldValues?.[key];
const newVal = newValues?.[key];
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
changes.push({ key, from: oldVal, to: newVal });
}
}
if (changes.length === 0) return null;
return (
<div className="mt-2 space-y-1">
{changes.slice(0, 5).map((c) => (
<div key={c.key} className="flex items-baseline gap-2 text-xs">
<span className="font-medium text-neutral-500">{c.key}:</span>
{c.from !== undefined && (
<span className="rounded bg-red-50 px-1 text-red-600 line-through">
{String(c.from)}
</span>
)}
{c.to !== undefined && (
<span className="rounded bg-emerald-50 px-1 text-emerald-600">
{String(c.to)}
</span>
)}
</div>
))}
{changes.length > 5 && (
<span className="text-xs text-neutral-400">
+{changes.length - 5} weitere Aenderungen
</span>
)}
</div>
);
}
export default function ProtokollPage() {
const { id } = useParams<{ id: string }>();
const searchParams = useSearchParams();
const page = Number(searchParams.get("page")) || 1;
const { data, isLoading } = useQuery({
queryKey: ["audit-log", id, page],
queryFn: () =>
api.get<AuditLogResponse>(
`/audit-log?entity_id=${id}&page=${page}&limit=50`,
),
});
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
);
}
const entries = data?.entries ?? [];
const total = data?.total ?? 0;
const totalPages = Math.ceil(total / 50);
if (entries.length === 0) {
return (
<div className="py-8 text-center text-sm text-neutral-400">
Keine Protokolleintraege vorhanden.
</div>
);
}
return (
<div>
<div className="space-y-3">
{entries.map((entry) => (
<div
key={entry.id}
className="rounded-md border border-neutral-100 bg-white px-4 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${ACTION_COLORS[entry.action] ?? "bg-neutral-100 text-neutral-600"}`}
>
{ACTION_LABELS[entry.action] ?? entry.action}
</span>
<span className="text-sm font-medium text-neutral-700">
{ENTITY_LABELS[entry.entity_type] ?? entry.entity_type}
</span>
</div>
<span className="shrink-0 text-xs text-neutral-400">
{format(new Date(entry.created_at), "d. MMM yyyy, HH:mm", {
locale: de,
})}
</span>
</div>
<DiffPreview
oldValues={entry.old_values}
newValues={entry.new_values}
/>
</div>
))}
</div>
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<span className="text-xs text-neutral-400">
{total} Eintraege, Seite {page} von {totalPages}
</span>
<div className="flex gap-1">
{page > 1 && (
<a
href={`?page=${page - 1}`}
className="inline-flex items-center gap-1 rounded-md border border-neutral-200 px-2 py-1 text-xs text-neutral-600 hover:bg-neutral-50"
>
<ChevronLeft className="h-3 w-3" /> Zurueck
</a>
)}
{page < totalPages && (
<a
href={`?page=${page + 1}`}
className="inline-flex items-center gap-1 rounded-md border border-neutral-200 px-2 py-1 text-xs text-neutral-600 hover:bg-neutral-50"
>
Weiter <ChevronRight className="h-3 w-3" />
</a>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -10,6 +10,7 @@ import { Plus, Search, FolderOpen } from "lucide-react";
import { useState } from "react";
import { SkeletonTable } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState";
import { usePermissions } from "@/lib/hooks/usePermissions";
const STATUS_OPTIONS = [
{ value: "", label: "Alle Status" },
@@ -49,6 +50,8 @@ const inputClass =
export default function CasesPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { can } = usePermissions();
const canCreateCase = can("create_case");
const [search, setSearch] = useState(searchParams.get("search") ?? "");
const [status, setStatus] = useState(searchParams.get("status") ?? "");
@@ -86,13 +89,15 @@ export default function CasesPage() {
{data ? `${data.total} Akten` : "\u00A0"}
</p>
</div>
<Link
href="/cases/new"
className="inline-flex w-fit items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
<Plus className="h-4 w-4" />
Neue Akte
</Link>
{canCreateCase && (
<Link
href="/cases/new"
className="inline-flex w-fit items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
<Plus className="h-4 w-4" />
Neue Akte
</Link>
)}
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
@@ -145,7 +150,7 @@ export default function CasesPage() {
: "Erstellen Sie Ihre erste Akte, um loszulegen."
}
action={
!search && !status && !type ? (
!search && !status && !type && canCreateCase ? (
<Link
href="/cases/new"
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"

View File

@@ -1,11 +1,12 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Settings, Calendar, Users } from "lucide-react";
import { Settings, Calendar, Users, Bell } from "lucide-react";
import Link from "next/link";
import { api } from "@/lib/api";
import type { Tenant } from "@/lib/types";
import { CalDAVSettings } from "@/components/settings/CalDAVSettings";
import { NotificationSettings } from "@/components/settings/NotificationSettings";
import { SkeletonCard } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState";
@@ -97,6 +98,19 @@ export default function EinstellungenPage() {
</div>
</section>
{/* Notification Settings */}
<section className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2.5 border-b border-neutral-100 pb-3">
<Bell className="h-4 w-4 text-neutral-500" />
<h2 className="text-sm font-semibold text-neutral-900">
Benachrichtigungen
</h2>
</div>
<div className="mt-4">
<NotificationSettings />
</div>
</section>
{/* CalDAV Settings */}
<section className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2.5 border-b border-neutral-100 pb-3">

View File

@@ -0,0 +1,180 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { UserPlus, Trash2, Users } from "lucide-react";
import { api } from "@/lib/api";
import type { CaseAssignment, UserTenant } from "@/lib/types";
import { CASE_ASSIGNMENT_ROLE_LABELS } from "@/lib/types";
import type { CaseAssignmentRole } from "@/lib/types";
import { Skeleton } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState";
import { usePermissions } from "@/lib/hooks/usePermissions";
export function CaseAssignments({ caseId }: { caseId: string }) {
const queryClient = useQueryClient();
const { can } = usePermissions();
const canManage = can("manage_team");
const tenantId =
typeof window !== "undefined"
? localStorage.getItem("kanzlai_tenant_id")
: null;
const [selectedUser, setSelectedUser] = useState("");
const [assignRole, setAssignRole] = useState<CaseAssignmentRole>("team");
const { data, isLoading } = useQuery({
queryKey: ["case-assignments", caseId],
queryFn: () =>
api.get<{ assignments: CaseAssignment[]; total: number }>(
`/cases/${caseId}/assignments`,
),
});
const { data: members } = useQuery({
queryKey: ["tenant-members", tenantId],
queryFn: () =>
api.get<UserTenant[]>(`/tenants/${tenantId}/members`),
enabled: !!tenantId && canManage,
});
const assignMutation = useMutation({
mutationFn: (input: { user_id: string; role: string }) =>
api.post(`/cases/${caseId}/assignments`, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["case-assignments", caseId] });
setSelectedUser("");
toast.success("Mitarbeiter zugewiesen");
},
onError: (err: { error?: string }) => {
toast.error(err.error || "Fehler beim Zuweisen");
},
});
const unassignMutation = useMutation({
mutationFn: (userId: string) =>
api.delete(`/cases/${caseId}/assignments/${userId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["case-assignments", caseId] });
toast.success("Zuweisung entfernt");
},
onError: (err: { error?: string }) => {
toast.error(err.error || "Fehler beim Entfernen");
},
});
const assignments = data?.assignments ?? [];
const assignedUserIds = new Set(assignments.map((a) => a.user_id));
const availableMembers = (members ?? []).filter(
(m) => !assignedUserIds.has(m.user_id),
);
const handleAssign = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedUser) return;
assignMutation.mutate({ user_id: selectedUser, role: assignRole });
};
if (isLoading) {
return (
<div className="space-y-3">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-neutral-900">
Zugewiesene Mitarbeiter
</h3>
{/* Assign form — only for owners/partners */}
{canManage && availableMembers.length > 0 && (
<form onSubmit={handleAssign} className="flex flex-col gap-2 sm:flex-row">
<select
value={selectedUser}
onChange={(e) => setSelectedUser(e.target.value)}
className="flex-1 rounded-md border border-neutral-200 px-2 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
>
<option value="">Mitarbeiter auswählen...</option>
{availableMembers.map((m) => (
<option key={m.user_id} value={m.user_id}>
{m.user_id.slice(0, 8)}... ({m.role})
</option>
))}
</select>
<select
value={assignRole}
onChange={(e) => setAssignRole(e.target.value as CaseAssignmentRole)}
className="rounded-md border border-neutral-200 px-2 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
>
{(Object.keys(CASE_ASSIGNMENT_ROLE_LABELS) as CaseAssignmentRole[]).map(
(r) => (
<option key={r} value={r}>
{CASE_ASSIGNMENT_ROLE_LABELS[r]}
</option>
),
)}
</select>
<button
type="submit"
disabled={assignMutation.isPending || !selectedUser}
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
<UserPlus className="h-3.5 w-3.5" />
Zuweisen
</button>
</form>
)}
{/* Assignments list */}
{assignments.length > 0 ? (
<div className="overflow-hidden rounded-md border border-neutral-200">
{assignments.map((a, i) => (
<div
key={a.id}
className={`flex items-center justify-between px-4 py-2.5 ${
i < assignments.length - 1 ? "border-b border-neutral-100" : ""
}`}
>
<div className="flex items-center gap-3">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-neutral-100">
<Users className="h-3.5 w-3.5 text-neutral-500" />
</div>
<div>
<p className="text-sm text-neutral-900">
{a.user_id.slice(0, 8)}...
</p>
<p className="text-xs text-neutral-500">
{CASE_ASSIGNMENT_ROLE_LABELS[a.role as CaseAssignmentRole] ??
a.role}
</p>
</div>
</div>
{canManage && (
<button
onClick={() => unassignMutation.mutate(a.user_id)}
disabled={unassignMutation.isPending}
className="rounded-md p-1 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
title="Zuweisung entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
) : (
<EmptyState
icon={Users}
title="Keine Zuweisungen"
description="Noch keine Mitarbeiter zugewiesen."
/>
)}
</div>
);
}

View File

@@ -2,6 +2,7 @@
import { createClient } from "@/lib/supabase/client";
import { TenantSwitcher } from "./TenantSwitcher";
import { NotificationBell } from "@/components/notifications/NotificationBell";
import { LogOut } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
@@ -29,6 +30,7 @@ export function Header() {
<div className="w-8 lg:w-0" />
<div className="flex items-center gap-2 sm:gap-3">
<TenantSwitcher />
<NotificationBell />
{email && (
<span className="hidden text-sm text-neutral-500 sm:inline">
{email}

View File

@@ -8,24 +8,39 @@ import {
Clock,
Calendar,
Brain,
BarChart3,
Settings,
Menu,
X,
} from "lucide-react";
import { useState, useEffect } from "react";
import { usePermissions } from "@/lib/hooks/usePermissions";
const navigation = [
interface NavItem {
name: string;
href: string;
icon: typeof LayoutDashboard;
permission?: string;
}
const allNavigation: NavItem[] = [
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Akten", href: "/cases", icon: FolderOpen },
{ name: "Fristen", href: "/fristen", icon: Clock },
{ name: "Termine", href: "/termine", icon: Calendar },
{ name: "AI Analyse", href: "/ai/extract", icon: Brain },
{ name: "Einstellungen", href: "/einstellungen", icon: Settings },
{ name: "Berichte", href: "/berichte", icon: BarChart3 },
{ name: "AI Analyse", href: "/ai/extract", icon: Brain, permission: "ai_extraction" },
{ name: "Einstellungen", href: "/einstellungen", icon: Settings, permission: "manage_settings" },
];
export function Sidebar() {
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
const { can, isLoading: permLoading } = usePermissions();
const navigation = allNavigation.filter(
(item) => !item.permission || permLoading || can(item.permission),
);
// Close on route change
useEffect(() => {

View File

@@ -0,0 +1,205 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, CheckCheck, ExternalLink } from "lucide-react";
import { api } from "@/lib/api";
import type { Notification, NotificationListResponse } from "@/lib/types";
function getEntityLink(n: Notification): string | null {
if (!n.entity_type || !n.entity_id) return null;
switch (n.entity_type) {
case "deadline":
return `/fristen/${n.entity_id}`;
case "appointment":
return `/termine/${n.entity_id}`;
case "case":
return `/akten/${n.entity_id}`;
default:
return null;
}
}
function getTypeColor(type: Notification["type"]): string {
switch (type) {
case "deadline_overdue":
return "bg-red-500";
case "deadline_reminder":
return "bg-amber-500";
case "case_update":
return "bg-blue-500";
case "assignment":
return "bg-violet-500";
default:
return "bg-neutral-500";
}
}
function timeAgo(dateStr: string): string {
const now = new Date();
const date = new Date(dateStr);
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return "gerade eben";
if (diffMin < 60) return `vor ${diffMin} Min.`;
const diffHours = Math.floor(diffMin / 60);
if (diffHours < 24) return `vor ${diffHours} Std.`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays === 1) return "gestern";
return `vor ${diffDays} Tagen`;
}
export function NotificationBell() {
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient();
const { data: unreadData } = useQuery({
queryKey: ["notifications-unread-count"],
queryFn: () =>
api.get<{ unread_count: number }>("/api/notifications/unread-count"),
refetchInterval: 30_000,
});
const { data: notifData } = useQuery({
queryKey: ["notifications"],
queryFn: () =>
api.get<NotificationListResponse>("/api/notifications?limit=20"),
enabled: open,
});
const markRead = useMutation({
mutationFn: (id: string) =>
api.patch(`/api/notifications/${id}/read`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notifications"] });
queryClient.invalidateQueries({
queryKey: ["notifications-unread-count"],
});
},
});
const markAllRead = useMutation({
mutationFn: () => api.patch("/api/notifications/read-all"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notifications"] });
queryClient.invalidateQueries({
queryKey: ["notifications-unread-count"],
});
},
});
// Close on click outside
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
const unreadCount = unreadData?.unread_count ?? 0;
const notifications = notifData?.data ?? [];
return (
<div className="relative" ref={panelRef}>
<button
onClick={() => setOpen(!open)}
className="relative rounded-md p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
title="Benachrichtigungen"
>
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-xl border border-neutral-200 bg-white shadow-lg sm:w-96">
{/* Header */}
<div className="flex items-center justify-between border-b border-neutral-100 px-4 py-3">
<h3 className="text-sm font-semibold text-neutral-900">
Benachrichtigungen
</h3>
{unreadCount > 0 && (
<button
onClick={() => markAllRead.mutate()}
className="flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700"
>
<CheckCheck className="h-3 w-3" />
Alle gelesen
</button>
)}
</div>
{/* Notification list */}
<div className="max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-6 text-center text-sm text-neutral-400">
Keine Benachrichtigungen
</div>
) : (
notifications.map((n) => {
const link = getEntityLink(n);
return (
<div
key={n.id}
className={`flex items-start gap-3 border-b border-neutral-50 px-4 py-3 transition-colors last:border-0 ${
n.read_at
? "bg-white"
: "bg-blue-50/50"
}`}
>
<div
className={`mt-1.5 h-2 w-2 flex-shrink-0 rounded-full ${getTypeColor(n.type)}`}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-neutral-900 leading-snug">
{n.title}
</p>
{n.body && (
<p className="mt-0.5 text-xs text-neutral-500 line-clamp-2">
{n.body}
</p>
)}
<div className="mt-1.5 flex items-center gap-2">
<span className="text-[11px] text-neutral-400">
{timeAgo(n.created_at)}
</span>
{link && (
<a
href={link}
onClick={() => setOpen(false)}
className="flex items-center gap-0.5 text-[11px] text-blue-600 hover:text-blue-700"
>
<ExternalLink className="h-2.5 w-2.5" />
Anzeigen
</a>
)}
</div>
</div>
{!n.read_at && (
<button
onClick={() => markRead.mutate(n.id)}
className="flex-shrink-0 rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
title="Als gelesen markieren"
>
<Check className="h-3 w-3" />
</button>
)}
</div>
);
})
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,240 @@
"use client";
import type { BillingReport } from "@/lib/types";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
LineChart,
Line,
} from "recharts";
import { Receipt, TrendingUp, FolderOpen } from "lucide-react";
function formatMonth(period: string): string {
const [year, month] = period.split("-");
const months = [
"Jan",
"Feb",
"Mär",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dez",
];
return `${months[parseInt(month, 10) - 1]} ${year.slice(2)}`;
}
export function BillingTab({ data }: { data: BillingReport }) {
const chartData = data.monthly.map((m) => ({
...m,
name: formatMonth(m.period),
}));
const totalNew = data.monthly.reduce((sum, m) => sum + m.cases_new, 0);
const totalClosed = data.monthly.reduce((sum, m) => sum + m.cases_closed, 0);
const totalByType = data.by_type.reduce((sum, t) => sum + t.total, 0);
return (
<div className="space-y-6">
{/* Summary cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<FolderOpen className="h-4 w-4" />
Neue Mandate
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{totalNew}
</p>
<p className="mt-1 text-xs text-neutral-500">im Zeitraum</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Receipt className="h-4 w-4" />
Abgeschlossen
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{totalClosed}
</p>
<p className="mt-1 text-xs text-neutral-500">abrechenbar</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<TrendingUp className="h-4 w-4" />
Verfahrensarten
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{data.by_type.length}
</p>
<p className="mt-1 text-xs text-neutral-500">
{totalByType} Akten gesamt
</p>
</div>
</div>
{/* New cases trend */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Umsatzentwicklung (Mandate)
</h3>
{chartData.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten im gewählten Zeitraum
</p>
) : (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} stroke="#a3a3a3" />
<YAxis
allowDecimals={false}
tick={{ fontSize: 12 }}
stroke="#a3a3a3"
/>
<Tooltip
contentStyle={{
border: "1px solid #e5e5e5",
borderRadius: 8,
fontSize: 13,
}}
/>
<Legend wrapperStyle={{ fontSize: 13 }} />
<Line
type="monotone"
dataKey="cases_new"
name="Neue Mandate"
stroke="#171717"
strokeWidth={2}
dot={{ fill: "#171717", r: 4 }}
/>
<Line
type="monotone"
dataKey="cases_closed"
name="Abgeschlossen"
stroke="#a3a3a3"
strokeWidth={2}
dot={{ fill: "#a3a3a3", r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
{/* By type breakdown */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Mandate nach Verfahrensart
</h3>
{data.by_type.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten
</p>
) : (
<ResponsiveContainer width="100%" height={250}>
<BarChart data={data.by_type} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
<XAxis
type="number"
allowDecimals={false}
tick={{ fontSize: 12 }}
stroke="#a3a3a3"
/>
<YAxis
type="category"
dataKey="case_type"
tick={{ fontSize: 12 }}
stroke="#a3a3a3"
width={100}
/>
<Tooltip
contentStyle={{
border: "1px solid #e5e5e5",
borderRadius: 8,
fontSize: 13,
}}
/>
<Legend wrapperStyle={{ fontSize: 13 }} />
<Bar
dataKey="active"
name="Aktiv"
stackId="a"
fill="#171717"
/>
<Bar
dataKey="closed"
name="Geschlossen"
stackId="a"
fill="#a3a3a3"
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Summary table */}
<div className="rounded-xl border border-neutral-200 bg-white">
<div className="border-b border-neutral-100 px-5 py-4">
<h3 className="text-sm font-medium text-neutral-900">
Zusammenfassung
</h3>
</div>
{data.by_type.length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-neutral-400">
Keine Daten
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-100 text-left text-neutral-500">
<th className="px-5 py-3 font-medium">Verfahrensart</th>
<th className="px-5 py-3 font-medium text-right">Aktiv</th>
<th className="px-5 py-3 font-medium text-right">
Geschlossen
</th>
<th className="px-5 py-3 font-medium text-right">
Gesamt
</th>
</tr>
</thead>
<tbody>
{data.by_type.map((t) => (
<tr
key={t.case_type}
className="border-b border-neutral-50 last:border-b-0"
>
<td className="px-5 py-3 text-neutral-900">
{t.case_type}
</td>
<td className="px-5 py-3 text-right text-neutral-600">
{t.active}
</td>
<td className="px-5 py-3 text-right text-neutral-600">
{t.closed}
</td>
<td className="px-5 py-3 text-right font-medium text-neutral-900">
{t.total}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,223 @@
"use client";
import type { CaseReport } from "@/lib/types";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
PieChart,
Pie,
Cell,
} from "recharts";
import { FolderOpen, TrendingUp, TrendingDown } from "lucide-react";
const COLORS = [
"#171717",
"#525252",
"#a3a3a3",
"#d4d4d4",
"#737373",
"#404040",
"#e5e5e5",
"#262626",
];
function formatMonth(period: string): string {
const [year, month] = period.split("-");
const months = [
"Jan",
"Feb",
"Mär",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dez",
];
return `${months[parseInt(month, 10) - 1]} ${year.slice(2)}`;
}
export function CasesTab({ data }: { data: CaseReport }) {
const chartData = data.monthly.map((m) => ({
...m,
name: formatMonth(m.period),
}));
return (
<div className="space-y-6">
{/* Summary cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<FolderOpen className="h-4 w-4" />
Eröffnet
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{data.total.opened}
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<TrendingDown className="h-4 w-4" />
Geschlossen
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{data.total.closed}
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<TrendingUp className="h-4 w-4" />
Aktiv
</div>
<p className="mt-2 text-2xl font-semibold text-emerald-600">
{data.total.active}
</p>
</div>
</div>
{/* Bar chart: opened/closed per month */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Akten pro Monat
</h3>
{chartData.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten im gewählten Zeitraum
</p>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} stroke="#a3a3a3" />
<YAxis
allowDecimals={false}
tick={{ fontSize: 12 }}
stroke="#a3a3a3"
/>
<Tooltip
contentStyle={{
border: "1px solid #e5e5e5",
borderRadius: 8,
fontSize: 13,
}}
/>
<Legend wrapperStyle={{ fontSize: 13 }} />
<Bar
dataKey="opened"
name="Eröffnet"
fill="#171717"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="closed"
name="Geschlossen"
fill="#a3a3a3"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Pie charts row */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* By type */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Nach Verfahrensart
</h3>
{data.by_type.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten
</p>
) : (
<div className="flex items-center gap-4">
<ResponsiveContainer width="50%" height={200}>
<PieChart>
<Pie
data={data.by_type}
dataKey="count"
nameKey="case_type"
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={80}
>
{data.by_type.map((_, i) => (
<Cell
key={i}
fill={COLORS[i % COLORS.length]}
/>
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
<div className="flex-1 space-y-2">
{data.by_type.map((item, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<div
className="h-3 w-3 rounded-full"
style={{
backgroundColor: COLORS[i % COLORS.length],
}}
/>
<span className="text-neutral-600">{item.case_type}</span>
<span className="ml-auto font-medium text-neutral-900">
{item.count}
</span>
</div>
))}
</div>
</div>
)}
</div>
{/* By court */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Nach Gericht
</h3>
{data.by_court.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten
</p>
) : (
<div className="space-y-3">
{data.by_court.map((item, i) => {
const maxCount = Math.max(...data.by_court.map((c) => c.count));
const pct = maxCount > 0 ? (item.count / maxCount) * 100 : 0;
return (
<div key={i}>
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-600">{item.court}</span>
<span className="font-medium text-neutral-900">
{item.count}
</span>
</div>
<div className="mt-1 h-2 rounded-full bg-neutral-100">
<div
className="h-2 rounded-full bg-neutral-900 transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,204 @@
"use client";
import type { DeadlineReport } from "@/lib/types";
import Link from "next/link";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import { CheckCircle, XCircle, Clock, AlertTriangle } from "lucide-react";
function formatMonth(period: string): string {
const [year, month] = period.split("-");
const months = [
"Jan",
"Feb",
"Mär",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dez",
];
return `${months[parseInt(month, 10) - 1]} ${year.slice(2)}`;
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export function DeadlinesTab({ data }: { data: DeadlineReport }) {
const chartData = data.monthly.map((m) => ({
...m,
name: formatMonth(m.period),
compliance_rate: Math.round(m.compliance_rate * 10) / 10,
}));
const complianceColor =
data.total.compliance_rate >= 90
? "text-emerald-600"
: data.total.compliance_rate >= 70
? "text-amber-600"
: "text-red-600";
return (
<div className="space-y-6">
{/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Clock className="h-4 w-4" />
Gesamt
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{data.total.total}
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<CheckCircle className="h-4 w-4" />
Eingehalten
</div>
<p className="mt-2 text-2xl font-semibold text-emerald-600">
{data.total.met}
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<XCircle className="h-4 w-4" />
Versäumt
</div>
<p className="mt-2 text-2xl font-semibold text-red-600">
{data.total.missed}
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<CheckCircle className="h-4 w-4" />
Einhaltungsquote
</div>
<p className={`mt-2 text-2xl font-semibold ${complianceColor}`}>
{data.total.compliance_rate.toFixed(1)}%
</p>
</div>
</div>
{/* Compliance rate over time */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Fristeneinhaltung im Zeitverlauf
</h3>
{chartData.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten im gewählten Zeitraum
</p>
) : (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} stroke="#a3a3a3" />
<YAxis
domain={[0, 100]}
tick={{ fontSize: 12 }}
stroke="#a3a3a3"
unit="%"
/>
<Tooltip
contentStyle={{
border: "1px solid #e5e5e5",
borderRadius: 8,
fontSize: 13,
}}
formatter={(value) => [`${value}%`, "Quote"]}
/>
<Legend wrapperStyle={{ fontSize: 13 }} />
<Line
type="monotone"
dataKey="compliance_rate"
name="Einhaltungsquote"
stroke="#171717"
strokeWidth={2}
dot={{ fill: "#171717", r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
{/* Missed deadlines table */}
<div className="rounded-xl border border-neutral-200 bg-white">
<div className="border-b border-neutral-100 px-5 py-4">
<h3 className="text-sm font-medium text-neutral-900">
Versäumte Fristen
</h3>
</div>
{data.missed.length === 0 ? (
<div className="px-5 py-8 text-center">
<CheckCircle className="mx-auto h-8 w-8 text-emerald-400" />
<p className="mt-2 text-sm text-neutral-500">
Keine versäumten Fristen im gewählten Zeitraum
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-100 text-left text-neutral-500">
<th className="px-5 py-3 font-medium">Frist</th>
<th className="px-5 py-3 font-medium">Akte</th>
<th className="px-5 py-3 font-medium">Fällig am</th>
<th className="px-5 py-3 font-medium text-right">
Tage überfällig
</th>
</tr>
</thead>
<tbody>
{data.missed.map((d) => (
<tr
key={d.id}
className="border-b border-neutral-50 last:border-b-0"
>
<td className="px-5 py-3 text-neutral-900">{d.title}</td>
<td className="px-5 py-3">
<Link
href={`/cases/${d.case_id}`}
className="text-neutral-600 hover:text-neutral-900"
>
{d.case_number} {d.case_title}
</Link>
</td>
<td className="px-5 py-3 text-neutral-600">
{formatDate(d.due_date)}
</td>
<td className="px-5 py-3 text-right">
<span className="inline-flex items-center gap-1 rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">
<AlertTriangle className="h-3 w-3" />
{d.days_overdue}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,187 @@
"use client";
import type { WorkloadReport } from "@/lib/types";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import { Users, AlertTriangle, CheckCircle } from "lucide-react";
export function WorkloadTab({ data }: { data: WorkloadReport }) {
const chartData = data.users.map((u, i) => ({
name: `Nutzer ${i + 1}`,
user_id: u.user_id,
active_cases: u.active_cases,
deadlines: u.deadlines,
overdue: u.overdue,
completed: u.completed,
}));
const totalCases = data.users.reduce((sum, u) => sum + u.active_cases, 0);
const totalOverdue = data.users.reduce((sum, u) => sum + u.overdue, 0);
const totalCompleted = data.users.reduce((sum, u) => sum + u.completed, 0);
return (
<div className="space-y-6">
{/* Summary cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Users className="h-4 w-4" />
Mitarbeiter
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{data.users.length}
</p>
<p className="mt-1 text-xs text-neutral-500">
{totalCases} aktive Akten gesamt
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<AlertTriangle className="h-4 w-4" />
Überfällige Fristen
</div>
<p className="mt-2 text-2xl font-semibold text-red-600">
{totalOverdue}
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<CheckCircle className="h-4 w-4" />
Erledigte Fristen
</div>
<p className="mt-2 text-2xl font-semibold text-emerald-600">
{totalCompleted}
</p>
</div>
</div>
{/* Stacked bar chart */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Auslastung pro Mitarbeiter
</h3>
{chartData.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten im gewählten Zeitraum
</p>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} stroke="#a3a3a3" />
<YAxis
allowDecimals={false}
tick={{ fontSize: 12 }}
stroke="#a3a3a3"
/>
<Tooltip
contentStyle={{
border: "1px solid #e5e5e5",
borderRadius: 8,
fontSize: 13,
}}
/>
<Legend wrapperStyle={{ fontSize: 13 }} />
<Bar
dataKey="active_cases"
name="Aktive Akten"
stackId="work"
fill="#171717"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="completed"
name="Erledigt"
stackId="deadlines"
fill="#a3a3a3"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="overdue"
name="Überfällig"
stackId="deadlines"
fill="#dc2626"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Table */}
<div className="rounded-xl border border-neutral-200 bg-white">
<div className="border-b border-neutral-100 px-5 py-4">
<h3 className="text-sm font-medium text-neutral-900">
Übersicht pro Mitarbeiter
</h3>
</div>
{data.users.length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-neutral-400">
Keine Mitarbeiter mit zugewiesenen Akten
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-100 text-left text-neutral-500">
<th className="px-5 py-3 font-medium">Mitarbeiter</th>
<th className="px-5 py-3 font-medium text-right">
Aktive Akten
</th>
<th className="px-5 py-3 font-medium text-right">Fristen</th>
<th className="px-5 py-3 font-medium text-right">
Überfällig
</th>
<th className="px-5 py-3 font-medium text-right">
Erledigt
</th>
</tr>
</thead>
<tbody>
{data.users.map((u, i) => (
<tr
key={u.user_id}
className="border-b border-neutral-50 last:border-b-0"
>
<td className="px-5 py-3 text-neutral-900">
Nutzer {i + 1}
<span className="ml-2 text-xs text-neutral-400">
{u.user_id.slice(0, 8)}...
</span>
</td>
<td className="px-5 py-3 text-right font-medium text-neutral-900">
{u.active_cases}
</td>
<td className="px-5 py-3 text-right text-neutral-600">
{u.deadlines}
</td>
<td className="px-5 py-3 text-right">
{u.overdue > 0 ? (
<span className="inline-flex items-center rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">
{u.overdue}
</span>
) : (
<span className="text-neutral-400">0</span>
)}
</td>
<td className="px-5 py-3 text-right text-emerald-600">
{u.completed}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,167 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { NotificationPreferences } from "@/lib/types";
const REMINDER_OPTIONS = [
{ value: 14, label: "14 Tage" },
{ value: 7, label: "7 Tage" },
{ value: 3, label: "3 Tage" },
{ value: 1, label: "1 Tag" },
];
export function NotificationSettings() {
const queryClient = useQueryClient();
const [saved, setSaved] = useState(false);
const { data: prefs, isLoading } = useQuery({
queryKey: ["notification-preferences"],
queryFn: () =>
api.get<NotificationPreferences>("/api/notification-preferences"),
});
const [reminderDays, setReminderDays] = useState<number[]>([]);
const [emailEnabled, setEmailEnabled] = useState(true);
const [dailyDigest, setDailyDigest] = useState(false);
const [initialized, setInitialized] = useState(false);
// Sync state from server once loaded
if (prefs && !initialized) {
setReminderDays(prefs.deadline_reminder_days);
setEmailEnabled(prefs.email_enabled);
setDailyDigest(prefs.daily_digest);
setInitialized(true);
}
const update = useMutation({
mutationFn: (input: {
deadline_reminder_days: number[];
email_enabled: boolean;
daily_digest: boolean;
}) => api.put<NotificationPreferences>("/api/notification-preferences", input),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["notification-preferences"],
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
function toggleDay(day: number) {
setReminderDays((prev) =>
prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day].sort((a, b) => b - a),
);
}
function handleSave() {
update.mutate({
deadline_reminder_days: reminderDays,
email_enabled: emailEnabled,
daily_digest: dailyDigest,
});
}
if (isLoading) {
return (
<div className="animate-pulse space-y-3">
<div className="h-4 w-48 rounded bg-neutral-200" />
<div className="h-8 w-full rounded bg-neutral-100" />
<div className="h-8 w-full rounded bg-neutral-100" />
</div>
);
}
return (
<div className="space-y-5">
{/* Reminder days */}
<div>
<p className="text-sm font-medium text-neutral-700">
Fristen-Erinnerungen
</p>
<p className="mt-0.5 text-xs text-neutral-500">
Erinnern Sie mich vor Fristablauf:
</p>
<div className="mt-2 flex flex-wrap gap-2">
{REMINDER_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => toggleDay(opt.value)}
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
reminderDays.includes(opt.value)
? "border-blue-500 bg-blue-50 text-blue-700"
: "border-neutral-200 bg-white text-neutral-600 hover:border-neutral-300"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Email toggle */}
<label className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-neutral-700">
E-Mail-Benachrichtigungen
</p>
<p className="text-xs text-neutral-500">
Erinnerungen per E-Mail erhalten
</p>
</div>
<button
onClick={() => setEmailEnabled(!emailEnabled)}
className={`relative h-6 w-11 rounded-full transition-colors ${
emailEnabled ? "bg-blue-500" : "bg-neutral-300"
}`}
>
<span
className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
emailEnabled ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</label>
{/* Daily digest toggle */}
<label className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-neutral-700">
Tagesübersicht
</p>
<p className="text-xs text-neutral-500">
Alle Benachrichtigungen gesammelt um 8:00 Uhr per E-Mail
</p>
</div>
<button
onClick={() => setDailyDigest(!dailyDigest)}
className={`relative h-6 w-11 rounded-full transition-colors ${
dailyDigest ? "bg-blue-500" : "bg-neutral-300"
}`}
>
<span
className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
dailyDigest ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</label>
{/* Save */}
<div className="flex items-center gap-3 pt-2">
<button
onClick={handleSave}
disabled={update.isPending}
className="rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
{update.isPending ? "Speichern..." : "Speichern"}
</button>
{saved && (
<span className="text-sm text-green-600">Gespeichert</span>
)}
</div>
</div>
);
}

View File

@@ -3,27 +3,36 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { UserPlus, Trash2, Shield, Crown, User } from "lucide-react";
import { UserPlus, Trash2, Crown, Scale, Briefcase, FileText, Phone } from "lucide-react";
import { api } from "@/lib/api";
import type { UserTenant } from "@/lib/types";
import type { UserTenant, UserRole } from "@/lib/types";
import { ROLE_LABELS } from "@/lib/types";
import { Skeleton } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState";
import { usePermissions } from "@/lib/hooks/usePermissions";
const ROLE_LABELS: Record<string, { label: string; icon: typeof Crown }> = {
owner: { label: "Eigentümer", icon: Crown },
admin: { label: "Administrator", icon: Shield },
member: { label: "Mitglied", icon: User },
const ROLE_CONFIG: Record<UserRole, { label: string; icon: typeof Crown }> = {
owner: { label: ROLE_LABELS.owner, icon: Crown },
partner: { label: ROLE_LABELS.partner, icon: Scale },
associate: { label: ROLE_LABELS.associate, icon: Briefcase },
paralegal: { label: ROLE_LABELS.paralegal, icon: FileText },
secretary: { label: ROLE_LABELS.secretary, icon: Phone },
};
const INVITE_ROLES: UserRole[] = ["partner", "associate", "paralegal", "secretary"];
export function TeamSettings() {
const queryClient = useQueryClient();
const { can, role: myRole } = usePermissions();
const tenantId =
typeof window !== "undefined"
? localStorage.getItem("kanzlai_tenant_id")
: null;
const [email, setEmail] = useState("");
const [role, setRole] = useState("member");
const [role, setRole] = useState<string>("associate");
const canManageTeam = can("manage_team");
const {
data: members,
@@ -42,7 +51,7 @@ export function TeamSettings() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
setEmail("");
setRole("member");
setRole("associate");
toast.success("Benutzer eingeladen");
},
onError: (err: { error?: string }) => {
@@ -62,6 +71,19 @@ export function TeamSettings() {
},
});
const updateRoleMutation = useMutation({
mutationFn: ({ userId, newRole }: { userId: string; newRole: string }) =>
api.put(`/tenants/${tenantId}/members/${userId}/role`, { role: newRole }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
toast.success("Rolle aktualisiert");
},
onError: (err: { error?: string }) => {
toast.error(err.error || "Fehler beim Aktualisieren der Rolle");
},
});
const handleInvite = (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) return;
@@ -81,7 +103,7 @@ export function TeamSettings() {
if (error) {
return (
<EmptyState
icon={User}
icon={Briefcase}
title="Fehler beim Laden"
description="Team-Mitglieder konnten nicht geladen werden."
/>
@@ -90,38 +112,44 @@ export function TeamSettings() {
return (
<div className="space-y-6">
{/* Invite Form */}
<form onSubmit={handleInvite} className="flex flex-col gap-3 sm:flex-row">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
className="flex-1 rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
/>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
className="rounded-md border border-neutral-200 px-2 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
>
<option value="member">Mitglied</option>
<option value="admin">Administrator</option>
</select>
<button
type="submit"
disabled={inviteMutation.isPending || !email.trim()}
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
<UserPlus className="h-3.5 w-3.5" />
{inviteMutation.isPending ? "Einladen..." : "Einladen"}
</button>
</form>
{/* Invite Form — only for owners/partners */}
{canManageTeam && (
<form onSubmit={handleInvite} className="flex flex-col gap-3 sm:flex-row">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
className="flex-1 rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
/>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
className="rounded-md border border-neutral-200 px-2 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
>
{INVITE_ROLES.map((r) => (
<option key={r} value={r}>
{ROLE_LABELS[r]}
</option>
))}
</select>
<button
type="submit"
disabled={inviteMutation.isPending || !email.trim()}
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
<UserPlus className="h-3.5 w-3.5" />
{inviteMutation.isPending ? "Einladen..." : "Einladen"}
</button>
</form>
)}
{/* Members List */}
{Array.isArray(members) && members.length > 0 ? (
<div className="overflow-hidden rounded-md border border-neutral-200">
{members.map((member, i) => {
const roleInfo = ROLE_LABELS[member.role] || ROLE_LABELS.member;
const roleKey = (member.role as UserRole) || "associate";
const roleInfo = ROLE_CONFIG[roleKey] || ROLE_CONFIG.associate;
const RoleIcon = roleInfo.icon;
return (
<div
@@ -141,23 +169,48 @@ export function TeamSettings() {
<p className="text-xs text-neutral-500">{roleInfo.label}</p>
</div>
</div>
{member.role !== "owner" && (
<button
onClick={() => removeMutation.mutate(member.user_id)}
disabled={removeMutation.isPending}
className="rounded-md p-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
title="Mitglied entfernen"
>
<Trash2 className="h-4 w-4" />
</button>
)}
<div className="flex items-center gap-2">
{/* Role dropdown — only for owners/partners, not for the member's own row if they are owner */}
{canManageTeam && member.role !== "owner" && (
<select
value={member.role}
onChange={(e) =>
updateRoleMutation.mutate({
userId: member.user_id,
newRole: e.target.value,
})
}
disabled={updateRoleMutation.isPending}
className="rounded-md border border-neutral-200 px-2 py-1 text-xs outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
>
{myRole === "owner" && (
<option value="owner">{ROLE_LABELS.owner}</option>
)}
{INVITE_ROLES.map((r) => (
<option key={r} value={r}>
{ROLE_LABELS[r]}
</option>
))}
</select>
)}
{canManageTeam && member.role !== "owner" && (
<button
onClick={() => removeMutation.mutate(member.user_id)}
disabled={removeMutation.isPending}
className="rounded-md p-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
title="Mitglied entfernen"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</div>
);
})}
</div>
) : (
<EmptyState
icon={User}
icon={Briefcase}
title="Noch keine Mitglieder"
description="Laden Sie Teammitglieder per E-Mail ein."
/>

View File

@@ -0,0 +1,29 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { UserInfo } from "@/lib/types";
export function usePermissions() {
const { data, isLoading } = useQuery({
queryKey: ["me"],
queryFn: () => api.get<UserInfo>("/me"),
staleTime: 60 * 1000,
});
const role = data?.role ?? null;
const permissions = data?.permissions ?? [];
function can(permission: string): boolean {
return permissions.includes(permission);
}
return {
role,
permissions,
can,
isLoading,
userId: data?.user_id ?? null,
tenantId: data?.tenant_id ?? null,
};
}

View File

@@ -189,6 +189,40 @@ export interface Note {
updated_at: string;
}
export interface CaseAssignment {
id: string;
case_id: string;
user_id: string;
role: string;
assigned_at: string;
}
export interface UserInfo {
user_id: string;
tenant_id: string;
role: UserRole;
permissions: string[];
}
export type UserRole = "owner" | "partner" | "associate" | "paralegal" | "secretary";
export const ROLE_LABELS: Record<UserRole, string> = {
owner: "Inhaber",
partner: "Partner",
associate: "Anwalt",
paralegal: "Paralegal",
secretary: "Sekretariat",
};
export const CASE_ASSIGNMENT_ROLES = ["lead", "team", "viewer"] as const;
export type CaseAssignmentRole = (typeof CASE_ASSIGNMENT_ROLES)[number];
export const CASE_ASSIGNMENT_ROLE_LABELS: Record<CaseAssignmentRole, string> = {
lead: "Federführend",
team: "Team",
viewer: "Einsicht",
};
export interface ApiError {
error: string;
status: number;
@@ -295,3 +329,149 @@ export interface ExtractionResponse {
deadlines: ExtractedDeadline[];
count: number;
}
// Notification types
export interface Notification {
id: string;
tenant_id: string;
user_id: string;
type: string;
entity_type?: string;
entity_id?: string;
title: string;
body?: string;
sent_at?: string;
read_at?: string;
created_at: string;
}
export interface NotificationPreferences {
user_id: string;
tenant_id: string;
deadline_reminder_days: number[];
email_enabled: boolean;
daily_digest: boolean;
created_at: string;
updated_at: string;
}
export interface NotificationListResponse {
data: Notification[];
total: number;
}
// Audit log types
export interface AuditLogEntry {
id: number;
tenant_id: string;
user_id?: string;
action: string;
entity_type: string;
entity_id?: string;
old_values?: Record<string, unknown>;
new_values?: Record<string, unknown>;
ip_address?: string;
user_agent?: string;
created_at: string;
}
export interface AuditLogResponse {
entries: AuditLogEntry[];
total: number;
page: number;
limit: number;
}
// Reporting types
export interface CaseStats {
period: string;
opened: number;
closed: number;
active: number;
}
export interface CasesByType {
case_type: string;
count: number;
}
export interface CasesByCourt {
court: string;
count: number;
}
export interface CaseReport {
monthly: CaseStats[];
by_type: CasesByType[];
by_court: CasesByCourt[];
total: {
opened: number;
closed: number;
active: number;
};
}
export interface DeadlineCompliance {
period: string;
total: number;
met: number;
missed: number;
pending: number;
compliance_rate: number;
}
export interface MissedDeadline {
id: string;
title: string;
due_date: string;
case_id: string;
case_number: string;
case_title: string;
days_overdue: number;
}
export interface DeadlineReport {
monthly: DeadlineCompliance[];
missed: MissedDeadline[];
total: {
total: number;
met: number;
missed: number;
pending: number;
compliance_rate: number;
};
}
export interface UserWorkload {
user_id: string;
active_cases: number;
deadlines: number;
overdue: number;
completed: number;
}
export interface WorkloadReport {
users: UserWorkload[];
}
export interface BillingByMonth {
period: string;
cases_active: number;
cases_closed: number;
cases_new: number;
}
export interface BillingByType {
case_type: string;
active: number;
closed: number;
total: number;
}
export interface BillingReport {
monthly: BillingByMonth[];
by_type: BillingByType[];
}