236 lines
10 KiB
TypeScript
236 lines
10 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Link, useLoaderData } from "react-router";
|
|
import { requireUser } from "@/session.server";
|
|
import prisma from "@/lib/prisma";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { formatCurrency } from "@/lib/tax";
|
|
import { ChevronLeft, TrendingUp, BarChart3 } from "lucide-react";
|
|
|
|
export const handle = {
|
|
breadcrumbs: (data: { companyId: string; companyName: string }) => [
|
|
{ label: "Mandanten", href: "/companies" },
|
|
{ label: data.companyName, href: `/companies/${data.companyId}` },
|
|
{ label: "Berichte" },
|
|
],
|
|
};
|
|
|
|
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
|
const user = await requireUser(request);
|
|
const company = await prisma.company.findFirst({
|
|
where: { id: params.id, userId: user.id },
|
|
select: { id: true, name: true },
|
|
});
|
|
if (!company) throw new Response("Not Found", { status: 404 });
|
|
return { companyId: company.id, companyName: company.name };
|
|
}
|
|
|
|
const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
|
|
|
|
interface TaxGroup {
|
|
netAmount: number;
|
|
taxAmount: number;
|
|
}
|
|
|
|
interface MonthData {
|
|
month: number;
|
|
invoiceCount: number;
|
|
netTotal: number;
|
|
taxTotal: number;
|
|
grossTotal: number;
|
|
taxGroups: Record<string, TaxGroup>;
|
|
}
|
|
|
|
interface QuarterData {
|
|
quarter: number;
|
|
invoiceCount: number;
|
|
netTotal: number;
|
|
taxTotal: number;
|
|
grossTotal: number;
|
|
taxGroups: Record<string, TaxGroup>;
|
|
}
|
|
|
|
interface ReportData {
|
|
year: number;
|
|
monthly: MonthData[];
|
|
quarterly: QuarterData[];
|
|
yearTotal: {
|
|
invoiceCount: number;
|
|
netTotal: number;
|
|
taxTotal: number;
|
|
grossTotal: number;
|
|
};
|
|
}
|
|
|
|
export default function ReportsPage() {
|
|
const { companyId } = useLoaderData<typeof loader>();
|
|
const [year, setYear] = useState(new Date().getFullYear());
|
|
const [data, setData] = useState<ReportData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
fetch(`/api/reports?companyId=${companyId}&year=${year}`)
|
|
.then((r) => r.json())
|
|
.then((d) => { setData(d); setLoading(false); });
|
|
}, [companyId, year]);
|
|
|
|
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
|
|
|
|
return (
|
|
<div>
|
|
<Link
|
|
to={`/companies/${companyId}`}
|
|
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
|
|
</Link>
|
|
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Steuerberichte</h1>
|
|
<p className="text-gray-500 mt-1">Auswertungen für Steuererklärung und USt-Voranmeldung</p>
|
|
</div>
|
|
<select
|
|
value={year}
|
|
onChange={(e) => setYear(Number(e.target.value))}
|
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
>
|
|
{years.map((y) => <option key={y} value={y}>{y}</option>)}
|
|
</select>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="text-center text-gray-500 py-12">Lade Auswertung...</div>
|
|
) : data && (
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<p className="text-sm text-gray-500 mb-1">Rechnungen</p>
|
|
<p className="text-2xl font-bold text-gray-900">{data.yearTotal.invoiceCount}</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<p className="text-sm text-gray-500 mb-1">Umsatz (netto)</p>
|
|
<p className="text-2xl font-bold text-gray-900">{formatCurrency(data.yearTotal.netTotal)}</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<p className="text-sm text-gray-500 mb-1">USt. gesamt</p>
|
|
<p className="text-2xl font-bold text-indigo-600">{formatCurrency(data.yearTotal.taxTotal)}</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<p className="text-sm text-gray-500 mb-1">Umsatz (brutto)</p>
|
|
<p className="text-2xl font-bold text-gray-900">{formatCurrency(data.yearTotal.grossTotal)}</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-2">
|
|
<BarChart3 className="h-5 w-5 text-indigo-600" />
|
|
<CardTitle>USt-Voranmeldung (quartalsweise)</CardTitle>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-200">
|
|
<th className="text-left pb-3 text-gray-500 font-medium">Quartal</th>
|
|
<th className="text-right pb-3 text-gray-500 font-medium">Rechnungen</th>
|
|
<th className="text-right pb-3 text-gray-500 font-medium">Netto</th>
|
|
<th className="text-right pb-3 text-gray-500 font-medium">USt. 19%</th>
|
|
<th className="text-right pb-3 text-gray-500 font-medium">USt. 7%</th>
|
|
<th className="text-right pb-3 text-gray-500 font-medium">USt. gesamt</th>
|
|
<th className="text-right pb-3 text-gray-500 font-medium">Brutto</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{data.quarterly.map((q) => (
|
|
<tr key={q.quarter} className="hover:bg-gray-50">
|
|
<td className="py-3 font-medium text-gray-900">Q{q.quarter} {year}</td>
|
|
<td className="py-3 text-right text-gray-700">{q.invoiceCount}</td>
|
|
<td className="py-3 text-right text-gray-700">{formatCurrency(q.netTotal)}</td>
|
|
<td className="py-3 text-right text-gray-700">
|
|
{q.taxGroups["19"] ? formatCurrency(q.taxGroups["19"].taxAmount) : "—"}
|
|
</td>
|
|
<td className="py-3 text-right text-gray-700">
|
|
{q.taxGroups["7"] ? formatCurrency(q.taxGroups["7"].taxAmount) : "—"}
|
|
</td>
|
|
<td className="py-3 text-right font-medium text-indigo-700">{formatCurrency(q.taxTotal)}</td>
|
|
<td className="py-3 text-right font-semibold text-gray-900">{formatCurrency(q.grossTotal)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr className="border-t-2 border-gray-300">
|
|
<td className="pt-3 font-bold text-gray-900">Gesamt {year}</td>
|
|
<td className="pt-3 text-right font-bold text-gray-900">{data.yearTotal.invoiceCount}</td>
|
|
<td className="pt-3 text-right font-bold text-gray-900">{formatCurrency(data.yearTotal.netTotal)}</td>
|
|
<td className="pt-3 text-right font-bold text-gray-900">
|
|
{formatCurrency(data.quarterly.reduce((s, q) => s + (q.taxGroups["19"]?.taxAmount ?? 0), 0))}
|
|
</td>
|
|
<td className="pt-3 text-right font-bold text-gray-900">
|
|
{formatCurrency(data.quarterly.reduce((s, q) => s + (q.taxGroups["7"]?.taxAmount ?? 0), 0))}
|
|
</td>
|
|
<td className="pt-3 text-right font-bold text-indigo-700">{formatCurrency(data.yearTotal.taxTotal)}</td>
|
|
<td className="pt-3 text-right font-bold text-gray-900">{formatCurrency(data.yearTotal.grossTotal)}</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-2">
|
|
<TrendingUp className="h-5 w-5 text-indigo-600" />
|
|
<CardTitle>Monatliche Übersicht</CardTitle>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-200">
|
|
<th className="text-left pb-3 text-gray-500 font-medium">Monat</th>
|
|
<th className="text-right pb-3 text-gray-500 font-medium">Rechnungen</th>
|
|
<th className="text-right pb-3 text-gray-500 font-medium">Netto</th>
|
|
<th className="text-right pb-3 text-gray-500 font-medium">USt.</th>
|
|
<th className="text-right pb-3 text-gray-500 font-medium">Brutto</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{data.monthly.map((m) => (
|
|
<tr key={m.month} className={`hover:bg-gray-50 ${m.invoiceCount === 0 ? "opacity-40" : ""}`}>
|
|
<td className="py-2.5 font-medium text-gray-900">{MONTHS[m.month - 1]} {year}</td>
|
|
<td className="py-2.5 text-right text-gray-700">{m.invoiceCount || "—"}</td>
|
|
<td className="py-2.5 text-right text-gray-700">
|
|
{m.netTotal > 0 ? formatCurrency(m.netTotal) : "—"}
|
|
</td>
|
|
<td className="py-2.5 text-right text-indigo-700">
|
|
{m.taxTotal > 0 ? formatCurrency(m.taxTotal) : "—"}
|
|
</td>
|
|
<td className="py-2.5 text-right font-medium text-gray-900">
|
|
{m.grossTotal > 0 ? formatCurrency(m.grossTotal) : "—"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|