@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,292 @@
1
+ <template>
2
+ <div class="sa-pv-list">
3
+ <div class="sa-pv-list__kpis">
4
+ <PlanVersionsKpi label="Pakete" :value="snapshot.plans.length" icon="inventory_2" />
5
+ <PlanVersionsKpi
6
+ label="Self-Service"
7
+ :value="marketedCount"
8
+ icon="storefront"
9
+ :sub="`von ${snapshot.plans.length}`"
10
+ />
11
+ <PlanVersionsKpi label="Features gesamt" :value="totalFeatures" icon="extension" />
12
+ </div>
13
+
14
+ <div v-if="missingPlans.length > 0" class="sa-pv-list__missing">
15
+ <q-icon name="info" size="16px" />
16
+ <div>
17
+ <strong>Fehlende Pakete:</strong> {{ missingPlans.join(', ') }} — keine Live-Version
18
+ in diesem Snapshot.
19
+ <template v-if="canEdit">
20
+ <button
21
+ v-for="planId in missingPlans"
22
+ :key="`new-${planId}`"
23
+ type="button"
24
+ class="sa-pv-list__missing-btn"
25
+ @click="emit('createPlanDraft', planId)"
26
+ >
27
+ Draft für {{ planId }}
28
+ </button>
29
+ </template>
30
+ </div>
31
+ </div>
32
+
33
+ <div class="sa-pv-list__table">
34
+ <slot name="table-head">
35
+ <div
36
+ class="sa-pv-list__head sa-pv-list__row sa-pv-list__row--default"
37
+ :style="rowGridStyle"
38
+ >
39
+ <div>Paket</div>
40
+ <div class="sa-pv-list__th--num">Preis (netto)</div>
41
+ <div
42
+ v-for="col in quotaColumns"
43
+ :key="`hd-${col.key}`"
44
+ class="sa-pv-list__th--num"
45
+ >
46
+ {{ col.label }}
47
+ </div>
48
+ <div class="sa-pv-list__th--num">Features</div>
49
+ </div>
50
+ </slot>
51
+
52
+ <template v-for="(plan, i) in snapshot.plans" :key="plan.planId">
53
+ <slot
54
+ name="row"
55
+ :plan="plan"
56
+ :is-open="openId === plan.planId"
57
+ :is-last="i === snapshot.plans.length - 1"
58
+ :can-edit="canEdit"
59
+ :toggle="() => onToggle(plan.planId)"
60
+ >
61
+ <div
62
+ class="sa-pv-list__default-row sa-pv-list__row sa-pv-list__row--default"
63
+ :style="rowGridStyle"
64
+ >
65
+ <div class="sa-pv-list__default-name">
66
+ <strong>{{ plan.planId }}</strong>
67
+ <span class="sa-pv-list__default-version">v{{ plan.version }}</span>
68
+ <span v-if="plan.isDraft" class="sa-pv-list__default-chip">DRAFT</span>
69
+ </div>
70
+ <div class="sa-pv-list__th--num">
71
+ {{ formatEuro(plan.monthlyNet) }}
72
+ </div>
73
+ <div
74
+ v-for="col in quotaColumns"
75
+ :key="`val-${plan.planId}-${col.key}`"
76
+ class="sa-pv-list__th--num"
77
+ >
78
+ {{ formatQuotaValue(plan.quotas[col.key], col) }}
79
+ </div>
80
+ <div class="sa-pv-list__th--num">{{ plan.features.length }}</div>
81
+ </div>
82
+ </slot>
83
+ </template>
84
+ </div>
85
+
86
+ <div v-if="snapshot.kind === 'drafts'" class="sa-pv-list__draft-hint">
87
+ <q-icon name="info" size="18px" />
88
+ <div>
89
+ <strong>Arbeitsstand:</strong> Drafts oben in der Liste. Limits, Preise und Features
90
+ per Klick auf <em>„Bearbeiten"</em> ändern (ohne MFA). Erst
91
+ <strong>Publish-Flow</strong> (Validate → Diff-Review → MFA → Apply) macht sie für
92
+ künftige Renewals wirksam.
93
+ </div>
94
+ </div>
95
+ </div>
96
+ </template>
97
+
98
+ <script setup lang="ts">
99
+ import { computed, ref } from 'vue';
100
+ import type { CatalogSnapshot } from '../../plan-versions-catalog.js';
101
+ import PlanVersionsKpi from './PlanVersionsKpi.vue';
102
+ import { fmtEuro } from './format.js';
103
+
104
+ /**
105
+ * Configuration of a quota column (e.g. `users` → "Benutzer"). Optional
106
+ * `fractional: true` → 1 decimal place (storage GB). Optional `unit` →
107
+ * suffix like "GB" after the value. `-1` is rendered as ∞.
108
+ */
109
+ export interface QuotaColumnConfig {
110
+ key: string;
111
+ label: string;
112
+ fractional?: boolean;
113
+ unit?: string;
114
+ }
115
+
116
+ const props = defineProps<{
117
+ snapshot: CatalogSnapshot;
118
+ /**
119
+ * Optional: all expected plan IDs. When set, the list shows a
120
+ * warning with "create draft" buttons for missing plans.
121
+ */
122
+ expectedPlanIds?: readonly string[];
123
+ /**
124
+ * Optional: additional quota columns between "Preis" and "Features".
125
+ * Example: `[{ key: 'users', label: 'Benutzer' },
126
+ * { key: 'members', label: 'Mitglieder' }, { key: 'storageGb',
127
+ * label: 'Speicher (GB)', fractional: true }, { key: 'resources',
128
+ * label: 'Plätze' }]`. Default: no additional columns
129
+ * (backwards-compatible).
130
+ */
131
+ quotaColumns?: ReadonlyArray<QuotaColumnConfig>;
132
+ }>();
133
+
134
+ const emit = defineEmits<{
135
+ (e: 'editPlanDraft', draftId: string): void;
136
+ (e: 'createPlanDraft', planId: string): void;
137
+ }>();
138
+
139
+ const openId = ref<string | null>(null);
140
+ const canEdit = computed(() => props.snapshot.kind === 'drafts');
141
+
142
+ const marketedCount = computed(() => props.snapshot.plans.filter((p) => p.marketed).length);
143
+ const totalFeatures = computed(() => new Set(props.snapshot.plans.flatMap((p) => p.features)).size);
144
+
145
+ const missingPlans = computed<string[]>(() => {
146
+ if (!props.expectedPlanIds) return [];
147
+ const present = new Set(props.snapshot.plans.map((p) => p.planId));
148
+ return props.expectedPlanIds.filter((id) => !present.has(id));
149
+ });
150
+
151
+ const quotaColumns = computed<ReadonlyArray<QuotaColumnConfig>>(() => props.quotaColumns ?? []);
152
+
153
+ const rowGridStyle = computed(() => {
154
+ if (quotaColumns.value.length === 0) {
155
+ return { gridTemplateColumns: '1.6fr 1fr 0.6fr' };
156
+ }
157
+ // Name (1.6fr), price (1fr), N×quota (0.7fr), features (0.5fr)
158
+ const quotaCols = Array(quotaColumns.value.length).fill('0.7fr').join(' ');
159
+ return { gridTemplateColumns: `1.6fr 1fr ${quotaCols} 0.5fr` };
160
+ });
161
+
162
+ function onToggle(id: string): void {
163
+ openId.value = openId.value === id ? null : id;
164
+ }
165
+
166
+ function formatEuro(n: number): string {
167
+ return fmtEuro(n);
168
+ }
169
+
170
+ function formatQuotaValue(value: number | undefined, col: QuotaColumnConfig): string {
171
+ if (value === undefined) return '–';
172
+ if (value === -1) return '∞';
173
+ const formatted = col.fractional ? value.toFixed(1) : Math.round(value).toString();
174
+ return col.unit ? `${formatted} ${col.unit}` : formatted;
175
+ }
176
+ </script>
177
+
178
+ <style scoped>
179
+ .sa-pv-list {
180
+ padding: 20px 28px;
181
+ }
182
+ .sa-pv-list__kpis {
183
+ display: grid;
184
+ grid-template-columns: repeat(4, 1fr);
185
+ gap: 12px;
186
+ margin-bottom: 18px;
187
+ }
188
+ .sa-pv-list__missing {
189
+ display: flex;
190
+ align-items: flex-start;
191
+ gap: 10px;
192
+ background: var(--sa-warning-soft, rgba(245, 158, 11, 0.1));
193
+ border: 1px solid var(--sa-amber-border, rgba(245, 158, 11, 0.3));
194
+ border-radius: 10px;
195
+ padding: 12px 16px;
196
+ margin-bottom: 14px;
197
+ font-size: 12.5px;
198
+ color: #7c2d12;
199
+ }
200
+ .sa-pv-list__missing :deep(.q-icon) {
201
+ color: var(--sa-warning, #b45309);
202
+ }
203
+ .sa-pv-list__missing-btn {
204
+ background: #fff;
205
+ border: 1px solid var(--sa-amber-border, rgba(245, 158, 11, 0.3));
206
+ color: var(--sa-warning, #b45309);
207
+ border-radius: 6px;
208
+ padding: 3px 8px;
209
+ font-size: 11.5px;
210
+ font-weight: 600;
211
+ cursor: pointer;
212
+ margin-left: 6px;
213
+ }
214
+
215
+ .sa-pv-list__table {
216
+ background: #fff;
217
+ border: 1px solid var(--sa-border, #e2e8f0);
218
+ border-radius: 12px;
219
+ overflow: hidden;
220
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
221
+ }
222
+ .sa-pv-list__row {
223
+ display: grid;
224
+ align-items: center;
225
+ gap: 12px;
226
+ }
227
+ .sa-pv-list__row--default {
228
+ grid-template-columns: 1.6fr 1fr 0.6fr;
229
+ padding: 10px 14px;
230
+ }
231
+ .sa-pv-list__head {
232
+ background: #fafbfc;
233
+ border-bottom: 1px solid var(--sa-border, #e2e8f0);
234
+ font-size: 10.5px;
235
+ font-weight: 700;
236
+ letter-spacing: 0.08em;
237
+ color: var(--sa-muted, #64748b);
238
+ text-transform: uppercase;
239
+ }
240
+ .sa-pv-list__th--num {
241
+ text-align: right;
242
+ font-family: var(--sa-font-mono, ui-monospace, monospace);
243
+ font-size: 13px;
244
+ }
245
+
246
+ .sa-pv-list__default-row {
247
+ border-bottom: 1px solid var(--sa-border-soft, #f1f5f9);
248
+ }
249
+ .sa-pv-list__default-row:last-child {
250
+ border-bottom: none;
251
+ }
252
+ .sa-pv-list__default-name {
253
+ display: flex;
254
+ align-items: center;
255
+ gap: 8px;
256
+ }
257
+ .sa-pv-list__default-version {
258
+ font-family: var(--sa-font-mono, ui-monospace, monospace);
259
+ font-size: 11px;
260
+ font-weight: 600;
261
+ color: var(--sa-muted, #64748b);
262
+ background: #f1f5f9;
263
+ padding: 2px 6px;
264
+ border-radius: 4px;
265
+ }
266
+ .sa-pv-list__default-chip {
267
+ font-size: 9.5px;
268
+ font-weight: 700;
269
+ letter-spacing: 0.06em;
270
+ padding: 2px 6px;
271
+ border-radius: 4px;
272
+ background: var(--sa-warning-soft, rgba(245, 158, 11, 0.1));
273
+ color: var(--sa-warning, #b45309);
274
+ }
275
+
276
+ .sa-pv-list__draft-hint {
277
+ margin-top: 14px;
278
+ background: var(--sa-warning-soft, rgba(245, 158, 11, 0.1));
279
+ border: 1px solid var(--sa-amber-border, rgba(245, 158, 11, 0.3));
280
+ border-radius: 10px;
281
+ padding: 12px 16px;
282
+ display: flex;
283
+ align-items: flex-start;
284
+ gap: 12px;
285
+ font-size: 12.5px;
286
+ color: #7c2d12;
287
+ line-height: 1.5;
288
+ }
289
+ .sa-pv-list__draft-hint :deep(.q-icon) {
290
+ color: var(--sa-warning, #b45309);
291
+ }
292
+ </style>
@@ -0,0 +1,261 @@
1
+ <template>
2
+ <div class="sa-pv-matrix">
3
+ <div class="sa-pv-matrix__card">
4
+ <div class="sa-pv-matrix__head" :style="gridStyle">
5
+ <div class="sa-pv-matrix__th">Feature</div>
6
+ <div v-for="plan in snapshot.plans" :key="plan.planId" class="sa-pv-matrix__plan">
7
+ <div class="sa-pv-matrix__plan-name">{{ plan.planId }}</div>
8
+ <div class="sa-pv-matrix__plan-price">
9
+ {{ fmtEuro(plan.monthlyNet) }}/Mo · v{{ plan.version }}
10
+ </div>
11
+ </div>
12
+ </div>
13
+
14
+ <template v-for="grp in groupsWithItems" :key="grp.name">
15
+ <div v-if="grp.name" class="sa-pv-matrix__group">{{ grp.name }}</div>
16
+ <div
17
+ v-for="f in grp.items"
18
+ :key="f.key"
19
+ class="sa-pv-matrix__row"
20
+ :style="gridStyle"
21
+ >
22
+ <div class="sa-pv-matrix__feat-cell">
23
+ <div class="sa-pv-matrix__feat-icon">
24
+ <q-icon :name="f.icon" size="14px" />
25
+ </div>
26
+ <div>
27
+ <div class="sa-pv-matrix__feat-label">
28
+ {{ f.label }}
29
+ <span v-if="f.plannedOnly" class="sa-pv-matrix__planned"
30
+ >PLANNED</span
31
+ >
32
+ </div>
33
+ <div class="sa-pv-matrix__feat-key">{{ f.key }}</div>
34
+ </div>
35
+ </div>
36
+ <div
37
+ v-for="plan in snapshot.plans"
38
+ :key="plan.planId"
39
+ class="sa-pv-matrix__cell"
40
+ :class="{ 'sa-pv-matrix__cell--off': !plan.features.includes(f.key) }"
41
+ >
42
+ <span v-if="plan.features.includes(f.key)" class="sa-pv-matrix__check">
43
+ <q-icon name="check" size="14px" />
44
+ </span>
45
+ <span v-else class="sa-pv-matrix__dash" aria-hidden="true" />
46
+ </div>
47
+ </div>
48
+ </template>
49
+ </div>
50
+ </div>
51
+ </template>
52
+
53
+ <script setup lang="ts">
54
+ import { computed } from 'vue';
55
+ import type { CatalogSnapshot } from '../../plan-versions-catalog.js';
56
+ import { fmtEuro } from './format.js';
57
+
58
+ // Generic feature matrix. Apps pass `featureRegistry` for label/icon/
59
+ // plannedOnly hint and optionally `featureGroups` for the UX grouping of
60
+ // the rows (e.g. Core, Billing, Data, …).
61
+ //
62
+ // Without `featureGroups` the matrix renders a single group without a header.
63
+
64
+ interface FeatureMeta {
65
+ label?: string;
66
+ icon?: string;
67
+ plannedOnly?: boolean;
68
+ }
69
+
70
+ interface FeatureGroupsConfig {
71
+ /** Order of the group headers. */
72
+ order: readonly string[];
73
+ /** Map featureKey → group name. Unassigned features land in "Sonstige". */
74
+ byKey: Record<string, string>;
75
+ /** Default bucket for features without a `byKey` entry. Default: 'Sonstige'. */
76
+ fallbackGroup?: string;
77
+ }
78
+
79
+ const props = defineProps<{
80
+ snapshot: CatalogSnapshot;
81
+ featureRegistry: Record<string, FeatureMeta>;
82
+ featureGroups?: FeatureGroupsConfig;
83
+ }>();
84
+
85
+ const gridStyle = computed(() => ({
86
+ gridTemplateColumns: `minmax(280px, 1.5fr) repeat(${props.snapshot.plans.length}, minmax(110px, 1fr))`,
87
+ }));
88
+
89
+ interface MatrixFeature {
90
+ key: string;
91
+ label: string;
92
+ icon: string;
93
+ plannedOnly: boolean;
94
+ }
95
+
96
+ const groupsWithItems = computed<Array<{ name: string | null; items: MatrixFeature[] }>>(() => {
97
+ const allItems: MatrixFeature[] = Object.entries(props.featureRegistry).map(([key, meta]) => ({
98
+ key,
99
+ label: meta.label ?? key,
100
+ icon: meta.icon ?? 'extension',
101
+ plannedOnly: meta.plannedOnly === true,
102
+ }));
103
+
104
+ if (!props.featureGroups) {
105
+ return allItems.length > 0 ? [{ name: null, items: allItems }] : [];
106
+ }
107
+
108
+ const fallback = props.featureGroups.fallbackGroup ?? 'Sonstige';
109
+ const byGroup = new Map<string, MatrixFeature[]>();
110
+ for (const item of allItems) {
111
+ const group = props.featureGroups.byKey[item.key] ?? fallback;
112
+ const list = byGroup.get(group) ?? [];
113
+ list.push(item);
114
+ byGroup.set(group, list);
115
+ }
116
+ const orderWithFallback = [
117
+ ...props.featureGroups.order,
118
+ ...(byGroup.has(fallback) && !props.featureGroups.order.includes(fallback)
119
+ ? [fallback]
120
+ : []),
121
+ ];
122
+ return orderWithFallback.flatMap((g) => {
123
+ const items = byGroup.get(g);
124
+ return items && items.length > 0 ? [{ name: g, items }] : [];
125
+ });
126
+ });
127
+ </script>
128
+
129
+ <style scoped>
130
+ .sa-pv-matrix {
131
+ padding: 20px 28px;
132
+ }
133
+ .sa-pv-matrix__card {
134
+ background: #fff;
135
+ border: 1px solid var(--sa-border, #e2e8f0);
136
+ border-radius: 12px;
137
+ overflow: hidden;
138
+ }
139
+
140
+ .sa-pv-matrix__head {
141
+ position: sticky;
142
+ top: 0;
143
+ z-index: 2;
144
+ display: grid;
145
+ background: #fafbfc;
146
+ border-bottom: 1px solid var(--sa-border, #e2e8f0);
147
+ }
148
+ .sa-pv-matrix__th {
149
+ padding: 12px 16px;
150
+ font-size: 11px;
151
+ font-weight: 700;
152
+ letter-spacing: 0.08em;
153
+ color: var(--sa-muted, #64748b);
154
+ text-transform: uppercase;
155
+ border-right: 1px solid var(--sa-border, #e2e8f0);
156
+ }
157
+ .sa-pv-matrix__plan {
158
+ padding: 12px 14px;
159
+ text-align: center;
160
+ border-right: 1px solid var(--sa-border, #e2e8f0);
161
+ }
162
+ .sa-pv-matrix__plan-name {
163
+ font-size: 12.5px;
164
+ font-weight: 700;
165
+ color: var(--sa-heading, #0f172a);
166
+ font-family: var(--sa-font-head, system-ui, sans-serif);
167
+ }
168
+ .sa-pv-matrix__plan-price {
169
+ font-size: 10.5px;
170
+ color: var(--sa-muted, #64748b);
171
+ margin-top: 1px;
172
+ font-family: var(--sa-font-mono, ui-monospace, monospace);
173
+ }
174
+
175
+ .sa-pv-matrix__group {
176
+ padding: 8px 16px;
177
+ background: var(--sa-border-soft, #f1f5f9);
178
+ font-size: 10.5px;
179
+ font-weight: 700;
180
+ letter-spacing: 0.08em;
181
+ color: var(--sa-muted, #64748b);
182
+ text-transform: uppercase;
183
+ border-bottom: 1px solid var(--sa-border, #e2e8f0);
184
+ }
185
+ .sa-pv-matrix__row {
186
+ display: grid;
187
+ border-bottom: 1px solid var(--sa-border-soft, #f1f5f9);
188
+ }
189
+
190
+ .sa-pv-matrix__feat-cell {
191
+ padding: 10px 16px;
192
+ display: flex;
193
+ align-items: center;
194
+ gap: 10px;
195
+ border-right: 1px solid var(--sa-border-soft, #f1f5f9);
196
+ }
197
+ .sa-pv-matrix__feat-icon {
198
+ width: 24px;
199
+ height: 24px;
200
+ border-radius: 6px;
201
+ background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
202
+ display: flex;
203
+ align-items: center;
204
+ justify-content: center;
205
+ flex-shrink: 0;
206
+ }
207
+ .sa-pv-matrix__feat-icon :deep(.q-icon) {
208
+ color: var(--sa-primary, #3f6bff);
209
+ }
210
+ .sa-pv-matrix__feat-label {
211
+ font-size: 12.5px;
212
+ font-weight: 600;
213
+ color: var(--sa-heading, #0f172a);
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 5px;
217
+ }
218
+ .sa-pv-matrix__planned {
219
+ font-size: 9px;
220
+ font-weight: 700;
221
+ letter-spacing: 0.06em;
222
+ background: #fef3c7;
223
+ color: #92400e;
224
+ padding: 1px 5px;
225
+ border-radius: 3px;
226
+ }
227
+ .sa-pv-matrix__feat-key {
228
+ font-size: 10.5px;
229
+ color: var(--sa-muted, #64748b);
230
+ margin-top: 1px;
231
+ font-family: var(--sa-font-mono, ui-monospace, monospace);
232
+ }
233
+
234
+ .sa-pv-matrix__cell {
235
+ display: flex;
236
+ align-items: center;
237
+ justify-content: center;
238
+ border-right: 1px solid var(--sa-border-soft, #f1f5f9);
239
+ }
240
+ .sa-pv-matrix__cell--off {
241
+ background: #fafbfc;
242
+ }
243
+ .sa-pv-matrix__check {
244
+ width: 22px;
245
+ height: 22px;
246
+ border-radius: 50%;
247
+ background: var(--sa-positive-soft, rgba(4, 120, 87, 0.1));
248
+ display: flex;
249
+ align-items: center;
250
+ justify-content: center;
251
+ }
252
+ .sa-pv-matrix__check :deep(.q-icon) {
253
+ color: var(--sa-positive, #047857);
254
+ }
255
+ .sa-pv-matrix__dash {
256
+ width: 22px;
257
+ height: 22px;
258
+ border-radius: 50%;
259
+ border: 1px dashed var(--sa-border, #e2e8f0);
260
+ }
261
+ </style>