@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,663 @@
1
+ <template>
2
+ <div class="sa-discovery">
3
+ <DiscoveryHeader :loading="loading" @run-discovery="onRunDiscovery" />
4
+
5
+ <DiscoveryMetaBanner
6
+ :app-label="appLabel"
7
+ :app-key="appKey"
8
+ :app-version="appVersion"
9
+ :scan-label="scanLabel"
10
+ />
11
+
12
+ <q-banner v-if="error" class="sa-discovery__error" rounded>
13
+ <template #avatar><q-icon name="warning" color="negative" /></template>
14
+ Fehler: {{ error.message }}
15
+ </q-banner>
16
+
17
+ <DiscoveryKpis
18
+ :features-count="features.length"
19
+ :capabilities-count="capabilities.length"
20
+ :approved-count="approvedCount"
21
+ :pending-count="pendingCount"
22
+ :outdated-count="outdatedCount"
23
+ :obsolete-count="obsoleteCount"
24
+ :orphan-count="orphanCaps.length"
25
+ />
26
+
27
+ <q-tabs v-model="activeTab" align="left" dense class="sa-discovery__tabs">
28
+ <q-tab name="features" :label="`Features (${features.length})`" />
29
+ <q-tab name="quotas" :label="`Quotas (${quotas.length})`" />
30
+ </q-tabs>
31
+
32
+ <q-tab-panels v-model="activeTab" animated class="sa-discovery__panels">
33
+ <q-tab-panel name="features" class="sa-discovery__panel">
34
+ <div class="sa-discovery__toolbar">
35
+ <q-input
36
+ v-model="featureQuery"
37
+ dense
38
+ outlined
39
+ clearable
40
+ placeholder="Feature-Key, Label oder Capability suchen …"
41
+ class="sa-discovery__search"
42
+ >
43
+ <template #prepend><q-icon name="search" /></template>
44
+ </q-input>
45
+ <q-select
46
+ v-model="statusFilter"
47
+ dense
48
+ outlined
49
+ emit-value
50
+ map-options
51
+ :options="STATUS_FILTER_OPTIONS"
52
+ class="sa-discovery__filter"
53
+ />
54
+ </div>
55
+
56
+ <div v-for="group in featureGroups" :key="group.label" class="sa-discovery__group">
57
+ <div class="sa-discovery__group-head">
58
+ <span class="sa-discovery__group-title">{{ group.label }}</span>
59
+ <span class="sa-discovery__group-count">{{ group.features.length }}</span>
60
+ </div>
61
+ <div class="sa-discovery__cardlist">
62
+ <DiscoveryFeatureCard
63
+ v-for="f in group.features"
64
+ :key="f.featureKey"
65
+ :feature="f"
66
+ :capabilities="capsByFeature.get(f.featureKey) ?? []"
67
+ :owners="ownersByFeature.get(f.featureKey) ?? []"
68
+ :declared-at-by-key="declaredAtByKey"
69
+ :active-locales="activeLocales"
70
+ :expanded="expandedFeature === f.featureKey"
71
+ @toggle="toggleFeature(f.featureKey)"
72
+ @review="onFeatureReview"
73
+ @feature-base="onFeatureBase"
74
+ @feature-locale="onFeatureLocale"
75
+ />
76
+ </div>
77
+ </div>
78
+ <div v-if="filteredFeatures.length === 0" class="sa-discovery__empty-row">
79
+ Keine Features entsprechen den Filtern.
80
+ </div>
81
+
82
+ <div v-if="orphanCaps.length" class="sa-discovery__group">
83
+ <div class="sa-discovery__group-head">
84
+ <span class="sa-discovery__group-title sa-discovery__group-title--orphan">
85
+ <q-icon name="warning" size="14px" />
86
+ Capabilities ohne Feature
87
+ </span>
88
+ <span class="sa-discovery__group-count">{{ orphanCaps.length }}</span>
89
+ </div>
90
+ <p class="sa-discovery__orphan-hint">
91
+ Diesen Capabilities fehlt der <code>feature:</code>-Tag im
92
+ <code>@ImplementsCapability</code>-Decorator — Feature-Zuordnung im Code
93
+ nachziehen, sonst sind sie nicht verkaufbar.
94
+ </p>
95
+ <DiscoveryCapList
96
+ :capabilities="orphanCaps"
97
+ :declared-at-by-key="declaredAtByKey"
98
+ />
99
+ </div>
100
+ </q-tab-panel>
101
+
102
+ <q-tab-panel name="quotas" class="sa-discovery__panel">
103
+ <div class="sa-discovery__cardlist">
104
+ <DiscoveryQuotaCard
105
+ v-for="q in quotas"
106
+ :key="q.quotaKey"
107
+ :quota="q"
108
+ :active-locales="activeLocales"
109
+ :expanded="expandedQuota === q.quotaKey"
110
+ @toggle="toggleQuota(q.quotaKey)"
111
+ @review="onQuotaReview"
112
+ @quota-base="onQuotaBase"
113
+ @quota-locale="onQuotaLocale"
114
+ />
115
+ <div v-if="quotas.length === 0" class="sa-discovery__empty-row">
116
+ Keine Quotas im Code deklariert.
117
+ </div>
118
+ </div>
119
+ </q-tab-panel>
120
+ </q-tab-panels>
121
+ </div>
122
+ </template>
123
+
124
+ <script setup lang="ts">
125
+ import { computed, onMounted, ref } from 'vue';
126
+ import type {
127
+ CapabilityCatalogEntryRow,
128
+ CatalogEntryI18n,
129
+ CatalogEntryI18nFields,
130
+ DiscoverySnapshot,
131
+ DiscoveryStatus,
132
+ FeatureCatalogEntryRow,
133
+ QuotaCatalogEntryRow,
134
+ ReviewCatalogEntryData,
135
+ UpdateCatalogEntryBaseData,
136
+ } from '@saasicat/types';
137
+ import DiscoveryCapList from './discovery-page/DiscoveryCapList.vue';
138
+ import DiscoveryFeatureCard from './discovery-page/DiscoveryFeatureCard.vue';
139
+ import DiscoveryHeader from './discovery-page/DiscoveryHeader.vue';
140
+ import DiscoveryKpis from './discovery-page/DiscoveryKpis.vue';
141
+ import DiscoveryMetaBanner from './discovery-page/DiscoveryMetaBanner.vue';
142
+ import DiscoveryQuotaCard from './discovery-page/DiscoveryQuotaCard.vue';
143
+ import { STATUS_META } from './discovery-page/discovery-ui.js';
144
+
145
+ // Platform standard page: Discovery review, feature-centric (#20).
146
+ // Two tabs (Features + Quotas); each entry is an expandable card with
147
+ // StatusControl (approval state machine pending → approved ↔ outdated · obsolete),
148
+ // base data, translations and read-only code capabilities. Features are
149
+ // grouped by owner (rollup from the capability decorators, #14).
150
+ // Dumb component — the consumer wrapper wires up `useDiscovery` (snapshot)
151
+ // + `useCatalogEntries` (review status, translations) and passes the
152
+ // results through as props.
153
+
154
+ const props = defineProps<{
155
+ /** Discovery snapshot — enrichment only (declaredAt, scan meta). */
156
+ snapshot: DiscoverySnapshot | null;
157
+ capabilities: CapabilityCatalogEntryRow[];
158
+ features: FeatureCatalogEntryRow[];
159
+ quotas: QuotaCatalogEntryRow[];
160
+ loading: boolean;
161
+ error: Error | null;
162
+ /** Active locales from the project (incl. default locale). */
163
+ activeLocales: string[];
164
+ /** Reload snapshot → sync → reload catalog entries. */
165
+ runDiscovery: () => Promise<void>;
166
+ /** Approval transition of a feature (PATCH …/features/:key/review). */
167
+ reviewFeature: (featureKey: string, data: ReviewCatalogEntryData) => Promise<unknown>;
168
+ /** Approval transition of a quota (PATCH …/quotas/:key/review). */
169
+ reviewQuota: (quotaKey: string, data: ReviewCatalogEntryData) => Promise<unknown>;
170
+ setFeatureI18n: (featureKey: string, i18n: CatalogEntryI18n) => Promise<unknown>;
171
+ setQuotaI18n: (quotaKey: string, i18n: CatalogEntryI18n) => Promise<unknown>;
172
+ setFeatureBase: (featureKey: string, data: UpdateCatalogEntryBaseData) => Promise<unknown>;
173
+ setQuotaBase: (quotaKey: string, data: UpdateCatalogEntryBaseData) => Promise<unknown>;
174
+ }>();
175
+
176
+ const activeTab = ref<'features' | 'quotas'>('features');
177
+ const featureQuery = ref('');
178
+ const statusFilter = ref<DiscoveryStatus | 'all'>('all');
179
+ const expandedFeature = ref<string | null>(null);
180
+ const expandedQuota = ref<string | null>(null);
181
+
182
+ const STATUS_FILTER_OPTIONS: Array<{ label: string; value: DiscoveryStatus | 'all' }> = [
183
+ { label: 'Alle Status', value: 'all' },
184
+ ...(['pending', 'approved', 'outdated', 'obsolete'] as const).map((status) => ({
185
+ label: STATUS_META[status].label,
186
+ value: status,
187
+ })),
188
+ ];
189
+
190
+ const appKey = computed(() => props.snapshot?.app.key ?? '—');
191
+ const appLabel = computed(() => {
192
+ const k = props.snapshot?.app.key ?? '';
193
+ return k ? k.charAt(0).toUpperCase() + k.slice(1) : 'Discovery';
194
+ });
195
+ const appVersion = computed(() => props.snapshot?.app.version ?? '0.0.0');
196
+ const scanLabel = computed(() => {
197
+ if (!props.snapshot?.scannedAt) return 'noch nicht gescannt';
198
+ try {
199
+ return new Date(props.snapshot.scannedAt).toLocaleString('de-DE');
200
+ } catch {
201
+ return props.snapshot.scannedAt;
202
+ }
203
+ });
204
+
205
+ const declaredAtByKey = computed<Record<string, string>>(() => {
206
+ const map: Record<string, string> = {};
207
+ for (const c of props.snapshot?.capabilities ?? []) {
208
+ map[c.capabilityKey] = c.declaredAt;
209
+ }
210
+ return map;
211
+ });
212
+
213
+ // ─── Feature-centric aggregation ────────────────────────────────────────────
214
+
215
+ const capsByFeature = computed<Map<string, CapabilityCatalogEntryRow[]>>(() => {
216
+ const map = new Map<string, CapabilityCatalogEntryRow[]>();
217
+ for (const c of props.capabilities) {
218
+ if (!c.featureKey) continue;
219
+ const list = map.get(c.featureKey);
220
+ if (list) list.push(c);
221
+ else map.set(c.featureKey, [c]);
222
+ }
223
+ return map;
224
+ });
225
+
226
+ /** Owner rollup per feature (#14): owners of the capabilities, most frequent first. */
227
+ const ownersByFeature = computed<Map<string, string[]>>(() => {
228
+ const map = new Map<string, string[]>();
229
+ for (const [featureKey, caps] of capsByFeature.value) {
230
+ const counts = new Map<string, number>();
231
+ for (const c of caps) {
232
+ if (c.owner) counts.set(c.owner, (counts.get(c.owner) ?? 0) + 1);
233
+ }
234
+ map.set(
235
+ featureKey,
236
+ [...counts.entries()]
237
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
238
+ .map(([owner]) => owner),
239
+ );
240
+ }
241
+ return map;
242
+ });
243
+
244
+ const orphanCaps = computed(() =>
245
+ props.capabilities.filter((c) => !c.featureKey && c.codeStatus !== 'retired'),
246
+ );
247
+
248
+ const approvedCount = computed(
249
+ () => props.features.filter((f) => f.discoveryStatus === 'approved').length,
250
+ );
251
+ const pendingCount = computed(
252
+ () => props.features.filter((f) => f.discoveryStatus === 'pending').length,
253
+ );
254
+ const outdatedCount = computed(
255
+ () => props.features.filter((f) => f.discoveryStatus === 'outdated').length,
256
+ );
257
+ const obsoleteCount = computed(
258
+ () => props.features.filter((f) => f.discoveryStatus === 'obsolete').length,
259
+ );
260
+
261
+ const filteredFeatures = computed(() => {
262
+ const q = featureQuery.value.trim().toLowerCase();
263
+ return props.features.filter((f) => {
264
+ if (statusFilter.value !== 'all' && f.discoveryStatus !== statusFilter.value) {
265
+ return false;
266
+ }
267
+ if (!q) return true;
268
+ if (f.featureKey.toLowerCase().includes(q) || (f.label ?? '').toLowerCase().includes(q)) {
269
+ return true;
270
+ }
271
+ const caps = capsByFeature.value.get(f.featureKey) ?? [];
272
+ return caps.some(
273
+ (c) =>
274
+ c.capabilityKey.toLowerCase().includes(q) ||
275
+ (c.label ?? '').toLowerCase().includes(q),
276
+ );
277
+ });
278
+ });
279
+
280
+ const NO_OWNER_LABEL = 'Ohne Owner';
281
+
282
+ /** Grouping by primary owner (#14); "Ohne Owner" last. */
283
+ const featureGroups = computed<Array<{ label: string; features: FeatureCatalogEntryRow[] }>>(() => {
284
+ const groups = new Map<string, FeatureCatalogEntryRow[]>();
285
+ for (const f of filteredFeatures.value) {
286
+ const owner = ownersByFeature.value.get(f.featureKey)?.[0] ?? NO_OWNER_LABEL;
287
+ const list = groups.get(owner);
288
+ if (list) list.push(f);
289
+ else groups.set(owner, [f]);
290
+ }
291
+ return [...groups.entries()]
292
+ .sort((a, b) => {
293
+ if (a[0] === NO_OWNER_LABEL) return 1;
294
+ if (b[0] === NO_OWNER_LABEL) return -1;
295
+ return a[0].localeCompare(b[0]);
296
+ })
297
+ .map(([label, features]) => ({ label, features }));
298
+ });
299
+
300
+ function toggleFeature(key: string): void {
301
+ expandedFeature.value = expandedFeature.value === key ? null : key;
302
+ }
303
+ function toggleQuota(key: string): void {
304
+ expandedQuota.value = expandedQuota.value === key ? null : key;
305
+ }
306
+
307
+ async function onRunDiscovery(): Promise<void> {
308
+ await props.runDiscovery();
309
+ }
310
+
311
+ function onFeatureReview(key: string, target: DiscoveryStatus): void {
312
+ persist(props.reviewFeature(key, { discoveryStatus: target }));
313
+ }
314
+ function onQuotaReview(key: string, target: DiscoveryStatus): void {
315
+ persist(props.reviewQuota(key, { discoveryStatus: target }));
316
+ }
317
+
318
+ // Persistence is debounced — the editor fires on every keystroke. Patches
319
+ // are accumulated per target (base or locale) so that label + description
320
+ // end up in a single request and nothing gets lost.
321
+ const I18N_DEBOUNCE_MS = 500;
322
+ const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
323
+ function debounced(id: string, fn: () => void): void {
324
+ const prev = debounceTimers.get(id);
325
+ if (prev) clearTimeout(prev);
326
+ debounceTimers.set(
327
+ id,
328
+ setTimeout(() => {
329
+ debounceTimers.delete(id);
330
+ fn();
331
+ }, I18N_DEBOUNCE_MS),
332
+ );
333
+ }
334
+
335
+ const pendingBase = new Map<string, UpdateCatalogEntryBaseData>();
336
+ const pendingLocale = new Map<string, CatalogEntryI18nFields>();
337
+
338
+ /** Settle the persistence promise so no error stays unhandled. */
339
+ function persist(p: Promise<unknown>): void {
340
+ p.catch((err) => {
341
+ // Auth renewal/redirect is handled by the HTTP client; here just log
342
+ // so a failed save stays visible.
343
+ console.error('Catalog-Entry konnte nicht gespeichert werden', err);
344
+ });
345
+ }
346
+
347
+ /** Treats an empty field as deleted so the fallback to DE takes effect. */
348
+ function withField(target: Record<string, string>, field: string, value: string): void {
349
+ if (value) target[field] = value;
350
+ else delete target[field];
351
+ }
352
+
353
+ function onBaseUpdate(
354
+ kind: 'feature' | 'quota',
355
+ key: string,
356
+ patch: UpdateCatalogEntryBaseData,
357
+ ): void {
358
+ const id = `${kind}-base:${key}`;
359
+ const acc = pendingBase.get(id) ?? {};
360
+ Object.assign(acc, patch);
361
+ pendingBase.set(id, acc);
362
+ debounced(id, () => {
363
+ const data = pendingBase.get(id);
364
+ pendingBase.delete(id);
365
+ if (!data) return;
366
+ persist(
367
+ kind === 'feature' ? props.setFeatureBase(key, data) : props.setQuotaBase(key, data),
368
+ );
369
+ });
370
+ }
371
+
372
+ function onLocaleUpdate(
373
+ kind: 'feature' | 'quota',
374
+ key: string,
375
+ locale: string,
376
+ patch: CatalogEntryI18nFields,
377
+ ): void {
378
+ const id = `${kind}-i18n:${key}|${locale}`;
379
+ const acc = pendingLocale.get(id) ?? {};
380
+ Object.assign(acc, patch);
381
+ pendingLocale.set(id, acc);
382
+ debounced(id, () => {
383
+ const data = pendingLocale.get(id);
384
+ pendingLocale.delete(id);
385
+ if (!data) return;
386
+ const rows: Array<{ i18n?: CatalogEntryI18n }> =
387
+ kind === 'feature' ? props.features : props.quotas;
388
+ const idKey = kind === 'feature' ? 'featureKey' : 'quotaKey';
389
+ const row = (rows as Array<Record<string, unknown>>).find((r) => r[idKey] === key);
390
+ const next: CatalogEntryI18n = { ...((row?.i18n as CatalogEntryI18n) ?? {}) };
391
+ const localeFields = { ...(next[locale] ?? {}) } as Record<string, string>;
392
+ for (const [field, value] of Object.entries(data)) {
393
+ withField(localeFields, field, String(value ?? ''));
394
+ }
395
+ next[locale] = localeFields;
396
+ persist(
397
+ kind === 'feature' ? props.setFeatureI18n(key, next) : props.setQuotaI18n(key, next),
398
+ );
399
+ });
400
+ }
401
+
402
+ function onFeatureBase(key: string, patch: UpdateCatalogEntryBaseData): void {
403
+ onBaseUpdate('feature', key, patch);
404
+ }
405
+ function onQuotaBase(key: string, patch: UpdateCatalogEntryBaseData): void {
406
+ onBaseUpdate('quota', key, patch);
407
+ }
408
+ function onFeatureLocale(key: string, locale: string, patch: CatalogEntryI18nFields): void {
409
+ onLocaleUpdate('feature', key, locale, patch);
410
+ }
411
+ function onQuotaLocale(key: string, locale: string, patch: CatalogEntryI18nFields): void {
412
+ onLocaleUpdate('quota', key, locale, patch);
413
+ }
414
+
415
+ onMounted(() => {
416
+ if (props.capabilities.length === 0 && !props.loading) {
417
+ void props.runDiscovery();
418
+ }
419
+ });
420
+ </script>
421
+
422
+ <style>
423
+ .sa-discovery {
424
+ padding: 16px;
425
+ display: flex;
426
+ flex-direction: column;
427
+ gap: 14px;
428
+ }
429
+ .sa-discovery__head {
430
+ display: flex;
431
+ justify-content: space-between;
432
+ align-items: flex-end;
433
+ gap: 16px;
434
+ }
435
+ .sa-discovery__title {
436
+ font-size: 20px;
437
+ font-weight: 700;
438
+ margin: 0;
439
+ }
440
+ .sa-discovery__sub {
441
+ margin: 4px 0 0;
442
+ color: #64748b;
443
+ font-size: 12px;
444
+ max-width: 640px;
445
+ }
446
+ .sa-discovery__banner {
447
+ display: flex;
448
+ justify-content: space-between;
449
+ align-items: center;
450
+ background: #0f172a;
451
+ color: #e2e8f0;
452
+ border-radius: 12px;
453
+ padding: 14px 18px;
454
+ }
455
+ .sa-discovery__banner-app {
456
+ display: flex;
457
+ align-items: center;
458
+ gap: 12px;
459
+ }
460
+ .sa-discovery__banner-name {
461
+ font-weight: 700;
462
+ font-size: 14px;
463
+ }
464
+ .sa-discovery__banner-meta {
465
+ font-size: 11px;
466
+ color: #94a3b8;
467
+ }
468
+ .sa-discovery__banner-time {
469
+ text-align: right;
470
+ }
471
+ .sa-discovery__banner-time-lbl {
472
+ font-size: 10px;
473
+ text-transform: uppercase;
474
+ letter-spacing: 0.5px;
475
+ color: #94a3b8;
476
+ }
477
+ .sa-discovery__banner-time-val {
478
+ font-size: 13px;
479
+ font-weight: 600;
480
+ }
481
+ .sa-discovery__error {
482
+ border-left: 4px solid #dc2626;
483
+ }
484
+ .sa-discovery__kpis {
485
+ display: grid;
486
+ grid-template-columns: repeat(5, 1fr);
487
+ gap: 10px;
488
+ }
489
+ .sa-discovery__kpi {
490
+ background: #fff;
491
+ border: 1px solid #e2e8f0;
492
+ padding: 12px 14px;
493
+ border-radius: 10px;
494
+ }
495
+ .sa-discovery__kpi.good {
496
+ border-color: #a7f3d0;
497
+ }
498
+ .sa-discovery__kpi.good .sa-discovery__kpi-label,
499
+ .sa-discovery__kpi.good .sa-discovery__kpi-value {
500
+ color: #047857;
501
+ }
502
+ .sa-discovery__kpi.warn {
503
+ border-color: #fde68a;
504
+ background: #fffbeb;
505
+ }
506
+ .sa-discovery__kpi.bad {
507
+ border-color: #fecaca;
508
+ background: #fef2f2;
509
+ }
510
+ .sa-discovery__kpi-label {
511
+ font-size: 10px;
512
+ text-transform: uppercase;
513
+ letter-spacing: 0.5px;
514
+ color: #64748b;
515
+ }
516
+ .sa-discovery__kpi-value {
517
+ font-size: 26px;
518
+ font-weight: 700;
519
+ }
520
+ .sa-discovery__kpi-sub {
521
+ font-size: 11px;
522
+ color: #94a3b8;
523
+ }
524
+ .sa-discovery__tabs {
525
+ border-bottom: 1px solid #e2e8f0;
526
+ }
527
+ .sa-discovery__panel {
528
+ padding: 14px 0;
529
+ }
530
+ .sa-discovery__toolbar {
531
+ display: flex;
532
+ gap: 12px;
533
+ margin-bottom: 14px;
534
+ align-items: center;
535
+ }
536
+ .sa-discovery__search {
537
+ flex: 1;
538
+ }
539
+ .sa-discovery__filter {
540
+ min-width: 180px;
541
+ }
542
+ .sa-discovery__cardlist {
543
+ display: flex;
544
+ flex-direction: column;
545
+ gap: 8px;
546
+ }
547
+ .sa-discovery__group {
548
+ margin-bottom: 16px;
549
+ }
550
+ .sa-discovery__group-head {
551
+ display: flex;
552
+ align-items: center;
553
+ gap: 8px;
554
+ margin-bottom: 8px;
555
+ }
556
+ .sa-discovery__group-title {
557
+ font-size: 11px;
558
+ font-weight: 700;
559
+ text-transform: uppercase;
560
+ letter-spacing: 0.5px;
561
+ color: #475569;
562
+ display: flex;
563
+ align-items: center;
564
+ gap: 5px;
565
+ }
566
+ .sa-discovery__group-title--orphan {
567
+ color: #b45309;
568
+ }
569
+ .sa-discovery__group-count {
570
+ font-size: 10px;
571
+ font-weight: 700;
572
+ background: #e2e8f0;
573
+ color: #475569;
574
+ padding: 1px 7px;
575
+ border-radius: 8px;
576
+ }
577
+ .sa-discovery__orphan-hint {
578
+ margin: 0 0 8px;
579
+ font-size: 11px;
580
+ color: #b45309;
581
+ }
582
+ .sa-discovery__orphan-hint code {
583
+ font-size: 10px;
584
+ background: #fffbeb;
585
+ padding: 1px 4px;
586
+ border-radius: 4px;
587
+ }
588
+ .sa-discovery__empty-row {
589
+ padding: 36px;
590
+ text-align: center;
591
+ color: #94a3b8;
592
+ font-size: 13px;
593
+ border: 1px dashed #cbd5e1;
594
+ border-radius: 12px;
595
+ }
596
+ .sa-muted {
597
+ color: #94a3b8;
598
+ margin-right: 3px;
599
+ }
600
+ /* Status badge (review lifecycle) — shared by feature and quota card. */
601
+ .sa-review {
602
+ font-size: 10px;
603
+ font-weight: 700;
604
+ text-transform: uppercase;
605
+ padding: 3px 8px;
606
+ border-radius: 6px;
607
+ background: #f1f5f9;
608
+ color: #475569;
609
+ flex-shrink: 0;
610
+ }
611
+ .sa-review--pending {
612
+ background: #fef3c7;
613
+ color: #92400e;
614
+ }
615
+ .sa-review--approved {
616
+ background: #dcfce7;
617
+ color: #166534;
618
+ }
619
+ .sa-review--outdated {
620
+ background: #fee2e2;
621
+ color: #b91c1c;
622
+ }
623
+ .sa-review--obsolete {
624
+ background: #e2e8f0;
625
+ color: #64748b;
626
+ }
627
+ .sa-chip {
628
+ font-size: 10px;
629
+ font-weight: 600;
630
+ padding: 2px 7px;
631
+ border-radius: 6px;
632
+ background: #f1f5f9;
633
+ color: #475569;
634
+ }
635
+ /* i18n coverage pill — shared by feature and quota card. */
636
+ .sa-cov-pill {
637
+ display: inline-flex;
638
+ align-items: center;
639
+ gap: 4px;
640
+ font-size: 10px;
641
+ font-weight: 700;
642
+ padding: 2px 7px;
643
+ border-radius: 8px;
644
+ border: 1px solid #e2e8f0;
645
+ background: #f8fafc;
646
+ color: #475569;
647
+ }
648
+ .sa-cov-pill.complete {
649
+ border-color: #a7f3d0;
650
+ background: #ecfdf5;
651
+ color: #047857;
652
+ }
653
+ .sa-cov-pill.warn {
654
+ border-color: #fde68a;
655
+ background: #fffbeb;
656
+ color: #b45309;
657
+ }
658
+ .sa-cov-pill.low {
659
+ border-color: #fecaca;
660
+ background: #fef2f2;
661
+ color: #b91c1c;
662
+ }
663
+ </style>