@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,577 @@
1
+ <template>
2
+ <div class="sa-pilots">
3
+ <header class="sa-page-head">
4
+ <div>
5
+ <h1 class="sa-page-head__title">Pilot-Mandanten</h1>
6
+ <p class="sa-page-head__sub">{{ rows.length }} Pilot-Tenants insgesamt.</p>
7
+ </div>
8
+ <div class="sa-page-head__actions">
9
+ <slot name="head-actions">
10
+ <q-btn
11
+ v-if="enableCreate"
12
+ unelevated
13
+ color="primary"
14
+ icon="add"
15
+ :label="createLabel"
16
+ @click="showCreate = true"
17
+ />
18
+ </slot>
19
+ </div>
20
+ </header>
21
+
22
+ <div class="sa-stats">
23
+ <button
24
+ v-for="tile in statTiles"
25
+ :key="tile.id"
26
+ class="sa-stat"
27
+ :class="[
28
+ tile.tone ? `sa-stat--${tile.tone}` : null,
29
+ filter === tile.id ? 'sa-stat--active' : null,
30
+ ]"
31
+ @click="filter = tile.id"
32
+ >
33
+ <span class="sa-stat__num">{{ tile.count }}</span>
34
+ <span class="sa-stat__label">{{ tile.label }}</span>
35
+ <span v-if="tile.hint" class="sa-stat__hint">{{ tile.hint }}</span>
36
+ </button>
37
+ </div>
38
+
39
+ <q-banner v-if="reviewSoon.length" class="bg-amber-2 text-grey-9 q-mb-md" rounded>
40
+ <template #avatar><q-icon name="event" color="amber-9" /></template>
41
+ {{ reviewSoon.length }} Pilot-Mandanten enden in den nächsten 30 Tagen — bitte prüfen.
42
+ </q-banner>
43
+
44
+ <div class="sa-pilots__card">
45
+ <q-table
46
+ flat
47
+ :rows="filteredRows"
48
+ :columns="effectiveColumns"
49
+ row-key="id"
50
+ :pagination="{ rowsPerPage: 0 }"
51
+ :loading="loading"
52
+ hide-pagination
53
+ >
54
+ <template #body-cell-actions="{ row }">
55
+ <q-td>
56
+ <slot name="row-actions" :row="row">
57
+ <q-btn
58
+ v-for="action in visibleActions(row)"
59
+ :key="action.id"
60
+ flat
61
+ dense
62
+ :icon="action.icon"
63
+ :title="action.label"
64
+ :color="action.color ?? 'grey-7'"
65
+ @click="action.handler(row)"
66
+ />
67
+ </slot>
68
+ </q-td>
69
+ </template>
70
+ </q-table>
71
+ </div>
72
+
73
+ <PilotCreateDialog
74
+ v-if="enableCreate && submitCreate"
75
+ v-model="showCreate"
76
+ :plan-options="effectiveCreatePlanOptions"
77
+ :default-plan="defaultPlan"
78
+ :copy="copy"
79
+ :require-mfa="requireMfa"
80
+ :mfa-setup-hint="mfaSetupHint"
81
+ :submit="submitCreate"
82
+ @created="onCreated"
83
+ />
84
+
85
+ <PilotEditDialog
86
+ v-if="enableEdit && submitEdit"
87
+ v-model="showEdit"
88
+ :row="editRow"
89
+ :plan-options="bakedPlanOptions"
90
+ :copy="copy"
91
+ :require-mfa="requireMfa"
92
+ :mfa-setup-hint="mfaSetupHint"
93
+ :submit="submitEdit"
94
+ @updated="onUpdated"
95
+ />
96
+
97
+ <MfaPromptDialog
98
+ v-if="needsMfaDialog"
99
+ :model-value="showMfa"
100
+ :description="mfaDescription"
101
+ :error="mfaError"
102
+ :setup-hint="mfaSetupHint"
103
+ @update:model-value="onMfaDialogVisibility"
104
+ @confirm="onMfaConfirm"
105
+ />
106
+ </div>
107
+ </template>
108
+
109
+ <script setup lang="ts">
110
+ import { computed, onMounted, ref } from 'vue';
111
+ import { useQuasar } from 'quasar';
112
+ import PilotCreateDialog from '../components/dialogs/PilotCreateDialog.vue';
113
+ import PilotEditDialog from '../components/dialogs/PilotEditDialog.vue';
114
+ import MfaPromptDialog from '../components/MfaPromptDialog.vue';
115
+ import type {
116
+ PilotCopy,
117
+ PilotCreatePayload,
118
+ PilotCreateResult,
119
+ PilotEditPayload,
120
+ PilotEditResult,
121
+ } from '../components/dialogs/types.js';
122
+
123
+ // Platform standard page: pilot tenants. Data-agnostic.
124
+ //
125
+ // Optional baked-in flows: consumers can set `enableCreate/Edit/Extend/Revoke`
126
+ // + provide the matching `submit*` callbacks, then the page mounts the
127
+ // dialogs itself and appends the default actions to the `actions` prop.
128
+ // Anyone needing more control (e.g. MFA flows) omits enable* and provides
129
+ // the actions/dialogs themselves as before — no behavior change.
130
+
131
+ export interface PilotRow {
132
+ id: string;
133
+ tenant: { id: string; slug: string; name: string };
134
+ plan: string;
135
+ pilotEndsAt: string | null;
136
+ pilotNote: string | null;
137
+ grantedBy: string | null;
138
+ grantedAt?: string | null;
139
+ [extra: string]: unknown;
140
+ }
141
+
142
+ export interface PilotRowAction {
143
+ id: string;
144
+ label: string;
145
+ icon: string;
146
+ color?: string;
147
+ condition?: (row: PilotRow) => boolean;
148
+ handler: (row: PilotRow) => void;
149
+ }
150
+
151
+ /** Plan option for the built-in create/edit dialog. */
152
+ export interface PilotPlanOption {
153
+ label: string;
154
+ value: string;
155
+ color?: string;
156
+ }
157
+
158
+ export type PilotDefaultActionId = 'edit' | 'extend' | 'revoke';
159
+
160
+ const props = withDefaults(
161
+ defineProps<{
162
+ loadPilots: () => Promise<PilotRow[]>;
163
+ loadReviewSoon?: () => Promise<PilotRow[]>;
164
+ actions?: readonly PilotRowAction[];
165
+ enableCreate?: boolean;
166
+ enableEdit?: boolean;
167
+ enableExtend?: boolean;
168
+ enableRevoke?: boolean;
169
+ submitCreate?: (payload: PilotCreatePayload, mfaCode: string) => Promise<PilotCreateResult>;
170
+ submitEdit?: (
171
+ slug: string,
172
+ payload: PilotEditPayload,
173
+ mfaCode: string,
174
+ ) => Promise<PilotEditResult>;
175
+ submitExtend?: (slug: string, until: string, mfaCode?: string) => Promise<void>;
176
+ submitRevoke?: (slug: string, mfaCode?: string) => Promise<void>;
177
+ loadPlanOptions?: () => Promise<PilotPlanOption[]>;
178
+ /** Tenant vocabulary for the create/edit dialog (neutral defaults otherwise). */
179
+ copy?: PilotCopy;
180
+ /** Static plan options (alternative to loadPlanOptions). */
181
+ createPlanOptions?: readonly (string | PilotPlanOption)[];
182
+ defaultCreatePlan?: string;
183
+ /** MFA requirement for the create/edit dialog (passed through to sub-dialogs). */
184
+ requireMfa?: boolean;
185
+ /** Per-flow MFA for Extend — shows MfaPromptDialog after the date prompt. */
186
+ requireMfaForExtend?: boolean;
187
+ /** Per-flow MFA for Revoke — shows MfaPromptDialog after the confirm prompt. */
188
+ requireMfaForRevoke?: boolean;
189
+ mfaSetupHint?: string;
190
+ createLabel?: string;
191
+ defaultActions?: readonly PilotDefaultActionId[];
192
+ }>(),
193
+ {
194
+ requireMfa: false,
195
+ requireMfaForExtend: false,
196
+ requireMfaForRevoke: false,
197
+ createLabel: 'Pilot anlegen',
198
+ defaultActions: () => ['edit', 'extend', 'revoke'],
199
+ },
200
+ );
201
+
202
+ const q = useQuasar();
203
+ const rows = ref<PilotRow[]>([]);
204
+ const reviewSoon = ref<PilotRow[]>([]);
205
+ const loading = ref(false);
206
+
207
+ const showCreate = ref(false);
208
+ const showEdit = ref(false);
209
+ const editRow = ref<PilotRow | null>(null);
210
+ const bakedPlanOptions = ref<PilotPlanOption[]>([]);
211
+
212
+ // MFA dialog state for per-flow MFA (extend/revoke). Promise-resolver pattern
213
+ // analogous to use-platform-tenant-actions.ts: `onExtendClick`/`onRevokeClick`
214
+ // calls `runWithMfa(...)`, which opens the dialog and waits for `onMfaConfirm`
215
+ // (or cancellation via `update:modelValue=false`).
216
+ const showMfa = ref(false);
217
+ const mfaError = ref('');
218
+ const mfaDescription = ref('');
219
+ let pendingMfaResolve: ((code: string | null) => void) | null = null;
220
+ const needsMfaDialog = computed(() => props.requireMfaForExtend || props.requireMfaForRevoke);
221
+
222
+ function promptMfa(description: string): Promise<string | null> {
223
+ return new Promise((resolve) => {
224
+ mfaDescription.value = description;
225
+ mfaError.value = '';
226
+ showMfa.value = true;
227
+ pendingMfaResolve = (code) => {
228
+ pendingMfaResolve = null;
229
+ resolve(code);
230
+ };
231
+ });
232
+ }
233
+
234
+ function onMfaConfirm(code: string): void {
235
+ pendingMfaResolve?.(code);
236
+ }
237
+
238
+ function onMfaDialogVisibility(open: boolean): void {
239
+ showMfa.value = open;
240
+ if (!open && pendingMfaResolve) {
241
+ pendingMfaResolve(null);
242
+ }
243
+ }
244
+ // Consumers can set `createPlanOptions` instead of `loadPlanOptions`
245
+ // (e.g. apps with a hard-coded plan list).
246
+ const effectiveCreatePlanOptions = computed<readonly (string | PilotPlanOption)[]>(() => {
247
+ if (props.createPlanOptions && props.createPlanOptions.length > 0) {
248
+ return props.createPlanOptions;
249
+ }
250
+ return bakedPlanOptions.value;
251
+ });
252
+ const defaultPlan = computed<string | undefined>(() => {
253
+ if (props.defaultCreatePlan) return props.defaultCreatePlan;
254
+ const first = effectiveCreatePlanOptions.value[0];
255
+ if (!first) return undefined;
256
+ return typeof first === 'string' ? first : first.value;
257
+ });
258
+
259
+ // Stat pill filter — analogous to the plan-simulation pilots.jsx:
260
+ // all | active | expiring (≤14 days) | expired.
261
+ const EXPIRING_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;
262
+ type StatusFilter = 'all' | 'active' | 'expiring' | 'expired';
263
+ const filter = ref<StatusFilter>('all');
264
+
265
+ function classifyRow(row: PilotRow): Exclude<StatusFilter, 'all'> {
266
+ if (!row.pilotEndsAt) return 'active';
267
+ const t = new Date(row.pilotEndsAt).getTime();
268
+ if (Number.isNaN(t)) return 'active';
269
+ const now = Date.now();
270
+ if (t <= now) return 'expired';
271
+ if (t - now <= EXPIRING_WINDOW_MS) return 'expiring';
272
+ return 'active';
273
+ }
274
+
275
+ const filteredRows = computed(() => {
276
+ if (filter.value === 'all') return rows.value;
277
+ return rows.value.filter((r) => classifyRow(r) === filter.value);
278
+ });
279
+
280
+ const statTiles = computed<
281
+ Array<{
282
+ id: StatusFilter;
283
+ label: string;
284
+ count: number;
285
+ tone?: 'positive' | 'warn' | 'danger';
286
+ hint?: string;
287
+ }>
288
+ >(() => {
289
+ const counts = { all: rows.value.length, active: 0, expiring: 0, expired: 0 };
290
+ for (const r of rows.value) counts[classifyRow(r)]++;
291
+ return [
292
+ { id: 'all', label: 'Alle', count: counts.all },
293
+ { id: 'active', label: 'Aktiv', count: counts.active, tone: 'positive' },
294
+ {
295
+ id: 'expiring',
296
+ label: 'Läuft bald aus',
297
+ count: counts.expiring,
298
+ tone: 'warn',
299
+ hint: '≤ 14 Tage',
300
+ },
301
+ { id: 'expired', label: 'Abgelaufen', count: counts.expired, tone: 'danger' },
302
+ ];
303
+ });
304
+
305
+ const baseColumns = [
306
+ {
307
+ name: 'slug',
308
+ label: 'Slug',
309
+ field: (r: PilotRow) => r.tenant.slug,
310
+ align: 'left' as const,
311
+ sortable: true,
312
+ },
313
+ {
314
+ name: 'name',
315
+ label: 'Name',
316
+ field: (r: PilotRow) => r.tenant.name,
317
+ align: 'left' as const,
318
+ },
319
+ { name: 'plan', label: 'Plan', field: 'plan', align: 'left' as const },
320
+ {
321
+ name: 'note',
322
+ label: 'Note',
323
+ field: (r: PilotRow) => r.pilotNote ?? '—',
324
+ align: 'left' as const,
325
+ },
326
+ {
327
+ name: 'grantedBy',
328
+ label: 'Granted by',
329
+ field: (r: PilotRow) => r.grantedBy ?? '—',
330
+ align: 'left' as const,
331
+ },
332
+ {
333
+ name: 'pilotEndsAt',
334
+ label: 'Endet',
335
+ field: (r: PilotRow) => formatDate(r.pilotEndsAt) ?? '∞',
336
+ align: 'left' as const,
337
+ },
338
+ ];
339
+
340
+ // Built-in default actions (edit/extend/revoke) — are APPENDED to the
341
+ // consumer `actions`, not replaced. Order follows the `defaultActions` prop.
342
+ const bakedActions = computed<PilotRowAction[]>(() => {
343
+ const out: PilotRowAction[] = [];
344
+ for (const id of props.defaultActions) {
345
+ if (id === 'edit' && props.enableEdit && props.submitEdit) {
346
+ out.push({
347
+ id: 'edit',
348
+ label: 'Bearbeiten',
349
+ icon: 'edit',
350
+ color: 'primary',
351
+ handler: (row) => onEditClick(row),
352
+ });
353
+ } else if (id === 'extend' && props.enableExtend && props.submitExtend) {
354
+ out.push({
355
+ id: 'extend',
356
+ label: 'Verlängern',
357
+ icon: 'event_repeat',
358
+ color: 'primary',
359
+ handler: (row) => onExtendClick(row),
360
+ });
361
+ } else if (id === 'revoke' && props.enableRevoke && props.submitRevoke) {
362
+ out.push({
363
+ id: 'revoke',
364
+ label: 'Widerrufen',
365
+ icon: 'block',
366
+ color: 'negative',
367
+ handler: (row) => onRevokeClick(row),
368
+ });
369
+ }
370
+ }
371
+ return out;
372
+ });
373
+
374
+ const mergedActions = computed<readonly PilotRowAction[]>(() => [
375
+ ...(props.actions ?? []),
376
+ ...bakedActions.value,
377
+ ]);
378
+
379
+ const effectiveColumns = computed(() => {
380
+ const cols = [...baseColumns];
381
+ if (mergedActions.value.length > 0) {
382
+ cols.push({
383
+ name: 'actions',
384
+ label: '',
385
+ field: ((r: PilotRow) => r.id) as never,
386
+ align: 'right' as 'left',
387
+ });
388
+ }
389
+ return cols;
390
+ });
391
+
392
+ function visibleActions(row: PilotRow): PilotRowAction[] {
393
+ return mergedActions.value.filter((a) => !a.condition || a.condition(row));
394
+ }
395
+
396
+ async function reload() {
397
+ loading.value = true;
398
+ try {
399
+ rows.value = await props.loadPilots();
400
+ if (props.loadReviewSoon) {
401
+ try {
402
+ reviewSoon.value = await props.loadReviewSoon();
403
+ } catch {
404
+ reviewSoon.value = [];
405
+ }
406
+ }
407
+ } catch (err) {
408
+ rows.value = [];
409
+ console.warn('[PilotsPage] loadPilots failed:', err);
410
+ } finally {
411
+ loading.value = false;
412
+ }
413
+ }
414
+
415
+ defineExpose({ reload });
416
+
417
+ async function reloadPlanOptions(): Promise<void> {
418
+ if (!props.loadPlanOptions) {
419
+ bakedPlanOptions.value = [];
420
+ return;
421
+ }
422
+ try {
423
+ bakedPlanOptions.value = (await props.loadPlanOptions()) ?? [];
424
+ } catch {
425
+ bakedPlanOptions.value = [];
426
+ }
427
+ }
428
+
429
+ onMounted(() => {
430
+ void reload();
431
+ void reloadPlanOptions();
432
+ });
433
+
434
+ function errMsg(err: unknown): string {
435
+ return (
436
+ (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
437
+ (err as Error)?.message ??
438
+ 'Aktion fehlgeschlagen'
439
+ );
440
+ }
441
+
442
+ function onEditClick(row: PilotRow): void {
443
+ editRow.value = row;
444
+ showEdit.value = true;
445
+ }
446
+
447
+ function onUpdated(result: PilotEditResult): void {
448
+ q.notify({
449
+ type: 'positive',
450
+ message: `Pilot ${result.slug} aktualisiert.`,
451
+ caption: result.changed?.length ? `Geändert: ${result.changed.join(', ')}` : undefined,
452
+ position: 'top',
453
+ });
454
+ void reload();
455
+ }
456
+
457
+ function onCreated(result: PilotCreateResult): void {
458
+ q.notify({
459
+ type: 'positive',
460
+ message: `Pilot ${result.slug} angelegt.`,
461
+ caption: result.initialPassword ? `Initial-Passwort: ${result.initialPassword}` : undefined,
462
+ position: 'top',
463
+ timeout: 8000,
464
+ });
465
+ void reload();
466
+ }
467
+
468
+ // Called by the MFA submit — on HTTP 401 the MFA dialog stays open
469
+ // so the user can correct the code. Otherwise notify+close+reload.
470
+ async function runAction(
471
+ actionLabel: string,
472
+ successMessage: string,
473
+ requireMfa: boolean,
474
+ invoke: (code: string) => Promise<void>,
475
+ ): Promise<void> {
476
+ if (!requireMfa) {
477
+ try {
478
+ await invoke('');
479
+ q.notify({ type: 'positive', message: successMessage, position: 'top' });
480
+ await reload();
481
+ } catch (err) {
482
+ q.notify({ type: 'negative', message: errMsg(err), position: 'top' });
483
+ }
484
+ return;
485
+ }
486
+ // MFA loop: as long as the server returns 401, keep the dialog open and
487
+ // wait again for a code. Cancelling (resolver === null) ends it.
488
+ while (true) {
489
+ const code = await promptMfa(actionLabel);
490
+ if (code === null) return;
491
+ try {
492
+ await invoke(code);
493
+ showMfa.value = false;
494
+ q.notify({ type: 'positive', message: successMessage, position: 'top' });
495
+ await reload();
496
+ return;
497
+ } catch (err) {
498
+ const status = (err as { response?: { status?: number } })?.response?.status;
499
+ if (status === 401) {
500
+ mfaError.value = 'TOTP-Code ungültig oder MFA nicht eingerichtet.';
501
+ continue;
502
+ }
503
+ showMfa.value = false;
504
+ q.notify({ type: 'negative', message: errMsg(err), position: 'top' });
505
+ return;
506
+ }
507
+ }
508
+ }
509
+
510
+ function onExtendClick(row: PilotRow): void {
511
+ if (!props.submitExtend) return;
512
+ const submit = props.submitExtend;
513
+ q.dialog({
514
+ title: `Pilot ${row.tenant.slug} verlängern`,
515
+ message: 'Neues Enddatum:',
516
+ prompt: {
517
+ model: row.pilotEndsAt?.slice(0, 10) ?? '',
518
+ type: 'date',
519
+ },
520
+ cancel: 'Abbrechen',
521
+ ok: { label: 'Verlängern', color: 'primary' },
522
+ }).onOk(async (until: string) => {
523
+ if (!until) return;
524
+ await runAction(
525
+ `Pilot "${row.tenant.slug}" bis ${until} verlängern.`,
526
+ `Pilot bis ${until} verlängert.`,
527
+ !!props.requireMfaForExtend,
528
+ (code) => submit(row.tenant.slug, until, code),
529
+ );
530
+ });
531
+ }
532
+
533
+ function onRevokeClick(row: PilotRow): void {
534
+ if (!props.submitRevoke) return;
535
+ const submit = props.submitRevoke;
536
+ q.dialog({
537
+ title: `Pilot ${row.tenant.slug} widerrufen`,
538
+ message: 'Pilot-Status entfernen. Subscription bleibt bestehen.',
539
+ cancel: 'Abbrechen',
540
+ ok: { label: 'Widerrufen', color: 'negative' },
541
+ }).onOk(async () => {
542
+ await runAction(
543
+ `Pilot-Status für "${row.tenant.slug}" widerrufen.`,
544
+ 'Pilot widerrufen.',
545
+ !!props.requireMfaForRevoke,
546
+ (code) => submit(row.tenant.slug, code),
547
+ );
548
+ });
549
+ }
550
+
551
+ function formatDate(iso: string | null | undefined): string | null {
552
+ if (!iso) return null;
553
+ try {
554
+ return new Date(iso).toLocaleDateString('de-DE', {
555
+ day: '2-digit',
556
+ month: 'short',
557
+ year: 'numeric',
558
+ });
559
+ } catch {
560
+ return String(iso);
561
+ }
562
+ }
563
+ </script>
564
+
565
+ <style scoped>
566
+ .sa-pilots {
567
+ min-height: calc(100vh - 56px);
568
+ background: var(--sa-bg-app, #f1f5f9);
569
+ padding: 20px 28px 28px;
570
+ }
571
+ .sa-pilots__card {
572
+ background: #fff;
573
+ border: 1px solid var(--sa-border, #e2e8f0);
574
+ border-radius: 12px;
575
+ overflow: hidden;
576
+ }
577
+ </style>