@saasicat/ui-vue 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +47 -0
  3. package/dist/index.cjs +3776 -0
  4. package/dist/index.d.cts +2395 -0
  5. package/dist/index.d.ts +2395 -0
  6. package/dist/index.js +3659 -0
  7. package/dist/testing-e2e/admin-pages-suite.cjs +134 -0
  8. package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
  9. package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
  10. package/dist/testing-e2e/admin-pages-suite.js +109 -0
  11. package/package.json +87 -0
  12. package/src/action-registry.ts +110 -0
  13. package/src/batch-column-fetcher.ts +152 -0
  14. package/src/boot-loader.ts +60 -0
  15. package/src/components/BundleVersionPublishDialog.vue +359 -0
  16. package/src/components/BusinessTypeVersionEditorDialog.vue +417 -0
  17. package/src/components/BusinessTypeVersionPublishDialog.vue +258 -0
  18. package/src/components/FeatureGate.vue +56 -0
  19. package/src/components/KpiCard.vue +53 -0
  20. package/src/components/KvBlock.vue +36 -0
  21. package/src/components/MarketingPromotionsTab.vue +861 -0
  22. package/src/components/MfaPromptDialog.vue +104 -0
  23. package/src/components/TenantActionConfirmDialog.vue +252 -0
  24. package/src/components/VersionDiffPreview.vue +150 -0
  25. package/src/components/bundle-editor/BundleCreatePanel.vue +681 -0
  26. package/src/components/bundle-editor/BundleFeaturesEditor.vue +215 -0
  27. package/src/components/bundle-editor/BundlePlanCompatPicker.vue +316 -0
  28. package/src/components/bundle-editor/BundleQuotasEditor.vue +219 -0
  29. package/src/components/bundle-editor/BundleStatusBanner.vue +189 -0
  30. package/src/components/bundle-editor/BundleVersionInlineEditor.vue +659 -0
  31. package/src/components/bundle-editor/BundleVersionStrip.vue +207 -0
  32. package/src/components/bundle-editor/bundle-version-status.ts +164 -0
  33. package/src/components/bundle-editor/catalog-i18n.ts +85 -0
  34. package/src/components/dialogs/PilotCreateDialog.vue +780 -0
  35. package/src/components/dialogs/PilotEditDialog.vue +548 -0
  36. package/src/components/dialogs/PromoCodeCreateDialog.vue +737 -0
  37. package/src/components/dialogs/PromoCodeEditDialog.vue +859 -0
  38. package/src/components/dialogs/types.ts +126 -0
  39. package/src/components/plan/PlanCycleToggle.vue +99 -0
  40. package/src/components/plan/PlanGrid.vue +211 -0
  41. package/src/components/plan/PriceSummary.vue +293 -0
  42. package/src/components/plan/PromoCodeInput.vue +192 -0
  43. package/src/components/plan/PublicBundleGrid.vue +287 -0
  44. package/src/components/plan-cockpit/PlanCockpit.vue +993 -0
  45. package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +65 -0
  46. package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +91 -0
  47. package/src/components/plan-cockpit/PlanCockpitHeader.vue +98 -0
  48. package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +99 -0
  49. package/src/components/plan-cockpit/PlanCockpitKpis.vue +57 -0
  50. package/src/components/plan-cockpit/PlanCockpitVersions.vue +226 -0
  51. package/src/components/plan-cockpit/types.ts +72 -0
  52. package/src/components/plan-create-dialog/PlanCreateDialog.vue +508 -0
  53. package/src/components/plan-detail/PlanAuditLog.vue +59 -0
  54. package/src/components/plan-detail/PlanDetail.vue +1195 -0
  55. package/src/components/plan-detail/PlanDetailHeader.vue +185 -0
  56. package/src/components/plan-detail/PlanDetailKpis.vue +57 -0
  57. package/src/components/plan-detail/PlanTerminateDialog.vue +76 -0
  58. package/src/components/plan-detail/PlanVersionDiffPanel.vue +149 -0
  59. package/src/components/plan-detail/PlanVersionsPanel.vue +255 -0
  60. package/src/components/plan-detail/types.ts +59 -0
  61. package/src/components/plan-list/PlanList.vue +1067 -0
  62. package/src/components/plan-matrix/PlanMatrix.vue +1113 -0
  63. package/src/components/plan-review/PlanReview.vue +742 -0
  64. package/src/components/plan-version-editor/PlanCatalogPreview.vue +184 -0
  65. package/src/components/plan-version-editor/PlanComponentPool.vue +272 -0
  66. package/src/components/plan-version-editor/PlanVersionBasket.vue +281 -0
  67. package/src/components/plan-version-editor/PlanVersionDiffDialog.vue +84 -0
  68. package/src/components/plan-version-editor/PlanVersionEditor.vue +1728 -0
  69. package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +94 -0
  70. package/src/components/plan-version-editor/types.ts +96 -0
  71. package/src/components/wizard-stepper/WizardStepper.vue +107 -0
  72. package/src/create-admin-routes.ts +68 -0
  73. package/src/create-super-admin-app.ts +345 -0
  74. package/src/entitlement-provider.ts +36 -0
  75. package/src/feature-router-guard.ts +77 -0
  76. package/src/http-json.ts +62 -0
  77. package/src/index.ts +98 -0
  78. package/src/manifest-loader.ts +139 -0
  79. package/src/manifest-store-factory.ts +114 -0
  80. package/src/nav-builder.ts +274 -0
  81. package/src/pages-standard/AdminLayout.vue +411 -0
  82. package/src/pages-standard/AdminManifestErrorPage.vue +79 -0
  83. package/src/pages-standard/AuditPage.vue +223 -0
  84. package/src/pages-standard/BundlesPage.vue +910 -0
  85. package/src/pages-standard/BusinessTypesPage.vue +662 -0
  86. package/src/pages-standard/DashboardPage.vue +595 -0
  87. package/src/pages-standard/DiscoveryPage.vue +663 -0
  88. package/src/pages-standard/EmailHistoryPage.vue +576 -0
  89. package/src/pages-standard/MarketingCatalogPage.vue +1771 -0
  90. package/src/pages-standard/PilotsPage.vue +577 -0
  91. package/src/pages-standard/PlanVersionsPage.vue +216 -0
  92. package/src/pages-standard/PlansPage.vue +1222 -0
  93. package/src/pages-standard/PlatformEmailPage.vue +434 -0
  94. package/src/pages-standard/PromoCodeDetailPage.vue +331 -0
  95. package/src/pages-standard/PromoCodesPage.vue +589 -0
  96. package/src/pages-standard/SubscriptionsPage.vue +159 -0
  97. package/src/pages-standard/SuperAdminLoginPage.vue +299 -0
  98. package/src/pages-standard/SuperAdminSetupWizard.vue +405 -0
  99. package/src/pages-standard/TenantDetailPage.vue +432 -0
  100. package/src/pages-standard/TenantsPage.vue +732 -0
  101. package/src/pages-standard/UsersPage.vue +504 -0
  102. package/src/pages-standard/bundles-page/BundleAccordionList.vue +116 -0
  103. package/src/pages-standard/bundles-page/BundleDetailPanel.vue +193 -0
  104. package/src/pages-standard/bundles-page/BundlesFilterBar.vue +48 -0
  105. package/src/pages-standard/bundles-page/BundlesHeader.vue +67 -0
  106. package/src/pages-standard/bundles-page/BundlesKpis.vue +39 -0
  107. package/src/pages-standard/bundles-page/types.ts +15 -0
  108. package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +246 -0
  109. package/src/pages-standard/discovery-page/DiscoveryCapList.vue +171 -0
  110. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +410 -0
  111. package/src/pages-standard/discovery-page/DiscoveryHeader.vue +31 -0
  112. package/src/pages-standard/discovery-page/DiscoveryKpis.vue +47 -0
  113. package/src/pages-standard/discovery-page/DiscoveryMetaBanner.vue +26 -0
  114. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +247 -0
  115. package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +73 -0
  116. package/src/pages-standard/discovery-page/discovery-ui.ts +166 -0
  117. package/src/pages-standard/email-history.types.ts +48 -0
  118. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +448 -0
  119. package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +93 -0
  120. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +138 -0
  121. package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +58 -0
  122. package/src/pages-standard/marketing-catalog/types.ts +43 -0
  123. package/src/pages-standard/plan-versions/PlanDiffCard.vue +132 -0
  124. package/src/pages-standard/plan-versions/PlanVersionHeader.vue +293 -0
  125. package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +48 -0
  126. package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +307 -0
  127. package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +269 -0
  128. package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +65 -0
  129. package/src/pages-standard/plan-versions/PlanVersionsList.vue +292 -0
  130. package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +261 -0
  131. package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +300 -0
  132. package/src/pages-standard/plan-versions/VersionDiffPreview.vue +167 -0
  133. package/src/pages-standard/plan-versions/format.ts +53 -0
  134. package/src/pages-standard/plan-versions/types.ts +33 -0
  135. package/src/pages-standard/plans-page/PlanArchiveDialog.vue +50 -0
  136. package/src/pages-standard/plans-page/PlanBundleOverview.vue +147 -0
  137. package/src/pages-standard/plans-page/PlanDiscardDraftDialog.vue +46 -0
  138. package/src/pages-standard/plans-page/PlanPublishDialog.vue +106 -0
  139. package/src/pages-standard/plans-page/PlansPageToast.vue +69 -0
  140. package/src/pages-standard/plans-page/types.ts +18 -0
  141. package/src/pages-standard/platform-email.types.ts +41 -0
  142. package/src/pages-standard/sa-theme.css +223 -0
  143. package/src/pages-standard/tenants/StatusPill.vue +62 -0
  144. package/src/pages-standard/tenants/format.ts +38 -0
  145. package/src/pages-tenant/LimitsRow.vue +67 -0
  146. package/src/pages-tenant/MySubscriptionBundlesPage.vue +604 -0
  147. package/src/pages-tenant/OnboardingConfigurator.vue +417 -0
  148. package/src/pages-tenant/PackageSnapshotPanel.vue +241 -0
  149. package/src/pages-tenant/PendingVersionBanner.vue +126 -0
  150. package/src/pages-tenant/PlanChangeWizard.vue +630 -0
  151. package/src/pages-tenant/TenantPlanSection.vue +729 -0
  152. package/src/pages-tenant/UsageBar.vue +107 -0
  153. package/src/pages-tenant/default-i18n.ts +280 -0
  154. package/src/pages-tenant/tenant-plan-section/BundlePreviewDialog.vue +304 -0
  155. package/src/pages-tenant/tenant-plan-section/TenantBundleStore.vue +323 -0
  156. package/src/pages-tenant/tenant-plan-section/TenantFeatureMatrix.vue +122 -0
  157. package/src/pages-tenant/tenant-plan-section/TenantPlanCardHeader.vue +67 -0
  158. package/src/pages-tenant/tenant-plan-section/TenantUsageGrid.vue +31 -0
  159. package/src/plan-versions-catalog.ts +368 -0
  160. package/src/platform-loaders.ts +68 -0
  161. package/src/project-page-host.ts +108 -0
  162. package/src/testing-e2e/admin-pages-suite.ts +187 -0
  163. package/src/types.ts +58 -0
  164. package/src/use-actions.ts +25 -0
  165. package/src/use-api-list.ts +151 -0
  166. package/src/use-audit-entries.ts +48 -0
  167. package/src/use-batch-columns.ts +59 -0
  168. package/src/use-bulk-publish.ts +150 -0
  169. package/src/use-bundle-versions-map.ts +112 -0
  170. package/src/use-bundles.ts +297 -0
  171. package/src/use-business-types.ts +269 -0
  172. package/src/use-catalog-entries.ts +243 -0
  173. package/src/use-discovery.ts +158 -0
  174. package/src/use-entitlement.ts +87 -0
  175. package/src/use-live-plan-versions.ts +126 -0
  176. package/src/use-manifest.ts +57 -0
  177. package/src/use-marketing-projections.ts +160 -0
  178. package/src/use-nav.ts +33 -0
  179. package/src/use-plan-editor.ts +162 -0
  180. package/src/use-plan-versions.ts +83 -0
  181. package/src/use-plans.ts +375 -0
  182. package/src/use-platform-tenant-actions.ts +286 -0
  183. package/src/use-promotions.ts +126 -0
  184. package/src/use-public-boot.ts +40 -0
  185. package/src/use-subscription-draft.ts +355 -0
  186. package/src/use-super-admin-context.ts +93 -0
  187. package/src/use-tenant-action-flow.ts +238 -0
  188. package/src/use-tenant-billing-catalog.ts +160 -0
  189. package/src/use-tenant-billing.ts +453 -0
  190. package/src/use-tenant-manifest.ts +94 -0
  191. package/src/use-tenant-subscription-bundles.ts +148 -0
  192. package/src/use-tenants.ts +61 -0
  193. package/src/version.ts +10 -0
@@ -0,0 +1,576 @@
1
+ <template>
2
+ <div class="sa-emh">
3
+ <header class="sa-emh__head">
4
+ <div>
5
+ <h1 class="sa-emh__title">{{ title }}</h1>
6
+ <p class="sa-emh__sub">
7
+ Über den Plattform-Absender versendete System-Mails — Empfänger, Status und
8
+ Fehler.
9
+ </p>
10
+ </div>
11
+ <q-btn flat icon="refresh" label="Neu laden" @click="applyFilter" />
12
+ </header>
13
+
14
+ <div class="sa-emh__body">
15
+ <div class="sa-emh__filter">
16
+ <q-input
17
+ v-model="filter.search"
18
+ outlined
19
+ dense
20
+ clearable
21
+ label="Suche (Empfänger, Betreff, Inhalt …)"
22
+ debounce="350"
23
+ @keyup.enter="applyFilter"
24
+ @update:model-value="applyFilter"
25
+ >
26
+ <template #prepend><q-icon name="search" /></template>
27
+ </q-input>
28
+ <q-select
29
+ v-model="filter.status"
30
+ outlined
31
+ dense
32
+ clearable
33
+ emit-value
34
+ map-options
35
+ label="Status"
36
+ :options="statusOptions"
37
+ @update:model-value="applyFilter"
38
+ />
39
+ <q-input
40
+ v-model="filter.from"
41
+ outlined
42
+ dense
43
+ clearable
44
+ type="date"
45
+ label="Von"
46
+ @update:model-value="applyFilter"
47
+ />
48
+ <q-input
49
+ v-model="filter.to"
50
+ outlined
51
+ dense
52
+ clearable
53
+ type="date"
54
+ label="Bis"
55
+ @update:model-value="applyFilter"
56
+ />
57
+ </div>
58
+
59
+ <div class="sa-emh__card">
60
+ <q-table
61
+ v-model:pagination="pagination"
62
+ flat
63
+ :rows="rows"
64
+ :columns="columns"
65
+ row-key="id"
66
+ :loading="loading"
67
+ :rows-number="pagination.rowsNumber"
68
+ @request="onRequest"
69
+ @row-click="(_evt, row) => openDetail(row)"
70
+ >
71
+ <template #body-cell-status="{ row }">
72
+ <q-td>
73
+ <q-badge :color="statusColor(row.status)" :label="statusLabel(row.status)" />
74
+ </q-td>
75
+ </template>
76
+ <template #body-cell-actions="{ row }">
77
+ <q-td class="text-right">
78
+ <q-btn
79
+ flat
80
+ dense
81
+ icon="send"
82
+ color="primary"
83
+ title="Erneut senden"
84
+ @click.stop="onResend(row.id)"
85
+ />
86
+ <q-btn
87
+ flat
88
+ dense
89
+ icon="delete"
90
+ color="negative"
91
+ title="Aus Verlauf entfernen"
92
+ @click.stop="askDelete(row.id)"
93
+ />
94
+ </q-td>
95
+ </template>
96
+ <template #no-data>
97
+ <div class="sa-emh__empty">Keine E-Mails im Verlauf.</div>
98
+ </template>
99
+ </q-table>
100
+ </div>
101
+ </div>
102
+
103
+ <q-dialog v-model="detailOpen">
104
+ <q-card class="sa-emh__detail">
105
+ <q-inner-loading :showing="detailLoading" />
106
+ <template v-if="detail">
107
+ <q-card-section>
108
+ <div class="row items-center no-wrap q-gutter-sm">
109
+ <q-badge :color="statusColor(detail.status)" :label="statusLabel(detail.status)" />
110
+ <div class="text-h6 ellipsis">{{ detail.subject }}</div>
111
+ </div>
112
+ <div class="text-caption text-grey-7 q-mt-xs">
113
+ Von {{ detail.fromEmail }} · An {{ detail.toEmail }}
114
+ <span v-if="detail.ccEmail"> · Cc {{ detail.ccEmail }}</span>
115
+ <span v-if="detail.bccEmail"> · Bcc {{ detail.bccEmail }}</span>
116
+ </div>
117
+ <div class="text-caption text-grey-7">
118
+ Erstellt {{ formatTs(detail.createdAt) }} · Gesendet
119
+ {{ formatTs(detail.sentAt) }}
120
+ </div>
121
+ </q-card-section>
122
+
123
+ <q-card-section v-if="detail.status === 'FAILED' && detail.errorMessage" class="q-pt-none">
124
+ <q-banner dense class="bg-red-1 text-red-9 sa-emh__error">
125
+ <template #avatar><q-icon name="error" color="negative" /></template>
126
+ {{ detail.errorMessage }}
127
+ </q-banner>
128
+ </q-card-section>
129
+
130
+ <q-card-section class="q-pt-none">
131
+ <!-- Sandbox without flags: no script, no same-origin — safe
132
+ preview rendering of arbitrary email HTML. -->
133
+ <iframe
134
+ v-if="detail.bodyHtml"
135
+ :srcdoc="detail.bodyHtml"
136
+ sandbox=""
137
+ referrerpolicy="no-referrer"
138
+ class="sa-emh__frame"
139
+ />
140
+ <pre v-else-if="detail.bodyText" class="sa-emh__text">{{ detail.bodyText }}</pre>
141
+ <div v-else class="text-grey-6">Kein Inhalt gespeichert.</div>
142
+ </q-card-section>
143
+
144
+ <q-card-section v-if="detail.smtpResponse" class="q-pt-none">
145
+ <div class="text-caption text-grey-7">SMTP-Antwort</div>
146
+ <pre class="sa-emh__smtp">{{ detail.smtpResponse }}</pre>
147
+ </q-card-section>
148
+
149
+ <q-card-actions align="right">
150
+ <q-btn
151
+ flat
152
+ color="negative"
153
+ icon="delete"
154
+ label="Entfernen"
155
+ @click="askDelete(detail.id)"
156
+ />
157
+ <q-btn
158
+ unelevated
159
+ color="primary"
160
+ icon="send"
161
+ label="Erneut senden"
162
+ @click="onResend(detail.id)"
163
+ />
164
+ <q-btn v-close-popup flat label="Schließen" />
165
+ </q-card-actions>
166
+ </template>
167
+ </q-card>
168
+ </q-dialog>
169
+
170
+ <q-dialog v-model="confirmDeleteOpen">
171
+ <q-card class="sa-emh__confirm">
172
+ <q-card-section>
173
+ <div class="text-h6">E-Mail entfernen</div>
174
+ </q-card-section>
175
+ <q-card-section class="q-pt-none">
176
+ Diese E-Mail aus dem Verlauf entfernen? Der Eintrag wird ausgeblendet, bleibt
177
+ aber für das Audit erhalten.
178
+ </q-card-section>
179
+ <q-card-actions align="right">
180
+ <q-btn v-close-popup flat label="Abbrechen" />
181
+ <q-btn unelevated color="negative" label="Entfernen" @click="confirmDelete" />
182
+ </q-card-actions>
183
+ </q-card>
184
+ </q-dialog>
185
+
186
+ <MfaPromptDialog
187
+ v-if="requireMfaForWrite"
188
+ :model-value="showMfa"
189
+ :description="mfaDescription"
190
+ :error="mfaError"
191
+ :setup-hint="mfaSetupHint"
192
+ @update:model-value="onMfaDialogVisibility"
193
+ @confirm="onMfaConfirm"
194
+ />
195
+ </div>
196
+ </template>
197
+
198
+ <script setup lang="ts">
199
+ import { reactive, ref } from 'vue';
200
+ import { useQuasar } from 'quasar';
201
+ import MfaPromptDialog from '../components/MfaPromptDialog.vue';
202
+ import type {
203
+ EmailHistoryRow,
204
+ EmailHistoryDetail,
205
+ EmailHistoryFilter,
206
+ EmailHistoryListResult,
207
+ EmailHistoryResendResult,
208
+ EmailHistoryStatus,
209
+ } from './email-history.types';
210
+
211
+ // Platform standard page: email history of the platform sender. Like all
212
+ // standard pages data-agnostic — the app passes the API calls as props (with
213
+ // its own auth/MFA wiring). List/detail are read-only; remove and resend
214
+ // are MFA-required.
215
+
216
+ const props = withDefaults(
217
+ defineProps<{
218
+ loadEmails: (filter: EmailHistoryFilter) => Promise<EmailHistoryListResult>;
219
+ loadEmailDetail: (id: string) => Promise<EmailHistoryDetail>;
220
+ deleteEmail: (id: string, mfaCode?: string) => Promise<unknown>;
221
+ resendEmail: (id: string, mfaCode?: string) => Promise<EmailHistoryResendResult>;
222
+ title?: string;
223
+ pageSize?: number;
224
+ requireMfaForWrite?: boolean;
225
+ mfaSetupHint?: string;
226
+ }>(),
227
+ {
228
+ title: 'Plattform-E-Mail-Verlauf',
229
+ pageSize: 25,
230
+ requireMfaForWrite: false,
231
+ },
232
+ );
233
+
234
+ const q = useQuasar();
235
+
236
+ const rows = ref<EmailHistoryRow[]>([]);
237
+ const loading = ref(false);
238
+ const filter = reactive<{
239
+ search: string;
240
+ status: EmailHistoryStatus | null;
241
+ from: string;
242
+ to: string;
243
+ }>({ search: '', status: null, from: '', to: '' });
244
+
245
+ // Server always sorts createdAt desc — hence no sortBy/descending in the model.
246
+ const pagination = ref({
247
+ page: 1,
248
+ rowsPerPage: props.pageSize,
249
+ rowsNumber: 0,
250
+ });
251
+
252
+ const statusOptions = [
253
+ { label: 'Gesendet', value: 'SENT' },
254
+ { label: 'Fehlgeschlagen', value: 'FAILED' },
255
+ { label: 'Ausstehend', value: 'PENDING' },
256
+ { label: 'Bounced', value: 'BOUNCED' },
257
+ ];
258
+
259
+ const columns = [
260
+ { name: 'status', label: 'Status', field: 'status', align: 'left' as const },
261
+ { name: 'toEmail', label: 'Empfänger', field: 'toEmail', align: 'left' as const },
262
+ { name: 'subject', label: 'Betreff', field: 'subject', align: 'left' as const },
263
+ { name: 'fromEmail', label: 'Absender', field: 'fromEmail', align: 'left' as const },
264
+ {
265
+ name: 'createdAt',
266
+ label: 'Erstellt',
267
+ field: (r: EmailHistoryRow) => formatTs(r.createdAt),
268
+ align: 'left' as const,
269
+ },
270
+ {
271
+ name: 'sentAt',
272
+ label: 'Gesendet',
273
+ field: (r: EmailHistoryRow) => formatTs(r.sentAt),
274
+ align: 'left' as const,
275
+ },
276
+ { name: 'actions', label: '', field: 'id' as never, align: 'right' as const },
277
+ ];
278
+
279
+ const detailOpen = ref(false);
280
+ const detailLoading = ref(false);
281
+ const detail = ref<EmailHistoryDetail | null>(null);
282
+
283
+ const confirmDeleteOpen = ref(false);
284
+ const pendingDeleteId = ref<string | null>(null);
285
+
286
+ // MFA loop analogous to PlatformEmailPage (promise-resolver pattern).
287
+ const showMfa = ref(false);
288
+ const mfaError = ref('');
289
+ const mfaDescription = ref('');
290
+ let pendingMfaResolve: ((code: string | null) => void) | null = null;
291
+
292
+ // Sequence guard: with rapidly changing filters a stale (out-of-order)
293
+ // response must not overwrite the newest.
294
+ let reloadSeq = 0;
295
+
296
+ async function reload(): Promise<void> {
297
+ const seq = ++reloadSeq;
298
+ loading.value = true;
299
+ try {
300
+ const result = await props.loadEmails({
301
+ search: filter.search || undefined,
302
+ status: filter.status || undefined,
303
+ from: filter.from || undefined,
304
+ to: filter.to || undefined,
305
+ page: pagination.value.page,
306
+ limit: pagination.value.rowsPerPage,
307
+ });
308
+ if (seq !== reloadSeq) return;
309
+ rows.value = result.rows;
310
+ pagination.value.rowsNumber = result.total;
311
+ } catch (err) {
312
+ if (seq !== reloadSeq) return;
313
+ rows.value = [];
314
+ pagination.value.rowsNumber = 0;
315
+ q.notify({ type: 'negative', message: errMsg(err), position: 'top' });
316
+ } finally {
317
+ if (seq === reloadSeq) loading.value = false;
318
+ }
319
+ }
320
+
321
+ void reload();
322
+ defineExpose({ reload });
323
+
324
+ async function onRequest(req: {
325
+ pagination: { page: number; rowsPerPage: number };
326
+ }): Promise<void> {
327
+ pagination.value.page = req.pagination.page;
328
+ pagination.value.rowsPerPage = req.pagination.rowsPerPage;
329
+ await reload();
330
+ }
331
+
332
+ function applyFilter(): void {
333
+ pagination.value.page = 1;
334
+ void reload();
335
+ }
336
+
337
+ async function openDetail(row: EmailHistoryRow): Promise<void> {
338
+ detailOpen.value = true;
339
+ detailLoading.value = true;
340
+ detail.value = null;
341
+ try {
342
+ detail.value = await props.loadEmailDetail(row.id);
343
+ } catch (err) {
344
+ detailOpen.value = false;
345
+ q.notify({ type: 'negative', message: errMsg(err), position: 'top' });
346
+ } finally {
347
+ detailLoading.value = false;
348
+ }
349
+ }
350
+
351
+ async function onResend(id: string): Promise<void> {
352
+ const { ok, result } = await runWrite('Plattform-Mail erneut senden', (code) =>
353
+ props.resendEmail(id, code || undefined),
354
+ );
355
+ if (!ok) return;
356
+ if (result && result.success === false) {
357
+ q.notify({
358
+ type: 'negative',
359
+ message: result.message ?? 'Versand fehlgeschlagen',
360
+ position: 'top',
361
+ });
362
+ } else {
363
+ q.notify({ type: 'positive', message: 'E-Mail erneut versendet', position: 'top' });
364
+ }
365
+ await reload();
366
+ }
367
+
368
+ function askDelete(id: string): void {
369
+ pendingDeleteId.value = id;
370
+ confirmDeleteOpen.value = true;
371
+ }
372
+
373
+ async function confirmDelete(): Promise<void> {
374
+ const id = pendingDeleteId.value;
375
+ confirmDeleteOpen.value = false;
376
+ if (!id) return;
377
+ const { ok } = await runWrite('Plattform-Mail aus Verlauf entfernen', (code) =>
378
+ props.deleteEmail(id, code || undefined),
379
+ );
380
+ if (!ok) return;
381
+ q.notify({ type: 'positive', message: 'Aus Verlauf entfernt', position: 'top' });
382
+ if (detail.value?.id === id) detailOpen.value = false;
383
+ await reload();
384
+ }
385
+
386
+ // MFA loop: on 401 the dialog stays open and asks again. Also returns the
387
+ // result of the write (for resend, whose SMTP outcome is in the body).
388
+ async function runWrite<T>(
389
+ label: string,
390
+ invoke: (code: string) => Promise<T>,
391
+ ): Promise<{ ok: boolean; result?: T }> {
392
+ if (!props.requireMfaForWrite) {
393
+ try {
394
+ const result = await invoke('');
395
+ return { ok: true, result };
396
+ } catch (err) {
397
+ q.notify({ type: 'negative', message: errMsg(err), position: 'top' });
398
+ return { ok: false };
399
+ }
400
+ }
401
+ for (;;) {
402
+ const code = await promptMfa(label);
403
+ if (code === null) return { ok: false };
404
+ try {
405
+ const result = await invoke(code);
406
+ showMfa.value = false;
407
+ return { ok: true, result };
408
+ } catch (err) {
409
+ const status = (err as { response?: { status?: number } })?.response?.status;
410
+ if (status === 401) {
411
+ mfaError.value = 'TOTP-Code ungültig oder MFA nicht eingerichtet.';
412
+ continue;
413
+ }
414
+ showMfa.value = false;
415
+ q.notify({ type: 'negative', message: errMsg(err), position: 'top' });
416
+ return { ok: false };
417
+ }
418
+ }
419
+ }
420
+
421
+ function promptMfa(description: string): Promise<string | null> {
422
+ return new Promise((resolve) => {
423
+ mfaDescription.value = description;
424
+ mfaError.value = '';
425
+ showMfa.value = true;
426
+ pendingMfaResolve = (code) => {
427
+ pendingMfaResolve = null;
428
+ resolve(code);
429
+ };
430
+ });
431
+ }
432
+
433
+ function onMfaConfirm(code: string): void {
434
+ pendingMfaResolve?.(code);
435
+ }
436
+
437
+ function onMfaDialogVisibility(open: boolean): void {
438
+ showMfa.value = open;
439
+ if (!open && pendingMfaResolve) {
440
+ pendingMfaResolve(null);
441
+ }
442
+ }
443
+
444
+ function statusColor(status: EmailHistoryStatus): string {
445
+ switch (status) {
446
+ case 'SENT':
447
+ return 'positive';
448
+ case 'FAILED':
449
+ return 'negative';
450
+ case 'BOUNCED':
451
+ return 'orange';
452
+ default:
453
+ return 'grey';
454
+ }
455
+ }
456
+
457
+ function statusLabel(status: EmailHistoryStatus): string {
458
+ switch (status) {
459
+ case 'SENT':
460
+ return 'Gesendet';
461
+ case 'FAILED':
462
+ return 'Fehlgeschlagen';
463
+ case 'BOUNCED':
464
+ return 'Bounced';
465
+ default:
466
+ return 'Ausstehend';
467
+ }
468
+ }
469
+
470
+ function errMsg(err: unknown): string {
471
+ return (
472
+ (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
473
+ (err as Error)?.message ??
474
+ 'Aktion fehlgeschlagen'
475
+ );
476
+ }
477
+
478
+ function formatTs(iso: string | null | undefined): string {
479
+ if (!iso) return '—';
480
+ try {
481
+ return new Date(iso).toLocaleString('de-DE', {
482
+ day: '2-digit',
483
+ month: 'short',
484
+ year: 'numeric',
485
+ hour: '2-digit',
486
+ minute: '2-digit',
487
+ });
488
+ } catch {
489
+ return String(iso);
490
+ }
491
+ }
492
+ </script>
493
+
494
+ <style scoped>
495
+ .sa-emh {
496
+ min-height: calc(100vh - 56px);
497
+ background: var(--sa-bg-app, #f1f5f9);
498
+ }
499
+ .sa-emh__head {
500
+ padding: 20px 28px 8px;
501
+ display: flex;
502
+ align-items: flex-start;
503
+ justify-content: space-between;
504
+ gap: 16px;
505
+ }
506
+ .sa-emh__title {
507
+ margin: 0;
508
+ font-family: var(--sa-font-head, system-ui, sans-serif);
509
+ font-weight: 700;
510
+ font-size: 22px;
511
+ color: var(--sa-heading, #0f172a);
512
+ }
513
+ .sa-emh__sub {
514
+ margin: 4px 0 0;
515
+ color: var(--sa-muted-dark, #475569);
516
+ font-size: 13.5px;
517
+ }
518
+ .sa-emh__body {
519
+ padding: 12px 28px 28px;
520
+ }
521
+ .sa-emh__filter {
522
+ display: flex;
523
+ gap: 10px;
524
+ margin-bottom: 12px;
525
+ flex-wrap: wrap;
526
+ }
527
+ .sa-emh__filter > * {
528
+ flex: 1;
529
+ min-width: 180px;
530
+ }
531
+ .sa-emh__card {
532
+ background: #fff;
533
+ border: 1px solid var(--sa-border, #e2e8f0);
534
+ border-radius: 12px;
535
+ overflow: hidden;
536
+ padding: 8px 0;
537
+ }
538
+ .sa-emh__empty {
539
+ width: 100%;
540
+ text-align: center;
541
+ color: var(--sa-muted-dark, #64748b);
542
+ padding: 24px 0;
543
+ }
544
+ .sa-emh__detail {
545
+ min-width: 560px;
546
+ max-width: 96vw;
547
+ }
548
+ .sa-emh__confirm {
549
+ min-width: 380px;
550
+ max-width: 96vw;
551
+ }
552
+ .sa-emh__error {
553
+ border-radius: 8px;
554
+ }
555
+ .sa-emh__frame {
556
+ width: 100%;
557
+ min-height: 320px;
558
+ max-height: 60vh;
559
+ border: 1px solid var(--sa-border, #e2e8f0);
560
+ border-radius: 8px;
561
+ background: #fff;
562
+ }
563
+ .sa-emh__text,
564
+ .sa-emh__smtp {
565
+ background: #f8fafc;
566
+ border: 1px solid var(--sa-border, #e2e8f0);
567
+ border-radius: 8px;
568
+ padding: 12px;
569
+ font-size: 12px;
570
+ margin: 4px 0 0;
571
+ overflow-x: auto;
572
+ max-height: 50vh;
573
+ white-space: pre-wrap;
574
+ word-break: break-word;
575
+ }
576
+ </style>