@saasicat/ui-vue 0.19.0 → 0.22.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 (145) hide show
  1. package/dist/{catalog-JXdKdIqG.d.cts → catalog-DJcfm5N2.d.cts} +21 -7
  2. package/dist/{catalog-JXdKdIqG.d.ts → catalog-DJcfm5N2.d.ts} +21 -7
  3. package/dist/{chunk-DPTIOQXX.js → chunk-F7NRM6KI.js} +56 -28
  4. package/dist/{chunk-RVG6LQHL.js → chunk-LY52T4N4.js} +21 -2
  5. package/dist/{chunk-ORX3ZG47.js → chunk-XMMFN2WJ.js} +74 -3
  6. package/dist/client/index.cjs +134 -29
  7. package/dist/client/index.d.cts +53 -3
  8. package/dist/client/index.d.ts +53 -3
  9. package/dist/client/index.js +14 -2
  10. package/dist/index.cjs +321 -162
  11. package/dist/index.d.cts +30 -8
  12. package/dist/index.d.ts +30 -8
  13. package/dist/index.js +176 -133
  14. package/dist/quasar/index.cjs +78 -29
  15. package/dist/quasar/index.d.cts +2 -2
  16. package/dist/quasar/index.d.ts +2 -2
  17. package/dist/quasar/index.js +6 -2
  18. package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
  19. package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
  20. package/dist/{use-super-admin-i18n-BlqcD0N7.d.ts → use-super-admin-i18n-CHNdsSlm.d.ts} +34 -7
  21. package/dist/{use-super-admin-i18n-1nCOkvgi.d.cts → use-super-admin-i18n-CgTuNi8h.d.cts} +34 -7
  22. package/package.json +18 -8
  23. package/src/client/i18n/messages/bundles.ts +0 -2
  24. package/src/client/i18n/messages/common.ts +24 -0
  25. package/src/client/i18n/messages/discovery.ts +2 -2
  26. package/src/client/i18n/messages/marketing.ts +2 -4
  27. package/src/client/i18n/messages/plan-detail.ts +0 -4
  28. package/src/client/i18n/messages/plans.ts +20 -8
  29. package/src/client/i18n/messages/tenants.ts +0 -2
  30. package/src/client/index.ts +1 -0
  31. package/src/client/manifest-loader.ts +1 -1
  32. package/src/client/resolve-plans.ts +144 -0
  33. package/src/components/BundleVersionPublishDialog.vue +6 -6
  34. package/src/components/KvBlock.vue +4 -4
  35. package/src/components/MarketingPromotionsTab.vue +34 -48
  36. package/src/components/MfaPromptDialog.vue +8 -2
  37. package/src/components/TenantActionConfirmDialog.vue +4 -4
  38. package/src/components/VersionDiffPreview.vue +7 -7
  39. package/src/components/admin-page/AdminBody.vue +40 -0
  40. package/src/components/admin-page/AdminFilters.vue +12 -0
  41. package/src/components/admin-page/AdminHero.vue +49 -0
  42. package/src/components/admin-page/AdminKpi.vue +84 -0
  43. package/src/components/admin-page/AdminPage.vue +16 -0
  44. package/src/components/admin-page/AdminPaginator.vue +195 -0
  45. package/src/components/admin-page/AdminRefreshBtn.vue +39 -0
  46. package/src/components/admin-page/AdminSection.vue +53 -0
  47. package/src/components/admin-page/AdminStatistics.vue +40 -0
  48. package/src/components/admin-page/AdminTable.vue +175 -0
  49. package/src/components/bundle-editor/BundleCreatePanel.vue +23 -23
  50. package/src/components/bundle-editor/BundleFeaturesEditor.vue +7 -7
  51. package/src/components/bundle-editor/BundlePlanCompatPicker.vue +9 -9
  52. package/src/components/bundle-editor/BundleQuotasEditor.vue +11 -11
  53. package/src/components/bundle-editor/BundleStatusBanner.vue +6 -6
  54. package/src/components/bundle-editor/BundleVersionInlineEditor.vue +18 -18
  55. package/src/components/bundle-editor/BundleVersionStrip.vue +12 -12
  56. package/src/components/dialogs/PilotCreateDialog.vue +12 -12
  57. package/src/components/dialogs/PilotEditDialog.vue +7 -7
  58. package/src/components/dialogs/PromoCodeCreateDialog.vue +8 -8
  59. package/src/components/dialogs/PromoCodeDialogFields.vue +33 -19
  60. package/src/components/dialogs/PromoCodeEditDialog.vue +10 -10
  61. package/src/components/plan/PlanGrid.vue +1 -1
  62. package/src/components/plan-create-dialog/PlanCreateDialog.vue +34 -46
  63. package/src/components/plan-detail/PlanAuditLog.vue +7 -8
  64. package/src/components/plan-detail/PlanDetail.vue +95 -292
  65. package/src/components/plan-detail/PlanDetailKpis.vue +22 -27
  66. package/src/components/plan-detail/PlanTitleEdit.vue +133 -0
  67. package/src/components/plan-detail/PlanVersionDiffPanel.vue +5 -8
  68. package/src/components/plan-detail/PlanVersionsPanel.vue +9 -11
  69. package/src/components/plan-list/PlanList.vue +206 -457
  70. package/src/components/plan-matrix/PlanMatrix.vue +66 -183
  71. package/src/components/plan-review/PlanReview.vue +191 -275
  72. package/src/components/plan-version-editor/PlanCatalogPreview.vue +7 -5
  73. package/src/components/plan-version-editor/PlanVersionEditor.vue +200 -295
  74. package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +23 -55
  75. package/src/index.ts +1 -0
  76. package/src/pages-standard/AdminLayout.vue +38 -12
  77. package/src/pages-standard/AdminManifestErrorPage.vue +79 -9
  78. package/src/pages-standard/AuditPage.vue +65 -109
  79. package/src/pages-standard/BundlesPage.vue +146 -179
  80. package/src/pages-standard/DashboardPage.vue +59 -154
  81. package/src/pages-standard/DiscoveryPage.vue +172 -227
  82. package/src/pages-standard/EmailHistoryPage.vue +118 -159
  83. package/src/pages-standard/MarketingCatalogPage.vue +399 -440
  84. package/src/pages-standard/PilotsPage.vue +69 -117
  85. package/src/pages-standard/PlansPage.vue +240 -112
  86. package/src/pages-standard/PlatformEmailPage.vue +56 -91
  87. package/src/pages-standard/PromoCodeDetailPage.vue +49 -74
  88. package/src/pages-standard/PromoCodesPage.vue +95 -128
  89. package/src/pages-standard/SubscriptionsPage.vue +19 -52
  90. package/src/pages-standard/SuperAdminLoginPage.vue +12 -12
  91. package/src/pages-standard/SuperAdminSetupWizard.vue +22 -18
  92. package/src/pages-standard/TenantDetailPage.vue +100 -191
  93. package/src/pages-standard/TenantsPage.vue +190 -295
  94. package/src/pages-standard/UsersPage.vue +89 -157
  95. package/src/pages-standard/bundles-page/BundleAccordionList.vue +20 -17
  96. package/src/pages-standard/bundles-page/BundlesKpis.vue +13 -22
  97. package/src/pages-standard/bundles-page/BundlesToolbar.vue +63 -0
  98. package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +10 -10
  99. package/src/pages-standard/discovery-page/DiscoveryCapList.vue +5 -5
  100. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +16 -16
  101. package/src/pages-standard/discovery-page/DiscoveryKpis.vue +31 -27
  102. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +7 -7
  103. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +111 -73
  104. package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +43 -62
  105. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +53 -35
  106. package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +7 -7
  107. package/src/pages-standard/plan-versions/PlanDiffCard.vue +6 -6
  108. package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +6 -6
  109. package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +16 -16
  110. package/src/pages-standard/plans-page/PlanBundleOverview.vue +14 -29
  111. package/src/pages-standard/plans-page/PlanPublishDialog.vue +1 -1
  112. package/src/pages-standard/plans-page/PlansPageToast.vue +6 -6
  113. package/src/pages-standard/sa-theme.css +552 -86
  114. package/src/pages-standard/tenant-detail/TenantMasterData.vue +28 -0
  115. package/src/pages-standard/tenant-detail/TenantUsage.vue +31 -0
  116. package/src/pages-standard/tenant-detail/TenantUsers.vue +15 -0
  117. package/src/pages-standard/tenant-detail/types.ts +38 -0
  118. package/src/pages-standard/tenants/StatusPill.vue +2 -2
  119. package/src/pages-tenant/MySubscriptionBundlesPage.vue +25 -25
  120. package/src/pages-tenant/OnboardingConfigurator.vue +1 -5
  121. package/src/pages-tenant/PackageSnapshotPanel.vue +1 -1
  122. package/src/pages-tenant/TenantPlanSection.vue +12 -20
  123. package/src/quasar/create-super-admin-app.ts +4 -0
  124. package/src/vue/entitlement-provider.ts +0 -1
  125. package/src/vue/feature-router-guard.ts +0 -1
  126. package/src/vue/super-admin-context.ts +106 -1
  127. package/src/vue/use-api-list.ts +2 -1
  128. package/src/vue/use-mfa-prompt.ts +72 -0
  129. package/src/vue/use-pagination.ts +63 -0
  130. package/src/vue/use-plans.ts +1 -2
  131. package/src/vue/use-sign-out.ts +90 -0
  132. package/src/vue/use-tenant-billing.ts +1 -1
  133. package/src/vue/use-tenant-manifest.ts +0 -1
  134. package/src/components/KpiCard.vue +0 -53
  135. package/src/components/plan-cockpit/PlanCockpit.vue +0 -1012
  136. package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +0 -72
  137. package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +0 -100
  138. package/src/components/plan-cockpit/PlanCockpitHeader.vue +0 -101
  139. package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +0 -112
  140. package/src/components/plan-cockpit/PlanCockpitKpis.vue +0 -68
  141. package/src/components/plan-cockpit/PlanCockpitVersions.vue +0 -251
  142. package/src/components/plan-cockpit/types.ts +0 -72
  143. package/src/components/plan-detail/PlanDetailHeader.vue +0 -194
  144. package/src/pages-standard/bundles-page/BundlesHeader.vue +0 -68
  145. package/src/pages-standard/discovery-page/DiscoveryHeader.vue +0 -34
@@ -27,6 +27,9 @@ export const commonMessages = defineMessages(
27
27
  loading: 'Lade …',
28
28
  saving: 'Speichere …',
29
29
  loadingData: 'Daten werden geladen…',
30
+ noData: 'Keine Daten vorhanden',
31
+ filters: 'Filter',
32
+ results: 'Ergebnisse',
30
33
  error: 'Fehler',
31
34
  errorLoadFailed: 'Laden fehlgeschlagen',
32
35
  errorSaveFailed: 'Speichern fehlgeschlagen',
@@ -73,6 +76,15 @@ export const commonMessages = defineMessages(
73
76
  both: 'Beide',
74
77
  createFailed: 'Anlegen fehlgeschlagen',
75
78
  noChanges: 'Keine Änderungen',
79
+ paginator: {
80
+ label: 'Seitennavigation',
81
+ rowsPerPage: 'Zeilen pro Seite',
82
+ all: 'Alle',
83
+ page: 'Seite {page}/{pages}',
84
+ pageInput: 'Seite, 1 bis {pages}',
85
+ previous: 'Vorherige Seite',
86
+ next: 'Nächste Seite',
87
+ },
76
88
  },
77
89
  {
78
90
  save: 'Save',
@@ -97,6 +109,9 @@ export const commonMessages = defineMessages(
97
109
  loading: 'Loading …',
98
110
  saving: 'Saving …',
99
111
  loadingData: 'Loading data…',
112
+ noData: 'No data available',
113
+ filters: 'Filters',
114
+ results: 'Results',
100
115
  error: 'Error',
101
116
  errorLoadFailed: 'Failed to load',
102
117
  errorSaveFailed: 'Failed to save',
@@ -143,5 +158,14 @@ export const commonMessages = defineMessages(
143
158
  both: 'Both',
144
159
  createFailed: 'Creation failed',
145
160
  noChanges: 'No changes',
161
+ paginator: {
162
+ label: 'Pagination',
163
+ rowsPerPage: 'Rows per page',
164
+ all: 'All',
165
+ page: 'Page {page}/{pages}',
166
+ pageInput: 'Page, 1 to {pages}',
167
+ previous: 'Previous page',
168
+ next: 'Next page',
169
+ },
146
170
  },
147
171
  );
@@ -91,7 +91,7 @@ export const discoveryMessages = defineMessages(
91
91
  usageProvider: 'UsageProvider',
92
92
  usageProviderMissing: 'fehlt',
93
93
  noUsageProviderWarning:
94
- 'Eine harte Quota ohne UsageProvider ist nicht deploy-fähig (Preflight, SPEC_V2 §6.3).',
94
+ 'Eine harte Quota ohne UsageProvider ist nicht deploy-fähig (Preflight).',
95
95
  outdatedBanner:
96
96
  'Die code-abgeleiteten Quota-Fakten (Einheit/Enforcement/Provider) haben sich seit der letzten Freigabe geändert — bitte prüfen und',
97
97
  },
@@ -202,7 +202,7 @@ export const discoveryMessages = defineMessages(
202
202
  usageProvider: 'UsageProvider',
203
203
  usageProviderMissing: 'missing',
204
204
  noUsageProviderWarning:
205
- 'A hard quota without a UsageProvider is not deployable (preflight, SPEC_V2 §6.3).',
205
+ 'A hard quota without a UsageProvider is not deployable (preflight).',
206
206
  outdatedBanner:
207
207
  'The code-derived quota facts (unit/enforcement/provider) have changed since the last approval — please review and',
208
208
  },
@@ -16,7 +16,6 @@ export const marketingMessages = defineMessages(
16
16
  defaultLocale: 'Default-Sprache',
17
17
  deactivateLocale: 'Sprache deaktivieren',
18
18
  addLocale: '+ Sprache',
19
- refresh: 'Aktualisieren',
20
19
  },
21
20
  page: {
22
21
  loading: 'Lade Marketing-Catalog …',
@@ -89,7 +88,7 @@ export const marketingMessages = defineMessages(
89
88
  promotionsTab: {
90
89
  title: 'Aktionen & Rabatte',
91
90
  subtitle:
92
- 'Zeitlich begrenzte Preis-Aktionen — überschreiben den regulären Preis auf der Pricing-Page (SPEC_V2 §9a).',
91
+ 'Zeitlich begrenzte Preis-Aktionen — überschreiben den regulären Preis auf der Pricing-Page.',
93
92
  add: '+ Aktion anlegen',
94
93
  timelineHead: 'Aktions-Timeline · gestrichelt = heute',
95
94
  today: 'heute',
@@ -150,7 +149,6 @@ export const marketingMessages = defineMessages(
150
149
  defaultLocale: 'Default language',
151
150
  deactivateLocale: 'Deactivate language',
152
151
  addLocale: '+ Language',
153
- refresh: 'Refresh',
154
152
  },
155
153
  page: {
156
154
  loading: 'Loading marketing catalog …',
@@ -221,7 +219,7 @@ export const marketingMessages = defineMessages(
221
219
  promotionsTab: {
222
220
  title: 'Promotions & discounts',
223
221
  subtitle:
224
- 'Time-limited price promotions — they override the regular price on the pricing page (SPEC_V2 §9a).',
222
+ 'Time-limited price promotions — they override the regular price on the pricing page.',
225
223
  add: '+ Create promotion',
226
224
  timelineHead: 'Promotion timeline · dashed = today',
227
225
  today: 'today',
@@ -26,10 +26,8 @@ export const planDetailMessages = defineMessages(
26
26
  versionsSummary: '{published} live/superseded',
27
27
  versionsSummaryWithDrafts: '{published} live/superseded · {drafts} Draft',
28
28
  openDraft: 'Offener Draft',
29
- noDraft: 'Kein Draft',
30
29
  readyToEdit: 'Bereit zum Bearbeiten',
31
30
  noOpenDraft: 'Keine Draft-Version offen',
32
- createNewDraft: 'Neue Draft anlegen',
33
31
  },
34
32
  versions: {
35
33
  title: 'Versionen',
@@ -157,10 +155,8 @@ export const planDetailMessages = defineMessages(
157
155
  versionsSummary: '{published} live/superseded',
158
156
  versionsSummaryWithDrafts: '{published} live/superseded · {drafts} draft',
159
157
  openDraft: 'Open draft',
160
- noDraft: 'No draft',
161
158
  readyToEdit: 'Ready to edit',
162
159
  noOpenDraft: 'No draft version open',
163
- createNewDraft: 'Create a new draft',
164
160
  },
165
161
  versions: {
166
162
  title: 'Versions',
@@ -5,6 +5,8 @@ export const plansMessages = defineMessages(
5
5
  // PlansPage — shell of the plan area: loading state, toasts and the
6
6
  // error texts of the draft/publish/archive/discard actions.
7
7
  page: {
8
+ subtitle:
9
+ 'Pläne bündeln Features & Quotas zu einem Angebot. Jede Änderung wird als neue Version veröffentlicht — Live-Versionen bleiben unangetastet.',
8
10
  loading: 'Pläne werden geladen…',
9
11
  toastPlanCreated: 'Plan {planKey} angelegt — jetzt die erste Version zusammenstellen.',
10
12
  toastDraftSaved: 'Draft v{version} von {planKey} gespeichert.',
@@ -68,8 +70,10 @@ export const plansMessages = defineMessages(
68
70
  // PlanList — default plan overview with one row per plan.
69
71
  list: {
70
72
  title: 'Alle Pläne',
71
- summary:
72
- '{plans} Pläne · {live} live · {drafts} offene Drafts · {tenants} Mandanten zugeordnet',
73
+ statPlans: 'Pläne',
74
+ statLive: 'Live',
75
+ statDrafts: 'Offene Drafts',
76
+ statTenants: 'Mandanten',
73
77
  matrixView: 'Matrix-Ansicht',
74
78
  newPlan: 'Neuer Plan',
75
79
  searchPlaceholder: 'Plan suchen…',
@@ -108,8 +112,10 @@ export const plansMessages = defineMessages(
108
112
  // PlanMatrix — all plans side by side, components as rows.
109
113
  matrix: {
110
114
  title: 'Pläne · Matrix-Vergleich',
111
- summary:
112
- 'Alle Pläne nebeneinander · {plans} Pläne · {features} Features · {quotas} Quotas · {bundles} Bundles',
115
+ statPlans: 'Pläne',
116
+ statFeatures: 'Features',
117
+ statQuotas: 'Quotas',
118
+ statBundles: 'Bundles',
113
119
  catalogPreview: 'Catalog-Vorschau',
114
120
  createPlan: 'Plan anlegen',
115
121
  createPlanSub: 'oder Klon erstellen',
@@ -260,6 +266,8 @@ export const plansMessages = defineMessages(
260
266
  },
261
267
  {
262
268
  page: {
269
+ subtitle:
270
+ 'Plans bundle features & quotas into an offering. Every change ships as a new version — live versions stay untouched.',
263
271
  loading: 'Loading plans…',
264
272
  toastPlanCreated: 'Plan {planKey} created — now assemble the first version.',
265
273
  toastDraftSaved: 'Draft v{version} of {planKey} saved.',
@@ -319,8 +327,10 @@ export const plansMessages = defineMessages(
319
327
  },
320
328
  list: {
321
329
  title: 'All plans',
322
- summary:
323
- '{plans} plans · {live} live · {drafts} open drafts · {tenants} tenants assigned',
330
+ statPlans: 'Plans',
331
+ statLive: 'Live',
332
+ statDrafts: 'Open drafts',
333
+ statTenants: 'Tenants',
324
334
  matrixView: 'Matrix view',
325
335
  newPlan: 'New plan',
326
336
  searchPlaceholder: 'Search plan…',
@@ -358,8 +368,10 @@ export const plansMessages = defineMessages(
358
368
  },
359
369
  matrix: {
360
370
  title: 'Plans · matrix comparison',
361
- summary:
362
- 'All plans side by side · {plans} plans · {features} features · {quotas} quotas · {bundles} bundles',
371
+ statPlans: 'Plans',
372
+ statFeatures: 'Features',
373
+ statQuotas: 'Quotas',
374
+ statBundles: 'Bundles',
363
375
  catalogPreview: 'Catalog preview',
364
376
  createPlan: 'Create plan',
365
377
  createPlanSub: 'or create a clone',
@@ -14,7 +14,6 @@ export const tenantsMessages = defineMessages(
14
14
  columnCreatedAt: 'Angelegt',
15
15
  suspended: 'Suspendiert',
16
16
  empty: 'Keine Mandanten mit diesen Filtern.',
17
- pagination: '{total} Mandanten · Seite {page} / {pages}',
18
17
  },
19
18
  detail: {
20
19
  backToList: 'Mandanten-Liste',
@@ -63,7 +62,6 @@ export const tenantsMessages = defineMessages(
63
62
  columnCreatedAt: 'Created',
64
63
  suspended: 'Suspended',
65
64
  empty: 'No tenants match these filters.',
66
- pagination: '{total} tenants · Page {page} / {pages}',
67
65
  },
68
66
  detail: {
69
67
  backToList: 'Tenant list',
@@ -25,3 +25,4 @@ export * from './batch-column-fetcher.js';
25
25
  export * from './admin-resource-client.js';
26
26
  export * from './i18n/index.js';
27
27
  export * from './login-branding.js';
28
+ export * from './resolve-plans.js';
@@ -106,7 +106,7 @@ export class ManifestLoader {
106
106
  if (res.status !== 200) {
107
107
  throw new ManifestLoadError(
108
108
  res.status,
109
- `Manifest-Endpunkt antwortete HTTP ${res.status}`,
109
+ `Manifest endpoint responded with HTTP ${res.status}`,
110
110
  );
111
111
  }
112
112
 
@@ -0,0 +1,144 @@
1
+ // Resolving plans into what a listing actually shows — which version is live,
2
+ // which are scheduled, which is the open draft — and the four counts derived
3
+ // from that.
4
+ //
5
+ // It lives outside the components because two of them need the same answer:
6
+ // `PlanList` renders the rows, and `PlansPage` shows the counts above it. The
7
+ // alternative was the same sixty lines in both, drifting apart the first time
8
+ // a validity rule changed.
9
+ //
10
+ // Framework-free on purpose: no Vue, so the rules can be tested as plain data
11
+ // in and data out.
12
+
13
+ export interface PlanRowLike {
14
+ id: string;
15
+ planKey: string;
16
+ label: string;
17
+ description?: string | null;
18
+ sortOrder: number;
19
+ }
20
+
21
+ export interface PlanVersionLike {
22
+ id: string;
23
+ publishedAt?: string | null;
24
+ validFrom?: string | null;
25
+ validUntil?: string | null;
26
+ }
27
+
28
+ export interface ResolvedPlan<P extends PlanRowLike, V extends PlanVersionLike> {
29
+ plan: P;
30
+ planKey: string;
31
+ label: string;
32
+ description: string | null;
33
+ /** Version currently active for new bookings. */
34
+ currentLive: V | null;
35
+ /** Version shown on the parent row — the live one where there is one. */
36
+ primary: V | null;
37
+ /** Future-published versions first, then drafts. */
38
+ subRows: V[];
39
+ hasAnyVersion: boolean;
40
+ /** Every version has run out: hidden from the admin listing. */
41
+ allExpired: boolean;
42
+ draft: V | null;
43
+ tenantCount: number;
44
+ }
45
+
46
+ export function todayIsoDate(): string {
47
+ return new Date().toISOString().slice(0, 10);
48
+ }
49
+
50
+ export function isCurrentlyValid(v: PlanVersionLike, today: string): boolean {
51
+ if (!v.publishedAt) return false;
52
+ if (v.validFrom && v.validFrom.slice(0, 10) > today) return false;
53
+ if (v.validUntil && v.validUntil.slice(0, 10) < today) return false;
54
+ return true;
55
+ }
56
+
57
+ export function isFutureScheduled(v: PlanVersionLike, today: string): boolean {
58
+ if (!v.publishedAt) return false;
59
+ if (!v.validFrom) return false;
60
+ return v.validFrom.slice(0, 10) > today;
61
+ }
62
+
63
+ export function isExpired(v: PlanVersionLike, today: string): boolean {
64
+ if (!v.publishedAt) return false;
65
+ if (!v.validUntil) return false;
66
+ return v.validUntil.slice(0, 10) < today;
67
+ }
68
+
69
+ export interface ResolvePlansInput<P extends PlanRowLike, V extends PlanVersionLike> {
70
+ plans: readonly P[];
71
+ versionsByPlanId: Record<string, V[]>;
72
+ tenantCountsByPlanKey: Record<string, number>;
73
+ /** Injectable for tests; defaults to today. */
74
+ today?: string;
75
+ }
76
+
77
+ export function resolvePlans<P extends PlanRowLike, V extends PlanVersionLike>({
78
+ plans,
79
+ versionsByPlanId,
80
+ tenantCountsByPlanKey,
81
+ today = todayIsoDate(),
82
+ }: ResolvePlansInput<P, V>): ResolvedPlan<P, V>[] {
83
+ return [...plans]
84
+ .sort((a, b) => a.sortOrder - b.sortOrder || a.planKey.localeCompare(b.planKey))
85
+ .map<ResolvedPlan<P, V>>((plan) => {
86
+ const versions = versionsByPlanId[plan.id] ?? [];
87
+ const drafts = versions.filter((v) => !v.publishedAt);
88
+ const published = versions.filter((v) => Boolean(v.publishedAt));
89
+ const currentLive = published.find((v) => isCurrentlyValid(v, today)) ?? null;
90
+ const futureScheduled = published
91
+ .filter((v) => isFutureScheduled(v, today))
92
+ .sort((a, b) => (a.validFrom ?? '').localeCompare(b.validFrom ?? ''));
93
+
94
+ // Parent row: the live version where there is one, otherwise the
95
+ // next scheduled one, otherwise nothing — a plan with only drafts
96
+ // still gets a row.
97
+ const primary = currentLive ?? futureScheduled[0] ?? null;
98
+
99
+ const seen = new Set<string>();
100
+ if (primary) seen.add(primary.id);
101
+ const subRows: V[] = [];
102
+ for (const v of [...futureScheduled, ...drafts]) {
103
+ if (seen.has(v.id)) continue;
104
+ subRows.push(v);
105
+ seen.add(v.id);
106
+ }
107
+
108
+ return {
109
+ plan,
110
+ planKey: plan.planKey,
111
+ label: plan.label,
112
+ description: plan.description ?? null,
113
+ currentLive,
114
+ primary,
115
+ subRows,
116
+ hasAnyVersion: versions.length > 0,
117
+ allExpired:
118
+ versions.length > 0 &&
119
+ drafts.length === 0 &&
120
+ futureScheduled.length === 0 &&
121
+ published.every((v) => isExpired(v, today)),
122
+ draft: drafts[0] ?? null,
123
+ tenantCount: tenantCountsByPlanKey[plan.planKey] ?? 0,
124
+ };
125
+ });
126
+ }
127
+
128
+ export interface PlanCounts {
129
+ plans: number;
130
+ live: number;
131
+ drafts: number;
132
+ tenants: number;
133
+ }
134
+
135
+ export function countPlans<P extends PlanRowLike, V extends PlanVersionLike>(
136
+ resolved: readonly ResolvedPlan<P, V>[],
137
+ ): PlanCounts {
138
+ return {
139
+ plans: resolved.length,
140
+ live: resolved.filter((p) => p.currentLive !== null).length,
141
+ drafts: resolved.filter((p) => p.draft !== null).length,
142
+ tenants: resolved.reduce((sum, p) => sum + p.tenantCount, 0),
143
+ };
144
+ }
@@ -130,7 +130,7 @@
130
130
  "
131
131
  :loading="submitting"
132
132
  :disable="!canSubmit"
133
- @click="submit"
133
+ @click="onSubmit"
134
134
  />
135
135
  </q-card-actions>
136
136
  </q-card>
@@ -246,7 +246,7 @@ function close(): void {
246
246
  open.value = false;
247
247
  }
248
248
 
249
- async function submit(): Promise<void> {
249
+ async function onSubmit(): Promise<void> {
250
250
  if (!canSubmit.value) return;
251
251
  submitting.value = true;
252
252
  try {
@@ -323,8 +323,8 @@ function formatValue(v: unknown): string {
323
323
  }
324
324
  .bvpd__validity {
325
325
  padding: 12px;
326
- background: #f8fafc;
327
- border: 1px solid #e2e8f0;
326
+ background: var(--sa-bg-surface-2);
327
+ border: 1px solid var(--sa-border);
328
328
  border-radius: 8px;
329
329
  }
330
330
  .bvpd__validity-grid {
@@ -337,7 +337,7 @@ function formatValue(v: unknown): string {
337
337
  gap: 6px;
338
338
  font-size: 12px;
339
339
  font-weight: 700;
340
- color: #475569;
340
+ color: var(--sa-muted-dark);
341
341
  }
342
342
  .bvpd__input {
343
343
  min-height: 36px;
@@ -345,7 +345,7 @@ function formatValue(v: unknown): string {
345
345
  border-radius: 6px;
346
346
  padding: 0 10px;
347
347
  font: inherit;
348
- color: #0f172a;
348
+ color: var(--sa-heading);
349
349
  background: #fff;
350
350
  }
351
351
  .bvpd__error {
@@ -15,13 +15,13 @@ defineProps<{ label: string; value: string }>();
15
15
 
16
16
  <style scoped>
17
17
  .kv {
18
- background: #f8fafc;
19
- border: 1px solid #e2e8f0;
18
+ background: var(--sa-bg-surface-2);
19
+ border: 1px solid var(--sa-border);
20
20
  border-radius: 10px;
21
21
  padding: 10px 12px;
22
22
  }
23
23
  .kv__label {
24
- color: #64748b;
24
+ color: var(--sa-muted);
25
25
  font-size: 11px;
26
26
  text-transform: uppercase;
27
27
  letter-spacing: 0.05em;
@@ -30,7 +30,7 @@ defineProps<{ label: string; value: string }>();
30
30
  .kv__value {
31
31
  font-size: 14px;
32
32
  font-weight: 600;
33
- color: #0f172a;
33
+ color: var(--sa-heading);
34
34
  margin-top: 4px;
35
35
  }
36
36
  </style>
@@ -17,7 +17,7 @@
17
17
  {{ counts.expired }} {{ msg.promotionsTab.statusExpired }}
18
18
  </span>
19
19
  </div>
20
- <button class="mc-promo-add" type="button" :disabled="busy" @click="onAdd">
20
+ <button class="sa-btn sa-btn--primary" type="button" :disabled="busy" @click="onAdd">
21
21
  {{ msg.promotionsTab.add }}
22
22
  </button>
23
23
  </div>
@@ -325,7 +325,7 @@ import {
325
325
  import { formatMessage } from '../client/i18n/format.js';
326
326
  import { useSaMessages, useSuperAdminI18n } from '../vue/use-super-admin-i18n.js';
327
327
 
328
- // Promotions tab of the Marketing Catalog (SPEC_V2 §9a). Standalone child
328
+ // Promotions tab of the Marketing Catalog. Standalone child
329
329
  // component — the MarketingCatalogPage wires `usePromotions` and passes the
330
330
  // list + CRUD callbacks through.
331
331
 
@@ -586,7 +586,7 @@ async function onRemove(p: PromotionRow): Promise<void> {
586
586
  }
587
587
  .mc-promo-subtitle {
588
588
  font-size: 12px;
589
- color: #64748b;
589
+ color: var(--sa-muted);
590
590
  }
591
591
  .mc-promo-stats {
592
592
  display: flex;
@@ -597,8 +597,8 @@ async function onRemove(p: PromotionRow): Promise<void> {
597
597
  font-weight: 600;
598
598
  padding: 3px 9px;
599
599
  border-radius: 999px;
600
- background: #f1f5f9;
601
- color: #475569;
600
+ background: var(--sa-border-soft);
601
+ color: var(--sa-muted-dark);
602
602
  }
603
603
  .mc-promo-stat.active {
604
604
  background: #dcfce7;
@@ -606,54 +606,40 @@ async function onRemove(p: PromotionRow): Promise<void> {
606
606
  }
607
607
  .mc-promo-stat.scheduled {
608
608
  background: #dbeafe;
609
- color: #1d4ed8;
609
+ color: var(--sa-primary-strong);
610
610
  }
611
611
  .mc-promo-stat.expired {
612
- background: #f1f5f9;
613
- color: #94a3b8;
614
- }
615
- .mc-promo-add {
616
- border: 0;
617
- background: #3f6bff;
618
- color: #fff;
619
- font-weight: 600;
620
- font-size: 13px;
621
- padding: 8px 14px;
622
- border-radius: 8px;
623
- cursor: pointer;
624
- }
625
- .mc-promo-add:disabled {
626
- opacity: 0.5;
627
- cursor: default;
612
+ background: var(--sa-border-soft);
613
+ color: var(--sa-muted-light);
628
614
  }
629
615
  .mc-promo-timeline {
630
616
  background: #fff;
631
- border: 1px solid #e2e8f0;
617
+ border: 1px solid var(--sa-border);
632
618
  border-radius: 10px;
633
619
  padding: 12px 14px;
634
620
  }
635
621
  .mc-promo-timeline-head {
636
622
  font-size: 11px;
637
- color: #64748b;
623
+ color: var(--sa-muted);
638
624
  margin-bottom: 6px;
639
625
  }
640
626
  .mc-promo-timeline-chart {
641
627
  position: relative;
642
- border-left: 1px solid #e2e8f0;
628
+ border-left: 1px solid var(--sa-border);
643
629
  }
644
630
  .mc-promo-tick {
645
631
  position: absolute;
646
632
  top: 0;
647
633
  bottom: 0;
648
634
  width: 1px;
649
- background: #f1f5f9;
635
+ background: var(--sa-border-soft);
650
636
  }
651
637
  .mc-promo-tick-label {
652
638
  position: absolute;
653
639
  bottom: 0;
654
640
  left: 3px;
655
641
  font-size: 9px;
656
- color: #94a3b8;
642
+ color: var(--sa-muted-light);
657
643
  white-space: nowrap;
658
644
  }
659
645
  .mc-promo-today {
@@ -694,7 +680,7 @@ async function onRemove(p: PromotionRow): Promise<void> {
694
680
  .mc-promo-empty {
695
681
  padding: 28px;
696
682
  text-align: center;
697
- color: #94a3b8;
683
+ color: var(--sa-muted-light);
698
684
  font-size: 13px;
699
685
  border: 1px dashed #cbd5e1;
700
686
  border-radius: 10px;
@@ -709,7 +695,7 @@ async function onRemove(p: PromotionRow): Promise<void> {
709
695
  align-items: center;
710
696
  gap: 12px;
711
697
  background: #fff;
712
- border: 1px solid #e2e8f0;
698
+ border: 1px solid var(--sa-border);
713
699
  border-radius: 8px;
714
700
  padding: 10px 12px;
715
701
  cursor: pointer;
@@ -743,30 +729,30 @@ async function onRemove(p: PromotionRow): Promise<void> {
743
729
  .mc-promo-typechip {
744
730
  font-size: 10px;
745
731
  font-weight: 700;
746
- background: #eff6ff;
747
- color: #1d4ed8;
732
+ background: var(--sa-primary-50);
733
+ color: var(--sa-primary-strong);
748
734
  padding: 1px 6px;
749
735
  border-radius: 4px;
750
736
  }
751
737
  .mc-promo-planchip {
752
738
  font-size: 10px;
753
- background: #f1f5f9;
754
- color: #475569;
739
+ background: var(--sa-border-soft);
740
+ color: var(--sa-muted-dark);
755
741
  padding: 1px 6px;
756
742
  border-radius: 4px;
757
743
  }
758
744
  .mc-promo-muted {
759
745
  font-size: 11px;
760
- color: #94a3b8;
746
+ color: var(--sa-muted-light);
761
747
  }
762
748
  .mc-promo-row-when {
763
749
  font-size: 11px;
764
- color: #64748b;
750
+ color: var(--sa-muted);
765
751
  text-align: right;
766
752
  }
767
753
  .mc-promo-cycle {
768
754
  display: block;
769
- color: #94a3b8;
755
+ color: var(--sa-muted-light);
770
756
  }
771
757
  .mc-promo-status {
772
758
  font-size: 10px;
@@ -781,17 +767,17 @@ async function onRemove(p: PromotionRow): Promise<void> {
781
767
  }
782
768
  .mc-promo-status.scheduled {
783
769
  background: #dbeafe;
784
- color: #1d4ed8;
770
+ color: var(--sa-primary-strong);
785
771
  }
786
772
  .mc-promo-status.expired {
787
- background: #f1f5f9;
788
- color: #94a3b8;
773
+ background: var(--sa-border-soft);
774
+ color: var(--sa-muted-light);
789
775
  }
790
776
  .mc-promo-editor {
791
777
  border: 1px solid #3f6bff;
792
778
  border-top: 0;
793
779
  border-radius: 0 0 8px 8px;
794
- background: #f8fafc;
780
+ background: var(--sa-bg-surface-2);
795
781
  padding: 14px;
796
782
  }
797
783
  .mc-promo-editor-grid {
@@ -809,7 +795,7 @@ async function onRemove(p: PromotionRow): Promise<void> {
809
795
  font-weight: 700;
810
796
  text-transform: uppercase;
811
797
  letter-spacing: 0.5px;
812
- color: #475569;
798
+ color: var(--sa-muted-dark);
813
799
  margin-top: 6px;
814
800
  }
815
801
  .mc-promo-input {
@@ -841,8 +827,8 @@ async function onRemove(p: PromotionRow): Promise<void> {
841
827
  }
842
828
  .mc-promo-typeopt.active {
843
829
  border-color: #3f6bff;
844
- background: #eff6ff;
845
- color: #1d4ed8;
830
+ background: var(--sa-primary-50);
831
+ color: var(--sa-primary-strong);
846
832
  font-weight: 600;
847
833
  }
848
834
  .mc-promo-planlist {
@@ -862,10 +848,10 @@ async function onRemove(p: PromotionRow): Promise<void> {
862
848
  }
863
849
  .mc-promo-planopt.active {
864
850
  border-color: #3f6bff;
865
- background: #eff6ff;
851
+ background: var(--sa-primary-50);
866
852
  }
867
853
  .mc-promo-planopt code {
868
- color: #94a3b8;
854
+ color: var(--sa-muted-light);
869
855
  font-size: 10px;
870
856
  }
871
857
  .mc-promo-i18n-block {
@@ -873,14 +859,14 @@ async function onRemove(p: PromotionRow): Promise<void> {
873
859
  flex-direction: column;
874
860
  gap: 4px;
875
861
  padding: 8px;
876
- border: 1px solid #e2e8f0;
862
+ border: 1px solid var(--sa-border);
877
863
  border-radius: 8px;
878
864
  background: #fff;
879
865
  }
880
866
  .mc-promo-i18n-code {
881
867
  font-size: 10px;
882
868
  font-weight: 700;
883
- background: #f1f5f9;
869
+ background: var(--sa-border-soft);
884
870
  padding: 2px 7px;
885
871
  border-radius: 5px;
886
872
  align-self: flex-start;
@@ -897,7 +883,7 @@ async function onRemove(p: PromotionRow): Promise<void> {
897
883
  cursor: pointer;
898
884
  }
899
885
  .mc-promo-colorbtn.active {
900
- border-color: #0f172a;
886
+ border-color: var(--sa-heading);
901
887
  }
902
888
  .mc-promo-delete {
903
889
  margin-top: 12px;