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)
210 lines
5.3 KiB
Go
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})
|
|
}
|