@saasicat/ui-vue 0.24.2 → 0.26.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 (90) hide show
  1. package/README.md +32 -8
  2. package/dist/{catalog-DJcfm5N2.d.cts → catalog-Dch1Ryw0.d.cts} +353 -69
  3. package/dist/{catalog-DJcfm5N2.d.ts → catalog-Dch1Ryw0.d.ts} +353 -69
  4. package/dist/{chunk-F7NRM6KI.js → chunk-BPF2BMCQ.js} +748 -24
  5. package/dist/{chunk-D74FFCCP.js → chunk-NEXTZZRQ.js} +95 -177
  6. package/dist/{chunk-TQQMKWN6.js → chunk-O3J3ITF2.js} +119 -7
  7. package/dist/client/index.cjs +758 -83
  8. package/dist/client/index.d.cts +414 -9
  9. package/dist/client/index.d.ts +414 -9
  10. package/dist/client/index.js +84 -12
  11. package/dist/index.cjs +1325 -294
  12. package/dist/index.d.cts +205 -15
  13. package/dist/index.d.ts +205 -15
  14. package/dist/index.js +566 -240
  15. package/dist/quasar/index.cjs +574 -62
  16. package/dist/quasar/index.d.cts +61 -6
  17. package/dist/quasar/index.d.ts +61 -6
  18. package/dist/quasar/index.js +83 -19
  19. package/dist/{use-sa-theme-OUBlaqgl.d.ts → resource-registry-D7Xjs-5f.d.cts} +157 -7
  20. package/dist/{use-sa-theme-B5t7oXph.d.cts → resource-registry-TaR7rtq0.d.ts} +157 -7
  21. package/package.json +4 -3
  22. package/src/client/admin-error.ts +552 -0
  23. package/src/client/admin-resource-client.ts +9 -12
  24. package/src/client/batch-column-fetcher.ts +2 -2
  25. package/src/client/boot-loader.ts +12 -1
  26. package/src/client/http/axios-http-client.ts +383 -0
  27. package/src/client/http/fetch-http-client.ts +89 -0
  28. package/src/client/http/index.ts +6 -0
  29. package/src/client/http-json.ts +82 -16
  30. package/src/client/i18n/messages/discovery.ts +0 -6
  31. package/src/client/i18n/messages/errors.ts +44 -0
  32. package/src/client/i18n/messages/marketing.ts +0 -6
  33. package/src/client/i18n/messages/promos.ts +0 -2
  34. package/src/client/i18n/messages/shell.ts +12 -0
  35. package/src/client/i18n/messages.ts +3 -0
  36. package/src/client/index.ts +6 -0
  37. package/src/client/manifest-loader.ts +65 -15
  38. package/src/client/resources/audit.resource.ts +25 -0
  39. package/src/client/resources/define-resource.ts +166 -0
  40. package/src/client/resources/index.ts +42 -0
  41. package/src/client/resources/list-resource.ts +176 -0
  42. package/src/client/resources/plans.resource.ts +169 -0
  43. package/src/client/resources/resource-request.ts +117 -0
  44. package/src/client/resources/tenants.resource.ts +27 -0
  45. package/src/client/types.ts +10 -3
  46. package/src/components/BundleVersionPublishDialog.vue +1 -1
  47. package/src/components/MarketingPromotionsTab.vue +263 -241
  48. package/src/components/ThemeSwitcher.vue +85 -0
  49. package/src/components/admin-page/AdminAccordion.vue +125 -0
  50. package/src/components/bundle-editor/BundleCreatePanel.vue +1 -1
  51. package/src/components/dialogs/PromoCodeDialogFields.vue +20 -20
  52. package/src/components/plan/PlanCycleToggle.vue +1 -1
  53. package/src/components/plan-detail/PlanVersionsPanel.vue +5 -2
  54. package/src/index.ts +5 -0
  55. package/src/pages-standard/AdminLayout.vue +57 -0
  56. package/src/pages-standard/AdminManifestErrorPage.vue +4 -3
  57. package/src/pages-standard/BundlesPage.vue +5 -35
  58. package/src/pages-standard/DiscoveryPage.vue +8 -1
  59. package/src/pages-standard/MarketingCatalogPage.vue +14 -3
  60. package/src/pages-standard/SuperAdminLoginPage.vue +23 -2
  61. package/src/pages-standard/SuperAdminSetupWizard.vue +37 -3
  62. package/src/pages-standard/bundles-page/BundleAccordionList.vue +30 -32
  63. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +80 -88
  64. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +73 -79
  65. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +44 -6
  66. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +8 -2
  67. package/src/pages-tenant/PackageSnapshotPanel.vue +31 -2
  68. package/src/quasar/confirm.ts +83 -0
  69. package/src/quasar/create-super-admin-app.ts +84 -10
  70. package/src/quasar/dark-bridge.ts +47 -10
  71. package/src/quasar/index.ts +1 -0
  72. package/src/ui/theme/components/accordion.css +118 -0
  73. package/src/ui/theme/index.css +1 -0
  74. package/src/vue/create-admin-routes.ts +9 -1
  75. package/src/vue/platform-loaders.ts +12 -2
  76. package/src/vue/resource-registry.ts +211 -0
  77. package/src/vue/super-admin-context.ts +20 -5
  78. package/src/vue/ui-confirm.ts +76 -0
  79. package/src/vue/use-api-list.ts +50 -45
  80. package/src/vue/use-async-action.ts +170 -0
  81. package/src/vue/use-async-data.ts +83 -0
  82. package/src/vue/use-bundles.ts +34 -7
  83. package/src/vue/use-catalog-entries.ts +37 -12
  84. package/src/vue/use-discovery.ts +27 -6
  85. package/src/vue/use-marketing-projections.ts +29 -7
  86. package/src/vue/use-plans.ts +36 -8
  87. package/src/vue/use-promotions.ts +27 -7
  88. package/src/vue/use-resource-list.ts +272 -0
  89. package/src/vue/use-sa-theme.ts +34 -5
  90. package/src/vue/use-tenant-subscription-bundles.ts +22 -3
@@ -3,11 +3,12 @@
3
3
  <div class="sa-setup-card">
4
4
  <div class="sa-setup-head">
5
5
  <div class="sa-setup-badge">{{ iconText }}</div>
6
- <div>
6
+ <div class="sa-setup-headings">
7
7
  <h1 class="sa-setup-title">{{ msg.setup.title }}</h1>
8
8
  <div class="sa-setup-sub">{{ subtitleText }}</div>
9
9
  </div>
10
10
  <LocaleSwitcher class="sa-setup-locale" />
11
+ <ThemeSwitcher class="sa-setup-theme" />
11
12
  </div>
12
13
 
13
14
  <q-banner v-if="errorMessage" class="sa-setup-error" rounded>
@@ -162,6 +163,7 @@ import { HttpJsonError, postJson as httpPostJson } from '../client/http-json.js'
162
163
  import { formatMessage } from '../client/i18n/format.js';
163
164
  import { useSaMessages } from '../vue/use-super-admin-i18n.js';
164
165
  import LocaleSwitcher from '../components/LocaleSwitcher.vue';
166
+ import ThemeSwitcher from '../components/ThemeSwitcher.vue';
165
167
 
166
168
  interface Props {
167
169
  /** Display name + badge abbreviation (override the app branding, e.g. from PublicBoot). */
@@ -174,6 +176,23 @@ const props = defineProps<Props>();
174
176
  // shown again — the freshly created SuperAdmin then signs in.
175
177
  const emit = defineEmits<{ done: [] }>();
176
178
 
179
+ // sa-disclosure-exempt(uses `<details>`):
180
+ // the platform's own disclosure, on the one screen that owns its own frame
181
+ //
182
+ // The `otpauth://` URI hides behind a `<details>`/`<summary>` above, and it
183
+ // stays there. `AdminAccordion` exists because four of the eight surfaces it
184
+ // replaced were a `<div>` with a click handler — no keyboard, nothing to
185
+ // announce. `<details>` has none of that problem: it is the platform's own
186
+ // disclosure, operable by keyboard, announced as one, and it needs no state
187
+ // from the page at all.
188
+ //
189
+ // What is left is a consistency argument, and it points the other way here.
190
+ // This wizard renders OUTSIDE `AdminLayout` — it is one of the screens the page
191
+ // shell exempts, a centred card on a first run when no admin exists yet — and
192
+ // the surface in question is a one-line "show me the URI" for someone whose
193
+ // authenticator app could not read the QR code. An accordion card with a badge
194
+ // frame, an accent open border and a sunken body would make it the loudest
195
+ // element on the screen, to reveal one line of text.
177
196
  const msg = useSaMessages('shell');
178
197
  const brand = useSuperAdminBrand();
179
198
  const endpoints = useSuperAdminEndpoints();
@@ -297,14 +316,24 @@ async function submitConfirm(): Promise<void> {
297
316
  gap: 12px;
298
317
  margin-bottom: 20px;
299
318
  }
300
- .sa-setup-locale {
301
- margin-left: auto;
319
+ .sa-setup-locale,
320
+ .sa-setup-theme {
302
321
  align-self: flex-start;
303
322
  color: var(--sa-color-fg-secondary);
304
323
  }
324
+ /* One `auto` on the first of the pair moves both. */
325
+ .sa-setup-locale {
326
+ margin-left: auto;
327
+ }
305
328
  .sa-setup-badge {
306
329
  width: 44px;
307
330
  height: 44px;
331
+ /* Same reasoning as `.sa-login-logo` on the login card: the mark is a fixed
332
+ square, so it is the wrong flex item to take the row's shortfall from.
333
+ Here it was squeezed at every width, 1440 included — 35.61px in the
334
+ platform's own fixture, 33.56px in the example app — because the two
335
+ switchers made the row's content wider than the card outright. */
336
+ flex: none;
308
337
  border-radius: 10px;
309
338
  display: flex;
310
339
  align-items: center;
@@ -322,6 +351,11 @@ async function submitConfirm(): Promise<void> {
322
351
  font-size: var(--sa-text-xl);
323
352
  text-transform: uppercase;
324
353
  }
354
+ /* The block that absorbs what the pinned badge no longer gives up — see
355
+ `.sa-login-brand__text`, which carries the same rule for the same reason. */
356
+ .sa-setup-headings {
357
+ min-width: 0;
358
+ }
325
359
  .sa-setup-title {
326
360
  margin: 0;
327
361
  font-weight: 800;
@@ -1,44 +1,41 @@
1
1
  <template>
2
2
  <div class="sa-bundles__list">
3
- <div
3
+ <AdminAccordion
4
4
  v-for="bundle in filteredBundles"
5
5
  :key="bundle.id"
6
6
  class="sa-bd-card"
7
- :class="{ open: openKey === bundle.id }"
7
+ :open="openKey === bundle.id"
8
+ @update:open="emit('toggle', bundle)"
8
9
  >
9
- <div class="sa-bd-card__head" @click="emit('toggle', bundle)">
10
- <div class="sa-bd-card__mark"><q-icon name="inventory_2" size="18px" /></div>
11
- <div class="sa-bd-card__titlewrap">
12
- <div class="sa-bd-card__titlerow">
13
- <span class="sa-bd-card__key">{{ bundle.bundleKey }}</span>
14
- <span
15
- class="sa-bundle-chip"
16
- :class="`sa-bundle-chip--${statusClass(bundle)}`"
17
- :title="statusTooltip(bundle)"
18
- >
19
- {{ statusLabel(bundle) }}
20
- </span>
21
- <span
22
- v-if="i18nLocaleCount(bundle) > 0"
23
- class="sa-bundle-chip sa-bundle-chip--info"
24
- >
25
- {{ translationCount(bundle) }}
26
- </span>
10
+ <template #mark><q-icon name="inventory_2" size="18px" /></template>
11
+
12
+ <template #header>
13
+ <div class="sa-bd-card__head">
14
+ <div class="sa-bd-card__titlewrap">
15
+ <div class="sa-bd-card__titlerow">
16
+ <span class="sa-bd-card__key">{{ bundle.bundleKey }}</span>
17
+ <span
18
+ class="sa-bundle-chip"
19
+ :class="`sa-bundle-chip--${statusClass(bundle)}`"
20
+ :title="statusTooltip(bundle)"
21
+ >
22
+ {{ statusLabel(bundle) }}
23
+ </span>
24
+ <span
25
+ v-if="i18nLocaleCount(bundle) > 0"
26
+ class="sa-bundle-chip sa-bundle-chip--info"
27
+ >
28
+ {{ translationCount(bundle) }}
29
+ </span>
30
+ </div>
31
+ <div class="sa-bd-card__name">{{ bundle.label }}</div>
32
+ <div class="sa-bd-card__desc">{{ bundle.description || '—' }}</div>
27
33
  </div>
28
- <div class="sa-bd-card__name">{{ bundle.label }}</div>
29
- <div class="sa-bd-card__desc">{{ bundle.description || '—' }}</div>
30
34
  </div>
31
- <q-icon
32
- name="chevron_right"
33
- class="sa-bd-card__chev"
34
- :class="{ open: openKey === bundle.id }"
35
- />
36
- </div>
35
+ </template>
37
36
 
38
- <div v-if="openKey === bundle.id" class="sa-bd-card__body">
39
- <slot name="detail" :bundle="bundle" />
40
- </div>
41
- </div>
37
+ <slot name="detail" :bundle="bundle" />
38
+ </AdminAccordion>
42
39
 
43
40
  <div v-if="bundlesTotal > 0 && filteredBundles.length === 0" class="sa-bd-empty-row">
44
41
  {{ msg.list.emptyNoMatch }}
@@ -48,6 +45,7 @@
48
45
 
49
46
  <script setup lang="ts">
50
47
  import type { BundleRow } from '@saasicat/types';
48
+ import AdminAccordion from '../../components/admin-page/AdminAccordion.vue';
51
49
  import {
52
50
  bundleStatusMeta,
53
51
  type BundleAggregateStatus,
@@ -1,71 +1,79 @@
1
1
  <template>
2
- <div class="sa-fc" :class="{ expanded }">
3
- <div class="sa-fc__head" @click="emit('toggle')">
4
- <q-icon :name="iconValue || 'help_outline'" size="22px" class="sa-fc__icon" />
5
- <div class="sa-fc__main">
6
- <div class="sa-fc__titlerow">
7
- <code class="sa-fc__key">{{ feature.featureKey }}</code>
8
- <span class="sa-fc__label">{{ labelValue || feature.featureKey }}</span>
9
- <span v-if="newCapsCount > 0" class="sa-fc__flag sa-fc__flag--new">
10
- {{ newCapsLabel }}
11
- </span>
12
- <span v-if="deprecatedCapsCount > 0" class="sa-fc__flag sa-fc__flag--dep">
13
- {{ deprecatedCapsLabel }}
14
- </span>
15
- <span
16
- v-if="feature.successorKey"
17
- class="sa-fc__flag sa-fc__flag--succ"
18
- :title="replacedByLabel"
19
- >
20
- {{ replacedByLabel }}
21
- </span>
2
+ <AdminAccordion class="sa-fc" :open="expanded" @update:open="emit('toggle')">
3
+ <template #mark><q-icon :name="iconValue || 'help_outline'" size="18px" /></template>
4
+
5
+ <template #header>
6
+ <div class="sa-fc__head">
7
+ <div class="sa-fc__main">
8
+ <div class="sa-fc__titlerow">
9
+ <code class="sa-fc__key">{{ feature.featureKey }}</code>
10
+ <span class="sa-fc__label">{{ labelValue || feature.featureKey }}</span>
11
+ <span v-if="newCapsCount > 0" class="sa-fc__flag sa-fc__flag--new">
12
+ {{ newCapsLabel }}
13
+ </span>
14
+ <span v-if="deprecatedCapsCount > 0" class="sa-fc__flag sa-fc__flag--dep">
15
+ {{ deprecatedCapsLabel }}
16
+ </span>
17
+ <span
18
+ v-if="feature.successorKey"
19
+ class="sa-fc__flag sa-fc__flag--succ"
20
+ :title="replacedByLabel"
21
+ >
22
+ {{ replacedByLabel }}
23
+ </span>
24
+ <span
25
+ v-if="feature.replaces.length"
26
+ class="sa-fc__flag sa-fc__flag--repl"
27
+ :title="replacesLabel"
28
+ >
29
+ {{ replacesLabel }}
30
+ </span>
31
+ </div>
32
+ <div class="sa-fc__sub">
33
+ <template v-if="owners.length">
34
+ <span class="sa-muted">{{ msg.owner }}</span>
35
+ {{ owners.join(', ') }}
36
+ <span class="sa-fc__dot">·</span>
37
+ </template>
38
+ {{ capabilities.length }}
39
+ {{
40
+ capabilities.length === 1
41
+ ? msg.feature.capabilityOne
42
+ : msg.feature.capabilityMany
43
+ }}
44
+ <template v-if="tierValue">
45
+ <span class="sa-fc__dot">·</span>
46
+ <span class="sa-fc__tier">{{ tierValue }}</span>
47
+ </template>
48
+ </div>
49
+ </div>
50
+
51
+ <div class="sa-fc__coverage">
22
52
  <span
23
- v-if="feature.replaces.length"
24
- class="sa-fc__flag sa-fc__flag--repl"
25
- :title="replacesLabel"
53
+ v-for="lng in targetLocales"
54
+ :key="lng"
55
+ class="sa-cov-pill"
56
+ :class="coverageClass(coverage(lng))"
26
57
  >
27
- {{ replacesLabel }}
58
+ <span>{{ localeShort(lng) }}</span>
59
+ <span>{{ coveragePct(coverage(lng)) }}%</span>
28
60
  </span>
29
61
  </div>
30
- <div class="sa-fc__sub">
31
- <template v-if="owners.length">
32
- <span class="sa-muted">{{ msg.owner }}</span>
33
- {{ owners.join(', ') }}
34
- <span class="sa-fc__dot">·</span>
35
- </template>
36
- {{ capabilities.length }}
37
- {{
38
- capabilities.length === 1
39
- ? msg.feature.capabilityOne
40
- : msg.feature.capabilityMany
41
- }}
42
- <template v-if="tierValue">
43
- <span class="sa-fc__dot">·</span>
44
- <span class="sa-fc__tier">{{ tierValue }}</span>
45
- </template>
46
- </div>
47
- </div>
48
-
49
- <div class="sa-fc__coverage">
50
- <span
51
- v-for="lng in targetLocales"
52
- :key="lng"
53
- class="sa-cov-pill"
54
- :class="coverageClass(coverage(lng))"
55
- >
56
- <span>{{ localeShort(lng) }}</span>
57
- <span>{{ coveragePct(coverage(lng)) }}%</span>
58
- </span>
59
62
  </div>
63
+ </template>
60
64
 
65
+ <!-- Outside the trigger, not merely `@click.stop` inside it. The old
66
+ head was a `<div @click>` with this control nested in it, and the
67
+ only thing keeping a status change from also toggling the card was
68
+ a `.stop` somebody remembered. Structure beats memory. -->
69
+ <template #header-actions>
61
70
  <DiscoveryStatusControl
62
71
  :status="feature.discoveryStatus"
63
72
  @set-status="(target) => emit('review', feature.featureKey, target)"
64
73
  />
65
- <q-icon name="chevron_right" class="sa-fc__chev" :class="{ open: expanded }" />
66
- </div>
74
+ </template>
67
75
 
68
- <div v-if="expanded" class="sa-fc__body">
76
+ <div class="sa-fc__body">
69
77
  <div v-if="feature.discoveryStatus === 'outdated'" class="sa-fc__banner warn">
70
78
  <q-icon name="warning" size="16px" />
71
79
  <span>
@@ -140,7 +148,7 @@
140
148
  "
141
149
  />
142
150
  </div>
143
- </div>
151
+ </AdminAccordion>
144
152
  </template>
145
153
 
146
154
  <script setup lang="ts">
@@ -152,6 +160,7 @@ import type {
152
160
  FeatureCatalogEntryRow,
153
161
  UpdateCatalogEntryBaseData,
154
162
  } from '@saasicat/types';
163
+ import AdminAccordion from '../../components/admin-page/AdminAccordion.vue';
155
164
  import CatalogEntryTransPanel from './CatalogEntryTransPanel.vue';
156
165
  import DiscoveryCapList from './DiscoveryCapList.vue';
157
166
  import DiscoveryStatusControl from './DiscoveryStatusControl.vue';
@@ -268,26 +277,16 @@ function coverage(locale: string): number {
268
277
  </script>
269
278
 
270
279
  <style scoped>
271
- .sa-fc {
272
- border: 1px solid var(--sa-color-border);
273
- border-radius: 10px;
274
- background: var(--sa-color-bg-surface);
275
- overflow: hidden;
276
- }
277
- .sa-fc.expanded {
278
- border-color: var(--sa-color-scheduled-border);
279
- box-shadow: 0 1px 3px var(--sa-shadow-tint-2);
280
- }
280
+ /* Surface, radius, open border, head padding, chevron and body come from
281
+ * `AdminAccordion`. What is left is what a FEATURE row puts in that header.
282
+ *
283
+ * These seven rules were byte-identical to `DiscoveryQuotaCard`'s after a
284
+ * prefix rename — jscpd could not see it, because `minTokens: 50` is larger
285
+ * than any single rule here. The duplication was real and below the threshold. */
281
286
  .sa-fc__head {
282
287
  display: flex;
283
288
  align-items: center;
284
- gap: 12px;
285
- padding: 10px 12px;
286
- cursor: pointer;
287
- }
288
- .sa-fc__icon {
289
- color: var(--sa-color-fg-secondary);
290
- flex-shrink: 0;
289
+ gap: var(--sa-space-4);
291
290
  }
292
291
  .sa-fc__main {
293
292
  flex: 1;
@@ -340,9 +339,13 @@ function coverage(locale: string): number {
340
339
  color: var(--sa-color-fg-muted);
341
340
  margin-top: 1px;
342
341
  }
342
+ /* `-subtle`, not `-disabled`. Nothing here is disabled: this is the quietest
343
+ * rung of readable text, and the disabled rung is not on that ladder — it read
344
+ * 1.48:1 on the card's own surface, which the contrast check only saw once a
345
+ * fixture rendered a feature row for the first time. */
343
346
  .sa-fc__dot {
344
347
  margin: 0 4px;
345
- color: var(--sa-color-fg-disabled);
348
+ color: var(--sa-color-fg-subtle);
346
349
  }
347
350
  .sa-fc__tier {
348
351
  font-weight: 700;
@@ -355,18 +358,6 @@ function coverage(locale: string): number {
355
358
  gap: 6px;
356
359
  flex-shrink: 0;
357
360
  }
358
- .sa-fc__chev {
359
- color: var(--sa-color-fg-subtle);
360
- transition: transform 0.15s;
361
- }
362
- .sa-fc__chev.open {
363
- transform: rotate(90deg);
364
- }
365
- .sa-fc__body {
366
- border-top: 1px solid var(--sa-color-border-soft);
367
- padding: 12px;
368
- background: var(--sa-color-bg-sunken);
369
- }
370
361
  .sa-fc__banner {
371
362
  display: flex;
372
363
  align-items: center;
@@ -403,11 +394,12 @@ function coverage(locale: string): number {
403
394
  color: var(--sa-color-fg-subtle);
404
395
  margin-bottom: 8px;
405
396
  }
397
+ /* Same swap, same reason: "2 · read-only" sat at 1.42:1 on the sunken column. */
406
398
  .sa-fc__split-count {
407
399
  font-weight: 600;
408
400
  text-transform: none;
409
401
  letter-spacing: 0;
410
- color: var(--sa-color-fg-disabled);
402
+ color: var(--sa-color-fg-subtle);
411
403
  margin-left: 6px;
412
404
  }
413
405
  .sa-fc__fields {
@@ -1,62 +1,76 @@
1
1
  <template>
2
- <div class="sa-qc" :class="{ expanded, warn: !quota.usageProvider }">
3
- <div class="sa-qc__head" @click="emit('toggle')">
4
- <q-icon
5
- :name="quota.usageProvider ? 'inventory_2' : 'error'"
6
- :color="quota.usageProvider ? 'primary' : 'negative'"
7
- size="20px"
8
- class="sa-qc__icon"
9
- />
10
- <div class="sa-qc__main">
11
- <div class="sa-qc__titlerow">
12
- <span class="sa-qc__label">{{ labelValue || quota.quotaKey }}</span>
13
- <code class="sa-qc__key">{{ quota.quotaKey }}</code>
14
- <span class="sa-chip">{{ quota.enforcementMode }}</span>
15
- <span
16
- v-if="quota.successorKey"
17
- class="sa-qc__flag sa-qc__flag--succ"
18
- :title="replacedByLabel"
19
- >
20
- {{ replacedByLabel }}
21
- </span>
2
+ <AdminAccordion
3
+ class="sa-qc"
4
+ :class="{ warn: !quota.usageProvider }"
5
+ :open="expanded"
6
+ :mark-tone="quota.usageProvider ? 'accent' : 'negative'"
7
+ @update:open="emit('toggle')"
8
+ >
9
+ <!-- The glyph already says it; the tone lets the badge say it too. The
10
+ `:color` this replaces was a Quasar PALETTE name, which reaches past
11
+ the role layer — `negative` there is Quasar's, not the theme's. -->
12
+ <template #mark>
13
+ <q-icon :name="quota.usageProvider ? 'inventory_2' : 'error'" size="18px" />
14
+ </template>
15
+
16
+ <template #header>
17
+ <div class="sa-qc__head">
18
+ <div class="sa-qc__main">
19
+ <div class="sa-qc__titlerow">
20
+ <span class="sa-qc__label">{{ labelValue || quota.quotaKey }}</span>
21
+ <code class="sa-qc__key">{{ quota.quotaKey }}</code>
22
+ <span class="sa-chip">{{ quota.enforcementMode }}</span>
23
+ <span
24
+ v-if="quota.successorKey"
25
+ class="sa-qc__flag sa-qc__flag--succ"
26
+ :title="replacedByLabel"
27
+ >
28
+ {{ replacedByLabel }}
29
+ </span>
30
+ <span
31
+ v-if="quota.replaces.length"
32
+ class="sa-qc__flag sa-qc__flag--repl"
33
+ :title="replacesLabel"
34
+ >
35
+ {{ replacesLabel }}
36
+ </span>
37
+ </div>
38
+ <div class="sa-qc__sub">
39
+ {{ msg.unit }} <code>{{ quota.unit }}</code> · {{ msg.quota.usageProvider }}
40
+ <code v-if="quota.usageProvider">{{ quota.usageProvider }}</code>
41
+ <span v-else class="sa-qc__missing">{{
42
+ msg.quota.usageProviderMissing
43
+ }}</span>
44
+ </div>
45
+ <div v-if="!quota.usageProvider" class="sa-qc__warning">
46
+ {{ msg.quota.noUsageProviderWarning }}
47
+ </div>
48
+ </div>
49
+
50
+ <div class="sa-qc__coverage">
22
51
  <span
23
- v-if="quota.replaces.length"
24
- class="sa-qc__flag sa-qc__flag--repl"
25
- :title="replacesLabel"
52
+ v-for="lng in targetLocales"
53
+ :key="lng"
54
+ class="sa-cov-pill"
55
+ :class="coverageClass(coverage(lng))"
26
56
  >
27
- {{ replacesLabel }}
57
+ <span>{{ localeShort(lng) }}</span>
58
+ <span>{{ coveragePct(coverage(lng)) }}%</span>
28
59
  </span>
29
60
  </div>
30
- <div class="sa-qc__sub">
31
- {{ msg.unit }} <code>{{ quota.unit }}</code> · {{ msg.quota.usageProvider }}
32
- <code v-if="quota.usageProvider">{{ quota.usageProvider }}</code>
33
- <span v-else class="sa-qc__missing">{{ msg.quota.usageProviderMissing }}</span>
34
- </div>
35
- <div v-if="!quota.usageProvider" class="sa-qc__warning">
36
- {{ msg.quota.noUsageProviderWarning }}
37
- </div>
38
- </div>
39
-
40
- <div class="sa-qc__coverage">
41
- <span
42
- v-for="lng in targetLocales"
43
- :key="lng"
44
- class="sa-cov-pill"
45
- :class="coverageClass(coverage(lng))"
46
- >
47
- <span>{{ localeShort(lng) }}</span>
48
- <span>{{ coveragePct(coverage(lng)) }}%</span>
49
- </span>
50
61
  </div>
62
+ </template>
51
63
 
64
+ <!-- See DiscoveryFeatureCard: outside the trigger, not `@click.stop`
65
+ inside it. -->
66
+ <template #header-actions>
52
67
  <DiscoveryStatusControl
53
68
  :status="quota.discoveryStatus"
54
69
  @set-status="(target) => emit('review', quota.quotaKey, target)"
55
70
  />
56
- <q-icon name="chevron_right" class="sa-qc__chev" :class="{ open: expanded }" />
57
- </div>
71
+ </template>
58
72
 
59
- <div v-if="expanded" class="sa-qc__body">
73
+ <div class="sa-qc__body">
60
74
  <div v-if="quota.discoveryStatus === 'outdated'" class="sa-qc__banner">
61
75
  <q-icon name="warning" size="16px" />
62
76
  <span>
@@ -74,7 +88,7 @@
74
88
  "
75
89
  />
76
90
  </div>
77
- </div>
91
+ </AdminAccordion>
78
92
  </template>
79
93
 
80
94
  <script setup lang="ts">
@@ -85,6 +99,7 @@ import type {
85
99
  QuotaCatalogEntryRow,
86
100
  UpdateCatalogEntryBaseData,
87
101
  } from '@saasicat/types';
102
+ import AdminAccordion from '../../components/admin-page/AdminAccordion.vue';
88
103
  import CatalogEntryTransPanel from './CatalogEntryTransPanel.vue';
89
104
  import DiscoveryStatusControl from './DiscoveryStatusControl.vue';
90
105
  import { formatMessage } from '../../client/i18n/format.js';
@@ -153,31 +168,22 @@ function coverage(locale: string): number {
153
168
  </script>
154
169
 
155
170
  <style scoped>
156
- .sa-qc {
157
- border: 1px solid var(--sa-color-border);
158
- border-radius: 10px;
159
- background: var(--sa-color-bg-surface);
160
- overflow: hidden;
161
- }
171
+ /* Surface, radius, open border, head padding, chevron and body come from
172
+ * `AdminAccordion`. What is left is what a QUOTA row puts in that header — and
173
+ * `warn`, which is this card's own idea and not the accordion's.
174
+ *
175
+ * Two values changed by moving: the open border was
176
+ * `--sa-color-scheduled-border` plus a shadow here and `--sa-color-accent`
177
+ * everywhere else, and the head padding was 10/12 against 12/14 next door.
178
+ * Those differences were the finding, not the design. */
162
179
  .sa-qc.warn {
163
180
  border-color: var(--sa-color-negative-border);
164
- }
165
- .sa-qc.expanded {
166
- border-color: var(--sa-color-scheduled-border);
167
- box-shadow: 0 1px 3px var(--sa-shadow-tint-2);
181
+ background: var(--sa-color-negative-surface);
168
182
  }
169
183
  .sa-qc__head {
170
184
  display: flex;
171
185
  align-items: center;
172
- gap: 12px;
173
- padding: 10px 12px;
174
- cursor: pointer;
175
- }
176
- .sa-qc.warn .sa-qc__head {
177
- background: var(--sa-color-negative-surface);
178
- }
179
- .sa-qc__icon {
180
- flex-shrink: 0;
186
+ gap: var(--sa-space-4);
181
187
  }
182
188
  .sa-qc__main {
183
189
  flex: 1;
@@ -230,18 +236,6 @@ function coverage(locale: string): number {
230
236
  gap: 6px;
231
237
  flex-shrink: 0;
232
238
  }
233
- .sa-qc__chev {
234
- color: var(--sa-color-fg-subtle);
235
- transition: transform 0.15s;
236
- }
237
- .sa-qc__chev.open {
238
- transform: rotate(90deg);
239
- }
240
- .sa-qc__body {
241
- border-top: 1px solid var(--sa-color-border-soft);
242
- padding: 12px;
243
- background: var(--sa-color-bg-sunken);
244
- }
245
239
  .sa-qc__banner {
246
240
  display: flex;
247
241
  align-items: center;