ADD: changed to rect router
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const company = await prisma.company.findFirst({ where: { id: params.id, userId: user.id } });
|
||||
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const customers = await prisma.customer.findMany({
|
||||
where: { companyId: params.id },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return Response.json(customers);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const company = await prisma.company.findFirst({ where: { id: params.id, userId: user.id } });
|
||||
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const invoices = await prisma.invoice.findMany({
|
||||
where: { companyId: params.id },
|
||||
include: { customer: { select: { name: true } } },
|
||||
orderBy: { issueDate: "desc" },
|
||||
});
|
||||
|
||||
return Response.json(invoices);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { z } from "zod";
|
||||
|
||||
const companySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
legalForm: z.string().optional(),
|
||||
taxId: z.string().optional(),
|
||||
vatId: z.string().optional(),
|
||||
address: z.string().min(1),
|
||||
zip: z.string().min(1),
|
||||
city: z.string().min(1),
|
||||
country: z.string().optional(),
|
||||
email: z.string().email().optional().or(z.literal("")),
|
||||
phone: z.string().optional(),
|
||||
website: z.string().optional(),
|
||||
bankIban: z.string().optional(),
|
||||
bankBic: z.string().optional(),
|
||||
bankName: z.string().optional(),
|
||||
invoicePrefix: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const company = await prisma.company.findFirst({ where: { id: params.id, userId: user.id } });
|
||||
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
return Response.json(company);
|
||||
}
|
||||
|
||||
export async function action({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const company = await prisma.company.findFirst({ where: { id: params.id, userId: user.id } });
|
||||
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
if (request.method === "DELETE") {
|
||||
await prisma.company.delete({ where: { id: params.id } });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
// PUT
|
||||
const body = await request.json();
|
||||
const parsed = companySchema.safeParse(body);
|
||||
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||
|
||||
const updated = await prisma.company.update({ where: { id: params.id }, data: parsed.data });
|
||||
return Response.json(updated);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { z } from "zod";
|
||||
|
||||
const companySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
legalForm: z.string().optional(),
|
||||
taxId: z.string().optional(),
|
||||
vatId: z.string().optional(),
|
||||
address: z.string().min(1),
|
||||
zip: z.string().min(1),
|
||||
city: z.string().min(1),
|
||||
country: z.string().optional().default("DE"),
|
||||
email: z.string().email().optional().or(z.literal("")),
|
||||
phone: z.string().optional(),
|
||||
website: z.string().optional(),
|
||||
bankIban: z.string().optional(),
|
||||
bankBic: z.string().optional(),
|
||||
bankName: z.string().optional(),
|
||||
invoicePrefix: z.string().optional().default("RE"),
|
||||
});
|
||||
|
||||
export async function loader({ request }: { request: Request }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const companies = await prisma.company.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { _count: { select: { invoices: true, customers: true } } },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return Response.json(companies);
|
||||
}
|
||||
|
||||
export async function action({ request }: { request: Request }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = companySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const company = await prisma.company.create({
|
||||
data: { ...parsed.data, userId: user.id },
|
||||
});
|
||||
|
||||
return Response.json(company, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { z } from "zod";
|
||||
|
||||
const customerSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
vatId: z.string().optional(),
|
||||
taxId: z.string().optional(),
|
||||
address: z.string().min(1),
|
||||
zip: z.string().min(1),
|
||||
city: z.string().min(1),
|
||||
country: z.string().optional(),
|
||||
email: z.string().email().optional().or(z.literal("")),
|
||||
phone: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const customer = await prisma.customer.findFirst({
|
||||
where: { id: params.id, company: { userId: user.id } },
|
||||
});
|
||||
if (!customer) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
return Response.json(customer);
|
||||
}
|
||||
|
||||
export async function action({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const customer = await prisma.customer.findFirst({
|
||||
where: { id: params.id, company: { userId: user.id } },
|
||||
});
|
||||
if (!customer) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
if (request.method === "DELETE") {
|
||||
await prisma.customer.delete({ where: { id: params.id } });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
// PUT
|
||||
const body = await request.json();
|
||||
const parsed = customerSchema.safeParse(body);
|
||||
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||
|
||||
const updated = await prisma.customer.update({ where: { id: params.id }, data: parsed.data });
|
||||
return Response.json(updated);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { z } from "zod";
|
||||
|
||||
const customerSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
vatId: z.string().optional(),
|
||||
taxId: z.string().optional(),
|
||||
address: z.string().min(1),
|
||||
zip: z.string().min(1),
|
||||
city: z.string().min(1),
|
||||
country: z.string().optional().default("DE"),
|
||||
email: z.string().email().optional().or(z.literal("")),
|
||||
phone: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function action({ request }: { request: Request }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = customerSchema.safeParse(body);
|
||||
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||
|
||||
const company = await prisma.company.findFirst({
|
||||
where: { id: parsed.data.companyId, userId: user.id },
|
||||
});
|
||||
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
|
||||
|
||||
const customer = await prisma.customer.create({ data: parsed.data });
|
||||
return Response.json(customer, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const invoice = await prisma.invoice.findFirst({
|
||||
where: { id: params.id, company: { userId: user.id } },
|
||||
include: {
|
||||
items: { orderBy: { position: "asc" } },
|
||||
customer: true,
|
||||
company: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const { renderToBuffer } = await import("@react-pdf/renderer");
|
||||
const React = (await import("react")).default;
|
||||
const { InvoicePDFDocument } = await import("@/components/invoice/invoice-pdf");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const element = React.createElement(InvoicePDFDocument as any, { invoice }) as any;
|
||||
const buffer = await renderToBuffer(element);
|
||||
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="rechnung-${invoice.number}.pdf"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { InvoiceStatus } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
async function getInvoice(id: string, userId: string) {
|
||||
return prisma.invoice.findFirst({
|
||||
where: { id, company: { userId } },
|
||||
include: { items: { orderBy: { position: "asc" } }, customer: true, company: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const invoice = await getInvoice(params.id, user.id);
|
||||
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
return Response.json(invoice);
|
||||
}
|
||||
|
||||
const statusSchema = z.object({ status: z.nativeEnum(InvoiceStatus) });
|
||||
|
||||
export async function action({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const invoice = await getInvoice(params.id, user.id);
|
||||
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
if (request.method === "DELETE") {
|
||||
await prisma.invoice.delete({ where: { id: params.id } });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
// PATCH
|
||||
const body = await request.json();
|
||||
const parsed = statusSchema.safeParse(body);
|
||||
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||
|
||||
const updated = await prisma.invoice.update({
|
||||
where: { id: params.id },
|
||||
data: { status: parsed.data.status },
|
||||
});
|
||||
return Response.json(updated);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { generateInvoiceNumber } from "@/lib/invoice-number";
|
||||
import { z } from "zod";
|
||||
|
||||
const itemSchema = z.object({
|
||||
position: z.number().int(),
|
||||
description: z.string().min(1),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.string().optional(),
|
||||
unitPrice: z.number(),
|
||||
taxRate: z.number(),
|
||||
netAmount: z.number(),
|
||||
taxAmount: z.number(),
|
||||
grossAmount: z.number(),
|
||||
});
|
||||
|
||||
const invoiceSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
customerId: z.string().min(1),
|
||||
issueDate: z.string(),
|
||||
deliveryDate: z.string().optional(),
|
||||
dueDate: z.string(),
|
||||
notes: z.string().optional(),
|
||||
items: z.array(itemSchema).min(1),
|
||||
netTotal: z.number(),
|
||||
taxTotal: z.number(),
|
||||
grossTotal: z.number(),
|
||||
});
|
||||
|
||||
export async function action({ request }: { request: Request }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = invoiceSchema.safeParse(body);
|
||||
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||
|
||||
const { items, companyId, ...invoiceData } = parsed.data;
|
||||
|
||||
const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
|
||||
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
|
||||
|
||||
const number = await generateInvoiceNumber(companyId);
|
||||
|
||||
const invoice = await prisma.invoice.create({
|
||||
data: {
|
||||
...invoiceData,
|
||||
number,
|
||||
companyId,
|
||||
issueDate: new Date(invoiceData.issueDate),
|
||||
deliveryDate: invoiceData.deliveryDate ? new Date(invoiceData.deliveryDate) : null,
|
||||
dueDate: new Date(invoiceData.dueDate),
|
||||
items: { create: items },
|
||||
},
|
||||
include: { items: true, customer: true },
|
||||
});
|
||||
|
||||
return Response.json(invoice, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { InvoiceStatus } from "@prisma/client";
|
||||
|
||||
export async function loader({ request }: { request: Request }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const companyId = searchParams.get("companyId");
|
||||
const year = parseInt(searchParams.get("year") ?? String(new Date().getFullYear()));
|
||||
|
||||
if (!companyId) return Response.json({ error: "companyId required" }, { status: 400 });
|
||||
|
||||
const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
|
||||
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const invoices = await prisma.invoice.findMany({
|
||||
where: {
|
||||
companyId,
|
||||
status: { in: [InvoiceStatus.PAID, InvoiceStatus.SENT] },
|
||||
issueDate: {
|
||||
gte: new Date(`${year}-01-01`),
|
||||
lt: new Date(`${year + 1}-01-01`),
|
||||
},
|
||||
},
|
||||
include: { items: true, customer: { select: { name: true } } },
|
||||
orderBy: { issueDate: "asc" },
|
||||
});
|
||||
|
||||
const monthly: Record<number, {
|
||||
month: number;
|
||||
invoiceCount: number;
|
||||
netTotal: number;
|
||||
taxTotal: number;
|
||||
grossTotal: number;
|
||||
taxGroups: Record<number, { netAmount: number; taxAmount: number }>;
|
||||
}> = {};
|
||||
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
monthly[m] = { month: m, invoiceCount: 0, netTotal: 0, taxTotal: 0, grossTotal: 0, taxGroups: {} };
|
||||
}
|
||||
|
||||
for (const invoice of invoices) {
|
||||
const month = new Date(invoice.issueDate).getMonth() + 1;
|
||||
const m = monthly[month];
|
||||
m.invoiceCount++;
|
||||
m.netTotal += Number(invoice.netTotal);
|
||||
m.taxTotal += Number(invoice.taxTotal);
|
||||
m.grossTotal += Number(invoice.grossTotal);
|
||||
|
||||
for (const item of invoice.items) {
|
||||
const rate = Number(item.taxRate);
|
||||
if (!m.taxGroups[rate]) m.taxGroups[rate] = { netAmount: 0, taxAmount: 0 };
|
||||
m.taxGroups[rate].netAmount += Number(item.netAmount);
|
||||
m.taxGroups[rate].taxAmount += Number(item.taxAmount);
|
||||
}
|
||||
}
|
||||
|
||||
const quarterly = [1, 2, 3, 4].map((q) => {
|
||||
const months = [q * 3 - 2, q * 3 - 1, q * 3];
|
||||
const data = months.map((m) => monthly[m]);
|
||||
const taxGroups: Record<number, { netAmount: number; taxAmount: number }> = {};
|
||||
|
||||
for (const m of data) {
|
||||
for (const [rate, group] of Object.entries(m.taxGroups)) {
|
||||
const r = Number(rate);
|
||||
if (!taxGroups[r]) taxGroups[r] = { netAmount: 0, taxAmount: 0 };
|
||||
taxGroups[r].netAmount += group.netAmount;
|
||||
taxGroups[r].taxAmount += group.taxAmount;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
quarter: q,
|
||||
invoiceCount: data.reduce((s, m) => s + m.invoiceCount, 0),
|
||||
netTotal: data.reduce((s, m) => s + m.netTotal, 0),
|
||||
taxTotal: data.reduce((s, m) => s + m.taxTotal, 0),
|
||||
grossTotal: data.reduce((s, m) => s + m.grossTotal, 0),
|
||||
taxGroups,
|
||||
};
|
||||
});
|
||||
|
||||
const yearTotal = {
|
||||
invoiceCount: invoices.length,
|
||||
netTotal: invoices.reduce((s, i) => s + Number(i.netTotal), 0),
|
||||
taxTotal: invoices.reduce((s, i) => s + Number(i.taxTotal), 0),
|
||||
grossTotal: invoices.reduce((s, i) => s + Number(i.grossTotal), 0),
|
||||
};
|
||||
|
||||
return Response.json({ year, monthly: Object.values(monthly), quarterly, yearTotal, invoices });
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useLoaderData, useParams, useRevalidator } from "react-router";
|
||||
import { requireUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Users, Plus, Edit, Trash2, ChevronLeft, Mail, Phone } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Pflichtfeld"),
|
||||
vatId: z.string().optional(),
|
||||
address: z.string().min(1, "Pflichtfeld"),
|
||||
zip: z.string().min(1, "Pflichtfeld"),
|
||||
city: z.string().min(1, "Pflichtfeld"),
|
||||
country: z.string().optional(),
|
||||
email: z.string().email("Ungültige E-Mail").optional().or(z.literal("")),
|
||||
phone: z.string().optional(),
|
||||
});
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
name: string;
|
||||
vatId?: string | null;
|
||||
address: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await requireUser(request);
|
||||
const company = await prisma.company.findFirst({
|
||||
where: { id: params.id, userId: user.id },
|
||||
});
|
||||
if (!company) throw new Response("Not Found", { status: 404 });
|
||||
|
||||
const customers = await prisma.customer.findMany({
|
||||
where: { companyId: params.id },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return { customers, companyId: params.id };
|
||||
}
|
||||
|
||||
function CustomerForm({
|
||||
defaultValues,
|
||||
onSubmit,
|
||||
submitLabel,
|
||||
}: {
|
||||
defaultValues?: Partial<FormData>;
|
||||
onSubmit: (d: FormData) => Promise<void>;
|
||||
submitLabel: string;
|
||||
}) {
|
||||
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { country: "DE", ...defaultValues },
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>Name *</Label>
|
||||
<Input {...register("name")} placeholder="Beispiel AG" />
|
||||
{errors.name && <p className="text-xs text-red-600">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>Straße & Nr. *</Label>
|
||||
<Input {...register("address")} placeholder="Musterstr. 1" />
|
||||
{errors.address && <p className="text-xs text-red-600">{errors.address.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>PLZ *</Label>
|
||||
<Input {...register("zip")} placeholder="10115" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Ort *</Label>
|
||||
<Input {...register("city")} placeholder="Berlin" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>USt-IdNr.</Label>
|
||||
<Input {...register("vatId")} placeholder="DE..." />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>E-Mail</Label>
|
||||
<Input {...register("email")} type="email" placeholder="kontakt@..." />
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>Telefon</Label>
|
||||
<Input {...register("phone")} placeholder="+49..." />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button type="submit" disabled={isSubmitting}>{isSubmitting ? "Speichern..." : submitLabel}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CustomersPage() {
|
||||
const { customers, companyId } = useLoaderData<typeof loader>();
|
||||
const { revalidate } = useRevalidator();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editCustomer, setEditCustomer] = useState<Customer | null>(null);
|
||||
|
||||
async function handleCreate(data: FormData) {
|
||||
await fetch("/api/customers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...data, companyId }),
|
||||
});
|
||||
setOpen(false);
|
||||
revalidate();
|
||||
}
|
||||
|
||||
async function handleEdit(data: FormData) {
|
||||
if (!editCustomer) return;
|
||||
await fetch(`/api/customers/${editCustomer.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
setEditCustomer(null);
|
||||
revalidate();
|
||||
}
|
||||
|
||||
async function handleDelete(customerId: string) {
|
||||
if (!confirm("Kunden wirklich löschen?")) return;
|
||||
await fetch(`/api/customers/${customerId}`, { method: "DELETE" });
|
||||
revalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to={`/companies/${companyId}`}
|
||||
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Kunden</h1>
|
||||
<p className="text-gray-500 mt-1">{customers.length} Kunden</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="h-4 w-4" /> Kunde anlegen</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neuer Kunde</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CustomerForm onSubmit={handleCreate} submitLabel="Anlegen" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!editCustomer} onOpenChange={(o) => !o && setEditCustomer(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kunde bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editCustomer && (
|
||||
<CustomerForm
|
||||
defaultValues={{
|
||||
name: editCustomer.name,
|
||||
address: editCustomer.address,
|
||||
zip: editCustomer.zip,
|
||||
city: editCustomer.city,
|
||||
vatId: editCustomer.vatId ?? undefined,
|
||||
email: editCustomer.email ?? undefined,
|
||||
phone: editCustomer.phone ?? undefined,
|
||||
}}
|
||||
onSubmit={handleEdit}
|
||||
submitLabel="Speichern"
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{customers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<Users className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-sm">Noch keine Kunden angelegt</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{customers.map((customer) => (
|
||||
<Card key={customer.id}>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">{customer.name}</p>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{customer.address}, {customer.zip} {customer.city}</p>
|
||||
{customer.vatId && <p className="text-xs text-gray-400 mt-0.5">USt-IdNr.: {customer.vatId}</p>}
|
||||
<div className="flex gap-3 mt-2">
|
||||
{customer.email && (
|
||||
<span className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<Mail className="h-3 w-3" />{customer.email}
|
||||
</span>
|
||||
)}
|
||||
{customer.phone && (
|
||||
<span className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<Phone className="h-3 w-3" />{customer.phone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditCustomer(customer)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleDelete(customer.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Link, useLoaderData, useNavigate } from "react-router";
|
||||
import { requireUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CompanyForm } from "@/components/company/company-form";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await requireUser(request);
|
||||
const company = await prisma.company.findFirst({
|
||||
where: { id: params.id, userId: user.id },
|
||||
});
|
||||
if (!company) throw new Response("Not Found", { status: 404 });
|
||||
return { company };
|
||||
}
|
||||
|
||||
export default function EditCompanyPage() {
|
||||
const { company } = useLoaderData<typeof loader>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleSubmit(data: Record<string, unknown>) {
|
||||
const res = await fetch(`/api/companies/${company.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.ok) navigate(`/companies/${company.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to={`/companies/${company.id}`}
|
||||
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
|
||||
</Link>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Mandant bearbeiten</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mandantendaten</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CompanyForm
|
||||
defaultValues={company as Record<string, string>}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Änderungen speichern"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { Link, useLoaderData, useNavigate, useRevalidator } from "react-router";
|
||||
import { requireUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { useState } from "react";
|
||||
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 { InvoiceStatus } from "@prisma/client";
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
params,
|
||||
}: {
|
||||
request: Request;
|
||||
params: { id: string; invoiceId: string };
|
||||
}) {
|
||||
const user = await requireUser(request);
|
||||
const { id, invoiceId } = params;
|
||||
|
||||
const invoice = await prisma.invoice.findFirst({
|
||||
where: { id: invoiceId, companyId: id, company: { userId: user.id } },
|
||||
include: {
|
||||
items: { orderBy: { position: "asc" } },
|
||||
customer: true,
|
||||
company: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) throw new Response("Not Found", { status: 404 });
|
||||
|
||||
return {
|
||||
invoice: {
|
||||
...invoice,
|
||||
netTotal: Number(invoice.netTotal),
|
||||
taxTotal: Number(invoice.taxTotal),
|
||||
grossTotal: Number(invoice.grossTotal),
|
||||
issueDate: invoice.issueDate.toISOString(),
|
||||
dueDate: invoice.dueDate.toISOString(),
|
||||
deliveryDate: invoice.deliveryDate?.toISOString() ?? null,
|
||||
items: invoice.items.map((item) => ({
|
||||
...item,
|
||||
quantity: Number(item.quantity),
|
||||
unitPrice: Number(item.unitPrice),
|
||||
taxRate: Number(item.taxRate),
|
||||
netAmount: Number(item.netAmount),
|
||||
taxAmount: Number(item.taxAmount),
|
||||
grossAmount: Number(item.grossAmount),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function InvoiceDetailPage() {
|
||||
const { invoice } = useLoaderData<typeof loader>();
|
||||
const navigate = useNavigate();
|
||||
const { revalidate } = useRevalidator();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const id = invoice.companyId;
|
||||
|
||||
const taxGroups = invoice.items.reduce(
|
||||
(acc, item) => {
|
||||
const rate = item.taxRate;
|
||||
if (!acc[rate]) acc[rate] = { net: 0, tax: 0 };
|
||||
acc[rate].net += item.netAmount;
|
||||
acc[rate].tax += item.taxAmount;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, { net: number; tax: number }>
|
||||
);
|
||||
|
||||
async function updateStatus(status: InvoiceStatus) {
|
||||
setLoading(true);
|
||||
await fetch(`/api/invoices/${invoice.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
setLoading(false);
|
||||
revalidate();
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm("Rechnung wirklich löschen?")) return;
|
||||
await fetch(`/api/invoices/${invoice.id}`, { method: "DELETE" });
|
||||
navigate(`/companies/${id}/invoices`);
|
||||
}
|
||||
|
||||
async function downloadPdf() {
|
||||
const res = await fetch(`/api/invoices/${invoice.id}/pdf`);
|
||||
if (!res.ok) return;
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `rechnung-${invoice.number}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to={`/companies/${id}/invoices`}
|
||||
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" /> Zurück zu Rechnungen
|
||||
</Link>
|
||||
|
||||
<div className="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{invoice.number}</h1>
|
||||
<InvoiceStatusBadge status={invoice.status} />
|
||||
</div>
|
||||
<p className="text-gray-500">
|
||||
{invoice.customer.name} · {formatDate(invoice.issueDate)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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 === "DRAFT" && (
|
||||
<Button size="sm" onClick={() => updateStatus(InvoiceStatus.SENT)} disabled={loading}>
|
||||
<Send className="h-4 w-4" /> Als versendet markieren
|
||||
</Button>
|
||||
)}
|
||||
{invoice.status === "SENT" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={() => updateStatus(InvoiceStatus.PAID)}
|
||||
disabled={loading}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" /> Als bezahlt markieren
|
||||
</Button>
|
||||
)}
|
||||
{(invoice.status === "DRAFT" || invoice.status === "CANCELLED") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-2 gap-8 mb-8">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Absender</p>
|
||||
<p className="font-semibold text-gray-900">{invoice.company.name}</p>
|
||||
{invoice.company.legalForm && <p className="text-sm text-gray-600">{invoice.company.legalForm}</p>}
|
||||
<p className="text-sm text-gray-600">{invoice.company.address}</p>
|
||||
<p className="text-sm text-gray-600">{invoice.company.zip} {invoice.company.city}</p>
|
||||
{invoice.company.taxId && (
|
||||
<p className="text-xs text-gray-500 mt-1">St.-Nr.: {invoice.company.taxId}</p>
|
||||
)}
|
||||
{invoice.company.vatId && (
|
||||
<p className="text-xs text-gray-500">USt-IdNr.: {invoice.company.vatId}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Rechnungsempfänger</p>
|
||||
<p className="font-semibold text-gray-900">{invoice.customer.name}</p>
|
||||
<p className="text-sm text-gray-600">{invoice.customer.address}</p>
|
||||
<p className="text-sm text-gray-600">{invoice.customer.zip} {invoice.customer.city}</p>
|
||||
{invoice.customer.vatId && (
|
||||
<p className="text-xs text-gray-500 mt-1">USt-IdNr.: {invoice.customer.vatId}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-8 p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Rechnungsnummer</p>
|
||||
<p className="text-sm font-semibold text-gray-900">{invoice.number}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Rechnungsdatum</p>
|
||||
<p className="text-sm font-medium text-gray-900">{formatDate(invoice.issueDate)}</p>
|
||||
</div>
|
||||
{invoice.deliveryDate && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Leistungsdatum</p>
|
||||
<p className="text-sm font-medium text-gray-900">{formatDate(invoice.deliveryDate)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Fällig am</p>
|
||||
<p className="text-sm font-medium text-gray-900">{formatDate(invoice.dueDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden mb-6">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left px-4 py-2.5 font-medium text-gray-600 w-8">#</th>
|
||||
<th className="text-left px-4 py-2.5 font-medium text-gray-600">Beschreibung</th>
|
||||
<th className="text-right px-4 py-2.5 font-medium text-gray-600">Menge</th>
|
||||
<th className="text-right px-4 py-2.5 font-medium text-gray-600">EP (netto)</th>
|
||||
<th className="text-right px-4 py-2.5 font-medium text-gray-600">MwSt.</th>
|
||||
<th className="text-right px-4 py-2.5 font-medium text-gray-600">Betrag (brutto)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{invoice.items.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-500">{item.position}</td>
|
||||
<td className="px-4 py-3 text-gray-900">{item.description}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-700">
|
||||
{item.quantity} {item.unit && <span className="text-gray-500">{item.unit}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-700">{formatCurrency(item.unitPrice)}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-700">{item.taxRate}%</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-gray-900">{formatCurrency(item.grossAmount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="w-72 space-y-1.5">
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>Nettobetrag</span>
|
||||
<span>{formatCurrency(invoice.netTotal)}</span>
|
||||
</div>
|
||||
{Object.entries(taxGroups).map(([rate, { net, tax }]) => (
|
||||
<div key={rate} className="flex justify-between text-sm text-gray-600">
|
||||
<span>MwSt. {rate}% auf {formatCurrency(net)}</span>
|
||||
<span>{formatCurrency(tax)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between text-base font-bold text-gray-900 border-t border-gray-300 pt-2">
|
||||
<span>Gesamtbetrag (brutto)</span>
|
||||
<span>{formatCurrency(invoice.grossTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{invoice.notes && (
|
||||
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Hinweise</p>
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">{invoice.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoice.company.bankIban && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-500">
|
||||
Bankverbindung: {invoice.company.bankName && `${invoice.company.bankName} · `}
|
||||
IBAN: {invoice.company.bankIban}
|
||||
{invoice.company.bankBic && ` · BIC: ${invoice.company.bankBic}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Zusammenfassung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Netto</p>
|
||||
<p className="font-medium text-gray-900">{formatCurrency(invoice.netTotal)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">MwSt.</p>
|
||||
<p className="font-medium text-gray-900">{formatCurrency(invoice.taxTotal)}</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-2">
|
||||
<p className="text-xs text-gray-500">Brutto</p>
|
||||
<p className="text-lg font-bold text-gray-900">{formatCurrency(invoice.grossTotal)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Link, useLoaderData, useNavigate, redirect } from "react-router";
|
||||
import { requireUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { InvoiceForm } from "@/components/invoice/invoice-form";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await requireUser(request);
|
||||
const { id } = params;
|
||||
|
||||
const company = await prisma.company.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
});
|
||||
if (!company) throw new Response("Not Found", { status: 404 });
|
||||
|
||||
const customers = await prisma.customer.findMany({
|
||||
where: { companyId: id },
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
if (customers.length === 0) {
|
||||
throw redirect(`/companies/${id}/customers`);
|
||||
}
|
||||
|
||||
return { company, customers };
|
||||
}
|
||||
|
||||
export default function NewInvoicePage() {
|
||||
const { company, customers } = useLoaderData<typeof loader>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleSubmit(data: Record<string, unknown>) {
|
||||
const res = await fetch("/api/invoices", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const invoice = await res.json();
|
||||
navigate(`/companies/${company.id}/invoices/${invoice.id}`);
|
||||
} else {
|
||||
alert("Fehler beim Erstellen der Rechnung.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to={`/companies/${company.id}/invoices`}
|
||||
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" /> Zurück zu Rechnungen
|
||||
</Link>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Neue Rechnung</h1>
|
||||
<p className="text-gray-500 mt-1">Für Mandant: {company.name}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rechnungsdaten</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InvoiceForm customers={customers} companyId={company.id} onSubmit={handleSubmit} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
import { requireUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } 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";
|
||||
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await requireUser(request);
|
||||
const { id } = params;
|
||||
|
||||
const company = await prisma.company.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
});
|
||||
if (!company) throw new Response("Not Found", { status: 404 });
|
||||
|
||||
const invoices = await prisma.invoice.findMany({
|
||||
where: { companyId: id },
|
||||
include: { customer: { select: { name: true } } },
|
||||
orderBy: { issueDate: "desc" },
|
||||
});
|
||||
|
||||
return {
|
||||
company,
|
||||
invoices: invoices.map((inv) => ({
|
||||
...inv,
|
||||
grossTotal: Number(inv.grossTotal),
|
||||
issueDate: inv.issueDate.toISOString(),
|
||||
dueDate: inv.dueDate.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const { company, invoices } = useLoaderData<typeof loader>();
|
||||
const id = company.id;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to={`/companies/${id}`}
|
||||
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" /> {company.name}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<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>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link to={`/companies/${id}/invoices/new`}>
|
||||
<Plus className="h-4 w-4" /> Neue Rechnung
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{invoices.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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatCurrency } from "@/lib/tax";
|
||||
import { ChevronLeft, TrendingUp, BarChart3 } from "lucide-react";
|
||||
|
||||
const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
|
||||
|
||||
interface TaxGroup {
|
||||
netAmount: number;
|
||||
taxAmount: number;
|
||||
}
|
||||
|
||||
interface MonthData {
|
||||
month: number;
|
||||
invoiceCount: number;
|
||||
netTotal: number;
|
||||
taxTotal: number;
|
||||
grossTotal: number;
|
||||
taxGroups: Record<string, TaxGroup>;
|
||||
}
|
||||
|
||||
interface QuarterData {
|
||||
quarter: number;
|
||||
invoiceCount: number;
|
||||
netTotal: number;
|
||||
taxTotal: number;
|
||||
grossTotal: number;
|
||||
taxGroups: Record<string, TaxGroup>;
|
||||
}
|
||||
|
||||
interface ReportData {
|
||||
year: number;
|
||||
monthly: MonthData[];
|
||||
quarterly: QuarterData[];
|
||||
yearTotal: {
|
||||
invoiceCount: number;
|
||||
netTotal: number;
|
||||
taxTotal: number;
|
||||
grossTotal: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const { id: companyId } = useParams<{ id: string }>();
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [data, setData] = useState<ReportData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch(`/api/reports?companyId=${companyId}&year=${year}`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => { setData(d); setLoading(false); });
|
||||
}, [companyId, year]);
|
||||
|
||||
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to={`/companies/${companyId}`}
|
||||
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Steuerberichte</h1>
|
||||
<p className="text-gray-500 mt-1">Auswertungen für Steuererklärung und USt-Voranmeldung</p>
|
||||
</div>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(Number(e.target.value))}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
{years.map((y) => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500 py-12">Lade Auswertung...</div>
|
||||
) : data && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-gray-500 mb-1">Rechnungen</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{data.yearTotal.invoiceCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-gray-500 mb-1">Umsatz (netto)</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{formatCurrency(data.yearTotal.netTotal)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-gray-500 mb-1">USt. gesamt</p>
|
||||
<p className="text-2xl font-bold text-indigo-600">{formatCurrency(data.yearTotal.taxTotal)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-gray-500 mb-1">Umsatz (brutto)</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{formatCurrency(data.yearTotal.grossTotal)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-indigo-600" />
|
||||
<CardTitle>USt-Voranmeldung (quartalsweise)</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left pb-3 text-gray-500 font-medium">Quartal</th>
|
||||
<th className="text-right pb-3 text-gray-500 font-medium">Rechnungen</th>
|
||||
<th className="text-right pb-3 text-gray-500 font-medium">Netto</th>
|
||||
<th className="text-right pb-3 text-gray-500 font-medium">USt. 19%</th>
|
||||
<th className="text-right pb-3 text-gray-500 font-medium">USt. 7%</th>
|
||||
<th className="text-right pb-3 text-gray-500 font-medium">USt. gesamt</th>
|
||||
<th className="text-right pb-3 text-gray-500 font-medium">Brutto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{data.quarterly.map((q) => (
|
||||
<tr key={q.quarter} className="hover:bg-gray-50">
|
||||
<td className="py-3 font-medium text-gray-900">Q{q.quarter} {year}</td>
|
||||
<td className="py-3 text-right text-gray-700">{q.invoiceCount}</td>
|
||||
<td className="py-3 text-right text-gray-700">{formatCurrency(q.netTotal)}</td>
|
||||
<td className="py-3 text-right text-gray-700">
|
||||
{q.taxGroups["19"] ? formatCurrency(q.taxGroups["19"].taxAmount) : "—"}
|
||||
</td>
|
||||
<td className="py-3 text-right text-gray-700">
|
||||
{q.taxGroups["7"] ? formatCurrency(q.taxGroups["7"].taxAmount) : "—"}
|
||||
</td>
|
||||
<td className="py-3 text-right font-medium text-indigo-700">{formatCurrency(q.taxTotal)}</td>
|
||||
<td className="py-3 text-right font-semibold text-gray-900">{formatCurrency(q.grossTotal)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300">
|
||||
<td className="pt-3 font-bold text-gray-900">Gesamt {year}</td>
|
||||
<td className="pt-3 text-right font-bold text-gray-900">{data.yearTotal.invoiceCount}</td>
|
||||
<td className="pt-3 text-right font-bold text-gray-900">{formatCurrency(data.yearTotal.netTotal)}</td>
|
||||
<td className="pt-3 text-right font-bold text-gray-900">
|
||||
{formatCurrency(data.quarterly.reduce((s, q) => s + (q.taxGroups["19"]?.taxAmount ?? 0), 0))}
|
||||
</td>
|
||||
<td className="pt-3 text-right font-bold text-gray-900">
|
||||
{formatCurrency(data.quarterly.reduce((s, q) => s + (q.taxGroups["7"]?.taxAmount ?? 0), 0))}
|
||||
</td>
|
||||
<td className="pt-3 text-right font-bold text-indigo-700">{formatCurrency(data.yearTotal.taxTotal)}</td>
|
||||
<td className="pt-3 text-right font-bold text-gray-900">{formatCurrency(data.yearTotal.grossTotal)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-indigo-600" />
|
||||
<CardTitle>Monatliche Übersicht</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left pb-3 text-gray-500 font-medium">Monat</th>
|
||||
<th className="text-right pb-3 text-gray-500 font-medium">Rechnungen</th>
|
||||
<th className="text-right pb-3 text-gray-500 font-medium">Netto</th>
|
||||
<th className="text-right pb-3 text-gray-500 font-medium">USt.</th>
|
||||
<th className="text-right pb-3 text-gray-500 font-medium">Brutto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{data.monthly.map((m) => (
|
||||
<tr key={m.month} className={`hover:bg-gray-50 ${m.invoiceCount === 0 ? "opacity-40" : ""}`}>
|
||||
<td className="py-2.5 font-medium text-gray-900">{MONTHS[m.month - 1]} {year}</td>
|
||||
<td className="py-2.5 text-right text-gray-700">{m.invoiceCount || "—"}</td>
|
||||
<td className="py-2.5 text-right text-gray-700">
|
||||
{m.netTotal > 0 ? formatCurrency(m.netTotal) : "—"}
|
||||
</td>
|
||||
<td className="py-2.5 text-right text-indigo-700">
|
||||
{m.taxTotal > 0 ? formatCurrency(m.taxTotal) : "—"}
|
||||
</td>
|
||||
<td className="py-2.5 text-right font-medium text-gray-900">
|
||||
{m.grossTotal > 0 ? formatCurrency(m.grossTotal) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
import { requireUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatCurrency, formatDate } from "@/lib/tax";
|
||||
import {
|
||||
FileText, Users, BarChart3, Plus, Edit, Building2,
|
||||
Mail, Phone, CreditCard, Receipt
|
||||
} from "lucide-react";
|
||||
import { InvoiceStatus } from "@prisma/client";
|
||||
|
||||
const statusLabels: Record<InvoiceStatus, string> = {
|
||||
DRAFT: "Entwurf",
|
||||
SENT: "Versendet",
|
||||
PAID: "Bezahlt",
|
||||
CANCELLED: "Storniert",
|
||||
};
|
||||
|
||||
const statusVariants: Record<InvoiceStatus, "secondary" | "default" | "success" | "destructive" | "warning"> = {
|
||||
DRAFT: "secondary",
|
||||
SENT: "warning",
|
||||
PAID: "success",
|
||||
CANCELLED: "destructive",
|
||||
};
|
||||
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await requireUser(request);
|
||||
const { id } = params;
|
||||
|
||||
const company = await prisma.company.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
include: {
|
||||
invoices: {
|
||||
include: { customer: { select: { name: true } } },
|
||||
orderBy: { issueDate: "desc" },
|
||||
take: 5,
|
||||
},
|
||||
_count: { select: { invoices: true, customers: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!company) throw new Response("Not Found", { status: 404 });
|
||||
|
||||
const revenue = await prisma.invoice.aggregate({
|
||||
where: { companyId: id, status: InvoiceStatus.PAID },
|
||||
_sum: { grossTotal: true },
|
||||
});
|
||||
|
||||
return {
|
||||
company: {
|
||||
...company,
|
||||
invoices: company.invoices.map((inv) => ({
|
||||
...inv,
|
||||
grossTotal: Number(inv.grossTotal),
|
||||
issueDate: inv.issueDate.toISOString(),
|
||||
dueDate: inv.dueDate.toISOString(),
|
||||
})),
|
||||
},
|
||||
revenue: Number(revenue._sum.grossTotal ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export default function CompanyPage() {
|
||||
const { company, revenue } = useLoaderData<typeof loader>();
|
||||
const id = company.id;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold 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>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
|
||||
<Link to={`/companies/${id}/invoices/new`} className="block">
|
||||
<Card className="hover:border-indigo-200 hover:shadow-sm transition-all cursor-pointer">
|
||||
<CardContent className="pt-4 pb-4 flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-indigo-50">
|
||||
<Plus className="h-4 w-4 text-indigo-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">Rechnung erstellen</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link to={`/companies/${id}/invoices`} className="block">
|
||||
<Card className="hover:border-blue-200 hover:shadow-sm transition-all cursor-pointer">
|
||||
<CardContent className="pt-4 pb-4 flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-50">
|
||||
<FileText className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">Rechnungen</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link to={`/companies/${id}/customers`} className="block">
|
||||
<Card className="hover:border-green-200 hover:shadow-sm transition-all cursor-pointer">
|
||||
<CardContent className="pt-4 pb-4 flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-green-50">
|
||||
<Users className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">Kunden</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link to={`/companies/${id}/reports`} className="block">
|
||||
<Card className="hover:border-purple-200 hover:shadow-sm transition-all cursor-pointer">
|
||||
<CardContent className="pt-4 pb-4 flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-purple-50">
|
||||
<BarChart3 className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">Berichte</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold text-gray-900">Letzte Rechnungen</h2>
|
||||
<Link to={`/companies/${id}/invoices`} className="text-sm text-indigo-600 hover:text-indigo-700">
|
||||
Alle anzeigen →
|
||||
</Link>
|
||||
</div>
|
||||
<Card>
|
||||
{company.invoices.length === 0 ? (
|
||||
<CardContent className="py-8 text-center text-gray-500">
|
||||
<Receipt className="h-8 w-8 mx-auto mb-2 text-gray-300" />
|
||||
<p className="text-sm">Noch keine Rechnungen</p>
|
||||
</CardContent>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{company.invoices.map((invoice) => (
|
||||
<Link
|
||||
key={invoice.id}
|
||||
to={`/companies/${id}/invoices/${invoice.id}`}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{invoice.number}</p>
|
||||
<p className="text-xs text-gray-500">{invoice.customer.name} · {formatDate(invoice.issueDate)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={statusVariants[invoice.status]}>{statusLabels[invoice.status]}</Badge>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{formatCurrency(invoice.grossTotal)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Steuer & Umsatz</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Bezahlt (gesamt)</p>
|
||||
<p className="font-semibold text-gray-900">{formatCurrency(revenue)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Rechnungen</p>
|
||||
<p className="font-semibold text-gray-900">{company._count.invoices}</p>
|
||||
</div>
|
||||
{company.taxId && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Steuernummer</p>
|
||||
<p className="text-sm font-mono text-gray-900">{company.taxId}</p>
|
||||
</div>
|
||||
)}
|
||||
{company.vatId && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">USt-IdNr.</p>
|
||||
<p className="text-sm font-mono text-gray-900">{company.vatId}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{(company.email || company.phone) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Kontakt</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{company.email && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Mail className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
{company.email}
|
||||
</div>
|
||||
)}
|
||||
{company.phone && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Phone className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
{company.phone}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{company.bankIban && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Bankverbindung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<CreditCard className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
<span className="font-mono text-xs">{company.bankIban}</span>
|
||||
</div>
|
||||
{company.bankName && <p className="text-xs text-gray-500 ml-5">{company.bankName}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { CompanyForm } from "@/components/company/company-form";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
|
||||
export default function NewCompanyPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleSubmit(data: Record<string, unknown>) {
|
||||
const res = await fetch("/api/companies", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const company = await res.json();
|
||||
navigate(`/companies/${company.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to="/companies"
|
||||
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" /> Zurück zu Mandanten
|
||||
</Link>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Neuer Mandant</h1>
|
||||
<p className="text-gray-500 mt-1">Legen Sie einen neuen Mandanten an</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mandantendaten</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CompanyForm
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Mandant anlegen"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
import { requireUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Building2, Plus, FileText, Users } from "lucide-react";
|
||||
|
||||
export async function loader({ request }: { request: Request }) {
|
||||
const user = await requireUser(request);
|
||||
const companies = await prisma.company.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { _count: { select: { invoices: true, customers: true } } },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
return { companies };
|
||||
}
|
||||
|
||||
export default function CompaniesPage() {
|
||||
const { companies } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Mandanten</h1>
|
||||
<p className="text-gray-500 mt-1">{companies.length} Mandanten verwaltet</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link to="/companies/new">
|
||||
<Plus className="h-4 w-4" />
|
||||
Mandant anlegen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{companies.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center">
|
||||
<Building2 className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="font-semibold text-gray-700 mb-2">Noch keine Mandanten</h3>
|
||||
<p className="text-gray-500 mb-6 text-sm">Legen Sie Ihren ersten Mandanten an, um loszulegen.</p>
|
||||
<Button asChild>
|
||||
<Link to="/companies/new">
|
||||
<Plus className="h-4 w-4" />
|
||||
Ersten Mandanten anlegen
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{companies.map((company) => (
|
||||
<Link key={company.id} to={`/companies/${company.id}`}>
|
||||
<Card className="hover:shadow-md transition-all hover:border-indigo-200 cursor-pointer h-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-indigo-50 shrink-0">
|
||||
<Building2 className="h-5 w-5 text-indigo-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-base truncate">{company.name}</CardTitle>
|
||||
{company.legalForm && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">{company.legalForm}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-4 text-sm text-gray-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{company._count.invoices} Rechnungen
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{company._count.customers} Kunden
|
||||
</span>
|
||||
</div>
|
||||
{company.city && (
|
||||
<p className="text-xs text-gray-400 mt-2">{company.zip} {company.city}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Outlet, useLoaderData } from "react-router";
|
||||
import { requireUser } from "@/session.server";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
|
||||
export async function loader({ request }: { request: Request }) {
|
||||
const user = await requireUser(request);
|
||||
return { userName: user.name };
|
||||
}
|
||||
|
||||
export default function DashboardLayout() {
|
||||
const { userName } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
<Sidebar userName={userName} />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
import { requireUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { formatCurrency } from "@/lib/tax";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Building2, FileText, Euro } from "lucide-react";
|
||||
import { InvoiceStatus } from "@prisma/client";
|
||||
|
||||
export async function loader({ request }: { request: Request }) {
|
||||
const user = await requireUser(request);
|
||||
const userId = user.id;
|
||||
|
||||
const [companies, invoiceStats, paidTotal, openInvoices] = await Promise.all([
|
||||
prisma.company.findMany({
|
||||
where: { userId },
|
||||
include: { _count: { select: { invoices: true, customers: true } } },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.invoice.aggregate({
|
||||
where: { company: { userId } },
|
||||
_count: true,
|
||||
_sum: { grossTotal: true },
|
||||
}),
|
||||
prisma.invoice.aggregate({
|
||||
where: { company: { userId }, status: InvoiceStatus.PAID },
|
||||
_sum: { grossTotal: true },
|
||||
}),
|
||||
prisma.invoice.count({
|
||||
where: { company: { userId }, status: { in: [InvoiceStatus.SENT, InvoiceStatus.DRAFT] } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
companies,
|
||||
totalInvoices: invoiceStats._count,
|
||||
paidTotal: Number(paidTotal._sum.grossTotal ?? 0),
|
||||
openInvoices,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { companies, totalInvoices, paidTotal, openInvoices } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-500 mt-1">Übersicht aller Mandanten und Rechnungen</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 rounded-lg bg-indigo-50">
|
||||
<Building2 className="h-5 w-5 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900">{companies.length}</p>
|
||||
<p className="text-sm text-gray-500">Mandanten</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 rounded-lg bg-blue-50">
|
||||
<FileText className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900">{totalInvoices}</p>
|
||||
<p className="text-sm text-gray-500">Rechnungen gesamt</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 rounded-lg bg-yellow-50">
|
||||
<FileText className="h-5 w-5 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900">{openInvoices}</p>
|
||||
<p className="text-sm text-gray-500">Offen / Entwurf</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 rounded-lg bg-green-50">
|
||||
<Euro className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900">{formatCurrency(paidTotal)}</p>
|
||||
<p className="text-sm text-gray-500">Bezahlt (brutto)</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Mandanten</h2>
|
||||
<Link to="/companies" className="text-sm text-indigo-600 hover:text-indigo-700 font-medium">
|
||||
Alle anzeigen →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{companies.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<Building2 className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500 mb-4">Noch keine Mandanten angelegt.</p>
|
||||
<Link
|
||||
to="/companies/new"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Mandant anlegen
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{companies.map((company) => (
|
||||
<Link key={company.id} to={`/companies/${company.id}`}>
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{company.name}</CardTitle>
|
||||
{company.legalForm && (
|
||||
<p className="text-xs text-gray-500">{company.legalForm}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-4 text-sm text-gray-600">
|
||||
<span>{company._count.invoices} Rechnungen</span>
|
||||
<span>{company._count.customers} Kunden</span>
|
||||
</div>
|
||||
{company.city && (
|
||||
<p className="text-xs text-gray-400 mt-1">{company.zip} {company.city}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Form, useActionData, useNavigation, redirect } from "react-router";
|
||||
import { login, createUserSession, getUserSession } from "@/session.server";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Calculator, AlertCircle } from "lucide-react";
|
||||
|
||||
export async function loader({ request }: { request: Request }) {
|
||||
const { userId } = await getUserSession(request);
|
||||
if (userId) throw redirect("/");
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function action({ request }: { request: Request }) {
|
||||
const formData = await request.formData();
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
const user = await login(email, password);
|
||||
if (!user) return { error: "E-Mail oder Passwort falsch." };
|
||||
|
||||
return createUserSession(user.id, user.name, "/");
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const loading = navigation.state === "submitting";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-blue-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-indigo-600 mb-4">
|
||||
<Calculator className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Annas Rechnungsmanager</h1>
|
||||
<p className="text-gray-500 mt-1">Buchhaltung & Rechnungsverwaltung</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anmelden</CardTitle>
|
||||
<CardDescription>Geben Sie Ihre Zugangsdaten ein</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post" className="space-y-4">
|
||||
{actionData?.error && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{actionData.error}
|
||||
</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
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">Passwort</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Anmelden..." : "Anmelden"}
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { logout } from "@/session.server";
|
||||
|
||||
export async function action({ request }: { request: Request }) {
|
||||
return logout(request);
|
||||
}
|
||||
Reference in New Issue
Block a user