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