@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,1113 @@
1
+ <template>
2
+ <div class="pm">
3
+ <!-- Page header -->
4
+ <div class="pm-page-head">
5
+ <div>
6
+ <h2 class="pm-h-title">Pläne · Matrix-Vergleich</h2>
7
+ <p class="pm-h-sub">
8
+ Alle Pläne nebeneinander · {{ plans.length }} Pläne ·
9
+ {{ orderedFeatureKeys.length }} Features · {{ orderedQuotaKeys.length }} Quotas
10
+ · {{ orderedBundleKeys.length }} Bundles
11
+ </p>
12
+ </div>
13
+ <div class="pm-head-actions">
14
+ <button class="pm-btn" type="button" @click="$emit('viewCatalog')">
15
+ <svg
16
+ width="14"
17
+ height="14"
18
+ viewBox="0 0 24 24"
19
+ fill="none"
20
+ stroke="currentColor"
21
+ stroke-width="2"
22
+ >
23
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
24
+ <circle cx="12" cy="12" r="3" />
25
+ </svg>
26
+ <span>Catalog-Vorschau</span>
27
+ </button>
28
+ <button class="pm-btn pm-btn--primary" type="button" @click="$emit('createPlan')">
29
+ <svg
30
+ width="14"
31
+ height="14"
32
+ viewBox="0 0 24 24"
33
+ fill="none"
34
+ stroke="currentColor"
35
+ stroke-width="2.5"
36
+ >
37
+ <path d="M12 5v14M5 12h14" />
38
+ </svg>
39
+ <span>Plan anlegen</span>
40
+ </button>
41
+ </div>
42
+ </div>
43
+
44
+ <!-- Matrix -->
45
+ <div class="pm-card pm-wrap">
46
+ <table class="pm-table">
47
+ <thead>
48
+ <tr class="pm-head">
49
+ <th class="pm-rowhead-cell">
50
+ <span class="pm-component-kicker">Komponente</span>
51
+ </th>
52
+ <th v-for="p in resolvedPlans" :key="p.planKey" class="pm-plan-head">
53
+ <div
54
+ class="pm-plan-card"
55
+ :style="{ borderTopColor: planAccent(p.planKey) }"
56
+ >
57
+ <div class="pm-plan-top">
58
+ <div>
59
+ <div class="pm-plan-key">{{ p.planKey }}</div>
60
+ <div class="pm-plan-label">{{ p.label }}</div>
61
+ </div>
62
+ <button
63
+ class="pm-kebab"
64
+ type="button"
65
+ @click="$emit('openPlan', p.plan)"
66
+ >
67
+
68
+ </button>
69
+ </div>
70
+ <div class="pm-plan-desc">{{ p.description || ' ' }}</div>
71
+ <div class="pm-plan-divider" />
72
+
73
+ <div class="pm-status-row">
74
+ <span v-if="p.live" class="pm-chip pm-chip--live pm-chip--dot"
75
+ >v{{ p.live.version }} live</span
76
+ >
77
+ <span v-else class="pm-chip pm-chip--supersed pm-chip--dot"
78
+ >keine live</span
79
+ >
80
+ <span
81
+ v-if="p.draft"
82
+ class="pm-chip pm-chip--draft pm-chip--dot"
83
+ >
84
+ v{{ p.draft.version }} Entwurf
85
+ </span>
86
+ </div>
87
+
88
+ <div class="pm-price">
89
+ <template
90
+ v-if="
91
+ p.live &&
92
+ Number(p.live.monthlyNet) === 0 &&
93
+ Number(p.live.yearlyNet) === 0
94
+ "
95
+ >
96
+ <span class="pm-price-free">Kostenlos</span>
97
+ </template>
98
+ <template v-else-if="p.live">
99
+ <span class="pm-price-big">{{
100
+ formatMoney(p.live.monthlyNet)
101
+ }}</span>
102
+ <span class="pm-price-unit">/ Mo</span>
103
+ <span class="pm-price-yearly"
104
+ >· {{ formatMoney(p.live.yearlyNet) }}/J</span
105
+ >
106
+ </template>
107
+ <template v-else-if="p.draft">
108
+ <span class="pm-price-big">{{
109
+ formatMoney(p.draft.monthlyNet)
110
+ }}</span>
111
+ <span class="pm-price-unit">/ Mo</span>
112
+ <span class="pm-price-yearly">(Entwurf)</span>
113
+ </template>
114
+ <template v-else>
115
+ <span class="pm-price-free">noch keine Preise</span>
116
+ </template>
117
+ </div>
118
+
119
+ <div class="pm-plan-meta">
120
+ <span>{{ p.tenantCount }} Mandanten</span>
121
+ <template v-if="p.live?.validFrom">
122
+ <span>·</span>
123
+ <span>ab {{ p.live.validFrom.slice(0, 10) }}</span>
124
+ </template>
125
+ <span
126
+ :class="[
127
+ 'pm-chip pm-chip--tiny',
128
+ p.live?.marketed ? '' : 'pm-chip--supersed',
129
+ ]"
130
+ >
131
+ {{ p.live?.marketed ? 'im Katalog' : 'privat' }}
132
+ </span>
133
+ </div>
134
+
135
+ <div class="pm-plan-actions">
136
+ <button
137
+ class="pm-btn pm-btn--sm pm-btn--flex"
138
+ type="button"
139
+ @click="$emit('openPlan', p.plan)"
140
+ >
141
+ <svg
142
+ width="12"
143
+ height="12"
144
+ viewBox="0 0 24 24"
145
+ fill="none"
146
+ stroke="currentColor"
147
+ stroke-width="2"
148
+ >
149
+ <path
150
+ d="M12 20h9M16.5 3.5a2.12 2.12 0 113 3L7 19l-4 1 1-4 12.5-12.5z"
151
+ />
152
+ </svg>
153
+ <span>Öffnen</span>
154
+ </button>
155
+ <button
156
+ class="pm-btn pm-btn--sm pm-btn--ghost"
157
+ type="button"
158
+ @click="$emit('clonePlan', p.plan)"
159
+ aria-label="Plan klonen"
160
+ >
161
+ <svg
162
+ width="12"
163
+ height="12"
164
+ viewBox="0 0 24 24"
165
+ fill="none"
166
+ stroke="currentColor"
167
+ stroke-width="2"
168
+ >
169
+ <rect x="9" y="9" width="11" height="11" rx="2" />
170
+ <path d="M5 15V5a2 2 0 012-2h10" />
171
+ </svg>
172
+ </button>
173
+ </div>
174
+ </div>
175
+ </th>
176
+ <th class="pm-add-col">
177
+ <button class="pm-add" type="button" @click="$emit('createPlan')">
178
+ <div class="pm-add-icon">
179
+ <svg
180
+ width="14"
181
+ height="14"
182
+ viewBox="0 0 24 24"
183
+ fill="none"
184
+ stroke="currentColor"
185
+ stroke-width="2.5"
186
+ >
187
+ <path d="M12 5v14M5 12h14" />
188
+ </svg>
189
+ </div>
190
+ <div class="pm-add-title">Plan anlegen</div>
191
+ <div class="pm-add-sub">oder Klon erstellen</div>
192
+ </button>
193
+ </th>
194
+ </tr>
195
+ </thead>
196
+
197
+ <tbody>
198
+ <!-- Quotas section -->
199
+ <tr class="pm-group">
200
+ <td :colspan="resolvedPlans.length + 2">
201
+ <div class="pm-group-inner">
202
+ <span class="pm-group-dot pm-group-dot--quota" />
203
+ <span>Quotas</span>
204
+ <span class="pm-group-count">{{ orderedQuotaKeys.length }}</span>
205
+ </div>
206
+ </td>
207
+ </tr>
208
+ <tr v-for="qKey in orderedQuotaKeys" :key="`q-${qKey}`" class="pm-row">
209
+ <td class="pm-rowhead">
210
+ <div class="pm-rowhead-inner">
211
+ <div class="pm-rh-label">{{ quotaLabel(qKey) }}</div>
212
+ <code class="pm-rh-key">{{ qKey }}</code>
213
+ </div>
214
+ </td>
215
+ <td
216
+ v-for="p in resolvedPlans"
217
+ :key="`q-${qKey}-${p.planKey}`"
218
+ :class="[
219
+ 'pm-cell',
220
+ 'pm-cell--val',
221
+ isDraftSource(p) ? 'pm-cell--draftsrc' : '',
222
+ ]"
223
+ >
224
+ <div v-if="quotaValueFor(p, qKey) !== undefined" class="pm-quota">
225
+ <span class="pm-num">{{
226
+ formatQuota(quotaValueFor(p, qKey))
227
+ }}</span>
228
+ <span v-if="quotaUnit(qKey)" class="pm-unit">{{
229
+ quotaUnit(qKey)
230
+ }}</span>
231
+ </div>
232
+ <span v-else class="pm-dash">—</span>
233
+ </td>
234
+ <td />
235
+ </tr>
236
+
237
+ <!-- Features section -->
238
+ <tr class="pm-group">
239
+ <td :colspan="resolvedPlans.length + 2">
240
+ <div class="pm-group-inner">
241
+ <span class="pm-group-dot pm-group-dot--feature" />
242
+ <span>Features</span>
243
+ <span class="pm-group-count">{{ orderedFeatureKeys.length }}</span>
244
+ </div>
245
+ </td>
246
+ </tr>
247
+ <tr v-for="fKey in orderedFeatureKeys" :key="`f-${fKey}`" class="pm-row">
248
+ <td class="pm-rowhead">
249
+ <div class="pm-rowhead-inner">
250
+ <div class="pm-rh-label">{{ featureLabel(fKey) }}</div>
251
+ <code class="pm-rh-key">{{ fKey }}</code>
252
+ </div>
253
+ </td>
254
+ <!-- Core features are base infrastructure: included in every
255
+ plan, so no per-plan checkmark but a single
256
+ "Basis" badge across all plan columns. -->
257
+ <td
258
+ v-if="isCoreFeature(fKey)"
259
+ class="pm-cell pm-cell--base"
260
+ :colspan="resolvedPlans.length"
261
+ >
262
+ <span class="pm-base-badge">
263
+ <svg
264
+ width="11"
265
+ height="11"
266
+ viewBox="0 0 24 24"
267
+ fill="none"
268
+ stroke="currentColor"
269
+ stroke-width="2.5"
270
+ >
271
+ <path d="M5 13l4 4L19 7" />
272
+ </svg>
273
+ Basis · immer enthalten
274
+ </span>
275
+ </td>
276
+ <template v-else>
277
+ <td
278
+ v-for="p in resolvedPlans"
279
+ :key="`f-${fKey}-${p.planKey}`"
280
+ :class="[
281
+ 'pm-cell',
282
+ hasFeature(p, fKey) ? 'pm-cell--ok' : 'pm-cell--no',
283
+ isDraftSource(p) ? 'pm-cell--draftsrc' : '',
284
+ ]"
285
+ >
286
+ <span
287
+ v-if="hasFeature(p, fKey)"
288
+ class="pm-check"
289
+ :style="{
290
+ background: planAccent(p.planKey) + '15',
291
+ color: planAccent(p.planKey),
292
+ }"
293
+ >
294
+ <svg
295
+ width="12"
296
+ height="12"
297
+ viewBox="0 0 24 24"
298
+ fill="none"
299
+ stroke="currentColor"
300
+ stroke-width="3"
301
+ >
302
+ <path d="M5 13l4 4L19 7" />
303
+ </svg>
304
+ </span>
305
+ <span v-else class="pm-dash">—</span>
306
+ </td>
307
+ </template>
308
+ <td />
309
+ </tr>
310
+
311
+ <!-- Bundles section -->
312
+ <tr v-if="orderedBundleKeys.length > 0" class="pm-group">
313
+ <td :colspan="resolvedPlans.length + 2">
314
+ <div class="pm-group-inner">
315
+ <span class="pm-group-dot pm-group-dot--bundle" />
316
+ <span>Bundles</span>
317
+ <span class="pm-group-count">{{ orderedBundleKeys.length }}</span>
318
+ </div>
319
+ </td>
320
+ </tr>
321
+ <tr v-for="bKey in orderedBundleKeys" :key="`b-${bKey}`" class="pm-row">
322
+ <td class="pm-rowhead">
323
+ <div class="pm-rowhead-inner">
324
+ <div class="pm-rh-label">{{ bundleLabel(bKey) }}</div>
325
+ <code class="pm-rh-key">{{ bKey }}</code>
326
+ </div>
327
+ </td>
328
+ <td
329
+ v-for="p in resolvedPlans"
330
+ :key="`b-${bKey}-${p.planKey}`"
331
+ :class="[
332
+ 'pm-cell',
333
+ hasBundle(p, bKey) ? 'pm-cell--ok' : 'pm-cell--no',
334
+ isDraftSource(p) ? 'pm-cell--draftsrc' : '',
335
+ ]"
336
+ >
337
+ <span
338
+ v-if="hasBundle(p, bKey)"
339
+ class="pm-check"
340
+ :style="{
341
+ background: planAccent(p.planKey) + '15',
342
+ color: planAccent(p.planKey),
343
+ }"
344
+ >
345
+ <svg
346
+ width="12"
347
+ height="12"
348
+ viewBox="0 0 24 24"
349
+ fill="none"
350
+ stroke="currentColor"
351
+ stroke-width="3"
352
+ >
353
+ <path d="M5 13l4 4L19 7" />
354
+ </svg>
355
+ </span>
356
+ <span v-else class="pm-dash">—</span>
357
+ </td>
358
+ <td />
359
+ </tr>
360
+ </tbody>
361
+ </table>
362
+ </div>
363
+
364
+ <!-- Legend -->
365
+ <div class="pm-legend">
366
+ <span class="pm-legend-item">
367
+ <span class="pm-legend-check"
368
+ ><svg
369
+ width="10"
370
+ height="10"
371
+ viewBox="0 0 24 24"
372
+ fill="none"
373
+ stroke="currentColor"
374
+ stroke-width="3"
375
+ >
376
+ <path d="M5 13l4 4L19 7" /></svg
377
+ ></span>
378
+ enthalten
379
+ </span>
380
+ <span class="pm-legend-item">
381
+ <span class="pm-legend-dash">—</span>
382
+ nicht enthalten
383
+ </span>
384
+ <span v-if="loading" class="pm-legend-loading">lade Versionen…</span>
385
+ </div>
386
+ </div>
387
+ </template>
388
+
389
+ <script setup lang="ts">
390
+ import { computed } from 'vue';
391
+ import type { PlanRow, PlanVersionRow } from '@saasicat/types';
392
+
393
+ // PlanMatrix — V1 matrix overview. Plans as columns, quotas/features/
394
+ // bundles as rows. Expects the plan master list plus, per plan, the
395
+ // mapping to its plan versions (live + draft). The data comes from the
396
+ // consumer (PlansPage), which loads per plan via usePlanVersions.
397
+
398
+ interface BundleEntry {
399
+ bundleKey: string;
400
+ label?: string | null;
401
+ features: string[];
402
+ /** Plan keys for which the bundle is bookable (empty/missing = all plans).
403
+ * Source: BundleVersion.compatibility.planIds — contains plan KEYS. */
404
+ compatiblePlanKeys?: string[] | null;
405
+ }
406
+
407
+ interface DiscoveryQuota {
408
+ quotaKey: string;
409
+ label?: string | null;
410
+ unit?: string | null;
411
+ }
412
+
413
+ interface FeatureMeta {
414
+ label?: string;
415
+ /** true = base infrastructure, included in every plan (not bookable per plan). */
416
+ core?: boolean;
417
+ }
418
+
419
+ const props = withDefaults(
420
+ defineProps<{
421
+ plans: PlanRow[];
422
+ /** Versions per plan (id → PlanVersionRow[]). */
423
+ versionsByPlanId: Record<string, PlanVersionRow[]>;
424
+ /** Discovery quotas (for labels + units). */
425
+ availableQuotas?: DiscoveryQuota[];
426
+ /** Bundles for the bundle section. */
427
+ availableBundles?: BundleEntry[];
428
+ /** Feature label map. */
429
+ featureRegistry?: Record<string, FeatureMeta>;
430
+ /** Tenant count per planKey (for the plan header). */
431
+ tenantCountsByPlanKey?: Record<string, number>;
432
+ /** Accent color per planKey for card top border + check badge. */
433
+ planAccents?: Record<string, string>;
434
+ loading?: boolean;
435
+ }>(),
436
+ {
437
+ availableQuotas: () => [],
438
+ availableBundles: () => [],
439
+ featureRegistry: () => ({}),
440
+ tenantCountsByPlanKey: () => ({}),
441
+ planAccents: () => ({}),
442
+ loading: false,
443
+ },
444
+ );
445
+
446
+ defineEmits<{
447
+ (e: 'openPlan', plan: PlanRow): void;
448
+ (e: 'createPlan'): void;
449
+ (e: 'clonePlan', plan: PlanRow): void;
450
+ (e: 'viewCatalog'): void;
451
+ }>();
452
+
453
+ interface ResolvedPlan {
454
+ plan: PlanRow;
455
+ planKey: string;
456
+ label: string;
457
+ description: string | null;
458
+ live: PlanVersionRow | null;
459
+ draft: PlanVersionRow | null;
460
+ tenantCount: number;
461
+ }
462
+
463
+ const DEFAULT_ACCENTS: Record<string, string> = {
464
+ STARTER: '#64748b',
465
+ STANDARD: '#2563eb',
466
+ PRO: '#7c3aed',
467
+ PROFESSIONAL: '#7c3aed',
468
+ BUSINESS: '#0ea5e9',
469
+ ENTERPRISE: '#0f766e',
470
+ BASIC: '#475569',
471
+ };
472
+ const FALLBACK_ACCENTS = ['#2563eb', '#7c3aed', '#0f766e', '#f59e0b', '#0ea5e9', '#ef4444'];
473
+
474
+ function planAccent(planKey: string): string {
475
+ const provided = props.planAccents[planKey];
476
+ if (provided) return provided;
477
+ const def = DEFAULT_ACCENTS[planKey];
478
+ if (def) return def;
479
+ const idx = props.plans.findIndex((p) => p.planKey === planKey);
480
+ return FALLBACK_ACCENTS[idx % FALLBACK_ACCENTS.length] ?? FALLBACK_ACCENTS[0]!;
481
+ }
482
+
483
+ const resolvedPlans = computed<ResolvedPlan[]>(() =>
484
+ [...props.plans]
485
+ .sort((a, b) => a.sortOrder - b.sortOrder || a.planKey.localeCompare(b.planKey))
486
+ .map((plan) => {
487
+ const versions = props.versionsByPlanId[plan.id] ?? [];
488
+ const live =
489
+ versions.find((v) => v.publishedAt !== null && v.supersededAt === null) ?? null;
490
+ const draft = versions.find((v) => v.publishedAt === null) ?? null;
491
+ return {
492
+ plan,
493
+ planKey: plan.planKey,
494
+ label: plan.label,
495
+ description: plan.description ?? null,
496
+ live,
497
+ draft,
498
+ tenantCount: props.tenantCountsByPlanKey[plan.planKey] ?? 0,
499
+ };
500
+ }),
501
+ );
502
+
503
+ function quotasOf(v: PlanVersionRow | null): Record<string, number> {
504
+ if (!v) return {};
505
+ if (v.quotas && Object.keys(v.quotas).length > 0) return v.quotas;
506
+ const legacy: Record<string, number> = {};
507
+ if (typeof v.maxUsers === 'number') legacy.users = v.maxUsers;
508
+ if (typeof v.maxVehicles === 'number') legacy.vehicles = v.maxVehicles;
509
+ if (typeof v.maxStorageGb === 'number') legacy.storageGb = v.maxStorageGb;
510
+ return legacy;
511
+ }
512
+
513
+ /**
514
+ * Effective version of a column: live, otherwise draft. This lets the
515
+ * matrix also render pure draft plans (initial population before the first
516
+ * publish); draft columns are visually marked via `isDraftSource`.
517
+ */
518
+ function effectiveOf(p: ResolvedPlan): PlanVersionRow | null {
519
+ return p.live ?? p.draft;
520
+ }
521
+
522
+ function isDraftSource(p: ResolvedPlan): boolean {
523
+ return !p.live && !!p.draft;
524
+ }
525
+
526
+ function quotaValueFor(p: ResolvedPlan, key: string): number | undefined {
527
+ const q = quotasOf(effectiveOf(p));
528
+ return Object.prototype.hasOwnProperty.call(q, key) ? q[key] : undefined;
529
+ }
530
+
531
+ const orderedQuotaKeys = computed<string[]>(() => {
532
+ const seen = new Set<string>();
533
+ const ordered: string[] = [];
534
+ for (const q of props.availableQuotas) {
535
+ if (!seen.has(q.quotaKey)) {
536
+ seen.add(q.quotaKey);
537
+ ordered.push(q.quotaKey);
538
+ }
539
+ }
540
+ for (const p of resolvedPlans.value) {
541
+ for (const k of Object.keys(quotasOf(effectiveOf(p)))) {
542
+ if (!seen.has(k)) {
543
+ seen.add(k);
544
+ ordered.push(k);
545
+ }
546
+ }
547
+ }
548
+ return ordered;
549
+ });
550
+
551
+ const orderedFeatureKeys = computed<string[]>(() => {
552
+ const seen = new Set<string>();
553
+ const ordered: string[] = [];
554
+ for (const key of Object.keys(props.featureRegistry)) {
555
+ if (!seen.has(key)) {
556
+ seen.add(key);
557
+ ordered.push(key);
558
+ }
559
+ }
560
+ for (const p of resolvedPlans.value) {
561
+ const version = effectiveOf(p);
562
+ if (!version) continue;
563
+ for (const f of version.features) {
564
+ if (!seen.has(f)) {
565
+ seen.add(f);
566
+ ordered.push(f);
567
+ }
568
+ }
569
+ }
570
+ // Staircase sorting: first the features with the broadest plan coverage;
571
+ // on a tie the ones already included in the cheaper plans
572
+ // (left columns) win — this is how the staircase forms. Features without
573
+ // any assignment land at the bottom; within the same tiers alphabetically.
574
+ const presence = new Map<string, { count: number; mask: string }>();
575
+ for (const key of ordered) {
576
+ let count = 0;
577
+ let mask = '';
578
+ for (const p of resolvedPlans.value) {
579
+ const has = hasFeature(p, key);
580
+ mask += has ? '1' : '0';
581
+ if (has) count += 1;
582
+ }
583
+ presence.set(key, { count, mask });
584
+ }
585
+ return ordered.sort((a, b) => {
586
+ const pa = presence.get(a)!;
587
+ const pb = presence.get(b)!;
588
+ if (pa.count !== pb.count) return pb.count - pa.count;
589
+ if (pa.mask !== pb.mask) return pb.mask.localeCompare(pa.mask);
590
+ return featureLabel(a).localeCompare(featureLabel(b), 'de-DE');
591
+ });
592
+ });
593
+
594
+ const orderedBundleKeys = computed<string[]>(() => props.availableBundles.map((b) => b.bundleKey));
595
+
596
+ function quotaLabel(key: string): string {
597
+ return props.availableQuotas.find((q) => q.quotaKey === key)?.label || key;
598
+ }
599
+ function quotaUnit(key: string): string {
600
+ return props.availableQuotas.find((q) => q.quotaKey === key)?.unit || '';
601
+ }
602
+ function featureLabel(key: string): string {
603
+ return props.featureRegistry[key]?.label ?? key;
604
+ }
605
+ function isCoreFeature(key: string): boolean {
606
+ return props.featureRegistry[key]?.core === true;
607
+ }
608
+ function bundleLabel(key: string): string {
609
+ return props.availableBundles.find((b) => b.bundleKey === key)?.label || key;
610
+ }
611
+
612
+ function hasFeature(p: ResolvedPlan, fKey: string): boolean {
613
+ const version = effectiveOf(p);
614
+ return !!version && version.features.includes(fKey);
615
+ }
616
+
617
+ function hasBundle(p: ResolvedPlan, bKey: string): boolean {
618
+ const bundle = props.availableBundles.find((b) => b.bundleKey === bKey);
619
+ if (!bundle) return false;
620
+ // Availability = plan compatibility of the bundle (empty = all plans).
621
+ // Previously wrongly "all bundle features included in the plan" → a bundle
622
+ // restricted to one plan thereby appeared for ALL plans.
623
+ const compat = bundle.compatiblePlanKeys ?? [];
624
+ return compat.length === 0 || compat.includes(p.planKey);
625
+ }
626
+
627
+ function formatMoney(raw: string | number): string {
628
+ const num = typeof raw === 'string' ? Number(raw) : raw;
629
+ if (!Number.isFinite(num)) return String(raw);
630
+ if (Number.isInteger(num)) return `${num} €`;
631
+ return `${num.toFixed(2).replace('.', ',')} €`;
632
+ }
633
+
634
+ function formatQuota(v: number | undefined): string {
635
+ if (v === undefined) return '—';
636
+ if (v === -1) return '∞';
637
+ return String(v);
638
+ }
639
+ </script>
640
+
641
+ <style scoped>
642
+ .pm {
643
+ --pm-bg: #f6f7f9;
644
+ --pm-surface: #ffffff;
645
+ --pm-surface-2: #f8fafc;
646
+ --pm-border: #e5e7eb;
647
+ --pm-border-strong: #d1d5db;
648
+ --pm-text: #0f172a;
649
+ --pm-text-2: #475569;
650
+ --pm-text-3: #94a3b8;
651
+ --pm-primary: #2563eb;
652
+ --pm-live: #10b981;
653
+ --pm-live-bg: #ecfdf5;
654
+ --pm-draft: #f59e0b;
655
+ --pm-draft-bg: #fffbeb;
656
+ --pm-supersed: #94a3b8;
657
+ --pm-supersed-bg: #f1f5f9;
658
+ --pm-font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
659
+ --pm-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
660
+
661
+ padding: 22px 26px;
662
+ background: var(--pm-bg);
663
+ color: var(--pm-text);
664
+ font-family: var(--pm-font-sans);
665
+ min-height: 100%;
666
+ box-sizing: border-box;
667
+ }
668
+ .pm * {
669
+ box-sizing: border-box;
670
+ }
671
+
672
+ .pm-page-head {
673
+ display: flex;
674
+ align-items: flex-end;
675
+ gap: 20px;
676
+ margin-bottom: 18px;
677
+ }
678
+ .pm-h-title {
679
+ font-size: 22px;
680
+ font-weight: 700;
681
+ letter-spacing: -0.02em;
682
+ margin: 0;
683
+ }
684
+ .pm-h-sub {
685
+ font-size: 12.5px;
686
+ color: var(--pm-text-2);
687
+ margin: 3px 0 0;
688
+ }
689
+ .pm-head-actions {
690
+ margin-left: auto;
691
+ display: flex;
692
+ gap: 8px;
693
+ }
694
+
695
+ .pm-btn {
696
+ display: inline-flex;
697
+ align-items: center;
698
+ gap: 7px;
699
+ padding: 8px 14px;
700
+ border-radius: 7px;
701
+ font: 500 13px var(--pm-font-sans);
702
+ cursor: pointer;
703
+ border: 1px solid var(--pm-border-strong);
704
+ background: #fff;
705
+ color: var(--pm-text);
706
+ transition:
707
+ background 0.12s,
708
+ border-color 0.12s;
709
+ }
710
+ .pm-btn:hover {
711
+ background: var(--pm-surface-2);
712
+ }
713
+ .pm-btn--primary {
714
+ background: var(--pm-primary);
715
+ border-color: var(--pm-primary);
716
+ color: #fff;
717
+ }
718
+ .pm-btn--primary:hover {
719
+ background: #1d4ed8;
720
+ }
721
+ .pm-btn--ghost {
722
+ border-color: transparent;
723
+ background: transparent;
724
+ }
725
+ .pm-btn--ghost:hover {
726
+ background: rgba(15, 23, 42, 0.05);
727
+ }
728
+ .pm-btn--sm {
729
+ padding: 5px 9px;
730
+ font-size: 12px;
731
+ gap: 5px;
732
+ }
733
+ .pm-btn--flex {
734
+ flex: 1;
735
+ }
736
+
737
+ .pm-card {
738
+ background: var(--pm-surface);
739
+ border: 1px solid var(--pm-border);
740
+ border-radius: 10px;
741
+ }
742
+ .pm-wrap {
743
+ overflow: auto;
744
+ }
745
+
746
+ .pm-table {
747
+ width: 100%;
748
+ border-collapse: separate;
749
+ border-spacing: 0;
750
+ font-size: 13px;
751
+ }
752
+ .pm-head th {
753
+ background: #fbfbfd;
754
+ border-bottom: 1px solid var(--pm-border);
755
+ }
756
+ .pm-rowhead-cell {
757
+ text-align: left;
758
+ padding: 12px 16px;
759
+ vertical-align: bottom;
760
+ width: 280px;
761
+ min-width: 240px;
762
+ }
763
+ .pm-component-kicker {
764
+ font-size: 11px;
765
+ text-transform: uppercase;
766
+ letter-spacing: 0.1em;
767
+ color: var(--pm-text-3);
768
+ font-weight: 700;
769
+ }
770
+
771
+ .pm-plan-head {
772
+ padding: 12px 8px;
773
+ vertical-align: top;
774
+ min-width: 200px;
775
+ }
776
+ .pm-plan-card {
777
+ background: #fff;
778
+ border: 1px solid var(--pm-border);
779
+ border-top: 3px solid;
780
+ border-radius: 10px;
781
+ padding: 14px 14px 12px;
782
+ display: flex;
783
+ flex-direction: column;
784
+ }
785
+ .pm-plan-top {
786
+ display: flex;
787
+ justify-content: space-between;
788
+ align-items: flex-start;
789
+ }
790
+ .pm-plan-key {
791
+ font: 700 11px var(--pm-font-mono);
792
+ letter-spacing: 0.08em;
793
+ color: var(--pm-text-2);
794
+ }
795
+ .pm-plan-label {
796
+ font-size: 17px;
797
+ font-weight: 700;
798
+ letter-spacing: -0.02em;
799
+ color: var(--pm-text);
800
+ }
801
+ .pm-plan-desc {
802
+ font-size: 11.5px;
803
+ color: #64748b;
804
+ margin-top: 4px;
805
+ line-height: 1.4;
806
+ min-height: 32px;
807
+ }
808
+ .pm-plan-divider {
809
+ height: 1px;
810
+ background: var(--pm-border);
811
+ margin: 10px 0;
812
+ }
813
+ .pm-kebab {
814
+ background: none;
815
+ border: 0;
816
+ color: var(--pm-text-3);
817
+ cursor: pointer;
818
+ font-size: 16px;
819
+ padding: 0;
820
+ line-height: 1;
821
+ }
822
+ .pm-status-row {
823
+ display: flex;
824
+ gap: 5px;
825
+ align-items: center;
826
+ flex-wrap: wrap;
827
+ }
828
+ .pm-price {
829
+ display: flex;
830
+ align-items: baseline;
831
+ gap: 4px;
832
+ margin-top: 10px;
833
+ flex-wrap: wrap;
834
+ }
835
+ .pm-price-big {
836
+ font-size: 20px;
837
+ font-weight: 700;
838
+ letter-spacing: -0.02em;
839
+ }
840
+ .pm-price-unit {
841
+ font-size: 11px;
842
+ color: #64748b;
843
+ }
844
+ .pm-price-yearly {
845
+ font-size: 10px;
846
+ color: var(--pm-text-3);
847
+ margin-left: 4px;
848
+ }
849
+ .pm-price-free {
850
+ font-size: 14px;
851
+ font-weight: 600;
852
+ color: var(--pm-text);
853
+ }
854
+ .pm-plan-meta {
855
+ display: flex;
856
+ gap: 6px;
857
+ flex-wrap: wrap;
858
+ align-items: center;
859
+ font-size: 11px;
860
+ color: #64748b;
861
+ margin-top: 8px;
862
+ }
863
+ .pm-plan-actions {
864
+ display: flex;
865
+ gap: 6px;
866
+ margin-top: 10px;
867
+ padding-top: 10px;
868
+ border-top: 1px solid #f1f5f9;
869
+ }
870
+
871
+ .pm-add-col {
872
+ width: 130px;
873
+ padding: 12px 8px;
874
+ vertical-align: top;
875
+ }
876
+ .pm-add {
877
+ border: 1.5px dashed #c7d2fe;
878
+ border-radius: 10px;
879
+ padding: 16px 10px;
880
+ background: #f5f7ff;
881
+ text-align: center;
882
+ cursor: pointer;
883
+ min-height: 180px;
884
+ width: 100%;
885
+ display: flex;
886
+ flex-direction: column;
887
+ justify-content: center;
888
+ font-family: inherit;
889
+ }
890
+ .pm-add:hover {
891
+ background: #eef2ff;
892
+ border-color: #818cf8;
893
+ }
894
+ .pm-add-icon {
895
+ display: grid;
896
+ place-items: center;
897
+ width: 30px;
898
+ height: 30px;
899
+ border-radius: 50%;
900
+ background: #eff6ff;
901
+ color: var(--pm-primary);
902
+ margin: 0 auto 6px;
903
+ }
904
+ .pm-add-title {
905
+ font-size: 12px;
906
+ font-weight: 600;
907
+ color: var(--pm-primary);
908
+ }
909
+ .pm-add-sub {
910
+ font-size: 10.5px;
911
+ color: var(--pm-text-3);
912
+ margin-top: 3px;
913
+ }
914
+
915
+ /* Chips */
916
+ .pm-chip {
917
+ display: inline-flex;
918
+ align-items: center;
919
+ gap: 5px;
920
+ padding: 2px 8px;
921
+ border-radius: 999px;
922
+ font-size: 11px;
923
+ font-weight: 600;
924
+ background: var(--pm-surface-2);
925
+ color: var(--pm-text-2);
926
+ border: 1px solid var(--pm-border);
927
+ }
928
+ .pm-chip--tiny {
929
+ padding: 1px 6px;
930
+ font-size: 10px;
931
+ }
932
+ .pm-chip--live {
933
+ background: var(--pm-live-bg);
934
+ color: #047857;
935
+ border-color: #a7f3d0;
936
+ }
937
+ .pm-chip--draft {
938
+ background: var(--pm-draft-bg);
939
+ color: #b45309;
940
+ border-color: #fde68a;
941
+ }
942
+ .pm-chip--supersed {
943
+ background: var(--pm-supersed-bg);
944
+ color: var(--pm-text-2);
945
+ border-color: #cbd5e1;
946
+ }
947
+ .pm-chip--dot::before {
948
+ content: '';
949
+ width: 6px;
950
+ height: 6px;
951
+ border-radius: 50%;
952
+ background: currentColor;
953
+ }
954
+
955
+ /* Group rows */
956
+ .pm-group td {
957
+ background: var(--pm-surface-2);
958
+ border-top: 1px solid var(--pm-border);
959
+ border-bottom: 1px solid var(--pm-border);
960
+ }
961
+ .pm-group-inner {
962
+ display: flex;
963
+ align-items: center;
964
+ gap: 8px;
965
+ padding: 9px 16px;
966
+ font-size: 11px;
967
+ font-weight: 700;
968
+ letter-spacing: 0.08em;
969
+ text-transform: uppercase;
970
+ color: var(--pm-text-2);
971
+ }
972
+ .pm-group-dot {
973
+ width: 7px;
974
+ height: 7px;
975
+ border-radius: 50%;
976
+ }
977
+ .pm-group-dot--quota {
978
+ background: #0ea5e9;
979
+ }
980
+ .pm-group-dot--feature {
981
+ background: #8b5cf6;
982
+ }
983
+ .pm-group-dot--bundle {
984
+ background: #f59e0b;
985
+ }
986
+ .pm-group-count {
987
+ background: #e2e8f0;
988
+ color: var(--pm-text-2);
989
+ padding: 1px 7px;
990
+ border-radius: 999px;
991
+ font-size: 10px;
992
+ }
993
+
994
+ /* Data rows */
995
+ .pm-row td {
996
+ border-bottom: 1px solid #f1f5f9;
997
+ height: 44px;
998
+ }
999
+ .pm-row:hover td {
1000
+ background: #fcfcfd;
1001
+ }
1002
+ .pm-rowhead {
1003
+ padding: 8px 16px;
1004
+ }
1005
+ .pm-rowhead-inner {
1006
+ display: flex;
1007
+ flex-direction: column;
1008
+ gap: 1px;
1009
+ }
1010
+ .pm-rh-label {
1011
+ font-size: 13px;
1012
+ font-weight: 500;
1013
+ color: var(--pm-text);
1014
+ }
1015
+ .pm-rh-key {
1016
+ font: 500 10px var(--pm-font-mono);
1017
+ color: var(--pm-text-3);
1018
+ }
1019
+ .pm-cell {
1020
+ text-align: center;
1021
+ padding: 8px 12px;
1022
+ vertical-align: middle;
1023
+ }
1024
+ /* Column sources from an unpublished draft (no live). */
1025
+ .pm-cell--draftsrc {
1026
+ opacity: 0.62;
1027
+ background-image: repeating-linear-gradient(
1028
+ 135deg,
1029
+ transparent 0 6px,
1030
+ rgba(217, 119, 6, 0.05) 6px 7px
1031
+ );
1032
+ }
1033
+ .pm-cell--base {
1034
+ text-align: center;
1035
+ background: var(--pm-surface-2);
1036
+ }
1037
+ .pm-base-badge {
1038
+ display: inline-flex;
1039
+ align-items: center;
1040
+ gap: 6px;
1041
+ padding: 3px 12px;
1042
+ border-radius: 999px;
1043
+ font-size: 11px;
1044
+ font-weight: 600;
1045
+ letter-spacing: 0.02em;
1046
+ background: #eef2ff;
1047
+ color: #4338ca;
1048
+ border: 1px solid #c7d2fe;
1049
+ }
1050
+ .pm-check {
1051
+ display: inline-grid;
1052
+ place-items: center;
1053
+ width: 22px;
1054
+ height: 22px;
1055
+ border-radius: 6px;
1056
+ }
1057
+ .pm-dash {
1058
+ color: #cbd5e1;
1059
+ font-weight: 500;
1060
+ }
1061
+ .pm-num {
1062
+ font: 600 14px var(--pm-font-sans);
1063
+ color: var(--pm-text);
1064
+ }
1065
+ .pm-unit {
1066
+ font-size: 10.5px;
1067
+ color: var(--pm-text-3);
1068
+ margin-left: 3px;
1069
+ }
1070
+ .pm-quota {
1071
+ display: flex;
1072
+ align-items: baseline;
1073
+ justify-content: center;
1074
+ }
1075
+
1076
+ /* Legend */
1077
+ .pm-legend {
1078
+ display: flex;
1079
+ gap: 18px;
1080
+ margin-top: 14px;
1081
+ font-size: 12px;
1082
+ color: var(--pm-text-2);
1083
+ align-items: center;
1084
+ }
1085
+ .pm-legend-item {
1086
+ display: flex;
1087
+ align-items: center;
1088
+ gap: 6px;
1089
+ }
1090
+ .pm-legend-check {
1091
+ width: 14px;
1092
+ height: 14px;
1093
+ border-radius: 4px;
1094
+ background: #dcfce7;
1095
+ color: #16a34a;
1096
+ display: grid;
1097
+ place-items: center;
1098
+ }
1099
+ .pm-legend-dash {
1100
+ width: 14px;
1101
+ height: 14px;
1102
+ color: #cbd5e1;
1103
+ display: grid;
1104
+ place-items: center;
1105
+ font-size: 14px;
1106
+ line-height: 1;
1107
+ }
1108
+ .pm-legend-loading {
1109
+ margin-left: auto;
1110
+ font-style: italic;
1111
+ color: var(--pm-text-3);
1112
+ }
1113
+ </style>