Files
KanzlAI-mGMT/frontend/src/app/(app)/einstellungen/abrechnung/page.tsx
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

167 lines
5.6 KiB
TypeScript

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