@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,681 @@
1
+ <template>
2
+ <section class="bcp">
3
+ <div class="bcp-head">
4
+ <div>
5
+ <div class="bcp-title">Neues Bundle anlegen</div>
6
+ <div class="bcp-sub">
7
+ Features &amp; Quotas zu einem Add-On bündeln — bepreist und kompatibel mit
8
+ ausgewählten Plänen.
9
+ </div>
10
+ </div>
11
+ <button class="bcp-close" type="button" aria-label="Schließen" @click="close">
12
+ <svg
13
+ width="14"
14
+ height="14"
15
+ viewBox="0 0 24 24"
16
+ fill="none"
17
+ stroke="currentColor"
18
+ stroke-width="2"
19
+ >
20
+ <path d="M18 6L6 18M6 6l12 12" />
21
+ </svg>
22
+ </button>
23
+ </div>
24
+
25
+ <div class="bcp-body">
26
+ <!-- 1: MASTER DATA -->
27
+ <section class="bcp-section">
28
+ <div class="bcp-section-head">
29
+ <span class="bcp-section-num">1</span>
30
+ <div>
31
+ <div class="bcp-section-title">Stammdaten</div>
32
+ <div class="bcp-section-sub">Sichtbarer Name + technischer Key.</div>
33
+ </div>
34
+ </div>
35
+ <div class="bcp-grid">
36
+ <label class="bcp-field bcp-col-2">
37
+ <span class="bcp-field-label">Label</span>
38
+ <input
39
+ ref="labelInput"
40
+ v-model="form.label"
41
+ class="bcp-input"
42
+ placeholder="z. B. Communication Pro"
43
+ />
44
+ </label>
45
+ <label class="bcp-field bcp-col-2">
46
+ <span class="bcp-field-label">
47
+ Bundle-Key
48
+ <span class="bcp-field-hint">
49
+ API-stabil · wird aus dem Label erzeugt
50
+ </span>
51
+ </span>
52
+ <input
53
+ v-model="form.bundleKey"
54
+ class="bcp-input bcp-input--mono"
55
+ placeholder="COMMUNICATION_PRO"
56
+ @input="onBundleKeyInput"
57
+ />
58
+ <span v-if="bundleKeyError" class="bcp-error-inline">
59
+ {{ bundleKeyError }}
60
+ </span>
61
+ </label>
62
+ <label class="bcp-field bcp-col-2">
63
+ <span class="bcp-field-label">Beschreibung</span>
64
+ <textarea
65
+ v-model="form.description"
66
+ rows="2"
67
+ class="bcp-input bcp-textarea"
68
+ placeholder="z. B. Kampagnen, WhatsApp und Korrespondenz für aktive Vereine."
69
+ />
70
+ </label>
71
+ </div>
72
+ </section>
73
+
74
+ <!-- 2: PRICING + VALIDFROM -->
75
+ <section class="bcp-section">
76
+ <div class="bcp-section-head">
77
+ <span class="bcp-section-num">2</span>
78
+ <div>
79
+ <div class="bcp-section-title">v1 · Pricing &amp; Gültigkeit</div>
80
+ <div class="bcp-section-sub">
81
+ Monats- &amp; Jahrespreis sowie Datum, ab dem das Bundle verkaufbar wird.
82
+ </div>
83
+ </div>
84
+ </div>
85
+ <div class="bcp-grid">
86
+ <label class="bcp-field">
87
+ <span class="bcp-field-label">Monatspreis</span>
88
+ <div class="bcp-input-grp">
89
+ <input
90
+ v-model="form.monthlyNet"
91
+ type="text"
92
+ inputmode="decimal"
93
+ class="bcp-input"
94
+ placeholder="9.90"
95
+ />
96
+ <span class="bcp-input-unit">€/Mo</span>
97
+ </div>
98
+ </label>
99
+ <label class="bcp-field">
100
+ <span class="bcp-field-label">Jahrespreis</span>
101
+ <div class="bcp-input-grp">
102
+ <input
103
+ v-model="form.yearlyNet"
104
+ type="text"
105
+ inputmode="decimal"
106
+ class="bcp-input"
107
+ placeholder="99.00"
108
+ />
109
+ <span class="bcp-input-unit">€/Jahr</span>
110
+ </div>
111
+ </label>
112
+ <label class="bcp-field bcp-col-2">
113
+ <span class="bcp-field-label">Gültig ab</span>
114
+ <input v-model="form.validFrom" type="date" class="bcp-input" />
115
+ <span class="bcp-field-hint">
116
+ <template v-if="form.validFrom && form.validFrom <= todayIso">
117
+ ✓ Bundle ist nach Anlage sofort live und verkaufbar.
118
+ </template>
119
+ <template v-else-if="form.validFrom">
120
+ ⏳ Geplant — wird ab {{ formatDateDE(form.validFrom) }} verkaufbar.
121
+ </template>
122
+ <template v-else>
123
+ Pflicht beim Publish — kann auch nach Anlage gesetzt werden.
124
+ </template>
125
+ </span>
126
+ </label>
127
+ </div>
128
+ </section>
129
+
130
+ <!-- 3: PLAN COMPAT -->
131
+ <section class="bcp-section">
132
+ <div class="bcp-section-head">
133
+ <span class="bcp-section-num">3</span>
134
+ <div>
135
+ <div class="bcp-section-title">Kompatibel mit Plänen</div>
136
+ <div class="bcp-section-sub">
137
+ Mit welchen Plänen darf dieses Bundle als Add-On gebucht werden?
138
+ Überschneidungen werden direkt markiert.
139
+ </div>
140
+ </div>
141
+ </div>
142
+ <BundlePlanCompatPicker
143
+ :plans="plans"
144
+ :live-plan-versions="livePlanVersions"
145
+ :bundle-features="form.features"
146
+ :bundle-quotas="form.quotas"
147
+ :selected-keys="form.planIds"
148
+ :feature-registry="featureRegistry"
149
+ :available-quotas="availableQuotas"
150
+ :quota-registry="quotaRegistry"
151
+ @toggle="onTogglePlan"
152
+ />
153
+ </section>
154
+
155
+ <!-- 4: FEATURES -->
156
+ <section class="bcp-section">
157
+ <div class="bcp-section-head">
158
+ <span class="bcp-section-num">4</span>
159
+ <div>
160
+ <div class="bcp-section-title">Features im Bundle</div>
161
+ <div class="bcp-section-sub">
162
+ {{ form.features.length }} von {{ availableFeatures.length }} ausgewählt
163
+ </div>
164
+ </div>
165
+ </div>
166
+ <BundleFeaturesEditor
167
+ :available-features="availableFeatures"
168
+ :features="form.features"
169
+ :feature-registry="featureRegistry"
170
+ :overlap-keys="aggregatedOverlapFeatures"
171
+ @toggle="onToggleFeature"
172
+ />
173
+ </section>
174
+
175
+ <!-- 5: QUOTAS -->
176
+ <section class="bcp-section">
177
+ <div class="bcp-section-head">
178
+ <span class="bcp-section-num">5</span>
179
+ <div>
180
+ <div class="bcp-section-title">
181
+ Quotas
182
+ <span class="bcp-field-hint" style="margin-left: 6px">optional</span>
183
+ </div>
184
+ <div class="bcp-section-sub">
185
+ Quotas, die das Bundle on top des Plans setzt.
186
+ </div>
187
+ </div>
188
+ </div>
189
+ <BundleQuotasEditor
190
+ :available-quotas="availableQuotas"
191
+ :quotas="form.quotas"
192
+ :overlap-keys="aggregatedOverlapQuotas"
193
+ :quota-registry="quotaRegistry"
194
+ @toggle="onToggleQuota"
195
+ @set-value="onSetQuotaValue"
196
+ />
197
+ </section>
198
+
199
+ <div v-if="submitError" class="bcp-error" role="alert">{{ submitError }}</div>
200
+ </div>
201
+
202
+ <div class="bcp-foot">
203
+ <span v-if="overlapPlansCount > 0" class="bcp-foot-hint bcp-foot-hint--warn">
204
+ ⚠ Überschneidung mit {{ overlapPlansCount }} Plan{{
205
+ overlapPlansCount === 1 ? '' : 'en'
206
+ }}
207
+ — bitte prüfen
208
+ </span>
209
+ <span v-else-if="form.features.length > 0" class="bcp-foot-hint">
210
+ <svg
211
+ width="12"
212
+ height="12"
213
+ viewBox="0 0 24 24"
214
+ fill="none"
215
+ stroke="currentColor"
216
+ stroke-width="3"
217
+ >
218
+ <path d="M5 13l4 4L19 7" />
219
+ </svg>
220
+ <span>
221
+ <b>{{ form.features.length }}</b> Feature{{
222
+ form.features.length === 1 ? '' : 's'
223
+ }}
224
+ <template v-if="quotaCount > 0">
225
+ · <b>{{ quotaCount }}</b> Quota{{ quotaCount === 1 ? '' : 's' }}
226
+ </template>
227
+ <template v-if="form.planIds.length > 0">
228
+ · <b>{{ form.planIds.length }}</b> Plan-Kompat.
229
+ </template>
230
+ </span>
231
+ </span>
232
+ <button class="bcp-btn" type="button" @click="close">Abbrechen</button>
233
+ <button
234
+ class="bcp-btn bcp-btn--primary"
235
+ type="button"
236
+ :disabled="!canSubmit || submitting"
237
+ @click="submit"
238
+ >
239
+ {{ submitting ? 'Lege an …' : 'Bundle anlegen' }}
240
+ </button>
241
+ </div>
242
+ </section>
243
+ </template>
244
+
245
+ <script setup lang="ts">
246
+ import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
247
+ import type {
248
+ BundleRow,
249
+ BundleVersionMutationResult,
250
+ CreateBundleData,
251
+ CreateBundleVersionDraftData,
252
+ DiscoveredFeature,
253
+ DiscoveredQuota,
254
+ PlanRow,
255
+ PlanVersionRow,
256
+ } from '@saasicat/types';
257
+
258
+ import BundleFeaturesEditor, { type FeatureMeta } from './BundleFeaturesEditor.vue';
259
+ import BundlePlanCompatPicker from './BundlePlanCompatPicker.vue';
260
+ import BundleQuotasEditor from './BundleQuotasEditor.vue';
261
+ import type { QuotaMeta } from './catalog-i18n.js';
262
+ import { findBundlePlanOverlap, formatDateDE } from './bundle-version-status';
263
+
264
+ // BundleCreatePanel — expandable inline panel for creating a new
265
+ // bundle (root + v1 draft) at the top of the bundle list. Uses the same
266
+ // sub-editors as the inline editor (features, quotas, plan-compat picker),
267
+ // so that creation and editing look consistent and behave the same.
268
+
269
+ interface Form {
270
+ label: string;
271
+ bundleKey: string;
272
+ description: string;
273
+ monthlyNet: string;
274
+ yearlyNet: string;
275
+ validFrom: string;
276
+ features: string[];
277
+ quotas: Record<string, number>;
278
+ planIds: string[];
279
+ }
280
+
281
+ const props = withDefaults(
282
+ defineProps<{
283
+ projectKey: string;
284
+ availableFeatures: DiscoveredFeature[];
285
+ availableQuotas: DiscoveredQuota[];
286
+ plans: PlanRow[];
287
+ livePlanVersions?: Record<string, PlanVersionRow | null>;
288
+ featureRegistry?: Record<string, FeatureMeta>;
289
+ quotaRegistry?: Record<string, QuotaMeta>;
290
+ existingBundleKeys: string[];
291
+ create: (data: CreateBundleData) => Promise<BundleRow>;
292
+ createDraft: (
293
+ bundleId: string,
294
+ data: Omit<CreateBundleVersionDraftData, 'bundleId'>,
295
+ ) => Promise<BundleVersionMutationResult>;
296
+ }>(),
297
+ {
298
+ livePlanVersions: () => ({}),
299
+ featureRegistry: () => ({}),
300
+ quotaRegistry: () => ({}),
301
+ },
302
+ );
303
+
304
+ const emit = defineEmits<{
305
+ (e: 'cancel'): void;
306
+ (e: 'created', bundle: BundleRow): void;
307
+ }>();
308
+
309
+ const todayIso = computed(() => new Date().toISOString().slice(0, 10));
310
+
311
+ const BUNDLE_KEY_PATTERN = /^[A-Z][A-Z0-9_]*$/;
312
+ const PRICE_RE = /^\d+(\.\d{1,2})?$/;
313
+
314
+ function emptyForm(): Form {
315
+ return {
316
+ label: '',
317
+ bundleKey: '',
318
+ description: '',
319
+ monthlyNet: '9.90',
320
+ yearlyNet: '99.00',
321
+ validFrom: new Date().toISOString().slice(0, 10),
322
+ features: [],
323
+ quotas: {},
324
+ planIds: [],
325
+ };
326
+ }
327
+
328
+ const form = reactive<Form>(emptyForm());
329
+ const submitting = ref(false);
330
+ const submitError = ref<string | null>(null);
331
+ const keyTouched = ref(false);
332
+ const labelInput = ref<HTMLInputElement | null>(null);
333
+
334
+ onMounted(() => {
335
+ nextTick(() => labelInput.value?.focus());
336
+ });
337
+
338
+ // As long as the key has not been edited manually, derive it from the label.
339
+ watch(
340
+ () => form.label,
341
+ (label) => {
342
+ if (!keyTouched.value) form.bundleKey = bundleKeyify(label);
343
+ },
344
+ );
345
+
346
+ function bundleKeyify(s: string): string {
347
+ return s
348
+ .toUpperCase()
349
+ .normalize('NFD')
350
+ .replace(/[̀-ͯ]/g, '')
351
+ .replace(/[^A-Z0-9]+/g, '_')
352
+ .replace(/^_+|_+$/g, '')
353
+ .slice(0, 32);
354
+ }
355
+
356
+ function onBundleKeyInput(event: Event): void {
357
+ keyTouched.value = true;
358
+ form.bundleKey = bundleKeyify((event.target as HTMLInputElement).value);
359
+ }
360
+
361
+ // ── Validation ────────────────────────────────────────────
362
+ const bundleKeyError = computed<string | null>(() => {
363
+ if (!form.bundleKey) return null;
364
+ if (!BUNDLE_KEY_PATTERN.test(form.bundleKey)) {
365
+ return 'Nur A-Z, 0-9, Underscore; muss mit Buchstabe beginnen.';
366
+ }
367
+ if (props.existingBundleKeys.includes(form.bundleKey)) {
368
+ return 'Dieser Bundle-Key existiert bereits.';
369
+ }
370
+ return null;
371
+ });
372
+
373
+ const canSubmit = computed(() => {
374
+ if (!form.label.trim()) return false;
375
+ if (!form.bundleKey) return false;
376
+ if (bundleKeyError.value) return false;
377
+ if (form.monthlyNet && !PRICE_RE.test(form.monthlyNet)) return false;
378
+ if (form.yearlyNet && !PRICE_RE.test(form.yearlyNet)) return false;
379
+ if (!form.validFrom) return false;
380
+ return true;
381
+ });
382
+
383
+ // ── Overlap aggregated across selected plans ───────────────
384
+ const aggregatedOverlap = computed(() => {
385
+ const features = new Set<string>();
386
+ const quotas = new Set<string>();
387
+ for (const planKey of form.planIds) {
388
+ const overlap = findBundlePlanOverlap(
389
+ { features: form.features, quotas: form.quotas },
390
+ props.livePlanVersions[planKey] ?? null,
391
+ );
392
+ for (const f of overlap.features) features.add(f);
393
+ for (const q of overlap.quotas) quotas.add(q);
394
+ }
395
+ return { features: [...features], quotas: [...quotas] };
396
+ });
397
+
398
+ const aggregatedOverlapFeatures = computed(() => aggregatedOverlap.value.features);
399
+ const aggregatedOverlapQuotas = computed(() => aggregatedOverlap.value.quotas);
400
+
401
+ const overlapPlansCount = computed(
402
+ () =>
403
+ form.planIds.filter(
404
+ (k) =>
405
+ findBundlePlanOverlap(
406
+ { features: form.features, quotas: form.quotas },
407
+ props.livePlanVersions[k] ?? null,
408
+ ).hasAny,
409
+ ).length,
410
+ );
411
+
412
+ const quotaCount = computed(() => Object.keys(form.quotas).length);
413
+
414
+ // ── Event handlers ────────────────────────────────────────
415
+ function onToggleFeature(featureKey: string): void {
416
+ const idx = form.features.indexOf(featureKey);
417
+ if (idx >= 0) form.features.splice(idx, 1);
418
+ else {
419
+ form.features.push(featureKey);
420
+ form.features.sort();
421
+ }
422
+ }
423
+
424
+ function onToggleQuota(quotaKey: string): void {
425
+ if (quotaKey in form.quotas) {
426
+ const next = { ...form.quotas };
427
+ delete next[quotaKey];
428
+ form.quotas = next;
429
+ } else {
430
+ form.quotas = { ...form.quotas, [quotaKey]: 0 };
431
+ }
432
+ }
433
+
434
+ function onSetQuotaValue(quotaKey: string, value: number): void {
435
+ form.quotas = { ...form.quotas, [quotaKey]: Number.isFinite(value) ? value : 0 };
436
+ }
437
+
438
+ function onTogglePlan(planKey: string): void {
439
+ const idx = form.planIds.indexOf(planKey);
440
+ if (idx >= 0) form.planIds.splice(idx, 1);
441
+ else form.planIds.push(planKey);
442
+ }
443
+
444
+ function close(): void {
445
+ emit('cancel');
446
+ }
447
+
448
+ async function submit(): Promise<void> {
449
+ if (!canSubmit.value || submitting.value) return;
450
+ submitting.value = true;
451
+ submitError.value = null;
452
+ try {
453
+ const bundle = await props.create({
454
+ projectKey: props.projectKey,
455
+ bundleKey: form.bundleKey,
456
+ label: form.label,
457
+ description: form.description || undefined,
458
+ });
459
+ await props.createDraft(bundle.id, {
460
+ features: [...form.features],
461
+ quotas: { ...form.quotas },
462
+ monthlyNet: form.monthlyNet || null,
463
+ yearlyNet: form.yearlyNet || null,
464
+ marketed: true,
465
+ changeNote: 'Initial v1',
466
+ validFrom: form.validFrom,
467
+ compatibility: form.planIds.length > 0 ? { planIds: [...form.planIds] } : undefined,
468
+ });
469
+ emit('created', bundle);
470
+ } catch (err) {
471
+ submitError.value = err instanceof Error ? err.message : String(err);
472
+ } finally {
473
+ submitting.value = false;
474
+ }
475
+ }
476
+ </script>
477
+
478
+ <style scoped>
479
+ .bcp {
480
+ background: #fff;
481
+ border: 1px solid #cbd5e1;
482
+ border-radius: 12px;
483
+ display: flex;
484
+ flex-direction: column;
485
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
486
+ }
487
+ .bcp-head {
488
+ display: flex;
489
+ align-items: flex-start;
490
+ justify-content: space-between;
491
+ gap: 16px;
492
+ padding: 18px 22px 12px;
493
+ border-bottom: 1px solid #e5e7eb;
494
+ }
495
+ .bcp-title {
496
+ font-size: 18px;
497
+ font-weight: 700;
498
+ color: #0f172a;
499
+ letter-spacing: -0.01em;
500
+ }
501
+ .bcp-sub {
502
+ margin-top: 4px;
503
+ font-size: 12.5px;
504
+ color: #64748b;
505
+ max-width: 580px;
506
+ line-height: 1.5;
507
+ }
508
+ .bcp-close {
509
+ background: transparent;
510
+ border: 0;
511
+ cursor: pointer;
512
+ color: #64748b;
513
+ padding: 4px;
514
+ border-radius: 4px;
515
+ }
516
+ .bcp-close:hover {
517
+ background: #f1f5f9;
518
+ color: #0f172a;
519
+ }
520
+ .bcp-body {
521
+ padding: 16px 22px;
522
+ display: flex;
523
+ flex-direction: column;
524
+ gap: 18px;
525
+ }
526
+ .bcp-section {
527
+ display: flex;
528
+ flex-direction: column;
529
+ gap: 10px;
530
+ }
531
+ .bcp-section-head {
532
+ display: flex;
533
+ align-items: flex-start;
534
+ gap: 10px;
535
+ }
536
+ .bcp-section-num {
537
+ display: inline-grid;
538
+ place-items: center;
539
+ width: 24px;
540
+ height: 24px;
541
+ border-radius: 999px;
542
+ background: #2563eb;
543
+ color: #fff;
544
+ font:
545
+ 700 12px 'JetBrains Mono',
546
+ ui-monospace,
547
+ monospace;
548
+ flex: 0 0 auto;
549
+ }
550
+ .bcp-section-title {
551
+ font-size: 14px;
552
+ font-weight: 700;
553
+ color: #0f172a;
554
+ }
555
+ .bcp-section-sub {
556
+ font-size: 12px;
557
+ color: #64748b;
558
+ margin-top: 2px;
559
+ line-height: 1.4;
560
+ }
561
+ .bcp-grid {
562
+ display: grid;
563
+ grid-template-columns: 1fr 1fr;
564
+ gap: 12px;
565
+ }
566
+ .bcp-col-2 {
567
+ grid-column: span 2;
568
+ }
569
+ .bcp-field {
570
+ display: flex;
571
+ flex-direction: column;
572
+ gap: 4px;
573
+ }
574
+ .bcp-field-label {
575
+ font-size: 11.5px;
576
+ font-weight: 600;
577
+ color: #475569;
578
+ }
579
+ .bcp-field-hint {
580
+ font-size: 10.5px;
581
+ color: #94a3b8;
582
+ font-weight: 500;
583
+ }
584
+ .bcp-input {
585
+ padding: 7px 10px;
586
+ border: 1px solid #cbd5e1;
587
+ border-radius: 6px;
588
+ font-family: inherit;
589
+ font-size: 13px;
590
+ color: #0f172a;
591
+ background: #fff;
592
+ }
593
+ .bcp-input--mono {
594
+ font:
595
+ 600 13px 'JetBrains Mono',
596
+ ui-monospace,
597
+ monospace;
598
+ letter-spacing: 0.02em;
599
+ }
600
+ .bcp-textarea {
601
+ resize: vertical;
602
+ }
603
+ .bcp-input-grp {
604
+ display: inline-flex;
605
+ align-items: stretch;
606
+ border: 1px solid #cbd5e1;
607
+ border-radius: 6px;
608
+ }
609
+ .bcp-input-grp .bcp-input {
610
+ border: 0;
611
+ border-radius: 6px 0 0 6px;
612
+ }
613
+ .bcp-input-unit {
614
+ display: inline-flex;
615
+ align-items: center;
616
+ padding: 0 10px;
617
+ font-size: 11.5px;
618
+ color: #64748b;
619
+ background: #f8fafc;
620
+ border-left: 1px solid #e2e8f0;
621
+ border-radius: 0 6px 6px 0;
622
+ }
623
+ .bcp-error-inline {
624
+ font-size: 11.5px;
625
+ color: #b91c1c;
626
+ }
627
+ .bcp-error {
628
+ padding: 10px 12px;
629
+ background: #fef2f2;
630
+ border: 1px solid #fecaca;
631
+ border-radius: 6px;
632
+ color: #b91c1c;
633
+ font-size: 12.5px;
634
+ }
635
+ .bcp-foot {
636
+ display: flex;
637
+ align-items: center;
638
+ gap: 10px;
639
+ padding: 12px 22px;
640
+ border-top: 1px solid #e5e7eb;
641
+ background: #f8fafc;
642
+ border-radius: 0 0 12px 12px;
643
+ }
644
+ .bcp-foot-hint {
645
+ flex: 1;
646
+ display: inline-flex;
647
+ align-items: center;
648
+ gap: 6px;
649
+ font-size: 12px;
650
+ color: #475569;
651
+ }
652
+ .bcp-foot-hint--warn {
653
+ color: #b91c1c;
654
+ font-weight: 600;
655
+ }
656
+ .bcp-btn {
657
+ padding: 7px 14px;
658
+ background: #fff;
659
+ border: 1px solid #cbd5e1;
660
+ border-radius: 6px;
661
+ cursor: pointer;
662
+ font-size: 12.5px;
663
+ font-family: inherit;
664
+ color: #0f172a;
665
+ }
666
+ .bcp-btn:hover:not(:disabled) {
667
+ background: #f8fafc;
668
+ }
669
+ .bcp-btn:disabled {
670
+ cursor: not-allowed;
671
+ opacity: 0.5;
672
+ }
673
+ .bcp-btn--primary {
674
+ background: #2563eb;
675
+ border-color: #2563eb;
676
+ color: #fff;
677
+ }
678
+ .bcp-btn--primary:hover:not(:disabled) {
679
+ background: #1d4ed8;
680
+ }
681
+ </style>