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:
m
2026-03-30 11:24:36 +02:00
parent 8e65463130
commit 238811727d
23 changed files with 2346 additions and 129 deletions

View File

@@ -0,0 +1,164 @@
"use client";
import { useQuery } from "@tanstack/react-query";
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 } from "lucide-react";
import { useState } from "react";
import Link from "next/link";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
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`;
}
export default function AbrechnungPage() {
const [from, setFrom] = useState(() => {
const d = new Date();
d.setDate(1);
return format(d, "yyyy-MM-dd");
});
const [to, setTo] = useState(() => format(new Date(), "yyyy-MM-dd"));
const { data, isLoading } = useQuery({
queryKey: ["time-entries", from, to],
queryFn: () =>
api.get<{ time_entries: TimeEntry[]; total: number }>(
`/time-entries?from=${from}&to=${to}&limit=100`,
),
});
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);
const totalAmount = entries
.filter((e) => e.billable && e.hourly_rate)
.reduce((s, e) => s + (e.duration_minutes / 60) * (e.hourly_rate ?? 0), 0);
return (
<div className="animate-fade-in">
<Breadcrumb
items={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Abrechnung" },
]}
/>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-lg font-semibold text-neutral-900">
Zeiterfassung
</h1>
<Link
href="/abrechnung/rechnungen"
className="text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
Rechnungen ansehen &rarr;
</Link>
</div>
{/* Filters */}
<div className="mt-4 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<label className="text-xs text-neutral-500">Von</label>
<input
type="date"
value={from}
onChange={(e) => setFrom(e.target.value)}
className="rounded-md border border-neutral-300 px-2 py-1 text-sm focus:border-neutral-500 focus:outline-none"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-neutral-500">Bis</label>
<input
type="date"
value={to}
onChange={(e) => setTo(e.target.value)}
className="rounded-md border border-neutral-300 px-2 py-1 text-sm focus:border-neutral-500 focus:outline-none"
/>
</div>
</div>
{/* Summary cards */}
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="rounded-md border border-neutral-200 bg-white p-4">
<p className="text-xs text-neutral-500">Gesamt</p>
<p className="mt-1 text-xl font-semibold text-neutral-900">
{formatDuration(totalMinutes)}
</p>
</div>
<div className="rounded-md border border-neutral-200 bg-white p-4">
<p className="text-xs text-neutral-500">Abrechenbar</p>
<p className="mt-1 text-xl font-semibold text-neutral-900">
{formatDuration(billableMinutes)}
</p>
</div>
<div className="rounded-md border border-neutral-200 bg-white p-4">
<p className="text-xs text-neutral-500">Betrag</p>
<p className="mt-1 text-xl font-semibold text-neutral-900">
{totalAmount.toFixed(2)} EUR
</p>
</div>
</div>
{/* Entries */}
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
) : entries.length === 0 ? (
<div className="flex flex-col items-center py-12 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 im gewaehlten Zeitraum.
</p>
</div>
) : (
<div className="mt-4 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">
<p className="text-sm font-medium text-neutral-900 truncate">
{entry.description}
</p>
<div className="mt-0.5 flex gap-3 text-xs text-neutral-500">
<span>
{format(new Date(entry.date), "d. MMM yyyy", { locale: de })}
</span>
<Link
href={`/cases/${entry.case_id}/zeiterfassung`}
className="hover:text-neutral-700"
>
Akte ansehen
</Link>
</div>
</div>
<div className="flex items-center gap-4 ml-4 text-sm">
{entry.billable ? (
<span className="text-emerald-600">abrechenbar</span>
) : (
<span className="text-neutral-400">intern</span>
)}
<span className="font-medium text-neutral-900 whitespace-nowrap">
{formatDuration(entry.duration_minutes)}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,225 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { api } from "@/lib/api";
import type { Invoice } from "@/lib/types";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Loader2, AlertTriangle } from "lucide-react";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
const STATUS_BADGE: Record<string, string> = {
draft: "bg-neutral-100 text-neutral-600",
sent: "bg-blue-50 text-blue-700",
paid: "bg-emerald-50 text-emerald-700",
cancelled: "bg-red-50 text-red-600",
};
const STATUS_LABEL: Record<string, string> = {
draft: "Entwurf",
sent: "Versendet",
paid: "Bezahlt",
cancelled: "Storniert",
};
const TRANSITIONS: Record<string, { label: string; next: string }[]> = {
draft: [
{ label: "Als versendet markieren", next: "sent" },
{ label: "Stornieren", next: "cancelled" },
],
sent: [
{ label: "Als bezahlt markieren", next: "paid" },
{ label: "Stornieren", next: "cancelled" },
],
paid: [],
cancelled: [],
};
export default function InvoiceDetailPage() {
const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient();
const { data: invoice, isLoading, error } = useQuery({
queryKey: ["invoice", id],
queryFn: () => api.get<Invoice>(`/invoices/${id}`),
});
const statusMutation = useMutation({
mutationFn: (status: string) =>
api.patch<Invoice>(`/invoices/${id}/status`, { status }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["invoice", id] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
);
}
if (error || !invoice) {
return (
<div className="py-12 text-center">
<AlertTriangle className="mx-auto h-6 w-6 text-red-500" />
<p className="mt-2 text-sm text-neutral-500">
Rechnung nicht gefunden.
</p>
</div>
);
}
const items = Array.isArray(invoice.items) ? invoice.items : [];
const actions = TRANSITIONS[invoice.status] ?? [];
return (
<div className="animate-fade-in">
<Breadcrumb
items={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Abrechnung", href: "/abrechnung" },
{ label: "Rechnungen", href: "/abrechnung/rechnungen" },
{ label: invoice.invoice_number },
]}
/>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold text-neutral-900">
{invoice.invoice_number}
</h1>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[invoice.status]}`}
>
{STATUS_LABEL[invoice.status]}
</span>
</div>
<p className="mt-1 text-sm text-neutral-500">
{invoice.client_name}
</p>
</div>
<div className="flex gap-2">
{actions.map((action) => (
<button
key={action.next}
onClick={() => statusMutation.mutate(action.next)}
disabled={statusMutation.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"
>
{action.label}
</button>
))}
</div>
</div>
{/* Invoice details */}
<div className="mt-6 rounded-md border border-neutral-200 bg-white">
{/* Client info */}
<div className="border-b border-neutral-100 p-4">
<p className="text-xs text-neutral-500">Empfaenger</p>
<p className="mt-1 text-sm font-medium text-neutral-900">
{invoice.client_name}
</p>
{invoice.client_address && (
<p className="mt-0.5 text-sm text-neutral-500 whitespace-pre-line">
{invoice.client_address}
</p>
)}
</div>
{/* Dates */}
<div className="flex flex-wrap gap-6 border-b border-neutral-100 p-4">
{invoice.issued_at && (
<div>
<p className="text-xs text-neutral-500">Rechnungsdatum</p>
<p className="mt-0.5 text-sm text-neutral-900">
{format(new Date(invoice.issued_at), "d. MMMM yyyy", { locale: de })}
</p>
</div>
)}
{invoice.due_at && (
<div>
<p className="text-xs text-neutral-500">Faellig am</p>
<p className="mt-0.5 text-sm text-neutral-900">
{format(new Date(invoice.due_at), "d. MMMM yyyy", { locale: de })}
</p>
</div>
)}
{invoice.paid_at && (
<div>
<p className="text-xs text-neutral-500">Bezahlt am</p>
<p className="mt-0.5 text-sm text-neutral-900">
{format(new Date(invoice.paid_at), "d. MMMM yyyy", { locale: de })}
</p>
</div>
)}
</div>
{/* Line items */}
<div className="p-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-100 text-left text-xs text-neutral-500">
<th className="pb-2 font-medium">Beschreibung</th>
<th className="pb-2 font-medium text-right">Betrag</th>
</tr>
</thead>
<tbody>
{items.map((item, i) => (
<tr key={i} className="border-b border-neutral-50">
<td className="py-2 text-neutral-900">
{item.description}
{item.duration_minutes && item.hourly_rate && (
<span className="ml-2 text-xs text-neutral-400">
({Math.floor(item.duration_minutes / 60)}h{" "}
{item.duration_minutes % 60}min x {item.hourly_rate} EUR/h)
</span>
)}
</td>
<td className="py-2 text-right text-neutral-900">
{item.amount.toFixed(2)} EUR
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Totals */}
<div className="border-t border-neutral-200 p-4">
<div className="flex justify-end">
<div className="w-48 space-y-1">
<div className="flex justify-between text-sm">
<span className="text-neutral-500">Netto</span>
<span>{invoice.subtotal.toFixed(2)} EUR</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-neutral-500">
USt. {invoice.tax_rate}%
</span>
<span>{invoice.tax_amount.toFixed(2)} EUR</span>
</div>
<div className="flex justify-between border-t border-neutral-200 pt-1 text-sm font-semibold">
<span>Gesamt</span>
<span>{invoice.total.toFixed(2)} EUR</span>
</div>
</div>
</div>
</div>
{/* Notes */}
{invoice.notes && (
<div className="border-t border-neutral-100 p-4">
<p className="text-xs text-neutral-500">Anmerkungen</p>
<p className="mt-1 text-sm text-neutral-700">{invoice.notes}</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { Invoice } from "@/lib/types";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Receipt, Loader2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
const STATUS_BADGE: Record<string, string> = {
draft: "bg-neutral-100 text-neutral-600",
sent: "bg-blue-50 text-blue-700",
paid: "bg-emerald-50 text-emerald-700",
cancelled: "bg-red-50 text-red-600",
};
const STATUS_LABEL: Record<string, string> = {
draft: "Entwurf",
sent: "Versendet",
paid: "Bezahlt",
cancelled: "Storniert",
};
export default function RechnungenPage() {
const [statusFilter, setStatusFilter] = useState("");
const { data, isLoading } = useQuery({
queryKey: ["invoices", statusFilter],
queryFn: () => {
const params = statusFilter ? `?status=${statusFilter}` : "";
return api.get<{ invoices: Invoice[] }>(`/invoices${params}`);
},
});
const invoices = data?.invoices ?? [];
return (
<div className="animate-fade-in">
<Breadcrumb
items={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Abrechnung", href: "/abrechnung" },
{ label: "Rechnungen" },
]}
/>
<div className="mt-4 flex items-center justify-between">
<h1 className="text-lg font-semibold text-neutral-900">Rechnungen</h1>
</div>
{/* Filters */}
<div className="mt-4 flex gap-2">
{["", "draft", "sent", "paid", "cancelled"].map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
statusFilter === s
? "bg-neutral-900 text-white"
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
}`}
>
{s === "" ? "Alle" : STATUS_LABEL[s]}
</button>
))}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
) : invoices.length === 0 ? (
<div className="flex flex-col items-center py-12 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<Receipt className="h-5 w-5 text-neutral-400" />
</div>
<p className="mt-2 text-sm text-neutral-500">
Keine Rechnungen vorhanden.
</p>
</div>
) : (
<div className="mt-4 space-y-2">
{invoices.map((inv) => (
<Link
key={inv.id}
href={`/abrechnung/rechnungen/${inv.id}`}
className="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-3 transition-colors hover:bg-neutral-50"
>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-neutral-900">
{inv.invoice_number}
</p>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[inv.status]}`}
>
{STATUS_LABEL[inv.status]}
</span>
</div>
<p className="mt-0.5 text-xs text-neutral-500">
{inv.client_name}
{inv.issued_at &&
`${format(new Date(inv.issued_at), "d. MMM yyyy", { locale: de })}`}
</p>
</div>
<p className="text-sm font-semibold text-neutral-900">
{inv.total.toFixed(2)} EUR
</p>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -17,6 +17,7 @@ import {
StickyNote,
AlertTriangle,
ScrollText,
Timer,
} from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
@@ -46,6 +47,7 @@ const TABS = [
{ segment: "dokumente", label: "Dokumente", icon: FileText },
{ segment: "parteien", label: "Parteien", icon: Users },
{ segment: "mitarbeiter", label: "Mitarbeiter", icon: UserCheck },
{ segment: "zeiterfassung", label: "Zeiterfassung", icon: Timer },
{ segment: "notizen", label: "Notizen", icon: StickyNote },
{ segment: "protokoll", label: "Protokoll", icon: ScrollText },
] as const;
@@ -56,6 +58,7 @@ const TAB_LABELS: Record<string, string> = {
dokumente: "Dokumente",
parteien: "Parteien",
mitarbeiter: "Mitarbeiter",
zeiterfassung: "Zeiterfassung",
notizen: "Notizen",
protokoll: "Protokoll",
};

View 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>
);
}

View File

@@ -0,0 +1,166 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { BillingRate } from "@/lib/types";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Loader2, Plus } from "lucide-react";
import { useState } from "react";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
export default function BillingRatesPage() {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [rate, setRate] = useState("");
const [validFrom, setValidFrom] = useState(format(new Date(), "yyyy-MM-dd"));
const { data, isLoading } = useQuery({
queryKey: ["billing-rates"],
queryFn: () =>
api.get<{ billing_rates: BillingRate[] }>("/billing-rates"),
});
const upsertMutation = useMutation({
mutationFn: (input: { rate: number; valid_from: string; currency: string }) =>
api.put<BillingRate>("/billing-rates", input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["billing-rates"] });
setShowForm(false);
setRate("");
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const rateNum = parseFloat(rate);
if (isNaN(rateNum) || rateNum < 0) return;
upsertMutation.mutate({
rate: rateNum,
valid_from: validFrom,
currency: "EUR",
});
}
const rates = data?.billing_rates ?? [];
return (
<div className="animate-fade-in">
<Breadcrumb
items={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Einstellungen", href: "/einstellungen" },
{ label: "Stundensaetze" },
]}
/>
<div className="mt-4 flex items-center justify-between">
<h1 className="text-lg font-semibold text-neutral-900">
Stundensaetze
</h1>
<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" />
Neuer Satz
</button>
</div>
{showForm && (
<form
onSubmit={handleSubmit}
className="mt-4 rounded-md border border-neutral-200 bg-neutral-50 p-4 space-y-3"
>
<div className="flex flex-wrap gap-3">
<div className="flex-1 min-w-[150px]">
<label className="block text-xs font-medium text-neutral-600 mb-1">
Stundensatz (EUR)
</label>
<input
type="number"
min="0"
step="0.01"
value={rate}
onChange={(e) => setRate(e.target.value)}
placeholder="z.B. 350.00"
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-1 min-w-[150px]">
<label className="block text-xs font-medium text-neutral-600 mb-1">
Gueltig ab
</label>
<input
type="date"
value={validFrom}
onChange={(e) => setValidFrom(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"
required
/>
</div>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setShowForm(false)}
className="rounded-md px-3 py-1.5 text-sm text-neutral-600 hover:bg-neutral-100"
>
Abbrechen
</button>
<button
type="submit"
disabled={upsertMutation.isPending}
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
Speichern
</button>
</div>
</form>
)}
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
) : rates.length === 0 ? (
<div className="mt-8 text-center">
<p className="text-sm text-neutral-500">
Noch keine Stundensaetze definiert.
</p>
</div>
) : (
<div className="mt-4 space-y-2">
{rates.map((r) => (
<div
key={r.id}
className="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-3"
>
<div>
<p className="text-sm font-medium text-neutral-900">
{r.rate.toFixed(2)} {r.currency}/h
</p>
<p className="mt-0.5 text-xs text-neutral-500">
{r.user_id ? `Benutzer: ${r.user_id.slice(0, 8)}...` : "Standard (alle Benutzer)"}
</p>
</div>
<div className="text-right text-xs text-neutral-500">
<p>
Ab{" "}
{format(new Date(r.valid_from), "d. MMM yyyy", { locale: de })}
</p>
{r.valid_to && (
<p>
Bis{" "}
{format(new Date(r.valid_to), "d. MMM yyyy", { locale: de })}
</p>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -11,6 +11,7 @@ import {
Settings,
Menu,
X,
Receipt,
} from "lucide-react";
import { useState, useEffect } from "react";
import { usePermissions } from "@/lib/hooks/usePermissions";
@@ -27,6 +28,7 @@ const allNavigation: NavItem[] = [
{ name: "Akten", href: "/cases", icon: FolderOpen },
{ name: "Fristen", href: "/fristen", icon: Clock },
{ name: "Termine", href: "/termine", icon: Calendar },
{ name: "Abrechnung", href: "/abrechnung", icon: Receipt, permission: "manage_billing" },
{ name: "AI Analyse", href: "/ai/extract", icon: Brain, permission: "ai_extraction" },
{ name: "Einstellungen", href: "/einstellungen", icon: Settings, permission: "manage_settings" },
];

View File

@@ -223,6 +223,125 @@ export const CASE_ASSIGNMENT_ROLE_LABELS: Record<CaseAssignmentRole, string> = {
viewer: "Einsicht",
};
// Time tracking & billing
export interface TimeEntry {
id: string;
tenant_id: string;
case_id: string;
user_id: string;
date: string;
duration_minutes: number;
description: string;
activity?: string;
billable: boolean;
billed: boolean;
invoice_id?: string;
hourly_rate?: number;
created_at: string;
updated_at: string;
}
export interface BillingRate {
id: string;
tenant_id: string;
user_id?: string;
rate: number;
currency: string;
valid_from: string;
valid_to?: string;
created_at: string;
}
export interface InvoiceItem {
description: string;
duration_minutes?: number;
hourly_rate?: number;
amount: number;
time_entry_id?: string;
}
export interface Invoice {
id: string;
tenant_id: string;
case_id: string;
invoice_number: string;
client_name: string;
client_address?: string;
items: InvoiceItem[];
subtotal: number;
tax_rate: number;
tax_amount: number;
total: number;
status: "draft" | "sent" | "paid" | "cancelled";
issued_at?: string;
due_at?: string;
paid_at?: string;
notes?: string;
created_by: string;
created_at: string;
updated_at: string;
}
export interface TimeEntrySummary {
group_key: string;
total_minutes: number;
billable_minutes: number;
total_amount: number;
entry_count: number;
}
// Notifications
export interface Notification {
id: string;
tenant_id: string;
user_id: string;
type: string;
title: string;
body?: string;
entity_type?: string;
entity_id?: 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 {
notifications: Notification[];
data: Notification[];
total: number;
}
// Audit log
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;
}
export interface ApiError {
error: string;
status: number;