ADD: added some quiality of life features
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRevalidator } from "react-router";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -7,6 +8,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { calcItemAmounts, calcItemAmountsKleinunternehmer, calcInvoiceTotals, formatCurrency, TAX_RATES } from "@/lib/tax";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
@@ -34,12 +36,23 @@ interface InvoiceFormValues {
|
||||
items: ItemFormData[];
|
||||
}
|
||||
|
||||
interface ServiceOption {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
unit: string | null;
|
||||
unitPrice: number;
|
||||
taxRate: number;
|
||||
}
|
||||
|
||||
interface InvoiceFormProps {
|
||||
customers: Customer[];
|
||||
companyId: string;
|
||||
onSubmit: (data: Record<string, unknown>) => Promise<void>;
|
||||
defaultValues?: Partial<InvoiceFormValues>;
|
||||
defaultKleinunternehmer?: boolean;
|
||||
submitLabel?: string;
|
||||
services?: ServiceOption[];
|
||||
}
|
||||
|
||||
const defaultItem = (): ItemFormData => ({
|
||||
@@ -54,19 +67,33 @@ const defaultItem = (): ItemFormData => ({
|
||||
grossAmount: 0,
|
||||
});
|
||||
|
||||
export function InvoiceForm({ customers, companyId, onSubmit, defaultKleinunternehmer = false }: InvoiceFormProps) {
|
||||
/**
|
||||
* A form for creating an invoice.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Customer[]} customers - List of customers
|
||||
* @param {string} companyId - Company ID
|
||||
* @param {(data: Record<string, unknown>) => Promise<void>} onSubmit - Callback for form submission
|
||||
* @param {Partial<InvoiceFormValues>} defaultValues - Default values for the form
|
||||
* @param {boolean} defaultKleinunternehmer - Whether to use Kleinunternehmer (§19 UStG) by default
|
||||
* @param {string} submitLabel - Label for the submit button
|
||||
* @param {ServiceOption[]} services - List of services that can be added to the invoice
|
||||
*/
|
||||
export function InvoiceForm({ customers, companyId, onSubmit, defaultValues, defaultKleinunternehmer = false, submitLabel = "Rechnung erstellen", services = [] }: InvoiceFormProps) {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const dueDefault = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
||||
const [kleinunternehmer, setKleinunternehmer] = useState(defaultKleinunternehmer);
|
||||
const [openDropdown, setOpenDropdown] = useState<number | null>(null);
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const { register, handleSubmit, watch, setValue, control, formState: { errors, isSubmitting } } = useForm<InvoiceFormValues>({
|
||||
defaultValues: {
|
||||
customerId: "",
|
||||
issueDate: today,
|
||||
deliveryDate: today,
|
||||
dueDate: dueDefault,
|
||||
notes: "",
|
||||
items: [defaultItem()],
|
||||
customerId: defaultValues?.customerId ?? "",
|
||||
issueDate: defaultValues?.issueDate ?? today,
|
||||
deliveryDate: defaultValues?.deliveryDate ?? today,
|
||||
dueDate: defaultValues?.dueDate ?? dueDefault,
|
||||
notes: defaultValues?.notes ?? "",
|
||||
items: defaultValues?.items ?? [defaultItem()],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -100,6 +127,17 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultKleinuntern
|
||||
}))
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles form submission by processing the items and calculating the totals.
|
||||
*
|
||||
* If Kleinunternehmer are enabled, the tax rate for each item is set to 0.
|
||||
*
|
||||
* If the item does not have a description, the tax rate is set to 0.
|
||||
*
|
||||
* The function then saves the new services created and revalidates the form if necessary.
|
||||
*
|
||||
* Finally, the function calls the onSubmit callback with the processed data.
|
||||
*/
|
||||
async function handleFormSubmit(data: InvoiceFormValues) {
|
||||
const items = data.items.map((item, i) => {
|
||||
const qty = parseFloat(item.quantity) || 0;
|
||||
@@ -135,6 +173,31 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultKleinuntern
|
||||
|
||||
const totals = calcInvoiceTotals(items);
|
||||
|
||||
// Neue Leistungen automatisch speichern
|
||||
const newServices = items.filter((item) =>
|
||||
item.description.trim() &&
|
||||
!services.some(
|
||||
(s) => s.name.toLowerCase() === item.description.trim().toLowerCase() ||
|
||||
(s.description ?? "").toLowerCase() === item.description.trim().toLowerCase()
|
||||
)
|
||||
);
|
||||
await Promise.all(
|
||||
newServices.map((item) =>
|
||||
fetch("/api/services", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
companyId,
|
||||
name: item.description.trim(),
|
||||
unit: item.unit || undefined,
|
||||
unitPrice: item.unitPrice,
|
||||
taxRate: item.taxRate,
|
||||
}),
|
||||
})
|
||||
)
|
||||
);
|
||||
if (newServices.length > 0) revalidate();
|
||||
|
||||
await onSubmit({
|
||||
companyId,
|
||||
customerId: data.customerId,
|
||||
@@ -153,7 +216,7 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultKleinuntern
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Kunde *</Label>
|
||||
<Select onValueChange={(v) => setValue("customerId", v)}>
|
||||
<Select defaultValue={defaultValues?.customerId} onValueChange={(v) => setValue("customerId", v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Kunde auswählen..." />
|
||||
</SelectTrigger>
|
||||
@@ -212,8 +275,8 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultKleinuntern
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className={`grid gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200 text-xs font-medium text-gray-600 ${kleinunternehmer ? "grid-cols-11" : "grid-cols-12"}`}>
|
||||
<div className="border border-gray-200 rounded-xl">
|
||||
<div className={`grid gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200 text-xs font-medium text-gray-600 rounded-t-xl ${kleinunternehmer ? "grid-cols-11" : "grid-cols-12"}`}>
|
||||
<div className="col-span-4">Beschreibung</div>
|
||||
<div className="col-span-1">Menge</div>
|
||||
<div className="col-span-1">Einh.</div>
|
||||
@@ -225,12 +288,51 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultKleinuntern
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className={`grid gap-2 px-3 py-2.5 border-b border-gray-100 last:border-0 items-center ${kleinunternehmer ? "grid-cols-11" : "grid-cols-12"}`}>
|
||||
<div className="col-span-4">
|
||||
<Input
|
||||
{...register(`items.${index}.description`, { required: true })}
|
||||
placeholder="Leistungsbeschreibung"
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="col-span-4 relative">
|
||||
{(() => {
|
||||
const descValue = watchedItems[index]?.description ?? "";
|
||||
const filtered = services.filter((s) =>
|
||||
descValue.length === 0 ||
|
||||
s.name.toLowerCase().includes(descValue.toLowerCase()) ||
|
||||
(s.description ?? "").toLowerCase().includes(descValue.toLowerCase())
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
value={descValue}
|
||||
onChange={(e) => setValue(`items.${index}.description`, e.target.value)}
|
||||
onFocus={() => setOpenDropdown(index)}
|
||||
onBlur={() => setOpenDropdown(null)}
|
||||
placeholder="Leistungsbeschreibung"
|
||||
className="text-sm"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{openDropdown === index && services.length > 0 && filtered.length > 0 && (
|
||||
<div className="absolute z-50 top-full left-0 right-0 mt-0.5 bg-white border border-gray-200 rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||||
{filtered.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex flex-col"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setValue(`items.${index}.description`, s.description ?? s.name);
|
||||
setValue(`items.${index}.unit`, s.unit ?? "Stück");
|
||||
setValue(`items.${index}.unitPrice`, String(s.unitPrice));
|
||||
setValue(`items.${index}.taxRate`, String(s.taxRate));
|
||||
setOpenDropdown(null);
|
||||
setTimeout(() => recalcItem(index), 0);
|
||||
}}
|
||||
>
|
||||
<span className="font-medium text-gray-800">{s.name}</span>
|
||||
{s.description && <span className="text-xs text-gray-400 truncate">{s.description}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Input
|
||||
@@ -256,7 +358,7 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultKleinuntern
|
||||
{!kleinunternehmer && (
|
||||
<div className="col-span-1">
|
||||
<Select
|
||||
defaultValue="19"
|
||||
defaultValue={defaultValues?.items?.[index]?.taxRate ?? "19"}
|
||||
onValueChange={(v) => {
|
||||
setValue(`items.${index}.taxRate`, v);
|
||||
recalcItem(index);
|
||||
@@ -334,7 +436,7 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultKleinuntern
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button type="submit" disabled={isSubmitting} size="lg">
|
||||
{isSubmitting ? "Erstelle Rechnung..." : "Rechnung erstellen"}
|
||||
{isSubmitting ? "Speichere..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -199,7 +199,7 @@ function formatDate(date: Date | string) {
|
||||
|
||||
interface InvoicePDFProps {
|
||||
invoice: {
|
||||
number: string;
|
||||
number: string | null;
|
||||
issueDate: Date | string;
|
||||
deliveryDate?: Date | string | null;
|
||||
dueDate: Date | string;
|
||||
@@ -302,7 +302,7 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
|
||||
<View style={styles.metaGrid}>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Rechnungsnummer</Text>
|
||||
<Text style={styles.metaValue}>{invoice.number}</Text>
|
||||
<Text style={styles.metaValue}>{invoice.number ?? "-"}</Text>
|
||||
</View>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Rechnungsdatum</Text>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { startCleanupScheduler } from "@/lib/cleanup.server";
|
||||
import { PassThrough } from "node:stream";
|
||||
import type { AppLoadContext, EntryContext } from "react-router";
|
||||
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||
import { ServerRouter } from "react-router";
|
||||
import { isbot } from "isbot";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
|
||||
startCleanupScheduler();
|
||||
|
||||
const ABORT_DELAY = 5_000;
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
_loadContext: AppLoadContext
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const userAgent = request.headers.get("user-agent");
|
||||
const readyCallback = isbot(userAgent ?? "") ? "onAllReady" : "onShellReady";
|
||||
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<ServerRouter context={routerContext} url={request.url} abortDelay={ABORT_DELAY} />,
|
||||
{
|
||||
[readyCallback]() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
resolve(
|
||||
new Response(createReadableStreamFromReadable(body), {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
})
|
||||
);
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
if (shellRendered) console.error(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import cron from "node-cron";
|
||||
import prisma from "@/lib/prisma.server";
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
async function purgeExpiredDeletedInvoices(): Promise<void> {
|
||||
const cutoff = new Date(Date.now() - THIRTY_DAYS_MS);
|
||||
const result = await prisma.invoice.deleteMany({
|
||||
where: { status: "DELETED", deletedAt: { lte: cutoff } },
|
||||
});
|
||||
if (result.count > 0) {
|
||||
console.log(
|
||||
`[cleanup] ${result.count} Rechnung(en) endgültig gelöscht (vor ${cutoff.toISOString()})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let scheduled = false;
|
||||
|
||||
export function startCleanupScheduler(): void {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
|
||||
purgeExpiredDeletedInvoices().catch((err) =>
|
||||
console.error("[cleanup] Startup-Bereinigung fehlgeschlagen:", err)
|
||||
);
|
||||
|
||||
cron.schedule("0 2 * * *", () => {
|
||||
purgeExpiredDeletedInvoices().catch((err) =>
|
||||
console.error("[cleanup] Geplante Bereinigung fehlgeschlagen:", err)
|
||||
);
|
||||
});
|
||||
|
||||
console.log("[cleanup] Scheduler aktiv — täglich 02:00 Uhr");
|
||||
}
|
||||
@@ -11,9 +11,11 @@ export default [
|
||||
route("companies/:id", "routes/companies.$id.tsx"),
|
||||
route("companies/:id/edit", "routes/companies.$id.edit.tsx"),
|
||||
route("companies/:id/customers", "routes/companies.$id.customers.tsx"),
|
||||
route("companies/:id/leistungen", "routes/companies.$id.leistungen.tsx"),
|
||||
route("companies/:id/invoices", "routes/companies.$id.invoices.tsx"),
|
||||
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/invoices/:invoiceId/edit", "routes/companies.$id.invoices.$invoiceId.edit.tsx"),
|
||||
route("companies/:id/reports", "routes/companies.$id.reports.tsx"),
|
||||
route("archiv", "routes/archiv.tsx"),
|
||||
route("settings/password", "routes/settings.password.tsx"),
|
||||
@@ -34,6 +36,8 @@ export default [
|
||||
route("api/companies/:id/invoices", "routes/api.companies.$id.invoices.ts"),
|
||||
route("api/customers", "routes/api.customers.ts"),
|
||||
route("api/customers/:id", "routes/api.customers.$id.ts"),
|
||||
route("api/services", "routes/api.services.ts"),
|
||||
route("api/services/:id", "routes/api.services.$id.ts"),
|
||||
route("api/invoices", "routes/api.invoices.ts"),
|
||||
route("api/invoices/:id", "routes/api.invoices.$id.ts"),
|
||||
route("api/invoices/:id/pdf", "routes/api.invoices.$id.pdf.ts"),
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="rechnung-${invoice.number}.pdf"`,
|
||||
"Content-Disposition": `attachment; filename="rechnung-${invoice.number ?? invoice.id}.pdf"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma.server";
|
||||
import { generateInvoiceNumber } from "@/lib/invoice-number.server";
|
||||
import { InvoiceStatus } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -22,6 +23,31 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
|
||||
const statusSchema = z.object({ status: z.nativeEnum(InvoiceStatus) });
|
||||
|
||||
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 updateSchema = z.object({
|
||||
customerId: z.string().min(1),
|
||||
issueDate: z.string(),
|
||||
deliveryDate: z.string().optional(),
|
||||
dueDate: z.string(),
|
||||
notes: z.string().optional(),
|
||||
kleinunternehmer: z.boolean().optional().default(false),
|
||||
items: z.array(itemSchema).min(1),
|
||||
netTotal: z.number(),
|
||||
taxTotal: z.number(),
|
||||
grossTotal: z.number(),
|
||||
});
|
||||
|
||||
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 });
|
||||
@@ -29,9 +55,36 @@ export async function action({ request, params }: { request: Request; params: {
|
||||
const invoice = await getInvoice(params.id, user.id);
|
||||
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
if (request.method === "PUT") {
|
||||
const body = await request.json();
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||
|
||||
const { items, ...invoiceData } = parsed.data;
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
await tx.invoiceItem.deleteMany({ where: { invoiceId: params.id } });
|
||||
return tx.invoice.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
customerId: invoiceData.customerId,
|
||||
issueDate: new Date(invoiceData.issueDate),
|
||||
deliveryDate: invoiceData.deliveryDate ? new Date(invoiceData.deliveryDate) : null,
|
||||
dueDate: new Date(invoiceData.dueDate),
|
||||
notes: invoiceData.notes ?? null,
|
||||
kleinunternehmer: invoiceData.kleinunternehmer,
|
||||
netTotal: invoiceData.netTotal,
|
||||
taxTotal: invoiceData.taxTotal,
|
||||
grossTotal: invoiceData.grossTotal,
|
||||
items: { create: items },
|
||||
},
|
||||
});
|
||||
});
|
||||
return Response.json(updated);
|
||||
}
|
||||
|
||||
if (request.method === "DELETE") {
|
||||
const isAdmin = user.role === "ADMIN";
|
||||
const deletableStatuses: InvoiceStatus[] = [InvoiceStatus.DRAFT, InvoiceStatus.CANCELLED];
|
||||
const deletableStatuses: InvoiceStatus[] = [InvoiceStatus.DRAFT, InvoiceStatus.CANCELLED, InvoiceStatus.DELETED];
|
||||
if (!isAdmin && !deletableStatuses.includes(invoice.status)) {
|
||||
return Response.json(
|
||||
{ error: "Nur Entwürfe und stornierte Rechnungen können gelöscht werden." },
|
||||
@@ -47,9 +100,22 @@ export async function action({ request, params }: { request: Request; params: {
|
||||
const parsed = statusSchema.safeParse(body);
|
||||
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||
|
||||
const newStatus = parsed.data.status;
|
||||
|
||||
let numberUpdate: string | null | undefined = undefined;
|
||||
if (newStatus === "DELETED") {
|
||||
numberUpdate = null;
|
||||
} else if (invoice.status === "DELETED") {
|
||||
numberUpdate = await generateInvoiceNumber(invoice.companyId);
|
||||
}
|
||||
|
||||
const updated = await prisma.invoice.update({
|
||||
where: { id: params.id },
|
||||
data: { status: parsed.data.status },
|
||||
data: {
|
||||
status: newStatus,
|
||||
deletedAt: newStatus === "DELETED" ? new Date() : null,
|
||||
...(numberUpdate !== undefined && { number: numberUpdate }),
|
||||
},
|
||||
});
|
||||
return Response.json(updated);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,39 @@ const invoiceSchema = z.object({
|
||||
grossTotal: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a new invoice for a given company.
|
||||
*
|
||||
* Requires a JSON object in the request body with the following shape:
|
||||
* {
|
||||
* companyId: string,
|
||||
* customerId: string,
|
||||
* issueDate: string,
|
||||
* deliveryDate?: string,
|
||||
* dueDate: string,
|
||||
* notes?: string,
|
||||
* kleinunternehmer?: boolean,
|
||||
* items: [
|
||||
* {
|
||||
* position: number,
|
||||
* description: string,
|
||||
* quantity: number,
|
||||
* unit?: string,
|
||||
* unitPrice: number,
|
||||
* taxRate: number,
|
||||
* netAmount: number,
|
||||
* taxAmount: number,
|
||||
* grossAmount: number,
|
||||
* },
|
||||
* ],
|
||||
* }
|
||||
*
|
||||
* Returns the created invoice as a JSON object.
|
||||
*
|
||||
* If the request is unauthorized, returns a 401 response with an error message.
|
||||
* If the request body is invalid, returns a 400 response with an error message containing the validation errors.
|
||||
* If the company is not found, returns a 404 response with an error message.
|
||||
*/
|
||||
export async function action({ request }: { request: Request }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma.server";
|
||||
import { z } from "zod";
|
||||
|
||||
const serviceSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
unitPrice: z.number(),
|
||||
taxRate: z.number(),
|
||||
});
|
||||
|
||||
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 service = await prisma.service.findFirst({
|
||||
where: { id: params.id, company: { userId: user.id } },
|
||||
});
|
||||
if (!service) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
if (request.method === "DELETE") {
|
||||
await prisma.service.delete({ where: { id: params.id } });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
// PUT
|
||||
const body = await request.json();
|
||||
const parsed = serviceSchema.safeParse(body);
|
||||
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||
|
||||
const updated = await prisma.service.update({ where: { id: params.id }, data: parsed.data });
|
||||
return Response.json(updated);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma.server";
|
||||
import { z } from "zod";
|
||||
|
||||
const serviceSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
unitPrice: z.number(),
|
||||
taxRate: 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 = serviceSchema.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 service = await prisma.service.create({ data: parsed.data });
|
||||
return Response.json(service, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Link, useLoaderData, useNavigate } from "react-router";
|
||||
|
||||
export const handle = {
|
||||
breadcrumbs: (data: { invoice: { companyId: string; number: string | null; company: { name: string } } }) => [
|
||||
{ label: "Mandanten", href: "/companies" },
|
||||
{ label: data.invoice.company.name, href: `/companies/${data.invoice.companyId}` },
|
||||
{ label: "Rechnungen", href: `/companies/${data.invoice.companyId}/invoices` },
|
||||
{ label: data.invoice.number ?? "-", href: `/companies/${data.invoice.companyId}/invoices/${data.invoice.id}` },
|
||||
{ label: "Bearbeiten" },
|
||||
],
|
||||
};
|
||||
|
||||
import { requireUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma.server";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { InvoiceForm } from "@/components/invoice/invoice-form";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Loads an invoice by its ID.
|
||||
*
|
||||
* The response contains the invoice's details, including the issue date, delivery date, due date, items, customers, and services.
|
||||
*
|
||||
* If the invoice is not found, returns a 404 response with an error message.
|
||||
* If the user is not authorized, returns a 401 response with an error message.
|
||||
*
|
||||
* @param {Request} request - The request object.
|
||||
* @param {{ id: string; invoiceId: string }} params - The route parameters.
|
||||
* @returns {Promise<Response>} - The response data.
|
||||
*/
|
||||
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" } },
|
||||
company: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) throw new Response("Not Found", { status: 404 });
|
||||
|
||||
const [customers, services] = await Promise.all([
|
||||
prisma.customer.findMany({
|
||||
where: { companyId: id },
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
prisma.service.findMany({
|
||||
where: { companyId: id },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
invoice: {
|
||||
...invoice,
|
||||
issueDate: invoice.issueDate.toISOString(),
|
||||
deliveryDate: invoice.deliveryDate?.toISOString() ?? null,
|
||||
dueDate: invoice.dueDate.toISOString(),
|
||||
items: invoice.items.map((item) => ({
|
||||
...item,
|
||||
quantity: String(item.quantity),
|
||||
unitPrice: String(item.unitPrice),
|
||||
taxRate: String(item.taxRate),
|
||||
netAmount: Number(item.netAmount),
|
||||
taxAmount: Number(item.taxAmount),
|
||||
grossAmount: Number(item.grossAmount),
|
||||
})),
|
||||
},
|
||||
customers,
|
||||
services: services.map((s) => ({
|
||||
...s,
|
||||
unitPrice: Number(s.unitPrice),
|
||||
taxRate: Number(s.taxRate),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EditInvoicePage
|
||||
*
|
||||
* This page allows the user to edit an existing invoice.
|
||||
* It will display the current data of the invoice and allow the user to update it.
|
||||
* The page will automatically revalidate when the user updates the invoice.
|
||||
*
|
||||
* @returns {JSX.Element} The JSX element representing the EditInvoicePage.
|
||||
*/
|
||||
export default function EditInvoicePage() {
|
||||
const { invoice, customers, services } = useLoaderData<typeof loader>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const defaultValues = {
|
||||
customerId: invoice.customerId,
|
||||
issueDate: invoice.issueDate.split("T")[0],
|
||||
deliveryDate: invoice.deliveryDate?.split("T")[0] ?? "",
|
||||
dueDate: invoice.dueDate.split("T")[0],
|
||||
notes: invoice.notes ?? "",
|
||||
items: invoice.items,
|
||||
};
|
||||
|
||||
/**
|
||||
* Submits the edited invoice to the API.
|
||||
* If the request is successful, navigates the user to the invoice detail page.
|
||||
* If the request fails, displays an error message.
|
||||
*
|
||||
* @param {Record<string, unknown>} data - The edited invoice data.
|
||||
*/
|
||||
async function handleSubmit(data: Record<string, unknown>) {
|
||||
const res = await fetch(`/api/invoices/${invoice.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
navigate(`/companies/${invoice.companyId}/invoices/${invoice.id}`);
|
||||
} else {
|
||||
alert("Fehler beim Speichern der Rechnung.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to={`/companies/${invoice.companyId}/invoices/${invoice.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 zur Rechnung
|
||||
</Link>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Rechnung bearbeiten</h1>
|
||||
<p className="text-gray-500 mt-1">Rechnungsnummer: {invoice.number ?? "-"}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rechnungsdaten</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InvoiceForm
|
||||
customers={customers}
|
||||
companyId={invoice.companyId}
|
||||
defaultValues={defaultValues}
|
||||
defaultKleinunternehmer={invoice.kleinunternehmer}
|
||||
services={services}
|
||||
submitLabel="Änderungen speichern"
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Link, useLoaderData, useNavigate, useRevalidator } from "react-router";
|
||||
|
||||
export const handle = {
|
||||
breadcrumbs: (data: { invoice: { companyId: string; number: string; company: { name: string } } }) => [
|
||||
breadcrumbs: (data: { invoice: { companyId: string; number: string | null; company: { name: string } } }) => [
|
||||
{ label: "Mandanten", href: "/companies" },
|
||||
{ label: data.invoice.company.name, href: `/companies/${data.invoice.companyId}` },
|
||||
{ label: "Rechnungen", href: `/companies/${data.invoice.companyId}/invoices` },
|
||||
{ label: data.invoice.number },
|
||||
{ label: data.invoice.number ?? "-" },
|
||||
],
|
||||
};
|
||||
import { requireUser } from "@/session.server";
|
||||
@@ -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, RotateCcw } from "lucide-react";
|
||||
import { ChevronLeft, Download, CheckCircle, Send, Trash2, RotateCcw, Pencil } from "lucide-react";
|
||||
import type { InvoiceStatus } from "@prisma/client";
|
||||
|
||||
export async function loader({
|
||||
@@ -63,6 +63,17 @@ export async function loader({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* InvoiceDetailPage
|
||||
*
|
||||
* This page displays the details of a single invoice, including the customer, items, and totals.
|
||||
* It also allows the user to download the invoice as a PDF and to update the status of the invoice.
|
||||
*
|
||||
* The page will only be accessible if the user is logged in and has the necessary permissions.
|
||||
* The page will automatically revalidate when the user updates the status of the invoice.
|
||||
*
|
||||
* @returns {JSX.Element} The JSX element representing the InvoiceDetailPage.
|
||||
*/
|
||||
export default function InvoiceDetailPage() {
|
||||
const { invoice, isAdmin } = useLoaderData<typeof loader>();
|
||||
const navigate = useNavigate();
|
||||
@@ -81,6 +92,15 @@ export default function InvoiceDetailPage() {
|
||||
{} as Record<number, { net: number; tax: number }>
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates the status of an invoice.
|
||||
*
|
||||
* This function sends a PATCH request to the API to update the status of the invoice.
|
||||
* It sets the loading state to true before sending the request and to false after the request is finished.
|
||||
* It also revalidates the page after the request is finished, so the user is shown the updated status.
|
||||
*
|
||||
* @param {InvoiceStatus} status The new status of the invoice.
|
||||
*/
|
||||
async function updateStatus(status: InvoiceStatus) {
|
||||
setLoading(true);
|
||||
await fetch(`/api/invoices/${invoice.id}`, {
|
||||
@@ -92,6 +112,13 @@ export default function InvoiceDetailPage() {
|
||||
revalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft deletes an invoice.
|
||||
*
|
||||
* This function shows a confirmation dialog to the user and if the user confirms, it sends a PATCH request to the API to update the status of the invoice to "DELETED".
|
||||
* It sets the loading state to true before sending the request and to false after the request is finished.
|
||||
* It also revalidates the page after the request is finished, so the user is shown the updated status.
|
||||
*/
|
||||
async function handleSoftDelete() {
|
||||
if (!confirm("Rechnung in den Papierkorb verschieben?")) return;
|
||||
setLoading(true);
|
||||
@@ -104,23 +131,43 @@ export default function InvoiceDetailPage() {
|
||||
revalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores an invoice to the "DRAFT" status.
|
||||
*
|
||||
* This function sends a PATCH request to the API to update the status of the invoice to "DRAFT".
|
||||
* It sets the loading state to true before sending the request and to false after the request is finished.
|
||||
* It also revalidates the page after the request is finished, so the user is shown the updated status.
|
||||
*/
|
||||
async function handleRestore() {
|
||||
setLoading(true);
|
||||
await fetch(`/api/invoices/${invoice.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "CANCELLED" }),
|
||||
body: JSON.stringify({ status: "DRAFT" }),
|
||||
});
|
||||
setLoading(false);
|
||||
revalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard deletes an invoice.
|
||||
*
|
||||
* This function shows a confirmation dialog to the user and if the user confirms, it sends a DELETE request to the API to delete the invoice.
|
||||
* It then navigates to the invoice list page.
|
||||
*/
|
||||
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`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the invoice as a PDF file.
|
||||
*
|
||||
* This function sends a GET request to the API to get the PDF file.
|
||||
* It then creates a blob URL from the response and creates a new anchor element with the blob URL and a download attribute with the filename.
|
||||
* It then simulates a click event on the anchor element, so the user is prompted to download the PDF file.
|
||||
*/
|
||||
async function downloadPdf() {
|
||||
const res = await fetch(`/api/invoices/${invoice.id}/pdf`);
|
||||
if (!res.ok) return;
|
||||
@@ -128,7 +175,7 @@ export default function InvoiceDetailPage() {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `rechnung-${invoice.number}.pdf`;
|
||||
a.download = `rechnung-${invoice.number ?? invoice.id}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -145,7 +192,7 @@ export default function InvoiceDetailPage() {
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{invoice.number ?? "-"}</h1>
|
||||
<InvoiceStatusBadge status={invoice.status} />
|
||||
</div>
|
||||
<p className="text-gray-500">
|
||||
@@ -160,6 +207,13 @@ export default function InvoiceDetailPage() {
|
||||
<Download className="h-4 w-4" /> PDF
|
||||
</Button>
|
||||
)}
|
||||
{invoice.status === "DRAFT" && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={`/companies/${id}/invoices/${invoice.id}/edit`}>
|
||||
<Pencil className="h-4 w-4" /> Bearbeiten
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{invoice.status === "DRAFT" && (
|
||||
<Button size="sm" onClick={() => updateStatus("SENT")} disabled={loading}>
|
||||
<Send className="h-4 w-4" /> Als versendet markieren
|
||||
|
||||
@@ -14,6 +14,17 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { InvoiceForm } from "@/components/invoice/invoice-form";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Loads the company, customers, and services for the given company ID.
|
||||
*
|
||||
* If the company is not found, returns a 404 response with an error message.
|
||||
* If the user is not authorized, returns a 401 response with an error message.
|
||||
* If there are no customers, redirects to the customers page.
|
||||
*
|
||||
* @param {Request} request - The request object.
|
||||
* @param {{ id: string }} params - The route parameters.
|
||||
* @returns {Promise<Response>} - The response data.
|
||||
*/
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await requireUser(request);
|
||||
const { id } = params;
|
||||
@@ -33,11 +44,31 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
throw redirect(`/companies/${id}/customers`);
|
||||
}
|
||||
|
||||
return { company, customers };
|
||||
const services = await prisma.service.findMany({
|
||||
where: { companyId: id },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return {
|
||||
company,
|
||||
customers,
|
||||
services: services.map((s) => ({
|
||||
...s,
|
||||
unitPrice: Number(s.unitPrice),
|
||||
taxRate: Number(s.taxRate),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* NewInvoicePage
|
||||
*
|
||||
* This page allows the user to create a new invoice for a given company.
|
||||
* It will display the company's name and allow the user to select a customer and add items to the invoice.
|
||||
* After submitting the form, the user will be redirected to the invoice detail page.
|
||||
*/
|
||||
export default function NewInvoicePage() {
|
||||
const { company, customers } = useLoaderData<typeof loader>();
|
||||
const { company, customers, services } = useLoaderData<typeof loader>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleSubmit(data: Record<string, unknown>) {
|
||||
@@ -74,7 +105,7 @@ export default function NewInvoicePage() {
|
||||
<CardTitle>Rechnungsdaten</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InvoiceForm customers={customers} companyId={company.id} defaultKleinunternehmer={company.kleinunternehmer} onSubmit={handleSubmit} />
|
||||
<InvoiceForm customers={customers} companyId={company.id} defaultKleinunternehmer={company.kleinunternehmer} services={services} onSubmit={handleSubmit} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
import { Link, useLoaderData, useRevalidator } from "react-router";
|
||||
|
||||
export const handle = {
|
||||
breadcrumbs: (data: { company: { id: string; name: string } }) => [
|
||||
@@ -13,7 +13,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
|
||||
import { formatCurrency, formatDate } from "@/lib/tax";
|
||||
import { Plus, FileText, ChevronLeft, ChevronDown, ChevronRight, Trash2 } from "lucide-react";
|
||||
import { Plus, FileText, ChevronLeft, ChevronDown, ChevronRight, Trash2, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
@@ -38,6 +38,7 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
grossTotal: Number(inv.grossTotal),
|
||||
issueDate: inv.issueDate.toISOString(),
|
||||
dueDate: inv.dueDate.toISOString(),
|
||||
deletedAt: inv.deletedAt?.toISOString() ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -65,7 +66,7 @@ function InvoiceRow({ invoice, companyId }: { invoice: InvoiceRow; companyId: st
|
||||
<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="font-medium text-slate-800 text-sm">{invoice.number ?? "-"}</p>
|
||||
<p className="text-xs text-slate-400">{invoice.customer.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,6 +127,47 @@ function YearPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function DeletedInvoiceRow({ invoice, companyId }: { invoice: InvoiceRow; companyId: string }) {
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
async function handleDelete(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
if (!window.confirm(`Rechnung ${invoice.number ?? "-"} endgültig löschen? Dies kann nicht rückgängig gemacht werden.`)) return;
|
||||
await fetch(`/api/invoices/${invoice.id}`, { method: "DELETE" });
|
||||
revalidator.revalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-5 py-3.5 hover:bg-red-50/30 transition-colors group">
|
||||
<Link
|
||||
to={`/companies/${companyId}/invoices/${invoice.id}`}
|
||||
className="flex items-center gap-4 flex-1 min-w-0"
|
||||
>
|
||||
<div className="p-1.5 rounded-lg bg-red-50">
|
||||
<FileText className="h-3.5 w-3.5 text-red-300" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-500 text-sm">{invoice.number ?? "-"}</p>
|
||||
<p className="text-xs text-slate-400">{invoice.customer.name}</p>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="text-sm text-slate-400 hidden sm:block">{formatDate(invoice.issueDate)}</p>
|
||||
<p className="font-medium text-slate-400 w-24 text-right text-sm">
|
||||
{formatCurrency(invoice.grossTotal)}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-1.5 rounded text-red-300 hover:text-red-600 hover:bg-red-100 transition-colors"
|
||||
title="Endgültig löschen"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeletedPanel({
|
||||
invoices,
|
||||
companyId,
|
||||
@@ -159,7 +201,7 @@ function DeletedPanel({
|
||||
{open && (
|
||||
<div className="divide-y divide-red-50 border-t border-red-100">
|
||||
{invoices.map((invoice) => (
|
||||
<InvoiceRow key={invoice.id} invoice={invoice} companyId={companyId} />
|
||||
<DeletedInvoiceRow key={invoice.id} invoice={invoice} companyId={companyId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useLoaderData, useRevalidator } from "react-router";
|
||||
|
||||
export const handle = {
|
||||
breadcrumbs: (data: { companyId: string; companyName: string }) => [
|
||||
{ label: "Mandanten", href: "/companies" },
|
||||
{ label: data.companyName, href: `/companies/${data.companyId}` },
|
||||
{ label: "Leistungen" },
|
||||
],
|
||||
};
|
||||
|
||||
import { requireUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma.server";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card"; // CardContent used for empty state
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Briefcase, Plus, Edit, Trash2, ChevronLeft, ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { TAX_RATES, formatCurrency } from "@/lib/tax";
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Pflichtfeld"),
|
||||
description: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
unitPrice: z.coerce.number({ invalid_type_error: "Ungültiger Betrag" }),
|
||||
taxRate: z.coerce.number(),
|
||||
});
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
unit: string | null;
|
||||
unitPrice: number;
|
||||
taxRate: number;
|
||||
}
|
||||
|
||||
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 services = await prisma.service.findMany({
|
||||
where: { companyId: params.id },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return {
|
||||
services: services.map((s) => ({
|
||||
...s,
|
||||
unitPrice: Number(s.unitPrice),
|
||||
taxRate: Number(s.taxRate),
|
||||
})),
|
||||
companyId: params.id,
|
||||
companyName: company.name,
|
||||
};
|
||||
}
|
||||
|
||||
function ServiceForm({
|
||||
defaultValues,
|
||||
onSubmit,
|
||||
submitLabel,
|
||||
}: {
|
||||
defaultValues?: Partial<FormData>;
|
||||
onSubmit: (d: FormData) => Promise<void>;
|
||||
submitLabel: string;
|
||||
}) {
|
||||
const { register, handleSubmit, setValue, formState: { errors, isSubmitting } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { taxRate: 19, ...defaultValues },
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Bezeichnung *</Label>
|
||||
<Input {...register("name")} placeholder="z.B. Beratung, Programmierung" />
|
||||
{errors.name && <p className="text-xs text-red-600">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Beschreibung</Label>
|
||||
<Input {...register("description")} placeholder="Optionale Leistungsbeschreibung" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Einheit</Label>
|
||||
<Input {...register("unit")} placeholder="Stunde, Stück, ..." />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Einzelpreis (€) *</Label>
|
||||
<Input {...register("unitPrice")} type="number" step="0.01" placeholder="0.00" />
|
||||
{errors.unitPrice && <p className="text-xs text-red-600">{errors.unitPrice.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Steuersatz</Label>
|
||||
<Select
|
||||
defaultValue={String(defaultValues?.taxRate ?? 19)}
|
||||
onValueChange={(v) => setValue("taxRate", Number(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TAX_RATES.map((r) => (
|
||||
<SelectItem key={r.value} value={String(r.value)}>
|
||||
{r.value}%
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Speichern..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type SortKey = "name" | "description" | "unit" | "unitPrice" | "taxRate";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
export default function LeistungenPage() {
|
||||
const { services, companyId } = useLoaderData<typeof loader>();
|
||||
const { revalidate } = useRevalidator();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editService, setEditService] = useState<Service | null>(null);
|
||||
const [sortKey, setSortKey] = useState<SortKey>("name");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir("asc");
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...services].sort((a, b) => {
|
||||
const av = a[sortKey] ?? "";
|
||||
const bv = b[sortKey] ?? "";
|
||||
const cmp = typeof av === "number" && typeof bv === "number"
|
||||
? av - bv
|
||||
: String(av).localeCompare(String(bv), "de");
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
async function handleCreate(data: FormData) {
|
||||
await fetch("/api/services", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...data, companyId }),
|
||||
});
|
||||
setOpen(false);
|
||||
revalidate();
|
||||
}
|
||||
|
||||
async function handleEdit(data: FormData) {
|
||||
if (!editService) return;
|
||||
await fetch(`/api/services/${editService.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
setEditService(null);
|
||||
revalidate();
|
||||
}
|
||||
|
||||
async function handleDelete(serviceId: string) {
|
||||
if (!confirm("Leistung wirklich löschen?")) return;
|
||||
await fetch(`/api/services/${serviceId}`, { 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">Leistungen</h1>
|
||||
<p className="text-gray-500 mt-1">{services.length} {services.length === 1 ? "Leistung" : "Leistungen"}</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="h-4 w-4" /> Leistung anlegen</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neue Leistung</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ServiceForm onSubmit={handleCreate} submitLabel="Anlegen" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!editService} onOpenChange={(o) => !o && setEditService(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Leistung bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editService && (
|
||||
<ServiceForm
|
||||
defaultValues={{
|
||||
name: editService.name,
|
||||
description: editService.description ?? undefined,
|
||||
unit: editService.unit ?? undefined,
|
||||
unitPrice: editService.unitPrice,
|
||||
taxRate: editService.taxRate,
|
||||
}}
|
||||
onSubmit={handleEdit}
|
||||
submitLabel="Speichern"
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{services.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<Briefcase className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-sm">Noch keine Leistungen angelegt</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||||
{(["name", "description", "unit", "unitPrice", "taxRate"] as SortKey[]).map((key, i) => {
|
||||
const labels: Record<SortKey, string> = { name: "Bezeichnung", description: "Beschreibung", unit: "Einheit", unitPrice: "Preis", taxRate: "MwSt." };
|
||||
const isNum = key === "unitPrice" || key === "taxRate";
|
||||
const active = sortKey === key;
|
||||
const Icon = active ? (sortDir === "asc" ? ChevronUp : ChevronDown) : ChevronsUpDown;
|
||||
return (
|
||||
<th key={key} className={`px-4 py-3 ${isNum ? "text-right" : "text-left"}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort(key)}
|
||||
className={`inline-flex items-center gap-1 hover:text-slate-800 transition-colors ${active ? "text-slate-800" : ""}`}
|
||||
>
|
||||
{labels[key]}
|
||||
<Icon className="h-3 w-3" />
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{sorted.map((service) => (
|
||||
<tr key={service.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-slate-800">{service.name}</td>
|
||||
<td className="px-4 py-3 text-slate-500 max-w-xs truncate">{service.description ?? "-"}</td>
|
||||
<td className="px-4 py-3 text-slate-500">{service.unit ?? "-"}</td>
|
||||
<td className="px-4 py-3 text-right text-slate-800">{formatCurrency(service.unitPrice)}</td>
|
||||
<td className="px-4 py-3 text-right text-slate-500">{service.taxRate}%</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => setEditService(service)}>
|
||||
<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(service.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { formatCurrency, formatDate } from "@/lib/tax";
|
||||
import {
|
||||
FileText, Users, BarChart3, Plus, Edit, Building2,
|
||||
Mail, Phone, CreditCard, Receipt, Archive, ArchiveRestore, AlertTriangle
|
||||
Mail, Phone, CreditCard, Receipt, Archive, ArchiveRestore, AlertTriangle, Briefcase
|
||||
} from "lucide-react";
|
||||
import { InvoiceStatus } from "@prisma/client";
|
||||
import { useState } from "react";
|
||||
@@ -35,6 +35,18 @@ const statusVariants: Record<InvoiceStatus, "secondary" | "default" | "success"
|
||||
DELETED: "outline",
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads a company by its ID.
|
||||
*
|
||||
* The response contains the company's name, archived at date, invoices, and revenue.
|
||||
*
|
||||
* The invoices are paginated to show the 5 most recent ones.
|
||||
*
|
||||
* The revenue is the sum of all paid invoices.
|
||||
*
|
||||
* If the company is not found, returns a 404 response with an error message.
|
||||
* If the user is not authorized, returns a 401 response with an error message.
|
||||
*/
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await requireUser(request);
|
||||
const { id } = params;
|
||||
@@ -43,6 +55,7 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
where: { id, userId: user.id },
|
||||
include: {
|
||||
invoices: {
|
||||
where: { status: { not: InvoiceStatus.DELETED } },
|
||||
include: { customer: { select: { name: true } } },
|
||||
orderBy: { issueDate: "desc" },
|
||||
take: 5,
|
||||
@@ -74,6 +87,22 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CompanyPage displays information about a company.
|
||||
*
|
||||
* The page displays the company's name, address, and legal form.
|
||||
* It also displays the company's archived status, if applicable.
|
||||
* If the user is an admin, the page displays buttons to toggle the company's archived status and to edit the company.
|
||||
*
|
||||
* The page also displays a list of the company's most recent invoices.
|
||||
* The list shows the invoice number, customer name, issue date, and gross total.
|
||||
* The user can click on an invoice to view its details.
|
||||
*
|
||||
* The page also displays the company's revenue, which is the sum of all paid invoices.
|
||||
* If the company has a tax ID or VAT ID, the page displays it.
|
||||
*
|
||||
* Finally, the page displays contact information for the company, if applicable.
|
||||
*/
|
||||
export default function CompanyPage() {
|
||||
const { company, revenue, isAdmin } = useLoaderData<typeof loader>();
|
||||
const { revalidate } = useRevalidator();
|
||||
@@ -183,6 +212,16 @@ export default function CompanyPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link to={`/companies/${id}/leistungen`} className="block">
|
||||
<Card className="hover:border-orange-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-orange-50">
|
||||
<Briefcase className="h-4 w-4 text-orange-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">Leistungen</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">
|
||||
@@ -218,7 +257,7 @@ export default function CompanyPage() {
|
||||
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-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">
|
||||
|
||||
@@ -319,7 +319,7 @@ export default function CompaniesPage() {
|
||||
className="flex items-center justify-between py-2.5 hover:bg-slate-50 -mx-1 px-1 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900">{invoice.number}</p>
|
||||
<p className="text-sm font-medium text-slate-900">{invoice.number ?? "-"}</p>
|
||||
<p className="text-xs text-slate-400 truncate">
|
||||
{invoice.customer.name} · {formatDate(invoice.issueDate)}
|
||||
</p>
|
||||
|
||||
+22
-8
@@ -3,7 +3,8 @@ 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 { Calculator, AlertCircle } from "lucide-react";
|
||||
import { Calculator, AlertCircle, Eye, EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export async function loader({ request }: { request: Request }) {
|
||||
const { userId } = await getUserSession(request);
|
||||
@@ -26,6 +27,7 @@ export default function LoginPage() {
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const loading = navigation.state === "submitting";
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
@@ -109,13 +111,25 @@ export default function LoginPage() {
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">Passwort</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-400 hover:text-slate-600"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? "Passwort verbergen" : "Passwort anzeigen"}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full h-10 mt-2" disabled={loading}>
|
||||
|
||||
@@ -7,7 +7,7 @@ const sessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: "__session",
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
maxAge: process.env.NODE_ENV === "development" ? 60 * 60 * 24 * 30 : 60 * 60 * 4,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secrets: [process.env.AUTH_SECRET ?? "fallback-secret-change-in-production"],
|
||||
|
||||
Reference in New Issue
Block a user