@saasicat/ui-vue 0.7.0 → 0.9.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/dist/{chunk-A3W2N26K.js → chunk-66BUSVC5.js} +68 -217
- package/dist/{chunk-X5SQ5HMB.js → chunk-TBLXKSTR.js} +0 -137
- package/dist/{chunk-QZOSDCNP.js → chunk-TY2WYKME.js} +1 -1
- package/dist/client/index.cjs +180 -463
- package/dist/client/index.d.cts +32 -57
- package/dist/client/index.d.ts +32 -57
- package/dist/client/index.js +4 -6
- package/dist/index.cjs +273 -580
- package/dist/index.d.cts +10 -55
- package/dist/index.d.ts +10 -55
- package/dist/index.js +76 -101
- package/dist/{messages-7b0P8W75.d.ts → messages-BIYw32u1.d.cts} +0 -67
- package/dist/{messages-7b0P8W75.d.cts → messages-BIYw32u1.d.ts} +0 -67
- package/dist/quasar/index.cjs +0 -136
- package/dist/quasar/index.d.cts +2 -2
- package/dist/quasar/index.d.ts +2 -2
- package/dist/quasar/index.js +2 -2
- package/dist/{use-super-admin-i18n-B3v3-SWZ.d.ts → use-super-admin-i18n-CWNu_W3u.d.ts} +1 -1
- package/dist/{use-super-admin-i18n-DMktRVEt.d.cts → use-super-admin-i18n-DL_qI1BY.d.cts} +1 -1
- package/package.json +5 -5
- package/src/client/admin-resource-client.ts +112 -0
- package/src/client/i18n/messages/nav.ts +0 -2
- package/src/client/i18n/messages/plan-versions.ts +0 -143
- package/src/client/index.ts +1 -2
- package/src/client/nav-builder.ts +9 -4
- package/src/index.ts +0 -1
- package/src/pages-standard/AdminLayout.vue +1 -1
- package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +1 -1
- package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +1 -1
- package/src/pages-standard/plan-versions/catalog-history.types.ts +37 -0
- package/src/pages-standard/plan-versions/format.ts +2 -38
- package/src/client/plan-versions-catalog.ts +0 -380
- package/src/pages-standard/PlanVersionsPage.vue +0 -220
- package/src/pages-standard/plan-versions/PlanVersionHeader.vue +0 -309
- package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +0 -48
- package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +0 -326
- package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +0 -65
- package/src/pages-standard/plan-versions/PlanVersionsList.vue +0 -315
- package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +0 -261
- package/src/pages-standard/plan-versions/types.ts +0 -33
- package/src/vue/use-plan-versions.ts +0 -83
|
@@ -25,7 +25,6 @@ export const DEFAULT_STANDARD_PAGE_ROUTES: Record<StandardPageKey, string> = {
|
|
|
25
25
|
subscriptions: '/admin/subscriptions',
|
|
26
26
|
promoCodes: '/admin/promo-codes',
|
|
27
27
|
plans: '/admin/plans',
|
|
28
|
-
planVersions: '/admin/plan-versions',
|
|
29
28
|
audit: '/admin/audit',
|
|
30
29
|
users: '/admin/users',
|
|
31
30
|
pilots: '/admin/pilots',
|
|
@@ -36,6 +35,10 @@ export const DEFAULT_STANDARD_PAGE_ROUTES: Record<StandardPageKey, string> = {
|
|
|
36
35
|
platformEmailHistory: '/admin/platform-email-history',
|
|
37
36
|
};
|
|
38
37
|
|
|
38
|
+
function isSupportedStandardPageKey(key: string): key is StandardPageKey {
|
|
39
|
+
return Object.hasOwn(DEFAULT_STANDARD_PAGE_ROUTES, key);
|
|
40
|
+
}
|
|
41
|
+
|
|
39
42
|
export interface BuildRouteEntry {
|
|
40
43
|
/** Stable identifier — the key for standard pages, the `id` for project pages. */
|
|
41
44
|
id: string;
|
|
@@ -92,7 +95,6 @@ const DEFAULT_ICONS: Record<StandardPageKey, string> = {
|
|
|
92
95
|
subscriptions: 'card_membership',
|
|
93
96
|
promoCodes: 'local_activity',
|
|
94
97
|
plans: 'workspace_premium',
|
|
95
|
-
planVersions: 'history_edu',
|
|
96
98
|
audit: 'history',
|
|
97
99
|
users: 'people',
|
|
98
100
|
pilots: 'flight_takeoff',
|
|
@@ -110,7 +112,6 @@ const PAGE_SECTIONS: Partial<Record<StandardPageKey, SectionKey>> = {
|
|
|
110
112
|
discovery: 'catalog',
|
|
111
113
|
bundles: 'catalog',
|
|
112
114
|
plans: 'catalog',
|
|
113
|
-
planVersions: 'catalog',
|
|
114
115
|
marketingCatalog: 'catalog',
|
|
115
116
|
promoCodes: 'catalog',
|
|
116
117
|
tenants: 'customers',
|
|
@@ -157,7 +158,11 @@ export function buildRoutes(
|
|
|
157
158
|
const overrideIcons = { ...DEFAULT_ICONS, ...options.standardPageIcons };
|
|
158
159
|
const overrideNavSection = { ...defaultNavSections, ...options.standardPageNavSection };
|
|
159
160
|
|
|
160
|
-
for (const key of Object.keys(standard)
|
|
161
|
+
for (const key of Object.keys(standard)) {
|
|
162
|
+
// Runtime manifests can come from an older/newer backend or the ETag
|
|
163
|
+
// cache. Ignore standard pages this UI build does not know instead of
|
|
164
|
+
// emitting navigation entries with undefined path/label/icon values.
|
|
165
|
+
if (!isSupportedStandardPageKey(key)) continue;
|
|
161
166
|
const def = standard[key] as StandardPageDef | undefined;
|
|
162
167
|
if (!def?.enabled) continue;
|
|
163
168
|
const cap = def.requiredCapability ?? null;
|
package/src/index.ts
CHANGED
|
@@ -50,7 +50,6 @@ export * from './vue/feature-router-guard.js';
|
|
|
50
50
|
export * from './vue/use-tenant-billing-catalog.js';
|
|
51
51
|
export * from './vue/use-tenant-billing.js';
|
|
52
52
|
export * from './vue/use-subscription-draft.js';
|
|
53
|
-
export * from './vue/use-plan-versions.js';
|
|
54
53
|
export * from './vue/use-bulk-publish.js';
|
|
55
54
|
export * from './vue/use-plan-editor.js';
|
|
56
55
|
export * from './vue/use-public-boot.js';
|
|
@@ -112,7 +112,7 @@ import { ADMIN_UI_VERSION } from '../client/version.js';
|
|
|
112
112
|
// - `manifest` : loaded AdminManifest (or null before auth)
|
|
113
113
|
// - `staticNavFallback` : sidebar before manifest load (no flicker)
|
|
114
114
|
// - `localItems` : additional sidebar items not present in the
|
|
115
|
-
// manifest
|
|
115
|
+
// manifest
|
|
116
116
|
// - `availableExtensions` : set for ProjectPage capability filter
|
|
117
117
|
// - `standardPageRoutes`: override per StandardPageKey (e.g.
|
|
118
118
|
// `dashboard` → '/admin/' instead of '/admin/dashboard')
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
<script setup lang="ts">
|
|
45
45
|
import { computed } from 'vue';
|
|
46
46
|
import type { VersionChange } from '@saasicat/types';
|
|
47
|
-
import type { CatalogSnapshot, ResolvedPlan } from '../../client/plan-versions-catalog.js';
|
|
48
47
|
import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
|
|
48
|
+
import type { CatalogSnapshot, ResolvedPlan } from './catalog-history.types.js';
|
|
49
49
|
import PlanDiffCard from './PlanDiffCard.vue';
|
|
50
50
|
|
|
51
51
|
interface SnapshotEntityDiff {
|
|
@@ -72,9 +72,9 @@
|
|
|
72
72
|
|
|
73
73
|
<script setup lang="ts">
|
|
74
74
|
import { computed } from 'vue';
|
|
75
|
-
import type { CatalogSnapshot } from '../../client/plan-versions-catalog.js';
|
|
76
75
|
import { formatMessage } from '../../client/i18n/format.js';
|
|
77
76
|
import { useSaMessages, useSuperAdminI18n } from '../../vue/use-super-admin-i18n.js';
|
|
77
|
+
import type { CatalogSnapshot } from './catalog-history.types.js';
|
|
78
78
|
import { formatRelative } from './format.js';
|
|
79
79
|
|
|
80
80
|
const props = defineProps<{
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { PlanVersionRow } from '@saasicat/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Presentation contract retained for the reusable catalog timeline and diff
|
|
5
|
+
* components. A future Publication Archive / Catalog History page should map
|
|
6
|
+
* the immutable snapshots from saasicat#30/#35 to this shape.
|
|
7
|
+
*/
|
|
8
|
+
export type CatalogSnapshotKind = 'drafts' | 'active' | 'historical';
|
|
9
|
+
|
|
10
|
+
export interface CatalogSnapshot<P extends PlanVersionRow = PlanVersionRow> {
|
|
11
|
+
id: string;
|
|
12
|
+
kind: CatalogSnapshotKind;
|
|
13
|
+
status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
|
|
14
|
+
label: string;
|
|
15
|
+
title: string;
|
|
16
|
+
description: string;
|
|
17
|
+
asOf: string | null;
|
|
18
|
+
createdAt: string | null;
|
|
19
|
+
publishedAt: string | null;
|
|
20
|
+
authorEmail: string | null;
|
|
21
|
+
plans: ResolvedPlan<P>[];
|
|
22
|
+
draftCount: number;
|
|
23
|
+
regressionCount: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ResolvedPlan<P extends PlanVersionRow = PlanVersionRow> {
|
|
27
|
+
source: P;
|
|
28
|
+
liveBase: P | null;
|
|
29
|
+
isDraft: boolean;
|
|
30
|
+
planId: string;
|
|
31
|
+
features: string[];
|
|
32
|
+
quotas: Record<string, number>;
|
|
33
|
+
monthlyNet: number;
|
|
34
|
+
yearlyNet: number;
|
|
35
|
+
marketed: boolean;
|
|
36
|
+
version: number;
|
|
37
|
+
}
|
|
@@ -1,13 +1,7 @@
|
|
|
1
|
-
// Formatting
|
|
2
|
-
//
|
|
3
|
-
// Phase 2c: Ported from a consumer admin.
|
|
4
|
-
// The time reference point for `formatRelative` is the moment of the call,
|
|
5
|
-
// not a hard-coded demo date (the ported template had `2026-05-04` —
|
|
6
|
-
// the platform must work in production).
|
|
1
|
+
// Formatting helper retained for the reusable catalog-history timeline.
|
|
7
2
|
|
|
8
|
-
import { formatCurrency } from '../../client/i18n/currency.js';
|
|
9
3
|
import { formatMessage } from '../../client/i18n/format.js';
|
|
10
|
-
import { DEFAULT_SA_LOCALE,
|
|
4
|
+
import { DEFAULT_SA_LOCALE, type SaLocale } from '../../client/i18n/locale.js';
|
|
11
5
|
import { planVersionsMessages } from '../../client/i18n/messages/plan-versions.js';
|
|
12
6
|
|
|
13
7
|
const EMPTY_VALUE = '—';
|
|
@@ -16,17 +10,6 @@ function texts(locale: SaLocale) {
|
|
|
16
10
|
return planVersionsMessages[locale].format;
|
|
17
11
|
}
|
|
18
12
|
|
|
19
|
-
export function fmtEuro(n: number, locale: SaLocale = DEFAULT_SA_LOCALE): string {
|
|
20
|
-
if (n === 0) return texts(locale).priceIndividual;
|
|
21
|
-
return formatCurrency(n, locale);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function fmtStorage(gb: number): string {
|
|
25
|
-
if (gb === 0) return '500 MB';
|
|
26
|
-
if (gb >= 1000 && gb % 1000 === 0) return `${gb / 1000} TB`;
|
|
27
|
-
return `${gb} GB`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
13
|
export function formatRelative(
|
|
31
14
|
iso: string | undefined,
|
|
32
15
|
locale: SaLocale = DEFAULT_SA_LOCALE,
|
|
@@ -48,22 +31,3 @@ export function formatRelative(
|
|
|
48
31
|
const y = Math.floor(days / 365);
|
|
49
32
|
return formatMessage(y > 1 ? msg.yearsAgoMany : msg.yearsAgoOne, { count: y });
|
|
50
33
|
}
|
|
51
|
-
|
|
52
|
-
export function formatDate(iso: string | undefined, locale: SaLocale = DEFAULT_SA_LOCALE): string {
|
|
53
|
-
if (!iso) return EMPTY_VALUE;
|
|
54
|
-
return new Date(iso).toLocaleDateString(SA_INTL_LOCALES[locale], {
|
|
55
|
-
day: '2-digit',
|
|
56
|
-
month: 'short',
|
|
57
|
-
year: 'numeric',
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function formatTimestamp(iso: string, locale: SaLocale = DEFAULT_SA_LOCALE): string {
|
|
62
|
-
return new Date(iso).toLocaleString(SA_INTL_LOCALES[locale], {
|
|
63
|
-
day: '2-digit',
|
|
64
|
-
month: 'short',
|
|
65
|
-
year: 'numeric',
|
|
66
|
-
hour: '2-digit',
|
|
67
|
-
minute: '2-digit',
|
|
68
|
-
});
|
|
69
|
-
}
|
|
@@ -1,380 +0,0 @@
|
|
|
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
|
-
import { formatMessage } from './i18n/format.js';
|
|
21
|
-
import { DEFAULT_SA_LOCALE, SA_INTL_LOCALES, type SaLocale } from './i18n/locale.js';
|
|
22
|
-
import { planVersionsMessages } from './i18n/messages/plan-versions.js';
|
|
23
|
-
|
|
24
|
-
export type SnapshotKind = 'drafts' | 'active' | 'historical';
|
|
25
|
-
|
|
26
|
-
export interface CatalogSnapshot<P extends PlanVersionRow = PlanVersionRow> {
|
|
27
|
-
id: string;
|
|
28
|
-
kind: SnapshotKind;
|
|
29
|
-
status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
|
|
30
|
-
label: string;
|
|
31
|
-
title: string;
|
|
32
|
-
description: string;
|
|
33
|
-
|
|
34
|
-
asOf: string | null;
|
|
35
|
-
createdAt: string | null;
|
|
36
|
-
publishedAt: string | null;
|
|
37
|
-
authorEmail: string | null;
|
|
38
|
-
|
|
39
|
-
plans: ResolvedPlan<P>[];
|
|
40
|
-
|
|
41
|
-
/** Number of open drafts for the `drafts` snapshot, otherwise 0. */
|
|
42
|
-
draftCount: number;
|
|
43
|
-
/** Number of entities in this snapshot whose publication flagged at least
|
|
44
|
-
* one regression (`nonRegressive === false`). */
|
|
45
|
-
regressionCount: number;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface ResolvedPlan<P extends PlanVersionRow = PlanVersionRow> {
|
|
49
|
-
/** Which PlanVersionRow was selected to represent this slot. */
|
|
50
|
-
source: P;
|
|
51
|
-
/** Live predecessor (only set when `source` is a DRAFT; otherwise null). */
|
|
52
|
-
liveBase: P | null;
|
|
53
|
-
isDraft: boolean;
|
|
54
|
-
planId: string;
|
|
55
|
-
features: string[];
|
|
56
|
-
/** Quota map (from `source.quotas` or legacy fields; empty if none). */
|
|
57
|
-
quotas: Record<string, number>;
|
|
58
|
-
monthlyNet: number;
|
|
59
|
-
yearlyNet: number;
|
|
60
|
-
marketed: boolean;
|
|
61
|
-
version: number;
|
|
62
|
-
|
|
63
|
-
// Legacy quota mirrors for UI components that have not yet switched to
|
|
64
|
-
// `quotas[key]`. Mirrored from `quotas['users'] / 'vehicles' / 'storageGb'`;
|
|
65
|
-
// for apps without these quotas they stay undefined.
|
|
66
|
-
/** @deprecated Read from `quotas['users']`. */
|
|
67
|
-
maxUsers?: number;
|
|
68
|
-
/** @deprecated Legacy field; read from `quotas['vehicles']`. */
|
|
69
|
-
maxVehicles?: number;
|
|
70
|
-
/** @deprecated Read from `quotas['storageGb']`. */
|
|
71
|
-
maxStorageGb?: number;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export interface RawCatalogData<P extends PlanVersionRow = PlanVersionRow> {
|
|
75
|
-
planVersions: P[];
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export interface BuildSnapshotsOptions {
|
|
79
|
-
/**
|
|
80
|
-
* App-specific plan order, e.g. `['BASIC', 'STANDARD',
|
|
81
|
-
* 'PROFESSIONAL', 'BUSINESS', 'ENTERPRISE']`. Plan IDs outside the list
|
|
82
|
-
* end up sorted alphabetically at the back.
|
|
83
|
-
*/
|
|
84
|
-
planSortOrder?: readonly string[];
|
|
85
|
-
/** UI locale for the generated snapshot labels/titles/descriptions. */
|
|
86
|
-
locale?: SaLocale;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
90
|
-
|
|
91
|
-
const dec = (s: string): number => Number(s);
|
|
92
|
-
|
|
93
|
-
function extractQuotas(p: PlanVersionRow): Record<string, number> {
|
|
94
|
-
if (p.quotas && Object.keys(p.quotas).length > 0) {
|
|
95
|
-
return { ...p.quotas };
|
|
96
|
-
}
|
|
97
|
-
// Legacy fallback: collect the flat fields.
|
|
98
|
-
const out: Record<string, number> = {};
|
|
99
|
-
if (typeof p.maxUsers === 'number') out.users = p.maxUsers;
|
|
100
|
-
if (typeof p.maxVehicles === 'number') out.vehicles = p.maxVehicles;
|
|
101
|
-
if (typeof p.maxStorageGb === 'number') out.storageGb = p.maxStorageGb;
|
|
102
|
-
return out;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const sortByPublishedDesc = <T extends { publishedAt: string | null; version: number }>(
|
|
106
|
-
rows: T[],
|
|
107
|
-
): T[] =>
|
|
108
|
-
[...rows].sort((a, b) => {
|
|
109
|
-
const at = a.publishedAt ? Date.parse(a.publishedAt) : Number.POSITIVE_INFINITY;
|
|
110
|
-
const bt = b.publishedAt ? Date.parse(b.publishedAt) : Number.POSITIVE_INFINITY;
|
|
111
|
-
if (at !== bt) return bt - at;
|
|
112
|
-
return b.version - a.version;
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Finds the live version valid for an entity slot at the point in time `asOf`.
|
|
117
|
-
* A version was live when `publishedAt <= asOf` and (`supersededAt == null`
|
|
118
|
-
* or `supersededAt > asOf`) held.
|
|
119
|
-
*/
|
|
120
|
-
function findLiveAt<
|
|
121
|
-
T extends { publishedAt: string | null; supersededAt: string | null; version: number },
|
|
122
|
-
>(rows: T[], asOfMs: number): T | null {
|
|
123
|
-
const candidates = rows.filter((r) => {
|
|
124
|
-
if (r.publishedAt === null) return false;
|
|
125
|
-
if (Date.parse(r.publishedAt) > asOfMs) return false;
|
|
126
|
-
if (r.supersededAt !== null && Date.parse(r.supersededAt) <= asOfMs) return false;
|
|
127
|
-
return true;
|
|
128
|
-
});
|
|
129
|
-
if (candidates.length === 0) return null;
|
|
130
|
-
return [...candidates].sort((a, b) => b.version - a.version)[0] ?? null;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function findCurrentLive<T extends { publishedAt: string | null; supersededAt: string | null }>(
|
|
134
|
-
rows: T[],
|
|
135
|
-
): T | null {
|
|
136
|
-
return rows.find((r) => r.publishedAt !== null && r.supersededAt === null) ?? null;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function findDraft<T extends { publishedAt: string | null }>(rows: T[]): T | null {
|
|
140
|
-
return rows.find((r) => r.publishedAt === null) ?? null;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function groupBy<T, K extends string>(rows: T[], keyOf: (r: T) => K): Map<K, T[]> {
|
|
144
|
-
const out = new Map<K, T[]>();
|
|
145
|
-
for (const r of rows) {
|
|
146
|
-
const k = keyOf(r);
|
|
147
|
-
const list = out.get(k) ?? [];
|
|
148
|
-
list.push(r);
|
|
149
|
-
out.set(k, list);
|
|
150
|
-
}
|
|
151
|
-
return out;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// ─── Resolver ────────────────────────────────────────────────────────────
|
|
155
|
-
|
|
156
|
-
function resolvePlan<P extends PlanVersionRow>(source: P, liveBase: P | null): ResolvedPlan<P> {
|
|
157
|
-
const quotas = extractQuotas(source);
|
|
158
|
-
return {
|
|
159
|
-
source,
|
|
160
|
-
liveBase,
|
|
161
|
-
isDraft: source.publishedAt === null,
|
|
162
|
-
planId: source.planId,
|
|
163
|
-
features: source.features,
|
|
164
|
-
quotas,
|
|
165
|
-
monthlyNet: dec(source.monthlyNet),
|
|
166
|
-
yearlyNet: dec(source.yearlyNet),
|
|
167
|
-
marketed: source.marketed,
|
|
168
|
-
version: source.version,
|
|
169
|
-
maxUsers: quotas.users,
|
|
170
|
-
maxVehicles: quotas.vehicles,
|
|
171
|
-
maxStorageGb: quotas.storageGb,
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// ─── Snapshot constructors ───────────────────────────────────────────────
|
|
176
|
-
|
|
177
|
-
type CatalogMessages = (typeof planVersionsMessages)['de']['catalog'];
|
|
178
|
-
|
|
179
|
-
function draftsTitle(draftCount: number, msg: CatalogMessages): string {
|
|
180
|
-
if (draftCount === 0) return msg.draftsTitleEmpty;
|
|
181
|
-
const template = draftCount === 1 ? msg.draftsTitleOne : msg.draftsTitleMany;
|
|
182
|
-
return formatMessage(template, { count: draftCount });
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function dateLabel(iso: string, locale: SaLocale): string {
|
|
186
|
-
return new Date(iso).toLocaleDateString(SA_INTL_LOCALES[locale], {
|
|
187
|
-
day: '2-digit',
|
|
188
|
-
month: 'short',
|
|
189
|
-
year: 'numeric',
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function buildDraftsSnapshot<P extends PlanVersionRow>(
|
|
194
|
-
data: RawCatalogData<P>,
|
|
195
|
-
sortPlansFn: (rows: ResolvedPlan<P>[]) => ResolvedPlan<P>[],
|
|
196
|
-
locale: SaLocale,
|
|
197
|
-
): CatalogSnapshot<P> {
|
|
198
|
-
const msg = planVersionsMessages[locale].catalog;
|
|
199
|
-
const planByKey = groupBy(data.planVersions, (r) => r.planId);
|
|
200
|
-
|
|
201
|
-
const plans: ResolvedPlan<P>[] = [];
|
|
202
|
-
for (const [, rows] of planByKey) {
|
|
203
|
-
const draft = findDraft(rows);
|
|
204
|
-
const live = findCurrentLive(rows);
|
|
205
|
-
if (draft) plans.push(resolvePlan(draft, live));
|
|
206
|
-
else if (live) plans.push(resolvePlan(live, null));
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const draftCount = plans.filter((p) => p.isDraft).length;
|
|
210
|
-
|
|
211
|
-
return {
|
|
212
|
-
id: 'drafts',
|
|
213
|
-
kind: 'drafts',
|
|
214
|
-
status: 'DRAFT',
|
|
215
|
-
label: msg.draftsLabel,
|
|
216
|
-
title: draftsTitle(draftCount, msg),
|
|
217
|
-
description: draftCount === 0 ? msg.draftsDescriptionEmpty : msg.draftsDescription,
|
|
218
|
-
asOf: null,
|
|
219
|
-
createdAt: null,
|
|
220
|
-
publishedAt: null,
|
|
221
|
-
authorEmail: null,
|
|
222
|
-
plans: sortPlansFn(plans),
|
|
223
|
-
draftCount,
|
|
224
|
-
regressionCount: 0,
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function buildActiveSnapshot<P extends PlanVersionRow>(
|
|
229
|
-
data: RawCatalogData<P>,
|
|
230
|
-
sortPlansFn: (rows: ResolvedPlan<P>[]) => ResolvedPlan<P>[],
|
|
231
|
-
locale: SaLocale,
|
|
232
|
-
): CatalogSnapshot<P> | null {
|
|
233
|
-
const msg = planVersionsMessages[locale].catalog;
|
|
234
|
-
const planByKey = groupBy(data.planVersions, (r) => r.planId);
|
|
235
|
-
|
|
236
|
-
const plans: ResolvedPlan<P>[] = [];
|
|
237
|
-
for (const [, rows] of planByKey) {
|
|
238
|
-
const live = findCurrentLive(rows);
|
|
239
|
-
if (live) plans.push(resolvePlan(live, null));
|
|
240
|
-
}
|
|
241
|
-
if (plans.length === 0) return null;
|
|
242
|
-
|
|
243
|
-
const publishedDates = plans
|
|
244
|
-
.map((p) => p.source.publishedAt)
|
|
245
|
-
.filter((t): t is string => t !== null)
|
|
246
|
-
.sort();
|
|
247
|
-
const latestPublished = publishedDates[publishedDates.length - 1];
|
|
248
|
-
|
|
249
|
-
return {
|
|
250
|
-
id: 'active',
|
|
251
|
-
kind: 'active',
|
|
252
|
-
status: 'ACTIVE',
|
|
253
|
-
label: msg.activeLabel,
|
|
254
|
-
title: msg.activeTitle,
|
|
255
|
-
description: msg.activeDescription,
|
|
256
|
-
asOf: latestPublished ?? null,
|
|
257
|
-
createdAt: null,
|
|
258
|
-
publishedAt: latestPublished ?? null,
|
|
259
|
-
authorEmail: null,
|
|
260
|
-
plans: sortPlansFn(plans),
|
|
261
|
-
draftCount: 0,
|
|
262
|
-
regressionCount: 0,
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function buildHistoricalSnapshots<P extends PlanVersionRow>(
|
|
267
|
-
data: RawCatalogData<P>,
|
|
268
|
-
sortPlansFn: (rows: ResolvedPlan<P>[]) => ResolvedPlan<P>[],
|
|
269
|
-
locale: SaLocale,
|
|
270
|
-
): CatalogSnapshot<P>[] {
|
|
271
|
-
const msg = planVersionsMessages[locale].catalog;
|
|
272
|
-
const allPublishedAt = new Set<string>();
|
|
273
|
-
for (const r of data.planVersions) if (r.publishedAt) allPublishedAt.add(r.publishedAt);
|
|
274
|
-
if (allPublishedAt.size === 0) return [];
|
|
275
|
-
|
|
276
|
-
const planByKey = groupBy(data.planVersions, (r) => r.planId);
|
|
277
|
-
|
|
278
|
-
// The active snapshot overlaps with the last publish event. We take the
|
|
279
|
-
// latest one in `active`, and map only the events *before* it here.
|
|
280
|
-
const liveTimestamps = new Set<string>();
|
|
281
|
-
for (const rows of planByKey.values()) {
|
|
282
|
-
const live = findCurrentLive(rows);
|
|
283
|
-
if (live?.publishedAt) liveTimestamps.add(live.publishedAt);
|
|
284
|
-
}
|
|
285
|
-
const liveSorted = [...liveTimestamps].sort();
|
|
286
|
-
const latestLive = liveSorted[liveSorted.length - 1];
|
|
287
|
-
|
|
288
|
-
const sortedDesc = [...allPublishedAt].sort((a, b) => Date.parse(b) - Date.parse(a));
|
|
289
|
-
|
|
290
|
-
const out: CatalogSnapshot<P>[] = [];
|
|
291
|
-
for (const ts of sortedDesc) {
|
|
292
|
-
if (latestLive && Date.parse(ts) >= Date.parse(latestLive)) continue;
|
|
293
|
-
const tMs = Date.parse(ts) + 1; // *after* the event
|
|
294
|
-
const plans: ResolvedPlan<P>[] = [];
|
|
295
|
-
for (const [, rows] of planByKey) {
|
|
296
|
-
const live = findLiveAt(rows, tMs);
|
|
297
|
-
if (live) plans.push(resolvePlan(live, null));
|
|
298
|
-
}
|
|
299
|
-
if (plans.length === 0) continue;
|
|
300
|
-
|
|
301
|
-
const publishedAtThisEvent: P[] = data.planVersions.filter((r) => r.publishedAt === ts);
|
|
302
|
-
const regressionCount = publishedAtThisEvent.filter((r) => !r.nonRegressive).length;
|
|
303
|
-
const author = publishedAtThisEvent[0]?.publishedByUserId ?? null;
|
|
304
|
-
|
|
305
|
-
out.push({
|
|
306
|
-
id: `hist-${ts}`,
|
|
307
|
-
kind: 'historical',
|
|
308
|
-
status: 'ARCHIVED',
|
|
309
|
-
label: dateLabel(ts, locale),
|
|
310
|
-
title: summarizeEvent(publishedAtThisEvent, msg),
|
|
311
|
-
description: detailEvent(publishedAtThisEvent, msg),
|
|
312
|
-
asOf: ts,
|
|
313
|
-
createdAt: ts,
|
|
314
|
-
publishedAt: ts,
|
|
315
|
-
authorEmail: author,
|
|
316
|
-
plans: sortPlansFn(plans),
|
|
317
|
-
draftCount: 0,
|
|
318
|
-
regressionCount,
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
return out;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function summarizeEvent(rows: PlanVersionRow[], msg: CatalogMessages): string {
|
|
325
|
-
if (rows.length === 0) return msg.publishEvent;
|
|
326
|
-
if (rows.length === 1) {
|
|
327
|
-
const r = rows[0]!;
|
|
328
|
-
return formatMessage(msg.publishEventSingle, { planId: r.planId, version: r.version });
|
|
329
|
-
}
|
|
330
|
-
return formatMessage(msg.publishEventMultiple, { count: rows.length });
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function detailEvent(rows: PlanVersionRow[], msg: CatalogMessages): string {
|
|
334
|
-
if (rows.length === 0) return '';
|
|
335
|
-
const note = rows[0]?.changeNote ?? '';
|
|
336
|
-
return note.length > 0 ? note : msg.reconstructedSnapshot;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// ─── Sorting ─────────────────────────────────────────────────────────────
|
|
340
|
-
|
|
341
|
-
function sortPlansBy<P extends PlanVersionRow>(
|
|
342
|
-
order: readonly string[] | undefined,
|
|
343
|
-
): (rows: ResolvedPlan<P>[]) => ResolvedPlan<P>[] {
|
|
344
|
-
if (!order || order.length === 0) {
|
|
345
|
-
return (rows) => [...rows].sort((a, b) => a.planId.localeCompare(b.planId));
|
|
346
|
-
}
|
|
347
|
-
const idx: Record<string, number> = {};
|
|
348
|
-
for (let i = 0; i < order.length; i++) idx[order[i]!] = i;
|
|
349
|
-
return (rows) =>
|
|
350
|
-
[...rows].sort((a, b) => {
|
|
351
|
-
const ai = idx[a.planId] ?? Number.POSITIVE_INFINITY;
|
|
352
|
-
const bi = idx[b.planId] ?? Number.POSITIVE_INFINITY;
|
|
353
|
-
if (ai !== bi) return ai - bi;
|
|
354
|
-
return a.planId.localeCompare(b.planId);
|
|
355
|
-
});
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// ─── Public API ──────────────────────────────────────────────────────────
|
|
359
|
-
|
|
360
|
-
export function buildSnapshots<P extends PlanVersionRow>(
|
|
361
|
-
data: RawCatalogData<P>,
|
|
362
|
-
options: BuildSnapshotsOptions = {},
|
|
363
|
-
): CatalogSnapshot<P>[] {
|
|
364
|
-
const locale = options.locale ?? DEFAULT_SA_LOCALE;
|
|
365
|
-
const sortPlansFn = sortPlansBy<P>(options.planSortOrder);
|
|
366
|
-
const drafts = buildDraftsSnapshot(data, sortPlansFn, locale);
|
|
367
|
-
const active = buildActiveSnapshot(data, sortPlansFn, locale);
|
|
368
|
-
const historical = buildHistoricalSnapshots(data, sortPlansFn, locale);
|
|
369
|
-
|
|
370
|
-
const out: CatalogSnapshot<P>[] = [drafts];
|
|
371
|
-
if (active) out.push(active);
|
|
372
|
-
out.push(...historical);
|
|
373
|
-
return out;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
export function listOpenDrafts<P extends PlanVersionRow>(data: RawCatalogData<P>): { plans: P[] } {
|
|
377
|
-
return {
|
|
378
|
-
plans: sortByPublishedDesc(data.planVersions).filter((r) => r.publishedAt === null),
|
|
379
|
-
};
|
|
380
|
-
}
|