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)
167 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|