@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,417 @@
1
+ <template>
2
+ <q-dialog v-model="open" persistent>
3
+ <q-card style="min-width: 720px; max-width: 96vw">
4
+ <q-card-section>
5
+ <div class="text-h6">
6
+ {{ mode === 'create' ? 'Neue BusinessTypeVersion' : 'Draft bearbeiten' }}
7
+ </div>
8
+ <p class="text-caption text-grey-7">
9
+ BusinessType <code>{{ businessTypeKey }}</code> · v{{ versionNumber }}
10
+ {{ mode === 'create' ? '(neu)' : '(Draft)' }}
11
+ </p>
12
+ </q-card-section>
13
+
14
+ <q-card-section class="bvt__body">
15
+ <!-- Bundle composition -->
16
+ <div>
17
+ <div class="bvt__label">Bundle-Komposition</div>
18
+ <p class="text-caption text-grey-7 q-mb-sm">
19
+ BusinessType referenziert konkrete <strong>published</strong>
20
+ BundleVersions. Reihenfolge bestimmt die Sortierung im UI.
21
+ </p>
22
+
23
+ <q-list bordered separator dense>
24
+ <q-item
25
+ v-for="(entry, idx) in form.bundles"
26
+ :key="`${entry.bundleVersionId}-${idx}`"
27
+ >
28
+ <q-item-section>
29
+ <q-select
30
+ :model-value="entry.bundleVersionId"
31
+ :options="bundleOptions"
32
+ option-label="label"
33
+ option-value="value"
34
+ emit-value
35
+ map-options
36
+ outlined
37
+ dense
38
+ label="BundleVersion"
39
+ @update:model-value="(v) => updateBundleAt(idx, String(v))"
40
+ />
41
+ </q-item-section>
42
+ <q-item-section side>
43
+ <q-btn
44
+ flat
45
+ dense
46
+ round
47
+ icon="arrow_upward"
48
+ :disable="idx === 0"
49
+ @click="moveBundle(idx, -1)"
50
+ />
51
+ <q-btn
52
+ flat
53
+ dense
54
+ round
55
+ icon="arrow_downward"
56
+ :disable="idx === form.bundles.length - 1"
57
+ @click="moveBundle(idx, 1)"
58
+ />
59
+ <q-btn
60
+ flat
61
+ dense
62
+ round
63
+ icon="delete"
64
+ color="negative"
65
+ @click="removeBundle(idx)"
66
+ />
67
+ </q-item-section>
68
+ </q-item>
69
+ </q-list>
70
+ <q-banner
71
+ v-if="form.bundles.length === 0"
72
+ class="bvt__warn"
73
+ inline-actions
74
+ rounded
75
+ >
76
+ <template #avatar>
77
+ <q-icon name="warning" color="warning" />
78
+ </template>
79
+ Mindestens ein Bundle muss referenziert werden, bevor die Version published
80
+ werden kann.
81
+ </q-banner>
82
+ <q-btn
83
+ flat
84
+ dense
85
+ icon="add"
86
+ label="Bundle ergänzen"
87
+ color="primary"
88
+ :disable="availableBundles.length === 0"
89
+ @click="addBundle"
90
+ />
91
+ </div>
92
+
93
+ <!-- Quota overrides -->
94
+ <div>
95
+ <div class="bvt__label">Quota-Overrides</div>
96
+ <p class="text-caption text-grey-7 q-mb-sm">
97
+ Fehlender Key = Σ(Bundle-Quotas); gesetzter Key ersetzt die Summe (-1 =
98
+ unbegrenzt).
99
+ </p>
100
+ <div class="bvt__quotas">
101
+ <div
102
+ v-for="(_value, key) in form.quotaOverrides"
103
+ :key="key"
104
+ class="bvt__quota-row"
105
+ >
106
+ <q-input
107
+ :model-value="key"
108
+ outlined
109
+ dense
110
+ label="Key"
111
+ @update:model-value="(newKey) => renameQuota(key, String(newKey))"
112
+ />
113
+ <q-input
114
+ v-model.number="form.quotaOverrides[key]"
115
+ outlined
116
+ dense
117
+ type="number"
118
+ label="Wert"
119
+ />
120
+ <q-btn
121
+ flat
122
+ dense
123
+ round
124
+ icon="delete"
125
+ color="negative"
126
+ @click="removeQuota(String(key))"
127
+ />
128
+ </div>
129
+ <q-btn
130
+ flat
131
+ dense
132
+ icon="add"
133
+ label="Quota-Override ergänzen"
134
+ color="primary"
135
+ @click="addQuota"
136
+ />
137
+ </div>
138
+ </div>
139
+
140
+ <!-- Pricing -->
141
+ <div class="bvt__pricing">
142
+ <q-input
143
+ v-model="form.monthlyNet"
144
+ outlined
145
+ dense
146
+ label="Monatspreis (Override, EUR)"
147
+ hint="leer = Σ(Bundle-Preise)"
148
+ clearable
149
+ />
150
+ <q-input
151
+ v-model="form.yearlyNet"
152
+ outlined
153
+ dense
154
+ label="Jahrespreis (Override, EUR)"
155
+ clearable
156
+ />
157
+ </div>
158
+
159
+ <q-toggle v-model="form.marketed" label="In Public-Catalog vermarkten" />
160
+
161
+ <q-input
162
+ v-model="form.changeNote"
163
+ outlined
164
+ dense
165
+ type="textarea"
166
+ autogrow
167
+ label="Change-Note"
168
+ />
169
+
170
+ <q-banner
171
+ v-if="lastWarnings.length > 0"
172
+ class="bvt__warnings"
173
+ inline-actions
174
+ rounded
175
+ >
176
+ <template #avatar>
177
+ <q-icon name="warning" color="warning" />
178
+ </template>
179
+ <strong>{{ lastWarnings.length }} Strict-Mode-Warnung(en):</strong>
180
+ <ul class="bvt__warnings-list">
181
+ <li v-for="(w, i) in lastWarnings" :key="i">
182
+ <code>{{ w.code }}</code>
183
+ <template v-if="w.value">
184
+ · <code>{{ w.value }}</code></template
185
+ >
186
+ — {{ w.message }}
187
+ </li>
188
+ </ul>
189
+ </q-banner>
190
+ </q-card-section>
191
+
192
+ <q-card-actions align="right">
193
+ <q-btn flat label="Abbrechen" @click="close" />
194
+ <q-btn
195
+ unelevated
196
+ color="primary"
197
+ :label="mode === 'create' ? 'Anlegen' : 'Speichern'"
198
+ :disable="form.bundles.length === 0"
199
+ :loading="submitting"
200
+ @click="submit"
201
+ />
202
+ </q-card-actions>
203
+ </q-card>
204
+ </q-dialog>
205
+ </template>
206
+
207
+ <script setup lang="ts">
208
+ import { computed, ref, watch } from 'vue';
209
+ import type {
210
+ BundleVersionRow,
211
+ BusinessTypeVersionMutationResult,
212
+ BusinessTypeVersionRow,
213
+ CreateBusinessTypeVersionDraftData,
214
+ StrictModeWarning,
215
+ UpdateBusinessTypeVersionDraftData,
216
+ } from '@saasicat/types';
217
+
218
+ interface FormState {
219
+ bundles: Array<{ bundleVersionId: string; sortOrder: number }>;
220
+ quotaOverrides: Record<string, number>;
221
+ monthlyNet: string | null;
222
+ yearlyNet: string | null;
223
+ marketed: boolean;
224
+ changeNote: string;
225
+ }
226
+
227
+ const props = defineProps<{
228
+ modelValue: boolean;
229
+ mode: 'create' | 'edit';
230
+ businessTypeKey: string;
231
+ draft?: BusinessTypeVersionRow | null;
232
+ versionNumber: number;
233
+ /**
234
+ * List of all available published BundleVersions in the project. The consumer
235
+ * wrapper collects this from useBundles + useBundleVersions.
236
+ */
237
+ availableBundles: BundleVersionRow[];
238
+ submit: (
239
+ data: CreateBusinessTypeVersionDraftData | UpdateBusinessTypeVersionDraftData,
240
+ ) => Promise<BusinessTypeVersionMutationResult>;
241
+ }>();
242
+
243
+ const emit = defineEmits<{
244
+ (e: 'update:modelValue', value: boolean): void;
245
+ (e: 'submitted', result: BusinessTypeVersionMutationResult): void;
246
+ }>();
247
+
248
+ const open = computed({
249
+ get: () => props.modelValue,
250
+ set: (v) => emit('update:modelValue', v),
251
+ });
252
+
253
+ const form = ref<FormState>(buildInitialForm());
254
+ const submitting = ref(false);
255
+ const lastWarnings = ref<StrictModeWarning[]>([]);
256
+
257
+ const bundleOptions = computed(() =>
258
+ props.availableBundles.map((bv) => ({
259
+ value: bv.id,
260
+ label: `${bv.bundleKey} · v${bv.version} (${bv.features.length} Features)`,
261
+ })),
262
+ );
263
+
264
+ watch(
265
+ () => [props.modelValue, props.draft],
266
+ () => {
267
+ if (props.modelValue) {
268
+ form.value = buildInitialForm();
269
+ lastWarnings.value = [];
270
+ }
271
+ },
272
+ { immediate: true },
273
+ );
274
+
275
+ function buildInitialForm(): FormState {
276
+ if (props.mode === 'edit' && props.draft) {
277
+ return {
278
+ bundles: props.draft.bundles.map((b) => ({
279
+ bundleVersionId: b.bundleVersionId,
280
+ sortOrder: b.sortOrder,
281
+ })),
282
+ quotaOverrides: { ...props.draft.quotaOverrides } as Record<string, number>,
283
+ monthlyNet: props.draft.monthlyNet,
284
+ yearlyNet: props.draft.yearlyNet,
285
+ marketed: props.draft.marketed,
286
+ changeNote: props.draft.changeNote,
287
+ };
288
+ }
289
+ return {
290
+ bundles: [],
291
+ quotaOverrides: {},
292
+ monthlyNet: null,
293
+ yearlyNet: null,
294
+ marketed: true,
295
+ changeNote: '',
296
+ };
297
+ }
298
+
299
+ function close(): void {
300
+ open.value = false;
301
+ }
302
+
303
+ function addBundle(): void {
304
+ const used = new Set(form.value.bundles.map((b) => b.bundleVersionId));
305
+ const next = props.availableBundles.find((bv) => !used.has(bv.id));
306
+ if (!next) return;
307
+ form.value.bundles = [
308
+ ...form.value.bundles,
309
+ { bundleVersionId: next.id, sortOrder: form.value.bundles.length },
310
+ ];
311
+ }
312
+
313
+ function updateBundleAt(idx: number, bundleVersionId: string): void {
314
+ form.value.bundles = form.value.bundles.map((b, i) =>
315
+ i === idx ? { ...b, bundleVersionId } : b,
316
+ );
317
+ }
318
+
319
+ function removeBundle(idx: number): void {
320
+ form.value.bundles = form.value.bundles
321
+ .filter((_, i) => i !== idx)
322
+ .map((b, i) => ({ ...b, sortOrder: i }));
323
+ }
324
+
325
+ function moveBundle(idx: number, delta: number): void {
326
+ const newIdx = idx + delta;
327
+ if (newIdx < 0 || newIdx >= form.value.bundles.length) return;
328
+ const next = [...form.value.bundles];
329
+ [next[idx], next[newIdx]] = [next[newIdx], next[idx]];
330
+ form.value.bundles = next.map((b, i) => ({ ...b, sortOrder: i }));
331
+ }
332
+
333
+ function addQuota(): void {
334
+ const newKey = `newQuota${Object.keys(form.value.quotaOverrides).length + 1}`;
335
+ form.value.quotaOverrides = { ...form.value.quotaOverrides, [newKey]: 0 };
336
+ }
337
+
338
+ function removeQuota(key: string): void {
339
+ const next = { ...form.value.quotaOverrides };
340
+ delete next[key];
341
+ form.value.quotaOverrides = next;
342
+ }
343
+
344
+ function renameQuota(oldKey: string | number, newKey: string): void {
345
+ if (!newKey || newKey === oldKey) return;
346
+ const next: Record<string, number> = {};
347
+ for (const [k, v] of Object.entries(form.value.quotaOverrides)) {
348
+ next[k === String(oldKey) ? newKey : k] = v;
349
+ }
350
+ form.value.quotaOverrides = next;
351
+ }
352
+
353
+ async function submit(): Promise<void> {
354
+ submitting.value = true;
355
+ try {
356
+ const payload = {
357
+ bundles: form.value.bundles,
358
+ quotaOverrides: form.value.quotaOverrides,
359
+ monthlyNet: form.value.monthlyNet || null,
360
+ yearlyNet: form.value.yearlyNet || null,
361
+ marketed: form.value.marketed,
362
+ changeNote: form.value.changeNote,
363
+ };
364
+ const result = await props.submit(payload);
365
+ lastWarnings.value = result.warnings;
366
+ emit('submitted', result);
367
+ if (result.warnings.length === 0) {
368
+ close();
369
+ }
370
+ } finally {
371
+ submitting.value = false;
372
+ }
373
+ }
374
+ </script>
375
+
376
+ <style scoped>
377
+ .bvt__body {
378
+ display: flex;
379
+ flex-direction: column;
380
+ gap: 16px;
381
+ }
382
+ .bvt__label {
383
+ font-size: 12px;
384
+ color: var(--q-grey-7, #757575);
385
+ text-transform: uppercase;
386
+ letter-spacing: 0.5px;
387
+ margin-bottom: 4px;
388
+ }
389
+ .bvt__quotas {
390
+ display: flex;
391
+ flex-direction: column;
392
+ gap: 8px;
393
+ }
394
+ .bvt__quota-row {
395
+ display: grid;
396
+ grid-template-columns: 1fr 1fr auto;
397
+ gap: 8px;
398
+ align-items: center;
399
+ }
400
+ .bvt__pricing {
401
+ display: grid;
402
+ grid-template-columns: 1fr 1fr;
403
+ gap: 12px;
404
+ }
405
+ .bvt__warn {
406
+ border-left: 4px solid var(--q-warning, #f2c037);
407
+ margin: 8px 0;
408
+ }
409
+ .bvt__warnings {
410
+ border-left: 4px solid var(--q-warning, #f2c037);
411
+ }
412
+ .bvt__warnings-list {
413
+ margin: 8px 0 0;
414
+ padding-left: 20px;
415
+ font-size: 13px;
416
+ }
417
+ </style>
@@ -0,0 +1,258 @@
1
+ <template>
2
+ <q-dialog v-model="open" persistent>
3
+ <q-card style="min-width: 600px; max-width: 96vw">
4
+ <q-card-section>
5
+ <div class="text-h6">BusinessTypeVersion publishen</div>
6
+ <p class="text-caption text-grey-7">
7
+ BusinessType <code>{{ businessTypeKey }}</code> · v{{ draft.version }}
8
+ </p>
9
+ </q-card-section>
10
+
11
+ <q-card-section v-if="!previewLoaded" class="bvtp__loading">
12
+ <q-spinner color="primary" size="32px" />
13
+ <span class="q-ml-sm">Diff wird geladen …</span>
14
+ </q-card-section>
15
+
16
+ <q-card-section v-else class="bvtp__body">
17
+ <q-banner v-if="warnings.length > 0" class="bvtp__warnings" inline-actions rounded>
18
+ <template #avatar>
19
+ <q-icon name="warning" color="warning" />
20
+ </template>
21
+ <strong>{{ warnings.length }} Strict-Mode-Warnung(en)</strong>
22
+ <ul class="bvtp__list">
23
+ <li v-for="(w, i) in warnings" :key="i">
24
+ <code>{{ w.code }}</code>
25
+ <template v-if="w.value">
26
+ · <code>{{ w.value }}</code></template
27
+ >
28
+ — {{ w.message }}
29
+ </li>
30
+ </ul>
31
+ </q-banner>
32
+
33
+ <div>
34
+ <div class="bvtp__label">
35
+ Änderungen gegenüber
36
+ <span v-if="previousVersion">v{{ previousVersion }}</span>
37
+ <span v-else class="text-grey-7">
38
+ — keine Vorgänger-Version (Erst-Veröffentlichung)
39
+ </span>
40
+ </div>
41
+ <q-banner
42
+ v-if="changes.length === 0 && previousVersion"
43
+ class="bvtp__neutral"
44
+ inline-actions
45
+ rounded
46
+ >
47
+ <template #avatar>
48
+ <q-icon name="info" color="grey-7" />
49
+ </template>
50
+ Keine Änderungen — die Versionen sind inhaltlich gleich.
51
+ </q-banner>
52
+ <q-list v-else-if="changes.length > 0" bordered separator>
53
+ <q-item v-for="(c, i) in changes" :key="i">
54
+ <q-item-section side>
55
+ <q-chip
56
+ dense
57
+ :color="directionColor(c.direction)"
58
+ text-color="white"
59
+ >
60
+ {{ directionLabel(c.direction) }}
61
+ </q-chip>
62
+ </q-item-section>
63
+ <q-item-section>
64
+ <q-item-label>
65
+ <code>{{ c.field }}</code>
66
+ </q-item-label>
67
+ <q-item-label caption>
68
+ <span class="bvtp__old">{{ formatValue(c.oldValue) }}</span>
69
+ <q-icon name="arrow_forward" size="14px" class="q-mx-xs" />
70
+ <span class="bvtp__new">{{ formatValue(c.newValue) }}</span>
71
+ </q-item-label>
72
+ </q-item-section>
73
+ </q-item>
74
+ </q-list>
75
+ </div>
76
+
77
+ <q-banner v-if="isRegression" class="bvtp__regression" inline-actions rounded>
78
+ <template #avatar>
79
+ <q-icon name="error" color="negative" />
80
+ </template>
81
+ <strong>Regressive Änderung erkannt.</strong>
82
+ Diese Version entfernt Bundles, senkt Quota-Overrides oder erhöht Preise.
83
+ Vertragsschutz P3 verlangt Bestand-Opt-in.
84
+ <template #action>
85
+ <q-toggle
86
+ v-model="forceRegressive"
87
+ label="Trotzdem publishen"
88
+ color="negative"
89
+ />
90
+ </template>
91
+ </q-banner>
92
+ </q-card-section>
93
+
94
+ <q-card-actions align="right">
95
+ <q-btn flat label="Abbrechen" @click="close" />
96
+ <q-btn
97
+ unelevated
98
+ :color="isRegression ? 'negative' : 'positive'"
99
+ :label="isRegression ? 'Regressiv publishen' : 'Publishen'"
100
+ :loading="submitting"
101
+ :disable="isRegression && !forceRegressive"
102
+ @click="submit"
103
+ />
104
+ </q-card-actions>
105
+ </q-card>
106
+ </q-dialog>
107
+ </template>
108
+
109
+ <script setup lang="ts">
110
+ import { computed, ref, watch } from 'vue';
111
+ import type {
112
+ BusinessTypeVersionMutationResult,
113
+ BusinessTypeVersionRow,
114
+ StrictModeWarning,
115
+ VersionChange,
116
+ } from '@saasicat/types';
117
+
118
+ const props = defineProps<{
119
+ modelValue: boolean;
120
+ businessTypeKey: string;
121
+ draft: BusinessTypeVersionRow;
122
+ previous: BusinessTypeVersionRow | null;
123
+ warnings: StrictModeWarning[];
124
+ /**
125
+ * Pure-function diff classifier for BusinessTypeVersion. The consumer
126
+ * wrapper imports `classifyBusinessTypeVersionDiff` from
127
+ * `@saasicat/types` and passes it through.
128
+ */
129
+ classifyDiff: (
130
+ previous: BusinessTypeVersionRow,
131
+ draft: BusinessTypeVersionRow,
132
+ ) => { changes: VersionChange[]; nonRegressive: boolean };
133
+ submit: (opts: { forceRegressive: boolean }) => Promise<BusinessTypeVersionMutationResult>;
134
+ }>();
135
+
136
+ const emit = defineEmits<{
137
+ (e: 'update:modelValue', value: boolean): void;
138
+ (e: 'submitted', result: BusinessTypeVersionMutationResult): void;
139
+ }>();
140
+
141
+ const open = computed({
142
+ get: () => props.modelValue,
143
+ set: (v) => emit('update:modelValue', v),
144
+ });
145
+
146
+ const previewLoaded = ref(false);
147
+ const changes = ref<VersionChange[]>([]);
148
+ const nonRegressive = ref(true);
149
+ const forceRegressive = ref(false);
150
+ const submitting = ref(false);
151
+
152
+ const previousVersion = computed(() => props.previous?.version ?? null);
153
+ const isRegression = computed(() => previewLoaded.value && !nonRegressive.value);
154
+
155
+ watch(
156
+ () => [props.modelValue, props.draft, props.previous],
157
+ () => {
158
+ if (!props.modelValue) return;
159
+ previewLoaded.value = false;
160
+ forceRegressive.value = false;
161
+ try {
162
+ const diff = props.previous
163
+ ? props.classifyDiff(props.previous, props.draft)
164
+ : { changes: [], nonRegressive: true };
165
+ changes.value = diff.changes;
166
+ nonRegressive.value = diff.nonRegressive;
167
+ } finally {
168
+ previewLoaded.value = true;
169
+ }
170
+ },
171
+ { immediate: true },
172
+ );
173
+
174
+ function close(): void {
175
+ open.value = false;
176
+ }
177
+
178
+ async function submit(): Promise<void> {
179
+ submitting.value = true;
180
+ try {
181
+ const result = await props.submit({ forceRegressive: forceRegressive.value });
182
+ emit('submitted', result);
183
+ close();
184
+ } finally {
185
+ submitting.value = false;
186
+ }
187
+ }
188
+
189
+ function directionColor(d: VersionChange['direction']): string {
190
+ switch (d) {
191
+ case 'IMPROVEMENT':
192
+ return 'positive';
193
+ case 'REGRESSION':
194
+ return 'negative';
195
+ default:
196
+ return 'grey-7';
197
+ }
198
+ }
199
+ function directionLabel(d: VersionChange['direction']): string {
200
+ switch (d) {
201
+ case 'IMPROVEMENT':
202
+ return '+';
203
+ case 'REGRESSION':
204
+ return '−';
205
+ default:
206
+ return '○';
207
+ }
208
+ }
209
+ function formatValue(v: unknown): string {
210
+ if (v === null || v === undefined) return '—';
211
+ if (Array.isArray(v)) return v.length === 0 ? '[]' : v.join(', ');
212
+ if (typeof v === 'object') return JSON.stringify(v);
213
+ return String(v);
214
+ }
215
+ </script>
216
+
217
+ <style scoped>
218
+ .bvtp__body {
219
+ display: flex;
220
+ flex-direction: column;
221
+ gap: 16px;
222
+ }
223
+ .bvtp__label {
224
+ font-size: 12px;
225
+ color: var(--q-grey-7, #757575);
226
+ text-transform: uppercase;
227
+ letter-spacing: 0.5px;
228
+ margin-bottom: 6px;
229
+ }
230
+ .bvtp__loading {
231
+ display: flex;
232
+ align-items: center;
233
+ justify-content: center;
234
+ padding: 32px;
235
+ }
236
+ .bvtp__warnings {
237
+ border-left: 4px solid var(--q-warning, #f2c037);
238
+ }
239
+ .bvtp__neutral {
240
+ border-left: 4px solid var(--q-grey-7, #757575);
241
+ }
242
+ .bvtp__regression {
243
+ border-left: 4px solid var(--q-negative, #c10015);
244
+ }
245
+ .bvtp__list {
246
+ margin: 8px 0 0;
247
+ padding-left: 20px;
248
+ font-size: 13px;
249
+ }
250
+ .bvtp__old {
251
+ color: var(--q-negative, #c10015);
252
+ text-decoration: line-through;
253
+ }
254
+ .bvtp__new {
255
+ color: var(--q-positive, #21ba45);
256
+ font-weight: 600;
257
+ }
258
+ </style>