diff --git a/lib/supabase/migrations/20261001120000_invoice_email.sql b/lib/supabase/migrations/20261001120000_invoice_email.sql new file mode 100644 index 0000000..591fb0c --- /dev/null +++ b/lib/supabase/migrations/20261001120000_invoice_email.sql @@ -0,0 +1,6 @@ +-- Wann und an wen eine Rechnung zuletzt per Mail verschickt wurde. +-- Versand passiert nur manuell durch den Admin; bestehende Rechnungen bleiben unverschickt (null). + +alter table public.invoices + add column if not exists email_sent_at timestamptz, + add column if not exists email_sent_to text; diff --git a/modules/admin/actions/client.ts b/modules/admin/actions/client.ts index 374f809..b8a8884 100644 --- a/modules/admin/actions/client.ts +++ b/modules/admin/actions/client.ts @@ -2,6 +2,9 @@ import { createAdminClient } from "@/lib/supabase/admin"; import { verifySession } from "@/lib/supabase/dal"; import { generatePassword } from "@/lib/generate-password"; +import { getAppUrl } from "@/modules/shared/mail/app-url"; +import { sendMail } from "@/modules/shared/mail/send-mail"; +import { welcomeMail } from "@/modules/shared/mail/templates"; import * as z from "zod"; @@ -13,7 +16,7 @@ const CreateClientSchema = z.object({ export type CreateClientState = | { error: string } - | { success: true; email: string; password: string } + | { success: true; email: string; password: string; welcomeMailSent: boolean } | undefined; export async function createClient( @@ -41,7 +44,7 @@ export async function createClient( const password = generatePassword(); const supabase = createAdminClient(); - const { error } = await supabase.auth.admin.createUser({ + const { data, error } = await supabase.auth.admin.createUser({ email, password, email_confirm: true, @@ -52,5 +55,19 @@ export async function createClient( return { error: error.code === "email_exists" ? "Diese Email ist bereits vergeben." : "Client konnte nicht angelegt werden." } } - return { success: true, email, password } + // Der Account existiert bereits: ein Mailfehler darf nicht verhindern, dass das Passwort angezeigt wird. + let welcomeMailSent = false; + try { + const sent = await sendMail({ + to: email, + ...welcomeMail({ name, email, appUrl: await getAppUrl() }), + type: "welcome", + idempotencyKey: `welcome-${data.user.id}`, + }); + welcomeMailSent = sent.ok; + } catch (mailError) { + console.error("[mail] Willkommensmail fehlgeschlagen:", mailError); + } + + return { success: true, email, password, welcomeMailSent } } \ No newline at end of file diff --git a/modules/admin/ui/components/add-client-dialog.tsx b/modules/admin/ui/components/add-client-dialog.tsx index e9ce5df..c53c72d 100644 --- a/modules/admin/ui/components/add-client-dialog.tsx +++ b/modules/admin/ui/components/add-client-dialog.tsx @@ -25,6 +25,11 @@ function AddClientDialogContent({ onDone }: { onDone: () => void }) { + {state.welcomeMailSent ? ( +

Willkommensmail an {state.email} wurde gesendet.

+ ) : ( +

Die Willkommensmail konnte nicht gesendet werden.

+ )}
diff --git a/modules/shared/actions/credentials.ts b/modules/shared/actions/credentials.ts index 986cc4e..b81f67f 100644 --- a/modules/shared/actions/credentials.ts +++ b/modules/shared/actions/credentials.ts @@ -2,10 +2,16 @@ import * as z from "zod"; import { revalidatePath } from "next/cache"; +import { after } from "next/server"; import { createAdminClient } from "@/lib/supabase/admin"; import { createClient } from "@/lib/supabase/server"; import { verifySession } from "@/lib/supabase/dal"; import { getClientId } from "@/modules/shared/queries/profile"; +import { getMailRecipient } from "@/modules/shared/queries/mail-recipient"; +import { getServiceById } from "@/modules/shared/data/services-data"; +import { getAppUrl } from "@/modules/shared/mail/app-url"; +import { sendMail } from "@/modules/shared/mail/send-mail"; +import { credentialAddedMail } from "@/modules/shared/mail/templates"; const CredentialSchema = z.object({ profileId: z.string().uuid(), @@ -46,7 +52,7 @@ export async function createCredential( } const supabase = await createClient(); - const { error } = await supabase.rpc("create_credential", { + const { data: credentialId, error } = await supabase.rpc("create_credential", { p_client_id: clientId, p_service: validatedFields.data.service, p_username: validatedFields.data.username, @@ -55,6 +61,25 @@ export async function createCredential( if (error) return { error: "Eintrag konnte nicht gespeichert werden." }; + // Nur wenn wir den Zugang hinterlegen – legt der Kunde ihn selbst an, braucht er keine Mail. + if (session.isAdmin) { + const serviceLabel = getServiceById(validatedFields.data.service).label; + after(async () => { + try { + const recipient = await getMailRecipient(profileId); + if (!recipient) return; + await sendMail({ + to: recipient.email, + ...credentialAddedMail({ name: recipient.name, serviceLabel, appUrl: await getAppUrl() }), + type: "credential", + idempotencyKey: `credential-${credentialId}`, + }); + } catch (error) { + console.error("[mail] Hinweis-Mail zum neuen Zugang fehlgeschlagen:", error); + } + }); + } + revalidatePath("/client/passwords"); revalidatePath(`/admin/clients/${profileId}/passwords`); diff --git a/modules/shared/actions/invoices.ts b/modules/shared/actions/invoices.ts index fcadb23..a38e387 100644 --- a/modules/shared/actions/invoices.ts +++ b/modules/shared/actions/invoices.ts @@ -5,6 +5,12 @@ 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 { getInvoicesForPdf } from "@/modules/shared/queries/invoices"; +import { getMailRecipient } from "@/modules/shared/queries/mail-recipient"; +import { getAppUrl } from "@/modules/shared/mail/app-url"; +import { sendMail } from "@/modules/shared/mail/send-mail"; +import { invoiceMail } from "@/modules/shared/mail/templates"; +import { invoiceFileName, renderInvoicePdf } from "@/modules/shared/pdf/render-invoice"; import { INVOICE_STATUSES, InvoiceDraftSchema, @@ -12,16 +18,69 @@ import { type InvoiceDraft, type InvoiceStatus, } from "@/modules/shared/schemas/invoice"; -import { toIsoDate } from "@/modules/shared/lib/invoice-math"; +import { invoiceTotals, toIsoDate } from "@/modules/shared/lib/invoice-math"; export type InvoiceActionState = { error: string } | { success: true }; +export type CreateInvoiceState = { error: string } | { success: true; mailError?: string }; function revalidateBilling(profileId: string) { revalidatePath("/client/billing"); revalidatePath(`/admin/clients/${profileId}/billing`); } -export async function createInvoice(draft: InvoiceDraft): Promise { +// Versendet eine Rechnung als PDF an den Ansprechpartner. Aufrufer muss Admin-Rechte bereits geprüft haben. +async function deliverInvoiceMail(profileId: string, clientId: string, invoiceId: string): Promise<{ error: string } | null> { + const supabase = await createClient(); + const { data: invoice } = await supabase + .from("invoices") + .select("id, status") + .eq("id", invoiceId) + .eq("client_id", clientId) + .maybeSingle(); + + if (!invoice) return { error: "Rechnung nicht gefunden." }; + if (invoice.status === "cancelled") return { error: "Stornierte Rechnungen werden nicht verschickt." }; + + // Die Rechnung ist beim Aufruf aus createInvoice schon gespeichert: Fehler hier dürfen nicht nach oben durchschlagen. + try { + const [pdfData] = await getInvoicesForPdf([invoiceId]); + const recipient = await getMailRecipient(profileId); + if (!pdfData || !recipient) return { error: "Rechnung oder Empfänger konnte nicht geladen werden." }; + + const [pdf, appUrl] = await Promise.all([renderInvoicePdf(pdfData), getAppUrl()]); + const mail = invoiceMail({ + name: recipient.name, + invoiceNumber: pdfData.invoiceNumber, + invoiceDate: pdfData.invoiceDate, + dueDate: pdfData.dueDate, + grossTotal: invoiceTotals(pdfData.items).gross, + appUrl, + }); + + const sent = await sendMail({ + to: recipient.email, + ...mail, + type: "invoice", + attachments: [{ filename: invoiceFileName(pdfData.invoiceNumber), content: new Uint8Array(pdf), contentType: "application/pdf" }], + }); + if (!sent.ok) return { error: sent.error }; + + const { error: updateError } = await supabase + .from("invoices") + .update({ email_sent_at: new Date().toISOString(), email_sent_to: recipient.email }) + .eq("id", invoiceId); + if (updateError) { + console.error(`[mail] Rechnung ${invoiceId} wurde versendet, Versandstatus aber nicht gespeichert:`, updateError.message); + } + + return null; + } catch (error) { + console.error(`[mail] Rechnung ${invoiceId} konnte nicht versendet werden:`, error); + return { error: "E-Mail konnte nicht gesendet werden." }; + } +} + +export async function createInvoice(draft: InvoiceDraft, options: { sendEmail?: boolean } = {}): Promise { const session = await verifySession(); if (!session.isAdmin) return { error: "Keine Berechtigung." }; @@ -33,7 +92,7 @@ export async function createInvoice(draft: InvoiceDraft): Promise { + const session = await verifySession(); + if (!session.isAdmin) return { error: "Keine Berechtigung." }; + + const validated = SendSchema.safeParse({ profileId, invoiceId }); + if (!validated.success) return { error: "Ungültige Rechnung." }; + + const clientId = await getClientId(validated.data.profileId); + if (!clientId) return { error: "Client hat noch keine Stammdaten hinterlegt." }; + + const failure = await deliverInvoiceMail(validated.data.profileId, clientId, validated.data.invoiceId); + if (failure) return failure; + + revalidateBilling(validated.data.profileId); return { success: true }; } diff --git a/modules/shared/data/company.ts b/modules/shared/data/company.ts new file mode 100644 index 0000000..819945e --- /dev/null +++ b/modules/shared/data/company.ts @@ -0,0 +1,17 @@ +// Firmendaten für Rechnungen (PDF) und E-Mails. +export 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", + // Kleinunternehmerregelung nach § 19 UStG. Bei Wechsel zur Regelbesteuerung auf false setzen. + smallBusiness: true, +} diff --git a/modules/shared/mail/app-url.ts b/modules/shared/mail/app-url.ts new file mode 100644 index 0000000..9aa2fd2 --- /dev/null +++ b/modules/shared/mail/app-url.ts @@ -0,0 +1,19 @@ +import "server-only" +import { headers } from "next/headers" + +// Basis-URL für Links in Mails. Nie aus Request-Headern in Produktion: Host/X-Forwarded-Host sind vom Client fälschbar. +// Reihenfolge: APP_URL, Vercel-Produktionsdomain, Vercel-Deployment-URL (Preview), lokal der Request-Host. +export async function getAppUrl() { + if (process.env.APP_URL) return process.env.APP_URL.replace(/\/$/, "") + if (process.env.VERCEL_ENV === "production" && process.env.VERCEL_PROJECT_PRODUCTION_URL) { + return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` + } + if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}` + + if (process.env.NODE_ENV === "development") { + const host = (await headers()).get("host") ?? "localhost:3000" + return `http://${host}` + } + + throw new Error("APP_URL ist nicht gesetzt – ohne feste Basis-URL werden keine Mail-Links erzeugt.") +} diff --git a/modules/shared/mail/assets/since3-logo-dark.png b/modules/shared/mail/assets/since3-logo-dark.png new file mode 100644 index 0000000..d96bf1a Binary files /dev/null and b/modules/shared/mail/assets/since3-logo-dark.png differ diff --git a/modules/shared/mail/assets/since3-logo-light.png b/modules/shared/mail/assets/since3-logo-light.png new file mode 100644 index 0000000..67ce103 Binary files /dev/null and b/modules/shared/mail/assets/since3-logo-light.png differ diff --git a/modules/shared/mail/layout.ts b/modules/shared/mail/layout.ts new file mode 100644 index 0000000..9b3711d --- /dev/null +++ b/modules/shared/mail/layout.ts @@ -0,0 +1,175 @@ +import { COMPANY } from "@/modules/shared/data/company" + +// Logos hängen als Inline-Bilder an jeder Mail (cid:), damit sie ohne öffentliche URL und auch lokal funktionieren. +export const LOGO_CIDS = { light: "since3-logo-light", dark: "since3-logo-dark" } + +// Farben aus dem Portal (Ink-Skala, monochrom) bzw. der Rechnung. +const COLORS = { + page: "#f2f2f2", + card: "#ffffff", + border: "#e3e3e3", + ink: "#0a0a0a", + text: "#1a1a1a", + muted: "#6e6e6e", + panel: "#f7f7f7", +} + +// Dark Mode wie im Portal (.dark): Hintergrund ink-950, Flächen ink-900/800, Primary-Button weiß. +const DARK = { + page: "#020202", + card: "#0c0c0c", + border: "#202020", + ink: "#ffffff", + text: "#ededed", + muted: "#949494", + panel: "#202020", +} + +// Apple Mail, iOS Mail und Outlook (Mac) werten prefers-color-scheme aus, Outlook.com/-App die [data-ogsc]/[data-ogsb]-Attribute. +// Gmail ignoriert beides und invertiert selbst – dafür hat das helle Logo einen weißen Rand. +const DARK_RULES = ` + .bg-page { background: ${DARK.page} !important; } + .bg-card { background: ${DARK.card} !important; border-color: ${DARK.border} !important; } + .bg-panel { background: ${DARK.panel} !important; border-color: ${DARK.border} !important; } + .text-ink { color: ${DARK.ink} !important; } + .text-body { color: ${DARK.text} !important; } + .text-muted { color: ${DARK.muted} !important; } + .btn { background: ${DARK.ink} !important; } + .btn-link { color: ${DARK.page} !important; } + .bar { background: #3a3a3a !important; background-image: linear-gradient(90deg, #3a3a3a 0%, #b9b9b9 100%) !important; } + .logo-light { display: none !important; } + .logo-dark { display: block !important; max-height: none !important; overflow: visible !important; }` + +const FONT = "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" + +export function escapeHtml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +export type MailLayout = { + preheader: string + heading: string + // Bereits escaptes HTML (mit escapeHtml gebaut). + paragraphs: string[] + details?: { title?: string; rows: [label: string, value: string][] } + cta?: { label: string; href: string } + closing?: string[] + appUrl: string +} + +function paragraph(html: string) { + return `

${html}

` +} + +function detailsPanel({ title, rows }: NonNullable) { + const rowsHtml = rows + .map( + ([label, value]) => ` + + ${escapeHtml(label)} + ${escapeHtml(value)} + ` + ) + .join("") + + return ` + + + + + ` +} + +function button({ label, href }: NonNullable) { + return ` + + + + +
+ ${escapeHtml(label)} +
` +} + +export function renderMailLayout(mail: MailLayout) { + const appUrl = mail.appUrl.replace(/\/$/, "") + const closing = mail.closing ?? ["Viele Grüße", "Ihr Since3 Team"] + + return ` + + + + + + + ${escapeHtml(mail.heading)} + + + +
${escapeHtml(mail.preheader)}​ ​ ​ ​ ​ 
+ + + + + + +` +} diff --git a/modules/shared/mail/send-mail.ts b/modules/shared/mail/send-mail.ts new file mode 100644 index 0000000..c57447d --- /dev/null +++ b/modules/shared/mail/send-mail.ts @@ -0,0 +1,88 @@ +import "server-only" +import { readFileSync } from "node:fs" +import path from "node:path" +import { COMPANY } from "@/modules/shared/data/company" +import { LOGO_CIDS } from "./layout" + +const RESEND_ENDPOINT = "https://api.resend.com/emails" +const DEFAULT_FROM = "Since3 " + +const ASSET_DIR = path.join(process.cwd(), "modules/shared/mail/assets") + +let logoAttachments: { filename: string; content: string; content_type: string; content_id: string }[] | null = null + +function logos() { + logoAttachments ??= (["light", "dark"] as const).map((variant) => ({ + filename: `since3-logo-${variant}.png`, + content: readFileSync(path.join(ASSET_DIR, `since3-logo-${variant}.png`)).toString("base64"), + content_type: "image/png", + content_id: LOGO_CIDS[variant], + })) + return logoAttachments +} + +export type MailAttachment = { filename: string; content: Uint8Array; contentType: string } + +export type SendMailResult = { ok: true; id: string } | { ok: false; error: string } + +// Wirft nie: Mailversand ist Nebeneffekt, der eigentliche Vorgang (Client, Rechnung, ...) soll davon nicht scheitern. +export async function sendMail({ + to, + subject, + html, + type, + attachments = [], + idempotencyKey, +}: { + to: string + subject: string + html: string + type: "welcome" | "credential" | "invoice" + attachments?: MailAttachment[] + idempotencyKey?: string +}): Promise { + const apiKey = process.env.RESEND_SECRET_API_KEY + if (!apiKey) { + console.error("[mail] RESEND_SECRET_API_KEY ist nicht gesetzt.") + return { ok: false, error: "Mailversand ist nicht eingerichtet." } + } + + try { + const response = await fetch(RESEND_ENDPOINT, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}), + }, + body: JSON.stringify({ + from: process.env.MAIL_FROM ?? DEFAULT_FROM, + to: [to], + reply_to: COMPANY.email, + subject, + html, + tags: [{ name: "type", value: type }], + attachments: [ + ...logos(), + ...attachments.map((attachment) => ({ + filename: attachment.filename, + content: Buffer.from(attachment.content).toString("base64"), + content_type: attachment.contentType, + })), + ], + }), + signal: AbortSignal.timeout(15_000), + }) + + const body = await response.json().catch(() => null) + if (!response.ok || typeof body?.id !== "string") { + console.error(`[mail] Resend hat ${type}-Mail abgelehnt (${response.status}):`, body?.message ?? body) + return { ok: false, error: "E-Mail konnte nicht gesendet werden." } + } + + return { ok: true, id: body.id } + } catch (error) { + console.error(`[mail] ${type}-Mail fehlgeschlagen:`, error) + return { ok: false, error: "E-Mail konnte nicht gesendet werden." } + } +} diff --git a/modules/shared/mail/templates.ts b/modules/shared/mail/templates.ts new file mode 100644 index 0000000..05247da --- /dev/null +++ b/modules/shared/mail/templates.ts @@ -0,0 +1,100 @@ +import { COMPANY } from "@/modules/shared/data/company" +import { formatCurrency, formatDate } from "@/modules/shared/lib/invoice-math" +import { escapeHtml, renderMailLayout } from "./layout" + +export type MailContent = { subject: string; html: string } + +function greeting(name: string | null) { + return name ? `Hallo ${escapeHtml(name)},` : "Hallo," +} + +export function welcomeMail({ name, email, appUrl }: { name: string; email: string; appUrl: string }): MailContent { + return { + subject: "Willkommen im Since3 Portal", + html: renderMailLayout({ + appUrl, + preheader: "Ihr Zugang zum Since3 Kundenportal ist eingerichtet.", + heading: "Willkommen im Since3 Portal", + paragraphs: [ + greeting(name), + "für Sie wurde ein Zugang zum Since3 Kundenportal eingerichtet. Dort finden Sie den Status Ihrer Systeme, Rechnungen, Dokumente und hinterlegte Zugangsdaten an einem Ort.", + `Bitte melden Sie sich einmal an und bestätigen Sie unsere Nutzungsbedingungen. Erst danach ist das Portal für Sie freigeschaltet.`, + ], + details: { + rows: [ + ["Login-E-Mail", email], + ["Passwort", "erhalten Sie separat von uns"], + ], + }, + cta: { label: "Zum Portal", href: appUrl }, + }), + } +} + +export function credentialAddedMail({ + name, + serviceLabel, + appUrl, +}: { + name: string | null + serviceLabel: string + appUrl: string +}): MailContent { + return { + subject: `Neuer Zugang hinterlegt: ${serviceLabel}`, + html: renderMailLayout({ + appUrl, + preheader: `Für ${serviceLabel} wurde ein Zugang im Since3 Portal hinterlegt.`, + heading: "Neuer Zugang hinterlegt", + paragraphs: [ + greeting(name), + `wir haben im Since3 Portal einen Zugang für ${escapeHtml(serviceLabel)} für Sie hinterlegt.`, + "Aus Sicherheitsgründen versenden wir Zugangsdaten nie per E-Mail. Sie finden Benutzername und Passwort jederzeit verschlüsselt im Portal unter „Passwörter“.", + ], + details: { rows: [["Dienst", serviceLabel]] }, + cta: { label: "Zugang ansehen", href: `${appUrl}/client/passwords` }, + }), + } +} + +export function invoiceMail({ + name, + invoiceNumber, + invoiceDate, + dueDate, + grossTotal, + appUrl, +}: { + name: string | null + invoiceNumber: string + invoiceDate: string + dueDate: string + grossTotal: number + appUrl: string +}): MailContent { + return { + subject: `Rechnung ${invoiceNumber} von Since3`, + html: renderMailLayout({ + appUrl, + preheader: `Rechnung ${invoiceNumber} über ${formatCurrency(grossTotal)}, fällig am ${formatDate(dueDate)}.`, + heading: `Rechnung ${invoiceNumber}`, + paragraphs: [ + greeting(name), + `anbei erhalten Sie unsere Rechnung ${escapeHtml(invoiceNumber)} vom ${formatDate(invoiceDate)} als PDF.`, + `Bitte überweisen Sie den Betrag bis zum ${formatDate(dueDate)} und geben Sie die Rechnungsnummer als Verwendungszweck an.`, + ], + details: { + title: "Zahlungsinformationen", + rows: [ + ["Betrag", formatCurrency(grossTotal)], + ["Fällig am", formatDate(dueDate)], + ["Kontoinhaber", COMPANY.accountHolder], + ["IBAN", COMPANY.iban], + ["BIC", COMPANY.bic], + ["Verwendungszweck", invoiceNumber], + ], + }, + cta: { label: "Im Portal ansehen", href: `${appUrl}/client/billing` }, + }), + } +} diff --git a/modules/shared/pdf/invoice-document.tsx b/modules/shared/pdf/invoice-document.tsx index 43a5959..5a1f414 100644 --- a/modules/shared/pdf/invoice-document.tsx +++ b/modules/shared/pdf/invoice-document.tsx @@ -1,23 +1,7 @@ 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", - // Kleinunternehmerregelung nach § 19 UStG. Bei Wechsel zur Regelbesteuerung auf false setzen. - smallBusiness: true, -} +import { COMPANY } from "@/modules/shared/data/company" const INK = "#1a1a1a" const MUTED = "#6e6e6e" diff --git a/modules/shared/queries/invoices.ts b/modules/shared/queries/invoices.ts index de90037..69dd977 100644 --- a/modules/shared/queries/invoices.ts +++ b/modules/shared/queries/invoices.ts @@ -4,14 +4,20 @@ import type { InvoiceListItem, InvoicePdfData, ProductTemplate } from "@/modules 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 }) + // Versandinfo direkt aus invoices: die View expandiert i.* nur zum Anlegezeitpunkt und kennt die Spalten nicht. + const [{ data, error }, { data: mailRows, error: mailError }] = await Promise.all([ + 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 }), + supabase.from("invoices").select("id, email_sent_at, email_sent_to").eq("client_id", clientId), + ]) - if (error) throw new Error("Rechnungen konnten nicht geladen werden.") + if (error || mailError) throw new Error("Rechnungen konnten nicht geladen werden.") + + const mailInfo = new Map(mailRows.map((row) => [row.id, row])) return data.map((row) => ({ id: row.id, @@ -21,6 +27,8 @@ export async function getInvoices(clientId: string): Promise grossTotal: Number(row.gross_total), dueDate: row.due_date, invoiceDate: row.invoice_date, + emailSentAt: mailInfo.get(row.id)?.email_sent_at ?? null, + emailSentTo: mailInfo.get(row.id)?.email_sent_to ?? null, })) } diff --git a/modules/shared/queries/mail-recipient.ts b/modules/shared/queries/mail-recipient.ts new file mode 100644 index 0000000..888a998 --- /dev/null +++ b/modules/shared/queries/mail-recipient.ts @@ -0,0 +1,29 @@ +import "server-only" +import { createAdminClient } from "@/lib/supabase/admin" + +export type MailRecipient = { email: string; name: string | null } + +// Mails gehen an den Ansprechpartner aus den Stammdaten; ohne Stammdaten an die Login-Email. +// Nur nach erfolgter Berechtigungsprüfung aufrufen (Service-Role umgeht RLS). +export async function getMailRecipient(profileId: string): Promise { + const supabase = createAdminClient() + const { data: profile } = await supabase + .from("profiles") + .select("email, name, client_id") + .eq("id", profileId) + .maybeSingle() + + if (!profile) return null + + if (profile.client_id) { + const { data: client } = await supabase + .from("clients") + .select("contact_name, contact_email") + .eq("id", profile.client_id) + .maybeSingle() + + if (client?.contact_email) return { email: client.contact_email, name: client.contact_name ?? null } + } + + return { email: profile.email, name: profile.name ?? null } +} diff --git a/modules/shared/schemas/invoice.ts b/modules/shared/schemas/invoice.ts index 08e2b16..8537334 100644 --- a/modules/shared/schemas/invoice.ts +++ b/modules/shared/schemas/invoice.ts @@ -63,6 +63,8 @@ export type InvoiceListItem = { grossTotal: number; dueDate: string; invoiceDate: string; + emailSentAt: string | null; + emailSentTo: string | null; }; export type ProductTemplate = { diff --git a/modules/shared/ui/components/billing-dialog.tsx b/modules/shared/ui/components/billing-dialog.tsx index 9f04ad4..f7e970a 100644 --- a/modules/shared/ui/components/billing-dialog.tsx +++ b/modules/shared/ui/components/billing-dialog.tsx @@ -61,12 +61,14 @@ function BillingDialogContent({ prefill, nextInvoiceNumber, templates, + recipientEmail, onDone, }: { profileId: string; prefill: BillingPrefill; nextInvoiceNumber: string | null; templates: ProductTemplate[]; + recipientEmail: string | null; onDone: () => void; }) { const [header, setHeader] = useState(() => { @@ -82,6 +84,8 @@ function BillingDialogContent({ const [previewUrl, setPreviewUrl] = useState(null); const [previewPending, setPreviewPending] = useState(false); const [createPending, startCreate] = useTransition(); + const [sendEmail, setSendEmail] = useState(false); + const [mailError, setMailError] = useState(null); useEffect(() => { return () => { @@ -162,11 +166,15 @@ function BillingDialogContent({ if (!draft) return; startCreate(async () => { - const result = await createInvoice(draft); + const result = await createInvoice(draft, { sendEmail: sendEmail && Boolean(recipientEmail) }); if ("error" in result) { setError(result.error); return; } + if (result.mailError) { + setMailError(result.mailError); + return; + } onDone(); }); } @@ -177,6 +185,37 @@ function BillingDialogContent({ ); + const sendToggle = recipientEmail ? ( + + ) : null; + + if (mailError) { + return ( + <> + + Rechnung erstellt + +

+ {mailError} Die Rechnung wurde trotzdem angelegt. Du kannst sie in der Tabelle erneut per E-Mail senden. +

+ + + + + ); + } + if (previewUrl) { return ( <> @@ -184,6 +223,7 @@ function BillingDialogContent({ Vorschau