@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,780 @@
1
+ <template>
2
+ <q-dialog
3
+ :model-value="modelValue"
4
+ @update:model-value="emit('update:modelValue', $event)"
5
+ persistent
6
+ >
7
+ <q-card class="pl-dlg">
8
+ <q-card-section class="pl-dlg__head">
9
+ <div>
10
+ <div class="pl-dlg__title">Pilot-Mandant anlegen</div>
11
+ <div class="pl-dlg__sub">
12
+ {{
13
+ subtitle ??
14
+ 'Neuer Mandant + Initial-Admin werden in einem Schritt angelegt.'
15
+ }}
16
+ </div>
17
+ </div>
18
+ <q-btn
19
+ class="pl-dlg__close"
20
+ flat
21
+ dense
22
+ round
23
+ icon="close"
24
+ v-close-popup
25
+ :disable="loading"
26
+ />
27
+ </q-card-section>
28
+
29
+ <q-card-section class="pl-dlg__body">
30
+ <!-- Section 1: Tenant -->
31
+ <section class="pl-section">
32
+ <header class="pl-section__head">
33
+ <span class="pl-section__num">1</span>
34
+ <div>
35
+ <div class="pl-section__title">Mandant</div>
36
+ <div class="pl-section__sub">{{ copy.tenantSubtitle }}</div>
37
+ </div>
38
+ </header>
39
+ <div class="pl-grid">
40
+ <div class="pl-field pl-field--full">
41
+ <label>{{ copy.tenantNameLabel }}</label>
42
+ <input
43
+ v-model="form.tenant.name"
44
+ class="pl-input"
45
+ :placeholder="copy.tenantNamePlaceholder"
46
+ autofocus
47
+ />
48
+ </div>
49
+
50
+ <div class="pl-field pl-field--full">
51
+ <label>
52
+ Slug
53
+ <span class="pl-field__hint"
54
+ >wird automatisch aus dem Namen erzeugt</span
55
+ >
56
+ </label>
57
+ <div class="pl-slug-input">
58
+ <span v-if="slugPrefix" class="pl-slug-input__prefix">{{
59
+ slugPrefix
60
+ }}</span>
61
+ <input
62
+ v-model="form.tenant.slug"
63
+ class="pl-input pl-input--flush"
64
+ :placeholder="copy.slugPlaceholder"
65
+ @input="onSlugInput"
66
+ />
67
+ </div>
68
+ <div v-if="slugConflict" class="pl-field__error">
69
+ Dieser Slug ist bereits vergeben.
70
+ </div>
71
+ </div>
72
+
73
+ <div v-if="showLegalFields" class="pl-field">
74
+ <label>Rechtsform</label>
75
+ <input
76
+ v-model="form.tenant.legalForm"
77
+ class="pl-input"
78
+ placeholder="e.V., GmbH, …"
79
+ />
80
+ </div>
81
+ <div v-if="showLegalFields" class="pl-field">
82
+ <label>
83
+ USt-IdNr.
84
+ <span class="pl-field__hint">optional</span>
85
+ </label>
86
+ <input
87
+ v-model="form.tenant.vatId"
88
+ class="pl-input"
89
+ placeholder="DE123456789"
90
+ />
91
+ </div>
92
+ </div>
93
+ <slot name="tenant-extra" :form="form" />
94
+ </section>
95
+
96
+ <!-- Section 2: Initial-Admin -->
97
+ <section class="pl-section">
98
+ <header class="pl-section__head">
99
+ <span class="pl-section__num">2</span>
100
+ <div>
101
+ <div class="pl-section__title">Initial-Admin</div>
102
+ <div class="pl-section__sub">
103
+ Erhält eine Einladungs-E-Mail mit Initial-Passwort.
104
+ </div>
105
+ </div>
106
+ </header>
107
+ <div class="pl-grid">
108
+ <div class="pl-field pl-field--full">
109
+ <label>E-Mail</label>
110
+ <input
111
+ v-model="form.admin.email"
112
+ class="pl-input"
113
+ :class="{ 'pl-input--invalid': form.admin.email && !emailValid }"
114
+ type="email"
115
+ :placeholder="copy.adminEmailPlaceholder"
116
+ />
117
+ <div v-if="form.admin.email && !emailValid" class="pl-field__error">
118
+ Bitte eine gültige E-Mail-Adresse eingeben.
119
+ </div>
120
+ </div>
121
+ <div class="pl-field">
122
+ <label>Vorname</label>
123
+ <input
124
+ v-model="form.admin.firstName"
125
+ class="pl-input"
126
+ placeholder="Erika"
127
+ />
128
+ </div>
129
+ <div class="pl-field">
130
+ <label>Nachname</label>
131
+ <input
132
+ v-model="form.admin.lastName"
133
+ class="pl-input"
134
+ placeholder="Mustermann"
135
+ />
136
+ </div>
137
+ <div class="pl-field pl-field--full">
138
+ <label>
139
+ Initial-Passwort
140
+ <span class="pl-field__hint"
141
+ >leer lassen → automatisch generieren</span
142
+ >
143
+ </label>
144
+ <input
145
+ v-model="form.admin.initialPassword"
146
+ class="pl-input"
147
+ placeholder="•••••••• (automatisch generieren)"
148
+ />
149
+ </div>
150
+ </div>
151
+ </section>
152
+
153
+ <!-- Section 3: Pilot configuration -->
154
+ <section class="pl-section">
155
+ <header class="pl-section__head">
156
+ <span class="pl-section__num">3</span>
157
+ <div>
158
+ <div class="pl-section__title">Pilot-Konfiguration</div>
159
+ <div class="pl-section__sub">
160
+ Plan-Zuweisung, Laufzeit und interne Notiz
161
+ </div>
162
+ </div>
163
+ </header>
164
+ <div class="pl-grid">
165
+ <div class="pl-field">
166
+ <label>Plan</label>
167
+ <div class="pl-plan-select">
168
+ <button
169
+ v-for="p in normalizedPlanOptions"
170
+ :key="p.value"
171
+ type="button"
172
+ class="pl-plan-opt"
173
+ :class="{ 'pl-plan-opt--active': form.pilot.plan === p.value }"
174
+ @click="form.pilot.plan = p.value"
175
+ >
176
+ <span
177
+ class="pl-plan-opt__dot"
178
+ :style="{ background: p.color ?? '#94a3b8' }"
179
+ />
180
+ <div class="pl-plan-opt__text">
181
+ <span class="pl-plan-opt__key">{{ p.value }}</span>
182
+ <span class="pl-plan-opt__label">{{ p.label }}</span>
183
+ </div>
184
+ <q-icon
185
+ v-if="form.pilot.plan === p.value"
186
+ name="check"
187
+ size="16px"
188
+ class="pl-plan-opt__check"
189
+ />
190
+ </button>
191
+ </div>
192
+ </div>
193
+
194
+ <div class="pl-field">
195
+ <label>Endet am</label>
196
+ <div class="pl-end-row">
197
+ <input v-model="form.pilot.endsAt" class="pl-input" type="date" />
198
+ <button
199
+ v-if="form.pilot.endsAt"
200
+ type="button"
201
+ class="pl-btn-mini"
202
+ title="Auf unbegrenzt setzen"
203
+ @click="form.pilot.endsAt = ''"
204
+ >
205
+ <q-icon name="close" size="12px" />
206
+ unbegrenzt
207
+ </button>
208
+ </div>
209
+ <div class="pl-end-presets">
210
+ <button
211
+ v-for="p in PRESET_ENDS"
212
+ :key="p.days"
213
+ type="button"
214
+ class="pl-preset-btn"
215
+ @click="setEndsAtDays(p.days)"
216
+ >
217
+ +{{ p.label }}
218
+ </button>
219
+ </div>
220
+ </div>
221
+
222
+ <div class="pl-field pl-field--full">
223
+ <label>
224
+ Note
225
+ <span class="pl-field__hint"
226
+ >intern · nicht für Kunden sichtbar</span
227
+ >
228
+ </label>
229
+ <textarea
230
+ v-model="form.pilot.note"
231
+ class="pl-input pl-textarea"
232
+ rows="3"
233
+ :placeholder="copy.notePlaceholder"
234
+ />
235
+ </div>
236
+ </div>
237
+ </section>
238
+
239
+ <p v-if="error" class="pl-error">{{ error }}</p>
240
+ </q-card-section>
241
+
242
+ <q-card-actions align="right" class="pl-dlg__foot">
243
+ <span v-if="form.admin.email && emailValid" class="pl-foot-hint">
244
+ <q-icon name="send" size="14px" />
245
+ Einladung geht an <strong>{{ form.admin.email }}</strong>
246
+ </span>
247
+ <q-btn flat label="Abbrechen" v-close-popup :disable="loading" />
248
+ <q-btn
249
+ unelevated
250
+ color="primary"
251
+ label="Pilot anlegen"
252
+ :loading="loading"
253
+ :disable="!isValid"
254
+ @click="onSubmit"
255
+ />
256
+ </q-card-actions>
257
+ </q-card>
258
+ </q-dialog>
259
+
260
+ <MfaPromptDialog
261
+ v-if="requireMfa"
262
+ v-model="showMfa"
263
+ :description="mfaDescription"
264
+ :error="mfaError"
265
+ :setup-hint="mfaSetupHint"
266
+ @confirm="(code) => doSubmit(code)"
267
+ />
268
+ </template>
269
+
270
+ <script setup lang="ts">
271
+ import { computed, reactive, ref, watch } from 'vue';
272
+ import MfaPromptDialog from '../MfaPromptDialog.vue';
273
+ import {
274
+ DEFAULT_PILOT_COPY,
275
+ type PilotCopy,
276
+ type PilotCreatePayload,
277
+ type PilotCreateResult,
278
+ } from './types.js';
279
+
280
+ // Platform pilot-create dialog (sim layout: 3 numbered sections,
281
+ // slug-prefix display, plan-tile picker, ends-at quick-sets, footer hint).
282
+ // The app provides plan options + submit handler. Custom fields per app via
283
+ // the `tenant-extra` slot; consumers can toggle `showLegalFields` /
284
+ // `slugPrefix` / `existingSlugs`.
285
+
286
+ /** Plan option either as a key (string) or with a readable label + color. */
287
+ type PlanOption = string | { label?: string; value: string; color?: string };
288
+
289
+ const props = withDefaults(
290
+ defineProps<{
291
+ modelValue: boolean;
292
+ planOptions: readonly PlanOption[];
293
+ defaultPlan?: string;
294
+ subtitle?: string;
295
+ /** Tenant-specific labels/placeholders; neutral defaults otherwise. */
296
+ copy?: PilotCopy;
297
+ showLegalFields?: boolean;
298
+ /** Optional prefix before the slug input (e.g. "example.com /"). */
299
+ slugPrefix?: string;
300
+ /** Known slugs for the conflict check (prevents submit). */
301
+ existingSlugs?: readonly string[];
302
+ requireMfa?: boolean;
303
+ mfaSetupHint?: string;
304
+ submit: (payload: PilotCreatePayload, mfaCode: string) => Promise<PilotCreateResult>;
305
+ }>(),
306
+ {
307
+ showLegalFields: true,
308
+ requireMfa: true,
309
+ existingSlugs: () => [],
310
+ },
311
+ );
312
+
313
+ const emit = defineEmits<{
314
+ (e: 'update:modelValue', v: boolean): void;
315
+ (e: 'created', result: PilotCreateResult): void;
316
+ }>();
317
+
318
+ const PRESET_ENDS: ReadonlyArray<{ label: string; days: number }> = [
319
+ { label: '30 Tage', days: 30 },
320
+ { label: '90 Tage', days: 90 },
321
+ { label: '6 Monate', days: 180 },
322
+ { label: '1 Jahr', days: 365 },
323
+ ];
324
+
325
+ const copy = computed<Required<PilotCopy>>(() => ({ ...DEFAULT_PILOT_COPY, ...props.copy }));
326
+
327
+ const normalizedPlanOptions = computed<Array<{ value: string; label: string; color?: string }>>(
328
+ () =>
329
+ props.planOptions.map((opt) => {
330
+ if (typeof opt === 'string') return { value: opt, label: opt };
331
+ return { value: opt.value, label: opt.label ?? opt.value, color: opt.color };
332
+ }),
333
+ );
334
+
335
+ function defaultPlanValue(): string {
336
+ if (props.defaultPlan) return props.defaultPlan;
337
+ return normalizedPlanOptions.value[0]?.value ?? '';
338
+ }
339
+
340
+ function emptyForm() {
341
+ return {
342
+ tenant: { name: '', slug: '', legalForm: '', vatId: '' },
343
+ admin: { email: '', firstName: '', lastName: '', initialPassword: '' },
344
+ pilot: {
345
+ plan: defaultPlanValue(),
346
+ note: '',
347
+ endsAt: '',
348
+ },
349
+ };
350
+ }
351
+
352
+ const form = reactive(emptyForm());
353
+ const loading = ref(false);
354
+ const error = ref('');
355
+ const showMfa = ref(false);
356
+ const mfaError = ref('');
357
+ const mfaDescription = ref('');
358
+ const slugTouched = ref(false);
359
+
360
+ function slugify(s: string): string {
361
+ return s
362
+ .toLowerCase()
363
+ .replace(/ä/g, 'ae')
364
+ .replace(/ö/g, 'oe')
365
+ .replace(/ü/g, 'ue')
366
+ .replace(/ß/g, 'ss')
367
+ .replace(/[^a-z0-9]+/g, '-')
368
+ .replace(/^-+|-+$/g, '');
369
+ }
370
+
371
+ function onSlugInput(): void {
372
+ slugTouched.value = true;
373
+ form.tenant.slug = slugify(form.tenant.slug);
374
+ }
375
+
376
+ watch(
377
+ () => form.tenant.name,
378
+ (name) => {
379
+ if (!slugTouched.value) form.tenant.slug = slugify(name);
380
+ },
381
+ );
382
+
383
+ const emailValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.admin.email));
384
+ const slugConflict = computed(
385
+ () => !!form.tenant.slug && props.existingSlugs.includes(form.tenant.slug),
386
+ );
387
+
388
+ const isValid = computed(
389
+ () =>
390
+ !!form.tenant.name.trim() &&
391
+ !!form.tenant.slug.trim() &&
392
+ !slugConflict.value &&
393
+ emailValid.value &&
394
+ !!form.admin.firstName.trim() &&
395
+ !!form.admin.lastName.trim() &&
396
+ !!form.pilot.plan,
397
+ );
398
+
399
+ function setEndsAtDays(days: number): void {
400
+ const d = new Date();
401
+ d.setDate(d.getDate() + days);
402
+ form.pilot.endsAt = d.toISOString().slice(0, 10);
403
+ }
404
+
405
+ watch(
406
+ () => props.modelValue,
407
+ (open) => {
408
+ if (open) {
409
+ const fresh = emptyForm();
410
+ Object.assign(form.tenant, fresh.tenant);
411
+ Object.assign(form.admin, fresh.admin);
412
+ Object.assign(form.pilot, fresh.pilot);
413
+ error.value = '';
414
+ slugTouched.value = false;
415
+ }
416
+ },
417
+ );
418
+
419
+ function onSubmit(): void {
420
+ if (!isValid.value) return;
421
+ if (props.requireMfa) {
422
+ mfaDescription.value = `Pilot-Mandant "${form.tenant.name}" anlegen — Plan ${form.pilot.plan}.`;
423
+ mfaError.value = '';
424
+ showMfa.value = true;
425
+ return;
426
+ }
427
+ void doSubmit('');
428
+ }
429
+
430
+ async function doSubmit(code: string): Promise<void> {
431
+ loading.value = true;
432
+ error.value = '';
433
+ try {
434
+ const result = await props.submit(
435
+ {
436
+ tenant: {
437
+ name: form.tenant.name,
438
+ slug: form.tenant.slug || undefined,
439
+ legalForm: form.tenant.legalForm || undefined,
440
+ vatId: form.tenant.vatId || undefined,
441
+ },
442
+ admin: {
443
+ email: form.admin.email,
444
+ firstName: form.admin.firstName,
445
+ lastName: form.admin.lastName,
446
+ initialPassword: form.admin.initialPassword || undefined,
447
+ },
448
+ pilot: {
449
+ plan: form.pilot.plan,
450
+ note: form.pilot.note || undefined,
451
+ endsAt: form.pilot.endsAt || undefined,
452
+ },
453
+ },
454
+ code,
455
+ );
456
+ showMfa.value = false;
457
+ emit('created', result);
458
+ emit('update:modelValue', false);
459
+ } catch (err) {
460
+ const response = (
461
+ err as {
462
+ response?: {
463
+ status?: number;
464
+ data?: { reason?: string; message?: string };
465
+ };
466
+ }
467
+ ).response;
468
+ const status = response?.status;
469
+ const reason = response?.data?.reason;
470
+ if (
471
+ props.requireMfa &&
472
+ status === 401 &&
473
+ (reason === 'MFA_FAILED' || reason === 'MFA_REQUIRED')
474
+ ) {
475
+ mfaError.value = 'TOTP-Code ungültig.';
476
+ } else if (props.requireMfa && status === 401 && reason === 'MFA_NOT_SET_UP') {
477
+ mfaError.value =
478
+ response?.data?.message ?? 'MFA ist für diesen Account nicht eingerichtet.';
479
+ } else {
480
+ error.value = response?.data?.message ?? 'Anlegen fehlgeschlagen';
481
+ showMfa.value = false;
482
+ }
483
+ } finally {
484
+ loading.value = false;
485
+ }
486
+ }
487
+ </script>
488
+
489
+ <style scoped>
490
+ .pl-dlg {
491
+ width: 760px;
492
+ max-width: 96vw;
493
+ }
494
+ .pl-dlg__head {
495
+ display: flex;
496
+ align-items: flex-start;
497
+ gap: 16px;
498
+ padding: 18px 22px 14px;
499
+ border-bottom: 1px solid var(--sa-border, #e2e8f0);
500
+ }
501
+ .pl-dlg__title {
502
+ font-family: var(--sa-font-head, system-ui, sans-serif);
503
+ font-weight: 700;
504
+ font-size: 17px;
505
+ color: var(--sa-heading, #0f172a);
506
+ letter-spacing: -0.015em;
507
+ }
508
+ .pl-dlg__sub {
509
+ font-size: 12.5px;
510
+ color: var(--sa-muted, #64748b);
511
+ margin-top: 3px;
512
+ line-height: 1.4;
513
+ }
514
+ .pl-dlg__close {
515
+ margin-left: auto;
516
+ }
517
+ .pl-dlg__body {
518
+ padding: 20px 22px;
519
+ max-height: 72vh;
520
+ overflow-y: auto;
521
+ }
522
+ .pl-dlg__foot {
523
+ border-top: 1px solid var(--sa-border, #e2e8f0);
524
+ background: #fbfbfd;
525
+ padding: 12px 18px;
526
+ }
527
+ .pl-foot-hint {
528
+ margin-right: auto;
529
+ display: inline-flex;
530
+ align-items: center;
531
+ gap: 6px;
532
+ font-size: 12px;
533
+ color: var(--sa-muted-dark, #475569);
534
+ background: #eff6ff;
535
+ border: 1px solid #bfdbfe;
536
+ border-radius: 999px;
537
+ padding: 4px 12px;
538
+ }
539
+
540
+ .pl-section {
541
+ margin-bottom: 20px;
542
+ }
543
+ .pl-section:last-of-type {
544
+ margin-bottom: 4px;
545
+ }
546
+ .pl-section__head {
547
+ display: flex;
548
+ align-items: flex-start;
549
+ gap: 12px;
550
+ margin-bottom: 12px;
551
+ padding-bottom: 10px;
552
+ border-bottom: 1px solid #f1f5f9;
553
+ }
554
+ .pl-section__num {
555
+ width: 24px;
556
+ height: 24px;
557
+ background: #eff6ff;
558
+ color: #1d4ed8;
559
+ border-radius: 50%;
560
+ font: 700 12px var(--sa-font-body, system-ui, sans-serif);
561
+ display: grid;
562
+ place-items: center;
563
+ flex: 0 0 auto;
564
+ }
565
+ .pl-section__title {
566
+ font-size: 13.5px;
567
+ font-weight: 700;
568
+ color: var(--sa-heading, #0f172a);
569
+ letter-spacing: -0.005em;
570
+ }
571
+ .pl-section__sub {
572
+ font-size: 11.5px;
573
+ color: #94a3b8;
574
+ margin-top: 2px;
575
+ }
576
+
577
+ .pl-grid {
578
+ display: grid;
579
+ grid-template-columns: 1fr 1fr;
580
+ gap: 12px 14px;
581
+ }
582
+ .pl-field {
583
+ display: flex;
584
+ flex-direction: column;
585
+ gap: 5px;
586
+ }
587
+ .pl-field--full {
588
+ grid-column: 1 / -1;
589
+ }
590
+ .pl-field label {
591
+ font: 600 11px var(--sa-font-body, system-ui, sans-serif);
592
+ letter-spacing: 0.04em;
593
+ text-transform: uppercase;
594
+ color: var(--sa-muted, #64748b);
595
+ display: flex;
596
+ align-items: baseline;
597
+ gap: 6px;
598
+ }
599
+ .pl-field__hint {
600
+ font-weight: 500;
601
+ text-transform: none;
602
+ letter-spacing: 0;
603
+ color: #94a3b8;
604
+ font-size: 11px;
605
+ }
606
+ .pl-field__error {
607
+ font-size: 11.5px;
608
+ color: #b91c1c;
609
+ }
610
+
611
+ .pl-input {
612
+ width: 100%;
613
+ padding: 9px 12px;
614
+ background: #fff;
615
+ border: 1px solid var(--sa-border, #e2e8f0);
616
+ border-radius: 8px;
617
+ font: 13px var(--sa-font-body, system-ui, sans-serif);
618
+ color: var(--sa-body, #1e293b);
619
+ outline: 0;
620
+ transition:
621
+ border-color 0.12s,
622
+ box-shadow 0.12s;
623
+ }
624
+ .pl-input:focus {
625
+ border-color: var(--sa-primary, #3f6bff);
626
+ box-shadow: 0 0 0 3px rgba(63, 107, 255, 0.12);
627
+ }
628
+ .pl-input--invalid {
629
+ border-color: #fca5a5;
630
+ }
631
+ .pl-input::placeholder {
632
+ color: #cbd5e1;
633
+ }
634
+ .pl-textarea {
635
+ resize: vertical;
636
+ min-height: 64px;
637
+ font-family: var(--sa-font-body, system-ui, sans-serif);
638
+ }
639
+
640
+ .pl-slug-input {
641
+ display: flex;
642
+ align-items: stretch;
643
+ background: #fff;
644
+ border: 1px solid var(--sa-border, #e2e8f0);
645
+ border-radius: 8px;
646
+ overflow: hidden;
647
+ }
648
+ .pl-slug-input__prefix {
649
+ padding: 9px 12px;
650
+ background: #f8fafc;
651
+ color: #94a3b8;
652
+ font: 500 12px var(--sa-font-mono, ui-monospace, monospace);
653
+ border-right: 1px solid var(--sa-border, #e2e8f0);
654
+ white-space: nowrap;
655
+ }
656
+ .pl-input--flush {
657
+ border: 0 !important;
658
+ border-radius: 0 !important;
659
+ box-shadow: none !important;
660
+ }
661
+ .pl-input--flush:focus {
662
+ box-shadow: none !important;
663
+ }
664
+
665
+ .pl-plan-select {
666
+ display: flex;
667
+ flex-direction: column;
668
+ gap: 5px;
669
+ }
670
+ .pl-plan-opt {
671
+ display: flex;
672
+ align-items: center;
673
+ gap: 10px;
674
+ padding: 8px 11px;
675
+ background: #fff;
676
+ border: 1px solid var(--sa-border, #e2e8f0);
677
+ border-radius: 8px;
678
+ cursor: pointer;
679
+ text-align: left;
680
+ font-family: var(--sa-font-body, system-ui, sans-serif);
681
+ transition:
682
+ border-color 0.12s,
683
+ background 0.12s;
684
+ }
685
+ .pl-plan-opt:hover {
686
+ border-color: #93c5fd;
687
+ background: #fafbff;
688
+ }
689
+ .pl-plan-opt--active {
690
+ border-color: var(--sa-primary, #2563eb);
691
+ background: #eff6ff;
692
+ box-shadow: 0 0 0 1px var(--sa-primary, #2563eb) inset;
693
+ }
694
+ .pl-plan-opt__dot {
695
+ width: 10px;
696
+ height: 10px;
697
+ border-radius: 50%;
698
+ flex: 0 0 auto;
699
+ }
700
+ .pl-plan-opt__text {
701
+ display: flex;
702
+ flex-direction: column;
703
+ flex: 1;
704
+ min-width: 0;
705
+ }
706
+ .pl-plan-opt__key {
707
+ font: 700 11px var(--sa-font-mono, ui-monospace, monospace);
708
+ letter-spacing: 0.05em;
709
+ color: var(--sa-heading, #0f172a);
710
+ }
711
+ .pl-plan-opt__label {
712
+ font-size: 11px;
713
+ color: var(--sa-muted, #64748b);
714
+ margin-top: 1px;
715
+ }
716
+ .pl-plan-opt__check {
717
+ color: #1d4ed8;
718
+ }
719
+
720
+ .pl-end-row {
721
+ display: flex;
722
+ align-items: center;
723
+ gap: 6px;
724
+ }
725
+ .pl-end-row .pl-input {
726
+ flex: 1;
727
+ }
728
+ .pl-btn-mini {
729
+ display: inline-flex;
730
+ align-items: center;
731
+ gap: 4px;
732
+ padding: 7px 10px;
733
+ background: #fff;
734
+ border: 1px solid var(--sa-border, #e2e8f0);
735
+ border-radius: 7px;
736
+ color: var(--sa-muted-dark, #475569);
737
+ font: 500 11.5px var(--sa-font-body, system-ui, sans-serif);
738
+ cursor: pointer;
739
+ white-space: nowrap;
740
+ }
741
+ .pl-btn-mini:hover {
742
+ background: #f8fafc;
743
+ border-color: #cbd5e1;
744
+ }
745
+ .pl-end-presets {
746
+ display: flex;
747
+ flex-wrap: wrap;
748
+ gap: 5px;
749
+ margin-top: 5px;
750
+ }
751
+ .pl-preset-btn {
752
+ padding: 4px 9px;
753
+ background: #fff;
754
+ border: 1px dashed #cbd5e1;
755
+ border-radius: 999px;
756
+ font: 600 11px var(--sa-font-body, system-ui, sans-serif);
757
+ color: var(--sa-muted-dark, #475569);
758
+ cursor: pointer;
759
+ transition:
760
+ background 0.12s,
761
+ border-color 0.12s,
762
+ color 0.12s;
763
+ }
764
+ .pl-preset-btn:hover {
765
+ background: #eff6ff;
766
+ border-color: #93c5fd;
767
+ border-style: solid;
768
+ color: #1d4ed8;
769
+ }
770
+
771
+ .pl-error {
772
+ background: #fef2f2;
773
+ border: 1px solid #fecaca;
774
+ color: #b91c1c;
775
+ font-size: 13px;
776
+ margin: 12px 0 0;
777
+ padding: 8px 12px;
778
+ border-radius: 8px;
779
+ }
780
+ </style>