@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,160 @@
1
+ // useMarketingProjections — Vue 3 composable over the
2
+ // MarketingProjectionsController (`/admin/catalog/marketing-projections`).
3
+ //
4
+ // Unlike Bundle/BusinessType, **without versioning**: marketing edits
5
+ // go live directly. The composable does not cache the list per filter tuple,
6
+ // but reloads fresh on every `load()` — marketing maintenance is
7
+ // low-frequency and consistency matters more than performance.
8
+
9
+ import { ref, type Ref } from 'vue';
10
+ import type {
11
+ CreateMarketingProjectionData,
12
+ MarketingProjectionFilter,
13
+ MarketingProjectionRow,
14
+ UpdateMarketingProjectionData,
15
+ } from '@saasicat/types';
16
+ import { defaultHttpClient, type HttpClient } from './types.js';
17
+
18
+ export interface UseMarketingProjectionsOptions {
19
+ adminEndpoint: string;
20
+ http?: HttpClient;
21
+ getAuthToken?: () => string | null;
22
+ /** Filter that is active on `load()`. Can be changed via `setFilter()`. */
23
+ filter: MarketingProjectionFilter;
24
+ autoLoad?: boolean;
25
+ }
26
+
27
+ export class MarketingProjectionsApiError extends Error {
28
+ constructor(
29
+ public readonly status: number,
30
+ public readonly body: unknown,
31
+ message: string,
32
+ ) {
33
+ super(message);
34
+ this.name = 'MarketingProjectionsApiError';
35
+ }
36
+ }
37
+
38
+ export interface UseMarketingProjectionsResult {
39
+ projections: Ref<MarketingProjectionRow[]>;
40
+ filter: Ref<MarketingProjectionFilter>;
41
+ loading: Ref<boolean>;
42
+ error: Ref<Error | null>;
43
+
44
+ /** Changes the filter and reloads fresh. */
45
+ setFilter: (next: MarketingProjectionFilter) => Promise<void>;
46
+ load: () => Promise<void>;
47
+ create: (data: CreateMarketingProjectionData) => Promise<MarketingProjectionRow>;
48
+ update: (id: string, data: UpdateMarketingProjectionData) => Promise<MarketingProjectionRow>;
49
+ remove: (id: string) => Promise<void>;
50
+ }
51
+
52
+ export function useMarketingProjections(
53
+ options: UseMarketingProjectionsOptions,
54
+ ): UseMarketingProjectionsResult {
55
+ if (!options?.adminEndpoint) {
56
+ throw new Error('useMarketingProjections: `adminEndpoint` ist Pflicht.');
57
+ }
58
+ if (!options?.filter?.projectKey) {
59
+ throw new Error('useMarketingProjections: `filter.projectKey` ist Pflicht.');
60
+ }
61
+
62
+ const http = options.http ?? defaultHttpClient();
63
+ const projections = ref<MarketingProjectionRow[]>([]);
64
+ const filter = ref<MarketingProjectionFilter>({ ...options.filter });
65
+ const loading = ref(false);
66
+ const error = ref<Error | null>(null);
67
+
68
+ const baseUrl = `${options.adminEndpoint}/catalog/marketing-projections`;
69
+
70
+ function authHeaders(): Record<string, string> {
71
+ const token = options.getAuthToken?.();
72
+ return token ? { Authorization: `Bearer ${token}` } : {};
73
+ }
74
+
75
+ function buildListUrl(): string {
76
+ const params = new URLSearchParams();
77
+ params.set('projectKey', filter.value.projectKey);
78
+ if (filter.value.targetType) params.set('targetType', filter.value.targetType);
79
+ if (filter.value.targetVersionId)
80
+ params.set('targetVersionId', filter.value.targetVersionId);
81
+ if (filter.value.locale) params.set('locale', filter.value.locale);
82
+ return `${baseUrl}?${params.toString()}`;
83
+ }
84
+
85
+ async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
86
+ const res = await http(url, {
87
+ method: init?.method ?? 'GET',
88
+ headers: { 'content-type': 'application/json', ...authHeaders(), ...init?.headers },
89
+ body: init?.body,
90
+ });
91
+ if (res.status === 204) return null;
92
+ const body = await res.json().catch(() => null);
93
+ if (res.status >= 400) {
94
+ throw new MarketingProjectionsApiError(
95
+ res.status,
96
+ body,
97
+ `MarketingProjections-API antwortete mit HTTP ${res.status}`,
98
+ );
99
+ }
100
+ return body as T;
101
+ }
102
+
103
+ async function load(): Promise<void> {
104
+ loading.value = true;
105
+ error.value = null;
106
+ try {
107
+ const data = await fetchJson<MarketingProjectionRow[]>(buildListUrl());
108
+ projections.value = data ?? [];
109
+ } catch (err) {
110
+ error.value = err instanceof Error ? err : new Error(String(err));
111
+ } finally {
112
+ loading.value = false;
113
+ }
114
+ }
115
+
116
+ async function setFilter(next: MarketingProjectionFilter): Promise<void> {
117
+ filter.value = { ...next };
118
+ await load();
119
+ }
120
+
121
+ async function create(data: CreateMarketingProjectionData): Promise<MarketingProjectionRow> {
122
+ const created = await fetchJson<MarketingProjectionRow>(baseUrl, {
123
+ method: 'POST',
124
+ body: JSON.stringify(data),
125
+ });
126
+ if (!created) {
127
+ throw new MarketingProjectionsApiError(0, null, 'Create gab keinen Body zurück');
128
+ }
129
+ // After create: reload the list (a unique tuple insert can
130
+ // affect the filter).
131
+ await load();
132
+ return created;
133
+ }
134
+
135
+ async function update(
136
+ id: string,
137
+ data: UpdateMarketingProjectionData,
138
+ ): Promise<MarketingProjectionRow> {
139
+ const updated = await fetchJson<MarketingProjectionRow>(`${baseUrl}/${id}`, {
140
+ method: 'PATCH',
141
+ body: JSON.stringify(data),
142
+ });
143
+ if (!updated) {
144
+ throw new MarketingProjectionsApiError(0, null, 'Update gab keinen Body zurück');
145
+ }
146
+ projections.value = projections.value.map((p) => (p.id === id ? updated : p));
147
+ return updated;
148
+ }
149
+
150
+ async function remove(id: string): Promise<void> {
151
+ await fetchJson<null>(`${baseUrl}/${id}`, { method: 'DELETE' });
152
+ projections.value = projections.value.filter((p) => p.id !== id);
153
+ }
154
+
155
+ if (options.autoLoad) {
156
+ void load();
157
+ }
158
+
159
+ return { projections, filter, loading, error, setFilter, load, create, update, remove };
160
+ }
package/src/use-nav.ts ADDED
@@ -0,0 +1,33 @@
1
+ // useNav — Vue 3 composable over NavBuilder.
2
+ //
3
+ // Wraps a reactive `Ref<AdminManifest | null>` (typically from
4
+ // `useManifest()`) and returns reactive `routes` + `sidebar` ComputedRefs.
5
+
6
+ import { computed, type ComputedRef, type Ref } from 'vue';
7
+ import type { AdminManifest } from '@saasicat/types';
8
+ import {
9
+ type BuildRouteEntry,
10
+ type NavBuilderOptions,
11
+ type SidebarSection,
12
+ buildRoutes,
13
+ buildSidebar,
14
+ } from './nav-builder.js';
15
+
16
+ export interface UseNavResult {
17
+ /** List of all routes available via the manifest — filtered by capabilities. */
18
+ routes: ComputedRef<BuildRouteEntry[]>;
19
+ /** Drawer items, grouped by navSection. */
20
+ sidebar: ComputedRef<SidebarSection[]>;
21
+ }
22
+
23
+ export function useNav(
24
+ manifest: Ref<AdminManifest | null>,
25
+ options: NavBuilderOptions = {},
26
+ ): UseNavResult {
27
+ const routes = computed<BuildRouteEntry[]>(() => {
28
+ if (!manifest.value) return [];
29
+ return buildRoutes(manifest.value, options);
30
+ });
31
+ const sidebar = computed<SidebarSection[]>(() => buildSidebar(routes.value));
32
+ return { routes, sidebar };
33
+ }
@@ -0,0 +1,162 @@
1
+ // usePlanEditor — plan-editor discovery + validation for the SuperAdmin UI.
2
+ //
3
+ // Reads the full feature catalog including the `plannedOnly` marker from the
4
+ // loaded manifest (`planCatalogSnapshot.features`) and provides:
5
+ //
6
+ // 1. `availableFeatures` — all features with reactive markers for the UI:
7
+ // `isPlannedOnly` (rendered in the UI as a "planned" chip), `isSelected`
8
+ // (in the current draft), `isInherited` (in the base plan, cannot be
9
+ // removed if `nonRegressive: true`).
10
+ //
11
+ // 2. `featuresByTier` — grouping by the `tier` convention (CORE,
12
+ // ADVANCED, PRO, BUSINESS, ENTERPRISE_ONLY) for the
13
+ // drawer rendering. Sorting as in the catalog.
14
+ //
15
+ // 3. `toggleFeature(key)` — toggles in the draft, but blocks `plannedOnly`
16
+ // features and (optionally) base-plan features on nonRegressive drafts.
17
+ //
18
+ // 4. `validateDraft()` — before the save: throws if the draft contains a
19
+ // plannedOnly feature (frontend mirror of the backend validation
20
+ // `assertNoPlannedOnlyFeatures` — prevents the UI from sending malformed
21
+ // POST bodies instead of showing the backend 400).
22
+ //
23
+ // What the platform does NOT do: the HTTP save itself. The consumer sends the
24
+ // draft to `PATCH /api/v1/admin/plan-versions/:id` with the endpoint adapter
25
+ // of its choice. Discovery + filter + validation lives centrally, persistence
26
+ // project-specific.
27
+
28
+ import { computed, ref, type ComputedRef, type Ref } from 'vue';
29
+ import type { AdminManifest, FeatureDef, FeatureKey } from '@saasicat/types';
30
+
31
+ export class PlannedOnlyFeatureError extends Error {
32
+ constructor(public readonly violations: FeatureKey[]) {
33
+ super(
34
+ `Folgende Features sind im Catalog als plannedOnly markiert und ` +
35
+ `dürfen nicht in einer Plan-Version stehen: ${violations.sort().join(', ')}.`,
36
+ );
37
+ this.name = 'PlannedOnlyFeatureError';
38
+ }
39
+ }
40
+
41
+ export interface FeatureRowMarkers {
42
+ /** Catalog definition (label, icon, tier, marker). */
43
+ def: FeatureDef;
44
+ /** Currently selected in the draft. */
45
+ isSelected: boolean;
46
+ /** Catalog marker `plannedOnly: true` — the UI shows it as roadmap, not bookable. */
47
+ isPlannedOnly: boolean;
48
+ /** In the base plan (inherited) — not removable on nonRegressive drafts. */
49
+ isInherited: boolean;
50
+ /** Computed: is the user allowed to toggle the feature? */
51
+ canToggle: boolean;
52
+ }
53
+
54
+ export interface UsePlanEditorOptions {
55
+ /** Initial selection — typically `draft.features` from PATCH preparation. */
56
+ initialFeatures?: FeatureKey[];
57
+ /** Features the base plan already contained; not removable under nonRegressive. */
58
+ baseFeatures?: FeatureKey[];
59
+ /** If `true`: base features are locked (default `true`, platform convention). */
60
+ nonRegressive?: boolean;
61
+ }
62
+
63
+ export interface UsePlanEditorResult {
64
+ /** Feature keys currently selected in the draft (Set for O(1) lookup). */
65
+ selectedFeatures: Ref<Set<FeatureKey>>;
66
+ /** Ordered list of all catalog features with UI markers. */
67
+ availableFeatures: ComputedRef<FeatureRowMarkers[]>;
68
+ /** Tier grouping for drawer sections (CORE, ADVANCED, PRO, …). */
69
+ featuresByTier: ComputedRef<Array<{ tier: string; rows: FeatureRowMarkers[] }>>;
70
+ /** Toggle without throwing: `plannedOnly` and locked features are ignored. */
71
+ toggleFeature: (key: FeatureKey) => void;
72
+ /** Pre-save validation. Throws `PlannedOnlyFeatureError` if disallowed keys are present. */
73
+ validateDraft: () => void;
74
+ /** Snapshot of the current selection for the PATCH body. */
75
+ snapshot: () => FeatureKey[];
76
+ }
77
+
78
+ const TIER_ORDER = ['CORE', 'ADVANCED', 'PRO', 'BUSINESS', 'ENTERPRISE_ONLY'];
79
+
80
+ function tierWeight(tier: string | undefined): number {
81
+ if (!tier) return TIER_ORDER.length; // ungrouped goes to the end
82
+ const i = TIER_ORDER.indexOf(tier);
83
+ return i === -1 ? TIER_ORDER.length : i;
84
+ }
85
+
86
+ /**
87
+ * Plan-editor discovery + validation. The manifest provides the source of
88
+ * truth (catalog snapshot including the `plannedOnly` marker). The consumer
89
+ * builds the Vue component and uses this composable for state + filter.
90
+ */
91
+ export function usePlanEditor(
92
+ manifest: AdminManifest,
93
+ options: UsePlanEditorOptions = {},
94
+ ): UsePlanEditorResult {
95
+ const baseSet = new Set(options.baseFeatures ?? []);
96
+ const nonRegressive = options.nonRegressive ?? true;
97
+
98
+ const selectedFeatures = ref<Set<FeatureKey>>(new Set(options.initialFeatures ?? []));
99
+
100
+ const catalogFeatures: FeatureDef[] = manifest.planCatalogSnapshot.features ?? [];
101
+ const plannedOnlySet = new Set(catalogFeatures.filter((f) => f.plannedOnly).map((f) => f.key));
102
+
103
+ const availableFeatures = computed<FeatureRowMarkers[]>(() => {
104
+ return catalogFeatures.map<FeatureRowMarkers>((def) => {
105
+ const isSelected = selectedFeatures.value.has(def.key);
106
+ const isPlannedOnly = !!def.plannedOnly;
107
+ const isInherited = baseSet.has(def.key);
108
+ const canToggle = !isPlannedOnly && (!nonRegressive || !isInherited || !isSelected);
109
+ return { def, isSelected, isPlannedOnly, isInherited, canToggle };
110
+ });
111
+ });
112
+
113
+ const featuresByTier = computed(() => {
114
+ const groups = new Map<string, FeatureRowMarkers[]>();
115
+ for (const row of availableFeatures.value) {
116
+ const tier = row.def.tier ?? 'OTHER';
117
+ const list = groups.get(tier) ?? [];
118
+ list.push(row);
119
+ groups.set(tier, list);
120
+ }
121
+ return [...groups.entries()]
122
+ .sort((a, b) => {
123
+ const wa = tierWeight(a[0]);
124
+ const wb = tierWeight(b[0]);
125
+ return wa !== wb ? wa - wb : a[0].localeCompare(b[0]);
126
+ })
127
+ .map(([tier, rows]) => ({ tier, rows }));
128
+ });
129
+
130
+ function toggleFeature(key: FeatureKey): void {
131
+ if (plannedOnlySet.has(key)) return; // roadmap feature, not bookable
132
+ const next = new Set(selectedFeatures.value);
133
+ if (next.has(key)) {
134
+ // On nonRegressive drafts an inherited feature must not be removed.
135
+ if (nonRegressive && baseSet.has(key)) return;
136
+ next.delete(key);
137
+ } else {
138
+ next.add(key);
139
+ }
140
+ selectedFeatures.value = next;
141
+ }
142
+
143
+ function validateDraft(): void {
144
+ const violations = [...selectedFeatures.value].filter((k) => plannedOnlySet.has(k));
145
+ if (violations.length > 0) {
146
+ throw new PlannedOnlyFeatureError(violations);
147
+ }
148
+ }
149
+
150
+ function snapshot(): FeatureKey[] {
151
+ return [...selectedFeatures.value].sort();
152
+ }
153
+
154
+ return {
155
+ selectedFeatures,
156
+ availableFeatures,
157
+ featuresByTier,
158
+ toggleFeature,
159
+ validateDraft,
160
+ snapshot,
161
+ };
162
+ }
@@ -0,0 +1,83 @@
1
+ // usePlanVersionsCatalog — composable for the plan-versions page
2
+ // (`<app> /admin/plans`).
3
+ //
4
+ // **Endpoint is mandatory** and is supplied by the app consumer, because
5
+ // apps have different `globalPrefix` conventions
6
+ // (e.g. `/api/admin/...` or `/api/v1/admin/...`).
7
+ //
8
+ // Platform pure functions (`assertDraftPublishable` etc. from
9
+ // `@saasicat/nest/billing`) validate the drafts before the
10
+ // publish POST.
11
+
12
+ import { ref, type Ref } from 'vue';
13
+ import { useApiList, type UseApiListOptions, type UseApiListResult } from './use-api-list.js';
14
+
15
+ /**
16
+ * Generic filter shape for versions lists.
17
+ * `state`: 'draft' (publishedAt null), 'live' (publishedAt set, supersededAt null),
18
+ * 'superseded' (supersededAt set), 'all' (default).
19
+ */
20
+ export interface PlanVersionListFilter {
21
+ state?: 'draft' | 'live' | 'superseded' | 'all';
22
+ /** PlanId (`'BASIC'`, `'STANDARD'`, …). */
23
+ planId?: string;
24
+ }
25
+
26
+ /**
27
+ * Generic versions row shape. Depending on the endpoint, the consumer backend
28
+ * returns slightly different fields; the platform composable takes the universal ones.
29
+ */
30
+ export interface PlanVersionRow {
31
+ id: string;
32
+ version: number;
33
+ publishedAt: string | null;
34
+ supersededAt: string | null;
35
+ nonRegressive: boolean;
36
+ changeNote?: string;
37
+ [extra: string]: unknown;
38
+ }
39
+
40
+ export interface VersionsOptions {
41
+ /**
42
+ * Fully qualified versions-list endpoint including the app globalPrefix
43
+ * (`/api/admin/plan-versions`, `/api/v1/admin/plan-versions`, …). Mandatory.
44
+ */
45
+ endpoint: string;
46
+ filter?: Ref<PlanVersionListFilter>;
47
+ http?: UseApiListOptions<Record<string, unknown>>['http'];
48
+ getAuthToken?: () => string | null;
49
+ autoLoad?: boolean;
50
+ }
51
+
52
+ export interface VersionsResult extends UseApiListResult<PlanVersionRow> {
53
+ filter: Ref<PlanVersionListFilter>;
54
+ }
55
+
56
+ function buildVersionsComposable(label: string) {
57
+ return function (options: VersionsOptions): VersionsResult {
58
+ if (!options?.endpoint) {
59
+ throw new Error(
60
+ `${label}: \`endpoint\` ist Pflicht (z. B. "/api/admin/${label}" ` +
61
+ `oder "/api/v1/admin/${label}"). Plattform hat keinen Default, ` +
62
+ `weil Apps unterschiedliche globalPrefix-Konventionen haben.`,
63
+ );
64
+ }
65
+ const filter = options.filter ?? ref<PlanVersionListFilter>({});
66
+ const list = useApiList<PlanVersionRow>({
67
+ endpoint: options.endpoint,
68
+ filter: filter as unknown as Ref<Record<string, unknown>>,
69
+ http: options.http,
70
+ getAuthToken: options.getAuthToken,
71
+ autoLoad: options.autoLoad,
72
+ });
73
+ return { ...list, filter };
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Read-only catalog view for PlanVersions (lift-and-shift from a
79
+ * consumer admin).
80
+ * Called `usePlanVersionsCatalog` since M6 Pack 2a, because `usePlanVersions`
81
+ * from `use-plans.ts` is now the lifecycle editor (createDraft/publish).
82
+ */
83
+ export const usePlanVersionsCatalog = buildVersionsComposable('plan-versions');