@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.
Files changed (41) hide show
  1. package/dist/{chunk-A3W2N26K.js → chunk-66BUSVC5.js} +68 -217
  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 +180 -463
  5. package/dist/client/index.d.cts +32 -57
  6. package/dist/client/index.d.ts +32 -57
  7. package/dist/client/index.js +4 -6
  8. package/dist/index.cjs +273 -580
  9. package/dist/index.d.cts +10 -55
  10. package/dist/index.d.ts +10 -55
  11. package/dist/index.js +76 -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 +5 -5
  21. package/src/client/admin-resource-client.ts +112 -0
  22. package/src/client/i18n/messages/nav.ts +0 -2
  23. package/src/client/i18n/messages/plan-versions.ts +0 -143
  24. package/src/client/index.ts +1 -2
  25. package/src/client/nav-builder.ts +9 -4
  26. package/src/index.ts +0 -1
  27. package/src/pages-standard/AdminLayout.vue +1 -1
  28. package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +1 -1
  29. package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +1 -1
  30. package/src/pages-standard/plan-versions/catalog-history.types.ts +37 -0
  31. package/src/pages-standard/plan-versions/format.ts +2 -38
  32. package/src/client/plan-versions-catalog.ts +0 -380
  33. package/src/pages-standard/PlanVersionsPage.vue +0 -220
  34. package/src/pages-standard/plan-versions/PlanVersionHeader.vue +0 -309
  35. package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +0 -48
  36. package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +0 -326
  37. package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +0 -65
  38. package/src/pages-standard/plan-versions/PlanVersionsList.vue +0 -315
  39. package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +0 -261
  40. package/src/pages-standard/plan-versions/types.ts +0 -33
  41. package/src/vue/use-plan-versions.ts +0 -83
@@ -1,326 +0,0 @@
1
- <template>
2
- <div class="sa-pv-audit">
3
- <div class="sa-pv-audit__card">
4
- <header class="sa-pv-audit__head">
5
- <q-icon name="history" size="18px" class="sa-pv-audit__head-icon" />
6
- <h3>{{ msg.audit.title }}</h3>
7
- <span class="sa-pv-audit__count">{{ eventCountLabel }}</span>
8
- <q-space />
9
- <button v-if="!loading" type="button" class="sa-pv-audit__refresh" @click="reload">
10
- <q-icon name="refresh" size="14px" /> {{ common.reload }}
11
- </button>
12
- </header>
13
- <div v-if="loading" class="sa-pv-audit__loading">
14
- <q-spinner size="20px" /> {{ msg.audit.loading }}
15
- </div>
16
- <div v-else-if="events.length === 0" class="sa-pv-audit__empty">
17
- {{ msg.audit.empty }}
18
- </div>
19
- <div v-else class="sa-pv-audit__list">
20
- <div
21
- v-for="(e, i) in events"
22
- :key="e.id"
23
- class="sa-pv-audit__item"
24
- :class="{ 'sa-pv-audit__item--last': i === events.length - 1 }"
25
- >
26
- <div
27
- class="sa-pv-audit__icon"
28
- :style="{ background: `${meta(e.action).color}14` }"
29
- >
30
- <q-icon
31
- :name="meta(e.action).icon"
32
- size="17px"
33
- :style="{ color: meta(e.action).color }"
34
- />
35
- </div>
36
- <div class="sa-pv-audit__body">
37
- <div class="sa-pv-audit__title-row">
38
- <span class="sa-pv-audit__title">{{ meta(e.action).label }}</span>
39
- <span class="sa-pv-audit__entity">{{ entityLabel(e) }}</span>
40
- </div>
41
- <div class="sa-pv-audit__detail">{{ detail(e) }}</div>
42
- <div class="sa-pv-audit__meta">
43
- <span
44
- ><q-icon name="schedule" size="12px" />
45
- {{ formatTimestamp(e.createdAt, locale) }}</span
46
- >
47
- <span v-if="actorEmail(e)">
48
- <q-icon name="person" size="12px" /> {{ actorEmail(e) }}
49
- </span>
50
- </div>
51
- </div>
52
- </div>
53
- </div>
54
- </div>
55
- </div>
56
- </template>
57
-
58
- <script setup lang="ts">
59
- import { computed, onMounted, ref, watch } from 'vue';
60
- import type { CatalogSnapshot } from '../../client/plan-versions-catalog.js';
61
- import { formatMessage } from '../../client/i18n/format.js';
62
- import { useSaMessages, useSuperAdminI18n } from '../../vue/use-super-admin-i18n.js';
63
- import { formatTimestamp } from './format.js';
64
-
65
- // PlanVersionsAudit — data-agnostic. Consumers pass in a `loadAudit` loader
66
- // that supplies events; the component filters them to match the current
67
- // snapshot (drafts/active = everything ≤ 50, historical = ≤ asOf).
68
- //
69
- // The action meta map can be extended per app so that additional actions
70
- // (e.g. app-specific audit keys) get their own icons and labels.
71
-
72
- interface AuditRow {
73
- id: string;
74
- createdAt: string;
75
- action: string;
76
- entity: string;
77
- entityId: string;
78
- changes: Record<string, unknown> | null;
79
- /** Legacy style: nested user object with email. */
80
- user?: { email: string; firstName?: string; lastName?: string } | null;
81
- /** Platform style: flat userEmail field. */
82
- userEmail?: string | null;
83
- }
84
-
85
- interface ActionMeta {
86
- icon: string;
87
- color: string;
88
- label: string;
89
- }
90
-
91
- const props = withDefaults(
92
- defineProps<{
93
- snapshot: CatalogSnapshot;
94
- loadAudit: () => Promise<AuditRow[]>;
95
- actionMeta?: Record<string, ActionMeta>;
96
- }>(),
97
- {
98
- actionMeta: () => ({}),
99
- },
100
- );
101
-
102
- const loading = ref(false);
103
- const events = ref<AuditRow[]>([]);
104
-
105
- const msg = useSaMessages('planVersions');
106
- const common = useSaMessages('common');
107
- const { locale } = useSuperAdminI18n();
108
-
109
- const eventCountLabel = computed(() =>
110
- formatMessage(msg.value.audit.eventCount, { count: events.value.length }),
111
- );
112
-
113
- const ACTION_ICONS: Record<string, { icon: string; color: string }> = {
114
- PLAN_VERSION_PUBLISH: { icon: 'rocket_launch', color: '#047857' },
115
- PLAN_VERSION_DRAFT_CREATE: { icon: 'edit_note', color: '#b45309' },
116
- PLAN_VERSION_DRAFT_UPDATE: { icon: 'edit', color: '#3f6bff' },
117
- PLAN_VERSION_DRAFT_DELETE: { icon: 'delete', color: '#dc2626' },
118
- PLAN_CATALOG_UPDATE: { icon: 'rocket_launch', color: '#047857' },
119
- };
120
-
121
- const defaultActionMeta = computed<Record<string, ActionMeta>>(() => ({
122
- PLAN_VERSION_PUBLISH: {
123
- ...ACTION_ICONS.PLAN_VERSION_PUBLISH,
124
- label: msg.value.audit.actionPlanPublished,
125
- },
126
- PLAN_VERSION_DRAFT_CREATE: {
127
- ...ACTION_ICONS.PLAN_VERSION_DRAFT_CREATE,
128
- label: msg.value.audit.actionPlanDraftCreated,
129
- },
130
- PLAN_VERSION_DRAFT_UPDATE: {
131
- ...ACTION_ICONS.PLAN_VERSION_DRAFT_UPDATE,
132
- label: msg.value.audit.actionPlanDraftUpdated,
133
- },
134
- PLAN_VERSION_DRAFT_DELETE: {
135
- ...ACTION_ICONS.PLAN_VERSION_DRAFT_DELETE,
136
- label: msg.value.audit.actionPlanDraftDeleted,
137
- },
138
- PLAN_CATALOG_UPDATE: {
139
- ...ACTION_ICONS.PLAN_CATALOG_UPDATE,
140
- label: msg.value.audit.actionCatalogPublished,
141
- },
142
- }));
143
-
144
- function meta(action: string): ActionMeta {
145
- return (
146
- props.actionMeta[action] ??
147
- defaultActionMeta.value[action] ?? {
148
- icon: 'circle',
149
- color: '#64748b',
150
- label: action,
151
- }
152
- );
153
- }
154
-
155
- function entityLabel(e: AuditRow): string {
156
- const c = (e.changes ?? {}) as {
157
- planId?: string;
158
- key?: string;
159
- featureKey?: string;
160
- version?: number;
161
- };
162
- const versionPart = c.version != null ? ` v${c.version}` : '';
163
- if (c.planId) return `${c.planId}${versionPart}`;
164
- if (c.key) return `${c.key}${versionPart}`;
165
- if (c.featureKey) return `${c.featureKey}${versionPart}`;
166
- return e.entity;
167
- }
168
-
169
- function detail(e: AuditRow): string {
170
- const c = (e.changes ?? {}) as {
171
- changeNote?: string;
172
- nonRegressive?: boolean;
173
- changes?: unknown;
174
- };
175
- if (c.changeNote) return c.changeNote;
176
- if (c.nonRegressive === false) return msg.value.audit.detailRegression;
177
- if (Array.isArray(c.changes)) {
178
- return formatMessage(msg.value.audit.detailFieldsChanged, { count: c.changes.length });
179
- }
180
- return e.action;
181
- }
182
-
183
- function actorEmail(e: AuditRow): string | null {
184
- return e.user?.email ?? e.userEmail ?? null;
185
- }
186
-
187
- onMounted(reload);
188
- watch(() => props.snapshot.id, reload);
189
-
190
- async function reload(): Promise<void> {
191
- loading.value = true;
192
- try {
193
- const merged = await props.loadAudit();
194
- events.value = filterForSnapshot(merged);
195
- } finally {
196
- loading.value = false;
197
- }
198
- }
199
-
200
- function filterForSnapshot(rows: AuditRow[]): AuditRow[] {
201
- if (props.snapshot.kind !== 'historical' || !props.snapshot.asOf) return rows.slice(0, 50);
202
- const asOfMs = Date.parse(props.snapshot.asOf);
203
- return rows.filter((r) => Date.parse(r.createdAt) <= asOfMs).slice(0, 50);
204
- }
205
- </script>
206
-
207
- <style scoped>
208
- .sa-pv-audit {
209
- padding: 20px 28px;
210
- }
211
- .sa-pv-audit__card {
212
- background: #fff;
213
- border: 1px solid var(--sa-border, #e2e8f0);
214
- border-radius: 12px;
215
- overflow: hidden;
216
- }
217
- .sa-pv-audit__head {
218
- padding: 14px 18px;
219
- border-bottom: 1px solid var(--sa-border, #e2e8f0);
220
- display: flex;
221
- align-items: center;
222
- gap: 8px;
223
- }
224
- .sa-pv-audit__head h3 {
225
- margin: 0;
226
- font-family: var(--sa-font-head, system-ui, sans-serif);
227
- font-size: 15px;
228
- font-weight: 700;
229
- color: var(--sa-heading, #0f172a);
230
- }
231
- .sa-pv-audit__head-icon {
232
- color: var(--sa-primary, #3f6bff);
233
- }
234
- .sa-pv-audit__count {
235
- font-size: 12px;
236
- color: var(--sa-muted, #64748b);
237
- }
238
- .sa-pv-audit__refresh {
239
- background: #fff;
240
- border: 1px solid var(--sa-border, #e2e8f0);
241
- border-radius: 6px;
242
- padding: 4px 10px;
243
- font-size: 11.5px;
244
- font-weight: 600;
245
- color: var(--sa-body, #1e293b);
246
- cursor: pointer;
247
- display: inline-flex;
248
- align-items: center;
249
- gap: 4px;
250
- }
251
-
252
- .sa-pv-audit__loading,
253
- .sa-pv-audit__empty {
254
- padding: 24px;
255
- color: var(--sa-muted, #64748b);
256
- display: flex;
257
- align-items: center;
258
- gap: 12px;
259
- }
260
-
261
- .sa-pv-audit__list {
262
- padding: 8px 0;
263
- }
264
- .sa-pv-audit__item {
265
- display: flex;
266
- align-items: flex-start;
267
- gap: 14px;
268
- padding: 12px 18px;
269
- border-bottom: 1px solid var(--sa-border-soft, #f1f5f9);
270
- }
271
- .sa-pv-audit__item--last {
272
- border-bottom: none;
273
- }
274
-
275
- .sa-pv-audit__icon {
276
- width: 32px;
277
- height: 32px;
278
- border-radius: 8px;
279
- display: flex;
280
- align-items: center;
281
- justify-content: center;
282
- flex-shrink: 0;
283
- }
284
-
285
- .sa-pv-audit__body {
286
- flex: 1;
287
- min-width: 0;
288
- }
289
- .sa-pv-audit__title-row {
290
- display: flex;
291
- align-items: center;
292
- gap: 8px;
293
- }
294
- .sa-pv-audit__title {
295
- font-size: 13px;
296
- font-weight: 600;
297
- color: var(--sa-heading, #0f172a);
298
- }
299
- .sa-pv-audit__entity {
300
- font-family: var(--sa-font-mono, ui-monospace, monospace);
301
- font-size: 10.5px;
302
- font-weight: 600;
303
- color: var(--sa-muted, #64748b);
304
- background: #f1f5f9;
305
- padding: 1px 6px;
306
- border-radius: 4px;
307
- }
308
- .sa-pv-audit__detail {
309
- font-size: 12.5px;
310
- color: var(--sa-body, #1e293b);
311
- margin-top: 3px;
312
- line-height: 1.4;
313
- }
314
- .sa-pv-audit__meta {
315
- font-size: 11px;
316
- color: var(--sa-muted, #64748b);
317
- margin-top: 4px;
318
- display: flex;
319
- gap: 10px;
320
- }
321
- .sa-pv-audit__meta span {
322
- display: inline-flex;
323
- align-items: center;
324
- gap: 4px;
325
- }
326
- </style>
@@ -1,65 +0,0 @@
1
- <template>
2
- <div class="sa-pv-kpi">
3
- <div class="sa-pv-kpi__icon"><q-icon :name="icon" size="18px" /></div>
4
- <div>
5
- <div class="sa-pv-kpi__label">{{ label }}</div>
6
- <div class="sa-pv-kpi__value">
7
- {{ value }}
8
- <span v-if="sub" class="sa-pv-kpi__sub">{{ sub }}</span>
9
- </div>
10
- </div>
11
- </div>
12
- </template>
13
-
14
- <script setup lang="ts">
15
- defineProps<{
16
- label: string;
17
- value: number | string;
18
- icon: string;
19
- sub?: string;
20
- }>();
21
- </script>
22
-
23
- <style scoped>
24
- .sa-pv-kpi {
25
- background: #fff;
26
- border: 1px solid var(--sa-border, #e2e8f0);
27
- border-radius: 10px;
28
- padding: 12px 14px;
29
- display: flex;
30
- align-items: center;
31
- gap: 12px;
32
- }
33
- .sa-pv-kpi__icon {
34
- width: 36px;
35
- height: 36px;
36
- border-radius: 9px;
37
- background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
38
- display: flex;
39
- align-items: center;
40
- justify-content: center;
41
- }
42
- .sa-pv-kpi__icon :deep(.q-icon) {
43
- color: var(--sa-primary, #3f6bff);
44
- }
45
- .sa-pv-kpi__label {
46
- font-size: 10.5px;
47
- font-weight: 700;
48
- letter-spacing: 0.08em;
49
- color: var(--sa-muted, #64748b);
50
- text-transform: uppercase;
51
- }
52
- .sa-pv-kpi__value {
53
- font-family: var(--sa-font-head, system-ui, sans-serif);
54
- font-weight: 700;
55
- font-size: 20px;
56
- color: var(--sa-heading, #0f172a);
57
- line-height: 1.1;
58
- margin-top: 2px;
59
- }
60
- .sa-pv-kpi__sub {
61
- font-size: 12px;
62
- font-weight: 500;
63
- color: var(--sa-muted, #64748b);
64
- }
65
- </style>
@@ -1,315 +0,0 @@
1
- <template>
2
- <div class="sa-pv-list">
3
- <div class="sa-pv-list__kpis">
4
- <PlanVersionsKpi
5
- :label="msg.list.kpiPlans"
6
- :value="snapshot.plans.length"
7
- icon="inventory_2"
8
- />
9
- <PlanVersionsKpi
10
- :label="msg.list.kpiSelfService"
11
- :value="marketedCount"
12
- icon="storefront"
13
- :sub="totalPlansLabel"
14
- />
15
- <PlanVersionsKpi
16
- :label="msg.list.kpiFeaturesTotal"
17
- :value="totalFeatures"
18
- icon="extension"
19
- />
20
- </div>
21
-
22
- <div v-if="missingPlans.length > 0" class="sa-pv-list__missing">
23
- <q-icon name="info" size="16px" />
24
- <div>
25
- <strong>{{ msg.list.missingPlansLabel }}</strong> {{ missingPlans.join(', ') }}
26
- {{ msg.list.missingPlansHint }}
27
- <template v-if="canEdit">
28
- <button
29
- v-for="planId in missingPlans"
30
- :key="`new-${planId}`"
31
- type="button"
32
- class="sa-pv-list__missing-btn"
33
- @click="emit('createPlanDraft', planId)"
34
- >
35
- {{ createDraftLabel(planId) }}
36
- </button>
37
- </template>
38
- </div>
39
- </div>
40
-
41
- <div class="sa-pv-list__table">
42
- <slot name="table-head">
43
- <div
44
- class="sa-pv-list__head sa-pv-list__row sa-pv-list__row--default"
45
- :style="rowGridStyle"
46
- >
47
- <div>{{ msg.list.colPlan }}</div>
48
- <div class="sa-pv-list__th--num">{{ msg.list.colPriceNet }}</div>
49
- <div
50
- v-for="col in quotaColumns"
51
- :key="`hd-${col.key}`"
52
- class="sa-pv-list__th--num"
53
- >
54
- {{ col.label }}
55
- </div>
56
- <div class="sa-pv-list__th--num">{{ msg.list.colFeatures }}</div>
57
- </div>
58
- </slot>
59
-
60
- <template v-for="(plan, i) in snapshot.plans" :key="plan.planId">
61
- <slot
62
- name="row"
63
- :plan="plan"
64
- :is-open="openId === plan.planId"
65
- :is-last="i === snapshot.plans.length - 1"
66
- :can-edit="canEdit"
67
- :toggle="() => onToggle(plan.planId)"
68
- >
69
- <div
70
- class="sa-pv-list__default-row sa-pv-list__row sa-pv-list__row--default"
71
- :style="rowGridStyle"
72
- >
73
- <div class="sa-pv-list__default-name">
74
- <strong>{{ plan.planId }}</strong>
75
- <span class="sa-pv-list__default-version">v{{ plan.version }}</span>
76
- <span v-if="plan.isDraft" class="sa-pv-list__default-chip">{{
77
- msg.status.draft
78
- }}</span>
79
- </div>
80
- <div class="sa-pv-list__th--num">
81
- {{ formatEuro(plan.monthlyNet) }}
82
- </div>
83
- <div
84
- v-for="col in quotaColumns"
85
- :key="`val-${plan.planId}-${col.key}`"
86
- class="sa-pv-list__th--num"
87
- >
88
- {{ formatQuotaValue(plan.quotas[col.key], col) }}
89
- </div>
90
- <div class="sa-pv-list__th--num">{{ plan.features.length }}</div>
91
- </div>
92
- </slot>
93
- </template>
94
- </div>
95
-
96
- <div v-if="snapshot.kind === 'drafts'" class="sa-pv-list__draft-hint">
97
- <q-icon name="info" size="18px" />
98
- <div>
99
- <strong>{{ msg.list.draftHintLabel }}</strong> {{ msg.list.draftHintBefore }}
100
- <em>{{ msg.list.draftHintEditButton }}</em> {{ msg.list.draftHintMiddle }}
101
- <strong>{{ msg.list.draftHintPublishFlow }}</strong>
102
- {{ msg.list.draftHintAfter }}
103
- </div>
104
- </div>
105
- </div>
106
- </template>
107
-
108
- <script setup lang="ts">
109
- import { computed, ref } from 'vue';
110
- import type { CatalogSnapshot } from '../../client/plan-versions-catalog.js';
111
- import { formatMessage } from '../../client/i18n/format.js';
112
- import { useSaMessages, useSuperAdminI18n } from '../../vue/use-super-admin-i18n.js';
113
- import PlanVersionsKpi from './PlanVersionsKpi.vue';
114
- import { fmtEuro } from './format.js';
115
-
116
- /**
117
- * Configuration of a quota column (e.g. `users` → "Benutzer"). Optional
118
- * `fractional: true` → 1 decimal place (storage GB). Optional `unit` →
119
- * suffix like "GB" after the value. `-1` is rendered as ∞.
120
- */
121
- export interface QuotaColumnConfig {
122
- key: string;
123
- label: string;
124
- fractional?: boolean;
125
- unit?: string;
126
- }
127
-
128
- const props = defineProps<{
129
- snapshot: CatalogSnapshot;
130
- /**
131
- * Optional: all expected plan IDs. When set, the list shows a
132
- * warning with "create draft" buttons for missing plans.
133
- */
134
- expectedPlanIds?: readonly string[];
135
- /**
136
- * Optional: additional quota columns between "Preis" and "Features".
137
- * Example: `[{ key: 'users', label: 'Benutzer' },
138
- * { key: 'members', label: 'Mitglieder' }, { key: 'storageGb',
139
- * label: 'Speicher (GB)', fractional: true }, { key: 'resources',
140
- * label: 'Plätze' }]`. Default: no additional columns
141
- * (backwards-compatible).
142
- */
143
- quotaColumns?: ReadonlyArray<QuotaColumnConfig>;
144
- }>();
145
-
146
- const emit = defineEmits<{
147
- (e: 'editPlanDraft', draftId: string): void;
148
- (e: 'createPlanDraft', planId: string): void;
149
- }>();
150
-
151
- const msg = useSaMessages('planVersions');
152
- const { locale } = useSuperAdminI18n();
153
-
154
- const openId = ref<string | null>(null);
155
- const canEdit = computed(() => props.snapshot.kind === 'drafts');
156
-
157
- const totalPlansLabel = computed(() =>
158
- formatMessage(msg.value.list.kpiOfTotal, { count: props.snapshot.plans.length }),
159
- );
160
-
161
- const marketedCount = computed(() => props.snapshot.plans.filter((p) => p.marketed).length);
162
- const totalFeatures = computed(() => new Set(props.snapshot.plans.flatMap((p) => p.features)).size);
163
-
164
- const missingPlans = computed<string[]>(() => {
165
- if (!props.expectedPlanIds) return [];
166
- const present = new Set(props.snapshot.plans.map((p) => p.planId));
167
- return props.expectedPlanIds.filter((id) => !present.has(id));
168
- });
169
-
170
- const quotaColumns = computed<ReadonlyArray<QuotaColumnConfig>>(() => props.quotaColumns ?? []);
171
-
172
- const rowGridStyle = computed(() => {
173
- if (quotaColumns.value.length === 0) {
174
- return { gridTemplateColumns: '1.6fr 1fr 0.6fr' };
175
- }
176
- // Name (1.6fr), price (1fr), N×quota (0.7fr), features (0.5fr)
177
- const quotaCols = Array(quotaColumns.value.length).fill('0.7fr').join(' ');
178
- return { gridTemplateColumns: `1.6fr 1fr ${quotaCols} 0.5fr` };
179
- });
180
-
181
- function onToggle(id: string): void {
182
- openId.value = openId.value === id ? null : id;
183
- }
184
-
185
- function createDraftLabel(planId: string): string {
186
- return formatMessage(msg.value.list.createDraftFor, { planId });
187
- }
188
-
189
- function formatEuro(n: number): string {
190
- return fmtEuro(n, locale.value);
191
- }
192
-
193
- function formatQuotaValue(value: number | undefined, col: QuotaColumnConfig): string {
194
- if (value === undefined) return '–';
195
- if (value === -1) return '∞';
196
- const formatted = col.fractional ? value.toFixed(1) : Math.round(value).toString();
197
- return col.unit ? `${formatted} ${col.unit}` : formatted;
198
- }
199
- </script>
200
-
201
- <style scoped>
202
- .sa-pv-list {
203
- padding: 20px 28px;
204
- }
205
- .sa-pv-list__kpis {
206
- display: grid;
207
- grid-template-columns: repeat(4, 1fr);
208
- gap: 12px;
209
- margin-bottom: 18px;
210
- }
211
- .sa-pv-list__missing {
212
- display: flex;
213
- align-items: flex-start;
214
- gap: 10px;
215
- background: var(--sa-warning-soft, rgba(245, 158, 11, 0.1));
216
- border: 1px solid var(--sa-amber-border, rgba(245, 158, 11, 0.3));
217
- border-radius: 10px;
218
- padding: 12px 16px;
219
- margin-bottom: 14px;
220
- font-size: 12.5px;
221
- color: #7c2d12;
222
- }
223
- .sa-pv-list__missing :deep(.q-icon) {
224
- color: var(--sa-warning, #b45309);
225
- }
226
- .sa-pv-list__missing-btn {
227
- background: #fff;
228
- border: 1px solid var(--sa-amber-border, rgba(245, 158, 11, 0.3));
229
- color: var(--sa-warning, #b45309);
230
- border-radius: 6px;
231
- padding: 3px 8px;
232
- font-size: 11.5px;
233
- font-weight: 600;
234
- cursor: pointer;
235
- margin-left: 6px;
236
- }
237
-
238
- .sa-pv-list__table {
239
- background: #fff;
240
- border: 1px solid var(--sa-border, #e2e8f0);
241
- border-radius: 12px;
242
- overflow: hidden;
243
- box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
244
- }
245
- .sa-pv-list__row {
246
- display: grid;
247
- align-items: center;
248
- gap: 12px;
249
- }
250
- .sa-pv-list__row--default {
251
- grid-template-columns: 1.6fr 1fr 0.6fr;
252
- padding: 10px 14px;
253
- }
254
- .sa-pv-list__head {
255
- background: #fafbfc;
256
- border-bottom: 1px solid var(--sa-border, #e2e8f0);
257
- font-size: 10.5px;
258
- font-weight: 700;
259
- letter-spacing: 0.08em;
260
- color: var(--sa-muted, #64748b);
261
- text-transform: uppercase;
262
- }
263
- .sa-pv-list__th--num {
264
- text-align: right;
265
- font-family: var(--sa-font-mono, ui-monospace, monospace);
266
- font-size: 13px;
267
- }
268
-
269
- .sa-pv-list__default-row {
270
- border-bottom: 1px solid var(--sa-border-soft, #f1f5f9);
271
- }
272
- .sa-pv-list__default-row:last-child {
273
- border-bottom: none;
274
- }
275
- .sa-pv-list__default-name {
276
- display: flex;
277
- align-items: center;
278
- gap: 8px;
279
- }
280
- .sa-pv-list__default-version {
281
- font-family: var(--sa-font-mono, ui-monospace, monospace);
282
- font-size: 11px;
283
- font-weight: 600;
284
- color: var(--sa-muted, #64748b);
285
- background: #f1f5f9;
286
- padding: 2px 6px;
287
- border-radius: 4px;
288
- }
289
- .sa-pv-list__default-chip {
290
- font-size: 9.5px;
291
- font-weight: 700;
292
- letter-spacing: 0.06em;
293
- padding: 2px 6px;
294
- border-radius: 4px;
295
- background: var(--sa-warning-soft, rgba(245, 158, 11, 0.1));
296
- color: var(--sa-warning, #b45309);
297
- }
298
-
299
- .sa-pv-list__draft-hint {
300
- margin-top: 14px;
301
- background: var(--sa-warning-soft, rgba(245, 158, 11, 0.1));
302
- border: 1px solid var(--sa-amber-border, rgba(245, 158, 11, 0.3));
303
- border-radius: 10px;
304
- padding: 12px 16px;
305
- display: flex;
306
- align-items: flex-start;
307
- gap: 12px;
308
- font-size: 12.5px;
309
- color: #7c2d12;
310
- line-height: 1.5;
311
- }
312
- .sa-pv-list__draft-hint :deep(.q-icon) {
313
- color: var(--sa-warning, #b45309);
314
- }
315
- </style>