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)
165 lines
5.8 KiB
TypeScript
165 lines
5.8 KiB
TypeScript
"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 →
|
|
</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>
|
|
);
|
|
}
|