@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,112 @@
1
+ // useBundleVersionsMap — loads the versions for each bundle in parallel and
2
+ // keeps the mapping `bundleId → BundleVersionRow[]` reactive. Used by the
3
+ // BundlesPage to correctly compute KPIs (live / scheduled / drafts) and a
4
+ // status filter across all bundles — before the user opens an accordion.
5
+ //
6
+ // Trade-off: 1+N HTTP requests on mount. Acceptable for SuperAdmin setups
7
+ // with few bundles (today typically < 20). A backend optimization via an
8
+ // aggregate endpoint (`/admin/catalog/bundles?include=versions` or
9
+ // `/admin/catalog/bundles/aggregates`) can come later additively, without
10
+ // breaking this composable API.
11
+
12
+ import { ref, watch, type Ref } from 'vue';
13
+ import type { BundleRow, BundleVersionRow } from '@saasicat/types';
14
+ import { defaultHttpClient, type HttpClient } from './types.js';
15
+
16
+ export interface UseBundleVersionsMapOptions {
17
+ adminEndpoint: string;
18
+ /** Reactive list of bundle roots; the watcher reloads when the IDs change. */
19
+ bundles: Ref<BundleRow[]>;
20
+ http?: HttpClient;
21
+ getAuthToken?: () => string | null;
22
+ }
23
+
24
+ export interface UseBundleVersionsMapResult {
25
+ /** `bundleId → BundleVersionRow[]`. Empty list for bundles without versions. */
26
+ versionsByBundle: Ref<Record<string, BundleVersionRow[]>>;
27
+ loading: Ref<boolean>;
28
+ error: Ref<Error | null>;
29
+ /** Forces a reload of all bundle versions. */
30
+ refresh: () => Promise<void>;
31
+ /** Refresh only a single bundle (e.g. after an inline-editor save). */
32
+ refreshOne: (bundleId: string) => Promise<void>;
33
+ }
34
+
35
+ export function useBundleVersionsMap(
36
+ options: UseBundleVersionsMapOptions,
37
+ ): UseBundleVersionsMapResult {
38
+ if (!options?.adminEndpoint) {
39
+ throw new Error('useBundleVersionsMap: `adminEndpoint` ist Pflicht.');
40
+ }
41
+ if (!options?.bundles) {
42
+ throw new Error('useBundleVersionsMap: `bundles` ist Pflicht.');
43
+ }
44
+
45
+ const http = options.http ?? defaultHttpClient();
46
+ const versionsByBundle = ref<Record<string, BundleVersionRow[]>>({});
47
+ const loading = ref(false);
48
+ const error = ref<Error | null>(null);
49
+
50
+ function authHeaders(): Record<string, string> {
51
+ const token = options.getAuthToken?.();
52
+ return token ? { Authorization: `Bearer ${token}` } : {};
53
+ }
54
+
55
+ async function loadForBundle(bundleId: string): Promise<BundleVersionRow[]> {
56
+ const res = await http(`${options.adminEndpoint}/catalog/bundles/${bundleId}/versions`, {
57
+ method: 'GET',
58
+ headers: { 'content-type': 'application/json', ...authHeaders() },
59
+ });
60
+ if (res.status >= 400) {
61
+ throw new Error(`HTTP ${res.status} beim Laden der Versionen für Bundle '${bundleId}'`);
62
+ }
63
+ return ((await res.json().catch(() => null)) as BundleVersionRow[] | null) ?? [];
64
+ }
65
+
66
+ async function refresh(): Promise<void> {
67
+ loading.value = true;
68
+ error.value = null;
69
+ try {
70
+ const entries = await Promise.all(
71
+ options.bundles.value.map(async (b) => {
72
+ try {
73
+ const versions = await loadForBundle(b.id);
74
+ return [b.id, versions] as const;
75
+ } catch (err) {
76
+ console.warn(
77
+ `useBundleVersionsMap: Bundle '${b.bundleKey}' Versions-Load failed`,
78
+ err,
79
+ );
80
+ return [b.id, []] as const;
81
+ }
82
+ }),
83
+ );
84
+ versionsByBundle.value = Object.fromEntries(entries);
85
+ } catch (err) {
86
+ error.value = err instanceof Error ? err : new Error(String(err));
87
+ } finally {
88
+ loading.value = false;
89
+ }
90
+ }
91
+
92
+ async function refreshOne(bundleId: string): Promise<void> {
93
+ try {
94
+ const versions = await loadForBundle(bundleId);
95
+ versionsByBundle.value = { ...versionsByBundle.value, [bundleId]: versions };
96
+ } catch (err) {
97
+ console.warn(`useBundleVersionsMap: refreshOne('${bundleId}') failed`, err);
98
+ }
99
+ }
100
+
101
+ // Auto-refresh when the bundle list changes (creation, soft-delete).
102
+ watch(
103
+ () => options.bundles.value.map((b) => b.id).join(','),
104
+ () => {
105
+ if (options.bundles.value.length > 0) void refresh();
106
+ else versionsByBundle.value = {};
107
+ },
108
+ { immediate: true },
109
+ );
110
+
111
+ return { versionsByBundle, loading, error, refresh, refreshOne };
112
+ }
@@ -0,0 +1,297 @@
1
+ // useBundles + useBundleVersions — Vue 3 composables for SuperAdmin
2
+ // bundle management (backend: BundlesController under
3
+ // /admin/catalog/bundles + /admin/catalog/bundle-versions).
4
+ //
5
+ // **The endpoint prefix is required** and is supplied by the consumer
6
+ // (e.g. `/api/admin` or `/api/v1/admin`). The composables append
7
+ // the catalog paths themselves — the consumer supplies only the admin prefix.
8
+
9
+ import { ref, type Ref } from 'vue';
10
+ import type {
11
+ BundleRow,
12
+ BundleVersionMutationResult,
13
+ BundleVersionRow,
14
+ CreateBundleData,
15
+ CreateBundleVersionDraftData,
16
+ UpdateBundleData,
17
+ UpdateBundleVersionDraftData,
18
+ } from '@saasicat/types';
19
+ import { defaultHttpClient, type HttpClient } from './types.js';
20
+
21
+ export interface UseBundlesOptions {
22
+ /**
23
+ * Fully-qualified admin endpoint prefix including the app globalPrefix
24
+ * (`/api/admin`, `/api/v1/admin`, …). Required. The composable appends
25
+ * the catalog paths itself (`/catalog/bundles`).
26
+ */
27
+ adminEndpoint: string;
28
+ http?: HttpClient;
29
+ getAuthToken?: () => string | null;
30
+ /** Required: projectKey the list is filtered against. */
31
+ projectKey: string;
32
+ /** When `true`, loads on composable init. Default `false`. */
33
+ autoLoad?: boolean;
34
+ }
35
+
36
+ export class BundlesApiError extends Error {
37
+ constructor(
38
+ public readonly status: number,
39
+ public readonly body: unknown,
40
+ message: string,
41
+ ) {
42
+ super(message);
43
+ this.name = 'BundlesApiError';
44
+ }
45
+ }
46
+
47
+ export interface UseBundlesResult {
48
+ bundles: Ref<BundleRow[]>;
49
+ loading: Ref<boolean>;
50
+ error: Ref<Error | null>;
51
+
52
+ load: () => Promise<void>;
53
+ create: (data: CreateBundleData) => Promise<BundleRow>;
54
+ update: (bundleId: string, data: UpdateBundleData) => Promise<BundleRow>;
55
+ softDelete: (bundleId: string) => Promise<void>;
56
+ }
57
+
58
+ export function useBundles(options: UseBundlesOptions): UseBundlesResult {
59
+ if (!options?.adminEndpoint) {
60
+ throw new Error(
61
+ 'useBundles: `adminEndpoint` ist Pflicht (z. B. "/api/admin" oder ' +
62
+ '"/api/v1/admin"). Plattform hat keinen Default, weil Apps ' +
63
+ 'unterschiedliche globalPrefix-Konventionen haben.',
64
+ );
65
+ }
66
+ if (!options?.projectKey) {
67
+ throw new Error('useBundles: `projectKey` ist Pflicht.');
68
+ }
69
+
70
+ const http = options.http ?? defaultHttpClient();
71
+ const bundles = ref<BundleRow[]>([]);
72
+ const loading = ref(false);
73
+ const error = ref<Error | null>(null);
74
+
75
+ const baseUrl = `${options.adminEndpoint}/catalog/bundles`;
76
+
77
+ function authHeaders(): Record<string, string> {
78
+ const token = options.getAuthToken?.();
79
+ return token ? { Authorization: `Bearer ${token}` } : {};
80
+ }
81
+
82
+ async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
83
+ const res = await http(url, {
84
+ method: init?.method ?? 'GET',
85
+ headers: { 'content-type': 'application/json', ...authHeaders(), ...init?.headers },
86
+ body: init?.body,
87
+ });
88
+ if (res.status === 204) return null;
89
+ const body = await res.json().catch(() => null);
90
+ if (res.status >= 400) {
91
+ throw new BundlesApiError(
92
+ res.status,
93
+ body,
94
+ `Bundles-API antwortete mit HTTP ${res.status}`,
95
+ );
96
+ }
97
+ return body as T;
98
+ }
99
+
100
+ async function load(): Promise<void> {
101
+ loading.value = true;
102
+ error.value = null;
103
+ try {
104
+ const data = await fetchJson<BundleRow[]>(
105
+ `${baseUrl}?projectKey=${encodeURIComponent(options.projectKey)}`,
106
+ );
107
+ bundles.value = data ?? [];
108
+ } catch (err) {
109
+ error.value = err instanceof Error ? err : new Error(String(err));
110
+ } finally {
111
+ loading.value = false;
112
+ }
113
+ }
114
+
115
+ async function create(data: CreateBundleData): Promise<BundleRow> {
116
+ const created = await fetchJson<BundleRow>(baseUrl, {
117
+ method: 'POST',
118
+ body: JSON.stringify(data),
119
+ });
120
+ if (!created) throw new BundlesApiError(0, null, 'Create gab keinen Body zurück');
121
+ bundles.value = [...bundles.value, created];
122
+ return created;
123
+ }
124
+
125
+ async function update(bundleId: string, data: UpdateBundleData): Promise<BundleRow> {
126
+ const updated = await fetchJson<BundleRow>(`${baseUrl}/${bundleId}`, {
127
+ method: 'PATCH',
128
+ body: JSON.stringify(data),
129
+ });
130
+ if (!updated) throw new BundlesApiError(0, null, 'Update gab keinen Body zurück');
131
+ bundles.value = bundles.value.map((b) => (b.id === bundleId ? updated : b));
132
+ return updated;
133
+ }
134
+
135
+ async function softDelete(bundleId: string): Promise<void> {
136
+ await fetchJson<null>(`${baseUrl}/${bundleId}`, { method: 'DELETE' });
137
+ bundles.value = bundles.value.filter((b) => b.id !== bundleId);
138
+ }
139
+
140
+ if (options.autoLoad) {
141
+ void load();
142
+ }
143
+
144
+ return { bundles, loading, error, load, create, update, softDelete };
145
+ }
146
+
147
+ // =============================================================================
148
+ // useBundleVersions — lifecycle operations for a concrete bundle ID
149
+ // =============================================================================
150
+
151
+ export interface UseBundleVersionsOptions {
152
+ adminEndpoint: string;
153
+ bundleId: string;
154
+ http?: HttpClient;
155
+ getAuthToken?: () => string | null;
156
+ autoLoad?: boolean;
157
+ }
158
+
159
+ export interface UseBundleVersionsResult {
160
+ versions: Ref<BundleVersionRow[]>;
161
+ loading: Ref<boolean>;
162
+ error: Ref<Error | null>;
163
+
164
+ load: () => Promise<void>;
165
+ /** Returns {row, warnings} — warnings should be shown to the user as a banner. */
166
+ createDraft: (
167
+ data: Omit<CreateBundleVersionDraftData, 'bundleId'>,
168
+ ) => Promise<BundleVersionMutationResult>;
169
+ updateDraft: (
170
+ versionId: string,
171
+ data: UpdateBundleVersionDraftData,
172
+ ) => Promise<BundleVersionMutationResult>;
173
+ publish: (
174
+ versionId: string,
175
+ opts?: {
176
+ forceRegressive?: boolean;
177
+ allowZeroPrice?: boolean;
178
+ validFrom?: string | null;
179
+ validUntil?: string | null;
180
+ },
181
+ ) => Promise<BundleVersionMutationResult>;
182
+ /**
183
+ * Discards a draft (`DELETE /admin/catalog/bundle-versions/:id`).
184
+ * Published versions cannot be discarded — the API responds
185
+ * with 422 `BUNDLE_VERSION_ALREADY_PUBLISHED`.
186
+ */
187
+ discardDraft: (versionId: string) => Promise<void>;
188
+ }
189
+
190
+ export function useBundleVersions(options: UseBundleVersionsOptions): UseBundleVersionsResult {
191
+ if (!options?.adminEndpoint) {
192
+ throw new Error('useBundleVersions: `adminEndpoint` ist Pflicht.');
193
+ }
194
+ if (!options?.bundleId) {
195
+ throw new Error('useBundleVersions: `bundleId` ist Pflicht.');
196
+ }
197
+
198
+ const http = options.http ?? defaultHttpClient();
199
+ const versions = ref<BundleVersionRow[]>([]);
200
+ const loading = ref(false);
201
+ const error = ref<Error | null>(null);
202
+
203
+ const bundleVersionsUrl = `${options.adminEndpoint}/catalog/bundles/${options.bundleId}/versions`;
204
+ const versionUrlBase = `${options.adminEndpoint}/catalog/bundle-versions`;
205
+
206
+ function authHeaders(): Record<string, string> {
207
+ const token = options.getAuthToken?.();
208
+ return token ? { Authorization: `Bearer ${token}` } : {};
209
+ }
210
+
211
+ async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
212
+ const res = await http(url, {
213
+ method: init?.method ?? 'GET',
214
+ headers: { 'content-type': 'application/json', ...authHeaders(), ...init?.headers },
215
+ body: init?.body,
216
+ });
217
+ if (res.status === 204) return null;
218
+ const body = await res.json().catch(() => null);
219
+ if (res.status >= 400) {
220
+ throw new BundlesApiError(
221
+ res.status,
222
+ body,
223
+ `BundleVersions-API antwortete mit HTTP ${res.status}`,
224
+ );
225
+ }
226
+ return body as T;
227
+ }
228
+
229
+ async function load(): Promise<void> {
230
+ loading.value = true;
231
+ error.value = null;
232
+ try {
233
+ const data = await fetchJson<BundleVersionRow[]>(bundleVersionsUrl);
234
+ versions.value = data ?? [];
235
+ } catch (err) {
236
+ error.value = err instanceof Error ? err : new Error(String(err));
237
+ } finally {
238
+ loading.value = false;
239
+ }
240
+ }
241
+
242
+ async function createDraft(
243
+ data: Omit<CreateBundleVersionDraftData, 'bundleId'>,
244
+ ): Promise<BundleVersionMutationResult> {
245
+ const result = await fetchJson<BundleVersionMutationResult>(bundleVersionsUrl, {
246
+ method: 'POST',
247
+ body: JSON.stringify(data),
248
+ });
249
+ if (!result) throw new BundlesApiError(0, null, 'CreateDraft gab keinen Body zurück');
250
+ versions.value = [...versions.value, result.bundleVersion];
251
+ return result;
252
+ }
253
+
254
+ async function updateDraft(
255
+ versionId: string,
256
+ data: UpdateBundleVersionDraftData,
257
+ ): Promise<BundleVersionMutationResult> {
258
+ const result = await fetchJson<BundleVersionMutationResult>(
259
+ `${versionUrlBase}/${versionId}`,
260
+ { method: 'PATCH', body: JSON.stringify(data) },
261
+ );
262
+ if (!result) throw new BundlesApiError(0, null, 'UpdateDraft gab keinen Body zurück');
263
+ versions.value = versions.value.map((v) => (v.id === versionId ? result.bundleVersion : v));
264
+ return result;
265
+ }
266
+
267
+ async function publish(
268
+ versionId: string,
269
+ opts: {
270
+ forceRegressive?: boolean;
271
+ allowZeroPrice?: boolean;
272
+ validFrom?: string | null;
273
+ validUntil?: string | null;
274
+ } = {},
275
+ ): Promise<BundleVersionMutationResult> {
276
+ const result = await fetchJson<BundleVersionMutationResult>(
277
+ `${versionUrlBase}/${versionId}/publish`,
278
+ { method: 'POST', body: JSON.stringify(opts) },
279
+ );
280
+ if (!result) throw new BundlesApiError(0, null, 'Publish gab keinen Body zurück');
281
+ // Reload versions, because publishing may mark another version as
282
+ // superseded — the local cache would otherwise be inconsistent.
283
+ await load();
284
+ return result;
285
+ }
286
+
287
+ async function discardDraft(versionId: string): Promise<void> {
288
+ await fetchJson<null>(`${versionUrlBase}/${versionId}`, { method: 'DELETE' });
289
+ versions.value = versions.value.filter((v) => v.id !== versionId);
290
+ }
291
+
292
+ if (options.autoLoad) {
293
+ void load();
294
+ }
295
+
296
+ return { versions, loading, error, load, createDraft, updateDraft, publish, discardDraft };
297
+ }
@@ -0,0 +1,269 @@
1
+ // useBusinessTypes + useBusinessTypeVersions — Vue 3 composables for
2
+ // SuperAdmin business-type management (backend: BusinessTypesController under
3
+ // /admin/catalog/business-types + /admin/catalog/business-type-versions).
4
+ //
5
+ // Pattern analogous to use-bundles.ts.
6
+
7
+ import { ref, type Ref } from 'vue';
8
+ import type {
9
+ BusinessTypeRow,
10
+ BusinessTypeVersionMutationResult,
11
+ BusinessTypeVersionRow,
12
+ CreateBusinessTypeData,
13
+ CreateBusinessTypeVersionDraftData,
14
+ UpdateBusinessTypeData,
15
+ UpdateBusinessTypeVersionDraftData,
16
+ } from '@saasicat/types';
17
+ import { defaultHttpClient, type HttpClient } from './types.js';
18
+
19
+ export interface UseBusinessTypesOptions {
20
+ adminEndpoint: string;
21
+ http?: HttpClient;
22
+ getAuthToken?: () => string | null;
23
+ projectKey: string;
24
+ autoLoad?: boolean;
25
+ }
26
+
27
+ export class BusinessTypesApiError extends Error {
28
+ constructor(
29
+ public readonly status: number,
30
+ public readonly body: unknown,
31
+ message: string,
32
+ ) {
33
+ super(message);
34
+ this.name = 'BusinessTypesApiError';
35
+ }
36
+ }
37
+
38
+ export interface UseBusinessTypesResult {
39
+ businessTypes: Ref<BusinessTypeRow[]>;
40
+ loading: Ref<boolean>;
41
+ error: Ref<Error | null>;
42
+
43
+ load: () => Promise<void>;
44
+ create: (data: CreateBusinessTypeData) => Promise<BusinessTypeRow>;
45
+ update: (businessTypeId: string, data: UpdateBusinessTypeData) => Promise<BusinessTypeRow>;
46
+ softDelete: (businessTypeId: string) => Promise<void>;
47
+ }
48
+
49
+ export function useBusinessTypes(options: UseBusinessTypesOptions): UseBusinessTypesResult {
50
+ if (!options?.adminEndpoint) {
51
+ throw new Error('useBusinessTypes: `adminEndpoint` ist Pflicht.');
52
+ }
53
+ if (!options?.projectKey) {
54
+ throw new Error('useBusinessTypes: `projectKey` ist Pflicht.');
55
+ }
56
+
57
+ const http = options.http ?? defaultHttpClient();
58
+ const businessTypes = ref<BusinessTypeRow[]>([]);
59
+ const loading = ref(false);
60
+ const error = ref<Error | null>(null);
61
+
62
+ const baseUrl = `${options.adminEndpoint}/catalog/business-types`;
63
+
64
+ function authHeaders(): Record<string, string> {
65
+ const token = options.getAuthToken?.();
66
+ return token ? { Authorization: `Bearer ${token}` } : {};
67
+ }
68
+
69
+ async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
70
+ const res = await http(url, {
71
+ method: init?.method ?? 'GET',
72
+ headers: { 'content-type': 'application/json', ...authHeaders(), ...init?.headers },
73
+ body: init?.body,
74
+ });
75
+ if (res.status === 204) return null;
76
+ const body = await res.json().catch(() => null);
77
+ if (res.status >= 400) {
78
+ throw new BusinessTypesApiError(
79
+ res.status,
80
+ body,
81
+ `BusinessTypes-API antwortete mit HTTP ${res.status}`,
82
+ );
83
+ }
84
+ return body as T;
85
+ }
86
+
87
+ async function load(): Promise<void> {
88
+ loading.value = true;
89
+ error.value = null;
90
+ try {
91
+ const data = await fetchJson<BusinessTypeRow[]>(
92
+ `${baseUrl}?projectKey=${encodeURIComponent(options.projectKey)}`,
93
+ );
94
+ businessTypes.value = data ?? [];
95
+ } catch (err) {
96
+ error.value = err instanceof Error ? err : new Error(String(err));
97
+ } finally {
98
+ loading.value = false;
99
+ }
100
+ }
101
+
102
+ async function create(data: CreateBusinessTypeData): Promise<BusinessTypeRow> {
103
+ const created = await fetchJson<BusinessTypeRow>(baseUrl, {
104
+ method: 'POST',
105
+ body: JSON.stringify(data),
106
+ });
107
+ if (!created) throw new BusinessTypesApiError(0, null, 'Create gab keinen Body zurück');
108
+ businessTypes.value = [...businessTypes.value, created];
109
+ return created;
110
+ }
111
+
112
+ async function update(
113
+ businessTypeId: string,
114
+ data: UpdateBusinessTypeData,
115
+ ): Promise<BusinessTypeRow> {
116
+ const updated = await fetchJson<BusinessTypeRow>(`${baseUrl}/${businessTypeId}`, {
117
+ method: 'PATCH',
118
+ body: JSON.stringify(data),
119
+ });
120
+ if (!updated) throw new BusinessTypesApiError(0, null, 'Update gab keinen Body zurück');
121
+ businessTypes.value = businessTypes.value.map((b) =>
122
+ b.id === businessTypeId ? updated : b,
123
+ );
124
+ return updated;
125
+ }
126
+
127
+ async function softDelete(businessTypeId: string): Promise<void> {
128
+ await fetchJson<null>(`${baseUrl}/${businessTypeId}`, { method: 'DELETE' });
129
+ businessTypes.value = businessTypes.value.filter((b) => b.id !== businessTypeId);
130
+ }
131
+
132
+ if (options.autoLoad) {
133
+ void load();
134
+ }
135
+
136
+ return { businessTypes, loading, error, load, create, update, softDelete };
137
+ }
138
+
139
+ // =============================================================================
140
+ // useBusinessTypeVersions
141
+ // =============================================================================
142
+
143
+ export interface UseBusinessTypeVersionsOptions {
144
+ adminEndpoint: string;
145
+ businessTypeId: string;
146
+ http?: HttpClient;
147
+ getAuthToken?: () => string | null;
148
+ autoLoad?: boolean;
149
+ }
150
+
151
+ export interface UseBusinessTypeVersionsResult {
152
+ versions: Ref<BusinessTypeVersionRow[]>;
153
+ loading: Ref<boolean>;
154
+ error: Ref<Error | null>;
155
+
156
+ load: () => Promise<void>;
157
+ createDraft: (
158
+ data: Omit<CreateBusinessTypeVersionDraftData, 'businessTypeId'>,
159
+ ) => Promise<BusinessTypeVersionMutationResult>;
160
+ updateDraft: (
161
+ versionId: string,
162
+ data: UpdateBusinessTypeVersionDraftData,
163
+ ) => Promise<BusinessTypeVersionMutationResult>;
164
+ publish: (
165
+ versionId: string,
166
+ opts?: { forceRegressive?: boolean },
167
+ ) => Promise<BusinessTypeVersionMutationResult>;
168
+ }
169
+
170
+ export function useBusinessTypeVersions(
171
+ options: UseBusinessTypeVersionsOptions,
172
+ ): UseBusinessTypeVersionsResult {
173
+ if (!options?.adminEndpoint) {
174
+ throw new Error('useBusinessTypeVersions: `adminEndpoint` ist Pflicht.');
175
+ }
176
+ if (!options?.businessTypeId) {
177
+ throw new Error('useBusinessTypeVersions: `businessTypeId` ist Pflicht.');
178
+ }
179
+
180
+ const http = options.http ?? defaultHttpClient();
181
+ const versions = ref<BusinessTypeVersionRow[]>([]);
182
+ const loading = ref(false);
183
+ const error = ref<Error | null>(null);
184
+
185
+ const versionsUrl = `${options.adminEndpoint}/catalog/business-types/${options.businessTypeId}/versions`;
186
+ const versionUrlBase = `${options.adminEndpoint}/catalog/business-type-versions`;
187
+
188
+ function authHeaders(): Record<string, string> {
189
+ const token = options.getAuthToken?.();
190
+ return token ? { Authorization: `Bearer ${token}` } : {};
191
+ }
192
+
193
+ async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
194
+ const res = await http(url, {
195
+ method: init?.method ?? 'GET',
196
+ headers: { 'content-type': 'application/json', ...authHeaders(), ...init?.headers },
197
+ body: init?.body,
198
+ });
199
+ if (res.status === 204) return null;
200
+ const body = await res.json().catch(() => null);
201
+ if (res.status >= 400) {
202
+ throw new BusinessTypesApiError(
203
+ res.status,
204
+ body,
205
+ `BusinessTypeVersions-API antwortete mit HTTP ${res.status}`,
206
+ );
207
+ }
208
+ return body as T;
209
+ }
210
+
211
+ async function load(): Promise<void> {
212
+ loading.value = true;
213
+ error.value = null;
214
+ try {
215
+ const data = await fetchJson<BusinessTypeVersionRow[]>(versionsUrl);
216
+ versions.value = data ?? [];
217
+ } catch (err) {
218
+ error.value = err instanceof Error ? err : new Error(String(err));
219
+ } finally {
220
+ loading.value = false;
221
+ }
222
+ }
223
+
224
+ async function createDraft(
225
+ data: Omit<CreateBusinessTypeVersionDraftData, 'businessTypeId'>,
226
+ ): Promise<BusinessTypeVersionMutationResult> {
227
+ const result = await fetchJson<BusinessTypeVersionMutationResult>(versionsUrl, {
228
+ method: 'POST',
229
+ body: JSON.stringify(data),
230
+ });
231
+ if (!result) throw new BusinessTypesApiError(0, null, 'CreateDraft gab keinen Body zurück');
232
+ versions.value = [...versions.value, result.businessTypeVersion];
233
+ return result;
234
+ }
235
+
236
+ async function updateDraft(
237
+ versionId: string,
238
+ data: UpdateBusinessTypeVersionDraftData,
239
+ ): Promise<BusinessTypeVersionMutationResult> {
240
+ const result = await fetchJson<BusinessTypeVersionMutationResult>(
241
+ `${versionUrlBase}/${versionId}`,
242
+ { method: 'PATCH', body: JSON.stringify(data) },
243
+ );
244
+ if (!result) throw new BusinessTypesApiError(0, null, 'UpdateDraft gab keinen Body zurück');
245
+ versions.value = versions.value.map((v) =>
246
+ v.id === versionId ? result.businessTypeVersion : v,
247
+ );
248
+ return result;
249
+ }
250
+
251
+ async function publish(
252
+ versionId: string,
253
+ opts: { forceRegressive?: boolean } = {},
254
+ ): Promise<BusinessTypeVersionMutationResult> {
255
+ const result = await fetchJson<BusinessTypeVersionMutationResult>(
256
+ `${versionUrlBase}/${versionId}/publish`,
257
+ { method: 'POST', body: JSON.stringify(opts) },
258
+ );
259
+ if (!result) throw new BusinessTypesApiError(0, null, 'Publish gab keinen Body zurück');
260
+ await load();
261
+ return result;
262
+ }
263
+
264
+ if (options.autoLoad) {
265
+ void load();
266
+ }
267
+
268
+ return { versions, loading, error, load, createDraft, updateDraft, publish };
269
+ }