@saasicat/ui-vue 0.19.0 → 0.22.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-DJcfm5N2.d.cts} +21 -7
- package/dist/{catalog-JXdKdIqG.d.ts → catalog-DJcfm5N2.d.ts} +21 -7
- package/dist/{chunk-DPTIOQXX.js → chunk-F7NRM6KI.js} +56 -28
- package/dist/{chunk-RVG6LQHL.js → chunk-LY52T4N4.js} +21 -2
- package/dist/{chunk-ORX3ZG47.js → chunk-XMMFN2WJ.js} +74 -3
- package/dist/client/index.cjs +134 -29
- package/dist/client/index.d.cts +53 -3
- package/dist/client/index.d.ts +53 -3
- package/dist/client/index.js +14 -2
- package/dist/index.cjs +321 -162
- package/dist/index.d.cts +30 -8
- package/dist/index.d.ts +30 -8
- package/dist/index.js +176 -133
- package/dist/quasar/index.cjs +78 -29
- package/dist/quasar/index.d.cts +2 -2
- package/dist/quasar/index.d.ts +2 -2
- package/dist/quasar/index.js +6 -2
- package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
- package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
- package/dist/{use-super-admin-i18n-BlqcD0N7.d.ts → use-super-admin-i18n-CHNdsSlm.d.ts} +34 -7
- package/dist/{use-super-admin-i18n-1nCOkvgi.d.cts → use-super-admin-i18n-CgTuNi8h.d.cts} +34 -7
- package/package.json +18 -8
- package/src/client/i18n/messages/bundles.ts +0 -2
- package/src/client/i18n/messages/common.ts +24 -0
- package/src/client/i18n/messages/discovery.ts +2 -2
- package/src/client/i18n/messages/marketing.ts +2 -4
- package/src/client/i18n/messages/plan-detail.ts +0 -4
- package/src/client/i18n/messages/plans.ts +20 -8
- package/src/client/i18n/messages/tenants.ts +0 -2
- package/src/client/index.ts +1 -0
- package/src/client/manifest-loader.ts +1 -1
- package/src/client/resolve-plans.ts +144 -0
- package/src/components/BundleVersionPublishDialog.vue +6 -6
- package/src/components/KvBlock.vue +4 -4
- package/src/components/MarketingPromotionsTab.vue +34 -48
- package/src/components/MfaPromptDialog.vue +8 -2
- package/src/components/TenantActionConfirmDialog.vue +4 -4
- package/src/components/VersionDiffPreview.vue +7 -7
- package/src/components/admin-page/AdminBody.vue +40 -0
- package/src/components/admin-page/AdminFilters.vue +12 -0
- package/src/components/admin-page/AdminHero.vue +49 -0
- package/src/components/admin-page/AdminKpi.vue +84 -0
- package/src/components/admin-page/AdminPage.vue +16 -0
- package/src/components/admin-page/AdminPaginator.vue +195 -0
- package/src/components/admin-page/AdminRefreshBtn.vue +39 -0
- package/src/components/admin-page/AdminSection.vue +53 -0
- package/src/components/admin-page/AdminStatistics.vue +40 -0
- package/src/components/admin-page/AdminTable.vue +175 -0
- package/src/components/bundle-editor/BundleCreatePanel.vue +23 -23
- package/src/components/bundle-editor/BundleFeaturesEditor.vue +7 -7
- package/src/components/bundle-editor/BundlePlanCompatPicker.vue +9 -9
- package/src/components/bundle-editor/BundleQuotasEditor.vue +11 -11
- package/src/components/bundle-editor/BundleStatusBanner.vue +6 -6
- package/src/components/bundle-editor/BundleVersionInlineEditor.vue +18 -18
- package/src/components/bundle-editor/BundleVersionStrip.vue +12 -12
- package/src/components/dialogs/PilotCreateDialog.vue +12 -12
- package/src/components/dialogs/PilotEditDialog.vue +7 -7
- package/src/components/dialogs/PromoCodeCreateDialog.vue +8 -8
- package/src/components/dialogs/PromoCodeDialogFields.vue +33 -19
- package/src/components/dialogs/PromoCodeEditDialog.vue +10 -10
- package/src/components/plan/PlanGrid.vue +1 -1
- package/src/components/plan-create-dialog/PlanCreateDialog.vue +34 -46
- package/src/components/plan-detail/PlanAuditLog.vue +7 -8
- package/src/components/plan-detail/PlanDetail.vue +95 -292
- package/src/components/plan-detail/PlanDetailKpis.vue +22 -27
- package/src/components/plan-detail/PlanTitleEdit.vue +133 -0
- 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 +206 -457
- package/src/components/plan-matrix/PlanMatrix.vue +66 -183
- package/src/components/plan-review/PlanReview.vue +191 -275
- package/src/components/plan-version-editor/PlanCatalogPreview.vue +7 -5
- package/src/components/plan-version-editor/PlanVersionEditor.vue +200 -295
- package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +23 -55
- package/src/index.ts +1 -0
- package/src/pages-standard/AdminLayout.vue +38 -12
- package/src/pages-standard/AdminManifestErrorPage.vue +79 -9
- package/src/pages-standard/AuditPage.vue +65 -109
- package/src/pages-standard/BundlesPage.vue +146 -179
- package/src/pages-standard/DashboardPage.vue +59 -154
- package/src/pages-standard/DiscoveryPage.vue +172 -227
- package/src/pages-standard/EmailHistoryPage.vue +118 -159
- package/src/pages-standard/MarketingCatalogPage.vue +399 -440
- package/src/pages-standard/PilotsPage.vue +69 -117
- package/src/pages-standard/PlansPage.vue +240 -112
- package/src/pages-standard/PlatformEmailPage.vue +56 -91
- package/src/pages-standard/PromoCodeDetailPage.vue +49 -74
- package/src/pages-standard/PromoCodesPage.vue +95 -128
- package/src/pages-standard/SubscriptionsPage.vue +19 -52
- package/src/pages-standard/SuperAdminLoginPage.vue +12 -12
- package/src/pages-standard/SuperAdminSetupWizard.vue +22 -18
- package/src/pages-standard/TenantDetailPage.vue +100 -191
- package/src/pages-standard/TenantsPage.vue +190 -295
- package/src/pages-standard/UsersPage.vue +89 -157
- package/src/pages-standard/bundles-page/BundleAccordionList.vue +20 -17
- package/src/pages-standard/bundles-page/BundlesKpis.vue +13 -22
- package/src/pages-standard/bundles-page/BundlesToolbar.vue +63 -0
- package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +10 -10
- package/src/pages-standard/discovery-page/DiscoveryCapList.vue +5 -5
- package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +16 -16
- package/src/pages-standard/discovery-page/DiscoveryKpis.vue +31 -27
- package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +7 -7
- package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +111 -73
- package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +43 -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/plan-versions/PlanDiffCard.vue +6 -6
- package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +6 -6
- package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +16 -16
- package/src/pages-standard/plans-page/PlanBundleOverview.vue +14 -29
- package/src/pages-standard/plans-page/PlanPublishDialog.vue +1 -1
- package/src/pages-standard/plans-page/PlansPageToast.vue +6 -6
- package/src/pages-standard/sa-theme.css +552 -86
- 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 +15 -0
- package/src/pages-standard/tenant-detail/types.ts +38 -0
- package/src/pages-standard/tenants/StatusPill.vue +2 -2
- package/src/pages-tenant/MySubscriptionBundlesPage.vue +25 -25
- package/src/pages-tenant/OnboardingConfigurator.vue +1 -5
- package/src/pages-tenant/PackageSnapshotPanel.vue +1 -1
- package/src/pages-tenant/TenantPlanSection.vue +12 -20
- package/src/quasar/create-super-admin-app.ts +4 -0
- package/src/vue/entitlement-provider.ts +0 -1
- package/src/vue/feature-router-guard.ts +0 -1
- package/src/vue/super-admin-context.ts +106 -1
- package/src/vue/use-api-list.ts +2 -1
- package/src/vue/use-mfa-prompt.ts +72 -0
- package/src/vue/use-pagination.ts +63 -0
- package/src/vue/use-plans.ts +1 -2
- package/src/vue/use-sign-out.ts +90 -0
- package/src/vue/use-tenant-billing.ts +1 -1
- package/src/vue/use-tenant-manifest.ts +0 -1
- package/src/components/KpiCard.vue +0 -53
- package/src/components/plan-cockpit/PlanCockpit.vue +0 -1012
- package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +0 -72
- package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +0 -100
- package/src/components/plan-cockpit/PlanCockpitHeader.vue +0 -101
- package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +0 -112
- package/src/components/plan-cockpit/PlanCockpitKpis.vue +0 -68
- package/src/components/plan-cockpit/PlanCockpitVersions.vue +0 -251
- package/src/components/plan-cockpit/types.ts +0 -72
- package/src/components/plan-detail/PlanDetailHeader.vue +0 -194
- package/src/pages-standard/bundles-page/BundlesHeader.vue +0 -68
- package/src/pages-standard/discovery-page/DiscoveryHeader.vue +0 -34
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<q-dialog
|
|
3
3
|
:model-value="modelValue"
|
|
4
|
-
@update:model-value="emit('update:modelValue', $event)"
|
|
5
4
|
persistent
|
|
5
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
6
6
|
>
|
|
7
7
|
<q-card class="mfa-card">
|
|
8
8
|
<q-card-section class="header">
|
|
@@ -23,15 +23,21 @@
|
|
|
23
23
|
:error-message="error"
|
|
24
24
|
/>
|
|
25
25
|
<slot name="hint">
|
|
26
|
+
<!-- `setupHint` is markup the hosting app authored in its
|
|
27
|
+
own source (typically a link to its MFA setup docs). It
|
|
28
|
+
never carries request data, and an app that wants full
|
|
29
|
+
control over the node uses the `hint` slot instead. -->
|
|
30
|
+
<!-- eslint-disable vue/no-v-html -->
|
|
26
31
|
<div
|
|
27
32
|
v-if="setupHint"
|
|
28
33
|
class="text-caption text-grey-7 q-mt-xs"
|
|
29
34
|
v-html="setupHint"
|
|
30
35
|
/>
|
|
36
|
+
<!-- eslint-enable vue/no-v-html -->
|
|
31
37
|
</slot>
|
|
32
38
|
</q-card-section>
|
|
33
39
|
<q-card-actions align="right">
|
|
34
|
-
<q-btn flat :label="common.cancel"
|
|
40
|
+
<q-btn v-close-popup flat :label="common.cancel" />
|
|
35
41
|
<q-btn
|
|
36
42
|
unelevated
|
|
37
43
|
color="amber-9"
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<q-dialog
|
|
3
3
|
:model-value="modelValue"
|
|
4
|
-
@update:model-value="emit('update:modelValue', $event)"
|
|
5
4
|
persistent
|
|
5
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
6
6
|
>
|
|
7
7
|
<q-card class="tenant-action-confirm">
|
|
8
8
|
<q-card-section class="header">
|
|
@@ -236,16 +236,16 @@ function onCancel(): void {
|
|
|
236
236
|
}
|
|
237
237
|
.tenant-action-confirm__lead {
|
|
238
238
|
margin: 0 0 12px;
|
|
239
|
-
color:
|
|
239
|
+
color: var(--sa-muted-dark);
|
|
240
240
|
line-height: 1.5;
|
|
241
241
|
}
|
|
242
242
|
.tenant-action-confirm__hint {
|
|
243
243
|
margin: 0 0 8px;
|
|
244
|
-
color:
|
|
244
|
+
color: var(--sa-muted-dark);
|
|
245
245
|
font-size: 13px;
|
|
246
246
|
}
|
|
247
247
|
.tenant-action-confirm__hint code {
|
|
248
|
-
background:
|
|
248
|
+
background: var(--sa-border-soft);
|
|
249
249
|
padding: 1px 6px;
|
|
250
250
|
border-radius: 4px;
|
|
251
251
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
|
@@ -109,10 +109,10 @@ function formatValue(value: unknown): string {
|
|
|
109
109
|
|
|
110
110
|
<style scoped>
|
|
111
111
|
.sa-diff-preview__empty {
|
|
112
|
-
color:
|
|
112
|
+
color: var(--sa-muted);
|
|
113
113
|
font-style: italic;
|
|
114
114
|
padding: 12px;
|
|
115
|
-
background:
|
|
115
|
+
background: var(--sa-bg-surface-2);
|
|
116
116
|
border-radius: 8px;
|
|
117
117
|
border: 1px dashed #cbd5e1;
|
|
118
118
|
}
|
|
@@ -142,9 +142,9 @@ function formatValue(value: unknown): string {
|
|
|
142
142
|
color: #991b1b;
|
|
143
143
|
}
|
|
144
144
|
.sa-diff-preview__row--neutral {
|
|
145
|
-
background:
|
|
146
|
-
border-color:
|
|
147
|
-
color:
|
|
145
|
+
background: var(--sa-bg-surface-2);
|
|
146
|
+
border-color: var(--sa-border);
|
|
147
|
+
color: var(--sa-muted-dark);
|
|
148
148
|
}
|
|
149
149
|
.sa-diff-preview__direction {
|
|
150
150
|
display: flex;
|
|
@@ -156,7 +156,7 @@ function formatValue(value: unknown): string {
|
|
|
156
156
|
letter-spacing: 0.04em;
|
|
157
157
|
}
|
|
158
158
|
.sa-diff-preview__field {
|
|
159
|
-
color:
|
|
159
|
+
color: var(--sa-heading);
|
|
160
160
|
font-weight: 500;
|
|
161
161
|
}
|
|
162
162
|
.sa-diff-preview__values {
|
|
@@ -165,7 +165,7 @@ function formatValue(value: unknown): string {
|
|
|
165
165
|
gap: 8px;
|
|
166
166
|
}
|
|
167
167
|
.sa-diff-preview__values .old {
|
|
168
|
-
color:
|
|
168
|
+
color: var(--sa-muted-light);
|
|
169
169
|
text-decoration: line-through;
|
|
170
170
|
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
|
171
171
|
font-size: 12px;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="sa-page-body">
|
|
3
|
+
<div v-if="loading" class="sa-page-body__state">
|
|
4
|
+
<slot name="loading">
|
|
5
|
+
<q-spinner size="32px" />
|
|
6
|
+
<span>{{ loadingText ?? common.loadingData }}</span>
|
|
7
|
+
</slot>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<div v-else-if="empty" class="sa-page-body__state sa-page-body__state--empty">
|
|
11
|
+
<slot name="empty">{{ emptyText ?? common.noData }}</slot>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<slot v-else />
|
|
15
|
+
</div>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
|
|
20
|
+
|
|
21
|
+
// The page body: everything below the hero, plus the two states that body is
|
|
22
|
+
// almost always in before it has content.
|
|
23
|
+
//
|
|
24
|
+
// Ten of the fifteen standard pages used to spell out their own spinner and
|
|
25
|
+
// their own empty text, each with a private class for the same centred,
|
|
26
|
+
// muted block. The states live here so a page describes *when* it is empty
|
|
27
|
+
// rather than *what* empty looks like.
|
|
28
|
+
//
|
|
29
|
+
// Like AdminPage, this is deliberately not <main>: the landmark belongs to
|
|
30
|
+
// AdminLayout and must appear once per document.
|
|
31
|
+
defineProps<{
|
|
32
|
+
loading?: boolean;
|
|
33
|
+
/** Shown when not loading. The page decides what counts as empty. */
|
|
34
|
+
empty?: boolean;
|
|
35
|
+
loadingText?: string;
|
|
36
|
+
emptyText?: string;
|
|
37
|
+
}>();
|
|
38
|
+
|
|
39
|
+
const common = useSaMessages('common');
|
|
40
|
+
</script>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="sa-filters">
|
|
3
|
+
<div class="sa-filters__body">
|
|
4
|
+
<slot />
|
|
5
|
+
</div>
|
|
6
|
+
</section>
|
|
7
|
+
</template>
|
|
8
|
+
|
|
9
|
+
<script setup lang="ts">
|
|
10
|
+
// Layout-only: the filter row is a flex container around whatever the page
|
|
11
|
+
// puts in it. No script state — the inputs are the page's, not ours.
|
|
12
|
+
</script>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<header class="sa-page-head">
|
|
3
|
+
<div>
|
|
4
|
+
<slot name="before-title" />
|
|
5
|
+
<component :is="`h${level}`" class="sa-page-head__title">
|
|
6
|
+
<slot name="title">{{ title }}</slot>
|
|
7
|
+
</component>
|
|
8
|
+
<p v-if="subtitle || $slots.subtitle" class="sa-page-head__sub">
|
|
9
|
+
<slot name="subtitle">{{ subtitle }}</slot>
|
|
10
|
+
</p>
|
|
11
|
+
</div>
|
|
12
|
+
<div v-if="$slots.actions" class="sa-page-head__actions">
|
|
13
|
+
<slot name="actions" />
|
|
14
|
+
</div>
|
|
15
|
+
</header>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
// The page hero: the one <h1> a page is allowed to have.
|
|
20
|
+
//
|
|
21
|
+
// `title` is required on purpose. It is the part of the contract that the
|
|
22
|
+
// previous class-name convention could not enforce — a page can forget to add
|
|
23
|
+
// `.sa-page-head`, but it cannot instantiate this component without a title,
|
|
24
|
+
// because vue-tsc rejects it.
|
|
25
|
+
//
|
|
26
|
+
// `subtitle` covers the plain-text case; the matching slot is for the detail
|
|
27
|
+
// pages whose subtitle carries markup (a status badge, a <code> slug).
|
|
28
|
+
// `before-title` holds the back link on detail pages, which sits above the
|
|
29
|
+
// heading rather than beside it.
|
|
30
|
+
//
|
|
31
|
+
// The `title` slot fills the heading with markup rather than text — a rename
|
|
32
|
+
// affordance, say. The hero still owns the element and its level; the page only
|
|
33
|
+
// says what goes inside it. `title` stays required either way, so the heading
|
|
34
|
+
// has an accessible name even while the slot renders a control.
|
|
35
|
+
//
|
|
36
|
+
// `level` is what lets the same title block appear twice on a page: `1` at the
|
|
37
|
+
// top, `2` for a titled group inside AdminBody. The markup is identical, so a
|
|
38
|
+
// second component would only have duplicated it — and the heading level is
|
|
39
|
+
// the one thing that actually differs.
|
|
40
|
+
withDefaults(
|
|
41
|
+
defineProps<{
|
|
42
|
+
title: string;
|
|
43
|
+
subtitle?: string;
|
|
44
|
+
/** Heading level. `1` for the page hero, `2` for a block inside the body. */
|
|
45
|
+
level?: 1 | 2;
|
|
46
|
+
}>(),
|
|
47
|
+
{ level: 1 },
|
|
48
|
+
);
|
|
49
|
+
</script>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<component
|
|
3
|
+
:is="action ? 'button' : 'div'"
|
|
4
|
+
:type="action ? 'button' : undefined"
|
|
5
|
+
:aria-pressed="action ? selected : undefined"
|
|
6
|
+
:class="[
|
|
7
|
+
'sa-kpi',
|
|
8
|
+
`sa-kpi--${layout}`,
|
|
9
|
+
tone !== 'neutral' && `sa-kpi--${tone}`,
|
|
10
|
+
emphasis === 'surface' && 'sa-kpi--surface',
|
|
11
|
+
action && 'sa-kpi--action',
|
|
12
|
+
action && selected && 'sa-kpi--selected',
|
|
13
|
+
]"
|
|
14
|
+
@click="action?.()"
|
|
15
|
+
>
|
|
16
|
+
<span v-if="icon" class="sa-kpi__icon">
|
|
17
|
+
<q-icon :name="icon" size="18px" />
|
|
18
|
+
</span>
|
|
19
|
+
<span class="sa-kpi__text">
|
|
20
|
+
<span class="sa-kpi__label">{{ label }}</span>
|
|
21
|
+
<span class="sa-kpi__value">
|
|
22
|
+
<slot name="value">{{ formattedValue }}</slot>
|
|
23
|
+
</span>
|
|
24
|
+
<span v-if="sub || $slots.sub" class="sa-kpi__sub">
|
|
25
|
+
<slot name="sub">{{ sub }}</slot>
|
|
26
|
+
</span>
|
|
27
|
+
</span>
|
|
28
|
+
</component>
|
|
29
|
+
</template>
|
|
30
|
+
|
|
31
|
+
<script setup lang="ts">
|
|
32
|
+
import { computed } from 'vue';
|
|
33
|
+
import { useSuperAdminI18n } from '../../vue/use-super-admin-i18n.js';
|
|
34
|
+
|
|
35
|
+
// One statistic tile. Seven near-copies of this existed across the admin
|
|
36
|
+
// pages, two of them byte identical; the differences that were real are the
|
|
37
|
+
// props below.
|
|
38
|
+
//
|
|
39
|
+
// `action` decides everything about interactivity: with one the tile is a
|
|
40
|
+
// <button> carrying `aria-pressed`, without one it is a <div>. Making that an
|
|
41
|
+
// explicit prop rather than inferring it from an attached listener keeps the
|
|
42
|
+
// rendered tag, the role and the focus behaviour visible at the call site.
|
|
43
|
+
//
|
|
44
|
+
// `layout` is the second: `stacked` leads with the number, which is what a
|
|
45
|
+
// count-at-a-glance row wants; `inline` puts the icon in a tinted square beside
|
|
46
|
+
// a label-first column, for rows where the icon carries the identity of the
|
|
47
|
+
// figure. It is a prop rather than a page-level style so both arrangements stay
|
|
48
|
+
// in this file and a page can only pick one, not invent a third.
|
|
49
|
+
//
|
|
50
|
+
// `emphasis` is the one genuine fork between the two families that came
|
|
51
|
+
// before: the filter pills recolour only the number, while the discovery and
|
|
52
|
+
// draft tiles tint the whole tile because there the colour IS the statement.
|
|
53
|
+
const props = withDefaults(
|
|
54
|
+
defineProps<{
|
|
55
|
+
label: string;
|
|
56
|
+
/** Numbers are localised here so every tile formats alike. */
|
|
57
|
+
value?: string | number | null;
|
|
58
|
+
sub?: string;
|
|
59
|
+
tone?: 'neutral' | 'positive' | 'info' | 'warn' | 'danger' | 'muted' | 'purple';
|
|
60
|
+
/** `value` colours the number only, `surface` tints the whole tile. */
|
|
61
|
+
emphasis?: 'value' | 'surface';
|
|
62
|
+
icon?: string;
|
|
63
|
+
/**
|
|
64
|
+
* `stacked` leads with the number, `inline` sets the icon beside a
|
|
65
|
+
* label-first column. One arrangement per tile row, never per page.
|
|
66
|
+
*/
|
|
67
|
+
layout?: 'stacked' | 'inline';
|
|
68
|
+
/** Supply to make the tile a button; omit for an informational tile. */
|
|
69
|
+
action?: () => void;
|
|
70
|
+
selected?: boolean;
|
|
71
|
+
}>(),
|
|
72
|
+
{ tone: 'neutral', emphasis: 'value', selected: false, layout: 'stacked' },
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const EMPTY = '—';
|
|
76
|
+
|
|
77
|
+
const { intlLocale } = useSuperAdminI18n();
|
|
78
|
+
|
|
79
|
+
const formattedValue = computed(() => {
|
|
80
|
+
const value = props.value;
|
|
81
|
+
if (value === null || value === undefined || value === '') return EMPTY;
|
|
82
|
+
return typeof value === 'number' ? value.toLocaleString(intlLocale.value) : value;
|
|
83
|
+
});
|
|
84
|
+
</script>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="sa-page">
|
|
3
|
+
<slot />
|
|
4
|
+
</div>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup lang="ts">
|
|
8
|
+
// Root of every SuperAdmin content page.
|
|
9
|
+
//
|
|
10
|
+
// It owns the page frame — canvas background, min-height and page padding —
|
|
11
|
+
// which eleven pages used to declare verbatim in their own scoped styles.
|
|
12
|
+
//
|
|
13
|
+
// It deliberately does NOT render <main>. The landmark belongs to the layout
|
|
14
|
+
// and must appear exactly once per document; AdminLayout provides it. Quasar's
|
|
15
|
+
// QPage also renders <main>, which is why pages must not use QPage either.
|
|
16
|
+
</script>
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<nav class="sa-paginator" :aria-label="msg.paginator.label">
|
|
3
|
+
<span class="sa-paginator__size-label">{{ msg.paginator.rowsPerPage }}</span>
|
|
4
|
+
<q-select
|
|
5
|
+
class="sa-paginator__select"
|
|
6
|
+
:model-value="rowsPerPage"
|
|
7
|
+
:options="sizeOptions"
|
|
8
|
+
:aria-label="msg.paginator.rowsPerPage"
|
|
9
|
+
dense
|
|
10
|
+
outlined
|
|
11
|
+
emit-value
|
|
12
|
+
map-options
|
|
13
|
+
@update:model-value="onSizeChange"
|
|
14
|
+
/>
|
|
15
|
+
|
|
16
|
+
<button
|
|
17
|
+
class="sa-paginator__step"
|
|
18
|
+
type="button"
|
|
19
|
+
:disabled="page <= 1"
|
|
20
|
+
:aria-label="msg.paginator.previous"
|
|
21
|
+
@click="goTo(page - 1)"
|
|
22
|
+
>
|
|
23
|
+
<q-icon name="chevron_left" size="18px" />
|
|
24
|
+
</button>
|
|
25
|
+
|
|
26
|
+
<input
|
|
27
|
+
v-if="editing"
|
|
28
|
+
ref="pageInput"
|
|
29
|
+
v-model="draft"
|
|
30
|
+
class="sa-paginator__input"
|
|
31
|
+
type="number"
|
|
32
|
+
inputmode="numeric"
|
|
33
|
+
:min="1"
|
|
34
|
+
:max="pageCount"
|
|
35
|
+
:aria-label="pageInputLabel"
|
|
36
|
+
@keydown.enter="commit"
|
|
37
|
+
@keydown.escape="cancel"
|
|
38
|
+
@blur="commit"
|
|
39
|
+
/>
|
|
40
|
+
<button
|
|
41
|
+
v-else
|
|
42
|
+
class="sa-paginator__page"
|
|
43
|
+
type="button"
|
|
44
|
+
:disabled="pageCount <= 1"
|
|
45
|
+
@click="startEdit"
|
|
46
|
+
>
|
|
47
|
+
{{ pageLabel }}
|
|
48
|
+
</button>
|
|
49
|
+
|
|
50
|
+
<button
|
|
51
|
+
class="sa-paginator__step"
|
|
52
|
+
type="button"
|
|
53
|
+
:disabled="page >= pageCount"
|
|
54
|
+
:aria-label="msg.paginator.next"
|
|
55
|
+
@click="goTo(page + 1)"
|
|
56
|
+
>
|
|
57
|
+
<q-icon name="chevron_right" size="18px" />
|
|
58
|
+
</button>
|
|
59
|
+
</nav>
|
|
60
|
+
</template>
|
|
61
|
+
|
|
62
|
+
<script setup lang="ts">
|
|
63
|
+
import { computed, nextTick, onMounted, ref } from 'vue';
|
|
64
|
+
import { formatMessage } from '../../client/i18n/format.js';
|
|
65
|
+
import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
|
|
66
|
+
// Imported, not redeclared: `withDefaults` hoists its defaults out of setup(),
|
|
67
|
+
// so a local const cannot be referenced there — and the sizes belong with the
|
|
68
|
+
// composable that slices by them anyway.
|
|
69
|
+
import { ALL_ROWS, PAGE_SIZE_OPTIONS } from '../../vue/use-pagination.js';
|
|
70
|
+
|
|
71
|
+
// The pager below a list. One control for every list in the admin, so a page
|
|
72
|
+
// contributes how many rows it has and which page it is on — never how paging
|
|
73
|
+
// looks or where it sits.
|
|
74
|
+
//
|
|
75
|
+
// The page indicator doubles as its own input: clicking it turns "Page 3/9"
|
|
76
|
+
// into a number field. That keeps a long list reachable without a row of page
|
|
77
|
+
// buttons whose width depends on the number of pages.
|
|
78
|
+
//
|
|
79
|
+
// `rowsPerPage` follows Quasar's convention where 0 means "all", so a page can
|
|
80
|
+
// hand this straight to a QTable's pagination object.
|
|
81
|
+
|
|
82
|
+
const STORAGE_PREFIX = 'saasicat.rowsPerPage.';
|
|
83
|
+
|
|
84
|
+
const props = withDefaults(
|
|
85
|
+
defineProps<{
|
|
86
|
+
page: number;
|
|
87
|
+
rowsPerPage: number;
|
|
88
|
+
/** Total rows across all pages, not the rows on this one. */
|
|
89
|
+
total: number;
|
|
90
|
+
/** Ordered page sizes; 0 renders as "all". */
|
|
91
|
+
pageSizes?: readonly number[];
|
|
92
|
+
/**
|
|
93
|
+
* Remembers the chosen size under this key. One key per list, not per
|
|
94
|
+
* page: a detail page with two tables would otherwise have them
|
|
95
|
+
* overwrite each other. Omit to keep the size for the visit only.
|
|
96
|
+
*/
|
|
97
|
+
storageKey?: string;
|
|
98
|
+
}>(),
|
|
99
|
+
{ pageSizes: () => [...PAGE_SIZE_OPTIONS] },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const emit = defineEmits<{
|
|
103
|
+
(e: 'update:page', page: number): void;
|
|
104
|
+
(e: 'update:rowsPerPage', rows: number): void;
|
|
105
|
+
}>();
|
|
106
|
+
|
|
107
|
+
const msg = useSaMessages('common');
|
|
108
|
+
|
|
109
|
+
// A page may start on a size that is not in the list (its own default); it is
|
|
110
|
+
// merged in rather than silently snapped to the nearest offered one.
|
|
111
|
+
const sizeOptions = computed(() => {
|
|
112
|
+
const sizes = props.pageSizes.includes(props.rowsPerPage)
|
|
113
|
+
? [...props.pageSizes]
|
|
114
|
+
: [...props.pageSizes, props.rowsPerPage].sort((a, b) => a - b);
|
|
115
|
+
return sizes.map((size) => ({
|
|
116
|
+
value: size,
|
|
117
|
+
label: size === ALL_ROWS ? msg.value.paginator.all : String(size),
|
|
118
|
+
}));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const pageCount = computed(() => {
|
|
122
|
+
if (props.rowsPerPage === ALL_ROWS) return 1;
|
|
123
|
+
return Math.max(1, Math.ceil(props.total / props.rowsPerPage));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const pageLabel = computed(() =>
|
|
127
|
+
formatMessage(msg.value.paginator.page, { page: props.page, pages: pageCount.value }),
|
|
128
|
+
);
|
|
129
|
+
const pageInputLabel = computed(() =>
|
|
130
|
+
formatMessage(msg.value.paginator.pageInput, { pages: pageCount.value }),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
function goTo(page: number): void {
|
|
134
|
+
const clamped = Math.min(Math.max(1, page), pageCount.value);
|
|
135
|
+
if (clamped !== props.page) emit('update:page', clamped);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function onSizeChange(value: number): void {
|
|
139
|
+
emit('update:rowsPerPage', value);
|
|
140
|
+
// A smaller page can put the current one past the end.
|
|
141
|
+
emit('update:page', 1);
|
|
142
|
+
writeStoredSize(value);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Persistence lives here rather than in each list's state, so a page that
|
|
146
|
+
// paginates on the server gets it on the same terms as one that slices
|
|
147
|
+
// locally. Storage can throw — Safari's private mode, a blocked third-party
|
|
148
|
+
// context — and a pager that cannot remember a number is not a reason to take
|
|
149
|
+
// the page down with it.
|
|
150
|
+
function readStoredSize(): number | null {
|
|
151
|
+
if (!props.storageKey) return null;
|
|
152
|
+
try {
|
|
153
|
+
const raw = window.localStorage.getItem(STORAGE_PREFIX + props.storageKey);
|
|
154
|
+
const parsed = raw === null ? Number.NaN : Number.parseInt(raw, 10);
|
|
155
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function writeStoredSize(value: number): void {
|
|
162
|
+
if (!props.storageKey) return;
|
|
163
|
+
try {
|
|
164
|
+
window.localStorage.setItem(STORAGE_PREFIX + props.storageKey, String(value));
|
|
165
|
+
} catch {
|
|
166
|
+
// Nothing to recover: the size simply lasts for this visit.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
onMounted(() => {
|
|
171
|
+
const stored = readStoredSize();
|
|
172
|
+
if (stored !== null && stored !== props.rowsPerPage) emit('update:rowsPerPage', stored);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const editing = ref(false);
|
|
176
|
+
const draft = ref('');
|
|
177
|
+
const pageInput = ref<HTMLInputElement | null>(null);
|
|
178
|
+
|
|
179
|
+
function startEdit(): void {
|
|
180
|
+
draft.value = String(props.page);
|
|
181
|
+
editing.value = true;
|
|
182
|
+
void nextTick(() => pageInput.value?.select());
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function commit(): void {
|
|
186
|
+
if (!editing.value) return;
|
|
187
|
+
editing.value = false;
|
|
188
|
+
const parsed = Number.parseInt(draft.value, 10);
|
|
189
|
+
if (Number.isFinite(parsed)) goTo(parsed);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function cancel(): void {
|
|
193
|
+
editing.value = false;
|
|
194
|
+
}
|
|
195
|
+
</script>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<button
|
|
3
|
+
class="sa-btn sa-btn--icon"
|
|
4
|
+
type="button"
|
|
5
|
+
:disabled="loading"
|
|
6
|
+
:aria-label="label ?? common.reload"
|
|
7
|
+
@click="emit('refresh')"
|
|
8
|
+
>
|
|
9
|
+
<q-spinner v-if="loading" size="16px" />
|
|
10
|
+
<q-icon v-else name="refresh" size="16px" />
|
|
11
|
+
</button>
|
|
12
|
+
</template>
|
|
13
|
+
|
|
14
|
+
<script setup lang="ts">
|
|
15
|
+
import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
|
|
16
|
+
|
|
17
|
+
// The reload control in a hero or a section head. Icon only, always the last
|
|
18
|
+
// action in its row.
|
|
19
|
+
//
|
|
20
|
+
// Seven pages wrote it by hand and no two agreed: three showed no progress at
|
|
21
|
+
// all, one disabled itself without a spinner so the page looked stuck, one had
|
|
22
|
+
// no accessible name whatsoever — an icon-only button that a screen reader
|
|
23
|
+
// announced as nothing — and the label came from three different message keys
|
|
24
|
+
// for the same word.
|
|
25
|
+
//
|
|
26
|
+
// `loading` drives both the spinner and the disabled state, so a page cannot
|
|
27
|
+
// have one without the other.
|
|
28
|
+
defineProps<{
|
|
29
|
+
loading?: boolean;
|
|
30
|
+
/** Accessible name. Defaults to the shared "reload" string. */
|
|
31
|
+
label?: string;
|
|
32
|
+
}>();
|
|
33
|
+
|
|
34
|
+
const emit = defineEmits<{
|
|
35
|
+
(e: 'refresh'): void;
|
|
36
|
+
}>();
|
|
37
|
+
|
|
38
|
+
const common = useSaMessages('common');
|
|
39
|
+
</script>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="sa-section" :aria-labelledby="hasTitle ? headingId : undefined">
|
|
3
|
+
<header v-if="hasTitle" class="sa-section__head">
|
|
4
|
+
<div>
|
|
5
|
+
<h2 :id="headingId" class="sa-section__title">
|
|
6
|
+
<slot name="title">{{ title }}</slot>
|
|
7
|
+
</h2>
|
|
8
|
+
<p v-if="subtitle || $slots.subtitle" class="sa-section__sub">
|
|
9
|
+
<slot name="subtitle">{{ subtitle }}</slot>
|
|
10
|
+
</p>
|
|
11
|
+
</div>
|
|
12
|
+
<div v-if="$slots.actions" class="sa-section__actions">
|
|
13
|
+
<slot name="actions" />
|
|
14
|
+
</div>
|
|
15
|
+
</header>
|
|
16
|
+
<div class="sa-section__body">
|
|
17
|
+
<slot />
|
|
18
|
+
</div>
|
|
19
|
+
</section>
|
|
20
|
+
</template>
|
|
21
|
+
|
|
22
|
+
<script setup lang="ts">
|
|
23
|
+
import { computed, useId, useSlots } from 'vue';
|
|
24
|
+
|
|
25
|
+
// A block of a page. Every child of AdminBody is one of these or an
|
|
26
|
+
// AdminStatistics — nothing else, so the page structure can be explained once
|
|
27
|
+
// instead of page by page.
|
|
28
|
+
//
|
|
29
|
+
// The title is optional. A filter row and a data table are blocks without a
|
|
30
|
+
// natural name, and inventing one per page is exactly how a design line
|
|
31
|
+
// splinters. When a title IS given, the head repeats the hero's treatment one
|
|
32
|
+
// level down: same block, `h2` instead of `h1`.
|
|
33
|
+
//
|
|
34
|
+
// The `title` slot is for the cases where the heading carries more than text —
|
|
35
|
+
// a count, a badge — so a page never has to rebuild the head to add one.
|
|
36
|
+
//
|
|
37
|
+
// `aria-labelledby` is why this is a component rather than a bare `<section>`:
|
|
38
|
+
// an unnamed section is not a landmark, and naming one by hand needs an id
|
|
39
|
+
// unique per instance. Without a title there is nothing to point at, so the
|
|
40
|
+
// attribute is left off rather than pointed at an element that is not there.
|
|
41
|
+
//
|
|
42
|
+
// The content sits in its own wrapper so a page can lay it out — a filter row
|
|
43
|
+
// as flex, a grid of cards — without the head being dragged into that layout.
|
|
44
|
+
// That wrapper is the only thing a page styles; the surface is not its business.
|
|
45
|
+
const props = defineProps<{
|
|
46
|
+
title?: string;
|
|
47
|
+
subtitle?: string;
|
|
48
|
+
}>();
|
|
49
|
+
|
|
50
|
+
const slots = useSlots();
|
|
51
|
+
const headingId = useId();
|
|
52
|
+
const hasTitle = computed(() => Boolean(props.title || slots.title));
|
|
53
|
+
</script>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="sa-stats" :style="columns ? gridStyle : undefined" :aria-label="label">
|
|
3
|
+
<slot />
|
|
4
|
+
</div>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup lang="ts">
|
|
8
|
+
import { computed } from 'vue';
|
|
9
|
+
|
|
10
|
+
// A row of AdminKpi tiles. One arrangement, everywhere: tiles share a track
|
|
11
|
+
// width and their sub-lines land on one baseline.
|
|
12
|
+
//
|
|
13
|
+
// There is deliberately no layout variant. An earlier version offered a
|
|
14
|
+
// content-sized "strip" alongside the grid, and the result was two different
|
|
15
|
+
// looking KPI rows in the same admin — which is the kind of divergence a
|
|
16
|
+
// prescribed design element exists to prevent.
|
|
17
|
+
//
|
|
18
|
+
// `columns` caps the track count without a media query: the tiles collapse to
|
|
19
|
+
// fewer columns on their own once the minimum width no longer fits.
|
|
20
|
+
const props = defineProps<{
|
|
21
|
+
/** Upper bound on tracks; fewer are used when they no longer fit. */
|
|
22
|
+
columns?: number;
|
|
23
|
+
/** Accessible name — worth setting when the tiles are interactive. */
|
|
24
|
+
label?: string;
|
|
25
|
+
}>();
|
|
26
|
+
|
|
27
|
+
const TILE_MIN_WIDTH = '150px';
|
|
28
|
+
const GAP_PX = 10;
|
|
29
|
+
|
|
30
|
+
const gridStyle = computed(() => {
|
|
31
|
+
const n = props.columns!;
|
|
32
|
+
// The gap total is computed here rather than left as `(n - 1) * 10px` in
|
|
33
|
+
// the declaration: a plain calc() sum is the conservative form, and one
|
|
34
|
+
// rejected expression would drop the whole track list back to an uncapped
|
|
35
|
+
// auto-fit — the cap would fail open, invisibly.
|
|
36
|
+
const gaps = `${(n - 1) * GAP_PX}px`;
|
|
37
|
+
const track = `max(${TILE_MIN_WIDTH}, calc((100% - ${gaps}) / ${n}))`;
|
|
38
|
+
return { gridTemplateColumns: `repeat(auto-fit, minmax(${track}, 1fr))` };
|
|
39
|
+
});
|
|
40
|
+
</script>
|