@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.
- package/LICENSE +202 -0
- package/README.md +47 -0
- package/dist/index.cjs +3776 -0
- package/dist/index.d.cts +2395 -0
- package/dist/index.d.ts +2395 -0
- package/dist/index.js +3659 -0
- package/dist/testing-e2e/admin-pages-suite.cjs +134 -0
- package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
- package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
- package/dist/testing-e2e/admin-pages-suite.js +109 -0
- package/package.json +87 -0
- package/src/action-registry.ts +110 -0
- package/src/batch-column-fetcher.ts +152 -0
- package/src/boot-loader.ts +60 -0
- package/src/components/BundleVersionPublishDialog.vue +359 -0
- package/src/components/BusinessTypeVersionEditorDialog.vue +417 -0
- package/src/components/BusinessTypeVersionPublishDialog.vue +258 -0
- package/src/components/FeatureGate.vue +56 -0
- package/src/components/KpiCard.vue +53 -0
- package/src/components/KvBlock.vue +36 -0
- package/src/components/MarketingPromotionsTab.vue +861 -0
- package/src/components/MfaPromptDialog.vue +104 -0
- package/src/components/TenantActionConfirmDialog.vue +252 -0
- package/src/components/VersionDiffPreview.vue +150 -0
- package/src/components/bundle-editor/BundleCreatePanel.vue +681 -0
- package/src/components/bundle-editor/BundleFeaturesEditor.vue +215 -0
- package/src/components/bundle-editor/BundlePlanCompatPicker.vue +316 -0
- package/src/components/bundle-editor/BundleQuotasEditor.vue +219 -0
- package/src/components/bundle-editor/BundleStatusBanner.vue +189 -0
- package/src/components/bundle-editor/BundleVersionInlineEditor.vue +659 -0
- package/src/components/bundle-editor/BundleVersionStrip.vue +207 -0
- package/src/components/bundle-editor/bundle-version-status.ts +164 -0
- package/src/components/bundle-editor/catalog-i18n.ts +85 -0
- package/src/components/dialogs/PilotCreateDialog.vue +780 -0
- package/src/components/dialogs/PilotEditDialog.vue +548 -0
- package/src/components/dialogs/PromoCodeCreateDialog.vue +737 -0
- package/src/components/dialogs/PromoCodeEditDialog.vue +859 -0
- package/src/components/dialogs/types.ts +126 -0
- package/src/components/plan/PlanCycleToggle.vue +99 -0
- package/src/components/plan/PlanGrid.vue +211 -0
- package/src/components/plan/PriceSummary.vue +293 -0
- package/src/components/plan/PromoCodeInput.vue +192 -0
- package/src/components/plan/PublicBundleGrid.vue +287 -0
- package/src/components/plan-cockpit/PlanCockpit.vue +993 -0
- package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +65 -0
- package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +91 -0
- package/src/components/plan-cockpit/PlanCockpitHeader.vue +98 -0
- package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +99 -0
- package/src/components/plan-cockpit/PlanCockpitKpis.vue +57 -0
- package/src/components/plan-cockpit/PlanCockpitVersions.vue +226 -0
- package/src/components/plan-cockpit/types.ts +72 -0
- package/src/components/plan-create-dialog/PlanCreateDialog.vue +508 -0
- package/src/components/plan-detail/PlanAuditLog.vue +59 -0
- package/src/components/plan-detail/PlanDetail.vue +1195 -0
- package/src/components/plan-detail/PlanDetailHeader.vue +185 -0
- package/src/components/plan-detail/PlanDetailKpis.vue +57 -0
- package/src/components/plan-detail/PlanTerminateDialog.vue +76 -0
- package/src/components/plan-detail/PlanVersionDiffPanel.vue +149 -0
- package/src/components/plan-detail/PlanVersionsPanel.vue +255 -0
- package/src/components/plan-detail/types.ts +59 -0
- package/src/components/plan-list/PlanList.vue +1067 -0
- package/src/components/plan-matrix/PlanMatrix.vue +1113 -0
- package/src/components/plan-review/PlanReview.vue +742 -0
- package/src/components/plan-version-editor/PlanCatalogPreview.vue +184 -0
- package/src/components/plan-version-editor/PlanComponentPool.vue +272 -0
- package/src/components/plan-version-editor/PlanVersionBasket.vue +281 -0
- package/src/components/plan-version-editor/PlanVersionDiffDialog.vue +84 -0
- package/src/components/plan-version-editor/PlanVersionEditor.vue +1728 -0
- package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +94 -0
- package/src/components/plan-version-editor/types.ts +96 -0
- package/src/components/wizard-stepper/WizardStepper.vue +107 -0
- package/src/create-admin-routes.ts +68 -0
- package/src/create-super-admin-app.ts +345 -0
- package/src/entitlement-provider.ts +36 -0
- package/src/feature-router-guard.ts +77 -0
- package/src/http-json.ts +62 -0
- package/src/index.ts +98 -0
- package/src/manifest-loader.ts +139 -0
- package/src/manifest-store-factory.ts +114 -0
- package/src/nav-builder.ts +274 -0
- package/src/pages-standard/AdminLayout.vue +411 -0
- package/src/pages-standard/AdminManifestErrorPage.vue +79 -0
- package/src/pages-standard/AuditPage.vue +223 -0
- package/src/pages-standard/BundlesPage.vue +910 -0
- package/src/pages-standard/BusinessTypesPage.vue +662 -0
- package/src/pages-standard/DashboardPage.vue +595 -0
- package/src/pages-standard/DiscoveryPage.vue +663 -0
- package/src/pages-standard/EmailHistoryPage.vue +576 -0
- package/src/pages-standard/MarketingCatalogPage.vue +1771 -0
- package/src/pages-standard/PilotsPage.vue +577 -0
- package/src/pages-standard/PlanVersionsPage.vue +216 -0
- package/src/pages-standard/PlansPage.vue +1222 -0
- package/src/pages-standard/PlatformEmailPage.vue +434 -0
- package/src/pages-standard/PromoCodeDetailPage.vue +331 -0
- package/src/pages-standard/PromoCodesPage.vue +589 -0
- package/src/pages-standard/SubscriptionsPage.vue +159 -0
- package/src/pages-standard/SuperAdminLoginPage.vue +299 -0
- package/src/pages-standard/SuperAdminSetupWizard.vue +405 -0
- package/src/pages-standard/TenantDetailPage.vue +432 -0
- package/src/pages-standard/TenantsPage.vue +732 -0
- package/src/pages-standard/UsersPage.vue +504 -0
- package/src/pages-standard/bundles-page/BundleAccordionList.vue +116 -0
- package/src/pages-standard/bundles-page/BundleDetailPanel.vue +193 -0
- package/src/pages-standard/bundles-page/BundlesFilterBar.vue +48 -0
- package/src/pages-standard/bundles-page/BundlesHeader.vue +67 -0
- package/src/pages-standard/bundles-page/BundlesKpis.vue +39 -0
- package/src/pages-standard/bundles-page/types.ts +15 -0
- package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +246 -0
- package/src/pages-standard/discovery-page/DiscoveryCapList.vue +171 -0
- package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +410 -0
- package/src/pages-standard/discovery-page/DiscoveryHeader.vue +31 -0
- package/src/pages-standard/discovery-page/DiscoveryKpis.vue +47 -0
- package/src/pages-standard/discovery-page/DiscoveryMetaBanner.vue +26 -0
- package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +247 -0
- package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +73 -0
- package/src/pages-standard/discovery-page/discovery-ui.ts +166 -0
- package/src/pages-standard/email-history.types.ts +48 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +448 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +93 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +138 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +58 -0
- package/src/pages-standard/marketing-catalog/types.ts +43 -0
- package/src/pages-standard/plan-versions/PlanDiffCard.vue +132 -0
- package/src/pages-standard/plan-versions/PlanVersionHeader.vue +293 -0
- package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +48 -0
- package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +307 -0
- package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +269 -0
- package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +65 -0
- package/src/pages-standard/plan-versions/PlanVersionsList.vue +292 -0
- package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +261 -0
- package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +300 -0
- package/src/pages-standard/plan-versions/VersionDiffPreview.vue +167 -0
- package/src/pages-standard/plan-versions/format.ts +53 -0
- package/src/pages-standard/plan-versions/types.ts +33 -0
- package/src/pages-standard/plans-page/PlanArchiveDialog.vue +50 -0
- package/src/pages-standard/plans-page/PlanBundleOverview.vue +147 -0
- package/src/pages-standard/plans-page/PlanDiscardDraftDialog.vue +46 -0
- package/src/pages-standard/plans-page/PlanPublishDialog.vue +106 -0
- package/src/pages-standard/plans-page/PlansPageToast.vue +69 -0
- package/src/pages-standard/plans-page/types.ts +18 -0
- package/src/pages-standard/platform-email.types.ts +41 -0
- package/src/pages-standard/sa-theme.css +223 -0
- package/src/pages-standard/tenants/StatusPill.vue +62 -0
- package/src/pages-standard/tenants/format.ts +38 -0
- package/src/pages-tenant/LimitsRow.vue +67 -0
- package/src/pages-tenant/MySubscriptionBundlesPage.vue +604 -0
- package/src/pages-tenant/OnboardingConfigurator.vue +417 -0
- package/src/pages-tenant/PackageSnapshotPanel.vue +241 -0
- package/src/pages-tenant/PendingVersionBanner.vue +126 -0
- package/src/pages-tenant/PlanChangeWizard.vue +630 -0
- package/src/pages-tenant/TenantPlanSection.vue +729 -0
- package/src/pages-tenant/UsageBar.vue +107 -0
- package/src/pages-tenant/default-i18n.ts +280 -0
- package/src/pages-tenant/tenant-plan-section/BundlePreviewDialog.vue +304 -0
- package/src/pages-tenant/tenant-plan-section/TenantBundleStore.vue +323 -0
- package/src/pages-tenant/tenant-plan-section/TenantFeatureMatrix.vue +122 -0
- package/src/pages-tenant/tenant-plan-section/TenantPlanCardHeader.vue +67 -0
- package/src/pages-tenant/tenant-plan-section/TenantUsageGrid.vue +31 -0
- package/src/plan-versions-catalog.ts +368 -0
- package/src/platform-loaders.ts +68 -0
- package/src/project-page-host.ts +108 -0
- package/src/testing-e2e/admin-pages-suite.ts +187 -0
- package/src/types.ts +58 -0
- package/src/use-actions.ts +25 -0
- package/src/use-api-list.ts +151 -0
- package/src/use-audit-entries.ts +48 -0
- package/src/use-batch-columns.ts +59 -0
- package/src/use-bulk-publish.ts +150 -0
- package/src/use-bundle-versions-map.ts +112 -0
- package/src/use-bundles.ts +297 -0
- package/src/use-business-types.ts +269 -0
- package/src/use-catalog-entries.ts +243 -0
- package/src/use-discovery.ts +158 -0
- package/src/use-entitlement.ts +87 -0
- package/src/use-live-plan-versions.ts +126 -0
- package/src/use-manifest.ts +57 -0
- package/src/use-marketing-projections.ts +160 -0
- package/src/use-nav.ts +33 -0
- package/src/use-plan-editor.ts +162 -0
- package/src/use-plan-versions.ts +83 -0
- package/src/use-plans.ts +375 -0
- package/src/use-platform-tenant-actions.ts +286 -0
- package/src/use-promotions.ts +126 -0
- package/src/use-public-boot.ts +40 -0
- package/src/use-subscription-draft.ts +355 -0
- package/src/use-super-admin-context.ts +93 -0
- package/src/use-tenant-action-flow.ts +238 -0
- package/src/use-tenant-billing-catalog.ts +160 -0
- package/src/use-tenant-billing.ts +453 -0
- package/src/use-tenant-manifest.ts +94 -0
- package/src/use-tenant-subscription-bundles.ts +148 -0
- package/src/use-tenants.ts +61 -0
- package/src/version.ts +10 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// Catalog snapshot builder — projects PlanVersion rows onto synthetic
|
|
2
|
+
// catalog snapshots that the plan-versions UI renders.
|
|
3
|
+
//
|
|
4
|
+
// The backend knows no catalog snapshots; we derive them from the real
|
|
5
|
+
// `publishedAt` / `supersededAt` events. Three snapshot kinds:
|
|
6
|
+
//
|
|
7
|
+
// - `drafts` : Hypothetical "after publishing all open drafts" state
|
|
8
|
+
// - `active` : Currently live (publishedAt set, supersededAt null)
|
|
9
|
+
// - `historical` : One snapshot per publish event, before `active`
|
|
10
|
+
//
|
|
11
|
+
// Plan IDs are sorted alphabetically; consumers may enforce a preferred
|
|
12
|
+
// order via `planSortOrder` (e.g.
|
|
13
|
+
// BASIC < STANDARD < PROFESSIONAL < BUSINESS < ENTERPRISE).
|
|
14
|
+
//
|
|
15
|
+
// Phase 2b: ported from a consumer admin,
|
|
16
|
+
// app-specific fields kept open via generics.
|
|
17
|
+
|
|
18
|
+
import type { PlanVersionRow } from '@saasicat/types';
|
|
19
|
+
|
|
20
|
+
export type SnapshotKind = 'drafts' | 'active' | 'historical';
|
|
21
|
+
|
|
22
|
+
export interface CatalogSnapshot<P extends PlanVersionRow = PlanVersionRow> {
|
|
23
|
+
id: string;
|
|
24
|
+
kind: SnapshotKind;
|
|
25
|
+
status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
|
|
26
|
+
label: string;
|
|
27
|
+
title: string;
|
|
28
|
+
description: string;
|
|
29
|
+
|
|
30
|
+
asOf: string | null;
|
|
31
|
+
createdAt: string | null;
|
|
32
|
+
publishedAt: string | null;
|
|
33
|
+
authorEmail: string | null;
|
|
34
|
+
|
|
35
|
+
plans: ResolvedPlan<P>[];
|
|
36
|
+
|
|
37
|
+
/** Number of open drafts for the `drafts` snapshot, otherwise 0. */
|
|
38
|
+
draftCount: number;
|
|
39
|
+
/** Number of entities in this snapshot whose publication flagged at least
|
|
40
|
+
* one regression (`nonRegressive === false`). */
|
|
41
|
+
regressionCount: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ResolvedPlan<P extends PlanVersionRow = PlanVersionRow> {
|
|
45
|
+
/** Which PlanVersionRow was selected to represent this slot. */
|
|
46
|
+
source: P;
|
|
47
|
+
/** Live predecessor (only set when `source` is a DRAFT; otherwise null). */
|
|
48
|
+
liveBase: P | null;
|
|
49
|
+
isDraft: boolean;
|
|
50
|
+
planId: string;
|
|
51
|
+
features: string[];
|
|
52
|
+
/** Quota map (from `source.quotas` or legacy fields; empty if none). */
|
|
53
|
+
quotas: Record<string, number>;
|
|
54
|
+
monthlyNet: number;
|
|
55
|
+
yearlyNet: number;
|
|
56
|
+
marketed: boolean;
|
|
57
|
+
version: number;
|
|
58
|
+
|
|
59
|
+
// Legacy quota mirrors for UI components that have not yet switched to
|
|
60
|
+
// `quotas[key]`. Mirrored from `quotas['users'] / 'vehicles' / 'storageGb'`;
|
|
61
|
+
// for apps without these quotas they stay undefined.
|
|
62
|
+
/** @deprecated Read from `quotas['users']`. */
|
|
63
|
+
maxUsers?: number;
|
|
64
|
+
/** @deprecated Legacy field; read from `quotas['vehicles']`. */
|
|
65
|
+
maxVehicles?: number;
|
|
66
|
+
/** @deprecated Read from `quotas['storageGb']`. */
|
|
67
|
+
maxStorageGb?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface RawCatalogData<P extends PlanVersionRow = PlanVersionRow> {
|
|
71
|
+
planVersions: P[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface BuildSnapshotsOptions {
|
|
75
|
+
/**
|
|
76
|
+
* App-specific plan order, e.g. `['BASIC', 'STANDARD',
|
|
77
|
+
* 'PROFESSIONAL', 'BUSINESS', 'ENTERPRISE']`. Plan IDs outside the list
|
|
78
|
+
* end up sorted alphabetically at the back.
|
|
79
|
+
*/
|
|
80
|
+
planSortOrder?: readonly string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
const dec = (s: string): number => Number(s);
|
|
86
|
+
|
|
87
|
+
function extractQuotas(p: PlanVersionRow): Record<string, number> {
|
|
88
|
+
if (p.quotas && Object.keys(p.quotas).length > 0) {
|
|
89
|
+
return { ...p.quotas };
|
|
90
|
+
}
|
|
91
|
+
// Legacy fallback: collect the flat fields.
|
|
92
|
+
const out: Record<string, number> = {};
|
|
93
|
+
if (typeof p.maxUsers === 'number') out.users = p.maxUsers;
|
|
94
|
+
if (typeof p.maxVehicles === 'number') out.vehicles = p.maxVehicles;
|
|
95
|
+
if (typeof p.maxStorageGb === 'number') out.storageGb = p.maxStorageGb;
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const sortByPublishedDesc = <T extends { publishedAt: string | null; version: number }>(
|
|
100
|
+
rows: T[],
|
|
101
|
+
): T[] =>
|
|
102
|
+
[...rows].sort((a, b) => {
|
|
103
|
+
const at = a.publishedAt ? Date.parse(a.publishedAt) : Number.POSITIVE_INFINITY;
|
|
104
|
+
const bt = b.publishedAt ? Date.parse(b.publishedAt) : Number.POSITIVE_INFINITY;
|
|
105
|
+
if (at !== bt) return bt - at;
|
|
106
|
+
return b.version - a.version;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Finds the live version valid for an entity slot at the point in time `asOf`.
|
|
111
|
+
* A version was live when `publishedAt <= asOf` and (`supersededAt == null`
|
|
112
|
+
* or `supersededAt > asOf`) held.
|
|
113
|
+
*/
|
|
114
|
+
function findLiveAt<
|
|
115
|
+
T extends { publishedAt: string | null; supersededAt: string | null; version: number },
|
|
116
|
+
>(rows: T[], asOfMs: number): T | null {
|
|
117
|
+
const candidates = rows.filter((r) => {
|
|
118
|
+
if (r.publishedAt === null) return false;
|
|
119
|
+
if (Date.parse(r.publishedAt) > asOfMs) return false;
|
|
120
|
+
if (r.supersededAt !== null && Date.parse(r.supersededAt) <= asOfMs) return false;
|
|
121
|
+
return true;
|
|
122
|
+
});
|
|
123
|
+
if (candidates.length === 0) return null;
|
|
124
|
+
return [...candidates].sort((a, b) => b.version - a.version)[0] ?? null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function findCurrentLive<T extends { publishedAt: string | null; supersededAt: string | null }>(
|
|
128
|
+
rows: T[],
|
|
129
|
+
): T | null {
|
|
130
|
+
return rows.find((r) => r.publishedAt !== null && r.supersededAt === null) ?? null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function findDraft<T extends { publishedAt: string | null }>(rows: T[]): T | null {
|
|
134
|
+
return rows.find((r) => r.publishedAt === null) ?? null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function groupBy<T, K extends string>(rows: T[], keyOf: (r: T) => K): Map<K, T[]> {
|
|
138
|
+
const out = new Map<K, T[]>();
|
|
139
|
+
for (const r of rows) {
|
|
140
|
+
const k = keyOf(r);
|
|
141
|
+
const list = out.get(k) ?? [];
|
|
142
|
+
list.push(r);
|
|
143
|
+
out.set(k, list);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Resolver ────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function resolvePlan<P extends PlanVersionRow>(source: P, liveBase: P | null): ResolvedPlan<P> {
|
|
151
|
+
const quotas = extractQuotas(source);
|
|
152
|
+
return {
|
|
153
|
+
source,
|
|
154
|
+
liveBase,
|
|
155
|
+
isDraft: source.publishedAt === null,
|
|
156
|
+
planId: source.planId,
|
|
157
|
+
features: source.features,
|
|
158
|
+
quotas,
|
|
159
|
+
monthlyNet: dec(source.monthlyNet),
|
|
160
|
+
yearlyNet: dec(source.yearlyNet),
|
|
161
|
+
marketed: source.marketed,
|
|
162
|
+
version: source.version,
|
|
163
|
+
maxUsers: quotas.users,
|
|
164
|
+
maxVehicles: quotas.vehicles,
|
|
165
|
+
maxStorageGb: quotas.storageGb,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── Snapshot constructors ───────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
function dateLabelDe(iso: string): string {
|
|
172
|
+
return new Date(iso).toLocaleDateString('de-DE', {
|
|
173
|
+
day: '2-digit',
|
|
174
|
+
month: 'short',
|
|
175
|
+
year: 'numeric',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function buildDraftsSnapshot<P extends PlanVersionRow>(
|
|
180
|
+
data: RawCatalogData<P>,
|
|
181
|
+
sortPlansFn: (rows: ResolvedPlan<P>[]) => ResolvedPlan<P>[],
|
|
182
|
+
): CatalogSnapshot<P> {
|
|
183
|
+
const planByKey = groupBy(data.planVersions, (r) => r.planId);
|
|
184
|
+
|
|
185
|
+
const plans: ResolvedPlan<P>[] = [];
|
|
186
|
+
for (const [, rows] of planByKey) {
|
|
187
|
+
const draft = findDraft(rows);
|
|
188
|
+
const live = findCurrentLive(rows);
|
|
189
|
+
if (draft) plans.push(resolvePlan(draft, live));
|
|
190
|
+
else if (live) plans.push(resolvePlan(live, null));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const draftCount = plans.filter((p) => p.isDraft).length;
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
id: 'drafts',
|
|
197
|
+
kind: 'drafts',
|
|
198
|
+
status: 'DRAFT',
|
|
199
|
+
label: 'Arbeitsstand',
|
|
200
|
+
title:
|
|
201
|
+
draftCount === 0
|
|
202
|
+
? 'Keine offenen Drafts'
|
|
203
|
+
: `${draftCount} offene${draftCount === 1 ? 'r' : ''} Draft${draftCount === 1 ? '' : 's'} bereit für Publish`,
|
|
204
|
+
description:
|
|
205
|
+
draftCount === 0
|
|
206
|
+
? 'Lege einen Draft an, um Limits, Preise oder Features zu ändern. Drafts sind ohne MFA editierbar und werden erst beim Publish wirksam.'
|
|
207
|
+
: 'Diese Tabelle zeigt, wie der Catalog nach Publish aller Drafts aussehen würde. Bestehende Mandanten bleiben auf der vorherigen Version, bis sie aktiv migriert werden.',
|
|
208
|
+
asOf: null,
|
|
209
|
+
createdAt: null,
|
|
210
|
+
publishedAt: null,
|
|
211
|
+
authorEmail: null,
|
|
212
|
+
plans: sortPlansFn(plans),
|
|
213
|
+
draftCount,
|
|
214
|
+
regressionCount: 0,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function buildActiveSnapshot<P extends PlanVersionRow>(
|
|
219
|
+
data: RawCatalogData<P>,
|
|
220
|
+
sortPlansFn: (rows: ResolvedPlan<P>[]) => ResolvedPlan<P>[],
|
|
221
|
+
): CatalogSnapshot<P> | null {
|
|
222
|
+
const planByKey = groupBy(data.planVersions, (r) => r.planId);
|
|
223
|
+
|
|
224
|
+
const plans: ResolvedPlan<P>[] = [];
|
|
225
|
+
for (const [, rows] of planByKey) {
|
|
226
|
+
const live = findCurrentLive(rows);
|
|
227
|
+
if (live) plans.push(resolvePlan(live, null));
|
|
228
|
+
}
|
|
229
|
+
if (plans.length === 0) return null;
|
|
230
|
+
|
|
231
|
+
const publishedDates = plans
|
|
232
|
+
.map((p) => p.source.publishedAt)
|
|
233
|
+
.filter((t): t is string => t !== null)
|
|
234
|
+
.sort();
|
|
235
|
+
const latestPublished = publishedDates[publishedDates.length - 1];
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
id: 'active',
|
|
239
|
+
kind: 'active',
|
|
240
|
+
status: 'ACTIVE',
|
|
241
|
+
label: 'Aktuell',
|
|
242
|
+
title: 'Live-Catalog',
|
|
243
|
+
description:
|
|
244
|
+
'Diese Plan-Versionen sind aktuell für neue Onboardings und für Renewals nach Stichtag aktiv.',
|
|
245
|
+
asOf: latestPublished ?? null,
|
|
246
|
+
createdAt: null,
|
|
247
|
+
publishedAt: latestPublished ?? null,
|
|
248
|
+
authorEmail: null,
|
|
249
|
+
plans: sortPlansFn(plans),
|
|
250
|
+
draftCount: 0,
|
|
251
|
+
regressionCount: 0,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function buildHistoricalSnapshots<P extends PlanVersionRow>(
|
|
256
|
+
data: RawCatalogData<P>,
|
|
257
|
+
sortPlansFn: (rows: ResolvedPlan<P>[]) => ResolvedPlan<P>[],
|
|
258
|
+
): CatalogSnapshot<P>[] {
|
|
259
|
+
const allPublishedAt = new Set<string>();
|
|
260
|
+
for (const r of data.planVersions) if (r.publishedAt) allPublishedAt.add(r.publishedAt);
|
|
261
|
+
if (allPublishedAt.size === 0) return [];
|
|
262
|
+
|
|
263
|
+
const planByKey = groupBy(data.planVersions, (r) => r.planId);
|
|
264
|
+
|
|
265
|
+
// The active snapshot overlaps with the last publish event. We take the
|
|
266
|
+
// latest one in `active`, and map only the events *before* it here.
|
|
267
|
+
const liveTimestamps = new Set<string>();
|
|
268
|
+
for (const rows of planByKey.values()) {
|
|
269
|
+
const live = findCurrentLive(rows);
|
|
270
|
+
if (live?.publishedAt) liveTimestamps.add(live.publishedAt);
|
|
271
|
+
}
|
|
272
|
+
const liveSorted = [...liveTimestamps].sort();
|
|
273
|
+
const latestLive = liveSorted[liveSorted.length - 1];
|
|
274
|
+
|
|
275
|
+
const sortedDesc = [...allPublishedAt].sort((a, b) => Date.parse(b) - Date.parse(a));
|
|
276
|
+
|
|
277
|
+
const out: CatalogSnapshot<P>[] = [];
|
|
278
|
+
for (const ts of sortedDesc) {
|
|
279
|
+
if (latestLive && Date.parse(ts) >= Date.parse(latestLive)) continue;
|
|
280
|
+
const tMs = Date.parse(ts) + 1; // *after* the event
|
|
281
|
+
const plans: ResolvedPlan<P>[] = [];
|
|
282
|
+
for (const [, rows] of planByKey) {
|
|
283
|
+
const live = findLiveAt(rows, tMs);
|
|
284
|
+
if (live) plans.push(resolvePlan(live, null));
|
|
285
|
+
}
|
|
286
|
+
if (plans.length === 0) continue;
|
|
287
|
+
|
|
288
|
+
const publishedAtThisEvent: P[] = data.planVersions.filter((r) => r.publishedAt === ts);
|
|
289
|
+
const regressionCount = publishedAtThisEvent.filter((r) => !r.nonRegressive).length;
|
|
290
|
+
const author = publishedAtThisEvent[0]?.publishedByUserId ?? null;
|
|
291
|
+
|
|
292
|
+
out.push({
|
|
293
|
+
id: `hist-${ts}`,
|
|
294
|
+
kind: 'historical',
|
|
295
|
+
status: 'ARCHIVED',
|
|
296
|
+
label: dateLabelDe(ts),
|
|
297
|
+
title: summarizeEvent(publishedAtThisEvent),
|
|
298
|
+
description: detailEvent(publishedAtThisEvent),
|
|
299
|
+
asOf: ts,
|
|
300
|
+
createdAt: ts,
|
|
301
|
+
publishedAt: ts,
|
|
302
|
+
authorEmail: author,
|
|
303
|
+
plans: sortPlansFn(plans),
|
|
304
|
+
draftCount: 0,
|
|
305
|
+
regressionCount,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function summarizeEvent(rows: PlanVersionRow[]): string {
|
|
312
|
+
if (rows.length === 0) return 'Publish-Event';
|
|
313
|
+
if (rows.length === 1) {
|
|
314
|
+
const r = rows[0]!;
|
|
315
|
+
return `${r.planId} v${r.version} publiziert`;
|
|
316
|
+
}
|
|
317
|
+
return `${rows.length} Versionen publiziert`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function detailEvent(rows: PlanVersionRow[]): string {
|
|
321
|
+
if (rows.length === 0) return '';
|
|
322
|
+
const note = rows[0]?.changeNote ?? '';
|
|
323
|
+
return note.length > 0 ? note : 'Snapshot rekonstruiert aus per-Entity-Versionen.';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ─── Sorting ─────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
function sortPlansBy<P extends PlanVersionRow>(
|
|
329
|
+
order: readonly string[] | undefined,
|
|
330
|
+
): (rows: ResolvedPlan<P>[]) => ResolvedPlan<P>[] {
|
|
331
|
+
if (!order || order.length === 0) {
|
|
332
|
+
return (rows) => [...rows].sort((a, b) => a.planId.localeCompare(b.planId));
|
|
333
|
+
}
|
|
334
|
+
const idx: Record<string, number> = {};
|
|
335
|
+
for (let i = 0; i < order.length; i++) idx[order[i]!] = i;
|
|
336
|
+
return (rows) =>
|
|
337
|
+
[...rows].sort((a, b) => {
|
|
338
|
+
const ai = idx[a.planId] ?? Number.POSITIVE_INFINITY;
|
|
339
|
+
const bi = idx[b.planId] ?? Number.POSITIVE_INFINITY;
|
|
340
|
+
if (ai !== bi) return ai - bi;
|
|
341
|
+
return a.planId.localeCompare(b.planId);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ─── Public API ──────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
export function buildSnapshots<P extends PlanVersionRow>(
|
|
348
|
+
data: RawCatalogData<P>,
|
|
349
|
+
options: BuildSnapshotsOptions = {},
|
|
350
|
+
): CatalogSnapshot<P>[] {
|
|
351
|
+
const sortPlansFn = sortPlansBy<P>(options.planSortOrder);
|
|
352
|
+
const drafts = buildDraftsSnapshot(data, sortPlansFn);
|
|
353
|
+
const active = buildActiveSnapshot(data, sortPlansFn);
|
|
354
|
+
const historical = buildHistoricalSnapshots(data, sortPlansFn);
|
|
355
|
+
|
|
356
|
+
const out: CatalogSnapshot<P>[] = [drafts];
|
|
357
|
+
if (active) out.push(active);
|
|
358
|
+
out.push(...historical);
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function listOpenDrafts<P extends PlanVersionRow>(
|
|
363
|
+
data: RawCatalogData<P>,
|
|
364
|
+
): { plans: P[] } {
|
|
365
|
+
return {
|
|
366
|
+
plans: sortByPublishedDesc(data.planVersions).filter((r) => r.publishedAt === null),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// createPlatformLoaders — factory that builds `BootLoader` + `ManifestLoader`
|
|
2
|
+
// from the same `SuperAdminEndpoints` shape that `createSuperAdminApp()` also
|
|
3
|
+
// consumes. This keeps the endpoint configuration (`apiBase`,
|
|
4
|
+
// `publicBootEndpoint`, `manifestEndpoint`) in exactly one place per app:
|
|
5
|
+
// a single constant that `main.ts` passes to `createSuperAdminApp()` and that
|
|
6
|
+
// the loader wiring from this helper consumes.
|
|
7
|
+
|
|
8
|
+
import { BootLoader } from './boot-loader.js';
|
|
9
|
+
import { ManifestLoader } from './manifest-loader.js';
|
|
10
|
+
import type { SuperAdminEndpoints } from './create-super-admin-app.js';
|
|
11
|
+
import type { HttpClient, KvStore } from './types.js';
|
|
12
|
+
|
|
13
|
+
export interface CreatePlatformLoadersOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Same endpoint configuration as for `createSuperAdminApp()`.
|
|
16
|
+
* `publicBootEndpoint` / `manifestEndpoint` are derived from `apiBase`
|
|
17
|
+
* when not set explicitly.
|
|
18
|
+
*/
|
|
19
|
+
endpoints: SuperAdminEndpoints;
|
|
20
|
+
/** HTTP adapter. App-specific, because auth-header / base-URL conventions vary. */
|
|
21
|
+
http: HttpClient;
|
|
22
|
+
/** Storage for the `ManifestLoader` ETag cache. Defaults to `defaultKvStore()`. */
|
|
23
|
+
storage?: KvStore;
|
|
24
|
+
/**
|
|
25
|
+
* Storage key prefix — consumers with multiple apps under one domain
|
|
26
|
+
* set this to e.g. `'ma:'` or `'da:'` so the caches stay separate.
|
|
27
|
+
* Only forwarded to the `ManifestLoader`.
|
|
28
|
+
*/
|
|
29
|
+
storageKeyPrefix?: string;
|
|
30
|
+
/** Auth-token provider for `Authorization: Bearer …` (ManifestLoader only). */
|
|
31
|
+
getAuthToken?: () => string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PlatformLoaders {
|
|
35
|
+
bootLoader: BootLoader;
|
|
36
|
+
manifestLoader: ManifestLoader;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveEndpoints(endpoints: SuperAdminEndpoints): Required<SuperAdminEndpoints> {
|
|
40
|
+
return {
|
|
41
|
+
apiBase: endpoints.apiBase,
|
|
42
|
+
publicBootEndpoint: endpoints.publicBootEndpoint ?? `${endpoints.apiBase}/boot`,
|
|
43
|
+
manifestEndpoint: endpoints.manifestEndpoint ?? `${endpoints.apiBase}/manifest`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Builds `BootLoader` + `ManifestLoader` from a single endpoint constant.
|
|
49
|
+
* Apps use the same constant for `createSuperAdminApp({ endpoints })`.
|
|
50
|
+
*/
|
|
51
|
+
export function createPlatformLoaders(options: CreatePlatformLoadersOptions): PlatformLoaders {
|
|
52
|
+
const resolved = resolveEndpoints(options.endpoints);
|
|
53
|
+
|
|
54
|
+
const bootLoader = new BootLoader({
|
|
55
|
+
endpoint: resolved.publicBootEndpoint,
|
|
56
|
+
http: options.http,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const manifestLoader = new ManifestLoader({
|
|
60
|
+
endpoint: resolved.manifestEndpoint,
|
|
61
|
+
http: options.http,
|
|
62
|
+
storage: options.storage,
|
|
63
|
+
storageKeyPrefix: options.storageKeyPrefix,
|
|
64
|
+
getAuthToken: options.getAuthToken,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return { bootLoader, manifestLoader };
|
|
68
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// ProjectPageHost — universal host for manifest `projectPages`. Apps
|
|
2
|
+
// register a catch-all child route under their `/admin` layout; the
|
|
3
|
+
// host finds the ProjectPage in the manifest matching the current URL, resolves
|
|
4
|
+
// `componentKey` against the `extensions:` map (via
|
|
5
|
+
// `useSuperAdminExtensions()`) and renders the component.
|
|
6
|
+
//
|
|
7
|
+
// This means consumers don't have to additionally duplicate project pages
|
|
8
|
+
// statically in the router — manifest + extensions: map are the single source of truth.
|
|
9
|
+
|
|
10
|
+
import { computed, defineAsyncComponent, defineComponent, h, inject, type Component } from 'vue';
|
|
11
|
+
import { useRoute, type RouteRecordRaw } from 'vue-router';
|
|
12
|
+
import type { AdminManifest, ProjectPageDef } from '@saasicat/types';
|
|
13
|
+
import {
|
|
14
|
+
SUPER_ADMIN_EXTENSIONS_KEY,
|
|
15
|
+
SUPER_ADMIN_MANIFEST_KEY,
|
|
16
|
+
type ExtensionsMap,
|
|
17
|
+
} from './create-super-admin-app.js';
|
|
18
|
+
|
|
19
|
+
// `defineAsyncComponent` gets its own wrapper component per loader
|
|
20
|
+
// function — without a cache, re-renders would recreate the same component
|
|
21
|
+
// and trigger the async resolve every time.
|
|
22
|
+
const asyncComponentCache = new WeakMap<ExtensionsMap, Map<string, Component>>();
|
|
23
|
+
|
|
24
|
+
function resolveAsync(extensions: ExtensionsMap, key: string): Component | null {
|
|
25
|
+
let bucket = asyncComponentCache.get(extensions);
|
|
26
|
+
if (!bucket) {
|
|
27
|
+
bucket = new Map();
|
|
28
|
+
asyncComponentCache.set(extensions, bucket);
|
|
29
|
+
}
|
|
30
|
+
const cached = bucket.get(key);
|
|
31
|
+
if (cached) return cached;
|
|
32
|
+
const loader = extensions[key];
|
|
33
|
+
if (!loader) return null;
|
|
34
|
+
const cmp = defineAsyncComponent(loader);
|
|
35
|
+
bucket.set(key, cmp);
|
|
36
|
+
return cmp;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const ProjectPageHost = defineComponent({
|
|
40
|
+
name: 'ProjectPageHost',
|
|
41
|
+
setup() {
|
|
42
|
+
const route = useRoute();
|
|
43
|
+
const extensions = inject(SUPER_ADMIN_EXTENSIONS_KEY, {} as ExtensionsMap);
|
|
44
|
+
const manifestAccessor = inject(SUPER_ADMIN_MANIFEST_KEY, null);
|
|
45
|
+
|
|
46
|
+
const matchingPage = computed<ProjectPageDef | null>(() => {
|
|
47
|
+
const manifest: AdminManifest | null = manifestAccessor ? manifestAccessor() : null;
|
|
48
|
+
if (!manifest) return null;
|
|
49
|
+
const pages = manifest.navigation?.projectPages ?? [];
|
|
50
|
+
return pages.find((p) => p.route === route.path) ?? null;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const resolvedComponent = computed<Component | null>(() => {
|
|
54
|
+
const page = matchingPage.value;
|
|
55
|
+
if (!page) return null;
|
|
56
|
+
return resolveAsync(extensions, page.componentKey);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return () => {
|
|
60
|
+
const page = matchingPage.value;
|
|
61
|
+
const cmp = resolvedComponent.value;
|
|
62
|
+
if (page && cmp) return h(cmp);
|
|
63
|
+
return h('div', { class: 'sa-project-page-host__missing', role: 'alert' }, [
|
|
64
|
+
h('h2', 'Seite nicht verfügbar'),
|
|
65
|
+
h(
|
|
66
|
+
'p',
|
|
67
|
+
page
|
|
68
|
+
? `componentKey "${page.componentKey}" ist nicht in der extensions:-Map registriert. Apps müssen die Komponente in createSuperAdminApp({ extensions }) eintragen.`
|
|
69
|
+
: `Keine Manifest-Definition für ${route.path}. Entweder fehlt die ProjectPage im Manifest, oder die Capability ist nicht aktiv.`,
|
|
70
|
+
),
|
|
71
|
+
]);
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns a Vue Router child route that is registered as a catch-all under
|
|
78
|
+
* the app's `/admin` layout. Children defined statically in the app router
|
|
79
|
+
* (e.g. `/admin/dashboard`) win, because
|
|
80
|
+
* Vue Router 4 matches specific routes before wildcard children.
|
|
81
|
+
*
|
|
82
|
+
* Example (simplified):
|
|
83
|
+
*
|
|
84
|
+
* {
|
|
85
|
+
* path: '/admin',
|
|
86
|
+
* component: AdminLayout,
|
|
87
|
+
* children: [
|
|
88
|
+
* { path: '', redirect: '/admin/dashboard' },
|
|
89
|
+
* { path: 'dashboard', component: DashboardPage },
|
|
90
|
+
* // ...more static children
|
|
91
|
+
* createProjectPageHostRoute(),
|
|
92
|
+
* ],
|
|
93
|
+
* }
|
|
94
|
+
*/
|
|
95
|
+
export function createProjectPageHostRoute(options?: {
|
|
96
|
+
/**
|
|
97
|
+
* Path pattern of the catch-all route. Default `:projectPagePath(.+)`, so
|
|
98
|
+
* that `/admin` and `/admin/` are not swallowed by the ProjectPageHost,
|
|
99
|
+
* but the dashboard redirect child can take effect.
|
|
100
|
+
*/
|
|
101
|
+
path?: string;
|
|
102
|
+
}): RouteRecordRaw {
|
|
103
|
+
return {
|
|
104
|
+
path: options?.path ?? ':projectPagePath(.+)',
|
|
105
|
+
component: ProjectPageHost,
|
|
106
|
+
meta: { isProjectPageHost: true },
|
|
107
|
+
};
|
|
108
|
+
}
|