@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.
Files changed (145) hide show
  1. package/dist/{catalog-JXdKdIqG.d.cts → catalog-DJcfm5N2.d.cts} +21 -7
  2. package/dist/{catalog-JXdKdIqG.d.ts → catalog-DJcfm5N2.d.ts} +21 -7
  3. package/dist/{chunk-DPTIOQXX.js → chunk-F7NRM6KI.js} +56 -28
  4. package/dist/{chunk-RVG6LQHL.js → chunk-LY52T4N4.js} +21 -2
  5. package/dist/{chunk-ORX3ZG47.js → chunk-XMMFN2WJ.js} +74 -3
  6. package/dist/client/index.cjs +134 -29
  7. package/dist/client/index.d.cts +53 -3
  8. package/dist/client/index.d.ts +53 -3
  9. package/dist/client/index.js +14 -2
  10. package/dist/index.cjs +321 -162
  11. package/dist/index.d.cts +30 -8
  12. package/dist/index.d.ts +30 -8
  13. package/dist/index.js +176 -133
  14. package/dist/quasar/index.cjs +78 -29
  15. package/dist/quasar/index.d.cts +2 -2
  16. package/dist/quasar/index.d.ts +2 -2
  17. package/dist/quasar/index.js +6 -2
  18. package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
  19. package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
  20. package/dist/{use-super-admin-i18n-BlqcD0N7.d.ts → use-super-admin-i18n-CHNdsSlm.d.ts} +34 -7
  21. package/dist/{use-super-admin-i18n-1nCOkvgi.d.cts → use-super-admin-i18n-CgTuNi8h.d.cts} +34 -7
  22. package/package.json +18 -8
  23. package/src/client/i18n/messages/bundles.ts +0 -2
  24. package/src/client/i18n/messages/common.ts +24 -0
  25. package/src/client/i18n/messages/discovery.ts +2 -2
  26. package/src/client/i18n/messages/marketing.ts +2 -4
  27. package/src/client/i18n/messages/plan-detail.ts +0 -4
  28. package/src/client/i18n/messages/plans.ts +20 -8
  29. package/src/client/i18n/messages/tenants.ts +0 -2
  30. package/src/client/index.ts +1 -0
  31. package/src/client/manifest-loader.ts +1 -1
  32. package/src/client/resolve-plans.ts +144 -0
  33. package/src/components/BundleVersionPublishDialog.vue +6 -6
  34. package/src/components/KvBlock.vue +4 -4
  35. package/src/components/MarketingPromotionsTab.vue +34 -48
  36. package/src/components/MfaPromptDialog.vue +8 -2
  37. package/src/components/TenantActionConfirmDialog.vue +4 -4
  38. package/src/components/VersionDiffPreview.vue +7 -7
  39. package/src/components/admin-page/AdminBody.vue +40 -0
  40. package/src/components/admin-page/AdminFilters.vue +12 -0
  41. package/src/components/admin-page/AdminHero.vue +49 -0
  42. package/src/components/admin-page/AdminKpi.vue +84 -0
  43. package/src/components/admin-page/AdminPage.vue +16 -0
  44. package/src/components/admin-page/AdminPaginator.vue +195 -0
  45. package/src/components/admin-page/AdminRefreshBtn.vue +39 -0
  46. package/src/components/admin-page/AdminSection.vue +53 -0
  47. package/src/components/admin-page/AdminStatistics.vue +40 -0
  48. package/src/components/admin-page/AdminTable.vue +175 -0
  49. package/src/components/bundle-editor/BundleCreatePanel.vue +23 -23
  50. package/src/components/bundle-editor/BundleFeaturesEditor.vue +7 -7
  51. package/src/components/bundle-editor/BundlePlanCompatPicker.vue +9 -9
  52. package/src/components/bundle-editor/BundleQuotasEditor.vue +11 -11
  53. package/src/components/bundle-editor/BundleStatusBanner.vue +6 -6
  54. package/src/components/bundle-editor/BundleVersionInlineEditor.vue +18 -18
  55. package/src/components/bundle-editor/BundleVersionStrip.vue +12 -12
  56. package/src/components/dialogs/PilotCreateDialog.vue +12 -12
  57. package/src/components/dialogs/PilotEditDialog.vue +7 -7
  58. package/src/components/dialogs/PromoCodeCreateDialog.vue +8 -8
  59. package/src/components/dialogs/PromoCodeDialogFields.vue +33 -19
  60. package/src/components/dialogs/PromoCodeEditDialog.vue +10 -10
  61. package/src/components/plan/PlanGrid.vue +1 -1
  62. package/src/components/plan-create-dialog/PlanCreateDialog.vue +34 -46
  63. package/src/components/plan-detail/PlanAuditLog.vue +7 -8
  64. package/src/components/plan-detail/PlanDetail.vue +95 -292
  65. package/src/components/plan-detail/PlanDetailKpis.vue +22 -27
  66. package/src/components/plan-detail/PlanTitleEdit.vue +133 -0
  67. package/src/components/plan-detail/PlanVersionDiffPanel.vue +5 -8
  68. package/src/components/plan-detail/PlanVersionsPanel.vue +9 -11
  69. package/src/components/plan-list/PlanList.vue +206 -457
  70. package/src/components/plan-matrix/PlanMatrix.vue +66 -183
  71. package/src/components/plan-review/PlanReview.vue +191 -275
  72. package/src/components/plan-version-editor/PlanCatalogPreview.vue +7 -5
  73. package/src/components/plan-version-editor/PlanVersionEditor.vue +200 -295
  74. package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +23 -55
  75. package/src/index.ts +1 -0
  76. package/src/pages-standard/AdminLayout.vue +38 -12
  77. package/src/pages-standard/AdminManifestErrorPage.vue +79 -9
  78. package/src/pages-standard/AuditPage.vue +65 -109
  79. package/src/pages-standard/BundlesPage.vue +146 -179
  80. package/src/pages-standard/DashboardPage.vue +59 -154
  81. package/src/pages-standard/DiscoveryPage.vue +172 -227
  82. package/src/pages-standard/EmailHistoryPage.vue +118 -159
  83. package/src/pages-standard/MarketingCatalogPage.vue +399 -440
  84. package/src/pages-standard/PilotsPage.vue +69 -117
  85. package/src/pages-standard/PlansPage.vue +240 -112
  86. package/src/pages-standard/PlatformEmailPage.vue +56 -91
  87. package/src/pages-standard/PromoCodeDetailPage.vue +49 -74
  88. package/src/pages-standard/PromoCodesPage.vue +95 -128
  89. package/src/pages-standard/SubscriptionsPage.vue +19 -52
  90. package/src/pages-standard/SuperAdminLoginPage.vue +12 -12
  91. package/src/pages-standard/SuperAdminSetupWizard.vue +22 -18
  92. package/src/pages-standard/TenantDetailPage.vue +100 -191
  93. package/src/pages-standard/TenantsPage.vue +190 -295
  94. package/src/pages-standard/UsersPage.vue +89 -157
  95. package/src/pages-standard/bundles-page/BundleAccordionList.vue +20 -17
  96. package/src/pages-standard/bundles-page/BundlesKpis.vue +13 -22
  97. package/src/pages-standard/bundles-page/BundlesToolbar.vue +63 -0
  98. package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +10 -10
  99. package/src/pages-standard/discovery-page/DiscoveryCapList.vue +5 -5
  100. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +16 -16
  101. package/src/pages-standard/discovery-page/DiscoveryKpis.vue +31 -27
  102. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +7 -7
  103. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +111 -73
  104. package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +43 -62
  105. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +53 -35
  106. package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +7 -7
  107. package/src/pages-standard/plan-versions/PlanDiffCard.vue +6 -6
  108. package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +6 -6
  109. package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +16 -16
  110. package/src/pages-standard/plans-page/PlanBundleOverview.vue +14 -29
  111. package/src/pages-standard/plans-page/PlanPublishDialog.vue +1 -1
  112. package/src/pages-standard/plans-page/PlansPageToast.vue +6 -6
  113. package/src/pages-standard/sa-theme.css +552 -86
  114. package/src/pages-standard/tenant-detail/TenantMasterData.vue +28 -0
  115. package/src/pages-standard/tenant-detail/TenantUsage.vue +31 -0
  116. package/src/pages-standard/tenant-detail/TenantUsers.vue +15 -0
  117. package/src/pages-standard/tenant-detail/types.ts +38 -0
  118. package/src/pages-standard/tenants/StatusPill.vue +2 -2
  119. package/src/pages-tenant/MySubscriptionBundlesPage.vue +25 -25
  120. package/src/pages-tenant/OnboardingConfigurator.vue +1 -5
  121. package/src/pages-tenant/PackageSnapshotPanel.vue +1 -1
  122. package/src/pages-tenant/TenantPlanSection.vue +12 -20
  123. package/src/quasar/create-super-admin-app.ts +4 -0
  124. package/src/vue/entitlement-provider.ts +0 -1
  125. package/src/vue/feature-router-guard.ts +0 -1
  126. package/src/vue/super-admin-context.ts +106 -1
  127. package/src/vue/use-api-list.ts +2 -1
  128. package/src/vue/use-mfa-prompt.ts +72 -0
  129. package/src/vue/use-pagination.ts +63 -0
  130. package/src/vue/use-plans.ts +1 -2
  131. package/src/vue/use-sign-out.ts +90 -0
  132. package/src/vue/use-tenant-billing.ts +1 -1
  133. package/src/vue/use-tenant-manifest.ts +0 -1
  134. package/src/components/KpiCard.vue +0 -53
  135. package/src/components/plan-cockpit/PlanCockpit.vue +0 -1012
  136. package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +0 -72
  137. package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +0 -100
  138. package/src/components/plan-cockpit/PlanCockpitHeader.vue +0 -101
  139. package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +0 -112
  140. package/src/components/plan-cockpit/PlanCockpitKpis.vue +0 -68
  141. package/src/components/plan-cockpit/PlanCockpitVersions.vue +0 -251
  142. package/src/components/plan-cockpit/types.ts +0 -72
  143. package/src/components/plan-detail/PlanDetailHeader.vue +0 -194
  144. package/src/pages-standard/bundles-page/BundlesHeader.vue +0 -68
  145. 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" v-close-popup />
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: #475569;
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: #475569;
244
+ color: var(--sa-muted-dark);
245
245
  font-size: 13px;
246
246
  }
247
247
  .tenant-action-confirm__hint code {
248
- background: #f1f5f9;
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: #64748b;
112
+ color: var(--sa-muted);
113
113
  font-style: italic;
114
114
  padding: 12px;
115
- background: #f8fafc;
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: #f8fafc;
146
- border-color: #e2e8f0;
147
- color: #475569;
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: #0f172a;
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: #94a3b8;
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>