ADD: added admin panel and archiv mandates

This commit is contained in:
hwinkel
2026-03-13 10:58:44 +01:00
parent a742d79457
commit 3a2a94ec19
32 changed files with 2023 additions and 177 deletions
+95
View File
@@ -0,0 +1,95 @@
import { Outlet, useLoaderData, Link, useLocation } from "react-router";
import { requireAdmin } from "@/session.server";
import { Shield, Users, ScrollText, LayoutDashboard } from "lucide-react";
export async function loader({ request }: { request: Request }) {
const user = await requireAdmin(request);
return { userName: user.name };
}
export default function AdminLayout() {
const { userName } = useLoaderData<typeof loader>();
const location = useLocation();
const navItems = [
{ to: "/admin/users", label: "Benutzerverwaltung", icon: Users },
{ to: "/admin/logs", label: "Audit-Log", icon: ScrollText },
];
return (
<div className="min-h-screen bg-slate-50 flex flex-col">
<header
className="sticky top-0 z-10 shrink-0"
style={{
height: "3.5rem",
background: "#1e1b4b",
borderBottom: "1px solid #312e81",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
paddingLeft: "1.5rem",
paddingRight: "1.5rem",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<Shield className="w-5 h-5 text-indigo-300" />
<span style={{ color: "#c7d2fe", fontWeight: 600, fontSize: "0.9rem" }}>
Admin
</span>
<span style={{ color: "#4338ca", marginLeft: "0.25rem" }}>/</span>
<nav style={{ display: "flex", gap: "0.25rem" }}>
{navItems.map(({ to, label, icon: Icon }) => {
const active = location.pathname.startsWith(to);
return (
<Link
key={to}
to={to}
style={{
display: "flex",
alignItems: "center",
gap: "0.375rem",
fontSize: "0.8125rem",
padding: "0.25rem 0.625rem",
borderRadius: "0.375rem",
color: active ? "#e0e7ff" : "#818cf8",
background: active ? "#312e81" : "transparent",
textDecoration: "none",
fontWeight: active ? 500 : 400,
}}
>
<Icon className="w-3.5 h-3.5" />
{label}
</Link>
);
})}
</nav>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<span style={{ fontSize: "0.8125rem", color: "#818cf8" }}>{userName}</span>
<Link
to="/"
style={{
display: "flex",
alignItems: "center",
gap: "0.375rem",
fontSize: "0.8125rem",
color: "#818cf8",
textDecoration: "none",
padding: "0.25rem 0.625rem",
borderRadius: "0.375rem",
border: "1px solid #312e81",
}}
>
<LayoutDashboard className="w-3.5 h-3.5" />
App
</Link>
</div>
</header>
<main className="flex-1">
<div className="max-w-6xl mx-auto px-6 py-8">
<Outlet />
</div>
</main>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
import { useLoaderData } from "react-router";
import { requireAdmin } from "@/session.server";
import prisma from "@/lib/prisma";
const ACTION_LABELS: Record<string, string> = {
LOGIN: "Anmeldung",
LOGIN_FAILED: "Anmeldung fehlgeschlagen",
LOGOUT: "Abmeldung",
CREATE_USER: "Benutzer erstellt",
UPDATE_USER: "Benutzer bearbeitet",
DELETE_USER: "Benutzer gelöscht",
CREATE_COMPANY: "Firma erstellt",
UPDATE_COMPANY: "Firma bearbeitet",
DELETE_COMPANY: "Firma gelöscht",
CREATE_INVOICE: "Rechnung erstellt",
UPDATE_INVOICE: "Rechnung bearbeitet",
DELETE_INVOICE: "Rechnung gelöscht",
};
const ACTION_COLORS: Record<string, string> = {
LOGIN: "bg-green-100 text-green-700",
LOGIN_FAILED: "bg-red-100 text-red-700",
LOGOUT: "bg-slate-100 text-slate-600",
CREATE_USER: "bg-blue-100 text-blue-700",
UPDATE_USER: "bg-amber-100 text-amber-700",
DELETE_USER: "bg-red-100 text-red-700",
CREATE_COMPANY: "bg-blue-100 text-blue-700",
UPDATE_COMPANY: "bg-amber-100 text-amber-700",
DELETE_COMPANY: "bg-red-100 text-red-700",
CREATE_INVOICE: "bg-blue-100 text-blue-700",
UPDATE_INVOICE: "bg-amber-100 text-amber-700",
DELETE_INVOICE: "bg-red-100 text-red-700",
};
export async function loader({ request }: { request: Request }) {
await requireAdmin(request);
const url = new URL(request.url);
const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1"));
const pageSize = 50;
const [logs, total] = await Promise.all([
prisma.auditLog.findMany({
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
include: { user: { select: { name: true, username: true } } },
}),
prisma.auditLog.count(),
]);
return {
logs: logs.map((l) => ({
id: l.id,
action: l.action,
entity: l.entity,
entityId: l.entityId,
metadata: l.metadata,
ipAddress: l.ipAddress,
createdAt: l.createdAt.toISOString(),
userName: l.user?.name ?? null,
userUsername: l.user?.username ?? null,
})),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
export default function AdminLogsPage() {
const { logs, total, page, totalPages } = useLoaderData<typeof loader>();
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900">Audit-Log</h1>
<p className="text-slate-500 text-sm mt-1">{total} Einträge insgesamt</p>
</div>
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden shadow-sm">
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "1px solid #e2e8f0", background: "#f8fafc" }}>
<th className="text-left px-5 py-3 font-medium text-slate-500">Zeitpunkt</th>
<th className="text-left px-5 py-3 font-medium text-slate-500">Aktion</th>
<th className="text-left px-5 py-3 font-medium text-slate-500">Benutzer</th>
<th className="text-left px-5 py-3 font-medium text-slate-500">Objekt</th>
<th className="text-left px-5 py-3 font-medium text-slate-500">IP</th>
</tr>
</thead>
<tbody>
{logs.length === 0 && (
<tr>
<td colSpan={5} className="px-5 py-8 text-center text-slate-400">
Noch keine Einträge vorhanden.
</td>
</tr>
)}
{logs.map((log, i) => (
<tr
key={log.id}
style={{ borderBottom: i < logs.length - 1 ? "1px solid #f1f5f9" : "none" }}
>
<td className="px-5 py-3 text-slate-400 text-xs whitespace-nowrap">
{new Date(log.createdAt).toLocaleString("de-DE")}
</td>
<td className="px-5 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${ACTION_COLORS[log.action] ?? "bg-slate-100 text-slate-600"}`}
>
{ACTION_LABELS[log.action] ?? log.action}
</span>
</td>
<td className="px-5 py-3 text-slate-600">
{log.userName ? (
<span>
{log.userName}{" "}
<span className="text-slate-400 text-xs">@{log.userUsername}</span>
</span>
) : (
<span className="text-slate-300"></span>
)}
</td>
<td className="px-5 py-3 text-slate-500 text-xs">
{log.entity ? (
<span>
{log.entity}
{log.entityId && (
<span className="text-slate-300"> #{log.entityId.slice(0, 8)}</span>
)}
</span>
) : (
<span className="text-slate-300"></span>
)}
</td>
<td className="px-5 py-3 text-slate-400 text-xs font-mono">
{log.ipAddress ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center gap-2 mt-6">
{page > 1 && (
<a
href={`?page=${page - 1}`}
className="px-3 py-1.5 text-sm rounded-lg border border-slate-200 text-slate-600 hover:bg-slate-50"
>
Zurück
</a>
)}
<span className="px-3 py-1.5 text-sm text-slate-500">
Seite {page} / {totalPages}
</span>
{page < totalPages && (
<a
href={`?page=${page + 1}`}
className="px-3 py-1.5 text-sm rounded-lg border border-slate-200 text-slate-600 hover:bg-slate-50"
>
Weiter
</a>
)}
</div>
)}
</div>
);
}
+214
View File
@@ -0,0 +1,214 @@
import {
Form,
useActionData,
useLoaderData,
useNavigation,
redirect,
Link,
} from "react-router";
import { requireAdmin } from "@/session.server";
import { log } from "@/lib/logger";
import prisma from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { AlertCircle, ArrowLeft, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export async function loader({
request,
params,
}: {
request: Request;
params: { id: string };
}) {
await requireAdmin(request);
const user = await prisma.user.findUnique({
where: { id: params.id },
select: { id: true, name: true, username: true, email: true, role: true },
});
if (!user) throw new Response("Nicht gefunden", { status: 404 });
return { user };
}
export async function action({
request,
params,
}: {
request: Request;
params: { id: string };
}) {
const admin = await requireAdmin(request);
const formData = await request.formData();
const intent = formData.get("intent") as string;
if (intent === "delete") {
if (params.id === admin.id) {
return { error: "Sie können Ihr eigenes Konto nicht löschen." };
}
await prisma.user.delete({ where: { id: params.id } });
await log({
userId: admin.id,
action: "DELETE_USER",
entity: "User",
entityId: params.id,
request,
});
return redirect("/admin/users");
}
// intent === "update"
const name = (formData.get("name") as string).trim();
const username = (formData.get("username") as string).trim().toLowerCase();
const email = (formData.get("email") as string).trim().toLowerCase();
const role = formData.get("role") as "USER" | "ADMIN";
const password = (formData.get("password") as string).trim();
if (!name || !username || !email) {
return { error: "Name, Benutzername und E-Mail sind Pflichtfelder." };
}
const conflict = await prisma.user.findFirst({
where: {
AND: [
{ id: { not: params.id } },
{ OR: [{ email }, { username }] },
],
},
});
if (conflict) {
return { error: "E-Mail oder Benutzername bereits von einem anderen Nutzer vergeben." };
}
const updateData: Record<string, unknown> = {
name,
username,
email,
role: role === "ADMIN" ? "ADMIN" : "USER",
};
if (password) {
if (password.length < 8) {
return { error: "Das Passwort muss mindestens 8 Zeichen lang sein." };
}
updateData.passwordHash = await bcrypt.hash(password, 12);
}
await prisma.user.update({ where: { id: params.id }, data: updateData });
await log({
userId: admin.id,
action: "UPDATE_USER",
entity: "User",
entityId: params.id,
metadata: { name, username, email, role, passwordChanged: !!password },
request,
});
return redirect("/admin/users");
}
export default function AdminUserEditPage() {
const { user } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const loading = navigation.state === "submitting";
return (
<div className="max-w-lg">
<div className="flex items-center gap-3 mb-6">
<Link to="/admin/users" className="text-slate-400 hover:text-slate-600">
<ArrowLeft className="w-4 h-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-slate-900">Benutzer bearbeiten</h1>
<p className="text-slate-500 text-sm mt-0.5">{user.name}</p>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6">
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="update" />
{actionData?.error && (
<div className="flex items-center gap-2.5 rounded-xl bg-red-50 border border-red-100 p-3.5 text-sm text-red-700">
<AlertCircle className="h-4 w-4 shrink-0 text-red-500" />
{actionData.error}
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" type="text" defaultValue={user.name} required />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="username">Benutzername</Label>
<Input id="username" name="username" type="text" defaultValue={user.username} required />
</div>
<div className="space-y-1.5">
<Label htmlFor="email">E-Mail</Label>
<Input id="email" name="email" type="email" defaultValue={user.email} required />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Neues Passwort</Label>
<Input
id="password"
name="password"
type="password"
placeholder="Leer lassen, um Passwort beizubehalten"
minLength={8}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="role">Rolle</Label>
<select
id="role"
name="role"
defaultValue={user.role}
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="USER">Benutzer</option>
<option value="ADMIN">Admin</option>
</select>
</div>
<div className="flex gap-3 pt-2">
<Button type="submit" disabled={loading} className="flex-1">
{loading ? "Speichern..." : "Speichern"}
</Button>
<Link to="/admin/users">
<Button type="button" variant="outline">Abbrechen</Button>
</Link>
</div>
</Form>
</div>
{/* Delete zone */}
<div className="mt-6 bg-white rounded-xl border border-red-100 shadow-sm p-6">
<h2 className="text-sm font-semibold text-red-700 mb-1">Benutzer löschen</h2>
<p className="text-xs text-slate-500 mb-4">
Löscht den Benutzer und alle zugehörigen Firmen, Kunden und Rechnungen unwiderruflich.
</p>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<Button
type="submit"
variant="destructive"
className="flex items-center gap-2 text-sm"
disabled={loading}
onClick={(e) => {
if (!confirm(`Benutzer "${user.name}" wirklich löschen?`)) e.preventDefault();
}}
>
<Trash2 className="w-4 h-4" />
Benutzer löschen
</Button>
</Form>
</div>
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { Form, useActionData, useNavigation, redirect, Link } from "react-router";
import { requireAdmin } from "@/session.server";
import { log } from "@/lib/logger";
import prisma from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { AlertCircle, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export async function loader({ request }: { request: Request }) {
await requireAdmin(request);
return null;
}
export async function action({ request }: { request: Request }) {
const admin = await requireAdmin(request);
const formData = await request.formData();
const name = (formData.get("name") as string).trim();
const username = (formData.get("username") as string).trim().toLowerCase();
const email = (formData.get("email") as string).trim().toLowerCase();
const password = formData.get("password") as string;
const role = formData.get("role") as "USER" | "ADMIN";
if (!name || !username || !email || !password) {
return { error: "Alle Felder sind Pflichtfelder." };
}
if (password.length < 8) {
return { error: "Das Passwort muss mindestens 8 Zeichen lang sein." };
}
const existing = await prisma.user.findFirst({
where: { OR: [{ email }, { username }] },
});
if (existing) {
return { error: "E-Mail oder Benutzername bereits vergeben." };
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { name, username, email, passwordHash, role: role === "ADMIN" ? "ADMIN" : "USER" },
});
await log({
userId: admin.id,
action: "CREATE_USER",
entity: "User",
entityId: user.id,
metadata: { name, username, email, role },
request,
});
return redirect("/admin/users");
}
export default function AdminUsersNewPage() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const loading = navigation.state === "submitting";
return (
<div className="max-w-lg">
<div className="flex items-center gap-3 mb-6">
<Link to="/admin/users" className="text-slate-400 hover:text-slate-600">
<ArrowLeft className="w-4 h-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-slate-900">Neuer Benutzer</h1>
<p className="text-slate-500 text-sm mt-0.5">Zugangsdaten und Rolle festlegen</p>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6">
<Form method="post" className="space-y-4">
{actionData?.error && (
<div className="flex items-center gap-2.5 rounded-xl bg-red-50 border border-red-100 p-3.5 text-sm text-red-700">
<AlertCircle className="h-4 w-4 shrink-0 text-red-500" />
{actionData.error}
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" type="text" placeholder="Anna Musterfrau" required />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="username">Benutzername</Label>
<Input id="username" name="username" type="text" placeholder="anna" required />
</div>
<div className="space-y-1.5">
<Label htmlFor="email">E-Mail</Label>
<Input id="email" name="email" type="email" placeholder="anna@example.de" required />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Passwort</Label>
<Input id="password" name="password" type="password" required minLength={8} />
<p className="text-xs text-slate-400">Mindestens 8 Zeichen</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="role">Rolle</Label>
<select
id="role"
name="role"
defaultValue="USER"
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="USER">Benutzer</option>
<option value="ADMIN">Admin</option>
</select>
</div>
<div className="flex gap-3 pt-2">
<Button type="submit" disabled={loading} className="flex-1">
{loading ? "Erstellen..." : "Benutzer erstellen"}
</Button>
<Link to="/admin/users">
<Button type="button" variant="outline">Abbrechen</Button>
</Link>
</div>
</Form>
</div>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { Link, useLoaderData } from "react-router";
import { requireAdmin } from "@/session.server";
import prisma from "@/lib/prisma";
import { UserPlus, Shield, User } from "lucide-react";
export async function loader({ request }: { request: Request }) {
await requireAdmin(request);
const users = await prisma.user.findMany({
orderBy: { createdAt: "asc" },
select: {
id: true,
email: true,
username: true,
name: true,
role: true,
createdAt: true,
_count: { select: { companies: true } },
},
});
return {
users: users.map((u) => ({ ...u, createdAt: u.createdAt.toISOString() })),
};
}
export default function AdminUsersPage() {
const { users } = useLoaderData<typeof loader>();
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Benutzerverwaltung</h1>
<p className="text-slate-500 text-sm mt-1">{users.length} Benutzer registriert</p>
</div>
<Link
to="/admin/users/new"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white"
style={{ background: "linear-gradient(135deg, #6366f1, #7c3aed)" }}
>
<UserPlus className="w-4 h-4" />
Neuer Benutzer
</Link>
</div>
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden shadow-sm">
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "1px solid #e2e8f0", background: "#f8fafc" }}>
<th className="text-left px-5 py-3 font-medium text-slate-500">Name</th>
<th className="text-left px-5 py-3 font-medium text-slate-500">Benutzername</th>
<th className="text-left px-5 py-3 font-medium text-slate-500">E-Mail</th>
<th className="text-left px-5 py-3 font-medium text-slate-500">Rolle</th>
<th className="text-left px-5 py-3 font-medium text-slate-500">Firmen</th>
<th className="text-left px-5 py-3 font-medium text-slate-500">Erstellt</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody>
{users.map((user, i) => (
<tr
key={user.id}
style={{ borderBottom: i < users.length - 1 ? "1px solid #f1f5f9" : "none" }}
>
<td className="px-5 py-3.5 font-medium text-slate-800">{user.name}</td>
<td className="px-5 py-3.5 text-slate-600 font-mono text-xs">{user.username}</td>
<td className="px-5 py-3.5 text-slate-600">{user.email}</td>
<td className="px-5 py-3.5">
{user.role === "ADMIN" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-700">
<Shield className="w-3 h-3" /> Admin
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-600">
<User className="w-3 h-3" /> Benutzer
</span>
)}
</td>
<td className="px-5 py-3.5 text-slate-600">{user._count.companies}</td>
<td className="px-5 py-3.5 text-slate-400 text-xs">
{new Date(user.createdAt).toLocaleDateString("de-DE")}
</td>
<td className="px-5 py-3.5 text-right">
<Link
to={`/admin/users/${user.id}`}
className="text-indigo-600 hover:text-indigo-800 text-xs font-medium"
>
Bearbeiten
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+13
View File
@@ -43,6 +43,19 @@ export async function action({ request, params }: { request: Request; params: {
return Response.json({ ok: true });
}
if (request.method === "PATCH") {
const body = await request.json();
const archive = body.archived === true;
await prisma.company.update({
where: { id: params.id },
data: {
archived: archive,
archivedAt: archive ? new Date() : null,
},
});
return Response.json({ ok: true });
}
// PUT
const body = await request.json();
const parsed = companySchema.safeParse(body);
+8
View File
@@ -30,6 +30,14 @@ export async function action({ request, params }: { request: Request; params: {
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
const isAdmin = user.role === "ADMIN";
const deletableStatuses: InvoiceStatus[] = [InvoiceStatus.DRAFT, InvoiceStatus.CANCELLED];
if (!isAdmin && !deletableStatuses.includes(invoice.status)) {
return Response.json(
{ error: "Nur Entwürfe und stornierte Rechnungen können gelöscht werden." },
{ status: 403 }
);
}
await prisma.invoice.delete({ where: { id: params.id } });
return Response.json({ ok: true });
}
+138
View File
@@ -0,0 +1,138 @@
import { Link, useLoaderData } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { formatCurrency } from "@/lib/tax";
import { Archive, Building2, FileText, Users, ArchiveRestore } from "lucide-react";
import { useRevalidator } from "react-router";
export const handle = {
breadcrumbs: () => [{ label: "Archiv" }],
};
export async function loader({ request }: { request: Request }) {
const user = await requireUser(request);
const companies = await prisma.company.findMany({
where: { userId: user.id, archived: true },
include: {
_count: { select: { invoices: true, customers: true } },
invoices: {
where: { status: "PAID" },
select: { grossTotal: true },
},
},
orderBy: { archivedAt: "desc" },
});
return {
isAdmin: user.role === "ADMIN",
companies: companies.map((c) => ({
...c,
archivedAt: c.archivedAt?.toISOString() ?? null,
revenue: c.invoices.reduce((s, inv) => s + Number(inv.grossTotal), 0),
invoices: undefined,
})),
};
}
export default function ArchivPage() {
const { companies, isAdmin } = useLoaderData<typeof loader>();
const { revalidate } = useRevalidator();
async function restore(id: string) {
if (!confirm("Archivierung aufheben?")) return;
await fetch(`/api/companies/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ archived: false }),
});
revalidate();
}
return (
<div className="animate-fade-in">
<div className="mb-8">
<div className="flex items-center gap-3 mb-1">
<Archive className="h-6 w-6 text-slate-400" />
<h1 className="text-2xl font-bold text-slate-900">Archiv</h1>
</div>
<p className="text-slate-500 text-sm mt-1">
{companies.length === 0
? "Keine archivierten Mandanten"
: `${companies.length} archivierte ${companies.length === 1 ? "Mandant" : "Mandanten"}`}
</p>
</div>
{companies.length === 0 ? (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm py-16 text-center">
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-100 mx-auto mb-4">
<Archive className="h-7 w-7 text-slate-300" />
</div>
<p className="text-slate-500 font-medium mb-1">Archiv ist leer</p>
<p className="text-slate-400 text-sm">Archivierte Mandanten erscheinen hier.</p>
</div>
) : (
<div className="space-y-3">
{companies.map((company) => (
<div
key={company.id}
className="bg-white rounded-xl border border-slate-200 shadow-sm p-5 flex items-center gap-4"
>
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-slate-100 shrink-0">
<Building2 className="h-5 w-5 text-slate-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Link
to={`/companies/${company.id}`}
className="font-semibold text-slate-700 hover:text-indigo-600 transition-colors text-sm truncate"
>
{company.name}
</Link>
{company.legalForm && (
<span className="text-xs text-slate-400">{company.legalForm}</span>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-xs text-slate-400">
<span className="flex items-center gap-1">
<FileText className="h-3 w-3" />
{company._count.invoices} Rechnungen
</span>
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{company._count.customers} Kunden
</span>
<span>Umsatz (bezahlt): {formatCurrency(company.revenue)}</span>
{company.archivedAt && (
<span>
Archiviert: {new Date(company.archivedAt).toLocaleDateString("de-DE")}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Link
to={`/companies/${company.id}`}
className="text-xs text-slate-500 hover:text-slate-700 px-3 py-1.5 rounded-lg border border-slate-200 hover:bg-slate-50 transition-colors"
>
Öffnen
</Link>
{isAdmin && (
<button
onClick={() => restore(company.id)}
className="flex items-center gap-1.5 text-xs text-indigo-600 hover:text-indigo-800 px-3 py-1.5 rounded-lg border border-indigo-100 hover:bg-indigo-50 transition-colors"
>
<ArchiveRestore className="h-3.5 w-3.5" />
Wiederherstellen
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
@@ -15,7 +15,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
import { formatCurrency, formatDate } from "@/lib/tax";
import { ChevronLeft, Download, CheckCircle, Send, Trash2 } from "lucide-react";
import { ChevronLeft, Download, CheckCircle, Send, Trash2, RotateCcw } from "lucide-react";
import { InvoiceStatus } from "@prisma/client";
export async function loader({
@@ -40,6 +40,7 @@ export async function loader({
if (!invoice) throw new Response("Not Found", { status: 404 });
return {
isAdmin: user.role === "ADMIN",
invoice: {
...invoice,
netTotal: Number(invoice.netTotal),
@@ -63,7 +64,7 @@ export async function loader({
}
export default function InvoiceDetailPage() {
const { invoice } = useLoaderData<typeof loader>();
const { invoice, isAdmin } = useLoaderData<typeof loader>();
const navigate = useNavigate();
const { revalidate } = useRevalidator();
const [loading, setLoading] = useState(false);
@@ -91,8 +92,31 @@ export default function InvoiceDetailPage() {
revalidate();
}
async function handleDelete() {
if (!confirm("Rechnung wirklich löschen?")) return;
async function handleSoftDelete() {
if (!confirm("Rechnung in den Papierkorb verschieben?")) return;
setLoading(true);
await fetch(`/api/invoices/${invoice.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "DELETED" }),
});
setLoading(false);
revalidate();
}
async function handleRestore() {
setLoading(true);
await fetch(`/api/invoices/${invoice.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "CANCELLED" }),
});
setLoading(false);
revalidate();
}
async function handleHardDelete() {
if (!confirm("Rechnung endgültig löschen? Dies kann nicht rückgängig gemacht werden.")) return;
await fetch(`/api/invoices/${invoice.id}`, { method: "DELETE" });
navigate(`/companies/${id}/invoices`);
}
@@ -131,9 +155,11 @@ export default function InvoiceDetailPage() {
{/* Invoice Actions */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={downloadPdf}>
<Download className="h-4 w-4" /> PDF
</Button>
{invoice.status !== "DELETED" && (
<Button variant="outline" size="sm" onClick={downloadPdf}>
<Download className="h-4 w-4" /> PDF
</Button>
)}
{invoice.status === "DRAFT" && (
<Button size="sm" onClick={() => updateStatus(InvoiceStatus.SENT)} disabled={loading}>
<Send className="h-4 w-4" /> Als versendet markieren
@@ -149,16 +175,46 @@ export default function InvoiceDetailPage() {
<CheckCircle className="h-4 w-4" /> Als bezahlt markieren
</Button>
)}
{(invoice.status === "DRAFT" || invoice.status === "CANCELLED") && (
{/* Soft-Delete für alle nicht-gelöschten Status */}
{invoice.status !== "DELETED" && (
<Button
variant="outline"
size="sm"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={handleDelete}
onClick={handleSoftDelete}
disabled={loading}
title="In Papierkorb verschieben"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
{/* Gelöschte Rechnung: Wiederherstellen + endgültig löschen (Admin) */}
{invoice.status === "DELETED" && (
<>
<Button
variant="outline"
size="sm"
className="text-slate-600 hover:text-slate-800"
onClick={handleRestore}
disabled={loading}
title="Als Storniert wiederherstellen"
>
<RotateCcw className="h-4 w-4" /> Wiederherstellen
</Button>
{isAdmin && (
<Button
variant="outline"
size="sm"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={handleHardDelete}
disabled={loading}
title="Endgültig löschen"
>
<Trash2 className="h-4 w-4" /> Endgültig löschen
</Button>
)}
</>
)}
</div>
</div>
+164 -40
View File
@@ -10,10 +10,11 @@ export const handle = {
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Card } from "@/components/ui/card";
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
import { formatCurrency, formatDate } from "@/lib/tax";
import { Plus, FileText, ChevronLeft } from "lucide-react";
import { Plus, FileText, ChevronLeft, ChevronDown, ChevronRight, Trash2 } from "lucide-react";
import { useState } from "react";
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
@@ -41,23 +42,160 @@ export async function loader({ request, params }: { request: Request; params: {
};
}
type InvoiceRow = ReturnType<typeof useLoaderData<typeof loader>>["invoices"][number];
function groupByYear(invoices: InvoiceRow[]): Map<number, InvoiceRow[]> {
const map = new Map<number, InvoiceRow[]>();
for (const inv of invoices) {
const year = new Date(inv.issueDate).getFullYear();
if (!map.has(year)) map.set(year, []);
map.get(year)!.push(inv);
}
return map;
}
function InvoiceRow({ invoice, companyId }: { invoice: InvoiceRow; companyId: string }) {
return (
<Link
to={`/companies/${companyId}/invoices/${invoice.id}`}
className="flex items-center justify-between px-5 py-3.5 hover:bg-slate-50 transition-colors group"
>
<div className="flex items-center gap-4">
<div className="p-1.5 rounded-lg bg-slate-100 group-hover:bg-indigo-50 transition-colors">
<FileText className="h-3.5 w-3.5 text-slate-400 group-hover:text-indigo-500 transition-colors" />
</div>
<div>
<p className="font-medium text-slate-800 text-sm">{invoice.number}</p>
<p className="text-xs text-slate-400">{invoice.customer.name}</p>
</div>
</div>
<div className="flex items-center gap-5">
<p className="text-sm text-slate-400 hidden sm:block">{formatDate(invoice.issueDate)}</p>
<InvoiceStatusBadge status={invoice.status} />
<p className="font-medium text-slate-700 w-24 text-right text-sm">
{formatCurrency(invoice.grossTotal)}
</p>
</div>
</Link>
);
}
function YearPanel({
year,
invoices,
companyId,
defaultOpen,
}: {
year: number;
invoices: InvoiceRow[];
companyId: string;
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const totalGross = invoices.reduce((s, i) => s + i.grossTotal, 0);
return (
<div className="border border-slate-200 rounded-xl overflow-hidden bg-white shadow-sm">
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-5 py-3.5 text-left hover:bg-slate-50 transition-colors"
aria-expanded={open}
>
<div className="flex items-center gap-3">
{open ? (
<ChevronDown className="w-4 h-4 text-slate-400 shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-slate-400 shrink-0" />
)}
<span className="font-semibold text-slate-800">{year}</span>
<span className="text-sm text-slate-400">
{invoices.length} {invoices.length === 1 ? "Rechnung" : "Rechnungen"}
</span>
</div>
<span className="text-sm font-medium text-slate-600">{formatCurrency(totalGross)}</span>
</button>
{open && (
<div className="divide-y divide-slate-100 border-t border-slate-100">
{invoices.map((invoice) => (
<InvoiceRow key={invoice.id} invoice={invoice} companyId={companyId} />
))}
</div>
)}
</div>
);
}
function DeletedPanel({
invoices,
companyId,
}: {
invoices: InvoiceRow[];
companyId: string;
}) {
const [open, setOpen] = useState(false);
return (
<div className="border border-red-100 rounded-xl overflow-hidden bg-white shadow-sm">
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-5 py-3.5 text-left hover:bg-red-50/50 transition-colors"
aria-expanded={open}
>
<div className="flex items-center gap-3">
{open ? (
<ChevronDown className="w-4 h-4 text-red-300 shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-red-300 shrink-0" />
)}
<Trash2 className="w-3.5 h-3.5 text-red-400" />
<span className="font-medium text-red-700 text-sm">Papierkorb</span>
<span className="text-sm text-red-300">
{invoices.length} {invoices.length === 1 ? "Rechnung" : "Rechnungen"}
</span>
</div>
</button>
{open && (
<div className="divide-y divide-red-50 border-t border-red-100">
{invoices.map((invoice) => (
<InvoiceRow key={invoice.id} invoice={invoice} companyId={companyId} />
))}
</div>
)}
</div>
);
}
export default function InvoicesPage() {
const { company, invoices } = useLoaderData<typeof loader>();
const id = company.id;
const currentYear = new Date().getFullYear();
const activeInvoices = invoices.filter((i) => i.status !== "DELETED");
const deletedInvoices = invoices.filter((i) => i.status === "DELETED");
const byYear = groupByYear(activeInvoices);
const years = Array.from(byYear.keys()).sort((a, b) => b - a);
return (
<div>
<Link
to={`/companies/${id}`}
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700 mb-6"
>
<ChevronLeft className="h-4 w-4" /> {company.name}
</Link>
<div className="flex items-center justify-between mb-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Rechnungen</h1>
<p className="text-gray-500 mt-1">{invoices.length} Rechnungen für {company.name}</p>
<h1 className="text-2xl font-bold text-slate-900">Rechnungen</h1>
<p className="text-slate-500 mt-1 text-sm">
{activeInvoices.length} Rechnungen für {company.name}
{deletedInvoices.length > 0 && (
<span className="text-red-400"> · {deletedInvoices.length} im Papierkorb</span>
)}
</p>
</div>
<Button asChild>
<Link to={`/companies/${id}/invoices/new`}>
@@ -66,48 +204,34 @@ export default function InvoicesPage() {
</Button>
</div>
{invoices.length === 0 ? (
{activeInvoices.length === 0 && deletedInvoices.length === 0 ? (
<Card>
<CardContent className="py-16 text-center">
<FileText className="h-12 w-12 text-gray-300 mx-auto mb-4" />
<h3 className="font-semibold text-gray-700 mb-2">Noch keine Rechnungen</h3>
<p className="text-gray-500 mb-6 text-sm">Erstellen Sie die erste Rechnung für diesen Mandanten.</p>
<div className="py-16 text-center">
<FileText className="h-12 w-12 text-slate-200 mx-auto mb-4" />
<h3 className="font-semibold text-slate-700 mb-2">Noch keine Rechnungen</h3>
<p className="text-slate-500 mb-6 text-sm">Erstellen Sie die erste Rechnung für diesen Mandanten.</p>
<Button asChild>
<Link to={`/companies/${id}/invoices/new`}>
<Plus className="h-4 w-4" /> Erste Rechnung erstellen
</Link>
</Button>
</CardContent>
</Card>
) : (
<Card>
<div className="divide-y divide-gray-100">
{invoices.map((invoice) => (
<Link
key={invoice.id}
to={`/companies/${id}/invoices/${invoice.id}`}
className="flex items-center justify-between px-6 py-4 hover:bg-gray-50 transition-colors group"
>
<div className="flex items-center gap-4">
<div className="p-2 rounded-lg bg-gray-100 group-hover:bg-indigo-50 transition-colors">
<FileText className="h-4 w-4 text-gray-500 group-hover:text-indigo-600 transition-colors" />
</div>
<div>
<p className="font-medium text-gray-900">{invoice.number}</p>
<p className="text-sm text-gray-500">{invoice.customer.name}</p>
</div>
</div>
<div className="flex items-center gap-6">
<p className="text-sm text-gray-500">{formatDate(invoice.issueDate)}</p>
<InvoiceStatusBadge status={invoice.status} />
<p className="font-medium text-gray-900 w-28 text-right">
{formatCurrency(invoice.grossTotal)}
</p>
</div>
</Link>
))}
</div>
</Card>
) : (
<div className="space-y-3">
{years.map((year) => (
<YearPanel
key={year}
year={year}
invoices={byYear.get(year)!}
companyId={id}
defaultOpen={year === currentYear}
/>
))}
{deletedInvoices.length > 0 && (
<DeletedPanel invoices={deletedInvoices} companyId={id} />
)}
</div>
)}
</div>
);
+69 -13
View File
@@ -1,4 +1,4 @@
import { Link, useLoaderData } from "react-router";
import { Link, useLoaderData, useRevalidator } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { Button } from "@/components/ui/button";
@@ -7,9 +7,10 @@ import { Badge } from "@/components/ui/badge";
import { formatCurrency, formatDate } from "@/lib/tax";
import {
FileText, Users, BarChart3, Plus, Edit, Building2,
Mail, Phone, CreditCard, Receipt
Mail, Phone, CreditCard, Receipt, Archive, ArchiveRestore, AlertTriangle
} from "lucide-react";
import { InvoiceStatus } from "@prisma/client";
import { useState } from "react";
export const handle = {
breadcrumbs: (data: { company: { id: string; name: string } }) => [
@@ -23,13 +24,15 @@ const statusLabels: Record<InvoiceStatus, string> = {
SENT: "Versendet",
PAID: "Bezahlt",
CANCELLED: "Storniert",
DELETED: "Gelöscht",
};
const statusVariants: Record<InvoiceStatus, "secondary" | "default" | "success" | "destructive" | "warning"> = {
const statusVariants: Record<InvoiceStatus, "secondary" | "default" | "success" | "destructive" | "warning" | "outline"> = {
DRAFT: "secondary",
SENT: "warning",
PAID: "success",
CANCELLED: "destructive",
DELETED: "outline",
};
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
@@ -56,8 +59,10 @@ export async function loader({ request, params }: { request: Request; params: {
});
return {
isAdmin: user.role === "ADMIN",
company: {
...company,
archivedAt: company.archivedAt?.toISOString() ?? null,
invoices: company.invoices.map((inv) => ({
...inv,
grossTotal: Number(inv.grossTotal),
@@ -70,30 +75,81 @@ export async function loader({ request, params }: { request: Request; params: {
}
export default function CompanyPage() {
const { company, revenue } = useLoaderData<typeof loader>();
const { company, revenue, isAdmin } = useLoaderData<typeof loader>();
const { revalidate } = useRevalidator();
const [archiving, setArchiving] = useState(false);
const id = company.id;
async function toggleArchive() {
const action = company.archived ? "Archivierung aufheben?" : "Mandanten archivieren?";
if (!confirm(action)) return;
setArchiving(true);
await fetch(`/api/companies/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ archived: !company.archived }),
});
setArchiving(false);
revalidate();
}
return (
<div>
{/* Archiv-Banner */}
{company.archived && (
<div className="flex items-center gap-3 mb-6 px-4 py-3 rounded-xl bg-amber-50 border border-amber-200 text-amber-800 text-sm">
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />
<span>
Dieser Mandant ist archiviert
{company.archivedAt && ` seit ${new Date(company.archivedAt).toLocaleDateString("de-DE")}`}.
</span>
{isAdmin && (
<button
onClick={toggleArchive}
disabled={archiving}
className="ml-auto text-amber-700 underline hover:text-amber-900 text-xs"
>
Archivierung aufheben
</button>
)}
</div>
)}
<div className="flex items-start justify-between mb-8">
<div className="flex items-start gap-4">
<div className="p-3 rounded-xl bg-indigo-50">
<Building2 className="h-6 w-6 text-indigo-600" />
<div className={`p-3 rounded-xl ${company.archived ? "bg-slate-100" : "bg-indigo-50"}`}>
<Building2 className={`h-6 w-6 ${company.archived ? "text-slate-400" : "text-indigo-600"}`} />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">{company.name}</h1>
<h1 className={`text-2xl font-bold ${company.archived ? "text-slate-500" : "text-gray-900"}`}>
{company.name}
</h1>
<p className="text-gray-500 mt-0.5">
{company.legalForm && `${company.legalForm} · `}
{company.zip} {company.city}
</p>
</div>
</div>
<Button variant="outline" asChild>
<Link to={`/companies/${id}/edit`}>
<Edit className="h-4 w-4" />
Bearbeiten
</Link>
</Button>
<div className="flex items-center gap-2">
{isAdmin && !company.archived && (
<Button variant="outline" onClick={toggleArchive} disabled={archiving}>
<Archive className="h-4 w-4" />
Archivieren
</Button>
)}
{isAdmin && company.archived && (
<Button variant="outline" onClick={toggleArchive} disabled={archiving}>
<ArchiveRestore className="h-4 w-4" />
Wiederherstellen
</Button>
)}
<Button variant="outline" asChild>
<Link to={`/companies/${id}/edit`}>
<Edit className="h-4 w-4" />
Bearbeiten
</Link>
</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
+104 -42
View File
@@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge";
import { formatCurrency, formatDate } from "@/lib/tax";
import {
Building2, Plus, FileText, Users, X, Edit, Receipt,
Mail, Phone, CreditCard, ChevronRight,
Mail, Phone, CreditCard, ChevronRight, ChevronDown, Archive,
} from "lucide-react";
import { InvoiceStatus } from "@prisma/client";
@@ -20,16 +20,18 @@ const statusLabels: Record<InvoiceStatus, string> = {
SENT: "Versendet",
PAID: "Bezahlt",
CANCELLED: "Storniert",
DELETED: "Gelöscht",
};
const statusVariants: Record<
InvoiceStatus,
"secondary" | "default" | "success" | "destructive" | "warning"
"secondary" | "default" | "success" | "destructive" | "warning" | "outline"
> = {
DRAFT: "secondary",
SENT: "warning",
PAID: "success",
CANCELLED: "destructive",
DELETED: "outline",
};
export async function loader({ request }: { request: Request }) {
@@ -58,11 +60,69 @@ export async function loader({ request }: { request: Request }) {
};
}
type Company = ReturnType<typeof useLoaderData<typeof loader>>["companies"][number];
function CompanyCard({
company,
isActive,
onSelect,
}: {
company: Company;
isActive: boolean;
onSelect: () => void;
}) {
const archived = company.archived;
return (
<button
type="button"
onClick={onSelect}
className={`text-left rounded-2xl border shadow-sm p-5 transition-all duration-200 cursor-pointer w-full ${
archived
? "bg-slate-50 border-slate-200 opacity-70 hover:opacity-100"
: isActive
? "bg-white border-indigo-400 ring-2 ring-indigo-100"
: "bg-white border-slate-200 hover:border-indigo-200 hover:shadow-md"
}`}
>
<div className="flex items-start gap-3 mb-4">
<div className={`flex items-center justify-center w-10 h-10 rounded-xl shrink-0 ${archived ? "bg-slate-100" : "bg-indigo-50"}`}>
{archived
? <Archive className="h-5 w-5 text-slate-400" />
: <Building2 className="h-5 w-5 text-indigo-600" />}
</div>
<div className="min-w-0">
<p className={`font-semibold text-sm truncate ${archived ? "text-slate-500" : "text-slate-900"}`}>{company.name}</p>
{company.legalForm && (
<p className="text-xs text-slate-400 mt-0.5">{company.legalForm}</p>
)}
</div>
</div>
<div className="flex gap-4 text-xs text-slate-500">
<span className="flex items-center gap-1.5">
<FileText className="h-3.5 w-3.5" />
{company._count.invoices} Rechnungen
</span>
<span className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" />
{company._count.customers} Kunden
</span>
</div>
{company.city && (
<p className="text-xs text-slate-400 mt-2">{company.zip} {company.city}</p>
)}
</button>
);
}
export default function CompaniesPage() {
const { companies } = useLoaderData<typeof loader>();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [archivedOpen, setArchivedOpen] = useState(false);
const selected = companies.find((c) => c.id === selectedId) ?? null;
const active = companies.filter((c) => !c.archived);
const archived = companies.filter((c) => c.archived);
return (
<div className="animate-fade-in flex gap-6">
{/* Kacheln */}
@@ -70,7 +130,10 @@ export default function CompaniesPage() {
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-slate-900">Mandanten</h1>
<p className="text-slate-500 mt-1 text-sm">{companies.length} Mandanten verwaltet</p>
<p className="text-slate-500 mt-1 text-sm">
{active.length} aktive Mandanten
{archived.length > 0 && <span className="text-slate-400"> · {archived.length} archiviert</span>}
</p>
</div>
<Button asChild>
<Link to="/companies/new">
@@ -80,7 +143,7 @@ export default function CompaniesPage() {
</Button>
</div>
{companies.length === 0 ? (
{active.length === 0 && archived.length === 0 ? (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm py-16 text-center">
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-100 mx-auto mb-4">
<Building2 className="h-7 w-7 text-slate-400" />
@@ -95,47 +158,46 @@ export default function CompaniesPage() {
</Button>
</div>
) : (
<div className={`grid gap-4 ${selected ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1 md:grid-cols-2 xl:grid-cols-3"}`}>
{companies.map((company) => {
const isActive = selectedId === company.id;
return (
<div className="space-y-6">
{/* Aktive Mandanten */}
{active.length > 0 && (
<div className={`grid gap-4 ${selected ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1 md:grid-cols-2 xl:grid-cols-3"}`}>
{active.map((company) => (
<CompanyCard
key={company.id}
company={company}
isActive={selectedId === company.id}
onSelect={() => setSelectedId(selectedId === company.id ? null : company.id)}
/>
))}
</div>
)}
{/* Archivierte Mandanten */}
{archived.length > 0 && (
<div>
<button
key={company.id}
type="button"
onClick={() => setSelectedId(isActive ? null : company.id)}
className={`text-left bg-white rounded-2xl border shadow-sm p-5 hover:shadow-md transition-all duration-200 cursor-pointer w-full ${
isActive
? "border-indigo-400 ring-2 ring-indigo-100"
: "border-slate-200 hover:border-indigo-200"
}`}
onClick={() => setArchivedOpen((v) => !v)}
className="flex items-center gap-2 text-sm text-slate-500 hover:text-slate-700 mb-3 transition-colors"
>
<div className="flex items-start gap-3 mb-4">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-indigo-50 shrink-0">
<Building2 className="h-5 w-5 text-indigo-600" />
</div>
<div className="min-w-0">
<p className="font-semibold text-slate-900 text-sm truncate">{company.name}</p>
{company.legalForm && (
<p className="text-xs text-slate-400 mt-0.5">{company.legalForm}</p>
)}
</div>
</div>
<div className="flex gap-4 text-xs text-slate-500">
<span className="flex items-center gap-1.5">
<FileText className="h-3.5 w-3.5" />
{company._count.invoices} Rechnungen
</span>
<span className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" />
{company._count.customers} Kunden
</span>
</div>
{company.city && (
<p className="text-xs text-slate-400 mt-2">{company.zip} {company.city}</p>
)}
{archivedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<Archive className="h-3.5 w-3.5" />
Archivierte Mandanten ({archived.length})
</button>
);
})}
{archivedOpen && (
<div className={`grid gap-4 ${selected ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1 md:grid-cols-2 xl:grid-cols-3"}`}>
{archived.map((company) => (
<CompanyCard
key={company.id}
company={company}
isActive={selectedId === company.id}
onSelect={() => setSelectedId(selectedId === company.id ? null : company.id)}
/>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
+3 -3
View File
@@ -4,15 +4,15 @@ import { Topbar } from "@/components/layout/topbar";
export async function loader({ request }: { request: Request }) {
const user = await requireUser(request);
return { userName: user.name };
return { userName: user.name, userRole: user.role };
}
export default function DashboardLayout() {
const { userName } = useLoaderData<typeof loader>();
const { userName, userRole } = useLoaderData<typeof loader>();
return (
<div className="min-h-screen bg-slate-50 flex flex-col">
<Topbar userName={userName} />
<Topbar userName={userName} userRole={userRole} />
<main className="flex-1">
<div className="max-w-7xl mx-auto px-6 py-8">
<Outlet />
+4 -4
View File
@@ -16,21 +16,21 @@ export async function loader({ request }: { request: Request }) {
const [companies, invoiceStats, paidTotal, openInvoices] = await Promise.all([
prisma.company.findMany({
where: { userId },
where: { userId, archived: false },
include: { _count: { select: { invoices: true, customers: true } } },
orderBy: { name: "asc" },
}),
prisma.invoice.aggregate({
where: { company: { userId } },
where: { company: { userId, archived: false } },
_count: true,
_sum: { grossTotal: true },
}),
prisma.invoice.aggregate({
where: { company: { userId }, status: InvoiceStatus.PAID },
where: { company: { userId, archived: false }, status: InvoiceStatus.PAID },
_sum: { grossTotal: true },
}),
prisma.invoice.count({
where: { company: { userId }, status: { in: [InvoiceStatus.SENT, InvoiceStatus.DRAFT] } },
where: { company: { userId, archived: false }, status: { in: [InvoiceStatus.SENT, InvoiceStatus.DRAFT] } },
}),
]);
+11 -11
View File
@@ -13,13 +13,13 @@ export async function loader({ request }: { request: Request }) {
export async function action({ request }: { request: Request }) {
const formData = await request.formData();
const email = formData.get("email") as string;
const identifier = formData.get("identifier") as string;
const password = formData.get("password") as string;
const user = await login(email, password);
if (!user) return { error: "E-Mail oder Passwort falsch." };
const user = await login(identifier, password, request);
if (!user) return { error: "Benutzername/E-Mail oder Passwort falsch." };
return createUserSession(user.id, user.name, "/");
return createUserSession(user.id, user.name, user.role, "/");
}
export default function LoginPage() {
@@ -83,7 +83,7 @@ export default function LoginPage() {
<div className="mb-8">
<h1 className="text-2xl font-bold text-slate-900">Willkommen zurück</h1>
<p className="text-slate-500 mt-1 text-sm">Melden Sie sich mit Ihren Zugangsdaten an</p>
<p className="text-slate-500 mt-1 text-sm">Benutzername oder E-Mail und Passwort eingeben</p>
</div>
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-8">
@@ -96,14 +96,14 @@ export default function LoginPage() {
)}
<div className="space-y-1.5">
<Label htmlFor="email">E-Mail</Label>
<Label htmlFor="identifier">Benutzername oder E-Mail</Label>
<Input
id="email"
name="email"
type="email"
placeholder="anna@example.de"
id="identifier"
name="identifier"
type="text"
placeholder="anna oder anna@example.de"
required
autoComplete="email"
autoComplete="username"
/>
</div>