Compare commits
1 Commits
mai/linus/
...
mai/ritchi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e53e1389f9 |
@@ -203,14 +203,3 @@ func (h *AppointmentHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
|
|||||||
32
backend/internal/handlers/dashboard.go
Normal file
32
backend/internal/handlers/dashboard.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DashboardHandler struct {
|
||||||
|
svc *services.DashboardService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDashboardHandler(svc *services.DashboardService) *DashboardHandler {
|
||||||
|
return &DashboardHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.svc.Get(r.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, data)
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
|
|||||||
// Middleware
|
// Middleware
|
||||||
tenantResolver := auth.NewTenantResolver(tenantSvc)
|
tenantResolver := auth.NewTenantResolver(tenantSvc)
|
||||||
|
|
||||||
|
dashboardSvc := services.NewDashboardService(db)
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
tenantH := handlers.NewTenantHandler(tenantSvc)
|
tenantH := handlers.NewTenantHandler(tenantSvc)
|
||||||
caseH := handlers.NewCaseHandler(caseSvc)
|
caseH := handlers.NewCaseHandler(caseSvc)
|
||||||
@@ -35,6 +37,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
|
|||||||
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc, db)
|
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc, db)
|
||||||
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
|
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
|
||||||
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
|
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
|
||||||
|
dashboardH := handlers.NewDashboardHandler(dashboardSvc)
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
mux.HandleFunc("GET /health", handleHealth(db))
|
mux.HandleFunc("GET /health", handleHealth(db))
|
||||||
@@ -86,6 +89,9 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
|
|||||||
scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update)
|
scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update)
|
||||||
scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete)
|
scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete)
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
|
||||||
|
|
||||||
// Placeholder routes for future phases
|
// Placeholder routes for future phases
|
||||||
scoped.HandleFunc("GET /api/documents", placeholder("documents"))
|
scoped.HandleFunc("GET /api/documents", placeholder("documents"))
|
||||||
|
|
||||||
|
|||||||
151
backend/internal/services/dashboard_service.go
Normal file
151
backend/internal/services/dashboard_service.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DashboardService struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDashboardService(db *sqlx.DB) *DashboardService {
|
||||||
|
return &DashboardService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardData struct {
|
||||||
|
DeadlineSummary DeadlineSummary `json:"deadline_summary"`
|
||||||
|
CaseSummary CaseSummary `json:"case_summary"`
|
||||||
|
UpcomingDeadlines []UpcomingDeadline `json:"upcoming_deadlines"`
|
||||||
|
UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"`
|
||||||
|
RecentActivity []RecentActivity `json:"recent_activity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeadlineSummary struct {
|
||||||
|
OverdueCount int `json:"overdue_count" db:"overdue_count"`
|
||||||
|
DueThisWeek int `json:"due_this_week" db:"due_this_week"`
|
||||||
|
DueNextWeek int `json:"due_next_week" db:"due_next_week"`
|
||||||
|
OKCount int `json:"ok_count" db:"ok_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CaseSummary struct {
|
||||||
|
ActiveCount int `json:"active_count" db:"active_count"`
|
||||||
|
NewThisMonth int `json:"new_this_month" db:"new_this_month"`
|
||||||
|
ClosedCount int `json:"closed_count" db:"closed_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpcomingDeadline struct {
|
||||||
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
|
Title string `json:"title" db:"title"`
|
||||||
|
DueDate string `json:"due_date" db:"due_date"`
|
||||||
|
CaseNumber string `json:"case_number" db:"case_number"`
|
||||||
|
CaseTitle string `json:"case_title" db:"case_title"`
|
||||||
|
Status string `json:"status" db:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpcomingAppointment struct {
|
||||||
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
|
Title string `json:"title" db:"title"`
|
||||||
|
StartAt time.Time `json:"start_at" db:"start_at"`
|
||||||
|
CaseNumber *string `json:"case_number" db:"case_number"`
|
||||||
|
Location *string `json:"location" db:"location"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecentActivity struct {
|
||||||
|
EventType *string `json:"event_type" db:"event_type"`
|
||||||
|
Title string `json:"title" db:"title"`
|
||||||
|
CaseNumber string `json:"case_number" db:"case_number"`
|
||||||
|
EventDate *time.Time `json:"event_date" db:"event_date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardService) Get(ctx context.Context, tenantID uuid.UUID) (*DashboardData, error) {
|
||||||
|
now := time.Now()
|
||||||
|
today := now.Format("2006-01-02")
|
||||||
|
endOfWeek := now.AddDate(0, 0, 7-int(now.Weekday())).Format("2006-01-02")
|
||||||
|
endOfNextWeek := now.AddDate(0, 0, 14-int(now.Weekday())).Format("2006-01-02")
|
||||||
|
in7Days := now.AddDate(0, 0, 7).Format("2006-01-02")
|
||||||
|
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
|
||||||
|
|
||||||
|
data := &DashboardData{}
|
||||||
|
|
||||||
|
// Single query with CTEs for deadline + case summaries
|
||||||
|
summaryQuery := `
|
||||||
|
WITH deadline_stats AS (
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE due_date < $2 AND status = 'pending') AS overdue_count,
|
||||||
|
COUNT(*) FILTER (WHERE due_date >= $2 AND due_date <= $3 AND status = 'pending') AS due_this_week,
|
||||||
|
COUNT(*) FILTER (WHERE due_date > $3 AND due_date <= $4 AND status = 'pending') AS due_next_week,
|
||||||
|
COUNT(*) FILTER (WHERE due_date > $4 AND status = 'pending') AS ok_count
|
||||||
|
FROM deadlines
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
),
|
||||||
|
case_stats AS (
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status = 'active') AS active_count,
|
||||||
|
COUNT(*) FILTER (WHERE created_at >= $5::date AND status != 'archived') AS new_this_month,
|
||||||
|
COUNT(*) FILTER (WHERE status IN ('closed', 'archived')) AS closed_count
|
||||||
|
FROM cases
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
ds.overdue_count, ds.due_this_week, ds.due_next_week, ds.ok_count,
|
||||||
|
cs.active_count, cs.new_this_month, cs.closed_count
|
||||||
|
FROM deadline_stats ds, case_stats cs`
|
||||||
|
|
||||||
|
var summaryRow struct {
|
||||||
|
DeadlineSummary
|
||||||
|
CaseSummary
|
||||||
|
}
|
||||||
|
err := s.db.GetContext(ctx, &summaryRow, summaryQuery, tenantID, today, endOfWeek, endOfNextWeek, startOfMonth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dashboard summary: %w", err)
|
||||||
|
}
|
||||||
|
data.DeadlineSummary = summaryRow.DeadlineSummary
|
||||||
|
data.CaseSummary = summaryRow.CaseSummary
|
||||||
|
|
||||||
|
// Upcoming deadlines (next 7 days)
|
||||||
|
deadlineQuery := `
|
||||||
|
SELECT d.id, d.title, d.due_date, c.case_number, c.title AS case_title, d.status
|
||||||
|
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.status = 'pending' AND d.due_date >= $2 AND d.due_date <= $3
|
||||||
|
ORDER BY d.due_date ASC`
|
||||||
|
|
||||||
|
data.UpcomingDeadlines = []UpcomingDeadline{}
|
||||||
|
if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, deadlineQuery, tenantID, today, in7Days); err != nil {
|
||||||
|
return nil, fmt.Errorf("dashboard upcoming deadlines: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upcoming appointments (next 7 days)
|
||||||
|
appointmentQuery := `
|
||||||
|
SELECT a.id, a.title, a.start_at, c.case_number, a.location
|
||||||
|
FROM appointments a
|
||||||
|
LEFT JOIN cases c ON c.id = a.case_id AND c.tenant_id = a.tenant_id
|
||||||
|
WHERE a.tenant_id = $1 AND a.start_at >= $2::timestamp AND a.start_at < ($2::date + interval '7 days')
|
||||||
|
ORDER BY a.start_at ASC`
|
||||||
|
|
||||||
|
data.UpcomingAppointments = []UpcomingAppointment{}
|
||||||
|
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, appointmentQuery, tenantID, now); err != nil {
|
||||||
|
return nil, fmt.Errorf("dashboard upcoming appointments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent activity (last 10 case events)
|
||||||
|
activityQuery := `
|
||||||
|
SELECT ce.event_type, ce.title, c.case_number, ce.event_date
|
||||||
|
FROM case_events ce
|
||||||
|
JOIN cases c ON c.id = ce.case_id AND c.tenant_id = ce.tenant_id
|
||||||
|
WHERE ce.tenant_id = $1
|
||||||
|
ORDER BY COALESCE(ce.event_date, ce.created_at) DESC
|
||||||
|
LIMIT 10`
|
||||||
|
|
||||||
|
data.RecentActivity = []RecentActivity{}
|
||||||
|
if err := s.db.SelectContext(ctx, &data.RecentActivity, activityQuery, tenantID); err != nil {
|
||||||
|
return nil, fmt.Errorf("dashboard recent activity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
33
backend/internal/services/dashboard_service_test.go
Normal file
33
backend/internal/services/dashboard_service_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDashboardDateCalculations(t *testing.T) {
|
||||||
|
// Verify the date range logic used in Get()
|
||||||
|
now := time.Date(2026, 3, 25, 14, 0, 0, 0, time.UTC) // Wednesday
|
||||||
|
|
||||||
|
today := now.Format("2006-01-02")
|
||||||
|
endOfWeek := now.AddDate(0, 0, 7-int(now.Weekday())).Format("2006-01-02")
|
||||||
|
endOfNextWeek := now.AddDate(0, 0, 14-int(now.Weekday())).Format("2006-01-02")
|
||||||
|
in7Days := now.AddDate(0, 0, 7).Format("2006-01-02")
|
||||||
|
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
|
||||||
|
|
||||||
|
if today != "2026-03-25" {
|
||||||
|
t.Errorf("today = %s, want 2026-03-25", today)
|
||||||
|
}
|
||||||
|
if endOfWeek != "2026-03-29" { // Sunday
|
||||||
|
t.Errorf("endOfWeek = %s, want 2026-03-29", endOfWeek)
|
||||||
|
}
|
||||||
|
if endOfNextWeek != "2026-04-05" {
|
||||||
|
t.Errorf("endOfNextWeek = %s, want 2026-04-05", endOfNextWeek)
|
||||||
|
}
|
||||||
|
if in7Days != "2026-04-01" {
|
||||||
|
t.Errorf("in7Days = %s, want 2026-04-01", in7Days)
|
||||||
|
}
|
||||||
|
if startOfMonth != "2026-03-01" {
|
||||||
|
t.Errorf("startOfMonth = %s, want 2026-03-01", startOfMonth)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user