ADD: added some quiality of life features
This commit is contained in:
@@ -28,7 +28,7 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="rechnung-${invoice.number}.pdf"`,
|
||||
"Content-Disposition": `attachment; filename="rechnung-${invoice.number ?? invoice.id}.pdf"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma.server";
|
||||
import { generateInvoiceNumber } from "@/lib/invoice-number.server";
|
||||
import { InvoiceStatus } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -22,6 +23,31 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
|
||||
const statusSchema = z.object({ status: z.nativeEnum(InvoiceStatus) });
|
||||
|
||||
const itemSchema = z.object({
|
||||
position: z.number().int(),
|
||||
description: z.string().min(1),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.string().optional(),
|
||||
unitPrice: z.number(),
|
||||
taxRate: z.number(),
|
||||
netAmount: z.number(),
|
||||
taxAmount: z.number(),
|
||||
grossAmount: z.number(),
|
||||
});
|
||||
|
||||
const updateSchema = z.object({
|
||||
customerId: z.string().min(1),
|
||||
issueDate: z.string(),
|
||||
deliveryDate: z.string().optional(),
|
||||
dueDate: z.string(),
|
||||
notes: z.string().optional(),
|
||||
kleinunternehmer: z.boolean().optional().default(false),
|
||||
items: z.array(itemSchema).min(1),
|
||||
netTotal: z.number(),
|
||||
taxTotal: z.number(),
|
||||
grossTotal: z.number(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
@@ -29,9 +55,36 @@ export async function action({ request, params }: { request: Request; params: {
|
||||
const invoice = await getInvoice(params.id, user.id);
|
||||
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
if (request.method === "PUT") {
|
||||
const body = await request.json();
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||
|
||||
const { items, ...invoiceData } = parsed.data;
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
await tx.invoiceItem.deleteMany({ where: { invoiceId: params.id } });
|
||||
return tx.invoice.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
customerId: invoiceData.customerId,
|
||||
issueDate: new Date(invoiceData.issueDate),
|
||||
deliveryDate: invoiceData.deliveryDate ? new Date(invoiceData.deliveryDate) : null,
|
||||
dueDate: new Date(invoiceData.dueDate),
|
||||
notes: invoiceData.notes ?? null,
|
||||
kleinunternehmer: invoiceData.kleinunternehmer,
|
||||
netTotal: invoiceData.netTotal,
|
||||
taxTotal: invoiceData.taxTotal,
|
||||
grossTotal: invoiceData.grossTotal,
|
||||
items: { create: items },
|
||||
},
|
||||
});
|
||||
});
|
||||
return Response.json(updated);
|
||||
}
|
||||
|
||||
if (request.method === "DELETE") {
|
||||
const isAdmin = user.role === "ADMIN";
|
||||
const deletableStatuses: InvoiceStatus[] = [InvoiceStatus.DRAFT, InvoiceStatus.CANCELLED];
|
||||
const deletableStatuses: InvoiceStatus[] = [InvoiceStatus.DRAFT, InvoiceStatus.CANCELLED, InvoiceStatus.DELETED];
|
||||
if (!isAdmin && !deletableStatuses.includes(invoice.status)) {
|
||||
return Response.json(
|
||||
{ error: "Nur Entwürfe und stornierte Rechnungen können gelöscht werden." },
|
||||
@@ -47,9 +100,22 @@ export async function action({ request, params }: { request: Request; params: {
|
||||
const parsed = statusSchema.safeParse(body);
|
||||
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||
|
||||
const newStatus = parsed.data.status;
|
||||
|
||||
let numberUpdate: string | null | undefined = undefined;
|
||||
if (newStatus === "DELETED") {
|
||||
numberUpdate = null;
|
||||
} else if (invoice.status === "DELETED") {
|
||||
numberUpdate = await generateInvoiceNumber(invoice.companyId);
|
||||
}
|
||||
|
||||
const updated = await prisma.invoice.update({
|
||||
where: { id: params.id },
|
||||
data: { status: parsed.data.status },
|
||||
data: {
|
||||
status: newStatus,
|
||||
deletedAt: newStatus === "DELETED" ? new Date() : null,
|
||||
...(numberUpdate !== undefined && { number: numberUpdate }),
|
||||
},
|
||||
});
|
||||
return Response.json(updated);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,39 @@ const invoiceSchema = z.object({
|
||||
grossTotal: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a new invoice for a given company.
|
||||
*
|
||||
* Requires a JSON object in the request body with the following shape:
|
||||
* {
|
||||
* companyId: string,
|
||||
* customerId: string,
|
||||
* issueDate: string,
|
||||
* deliveryDate?: string,
|
||||
* dueDate: string,
|
||||
* notes?: string,
|
||||
* kleinunternehmer?: boolean,
|
||||
* items: [
|
||||
* {
|
||||
* position: number,
|
||||
* description: string,
|
||||
* quantity: number,
|
||||
* unit?: string,
|
||||
* unitPrice: number,
|
||||
* taxRate: number,
|
||||
* netAmount: number,
|
||||
* taxAmount: number,
|
||||
* grossAmount: number,
|
||||
* },
|
||||
* ],
|
||||
* }
|
||||
*
|
||||
* Returns the created invoice as a JSON object.
|
||||
*
|
||||
* If the request is unauthorized, returns a 401 response with an error message.
|
||||
* If the request body is invalid, returns a 400 response with an error message containing the validation errors.
|
||||
* If the company is not found, returns a 404 response with an error message.
|
||||
*/
|
||||
export async function action({ request }: { request: Request }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma.server";
|
||||
import { z } from "zod";
|
||||
|
||||
const serviceSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
unitPrice: z.number(),
|
||||
taxRate: z.number(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const service = await prisma.service.findFirst({
|
||||
where: { id: params.id, company: { userId: user.id } },
|
||||
});
|
||||
if (!service) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
if (request.method === "DELETE") {
|
||||
await prisma.service.delete({ where: { id: params.id } });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
// PUT
|
||||
const body = await request.json();
|
||||
const parsed = serviceSchema.safeParse(body);
|
||||
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||
|
||||
const updated = await prisma.service.update({ where: { id: params.id }, data: parsed.data });
|
||||
return Response.json(updated);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma.server";
|
||||
import { z } from "zod";
|
||||
|
||||
const serviceSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
unitPrice: z.number(),
|
||||
taxRate: z.number(),
|
||||
});
|
||||
|
||||
export async function action({ request }: { request: Request }) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = serviceSchema.safeParse(body);
|
||||
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||
|
||||
const company = await prisma.company.findFirst({
|
||||
where: { id: parsed.data.companyId, userId: user.id },
|
||||
});
|
||||
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
|
||||
|
||||
const service = await prisma.service.create({ data: parsed.data });
|
||||
return Response.json(service, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Link, useLoaderData, useNavigate } from "react-router";
|
||||
|
||||
export const handle = {
|
||||
breadcrumbs: (data: { invoice: { companyId: string; number: string | null; company: { name: string } } }) => [
|
||||
{ label: "Mandanten", href: "/companies" },
|
||||
{ label: data.invoice.company.name, href: `/companies/${data.invoice.companyId}` },
|
||||
{ label: "Rechnungen", href: `/companies/${data.invoice.companyId}/invoices` },
|
||||
{ label: data.invoice.number ?? "-", href: `/companies/${data.invoice.companyId}/invoices/${data.invoice.id}` },
|
||||
{ label: "Bearbeiten" },
|
||||
],
|
||||
};
|
||||
|
||||
import { requireUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma.server";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { InvoiceForm } from "@/components/invoice/invoice-form";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Loads an invoice by its ID.
|
||||
*
|
||||
* The response contains the invoice's details, including the issue date, delivery date, due date, items, customers, and services.
|
||||
*
|
||||
* If the invoice is not found, returns a 404 response with an error message.
|
||||
* If the user is not authorized, returns a 401 response with an error message.
|
||||
*
|
||||
* @param {Request} request - The request object.
|
||||
* @param {{ id: string; invoiceId: string }} params - The route parameters.
|
||||
* @returns {Promise<Response>} - The response data.
|
||||
*/
|
||||
export async function loader({
|
||||
request,
|
||||
params,
|
||||
}: {
|
||||
request: Request;
|
||||
params: { id: string; invoiceId: string };
|
||||
}) {
|
||||
const user = await requireUser(request);
|
||||
const { id, invoiceId } = params;
|
||||
|
||||
const invoice = await prisma.invoice.findFirst({
|
||||
where: { id: invoiceId, companyId: id, company: { userId: user.id } },
|
||||
include: {
|
||||
items: { orderBy: { position: "asc" } },
|
||||
company: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) throw new Response("Not Found", { status: 404 });
|
||||
|
||||
const [customers, services] = await Promise.all([
|
||||
prisma.customer.findMany({
|
||||
where: { companyId: id },
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
prisma.service.findMany({
|
||||
where: { companyId: id },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
invoice: {
|
||||
...invoice,
|
||||
issueDate: invoice.issueDate.toISOString(),
|
||||
deliveryDate: invoice.deliveryDate?.toISOString() ?? null,
|
||||
dueDate: invoice.dueDate.toISOString(),
|
||||
items: invoice.items.map((item) => ({
|
||||
...item,
|
||||
quantity: String(item.quantity),
|
||||
unitPrice: String(item.unitPrice),
|
||||
taxRate: String(item.taxRate),
|
||||
netAmount: Number(item.netAmount),
|
||||
taxAmount: Number(item.taxAmount),
|
||||
grossAmount: Number(item.grossAmount),
|
||||
})),
|
||||
},
|
||||
customers,
|
||||
services: services.map((s) => ({
|
||||
...s,
|
||||
unitPrice: Number(s.unitPrice),
|
||||
taxRate: Number(s.taxRate),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EditInvoicePage
|
||||
*
|
||||
* This page allows the user to edit an existing invoice.
|
||||
* It will display the current data of the invoice and allow the user to update it.
|
||||
* The page will automatically revalidate when the user updates the invoice.
|
||||
*
|
||||
* @returns {JSX.Element} The JSX element representing the EditInvoicePage.
|
||||
*/
|
||||
export default function EditInvoicePage() {
|
||||
const { invoice, customers, services } = useLoaderData<typeof loader>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const defaultValues = {
|
||||
customerId: invoice.customerId,
|
||||
issueDate: invoice.issueDate.split("T")[0],
|
||||
deliveryDate: invoice.deliveryDate?.split("T")[0] ?? "",
|
||||
dueDate: invoice.dueDate.split("T")[0],
|
||||
notes: invoice.notes ?? "",
|
||||
items: invoice.items,
|
||||
};
|
||||
|
||||
/**
|
||||
* Submits the edited invoice to the API.
|
||||
* If the request is successful, navigates the user to the invoice detail page.
|
||||
* If the request fails, displays an error message.
|
||||
*
|
||||
* @param {Record<string, unknown>} data - The edited invoice data.
|
||||
*/
|
||||
async function handleSubmit(data: Record<string, unknown>) {
|
||||
const res = await fetch(`/api/invoices/${invoice.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
navigate(`/companies/${invoice.companyId}/invoices/${invoice.id}`);
|
||||
} else {
|
||||
alert("Fehler beim Speichern der Rechnung.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to={`/companies/${invoice.companyId}/invoices/${invoice.id}`}
|
||||
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" /> Zurück zur Rechnung
|
||||
</Link>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Rechnung bearbeiten</h1>
|
||||
<p className="text-gray-500 mt-1">Rechnungsnummer: {invoice.number ?? "-"}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rechnungsdaten</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InvoiceForm
|
||||
customers={customers}
|
||||
companyId={invoice.companyId}
|
||||
defaultValues={defaultValues}
|
||||
defaultKleinunternehmer={invoice.kleinunternehmer}
|
||||
services={services}
|
||||
submitLabel="Änderungen speichern"
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Link, useLoaderData, useNavigate, useRevalidator } from "react-router";
|
||||
|
||||
export const handle = {
|
||||
breadcrumbs: (data: { invoice: { companyId: string; number: string; company: { name: string } } }) => [
|
||||
breadcrumbs: (data: { invoice: { companyId: string; number: string | null; company: { name: string } } }) => [
|
||||
{ label: "Mandanten", href: "/companies" },
|
||||
{ label: data.invoice.company.name, href: `/companies/${data.invoice.companyId}` },
|
||||
{ label: "Rechnungen", href: `/companies/${data.invoice.companyId}/invoices` },
|
||||
{ label: data.invoice.number },
|
||||
{ label: data.invoice.number ?? "-" },
|
||||
],
|
||||
};
|
||||
import { requireUser } from "@/session.server";
|
||||
@@ -15,7 +15,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
|
||||
import { formatCurrency, formatDate } from "@/lib/tax";
|
||||
import { ChevronLeft, Download, CheckCircle, Send, Trash2, RotateCcw } from "lucide-react";
|
||||
import { ChevronLeft, Download, CheckCircle, Send, Trash2, RotateCcw, Pencil } from "lucide-react";
|
||||
import type { InvoiceStatus } from "@prisma/client";
|
||||
|
||||
export async function loader({
|
||||
@@ -63,6 +63,17 @@ export async function loader({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* InvoiceDetailPage
|
||||
*
|
||||
* This page displays the details of a single invoice, including the customer, items, and totals.
|
||||
* It also allows the user to download the invoice as a PDF and to update the status of the invoice.
|
||||
*
|
||||
* The page will only be accessible if the user is logged in and has the necessary permissions.
|
||||
* The page will automatically revalidate when the user updates the status of the invoice.
|
||||
*
|
||||
* @returns {JSX.Element} The JSX element representing the InvoiceDetailPage.
|
||||
*/
|
||||
export default function InvoiceDetailPage() {
|
||||
const { invoice, isAdmin } = useLoaderData<typeof loader>();
|
||||
const navigate = useNavigate();
|
||||
@@ -81,6 +92,15 @@ export default function InvoiceDetailPage() {
|
||||
{} as Record<number, { net: number; tax: number }>
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates the status of an invoice.
|
||||
*
|
||||
* This function sends a PATCH request to the API to update the status of the invoice.
|
||||
* It sets the loading state to true before sending the request and to false after the request is finished.
|
||||
* It also revalidates the page after the request is finished, so the user is shown the updated status.
|
||||
*
|
||||
* @param {InvoiceStatus} status The new status of the invoice.
|
||||
*/
|
||||
async function updateStatus(status: InvoiceStatus) {
|
||||
setLoading(true);
|
||||
await fetch(`/api/invoices/${invoice.id}`, {
|
||||
@@ -92,6 +112,13 @@ export default function InvoiceDetailPage() {
|
||||
revalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft deletes an invoice.
|
||||
*
|
||||
* This function shows a confirmation dialog to the user and if the user confirms, it sends a PATCH request to the API to update the status of the invoice to "DELETED".
|
||||
* It sets the loading state to true before sending the request and to false after the request is finished.
|
||||
* It also revalidates the page after the request is finished, so the user is shown the updated status.
|
||||
*/
|
||||
async function handleSoftDelete() {
|
||||
if (!confirm("Rechnung in den Papierkorb verschieben?")) return;
|
||||
setLoading(true);
|
||||
@@ -104,23 +131,43 @@ export default function InvoiceDetailPage() {
|
||||
revalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores an invoice to the "DRAFT" status.
|
||||
*
|
||||
* This function sends a PATCH request to the API to update the status of the invoice to "DRAFT".
|
||||
* It sets the loading state to true before sending the request and to false after the request is finished.
|
||||
* It also revalidates the page after the request is finished, so the user is shown the updated status.
|
||||
*/
|
||||
async function handleRestore() {
|
||||
setLoading(true);
|
||||
await fetch(`/api/invoices/${invoice.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "CANCELLED" }),
|
||||
body: JSON.stringify({ status: "DRAFT" }),
|
||||
});
|
||||
setLoading(false);
|
||||
revalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard deletes an invoice.
|
||||
*
|
||||
* This function shows a confirmation dialog to the user and if the user confirms, it sends a DELETE request to the API to delete the invoice.
|
||||
* It then navigates to the invoice list page.
|
||||
*/
|
||||
async function handleHardDelete() {
|
||||
if (!confirm("Rechnung endgültig löschen? Dies kann nicht rückgängig gemacht werden.")) return;
|
||||
await fetch(`/api/invoices/${invoice.id}`, { method: "DELETE" });
|
||||
navigate(`/companies/${id}/invoices`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the invoice as a PDF file.
|
||||
*
|
||||
* This function sends a GET request to the API to get the PDF file.
|
||||
* It then creates a blob URL from the response and creates a new anchor element with the blob URL and a download attribute with the filename.
|
||||
* It then simulates a click event on the anchor element, so the user is prompted to download the PDF file.
|
||||
*/
|
||||
async function downloadPdf() {
|
||||
const res = await fetch(`/api/invoices/${invoice.id}/pdf`);
|
||||
if (!res.ok) return;
|
||||
@@ -128,7 +175,7 @@ export default function InvoiceDetailPage() {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `rechnung-${invoice.number}.pdf`;
|
||||
a.download = `rechnung-${invoice.number ?? invoice.id}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -145,7 +192,7 @@ export default function InvoiceDetailPage() {
|
||||
<div className="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{invoice.number}</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{invoice.number ?? "-"}</h1>
|
||||
<InvoiceStatusBadge status={invoice.status} />
|
||||
</div>
|
||||
<p className="text-gray-500">
|
||||
@@ -160,6 +207,13 @@ export default function InvoiceDetailPage() {
|
||||
<Download className="h-4 w-4" /> PDF
|
||||
</Button>
|
||||
)}
|
||||
{invoice.status === "DRAFT" && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={`/companies/${id}/invoices/${invoice.id}/edit`}>
|
||||
<Pencil className="h-4 w-4" /> Bearbeiten
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{invoice.status === "DRAFT" && (
|
||||
<Button size="sm" onClick={() => updateStatus("SENT")} disabled={loading}>
|
||||
<Send className="h-4 w-4" /> Als versendet markieren
|
||||
|
||||
@@ -14,6 +14,17 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { InvoiceForm } from "@/components/invoice/invoice-form";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Loads the company, customers, and services for the given company ID.
|
||||
*
|
||||
* If the company is not found, returns a 404 response with an error message.
|
||||
* If the user is not authorized, returns a 401 response with an error message.
|
||||
* If there are no customers, redirects to the customers page.
|
||||
*
|
||||
* @param {Request} request - The request object.
|
||||
* @param {{ id: string }} params - The route parameters.
|
||||
* @returns {Promise<Response>} - The response data.
|
||||
*/
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await requireUser(request);
|
||||
const { id } = params;
|
||||
@@ -33,11 +44,31 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
throw redirect(`/companies/${id}/customers`);
|
||||
}
|
||||
|
||||
return { company, customers };
|
||||
const services = await prisma.service.findMany({
|
||||
where: { companyId: id },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return {
|
||||
company,
|
||||
customers,
|
||||
services: services.map((s) => ({
|
||||
...s,
|
||||
unitPrice: Number(s.unitPrice),
|
||||
taxRate: Number(s.taxRate),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* NewInvoicePage
|
||||
*
|
||||
* This page allows the user to create a new invoice for a given company.
|
||||
* It will display the company's name and allow the user to select a customer and add items to the invoice.
|
||||
* After submitting the form, the user will be redirected to the invoice detail page.
|
||||
*/
|
||||
export default function NewInvoicePage() {
|
||||
const { company, customers } = useLoaderData<typeof loader>();
|
||||
const { company, customers, services } = useLoaderData<typeof loader>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleSubmit(data: Record<string, unknown>) {
|
||||
@@ -74,7 +105,7 @@ export default function NewInvoicePage() {
|
||||
<CardTitle>Rechnungsdaten</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InvoiceForm customers={customers} companyId={company.id} defaultKleinunternehmer={company.kleinunternehmer} onSubmit={handleSubmit} />
|
||||
<InvoiceForm customers={customers} companyId={company.id} defaultKleinunternehmer={company.kleinunternehmer} services={services} onSubmit={handleSubmit} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
import { Link, useLoaderData, useRevalidator } from "react-router";
|
||||
|
||||
export const handle = {
|
||||
breadcrumbs: (data: { company: { id: string; name: string } }) => [
|
||||
@@ -13,7 +13,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
|
||||
import { formatCurrency, formatDate } from "@/lib/tax";
|
||||
import { Plus, FileText, ChevronLeft, ChevronDown, ChevronRight, Trash2 } from "lucide-react";
|
||||
import { Plus, FileText, ChevronLeft, ChevronDown, ChevronRight, Trash2, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
@@ -38,6 +38,7 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
grossTotal: Number(inv.grossTotal),
|
||||
issueDate: inv.issueDate.toISOString(),
|
||||
dueDate: inv.dueDate.toISOString(),
|
||||
deletedAt: inv.deletedAt?.toISOString() ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -65,7 +66,7 @@ function InvoiceRow({ invoice, companyId }: { invoice: InvoiceRow; companyId: st
|
||||
<FileText className="h-3.5 w-3.5 text-slate-400 group-hover:text-indigo-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-800 text-sm">{invoice.number}</p>
|
||||
<p className="font-medium text-slate-800 text-sm">{invoice.number ?? "-"}</p>
|
||||
<p className="text-xs text-slate-400">{invoice.customer.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,6 +127,47 @@ function YearPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function DeletedInvoiceRow({ invoice, companyId }: { invoice: InvoiceRow; companyId: string }) {
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
async function handleDelete(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
if (!window.confirm(`Rechnung ${invoice.number ?? "-"} endgültig löschen? Dies kann nicht rückgängig gemacht werden.`)) return;
|
||||
await fetch(`/api/invoices/${invoice.id}`, { method: "DELETE" });
|
||||
revalidator.revalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-5 py-3.5 hover:bg-red-50/30 transition-colors group">
|
||||
<Link
|
||||
to={`/companies/${companyId}/invoices/${invoice.id}`}
|
||||
className="flex items-center gap-4 flex-1 min-w-0"
|
||||
>
|
||||
<div className="p-1.5 rounded-lg bg-red-50">
|
||||
<FileText className="h-3.5 w-3.5 text-red-300" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-500 text-sm">{invoice.number ?? "-"}</p>
|
||||
<p className="text-xs text-slate-400">{invoice.customer.name}</p>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="text-sm text-slate-400 hidden sm:block">{formatDate(invoice.issueDate)}</p>
|
||||
<p className="font-medium text-slate-400 w-24 text-right text-sm">
|
||||
{formatCurrency(invoice.grossTotal)}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-1.5 rounded text-red-300 hover:text-red-600 hover:bg-red-100 transition-colors"
|
||||
title="Endgültig löschen"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeletedPanel({
|
||||
invoices,
|
||||
companyId,
|
||||
@@ -159,7 +201,7 @@ function DeletedPanel({
|
||||
{open && (
|
||||
<div className="divide-y divide-red-50 border-t border-red-100">
|
||||
{invoices.map((invoice) => (
|
||||
<InvoiceRow key={invoice.id} invoice={invoice} companyId={companyId} />
|
||||
<DeletedInvoiceRow key={invoice.id} invoice={invoice} companyId={companyId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useLoaderData, useRevalidator } from "react-router";
|
||||
|
||||
export const handle = {
|
||||
breadcrumbs: (data: { companyId: string; companyName: string }) => [
|
||||
{ label: "Mandanten", href: "/companies" },
|
||||
{ label: data.companyName, href: `/companies/${data.companyId}` },
|
||||
{ label: "Leistungen" },
|
||||
],
|
||||
};
|
||||
|
||||
import { requireUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma.server";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card"; // CardContent used for empty state
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Briefcase, Plus, Edit, Trash2, ChevronLeft, ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { TAX_RATES, formatCurrency } from "@/lib/tax";
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Pflichtfeld"),
|
||||
description: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
unitPrice: z.coerce.number({ invalid_type_error: "Ungültiger Betrag" }),
|
||||
taxRate: z.coerce.number(),
|
||||
});
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
unit: string | null;
|
||||
unitPrice: number;
|
||||
taxRate: number;
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await requireUser(request);
|
||||
const company = await prisma.company.findFirst({
|
||||
where: { id: params.id, userId: user.id },
|
||||
});
|
||||
if (!company) throw new Response("Not Found", { status: 404 });
|
||||
|
||||
const services = await prisma.service.findMany({
|
||||
where: { companyId: params.id },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return {
|
||||
services: services.map((s) => ({
|
||||
...s,
|
||||
unitPrice: Number(s.unitPrice),
|
||||
taxRate: Number(s.taxRate),
|
||||
})),
|
||||
companyId: params.id,
|
||||
companyName: company.name,
|
||||
};
|
||||
}
|
||||
|
||||
function ServiceForm({
|
||||
defaultValues,
|
||||
onSubmit,
|
||||
submitLabel,
|
||||
}: {
|
||||
defaultValues?: Partial<FormData>;
|
||||
onSubmit: (d: FormData) => Promise<void>;
|
||||
submitLabel: string;
|
||||
}) {
|
||||
const { register, handleSubmit, setValue, formState: { errors, isSubmitting } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { taxRate: 19, ...defaultValues },
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Bezeichnung *</Label>
|
||||
<Input {...register("name")} placeholder="z.B. Beratung, Programmierung" />
|
||||
{errors.name && <p className="text-xs text-red-600">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Beschreibung</Label>
|
||||
<Input {...register("description")} placeholder="Optionale Leistungsbeschreibung" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Einheit</Label>
|
||||
<Input {...register("unit")} placeholder="Stunde, Stück, ..." />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Einzelpreis (€) *</Label>
|
||||
<Input {...register("unitPrice")} type="number" step="0.01" placeholder="0.00" />
|
||||
{errors.unitPrice && <p className="text-xs text-red-600">{errors.unitPrice.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Steuersatz</Label>
|
||||
<Select
|
||||
defaultValue={String(defaultValues?.taxRate ?? 19)}
|
||||
onValueChange={(v) => setValue("taxRate", Number(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TAX_RATES.map((r) => (
|
||||
<SelectItem key={r.value} value={String(r.value)}>
|
||||
{r.value}%
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Speichern..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type SortKey = "name" | "description" | "unit" | "unitPrice" | "taxRate";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
export default function LeistungenPage() {
|
||||
const { services, companyId } = useLoaderData<typeof loader>();
|
||||
const { revalidate } = useRevalidator();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editService, setEditService] = useState<Service | null>(null);
|
||||
const [sortKey, setSortKey] = useState<SortKey>("name");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir("asc");
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...services].sort((a, b) => {
|
||||
const av = a[sortKey] ?? "";
|
||||
const bv = b[sortKey] ?? "";
|
||||
const cmp = typeof av === "number" && typeof bv === "number"
|
||||
? av - bv
|
||||
: String(av).localeCompare(String(bv), "de");
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
async function handleCreate(data: FormData) {
|
||||
await fetch("/api/services", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...data, companyId }),
|
||||
});
|
||||
setOpen(false);
|
||||
revalidate();
|
||||
}
|
||||
|
||||
async function handleEdit(data: FormData) {
|
||||
if (!editService) return;
|
||||
await fetch(`/api/services/${editService.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
setEditService(null);
|
||||
revalidate();
|
||||
}
|
||||
|
||||
async function handleDelete(serviceId: string) {
|
||||
if (!confirm("Leistung wirklich löschen?")) return;
|
||||
await fetch(`/api/services/${serviceId}`, { method: "DELETE" });
|
||||
revalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to={`/companies/${companyId}`}
|
||||
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Leistungen</h1>
|
||||
<p className="text-gray-500 mt-1">{services.length} {services.length === 1 ? "Leistung" : "Leistungen"}</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="h-4 w-4" /> Leistung anlegen</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neue Leistung</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ServiceForm onSubmit={handleCreate} submitLabel="Anlegen" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!editService} onOpenChange={(o) => !o && setEditService(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Leistung bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editService && (
|
||||
<ServiceForm
|
||||
defaultValues={{
|
||||
name: editService.name,
|
||||
description: editService.description ?? undefined,
|
||||
unit: editService.unit ?? undefined,
|
||||
unitPrice: editService.unitPrice,
|
||||
taxRate: editService.taxRate,
|
||||
}}
|
||||
onSubmit={handleEdit}
|
||||
submitLabel="Speichern"
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{services.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<Briefcase className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-sm">Noch keine Leistungen angelegt</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||||
{(["name", "description", "unit", "unitPrice", "taxRate"] as SortKey[]).map((key, i) => {
|
||||
const labels: Record<SortKey, string> = { name: "Bezeichnung", description: "Beschreibung", unit: "Einheit", unitPrice: "Preis", taxRate: "MwSt." };
|
||||
const isNum = key === "unitPrice" || key === "taxRate";
|
||||
const active = sortKey === key;
|
||||
const Icon = active ? (sortDir === "asc" ? ChevronUp : ChevronDown) : ChevronsUpDown;
|
||||
return (
|
||||
<th key={key} className={`px-4 py-3 ${isNum ? "text-right" : "text-left"}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort(key)}
|
||||
className={`inline-flex items-center gap-1 hover:text-slate-800 transition-colors ${active ? "text-slate-800" : ""}`}
|
||||
>
|
||||
{labels[key]}
|
||||
<Icon className="h-3 w-3" />
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{sorted.map((service) => (
|
||||
<tr key={service.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-slate-800">{service.name}</td>
|
||||
<td className="px-4 py-3 text-slate-500 max-w-xs truncate">{service.description ?? "-"}</td>
|
||||
<td className="px-4 py-3 text-slate-500">{service.unit ?? "-"}</td>
|
||||
<td className="px-4 py-3 text-right text-slate-800">{formatCurrency(service.unitPrice)}</td>
|
||||
<td className="px-4 py-3 text-right text-slate-500">{service.taxRate}%</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => setEditService(service)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleDelete(service.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { formatCurrency, formatDate } from "@/lib/tax";
|
||||
import {
|
||||
FileText, Users, BarChart3, Plus, Edit, Building2,
|
||||
Mail, Phone, CreditCard, Receipt, Archive, ArchiveRestore, AlertTriangle
|
||||
Mail, Phone, CreditCard, Receipt, Archive, ArchiveRestore, AlertTriangle, Briefcase
|
||||
} from "lucide-react";
|
||||
import { InvoiceStatus } from "@prisma/client";
|
||||
import { useState } from "react";
|
||||
@@ -35,6 +35,18 @@ const statusVariants: Record<InvoiceStatus, "secondary" | "default" | "success"
|
||||
DELETED: "outline",
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads a company by its ID.
|
||||
*
|
||||
* The response contains the company's name, archived at date, invoices, and revenue.
|
||||
*
|
||||
* The invoices are paginated to show the 5 most recent ones.
|
||||
*
|
||||
* The revenue is the sum of all paid invoices.
|
||||
*
|
||||
* If the company is not found, returns a 404 response with an error message.
|
||||
* If the user is not authorized, returns a 401 response with an error message.
|
||||
*/
|
||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const user = await requireUser(request);
|
||||
const { id } = params;
|
||||
@@ -43,6 +55,7 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
where: { id, userId: user.id },
|
||||
include: {
|
||||
invoices: {
|
||||
where: { status: { not: InvoiceStatus.DELETED } },
|
||||
include: { customer: { select: { name: true } } },
|
||||
orderBy: { issueDate: "desc" },
|
||||
take: 5,
|
||||
@@ -74,6 +87,22 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CompanyPage displays information about a company.
|
||||
*
|
||||
* The page displays the company's name, address, and legal form.
|
||||
* It also displays the company's archived status, if applicable.
|
||||
* If the user is an admin, the page displays buttons to toggle the company's archived status and to edit the company.
|
||||
*
|
||||
* The page also displays a list of the company's most recent invoices.
|
||||
* The list shows the invoice number, customer name, issue date, and gross total.
|
||||
* The user can click on an invoice to view its details.
|
||||
*
|
||||
* The page also displays the company's revenue, which is the sum of all paid invoices.
|
||||
* If the company has a tax ID or VAT ID, the page displays it.
|
||||
*
|
||||
* Finally, the page displays contact information for the company, if applicable.
|
||||
*/
|
||||
export default function CompanyPage() {
|
||||
const { company, revenue, isAdmin } = useLoaderData<typeof loader>();
|
||||
const { revalidate } = useRevalidator();
|
||||
@@ -183,6 +212,16 @@ export default function CompanyPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link to={`/companies/${id}/leistungen`} className="block">
|
||||
<Card className="hover:border-orange-200 hover:shadow-sm transition-all cursor-pointer">
|
||||
<CardContent className="pt-4 pb-4 flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-orange-50">
|
||||
<Briefcase className="h-4 w-4 text-orange-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">Leistungen</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link to={`/companies/${id}/reports`} className="block">
|
||||
<Card className="hover:border-purple-200 hover:shadow-sm transition-all cursor-pointer">
|
||||
<CardContent className="pt-4 pb-4 flex items-center gap-3">
|
||||
@@ -218,7 +257,7 @@ export default function CompanyPage() {
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{invoice.number}</p>
|
||||
<p className="text-sm font-medium text-gray-900">{invoice.number ?? "-"}</p>
|
||||
<p className="text-xs text-gray-500">{invoice.customer.name} · {formatDate(invoice.issueDate)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -319,7 +319,7 @@ export default function CompaniesPage() {
|
||||
className="flex items-center justify-between py-2.5 hover:bg-slate-50 -mx-1 px-1 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900">{invoice.number}</p>
|
||||
<p className="text-sm font-medium text-slate-900">{invoice.number ?? "-"}</p>
|
||||
<p className="text-xs text-slate-400 truncate">
|
||||
{invoice.customer.name} · {formatDate(invoice.issueDate)}
|
||||
</p>
|
||||
|
||||
+22
-8
@@ -3,7 +3,8 @@ import { login, createUserSession, getUserSession } from "@/session.server";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Calculator, AlertCircle } from "lucide-react";
|
||||
import { Calculator, AlertCircle, Eye, EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export async function loader({ request }: { request: Request }) {
|
||||
const { userId } = await getUserSession(request);
|
||||
@@ -26,6 +27,7 @@ export default function LoginPage() {
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const loading = navigation.state === "submitting";
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
@@ -109,13 +111,25 @@ export default function LoginPage() {
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">Passwort</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-400 hover:text-slate-600"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? "Passwort verbergen" : "Passwort anzeigen"}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full h-10 mt-2" disabled={loading}>
|
||||
|
||||
Reference in New Issue
Block a user