import { useState, useCallback } from "react"; import { Link, useLoaderData, useRevalidator } from "react-router"; import { requireUser } from "@/session.server"; import prisma from "@/lib/prisma.server"; import { Card, CardContent } from "@/components/ui/card"; import { ChevronLeft, Loader2 } from "lucide-react"; import { formatCurrency } from "@/lib/tax"; import { AUSGABE_KATEGORIEN, KATEGORIE_LABELS, type AusgabeKategorieKey } from "@/lib/ausgaben"; export { KATEGORIE_LABELS }; export const handle = { breadcrumbs: (data: { companyId: string; companyName: string }) => [ { label: "Mandanten", href: "/companies" }, { label: data.companyName, href: `/companies/${data.companyId}` }, { label: "Betriebsausgaben" }, ], }; const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]; interface Ausgabe { id: string; kategorie: AusgabeKategorieKey; betrag: number; datum: string; } // { [kategorie]: { [month 1-12]: { ids: string[]; betrag: number } } } type GridCell = { ids: string[]; betrag: number }; type GridData = Record>; function buildGrid(ausgaben: Ausgabe[]): GridData { const grid: GridData = {}; for (const k of AUSGABE_KATEGORIEN) { grid[k] = {}; for (let m = 1; m <= 12; m++) grid[k][m] = { ids: [], betrag: 0 }; } for (const a of ausgaben) { const month = new Date(a.datum).getMonth() + 1; if (grid[a.kategorie]?.[month] !== undefined) { grid[a.kategorie][month].ids.push(a.id); grid[a.kategorie][month].betrag += a.betrag; } } return grid; } 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 }); const year = new Date().getFullYear(); const ausgaben = await prisma.betriebsausgabe.findMany({ where: { companyId: params.id, datum: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`) }, }, orderBy: { datum: "asc" }, }); return { companyId: company.id, companyName: company.name, initialYear: year, ausgaben: ausgaben.map((a) => ({ id: a.id, kategorie: a.kategorie as AusgabeKategorieKey, betrag: Number(a.betrag), datum: a.datum.toISOString(), })), }; } export default function AusgabenPage() { const { ausgaben: initialAusgaben, companyId, companyName, initialYear } = useLoaderData(); const { revalidate } = useRevalidator(); const [year, setYear] = useState(initialYear); const [grid, setGrid] = useState(() => buildGrid(initialAusgaben)); const [loadingYear, setLoadingYear] = useState(false); const [editingCell, setEditingCell] = useState<{ kategorie: string; month: number } | null>(null); const [cellInput, setCellInput] = useState(""); const [savingCell, setSavingCell] = useState<{ kategorie: string; month: number } | null>(null); const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i); async function loadYear(y: number) { setEditingCell(null); setYear(y); setLoadingYear(true); const res = await fetch(`/api/ausgaben?companyId=${companyId}&year=${y}`); const data: Ausgabe[] = await res.json(); setGrid(buildGrid(data)); setLoadingYear(false); } function startEdit(kategorie: string, month: number) { if (savingCell) return; const cell = grid[kategorie]?.[month]; setEditingCell({ kategorie, month }); setCellInput(cell?.betrag ? String(cell.betrag) : ""); } const commitCell = useCallback(async () => { if (!editingCell) return; const { kategorie, month } = editingCell; setEditingCell(null); const newBetrag = parseFloat(cellInput.replace(",", ".")) || 0; const cell = grid[kategorie]?.[month]; const oldBetrag = cell?.betrag ?? 0; if (newBetrag === oldBetrag) return; setSavingCell({ kategorie, month }); try { if (newBetrag <= 0 && cell?.ids.length) { // Löschen aller Records für diese Zelle await Promise.all( cell.ids.map((id) => fetch(`/api/ausgaben/${id}`, { method: "DELETE" })) ); setGrid((g) => { const next = { ...g }; next[kategorie] = { ...next[kategorie], [month]: { ids: [], betrag: 0 } }; return next; }); } else if (newBetrag > 0 && cell?.ids.length === 1) { // Update des bestehenden Records await fetch(`/api/ausgaben/${cell.ids[0]}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kategorie, betrag: newBetrag, datum: `${year}-${String(month).padStart(2, "0")}-01`, }), }); setGrid((g) => { const next = { ...g }; next[kategorie] = { ...next[kategorie], [month]: { ids: cell.ids, betrag: newBetrag } }; return next; }); } else if (newBetrag > 0 && (cell?.ids.length ?? 0) > 1) { // Mehrere Records → alle löschen, einen neuen anlegen await Promise.all( cell!.ids.map((id) => fetch(`/api/ausgaben/${id}`, { method: "DELETE" })) ); const res = await fetch("/api/ausgaben", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ companyId, kategorie, betrag: newBetrag, datum: `${year}-${String(month).padStart(2, "0")}-01`, }), }); const created: Ausgabe = await res.json(); setGrid((g) => { const next = { ...g }; next[kategorie] = { ...next[kategorie], [month]: { ids: [created.id], betrag: newBetrag } }; return next; }); } else if (newBetrag > 0) { // Neuer Record const res = await fetch("/api/ausgaben", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ companyId, kategorie, betrag: newBetrag, datum: `${year}-${String(month).padStart(2, "0")}-01`, }), }); const created: Ausgabe = await res.json(); setGrid((g) => { const next = { ...g }; next[kategorie] = { ...next[kategorie], [month]: { ids: [created.id], betrag: newBetrag } }; return next; }); } revalidate(); } finally { setSavingCell(null); } }, [editingCell, cellInput, grid, companyId, year, revalidate]); // Berechnungen const rowTotals: Record = {}; const colTotals: Record = {}; let grandTotal = 0; for (const k of AUSGABE_KATEGORIEN) { rowTotals[k] = 0; for (let m = 1; m <= 12; m++) { const b = grid[k]?.[m]?.betrag ?? 0; rowTotals[k] += b; colTotals[m] = (colTotals[m] ?? 0) + b; grandTotal += b; } } const topKategorien = [...AUSGABE_KATEGORIEN] .filter((k) => rowTotals[k] > 0) .sort((a, b) => rowTotals[b] - rowTotals[a]) .slice(0, 2); return (
Zurück zum Mandanten

Betriebsausgaben

{companyName} · {year}

{/* Zusammenfassung */}

Gesamt {year}

{formatCurrency(grandTotal)}

Kategorien mit Einträgen

{AUSGABE_KATEGORIEN.filter((k) => rowTotals[k] > 0).length}

{topKategorien.map((k) => (

{KATEGORIE_LABELS[k]}

{formatCurrency(rowTotals[k])}

))} {topKategorien.length < 2 && Array.from({ length: 2 - topKategorien.length }).map((_, i) => (
))}
{/* Matrix-Tabelle */} {loadingYear ? (
Lade Ausgaben...
) : (
{MONTHS.map((_, i) => )} {MONTHS.map((m) => ( ))} {AUSGABE_KATEGORIEN.map((kat) => ( {Array.from({ length: 12 }, (_, i) => i + 1).map((month) => { const cell = grid[kat]?.[month]; const betrag = cell?.betrag ?? 0; const isEditing = editingCell?.kategorie === kat && editingCell?.month === month; const isSaving = savingCell?.kategorie === kat && savingCell?.month === month; return ( ); })} ))} {Array.from({ length: 12 }, (_, i) => i + 1).map((month) => ( ))}
Kategorie {m} Gesamt
{KATEGORIE_LABELS[kat]} {isSaving ? ( ) : isEditing ? ( setCellInput(e.target.value)} onBlur={commitCell} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); commitCell(); } if (e.key === "Escape") setEditingCell(null); }} className="w-full text-right text-sm border border-indigo-300 rounded px-1 py-0.5 focus:outline-none focus:ring-1 focus:ring-indigo-400" /> ) : ( )} 0 ? "text-rose-600" : "text-slate-300"}`}> {rowTotals[kat] > 0 ? rowTotals[kat].toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"}
Gesamt 0 ? "text-slate-800" : "text-slate-300"}`}> {colTotals[month] > 0 ? colTotals[month].toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"} {grandTotal.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
Zelle anklicken zum Bearbeiten · Enter speichern · Escape abbrechen · 0 löscht den Eintrag
)}
); }