ADD: added admin panel and archiv mandates
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { InvoiceStatus } from "@prisma/client";
|
||||
|
||||
const statusConfig: Record<InvoiceStatus, { label: string; variant: "secondary" | "default" | "success" | "destructive" | "warning" }> = {
|
||||
const statusConfig: Record<InvoiceStatus, { label: string; variant: "secondary" | "default" | "success" | "destructive" | "warning" | "outline" }> = {
|
||||
DRAFT: { label: "Entwurf", variant: "secondary" },
|
||||
SENT: { label: "Versendet", variant: "warning" },
|
||||
PAID: { label: "Bezahlt", variant: "success" },
|
||||
CANCELLED: { label: "Storniert", variant: "destructive" },
|
||||
DELETED: { label: "Gelöscht", variant: "outline" },
|
||||
};
|
||||
|
||||
export function InvoiceStatusBadge({ status }: { status: InvoiceStatus }) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMatches, useLocation, Link, Form } from "react-router";
|
||||
import { ChevronRight, LayoutDashboard, LogOut } from "lucide-react";
|
||||
import { ChevronRight, LayoutDashboard, LogOut, Shield, Archive } from "lucide-react";
|
||||
|
||||
interface Breadcrumb {
|
||||
label: string;
|
||||
@@ -24,7 +24,13 @@ function getInitials(name?: string | null): string {
|
||||
return name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
export function Topbar({ userName }: { userName?: string | null }) {
|
||||
export function Topbar({
|
||||
userName,
|
||||
userRole,
|
||||
}: {
|
||||
userName?: string | null;
|
||||
userRole?: string | null;
|
||||
}) {
|
||||
const matches = useMatches();
|
||||
const location = useLocation();
|
||||
const isOnDashboard = location.pathname === "/";
|
||||
@@ -85,10 +91,31 @@ export function Topbar({ userName }: { userName?: string | null }) {
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Dashboard Button */}
|
||||
{!isOnDashboard && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", flexShrink: 0 }}>
|
||||
{/* Dashboard Button */}
|
||||
{!isOnDashboard && (
|
||||
<Link
|
||||
to="/"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
color: "#64748b",
|
||||
textDecoration: "none",
|
||||
padding: "0.375rem 0.75rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "1px solid #e2e8f0",
|
||||
}}
|
||||
>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Archiv Link */}
|
||||
<Link
|
||||
to="/"
|
||||
to="/archiv"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -99,53 +126,74 @@ export function Topbar({ userName }: { userName?: string | null }) {
|
||||
padding: "0.375rem 0.75rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "1px solid #e2e8f0",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
Dashboard
|
||||
<Archive className="h-4 w-4" />
|
||||
Archiv
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* User + Logout */}
|
||||
{userName && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.625rem", marginLeft: "1rem", flexShrink: 0 }}>
|
||||
<span style={{ fontSize: "0.875rem", color: "#64748b" }}>{userName}</span>
|
||||
<div
|
||||
{/* Admin Link (only for admins) */}
|
||||
{userRole === "ADMIN" && (
|
||||
<Link
|
||||
to="/admin/users"
|
||||
style={{
|
||||
width: "2rem",
|
||||
height: "2rem",
|
||||
borderRadius: "9999px",
|
||||
background: "linear-gradient(135deg, #818cf8, #7c3aed)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#ffffff",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 700,
|
||||
gap: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
color: "#6366f1",
|
||||
textDecoration: "none",
|
||||
padding: "0.375rem 0.75rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "1px solid #e0e7ff",
|
||||
background: "#eef2ff",
|
||||
}}
|
||||
>
|
||||
{getInitials(userName)}
|
||||
</div>
|
||||
<Form method="post" action="/logout">
|
||||
<button
|
||||
type="submit"
|
||||
title="Abmelden"
|
||||
<Shield className="h-4 w-4" />
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* User + Logout */}
|
||||
{userName && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.625rem", marginLeft: "0.25rem" }}>
|
||||
<span style={{ fontSize: "0.875rem", color: "#64748b" }}>{userName}</span>
|
||||
<div
|
||||
style={{
|
||||
width: "2rem",
|
||||
height: "2rem",
|
||||
borderRadius: "9999px",
|
||||
background: "linear-gradient(135deg, #818cf8, #7c3aed)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "#94a3b8",
|
||||
padding: "0.25rem",
|
||||
justifyContent: "center",
|
||||
color: "#ffffff",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<LogOut style={{ width: "1rem", height: "1rem" }} />
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
{getInitials(userName)}
|
||||
</div>
|
||||
<Form method="post" action="/logout">
|
||||
<button
|
||||
type="submit"
|
||||
title="Abmelden"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "#94a3b8",
|
||||
padding: "0.25rem",
|
||||
}}
|
||||
>
|
||||
<LogOut style={{ width: "1rem", height: "1rem" }} />
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export type LogAction =
|
||||
| "LOGIN"
|
||||
| "LOGIN_FAILED"
|
||||
| "LOGOUT"
|
||||
| "CREATE_USER"
|
||||
| "UPDATE_USER"
|
||||
| "DELETE_USER"
|
||||
| "CREATE_COMPANY"
|
||||
| "UPDATE_COMPANY"
|
||||
| "DELETE_COMPANY"
|
||||
| "CREATE_INVOICE"
|
||||
| "UPDATE_INVOICE"
|
||||
| "DELETE_INVOICE";
|
||||
|
||||
export async function log({
|
||||
userId,
|
||||
action,
|
||||
entity,
|
||||
entityId,
|
||||
metadata,
|
||||
request,
|
||||
}: {
|
||||
userId?: string | null;
|
||||
action: LogAction;
|
||||
entity?: string;
|
||||
entityId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
request?: Request;
|
||||
}) {
|
||||
const ipAddress = request
|
||||
? request.headers.get("x-forwarded-for") ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
undefined
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: userId ?? null,
|
||||
action,
|
||||
entity: entity ?? null,
|
||||
entityId: entityId ?? null,
|
||||
metadata: metadata ?? undefined,
|
||||
ipAddress: ipAddress ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Never let logging failures break the app
|
||||
console.error("[AuditLog] Failed to write log entry:", err);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[AUDIT] ${new Date().toISOString()} | ${action}${entity ? ` | ${entity}` : ""}${entityId ? `:${entityId}` : ""}${userId ? ` | user:${userId}` : ""}${ipAddress ? ` | ip:${ipAddress}` : ""}`
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,15 @@ export default [
|
||||
route("companies/:id/invoices/new", "routes/companies.$id.invoices.new.tsx"),
|
||||
route("companies/:id/invoices/:invoiceId", "routes/companies.$id.invoices.$invoiceId.tsx"),
|
||||
route("companies/:id/reports", "routes/companies.$id.reports.tsx"),
|
||||
route("archiv", "routes/archiv.tsx"),
|
||||
]),
|
||||
|
||||
// Admin routes
|
||||
layout("routes/admin-layout.tsx", [
|
||||
route("admin/users", "routes/admin.users.tsx"),
|
||||
route("admin/users/new", "routes/admin.users.new.tsx"),
|
||||
route("admin/users/:id", "routes/admin.users.$id.tsx"),
|
||||
route("admin/logs", "routes/admin.logs.tsx"),
|
||||
]),
|
||||
|
||||
// API resource routes
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
+40
-9
@@ -1,6 +1,7 @@
|
||||
import { createCookieSessionStorage, redirect } from "react-router";
|
||||
import bcrypt from "bcryptjs";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { log } from "@/lib/logger";
|
||||
|
||||
const sessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
@@ -14,22 +15,43 @@ const sessionStorage = createCookieSessionStorage({
|
||||
},
|
||||
});
|
||||
|
||||
export async function login(email: string, password: string) {
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) return null;
|
||||
export async function login(
|
||||
identifier: string,
|
||||
password: string,
|
||||
request?: Request
|
||||
) {
|
||||
// Allow login via email or username
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: identifier }, { username: identifier }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
await log({ action: "LOGIN_FAILED", metadata: { identifier }, request });
|
||||
return null;
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!valid) return null;
|
||||
return { id: user.id, email: user.email, name: user.name };
|
||||
if (!valid) {
|
||||
await log({ action: "LOGIN_FAILED", metadata: { identifier }, request });
|
||||
return null;
|
||||
}
|
||||
|
||||
await log({ userId: user.id, action: "LOGIN", entity: "User", entityId: user.id, request });
|
||||
return { id: user.id, email: user.email, name: user.name, role: user.role };
|
||||
}
|
||||
|
||||
export async function createUserSession(
|
||||
userId: string,
|
||||
userName: string,
|
||||
userRole: string,
|
||||
redirectTo: string
|
||||
) {
|
||||
const session = await sessionStorage.getSession();
|
||||
session.set("userId", userId);
|
||||
session.set("userName", userName);
|
||||
session.set("userRole", userRole);
|
||||
return redirect(redirectTo, {
|
||||
headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
|
||||
});
|
||||
@@ -42,24 +64,33 @@ export async function getUserSession(request: Request) {
|
||||
return {
|
||||
userId: session.get("userId") as string | undefined,
|
||||
userName: session.get("userName") as string | undefined,
|
||||
userRole: session.get("userRole") as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireUser(request: Request) {
|
||||
const { userId, userName } = await getUserSession(request);
|
||||
const { userId, userName, userRole } = await getUserSession(request);
|
||||
if (!userId) throw redirect("/login");
|
||||
return { id: userId, name: userName as string | undefined };
|
||||
return { id: userId, name: userName as string | undefined, role: userRole as string | undefined };
|
||||
}
|
||||
|
||||
export async function requireAdmin(request: Request) {
|
||||
const user = await requireUser(request);
|
||||
if (user.role !== "ADMIN") throw redirect("/");
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getApiUser(request: Request) {
|
||||
const { userId } = await getUserSession(request);
|
||||
return userId ? { id: userId } : null;
|
||||
const { userId, userRole } = await getUserSession(request);
|
||||
return userId ? { id: userId, role: userRole } : null;
|
||||
}
|
||||
|
||||
export async function logout(request: Request) {
|
||||
const session = await sessionStorage.getSession(
|
||||
request.headers.get("Cookie")
|
||||
);
|
||||
const userId = session.get("userId") as string | undefined;
|
||||
await log({ userId, action: "LOGOUT", entity: "User", entityId: userId, request });
|
||||
return redirect("/login", {
|
||||
headers: { "Set-Cookie": await sessionStorage.destroySession(session) },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user