@saasicat/ui-vue 0.6.0 → 0.8.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-7N3CJTOA.js} +7 -227
- 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 +112 -467
- package/dist/client/index.d.cts +4 -62
- package/dist/client/index.d.ts +4 -62
- package/dist/client/index.js +2 -6
- package/dist/index.cjs +204 -583
- package/dist/index.d.cts +10 -55
- package/dist/index.d.ts +10 -55
- package/dist/index.js +74 -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 +2 -2
- package/src/client/i18n/messages/nav.ts +0 -2
- package/src/client/i18n/messages/plan-versions.ts +0 -143
- package/src/client/index.ts +0 -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
|
@@ -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
|
-
}
|
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<div class="sa-pv">
|
|
3
|
-
<div v-if="loading" class="sa-pv__loading">
|
|
4
|
-
<q-spinner size="32px" /> {{ msg.page.loadingCatalog }}
|
|
5
|
-
</div>
|
|
6
|
-
|
|
7
|
-
<template v-else>
|
|
8
|
-
<PlanVersionsTimeline
|
|
9
|
-
:snapshots="snapshots"
|
|
10
|
-
:selected-id="selectedId"
|
|
11
|
-
:compare-id="compareId"
|
|
12
|
-
@select="onSelect"
|
|
13
|
-
@set-compare="onSetCompare"
|
|
14
|
-
@clear-compare="compareId = null"
|
|
15
|
-
/>
|
|
16
|
-
|
|
17
|
-
<main class="sa-pv__main">
|
|
18
|
-
<PlanVersionHeader
|
|
19
|
-
v-if="selected"
|
|
20
|
-
:snapshot="selected"
|
|
21
|
-
:view-mode="viewMode"
|
|
22
|
-
:compare-snapshot="compare"
|
|
23
|
-
@view-change="viewMode = $event"
|
|
24
|
-
@publish="emit('publish')"
|
|
25
|
-
@clear-compare="compareId = null"
|
|
26
|
-
/>
|
|
27
|
-
|
|
28
|
-
<div class="sa-pv__body">
|
|
29
|
-
<template v-if="selected">
|
|
30
|
-
<PlanVersionsDiff
|
|
31
|
-
v-if="compare"
|
|
32
|
-
:from="compare"
|
|
33
|
-
:to="selected"
|
|
34
|
-
:plan-accents="planAccents"
|
|
35
|
-
:field-labels="diffFieldLabels"
|
|
36
|
-
/>
|
|
37
|
-
<template v-else-if="viewMode === 'list'">
|
|
38
|
-
<slot name="list" :snapshot="selected">
|
|
39
|
-
<PlanVersionsList
|
|
40
|
-
:snapshot="selected"
|
|
41
|
-
:expected-plan-ids="expectedPlanIds"
|
|
42
|
-
:quota-columns="quotaColumns"
|
|
43
|
-
@edit-plan-draft="emit('editPlanDraft', $event)"
|
|
44
|
-
@create-plan-draft="emit('createPlanDraft', $event)"
|
|
45
|
-
/>
|
|
46
|
-
</slot>
|
|
47
|
-
</template>
|
|
48
|
-
<template v-else-if="viewMode === 'matrix'">
|
|
49
|
-
<slot name="matrix" :snapshot="selected">
|
|
50
|
-
<PlanVersionsMatrix
|
|
51
|
-
:snapshot="selected"
|
|
52
|
-
:feature-registry="featureRegistry ?? {}"
|
|
53
|
-
:feature-groups="featureGroups"
|
|
54
|
-
/>
|
|
55
|
-
</slot>
|
|
56
|
-
</template>
|
|
57
|
-
<template v-else>
|
|
58
|
-
<slot name="audit" :snapshot="selected">
|
|
59
|
-
<PlanVersionsAudit
|
|
60
|
-
v-if="loadAudit"
|
|
61
|
-
:snapshot="selected"
|
|
62
|
-
:load-audit="loadAudit"
|
|
63
|
-
:action-meta="auditActionMeta"
|
|
64
|
-
/>
|
|
65
|
-
<div v-else class="sa-pv__no-audit">
|
|
66
|
-
{{ msg.page.noAuditLoader }}
|
|
67
|
-
</div>
|
|
68
|
-
</slot>
|
|
69
|
-
</template>
|
|
70
|
-
</template>
|
|
71
|
-
</div>
|
|
72
|
-
</main>
|
|
73
|
-
</template>
|
|
74
|
-
</div>
|
|
75
|
-
</template>
|
|
76
|
-
|
|
77
|
-
<script setup lang="ts">
|
|
78
|
-
import { computed, ref, watch } from 'vue';
|
|
79
|
-
import type { CatalogSnapshot, RawCatalogData } from '../client/plan-versions-catalog.js';
|
|
80
|
-
import { buildSnapshots } from '../client/plan-versions-catalog.js';
|
|
81
|
-
import PlanVersionsTimeline from './plan-versions/PlanVersionsTimeline.vue';
|
|
82
|
-
import PlanVersionHeader from './plan-versions/PlanVersionHeader.vue';
|
|
83
|
-
import PlanVersionsList, { type QuotaColumnConfig } from './plan-versions/PlanVersionsList.vue';
|
|
84
|
-
import PlanVersionsMatrix from './plan-versions/PlanVersionsMatrix.vue';
|
|
85
|
-
import PlanVersionsAudit from './plan-versions/PlanVersionsAudit.vue';
|
|
86
|
-
import PlanVersionsDiff from './plan-versions/PlanVersionsDiff.vue';
|
|
87
|
-
import type { PlanVersionViewMode } from './plan-versions/types.js';
|
|
88
|
-
import { useSaMessages, useSuperAdminI18n } from '../vue/use-super-admin-i18n.js';
|
|
89
|
-
|
|
90
|
-
// Platform standard page: plan versions.
|
|
91
|
-
//
|
|
92
|
-
// Composes the plan-versions subcomponents into the complete page. Consumers
|
|
93
|
-
// pass `data` (RawCatalogData) through and receive events for the app-
|
|
94
|
-
// specific edit/create flows (DraftDialog/PublishModal are intentionally
|
|
95
|
-
// not part of the platform — they hang off the app feature catalog).
|
|
96
|
-
//
|
|
97
|
-
// Slot API for richer app-specific renderings:
|
|
98
|
-
// - `#list`: replaces the default PlanVersionsList (e.g. with feature
|
|
99
|
-
// grouping per plan + additional quota columns).
|
|
100
|
-
// - `#matrix`, `#audit`: analogous.
|
|
101
|
-
|
|
102
|
-
interface FeatureGroupsConfig {
|
|
103
|
-
order: readonly string[];
|
|
104
|
-
byKey: Record<string, string>;
|
|
105
|
-
fallbackGroup?: string;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
interface AuditRow {
|
|
109
|
-
id: string;
|
|
110
|
-
createdAt: string;
|
|
111
|
-
action: string;
|
|
112
|
-
entity: string;
|
|
113
|
-
entityId: string;
|
|
114
|
-
changes: Record<string, unknown> | null;
|
|
115
|
-
user?: { email: string; firstName?: string; lastName?: string } | null;
|
|
116
|
-
userEmail?: string | null;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const props = withDefaults(
|
|
120
|
-
defineProps<{
|
|
121
|
-
data: RawCatalogData;
|
|
122
|
-
loading?: boolean;
|
|
123
|
-
/** Plan IDs in display order (e.g. BASIC,STANDARD,PROFESSIONAL,…). */
|
|
124
|
-
planSortOrder?: readonly string[];
|
|
125
|
-
/** Expected plan IDs for the "missing packages" warning. */
|
|
126
|
-
expectedPlanIds?: readonly string[];
|
|
127
|
-
/** Accent color per plan ID (for diff cards). */
|
|
128
|
-
planAccents?: Record<string, string>;
|
|
129
|
-
/** Feature metadata (label/icon/plannedOnly) for the matrix. */
|
|
130
|
-
featureRegistry?: Record<string, { label?: string; icon?: string; plannedOnly?: boolean }>;
|
|
131
|
-
/** UX grouping of the features in the matrix. */
|
|
132
|
-
featureGroups?: FeatureGroupsConfig;
|
|
133
|
-
/** Audit loader (optional; without it disables the audit view). */
|
|
134
|
-
loadAudit?: () => Promise<AuditRow[]>;
|
|
135
|
-
/** App-specific audit action meta map. */
|
|
136
|
-
auditActionMeta?: Record<string, { icon: string; color: string; label: string }>;
|
|
137
|
-
/** Field label overrides for the diff display. */
|
|
138
|
-
diffFieldLabels?: Record<string, string>;
|
|
139
|
-
/**
|
|
140
|
-
* Optional quota columns in the list view (e.g.
|
|
141
|
-
* users/members/storage/seats). Passed through to
|
|
142
|
-
* PlanVersionsList. Default: none — old display (only package/price/features).
|
|
143
|
-
*/
|
|
144
|
-
quotaColumns?: ReadonlyArray<QuotaColumnConfig>;
|
|
145
|
-
}>(),
|
|
146
|
-
{
|
|
147
|
-
loading: false,
|
|
148
|
-
},
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
const emit = defineEmits<{
|
|
152
|
-
(e: 'publish'): void;
|
|
153
|
-
(e: 'editPlanDraft', draftId: string): void;
|
|
154
|
-
(e: 'createPlanDraft', planId: string): void;
|
|
155
|
-
}>();
|
|
156
|
-
|
|
157
|
-
const msg = useSaMessages('planVersions');
|
|
158
|
-
const { locale } = useSuperAdminI18n();
|
|
159
|
-
|
|
160
|
-
const selectedId = ref<string>('drafts');
|
|
161
|
-
const compareId = ref<string | null>(null);
|
|
162
|
-
const viewMode = ref<PlanVersionViewMode>('list');
|
|
163
|
-
|
|
164
|
-
const snapshots = computed<CatalogSnapshot[]>(() =>
|
|
165
|
-
buildSnapshots(props.data, { planSortOrder: props.planSortOrder, locale: locale.value }),
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
const selected = computed<CatalogSnapshot | null>(
|
|
169
|
-
() => snapshots.value.find((s) => s.id === selectedId.value) ?? null,
|
|
170
|
-
);
|
|
171
|
-
const compare = computed<CatalogSnapshot | null>(() =>
|
|
172
|
-
compareId.value ? (snapshots.value.find((s) => s.id === compareId.value) ?? null) : null,
|
|
173
|
-
);
|
|
174
|
-
|
|
175
|
-
watch(snapshots, (snaps) => {
|
|
176
|
-
if (!snaps.find((s) => s.id === selectedId.value)) {
|
|
177
|
-
selectedId.value = snaps[0]?.id ?? 'drafts';
|
|
178
|
-
}
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
function onSelect(id: string): void {
|
|
182
|
-
selectedId.value = id;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function onSetCompare(id: string): void {
|
|
186
|
-
if (id !== selectedId.value) compareId.value = id;
|
|
187
|
-
}
|
|
188
|
-
</script>
|
|
189
|
-
|
|
190
|
-
<style scoped>
|
|
191
|
-
.sa-pv {
|
|
192
|
-
display: flex;
|
|
193
|
-
min-height: calc(100vh - 56px);
|
|
194
|
-
background: var(--sa-bg-app, #f1f5f9);
|
|
195
|
-
min-width: 0;
|
|
196
|
-
}
|
|
197
|
-
.sa-pv__main {
|
|
198
|
-
flex: 1;
|
|
199
|
-
display: flex;
|
|
200
|
-
flex-direction: column;
|
|
201
|
-
min-width: 0;
|
|
202
|
-
overflow: hidden;
|
|
203
|
-
}
|
|
204
|
-
.sa-pv__body {
|
|
205
|
-
flex: 1;
|
|
206
|
-
overflow-y: auto;
|
|
207
|
-
}
|
|
208
|
-
.sa-pv__loading {
|
|
209
|
-
display: flex;
|
|
210
|
-
align-items: center;
|
|
211
|
-
gap: 12px;
|
|
212
|
-
padding: 32px;
|
|
213
|
-
color: var(--sa-muted, #64748b);
|
|
214
|
-
}
|
|
215
|
-
.sa-pv__no-audit {
|
|
216
|
-
padding: 32px;
|
|
217
|
-
color: var(--sa-muted, #64748b);
|
|
218
|
-
font-style: italic;
|
|
219
|
-
}
|
|
220
|
-
</style>
|