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)
This commit is contained in:
306
frontend/src/app/(app)/cases/[id]/zeiterfassung/page.tsx
Normal file
306
frontend/src/app/(app)/cases/[id]/zeiterfassung/page.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import type { TimeEntry } from "@/lib/types";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { Timer, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
if (h === 0) return `${m}min`;
|
||||
if (m === 0) return `${h}h`;
|
||||
return `${h}h ${m}min`;
|
||||
}
|
||||
|
||||
function formatAmount(minutes: number, rate?: number): string {
|
||||
if (!rate) return "-";
|
||||
return `${((minutes / 60) * rate).toFixed(2)} EUR`;
|
||||
}
|
||||
|
||||
const ACTIVITIES = [
|
||||
{ value: "", label: "Keine Kategorie" },
|
||||
{ value: "research", label: "Recherche" },
|
||||
{ value: "drafting", label: "Entwurf" },
|
||||
{ value: "hearing", label: "Verhandlung" },
|
||||
{ value: "call", label: "Telefonat" },
|
||||
{ value: "review", label: "Prüfung" },
|
||||
{ value: "travel", label: "Reise" },
|
||||
{ value: "meeting", label: "Besprechung" },
|
||||
];
|
||||
|
||||
export default function ZeiterfassungPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [desc, setDesc] = useState("");
|
||||
const [hours, setHours] = useState("");
|
||||
const [minutes, setMinutes] = useState("");
|
||||
const [date, setDate] = useState(format(new Date(), "yyyy-MM-dd"));
|
||||
const [activity, setActivity] = useState("");
|
||||
const [billable, setBillable] = useState(true);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["case-time-entries", id],
|
||||
queryFn: () =>
|
||||
api.get<{ time_entries: TimeEntry[] }>(`/cases/${id}/time-entries`),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (input: {
|
||||
description: string;
|
||||
duration_minutes: number;
|
||||
date: string;
|
||||
activity?: string;
|
||||
billable: boolean;
|
||||
}) => api.post<TimeEntry>(`/cases/${id}/time-entries`, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["case-time-entries", id] });
|
||||
setShowForm(false);
|
||||
setDesc("");
|
||||
setHours("");
|
||||
setMinutes("");
|
||||
setActivity("");
|
||||
setBillable(true);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (entryId: string) =>
|
||||
api.delete(`/time-entries/${entryId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["case-time-entries", id] });
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const totalMinutes = (parseInt(hours || "0") * 60) + parseInt(minutes || "0");
|
||||
if (totalMinutes <= 0 || !desc.trim()) return;
|
||||
createMutation.mutate({
|
||||
description: desc.trim(),
|
||||
duration_minutes: totalMinutes,
|
||||
date,
|
||||
activity: activity || undefined,
|
||||
billable,
|
||||
});
|
||||
}
|
||||
|
||||
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?.time_entries ?? [];
|
||||
const totalMinutes = entries.reduce((s, e) => s + e.duration_minutes, 0);
|
||||
const billableMinutes = entries
|
||||
.filter((e) => e.billable)
|
||||
.reduce((s, e) => s + e.duration_minutes, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex gap-6 text-sm text-neutral-500">
|
||||
<span>
|
||||
Gesamt: <span className="font-medium text-neutral-900">{formatDuration(totalMinutes)}</span>
|
||||
</span>
|
||||
<span>
|
||||
Abrechenbar: <span className="font-medium text-neutral-900">{formatDuration(billableMinutes)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
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"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Eintrag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick add form */}
|
||||
{showForm && (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-md border border-neutral-200 bg-neutral-50 p-4 space-y-3"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-600 mb-1">
|
||||
Beschreibung
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
placeholder="Was wurde getan?"
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm focus:border-neutral-500 focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex-1 min-w-[120px]">
|
||||
<label className="block text-xs font-medium text-neutral-600 mb-1">
|
||||
Dauer
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={hours}
|
||||
onChange={(e) => setHours(e.target.value)}
|
||||
placeholder="0"
|
||||
className="w-16 rounded-md border border-neutral-300 px-2 py-1.5 text-sm focus:border-neutral-500 focus:outline-none"
|
||||
/>
|
||||
<span className="text-xs text-neutral-500">h</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value={minutes}
|
||||
onChange={(e) => setMinutes(e.target.value)}
|
||||
placeholder="0"
|
||||
className="w-16 rounded-md border border-neutral-300 px-2 py-1.5 text-sm focus:border-neutral-500 focus:outline-none"
|
||||
/>
|
||||
<span className="text-xs text-neutral-500">min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[120px]">
|
||||
<label className="block text-xs font-medium text-neutral-600 mb-1">
|
||||
Datum
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm focus:border-neutral-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[120px]">
|
||||
<label className="block text-xs font-medium text-neutral-600 mb-1">
|
||||
Kategorie
|
||||
</label>
|
||||
<select
|
||||
value={activity}
|
||||
onChange={(e) => setActivity(e.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm focus:border-neutral-500 focus:outline-none"
|
||||
>
|
||||
{ACTIVITIES.map((a) => (
|
||||
<option key={a.value} value={a.value}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={billable}
|
||||
onChange={(e) => setBillable(e.target.checked)}
|
||||
className="rounded border-neutral-300"
|
||||
/>
|
||||
Abrechenbar
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForm(false)}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-neutral-600 transition-colors hover:bg-neutral-100"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? "Speichern..." : "Speichern"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{createMutation.isError && (
|
||||
<p className="text-sm text-red-600">Fehler beim Speichern.</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Entries list */}
|
||||
{entries.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<div className="rounded-xl bg-neutral-100 p-3">
|
||||
<Timer className="h-5 w-5 text-neutral-400" />
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
Keine Zeiteintraege vorhanden.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-neutral-900 truncate">
|
||||
{entry.description}
|
||||
</p>
|
||||
{entry.activity && (
|
||||
<span className="shrink-0 rounded-full bg-neutral-100 px-2 py-0.5 text-xs text-neutral-500">
|
||||
{ACTIVITIES.find((a) => a.value === entry.activity)?.label ?? entry.activity}
|
||||
</span>
|
||||
)}
|
||||
{!entry.billable && (
|
||||
<span className="shrink-0 rounded-full bg-neutral-100 px-2 py-0.5 text-xs text-neutral-400">
|
||||
nicht abrechenbar
|
||||
</span>
|
||||
)}
|
||||
{entry.billed && (
|
||||
<span className="shrink-0 rounded-full bg-emerald-50 px-2 py-0.5 text-xs text-emerald-700">
|
||||
abgerechnet
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex gap-4 text-xs text-neutral-500">
|
||||
<span>
|
||||
{format(new Date(entry.date), "d. MMM yyyy", { locale: de })}
|
||||
</span>
|
||||
{entry.hourly_rate && (
|
||||
<span>{formatAmount(entry.duration_minutes, entry.hourly_rate)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-4">
|
||||
<span className="text-sm font-medium text-neutral-900 whitespace-nowrap">
|
||||
{formatDuration(entry.duration_minutes)}
|
||||
</span>
|
||||
{!entry.billed && (
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(entry.id)}
|
||||
className="rounded-md p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
title="Loeschen"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user