feat(projects): flag re-rendered emails in the feed

Each email in the rollup now carries a 'stored' flag (rendered_html present).
Emails without an exact stored copy show an amber '≈ re-rendered' tag next to
Preview, so it's visible at a glance — not just inside the modal. en + de.
This commit is contained in:
Luca
2026-06-06 22:50:03 +02:00
parent f02fba6332
commit 94f2c01590
5 changed files with 21 additions and 4 deletions
+5 -1
View File
@@ -255,12 +255,16 @@ async function getProjectOverview(id, perms = {}) {
}); });
} }
}) })
.select('id', 'recipient_email', 'email_type', 'status', 'created_at', 'sent_at', 'error_message', 'event_id') .select('id', 'recipient_email', 'email_type', 'status', 'created_at', 'sent_at', 'error_message', 'event_id',
// Exact stored preview available? (CASE is cross-DB: SQLite→0/1, PG→int)
db.raw('CASE WHEN rendered_html IS NOT NULL THEN 1 ELSE 0 END as has_rendered'))
.orderBy('created_at', 'desc') .orderBy('created_at', 'desc')
.limit(200); .limit(200);
out.emails = emails.map((e) => ({ out.emails = emails.map((e) => ({
id: e.id, recipient: e.recipient_email, type: e.email_type, status: e.status, id: e.id, recipient: e.recipient_email, type: e.email_type, status: e.status,
queuedAt: e.created_at, sentAt: e.sent_at, error: e.error_message, eventId: e.event_id, queuedAt: e.created_at, sentAt: e.sent_at, error: e.error_message, eventId: e.event_id,
// false → the cockpit preview will re-render from the current template.
stored: !!Number(e.has_rendered),
})); }));
} }
+2 -1
View File
@@ -3440,7 +3440,8 @@
"retry": "Wiederholen", "retry": "Wiederholen",
"previewTitle": "E-Mail-Vorschau", "previewTitle": "E-Mail-Vorschau",
"noPreview": "Keine gespeicherte Vorschau für diese E-Mail — sie wurde gesendet, bevor Vorschauen erfasst wurden.", "noPreview": "Keine gespeicherte Vorschau für diese E-Mail — sie wurde gesendet, bevor Vorschauen erfasst wurden.",
"reRendered": "Neu gerendert aus der aktuellen Vorlage — diese E-Mail wurde gesendet, bevor Vorschauen erfasst wurden, und kann daher leicht von der tatsächlich versendeten abweichen." "reRendered": "Neu gerendert aus der aktuellen Vorlage — diese E-Mail wurde gesendet, bevor Vorschauen erfasst wurden, und kann daher leicht von der tatsächlich versendeten abweichen.",
"reRenderedTag": "≈ neu gerendert"
}, },
"toast": { "toast": {
"created": "Projekt erstellt", "created": "Projekt erstellt",
+2 -1
View File
@@ -3440,7 +3440,8 @@
"retry": "Retry", "retry": "Retry",
"previewTitle": "Email preview", "previewTitle": "Email preview",
"noPreview": "No stored preview for this email — it was sent before previews were captured.", "noPreview": "No stored preview for this email — it was sent before previews were captured.",
"reRendered": "Re-rendered from the current template — this email was sent before previews were captured, so it may differ slightly from what the recipient received." "reRendered": "Re-rendered from the current template — this email was sent before previews were captured, so it may differ slightly from what the recipient received.",
"reRenderedTag": "≈ re-rendered"
}, },
"toast": { "toast": {
"created": "Project created", "created": "Project created",
@@ -39,6 +39,7 @@ interface FeedItem {
href?: string | null; href?: string | null;
emailId?: number; emailId?: number;
emailStatus?: string; emailStatus?: string;
reRendered?: boolean;
} }
/** Detail-page route for a clickable document, or null when there isn't one /** Detail-page route for a clickable document, or null when there isn't one
@@ -153,7 +154,7 @@ export const ProjectCockpitPage: React.FC = () => {
key: `email-${e.id}`, kind: 'email', date: e.sentAt || e.queuedAt, key: `email-${e.id}`, kind: 'email', date: e.sentAt || e.queuedAt,
title: t(`projects.feed.email`, 'Email') + ` · ${e.type}`, title: t(`projects.feed.email`, 'Email') + ` · ${e.type}`,
subtitle: e.recipient + (e.error ? `${e.error}` : ''), subtitle: e.recipient + (e.error ? `${e.error}` : ''),
status: e.status, emailId: e.id, emailStatus: e.status, status: e.status, emailId: e.id, emailStatus: e.status, reRendered: !e.stored,
}); });
} }
for (const q of data.quotes) { for (const q of data.quotes) {
@@ -361,6 +362,14 @@ export const ProjectCockpitPage: React.FC = () => {
<button onClick={() => openPreview(item.emailId as number)} className="inline-flex items-center gap-1 text-xs text-primary-600 hover:underline"> <button onClick={() => openPreview(item.emailId as number)} className="inline-flex items-center gap-1 text-xs text-primary-600 hover:underline">
<Eye className="w-3 h-3" />{t('projects.email.preview', 'Preview')} <Eye className="w-3 h-3" />{t('projects.email.preview', 'Preview')}
</button> </button>
{item.reRendered && (
<span
title={t('projects.email.reRendered', 'Re-rendered from the current template — may differ slightly from what was sent.') as string}
className="inline-block rounded-full px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300"
>
{t('projects.email.reRenderedTag', '≈ re-rendered')}
</span>
)}
{item.emailStatus === 'sent' && ( {item.emailStatus === 'sent' && (
<button onClick={() => emailActionMutation.mutate({ action: 'resend', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-neutral-600 dark:text-neutral-300 hover:underline"> <button onClick={() => emailActionMutation.mutate({ action: 'resend', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-neutral-600 dark:text-neutral-300 hover:underline">
<Send className="w-3 h-3" />{t('projects.email.resend', 'Resend')} <Send className="w-3 h-3" />{t('projects.email.resend', 'Resend')}
@@ -47,6 +47,8 @@ export interface ProjectEmail {
sentAt: string | null; sentAt: string | null;
error: string | null; error: string | null;
eventId: number | null; eventId: number | null;
/** true = exact HTML stored at send time; false = preview re-rendered. */
stored: boolean;
} }
export interface ProjectInvoice { export interface ProjectInvoice {