mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
added email delivery for Account creation, Invoices, Credentials Creation
This commit is contained in:
parent
96208249aa
commit
14e1f03b66
19 changed files with 697 additions and 43 deletions
6
lib/supabase/migrations/20261001120000_invoice_email.sql
Normal file
6
lib/supabase/migrations/20261001120000_invoice_email.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
17
modules/shared/data/company.ts
Normal file
17
modules/shared/data/company.ts
Normal 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,
|
||||
}
|
||||
15
modules/shared/mail/app-url.ts
Normal file
15
modules/shared/mail/app-url.ts
Normal 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}`
|
||||
}
|
||||
BIN
modules/shared/mail/assets/since3-logo-dark.png
Normal file
BIN
modules/shared/mail/assets/since3-logo-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
BIN
modules/shared/mail/assets/since3-logo-light.png
Normal file
BIN
modules/shared/mail/assets/since3-logo-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
175
modules/shared/mail/layout.ts
Normal file
175
modules/shared/mail/layout.ts
Normal 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, "&")
|
||||
.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 `<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)}​ ​ ​ ​ ​ </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%);"> </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>
|
||||
·
|
||||
<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>`
|
||||
}
|
||||
88
modules/shared/mail/send-mail.ts
Normal file
88
modules/shared/mail/send-mail.ts
Normal 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." }
|
||||
}
|
||||
}
|
||||
100
modules/shared/mail/templates.ts
Normal file
100
modules/shared/mail/templates.ts
Normal 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` },
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
29
modules/shared/queries/mail-recipient.ts
Normal file
29
modules/shared/queries/mail-recipient.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -63,6 +63,8 @@ export type InvoiceListItem = {
|
|||
grossTotal: number;
|
||||
dueDate: string;
|
||||
invoiceDate: string;
|
||||
emailSentAt: string | null;
|
||||
emailSentTo: string | null;
|
||||
};
|
||||
|
||||
export type ProductTemplate = {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue