added email delivery for Account creation, Invoices, Credentials Creation

This commit is contained in:
Rajpreet0 2026-10-01 13:43:24 +02:00
parent 96208249aa
commit 14e1f03b66
19 changed files with 697 additions and 43 deletions

View file

@ -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,13 @@ 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 }
const mail = welcomeMail({ name, email, appUrl: await getAppUrl() });
const sent = await sendMail({
to: email,
...mail,
type: "welcome",
idempotencyKey: `welcome-${data.user.id}`,
});
return { success: true, email, password, welcomeMailSent: sent.ok }
}

View file

@ -25,6 +25,11 @@ function AddClientDialogContent({ onDone }: { onDone: () => void }) {
<FieldGroup>
<CopyField label="Email" value={state.email} />
<CopyField label="Passwort" value={state.password} />
{state.welcomeMailSent ? (
<p className="text-small text-muted-foreground">Willkommensmail an {state.email} wurde gesendet.</p>
) : (
<p className="text-small text-destructive">Die Willkommensmail konnte nicht gesendet werden.</p>
)}
</FieldGroup>
<DialogFooter className="mt-6">
<Button type="button" onClick={onDone}>Fertig</Button>

View file

@ -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,22 @@ 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 appUrl = await getAppUrl();
const serviceLabel = getServiceById(validatedFields.data.service).label;
after(async () => {
const recipient = await getMailRecipient(profileId);
if (!recipient) return;
await sendMail({
to: recipient.email,
...credentialAddedMail({ name: recipient.name, serviceLabel, appUrl }),
type: "credential",
idempotencyKey: `credential-${credentialId}`,
});
});
}
revalidatePath("/client/passwords");
revalidatePath(`/admin/clients/${profileId}/passwords`);

View file

@ -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,60 @@ 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<InvoiceActionState> {
// 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." };
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 = await renderInvoicePdf(pdfData);
const mail = invoiceMail({
name: recipient.name,
invoiceNumber: pdfData.invoiceNumber,
invoiceDate: pdfData.invoiceDate,
dueDate: pdfData.dueDate,
grossTotal: invoiceTotals(pdfData.items).gross,
appUrl: await getAppUrl(),
});
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 };
await supabase
.from("invoices")
.update({ email_sent_at: new Date().toISOString(), email_sent_to: recipient.email })
.eq("id", invoiceId);
return null;
}
export async function createInvoice(draft: InvoiceDraft, options: { sendEmail?: boolean } = {}): Promise<CreateInvoiceState> {
const session = await verifySession();
if (!session.isAdmin) return { error: "Keine Berechtigung." };
@ -33,7 +83,7 @@ export async function createInvoice(draft: InvoiceDraft): Promise<InvoiceActionS
if (!clientId) return { error: "Client hat noch keine Stammdaten hinterlegt." };
const supabase = await createClient();
const { error } = await supabase.rpc("create_invoice", {
const { data: invoiceId, error } = await supabase.rpc("create_invoice", {
p_client_id: clientId,
p_invoice_date: data.invoiceDate,
p_due_date: data.dueDate,
@ -53,7 +103,28 @@ export async function createInvoice(draft: InvoiceDraft): Promise<InvoiceActionS
if (error) return { error: "Rechnung konnte nicht erstellt werden." };
const mailFailure = options.sendEmail ? await deliverInvoiceMail(data.profileId, clientId, invoiceId) : null;
revalidateBilling(data.profileId);
return mailFailure ? { success: true, mailError: mailFailure.error } : { success: true };
}
const SendSchema = z.object({ profileId: z.string().uuid(), invoiceId: z.string().uuid() });
export async function sendInvoiceEmail(profileId: string, invoiceId: string): Promise<InvoiceActionState> {
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 };
}

View file

@ -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,
}

View file

@ -0,0 +1,15 @@
import "server-only"
import { headers } from "next/headers"
// Basis-URL für Links in Mails. APP_URL hat Vorrang, auf Vercel greift die Produktionsdomain, sonst der aktuelle Request.
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}`
}
const headerList = await headers()
const host = headerList.get("x-forwarded-host") ?? headerList.get("host") ?? "localhost:3000"
const protocol = headerList.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https")
return `${protocol}://${host}`
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}
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 `<p class="text-body" style="margin:0 0 16px;font-size:15px;line-height:24px;color:${COLORS.text};">${html}</p>`
}
function detailsPanel({ title, rows }: NonNullable<MailLayout["details"]>) {
const rowsHtml = rows
.map(
([label, value]) => `
<tr>
<td class="text-muted" style="padding:6px 0;font-size:14px;line-height:20px;color:${COLORS.muted};">${escapeHtml(label)}</td>
<td class="text-ink" align="right" style="padding:6px 0 6px 16px;font-size:14px;line-height:20px;color:${COLORS.ink};font-weight:600;">${escapeHtml(value)}</td>
</tr>`
)
.join("")
return `
<table class="bg-panel" role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:8px 0 24px;background:${COLORS.panel};border:1px solid ${COLORS.border};border-radius:10px;">
<tr>
<td style="padding:16px 20px;">
${title ? `<p class="text-ink" style="margin:0 0 6px;font-size:13px;line-height:20px;font-weight:600;color:${COLORS.ink};">${escapeHtml(title)}</p>` : ""}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${rowsHtml}</table>
</td>
</tr>
</table>`
}
function button({ label, href }: NonNullable<MailLayout["cta"]>) {
return `
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:8px 0 24px;">
<tr>
<td class="btn" bgcolor="${COLORS.ink}" style="border-radius:8px;">
<a class="btn-link" href="${escapeHtml(href)}" target="_blank" style="display:inline-block;padding:12px 22px;font-family:${FONT};font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">${escapeHtml(label)}</a>
</td>
</tr>
</table>`
}
export function renderMailLayout(mail: MailLayout) {
const appUrl = mail.appUrl.replace(/\/$/, "")
const closing = mail.closing ?? ["Viele Grüße", "Ihr Since3 Team"]
return `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>${escapeHtml(mail.heading)}</title>
<style>
:root { color-scheme: light dark; supported-color-schemes: light dark; }
@media (prefers-color-scheme: dark) {${DARK_RULES}
}
${DARK_RULES.trim().split("\n").map((rule) => `[data-ogsc] ${rule.trim()}`).join("\n ")}
@media (max-width: 600px) {
.container { padding: 16px 8px !important; }
.content { padding: 28px 22px !important; }
.header { padding: 24px 22px 0 !important; }
}
</style>
</head>
<body class="bg-page" style="margin:0;padding:0;background:${COLORS.page};font-family:${FONT};-webkit-font-smoothing:antialiased;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">${escapeHtml(mail.preheader)}&#8203;&nbsp;&#8203;&nbsp;&#8203;&nbsp;&#8203;&nbsp;&#8203;&nbsp;</div>
<table class="bg-page" role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="${COLORS.page}" style="background:${COLORS.page};">
<tr>
<td align="center" class="container" style="padding:40px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;">
<tr>
<td class="bg-card" bgcolor="${COLORS.card}" style="background:${COLORS.card};border:1px solid ${COLORS.border};border-radius:12px;overflow:hidden;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="bar" height="5" bgcolor="#1a1a1a" style="height:5px;line-height:5px;font-size:0;background:#1a1a1a;background-image:linear-gradient(90deg,#000000 0%,#8c8c8c 100%);">&nbsp;</td>
</tr>
<tr>
<td class="header" style="padding:32px 40px 0;">
<a href="${escapeHtml(appUrl)}" target="_blank" style="text-decoration:none;">
<img class="logo-light" src="cid:${LOGO_CIDS.light}" width="144" height="28" alt="Since3" style="display:block;border:0;width:144px;height:auto;">
<!--[if !mso]><!--><div class="logo-dark" style="display:none;max-height:0;overflow:hidden;mso-hide:all;"><img src="cid:${LOGO_CIDS.dark}" width="144" height="28" alt="Since3" style="display:block;border:0;width:144px;height:auto;"></div><!--<![endif]-->
</a>
</td>
</tr>
<tr>
<td class="content" style="padding:32px 40px 36px;">
<h1 class="text-ink" style="margin:0 0 20px;font-size:22px;line-height:30px;font-weight:600;color:${COLORS.ink};">${escapeHtml(mail.heading)}</h1>
${mail.paragraphs.map(paragraph).join("")}
${mail.details ? detailsPanel(mail.details) : ""}
${mail.cta ? button(mail.cta) : ""}
<p class="text-body" style="margin:0;font-size:15px;line-height:24px;color:${COLORS.text};">${closing.map(escapeHtml).join("<br>")}</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="text-muted" style="padding:24px 8px 0;font-size:12px;line-height:18px;color:${COLORS.muted};text-align:center;">
Diese E-Mail wurde automatisch versendet. Bitte antworten Sie nicht auf diese E-Mail.<br>
Bei Fragen erreichen Sie uns unter
<a href="mailto:${COMPANY.email}" class="text-muted" style="color:${COLORS.muted};text-decoration:underline;">${COMPANY.email}</a>.<br><br>
${escapeHtml(COMPANY.name)} · ${escapeHtml(COMPANY.street)} · ${escapeHtml(COMPANY.city)}<br>
<a href="${escapeHtml(appUrl)}/impressum" class="text-muted" style="color:${COLORS.muted};text-decoration:underline;">Impressum</a>
&nbsp;·&nbsp;
<a href="${escapeHtml(appUrl)}/datenschutz" class="text-muted" style="color:${COLORS.muted};text-decoration:underline;">Datenschutz</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
}

View file

@ -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 <portal@contact.since3.de>"
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<SendMailResult> {
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." }
}
}

View file

@ -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 <a class="text-ink" href="${escapeHtml(appUrl)}/agb" style="color:#0a0a0a;text-decoration:underline;">Nutzungsbedingungen</a>. 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 <strong>${escapeHtml(serviceLabel)}</strong> 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 <strong>${escapeHtml(invoiceNumber)}</strong> vom ${formatDate(invoiceDate)} als PDF.`,
`Bitte überweisen Sie den Betrag bis zum <strong>${formatDate(dueDate)}</strong> 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` },
}),
}
}

View file

@ -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"

View file

@ -4,14 +4,20 @@ import type { InvoiceListItem, InvoicePdfData, ProductTemplate } from "@/modules
export async function getInvoices(clientId: string): Promise<InvoiceListItem[]> {
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<InvoiceListItem[]>
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,
}))
}

View file

@ -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<MailRecipient | null> {
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 }
}

View file

@ -63,6 +63,8 @@ export type InvoiceListItem = {
grossTotal: number;
dueDate: string;
invoiceDate: string;
emailSentAt: string | null;
emailSentTo: string | null;
};
export type ProductTemplate = {

View file

@ -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<string | null>(null);
const [previewPending, setPreviewPending] = useState(false);
const [createPending, startCreate] = useTransition();
const [sendEmail, setSendEmail] = useState(false);
const [mailError, setMailError] = useState<string | null>(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({
</Button>
);
const sendToggle = recipientEmail ? (
<label className="flex items-start gap-2 text-sm has-disabled:opacity-50">
<input
type="checkbox"
checked={sendEmail}
onChange={(event) => setSendEmail(event.target.checked)}
disabled={createPending}
className="mt-0.5 size-4 shrink-0 cursor-pointer accent-primary"
/>
<span>
Rechnung direkt per E-Mail an <span className="font-medium break-all">{recipientEmail}</span> senden
</span>
</label>
) : null;
if (mailError) {
return (
<>
<DialogHeader>
<DialogTitle>Rechnung erstellt</DialogTitle>
</DialogHeader>
<p className="text-sm text-destructive">
{mailError} Die Rechnung wurde trotzdem angelegt. Du kannst sie in der Tabelle erneut per E-Mail senden.
</p>
<DialogFooter>
<Button type="button" onClick={onDone}>Schließen</Button>
</DialogFooter>
</>
);
}
if (previewUrl) {
return (
<>
@ -184,6 +223,7 @@ function BillingDialogContent({
<DialogTitle>Vorschau</DialogTitle>
</DialogHeader>
<iframe src={previewUrl} title="Rechnungsvorschau" className="h-[60dvh] w-full rounded-md border border-border sm:h-[70dvh]" />
{sendToggle}
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setPreviewUrl(null)} disabled={createPending}>
@ -318,6 +358,7 @@ function BillingDialogContent({
</dl>
</div>
{sendToggle}
{error && <p className="text-sm text-destructive">{error}</p>}
</FieldGroup>
<DialogFooter className="gap-2">
@ -335,12 +376,14 @@ export function BillingDialog({
prefill,
nextInvoiceNumber,
templates,
recipientEmail = null,
trigger,
}: {
profileId: string;
prefill: BillingPrefill | null;
nextInvoiceNumber: string | null;
templates: ProductTemplate[];
recipientEmail?: string | null;
trigger?: React.ReactElement;
}) {
const [open, setOpen] = useState(false);
@ -371,6 +414,7 @@ export function BillingDialog({
prefill={prefill}
nextInvoiceNumber={nextInvoiceNumber}
templates={templates}
recipientEmail={recipientEmail}
onDone={() => handleOpenChange(false)}
/>
) : (

View file

@ -1,16 +1,17 @@
"use client";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { ArrowDownToLineIcon, PencilIcon, Search } from "lucide-react";
import { ArrowDownToLineIcon, MailCheckIcon, MailIcon, PencilIcon, Search } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Field, FieldLabel } from "@/components/ui/field";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { updateInvoiceStatus } from "@/modules/shared/actions/invoices";
import { sendInvoiceEmail, updateInvoiceStatus } from "@/modules/shared/actions/invoices";
import { INVOICE_STATUSES, type InvoiceListItem, type InvoiceStatus } from "@/modules/shared/schemas/invoice";
import { formatCurrency, formatDate } from "@/modules/shared/lib/invoice-math";
import { formatDateTime } from "@/modules/shared/lib/info-format";
const STATUS_LABELS: Record<InvoiceListItem["displayStatus"], string> = {
pending: "Offen",
@ -88,6 +89,73 @@ function EditStatusDialog({ profileId, invoice }: { profileId: string; invoice:
);
}
function SendInvoiceDialog({
profileId,
invoice,
recipientEmail,
}: {
profileId: string;
invoice: InvoiceListItem;
recipientEmail: string | null;
}) {
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const cancelled = invoice.status === "cancelled";
const label = invoice.emailSentAt
? `Per E-Mail gesendet am ${formatDateTime(invoice.emailSentAt)}`
: "Per E-Mail senden";
function handleOpenChange(nextOpen: boolean) {
setOpen(nextOpen);
if (nextOpen) setError(null);
}
function handleSend() {
startTransition(async () => {
const result = await sendInvoiceEmail(profileId, invoice.id);
if ("error" in result) {
setError(result.error);
return;
}
setOpen(false);
});
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger
render={<Button variant="ghost" size="icon-sm" aria-label={label} title={label} disabled={cancelled || !recipientEmail} />}
>
{invoice.emailSentAt ? <MailCheckIcon className="size-4" /> : <MailIcon className="size-4" />}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{invoice.invoiceNumber} per E-Mail senden</DialogTitle>
<DialogDescription>
Die Rechnung geht als PDF an <span className="font-medium break-all text-foreground">{recipientEmail}</span>.
</DialogDescription>
</DialogHeader>
{invoice.emailSentAt && (
<p className="text-sm text-muted-foreground">
Bereits gesendet am {formatDateTime(invoice.emailSentAt)}
{invoice.emailSentTo ? ` an ${invoice.emailSentTo}` : ""}.
</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="button" onClick={handleSend} disabled={pending}>
{pending ? "Wird gesendet..." : invoice.emailSentAt ? "Erneut senden" : "Senden"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function invoiceUrl(invoiceId: string) {
return `/api/invoices/pdf?id=${invoiceId}&inline`;
}
@ -109,11 +177,13 @@ export function InvoicesTable({
profileId,
invoices,
isAdmin,
recipientEmail = null,
actions,
}: {
profileId: string;
invoices: InvoiceListItem[];
isAdmin: boolean;
recipientEmail?: string | null;
actions?: React.ReactNode;
}) {
const [query, setQuery] = useState("");
@ -243,7 +313,7 @@ export function InvoicesTable({
<TableHead>Status</TableHead>
<TableHead className="text-right">Betrag</TableHead>
<TableHead className="hidden sm:table-cell">Fällig am</TableHead>
{isAdmin && <TableHead className="w-12" />}
{isAdmin && <TableHead className="w-20" />}
</TableRow>
</TableHeader>
<TableBody>
@ -292,7 +362,8 @@ export function InvoicesTable({
<TableCell className="hidden tabular-nums text-muted-foreground sm:table-cell">{formatDate(invoice.dueDate)}</TableCell>
{isAdmin && (
<TableCell>
<div className="flex justify-end">
<div className="flex justify-end gap-1">
<SendInvoiceDialog profileId={profileId} invoice={invoice} recipientEmail={recipientEmail} />
<EditStatusDialog profileId={profileId} invoice={invoice} />
</div>
</TableCell>

View file

@ -4,14 +4,15 @@ import { getStammdaten } from "@/modules/shared/queries/stammdaten"
import { BillingDialog, type BillingPrefill } from "@/modules/shared/ui/components/billing-dialog"
import { InvoicesTable } from "@/modules/shared/ui/components/invoices-table"
async function AdminBillingDialog({ profileId }: { profileId: string }) {
const [stammdaten, templates, nextInvoiceNumber] = await Promise.all([
getStammdaten(profileId),
type Stammdaten = Awaited<ReturnType<typeof getStammdaten>>
async function AdminBillingDialog({ profileId, stammdaten }: { profileId: string; stammdaten: Stammdaten }) {
const [templates, nextInvoiceNumber] = await Promise.all([
getProductTemplates(),
peekNextInvoiceNumber(),
])
const client = stammdaten?.client
const client = stammdaten.client
const prefill: BillingPrefill | null = client
? {
companyName: client.company_name,
@ -28,6 +29,7 @@ async function AdminBillingDialog({ profileId }: { profileId: string }) {
prefill={prefill}
nextInvoiceNumber={nextInvoiceNumber}
templates={templates}
recipientEmail={client?.contact_email ?? null}
/>
)
}
@ -40,7 +42,10 @@ export async function BillingPanel({
isAdmin: boolean
}) {
const clientId = await getClientId(profileId)
const invoices = clientId ? await getInvoices(clientId) : []
const [invoices, stammdaten] = await Promise.all([
clientId ? getInvoices(clientId) : [],
isAdmin ? getStammdaten(profileId) : null,
])
return (
<div className="flex w-full flex-col gap-6 md:gap-8">
@ -51,7 +56,8 @@ export async function BillingPanel({
profileId={profileId}
invoices={invoices}
isAdmin={isAdmin}
actions={isAdmin ? <AdminBillingDialog profileId={profileId} /> : undefined}
recipientEmail={stammdaten?.client?.contact_email ?? null}
actions={stammdaten ? <AdminBillingDialog profileId={profileId} stammdaten={stammdaten} /> : undefined}
/>
</div>
)