From 40a2764dd083a4b599d79e1ec2cc9c390b879ef7 Mon Sep 17 00:00:00 2001 From: hwinkel Date: Sun, 15 Mar 2026 19:53:11 +0100 Subject: [PATCH] ADD: added some quiality of life features --- .react-router/types/+routes.ts | 43 ++- .../app/routes/+types/api.services.$id.ts | 62 ++++ .../types/app/routes/+types/api.services.ts | 62 ++++ .../companies.$id.invoices.$invoiceId.edit.ts | 65 ++++ .../routes/+types/companies.$id.leistungen.ts | 65 ++++ app/components/invoice/invoice-form.tsx | 138 ++++++-- app/components/invoice/invoice-pdf.tsx | 4 +- app/entry.server.tsx | 51 +++ app/lib/cleanup.server.ts | 35 ++ app/routes.ts | 4 + app/routes/api.invoices.$id.pdf.ts | 2 +- app/routes/api.invoices.$id.ts | 70 +++- app/routes/api.invoices.ts | 33 ++ app/routes/api.services.$id.ts | 34 ++ app/routes/api.services.ts | 29 ++ ...companies.$id.invoices.$invoiceId.edit.tsx | 163 ++++++++++ .../companies.$id.invoices.$invoiceId.tsx | 66 +++- app/routes/companies.$id.invoices.new.tsx | 37 ++- app/routes/companies.$id.invoices.tsx | 50 ++- app/routes/companies.$id.leistungen.tsx | 300 ++++++++++++++++++ app/routes/companies.$id.tsx | 43 ++- app/routes/companies.tsx | 2 +- app/routes/login.tsx | 30 +- app/session.server.ts | 2 +- package-lock.json | 16 + package.json | 2 + .../migration.sql | 2 + .../migration.sql | 2 + .../20260315182705_add_services/migration.sql | 17 + prisma/schema.prisma | 19 +- 30 files changed, 1397 insertions(+), 51 deletions(-) create mode 100644 .react-router/types/app/routes/+types/api.services.$id.ts create mode 100644 .react-router/types/app/routes/+types/api.services.ts create mode 100644 .react-router/types/app/routes/+types/companies.$id.invoices.$invoiceId.edit.ts create mode 100644 .react-router/types/app/routes/+types/companies.$id.leistungen.ts create mode 100644 app/entry.server.tsx create mode 100644 app/lib/cleanup.server.ts create mode 100644 app/routes/api.services.$id.ts create mode 100644 app/routes/api.services.ts create mode 100644 app/routes/companies.$id.invoices.$invoiceId.edit.tsx create mode 100644 app/routes/companies.$id.leistungen.tsx create mode 100644 prisma/migrations/20260315175158_add_deleted_at_to_invoice/migration.sql create mode 100644 prisma/migrations/20260315181453_nullable_invoice_number/migration.sql create mode 100644 prisma/migrations/20260315182705_add_services/migration.sql diff --git a/.react-router/types/+routes.ts b/.react-router/types/+routes.ts index 3b34c78..ff92dfc 100644 --- a/.react-router/types/+routes.ts +++ b/.react-router/types/+routes.ts @@ -41,6 +41,11 @@ type Pages = { "id": string; }; }; + "/companies/:id/leistungen": { + params: { + "id": string; + }; + }; "/companies/:id/invoices": { params: { "id": string; @@ -57,6 +62,12 @@ type Pages = { "invoiceId": string; }; }; + "/companies/:id/invoices/:invoiceId/edit": { + params: { + "id": string; + "invoiceId": string; + }; + }; "/companies/:id/reports": { params: { "id": string; @@ -108,6 +119,14 @@ type Pages = { "id": string; }; }; + "/api/services": { + params: {}; + }; + "/api/services/:id": { + params: { + "id": string; + }; + }; "/api/invoices": { params: {}; }; @@ -129,7 +148,7 @@ type Pages = { type RouteFiles = { "root.tsx": { 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": { id: "routes/login"; @@ -141,7 +160,7 @@ type RouteFiles = { }; "routes/dashboard-layout.tsx": { 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": { id: "routes/home"; @@ -167,6 +186,10 @@ type RouteFiles = { id: "routes/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": { id: "routes/companies.$id.invoices"; page: "/companies/:id/invoices"; @@ -179,6 +202,10 @@ type RouteFiles = { id: "routes/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": { id: "routes/companies.$id.reports"; page: "/companies/:id/reports"; @@ -235,6 +262,14 @@ type RouteFiles = { id: "routes/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": { id: "routes/api.invoices"; page: "/api/invoices"; @@ -264,9 +299,11 @@ type RouteModules = { "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.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.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.edit": typeof import("./app/routes/companies.$id.invoices.$invoiceId.edit.tsx"); "routes/companies.$id.reports": typeof import("./app/routes/companies.$id.reports.tsx"); "routes/archiv": typeof import("./app/routes/archiv.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.customers": typeof import("./app/routes/api.customers.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.$id": typeof import("./app/routes/api.invoices.$id.ts"); "routes/api.invoices.$id.pdf": typeof import("./app/routes/api.invoices.$id.pdf.ts"); diff --git a/.react-router/types/app/routes/+types/api.services.$id.ts b/.react-router/types/app/routes/+types/api.services.$id.ts new file mode 100644 index 0000000..43a9d02 --- /dev/null +++ b/.react-router/types/app/routes/+types/api.services.$id.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; + +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"]; +} \ No newline at end of file diff --git a/.react-router/types/app/routes/+types/api.services.ts b/.react-router/types/app/routes/+types/api.services.ts new file mode 100644 index 0000000..2a3c837 --- /dev/null +++ b/.react-router/types/app/routes/+types/api.services.ts @@ -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; + +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"]; +} \ No newline at end of file diff --git a/.react-router/types/app/routes/+types/companies.$id.invoices.$invoiceId.edit.ts b/.react-router/types/app/routes/+types/companies.$id.invoices.$invoiceId.edit.ts new file mode 100644 index 0000000..fe6ec27 --- /dev/null +++ b/.react-router/types/app/routes/+types/companies.$id.invoices.$invoiceId.edit.ts @@ -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; + +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"]; +} \ No newline at end of file diff --git a/.react-router/types/app/routes/+types/companies.$id.leistungen.ts b/.react-router/types/app/routes/+types/companies.$id.leistungen.ts new file mode 100644 index 0000000..8e4ad2e --- /dev/null +++ b/.react-router/types/app/routes/+types/companies.$id.leistungen.ts @@ -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; + +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"]; +} \ No newline at end of file diff --git a/app/components/invoice/invoice-form.tsx b/app/components/invoice/invoice-form.tsx index 457382b..38f870e 100644 --- a/app/components/invoice/invoice-form.tsx +++ b/app/components/invoice/invoice-form.tsx @@ -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) => Promise; defaultValues?: Partial; 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) => Promise} onSubmit - Callback for form submission + * @param {Partial} 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(null); + const { revalidate } = useRevalidator(); const { register, handleSubmit, watch, setValue, control, formState: { errors, isSubmitting } } = useForm({ 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
- setValue("customerId", v)}> @@ -212,8 +275,8 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultKleinuntern
-
-
+
+
Beschreibung
Menge
Einh.
@@ -225,12 +288,51 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultKleinuntern {fields.map((field, index) => (
-
- +
+ {(() => { + 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 ( + <> + 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 && ( +
+ {filtered.map((s) => ( + + ))} +
+ )} + + ); + })()}
+ {errors.name &&

{errors.name.message}

} +
+
+ + +
+
+
+ + +
+
+ + + {errors.unitPrice &&

{errors.unitPrice.message}

} +
+
+
+ + +
+
+ +
+ + ); +} + +type SortKey = "name" | "description" | "unit" | "unitPrice" | "taxRate"; +type SortDir = "asc" | "desc"; + +export default function LeistungenPage() { + const { services, companyId } = useLoaderData(); + const { revalidate } = useRevalidator(); + const [open, setOpen] = useState(false); + const [editService, setEditService] = useState(null); + const [sortKey, setSortKey] = useState("name"); + const [sortDir, setSortDir] = useState("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 ( +
+ + Zurück zum Mandanten + + +
+
+

Leistungen

+

{services.length} {services.length === 1 ? "Leistung" : "Leistungen"}

+
+ + + + + + + Neue Leistung + + + + +
+ + !o && setEditService(null)}> + + + Leistung bearbeiten + + {editService && ( + + )} + + + + {services.length === 0 ? ( + + + +

Noch keine Leistungen angelegt

+
+
+ ) : ( + +
+ + + + {(["name", "description", "unit", "unitPrice", "taxRate"] as SortKey[]).map((key, i) => { + const labels: Record = { 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 ( + + ); + })} + + + + + {sorted.map((service) => ( + + + + + + + + + ))} + +
+ +
{service.name}{service.description ?? "-"}{service.unit ?? "-"}{formatCurrency(service.unitPrice)}{service.taxRate}% +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/app/routes/companies.$id.tsx b/app/routes/companies.$id.tsx index af1686a..ce5b74f 100644 --- a/app/routes/companies.$id.tsx +++ b/app/routes/companies.$id.tsx @@ -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(); const { revalidate } = useRevalidator(); @@ -183,6 +212,16 @@ export default function CompanyPage() { + + + +
+ +
+ Leistungen +
+
+ @@ -218,7 +257,7 @@ export default function CompanyPage() { className="flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition-colors" >
-

{invoice.number}

+

{invoice.number ?? "-"}

{invoice.customer.name} · {formatDate(invoice.issueDate)}

diff --git a/app/routes/companies.tsx b/app/routes/companies.tsx index 77e38d1..69d1c47 100644 --- a/app/routes/companies.tsx +++ b/app/routes/companies.tsx @@ -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" >
-

{invoice.number}

+

{invoice.number ?? "-"}

{invoice.customer.name} · {formatDate(invoice.issueDate)}

diff --git a/app/routes/login.tsx b/app/routes/login.tsx index b26837d..cd95fd2 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -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(); const navigation = useNavigation(); const loading = navigation.state === "submitting"; + const [showPassword, setShowPassword] = useState(false); return (
@@ -109,13 +111,25 @@ export default function LoginPage() {
- +
+ + +