@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.
Files changed (40) hide show
  1. package/dist/{chunk-A3W2N26K.js → chunk-7N3CJTOA.js} +7 -227
  2. package/dist/{chunk-X5SQ5HMB.js → chunk-TBLXKSTR.js} +0 -137
  3. package/dist/{chunk-QZOSDCNP.js → chunk-TY2WYKME.js} +1 -1
  4. package/dist/client/index.cjs +112 -467
  5. package/dist/client/index.d.cts +4 -62
  6. package/dist/client/index.d.ts +4 -62
  7. package/dist/client/index.js +2 -6
  8. package/dist/index.cjs +204 -583
  9. package/dist/index.d.cts +10 -55
  10. package/dist/index.d.ts +10 -55
  11. package/dist/index.js +74 -101
  12. package/dist/{messages-7b0P8W75.d.ts → messages-BIYw32u1.d.cts} +0 -67
  13. package/dist/{messages-7b0P8W75.d.cts → messages-BIYw32u1.d.ts} +0 -67
  14. package/dist/quasar/index.cjs +0 -136
  15. package/dist/quasar/index.d.cts +2 -2
  16. package/dist/quasar/index.d.ts +2 -2
  17. package/dist/quasar/index.js +2 -2
  18. package/dist/{use-super-admin-i18n-B3v3-SWZ.d.ts → use-super-admin-i18n-CWNu_W3u.d.ts} +1 -1
  19. package/dist/{use-super-admin-i18n-DMktRVEt.d.cts → use-super-admin-i18n-DL_qI1BY.d.cts} +1 -1
  20. package/package.json +2 -2
  21. package/src/client/i18n/messages/nav.ts +0 -2
  22. package/src/client/i18n/messages/plan-versions.ts +0 -143
  23. package/src/client/index.ts +0 -2
  24. package/src/client/nav-builder.ts +9 -4
  25. package/src/index.ts +0 -1
  26. package/src/pages-standard/AdminLayout.vue +1 -1
  27. package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +1 -1
  28. package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +1 -1
  29. package/src/pages-standard/plan-versions/catalog-history.types.ts +37 -0
  30. package/src/pages-standard/plan-versions/format.ts +2 -38
  31. package/src/client/plan-versions-catalog.ts +0 -380
  32. package/src/pages-standard/PlanVersionsPage.vue +0 -220
  33. package/src/pages-standard/plan-versions/PlanVersionHeader.vue +0 -309
  34. package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +0 -48
  35. package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +0 -326
  36. package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +0 -65
  37. package/src/pages-standard/plan-versions/PlanVersionsList.vue +0 -315
  38. package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +0 -261
  39. package/src/pages-standard/plan-versions/types.ts +0 -33
  40. package/src/vue/use-plan-versions.ts +0 -83
@@ -1,13 +1,7 @@
1
- // Formatting helpers for the plan-versions views.
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, SA_INTL_LOCALES, type SaLocale } from '../../client/i18n/locale.js';
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>