Compare commits
3 Commits
2f92213594
...
bc6487bba4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc6487bba4 | ||
|
|
d8b7076e7e | ||
|
|
9d888e6380 |
@@ -53,7 +53,7 @@ graph TD
|
|||||||
- Erstellt separate Orders in der Datenbank:
|
- Erstellt separate Orders in der Datenbank:
|
||||||
- Typ `purchase`: Anfrage für Kauf-Lizenzen (Zahlungsart: Vormerkung/Angebot).
|
- Typ `purchase`: Anfrage für Kauf-Lizenzen (Zahlungsart: Vormerkung/Angebot).
|
||||||
- Typ `subscription`: Anfrage für Software-Abonnement (Zahlungsart: SEPA).
|
- Typ `subscription`: Anfrage für Software-Abonnement (Zahlungsart: SEPA).
|
||||||
- Friert für jede Order einen konsolidierten `order_snapshot` (JSONB) und `customer_snapshot` (JSONB) ein.
|
- Friert für jede Order einen konsolidierten `order_data` (JSONB) und `customer_data` (JSONB) ein.
|
||||||
- Triggert die PDF-Erstellung und den E-Mail-Versand unabhängig für jede generierte Order (gibt Order-IDs für Post-Processing zurück).
|
- Triggert die PDF-Erstellung und den E-Mail-Versand unabhängig für jede generierte Order (gibt Order-IDs für Post-Processing zurück).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -63,10 +63,10 @@ graph TD
|
|||||||
- **Snapshot-Exklusivität**: Die PDF-Generierung erfolgt ausschließlich auf Basis der in `orders.order_data` und `orders.customer_data` eingefrorenen Snapshots.
|
- **Snapshot-Exklusivität**: Die PDF-Generierung erfolgt ausschließlich auf Basis der in `orders.order_data` und `orders.customer_data` eingefrorenen Snapshots.
|
||||||
- **Layout-Unterscheidung**:
|
- **Layout-Unterscheidung**:
|
||||||
- **Typ `purchase` (Kauf)**:
|
- **Typ `purchase` (Kauf)**:
|
||||||
- Generiert eine klassische **B2B-Angebot**.
|
- Generiert eine klassische **Anfragebestätigung Kauf**.
|
||||||
- Weist die einmalige Gesamtsumme aus.
|
- Weist die einmalige Gesamtsumme aus.
|
||||||
- **Typ `subscription` (Abonnement)**:
|
- **Typ `subscription` (Abonnement)**:
|
||||||
- Generiert eine **Angebotsbestätigung Abonnement**.
|
- Generiert eine **Anfragebestätigung Abonnement**.
|
||||||
- Weist monatlich wiederkehrende Kosten aus.
|
- Weist monatlich wiederkehrende Kosten aus.
|
||||||
- **Archivierung & Benachrichtigung**:
|
- **Archivierung & Benachrichtigung**:
|
||||||
- Hochladen des PDFs in den Supabase Storage (`invoices` Bucket).
|
- Hochladen des PDFs in den Supabase Storage (`invoices` Bucket).
|
||||||
|
|||||||
@@ -28,11 +28,12 @@ Das Datenbankschema besteht aus folgenden Tabellen im Schema `public`:
|
|||||||
- `id` (UUID, Primary Key)
|
- `id` (UUID, Primary Key)
|
||||||
- `name` (TEXT)
|
- `name` (TEXT)
|
||||||
- `is_required` (BOOLEAN) <-- *Muss ausgewählt werden*
|
- `is_required` (BOOLEAN) <-- *Muss ausgewählt werden*
|
||||||
- `allow_multiple` (BOOLEAN) <-- *Mehrfachauswahl erlaubt (Checkbox statt Radio)*
|
- `allow_multiselect` (BOOLEAN) <-- *Mehrfachauswahl erlaubt (Checkbox statt Radio)*
|
||||||
- `sort_order` (INTEGER) <-- *Sortierung im Wizard*
|
- `sort_order` (INTEGER) <-- *Sortierung im Wizard*
|
||||||
- `show_in_branches` (BOOLEAN)
|
- `show_in_branches` (BOOLEAN)
|
||||||
- `preselect` (BOOLEAN)
|
- `preselect` (BOOLEAN)
|
||||||
- `resets_others` (BOOLEAN)
|
- `resets_others` (BOOLEAN)
|
||||||
|
- `allow_update_discount` (BOOLEAN) <-- *Berechtigung für Update-Rabatt*
|
||||||
|
|
||||||
### `products` (Katalogprodukte / Basis-Editionen)
|
### `products` (Katalogprodukte / Basis-Editionen)
|
||||||
- Hauptlösungen (z.B. Basic-Kasse, Backoffice).
|
- Hauptlösungen (z.B. Basic-Kasse, Backoffice).
|
||||||
@@ -77,8 +78,8 @@ Das Datenbankschema besteht aus folgenden Tabellen im Schema `public`:
|
|||||||
- `type` (TEXT: `'purchase'` / `'subscription'`) <-- *Wichtig für den Checkout-Split*
|
- `type` (TEXT: `'purchase'` / `'subscription'`) <-- *Wichtig für den Checkout-Split*
|
||||||
- `payment_method` (TEXT) <-- *Rechnung bei Kauf, SEPA bei Abo*
|
- `payment_method` (TEXT) <-- *Rechnung bei Kauf, SEPA bei Abo*
|
||||||
- `total_price` (DECIMAL)
|
- `total_price` (DECIMAL)
|
||||||
- `customer_snapshot` (JSONB) <-- *Stammdaten zum Bestellzeitpunkt*
|
- `customer_data` (JSONB) <-- *Stammdaten zum Bestellzeitpunkt*
|
||||||
- `order_snapshot` (JSONB) <-- *Konfiguration zum Bestellzeitpunkt*
|
- `order_data` (JSONB) <-- *Konfiguration zum Bestellzeitpunkt*
|
||||||
- `pdf_url` (TEXT)
|
- `pdf_url` (TEXT)
|
||||||
- `status` (TEXT: `'pending'`, `'active'`, `'completed'`, `'cancelled'`)
|
- `status` (TEXT: `'pending'`, `'active'`, `'completed'`, `'cancelled'`)
|
||||||
- `created_at` (TIMESTAMPTZ)
|
- `created_at` (TIMESTAMPTZ)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const categorySchema = z.object({
|
|||||||
show_in_kauf: z.boolean().default(true),
|
show_in_kauf: z.boolean().default(true),
|
||||||
show_in_abo: z.boolean().default(true),
|
show_in_abo: z.boolean().default(true),
|
||||||
resets_others: z.boolean().default(false),
|
resets_others: z.boolean().default(false),
|
||||||
|
allow_update_discount: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
type CategoryFormValues = z.infer<typeof categorySchema>
|
type CategoryFormValues = z.infer<typeof categorySchema>
|
||||||
@@ -77,6 +78,7 @@ export function CategoryDialog({
|
|||||||
show_in_kauf: category?.show_in_kauf ?? true,
|
show_in_kauf: category?.show_in_kauf ?? true,
|
||||||
show_in_abo: category?.show_in_abo ?? true,
|
show_in_abo: category?.show_in_abo ?? true,
|
||||||
resets_others: category?.resets_others ?? false,
|
resets_others: category?.resets_others ?? false,
|
||||||
|
allow_update_discount: category?.allow_update_discount ?? true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -294,6 +296,29 @@ export function CategoryDialog({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="allow_update_discount"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-lg border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 p-4">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="space-y-1 leading-none">
|
||||||
|
<FormLabel className="text-slate-900 dark:text-white font-semibold cursor-pointer">
|
||||||
|
Update-Rabatt erlauben
|
||||||
|
</FormLabel>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Wenn aktiv, erhalten Kauf-Lizenzen dieser Kategorie den Update-Rabatt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ interface EmailDetails {
|
|||||||
customerCompanyName: string
|
customerCompanyName: string
|
||||||
totalDetailsText: string
|
totalDetailsText: string
|
||||||
totalDetailsHtml: string
|
totalDetailsHtml: string
|
||||||
|
itemsDetailsText?: string
|
||||||
|
itemsDetailsHtml?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOrderEmailTemplate(
|
export function getOrderEmailTemplate(
|
||||||
@@ -16,7 +18,7 @@ export function getOrderEmailTemplate(
|
|||||||
? `Deine Anfrage ${details.orderNumber} wurde aktualisiert.`
|
? `Deine Anfrage ${details.orderNumber} wurde aktualisiert.`
|
||||||
: `Deine Anfrage ${details.orderNumber} ist bei uns eingegangen und wird bearbeitet.`
|
: `Deine Anfrage ${details.orderNumber} ist bei uns eingegangen und wird bearbeitet.`
|
||||||
|
|
||||||
const text = `Hallo,\n\n${isUpdate ? 'Deine Anfrage wurde aktualisiert.' : 'Deine Anfrage ist bei uns eingegangen.'}\n\nDetails:\n- Nummer: ${details.orderNumber}\n- Datum: ${details.formattedDate}\n- Kunde: ${details.customerCompanyName}\n${details.totalDetailsText}\n\nDeine Anfragebestätigung findest du im Anhang als PDF.\n\nPDF-Link: ${siteUrl}/api/orders/${details.orderNumber}/download\n\nViele Grüße,\nDein CASPOS Team`
|
const text = `Hallo,\n\n${isUpdate ? 'Deine Anfrage wurde aktualisiert.' : 'Deine Anfrage ist bei uns eingegangen.'}\n\nDetails:\n- Nummer: ${details.orderNumber}\n- Datum: ${details.formattedDate}\n- Kunde: ${details.customerCompanyName}\n${details.itemsDetailsText || ''}\n${details.totalDetailsText}\n\nDeine Anfragebestätigung findest du im Anhang als PDF.\n\nPDF-Link: ${siteUrl}/api/orders/${details.orderNumber}/download\n\nViele Grüße,\nDein CASPOS Team`
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
|
||||||
@@ -38,11 +40,14 @@ export function getOrderEmailTemplate(
|
|||||||
<td style="padding: 4px 0; font-weight: bold;">Kunde:</td>
|
<td style="padding: 4px 0; font-weight: bold;">Kunde:</td>
|
||||||
<td style="padding: 4px 0;">${details.customerCompanyName}</td>
|
<td style="padding: 4px 0;">${details.customerCompanyName}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
${details.itemsDetailsHtml || ''}
|
||||||
${details.totalDetailsHtml}
|
${details.totalDetailsHtml}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Die Bestätigung liegt als PDF im Anhang.</p>
|
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Die Bestätigung liegt als PDF im Anhang.</p>
|
||||||
</div>
|
<p style="color: #475569; font-size: 16px; line-height: 1.5;">
|
||||||
|
<a href="${siteUrl}/api/orders/${details.orderNumber}/download" style="color: #3b82f6; text-decoration: underline; font-weight: 500;">Anfragebestätigung PDF herunterladen</a>
|
||||||
|
</p>
|
||||||
<p style="color: #475569; font-size: 16px; line-height: 1.5; margin-top: 24px;">Viele Grüße,<br>Dein CASPOS Team</p>
|
<p style="color: #475569; font-size: 16px; line-height: 1.5; margin-top: 24px;">Viele Grüße,<br>Dein CASPOS Team</p>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -16,6 +16,69 @@ import { getOrderEmailTemplate, getStatusEmailTemplate } from '@/lib/actions/ema
|
|||||||
|
|
||||||
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
|
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildEmailItemsSection(items: any[]) {
|
||||||
|
const groupedItems: { [key: string]: any[] } = {}
|
||||||
|
items.forEach((item: any) => {
|
||||||
|
const devName = item.device_name || 'Kasse 1'
|
||||||
|
if (!groupedItems[devName]) {
|
||||||
|
groupedItems[devName] = []
|
||||||
|
}
|
||||||
|
groupedItems[devName].push(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
let itemsDetailsText = '\nKassen-Konfigurationen:'
|
||||||
|
let itemsDetailsHtml = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" style="padding: 12px 0 6px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Kassen-Konfigurationen:</td>
|
||||||
|
</tr>
|
||||||
|
`
|
||||||
|
|
||||||
|
Object.entries(groupedItems).forEach(([deviceName, devItems]) => {
|
||||||
|
itemsDetailsText += `\n- Kasse: \${deviceName}`
|
||||||
|
itemsDetailsHtml += `
|
||||||
|
<tr style="background-color: #f1f5f9;">
|
||||||
|
<td colspan="2" style="padding: 6px 8px; font-weight: bold; color: #1e3a8a; font-size: 13px;">Kasse: \${deviceName}</td>
|
||||||
|
</tr>
|
||||||
|
`
|
||||||
|
|
||||||
|
devItems.forEach((item: any) => {
|
||||||
|
itemsDetailsText += `\n * \${item.product_name} (\${item.category_name}): \${item.base_price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} \${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}`
|
||||||
|
itemsDetailsHtml += `
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 6px 8px; font-size: 13px; color: #334155; font-weight: 500;">
|
||||||
|
\${item.product_name} <span style="font-size: 11px; color: #64748b;">(\${item.category_name})</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 6px 8px; text-align: right; font-size: 13px; font-weight: bold; color: #0f172a;">
|
||||||
|
\${item.base_price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} \${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`
|
||||||
|
|
||||||
|
item.selected_modules?.forEach((mod: any) => {
|
||||||
|
const qty = mod.quantity || 1
|
||||||
|
const price = mod.total_price ?? (mod.price * qty)
|
||||||
|
itemsDetailsText += `\n + \${mod.module_name} \${qty > 1 ? \`(x\${qty})\` : ''}: +\${price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} mtl.`
|
||||||
|
itemsDetailsHtml += `
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 8px 4px 20px; color: #64748b; font-size: 12px;">
|
||||||
|
+ \${mod.module_name} \${qty > 1 ? \`<span style="font-size: 10px; font-weight: 600;">(x\${qty})</span>\` : ''}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 4px 8px; text-align: right; color: #64748b; font-size: 12px;">
|
||||||
|
+\${price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} mtl.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: itemsDetailsText + '\n',
|
||||||
|
html: itemsDetailsHtml
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erzeugt eine menschenlesbare, nicht-sequenzielle Bestellnummer.
|
* Erzeugt eine menschenlesbare, nicht-sequenzielle Bestellnummer.
|
||||||
* Format: BE-YYYY-NNNNN (z.B. BE-2026-84731)
|
* Format: BE-YYYY-NNNNN (z.B. BE-2026-84731)
|
||||||
@@ -256,12 +319,16 @@ export async function submitOrder(params: {
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemsSection = buildEmailItemsSection(items)
|
||||||
|
|
||||||
const emailTemplate = getOrderEmailTemplate({
|
const emailTemplate = getOrderEmailTemplate({
|
||||||
orderNumber,
|
orderNumber,
|
||||||
formattedDate,
|
formattedDate,
|
||||||
customerCompanyName: customerSnapshot.company_name,
|
customerCompanyName: customerSnapshot.company_name,
|
||||||
totalDetailsText,
|
totalDetailsText,
|
||||||
totalDetailsHtml
|
totalDetailsHtml,
|
||||||
|
itemsDetailsText: itemsSection.text,
|
||||||
|
itemsDetailsHtml: itemsSection.html
|
||||||
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false)
|
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false)
|
||||||
|
|
||||||
await sendMail({
|
await sendMail({
|
||||||
@@ -577,12 +644,16 @@ export async function updateOrder(
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemsSection = buildEmailItemsSection(items)
|
||||||
|
|
||||||
const emailTemplate = getOrderEmailTemplate({
|
const emailTemplate = getOrderEmailTemplate({
|
||||||
orderNumber: order.order_number,
|
orderNumber: order.order_number,
|
||||||
formattedDate,
|
formattedDate,
|
||||||
customerCompanyName: customerSnapshot.company_name,
|
customerCompanyName: customerSnapshot.company_name,
|
||||||
totalDetailsText,
|
totalDetailsText,
|
||||||
totalDetailsHtml
|
totalDetailsHtml,
|
||||||
|
itemsDetailsText: itemsSection.text,
|
||||||
|
itemsDetailsHtml: itemsSection.html
|
||||||
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, true)
|
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, true)
|
||||||
|
|
||||||
await sendMail({
|
await sendMail({
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export type Category = {
|
|||||||
show_in_kauf?: boolean
|
show_in_kauf?: boolean
|
||||||
show_in_abo?: boolean
|
show_in_abo?: boolean
|
||||||
resets_others: boolean
|
resets_others: boolean
|
||||||
|
allow_update_discount?: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user