ADD: added some quiality of life features

This commit is contained in:
hwinkel
2026-03-15 19:53:11 +01:00
parent f5b259cae2
commit 40a2764dd0
30 changed files with 1397 additions and 51 deletions
+41 -2
View File
@@ -41,6 +41,11 @@ type Pages = {
"id": string; "id": string;
}; };
}; };
"/companies/:id/leistungen": {
params: {
"id": string;
};
};
"/companies/:id/invoices": { "/companies/:id/invoices": {
params: { params: {
"id": string; "id": string;
@@ -57,6 +62,12 @@ type Pages = {
"invoiceId": string; "invoiceId": string;
}; };
}; };
"/companies/:id/invoices/:invoiceId/edit": {
params: {
"id": string;
"invoiceId": string;
};
};
"/companies/:id/reports": { "/companies/:id/reports": {
params: { params: {
"id": string; "id": string;
@@ -108,6 +119,14 @@ type Pages = {
"id": string; "id": string;
}; };
}; };
"/api/services": {
params: {};
};
"/api/services/:id": {
params: {
"id": string;
};
};
"/api/invoices": { "/api/invoices": {
params: {}; params: {};
}; };
@@ -129,7 +148,7 @@ type Pages = {
type RouteFiles = { type RouteFiles = {
"root.tsx": { "root.tsx": {
id: "root"; id: "root";
page: "/" | "/login" | "/logout" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/reports" | "/archiv" | "/settings/password" | "/admin/users" | "/admin/users/new" | "/admin/users/:id" | "/admin/logs" | "/api/companies" | "/api/companies/:id" | "/api/companies/:id/customers" | "/api/companies/:id/invoices" | "/api/customers" | "/api/customers/:id" | "/api/invoices" | "/api/invoices/:id" | "/api/invoices/:id/pdf" | "/api/reports"; page: "/" | "/login" | "/logout" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/leistungen" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/invoices/:invoiceId/edit" | "/companies/:id/reports" | "/archiv" | "/settings/password" | "/admin/users" | "/admin/users/new" | "/admin/users/:id" | "/admin/logs" | "/api/companies" | "/api/companies/:id" | "/api/companies/:id/customers" | "/api/companies/:id/invoices" | "/api/customers" | "/api/customers/:id" | "/api/services" | "/api/services/:id" | "/api/invoices" | "/api/invoices/:id" | "/api/invoices/:id/pdf" | "/api/reports";
}; };
"routes/login.tsx": { "routes/login.tsx": {
id: "routes/login"; id: "routes/login";
@@ -141,7 +160,7 @@ type RouteFiles = {
}; };
"routes/dashboard-layout.tsx": { "routes/dashboard-layout.tsx": {
id: "routes/dashboard-layout"; id: "routes/dashboard-layout";
page: "/" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/reports" | "/archiv" | "/settings/password"; page: "/" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/leistungen" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/invoices/:invoiceId/edit" | "/companies/:id/reports" | "/archiv" | "/settings/password";
}; };
"routes/home.tsx": { "routes/home.tsx": {
id: "routes/home"; id: "routes/home";
@@ -167,6 +186,10 @@ type RouteFiles = {
id: "routes/companies.$id.customers"; id: "routes/companies.$id.customers";
page: "/companies/:id/customers"; page: "/companies/:id/customers";
}; };
"routes/companies.$id.leistungen.tsx": {
id: "routes/companies.$id.leistungen";
page: "/companies/:id/leistungen";
};
"routes/companies.$id.invoices.tsx": { "routes/companies.$id.invoices.tsx": {
id: "routes/companies.$id.invoices"; id: "routes/companies.$id.invoices";
page: "/companies/:id/invoices"; page: "/companies/:id/invoices";
@@ -179,6 +202,10 @@ type RouteFiles = {
id: "routes/companies.$id.invoices.$invoiceId"; id: "routes/companies.$id.invoices.$invoiceId";
page: "/companies/:id/invoices/:invoiceId"; page: "/companies/:id/invoices/:invoiceId";
}; };
"routes/companies.$id.invoices.$invoiceId.edit.tsx": {
id: "routes/companies.$id.invoices.$invoiceId.edit";
page: "/companies/:id/invoices/:invoiceId/edit";
};
"routes/companies.$id.reports.tsx": { "routes/companies.$id.reports.tsx": {
id: "routes/companies.$id.reports"; id: "routes/companies.$id.reports";
page: "/companies/:id/reports"; page: "/companies/:id/reports";
@@ -235,6 +262,14 @@ type RouteFiles = {
id: "routes/api.customers.$id"; id: "routes/api.customers.$id";
page: "/api/customers/:id"; page: "/api/customers/:id";
}; };
"routes/api.services.ts": {
id: "routes/api.services";
page: "/api/services";
};
"routes/api.services.$id.ts": {
id: "routes/api.services.$id";
page: "/api/services/:id";
};
"routes/api.invoices.ts": { "routes/api.invoices.ts": {
id: "routes/api.invoices"; id: "routes/api.invoices";
page: "/api/invoices"; page: "/api/invoices";
@@ -264,9 +299,11 @@ type RouteModules = {
"routes/companies.$id": typeof import("./app/routes/companies.$id.tsx"); "routes/companies.$id": typeof import("./app/routes/companies.$id.tsx");
"routes/companies.$id.edit": typeof import("./app/routes/companies.$id.edit.tsx"); "routes/companies.$id.edit": typeof import("./app/routes/companies.$id.edit.tsx");
"routes/companies.$id.customers": typeof import("./app/routes/companies.$id.customers.tsx"); "routes/companies.$id.customers": typeof import("./app/routes/companies.$id.customers.tsx");
"routes/companies.$id.leistungen": typeof import("./app/routes/companies.$id.leistungen.tsx");
"routes/companies.$id.invoices": typeof import("./app/routes/companies.$id.invoices.tsx"); "routes/companies.$id.invoices": typeof import("./app/routes/companies.$id.invoices.tsx");
"routes/companies.$id.invoices.new": typeof import("./app/routes/companies.$id.invoices.new.tsx"); "routes/companies.$id.invoices.new": typeof import("./app/routes/companies.$id.invoices.new.tsx");
"routes/companies.$id.invoices.$invoiceId": typeof import("./app/routes/companies.$id.invoices.$invoiceId.tsx"); "routes/companies.$id.invoices.$invoiceId": typeof import("./app/routes/companies.$id.invoices.$invoiceId.tsx");
"routes/companies.$id.invoices.$invoiceId.edit": typeof import("./app/routes/companies.$id.invoices.$invoiceId.edit.tsx");
"routes/companies.$id.reports": typeof import("./app/routes/companies.$id.reports.tsx"); "routes/companies.$id.reports": typeof import("./app/routes/companies.$id.reports.tsx");
"routes/archiv": typeof import("./app/routes/archiv.tsx"); "routes/archiv": typeof import("./app/routes/archiv.tsx");
"routes/settings.password": typeof import("./app/routes/settings.password.tsx"); "routes/settings.password": typeof import("./app/routes/settings.password.tsx");
@@ -281,6 +318,8 @@ type RouteModules = {
"routes/api.companies.$id.invoices": typeof import("./app/routes/api.companies.$id.invoices.ts"); "routes/api.companies.$id.invoices": typeof import("./app/routes/api.companies.$id.invoices.ts");
"routes/api.customers": typeof import("./app/routes/api.customers.ts"); "routes/api.customers": typeof import("./app/routes/api.customers.ts");
"routes/api.customers.$id": typeof import("./app/routes/api.customers.$id.ts"); "routes/api.customers.$id": typeof import("./app/routes/api.customers.$id.ts");
"routes/api.services": typeof import("./app/routes/api.services.ts");
"routes/api.services.$id": typeof import("./app/routes/api.services.$id.ts");
"routes/api.invoices": typeof import("./app/routes/api.invoices.ts"); "routes/api.invoices": typeof import("./app/routes/api.invoices.ts");
"routes/api.invoices.$id": typeof import("./app/routes/api.invoices.$id.ts"); "routes/api.invoices.$id": typeof import("./app/routes/api.invoices.$id.ts");
"routes/api.invoices.$id.pdf": typeof import("./app/routes/api.invoices.$id.pdf.ts"); "routes/api.invoices.$id.pdf": typeof import("./app/routes/api.invoices.$id.pdf.ts");
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.services.$id.js")
type Info = GetInfo<{
file: "routes/api.services.$id.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.services.$id";
module: typeof import("../api.services.$id.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.services.js")
type Info = GetInfo<{
file: "routes/api.services.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.services";
module: typeof import("../api.services.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,65 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.invoices.$invoiceId.edit.js")
type Info = GetInfo<{
file: "routes/companies.$id.invoices.$invoiceId.edit.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id.invoices.$invoiceId.edit";
module: typeof import("../companies.$id.invoices.$invoiceId.edit.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -0,0 +1,65 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.leistungen.js")
type Info = GetInfo<{
file: "routes/companies.$id.leistungen.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id.leistungen";
module: typeof import("../companies.$id.leistungen.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
+116 -14
View File
@@ -1,4 +1,5 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { useRevalidator } from "react-router";
import { useForm, useFieldArray } from "react-hook-form"; import { useForm, useFieldArray } from "react-hook-form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { calcItemAmounts, calcItemAmountsKleinunternehmer, calcInvoiceTotals, formatCurrency, TAX_RATES } from "@/lib/tax"; import { calcItemAmounts, calcItemAmountsKleinunternehmer, calcInvoiceTotals, formatCurrency, TAX_RATES } from "@/lib/tax";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface Customer { interface Customer {
id: string; id: string;
@@ -34,12 +36,23 @@ interface InvoiceFormValues {
items: ItemFormData[]; items: ItemFormData[];
} }
interface ServiceOption {
id: string;
name: string;
description: string | null;
unit: string | null;
unitPrice: number;
taxRate: number;
}
interface InvoiceFormProps { interface InvoiceFormProps {
customers: Customer[]; customers: Customer[];
companyId: string; companyId: string;
onSubmit: (data: Record<string, unknown>) => Promise<void>; onSubmit: (data: Record<string, unknown>) => Promise<void>;
defaultValues?: Partial<InvoiceFormValues>; defaultValues?: Partial<InvoiceFormValues>;
defaultKleinunternehmer?: boolean; defaultKleinunternehmer?: boolean;
submitLabel?: string;
services?: ServiceOption[];
} }
const defaultItem = (): ItemFormData => ({ const defaultItem = (): ItemFormData => ({
@@ -54,19 +67,33 @@ const defaultItem = (): ItemFormData => ({
grossAmount: 0, 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 today = new Date().toISOString().split("T")[0];
const dueDefault = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).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 [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>({ const { register, handleSubmit, watch, setValue, control, formState: { errors, isSubmitting } } = useForm<InvoiceFormValues>({
defaultValues: { defaultValues: {
customerId: "", customerId: defaultValues?.customerId ?? "",
issueDate: today, issueDate: defaultValues?.issueDate ?? today,
deliveryDate: today, deliveryDate: defaultValues?.deliveryDate ?? today,
dueDate: dueDefault, dueDate: defaultValues?.dueDate ?? dueDefault,
notes: "", notes: defaultValues?.notes ?? "",
items: [defaultItem()], 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) { async function handleFormSubmit(data: InvoiceFormValues) {
const items = data.items.map((item, i) => { const items = data.items.map((item, i) => {
const qty = parseFloat(item.quantity) || 0; const qty = parseFloat(item.quantity) || 0;
@@ -135,6 +173,31 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultKleinuntern
const totals = calcInvoiceTotals(items); 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({ await onSubmit({
companyId, companyId,
customerId: data.customerId, 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="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Kunde *</Label> <Label>Kunde *</Label>
<Select onValueChange={(v) => setValue("customerId", v)}> <Select defaultValue={defaultValues?.customerId} onValueChange={(v) => setValue("customerId", v)}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Kunde auswählen..." /> <SelectValue placeholder="Kunde auswählen..." />
</SelectTrigger> </SelectTrigger>
@@ -212,8 +275,8 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultKleinuntern
</Button> </Button>
</div> </div>
<div className="border border-gray-200 rounded-xl overflow-hidden"> <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 ${kleinunternehmer ? "grid-cols-11" : "grid-cols-12"}`}> <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-4">Beschreibung</div>
<div className="col-span-1">Menge</div> <div className="col-span-1">Menge</div>
<div className="col-span-1">Einh.</div> <div className="col-span-1">Einh.</div>
@@ -225,12 +288,51 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultKleinuntern
{fields.map((field, index) => ( {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 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"> <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 <Input
{...register(`items.${index}.description`, { required: true })} value={descValue}
onChange={(e) => setValue(`items.${index}.description`, e.target.value)}
onFocus={() => setOpenDropdown(index)}
onBlur={() => setOpenDropdown(null)}
placeholder="Leistungsbeschreibung" placeholder="Leistungsbeschreibung"
className="text-sm" 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>
<div className="col-span-1"> <div className="col-span-1">
<Input <Input
@@ -256,7 +358,7 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultKleinuntern
{!kleinunternehmer && ( {!kleinunternehmer && (
<div className="col-span-1"> <div className="col-span-1">
<Select <Select
defaultValue="19" defaultValue={defaultValues?.items?.[index]?.taxRate ?? "19"}
onValueChange={(v) => { onValueChange={(v) => {
setValue(`items.${index}.taxRate`, v); setValue(`items.${index}.taxRate`, v);
recalcItem(index); recalcItem(index);
@@ -334,7 +436,7 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultKleinuntern
<div className="flex justify-end pt-2"> <div className="flex justify-end pt-2">
<Button type="submit" disabled={isSubmitting} size="lg"> <Button type="submit" disabled={isSubmitting} size="lg">
{isSubmitting ? "Erstelle Rechnung..." : "Rechnung erstellen"} {isSubmitting ? "Speichere..." : submitLabel}
</Button> </Button>
</div> </div>
</form> </form>
+2 -2
View File
@@ -199,7 +199,7 @@ function formatDate(date: Date | string) {
interface InvoicePDFProps { interface InvoicePDFProps {
invoice: { invoice: {
number: string; number: string | null;
issueDate: Date | string; issueDate: Date | string;
deliveryDate?: Date | string | null; deliveryDate?: Date | string | null;
dueDate: Date | string; dueDate: Date | string;
@@ -302,7 +302,7 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
<View style={styles.metaGrid}> <View style={styles.metaGrid}>
<View style={styles.metaItem}> <View style={styles.metaItem}>
<Text style={styles.metaLabel}>Rechnungsnummer</Text> <Text style={styles.metaLabel}>Rechnungsnummer</Text>
<Text style={styles.metaValue}>{invoice.number}</Text> <Text style={styles.metaValue}>{invoice.number ?? "-"}</Text>
</View> </View>
<View style={styles.metaItem}> <View style={styles.metaItem}>
<Text style={styles.metaLabel}>Rechnungsdatum</Text> <Text style={styles.metaLabel}>Rechnungsdatum</Text>
+51
View File
@@ -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);
});
}
+35
View File
@@ -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");
}
+4
View File
@@ -11,9 +11,11 @@ export default [
route("companies/:id", "routes/companies.$id.tsx"), route("companies/:id", "routes/companies.$id.tsx"),
route("companies/:id/edit", "routes/companies.$id.edit.tsx"), route("companies/:id/edit", "routes/companies.$id.edit.tsx"),
route("companies/:id/customers", "routes/companies.$id.customers.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", "routes/companies.$id.invoices.tsx"),
route("companies/:id/invoices/new", "routes/companies.$id.invoices.new.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", "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("companies/:id/reports", "routes/companies.$id.reports.tsx"),
route("archiv", "routes/archiv.tsx"), route("archiv", "routes/archiv.tsx"),
route("settings/password", "routes/settings.password.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/companies/:id/invoices", "routes/api.companies.$id.invoices.ts"),
route("api/customers", "routes/api.customers.ts"), route("api/customers", "routes/api.customers.ts"),
route("api/customers/:id", "routes/api.customers.$id.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", "routes/api.invoices.ts"),
route("api/invoices/:id", "routes/api.invoices.$id.ts"), route("api/invoices/:id", "routes/api.invoices.$id.ts"),
route("api/invoices/:id/pdf", "routes/api.invoices.$id.pdf.ts"), route("api/invoices/:id/pdf", "routes/api.invoices.$id.pdf.ts"),
+1 -1
View File
@@ -28,7 +28,7 @@ export async function loader({ request, params }: { request: Request; params: {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "application/pdf", "Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="rechnung-${invoice.number}.pdf"`, "Content-Disposition": `attachment; filename="rechnung-${invoice.number ?? invoice.id}.pdf"`,
}, },
}); });
} }
+68 -2
View File
@@ -1,5 +1,6 @@
import { getApiUser } from "@/session.server"; import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server"; import prisma from "@/lib/prisma.server";
import { generateInvoiceNumber } from "@/lib/invoice-number.server";
import { InvoiceStatus } from "@prisma/client"; import { InvoiceStatus } from "@prisma/client";
import { z } from "zod"; 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 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 } }) { export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request); const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); 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); const invoice = await getInvoice(params.id, user.id);
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 }); 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") { if (request.method === "DELETE") {
const isAdmin = user.role === "ADMIN"; 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)) { if (!isAdmin && !deletableStatuses.includes(invoice.status)) {
return Response.json( return Response.json(
{ error: "Nur Entwürfe und stornierte Rechnungen können gelöscht werden." }, { 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); const parsed = statusSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 }); 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({ const updated = await prisma.invoice.update({
where: { id: params.id }, 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); return Response.json(updated);
} }
+33
View File
@@ -29,6 +29,39 @@ const invoiceSchema = z.object({
grossTotal: z.number(), 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 }) { export async function action({ request }: { request: Request }) {
const user = await getApiUser(request); const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
+34
View File
@@ -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);
}
+29
View File
@@ -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"; import { Link, useLoaderData, useNavigate, useRevalidator } from "react-router";
export const handle = { 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: "Mandanten", href: "/companies" },
{ label: data.invoice.company.name, href: `/companies/${data.invoice.companyId}` }, { label: data.invoice.company.name, href: `/companies/${data.invoice.companyId}` },
{ label: "Rechnungen", href: `/companies/${data.invoice.companyId}/invoices` }, { label: "Rechnungen", href: `/companies/${data.invoice.companyId}/invoices` },
{ label: data.invoice.number }, { label: data.invoice.number ?? "-" },
], ],
}; };
import { requireUser } from "@/session.server"; 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 { Button } from "@/components/ui/button";
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge"; import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
import { formatCurrency, formatDate } from "@/lib/tax"; 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"; import type { InvoiceStatus } from "@prisma/client";
export async function loader({ 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() { export default function InvoiceDetailPage() {
const { invoice, isAdmin } = useLoaderData<typeof loader>(); const { invoice, isAdmin } = useLoaderData<typeof loader>();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -81,6 +92,15 @@ export default function InvoiceDetailPage() {
{} as Record<number, { net: number; tax: number }> {} 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) { async function updateStatus(status: InvoiceStatus) {
setLoading(true); setLoading(true);
await fetch(`/api/invoices/${invoice.id}`, { await fetch(`/api/invoices/${invoice.id}`, {
@@ -92,6 +112,13 @@ export default function InvoiceDetailPage() {
revalidate(); 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() { async function handleSoftDelete() {
if (!confirm("Rechnung in den Papierkorb verschieben?")) return; if (!confirm("Rechnung in den Papierkorb verschieben?")) return;
setLoading(true); setLoading(true);
@@ -104,23 +131,43 @@ export default function InvoiceDetailPage() {
revalidate(); 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() { async function handleRestore() {
setLoading(true); setLoading(true);
await fetch(`/api/invoices/${invoice.id}`, { await fetch(`/api/invoices/${invoice.id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "CANCELLED" }), body: JSON.stringify({ status: "DRAFT" }),
}); });
setLoading(false); setLoading(false);
revalidate(); 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() { async function handleHardDelete() {
if (!confirm("Rechnung endgültig löschen? Dies kann nicht rückgängig gemacht werden.")) return; if (!confirm("Rechnung endgültig löschen? Dies kann nicht rückgängig gemacht werden.")) return;
await fetch(`/api/invoices/${invoice.id}`, { method: "DELETE" }); await fetch(`/api/invoices/${invoice.id}`, { method: "DELETE" });
navigate(`/companies/${id}/invoices`); 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() { async function downloadPdf() {
const res = await fetch(`/api/invoices/${invoice.id}/pdf`); const res = await fetch(`/api/invoices/${invoice.id}/pdf`);
if (!res.ok) return; if (!res.ok) return;
@@ -128,7 +175,7 @@ export default function InvoiceDetailPage() {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `rechnung-${invoice.number}.pdf`; a.download = `rechnung-${invoice.number ?? invoice.id}.pdf`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
@@ -145,7 +192,7 @@ export default function InvoiceDetailPage() {
<div className="flex items-start justify-between mb-8"> <div className="flex items-start justify-between mb-8">
<div> <div>
<div className="flex items-center gap-3 mb-1"> <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} /> <InvoiceStatusBadge status={invoice.status} />
</div> </div>
<p className="text-gray-500"> <p className="text-gray-500">
@@ -160,6 +207,13 @@ export default function InvoiceDetailPage() {
<Download className="h-4 w-4" /> PDF <Download className="h-4 w-4" /> PDF
</Button> </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" && ( {invoice.status === "DRAFT" && (
<Button size="sm" onClick={() => updateStatus("SENT")} disabled={loading}> <Button size="sm" onClick={() => updateStatus("SENT")} disabled={loading}>
<Send className="h-4 w-4" /> Als versendet markieren <Send className="h-4 w-4" /> Als versendet markieren
+34 -3
View File
@@ -14,6 +14,17 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { InvoiceForm } from "@/components/invoice/invoice-form"; import { InvoiceForm } from "@/components/invoice/invoice-form";
import { ChevronLeft } from "lucide-react"; 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 } }) { export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request); const user = await requireUser(request);
const { id } = params; const { id } = params;
@@ -33,11 +44,31 @@ export async function loader({ request, params }: { request: Request; params: {
throw redirect(`/companies/${id}/customers`); 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() { export default function NewInvoicePage() {
const { company, customers } = useLoaderData<typeof loader>(); const { company, customers, services } = useLoaderData<typeof loader>();
const navigate = useNavigate(); const navigate = useNavigate();
async function handleSubmit(data: Record<string, unknown>) { async function handleSubmit(data: Record<string, unknown>) {
@@ -74,7 +105,7 @@ export default function NewInvoicePage() {
<CardTitle>Rechnungsdaten</CardTitle> <CardTitle>Rechnungsdaten</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <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> </CardContent>
</Card> </Card>
</div> </div>
+46 -4
View File
@@ -1,4 +1,4 @@
import { Link, useLoaderData } from "react-router"; import { Link, useLoaderData, useRevalidator } from "react-router";
export const handle = { export const handle = {
breadcrumbs: (data: { company: { id: string; name: string } }) => [ breadcrumbs: (data: { company: { id: string; name: string } }) => [
@@ -13,7 +13,7 @@ import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge"; import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
import { formatCurrency, formatDate } from "@/lib/tax"; 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"; import { useState } from "react";
export async function loader({ request, params }: { request: Request; params: { id: string } }) { 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), grossTotal: Number(inv.grossTotal),
issueDate: inv.issueDate.toISOString(), issueDate: inv.issueDate.toISOString(),
dueDate: inv.dueDate.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" /> <FileText className="h-3.5 w-3.5 text-slate-400 group-hover:text-indigo-500 transition-colors" />
</div> </div>
<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> <p className="text-xs text-slate-400">{invoice.customer.name}</p>
</div> </div>
</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({ function DeletedPanel({
invoices, invoices,
companyId, companyId,
@@ -159,7 +201,7 @@ function DeletedPanel({
{open && ( {open && (
<div className="divide-y divide-red-50 border-t border-red-100"> <div className="divide-y divide-red-50 border-t border-red-100">
{invoices.map((invoice) => ( {invoices.map((invoice) => (
<InvoiceRow key={invoice.id} invoice={invoice} companyId={companyId} /> <DeletedInvoiceRow key={invoice.id} invoice={invoice} companyId={companyId} />
))} ))}
</div> </div>
)} )}
+300
View File
@@ -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>
);
}
+41 -2
View File
@@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge";
import { formatCurrency, formatDate } from "@/lib/tax"; import { formatCurrency, formatDate } from "@/lib/tax";
import { import {
FileText, Users, BarChart3, Plus, Edit, Building2, FileText, Users, BarChart3, Plus, Edit, Building2,
Mail, Phone, CreditCard, Receipt, Archive, ArchiveRestore, AlertTriangle Mail, Phone, CreditCard, Receipt, Archive, ArchiveRestore, AlertTriangle, Briefcase
} from "lucide-react"; } from "lucide-react";
import { InvoiceStatus } from "@prisma/client"; import { InvoiceStatus } from "@prisma/client";
import { useState } from "react"; import { useState } from "react";
@@ -35,6 +35,18 @@ const statusVariants: Record<InvoiceStatus, "secondary" | "default" | "success"
DELETED: "outline", 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 } }) { export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request); const user = await requireUser(request);
const { id } = params; const { id } = params;
@@ -43,6 +55,7 @@ export async function loader({ request, params }: { request: Request; params: {
where: { id, userId: user.id }, where: { id, userId: user.id },
include: { include: {
invoices: { invoices: {
where: { status: { not: InvoiceStatus.DELETED } },
include: { customer: { select: { name: true } } }, include: { customer: { select: { name: true } } },
orderBy: { issueDate: "desc" }, orderBy: { issueDate: "desc" },
take: 5, 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() { export default function CompanyPage() {
const { company, revenue, isAdmin } = useLoaderData<typeof loader>(); const { company, revenue, isAdmin } = useLoaderData<typeof loader>();
const { revalidate } = useRevalidator(); const { revalidate } = useRevalidator();
@@ -183,6 +212,16 @@ export default function CompanyPage() {
</CardContent> </CardContent>
</Card> </Card>
</Link> </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"> <Link to={`/companies/${id}/reports`} className="block">
<Card className="hover:border-purple-200 hover:shadow-sm transition-all cursor-pointer"> <Card className="hover:border-purple-200 hover:shadow-sm transition-all cursor-pointer">
<CardContent className="pt-4 pb-4 flex items-center gap-3"> <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" className="flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition-colors"
> >
<div> <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> <p className="text-xs text-gray-500">{invoice.customer.name} · {formatDate(invoice.issueDate)}</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
+1 -1
View File
@@ -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" 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"> <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"> <p className="text-xs text-slate-400 truncate">
{invoice.customer.name} · {formatDate(invoice.issueDate)} {invoice.customer.name} · {formatDate(invoice.issueDate)}
</p> </p>
+16 -2
View File
@@ -3,7 +3,8 @@ import { login, createUserSession, getUserSession } from "@/session.server";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; 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 }) { export async function loader({ request }: { request: Request }) {
const { userId } = await getUserSession(request); const { userId } = await getUserSession(request);
@@ -26,6 +27,7 @@ export default function LoginPage() {
const actionData = useActionData<typeof action>(); const actionData = useActionData<typeof action>();
const navigation = useNavigation(); const navigation = useNavigation();
const loading = navigation.state === "submitting"; const loading = navigation.state === "submitting";
const [showPassword, setShowPassword] = useState(false);
return ( return (
<div className="min-h-screen flex"> <div className="min-h-screen flex">
@@ -109,13 +111,25 @@ export default function LoginPage() {
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="password">Passwort</Label> <Label htmlFor="password">Passwort</Label>
<div className="relative">
<Input <Input
id="password" id="password"
name="password" name="password"
type="password" type={showPassword ? "text" : "password"}
required required
autoComplete="current-password" 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> </div>
<Button type="submit" className="w-full h-10 mt-2" disabled={loading}> <Button type="submit" className="w-full h-10 mt-2" disabled={loading}>
+1 -1
View File
@@ -7,7 +7,7 @@ const sessionStorage = createCookieSessionStorage({
cookie: { cookie: {
name: "__session", name: "__session",
httpOnly: true, httpOnly: true,
maxAge: 60 * 60 * 24 * 30, maxAge: process.env.NODE_ENV === "development" ? 60 * 60 * 24 * 30 : 60 * 60 * 4,
path: "/", path: "/",
sameSite: "lax", sameSite: "lax",
secrets: [process.env.AUTH_SECRET ?? "fallback-secret-change-in-production"], secrets: [process.env.AUTH_SECRET ?? "fallback-secret-change-in-production"],
+16
View File
@@ -27,6 +27,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"isbot": "^5", "isbot": "^5",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"node-cron": "^4.2.1",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"react": "^19", "react": "^19",
"react-dom": "^19", "react-dom": "^19",
@@ -39,6 +40,7 @@
"@react-router/dev": "^7.13.1", "@react-router/dev": "^7.13.1",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/node-cron": "^3.0.11",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitejs/plugin-react": "^4", "@vitejs/plugin-react": "^4",
@@ -3257,6 +3259,12 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/node-cron": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
"dev": true
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -5480,6 +5488,14 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.36", "version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
+2
View File
@@ -39,6 +39,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"isbot": "^5", "isbot": "^5",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"node-cron": "^4.2.1",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"react": "^19", "react": "^19",
"react-dom": "^19", "react-dom": "^19",
@@ -51,6 +52,7 @@
"@react-router/dev": "^7.13.1", "@react-router/dev": "^7.13.1",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/node-cron": "^3.0.11",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitejs/plugin-react": "^4", "@vitejs/plugin-react": "^4",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `invoices` ADD COLUMN `deletedAt` DATETIME(3) NULL;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `invoices` MODIFY `number` VARCHAR(191) NULL;
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE `services` (
`id` VARCHAR(191) NOT NULL,
`companyId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`description` TEXT NULL,
`unit` VARCHAR(191) NULL,
`unitPrice` DECIMAL(10, 2) NOT NULL,
`taxRate` DECIMAL(5, 2) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `services` ADD CONSTRAINT `services_companyId_fkey` FOREIGN KEY (`companyId`) REFERENCES `companies`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
+18 -1
View File
@@ -69,12 +69,28 @@ model Company {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
customers Customer[] customers Customer[]
invoices Invoice[] invoices Invoice[]
services Service[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@map("companies") @@map("companies")
} }
model Service {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
name String
description String? @db.Text
unit String?
unitPrice Decimal @db.Decimal(10, 2)
taxRate Decimal @db.Decimal(5, 2)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("services")
}
model Customer { model Customer {
id String @id @default(cuid()) id String @id @default(cuid())
companyId String companyId String
@@ -96,7 +112,7 @@ model Customer {
model Invoice { model Invoice {
id String @id @default(cuid()) id String @id @default(cuid())
number String number String?
companyId String companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
customerId String customerId String
@@ -113,6 +129,7 @@ model Invoice {
grossTotal Decimal @db.Decimal(10, 2) grossTotal Decimal @db.Decimal(10, 2)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime?
@@unique([companyId, number]) @@unique([companyId, number])
@@map("invoices") @@map("invoices")