@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.
- package/LICENSE +202 -0
- package/README.md +47 -0
- package/dist/index.cjs +3776 -0
- package/dist/index.d.cts +2395 -0
- package/dist/index.d.ts +2395 -0
- package/dist/index.js +3659 -0
- package/dist/testing-e2e/admin-pages-suite.cjs +134 -0
- package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
- package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
- package/dist/testing-e2e/admin-pages-suite.js +109 -0
- package/package.json +87 -0
- package/src/action-registry.ts +110 -0
- package/src/batch-column-fetcher.ts +152 -0
- package/src/boot-loader.ts +60 -0
- package/src/components/BundleVersionPublishDialog.vue +359 -0
- package/src/components/BusinessTypeVersionEditorDialog.vue +417 -0
- package/src/components/BusinessTypeVersionPublishDialog.vue +258 -0
- package/src/components/FeatureGate.vue +56 -0
- package/src/components/KpiCard.vue +53 -0
- package/src/components/KvBlock.vue +36 -0
- package/src/components/MarketingPromotionsTab.vue +861 -0
- package/src/components/MfaPromptDialog.vue +104 -0
- package/src/components/TenantActionConfirmDialog.vue +252 -0
- package/src/components/VersionDiffPreview.vue +150 -0
- package/src/components/bundle-editor/BundleCreatePanel.vue +681 -0
- package/src/components/bundle-editor/BundleFeaturesEditor.vue +215 -0
- package/src/components/bundle-editor/BundlePlanCompatPicker.vue +316 -0
- package/src/components/bundle-editor/BundleQuotasEditor.vue +219 -0
- package/src/components/bundle-editor/BundleStatusBanner.vue +189 -0
- package/src/components/bundle-editor/BundleVersionInlineEditor.vue +659 -0
- package/src/components/bundle-editor/BundleVersionStrip.vue +207 -0
- package/src/components/bundle-editor/bundle-version-status.ts +164 -0
- package/src/components/bundle-editor/catalog-i18n.ts +85 -0
- package/src/components/dialogs/PilotCreateDialog.vue +780 -0
- package/src/components/dialogs/PilotEditDialog.vue +548 -0
- package/src/components/dialogs/PromoCodeCreateDialog.vue +737 -0
- package/src/components/dialogs/PromoCodeEditDialog.vue +859 -0
- package/src/components/dialogs/types.ts +126 -0
- package/src/components/plan/PlanCycleToggle.vue +99 -0
- package/src/components/plan/PlanGrid.vue +211 -0
- package/src/components/plan/PriceSummary.vue +293 -0
- package/src/components/plan/PromoCodeInput.vue +192 -0
- package/src/components/plan/PublicBundleGrid.vue +287 -0
- package/src/components/plan-cockpit/PlanCockpit.vue +993 -0
- package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +65 -0
- package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +91 -0
- package/src/components/plan-cockpit/PlanCockpitHeader.vue +98 -0
- package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +99 -0
- package/src/components/plan-cockpit/PlanCockpitKpis.vue +57 -0
- package/src/components/plan-cockpit/PlanCockpitVersions.vue +226 -0
- package/src/components/plan-cockpit/types.ts +72 -0
- package/src/components/plan-create-dialog/PlanCreateDialog.vue +508 -0
- package/src/components/plan-detail/PlanAuditLog.vue +59 -0
- package/src/components/plan-detail/PlanDetail.vue +1195 -0
- package/src/components/plan-detail/PlanDetailHeader.vue +185 -0
- package/src/components/plan-detail/PlanDetailKpis.vue +57 -0
- package/src/components/plan-detail/PlanTerminateDialog.vue +76 -0
- package/src/components/plan-detail/PlanVersionDiffPanel.vue +149 -0
- package/src/components/plan-detail/PlanVersionsPanel.vue +255 -0
- package/src/components/plan-detail/types.ts +59 -0
- package/src/components/plan-list/PlanList.vue +1067 -0
- package/src/components/plan-matrix/PlanMatrix.vue +1113 -0
- package/src/components/plan-review/PlanReview.vue +742 -0
- package/src/components/plan-version-editor/PlanCatalogPreview.vue +184 -0
- package/src/components/plan-version-editor/PlanComponentPool.vue +272 -0
- package/src/components/plan-version-editor/PlanVersionBasket.vue +281 -0
- package/src/components/plan-version-editor/PlanVersionDiffDialog.vue +84 -0
- package/src/components/plan-version-editor/PlanVersionEditor.vue +1728 -0
- package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +94 -0
- package/src/components/plan-version-editor/types.ts +96 -0
- package/src/components/wizard-stepper/WizardStepper.vue +107 -0
- package/src/create-admin-routes.ts +68 -0
- package/src/create-super-admin-app.ts +345 -0
- package/src/entitlement-provider.ts +36 -0
- package/src/feature-router-guard.ts +77 -0
- package/src/http-json.ts +62 -0
- package/src/index.ts +98 -0
- package/src/manifest-loader.ts +139 -0
- package/src/manifest-store-factory.ts +114 -0
- package/src/nav-builder.ts +274 -0
- package/src/pages-standard/AdminLayout.vue +411 -0
- package/src/pages-standard/AdminManifestErrorPage.vue +79 -0
- package/src/pages-standard/AuditPage.vue +223 -0
- package/src/pages-standard/BundlesPage.vue +910 -0
- package/src/pages-standard/BusinessTypesPage.vue +662 -0
- package/src/pages-standard/DashboardPage.vue +595 -0
- package/src/pages-standard/DiscoveryPage.vue +663 -0
- package/src/pages-standard/EmailHistoryPage.vue +576 -0
- package/src/pages-standard/MarketingCatalogPage.vue +1771 -0
- package/src/pages-standard/PilotsPage.vue +577 -0
- package/src/pages-standard/PlanVersionsPage.vue +216 -0
- package/src/pages-standard/PlansPage.vue +1222 -0
- package/src/pages-standard/PlatformEmailPage.vue +434 -0
- package/src/pages-standard/PromoCodeDetailPage.vue +331 -0
- package/src/pages-standard/PromoCodesPage.vue +589 -0
- package/src/pages-standard/SubscriptionsPage.vue +159 -0
- package/src/pages-standard/SuperAdminLoginPage.vue +299 -0
- package/src/pages-standard/SuperAdminSetupWizard.vue +405 -0
- package/src/pages-standard/TenantDetailPage.vue +432 -0
- package/src/pages-standard/TenantsPage.vue +732 -0
- package/src/pages-standard/UsersPage.vue +504 -0
- package/src/pages-standard/bundles-page/BundleAccordionList.vue +116 -0
- package/src/pages-standard/bundles-page/BundleDetailPanel.vue +193 -0
- package/src/pages-standard/bundles-page/BundlesFilterBar.vue +48 -0
- package/src/pages-standard/bundles-page/BundlesHeader.vue +67 -0
- package/src/pages-standard/bundles-page/BundlesKpis.vue +39 -0
- package/src/pages-standard/bundles-page/types.ts +15 -0
- package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +246 -0
- package/src/pages-standard/discovery-page/DiscoveryCapList.vue +171 -0
- package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +410 -0
- package/src/pages-standard/discovery-page/DiscoveryHeader.vue +31 -0
- package/src/pages-standard/discovery-page/DiscoveryKpis.vue +47 -0
- package/src/pages-standard/discovery-page/DiscoveryMetaBanner.vue +26 -0
- package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +247 -0
- package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +73 -0
- package/src/pages-standard/discovery-page/discovery-ui.ts +166 -0
- package/src/pages-standard/email-history.types.ts +48 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +448 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +93 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +138 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +58 -0
- package/src/pages-standard/marketing-catalog/types.ts +43 -0
- package/src/pages-standard/plan-versions/PlanDiffCard.vue +132 -0
- package/src/pages-standard/plan-versions/PlanVersionHeader.vue +293 -0
- package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +48 -0
- package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +307 -0
- package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +269 -0
- package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +65 -0
- package/src/pages-standard/plan-versions/PlanVersionsList.vue +292 -0
- package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +261 -0
- package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +300 -0
- package/src/pages-standard/plan-versions/VersionDiffPreview.vue +167 -0
- package/src/pages-standard/plan-versions/format.ts +53 -0
- package/src/pages-standard/plan-versions/types.ts +33 -0
- package/src/pages-standard/plans-page/PlanArchiveDialog.vue +50 -0
- package/src/pages-standard/plans-page/PlanBundleOverview.vue +147 -0
- package/src/pages-standard/plans-page/PlanDiscardDraftDialog.vue +46 -0
- package/src/pages-standard/plans-page/PlanPublishDialog.vue +106 -0
- package/src/pages-standard/plans-page/PlansPageToast.vue +69 -0
- package/src/pages-standard/plans-page/types.ts +18 -0
- package/src/pages-standard/platform-email.types.ts +41 -0
- package/src/pages-standard/sa-theme.css +223 -0
- package/src/pages-standard/tenants/StatusPill.vue +62 -0
- package/src/pages-standard/tenants/format.ts +38 -0
- package/src/pages-tenant/LimitsRow.vue +67 -0
- package/src/pages-tenant/MySubscriptionBundlesPage.vue +604 -0
- package/src/pages-tenant/OnboardingConfigurator.vue +417 -0
- package/src/pages-tenant/PackageSnapshotPanel.vue +241 -0
- package/src/pages-tenant/PendingVersionBanner.vue +126 -0
- package/src/pages-tenant/PlanChangeWizard.vue +630 -0
- package/src/pages-tenant/TenantPlanSection.vue +729 -0
- package/src/pages-tenant/UsageBar.vue +107 -0
- package/src/pages-tenant/default-i18n.ts +280 -0
- package/src/pages-tenant/tenant-plan-section/BundlePreviewDialog.vue +304 -0
- package/src/pages-tenant/tenant-plan-section/TenantBundleStore.vue +323 -0
- package/src/pages-tenant/tenant-plan-section/TenantFeatureMatrix.vue +122 -0
- package/src/pages-tenant/tenant-plan-section/TenantPlanCardHeader.vue +67 -0
- package/src/pages-tenant/tenant-plan-section/TenantUsageGrid.vue +31 -0
- package/src/plan-versions-catalog.ts +368 -0
- package/src/platform-loaders.ts +68 -0
- package/src/project-page-host.ts +108 -0
- package/src/testing-e2e/admin-pages-suite.ts +187 -0
- package/src/types.ts +58 -0
- package/src/use-actions.ts +25 -0
- package/src/use-api-list.ts +151 -0
- package/src/use-audit-entries.ts +48 -0
- package/src/use-batch-columns.ts +59 -0
- package/src/use-bulk-publish.ts +150 -0
- package/src/use-bundle-versions-map.ts +112 -0
- package/src/use-bundles.ts +297 -0
- package/src/use-business-types.ts +269 -0
- package/src/use-catalog-entries.ts +243 -0
- package/src/use-discovery.ts +158 -0
- package/src/use-entitlement.ts +87 -0
- package/src/use-live-plan-versions.ts +126 -0
- package/src/use-manifest.ts +57 -0
- package/src/use-marketing-projections.ts +160 -0
- package/src/use-nav.ts +33 -0
- package/src/use-plan-editor.ts +162 -0
- package/src/use-plan-versions.ts +83 -0
- package/src/use-plans.ts +375 -0
- package/src/use-platform-tenant-actions.ts +286 -0
- package/src/use-promotions.ts +126 -0
- package/src/use-public-boot.ts +40 -0
- package/src/use-subscription-draft.ts +355 -0
- package/src/use-super-admin-context.ts +93 -0
- package/src/use-tenant-action-flow.ts +238 -0
- package/src/use-tenant-billing-catalog.ts +160 -0
- package/src/use-tenant-billing.ts +453 -0
- package/src/use-tenant-manifest.ts +94 -0
- package/src/use-tenant-subscription-bundles.ts +148 -0
- package/src/use-tenants.ts +61 -0
- package/src/version.ts +10 -0
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<q-page class="sa-plans">
|
|
3
|
+
<q-banner v-if="error" class="bg-negative text-white q-ma-md" rounded>
|
|
4
|
+
<template #avatar>
|
|
5
|
+
<q-icon name="error" />
|
|
6
|
+
</template>
|
|
7
|
+
{{ error.message }}
|
|
8
|
+
<template #action>
|
|
9
|
+
<q-btn flat label="Erneut laden" @click="load" />
|
|
10
|
+
</template>
|
|
11
|
+
</q-banner>
|
|
12
|
+
|
|
13
|
+
<div v-if="loading && plans.length === 0" class="sa-plans__loading">
|
|
14
|
+
<q-spinner size="32px" />
|
|
15
|
+
<span>Pläne werden geladen…</span>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<template v-else>
|
|
19
|
+
<!-- Default: list view (plan simulation) + Bundle overview -->
|
|
20
|
+
<template v-if="mode === 'list'">
|
|
21
|
+
<PlanList
|
|
22
|
+
:plans="plans"
|
|
23
|
+
:versions-by-plan-id="versionsByPlanId"
|
|
24
|
+
:tenant-counts-by-plan-key="tenantCountsByPlanKey"
|
|
25
|
+
:plan-accents="props.planAccents"
|
|
26
|
+
:highlight-plan-key="highlightPlanKey"
|
|
27
|
+
@open-plan="onOpenPlan"
|
|
28
|
+
@create-plan="openCreate"
|
|
29
|
+
@clone-plan="onClonePlan"
|
|
30
|
+
@new-version="onNewVersionFromList"
|
|
31
|
+
@edit-draft="onEditDraftFromList"
|
|
32
|
+
@discard-draft="onDiscardDraftFromList"
|
|
33
|
+
@archive-plan="onArchivePlanFromList"
|
|
34
|
+
@show-matrix="mode = 'matrix'"
|
|
35
|
+
/>
|
|
36
|
+
<PlanBundleOverview
|
|
37
|
+
:bundles="availableBundles"
|
|
38
|
+
:plans="plans"
|
|
39
|
+
:feature-registry="featureRegistry"
|
|
40
|
+
/>
|
|
41
|
+
</template>
|
|
42
|
+
|
|
43
|
+
<!-- V1 matrix: component comparison -->
|
|
44
|
+
<PlanMatrix
|
|
45
|
+
v-else-if="mode === 'matrix'"
|
|
46
|
+
:plans="plans"
|
|
47
|
+
:versions-by-plan-id="versionsByPlanId"
|
|
48
|
+
:available-quotas="availableQuotas"
|
|
49
|
+
:available-bundles="availableBundles"
|
|
50
|
+
:feature-registry="featureRegistry"
|
|
51
|
+
:tenant-counts-by-plan-key="tenantCountsByPlanKey"
|
|
52
|
+
:plan-accents="props.planAccents"
|
|
53
|
+
:loading="bulkVersionsLoading"
|
|
54
|
+
@open-plan="onOpenPlan"
|
|
55
|
+
@create-plan="openCreate"
|
|
56
|
+
@clone-plan="onClonePlan"
|
|
57
|
+
@view-catalog="mode = 'list'"
|
|
58
|
+
/>
|
|
59
|
+
|
|
60
|
+
<!-- Plan detail: drill-in for a single Plan (plan simulation) -->
|
|
61
|
+
<PlanDetail
|
|
62
|
+
v-else-if="mode === 'cockpit' && selectedPlan"
|
|
63
|
+
:plan="selectedPlan"
|
|
64
|
+
:versions="versions"
|
|
65
|
+
:available-quotas="availableQuotas"
|
|
66
|
+
:available-bundles="availableBundles"
|
|
67
|
+
:feature-registry="featureRegistry"
|
|
68
|
+
:impact-by-version="impactByVersion"
|
|
69
|
+
:audit-rows="auditRows"
|
|
70
|
+
:loading-audit="loadingAudit"
|
|
71
|
+
:submit-terminate="onSubmitTerminate"
|
|
72
|
+
@back="onBackToList"
|
|
73
|
+
@create-draft="openCreateDraft"
|
|
74
|
+
@edit-draft="openEditDraft"
|
|
75
|
+
@publish="openPublish"
|
|
76
|
+
@clone-plan="onClonePlan(selectedPlan)"
|
|
77
|
+
@delete-plan="onArchivePlanFromList(selectedPlan, false)"
|
|
78
|
+
@update-plan="onUpdatePlanFromDetail"
|
|
79
|
+
/>
|
|
80
|
+
|
|
81
|
+
<!-- V2 split-view editor as a full-screen view (plan simulation) -->
|
|
82
|
+
<PlanVersionEditor
|
|
83
|
+
v-else-if="mode === 'editor' && draftEditing && selectedPlan"
|
|
84
|
+
:plan-key="selectedPlan.planKey"
|
|
85
|
+
:editing-id="draftEditing.editingId"
|
|
86
|
+
:initial-form="draftEditing.initialForm"
|
|
87
|
+
:saving="draftSaving"
|
|
88
|
+
:available-features="availableFeatures"
|
|
89
|
+
:available-quotas="availableQuotas"
|
|
90
|
+
:available-bundles="availableBundles"
|
|
91
|
+
:feature-registry="featureRegistry"
|
|
92
|
+
:plan-display-name="selectedPlan.label"
|
|
93
|
+
:save-error="draftSaveError"
|
|
94
|
+
:predecessor-version="editorPredecessor"
|
|
95
|
+
@save="onEditorNext"
|
|
96
|
+
@cancel="onCancelEditor"
|
|
97
|
+
/>
|
|
98
|
+
|
|
99
|
+
<!-- V3 Review & Publish — wizard step 3 (plan simulation) -->
|
|
100
|
+
<PlanReview
|
|
101
|
+
v-else-if="mode === 'review' && reviewDraft && selectedPlan"
|
|
102
|
+
:plan="selectedPlan"
|
|
103
|
+
:version="reviewDraft"
|
|
104
|
+
:predecessor="reviewPredecessor"
|
|
105
|
+
:available-quotas="availableQuotas"
|
|
106
|
+
:available-bundles="availableBundles"
|
|
107
|
+
:feature-registry="featureRegistry"
|
|
108
|
+
:tenant-impact-count="tenantCountsByPlanKey[selectedPlan.planKey] ?? 0"
|
|
109
|
+
:saving="draftSaving"
|
|
110
|
+
:publishing="publishing"
|
|
111
|
+
:publish-error="reviewError"
|
|
112
|
+
@back="onReviewBack"
|
|
113
|
+
@save-and-exit="onReviewSaveExit"
|
|
114
|
+
@publish="onReviewPublish"
|
|
115
|
+
/>
|
|
116
|
+
</template>
|
|
117
|
+
|
|
118
|
+
<PlansPageToast :message="toastMessage" />
|
|
119
|
+
|
|
120
|
+
<!-- Create Plan master record (V1 plan simulation step 1) -->
|
|
121
|
+
<PlanCreateDialog
|
|
122
|
+
v-model="createDialogOpen"
|
|
123
|
+
:available-templates="availableTemplates"
|
|
124
|
+
:existing-plan-keys="existingPlanKeys"
|
|
125
|
+
:default-basis="cloneFromPlanKey ?? 'empty'"
|
|
126
|
+
:submitting="creatingPlan"
|
|
127
|
+
@submit="onCreateSubmit"
|
|
128
|
+
@cancel="createDialogOpen = false"
|
|
129
|
+
/>
|
|
130
|
+
|
|
131
|
+
<PlanArchiveDialog
|
|
132
|
+
v-model="archiveOpen"
|
|
133
|
+
:target="archiveTarget"
|
|
134
|
+
:error="archiveError"
|
|
135
|
+
:archiving="archiving"
|
|
136
|
+
@execute="executeArchive"
|
|
137
|
+
/>
|
|
138
|
+
|
|
139
|
+
<PlanDiscardDraftDialog
|
|
140
|
+
v-model="discardOpen"
|
|
141
|
+
:target="discardTarget"
|
|
142
|
+
:error="discardError"
|
|
143
|
+
:discarding="discarding"
|
|
144
|
+
@execute="executeDiscard"
|
|
145
|
+
/>
|
|
146
|
+
|
|
147
|
+
<PlanPublishDialog
|
|
148
|
+
v-model="publishOpen"
|
|
149
|
+
v-model:force-regressive="forceRegressive"
|
|
150
|
+
v-model:allow-zero-price="allowZeroPrice"
|
|
151
|
+
:selected-plan="selectedPlan"
|
|
152
|
+
:publish-target="publishTarget"
|
|
153
|
+
:publishing="publishing"
|
|
154
|
+
:publish-error="publishError"
|
|
155
|
+
:regression-changes="regressionChanges"
|
|
156
|
+
:field-label="fieldLabel"
|
|
157
|
+
:format-change-value="formatChangeValue"
|
|
158
|
+
@execute="executePublish"
|
|
159
|
+
/>
|
|
160
|
+
</q-page>
|
|
161
|
+
</template>
|
|
162
|
+
|
|
163
|
+
<script setup lang="ts">
|
|
164
|
+
import { computed, onMounted, ref, shallowRef, watch } from 'vue';
|
|
165
|
+
import type {
|
|
166
|
+
FeatureCatalogEntryRow,
|
|
167
|
+
PlanRow,
|
|
168
|
+
PlanVersionRow,
|
|
169
|
+
PlanVersionMutationResult,
|
|
170
|
+
} from '@saasicat/types';
|
|
171
|
+
import {
|
|
172
|
+
buildFeatureRegistry,
|
|
173
|
+
CATALOG_DEFAULT_LOCALE,
|
|
174
|
+
} from '../components/bundle-editor/catalog-i18n.js';
|
|
175
|
+
import {
|
|
176
|
+
usePlans,
|
|
177
|
+
usePlanVersions,
|
|
178
|
+
type UsePlansResult,
|
|
179
|
+
type UsePlanVersionsResult,
|
|
180
|
+
} from '../use-plans.js';
|
|
181
|
+
import type { HttpClient } from '../types.js';
|
|
182
|
+
import PlanVersionEditor from '../components/plan-version-editor/PlanVersionEditor.vue';
|
|
183
|
+
import PlanMatrix from '../components/plan-matrix/PlanMatrix.vue';
|
|
184
|
+
import PlanDetail from '../components/plan-detail/PlanDetail.vue';
|
|
185
|
+
import PlanList from '../components/plan-list/PlanList.vue';
|
|
186
|
+
import PlanCreateDialog, {
|
|
187
|
+
type PlanCreateSubmit,
|
|
188
|
+
type TemplateOption,
|
|
189
|
+
} from '../components/plan-create-dialog/PlanCreateDialog.vue';
|
|
190
|
+
import PlanReview from '../components/plan-review/PlanReview.vue';
|
|
191
|
+
import PlanArchiveDialog from './plans-page/PlanArchiveDialog.vue';
|
|
192
|
+
import PlanDiscardDraftDialog from './plans-page/PlanDiscardDraftDialog.vue';
|
|
193
|
+
import PlanPublishDialog from './plans-page/PlanPublishDialog.vue';
|
|
194
|
+
import PlanBundleOverview from './plans-page/PlanBundleOverview.vue';
|
|
195
|
+
import PlansPageToast from './plans-page/PlansPageToast.vue';
|
|
196
|
+
import type { PlanArchiveTarget, PlanDiscardTarget, RegressionChange } from './plans-page/types.js';
|
|
197
|
+
|
|
198
|
+
// SuperAdmin plan management — V1 matrix overview as the default view
|
|
199
|
+
// (all Plans side by side), drill-in into the V3 cockpit for a single
|
|
200
|
+
// Plan (versions timeline + diff + Tenant impact + audit), and V2
|
|
201
|
+
// split-view editor as a modal for draft editing. The plan master dialog
|
|
202
|
+
// (create + edit) stays unchanged.
|
|
203
|
+
|
|
204
|
+
interface DiscoveryFeature {
|
|
205
|
+
featureKey: string;
|
|
206
|
+
}
|
|
207
|
+
interface DiscoveryQuota {
|
|
208
|
+
quotaKey: string;
|
|
209
|
+
label?: string | null;
|
|
210
|
+
unit?: string | null;
|
|
211
|
+
}
|
|
212
|
+
interface BundleEntry {
|
|
213
|
+
bundleKey: string;
|
|
214
|
+
label?: string | null;
|
|
215
|
+
features: string[];
|
|
216
|
+
compatiblePlanKeys?: string[] | null;
|
|
217
|
+
}
|
|
218
|
+
interface AuditRow {
|
|
219
|
+
id: string;
|
|
220
|
+
createdAt: string;
|
|
221
|
+
action: string;
|
|
222
|
+
entity: string;
|
|
223
|
+
entityId: string;
|
|
224
|
+
changes: Record<string, unknown> | null;
|
|
225
|
+
user?: { email: string; firstName?: string; lastName?: string } | null;
|
|
226
|
+
userEmail?: string | null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const props = defineProps<{
|
|
230
|
+
adminEndpoint: string;
|
|
231
|
+
projectKey: string;
|
|
232
|
+
http?: HttpClient;
|
|
233
|
+
getAuthToken?: () => string | null;
|
|
234
|
+
/**
|
|
235
|
+
* Optional: feature label overrides. Since the catalog entry
|
|
236
|
+
* integration, labels come by default from the FeatureCatalogEntries
|
|
237
|
+
* (incl. i18n); this prop overrides individual keys (e.g. group).
|
|
238
|
+
*/
|
|
239
|
+
featureRegistry?: Record<string, { label?: string; group?: string }>;
|
|
240
|
+
/** Display language for feature labels from the catalog entries. */
|
|
241
|
+
displayLocale?: string;
|
|
242
|
+
/** Accent color per plan key for the matrix + cockpit header. */
|
|
243
|
+
planAccents?: Record<string, string>;
|
|
244
|
+
/** Tenant count per plan key for the matrix headers. */
|
|
245
|
+
tenantCountsByPlanKey?: Record<string, number>;
|
|
246
|
+
/** Optional: loader for a Plan's audit log (cockpit). */
|
|
247
|
+
loadPlanAudit?: (planId: string) => Promise<AuditRow[]>;
|
|
248
|
+
}>();
|
|
249
|
+
|
|
250
|
+
const composable: UsePlansResult = usePlans({
|
|
251
|
+
adminEndpoint: props.adminEndpoint,
|
|
252
|
+
projectKey: props.projectKey,
|
|
253
|
+
http: props.http,
|
|
254
|
+
getAuthToken: props.getAuthToken,
|
|
255
|
+
});
|
|
256
|
+
const {
|
|
257
|
+
plans,
|
|
258
|
+
loading,
|
|
259
|
+
error,
|
|
260
|
+
tenantCountsByPlanKey: loadedTenantCounts,
|
|
261
|
+
load,
|
|
262
|
+
loadTenantCounts,
|
|
263
|
+
create,
|
|
264
|
+
update,
|
|
265
|
+
hardDelete,
|
|
266
|
+
} = composable;
|
|
267
|
+
|
|
268
|
+
// ─── View mode + selection ───
|
|
269
|
+
// Plan simulation flow: list → cockpit (plan detail); editor ("Draft
|
|
270
|
+
// bearbeiten") → "Weiter · Review" → review ("Review & Publish").
|
|
271
|
+
// The editor does NOT persist — saving happens only in the review screen
|
|
272
|
+
// (via "Als Draft speichern" or "Publish").
|
|
273
|
+
type Mode = 'list' | 'matrix' | 'cockpit' | 'editor' | 'review';
|
|
274
|
+
const mode = ref<Mode>('list');
|
|
275
|
+
const selectedPlan = ref<PlanRow | null>(null);
|
|
276
|
+
|
|
277
|
+
// Synthetic (not yet persisted) PlanVersion from the editor form that the
|
|
278
|
+
// review screen displays. Persisted only on a review
|
|
279
|
+
// action.
|
|
280
|
+
const reviewDraft = ref<PlanVersionRow | null>(null);
|
|
281
|
+
// Shared error text for review actions (save + publish).
|
|
282
|
+
const reviewError = ref<string | null>(null);
|
|
283
|
+
|
|
284
|
+
// NEW highlight after plan create / version publish (see plan simulation).
|
|
285
|
+
const highlightPlanKey = ref<string | null>(null);
|
|
286
|
+
const toastMessage = ref<string | null>(null);
|
|
287
|
+
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
|
288
|
+
let highlightTimer: ReturnType<typeof setTimeout> | null = null;
|
|
289
|
+
|
|
290
|
+
function flashToast(msg: string, ms = 4000): void {
|
|
291
|
+
toastMessage.value = msg;
|
|
292
|
+
if (toastTimer) clearTimeout(toastTimer);
|
|
293
|
+
toastTimer = setTimeout(() => {
|
|
294
|
+
toastMessage.value = null;
|
|
295
|
+
}, ms);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function flashHighlight(planKey: string, ms = 4500): void {
|
|
299
|
+
highlightPlanKey.value = planKey;
|
|
300
|
+
if (highlightTimer) clearTimeout(highlightTimer);
|
|
301
|
+
highlightTimer = setTimeout(() => {
|
|
302
|
+
highlightPlanKey.value = null;
|
|
303
|
+
}, ms);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Prop stays an override; without the prop the platform-wide counters
|
|
307
|
+
// loaded by use-plans apply (GET …/catalog/plans/tenant-counts).
|
|
308
|
+
const tenantCountsByPlanKey = computed<Record<string, number>>(
|
|
309
|
+
() => props.tenantCountsByPlanKey ?? loadedTenantCounts.value,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
function planAccent(planKey: string): string {
|
|
313
|
+
return props.planAccents?.[planKey] ?? defaultAccent(planKey);
|
|
314
|
+
}
|
|
315
|
+
const DEFAULT_ACCENTS: Record<string, string> = {
|
|
316
|
+
STARTER: '#64748b',
|
|
317
|
+
STANDARD: '#2563eb',
|
|
318
|
+
PRO: '#7c3aed',
|
|
319
|
+
PROFESSIONAL: '#7c3aed',
|
|
320
|
+
BUSINESS: '#0ea5e9',
|
|
321
|
+
ENTERPRISE: '#0f766e',
|
|
322
|
+
BASIC: '#475569',
|
|
323
|
+
};
|
|
324
|
+
function defaultAccent(planKey: string): string {
|
|
325
|
+
return DEFAULT_ACCENTS[planKey] ?? '#2563eb';
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ─── Plan master creation (V1 plan simulation step 1) ───
|
|
329
|
+
const createDialogOpen = ref(false);
|
|
330
|
+
const creatingPlan = ref(false);
|
|
331
|
+
// When started from "Plan klonen", this is the default basis (plan key).
|
|
332
|
+
const cloneFromPlanKey = ref<string | null>(null);
|
|
333
|
+
|
|
334
|
+
const existingPlanKeys = computed<string[]>(() => plans.value.map((p) => p.planKey));
|
|
335
|
+
|
|
336
|
+
function quotasOfVersion(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 availableTemplates = computed<TemplateOption[]>(() =>
|
|
346
|
+
plans.value
|
|
347
|
+
.map<TemplateOption | null>((p) => {
|
|
348
|
+
const versions = versionsByPlanId.value[p.id] ?? [];
|
|
349
|
+
const live =
|
|
350
|
+
versions.find((v) => v.publishedAt !== null && v.supersededAt === null) ?? null;
|
|
351
|
+
if (!live) return null;
|
|
352
|
+
return {
|
|
353
|
+
key: p.planKey,
|
|
354
|
+
label: p.label,
|
|
355
|
+
features: [...live.features],
|
|
356
|
+
quotas: quotasOfVersion(live),
|
|
357
|
+
bundles: bundlesFullyOn(live.features),
|
|
358
|
+
};
|
|
359
|
+
})
|
|
360
|
+
.filter((t): t is TemplateOption => t !== null),
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
function bundlesFullyOn(features: string[]): string[] {
|
|
364
|
+
return availableBundles.value
|
|
365
|
+
.filter((b) => b.features.length > 0 && b.features.every((f) => features.includes(f)))
|
|
366
|
+
.map((b) => b.bundleKey);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function openCreate(): void {
|
|
370
|
+
cloneFromPlanKey.value = null;
|
|
371
|
+
createDialogOpen.value = true;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function onClonePlan(plan: PlanRow): void {
|
|
375
|
+
cloneFromPlanKey.value = plan.planKey;
|
|
376
|
+
createDialogOpen.value = true;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function onCreateSubmit(payload: PlanCreateSubmit): Promise<void> {
|
|
380
|
+
creatingPlan.value = true;
|
|
381
|
+
try {
|
|
382
|
+
const created: PlanRow = await create({
|
|
383
|
+
projectKey: props.projectKey,
|
|
384
|
+
planKey: payload.planKey,
|
|
385
|
+
label: payload.label,
|
|
386
|
+
description: payload.description === '' ? undefined : payload.description,
|
|
387
|
+
sortOrder: plans.value.length * 100,
|
|
388
|
+
});
|
|
389
|
+
createDialogOpen.value = false;
|
|
390
|
+
|
|
391
|
+
await reloadAllVersions();
|
|
392
|
+
flashHighlight(created.planKey);
|
|
393
|
+
flashToast(`Plan ${created.planKey} angelegt — jetzt die erste Version zusammenstellen.`);
|
|
394
|
+
await onOpenPlan(created);
|
|
395
|
+
openCreateDraftWithPrefill({
|
|
396
|
+
features: payload.initialFeatures,
|
|
397
|
+
quotas: payload.initialQuotas,
|
|
398
|
+
// Bundles from the master clone are already represented in `features`
|
|
399
|
+
// (set union). The active Bundle visualization in the editor detects
|
|
400
|
+
// them automatically once all Bundle features are in the basket.
|
|
401
|
+
});
|
|
402
|
+
} finally {
|
|
403
|
+
creatingPlan.value = false;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ─── Versions per Plan (matrix loads all, cockpit has one active selection) ───
|
|
408
|
+
const versionsByPlanId = ref<Record<string, PlanVersionRow[]>>({});
|
|
409
|
+
const bulkVersionsLoading = ref(false);
|
|
410
|
+
|
|
411
|
+
async function reloadAllVersions(): Promise<void> {
|
|
412
|
+
if (plans.value.length === 0) return;
|
|
413
|
+
bulkVersionsLoading.value = true;
|
|
414
|
+
try {
|
|
415
|
+
const http = props.http ?? ((url: string, init?: RequestInit) => fetch(url, init));
|
|
416
|
+
const token = props.getAuthToken?.();
|
|
417
|
+
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
|
|
418
|
+
const results = await Promise.all(
|
|
419
|
+
plans.value.map(async (p) => {
|
|
420
|
+
const r = await http(`${props.adminEndpoint}/catalog/plans/${p.id}/versions`, {
|
|
421
|
+
headers,
|
|
422
|
+
});
|
|
423
|
+
if (r.status !== 200) return [p.id, [] as PlanVersionRow[]] as const;
|
|
424
|
+
const body = (await r.json().catch(() => [])) as PlanVersionRow[];
|
|
425
|
+
return [p.id, body] as const;
|
|
426
|
+
}),
|
|
427
|
+
);
|
|
428
|
+
const next: Record<string, PlanVersionRow[]> = {};
|
|
429
|
+
for (const [id, vs] of results) next[id] = vs;
|
|
430
|
+
versionsByPlanId.value = next;
|
|
431
|
+
if (selectedPlan.value) {
|
|
432
|
+
versions.value = next[selectedPlan.value.id] ?? [];
|
|
433
|
+
}
|
|
434
|
+
} finally {
|
|
435
|
+
bulkVersionsLoading.value = false;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ─── Cockpit versions (for the drill-in) ───
|
|
440
|
+
const versions = ref<PlanVersionRow[]>([]);
|
|
441
|
+
const planVersions = shallowRef<UsePlanVersionsResult | null>(null);
|
|
442
|
+
|
|
443
|
+
async function loadCockpitVersions(plan: PlanRow): Promise<void> {
|
|
444
|
+
const pv = usePlanVersions({
|
|
445
|
+
adminEndpoint: props.adminEndpoint,
|
|
446
|
+
planId: plan.id,
|
|
447
|
+
http: props.http,
|
|
448
|
+
getAuthToken: props.getAuthToken,
|
|
449
|
+
});
|
|
450
|
+
planVersions.value = pv;
|
|
451
|
+
await pv.load();
|
|
452
|
+
versions.value = pv.versions.value;
|
|
453
|
+
versionsByPlanId.value = { ...versionsByPlanId.value, [plan.id]: pv.versions.value };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function reloadCockpitVersions(): Promise<void> {
|
|
457
|
+
if (!planVersions.value || !selectedPlan.value) return;
|
|
458
|
+
await planVersions.value.load();
|
|
459
|
+
versions.value = planVersions.value.versions.value;
|
|
460
|
+
versionsByPlanId.value = {
|
|
461
|
+
...versionsByPlanId.value,
|
|
462
|
+
[selectedPlan.value.id]: planVersions.value.versions.value,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ─── Cockpit audit log (optional) ───
|
|
467
|
+
const auditRows = ref<AuditRow[]>([]);
|
|
468
|
+
const loadingAudit = ref(false);
|
|
469
|
+
|
|
470
|
+
async function loadAuditFor(plan: PlanRow): Promise<void> {
|
|
471
|
+
if (!props.loadPlanAudit) {
|
|
472
|
+
auditRows.value = [];
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
loadingAudit.value = true;
|
|
476
|
+
try {
|
|
477
|
+
auditRows.value = await props.loadPlanAudit(plan.id);
|
|
478
|
+
} catch {
|
|
479
|
+
auditRows.value = [];
|
|
480
|
+
} finally {
|
|
481
|
+
loadingAudit.value = false;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ─── Impact per version (heuristic from Tenant counts) ───
|
|
486
|
+
const impactByVersion = computed<Record<number, number>>(() => {
|
|
487
|
+
// We only know the Tenant total per Plan. Without a dedicated
|
|
488
|
+
// "Tenants per version" endpoint we show the total on the active
|
|
489
|
+
// live version and 0 otherwise. Consumers can replace this later via a
|
|
490
|
+
// prop once the data is available.
|
|
491
|
+
const result: Record<number, number> = {};
|
|
492
|
+
if (!selectedPlan.value) return result;
|
|
493
|
+
const liveVersion =
|
|
494
|
+
versions.value.find((v) => v.publishedAt !== null && v.supersededAt === null) ?? null;
|
|
495
|
+
if (liveVersion) {
|
|
496
|
+
result[liveVersion.version] = tenantCountsByPlanKey.value[selectedPlan.value.planKey] ?? 0;
|
|
497
|
+
}
|
|
498
|
+
return result;
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// ─── Navigation matrix ↔ cockpit ───
|
|
502
|
+
async function onOpenPlan(plan: PlanRow): Promise<void> {
|
|
503
|
+
selectedPlan.value = plan;
|
|
504
|
+
mode.value = 'cockpit';
|
|
505
|
+
await Promise.all([loadCockpitVersions(plan), loadAuditFor(plan)]);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// "Neue Version" directly from the list row (plan simulation: pencil icon).
|
|
509
|
+
// Jumps into the cockpit + opens the V2 editor with the current live version
|
|
510
|
+
// as the basis. No create modal in between.
|
|
511
|
+
async function onNewVersionFromList(plan: PlanRow, basis: PlanVersionRow): Promise<void> {
|
|
512
|
+
selectedPlan.value = plan;
|
|
513
|
+
mode.value = 'cockpit';
|
|
514
|
+
await Promise.all([loadCockpitVersions(plan), loadAuditFor(plan)]);
|
|
515
|
+
// Editor with a basis clone — new version (the next version number is
|
|
516
|
+
// computed in the editor itself from the existing list).
|
|
517
|
+
openCreateDraftWithPrefill({
|
|
518
|
+
features: [...basis.features],
|
|
519
|
+
quotas: quotasOfVersion(basis),
|
|
520
|
+
monthlyNet: basis.monthlyNet,
|
|
521
|
+
yearlyNet: basis.yearlyNet,
|
|
522
|
+
marketed: basis.marketed,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// "Draft bearbeiten" from the list sub-row: loads the cockpit composable
|
|
527
|
+
// for the Plan (so persistDraft → updateDraft uses the correct planVersions
|
|
528
|
+
// instance) and opens the editor in edit mode (editingId set).
|
|
529
|
+
async function onEditDraftFromList(plan: PlanRow, draft: PlanVersionRow): Promise<void> {
|
|
530
|
+
selectedPlan.value = plan;
|
|
531
|
+
mode.value = 'cockpit';
|
|
532
|
+
await Promise.all([loadCockpitVersions(plan), loadAuditFor(plan)]);
|
|
533
|
+
openEditDraft(draft);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function onBackToList(): void {
|
|
537
|
+
mode.value = 'list';
|
|
538
|
+
selectedPlan.value = null;
|
|
539
|
+
planVersions.value = null;
|
|
540
|
+
versions.value = [];
|
|
541
|
+
auditRows.value = [];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Inline rename of the Plan title from the plan detail screen (only allowed
|
|
545
|
+
// with an open draft — the UI shows the edit button accordingly).
|
|
546
|
+
async function onUpdatePlanFromDetail(patch: { label: string }): Promise<void> {
|
|
547
|
+
if (!selectedPlan.value) return;
|
|
548
|
+
try {
|
|
549
|
+
const updated = await update(selectedPlan.value.id, patch);
|
|
550
|
+
selectedPlan.value = updated;
|
|
551
|
+
} catch (err: unknown) {
|
|
552
|
+
// eslint-disable-next-line no-console
|
|
553
|
+
console.error('[PlansPage] Plan-Rename fehlgeschlagen', err);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ─── Discovery + Bundles for the editor ───
|
|
558
|
+
const availableFeatures = ref<DiscoveryFeature[]>([]);
|
|
559
|
+
const availableQuotas = ref<DiscoveryQuota[]>([]);
|
|
560
|
+
const availableBundles = ref<BundleEntry[]>([]);
|
|
561
|
+
|
|
562
|
+
// Curated feature labels (FeatureCatalogEntry incl. i18n) as the basis,
|
|
563
|
+
// wrapper overrides win per key — same resolution as BundlesPage, so that
|
|
564
|
+
// all consumer admin UIs label things identically.
|
|
565
|
+
const featureCatalogEntries = ref<FeatureCatalogEntryRow[]>([]);
|
|
566
|
+
const featureRegistry = computed<Record<string, { label?: string; group?: string }>>(() => ({
|
|
567
|
+
...buildFeatureRegistry(featureCatalogEntries.value, props.displayLocale ?? CATALOG_DEFAULT_LOCALE),
|
|
568
|
+
...props.featureRegistry,
|
|
569
|
+
}));
|
|
570
|
+
|
|
571
|
+
async function loadEditorSources(): Promise<void> {
|
|
572
|
+
const http = props.http ?? ((url: string, init?: RequestInit) => fetch(url, init));
|
|
573
|
+
const token = props.getAuthToken?.();
|
|
574
|
+
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
|
|
575
|
+
try {
|
|
576
|
+
const [discRes, bundlesRes, featureEntriesRes] = await Promise.all([
|
|
577
|
+
http(`${props.adminEndpoint}/discovery`, { headers }),
|
|
578
|
+
http(
|
|
579
|
+
`${props.adminEndpoint}/catalog/bundles?projectKey=${encodeURIComponent(props.projectKey)}`,
|
|
580
|
+
{ headers },
|
|
581
|
+
),
|
|
582
|
+
http(
|
|
583
|
+
`${props.adminEndpoint}/catalog/features?projectKey=${encodeURIComponent(props.projectKey)}`,
|
|
584
|
+
{ headers },
|
|
585
|
+
),
|
|
586
|
+
]);
|
|
587
|
+
if (featureEntriesRes.status === 200) {
|
|
588
|
+
featureCatalogEntries.value = (await featureEntriesRes
|
|
589
|
+
.json()
|
|
590
|
+
.catch(() => [])) as FeatureCatalogEntryRow[];
|
|
591
|
+
}
|
|
592
|
+
if (discRes.status === 200 || discRes.status === 304) {
|
|
593
|
+
const body = (await discRes.json().catch(() => null)) as {
|
|
594
|
+
features?: DiscoveryFeature[];
|
|
595
|
+
quotas?: DiscoveryQuota[];
|
|
596
|
+
} | null;
|
|
597
|
+
availableFeatures.value = body?.features ?? [];
|
|
598
|
+
availableQuotas.value = body?.quotas ?? [];
|
|
599
|
+
}
|
|
600
|
+
if (bundlesRes.status === 200) {
|
|
601
|
+
const list = (await bundlesRes.json().catch(() => [])) as Array<{
|
|
602
|
+
id: string;
|
|
603
|
+
bundleKey: string;
|
|
604
|
+
label?: string | null;
|
|
605
|
+
}>;
|
|
606
|
+
const detailed = await Promise.all(
|
|
607
|
+
list.map(async (b) => {
|
|
608
|
+
const r = await http(`${props.adminEndpoint}/catalog/bundles/${b.id}`, {
|
|
609
|
+
headers,
|
|
610
|
+
});
|
|
611
|
+
if (r.status !== 200) return null;
|
|
612
|
+
const body = (await r.json().catch(() => null)) as {
|
|
613
|
+
bundle: { bundleKey: string; label?: string | null };
|
|
614
|
+
versions?: Array<{
|
|
615
|
+
features?: string[];
|
|
616
|
+
compatibility?: { planIds?: string[] | null } | null;
|
|
617
|
+
publishedAt: string | null;
|
|
618
|
+
supersededAt: string | null;
|
|
619
|
+
}>;
|
|
620
|
+
} | null;
|
|
621
|
+
if (!body) return null;
|
|
622
|
+
const live = body.versions?.find(
|
|
623
|
+
(v) => v.publishedAt !== null && v.supersededAt === null,
|
|
624
|
+
);
|
|
625
|
+
return {
|
|
626
|
+
bundleKey: body.bundle.bundleKey,
|
|
627
|
+
label: body.bundle.label,
|
|
628
|
+
features: live?.features ?? [],
|
|
629
|
+
// compatibility.planIds contains plan KEYS (see public-marketing-catalog).
|
|
630
|
+
compatiblePlanKeys: live?.compatibility?.planIds ?? [],
|
|
631
|
+
} as BundleEntry;
|
|
632
|
+
}),
|
|
633
|
+
);
|
|
634
|
+
availableBundles.value = detailed.filter((b): b is BundleEntry => b !== null);
|
|
635
|
+
}
|
|
636
|
+
} catch {
|
|
637
|
+
// best-effort
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ─── Draft editor (V2) — full-screen view, no modal ───
|
|
642
|
+
const draftSaving = ref(false);
|
|
643
|
+
const draftSaveError = ref<string | null>(null);
|
|
644
|
+
|
|
645
|
+
interface ApiErrorShape {
|
|
646
|
+
status?: number;
|
|
647
|
+
body?: {
|
|
648
|
+
code?: string;
|
|
649
|
+
message?: string;
|
|
650
|
+
warnings?: Array<{ message?: string }>;
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Translates a PlansApiError (or generic error) from the draft save into a
|
|
655
|
+
// clear, displayable message.
|
|
656
|
+
function describeDraftSaveError(err: unknown): string {
|
|
657
|
+
const e = err as ApiErrorShape;
|
|
658
|
+
const status = e?.status;
|
|
659
|
+
const body = e?.body;
|
|
660
|
+
const code = body?.code;
|
|
661
|
+
if (status === 401 || status === 403) {
|
|
662
|
+
return 'Sitzung abgelaufen — bitte neu anmelden und erneut speichern.';
|
|
663
|
+
}
|
|
664
|
+
if (status === 422 && code === 'STRICT_MODE_VIOLATIONS') {
|
|
665
|
+
const list = (body?.warnings ?? [])
|
|
666
|
+
.map((w) => w.message)
|
|
667
|
+
.filter(Boolean)
|
|
668
|
+
.join(' · ');
|
|
669
|
+
return `Strict-Mode-Check: ${list || 'Drift gegen den Discovery-Snapshot.'}`;
|
|
670
|
+
}
|
|
671
|
+
if (status === 422 && code === 'PLAN_VERSION_REGRESSION') {
|
|
672
|
+
return 'Diese Version ist regressiv (Feature entfernt / Quota gesenkt / Preis erhöht). Publish erfordert Force-Regressive — beim Speichern als Draft sollte das nicht auftreten.';
|
|
673
|
+
}
|
|
674
|
+
if (status === 422 && body?.message) {
|
|
675
|
+
// e.g. "Plan 'BASIC' hat bereits eine Draft-Version v4 …"
|
|
676
|
+
return body.message;
|
|
677
|
+
}
|
|
678
|
+
if (status !== undefined) {
|
|
679
|
+
return body?.message ?? `Speichern fehlgeschlagen (HTTP ${status}).`;
|
|
680
|
+
}
|
|
681
|
+
return err instanceof Error
|
|
682
|
+
? `Speichern fehlgeschlagen: ${err.message}`
|
|
683
|
+
: 'Speichern fehlgeschlagen — Details siehe Browser-Konsole.';
|
|
684
|
+
}
|
|
685
|
+
const draftEditing = ref<{
|
|
686
|
+
editingId: string | null;
|
|
687
|
+
initialForm: {
|
|
688
|
+
version: number;
|
|
689
|
+
features: string[];
|
|
690
|
+
quotas: Record<string, number>;
|
|
691
|
+
monthlyNet: string;
|
|
692
|
+
yearlyNet: string;
|
|
693
|
+
changeNote: string;
|
|
694
|
+
marketed: boolean;
|
|
695
|
+
validFrom: string | null;
|
|
696
|
+
validUntil: string | null;
|
|
697
|
+
};
|
|
698
|
+
} | null>(null);
|
|
699
|
+
|
|
700
|
+
function todayIso(): string {
|
|
701
|
+
return new Date().toISOString().slice(0, 10);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function openCreateDraft(): void {
|
|
705
|
+
openCreateDraftWithPrefill({ features: [], quotas: {} });
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function openCreateDraftWithPrefill(prefill: {
|
|
709
|
+
features: string[];
|
|
710
|
+
quotas: Record<string, number>;
|
|
711
|
+
monthlyNet?: string;
|
|
712
|
+
yearlyNet?: string;
|
|
713
|
+
marketed?: boolean;
|
|
714
|
+
}): void {
|
|
715
|
+
const nextVersion = (versions.value.reduce((m, v) => Math.max(m, v.version), 0) || 0) + 1;
|
|
716
|
+
draftEditing.value = {
|
|
717
|
+
editingId: null,
|
|
718
|
+
initialForm: {
|
|
719
|
+
version: nextVersion,
|
|
720
|
+
features: [...prefill.features].sort(),
|
|
721
|
+
quotas: { ...prefill.quotas },
|
|
722
|
+
monthlyNet: prefill.monthlyNet ?? '0.00',
|
|
723
|
+
yearlyNet: prefill.yearlyNet ?? '0.00',
|
|
724
|
+
changeNote: '',
|
|
725
|
+
marketed: prefill.marketed ?? true,
|
|
726
|
+
validFrom: todayIso(),
|
|
727
|
+
validUntil: null,
|
|
728
|
+
},
|
|
729
|
+
};
|
|
730
|
+
draftSaveError.value = null;
|
|
731
|
+
mode.value = 'editor';
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function openEditDraft(row: PlanVersionRow): void {
|
|
735
|
+
draftEditing.value = {
|
|
736
|
+
editingId: row.id,
|
|
737
|
+
initialForm: {
|
|
738
|
+
version: row.version,
|
|
739
|
+
features: [...row.features],
|
|
740
|
+
quotas: { ...(row.quotas ?? {}) },
|
|
741
|
+
monthlyNet: row.monthlyNet,
|
|
742
|
+
yearlyNet: row.yearlyNet,
|
|
743
|
+
changeNote: row.changeNote ?? '',
|
|
744
|
+
marketed: row.marketed,
|
|
745
|
+
validFrom: row.validFrom ? row.validFrom.slice(0, 10) : null,
|
|
746
|
+
validUntil: row.validUntil ? row.validUntil.slice(0, 10) : null,
|
|
747
|
+
},
|
|
748
|
+
};
|
|
749
|
+
draftSaveError.value = null;
|
|
750
|
+
mode.value = 'editor';
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Cancel the editor → back to the cockpit (or to the list if no Plan).
|
|
754
|
+
function onCancelEditor(): void {
|
|
755
|
+
draftEditing.value = null;
|
|
756
|
+
mode.value = selectedPlan.value ? 'cockpit' : 'list';
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Predecessor version for the "Diff vs. Vorgänger" view in the editor: the
|
|
760
|
+
// published version with the highest version number (= the state the draft
|
|
761
|
+
// would supersede on publish). Null for v1.
|
|
762
|
+
const editorPredecessor = computed<{
|
|
763
|
+
version: number;
|
|
764
|
+
features: string[];
|
|
765
|
+
quotas: Record<string, number>;
|
|
766
|
+
monthlyNet: string;
|
|
767
|
+
yearlyNet: string;
|
|
768
|
+
validFrom: string | null;
|
|
769
|
+
} | null>(() => {
|
|
770
|
+
const published = versions.value.filter((v) => v.publishedAt !== null);
|
|
771
|
+
if (published.length === 0) return null;
|
|
772
|
+
const editingId = draftEditing.value?.editingId ?? null;
|
|
773
|
+
const candidates = editingId ? published.filter((v) => v.id !== editingId) : published;
|
|
774
|
+
if (candidates.length === 0) return null;
|
|
775
|
+
const latest = candidates.reduce((a, b) => (a.version > b.version ? a : b));
|
|
776
|
+
return {
|
|
777
|
+
version: latest.version,
|
|
778
|
+
features: [...latest.features],
|
|
779
|
+
quotas: quotasOfVersion(latest),
|
|
780
|
+
monthlyNet: latest.monthlyNet,
|
|
781
|
+
yearlyNet: latest.yearlyNet,
|
|
782
|
+
validFrom: latest.validFrom,
|
|
783
|
+
};
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
interface EditorFormPayload {
|
|
787
|
+
version: number;
|
|
788
|
+
features: string[];
|
|
789
|
+
bundles: string[];
|
|
790
|
+
quotas: Record<string, number>;
|
|
791
|
+
monthlyNet: string;
|
|
792
|
+
yearlyNet: string;
|
|
793
|
+
changeNote: string;
|
|
794
|
+
marketed: boolean;
|
|
795
|
+
validFrom: string | null;
|
|
796
|
+
validUntil: string | null;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Editor button "Weiter · Review" → switches to the review screen WITHOUT
|
|
800
|
+
// persisting. The form is held as a synthetic (not yet saved) PlanVersion;
|
|
801
|
+
// saving happens only in the review.
|
|
802
|
+
function onEditorNext(payload: EditorFormPayload): void {
|
|
803
|
+
if (!selectedPlan.value || !draftEditing.value) return;
|
|
804
|
+
const nowIso = new Date().toISOString();
|
|
805
|
+
reviewDraft.value = {
|
|
806
|
+
id: draftEditing.value.editingId ?? '',
|
|
807
|
+
planId: selectedPlan.value.planKey,
|
|
808
|
+
version: payload.version,
|
|
809
|
+
baseVersionId: null,
|
|
810
|
+
features: [...payload.features],
|
|
811
|
+
bundles: [...payload.bundles],
|
|
812
|
+
quotas: { ...payload.quotas },
|
|
813
|
+
monthlyNet: payload.monthlyNet,
|
|
814
|
+
yearlyNet: payload.yearlyNet,
|
|
815
|
+
marketed: payload.marketed,
|
|
816
|
+
publishedAt: null,
|
|
817
|
+
supersededAt: null,
|
|
818
|
+
publishedChanges: null,
|
|
819
|
+
changeNote: payload.changeNote,
|
|
820
|
+
nonRegressive: true,
|
|
821
|
+
validFrom: payload.validFrom,
|
|
822
|
+
validUntil: payload.validUntil,
|
|
823
|
+
createdByUserId: null,
|
|
824
|
+
publishedByUserId: null,
|
|
825
|
+
createdAt: nowIso,
|
|
826
|
+
updatedAt: nowIso,
|
|
827
|
+
};
|
|
828
|
+
reviewError.value = null;
|
|
829
|
+
mode.value = 'review';
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ─── Review & Publish ───
|
|
833
|
+
// Predecessor (current live version) for the diff/impact display in the review.
|
|
834
|
+
const reviewPredecessor = computed<PlanVersionRow | null>(() => {
|
|
835
|
+
const live = versions.value.filter((v) => v.publishedAt !== null && v.supersededAt === null);
|
|
836
|
+
if (live.length === 0) return null;
|
|
837
|
+
return live.reduce((a, b) => (a.version > b.version ? a : b));
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
// "Zurück" — back to the editor, with the form values shown in the review
|
|
841
|
+
// (not yet saved), so no input is lost.
|
|
842
|
+
function onReviewBack(): void {
|
|
843
|
+
if (!reviewDraft.value || !draftEditing.value) {
|
|
844
|
+
mode.value = selectedPlan.value ? 'cockpit' : 'list';
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
const d = reviewDraft.value;
|
|
848
|
+
draftEditing.value = {
|
|
849
|
+
editingId: draftEditing.value.editingId,
|
|
850
|
+
initialForm: {
|
|
851
|
+
version: d.version,
|
|
852
|
+
features: [...d.features],
|
|
853
|
+
quotas: { ...(d.quotas ?? {}) },
|
|
854
|
+
monthlyNet: d.monthlyNet,
|
|
855
|
+
yearlyNet: d.yearlyNet,
|
|
856
|
+
changeNote: d.changeNote ?? '',
|
|
857
|
+
marketed: d.marketed,
|
|
858
|
+
validFrom: d.validFrom,
|
|
859
|
+
validUntil: d.validUntil,
|
|
860
|
+
},
|
|
861
|
+
};
|
|
862
|
+
reviewError.value = null;
|
|
863
|
+
mode.value = 'editor';
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Persists the review draft (createDraft for a new version, updateDraft for
|
|
867
|
+
// an existing one). Only here does the form go to the API.
|
|
868
|
+
async function persistDraft(): Promise<PlanVersionRow | null> {
|
|
869
|
+
if (!planVersions.value || !draftEditing.value || !reviewDraft.value) return null;
|
|
870
|
+
const d = reviewDraft.value;
|
|
871
|
+
const body = {
|
|
872
|
+
features: [...d.features],
|
|
873
|
+
bundles: [...(d.bundles ?? [])],
|
|
874
|
+
quotas: { ...(d.quotas ?? {}) },
|
|
875
|
+
monthlyNet: d.monthlyNet,
|
|
876
|
+
yearlyNet: d.yearlyNet,
|
|
877
|
+
marketed: d.marketed,
|
|
878
|
+
changeNote: d.changeNote ?? '',
|
|
879
|
+
validFrom: d.validFrom,
|
|
880
|
+
validUntil: d.validUntil,
|
|
881
|
+
};
|
|
882
|
+
let result: PlanVersionMutationResult;
|
|
883
|
+
if (draftEditing.value.editingId) {
|
|
884
|
+
result = await planVersions.value.updateDraft(draftEditing.value.editingId, body);
|
|
885
|
+
} else {
|
|
886
|
+
result = await planVersions.value.createDraft(body);
|
|
887
|
+
// Draft now has an ID — remember it in case it's published right
|
|
888
|
+
// after (otherwise a second createDraft → "hat bereits eine Draft").
|
|
889
|
+
draftEditing.value = { ...draftEditing.value, editingId: result.planVersion.id };
|
|
890
|
+
}
|
|
891
|
+
await reloadCockpitVersions();
|
|
892
|
+
return result.planVersion;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// "Als Draft speichern" — persists the draft and leaves the wizard
|
|
896
|
+
// (back to the plan detail) without publishing.
|
|
897
|
+
async function onReviewSaveExit(): Promise<void> {
|
|
898
|
+
if (draftSaving.value || publishing.value) return;
|
|
899
|
+
draftSaving.value = true;
|
|
900
|
+
reviewError.value = null;
|
|
901
|
+
try {
|
|
902
|
+
const saved = await persistDraft();
|
|
903
|
+
if (!saved) return;
|
|
904
|
+
const planKey = selectedPlan.value?.planKey ?? '';
|
|
905
|
+
flashHighlight(planKey);
|
|
906
|
+
flashToast(`Draft v${saved.version} von ${planKey} gespeichert.`);
|
|
907
|
+
reviewDraft.value = null;
|
|
908
|
+
draftEditing.value = null;
|
|
909
|
+
mode.value = selectedPlan.value ? 'cockpit' : 'list';
|
|
910
|
+
} catch (err: unknown) {
|
|
911
|
+
// eslint-disable-next-line no-console
|
|
912
|
+
console.error('[PlansPage] Draft speichern fehlgeschlagen', err);
|
|
913
|
+
reviewError.value = describeDraftSaveError(err);
|
|
914
|
+
} finally {
|
|
915
|
+
draftSaving.value = false;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
async function onReviewPublish(payload: {
|
|
920
|
+
forceRegressive: boolean;
|
|
921
|
+
allowZeroPrice: boolean;
|
|
922
|
+
}): Promise<void> {
|
|
923
|
+
if (!planVersions.value || !reviewDraft.value || !selectedPlan.value) return;
|
|
924
|
+
if (publishing.value || draftSaving.value) return;
|
|
925
|
+
publishing.value = true;
|
|
926
|
+
reviewError.value = null;
|
|
927
|
+
try {
|
|
928
|
+
// Persist first (the draft doesn't exist server-side yet), then publish.
|
|
929
|
+
const saved = await persistDraft();
|
|
930
|
+
if (!saved) return;
|
|
931
|
+
const result = await planVersions.value.publish(saved.id, {
|
|
932
|
+
forceRegressive: payload.forceRegressive,
|
|
933
|
+
allowZeroPrice: payload.allowZeroPrice,
|
|
934
|
+
});
|
|
935
|
+
await reloadCockpitVersions();
|
|
936
|
+
const planKey = selectedPlan.value.planKey;
|
|
937
|
+
flashHighlight(planKey);
|
|
938
|
+
flashToast(
|
|
939
|
+
`Plan ${planKey} wurde als v${result.planVersion.version} veröffentlicht — sichtbar im Katalog.`,
|
|
940
|
+
);
|
|
941
|
+
reviewDraft.value = null;
|
|
942
|
+
draftEditing.value = null;
|
|
943
|
+
mode.value = 'cockpit';
|
|
944
|
+
} catch (err: unknown) {
|
|
945
|
+
// eslint-disable-next-line no-console
|
|
946
|
+
console.error('[PlansPage] Publish fehlgeschlagen', err);
|
|
947
|
+
reviewError.value = describePublishError(err);
|
|
948
|
+
} finally {
|
|
949
|
+
publishing.value = false;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ─── Archive / hard-delete Plan (trash icon when there's no open draft) ───
|
|
954
|
+
const archiveOpen = ref(false);
|
|
955
|
+
const archiving = ref(false);
|
|
956
|
+
const archiveError = ref<string | null>(null);
|
|
957
|
+
const archiveTarget = ref<PlanArchiveTarget | null>(null);
|
|
958
|
+
|
|
959
|
+
function onArchivePlanFromList(plan: PlanRow, hasLive: boolean): void {
|
|
960
|
+
archiveTarget.value = { plan, hasLive };
|
|
961
|
+
archiveError.value = null;
|
|
962
|
+
archiveOpen.value = true;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async function executeArchive(): Promise<void> {
|
|
966
|
+
if (!archiveTarget.value) return;
|
|
967
|
+
const { plan } = archiveTarget.value;
|
|
968
|
+
archiving.value = true;
|
|
969
|
+
archiveError.value = null;
|
|
970
|
+
try {
|
|
971
|
+
// Hard-delete is the only path: published versions block deletion
|
|
972
|
+
// server-side, drafts must be removed first via the discard route.
|
|
973
|
+
// The UI aborts on both before this call is even reached.
|
|
974
|
+
await hardDelete(plan.id);
|
|
975
|
+
await reloadAllVersions();
|
|
976
|
+
archiveOpen.value = false;
|
|
977
|
+
flashToast(`Plan ${plan.planKey} komplett aus der DB gelöscht.`);
|
|
978
|
+
} catch (err: unknown) {
|
|
979
|
+
// eslint-disable-next-line no-console
|
|
980
|
+
console.error('[PlansPage] Archive/Purge fehlgeschlagen', err);
|
|
981
|
+
const status = (err as { status?: number })?.status;
|
|
982
|
+
const body = (err as { body?: { code?: string; message?: string } })?.body;
|
|
983
|
+
const errMessage = err instanceof Error ? err.message : String(err);
|
|
984
|
+
if (status === 422 && body?.code === 'PLAN_HAS_PUBLISHED_VERSIONS') {
|
|
985
|
+
archiveError.value =
|
|
986
|
+
body.message ??
|
|
987
|
+
'Plan hat published Versionen — kann nicht gelöscht werden (Vertragsschutz P1).';
|
|
988
|
+
} else if (status === 422 && body?.code === 'PLAN_HAS_DRAFTS') {
|
|
989
|
+
archiveError.value =
|
|
990
|
+
body.message ??
|
|
991
|
+
'Plan hat noch einen offenen Draft — bitte erst über das Mülleimer-Icon verwerfen.';
|
|
992
|
+
} else if (status === 422 && body?.code === 'PLAN_HARD_DELETE_NOT_IMPLEMENTED') {
|
|
993
|
+
archiveError.value =
|
|
994
|
+
'Backend unterstützt Hard-Delete noch nicht — API-Server neu bauen + starten.';
|
|
995
|
+
} else if (status === 404) {
|
|
996
|
+
archiveError.value = 'Plan wurde bereits entfernt (404). Liste wird neu geladen.';
|
|
997
|
+
await reloadAllVersions();
|
|
998
|
+
} else if (status !== undefined) {
|
|
999
|
+
archiveError.value = body?.message ?? `Vorgang fehlgeschlagen (HTTP ${status}).`;
|
|
1000
|
+
} else {
|
|
1001
|
+
archiveError.value = `Vorgang fehlgeschlagen: ${errMessage}. Details siehe Browser-Konsole.`;
|
|
1002
|
+
}
|
|
1003
|
+
} finally {
|
|
1004
|
+
archiving.value = false;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// ─── Discard draft (plan simulation: trash icon on the row) ───
|
|
1009
|
+
const discardOpen = ref(false);
|
|
1010
|
+
const discarding = ref(false);
|
|
1011
|
+
const discardError = ref<string | null>(null);
|
|
1012
|
+
const discardTarget = ref<PlanDiscardTarget | null>(null);
|
|
1013
|
+
|
|
1014
|
+
function onDiscardDraftFromList(plan: PlanRow, draft: PlanVersionRow): void {
|
|
1015
|
+
discardTarget.value = { plan, draft };
|
|
1016
|
+
discardError.value = null;
|
|
1017
|
+
discardOpen.value = true;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async function executeDiscard(): Promise<void> {
|
|
1021
|
+
if (!discardTarget.value) return;
|
|
1022
|
+
const { plan, draft } = discardTarget.value;
|
|
1023
|
+
discarding.value = true;
|
|
1024
|
+
discardError.value = null;
|
|
1025
|
+
try {
|
|
1026
|
+
// Per-Plan composable on demand, because the list usually doesn't have
|
|
1027
|
+
// a Plan's cockpit open. Doesn't persist into `planVersions` (that's
|
|
1028
|
+
// the cockpit composable); afterwards we reload the bulk state so the
|
|
1029
|
+
// list loses the removed draft immediately.
|
|
1030
|
+
const pv = usePlanVersions({
|
|
1031
|
+
adminEndpoint: props.adminEndpoint,
|
|
1032
|
+
planId: plan.id,
|
|
1033
|
+
http: props.http,
|
|
1034
|
+
getAuthToken: props.getAuthToken,
|
|
1035
|
+
});
|
|
1036
|
+
await pv.discardDraft(draft.id);
|
|
1037
|
+
await reloadAllVersions();
|
|
1038
|
+
discardOpen.value = false;
|
|
1039
|
+
flashToast(
|
|
1040
|
+
`Draft v${draft.version} von ${plan.planKey} verworfen — Live-Version unverändert.`,
|
|
1041
|
+
);
|
|
1042
|
+
} catch (err: unknown) {
|
|
1043
|
+
// Full error object to the console — in the diagnostic case you can see
|
|
1044
|
+
// e.g. CORS preflight errors or the real network stack there.
|
|
1045
|
+
// eslint-disable-next-line no-console
|
|
1046
|
+
console.error('[PlansPage] Discard fehlgeschlagen', err);
|
|
1047
|
+
|
|
1048
|
+
const status = (err as { status?: number })?.status;
|
|
1049
|
+
const body = (err as { body?: { code?: string; message?: string } })?.body;
|
|
1050
|
+
const errMessage = err instanceof Error ? err.message : String(err);
|
|
1051
|
+
|
|
1052
|
+
if (status === 422 && body?.code === 'PLAN_VERSION_ALREADY_PUBLISHED') {
|
|
1053
|
+
discardError.value =
|
|
1054
|
+
'Diese Version ist bereits published und kann nicht verworfen werden.';
|
|
1055
|
+
} else if (status === 422 && body?.code === 'PLAN_VERSION_DISCARD_NOT_IMPLEMENTED') {
|
|
1056
|
+
discardError.value =
|
|
1057
|
+
'Backend unterstützt das Verwerfen noch nicht — bitte API-Server neu bauen + starten.';
|
|
1058
|
+
} else if (status === 404) {
|
|
1059
|
+
discardError.value =
|
|
1060
|
+
'Draft wurde bereits entfernt (404) — Liste wird gleich neu geladen.';
|
|
1061
|
+
await reloadAllVersions();
|
|
1062
|
+
} else if (status !== undefined) {
|
|
1063
|
+
discardError.value = body?.message ?? `Verwerfen fehlgeschlagen (HTTP ${status}).`;
|
|
1064
|
+
} else {
|
|
1065
|
+
// No HTTP status → usually a network error / CORS preflight / failed
|
|
1066
|
+
// caller-side (e.g. usePlanVersions construction). Show the real
|
|
1067
|
+
// error message so the diagnostic path doesn't end at "HTTP ?".
|
|
1068
|
+
discardError.value = `Verwerfen fehlgeschlagen: ${errMessage}. Details siehe Browser-Konsole (oft: API-Server alt gebaut oder CORS blockiert DELETE).`;
|
|
1069
|
+
}
|
|
1070
|
+
} finally {
|
|
1071
|
+
discarding.value = false;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// ─── Terminate (end date for a published version) ───
|
|
1076
|
+
// Called by PlanDetail.vue as a prop callback. Uses the usePlanVersions
|
|
1077
|
+
// composable of the current cockpit so the UI state (versions ref) stays
|
|
1078
|
+
// consistent after the call.
|
|
1079
|
+
async function onSubmitTerminate(versionId: string, endsAt: string): Promise<void> {
|
|
1080
|
+
if (!planVersions.value) {
|
|
1081
|
+
throw new Error('PlanVersions-Composable nicht initialisiert');
|
|
1082
|
+
}
|
|
1083
|
+
await planVersions.value.terminateVersion(versionId, endsAt);
|
|
1084
|
+
await reloadCockpitVersions();
|
|
1085
|
+
if (selectedPlan.value) {
|
|
1086
|
+
flashToast(
|
|
1087
|
+
`v${versions.value.find((v) => v.id === versionId)?.version ?? '?'} terminiert.`,
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// ─── Publish ───
|
|
1093
|
+
const publishOpen = ref(false);
|
|
1094
|
+
const publishTarget = ref<PlanVersionRow | null>(null);
|
|
1095
|
+
const publishing = ref(false);
|
|
1096
|
+
const forceRegressive = ref(false);
|
|
1097
|
+
const allowZeroPrice = ref(false);
|
|
1098
|
+
const publishError = ref<string | null>(null);
|
|
1099
|
+
const regressionChanges = ref<RegressionChange[]>([]);
|
|
1100
|
+
|
|
1101
|
+
function openPublish(row: PlanVersionRow): void {
|
|
1102
|
+
publishTarget.value = row;
|
|
1103
|
+
forceRegressive.value = false;
|
|
1104
|
+
allowZeroPrice.value = false;
|
|
1105
|
+
publishError.value = null;
|
|
1106
|
+
regressionChanges.value = [];
|
|
1107
|
+
publishOpen.value = true;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
async function executePublish(): Promise<void> {
|
|
1111
|
+
if (!planVersions.value || !publishTarget.value) return;
|
|
1112
|
+
publishing.value = true;
|
|
1113
|
+
publishError.value = null;
|
|
1114
|
+
try {
|
|
1115
|
+
const result = await planVersions.value.publish(publishTarget.value.id, {
|
|
1116
|
+
forceRegressive: forceRegressive.value,
|
|
1117
|
+
allowZeroPrice: allowZeroPrice.value,
|
|
1118
|
+
});
|
|
1119
|
+
await reloadCockpitVersions();
|
|
1120
|
+
publishOpen.value = false;
|
|
1121
|
+
regressionChanges.value = [];
|
|
1122
|
+
if (selectedPlan.value) {
|
|
1123
|
+
flashHighlight(selectedPlan.value.planKey);
|
|
1124
|
+
flashToast(
|
|
1125
|
+
`Plan ${selectedPlan.value.planKey} wurde als v${result.planVersion.version} veröffentlicht — sichtbar im Katalog.`,
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
} catch (err: unknown) {
|
|
1129
|
+
publishError.value = describePublishError(err);
|
|
1130
|
+
const body = (err as { body?: { code?: string; changes?: unknown } }).body;
|
|
1131
|
+
if (body?.code === 'PLAN_VERSION_REGRESSION' && Array.isArray(body.changes)) {
|
|
1132
|
+
regressionChanges.value = (body.changes as RegressionChange[]).filter(
|
|
1133
|
+
(c) => c.direction === 'REGRESSION',
|
|
1134
|
+
);
|
|
1135
|
+
} else {
|
|
1136
|
+
regressionChanges.value = [];
|
|
1137
|
+
}
|
|
1138
|
+
} finally {
|
|
1139
|
+
publishing.value = false;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const REGRESSION_FIELD_LABELS: Record<string, string> = {
|
|
1144
|
+
monthlyNet: 'Preis (monatlich, netto)',
|
|
1145
|
+
yearlyNet: 'Preis (jährlich, netto)',
|
|
1146
|
+
'features.removed': 'Entfernte Features',
|
|
1147
|
+
'features.added': 'Hinzugefügte Features',
|
|
1148
|
+
'quotas.lowered': 'Gesenkte Quotas',
|
|
1149
|
+
'quotas.raised': 'Erhöhte Quotas',
|
|
1150
|
+
'bundles.removed': 'Entfernte Bundles',
|
|
1151
|
+
'bundles.added': 'Hinzugefügte Bundles',
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
function fieldLabel(field: string): string {
|
|
1155
|
+
return REGRESSION_FIELD_LABELS[field] ?? field;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function formatChangeValue(value: unknown): string {
|
|
1159
|
+
if (value === null || value === undefined || value === '') return '—';
|
|
1160
|
+
if (Array.isArray(value)) {
|
|
1161
|
+
return value.length === 0 ? '—' : (value as unknown[]).join(', ');
|
|
1162
|
+
}
|
|
1163
|
+
if (typeof value === 'object') {
|
|
1164
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
1165
|
+
if (entries.length === 0) return '—';
|
|
1166
|
+
return entries.map(([k, v]) => `${k}: ${String(v)}`).join(', ');
|
|
1167
|
+
}
|
|
1168
|
+
return String(value);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Translates a publish API error message into a displayable text.
|
|
1172
|
+
// Shared by executePublish (cockpit dialog) and onReviewPublish (review
|
|
1173
|
+
// screen).
|
|
1174
|
+
function describePublishError(err: unknown): string {
|
|
1175
|
+
const status = (err as { status?: number })?.status;
|
|
1176
|
+
const body = (err as { body?: { code?: string; message?: string } })?.body;
|
|
1177
|
+
if (status === 401 || status === 403) {
|
|
1178
|
+
return 'Sitzung abgelaufen — bitte neu anmelden und erneut versuchen.';
|
|
1179
|
+
}
|
|
1180
|
+
if (status === 422 && body?.code === 'PLAN_VERSION_REGRESSION') {
|
|
1181
|
+
return 'Diese Version ist regressiv. Aktiviere "Force-Publish", wenn der Schritt absichtlich ist.';
|
|
1182
|
+
}
|
|
1183
|
+
if (status === 422 && body?.code === 'PLAN_VERSION_VALID_FROM_REQUIRED') {
|
|
1184
|
+
return 'Beim Publish muss "Gültig ab" gesetzt sein. Bitte den Draft öffnen und das Datum eintragen.';
|
|
1185
|
+
}
|
|
1186
|
+
if (status === 422 && body?.code === 'PLAN_VERSION_VALID_FROM_NOT_AFTER_PREVIOUS') {
|
|
1187
|
+
return '"Gültig ab" muss strikt nach dem "Gültig ab" der Vorgänger-Version liegen. Bitte ein späteres Datum wählen.';
|
|
1188
|
+
}
|
|
1189
|
+
if (status === 422 && body?.code === 'PLAN_VERSION_ZERO_PRICE') {
|
|
1190
|
+
return 'Diese Version hat Preis 0,00 (Schutz gegen Seed-Platzhalter). Aktiviere "Preis 0,00 bewusst zulassen", wenn das ein gewollter kostenloser Sondervertrag ist.';
|
|
1191
|
+
}
|
|
1192
|
+
return body?.message ?? `Publish fehlgeschlagen (HTTP ${status ?? '?'})`;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
watch(plans, async () => {
|
|
1196
|
+
if (plans.value.length > 0) {
|
|
1197
|
+
await Promise.all([reloadAllVersions(), loadTenantCounts()]);
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
onMounted(async () => {
|
|
1202
|
+
await load();
|
|
1203
|
+
await Promise.all([loadEditorSources(), reloadAllVersions(), loadTenantCounts()]);
|
|
1204
|
+
});
|
|
1205
|
+
</script>
|
|
1206
|
+
|
|
1207
|
+
<style scoped>
|
|
1208
|
+
.sa-plans {
|
|
1209
|
+
min-height: calc(100vh - 56px);
|
|
1210
|
+
background: #f6f7f9;
|
|
1211
|
+
position: relative;
|
|
1212
|
+
}
|
|
1213
|
+
.sa-plans__loading {
|
|
1214
|
+
display: flex;
|
|
1215
|
+
align-items: center;
|
|
1216
|
+
justify-content: center;
|
|
1217
|
+
gap: 12px;
|
|
1218
|
+
padding: 48px;
|
|
1219
|
+
color: #64748b;
|
|
1220
|
+
font-size: 14px;
|
|
1221
|
+
}
|
|
1222
|
+
</style>
|