@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,659 @@
1
+ <template>
2
+ <div class="bve">
3
+ <BundleStatusBanner :version="version" :now="now" @discard="$emit('discard')" />
4
+
5
+ <div v-if="hasOverlap" class="bve-overlap-banner" role="alert">
6
+ <span class="bve-overlap-ico">⚠</span>
7
+ <span>
8
+ <b>{{ overlapPlansCount }}</b> Plan{{ overlapPlansCount === 1 ? '' : 'e' }} mit
9
+ Überschneidung — Features/Quotas dieses Bundles sind im Plan bereits enthalten.
10
+ Bitte vor Publish bereinigen.
11
+ </span>
12
+ </div>
13
+
14
+ <div class="bve-grid">
15
+ <!-- LEFT: Features + Quotas -->
16
+ <section class="bve-col">
17
+ <div class="bve-section-label">
18
+ <span>Features</span>
19
+ <span class="bve-section-count">
20
+ {{ form.features.length }} / {{ availableFeatures.length }}
21
+ </span>
22
+ </div>
23
+ <BundleFeaturesEditor
24
+ :available-features="availableFeatures"
25
+ :features="form.features"
26
+ :locked="locked"
27
+ :feature-registry="featureRegistry"
28
+ :overlap-keys="aggregatedOverlapFeatures"
29
+ @toggle="onToggleFeature"
30
+ />
31
+
32
+ <div class="bve-section-label bve-section-label--top">
33
+ <span>Quotas</span>
34
+ <span class="bve-section-count">
35
+ {{ Object.keys(form.quotas).length }} / {{ availableQuotas.length }}
36
+ </span>
37
+ </div>
38
+ <BundleQuotasEditor
39
+ :available-quotas="availableQuotas"
40
+ :quotas="form.quotas"
41
+ :locked="locked"
42
+ :overlap-keys="aggregatedOverlapQuotas"
43
+ :quota-registry="quotaRegistry"
44
+ @toggle="onToggleQuota"
45
+ @set-value="onSetQuotaValue"
46
+ />
47
+ </section>
48
+
49
+ <!-- RIGHT: Pricing · Validity · Compat -->
50
+ <section class="bve-col">
51
+ <div class="bve-section-label">
52
+ <span>Pricing</span>
53
+ <span class="bve-section-count">Bundle-Preis · zusätzlich zum Plan</span>
54
+ </div>
55
+ <div class="bve-row bve-row--pricing">
56
+ <label class="bve-field">
57
+ <span class="bve-field-label">Monatspreis</span>
58
+ <div class="bve-input-grp">
59
+ <input
60
+ type="text"
61
+ inputmode="decimal"
62
+ class="bve-input"
63
+ :value="form.monthlyNet ?? ''"
64
+ :disabled="locked"
65
+ @input="onPriceInput('monthlyNet', $event)"
66
+ />
67
+ <span class="bve-input-unit">€/Mo</span>
68
+ </div>
69
+ </label>
70
+ <label class="bve-field">
71
+ <span class="bve-field-label">Jahrespreis</span>
72
+ <div class="bve-input-grp">
73
+ <input
74
+ type="text"
75
+ inputmode="decimal"
76
+ class="bve-input"
77
+ :value="form.yearlyNet ?? ''"
78
+ :disabled="locked"
79
+ @input="onPriceInput('yearlyNet', $event)"
80
+ />
81
+ <span class="bve-input-unit">€/Jahr</span>
82
+ </div>
83
+ </label>
84
+ <div class="bve-savings">
85
+ <template v-if="savingsPercent !== null">
86
+ ≈ {{ monthlyEquivalent }} €/Mo bei Jahresbuchung
87
+ <span v-if="savingsPercent > 0" class="bve-savings-pill">
88
+ {{ savingsPercent }} % sparen
89
+ </span>
90
+ </template>
91
+ </div>
92
+ </div>
93
+
94
+ <div class="bve-section-label bve-section-label--top">
95
+ <span>Gültigkeit</span>
96
+ <span class="bve-section-count">validUntil automatisch (Auto-Sukzession)</span>
97
+ </div>
98
+ <div class="bve-row bve-row--validity">
99
+ <label class="bve-field">
100
+ <span class="bve-field-label">Gültig ab</span>
101
+ <input
102
+ type="date"
103
+ class="bve-input"
104
+ :value="form.validFrom ?? ''"
105
+ :disabled="locked"
106
+ @input="onValidFromInput"
107
+ />
108
+ </label>
109
+ <label class="bve-field">
110
+ <span class="bve-field-label">Gültig bis</span>
111
+ <input
112
+ type="text"
113
+ class="bve-input bve-input--readonly"
114
+ :value="formatDateDE(form.validUntil) || 'offen'"
115
+ disabled
116
+ />
117
+ <span class="bve-field-hint">
118
+ wird beim Publish einer Nachfolge-Version automatisch gesetzt
119
+ </span>
120
+ </label>
121
+ </div>
122
+
123
+ <div class="bve-section-label bve-section-label--top">
124
+ <span>Marketing</span>
125
+ </div>
126
+ <label class="bve-toggle-row">
127
+ <input
128
+ type="checkbox"
129
+ :checked="form.marketed"
130
+ :disabled="locked"
131
+ @change="onMarketedToggle"
132
+ />
133
+ <span class="bve-toggle-label">Im Public-Catalog vermarkten</span>
134
+ </label>
135
+
136
+ <div class="bve-section-label bve-section-label--top">
137
+ <span>Change-Note</span>
138
+ <span class="bve-section-count">Pflicht beim Publish</span>
139
+ </div>
140
+ <textarea
141
+ class="bve-textarea"
142
+ rows="2"
143
+ :value="form.changeNote"
144
+ :disabled="locked"
145
+ placeholder="z. B. Add WhatsApp + Preisanpassung +4 €"
146
+ @input="onChangeNoteInput"
147
+ />
148
+
149
+ <div class="bve-section-label bve-section-label--top">
150
+ <span>Kompatibel mit Plänen</span>
151
+ <span class="bve-section-count">{{ form.planIds.length }} ausgewählt</span>
152
+ </div>
153
+ <BundlePlanCompatPicker
154
+ :plans="plans"
155
+ :live-plan-versions="livePlanVersions"
156
+ :bundle-features="form.features"
157
+ :bundle-quotas="form.quotas"
158
+ :selected-keys="form.planIds"
159
+ :locked="locked"
160
+ :feature-registry="featureRegistry"
161
+ :available-quotas="availableQuotas"
162
+ :quota-registry="quotaRegistry"
163
+ @toggle="onTogglePlan"
164
+ />
165
+ </section>
166
+ </div>
167
+
168
+ <div v-if="!locked" class="bve-actions">
169
+ <button
170
+ type="button"
171
+ class="bve-btn"
172
+ :disabled="saving || !hasChanges"
173
+ @click="onReset"
174
+ >
175
+ Zurücksetzen
176
+ </button>
177
+ <button
178
+ type="button"
179
+ class="bve-btn bve-btn--primary"
180
+ :disabled="!canSave || saving"
181
+ :title="!canSave ? 'Form ist invalide oder unverändert' : 'Änderungen speichern'"
182
+ @click="onSave"
183
+ >
184
+ {{ saving ? 'Speichern …' : 'Speichern' }}
185
+ </button>
186
+ </div>
187
+ <div v-if="saveError" class="bve-error">
188
+ {{ saveError }}
189
+ </div>
190
+ </div>
191
+ </template>
192
+
193
+ <script setup lang="ts">
194
+ import { computed, reactive, watch } from 'vue';
195
+ import type {
196
+ BundleVersionRow,
197
+ DiscoveredFeature,
198
+ DiscoveredQuota,
199
+ PlanRow,
200
+ PlanVersionRow,
201
+ UpdateBundleVersionDraftData,
202
+ } from '@saasicat/types';
203
+
204
+ import BundleFeaturesEditor, { type FeatureMeta } from './BundleFeaturesEditor.vue';
205
+ import BundlePlanCompatPicker from './BundlePlanCompatPicker.vue';
206
+ import BundleQuotasEditor from './BundleQuotasEditor.vue';
207
+ import BundleStatusBanner from './BundleStatusBanner.vue';
208
+ import type { QuotaMeta } from './catalog-i18n.js';
209
+ import { bundleVersionStatus, findBundlePlanOverlap, formatDateDE } from './bundle-version-status';
210
+
211
+ // BundleVersionInlineEditor — orchestrates the five sub-components (status
212
+ // banner + features editor + quotas editor + pricing/validity block +
213
+ // plan compat picker) into a 2-column inline editor, modeled on the plan
214
+ // simulation (saasadminui/project/bundles.jsx → BundleVersionEditor).
215
+ //
216
+ // Editability follows the backend helper: live/superseded are read-only,
217
+ // draft/scheduled (pre-active, latest-in-chain, 0 subs) are freely editable.
218
+ // Saving emits a `save` event with the diff to the original — the
219
+ // consumer calls the composable method (`updateDraft`).
220
+
221
+ interface Form {
222
+ features: string[];
223
+ quotas: Record<string, number>;
224
+ monthlyNet: string | null;
225
+ yearlyNet: string | null;
226
+ marketed: boolean;
227
+ changeNote: string;
228
+ validFrom: string | null;
229
+ validUntil: string | null;
230
+ /** Compatible plan keys; wire field `compatibility.planIds`. */
231
+ planIds: string[];
232
+ }
233
+
234
+ const props = withDefaults(
235
+ defineProps<{
236
+ version: BundleVersionRow;
237
+ availableFeatures: DiscoveredFeature[];
238
+ availableQuotas: DiscoveredQuota[];
239
+ plans: PlanRow[];
240
+ /** Live (or latest) plan version per planKey for overlap check. */
241
+ livePlanVersions?: Record<string, PlanVersionRow | null>;
242
+ featureRegistry?: Record<string, FeatureMeta>;
243
+ quotaRegistry?: Record<string, QuotaMeta>;
244
+ saving?: boolean;
245
+ saveError?: string | null;
246
+ /** Reference timestamp for the status check (tests). */
247
+ now?: Date;
248
+ }>(),
249
+ {
250
+ livePlanVersions: () => ({}),
251
+ featureRegistry: () => ({}),
252
+ quotaRegistry: () => ({}),
253
+ saving: false,
254
+ saveError: null,
255
+ },
256
+ );
257
+
258
+ const emit = defineEmits<{
259
+ (e: 'save', data: UpdateBundleVersionDraftData): void;
260
+ (e: 'discard'): void;
261
+ }>();
262
+
263
+ const PRICE_RE = /^\d+(\.\d{1,2})?$/;
264
+
265
+ function buildForm(v: BundleVersionRow): Form {
266
+ return {
267
+ features: [...v.features],
268
+ quotas: { ...v.quotas },
269
+ monthlyNet: v.monthlyNet,
270
+ yearlyNet: v.yearlyNet,
271
+ marketed: v.marketed,
272
+ changeNote: v.changeNote,
273
+ validFrom: v.validFrom ? v.validFrom.slice(0, 10) : null,
274
+ validUntil: v.validUntil ? v.validUntil.slice(0, 10) : null,
275
+ planIds: [...(v.compatibility?.planIds ?? [])],
276
+ };
277
+ }
278
+
279
+ const form = reactive<Form>(buildForm(props.version));
280
+ let baseline: Form = buildForm(props.version);
281
+
282
+ watch(
283
+ () => props.version.id,
284
+ () => {
285
+ const next = buildForm(props.version);
286
+ Object.assign(form, next);
287
+ baseline = next;
288
+ },
289
+ );
290
+
291
+ const status = computed(() => bundleVersionStatus(props.version, props.now));
292
+ const locked = computed(() => status.value === 'live' || status.value === 'superseded');
293
+
294
+ // ── Pricing display ────────────────────────────────────────
295
+ const savingsPercent = computed<number | null>(() => {
296
+ const m = Number(form.monthlyNet);
297
+ const y = Number(form.yearlyNet);
298
+ if (!Number.isFinite(m) || !Number.isFinite(y) || m <= 0 || y <= 0) return null;
299
+ const fullYear = m * 12;
300
+ if (y >= fullYear) return 0;
301
+ return Math.round((1 - y / fullYear) * 100);
302
+ });
303
+
304
+ const monthlyEquivalent = computed(() => {
305
+ const y = Number(form.yearlyNet);
306
+ if (!Number.isFinite(y) || y <= 0) return '—';
307
+ return (y / 12).toFixed(2);
308
+ });
309
+
310
+ // ── Validation ─────────────────────────────────────────────
311
+ const validFromError = computed<string | null>(() => {
312
+ if (locked.value) return null;
313
+ if (!form.validFrom) return 'Gültig ab ist Pflicht.';
314
+ return null;
315
+ });
316
+
317
+ const priceError = computed<string | null>(() => {
318
+ if (locked.value) return null;
319
+ if (form.monthlyNet && !PRICE_RE.test(form.monthlyNet)) {
320
+ return 'Monatspreis: Dezimal mit max. 2 Nachkommastellen.';
321
+ }
322
+ if (form.yearlyNet && !PRICE_RE.test(form.yearlyNet)) {
323
+ return 'Jahrespreis: Dezimal mit max. 2 Nachkommastellen.';
324
+ }
325
+ return null;
326
+ });
327
+
328
+ const validationError = computed(() => priceError.value ?? validFromError.value);
329
+
330
+ // ── Overlap aggregated over all selected plans ─────────────
331
+ const aggregatedOverlap = computed(() => {
332
+ const features = new Set<string>();
333
+ const quotas = new Set<string>();
334
+ for (const planKey of form.planIds) {
335
+ const overlap = findBundlePlanOverlap(
336
+ { features: form.features, quotas: form.quotas },
337
+ props.livePlanVersions[planKey] ?? null,
338
+ );
339
+ for (const f of overlap.features) features.add(f);
340
+ for (const q of overlap.quotas) quotas.add(q);
341
+ }
342
+ return { features: [...features], quotas: [...quotas] };
343
+ });
344
+
345
+ const aggregatedOverlapFeatures = computed(() => aggregatedOverlap.value.features);
346
+ const aggregatedOverlapQuotas = computed(() => aggregatedOverlap.value.quotas);
347
+
348
+ const overlapPlansCount = computed(
349
+ () =>
350
+ form.planIds.filter(
351
+ (k) =>
352
+ findBundlePlanOverlap(
353
+ { features: form.features, quotas: form.quotas },
354
+ props.livePlanVersions[k] ?? null,
355
+ ).hasAny,
356
+ ).length,
357
+ );
358
+
359
+ const hasOverlap = computed(() => overlapPlansCount.value > 0);
360
+
361
+ // ── Change detection ───────────────────────────────────────
362
+ const hasChanges = computed(() => {
363
+ if (form.monthlyNet !== baseline.monthlyNet) return true;
364
+ if (form.yearlyNet !== baseline.yearlyNet) return true;
365
+ if (form.marketed !== baseline.marketed) return true;
366
+ if (form.changeNote !== baseline.changeNote) return true;
367
+ if (form.validFrom !== baseline.validFrom) return true;
368
+ if (form.validUntil !== baseline.validUntil) return true;
369
+ if (!arraysEqual(form.features, baseline.features)) return true;
370
+ if (!arraysEqual(form.planIds, baseline.planIds)) return true;
371
+ if (!recordsEqual(form.quotas, baseline.quotas)) return true;
372
+ return false;
373
+ });
374
+
375
+ const canSave = computed(() => hasChanges.value && validationError.value === null);
376
+
377
+ // ── Event handlers ─────────────────────────────────────────
378
+ function onToggleFeature(featureKey: string): void {
379
+ if (locked.value) return;
380
+ const idx = form.features.indexOf(featureKey);
381
+ if (idx >= 0) form.features.splice(idx, 1);
382
+ else {
383
+ form.features.push(featureKey);
384
+ form.features.sort();
385
+ }
386
+ }
387
+
388
+ function onToggleQuota(quotaKey: string): void {
389
+ if (locked.value) return;
390
+ if (quotaKey in form.quotas) {
391
+ const next = { ...form.quotas };
392
+ delete next[quotaKey];
393
+ form.quotas = next;
394
+ } else {
395
+ form.quotas = { ...form.quotas, [quotaKey]: 0 };
396
+ }
397
+ }
398
+
399
+ function onSetQuotaValue(quotaKey: string, value: number): void {
400
+ if (locked.value) return;
401
+ form.quotas = { ...form.quotas, [quotaKey]: Number.isFinite(value) ? value : 0 };
402
+ }
403
+
404
+ function onTogglePlan(planKey: string): void {
405
+ if (locked.value) return;
406
+ const idx = form.planIds.indexOf(planKey);
407
+ if (idx >= 0) form.planIds.splice(idx, 1);
408
+ else form.planIds.push(planKey);
409
+ }
410
+
411
+ function onPriceInput(field: 'monthlyNet' | 'yearlyNet', event: Event): void {
412
+ const value = (event.target as HTMLInputElement).value.trim();
413
+ form[field] = value.length === 0 ? null : value;
414
+ }
415
+
416
+ function onValidFromInput(event: Event): void {
417
+ const value = (event.target as HTMLInputElement).value;
418
+ form.validFrom = value.length === 0 ? null : value;
419
+ }
420
+
421
+ function onChangeNoteInput(event: Event): void {
422
+ form.changeNote = (event.target as HTMLTextAreaElement).value;
423
+ }
424
+
425
+ function onMarketedToggle(event: Event): void {
426
+ form.marketed = (event.target as HTMLInputElement).checked;
427
+ }
428
+
429
+ function onReset(): void {
430
+ Object.assign(form, buildForm(props.version));
431
+ }
432
+
433
+ function onSave(): void {
434
+ if (!canSave.value || props.saving) return;
435
+ emit('save', {
436
+ features: [...form.features],
437
+ quotas: { ...form.quotas },
438
+ monthlyNet: form.monthlyNet,
439
+ yearlyNet: form.yearlyNet,
440
+ marketed: form.marketed,
441
+ changeNote: form.changeNote,
442
+ validFrom: form.validFrom,
443
+ validUntil: form.validUntil,
444
+ compatibility: {
445
+ ...(props.version.compatibility ?? {}),
446
+ planIds: [...form.planIds],
447
+ },
448
+ });
449
+ }
450
+
451
+ function arraysEqual(a: string[], b: string[]): boolean {
452
+ if (a.length !== b.length) return false;
453
+ const sortedA = [...a].sort();
454
+ const sortedB = [...b].sort();
455
+ return sortedA.every((v, i) => v === sortedB[i]);
456
+ }
457
+
458
+ function recordsEqual(a: Record<string, number>, b: Record<string, number>): boolean {
459
+ const aKeys = Object.keys(a).sort();
460
+ const bKeys = Object.keys(b).sort();
461
+ if (aKeys.length !== bKeys.length) return false;
462
+ if (!aKeys.every((k, i) => k === bKeys[i])) return false;
463
+ return aKeys.every((k) => a[k] === b[k]);
464
+ }
465
+ </script>
466
+
467
+ <style scoped>
468
+ .bve {
469
+ display: flex;
470
+ flex-direction: column;
471
+ gap: 14px;
472
+ }
473
+ .bve-overlap-banner {
474
+ display: flex;
475
+ align-items: flex-start;
476
+ gap: 8px;
477
+ padding: 10px 12px;
478
+ background: #fef2f2;
479
+ border: 1px solid #fecaca;
480
+ border-radius: 8px;
481
+ color: #b91c1c;
482
+ font-size: 12.5px;
483
+ line-height: 1.5;
484
+ }
485
+ .bve-overlap-ico {
486
+ font-weight: 700;
487
+ }
488
+ .bve-grid {
489
+ display: grid;
490
+ grid-template-columns: 1fr 1fr;
491
+ gap: 18px;
492
+ }
493
+ @media (max-width: 1100px) {
494
+ .bve-grid {
495
+ grid-template-columns: 1fr;
496
+ }
497
+ }
498
+ .bve-col {
499
+ display: flex;
500
+ flex-direction: column;
501
+ gap: 10px;
502
+ }
503
+ .bve-section-label {
504
+ display: flex;
505
+ align-items: baseline;
506
+ justify-content: space-between;
507
+ gap: 12px;
508
+ font-size: 11.5px;
509
+ font-weight: 700;
510
+ text-transform: uppercase;
511
+ letter-spacing: 0.08em;
512
+ color: #475569;
513
+ padding-top: 2px;
514
+ }
515
+ .bve-section-label--top {
516
+ margin-top: 8px;
517
+ }
518
+ .bve-section-count {
519
+ font-size: 10.5px;
520
+ font-weight: 600;
521
+ color: #94a3b8;
522
+ text-transform: none;
523
+ letter-spacing: 0;
524
+ }
525
+ .bve-row {
526
+ display: grid;
527
+ gap: 10px;
528
+ }
529
+ .bve-row--pricing {
530
+ grid-template-columns: 1fr 1fr auto;
531
+ align-items: end;
532
+ }
533
+ .bve-row--validity {
534
+ grid-template-columns: 1fr 1fr;
535
+ }
536
+ .bve-field {
537
+ display: flex;
538
+ flex-direction: column;
539
+ gap: 4px;
540
+ }
541
+ .bve-field-label {
542
+ font-size: 11px;
543
+ color: #475569;
544
+ font-weight: 600;
545
+ }
546
+ .bve-field-hint {
547
+ font-size: 10.5px;
548
+ color: #94a3b8;
549
+ }
550
+ .bve-input-grp {
551
+ display: inline-flex;
552
+ align-items: stretch;
553
+ border: 1px solid #cbd5e1;
554
+ border-radius: 6px;
555
+ background: #fff;
556
+ }
557
+ .bve-input-grp .bve-input {
558
+ border: 0;
559
+ border-radius: 6px 0 0 6px;
560
+ }
561
+ .bve-input-unit {
562
+ display: inline-flex;
563
+ align-items: center;
564
+ padding: 0 8px;
565
+ font-size: 11.5px;
566
+ color: #64748b;
567
+ background: #f8fafc;
568
+ border-left: 1px solid #e2e8f0;
569
+ border-radius: 0 6px 6px 0;
570
+ }
571
+ .bve-input {
572
+ padding: 6px 10px;
573
+ border: 1px solid #cbd5e1;
574
+ border-radius: 6px;
575
+ font-size: 13px;
576
+ font-family: inherit;
577
+ color: #0f172a;
578
+ background: #fff;
579
+ }
580
+ .bve-input--readonly {
581
+ color: #94a3b8;
582
+ background: #f8fafc;
583
+ }
584
+ .bve-savings {
585
+ font-size: 11.5px;
586
+ color: #475569;
587
+ display: inline-flex;
588
+ align-items: center;
589
+ gap: 8px;
590
+ padding-bottom: 6px;
591
+ }
592
+ .bve-savings-pill {
593
+ padding: 1px 6px;
594
+ background: #d1fae5;
595
+ color: #065f46;
596
+ border-radius: 4px;
597
+ font-weight: 700;
598
+ font-size: 10.5px;
599
+ letter-spacing: 0.04em;
600
+ }
601
+ .bve-toggle-row {
602
+ display: inline-flex;
603
+ align-items: center;
604
+ gap: 8px;
605
+ cursor: pointer;
606
+ font-size: 13px;
607
+ }
608
+ .bve-toggle-label {
609
+ color: #0f172a;
610
+ }
611
+ .bve-textarea {
612
+ padding: 8px 10px;
613
+ border: 1px solid #cbd5e1;
614
+ border-radius: 6px;
615
+ font-size: 13px;
616
+ font-family: inherit;
617
+ color: #0f172a;
618
+ background: #fff;
619
+ resize: vertical;
620
+ }
621
+ .bve-actions {
622
+ display: flex;
623
+ justify-content: flex-end;
624
+ gap: 8px;
625
+ }
626
+ .bve-btn {
627
+ padding: 7px 14px;
628
+ background: #fff;
629
+ border: 1px solid #cbd5e1;
630
+ border-radius: 6px;
631
+ cursor: pointer;
632
+ font-size: 12.5px;
633
+ font-family: inherit;
634
+ color: #0f172a;
635
+ }
636
+ .bve-btn:hover:not(:disabled) {
637
+ background: #f8fafc;
638
+ }
639
+ .bve-btn:disabled {
640
+ cursor: not-allowed;
641
+ opacity: 0.5;
642
+ }
643
+ .bve-btn--primary {
644
+ background: #2563eb;
645
+ border-color: #2563eb;
646
+ color: #fff;
647
+ }
648
+ .bve-btn--primary:hover:not(:disabled) {
649
+ background: #1d4ed8;
650
+ }
651
+ .bve-error {
652
+ padding: 8px 12px;
653
+ background: #fef2f2;
654
+ border: 1px solid #fecaca;
655
+ border-radius: 6px;
656
+ color: #b91c1c;
657
+ font-size: 12.5px;
658
+ }
659
+ </style>