- {userName && (
-
{userName}
- )}
+ {/* User section */}
+
+
+
+ {getInitials(userName)}
+
+ {userName && (
+
{userName}
+ )}
+
diff --git a/app/components/layout/topbar.tsx b/app/components/layout/topbar.tsx
new file mode 100644
index 0000000..6f3ea7c
--- /dev/null
+++ b/app/components/layout/topbar.tsx
@@ -0,0 +1,110 @@
+import { useMatches, Link } from "react-router";
+import { ChevronRight } from "lucide-react";
+
+interface Breadcrumb {
+ label: string;
+ href?: string;
+}
+
+interface BreadcrumbHandle {
+ breadcrumbs: (data: unknown) => Breadcrumb[];
+}
+
+function isBreadcrumbHandle(handle: unknown): handle is BreadcrumbHandle {
+ return (
+ typeof handle === "object" &&
+ handle !== null &&
+ "breadcrumbs" in handle &&
+ typeof (handle as BreadcrumbHandle).breadcrumbs === "function"
+ );
+}
+
+function getInitials(name?: string | null): string {
+ if (!name) return "?";
+ return name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2);
+}
+
+export function Topbar({ userName }: { userName?: string | null }) {
+ const matches = useMatches();
+
+ const activeMatch = [...matches].reverse().find((m) => isBreadcrumbHandle(m.handle));
+ const breadcrumbs: Breadcrumb[] =
+ activeMatch && isBreadcrumbHandle(activeMatch.handle)
+ ? activeMatch.handle.breadcrumbs(activeMatch.data)
+ : [];
+
+ return (
+
+ );
+}
diff --git a/app/root.tsx b/app/root.tsx
index 8e30617..44c7222 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -8,6 +8,9 @@ export function Layout({ children }: { children: React.ReactNode }) {
Annas Rechnungsmanager
+
+
+
diff --git a/app/routes/companies.$id.customers.tsx b/app/routes/companies.$id.customers.tsx
index 0137490..81d95bb 100644
--- a/app/routes/companies.$id.customers.tsx
+++ b/app/routes/companies.$id.customers.tsx
@@ -1,5 +1,13 @@
import { useState } from "react";
-import { Link, useLoaderData, useParams, useRevalidator } from "react-router";
+import { Link, useLoaderData, useRevalidator } from "react-router";
+
+export const handle = {
+ breadcrumbs: (data: { companyId: string; companyName: string }) => [
+ { label: "Mandanten", href: "/companies" },
+ { label: data.companyName, href: `/companies/${data.companyId}` },
+ { label: "Kunden" },
+ ],
+};
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { Button } from "@/components/ui/button";
@@ -47,7 +55,7 @@ export async function loader({ request, params }: { request: Request; params: {
orderBy: { name: "asc" },
});
- return { customers, companyId: params.id };
+ return { customers, companyId: params.id, companyName: company.name };
}
function CustomerForm({
diff --git a/app/routes/companies.$id.edit.tsx b/app/routes/companies.$id.edit.tsx
index b22710a..bfdf43b 100644
--- a/app/routes/companies.$id.edit.tsx
+++ b/app/routes/companies.$id.edit.tsx
@@ -1,4 +1,12 @@
import { Link, useLoaderData, useNavigate } from "react-router";
+
+export const handle = {
+ breadcrumbs: (data: { company: { id: string; name: string } }) => [
+ { label: "Mandanten", href: "/companies" },
+ { label: data.company.name, href: `/companies/${data.company.id}` },
+ { label: "Bearbeiten" },
+ ],
+};
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { CompanyForm } from "@/components/company/company-form";
diff --git a/app/routes/companies.$id.invoices.$invoiceId.tsx b/app/routes/companies.$id.invoices.$invoiceId.tsx
index dfb1c4f..c03a09a 100644
--- a/app/routes/companies.$id.invoices.$invoiceId.tsx
+++ b/app/routes/companies.$id.invoices.$invoiceId.tsx
@@ -1,4 +1,13 @@
import { Link, useLoaderData, useNavigate, useRevalidator } from "react-router";
+
+export const handle = {
+ breadcrumbs: (data: { invoice: { companyId: string; number: string; company: { name: string } } }) => [
+ { label: "Mandanten", href: "/companies" },
+ { label: data.invoice.company.name, href: `/companies/${data.invoice.companyId}` },
+ { label: "Rechnungen", href: `/companies/${data.invoice.companyId}/invoices` },
+ { label: data.invoice.number },
+ ],
+};
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { useState } from "react";
diff --git a/app/routes/companies.$id.invoices.new.tsx b/app/routes/companies.$id.invoices.new.tsx
index 2f68f35..45bca15 100644
--- a/app/routes/companies.$id.invoices.new.tsx
+++ b/app/routes/companies.$id.invoices.new.tsx
@@ -1,4 +1,13 @@
import { Link, useLoaderData, useNavigate, redirect } from "react-router";
+
+export const handle = {
+ breadcrumbs: (data: { company: { id: string; name: string } }) => [
+ { label: "Mandanten", href: "/companies" },
+ { label: data.company.name, href: `/companies/${data.company.id}` },
+ { label: "Rechnungen", href: `/companies/${data.company.id}/invoices` },
+ { label: "Neue Rechnung" },
+ ],
+};
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
diff --git a/app/routes/companies.$id.invoices.tsx b/app/routes/companies.$id.invoices.tsx
index d3ff3c5..121cfcc 100644
--- a/app/routes/companies.$id.invoices.tsx
+++ b/app/routes/companies.$id.invoices.tsx
@@ -1,4 +1,12 @@
import { Link, useLoaderData } from "react-router";
+
+export const handle = {
+ breadcrumbs: (data: { company: { id: string; name: string } }) => [
+ { label: "Mandanten", href: "/companies" },
+ { label: data.company.name, href: `/companies/${data.company.id}` },
+ { label: "Rechnungen" },
+ ],
+};
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { Button } from "@/components/ui/button";
diff --git a/app/routes/companies.$id.reports.tsx b/app/routes/companies.$id.reports.tsx
index 22237bb..f260585 100644
--- a/app/routes/companies.$id.reports.tsx
+++ b/app/routes/companies.$id.reports.tsx
@@ -1,9 +1,29 @@
import { useState, useEffect } from "react";
-import { Link, useParams } from "react-router";
+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 {
@@ -42,7 +62,7 @@ interface ReportData {
}
export default function ReportsPage() {
- const { id: companyId } = useParams<{ id: string }>();
+ const { companyId } = useLoaderData
();
const [year, setYear] = useState(new Date().getFullYear());
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
diff --git a/app/routes/companies.$id.tsx b/app/routes/companies.$id.tsx
index f123693..f64e387 100644
--- a/app/routes/companies.$id.tsx
+++ b/app/routes/companies.$id.tsx
@@ -11,6 +11,13 @@ import {
} from "lucide-react";
import { InvoiceStatus } from "@prisma/client";
+export const handle = {
+ breadcrumbs: (data: { company: { id: string; name: string } }) => [
+ { label: "Mandanten", href: "/companies" },
+ { label: data.company.name },
+ ],
+};
+
const statusLabels: Record = {
DRAFT: "Entwurf",
SENT: "Versendet",
diff --git a/app/routes/companies.new.tsx b/app/routes/companies.new.tsx
index bc03317..e679ea7 100644
--- a/app/routes/companies.new.tsx
+++ b/app/routes/companies.new.tsx
@@ -1,4 +1,11 @@
import { Link, useNavigate } from "react-router";
+
+export const handle = {
+ breadcrumbs: () => [
+ { label: "Mandanten", href: "/companies" },
+ { label: "Neuer Mandant" },
+ ],
+};
import { CompanyForm } from "@/components/company/company-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ChevronLeft } from "lucide-react";
diff --git a/app/routes/companies.tsx b/app/routes/companies.tsx
index 81fa7a9..5f293cc 100644
--- a/app/routes/companies.tsx
+++ b/app/routes/companies.tsx
@@ -1,88 +1,281 @@
import { Link, useLoaderData } from "react-router";
+import { useState } from "react";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Building2, Plus, FileText, Users } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { formatCurrency, formatDate } from "@/lib/tax";
+import {
+ Building2, Plus, FileText, Users, X, Edit, Receipt,
+ Mail, Phone, CreditCard, ChevronRight,
+} from "lucide-react";
+import { InvoiceStatus } from "@prisma/client";
+
+export const handle = {
+ breadcrumbs: () => [{ label: "Mandanten" }],
+};
+
+const statusLabels: Record = {
+ DRAFT: "Entwurf",
+ SENT: "Versendet",
+ PAID: "Bezahlt",
+ CANCELLED: "Storniert",
+};
+
+const statusVariants: Record<
+ InvoiceStatus,
+ "secondary" | "default" | "success" | "destructive" | "warning"
+> = {
+ DRAFT: "secondary",
+ SENT: "warning",
+ PAID: "success",
+ CANCELLED: "destructive",
+};
export async function loader({ request }: { request: Request }) {
const user = await requireUser(request);
const companies = await prisma.company.findMany({
where: { userId: user.id },
- include: { _count: { select: { invoices: true, customers: true } } },
+ include: {
+ _count: { select: { invoices: true, customers: true } },
+ invoices: {
+ include: { customer: { select: { name: true } } },
+ orderBy: { issueDate: "desc" },
+ },
+ },
orderBy: { name: "asc" },
});
- return { companies };
+ return {
+ companies: companies.map((c) => ({
+ ...c,
+ invoices: c.invoices.map((inv) => ({
+ ...inv,
+ grossTotal: Number(inv.grossTotal),
+ issueDate: inv.issueDate.toISOString(),
+ dueDate: inv.dueDate.toISOString(),
+ })),
+ })),
+ };
}
export default function CompaniesPage() {
const { companies } = useLoaderData();
+ const [selectedId, setSelectedId] = useState(null);
+ const selected = companies.find((c) => c.id === selectedId) ?? null;
return (
-
-
-
-
Mandanten
-
{companies.length} Mandanten verwaltet
+
+ {/* Kacheln */}
+
+
+
+
Mandanten
+
{companies.length} Mandanten verwaltet
+
+
-
-
- {companies.length === 0 ? (
-
-
-
- Noch keine Mandanten
- Legen Sie Ihren ersten Mandanten an, um loszulegen.
+ {companies.length === 0 ? (
+
+
+
+
+
Noch keine Mandanten
+
Legen Sie Ihren ersten Mandanten an, um loszulegen.
-
-
- ) : (
-
- {companies.map((company) => (
-
-
-
-
-
+
+ ) : (
+
+ {companies.map((company) => {
+ const isActive = selectedId === company.id;
+ return (
+
+
+ {/* Detail-Panel */}
+ {selected && (
+
+
+ {/* Header */}
+
+
+
+
+
+
+
{selected.name}
+ {selected.legalForm && (
+
{selected.legalForm}
+ )}
+
+
+
+
+
+ {/* Aktionen */}
+
+
+
+
+
+ {/* Firmendaten */}
+
+ {selected.city && (
+
+
Adresse
+
{selected.zip} {selected.city}
+
+ )}
+ {selected.email && (
+
+
E-Mail
+
+
+ {selected.email}
+
+
+ )}
+ {selected.phone && (
+
+
Telefon
+
+
+ {selected.phone}
+
+
+ )}
+ {selected.taxId && (
+
+
Steuernummer
+
{selected.taxId}
+
+ )}
+ {selected.vatId && (
+
+
USt-IdNr.
+
{selected.vatId}
+
+ )}
+ {selected.bankIban && (
+
+
IBAN
+
+
+ {selected.bankIban}
+
+
+ )}
+
+
+ {/* Rechnungen */}
+
+
+
Rechnungen
+
+ Alle
+
+
+
+ {selected.invoices.length === 0 ? (
+
+
+
Noch keine Rechnungen
+
+ ) : (
+
+ {selected.invoices.map((invoice) => (
+
+
+
{invoice.number}
+
+ {invoice.customer.name} · {formatDate(invoice.issueDate)}
+
+
+
+
+ {statusLabels[invoice.status]}
+
+
+ {formatCurrency(invoice.grossTotal)}
+
+
+
+ ))}
+
+ )}
+
+
)}
diff --git a/app/routes/dashboard-layout.tsx b/app/routes/dashboard-layout.tsx
index ce26e2a..a25d2b8 100644
--- a/app/routes/dashboard-layout.tsx
+++ b/app/routes/dashboard-layout.tsx
@@ -1,6 +1,6 @@
import { Outlet, useLoaderData } from "react-router";
import { requireUser } from "@/session.server";
-import { Sidebar } from "@/components/layout/sidebar";
+import { Topbar } from "@/components/layout/topbar";
export async function loader({ request }: { request: Request }) {
const user = await requireUser(request);
@@ -11,9 +11,9 @@ export default function DashboardLayout() {
const { userName } = useLoaderData();
return (
-
-
-
+
+
+
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index e9921ca..8fe92f0 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -3,9 +3,13 @@ import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { formatCurrency } from "@/lib/tax";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Building2, FileText, Euro } from "lucide-react";
+import { Building2, FileText, Euro, TrendingUp } from "lucide-react";
import { InvoiceStatus } from "@prisma/client";
+export const handle = {
+ breadcrumbs: () => [{ label: "Dashboard" }],
+};
+
export async function loader({ request }: { request: Request }) {
const user = await requireUser(request);
const userId = user.id;
@@ -38,116 +42,150 @@ export async function loader({ request }: { request: Request }) {
};
}
+const statCards = [
+ {
+ key: "companies",
+ label: "Mandanten",
+ icon: Building2,
+ gradient: "from-indigo-500 to-violet-600",
+ bg: "bg-indigo-50",
+ iconColor: "text-indigo-600",
+ shadowColor: "shadow-indigo-500/15",
+ },
+ {
+ key: "totalInvoices",
+ label: "Rechnungen gesamt",
+ icon: FileText,
+ gradient: "from-blue-500 to-cyan-500",
+ bg: "bg-blue-50",
+ iconColor: "text-blue-600",
+ shadowColor: "shadow-blue-500/15",
+ },
+ {
+ key: "openInvoices",
+ label: "Offen / Entwurf",
+ icon: TrendingUp,
+ gradient: "from-amber-500 to-orange-500",
+ bg: "bg-amber-50",
+ iconColor: "text-amber-600",
+ shadowColor: "shadow-amber-500/15",
+ },
+ {
+ key: "paidTotal",
+ label: "Bezahlt (brutto)",
+ icon: Euro,
+ gradient: "from-emerald-500 to-teal-500",
+ bg: "bg-emerald-50",
+ iconColor: "text-emerald-600",
+ shadowColor: "shadow-emerald-500/15",
+ },
+];
+
export default function DashboardPage() {
const { companies, totalInvoices, paidTotal, openInvoices } = useLoaderData();
+ const statValues: Record = {
+ companies: companies.length,
+ totalInvoices,
+ openInvoices,
+ paidTotal: formatCurrency(paidTotal),
+ };
+
return (
-
+
-
Dashboard
-
Übersicht aller Mandanten und Rechnungen
+
Dashboard
+
Übersicht aller Mandanten und Rechnungen
+ {/* Stats */}
-
-
-
-
-
-
-
-
{companies.length}
-
Mandanten
+ {statCards.map((card) => {
+ const Icon = card.icon;
+ return (
+
+
+
+ {statValues[card.key]}
+
+
{card.label}
-
-
-
-
-
-
-
-
-
-
-
{totalInvoices}
-
Rechnungen gesamt
-
-
-
-
-
-
-
-
-
-
-
-
-
{openInvoices}
-
Offen / Entwurf
-
-
-
-
-
-
-
-
-
-
-
-
-
{formatCurrency(paidTotal)}
-
Bezahlt (brutto)
-
-
-
-
+ );
+ })}
+ {/* Companies */}
-
Mandanten
-
- Alle anzeigen →
+
+
Mandanten
+
{companies.length} Mandanten verwaltet
+
+
+ Alle anzeigen
+
→
{companies.length === 0 ? (
-
-
-
- Noch keine Mandanten angelegt.
-
- Mandant anlegen
-
-
-
+
+
+
+
+
Noch keine Mandanten angelegt
+
Legen Sie Ihren ersten Mandanten an, um loszulegen.
+
+ Mandant anlegen
+
+
) : (
-
+
{companies.map((company) => (
-
-
-
- {company.name}
- {company.legalForm && (
- {company.legalForm}
- )}
-
-
-
-
{company._count.invoices} Rechnungen
-
{company._count.customers} Kunden
+
+
+
+
+
- {company.city && (
-
{company.zip} {company.city}
- )}
-
-
+
+
{company.name}
+ {company.legalForm && (
+
{company.legalForm}
+ )}
+
+
+
+
+
+ {company._count.invoices} Rechnungen
+
+
+
+ {company._count.customers} Kunden
+
+
+ {company.city && (
+
+ {company.zip} {company.city}
+
+ )}
+
))}
diff --git a/app/routes/login.tsx b/app/routes/login.tsx
index e606636..9fa3431 100644
--- a/app/routes/login.tsx
+++ b/app/routes/login.tsx
@@ -3,7 +3,6 @@ import { login, createUserSession, getUserSession } from "@/session.server";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Calculator, AlertCircle } from "lucide-react";
export async function loader({ request }: { request: Request }) {
@@ -29,26 +28,69 @@ export default function LoginPage() {
const loading = navigation.state === "submitting";
return (
-
-
-
-
-
-
-
Annas Rechnungsmanager
-
Buchhaltung & Rechnungsverwaltung
+
+ {/* Left decorative panel */}
+
+
+ {/* Decorative gradient blobs */}
+
+
+
+
+
+
+
+
+
Rechnungsmanager
+
+
+ Buchhaltung
+ einfach gemacht.
+
+
+ Verwalten Sie Mandanten, erstellen Sie Rechnungen und behalten Sie den Überblick über alle Zahlungen.
+
-
-
- Anmelden
- Geben Sie Ihre Zugangsdaten ein
-
-
-
+
+
+ {/* Right login form */}
+
+
+ {/* Mobile logo */}
+
+
+
+
+
Rechnungsmanager
+
+
+
+
Willkommen zurück
+
Melden Sie sich mit Ihren Zugangsdaten an
+
+
+
+
);
diff --git a/postcss.config.ts b/postcss.config.ts
new file mode 100644
index 0000000..c2ddf74
--- /dev/null
+++ b/postcss.config.ts
@@ -0,0 +1,5 @@
+export default {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};