diff --git a/app/admin/clients/[id]/billing/page.tsx b/app/admin/clients/[id]/billing/page.tsx new file mode 100644 index 0000000..5479023 --- /dev/null +++ b/app/admin/clients/[id]/billing/page.tsx @@ -0,0 +1,9 @@ +import { Params } from "@/modules/shared/types/user" +import { BillingPanel } from "@/modules/shared/ui/views/billing-panel" + +const BillingAdminPage = async ({ params } : { params: Params }) => { + const { id } = await params + return +} + +export default BillingAdminPage \ No newline at end of file diff --git a/app/admin/clients/[id]/data/page.tsx b/app/admin/clients/[id]/data/page.tsx index 6f884b3..d71e3a6 100644 --- a/app/admin/clients/[id]/data/page.tsx +++ b/app/admin/clients/[id]/data/page.tsx @@ -1,10 +1,18 @@ -import StammdatenAdminViewPage from "@/modules/admin/ui/views/stammdaten-admin-view" - -type Params = Promise<{ id: string }>; +import { getStammdaten } from "@/modules/shared/queries/stammdaten" +import { Params } from "@/modules/shared/types/user" +import { StammdatenPanel } from "@/modules/shared/ui/views/stammdaten-panel" const DataAdminPage = async ({ params }: { params: Params }) => { const { id } = await params - return + const stammdaten = await getStammdaten(id) + + return ( + + ) } export default DataAdminPage diff --git a/app/admin/clients/[id]/layout.tsx b/app/admin/clients/[id]/layout.tsx index 771e83d..c303fa6 100644 --- a/app/admin/clients/[id]/layout.tsx +++ b/app/admin/clients/[id]/layout.tsx @@ -1,8 +1,7 @@ import { notFound, redirect } from "next/navigation"; import { createAdminClient } from "@/lib/supabase/admin"; import { verifySession } from "@/lib/supabase/dal"; - -type Params = Promise<{ id: string }>; +import { Params } from "@/modules/shared/types/user"; export default async function ClientDetailLayout({ children, diff --git a/app/admin/clients/[id]/page.tsx b/app/admin/clients/[id]/page.tsx index 1de47bb..ccd6c24 100644 --- a/app/admin/clients/[id]/page.tsx +++ b/app/admin/clients/[id]/page.tsx @@ -1,7 +1,7 @@ import { notFound } from "next/navigation"; import { createAdminClient } from "@/lib/supabase/admin"; +import { Params } from "@/modules/shared/types/user"; -type Params = Promise<{ id: string }>; const ClientDetailPage = async ({ params }: { params: Params }) => { const { id } = await params; diff --git a/app/admin/clients/[id]/passwords/page.tsx b/app/admin/clients/[id]/passwords/page.tsx index 6fff790..bde83f5 100644 --- a/app/admin/clients/[id]/passwords/page.tsx +++ b/app/admin/clients/[id]/passwords/page.tsx @@ -1,10 +1,9 @@ -import PasswordAdminViewPage from "@/modules/admin/ui/views/password-admin-view" - -type Params = Promise<{id: string}> +import { Params } from "@/modules/shared/types/user" +import { CredentialsPanel } from "@/modules/shared/ui/views/credentials-panel" const PasswordAdminPage = async ({ params } : { params: Params }) => { const { id } = await params - return + return } -export default PasswordAdminPage \ No newline at end of file +export default PasswordAdminPage diff --git a/app/api/invoices/pdf/route.ts b/app/api/invoices/pdf/route.ts new file mode 100644 index 0000000..eaf614c --- /dev/null +++ b/app/api/invoices/pdf/route.ts @@ -0,0 +1,45 @@ +import * as z from "zod" +import { zipSync } from "fflate" +import type { NextRequest } from "next/server" +import { getSession } from "@/lib/supabase/dal" +import { getInvoicesForPdf } from "@/modules/shared/queries/invoices" +import { invoiceFileName, renderInvoicePdf } from "@/modules/shared/pdf/render-invoice" + +const IdsSchema = z.array(z.string().uuid()).min(1).max(100) + +// Zugriff regelt RLS: Clients bekommen nur eigene, nicht stornierte Rechnungen zurück. +export async function GET(request: NextRequest) { + const user = await getSession() + if (!user) return Response.json({ error: "Nicht angemeldet." }, { status: 401 }) + + const parsed = IdsSchema.safeParse(request.nextUrl.searchParams.getAll("id")) + if (!parsed.success) return Response.json({ error: "Ungültige Auswahl." }, { status: 400 }) + + const invoices = await getInvoicesForPdf(parsed.data) + if (invoices.length === 0) return Response.json({ error: "Keine Rechnungen gefunden." }, { status: 404 }) + + if (invoices.length === 1) { + const pdf = await renderInvoicePdf(invoices[0]) + const disposition = request.nextUrl.searchParams.has("inline") ? "inline" : "attachment" + return new Response(new Uint8Array(pdf), { + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `${disposition}; filename="${invoiceFileName(invoices[0].invoiceNumber)}"`, + "Cache-Control": "no-store", + }, + }) + } + + const files: Record = {} + for (const invoice of invoices) { + files[invoiceFileName(invoice.invoiceNumber)] = new Uint8Array(await renderInvoicePdf(invoice)) + } + + return new Response(zipSync(files, { level: 0 }), { + headers: { + "Content-Type": "application/zip", + "Content-Disposition": "attachment; filename=\"Rechnungen.zip\"", + "Cache-Control": "no-store", + }, + }) +} diff --git a/app/api/invoices/preview/route.ts b/app/api/invoices/preview/route.ts new file mode 100644 index 0000000..9bec498 --- /dev/null +++ b/app/api/invoices/preview/route.ts @@ -0,0 +1,41 @@ +import * as z from "zod" +import { getProfile } from "@/lib/supabase/dal" +import { InvoiceDraftSchema, firstIssueMessage } from "@/modules/shared/schemas/invoice" +import { renderInvoicePdf } from "@/modules/shared/pdf/render-invoice" + +const PreviewSchema = z.object({ + draft: InvoiceDraftSchema, + invoiceNumber: z.string().trim().min(1).max(40), +}) + +export async function POST(request: Request) { + const profile = await getProfile() + if (!profile) return Response.json({ error: "Nicht angemeldet." }, { status: 401 }) + if (profile.role !== "admin") return Response.json({ error: "Keine Berechtigung." }, { status: 403 }) + + const parsed = PreviewSchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success) { + return Response.json({ error: firstIssueMessage(parsed.error) }, { status: 400 }) + } + + const { draft, invoiceNumber } = parsed.data + const pdf = await renderInvoicePdf({ + invoiceNumber, + invoiceDate: draft.invoiceDate, + dueDate: draft.dueDate, + companyName: draft.companyName, + billingStreet: draft.billingStreet, + billingPostalCode: draft.billingPostalCode, + billingCity: draft.billingCity, + billingCountry: draft.billingCountry, + items: draft.items, + }) + + return new Response(new Uint8Array(pdf), { + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": "inline; filename=\"Vorschau.pdf\"", + "Cache-Control": "no-store", + }, + }) +} diff --git a/app/client/billing/page.tsx b/app/client/billing/page.tsx new file mode 100644 index 0000000..557d2e4 --- /dev/null +++ b/app/client/billing/page.tsx @@ -0,0 +1,9 @@ +import { verifySession } from "@/lib/supabase/dal" +import { BillingPanel } from "@/modules/shared/ui/views/billing-panel"; + +const BillingPage = async () => { + const session = await verifySession(); + return +} + +export default BillingPage \ No newline at end of file diff --git a/app/client/data/page.tsx b/app/client/data/page.tsx index 7abde8a..44f754b 100644 --- a/app/client/data/page.tsx +++ b/app/client/data/page.tsx @@ -1,7 +1,18 @@ -import StammdatenClientViewPage from "@/modules/client/ui/views/stammdaten-client-view" +import { verifySession } from "@/lib/supabase/dal" +import { getStammdaten } from "@/modules/shared/queries/stammdaten" +import { StammdatenPanel } from "@/modules/shared/ui/views/stammdaten-panel" -const DataPage = () => { - return +const DataPage = async () => { + const session = await verifySession() + const stammdaten = await getStammdaten(session.userId) + + return ( + + ) } -export default DataPage \ No newline at end of file +export default DataPage diff --git a/app/client/passwords/page.tsx b/app/client/passwords/page.tsx index 5f688f2..069ac29 100644 --- a/app/client/passwords/page.tsx +++ b/app/client/passwords/page.tsx @@ -1,7 +1,9 @@ -import PasswordClientViewPage from "@/modules/client/ui/views/password-client-view" +import { verifySession } from "@/lib/supabase/dal" +import { CredentialsPanel } from "@/modules/shared/ui/views/credentials-panel" -const PasswordPage = () => { - return +const PasswordPage = async () => { + const session = await verifySession() + return } -export default PasswordPage \ No newline at end of file +export default PasswordPage diff --git a/lib/supabase/migrations/20260929150000_invoice_creation.sql b/lib/supabase/migrations/20260929150000_invoice_creation.sql new file mode 100644 index 0000000..c8136ee --- /dev/null +++ b/lib/supabase/migrations/20260929150000_invoice_creation.sql @@ -0,0 +1,73 @@ +-- Zeigt die nächste Rechnungsnummer an, ohne den Zähler zu erhöhen (für Vorbelegung im Dialog). +-- Die echte Nummer vergibt weiterhin next_invoice_number() beim Insert. +create or replace function public.peek_next_invoice_number() +returns text language plpgsql stable security definer set search_path = '' as $$ +declare + y int := extract(year from current_date)::int; + n int; +begin + if not public.is_admin() then + raise exception 'forbidden' using errcode = '42501'; + end if; + select coalesce(last_number, 0) + 1 into n from public.invoice_counters where year = y; + return format('RE-%s-%s', y, lpad(coalesce(n, 1)::text, 4, '0')); +end $$; + +-- Rechnung + Positionen in einer Transaktion anlegen, damit bei Fehlern +-- keine Rechnung ohne Positionen (und keine verbrauchte Nummer) übrig bleibt. +-- security invoker: RLS (admin_insert / admin_all) greift. +create or replace function public.create_invoice( + p_client_id uuid, + p_invoice_date date, + p_due_date date, + p_company_name text, + p_billing_street text, + p_billing_postal_code text, + p_billing_city text, + p_billing_country char(2), + p_items jsonb +) returns uuid +language plpgsql +security invoker +set search_path = '' +as $$ +declare + v_invoice_id uuid; +begin + if not public.is_admin() then + raise exception 'forbidden' using errcode = '42501'; + end if; + + if p_items is null or jsonb_array_length(p_items) = 0 then + raise exception 'invoice needs at least one item' using errcode = '22023'; + end if; + + insert into public.invoices ( + client_id, invoice_date, due_date, + company_name, billing_street, billing_postal_code, billing_city, billing_country + ) values ( + p_client_id, p_invoice_date, p_due_date, + p_company_name, p_billing_street, p_billing_postal_code, p_billing_city, p_billing_country + ) + returning id into v_invoice_id; + + insert into public.invoice_items ( + invoice_id, position, product_template_id, description, quantity, unit_price, tax_rate + ) + select + v_invoice_id, + item.ordinality::int, + nullif(item.value->>'product_template_id', '')::uuid, + item.value->>'description', + (item.value->>'quantity')::numeric, + (item.value->>'unit_price')::numeric, + (item.value->>'tax_rate')::numeric + from jsonb_array_elements(p_items) with ordinality as item(value, ordinality); + + return v_invoice_id; +end $$; + +revoke execute on function public.peek_next_invoice_number() from public, anon; +revoke execute on function public.create_invoice(uuid, date, date, text, text, text, text, char(2), jsonb) from public, anon; +grant execute on function public.peek_next_invoice_number() to authenticated; +grant execute on function public.create_invoice(uuid, date, date, text, text, text, text, char(2), jsonb) to authenticated; diff --git a/modules/admin/ui/views/password-admin-view.tsx b/modules/admin/ui/views/password-admin-view.tsx deleted file mode 100644 index e6ac5ec..0000000 --- a/modules/admin/ui/views/password-admin-view.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { getCredentials } from "@/modules/shared/queries/credentials" -import { CredentialsTable } from "@/modules/shared/ui/components/credentials-table" -import { CredentialDialog } from "@/modules/shared/ui/components/credential-dialog" -import { createAdminClient } from "@/lib/supabase/admin" - -const PasswordAdminViewPage = async ({ clientProfileId }: { clientProfileId: string }) => { - const supabase = createAdminClient() - const { data: profile } = await supabase - .from("profiles") - .select("client_id") - .eq("id", clientProfileId) - .single() - - const credentials = profile?.client_id ? await getCredentials(profile.client_id) : [] - - return ( -
-
-

Passwörter

- -
- -
- ) -} - -export default PasswordAdminViewPage diff --git a/modules/admin/ui/views/stammdaten-admin-view.tsx b/modules/admin/ui/views/stammdaten-admin-view.tsx deleted file mode 100644 index d186853..0000000 --- a/modules/admin/ui/views/stammdaten-admin-view.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { getStammdaten } from "@/modules/shared/queries/stammdaten" -import { StammdatenPanel } from "@/modules/shared/ui/components/stammdaten-panel" - -const StammdatenAdminViewPage = async ({ clientProfileId }: { clientProfileId: string }) => { - const stammdaten = await getStammdaten(clientProfileId) - - return ( - - ) -} - -export default StammdatenAdminViewPage diff --git a/modules/client/ui/views/password-client-view.tsx b/modules/client/ui/views/password-client-view.tsx deleted file mode 100644 index ce9cb2e..0000000 --- a/modules/client/ui/views/password-client-view.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { verifySession } from "@/lib/supabase/dal" -import { createAdminClient } from "@/lib/supabase/admin" -import { getCredentials } from "@/modules/shared/queries/credentials" -import { CredentialsTable } from "@/modules/shared/ui/components/credentials-table" -import { CredentialDialog } from "@/modules/shared/ui/components/credential-dialog" - -const PasswordClientViewPage = async () => { - const session = await verifySession() - - const supabase = createAdminClient() - const { data: profile } = await supabase - .from("profiles") - .select("client_id") - .eq("id", session.userId) - .single() - - const credentials = profile?.client_id ? await getCredentials(profile.client_id) : [] - - return ( -
-
-

Passwörter

- -
- -
- ) -} - -export default PasswordClientViewPage diff --git a/modules/client/ui/views/stammdaten-client-view.tsx b/modules/client/ui/views/stammdaten-client-view.tsx deleted file mode 100644 index 35a2918..0000000 --- a/modules/client/ui/views/stammdaten-client-view.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { verifySession } from "@/lib/supabase/dal" -import { getStammdaten } from "@/modules/shared/queries/stammdaten" -import { StammdatenPanel } from "@/modules/shared/ui/components/stammdaten-panel" - -const StammdatenClientViewPage = async () => { - const session = await verifySession() - const stammdaten = await getStammdaten(session.userId) - - return ( - - ) -} - -export default StammdatenClientViewPage diff --git a/modules/shared/actions/credentials.ts b/modules/shared/actions/credentials.ts index e09a8a6..986cc4e 100644 --- a/modules/shared/actions/credentials.ts +++ b/modules/shared/actions/credentials.ts @@ -5,18 +5,7 @@ import { revalidatePath } from "next/cache"; import { createAdminClient } from "@/lib/supabase/admin"; import { createClient } from "@/lib/supabase/server"; import { verifySession } from "@/lib/supabase/dal"; - -async function resolveClientId(profileId: string) { - const supabase = createAdminClient(); - const { data: profile, error } = await supabase - .from("profiles") - .select("client_id") - .eq("id", profileId) - .single(); - - if (error || !profile?.client_id) return null; - return profile.client_id as string; -} +import { getClientId } from "@/modules/shared/queries/profile"; const CredentialSchema = z.object({ profileId: z.string().uuid(), @@ -51,7 +40,7 @@ export async function createCredential( return { error: firstError ?? "Bitte alle Felder ausfüllen." }; } - const clientId = await resolveClientId(profileId); + const clientId = await getClientId(profileId); if (!clientId) { return { error: "Client hat noch keine Stammdaten hinterlegt." }; } diff --git a/modules/shared/actions/invoices.ts b/modules/shared/actions/invoices.ts new file mode 100644 index 0000000..216b97e --- /dev/null +++ b/modules/shared/actions/invoices.ts @@ -0,0 +1,90 @@ +"use server"; + +import * as z from "zod"; +import { revalidatePath } from "next/cache"; +import { createClient } from "@/lib/supabase/server"; +import { verifySession } from "@/lib/supabase/dal"; +import { getClientId } from "@/modules/shared/queries/profile"; +import { + INVOICE_STATUSES, + InvoiceDraftSchema, + firstIssueMessage, + type InvoiceDraft, + type InvoiceStatus, +} from "@/modules/shared/schemas/invoice"; +import { toIsoDate } from "@/modules/shared/lib/invoice-math"; + +export type InvoiceActionState = { error: string } | { success: true }; + +function revalidateBilling(profileId: string) { + revalidatePath("/client/billing"); + revalidatePath(`/admin/clients/${profileId}/billing`); +} + +export async function createInvoice(draft: InvoiceDraft): Promise { + const session = await verifySession(); + if (!session.isAdmin) return { error: "Keine Berechtigung." }; + + const validated = InvoiceDraftSchema.safeParse(draft); + if (!validated.success) return { error: firstIssueMessage(validated.error) }; + + const data = validated.data; + const clientId = await getClientId(data.profileId); + if (!clientId) return { error: "Client hat noch keine Stammdaten hinterlegt." }; + + const supabase = await createClient(); + const { error } = await supabase.rpc("create_invoice", { + p_client_id: clientId, + p_invoice_date: data.invoiceDate, + p_due_date: data.dueDate, + p_company_name: data.companyName, + p_billing_street: data.billingStreet, + p_billing_postal_code: data.billingPostalCode, + p_billing_city: data.billingCity, + p_billing_country: data.billingCountry.toUpperCase(), + p_items: data.items.map((item) => ({ + product_template_id: item.productTemplateId, + description: item.description, + quantity: item.quantity, + unit_price: item.unitPrice, + tax_rate: item.taxRate, + })), + }); + + if (error) return { error: "Rechnung konnte nicht erstellt werden." }; + + revalidateBilling(data.profileId); + return { success: true }; +} + +const StatusSchema = z.object({ + profileId: z.string().uuid(), + invoiceId: z.string().uuid(), + status: z.enum(INVOICE_STATUSES), +}); + +export async function updateInvoiceStatus( + profileId: string, + invoiceId: string, + status: InvoiceStatus +): Promise { + const session = await verifySession(); + if (!session.isAdmin) return { error: "Keine Berechtigung." }; + + const validated = StatusSchema.safeParse({ profileId, invoiceId, status }); + if (!validated.success) return { error: "Ungültiger Status." }; + + const supabase = await createClient(); + const { error } = await supabase + .from("invoices") + .update({ + status: validated.data.status, + paid_at: validated.data.status === "paid" ? toIsoDate(new Date()) : null, + }) + .eq("id", validated.data.invoiceId); + + if (error) return { error: "Status konnte nicht geändert werden." }; + + revalidateBilling(validated.data.profileId); + return { success: true }; +} diff --git a/modules/shared/actions/stammdaten.ts b/modules/shared/actions/stammdaten.ts index c227d49..5eab1c8 100644 --- a/modules/shared/actions/stammdaten.ts +++ b/modules/shared/actions/stammdaten.ts @@ -4,6 +4,7 @@ import * as z from "zod"; import { revalidatePath } from "next/cache"; import { createAdminClient } from "@/lib/supabase/admin"; import { verifySession } from "@/lib/supabase/dal"; +import { getClientId } from "@/modules/shared/queries/profile"; const IBAN_REGEX = /^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/; @@ -147,16 +148,6 @@ export async function saveStammdaten( const supabase = createAdminClient(); - const { data: profile, error: profileError } = await supabase - .from("profiles") - .select("client_id") - .eq("id", data.profileId) - .single(); - - if (profileError || !profile) { - return { error: "Profil konnte nicht geladen werden." }; - } - const clientPayload = { company_name: data.companyName, legal_form: data.legalForm, @@ -178,7 +169,7 @@ export async function saveStammdaten( contact_phone: data.contactPhone ?? null, }; - let clientId = profile.client_id; + let clientId = await getClientId(data.profileId); if (clientId) { const { error } = await supabase.from("clients").update(clientPayload).eq("id", clientId); diff --git a/modules/shared/lib/invoice-math.ts b/modules/shared/lib/invoice-math.ts new file mode 100644 index 0000000..d0ab283 --- /dev/null +++ b/modules/shared/lib/invoice-math.ts @@ -0,0 +1,38 @@ +type Item = { quantity: number; unitPrice: number; taxRate: number }; + +const round2 = (value: number) => Math.round(value * 100) / 100; + +// Gleiche Rundung pro Position wie die View invoices_overview. +export function lineNet(item: Item) { + return round2(item.quantity * item.unitPrice); +} + +export function invoiceTotals(items: Item[]) { + const net = round2(items.reduce((sum, item) => sum + lineNet(item), 0)); + const tax = round2( + items.reduce((sum, item) => sum + round2((item.quantity * item.unitPrice * item.taxRate) / 100), 0) + ); + const rates = new Set(items.map((item) => item.taxRate)); + return { net, tax, gross: round2(net + tax), uniformTaxRate: rates.size === 1 ? [...rates][0] : null }; +} + +const numberFormat = new Intl.NumberFormat("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +export function formatAmount(value: number) { + return numberFormat.format(value); +} + +export function formatCurrency(value: number) { + return `${numberFormat.format(value)} €`; +} + +export function formatDate(isoDate: string) { + const [year, month, day] = isoDate.slice(0, 10).split("-"); + return `${day}.${month}.${year}`; +} + +export function toIsoDate(date: Date) { + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${date.getFullYear()}-${month}-${day}`; +} diff --git a/modules/shared/pdf/assets/GoogleSans-bold.ttf b/modules/shared/pdf/assets/GoogleSans-bold.ttf new file mode 100644 index 0000000..ef9b7ae Binary files /dev/null and b/modules/shared/pdf/assets/GoogleSans-bold.ttf differ diff --git a/modules/shared/pdf/assets/GoogleSans-regular.ttf b/modules/shared/pdf/assets/GoogleSans-regular.ttf new file mode 100644 index 0000000..03c4127 Binary files /dev/null and b/modules/shared/pdf/assets/GoogleSans-regular.ttf differ diff --git a/modules/shared/pdf/assets/gradient-line.png b/modules/shared/pdf/assets/gradient-line.png new file mode 100644 index 0000000..ef9cade Binary files /dev/null and b/modules/shared/pdf/assets/gradient-line.png differ diff --git a/modules/shared/pdf/assets/since3-logo.jpg b/modules/shared/pdf/assets/since3-logo.jpg new file mode 100644 index 0000000..17ad4a0 Binary files /dev/null and b/modules/shared/pdf/assets/since3-logo.jpg differ diff --git a/modules/shared/pdf/invoice-document.tsx b/modules/shared/pdf/invoice-document.tsx new file mode 100644 index 0000000..18d075b --- /dev/null +++ b/modules/shared/pdf/invoice-document.tsx @@ -0,0 +1,212 @@ +import { Document, Image as PdfImage, Link, Page, StyleSheet, Text, View } from "@react-pdf/renderer" +import type { InvoicePdfData } from "@/modules/shared/schemas/invoice" +import { formatAmount, formatCurrency, formatDate, invoiceTotals, lineNet } from "@/modules/shared/lib/invoice-math" + +const COMPANY = { + name: "Since3 GbR", + street: "Sandweg 93", + city: "48565 Steinfurt", + phone: "+49 176 47601638", + email: "info@since3.de", + web: "www.since3.de", + taxNumber: "311/5963/2094", + accountHolder: "Since3", + iban: "DE35 4007 0211 0068 9224 00", + bic: "DEUTDE3BP10", + bank: "Postbank", + partners: "Ben Luis Dalhoff, Rajpreet Singh", +} + +const INK = "#1a1a1a" +const MUTED = "#6e6e6e" +const LINK = "#1155cc" +const RULE = "#d9d9d9" + +// Maße aus Rechnung_Template_Since3_.docx (twips / 20 = pt) +const ITEM_COLUMNS = [45, 225.3, 75, 75, 85] +const SUMMARY_COLUMNS = [85.3, 84, 84, 84, 84, 84] + +const styles = StyleSheet.create({ + // Ligaturen aus, sonst wird "tt"/"ff" beim Kopieren/Auslesen falsch extrahiert ("Bite" statt "Bitte"). + page: { paddingTop: 35, paddingBottom: 95, paddingHorizontal: 45, fontFamily: "Google Sans", fontSize: 9, color: "#000", fontFeatureSettings: { liga: false, clig: false } }, + gradient: { width: 499.5, height: 5.25 }, + row: { flexDirection: "row" }, + left: { width: 305.3 }, + right: { width: 200, alignItems: "flex-end" }, + senderLine: { fontSize: 7, color: MUTED, marginTop: 14, marginBottom: 6 }, + recipient: { fontSize: 10.5, lineHeight: 1.35 }, + bold: { fontWeight: 700 }, + logo: { width: 122, height: 21.75, marginTop: 10 }, + companyInfo: { fontSize: 8.5, textAlign: "right", lineHeight: 1.35 }, + title: { fontSize: 20, fontWeight: 700, color: INK }, + refBox: { width: 190.5, backgroundColor: "#f2f2f2", padding: 8, gap: 2 }, + refHint: { fontSize: 8, fontWeight: 700, marginBottom: 2 }, + refLine: { fontSize: 8.5, fontWeight: 700, color: MUTED }, + itemHeader: { flexDirection: "row", backgroundColor: INK, color: "#fff", fontSize: 8, fontWeight: 700 }, + itemRow: { flexDirection: "row", borderBottomWidth: 0.5, borderBottomColor: RULE, fontSize: 9 }, + cell: { paddingVertical: 5, paddingHorizontal: 5 }, + right_: { textAlign: "right" }, + summaryHeader: { flexDirection: "row", borderTopWidth: 1, borderTopColor: INK, fontSize: 7.5, fontWeight: 700, color: MUTED }, + summaryValues: { flexDirection: "row", fontSize: 9 }, + total: { fontSize: 11, fontWeight: 700, color: INK, backgroundColor: "#f2f2f2" }, + note: { fontSize: 8, color: MUTED, marginTop: 10 }, + paymentBox: { borderWidth: 0.5, borderColor: RULE, backgroundColor: "#fafafa", padding: 10, marginTop: 16, gap: 2 }, + paymentTitle: { fontSize: 9.5, fontWeight: 700, color: INK, marginBottom: 4 }, + paymentText: { fontSize: 9, fontWeight: 700 }, + footer: { position: "absolute", bottom: 35, left: 45, right: 45, gap: 4 }, + footerText: { fontSize: 7, color: MUTED }, +}) + +type PdfStyle = React.ComponentProps["style"] + +function Cell({ width, align, children, style }: { + width: number + align?: "right" + children: React.ReactNode + style?: PdfStyle +}) { + return ( + + {children} + + ) +} + +export type InvoiceAssets = { + logo: { data: Buffer; format: "jpg" } + gradient: { data: Buffer; format: "png" } +} + +function InvoicePage({ invoice, assets }: { invoice: InvoicePdfData; assets: InvoiceAssets }) { + const totals = invoiceTotals(invoice.items) + const showCountry = invoice.billingCountry.toUpperCase() !== "DE" + + return ( + + + + + + + {COMPANY.name} · {COMPANY.street} · {COMPANY.city} + + + + + + + + + + {invoice.companyName} + {invoice.billingStreet} + {invoice.billingPostalCode} {invoice.billingCity} + {showCountry && {invoice.billingCountry.toUpperCase()}} + + + {COMPANY.name} + {COMPANY.street} + {COMPANY.city} + {COMPANY.phone} + {COMPANY.email} + {COMPANY.web} + St.-Nr.: {COMPANY.taxNumber} + + + + + + Rechnung + + + + Bitte bei Zahlung und Rückfragen angeben: + Rechnungsnummer: {invoice.invoiceNumber} + Rechnungsdatum: {formatDate(invoice.invoiceDate)} + Leistungsdatum: {formatDate(invoice.invoiceDate)} + + + + + + Menge + Bezeichnung + Preis/Einh. (€) + Gesamt (€) + MwSt (%) + + {invoice.items.map((item, index) => ( + + {item.quantity.toLocaleString("de-DE")} + {item.description} + {formatAmount(item.unitPrice)} + {formatAmount(lineNet(item))} + {formatAmount(item.taxRate)} + + ))} + + + + Fälligkeit + Teilbetrag + MwSt + Betrag MwSt + Brutto + Gesamtbetrag + + + {formatDate(invoice.dueDate)} + {formatCurrency(totals.net)} + + {totals.uniformTaxRate === null ? "gemischt" : `${formatAmount(totals.uniformTaxRate)} %`} + + {formatCurrency(totals.tax)} + {formatCurrency(totals.gross)} + {formatCurrency(totals.gross)} + + {totals.tax === 0 && ( + Gemäß § 19 UStG enthält der Rechnungsbetrag keine Umsatzsteuer. + )} + + + + Zahlungsinformationen + + Bitte überweisen Sie den Gesamtbetrag bis zum {formatDate(invoice.dueDate)} auf folgendes Konto: + + Kontoinhaber: {COMPANY.accountHolder} + IBAN: {COMPANY.iban} + BIC: {COMPANY.bic} + Betrag: {formatCurrency(totals.gross)} + Verwendungszweck: {invoice.invoiceNumber} + + + + + Hinweis: Bei Rückfragen kontaktieren Sie uns gerne unter {COMPANY.email}. + + Mit freundlichen Grüßen + + + + + + Das Produkt bleibt bis zur vollständigen Bezahlung unser Eigentum. + + {COMPANY.name}. Sitz der Gesellschaft: Steinfurt, Deutschland. Gesellschafter: {COMPANY.partners}. + + + {COMPANY.bank}, Kontoinhaber: {COMPANY.accountHolder}, IBAN: {COMPANY.iban}, BIC: {COMPANY.bic} + + + + ) +} + +export function InvoiceDocument({ invoice, assets }: { invoice: InvoicePdfData; assets: InvoiceAssets }) { + return ( + + + + ) +} diff --git a/modules/shared/pdf/render-invoice.tsx b/modules/shared/pdf/render-invoice.tsx new file mode 100644 index 0000000..00ef63d --- /dev/null +++ b/modules/shared/pdf/render-invoice.tsx @@ -0,0 +1,35 @@ +import "server-only" +import { readFileSync } from "node:fs" +import path from "node:path" +import { Font, renderToBuffer } from "@react-pdf/renderer" +import type { InvoicePdfData } from "@/modules/shared/schemas/invoice" +import { InvoiceDocument, type InvoiceAssets } from "./invoice-document" + +const ASSET_DIR = path.join(process.cwd(), "modules/shared/pdf/assets") + +Font.register({ + family: "Google Sans", + fonts: [ + { src: path.join(ASSET_DIR, "GoogleSans-regular.ttf"), fontWeight: 400 }, + { src: path.join(ASSET_DIR, "GoogleSans-bold.ttf"), fontWeight: 700 }, + ], +}) +Font.registerHyphenationCallback((word) => [word]) + +let assets: InvoiceAssets | null = null + +function loadAssets(): InvoiceAssets { + assets ??= { + logo: { data: readFileSync(path.join(ASSET_DIR, "since3-logo.jpg")), format: "jpg" }, + gradient: { data: readFileSync(path.join(ASSET_DIR, "gradient-line.png")), format: "png" }, + } + return assets +} + +export function renderInvoicePdf(invoice: InvoicePdfData) { + return renderToBuffer() +} + +export function invoiceFileName(invoiceNumber: string) { + return `Rechnung_${invoiceNumber.replace(/[^\w-]/g, "_")}.pdf` +} diff --git a/modules/shared/queries/invoices.ts b/modules/shared/queries/invoices.ts new file mode 100644 index 0000000..de90037 --- /dev/null +++ b/modules/shared/queries/invoices.ts @@ -0,0 +1,85 @@ +import "server-only" +import { createClient } from "@/lib/supabase/server" +import type { InvoiceListItem, InvoicePdfData, ProductTemplate } from "@/modules/shared/schemas/invoice" + +export async function getInvoices(clientId: string): Promise { + const supabase = await createClient() + const { data, error } = await supabase + .from("invoices_overview") + .select("id, invoice_number, status, display_status, gross_total, due_date, invoice_date") + .eq("client_id", clientId) + .order("invoice_date", { ascending: false }) + .order("invoice_number", { ascending: false }) + + if (error) throw new Error("Rechnungen konnten nicht geladen werden.") + + return data.map((row) => ({ + id: row.id, + invoiceNumber: row.invoice_number, + status: row.status, + displayStatus: row.display_status, + grossTotal: Number(row.gross_total), + dueDate: row.due_date, + invoiceDate: row.invoice_date, + })) +} + +export async function getProductTemplates(): Promise { + const supabase = await createClient() + const { data, error } = await supabase + .from("product_templates") + .select("id, name, description, unit_price, tax_rate") + .eq("is_active", true) + .order("name") + + if (error) throw new Error("Produktvorlagen konnten nicht geladen werden.") + + return data.map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + unitPrice: Number(row.unit_price), + taxRate: Number(row.tax_rate), + })) +} + +export async function peekNextInvoiceNumber(): Promise { + const supabase = await createClient() + const { data, error } = await supabase.rpc("peek_next_invoice_number") + if (error || typeof data !== "string") return null + return data +} + +export async function getInvoicesForPdf(invoiceIds: string[]): Promise { + const supabase = await createClient() + const { data, error } = await supabase + .from("invoices") + .select(` + invoice_number, invoice_date, due_date, company_name, + billing_street, billing_postal_code, billing_city, billing_country, + invoice_items ( position, description, quantity, unit_price, tax_rate ) + `) + .in("id", invoiceIds) + .order("invoice_number") + + if (error) throw new Error("Rechnungen konnten nicht geladen werden.") + + return data.map((row) => ({ + invoiceNumber: row.invoice_number, + invoiceDate: row.invoice_date, + dueDate: row.due_date, + companyName: row.company_name, + billingStreet: row.billing_street, + billingPostalCode: row.billing_postal_code, + billingCity: row.billing_city, + billingCountry: row.billing_country, + items: [...row.invoice_items] + .sort((a, b) => a.position - b.position) + .map((item) => ({ + description: item.description, + quantity: Number(item.quantity), + unitPrice: Number(item.unit_price), + taxRate: Number(item.tax_rate), + })), + })) +} diff --git a/modules/shared/queries/profile.ts b/modules/shared/queries/profile.ts new file mode 100644 index 0000000..5c75d8d --- /dev/null +++ b/modules/shared/queries/profile.ts @@ -0,0 +1,13 @@ +import "server-only" +import { createClient } from "@/lib/supabase/server" + +export async function getClientId(profileId: string): Promise { + const supabase = await createClient() + const { data } = await supabase + .from("profiles") + .select("client_id") + .eq("id", profileId) + .maybeSingle() + + return data?.client_id ?? null +} diff --git a/modules/shared/queries/stammdaten.ts b/modules/shared/queries/stammdaten.ts index c502829..12f6270 100644 --- a/modules/shared/queries/stammdaten.ts +++ b/modules/shared/queries/stammdaten.ts @@ -1,24 +1,18 @@ import "server-only" import { createAdminClient } from "@/lib/supabase/admin" +import { getClientId } from "@/modules/shared/queries/profile" export async function getStammdaten(profileId: string) { - const supabase = createAdminClient() + const clientId = await getClientId(profileId) - const { data: profile, error: profileError } = await supabase - .from("profiles") - .select("client_id") - .eq("id", profileId) - .single() - - if (profileError || !profile) return null - - if (!profile.client_id) { + if (!clientId) { return { client: null, paymentDetails: null } } + const supabase = createAdminClient() const [clientRes, paymentRes] = await Promise.all([ - supabase.from("clients").select("*").eq("id", profile.client_id).maybeSingle(), - supabase.from("client_payment_details").select("*").eq("client_id", profile.client_id).maybeSingle(), + supabase.from("clients").select("*").eq("id", clientId).maybeSingle(), + supabase.from("client_payment_details").select("*").eq("client_id", clientId).maybeSingle(), ]) if (clientRes.error || paymentRes.error) { diff --git a/modules/shared/schemas/invoice.ts b/modules/shared/schemas/invoice.ts new file mode 100644 index 0000000..8b38678 --- /dev/null +++ b/modules/shared/schemas/invoice.ts @@ -0,0 +1,68 @@ +import * as z from "zod"; + +export const INVOICE_STATUSES = ["pending", "paid", "cancelled"] as const; +export type InvoiceStatus = (typeof INVOICE_STATUSES)[number]; + +const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { error: "Ungültiges Datum." }); + +export const InvoiceItemSchema = z.object({ + productTemplateId: z.string().uuid().nullable(), + description: z.string().trim().min(1, { error: "Jede Position braucht eine Bezeichnung." }), + quantity: z.number().positive({ error: "Menge muss größer als 0 sein." }), + unitPrice: z.number().min(0, { error: "Preis darf nicht negativ sein." }), + taxRate: z.number().min(0).max(100), +}); + +export const InvoiceDraftSchema = z + .object({ + profileId: z.string().uuid(), + companyName: z.string().trim().min(1, { error: "Firmenname ist erforderlich." }), + billingStreet: z.string().trim().min(1, { error: "Straße ist erforderlich." }), + billingPostalCode: z.string().trim().min(1, { error: "PLZ ist erforderlich." }), + billingCity: z.string().trim().min(1, { error: "Stadt ist erforderlich." }), + billingCountry: z.string().trim().length(2, { error: "Land muss ein 2-stelliger Code sein." }), + invoiceDate: isoDate, + dueDate: isoDate, + items: z.array(InvoiceItemSchema).min(1, { error: "Mindestens eine Position hinzufügen." }), + }) + .refine((data) => data.dueDate >= data.invoiceDate, { + path: ["dueDate"], + error: "Fälligkeit darf nicht vor dem Rechnungsdatum liegen.", + }); + +export type InvoiceItemDraft = z.infer; +export type InvoiceDraft = z.infer; + +export type InvoicePdfData = { + invoiceNumber: string; + invoiceDate: string; + dueDate: string; + companyName: string; + billingStreet: string; + billingPostalCode: string; + billingCity: string; + billingCountry: string; + items: { description: string; quantity: number; unitPrice: number; taxRate: number }[]; +}; + +export type InvoiceListItem = { + id: string; + invoiceNumber: string; + status: InvoiceStatus; + displayStatus: InvoiceStatus | "overdue"; + grossTotal: number; + dueDate: string; + invoiceDate: string; +}; + +export type ProductTemplate = { + id: string; + name: string; + description: string | null; + unitPrice: number; + taxRate: number; +}; + +export function firstIssueMessage(error: z.ZodError) { + return error.issues[0]?.message ?? "Bitte alle Pflichtfelder ausfüllen."; +} diff --git a/modules/shared/types/user.ts b/modules/shared/types/user.ts index e07f403..6fdc6f5 100644 --- a/modules/shared/types/user.ts +++ b/modules/shared/types/user.ts @@ -1,4 +1,7 @@ import { verifySession } from "@/lib/supabase/dal"; -export type Session = Awaited> \ No newline at end of file +export type Session = Awaited> + +// Used for the Admin to extract URI ID for Client Profile +export type Params = Promise<{id: string}> \ No newline at end of file diff --git a/modules/shared/ui/components/billing-dialog.tsx b/modules/shared/ui/components/billing-dialog.tsx new file mode 100644 index 0000000..208207e --- /dev/null +++ b/modules/shared/ui/components/billing-dialog.tsx @@ -0,0 +1,392 @@ +"use client"; + +import { useEffect, useState, useTransition } from "react"; +import { ArrowLeftIcon, EyeIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { createInvoice } from "@/modules/shared/actions/invoices"; +import { + InvoiceDraftSchema, + firstIssueMessage, + type InvoiceDraft, + type ProductTemplate, +} from "@/modules/shared/schemas/invoice"; +import { formatCurrency, invoiceTotals, lineNet, toIsoDate } from "@/modules/shared/lib/invoice-math"; + +export type BillingPrefill = { + companyName: string; + billingStreet: string; + billingPostalCode: string; + billingCity: string; + billingCountry: string; +}; + +type ItemRow = { + key: number; + productTemplateId: string | null; + description: string; + quantity: string; + unitPrice: string; + taxRate: string; +}; + +const DEFAULT_PAYMENT_DAYS = 14; + +const selectClassName = + "flex h-10 w-full rounded-md border border-input bg-background px-3 text-body outline-none transition-colors disabled:cursor-not-allowed disabled:opacity-50"; + +let nextRowKey = 0; + +function emptyRow(): ItemRow { + return { key: nextRowKey++, productTemplateId: null, description: "", quantity: "1", unitPrice: "", taxRate: "0" }; +} + +function parseDecimal(value: string) { + const trimmed = value.trim(); + const normalized = trimmed.includes(",") ? trimmed.replace(/\./g, "").replace(",", ".") : trimmed; + return normalized === "" ? Number.NaN : Number(normalized); +} + +function formatInputNumber(value: number) { + return value.toLocaleString("de-DE", { maximumFractionDigits: 2, useGrouping: false }); +} + +function addDays(date: Date, days: number) { + const copy = new Date(date); + copy.setDate(copy.getDate() + days); + return copy; +} + +function BillingDialogContent({ + profileId, + prefill, + nextInvoiceNumber, + templates, + onDone, +}: { + profileId: string; + prefill: BillingPrefill; + nextInvoiceNumber: string | null; + templates: ProductTemplate[]; + onDone: () => void; +}) { + const [header, setHeader] = useState(() => { + const today = new Date(); + return { + ...prefill, + invoiceDate: toIsoDate(today), + dueDate: toIsoDate(addDays(today, DEFAULT_PAYMENT_DAYS)), + }; + }); + const [rows, setRows] = useState(() => [emptyRow()]); + const [error, setError] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [previewPending, setPreviewPending] = useState(false); + const [createPending, startCreate] = useTransition(); + + useEffect(() => { + return () => { + if (previewUrl) URL.revokeObjectURL(previewUrl); + }; + }, [previewUrl]); + + const parsedItems = rows.map((row) => ({ + productTemplateId: row.productTemplateId, + description: row.description, + quantity: parseDecimal(row.quantity), + unitPrice: parseDecimal(row.unitPrice), + taxRate: parseDecimal(row.taxRate), + })); + const validItems = parsedItems.filter( + (item) => Number.isFinite(item.quantity) && Number.isFinite(item.unitPrice) && Number.isFinite(item.taxRate) + ); + const totals = invoiceTotals(validItems); + + function updateHeader(field: keyof typeof header, value: string) { + setHeader((current) => ({ ...current, [field]: value })); + } + + function updateRow(key: number, patch: Partial) { + setRows((current) => current.map((row) => (row.key === key ? { ...row, ...patch } : row))); + } + + function applyTemplate(key: number, templateId: string) { + const template = templates.find((entry) => entry.id === templateId); + if (!template) { + updateRow(key, { productTemplateId: null }); + return; + } + updateRow(key, { + productTemplateId: template.id, + description: template.name, + unitPrice: formatInputNumber(template.unitPrice), + taxRate: formatInputNumber(template.taxRate), + }); + } + + function buildDraft(): InvoiceDraft | null { + const result = InvoiceDraftSchema.safeParse({ profileId, ...header, items: parsedItems }); + if (!result.success) { + setError(firstIssueMessage(result.error)); + return null; + } + setError(null); + return result.data; + } + + async function handlePreview() { + const draft = buildDraft(); + if (!draft) return; + + setPreviewPending(true); + try { + const response = await fetch("/api/invoices/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ draft, invoiceNumber: nextInvoiceNumber ?? "ENTWURF" }), + }); + if (!response.ok) { + const body = await response.json().catch(() => null); + setError(body?.error ?? "Vorschau konnte nicht erstellt werden."); + return; + } + setPreviewUrl(URL.createObjectURL(await response.blob())); + } catch { + setError("Vorschau konnte nicht erstellt werden."); + } finally { + setPreviewPending(false); + } + } + + function handleCreate() { + const draft = buildDraft(); + if (!draft) return; + + startCreate(async () => { + const result = await createInvoice(draft); + if ("error" in result) { + setError(result.error); + return; + } + onDone(); + }); + } + + const createButton = ( + + ); + + if (previewUrl) { + return ( + <> + + Vorschau + +