@saasicat/ui-vue 0.19.0 → 0.21.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/{catalog-JXdKdIqG.d.cts → catalog-BY_t9prv.d.cts} +1 -0
- package/dist/{catalog-JXdKdIqG.d.ts → catalog-BY_t9prv.d.ts} +1 -0
- package/dist/{chunk-RVG6LQHL.js → chunk-7IFKVGZV.js} +1 -1
- package/dist/{chunk-DPTIOQXX.js → chunk-JN6HZGLV.js} +2 -0
- package/dist/{chunk-ORX3ZG47.js → chunk-OTP3OSSW.js} +1 -1
- package/dist/client/index.cjs +2 -0
- package/dist/client/index.d.cts +2 -2
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +2 -2
- package/dist/index.cjs +2 -0
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/quasar/index.cjs +2 -0
- 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-1nCOkvgi.d.cts → use-super-admin-i18n-DlJRGIVq.d.cts} +1 -1
- package/dist/{use-super-admin-i18n-BlqcD0N7.d.ts → use-super-admin-i18n-c_Eqsu9u.d.ts} +1 -1
- package/package.json +2 -2
- package/src/client/i18n/messages/common.ts +2 -0
- package/src/components/admin-page/AdminBody.vue +40 -0
- package/src/components/admin-page/AdminHero.vue +32 -0
- package/src/components/admin-page/AdminPage.vue +16 -0
- package/src/components/admin-page/AdminSection.vue +38 -0
- package/src/components/plan-cockpit/PlanCockpit.vue +15 -0
- package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +8 -8
- package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +10 -11
- package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +8 -8
- package/src/components/plan-cockpit/PlanCockpitVersions.vue +9 -11
- package/src/components/plan-detail/PlanAuditLog.vue +7 -8
- package/src/components/plan-detail/PlanDetail.vue +14 -1
- package/src/components/plan-detail/PlanVersionDiffPanel.vue +5 -8
- package/src/components/plan-detail/PlanVersionsPanel.vue +9 -11
- package/src/components/plan-list/PlanList.vue +224 -257
- package/src/pages-standard/AdminLayout.vue +4 -2
- package/src/pages-standard/AuditPage.vue +18 -43
- package/src/pages-standard/BundlesPage.vue +17 -34
- package/src/pages-standard/DashboardPage.vue +20 -51
- package/src/pages-standard/DiscoveryPage.vue +38 -51
- package/src/pages-standard/EmailHistoryPage.vue +20 -71
- package/src/pages-standard/MarketingCatalogPage.vue +304 -315
- package/src/pages-standard/PilotsPage.vue +24 -56
- package/src/pages-standard/PlansPage.vue +25 -22
- package/src/pages-standard/PlatformEmailPage.vue +19 -51
- package/src/pages-standard/PromoCodeDetailPage.vue +39 -50
- package/src/pages-standard/PromoCodesPage.vue +8 -15
- package/src/pages-standard/SubscriptionsPage.vue +9 -35
- package/src/pages-standard/SuperAdminSetupWizard.vue +2 -1
- package/src/pages-standard/TenantDetailPage.vue +79 -174
- package/src/pages-standard/TenantsPage.vue +8 -33
- package/src/pages-standard/UsersPage.vue +21 -54
- package/src/pages-standard/bundles-page/BundlesToolbar.vue +67 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +111 -73
- package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +54 -62
- package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +53 -35
- package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +7 -7
- package/src/pages-standard/plans-page/PlansPageToast.vue +5 -5
- package/src/pages-standard/sa-theme.css +85 -29
- package/src/pages-standard/tenant-detail/TenantMasterData.vue +28 -0
- package/src/pages-standard/tenant-detail/TenantUsage.vue +31 -0
- package/src/pages-standard/tenant-detail/TenantUsers.vue +21 -0
- package/src/pages-standard/tenant-detail/types.ts +38 -0
- package/src/vue/use-mfa-prompt.ts +72 -0
- package/src/pages-standard/bundles-page/BundlesHeader.vue +0 -68
- package/src/pages-standard/discovery-page/DiscoveryHeader.vue +0 -34
|
@@ -1,75 +1,90 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="
|
|
3
|
-
<div class="
|
|
4
|
-
<span class="
|
|
5
|
-
<span class="
|
|
6
|
-
<span class="
|
|
7
|
-
<div class="
|
|
2
|
+
<div class="sa-marketing-window">
|
|
3
|
+
<div class="sa-marketing-chrome">
|
|
4
|
+
<span class="sa-marketing-chrome-dot" style="background: #ef4444" />
|
|
5
|
+
<span class="sa-marketing-chrome-dot" style="background: #f59e0b" />
|
|
6
|
+
<span class="sa-marketing-chrome-dot" style="background: #10b981" />
|
|
7
|
+
<div class="sa-marketing-chrome-url">{{ previewUrl }}</div>
|
|
8
8
|
</div>
|
|
9
|
-
<div class="
|
|
10
|
-
<div class="
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
<div class="sa-marketing-canvas">
|
|
10
|
+
<div class="sa-marketing-eyebrow">{{ msg.preview.eyebrow }}</div>
|
|
11
|
+
<!-- Not a heading element: this is a picture of the public landing
|
|
12
|
+
page inside a browser mock-up, and assistive technology cannot
|
|
13
|
+
tell a mock-up from the document it is embedded in — a real
|
|
14
|
+
<h1> here would be a second page heading. -->
|
|
15
|
+
<div class="sa-marketing-hero">{{ msg.preview.hero }}</div>
|
|
16
|
+
<p class="sa-marketing-sub">{{ msg.preview.sub }}</p>
|
|
13
17
|
|
|
14
|
-
<div
|
|
18
|
+
<div
|
|
19
|
+
v-if="visibleRows.length === 0"
|
|
20
|
+
class="sa-marketing-banner sa-marketing-banner--info"
|
|
21
|
+
>
|
|
15
22
|
{{ msg.preview.emptyBefore }} <strong>{{ msg.tabs.admin }}</strong
|
|
16
23
|
>{{ msg.preview.emptyAfter }}
|
|
17
24
|
</div>
|
|
18
25
|
|
|
19
|
-
<div v-else class="
|
|
26
|
+
<div v-else class="sa-marketing-grid">
|
|
20
27
|
<div
|
|
21
28
|
v-for="row in visibleRows"
|
|
22
29
|
:key="row.plan.id"
|
|
23
|
-
class="
|
|
30
|
+
class="sa-marketing-card"
|
|
24
31
|
:class="{ featured: row.m.highlight, 'has-promo': !!promoOf(row) }"
|
|
25
32
|
>
|
|
26
33
|
<div
|
|
27
34
|
v-if="promoOf(row)"
|
|
28
|
-
class="
|
|
35
|
+
class="sa-marketing-promo-ribbon"
|
|
29
36
|
:style="{ background: promoColorOf(row) }"
|
|
30
37
|
>
|
|
31
38
|
{{ promoBadgeOf(row) }}
|
|
32
39
|
</div>
|
|
33
|
-
<span v-else-if="row.m.badge" class="
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
<span v-else-if="row.m.badge" class="sa-marketing-card-badge">{{
|
|
41
|
+
row.m.badge
|
|
42
|
+
}}</span>
|
|
43
|
+
<div class="sa-marketing-card-key">{{ row.plan.planKey }}</div>
|
|
44
|
+
<div class="sa-marketing-card-name">
|
|
36
45
|
{{ row.m.displayLabel || row.plan.label }}
|
|
37
46
|
</div>
|
|
38
|
-
<div class="
|
|
47
|
+
<div class="sa-marketing-card-desc">
|
|
39
48
|
{{ row.m.description || row.plan.description || '—' }}
|
|
40
49
|
</div>
|
|
41
50
|
|
|
42
|
-
<div class="
|
|
51
|
+
<div class="sa-marketing-card-price">
|
|
43
52
|
<template v-if="row.m.priceTag">
|
|
44
|
-
<span class="
|
|
53
|
+
<span class="sa-marketing-card-price-big" style="font-size: 22px">
|
|
45
54
|
{{ row.m.priceTag }}
|
|
46
55
|
</span>
|
|
47
56
|
</template>
|
|
48
57
|
<template v-else-if="!row.liveVersion">
|
|
49
|
-
<span class="
|
|
58
|
+
<span class="sa-marketing-card-price-big" style="font-size: 22px">
|
|
50
59
|
{{ msg.preview.priceOnRequest }}
|
|
51
60
|
</span>
|
|
52
61
|
</template>
|
|
53
62
|
<template v-else-if="promoResultOf(row)">
|
|
54
|
-
<span class="
|
|
63
|
+
<span class="sa-marketing-card-price-big">
|
|
55
64
|
{{ formatEuro(promoResultOf(row)?.discounted ?? 0) }}
|
|
56
65
|
</span>
|
|
57
|
-
<span class="
|
|
66
|
+
<span class="sa-marketing-card-price-unit">{{
|
|
67
|
+
msg.preview.perMonth
|
|
68
|
+
}}</span>
|
|
58
69
|
</template>
|
|
59
70
|
<template v-else>
|
|
60
|
-
<span class="
|
|
71
|
+
<span class="sa-marketing-card-price-big">
|
|
61
72
|
{{ formatEuro(monthlyOf(row)) }}
|
|
62
73
|
</span>
|
|
63
|
-
<span class="
|
|
74
|
+
<span class="sa-marketing-card-price-unit">{{
|
|
75
|
+
msg.preview.perMonth
|
|
76
|
+
}}</span>
|
|
64
77
|
</template>
|
|
65
78
|
</div>
|
|
66
|
-
<div v-if="promoResultOf(row)" class="
|
|
79
|
+
<div v-if="promoResultOf(row)" class="sa-marketing-card-price-strike">
|
|
67
80
|
<s>{{ formatEuro(promoResultOf(row)?.original ?? 0) }}</s>
|
|
68
|
-
<span class="
|
|
81
|
+
<span class="sa-marketing-price-regular">{{
|
|
82
|
+
msg.preview.regularPrice
|
|
83
|
+
}}</span>
|
|
69
84
|
</div>
|
|
70
85
|
<div
|
|
71
86
|
v-else-if="row.liveVersion && yearlyOf(row) > 0 && !row.m.priceTag"
|
|
72
|
-
class="
|
|
87
|
+
class="sa-marketing-card-price-y"
|
|
73
88
|
>
|
|
74
89
|
{{
|
|
75
90
|
formatMessage(msg.preview.orYearly, {
|
|
@@ -78,18 +93,21 @@
|
|
|
78
93
|
}}
|
|
79
94
|
</div>
|
|
80
95
|
|
|
81
|
-
<button type="button" class="
|
|
82
|
-
<div
|
|
96
|
+
<button type="button" class="sa-marketing-card-cta">{{ ctaText(row) }}</button>
|
|
97
|
+
<div
|
|
98
|
+
v-if="showTrialNote(row) && !promoOf(row)"
|
|
99
|
+
class="sa-marketing-card-trialnote"
|
|
100
|
+
>
|
|
83
101
|
{{ formatMessage(msg.preview.trialNote, { days: row.m.trialDays }) }}
|
|
84
102
|
</div>
|
|
85
|
-
<div v-if="promoFineprintOf(row)" class="
|
|
103
|
+
<div v-if="promoFineprintOf(row)" class="sa-marketing-card-fineprint">
|
|
86
104
|
{{ promoFineprintOf(row) }}
|
|
87
105
|
</div>
|
|
88
106
|
|
|
89
|
-
<div class="
|
|
90
|
-
<ul v-if="row.m.topFeatures.length > 0" class="
|
|
107
|
+
<div class="sa-marketing-card-includes">{{ msg.topFeatures }}</div>
|
|
108
|
+
<ul v-if="row.m.topFeatures.length > 0" class="sa-marketing-card-features">
|
|
91
109
|
<li v-for="(f, i) in row.m.topFeatures" :key="i">
|
|
92
|
-
<span class="
|
|
110
|
+
<span class="sa-marketing-tick">
|
|
93
111
|
<svg
|
|
94
112
|
width="14"
|
|
95
113
|
height="14"
|
|
@@ -107,7 +125,7 @@
|
|
|
107
125
|
</span>
|
|
108
126
|
</li>
|
|
109
127
|
</ul>
|
|
110
|
-
<div v-else class="
|
|
128
|
+
<div v-else class="sa-marketing-card-features-empty">
|
|
111
129
|
{{ msg.preview.noTopFeatures }}
|
|
112
130
|
</div>
|
|
113
131
|
</div>
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="
|
|
3
|
-
<div class="
|
|
2
|
+
<div class="sa-marketing-toolbar">
|
|
3
|
+
<div class="sa-marketing-tabbar">
|
|
4
4
|
<button
|
|
5
5
|
type="button"
|
|
6
|
-
class="
|
|
6
|
+
class="sa-marketing-tab"
|
|
7
7
|
:class="{ active: tab === 'preview' }"
|
|
8
8
|
@click="$emit('update:tab', 'preview')"
|
|
9
9
|
>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
</button>
|
|
12
12
|
<button
|
|
13
13
|
type="button"
|
|
14
|
-
class="
|
|
14
|
+
class="sa-marketing-tab"
|
|
15
15
|
:class="{ active: tab === 'admin' }"
|
|
16
16
|
@click="$emit('update:tab', 'admin')"
|
|
17
17
|
>
|
|
@@ -19,17 +19,17 @@
|
|
|
19
19
|
</button>
|
|
20
20
|
<button
|
|
21
21
|
type="button"
|
|
22
|
-
class="
|
|
22
|
+
class="sa-marketing-tab"
|
|
23
23
|
:class="{ active: tab === 'promos' }"
|
|
24
24
|
@click="$emit('update:tab', 'promos')"
|
|
25
25
|
>
|
|
26
26
|
{{ msg.tabs.promos }}
|
|
27
|
-
<span v-if="activePromoCount > 0" class="
|
|
27
|
+
<span v-if="activePromoCount > 0" class="sa-marketing-tab-count">
|
|
28
28
|
{{ activePromoCount }}
|
|
29
29
|
</span>
|
|
30
30
|
</button>
|
|
31
31
|
</div>
|
|
32
|
-
<div class="
|
|
32
|
+
<div class="sa-marketing-meta">
|
|
33
33
|
<span>catalogVersion</span>
|
|
34
34
|
<code>{{ catalogVersion }}</code>
|
|
35
35
|
<span>·</span>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<transition name="
|
|
2
|
+
<transition name="sa-plans-toast">
|
|
3
3
|
<div v-if="message" class="sa-plans__toast">
|
|
4
4
|
<span class="sa-plans__toast-tick" aria-hidden="true">
|
|
5
5
|
<svg
|
|
@@ -55,14 +55,14 @@ defineProps<{
|
|
|
55
55
|
place-items: center;
|
|
56
56
|
flex: 0 0 auto;
|
|
57
57
|
}
|
|
58
|
-
.
|
|
59
|
-
.
|
|
58
|
+
.sa-plans-toast-enter-active,
|
|
59
|
+
.sa-plans-toast-leave-active {
|
|
60
60
|
transition:
|
|
61
61
|
opacity 0.22s ease,
|
|
62
62
|
transform 0.22s ease;
|
|
63
63
|
}
|
|
64
|
-
.
|
|
65
|
-
.
|
|
64
|
+
.sa-plans-toast-enter-from,
|
|
65
|
+
.sa-plans-toast-leave-to {
|
|
66
66
|
opacity: 0;
|
|
67
67
|
transform: translate(-50%, 12px);
|
|
68
68
|
}
|
|
@@ -37,18 +37,43 @@
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/* ─────────────────────────────────────────────────────────────
|
|
40
|
-
* Shared page chrome — header strip, stat pills,
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
40
|
+
* Shared page chrome — page frame, header strip, section, stat pills, card.
|
|
41
|
+
*
|
|
42
|
+
* These classes are the styling half of the page shell. The structural half is
|
|
43
|
+
* the `AdminPage` / `AdminHero` / `AdminSection` components in
|
|
44
|
+
* `components/admin-page/`, and pages are expected to reach for those rather
|
|
45
|
+
* than write the markup by hand: a component with a required `title` prop
|
|
46
|
+
* cannot be half-adopted the way a class name can.
|
|
47
|
+
*
|
|
48
|
+
* The classes stay public and unprefixed by scoping on purpose, so a consumer
|
|
49
|
+
* app can restyle `.sa-page-head__title` from its own CSS without patching the
|
|
50
|
+
* platform.
|
|
44
51
|
* ───────────────────────────────────────────────────────────── */
|
|
45
52
|
|
|
53
|
+
/* Page frame — canvas, min-height and the page padding, in one place
|
|
54
|
+
* instead of once per page (see AdminPage). */
|
|
55
|
+
.sa-page {
|
|
56
|
+
min-height: calc(100vh - 56px);
|
|
57
|
+
background: var(--sa-bg-app, #f1f5f9);
|
|
58
|
+
padding: 20px 28px 28px;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* The hero reads as a banner rather than as the first paragraph of the page:
|
|
62
|
+
* a tinted, bordered block that separates "which page am I on" from the
|
|
63
|
+
* content below it. Tint and border come from the primary token, so an app
|
|
64
|
+
* that overrides `--sa-primary` re-tints the hero with it. */
|
|
46
65
|
.sa-page-head {
|
|
66
|
+
/* Top-aligned, so the controls sit level with the title rather than
|
|
67
|
+
* drifting down with the subtitle's height. */
|
|
47
68
|
display: flex;
|
|
48
|
-
align-items: flex-
|
|
69
|
+
align-items: flex-start;
|
|
49
70
|
gap: 20px;
|
|
50
|
-
margin-bottom:
|
|
71
|
+
margin-bottom: 20px;
|
|
51
72
|
flex-wrap: wrap;
|
|
73
|
+
padding: 13px 22px 15px;
|
|
74
|
+
border: 1px solid var(--sa-primary-border);
|
|
75
|
+
border-radius: 16px;
|
|
76
|
+
background: linear-gradient(135deg, var(--sa-primary-soft) 0%, var(--sa-primary-softer) 100%);
|
|
52
77
|
}
|
|
53
78
|
.sa-page-head__title {
|
|
54
79
|
margin: 0;
|
|
@@ -56,11 +81,13 @@
|
|
|
56
81
|
font-size: 22px;
|
|
57
82
|
font-weight: 700;
|
|
58
83
|
letter-spacing: -0.02em;
|
|
84
|
+
line-height: 1.25;
|
|
59
85
|
color: var(--sa-heading);
|
|
60
86
|
}
|
|
61
87
|
.sa-page-head__sub {
|
|
62
|
-
margin:
|
|
88
|
+
margin: 1px 0 0;
|
|
63
89
|
font-size: 12.5px;
|
|
90
|
+
line-height: 1.35;
|
|
64
91
|
color: var(--sa-muted);
|
|
65
92
|
}
|
|
66
93
|
.sa-page-head__actions {
|
|
@@ -175,35 +202,64 @@
|
|
|
175
202
|
0 2px 6px rgba(168, 85, 247, 0.1);
|
|
176
203
|
}
|
|
177
204
|
|
|
178
|
-
/*
|
|
179
|
-
.
|
|
205
|
+
/* Page body — everything below the hero (see AdminBody), including the
|
|
206
|
+
* loading and empty states the pages used to spell out one by one. */
|
|
207
|
+
.sa-page-body__state {
|
|
208
|
+
padding: 32px 0;
|
|
209
|
+
color: var(--sa-muted);
|
|
180
210
|
display: flex;
|
|
181
211
|
align-items: center;
|
|
182
|
-
gap:
|
|
183
|
-
margin-bottom: 14px;
|
|
184
|
-
flex-wrap: wrap;
|
|
212
|
+
gap: 12px;
|
|
185
213
|
}
|
|
186
|
-
.sa-
|
|
187
|
-
|
|
188
|
-
|
|
214
|
+
.sa-page-body__state--empty {
|
|
215
|
+
font-size: 13px;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* Rhythm only for sections sitting directly in the page flow. Deliberately a
|
|
219
|
+
* child selector: sections placed inside a grid or flex container get their
|
|
220
|
+
* spacing from that container, and a blanket margin would fight it. */
|
|
221
|
+
.sa-page > .sa-section,
|
|
222
|
+
.sa-page-body > .sa-section {
|
|
223
|
+
margin-bottom: 16px;
|
|
224
|
+
}
|
|
225
|
+
.sa-page > .sa-section:last-child,
|
|
226
|
+
.sa-page-body > .sa-section:last-child {
|
|
227
|
+
margin-bottom: 0;
|
|
228
|
+
}
|
|
229
|
+
/* Section — a labelled block within a page (see AdminSection). Purely
|
|
230
|
+
* structural: it carries the heading scale and the rhythm between blocks, not
|
|
231
|
+
* a surface. A section that should read as a card adds `.sa-card` alongside. */
|
|
232
|
+
.sa-section__head {
|
|
189
233
|
display: flex;
|
|
190
234
|
align-items: center;
|
|
191
|
-
gap:
|
|
192
|
-
|
|
193
|
-
border: 1px solid var(--sa-border);
|
|
194
|
-
border-radius: 10px;
|
|
195
|
-
padding: 6px 12px;
|
|
235
|
+
gap: 10px;
|
|
236
|
+
margin-bottom: 12px;
|
|
196
237
|
}
|
|
197
|
-
.sa-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
color: var(--sa-
|
|
238
|
+
.sa-section__title {
|
|
239
|
+
margin: 0;
|
|
240
|
+
font-family: var(--sa-font-head);
|
|
241
|
+
font-size: 14px;
|
|
242
|
+
font-weight: 700;
|
|
243
|
+
letter-spacing: -0.005em;
|
|
244
|
+
color: var(--sa-heading);
|
|
204
245
|
}
|
|
205
|
-
.sa-
|
|
206
|
-
|
|
246
|
+
.sa-section__sub {
|
|
247
|
+
margin: 2px 0 0;
|
|
248
|
+
font-size: 12.5px;
|
|
249
|
+
color: var(--sa-muted);
|
|
250
|
+
}
|
|
251
|
+
.sa-section__actions {
|
|
252
|
+
margin-left: auto;
|
|
253
|
+
display: flex;
|
|
254
|
+
gap: 8px;
|
|
255
|
+
align-items: center;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* Key-value grid — the layout `KvBlock` is meant to sit in. */
|
|
259
|
+
.sa-kv-grid {
|
|
260
|
+
display: grid;
|
|
261
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
262
|
+
gap: 12px;
|
|
207
263
|
}
|
|
208
264
|
|
|
209
265
|
/* Generic card (cards in dashboard/detail sections). */
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="sa-kv-grid">
|
|
3
|
+
<KvBlock :label="labels.plan" :value="data.subscription?.plan ?? EMPTY" />
|
|
4
|
+
<KvBlock :label="labels.status" :value="data.subscription?.status ?? EMPTY" />
|
|
5
|
+
<KvBlock :label="labels.pilot" :value="data.subscription?.isPilot ? yes : no" />
|
|
6
|
+
<KvBlock :label="labels.trialEnd" :value="formatDate(data.subscription?.trialEndsAt)" />
|
|
7
|
+
<KvBlock :label="labels.pilotEnd" :value="formatDate(data.subscription?.pilotEndsAt)" />
|
|
8
|
+
<KvBlock v-if="data.vatId" :label="labels.vatId" :value="data.vatId" />
|
|
9
|
+
<slot />
|
|
10
|
+
</div>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script setup lang="ts">
|
|
14
|
+
import KvBlock from '../../components/KvBlock.vue';
|
|
15
|
+
import type { TenantDetailData, TenantMasterDataLabels } from './types.js';
|
|
16
|
+
|
|
17
|
+
// The subscription facts of a tenant. Pure presentation: the page resolves the
|
|
18
|
+
// labels and the date format, so this block has no i18n and no data access.
|
|
19
|
+
defineProps<{
|
|
20
|
+
data: TenantDetailData;
|
|
21
|
+
labels: TenantMasterDataLabels;
|
|
22
|
+
formatDate: (value: string | null | undefined) => string;
|
|
23
|
+
yes: string;
|
|
24
|
+
no: string;
|
|
25
|
+
}>();
|
|
26
|
+
|
|
27
|
+
const EMPTY = '—';
|
|
28
|
+
</script>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="sa-kv-grid">
|
|
3
|
+
<KvBlock
|
|
4
|
+
v-for="(field, i) in fields"
|
|
5
|
+
:key="field.label + i"
|
|
6
|
+
:label="field.label"
|
|
7
|
+
:value="resolve(field)"
|
|
8
|
+
/>
|
|
9
|
+
</div>
|
|
10
|
+
</template>
|
|
11
|
+
|
|
12
|
+
<script setup lang="ts">
|
|
13
|
+
import KvBlock from '../../components/KvBlock.vue';
|
|
14
|
+
import type { TenantDetailData, VerbrauchField } from './types.js';
|
|
15
|
+
|
|
16
|
+
// Usage counters. A field either names a key in `data.counts` or brings its own
|
|
17
|
+
// getter, so a consumer can surface a number the platform knows nothing about.
|
|
18
|
+
const props = defineProps<{
|
|
19
|
+
data: TenantDetailData;
|
|
20
|
+
fields: readonly VerbrauchField[];
|
|
21
|
+
}>();
|
|
22
|
+
|
|
23
|
+
const EMPTY = '—';
|
|
24
|
+
|
|
25
|
+
function resolve(field: VerbrauchField): string {
|
|
26
|
+
if (field.getter) return String(field.getter(props.data));
|
|
27
|
+
if (!field.key) return EMPTY;
|
|
28
|
+
const value = props.data.counts?.[field.key];
|
|
29
|
+
return value === undefined || value === null ? '0' : String(value);
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<q-table
|
|
3
|
+
flat
|
|
4
|
+
:rows="users"
|
|
5
|
+
:columns="columns"
|
|
6
|
+
row-key="id"
|
|
7
|
+
:pagination="{ rowsPerPage: 0 }"
|
|
8
|
+
hide-pagination
|
|
9
|
+
/>
|
|
10
|
+
</template>
|
|
11
|
+
|
|
12
|
+
<script setup lang="ts">
|
|
13
|
+
import type { QTableColumn } from 'quasar';
|
|
14
|
+
|
|
15
|
+
// The tenant's users. The column set comes from the page, which falls back to
|
|
16
|
+
// the platform default when the consumer supplies none.
|
|
17
|
+
defineProps<{
|
|
18
|
+
users: Array<Record<string, unknown> & { id: string }>;
|
|
19
|
+
columns: QTableColumn[];
|
|
20
|
+
}>();
|
|
21
|
+
</script>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface TenantDetailData {
|
|
2
|
+
id: string;
|
|
3
|
+
slug: string;
|
|
4
|
+
name: string;
|
|
5
|
+
isActive: boolean;
|
|
6
|
+
vatId?: string | null;
|
|
7
|
+
subscription?: {
|
|
8
|
+
plan?: string | null;
|
|
9
|
+
status?: string | null;
|
|
10
|
+
isPilot?: boolean | null;
|
|
11
|
+
trialEndsAt?: string | null;
|
|
12
|
+
pilotEndsAt?: string | null;
|
|
13
|
+
} | null;
|
|
14
|
+
users?: Array<Record<string, unknown> & { id: string }>;
|
|
15
|
+
/** Freely selectable usage numbers — the page renders them via `verbrauchFields`. */
|
|
16
|
+
counts?: Record<string, number | string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface VerbrauchField {
|
|
20
|
+
label: string;
|
|
21
|
+
/** Lookup key in `data.counts`. */
|
|
22
|
+
key?: string;
|
|
23
|
+
/** Alternative: custom getter. Takes precedence over `key`. */
|
|
24
|
+
getter?: (data: TenantDetailData) => string | number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The resolved labels for the master-data block. The page owns the i18n
|
|
29
|
+
* lookup and the per-label prop overrides; the block only renders them.
|
|
30
|
+
*/
|
|
31
|
+
export interface TenantMasterDataLabels {
|
|
32
|
+
plan: string;
|
|
33
|
+
status: string;
|
|
34
|
+
pilot: string;
|
|
35
|
+
trialEnd: string;
|
|
36
|
+
pilotEnd: string;
|
|
37
|
+
vatId: string;
|
|
38
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { ref, type Ref } from 'vue';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Turns the MFA dialog into an awaitable step.
|
|
5
|
+
*
|
|
6
|
+
* A write flow that needs a second factor wants to read as one function:
|
|
7
|
+
* ask for the code, then call the API with it. What that needs is a promise
|
|
8
|
+
* the dialog resolves — and the plumbing for that (a visibility flag, an
|
|
9
|
+
* error slot, a description, and a resolver held between two event handlers)
|
|
10
|
+
* was spelled out identically on five standard pages.
|
|
11
|
+
*
|
|
12
|
+
* Cancelling resolves to `null` rather than rejecting: a user closing the
|
|
13
|
+
* dialog is an outcome, not an error, and `if (!code) return` reads better at
|
|
14
|
+
* the call site than a try/catch around the happy path.
|
|
15
|
+
*/
|
|
16
|
+
export interface MfaPrompt {
|
|
17
|
+
/** Bind to the dialog's `model-value`. */
|
|
18
|
+
readonly show: Ref<boolean>;
|
|
19
|
+
/** Bind to the dialog's `description`. */
|
|
20
|
+
readonly description: Ref<string>;
|
|
21
|
+
/** Bind to the dialog's `error`; set it to keep the dialog open on a failed code. */
|
|
22
|
+
readonly error: Ref<string>;
|
|
23
|
+
/** Resolves with the entered code, or `null` when the user cancels. */
|
|
24
|
+
prompt: (description: string) => Promise<string | null>;
|
|
25
|
+
/** Bind to the dialog's `confirm` event. */
|
|
26
|
+
onConfirm: (code: string) => void;
|
|
27
|
+
/** Bind to the dialog's `update:model-value` event. */
|
|
28
|
+
onVisibility: (open: boolean) => void;
|
|
29
|
+
/** Closes the dialog and cancels a pending prompt. */
|
|
30
|
+
close: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useMfaPrompt(): MfaPrompt {
|
|
34
|
+
const show = ref(false);
|
|
35
|
+
const description = ref('');
|
|
36
|
+
const error = ref('');
|
|
37
|
+
let pendingResolve: ((code: string | null) => void) | null = null;
|
|
38
|
+
|
|
39
|
+
function settle(code: string | null): void {
|
|
40
|
+
const resolve = pendingResolve;
|
|
41
|
+
pendingResolve = null;
|
|
42
|
+
resolve?.(code);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
show,
|
|
47
|
+
description,
|
|
48
|
+
error,
|
|
49
|
+
prompt(next: string): Promise<string | null> {
|
|
50
|
+
// A prompt opening while another is pending would strand the first
|
|
51
|
+
// caller's promise forever; cancel it rather than leak it.
|
|
52
|
+
settle(null);
|
|
53
|
+
description.value = next;
|
|
54
|
+
error.value = '';
|
|
55
|
+
show.value = true;
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
pendingResolve = resolve;
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
onConfirm(code: string): void {
|
|
61
|
+
settle(code);
|
|
62
|
+
},
|
|
63
|
+
onVisibility(open: boolean): void {
|
|
64
|
+
show.value = open;
|
|
65
|
+
if (!open) settle(null);
|
|
66
|
+
},
|
|
67
|
+
close(): void {
|
|
68
|
+
show.value = false;
|
|
69
|
+
settle(null);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<header class="sa-bundles__head">
|
|
3
|
-
<div>
|
|
4
|
-
<h1 class="sa-bundles__title">{{ msg.header.title }}</h1>
|
|
5
|
-
<p class="sa-bundles__sub">{{ msg.header.subtitle }}</p>
|
|
6
|
-
</div>
|
|
7
|
-
<div class="sa-bundles__head-actions">
|
|
8
|
-
<q-select
|
|
9
|
-
v-if="locales.length > 1"
|
|
10
|
-
:model-value="displayLocale"
|
|
11
|
-
:options="localeOptions"
|
|
12
|
-
outlined
|
|
13
|
-
dense
|
|
14
|
-
emit-value
|
|
15
|
-
map-options
|
|
16
|
-
class="sa-bundles__locale"
|
|
17
|
-
:label="msg.header.displayLocale"
|
|
18
|
-
@update:model-value="(value) => emit('update:displayLocale', String(value))"
|
|
19
|
-
>
|
|
20
|
-
<template #prepend><q-icon name="translate" size="18px" /></template>
|
|
21
|
-
</q-select>
|
|
22
|
-
<q-btn
|
|
23
|
-
unelevated
|
|
24
|
-
color="primary"
|
|
25
|
-
icon="add"
|
|
26
|
-
:label="msg.header.newBundle"
|
|
27
|
-
@click="emit('create')"
|
|
28
|
-
/>
|
|
29
|
-
<q-btn flat icon="refresh" :loading="loading" @click="emit('refresh')">
|
|
30
|
-
<q-tooltip>{{ msg.header.reload }}</q-tooltip>
|
|
31
|
-
</q-btn>
|
|
32
|
-
</div>
|
|
33
|
-
</header>
|
|
34
|
-
</template>
|
|
35
|
-
|
|
36
|
-
<script setup lang="ts">
|
|
37
|
-
import { computed } from 'vue';
|
|
38
|
-
|
|
39
|
-
import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
|
|
40
|
-
|
|
41
|
-
// The language selector controls which locale feature/quota labels are resolved
|
|
42
|
-
// on the create form and in the detail view (fallback locale → DE → key). The default is the
|
|
43
|
-
// default locale (DE); further options come from the active project locales.
|
|
44
|
-
|
|
45
|
-
const props = defineProps<{
|
|
46
|
-
loading: boolean;
|
|
47
|
-
displayLocale: string;
|
|
48
|
-
locales: string[];
|
|
49
|
-
}>();
|
|
50
|
-
|
|
51
|
-
const emit = defineEmits<{
|
|
52
|
-
create: [];
|
|
53
|
-
refresh: [];
|
|
54
|
-
'update:displayLocale': [locale: string];
|
|
55
|
-
}>();
|
|
56
|
-
|
|
57
|
-
const msg = useSaMessages('bundles');
|
|
58
|
-
|
|
59
|
-
const localeOptions = computed(() =>
|
|
60
|
-
props.locales.map((locale) => ({ label: locale.toUpperCase(), value: locale })),
|
|
61
|
-
);
|
|
62
|
-
</script>
|
|
63
|
-
|
|
64
|
-
<style scoped>
|
|
65
|
-
.sa-bundles__locale {
|
|
66
|
-
min-width: 150px;
|
|
67
|
-
}
|
|
68
|
-
</style>
|