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:
118
frontend/src/app/(app)/abrechnung/rechnungen/page.tsx
Normal file
118
frontend/src/app/(app)/abrechnung/rechnungen/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user