@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,742 @@
1
+ <template>
2
+ <div class="pr">
3
+ <!-- Header -->
4
+ <div class="pr-head">
5
+ <div class="pr-head-text">
6
+ <h2 class="pr-title">Review &amp; Publish</h2>
7
+ <p class="pr-sub">
8
+ Letzte Prüfung, bevor
9
+ <code class="pr-mono">{{ plan.planKey }}@v{{ version.version }}</code>
10
+ live geht.
11
+ </p>
12
+ </div>
13
+ <div class="pr-head-actions">
14
+ <button
15
+ class="pr-btn"
16
+ type="button"
17
+ :disabled="publishing || saving"
18
+ @click="$emit('back')"
19
+ >
20
+ <span class="pr-ico pr-ico--back" aria-hidden="true">
21
+ <svg
22
+ width="14"
23
+ height="14"
24
+ viewBox="0 0 24 24"
25
+ fill="none"
26
+ stroke="currentColor"
27
+ stroke-width="2"
28
+ >
29
+ <path d="M5 12h14M13 5l7 7-7 7" />
30
+ </svg>
31
+ </span>
32
+ <span>Zurück</span>
33
+ </button>
34
+ <button
35
+ class="pr-btn"
36
+ type="button"
37
+ :disabled="publishing || saving"
38
+ @click="$emit('saveAndExit')"
39
+ >
40
+ {{ saving ? 'Wird gespeichert…' : 'Als Draft speichern' }}
41
+ </button>
42
+ <button
43
+ class="pr-btn pr-btn--primary"
44
+ type="button"
45
+ :disabled="!canPublish || publishing || saving"
46
+ :title="canPublish ? undefined : 'Publish-Checkliste noch nicht vollständig'"
47
+ @click="onPublish"
48
+ >
49
+ <svg
50
+ width="14"
51
+ height="14"
52
+ viewBox="0 0 24 24"
53
+ fill="none"
54
+ stroke="currentColor"
55
+ stroke-width="2"
56
+ >
57
+ <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
58
+ </svg>
59
+ <span>{{
60
+ publishing ? 'Wird veröffentlicht…' : `Publish v${version.version}`
61
+ }}</span>
62
+ </button>
63
+ </div>
64
+ </div>
65
+
66
+ <div v-if="publishError" class="pr-banner">{{ publishError }}</div>
67
+
68
+ <div class="pr-grid">
69
+ <!-- Left column -->
70
+ <div class="pr-col">
71
+ <div class="pr-card">
72
+ <h3 class="pr-card-title">Stammdaten</h3>
73
+ <div class="pr-row">
74
+ <div class="pr-label">Plan-Key</div>
75
+ <div class="pr-val pr-mono">{{ plan.planKey }}</div>
76
+ </div>
77
+ <div class="pr-row">
78
+ <div class="pr-label">Anzeigename</div>
79
+ <div class="pr-val">{{ plan.label }}</div>
80
+ </div>
81
+ <div class="pr-row">
82
+ <div class="pr-label">Beschreibung</div>
83
+ <div class="pr-val">
84
+ <template v-if="plan.description">{{ plan.description }}</template>
85
+ <i v-else class="pr-missing">fehlt</i>
86
+ </div>
87
+ </div>
88
+ </div>
89
+
90
+ <div class="pr-card">
91
+ <h3 class="pr-card-title">
92
+ Version v{{ version.version }} · Pricing &amp; Sichtbarkeit
93
+ </h3>
94
+ <div class="pr-row">
95
+ <div class="pr-label">Gültig ab</div>
96
+ <div class="pr-val">
97
+ <template v-if="version.validFrom">{{
98
+ version.validFrom.slice(0, 10)
99
+ }}</template>
100
+ <i v-else class="pr-missing">fehlt — beim Publish Pflicht</i>
101
+ </div>
102
+ </div>
103
+ <div class="pr-row">
104
+ <div class="pr-label">Gültig bis</div>
105
+ <div class="pr-val">
106
+ <template v-if="version.validUntil">{{
107
+ version.validUntil.slice(0, 10)
108
+ }}</template>
109
+ <span v-else class="pr-inf">∞ unbegrenzt</span>
110
+ </div>
111
+ </div>
112
+ <div class="pr-row">
113
+ <div class="pr-label">Preis monatlich</div>
114
+ <div class="pr-val">
115
+ <template v-if="hasMonthly">{{
116
+ formatMoney(version.monthlyNet)
117
+ }}</template>
118
+ <i v-else class="pr-missing">fehlt</i>
119
+ </div>
120
+ </div>
121
+ <div class="pr-row">
122
+ <div class="pr-label">Preis jährlich</div>
123
+ <div class="pr-val">{{ formatMoney(version.yearlyNet) }}</div>
124
+ </div>
125
+ <div class="pr-row">
126
+ <div class="pr-label">Public-Catalog</div>
127
+ <div class="pr-val">
128
+ <span v-if="version.marketed" class="pr-chip pr-chip--live"
129
+ >Im Catalog sichtbar</span
130
+ >
131
+ <span v-else class="pr-chip pr-chip--muted">Ausgeblendet</span>
132
+ </div>
133
+ </div>
134
+ </div>
135
+
136
+ <div class="pr-card">
137
+ <h3 class="pr-card-title">Komponenten · {{ componentTotal }} insgesamt</h3>
138
+ <div class="pr-components">
139
+ <div class="pr-comp-col">
140
+ <div class="pr-comp-head pr-comp-head--quota">
141
+ Quotas · {{ quotaList.length }}
142
+ </div>
143
+ <div v-for="q in quotaList" :key="q.key" class="pr-comp-item">
144
+ <span>{{ q.label }}</span>
145
+ <b>{{ q.value }}{{ q.unit ? ' ' + q.unit : '' }}</b>
146
+ </div>
147
+ <div v-if="quotaList.length === 0" class="pr-comp-empty">keine</div>
148
+ </div>
149
+ <div class="pr-comp-col">
150
+ <div class="pr-comp-head pr-comp-head--feature">
151
+ Features · {{ featureList.length }}
152
+ </div>
153
+ <div v-for="f in featureList" :key="f.key" class="pr-comp-item">
154
+ <span>{{ f.label }}</span>
155
+ </div>
156
+ <div v-if="featureList.length === 0" class="pr-comp-empty">keine</div>
157
+ </div>
158
+ <div class="pr-comp-col">
159
+ <div class="pr-comp-head pr-comp-head--bundle">
160
+ Bundles · {{ bundleList.length }}
161
+ </div>
162
+ <div v-for="b in bundleList" :key="b.key" class="pr-comp-item">
163
+ <span>{{ b.label }}</span>
164
+ </div>
165
+ <div v-if="bundleList.length === 0" class="pr-comp-empty">keine</div>
166
+ </div>
167
+ </div>
168
+ </div>
169
+ </div>
170
+
171
+ <!-- Right column -->
172
+ <div class="pr-col">
173
+ <div class="pr-card">
174
+ <h3 class="pr-card-title">Change-Note <span class="pr-req">*</span></h3>
175
+ <p class="pr-card-hint">
176
+ Wird im Audit-Log gespeichert und mit der Versions-History angezeigt.
177
+ </p>
178
+ <div v-if="version.changeNote" class="pr-note">{{ version.changeNote }}</div>
179
+ <div v-else class="pr-note pr-note--missing">
180
+ Keine Change-Note — im Editor nachtragen (Pflicht beim Publish,
181
+ Vertragsschutz&nbsp;P3).
182
+ </div>
183
+ </div>
184
+
185
+ <div class="pr-card">
186
+ <h3 class="pr-card-title">Publish-Checkliste</h3>
187
+ <div class="pr-checks">
188
+ <div
189
+ v-for="c in checks"
190
+ :key="c.id"
191
+ class="pr-check"
192
+ :class="c.ok ? 'pr-check--ok' : 'pr-check--warn'"
193
+ >
194
+ <span class="pr-check-mark">
195
+ <svg
196
+ v-if="c.ok"
197
+ width="11"
198
+ height="11"
199
+ viewBox="0 0 24 24"
200
+ fill="none"
201
+ stroke="currentColor"
202
+ stroke-width="3.5"
203
+ >
204
+ <path d="M5 13l4 4L19 7" />
205
+ </svg>
206
+ <svg
207
+ v-else
208
+ width="11"
209
+ height="11"
210
+ viewBox="0 0 24 24"
211
+ fill="none"
212
+ stroke="currentColor"
213
+ stroke-width="2.5"
214
+ >
215
+ <path
216
+ d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0zM12 9v4M12 17h.01"
217
+ />
218
+ </svg>
219
+ </span>
220
+ <span>{{ c.label }}</span>
221
+ </div>
222
+ </div>
223
+ </div>
224
+
225
+ <div v-if="predecessor" class="pr-card pr-card--regress">
226
+ <h3 class="pr-card-title">Vertragsschutz P3 — Regression</h3>
227
+ <p class="pr-card-hint">
228
+ Wenn diese Version regressiv ist (Feature entfernt, Quota gesenkt, Preis
229
+ erhöht), blockiert das Backend den Publish. Mit Force-Publish wird die
230
+ Regression bewusst freigegeben und löst die Notification-Welle aus.
231
+ </p>
232
+ <label class="pr-toggle">
233
+ <input v-model="forceRegressive" type="checkbox" />
234
+ <span>Force-Publish auch bei Regression</span>
235
+ </label>
236
+ </div>
237
+
238
+ <div class="pr-card">
239
+ <h3 class="pr-card-title">Preis 0,00</h3>
240
+ <p class="pr-card-hint">
241
+ Standardmäßig blockt das Backend einen Publish mit Preis 0,00
242
+ (Schutz gegen Seed-Platzhalter). Für bewusst kostenlose
243
+ Sonderverträge hier freigeben.
244
+ </p>
245
+ <label class="pr-toggle">
246
+ <input v-model="allowZeroPrice" type="checkbox" />
247
+ <span>Preis 0,00 bewusst zulassen</span>
248
+ </label>
249
+ </div>
250
+
251
+ <div class="pr-card pr-card--impact">
252
+ <h3 class="pr-card-title">Tenant-Impact bei Publish</h3>
253
+ <div class="pr-impact">
254
+ <div class="pr-impact-num">{{ tenantImpactCount }}</div>
255
+ <div class="pr-impact-text">
256
+ <template v-if="tenantImpactCount === 0">
257
+ Aktuell sind keine Mandanten betroffen. Sobald die Version live ist,
258
+ können Mandanten den Plan im Catalog buchen.
259
+ </template>
260
+ <template v-else>
261
+ {{ tenantImpactCount }} Mandant(en) auf der aktuellen Live-Version
262
+ bleiben per Bestandsschutz (P1) auf ihrer Version — neue Buchungen
263
+ laufen auf v{{ version.version }}.
264
+ </template>
265
+ </div>
266
+ </div>
267
+ </div>
268
+ </div>
269
+ </div>
270
+ </div>
271
+ </template>
272
+
273
+ <script setup lang="ts">
274
+ import { computed, ref } from 'vue';
275
+ import type { PlanRow, PlanVersionRow } from '@saasicat/types';
276
+
277
+ // PlanReview — step 3 of the plan wizard (SPEC_V2 §6, plan simulation
278
+ // "Review & Publish"). Shows the saved draft read-only, checks the
279
+ // publish checklist and triggers publish. The draft was already persisted
280
+ // in the editor (step 2); review mutates nothing except publish.
281
+
282
+ interface DiscoveryQuota {
283
+ quotaKey: string;
284
+ label?: string | null;
285
+ unit?: string | null;
286
+ }
287
+ interface BundleEntry {
288
+ bundleKey: string;
289
+ label?: string | null;
290
+ features: string[];
291
+ }
292
+ interface FeatureMeta {
293
+ label?: string;
294
+ }
295
+
296
+ const props = withDefaults(
297
+ defineProps<{
298
+ plan: PlanRow;
299
+ /** The draft to be reviewed (publishedAt === null). */
300
+ version: PlanVersionRow;
301
+ /** Current live version being published against (null for v1). */
302
+ predecessor?: PlanVersionRow | null;
303
+ availableQuotas?: DiscoveryQuota[];
304
+ availableBundles?: BundleEntry[];
305
+ featureRegistry?: Record<string, FeatureMeta>;
306
+ /** Tenants on the current live version (for the impact card). */
307
+ tenantImpactCount?: number;
308
+ /** "Save as draft" in progress. */
309
+ saving?: boolean;
310
+ /** "Publish" in progress. */
311
+ publishing?: boolean;
312
+ /** Error text of a review action (save or publish). */
313
+ publishError?: string | null;
314
+ }>(),
315
+ {
316
+ predecessor: null,
317
+ availableQuotas: () => [],
318
+ availableBundles: () => [],
319
+ featureRegistry: () => ({}),
320
+ tenantImpactCount: 0,
321
+ saving: false,
322
+ publishing: false,
323
+ publishError: null,
324
+ },
325
+ );
326
+
327
+ const emit = defineEmits<{
328
+ (e: 'back'): void;
329
+ (e: 'saveAndExit'): void;
330
+ (e: 'publish', payload: { forceRegressive: boolean; allowZeroPrice: boolean }): void;
331
+ }>();
332
+
333
+ const forceRegressive = ref(false);
334
+ const allowZeroPrice = ref(false);
335
+
336
+ function quotasOf(v: PlanVersionRow): Record<string, number> {
337
+ if (v.quotas && Object.keys(v.quotas).length > 0) return v.quotas;
338
+ const legacy: Record<string, number> = {};
339
+ if (typeof v.maxUsers === 'number') legacy.users = v.maxUsers;
340
+ if (typeof v.maxVehicles === 'number') legacy.vehicles = v.maxVehicles;
341
+ if (typeof v.maxStorageGb === 'number') legacy.storageGb = v.maxStorageGb;
342
+ return legacy;
343
+ }
344
+
345
+ const quotaList = computed(() =>
346
+ Object.entries(quotasOf(props.version))
347
+ .sort(([a], [b]) => a.localeCompare(b))
348
+ .map(([key, value]) => {
349
+ const def = props.availableQuotas.find((q) => q.quotaKey === key);
350
+ return { key, value, label: def?.label || key, unit: def?.unit || '' };
351
+ }),
352
+ );
353
+
354
+ const featureList = computed(() =>
355
+ [...props.version.features].sort().map((key) => ({
356
+ key,
357
+ label: props.featureRegistry[key]?.label ?? key,
358
+ })),
359
+ );
360
+
361
+ const bundleList = computed(() =>
362
+ [...(props.version.bundles ?? [])].sort().map((key) => {
363
+ const def = props.availableBundles.find((b) => b.bundleKey === key);
364
+ return { key, label: def?.label || key };
365
+ }),
366
+ );
367
+
368
+ const componentTotal = computed(
369
+ () => quotaList.value.length + featureList.value.length + bundleList.value.length,
370
+ );
371
+
372
+ // Valid price — even 0 € (free plan) counts as set; only an
373
+ // invalid/empty value counts as "missing".
374
+ const hasMonthly = computed(() => {
375
+ const n = Number(props.version.monthlyNet);
376
+ return props.version.monthlyNet !== '' && Number.isFinite(n) && n >= 0;
377
+ });
378
+
379
+ // "Gültig ab" must lie strictly after the "Gültig ab" of the predecessor
380
+ // version (SPEC_V2 §4.2.1) — otherwise the publish endpoint rejects.
381
+ const validFromAfterPredecessor = computed(() => {
382
+ const prev = props.predecessor;
383
+ if (!prev?.validFrom || !props.version.validFrom) return true;
384
+ return props.version.validFrom.slice(0, 10) > prev.validFrom.slice(0, 10);
385
+ });
386
+
387
+ function formatMoney(raw: string | number): string {
388
+ const num = typeof raw === 'string' ? Number(raw) : raw;
389
+ if (!Number.isFinite(num)) return String(raw);
390
+ if (num === 0) return 'Kostenlos';
391
+ if (Number.isInteger(num)) return `${num} €`;
392
+ return `${num.toFixed(2).replace('.', ',')} €`;
393
+ }
394
+
395
+ interface Check {
396
+ id: string;
397
+ label: string;
398
+ ok: boolean;
399
+ }
400
+
401
+ const checks = computed<Check[]>(() => [
402
+ { id: 'key', label: 'Plan-Key vergeben', ok: Boolean(props.plan.planKey) },
403
+ { id: 'label', label: 'Anzeigename vorhanden', ok: Boolean(props.plan.label) },
404
+ {
405
+ id: 'quota',
406
+ label: 'Mindestens 1 Quota zugewiesen',
407
+ ok: quotaList.value.length > 0,
408
+ },
409
+ {
410
+ id: 'feature',
411
+ label: 'Mindestens 1 Feature zugewiesen',
412
+ ok: featureList.value.length > 0,
413
+ },
414
+ {
415
+ id: 'validFrom',
416
+ label: '„Gültig ab" gesetzt (Pflicht beim Publish)',
417
+ ok: Boolean(props.version.validFrom),
418
+ },
419
+ {
420
+ id: 'validFromOrder',
421
+ label: props.predecessor
422
+ ? `„Gültig ab" liegt nach Vorgänger v${props.predecessor.version}`
423
+ : '„Gültig ab" liegt nach der Vorgänger-Version',
424
+ ok: validFromAfterPredecessor.value,
425
+ },
426
+ {
427
+ id: 'changeNote',
428
+ label: 'Change-Note vorhanden (Vertragsschutz P3)',
429
+ ok: Boolean(props.version.changeNote && props.version.changeNote.trim()),
430
+ },
431
+ ]);
432
+
433
+ const canPublish = computed(() => checks.value.every((c) => c.ok));
434
+
435
+ function onPublish(): void {
436
+ if (!canPublish.value || props.publishing || props.saving) return;
437
+ emit('publish', {
438
+ forceRegressive: forceRegressive.value,
439
+ allowZeroPrice: allowZeroPrice.value,
440
+ });
441
+ }
442
+ </script>
443
+
444
+ <style scoped>
445
+ .pr {
446
+ padding: 22px 26px;
447
+ background: var(--sa-bg-app);
448
+ min-height: 100%;
449
+ box-sizing: border-box;
450
+ font-family: var(--sa-font-body);
451
+ color: var(--sa-body);
452
+ }
453
+ .pr * {
454
+ box-sizing: border-box;
455
+ }
456
+ .pr-mono {
457
+ font-family: var(--sa-font-mono);
458
+ font-weight: 600;
459
+ }
460
+
461
+ .pr-head {
462
+ display: flex;
463
+ align-items: flex-end;
464
+ gap: 20px;
465
+ margin-bottom: 16px;
466
+ }
467
+ .pr-head-text {
468
+ flex: 1;
469
+ min-width: 0;
470
+ }
471
+ .pr-title {
472
+ font: 700 22px/1.2 var(--sa-font-head);
473
+ letter-spacing: -0.02em;
474
+ color: var(--sa-heading);
475
+ margin: 0;
476
+ }
477
+ .pr-sub {
478
+ font-size: 12.5px;
479
+ color: var(--sa-muted);
480
+ margin: 4px 0 0;
481
+ }
482
+ .pr-head-actions {
483
+ display: flex;
484
+ gap: 8px;
485
+ }
486
+
487
+ .pr-btn {
488
+ display: inline-flex;
489
+ align-items: center;
490
+ gap: 7px;
491
+ padding: 8px 14px;
492
+ border-radius: 7px;
493
+ font: 500 13px var(--sa-font-body);
494
+ cursor: pointer;
495
+ border: 1px solid var(--sa-border);
496
+ background: var(--sa-bg-surface);
497
+ color: var(--sa-heading);
498
+ transition:
499
+ background 0.12s,
500
+ border-color 0.12s;
501
+ }
502
+ .pr-btn:hover:not(:disabled) {
503
+ background: var(--sa-border-soft);
504
+ }
505
+ .pr-btn:disabled {
506
+ cursor: not-allowed;
507
+ opacity: 0.5;
508
+ }
509
+ .pr-btn--primary {
510
+ background: var(--sa-primary);
511
+ border-color: var(--sa-primary);
512
+ color: #fff;
513
+ }
514
+ .pr-btn--primary:hover:not(:disabled) {
515
+ filter: brightness(0.94);
516
+ }
517
+ .pr-ico--back {
518
+ display: inline-flex;
519
+ transform: rotate(180deg);
520
+ }
521
+
522
+ .pr-banner {
523
+ background: var(--sa-warning-soft);
524
+ border: 1px solid var(--sa-amber-border);
525
+ color: var(--sa-warning);
526
+ border-radius: 8px;
527
+ padding: 10px 14px;
528
+ font-size: 12.5px;
529
+ margin-bottom: 14px;
530
+ }
531
+
532
+ .pr-grid {
533
+ display: grid;
534
+ grid-template-columns: 1.2fr 1fr;
535
+ gap: 16px;
536
+ align-items: start;
537
+ }
538
+ .pr-col {
539
+ display: flex;
540
+ flex-direction: column;
541
+ gap: 14px;
542
+ }
543
+ .pr-card {
544
+ background: var(--sa-bg-surface);
545
+ border: 1px solid var(--sa-border);
546
+ border-radius: 10px;
547
+ padding: 16px 20px;
548
+ }
549
+ .pr-card--regress {
550
+ background: var(--sa-warning-soft);
551
+ border-color: var(--sa-amber-border);
552
+ }
553
+ .pr-card--impact {
554
+ background: linear-gradient(180deg, var(--sa-primary-soft) 0%, var(--sa-bg-surface) 100%);
555
+ border-color: var(--sa-primary-border);
556
+ }
557
+ .pr-card-title {
558
+ font: 700 14px var(--sa-font-head);
559
+ letter-spacing: -0.01em;
560
+ color: var(--sa-heading);
561
+ margin: 0 0 12px;
562
+ }
563
+ .pr-req {
564
+ color: var(--sa-negative);
565
+ }
566
+ .pr-card-hint {
567
+ font-size: 11.5px;
568
+ color: var(--sa-muted);
569
+ margin: -6px 0 10px;
570
+ line-height: 1.5;
571
+ }
572
+
573
+ .pr-row {
574
+ display: flex;
575
+ align-items: baseline;
576
+ gap: 12px;
577
+ padding: 8px 0;
578
+ border-bottom: 1px solid var(--sa-border-soft);
579
+ }
580
+ .pr-row:last-child {
581
+ border-bottom: 0;
582
+ }
583
+ .pr-label {
584
+ font-size: 12px;
585
+ color: var(--sa-muted);
586
+ min-width: 130px;
587
+ }
588
+ .pr-val {
589
+ font-size: 13px;
590
+ color: var(--sa-heading);
591
+ font-weight: 500;
592
+ }
593
+ .pr-missing {
594
+ color: var(--sa-muted);
595
+ font-weight: 400;
596
+ }
597
+ .pr-inf {
598
+ color: var(--sa-positive);
599
+ font-weight: 600;
600
+ }
601
+
602
+ .pr-chip {
603
+ display: inline-flex;
604
+ align-items: center;
605
+ padding: 2px 9px;
606
+ border-radius: 999px;
607
+ font-size: 11px;
608
+ font-weight: 600;
609
+ border: 1px solid;
610
+ }
611
+ .pr-chip--live {
612
+ background: var(--sa-positive-soft);
613
+ color: var(--sa-positive);
614
+ border-color: rgba(4, 120, 87, 0.3);
615
+ }
616
+ .pr-chip--muted {
617
+ background: var(--sa-border-soft);
618
+ color: var(--sa-muted);
619
+ border-color: var(--sa-border);
620
+ }
621
+
622
+ .pr-components {
623
+ display: grid;
624
+ grid-template-columns: repeat(3, 1fr);
625
+ gap: 14px;
626
+ }
627
+ .pr-comp-head {
628
+ font-size: 11px;
629
+ text-transform: uppercase;
630
+ letter-spacing: 0.08em;
631
+ font-weight: 700;
632
+ margin-bottom: 6px;
633
+ }
634
+ .pr-comp-head--quota {
635
+ color: #0ea5e9;
636
+ }
637
+ .pr-comp-head--feature {
638
+ color: #8b5cf6;
639
+ }
640
+ .pr-comp-head--bundle {
641
+ color: var(--sa-amber);
642
+ }
643
+ .pr-comp-item {
644
+ display: flex;
645
+ justify-content: space-between;
646
+ gap: 6px;
647
+ font-size: 12px;
648
+ padding: 3px 0;
649
+ border-bottom: 1px solid var(--sa-border-soft);
650
+ color: var(--sa-body);
651
+ }
652
+ .pr-comp-item b {
653
+ color: var(--sa-heading);
654
+ }
655
+ .pr-comp-empty {
656
+ font-size: 12px;
657
+ color: var(--sa-muted);
658
+ font-style: italic;
659
+ padding: 3px 0;
660
+ }
661
+
662
+ .pr-note {
663
+ font-size: 13px;
664
+ line-height: 1.55;
665
+ color: var(--sa-body);
666
+ background: var(--sa-border-soft);
667
+ border-radius: 7px;
668
+ padding: 10px 12px;
669
+ }
670
+ .pr-note--missing {
671
+ background: var(--sa-warning-soft);
672
+ color: var(--sa-warning);
673
+ }
674
+
675
+ .pr-checks {
676
+ display: flex;
677
+ flex-direction: column;
678
+ gap: 7px;
679
+ }
680
+ .pr-check {
681
+ display: flex;
682
+ align-items: center;
683
+ gap: 9px;
684
+ font-size: 12.5px;
685
+ }
686
+ .pr-check--ok {
687
+ color: var(--sa-heading);
688
+ }
689
+ .pr-check--warn {
690
+ color: var(--sa-warning);
691
+ }
692
+ .pr-check-mark {
693
+ width: 18px;
694
+ height: 18px;
695
+ border-radius: 50%;
696
+ display: grid;
697
+ place-items: center;
698
+ flex: 0 0 auto;
699
+ }
700
+ .pr-check--ok .pr-check-mark {
701
+ background: var(--sa-positive-soft);
702
+ color: var(--sa-positive);
703
+ }
704
+ .pr-check--warn .pr-check-mark {
705
+ background: var(--sa-warning-soft);
706
+ color: var(--sa-warning);
707
+ }
708
+
709
+ .pr-toggle {
710
+ display: flex;
711
+ align-items: center;
712
+ gap: 8px;
713
+ font-size: 12.5px;
714
+ color: var(--sa-heading);
715
+ cursor: pointer;
716
+ }
717
+
718
+ .pr-impact {
719
+ display: flex;
720
+ gap: 16px;
721
+ align-items: center;
722
+ }
723
+ .pr-impact-num {
724
+ font: 700 40px/1 var(--sa-font-head);
725
+ letter-spacing: -0.03em;
726
+ color: var(--sa-heading);
727
+ }
728
+ .pr-impact-text {
729
+ font-size: 12.5px;
730
+ color: var(--sa-body);
731
+ line-height: 1.5;
732
+ }
733
+
734
+ @media (max-width: 1100px) {
735
+ .pr-grid {
736
+ grid-template-columns: 1fr;
737
+ }
738
+ .pr-components {
739
+ grid-template-columns: 1fr;
740
+ }
741
+ }
742
+ </style>