@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,375 @@
1
+ // usePlans + usePlanVersions — Vue 3 composables for the SuperAdmin
2
+ // plan management. SPEC_V2 §11.1 M6:
3
+ // Pack 1 → usePlans (root CRUD)
4
+ // Pack 2a → usePlanVersions (draft/publish lifecycle)
5
+
6
+ import { ref, type Ref } from 'vue';
7
+ import type {
8
+ CreatePlanData,
9
+ CreatePlanVersionDraftData,
10
+ PlanRow,
11
+ PlanVersionMutationResult,
12
+ PlanVersionRow,
13
+ UpdatePlanData,
14
+ UpdatePlanVersionDraftData,
15
+ } from '@saasicat/types';
16
+ import { defaultHttpClient, type HttpClient } from './types.js';
17
+
18
+ export interface UsePlansOptions {
19
+ /**
20
+ * Fully-qualified admin endpoint prefix incl. the app globalPrefix
21
+ * (`/api/admin`, `/api/v1/admin`, …). Required.
22
+ */
23
+ adminEndpoint: string;
24
+ http?: HttpClient;
25
+ getAuthToken?: () => string | null;
26
+ /** Required: projectKey the list is filtered against. */
27
+ projectKey: string;
28
+ /** With `true`, loads on composable init. Default `false`. */
29
+ autoLoad?: boolean;
30
+ }
31
+
32
+ export class PlansApiError extends Error {
33
+ constructor(
34
+ public readonly status: number,
35
+ public readonly body: unknown,
36
+ message: string,
37
+ ) {
38
+ super(message);
39
+ this.name = 'PlansApiError';
40
+ }
41
+ }
42
+
43
+ export interface UsePlansResult {
44
+ plans: Ref<PlanRow[]>;
45
+ loading: Ref<boolean>;
46
+ error: Ref<Error | null>;
47
+ /**
48
+ * planKey → number of active (ACTIVE/TRIAL) subscriptions, across
49
+ * versions and tenants. Plans without a subscription are absent from the
50
+ * map (default 0 on read). Populated by `loadTenantCounts()`.
51
+ */
52
+ tenantCountsByPlanKey: Ref<Record<string, number>>;
53
+
54
+ load: () => Promise<void>;
55
+ /**
56
+ * Loads the platform-wide tenant counters
57
+ * (`GET /admin/catalog/plans/tenant-counts?projectKey=…`) and writes them
58
+ * into `tenantCountsByPlanKey`. Best-effort: errors are swallowed
59
+ * (empty map), since the counters are only decorative in the plan overview.
60
+ */
61
+ loadTenantCounts: () => Promise<void>;
62
+ create: (data: CreatePlanData) => Promise<PlanRow>;
63
+ update: (planId: string, data: UpdatePlanData) => Promise<PlanRow>;
64
+ softDelete: (planId: string) => Promise<void>;
65
+ /**
66
+ * Hard delete (`DELETE /admin/catalog/plans/:id/purge`). Only allowed
67
+ * for plans without PlanVersions — otherwise the backend responds with
68
+ * 422 `PLAN_HAS_VERSIONS`. Also removes the plan from `plans`.
69
+ */
70
+ hardDelete: (planId: string) => Promise<void>;
71
+ }
72
+
73
+ export function usePlans(options: UsePlansOptions): UsePlansResult {
74
+ if (!options?.adminEndpoint) {
75
+ throw new Error(
76
+ 'usePlans: `adminEndpoint` ist Pflicht (z. B. "/api/admin" oder ' + '"/api/v1/admin").',
77
+ );
78
+ }
79
+ if (!options?.projectKey) {
80
+ throw new Error('usePlans: `projectKey` ist Pflicht.');
81
+ }
82
+
83
+ const http = options.http ?? defaultHttpClient();
84
+ const plans = ref<PlanRow[]>([]);
85
+ const loading = ref(false);
86
+ const error = ref<Error | null>(null);
87
+ const tenantCountsByPlanKey = ref<Record<string, number>>({});
88
+
89
+ const baseUrl = `${options.adminEndpoint}/catalog/plans`;
90
+
91
+ function authHeaders(): Record<string, string> {
92
+ const token = options.getAuthToken?.();
93
+ return token ? { Authorization: `Bearer ${token}` } : {};
94
+ }
95
+
96
+ async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
97
+ const res = await http(url, {
98
+ method: init?.method ?? 'GET',
99
+ headers: {
100
+ 'content-type': 'application/json',
101
+ ...authHeaders(),
102
+ ...init?.headers,
103
+ },
104
+ body: init?.body,
105
+ });
106
+ if (res.status === 204) return null;
107
+ const body = await res.json().catch(() => null);
108
+ if (res.status >= 400) {
109
+ throw new PlansApiError(
110
+ res.status,
111
+ body,
112
+ `Plans-API antwortete mit HTTP ${res.status}`,
113
+ );
114
+ }
115
+ return body as T;
116
+ }
117
+
118
+ async function load(): Promise<void> {
119
+ loading.value = true;
120
+ error.value = null;
121
+ try {
122
+ const data = await fetchJson<PlanRow[]>(
123
+ `${baseUrl}?projectKey=${encodeURIComponent(options.projectKey)}`,
124
+ );
125
+ plans.value = data ?? [];
126
+ } catch (err) {
127
+ error.value = err instanceof Error ? err : new Error(String(err));
128
+ } finally {
129
+ loading.value = false;
130
+ }
131
+ }
132
+
133
+ async function loadTenantCounts(): Promise<void> {
134
+ try {
135
+ const data = await fetchJson<Record<string, number>>(
136
+ `${baseUrl}/tenant-counts?projectKey=${encodeURIComponent(options.projectKey)}`,
137
+ );
138
+ tenantCountsByPlanKey.value = data ?? {};
139
+ } catch {
140
+ // Best-effort: counters are decorative — do not block plan loading.
141
+ tenantCountsByPlanKey.value = {};
142
+ }
143
+ }
144
+
145
+ async function create(data: CreatePlanData): Promise<PlanRow> {
146
+ const created = await fetchJson<PlanRow>(baseUrl, {
147
+ method: 'POST',
148
+ body: JSON.stringify(data),
149
+ });
150
+ if (!created) throw new PlansApiError(0, null, 'Create gab keinen Body zurück');
151
+ plans.value = [...plans.value, created];
152
+ return created;
153
+ }
154
+
155
+ async function update(planId: string, data: UpdatePlanData): Promise<PlanRow> {
156
+ const updated = await fetchJson<PlanRow>(`${baseUrl}/${planId}`, {
157
+ method: 'PATCH',
158
+ body: JSON.stringify(data),
159
+ });
160
+ if (!updated) throw new PlansApiError(0, null, 'Update gab keinen Body zurück');
161
+ plans.value = plans.value.map((p) => (p.id === planId ? updated : p));
162
+ return updated;
163
+ }
164
+
165
+ async function softDelete(planId: string): Promise<void> {
166
+ await fetchJson<null>(`${baseUrl}/${planId}`, { method: 'DELETE' });
167
+ plans.value = plans.value.filter((p) => p.id !== planId);
168
+ }
169
+
170
+ async function hardDelete(planId: string): Promise<void> {
171
+ await fetchJson<null>(`${baseUrl}/${planId}/purge`, { method: 'DELETE' });
172
+ plans.value = plans.value.filter((p) => p.id !== planId);
173
+ }
174
+
175
+ if (options.autoLoad) {
176
+ void load();
177
+ }
178
+
179
+ return {
180
+ plans,
181
+ loading,
182
+ error,
183
+ tenantCountsByPlanKey,
184
+ load,
185
+ loadTenantCounts,
186
+ create,
187
+ update,
188
+ softDelete,
189
+ hardDelete,
190
+ };
191
+ }
192
+
193
+ // =============================================================================
194
+ // usePlanVersions — lifecycle operations for a specific plan ID
195
+ // (SPEC_V2 §11.1 M6 Pack 2a).
196
+ // =============================================================================
197
+
198
+ export interface UsePlanVersionsOptions {
199
+ adminEndpoint: string;
200
+ /** UUID of the plan root (Plan.id), not the planKey. */
201
+ planId: string;
202
+ http?: HttpClient;
203
+ getAuthToken?: () => string | null;
204
+ autoLoad?: boolean;
205
+ }
206
+
207
+ export interface UsePlanVersionsResult {
208
+ versions: Ref<PlanVersionRow[]>;
209
+ loading: Ref<boolean>;
210
+ error: Ref<Error | null>;
211
+
212
+ load: () => Promise<void>;
213
+ /**
214
+ * The caller does not need to set `data.planId` — it comes from the
215
+ * composable options (`adminEndpoint/catalog/plans/:id/versions`).
216
+ */
217
+ createDraft: (
218
+ data: Omit<CreatePlanVersionDraftData, 'planId'>,
219
+ ) => Promise<PlanVersionMutationResult>;
220
+ updateDraft: (
221
+ versionId: string,
222
+ data: UpdatePlanVersionDraftData,
223
+ ) => Promise<PlanVersionMutationResult>;
224
+ publish: (
225
+ versionId: string,
226
+ opts?: {
227
+ forceRegressive?: boolean;
228
+ allowZeroPrice?: boolean;
229
+ validFrom?: string | null;
230
+ validUntil?: string | null;
231
+ },
232
+ ) => Promise<PlanVersionMutationResult>;
233
+ /**
234
+ * Discards a draft (`DELETE /admin/catalog/plan-versions/:id`).
235
+ * Published versions cannot be discarded — the API responds with
236
+ * 422 and code `PLAN_VERSION_ALREADY_PUBLISHED`.
237
+ */
238
+ discardDraft: (versionId: string) => Promise<void>;
239
+ /**
240
+ * Terminates a live PlanVersion with `endsAt` (without a successor
241
+ * version). Idempotent — a second call with a different date overwrites.
242
+ */
243
+ terminateVersion: (versionId: string, endsAt: string) => Promise<PlanVersionRow>;
244
+ }
245
+
246
+ export function usePlanVersions(options: UsePlanVersionsOptions): UsePlanVersionsResult {
247
+ if (!options?.adminEndpoint) {
248
+ throw new Error('usePlanVersions: `adminEndpoint` ist Pflicht.');
249
+ }
250
+ if (!options?.planId) {
251
+ throw new Error('usePlanVersions: `planId` ist Pflicht.');
252
+ }
253
+
254
+ const http = options.http ?? defaultHttpClient();
255
+ const versions = ref<PlanVersionRow[]>([]);
256
+ const loading = ref(false);
257
+ const error = ref<Error | null>(null);
258
+
259
+ const planVersionsUrl = `${options.adminEndpoint}/catalog/plans/${options.planId}/versions`;
260
+ const versionUrlBase = `${options.adminEndpoint}/catalog/plan-versions`;
261
+
262
+ function authHeaders(): Record<string, string> {
263
+ const token = options.getAuthToken?.();
264
+ return token ? { Authorization: `Bearer ${token}` } : {};
265
+ }
266
+
267
+ async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
268
+ const res = await http(url, {
269
+ method: init?.method ?? 'GET',
270
+ headers: {
271
+ 'content-type': 'application/json',
272
+ ...authHeaders(),
273
+ ...init?.headers,
274
+ },
275
+ body: init?.body,
276
+ });
277
+ if (res.status === 204) return null;
278
+ const body = await res.json().catch(() => null);
279
+ if (res.status >= 400) {
280
+ throw new PlansApiError(
281
+ res.status,
282
+ body,
283
+ `PlanVersions-API antwortete mit HTTP ${res.status}`,
284
+ );
285
+ }
286
+ return body as T;
287
+ }
288
+
289
+ async function load(): Promise<void> {
290
+ loading.value = true;
291
+ error.value = null;
292
+ try {
293
+ const data = await fetchJson<PlanVersionRow[]>(planVersionsUrl);
294
+ versions.value = data ?? [];
295
+ } catch (err) {
296
+ error.value = err instanceof Error ? err : new Error(String(err));
297
+ } finally {
298
+ loading.value = false;
299
+ }
300
+ }
301
+
302
+ async function createDraft(
303
+ data: Omit<CreatePlanVersionDraftData, 'planId'>,
304
+ ): Promise<PlanVersionMutationResult> {
305
+ const result = await fetchJson<PlanVersionMutationResult>(planVersionsUrl, {
306
+ method: 'POST',
307
+ body: JSON.stringify(data),
308
+ });
309
+ if (!result) throw new PlansApiError(0, null, 'CreateDraft gab keinen Body zurück');
310
+ versions.value = [...versions.value, result.planVersion];
311
+ return result;
312
+ }
313
+
314
+ async function updateDraft(
315
+ versionId: string,
316
+ data: UpdatePlanVersionDraftData,
317
+ ): Promise<PlanVersionMutationResult> {
318
+ const result = await fetchJson<PlanVersionMutationResult>(
319
+ `${versionUrlBase}/${versionId}`,
320
+ { method: 'PATCH', body: JSON.stringify(data) },
321
+ );
322
+ if (!result) throw new PlansApiError(0, null, 'UpdateDraft gab keinen Body zurück');
323
+ versions.value = versions.value.map((v) => (v.id === versionId ? result.planVersion : v));
324
+ return result;
325
+ }
326
+
327
+ async function publish(
328
+ versionId: string,
329
+ opts: {
330
+ forceRegressive?: boolean;
331
+ allowZeroPrice?: boolean;
332
+ validFrom?: string | null;
333
+ validUntil?: string | null;
334
+ } = {},
335
+ ): Promise<PlanVersionMutationResult> {
336
+ const result = await fetchJson<PlanVersionMutationResult>(
337
+ `${versionUrlBase}/${versionId}/publish`,
338
+ { method: 'POST', body: JSON.stringify(opts) },
339
+ );
340
+ if (!result) throw new PlansApiError(0, null, 'Publish gab keinen Body zurück');
341
+ versions.value = versions.value.map((v) => (v.id === versionId ? result.planVersion : v));
342
+ return result;
343
+ }
344
+
345
+ async function discardDraft(versionId: string): Promise<void> {
346
+ await fetchJson<null>(`${versionUrlBase}/${versionId}`, { method: 'DELETE' });
347
+ versions.value = versions.value.filter((v) => v.id !== versionId);
348
+ }
349
+
350
+ async function terminateVersion(versionId: string, endsAt: string): Promise<PlanVersionRow> {
351
+ const updated = await fetchJson<PlanVersionRow>(
352
+ `${versionUrlBase}/${versionId}/terminate`,
353
+ { method: 'POST', body: JSON.stringify({ endsAt }) },
354
+ );
355
+ if (!updated) throw new PlansApiError(0, null, 'Terminate gab keinen Body zurück');
356
+ versions.value = versions.value.map((v) => (v.id === versionId ? updated : v));
357
+ return updated;
358
+ }
359
+
360
+ if (options.autoLoad) {
361
+ void load();
362
+ }
363
+
364
+ return {
365
+ versions,
366
+ loading,
367
+ error,
368
+ load,
369
+ createDraft,
370
+ updateDraft,
371
+ publish,
372
+ discardDraft,
373
+ terminateVersion,
374
+ };
375
+ }
@@ -0,0 +1,286 @@
1
+ // usePlatformTenantActions — encapsulates the orchestration that was
2
+ // previously duplicated in every app wrapper (the tenants pages of the
3
+ // consumer admins):
4
+ // - MFA dialog state + promise resolver (Quasar dialog × promise flow)
5
+ // - Confirm dialog state + promise resolver
6
+ // - `useTenantActionFlow` wiring (confirm/mfa/notify/onSuccess providers)
7
+ // - Manifest → `TenantRowAction` mapping (icon/tone + condition/handler)
8
+ // - Drift diagnosis (`realOrphans` with capability filter)
9
+ //
10
+ // Used internally by `pages-standard/TenantsPage.vue` when the app
11
+ // provides a `manifest` prop. Apps then no longer have to set up the
12
+ // state boilerplate themselves.
13
+
14
+ import { computed, ref, type ComputedRef, type Ref } from 'vue';
15
+ import type { AdminManifest, TenantActionDef, TenantDto } from '@saasicat/types';
16
+ import { useTenantActionFlow } from './use-tenant-action-flow.js';
17
+
18
+ export type TenantRowLike = TenantDto & Record<string, unknown>;
19
+
20
+ export interface PlatformTenantActionTone {
21
+ tone: 'positive' | 'negative' | 'primary' | 'warning' | 'accent';
22
+ }
23
+
24
+ export interface PlatformTenantActionRow<TRow extends TenantRowLike> {
25
+ id: string;
26
+ label: string;
27
+ icon: string;
28
+ tone: PlatformTenantActionTone['tone'];
29
+ actionKey: string;
30
+ condition: (row: TRow) => boolean;
31
+ handler: (row: TRow) => void;
32
+ }
33
+
34
+ export interface PlatformTenantActionsOptions<TRow extends TenantRowLike> {
35
+ /** Reactive manifest source — usually `storeToRefs(useManifestStore()).manifest`. */
36
+ manifest: Ref<AdminManifest | null>;
37
+ /** Notify provider (toast/snackbar). */
38
+ notify: (kind: 'positive' | 'negative', message: string) => void;
39
+ /** Success hook after a successful dispatch (e.g. reload the list). */
40
+ onSuccess: () => Promise<void> | void;
41
+ /** Row-specific visibility. Default: suspend only for active, reactivate only for inactive tenants. */
42
+ visibleForRow?: (def: TenantActionDef, row: TRow) => boolean;
43
+ /** MFA dialog description. Default: `"{def.label} — Tenant „{row.name}". TOTP-Code aus Authenticator eingeben."`. */
44
+ mfaDescription?: (def: TenantActionDef, row: TRow) => string;
45
+ /** Manifest action → icon. Default: suspend→block, reactivate→play_arrow, grant→star, revoke→star_outline, impersonate→switch_account, export→download, otherwise→bolt. */
46
+ iconForActionKey?: (actionKey: string) => string;
47
+ /** Manifest action → tone. Default: suspend/revoke→negative, reactivate/grant→positive, otherwise→primary. */
48
+ toneForActionKey?: (actionKey: string) => PlatformTenantActionTone['tone'];
49
+ }
50
+
51
+ type ConfirmResult = {
52
+ ok: boolean;
53
+ reason?: string | null;
54
+ extras?: Record<string, unknown>;
55
+ };
56
+
57
+ export interface MfaDialogState {
58
+ show: boolean;
59
+ description: string;
60
+ error: string;
61
+ }
62
+
63
+ export interface ConfirmDialogState<TRow extends TenantRowLike> {
64
+ show: boolean;
65
+ def: TenantActionDef | null;
66
+ row: TRow | null;
67
+ }
68
+
69
+ export interface PlatformTenantActionsResult<TRow extends TenantRowLike> {
70
+ /** Reactive list of fully configured row actions — pass straight to the page action renderer. */
71
+ manifestActions: ComputedRef<PlatformTenantActionRow<TRow>[]>;
72
+ /** Manifest drift: declared actions without a handler (Capability=false filtered out). */
73
+ realOrphans: ComputedRef<string[]>;
74
+ // MFA dialog state + handler — bind directly to `<MfaPromptDialog>`.
75
+ mfa: Ref<MfaDialogState>;
76
+ onMfaConfirm: (code: string) => void;
77
+ onMfaDialogVisibility: (open: boolean) => void;
78
+ // Confirm dialog state + handler — bind directly to `<TenantActionConfirmDialog>`.
79
+ confirmDialog: Ref<ConfirmDialogState<TRow>>;
80
+ onConfirmSubmit: (payload: { reason: string | null; extras?: Record<string, unknown> }) => void;
81
+ onConfirmCancel: () => void;
82
+ onConfirmDialogVisibility: (open: boolean) => void;
83
+ }
84
+
85
+ const DEFAULT_VISIBLE_FOR_ROW = <TRow extends TenantRowLike>(
86
+ def: TenantActionDef,
87
+ row: TRow,
88
+ ): boolean => {
89
+ if (def.actionKey === 'tenants.suspend') return row.isActive;
90
+ if (def.actionKey === 'tenants.reactivate') return !row.isActive;
91
+ return true;
92
+ };
93
+
94
+ const DEFAULT_MFA_DESCRIPTION = <TRow extends TenantRowLike>(
95
+ def: TenantActionDef,
96
+ row: TRow,
97
+ ): string => `${def.label} — Tenant „${row.name}". TOTP-Code aus Authenticator eingeben.`;
98
+
99
+ const DEFAULT_ICON_FOR_ACTION_KEY = (actionKey: string): string => {
100
+ if (actionKey.endsWith('.suspend')) return 'block';
101
+ if (actionKey.endsWith('.reactivate')) return 'play_arrow';
102
+ if (actionKey.endsWith('.grant')) return 'star';
103
+ if (actionKey.endsWith('.revoke')) return 'star_outline';
104
+ if (actionKey.endsWith('.impersonate')) return 'switch_account';
105
+ if (actionKey.endsWith('.export')) return 'download';
106
+ return 'bolt';
107
+ };
108
+
109
+ const DEFAULT_TONE_FOR_ACTION_KEY = (actionKey: string): PlatformTenantActionTone['tone'] => {
110
+ if (actionKey.endsWith('.suspend') || actionKey.endsWith('.revoke')) return 'negative';
111
+ if (actionKey.endsWith('.reactivate') || actionKey.endsWith('.grant')) return 'positive';
112
+ return 'primary';
113
+ };
114
+
115
+ /**
116
+ * Default helper for apps that want to override `iconForActionKey`
117
+ * themselves but use the platform defaults as a fallback.
118
+ */
119
+ export function defaultIconForActionKey(actionKey: string): string {
120
+ return DEFAULT_ICON_FOR_ACTION_KEY(actionKey);
121
+ }
122
+
123
+ /**
124
+ * Default helper for apps that want to override `toneForActionKey`
125
+ * themselves but use the platform defaults as a fallback.
126
+ */
127
+ export function defaultToneForActionKey(actionKey: string): PlatformTenantActionTone['tone'] {
128
+ return DEFAULT_TONE_FOR_ACTION_KEY(actionKey);
129
+ }
130
+
131
+ export function usePlatformTenantActions<TRow extends TenantRowLike>(
132
+ options: PlatformTenantActionsOptions<TRow>,
133
+ ): PlatformTenantActionsResult<TRow> {
134
+ const mfaDescription = options.mfaDescription ?? DEFAULT_MFA_DESCRIPTION;
135
+ const iconForActionKey = options.iconForActionKey ?? DEFAULT_ICON_FOR_ACTION_KEY;
136
+ const toneForActionKey = options.toneForActionKey ?? DEFAULT_TONE_FOR_ACTION_KEY;
137
+ const visibleForRow = options.visibleForRow ?? DEFAULT_VISIBLE_FOR_ROW;
138
+
139
+ // ── MFA dialog state + resolver ───────────────────────────────────
140
+ // The `mfa` provider below sets `pendingMfaResolve` and waits until
141
+ // `onMfaConfirm` (submit) or cancellation (updating v-model to false)
142
+ // resolves it. This merges the declarative Quasar dialog with the
143
+ // promise-based action flow.
144
+ const mfa = ref<MfaDialogState>({ show: false, description: '', error: '' });
145
+ let pendingMfaResolve: ((code: string | null) => void) | null = null;
146
+
147
+ function showMfaDialog(def: TenantActionDef, ctx: { row: TRow }): Promise<string | null> {
148
+ return new Promise((resolve) => {
149
+ mfa.value = {
150
+ show: true,
151
+ description: mfaDescription(def, ctx.row),
152
+ error: '',
153
+ };
154
+ pendingMfaResolve = (code) => {
155
+ pendingMfaResolve = null;
156
+ resolve(code);
157
+ };
158
+ });
159
+ }
160
+
161
+ function onMfaConfirm(code: string): void {
162
+ pendingMfaResolve?.(code);
163
+ mfa.value.show = false;
164
+ }
165
+
166
+ // The dialog can also be closed via v-close-popup (cancel button), ESC
167
+ // or a backdrop click — in that case only `update:modelValue=false`
168
+ // fires, not `@confirm`. Without this handler the action-flow promise
169
+ // would hang.
170
+ function onMfaDialogVisibility(open: boolean): void {
171
+ mfa.value.show = open;
172
+ if (!open && pendingMfaResolve) {
173
+ pendingMfaResolve(null);
174
+ }
175
+ }
176
+
177
+ // ── Confirm dialog state + resolver ───────────────────────────────
178
+ const confirmDialog = ref<ConfirmDialogState<TRow>>({
179
+ show: false,
180
+ def: null,
181
+ row: null,
182
+ }) as Ref<ConfirmDialogState<TRow>>;
183
+ let pendingConfirmResolve: ((result: ConfirmResult) => void) | null = null;
184
+
185
+ function showConfirmDialog(def: TenantActionDef, ctx: { row: TRow }): Promise<ConfirmResult> {
186
+ return new Promise((resolve) => {
187
+ confirmDialog.value = { show: true, def, row: ctx.row };
188
+ pendingConfirmResolve = (result) => {
189
+ pendingConfirmResolve = null;
190
+ resolve(result);
191
+ };
192
+ });
193
+ }
194
+
195
+ function onConfirmSubmit(payload: {
196
+ reason: string | null;
197
+ extras?: Record<string, unknown>;
198
+ }): void {
199
+ pendingConfirmResolve?.({
200
+ ok: true,
201
+ reason: payload.reason,
202
+ extras: payload.extras,
203
+ });
204
+ confirmDialog.value.show = false;
205
+ }
206
+
207
+ function onConfirmCancel(): void {
208
+ pendingConfirmResolve?.({ ok: false });
209
+ confirmDialog.value.show = false;
210
+ }
211
+
212
+ function onConfirmDialogVisibility(open: boolean): void {
213
+ confirmDialog.value.show = open;
214
+ if (!open && pendingConfirmResolve) {
215
+ pendingConfirmResolve({ ok: false });
216
+ }
217
+ }
218
+
219
+ // ── Flow ──────────────────────────────────────────────────────────
220
+ const flow = useTenantActionFlow<TRow>(options.manifest, {
221
+ confirm: showConfirmDialog,
222
+ mfa: showMfaDialog,
223
+ notify: options.notify,
224
+ onSuccess: () => {
225
+ void options.onSuccess();
226
+ },
227
+ visibleForRow,
228
+ });
229
+
230
+ // ── Manifest → TenantRowAction mapping ────────────────────────────
231
+ // `availableActions` is row-independent (capability + handler existence)
232
+ // — the row-specific visibility comes via `condition()` +
233
+ // `actionsForRow(row)`. This is why e.g. `tenants.reactivate` appears
234
+ // for inactive tenants even though no real row is available during the
235
+ // initial computation.
236
+ const manifestActions = computed<PlatformTenantActionRow<TRow>[]>(() =>
237
+ flow.availableActions.value.map((def) => ({
238
+ id: def.id,
239
+ label: def.label,
240
+ icon: iconForActionKey(def.actionKey),
241
+ tone: toneForActionKey(def.actionKey),
242
+ actionKey: def.actionKey,
243
+ condition: (row: TRow) =>
244
+ flow.actionsForRow(row).some((a) => a.def.actionKey === def.actionKey),
245
+ handler: (row: TRow) => {
246
+ const resolved = flow
247
+ .actionsForRow(row)
248
+ .find((a) => a.def.actionKey === def.actionKey);
249
+ // Can only happen if the visibility changed between
250
+ // condition() and handler() (e.g. a parallel reload). In
251
+ // that case the composable aborts the flow cleanly.
252
+ void resolved?.invoke(row);
253
+ },
254
+ })),
255
+ );
256
+
257
+ // ── Drift diagnosis ───────────────────────────────────────────────
258
+ // We filter out orphans whose `requiredCapability` is set to `false`
259
+ // in the manifest: platform core declares e.g. `tenants.impersonate`
260
+ // and `subscriptions.cancel`, which some apps deliberately do not
261
+ // implement (Capability=false, the button stays hidden). These are
262
+ // not drift but intentional non-implementation — do not warn.
263
+ const realOrphans = computed<string[]>(() => {
264
+ const caps = options.manifest.value?.capabilities ?? {};
265
+ const defs = options.manifest.value?.tenants?.actions ?? [];
266
+ const defByActionKey = new Map(defs.map((d) => [d.actionKey, d]));
267
+ return flow.orphanedDefs.value.filter((actionKey) => {
268
+ const def = defByActionKey.get(actionKey);
269
+ const requiredCap = def?.requiredCapability;
270
+ if (!requiredCap) return true;
271
+ return caps[requiredCap] === true;
272
+ });
273
+ });
274
+
275
+ return {
276
+ manifestActions,
277
+ realOrphans,
278
+ mfa,
279
+ onMfaConfirm,
280
+ onMfaDialogVisibility,
281
+ confirmDialog,
282
+ onConfirmSubmit,
283
+ onConfirmCancel,
284
+ onConfirmDialogVisibility,
285
+ };
286
+ }