@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,207 @@
1
+ <template>
2
+ <div class="bv-vstrip">
3
+ <div class="bv-vstrip-label">Versionen</div>
4
+ <div class="bv-vstrip-tabs">
5
+ <button
6
+ v-for="v in sortedVersions"
7
+ :key="v.id"
8
+ type="button"
9
+ class="bv-vtab"
10
+ :class="[`bv-vtab-${statusOf(v)}`, { 'bv-vtab-current': v.id === modelValue }]"
11
+ :title="BUNDLE_STATUS_META[statusOf(v)].tooltip"
12
+ @click="$emit('update:modelValue', v.id)"
13
+ >
14
+ <span class="bv-vtab-name">v{{ v.version }}</span>
15
+ <span :class="['bv-vtab-status', statusOf(v)]">
16
+ {{ BUNDLE_STATUS_META[statusOf(v)].label }}
17
+ </span>
18
+ <span class="bv-vtab-dates">
19
+ {{ formatDateDE(v.validFrom) }}
20
+ <template v-if="v.validUntil"> – {{ formatDateDE(v.validUntil) }}</template>
21
+ <template v-else> – offen</template>
22
+ </span>
23
+ <span class="bv-vtab-price">
24
+ {{ v.monthlyNet ?? '—' }}<template v-if="v.monthlyNet"> €</template> / Mo
25
+ </span>
26
+ </button>
27
+ <button
28
+ type="button"
29
+ class="bv-vtab bv-vtab-new"
30
+ :disabled="!canAddVersion"
31
+ :title="
32
+ canAddVersion
33
+ ? 'Neue, zukünftige Version anlegen'
34
+ : 'Bundle hat bereits eine Draft-Version — erst publishen oder verwerfen'
35
+ "
36
+ @click="$emit('addVersion')"
37
+ >
38
+ <svg
39
+ width="14"
40
+ height="14"
41
+ viewBox="0 0 24 24"
42
+ fill="none"
43
+ stroke="currentColor"
44
+ stroke-width="2"
45
+ >
46
+ <path d="M12 5v14M5 12h14" />
47
+ </svg>
48
+ <span>Neue Version</span>
49
+ </button>
50
+ </div>
51
+ </div>
52
+ </template>
53
+
54
+ <script setup lang="ts">
55
+ import { computed } from 'vue';
56
+ import type { BundleVersionRow } from '@saasicat/types';
57
+
58
+ import {
59
+ BUNDLE_STATUS_META,
60
+ bundleVersionStatus,
61
+ bundleVersionsSorted,
62
+ formatDateDE,
63
+ } from './bundle-version-status';
64
+
65
+ // BundleVersionStrip — tab bar across all versions of a bundle, modeled on
66
+ // the plan simulation (saasadminui/project/bundles.jsx → BundleVersionStrip).
67
+ // Shows per tab: version number, status chip, validity span,
68
+ // monthly price. "New version" button on the far right; disabled when a
69
+ // draft already exists (single-draft constraint of the BundleRepository).
70
+
71
+ const props = defineProps<{
72
+ /** All versions of the bundle (all lifecycle statuses). */
73
+ versions: BundleVersionRow[];
74
+ /** ID of the currently selected version. */
75
+ modelValue: string | null;
76
+ /** Optional: reference point in time for the status check (tests). */
77
+ now?: Date;
78
+ }>();
79
+
80
+ defineEmits<{
81
+ (e: 'update:modelValue', versionId: string): void;
82
+ (e: 'addVersion'): void;
83
+ }>();
84
+
85
+ const sortedVersions = computed(() => bundleVersionsSorted(props.versions));
86
+
87
+ const canAddVersion = computed(() => !props.versions.some((v) => v.publishedAt === null));
88
+
89
+ function statusOf(v: BundleVersionRow) {
90
+ return bundleVersionStatus(v, props.now);
91
+ }
92
+ </script>
93
+
94
+ <style scoped>
95
+ .bv-vstrip {
96
+ display: flex;
97
+ flex-direction: column;
98
+ gap: 8px;
99
+ padding: 14px 16px;
100
+ background: #fbfbfd;
101
+ border: 1px solid var(--bv-border, #e5e7eb);
102
+ border-radius: 10px;
103
+ }
104
+ .bv-vstrip-label {
105
+ font-size: 10.5px;
106
+ font-weight: 700;
107
+ text-transform: uppercase;
108
+ letter-spacing: 0.1em;
109
+ color: #64748b;
110
+ }
111
+ .bv-vstrip-tabs {
112
+ display: flex;
113
+ flex-wrap: wrap;
114
+ gap: 8px;
115
+ }
116
+
117
+ .bv-vtab {
118
+ display: grid;
119
+ grid-template-columns: auto auto;
120
+ grid-template-rows: auto auto;
121
+ column-gap: 8px;
122
+ row-gap: 2px;
123
+ align-items: center;
124
+ padding: 8px 12px;
125
+ background: #fff;
126
+ border: 1px solid #e2e8f0;
127
+ border-radius: 8px;
128
+ cursor: pointer;
129
+ font-family: inherit;
130
+ color: #0f172a;
131
+ text-align: left;
132
+ transition:
133
+ border-color 0.12s,
134
+ box-shadow 0.12s,
135
+ background 0.12s;
136
+ }
137
+ .bv-vtab:hover {
138
+ background: #f8fafc;
139
+ }
140
+ .bv-vtab-current {
141
+ border-color: #2563eb;
142
+ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12);
143
+ }
144
+ .bv-vtab-name {
145
+ font:
146
+ 700 13px 'JetBrains Mono',
147
+ ui-monospace,
148
+ monospace;
149
+ letter-spacing: 0.02em;
150
+ }
151
+ .bv-vtab-status {
152
+ font-size: 10.5px;
153
+ font-weight: 700;
154
+ letter-spacing: 0.04em;
155
+ text-transform: uppercase;
156
+ padding: 1px 6px;
157
+ border-radius: 4px;
158
+ justify-self: end;
159
+ }
160
+ .bv-vtab-status.live {
161
+ background: #d1fae5;
162
+ color: #065f46;
163
+ }
164
+ .bv-vtab-status.scheduled {
165
+ background: #fef3c7;
166
+ color: #92400e;
167
+ }
168
+ .bv-vtab-status.superseded {
169
+ background: #e2e8f0;
170
+ color: #475569;
171
+ }
172
+ .bv-vtab-status.draft {
173
+ background: #dbeafe;
174
+ color: #1e40af;
175
+ }
176
+ .bv-vtab-dates {
177
+ grid-column: 1 / -1;
178
+ font-size: 11px;
179
+ color: #64748b;
180
+ }
181
+ .bv-vtab-price {
182
+ grid-column: 1 / -1;
183
+ font-size: 11.5px;
184
+ color: #475569;
185
+ font-weight: 600;
186
+ }
187
+
188
+ .bv-vtab-new {
189
+ display: flex;
190
+ align-items: center;
191
+ gap: 6px;
192
+ padding: 8px 12px;
193
+ background: #fff;
194
+ border: 1px dashed #cbd5e1;
195
+ color: #2563eb;
196
+ font-size: 12.5px;
197
+ font-weight: 600;
198
+ }
199
+ .bv-vtab-new:hover:not(:disabled) {
200
+ background: #eff6ff;
201
+ border-color: #93c5fd;
202
+ }
203
+ .bv-vtab-new:disabled {
204
+ opacity: 0.45;
205
+ cursor: not-allowed;
206
+ }
207
+ </style>
@@ -0,0 +1,164 @@
1
+ // Pure helper functions for the bundle-version UI — analogous to the
2
+ // plan simulation (saasadminui/project/bundles.jsx). The logic knows only
3
+ // the wire format (`BundleVersionRow`, `PlanVersionRow`), no DOM, no
4
+ // API calls — so it can be consumed 1:1 by the inline editor + strip +
5
+ // status banner + compat picker and called purely in tests.
6
+
7
+ import type { BundleVersionRow, PlanVersionRow } from '@saasicat/types';
8
+
9
+ /** UI lifecycle status of a single BundleVersion. */
10
+ export type BundleVersionUiStatus = 'draft' | 'live' | 'scheduled' | 'superseded';
11
+
12
+ /** Top-level status of a bundle stem across all versions. */
13
+ export type BundleAggregateStatus = BundleVersionUiStatus | 'retired';
14
+
15
+ /**
16
+ * Status of a BundleVersion at a given date. Mapping:
17
+ * - draft : publishedAt === null
18
+ * - superseded: supersededAt !== null OR validUntil < today
19
+ * - scheduled : published, validFrom > today, supersededAt === null
20
+ * - live : otherwise (published, validFrom ≤ today ≤ validUntil)
21
+ *
22
+ * When `validFrom` is null (legacy version without lifecycle backfill),
23
+ * the version is interpreted as "live" — this is the pragmatic
24
+ * transitional behavior until the backfill migration.
25
+ */
26
+ export function bundleVersionStatus(
27
+ v: BundleVersionRow,
28
+ now: Date = new Date(),
29
+ ): BundleVersionUiStatus {
30
+ if (v.publishedAt === null) return 'draft';
31
+ if (v.supersededAt !== null) return 'superseded';
32
+ if (v.validUntil) {
33
+ const until = new Date(v.validUntil);
34
+ if (!Number.isNaN(until.getTime()) && until.getTime() < now.getTime()) {
35
+ return 'superseded';
36
+ }
37
+ }
38
+ if (v.validFrom) {
39
+ const from = new Date(v.validFrom);
40
+ if (!Number.isNaN(from.getTime()) && from.getTime() > now.getTime()) {
41
+ return 'scheduled';
42
+ }
43
+ }
44
+ return 'live';
45
+ }
46
+
47
+ /** UI metadata for a status (label, CSS class, tooltip). */
48
+ export interface BundleStatusMeta {
49
+ label: string;
50
+ cls: 'draft' | 'live' | 'scheduled' | 'supersed';
51
+ tooltip: string;
52
+ }
53
+
54
+ export const BUNDLE_STATUS_META: Record<BundleAggregateStatus, BundleStatusMeta> = {
55
+ draft: {
56
+ label: 'Draft',
57
+ cls: 'draft',
58
+ tooltip: 'Noch nicht veröffentlicht — frei editierbar',
59
+ },
60
+ live: {
61
+ label: 'Live',
62
+ cls: 'live',
63
+ tooltip: 'Aktive Version · verkaufbar · read-only (laufende Verträge)',
64
+ },
65
+ scheduled: {
66
+ label: 'Geplant',
67
+ cls: 'scheduled',
68
+ tooltip: 'Zukünftig aktiv · noch nicht verkaufbar · frei editierbar',
69
+ },
70
+ superseded: {
71
+ label: 'Abgelöst',
72
+ cls: 'supersed',
73
+ tooltip: 'Durch neue Version ersetzt · Bestand bleibt',
74
+ },
75
+ retired: {
76
+ label: 'Retired',
77
+ cls: 'supersed',
78
+ tooltip: 'Bundle-Stamm wurde soft-deleted',
79
+ },
80
+ };
81
+
82
+ /**
83
+ * Sorts the versions of a bundle line ascending by
84
+ * `validFrom` (drafts without validFrom go to the end).
85
+ */
86
+ export function bundleVersionsSorted(versions: BundleVersionRow[]): BundleVersionRow[] {
87
+ return [...versions].sort((a, b) => {
88
+ const av = a.validFrom ?? '';
89
+ const bv = b.validFrom ?? '';
90
+ if (av === bv) return a.version - b.version;
91
+ if (!av) return 1;
92
+ if (!bv) return -1;
93
+ return av.localeCompare(bv);
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Currently active (live) bundle version or `null`.
99
+ * Unlike `bundleVersionStatus`, this refers to the whole bundle
100
+ * stem — used in the card header for the "Live · v3" display.
101
+ */
102
+ export function bundleActiveVersionAt(
103
+ versions: BundleVersionRow[],
104
+ now: Date = new Date(),
105
+ ): BundleVersionRow | null {
106
+ return versions.find((v) => bundleVersionStatus(v, now) === 'live') ?? null;
107
+ }
108
+
109
+ export function bundleAggregateStatus(
110
+ versions: BundleVersionRow[],
111
+ deletedAt: string | null,
112
+ now: Date = new Date(),
113
+ ): BundleAggregateStatus {
114
+ if (deletedAt) return 'retired';
115
+ let hasLive = false;
116
+ let hasScheduled = false;
117
+ let hasSuperseded = false;
118
+ let hasDraft = false;
119
+ for (const v of versions) {
120
+ const status = bundleVersionStatus(v, now);
121
+ if (status === 'live') hasLive = true;
122
+ else if (status === 'scheduled') hasScheduled = true;
123
+ else if (status === 'superseded') hasSuperseded = true;
124
+ else if (status === 'draft') hasDraft = true;
125
+ }
126
+ if (hasLive) return 'live';
127
+ if (hasScheduled) return 'scheduled';
128
+ if (hasDraft) return 'draft';
129
+ if (hasSuperseded) return 'superseded';
130
+ return 'draft';
131
+ }
132
+
133
+ /**
134
+ * Overlap between a BundleVersion and a PlanVersion:
135
+ * features + quotas that the plan already contains. Double counting
136
+ * → warning in the editor (the bundle would count features multiple
137
+ * times in the combined plan).
138
+ */
139
+ export interface BundlePlanOverlap {
140
+ features: string[];
141
+ quotas: string[];
142
+ hasAny: boolean;
143
+ }
144
+
145
+ export function findBundlePlanOverlap(
146
+ bundle: { features: string[]; quotas?: Record<string, number> },
147
+ plan: PlanVersionRow | null | undefined,
148
+ ): BundlePlanOverlap {
149
+ if (!plan) return { features: [], quotas: [], hasAny: false };
150
+ const planFeatures = new Set(plan.features);
151
+ const planQuotas = new Set(Object.keys(plan.quotas ?? {}));
152
+ const features = bundle.features.filter((f) => planFeatures.has(f));
153
+ const quotas = Object.keys(bundle.quotas ?? {}).filter((q) => planQuotas.has(q));
154
+ return { features, quotas, hasAny: features.length > 0 || quotas.length > 0 };
155
+ }
156
+
157
+ /** ISO `YYYY-MM-DD` → `DD.MM.YYYY` for UI display; null/empty → '—'. */
158
+ export function formatDateDE(iso: string | null | undefined): string {
159
+ if (!iso) return '—';
160
+ const day = iso.slice(0, 10);
161
+ const [y, m, d] = day.split('-');
162
+ if (!y || !m || !d) return iso;
163
+ return `${d}.${m}.${y}`;
164
+ }
@@ -0,0 +1,85 @@
1
+ // catalog-i18n — pure resolvers that derive the labels/units to display for a
2
+ // chosen display locale from the catalog entries (feature/quota catalog incl.
3
+ // `i18n`). The single source of truth for translations remains the Discovery
4
+ // translation tab (`FeatureCatalogEntryRow.i18n` /
5
+ // `QuotaCatalogEntryRow.i18n`); here we only project.
6
+ //
7
+ // Resolution chain per entry: `i18n[locale]` → default-locale field (DE) →
8
+ // (in the editor) Discovery fallback or the bare key. By convention the
9
+ // default locale is NOT held in `i18n`, so for DE the base field applies
10
+ // directly.
11
+
12
+ import type {
13
+ CatalogEntryI18n,
14
+ FeatureCatalogEntryRow,
15
+ QuotaCatalogEntryRow,
16
+ } from '@saasicat/types';
17
+
18
+ export const CATALOG_DEFAULT_LOCALE = 'de';
19
+
20
+ /** Display mapping per feature key (compatible with `FeatureMeta`). */
21
+ export interface FeatureRegistryEntry {
22
+ label?: string;
23
+ group?: string;
24
+ core?: boolean;
25
+ }
26
+
27
+ /** Display mapping per quota key. */
28
+ export interface QuotaMeta {
29
+ label?: string;
30
+ unit?: string;
31
+ }
32
+
33
+ function localized(
34
+ i18n: CatalogEntryI18n | undefined,
35
+ locale: string,
36
+ field: 'label' | 'unit',
37
+ ): string | undefined {
38
+ if (locale === CATALOG_DEFAULT_LOCALE) return undefined;
39
+ const value = i18n?.[locale]?.[field];
40
+ return value && value.trim().length > 0 ? value : undefined;
41
+ }
42
+
43
+ function resolveLabel(
44
+ baseLabel: string | null | undefined,
45
+ i18n: CatalogEntryI18n | undefined,
46
+ locale: string,
47
+ ): string | undefined {
48
+ return localized(i18n, locale, 'label') ?? (baseLabel || undefined);
49
+ }
50
+
51
+ /**
52
+ * Builds the `featureRegistry` for the bundle editors from the feature catalog
53
+ * entries. Keys without a resolvable label are omitted — the editor then falls
54
+ * back to the feature key.
55
+ */
56
+ export function buildFeatureRegistry(
57
+ featureCatalog: FeatureCatalogEntryRow[],
58
+ locale: string,
59
+ ): Record<string, FeatureRegistryEntry> {
60
+ const registry: Record<string, FeatureRegistryEntry> = {};
61
+ for (const feature of featureCatalog) {
62
+ const label = resolveLabel(feature.label, feature.i18n, locale);
63
+ if (label) registry[feature.featureKey] = { label, core: feature.core };
64
+ }
65
+ return registry;
66
+ }
67
+
68
+ /**
69
+ * Builds the `quotaRegistry` for the bundle editors from the quota catalog
70
+ * entries. Label and unit are resolved independently; if a translation is
71
+ * missing, the editor falls back to the Discovery value or the quota key.
72
+ */
73
+ export function buildQuotaRegistry(
74
+ quotaCatalog: QuotaCatalogEntryRow[],
75
+ locale: string,
76
+ ): Record<string, QuotaMeta> {
77
+ const registry: Record<string, QuotaMeta> = {};
78
+ for (const quota of quotaCatalog) {
79
+ registry[quota.quotaKey] = {
80
+ label: resolveLabel(quota.label, quota.i18n, locale),
81
+ unit: localized(quota.i18n, locale, 'unit') ?? (quota.unit || undefined),
82
+ };
83
+ }
84
+ return registry;
85
+ }