Files
KanzlAI-mGMT/backend/internal/handlers/time_entries.go
m 238811727d feat: time tracking + billing — hourly rates, time entries, invoices (P1)
Database: time_entries, billing_rates, invoices tables with RLS.
Backend: CRUD services+handlers for time entries, billing rates, invoices.
  - Time entries: list/create/update/delete, summary by case/user/month
  - Billing rates: upsert with auto-close previous, current rate lookup
  - Invoices: create with auto-number (RE-YYYY-NNN), status transitions
    (draft->sent->paid, cancellation), link time entries on invoice create
API: 11 new endpoints under /api/time-entries, /api/billing-rates, /api/invoices
Frontend: Zeiterfassung tab on case detail, /abrechnung overview with filters,
  /abrechnung/rechnungen list+detail with status actions, billing rates settings
Also: resolved merge conflicts between audit-trail and role-based branches,
  added missing types (Notification, AuditLogResponse, NotificationPreferences)
2026-03-30 11:24:36 +02:00

210 lines
5.3 KiB
Go

package handlers
import (
"encoding/json"
"net/http"
"strconv"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
"github.com/google/uuid"
)
type TimeEntryHandler struct {
svc *services.TimeEntryService
}
func NewTimeEntryHandler(svc *services.TimeEntryService) *TimeEntryHandler {
return &TimeEntryHandler{svc: svc}
}
// ListForCase handles GET /api/cases/{id}/time-entries
func (h *TimeEntryHandler) ListForCase(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
entries, err := h.svc.ListForCase(r.Context(), tenantID, caseID)
if err != nil {
internalError(w, "failed to list time entries", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"time_entries": entries})
}
// List handles GET /api/time-entries?case_id=&user_id=&from=&to=
func (h *TimeEntryHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, offset = clampPagination(limit, offset)
filter := services.TimeEntryFilter{
From: r.URL.Query().Get("from"),
To: r.URL.Query().Get("to"),
Limit: limit,
Offset: offset,
}
if caseStr := r.URL.Query().Get("case_id"); caseStr != "" {
caseID, err := uuid.Parse(caseStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case_id")
return
}
filter.CaseID = &caseID
}
if userStr := r.URL.Query().Get("user_id"); userStr != "" {
userID, err := uuid.Parse(userStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid user_id")
return
}
filter.UserID = &userID
}
entries, total, err := h.svc.List(r.Context(), tenantID, filter)
if err != nil {
internalError(w, "failed to list time entries", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"time_entries": entries,
"total": total,
})
}
// Create handles POST /api/cases/{id}/time-entries
func (h *TimeEntryHandler) Create(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
userID, _ := auth.UserFromContext(r.Context())
caseID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
var input services.CreateTimeEntryInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
input.CaseID = caseID
if input.Description == "" {
writeError(w, http.StatusBadRequest, "description is required")
return
}
if input.DurationMinutes <= 0 {
writeError(w, http.StatusBadRequest, "duration_minutes must be positive")
return
}
if input.Date == "" {
writeError(w, http.StatusBadRequest, "date is required")
return
}
entry, err := h.svc.Create(r.Context(), tenantID, userID, input)
if err != nil {
internalError(w, "failed to create time entry", err)
return
}
writeJSON(w, http.StatusCreated, entry)
}
// Update handles PUT /api/time-entries/{id}
func (h *TimeEntryHandler) Update(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
entryID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid time entry ID")
return
}
var input services.UpdateTimeEntryInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
entry, err := h.svc.Update(r.Context(), tenantID, entryID, input)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, entry)
}
// Delete handles DELETE /api/time-entries/{id}
func (h *TimeEntryHandler) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
entryID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid time entry ID")
return
}
if err := h.svc.Delete(r.Context(), tenantID, entryID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// Summary handles GET /api/time-entries/summary?group_by=case|user|month&from=&to=
func (h *TimeEntryHandler) Summary(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
groupBy := r.URL.Query().Get("group_by")
if groupBy == "" {
groupBy = "case"
}
summaries, err := h.svc.Summary(r.Context(), tenantID, groupBy,
r.URL.Query().Get("from"), r.URL.Query().Get("to"))
if err != nil {
internalError(w, "failed to get summary", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"summary": summaries})
}