@saasicat/ui-vue 0.2.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 (193) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +47 -0
  3. package/dist/index.cjs +3776 -0
  4. package/dist/index.d.cts +2395 -0
  5. package/dist/index.d.ts +2395 -0
  6. package/dist/index.js +3659 -0
  7. package/dist/testing-e2e/admin-pages-suite.cjs +134 -0
  8. package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
  9. package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
  10. package/dist/testing-e2e/admin-pages-suite.js +109 -0
  11. package/package.json +87 -0
  12. package/src/action-registry.ts +110 -0
  13. package/src/batch-column-fetcher.ts +152 -0
  14. package/src/boot-loader.ts +60 -0
  15. package/src/components/BundleVersionPublishDialog.vue +359 -0
  16. package/src/components/BusinessTypeVersionEditorDialog.vue +417 -0
  17. package/src/components/BusinessTypeVersionPublishDialog.vue +258 -0
  18. package/src/components/FeatureGate.vue +56 -0
  19. package/src/components/KpiCard.vue +53 -0
  20. package/src/components/KvBlock.vue +36 -0
  21. package/src/components/MarketingPromotionsTab.vue +861 -0
  22. package/src/components/MfaPromptDialog.vue +104 -0
  23. package/src/components/TenantActionConfirmDialog.vue +252 -0
  24. package/src/components/VersionDiffPreview.vue +150 -0
  25. package/src/components/bundle-editor/BundleCreatePanel.vue +681 -0
  26. package/src/components/bundle-editor/BundleFeaturesEditor.vue +215 -0
  27. package/src/components/bundle-editor/BundlePlanCompatPicker.vue +316 -0
  28. package/src/components/bundle-editor/BundleQuotasEditor.vue +219 -0
  29. package/src/components/bundle-editor/BundleStatusBanner.vue +189 -0
  30. package/src/components/bundle-editor/BundleVersionInlineEditor.vue +659 -0
  31. package/src/components/bundle-editor/BundleVersionStrip.vue +207 -0
  32. package/src/components/bundle-editor/bundle-version-status.ts +164 -0
  33. package/src/components/bundle-editor/catalog-i18n.ts +85 -0
  34. package/src/components/dialogs/PilotCreateDialog.vue +780 -0
  35. package/src/components/dialogs/PilotEditDialog.vue +548 -0
  36. package/src/components/dialogs/PromoCodeCreateDialog.vue +737 -0
  37. package/src/components/dialogs/PromoCodeEditDialog.vue +859 -0
  38. package/src/components/dialogs/types.ts +126 -0
  39. package/src/components/plan/PlanCycleToggle.vue +99 -0
  40. package/src/components/plan/PlanGrid.vue +211 -0
  41. package/src/components/plan/PriceSummary.vue +293 -0
  42. package/src/components/plan/PromoCodeInput.vue +192 -0
  43. package/src/components/plan/PublicBundleGrid.vue +287 -0
  44. package/src/components/plan-cockpit/PlanCockpit.vue +993 -0
  45. package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +65 -0
  46. package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +91 -0
  47. package/src/components/plan-cockpit/PlanCockpitHeader.vue +98 -0
  48. package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +99 -0
  49. package/src/components/plan-cockpit/PlanCockpitKpis.vue +57 -0
  50. package/src/components/plan-cockpit/PlanCockpitVersions.vue +226 -0
  51. package/src/components/plan-cockpit/types.ts +72 -0
  52. package/src/components/plan-create-dialog/PlanCreateDialog.vue +508 -0
  53. package/src/components/plan-detail/PlanAuditLog.vue +59 -0
  54. package/src/components/plan-detail/PlanDetail.vue +1195 -0
  55. package/src/components/plan-detail/PlanDetailHeader.vue +185 -0
  56. package/src/components/plan-detail/PlanDetailKpis.vue +57 -0
  57. package/src/components/plan-detail/PlanTerminateDialog.vue +76 -0
  58. package/src/components/plan-detail/PlanVersionDiffPanel.vue +149 -0
  59. package/src/components/plan-detail/PlanVersionsPanel.vue +255 -0
  60. package/src/components/plan-detail/types.ts +59 -0
  61. package/src/components/plan-list/PlanList.vue +1067 -0
  62. package/src/components/plan-matrix/PlanMatrix.vue +1113 -0
  63. package/src/components/plan-review/PlanReview.vue +742 -0
  64. package/src/components/plan-version-editor/PlanCatalogPreview.vue +184 -0
  65. package/src/components/plan-version-editor/PlanComponentPool.vue +272 -0
  66. package/src/components/plan-version-editor/PlanVersionBasket.vue +281 -0
  67. package/src/components/plan-version-editor/PlanVersionDiffDialog.vue +84 -0
  68. package/src/components/plan-version-editor/PlanVersionEditor.vue +1728 -0
  69. package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +94 -0
  70. package/src/components/plan-version-editor/types.ts +96 -0
  71. package/src/components/wizard-stepper/WizardStepper.vue +107 -0
  72. package/src/create-admin-routes.ts +68 -0
  73. package/src/create-super-admin-app.ts +345 -0
  74. package/src/entitlement-provider.ts +36 -0
  75. package/src/feature-router-guard.ts +77 -0
  76. package/src/http-json.ts +62 -0
  77. package/src/index.ts +98 -0
  78. package/src/manifest-loader.ts +139 -0
  79. package/src/manifest-store-factory.ts +114 -0
  80. package/src/nav-builder.ts +274 -0
  81. package/src/pages-standard/AdminLayout.vue +411 -0
  82. package/src/pages-standard/AdminManifestErrorPage.vue +79 -0
  83. package/src/pages-standard/AuditPage.vue +223 -0
  84. package/src/pages-standard/BundlesPage.vue +910 -0
  85. package/src/pages-standard/BusinessTypesPage.vue +662 -0
  86. package/src/pages-standard/DashboardPage.vue +595 -0
  87. package/src/pages-standard/DiscoveryPage.vue +663 -0
  88. package/src/pages-standard/EmailHistoryPage.vue +576 -0
  89. package/src/pages-standard/MarketingCatalogPage.vue +1771 -0
  90. package/src/pages-standard/PilotsPage.vue +577 -0
  91. package/src/pages-standard/PlanVersionsPage.vue +216 -0
  92. package/src/pages-standard/PlansPage.vue +1222 -0
  93. package/src/pages-standard/PlatformEmailPage.vue +434 -0
  94. package/src/pages-standard/PromoCodeDetailPage.vue +331 -0
  95. package/src/pages-standard/PromoCodesPage.vue +589 -0
  96. package/src/pages-standard/SubscriptionsPage.vue +159 -0
  97. package/src/pages-standard/SuperAdminLoginPage.vue +299 -0
  98. package/src/pages-standard/SuperAdminSetupWizard.vue +405 -0
  99. package/src/pages-standard/TenantDetailPage.vue +432 -0
  100. package/src/pages-standard/TenantsPage.vue +732 -0
  101. package/src/pages-standard/UsersPage.vue +504 -0
  102. package/src/pages-standard/bundles-page/BundleAccordionList.vue +116 -0
  103. package/src/pages-standard/bundles-page/BundleDetailPanel.vue +193 -0
  104. package/src/pages-standard/bundles-page/BundlesFilterBar.vue +48 -0
  105. package/src/pages-standard/bundles-page/BundlesHeader.vue +67 -0
  106. package/src/pages-standard/bundles-page/BundlesKpis.vue +39 -0
  107. package/src/pages-standard/bundles-page/types.ts +15 -0
  108. package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +246 -0
  109. package/src/pages-standard/discovery-page/DiscoveryCapList.vue +171 -0
  110. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +410 -0
  111. package/src/pages-standard/discovery-page/DiscoveryHeader.vue +31 -0
  112. package/src/pages-standard/discovery-page/DiscoveryKpis.vue +47 -0
  113. package/src/pages-standard/discovery-page/DiscoveryMetaBanner.vue +26 -0
  114. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +247 -0
  115. package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +73 -0
  116. package/src/pages-standard/discovery-page/discovery-ui.ts +166 -0
  117. package/src/pages-standard/email-history.types.ts +48 -0
  118. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +448 -0
  119. package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +93 -0
  120. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +138 -0
  121. package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +58 -0
  122. package/src/pages-standard/marketing-catalog/types.ts +43 -0
  123. package/src/pages-standard/plan-versions/PlanDiffCard.vue +132 -0
  124. package/src/pages-standard/plan-versions/PlanVersionHeader.vue +293 -0
  125. package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +48 -0
  126. package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +307 -0
  127. package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +269 -0
  128. package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +65 -0
  129. package/src/pages-standard/plan-versions/PlanVersionsList.vue +292 -0
  130. package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +261 -0
  131. package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +300 -0
  132. package/src/pages-standard/plan-versions/VersionDiffPreview.vue +167 -0
  133. package/src/pages-standard/plan-versions/format.ts +53 -0
  134. package/src/pages-standard/plan-versions/types.ts +33 -0
  135. package/src/pages-standard/plans-page/PlanArchiveDialog.vue +50 -0
  136. package/src/pages-standard/plans-page/PlanBundleOverview.vue +147 -0
  137. package/src/pages-standard/plans-page/PlanDiscardDraftDialog.vue +46 -0
  138. package/src/pages-standard/plans-page/PlanPublishDialog.vue +106 -0
  139. package/src/pages-standard/plans-page/PlansPageToast.vue +69 -0
  140. package/src/pages-standard/plans-page/types.ts +18 -0
  141. package/src/pages-standard/platform-email.types.ts +41 -0
  142. package/src/pages-standard/sa-theme.css +223 -0
  143. package/src/pages-standard/tenants/StatusPill.vue +62 -0
  144. package/src/pages-standard/tenants/format.ts +38 -0
  145. package/src/pages-tenant/LimitsRow.vue +67 -0
  146. package/src/pages-tenant/MySubscriptionBundlesPage.vue +604 -0
  147. package/src/pages-tenant/OnboardingConfigurator.vue +417 -0
  148. package/src/pages-tenant/PackageSnapshotPanel.vue +241 -0
  149. package/src/pages-tenant/PendingVersionBanner.vue +126 -0
  150. package/src/pages-tenant/PlanChangeWizard.vue +630 -0
  151. package/src/pages-tenant/TenantPlanSection.vue +729 -0
  152. package/src/pages-tenant/UsageBar.vue +107 -0
  153. package/src/pages-tenant/default-i18n.ts +280 -0
  154. package/src/pages-tenant/tenant-plan-section/BundlePreviewDialog.vue +304 -0
  155. package/src/pages-tenant/tenant-plan-section/TenantBundleStore.vue +323 -0
  156. package/src/pages-tenant/tenant-plan-section/TenantFeatureMatrix.vue +122 -0
  157. package/src/pages-tenant/tenant-plan-section/TenantPlanCardHeader.vue +67 -0
  158. package/src/pages-tenant/tenant-plan-section/TenantUsageGrid.vue +31 -0
  159. package/src/plan-versions-catalog.ts +368 -0
  160. package/src/platform-loaders.ts +68 -0
  161. package/src/project-page-host.ts +108 -0
  162. package/src/testing-e2e/admin-pages-suite.ts +187 -0
  163. package/src/types.ts +58 -0
  164. package/src/use-actions.ts +25 -0
  165. package/src/use-api-list.ts +151 -0
  166. package/src/use-audit-entries.ts +48 -0
  167. package/src/use-batch-columns.ts +59 -0
  168. package/src/use-bulk-publish.ts +150 -0
  169. package/src/use-bundle-versions-map.ts +112 -0
  170. package/src/use-bundles.ts +297 -0
  171. package/src/use-business-types.ts +269 -0
  172. package/src/use-catalog-entries.ts +243 -0
  173. package/src/use-discovery.ts +158 -0
  174. package/src/use-entitlement.ts +87 -0
  175. package/src/use-live-plan-versions.ts +126 -0
  176. package/src/use-manifest.ts +57 -0
  177. package/src/use-marketing-projections.ts +160 -0
  178. package/src/use-nav.ts +33 -0
  179. package/src/use-plan-editor.ts +162 -0
  180. package/src/use-plan-versions.ts +83 -0
  181. package/src/use-plans.ts +375 -0
  182. package/src/use-platform-tenant-actions.ts +286 -0
  183. package/src/use-promotions.ts +126 -0
  184. package/src/use-public-boot.ts +40 -0
  185. package/src/use-subscription-draft.ts +355 -0
  186. package/src/use-super-admin-context.ts +93 -0
  187. package/src/use-tenant-action-flow.ts +238 -0
  188. package/src/use-tenant-billing-catalog.ts +160 -0
  189. package/src/use-tenant-billing.ts +453 -0
  190. package/src/use-tenant-manifest.ts +94 -0
  191. package/src/use-tenant-subscription-bundles.ts +148 -0
  192. package/src/use-tenants.ts +61 -0
  193. package/src/version.ts +10 -0
@@ -0,0 +1,732 @@
1
+ <template>
2
+ <div class="sa-tenants">
3
+ <header class="sa-tenants__head">
4
+ <div>
5
+ <h1 class="sa-tenants__title">Mandanten</h1>
6
+ <p v-if="subtitle" class="sa-tenants__sub">{{ subtitle }}</p>
7
+ </div>
8
+ </header>
9
+
10
+ <div class="sa-tenants__filter">
11
+ <div class="sa-tenants__search">
12
+ <q-icon name="search" size="15px" />
13
+ <input
14
+ v-model="searchInput"
15
+ placeholder="Slug oder Name …"
16
+ @input="reloadDebounced"
17
+ />
18
+ <button v-if="searchInput" class="sa-tenants__clear" @click="onClearSearch">
19
+ <q-icon name="close" size="13px" />
20
+ </button>
21
+ </div>
22
+ <select v-model="statusFilter" class="sa-tenants__select" @change="applyFilter">
23
+ <option :value="null">Alle Aktiv-Status</option>
24
+ <option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
25
+ {{ opt.label }}
26
+ </option>
27
+ </select>
28
+ <select
29
+ v-if="planOptions && planOptions.length > 0"
30
+ v-model="planFilter"
31
+ class="sa-tenants__select"
32
+ @change="applyFilter"
33
+ >
34
+ <option :value="null">{{ planFilterLabel }}</option>
35
+ <option v-for="p in planOptions" :key="p" :value="p">{{ p }}</option>
36
+ </select>
37
+ <slot name="filters-extra" />
38
+ </div>
39
+
40
+ <q-banner v-if="error" class="bg-red-1 text-red-9 q-mb-md" rounded>
41
+ <strong>Fehler:</strong> {{ error.message }}
42
+ </q-banner>
43
+
44
+ <div class="sa-tenants__card">
45
+ <div class="sa-tenants__wrap">
46
+ <table>
47
+ <thead>
48
+ <tr>
49
+ <th>Mandant</th>
50
+ <th v-if="showPlanColumn">{{ planColumnLabel }}</th>
51
+ <th>Status</th>
52
+ <th v-if="usageFields.length > 0" class="num">Verbrauch</th>
53
+ <th class="num">Angelegt</th>
54
+ <th v-if="hasActions" class="num"></th>
55
+ </tr>
56
+ </thead>
57
+ <tbody>
58
+ <tr v-for="row in items" :key="row.id">
59
+ <td>
60
+ <div class="sa-tenants__tenant">
61
+ <div class="sa-tenants__avatar" :style="avatarStyle(row)">
62
+ {{ tenantInitials(row.name) }}
63
+ </div>
64
+ <div>
65
+ <div class="sa-tenants__name">{{ row.name }}</div>
66
+ <div class="sa-tenants__slug">{{ row.slug }}</div>
67
+ </div>
68
+ </div>
69
+ </td>
70
+ <td v-if="showPlanColumn">
71
+ <span class="sa-tenants__plan">
72
+ <span
73
+ class="sa-tenants__plan-dot"
74
+ :style="{ background: planAccentFor(row) }"
75
+ />
76
+ {{ planLabel(row) }}
77
+ </span>
78
+ </td>
79
+ <td>
80
+ <div class="sa-tenants__pills">
81
+ <slot name="status-pills" :row="row">
82
+ <StatusPill
83
+ v-for="(p, i) in resolvedPills(row)"
84
+ :key="i"
85
+ :label="p.label"
86
+ :icon="p.icon"
87
+ :tone="p.tone"
88
+ />
89
+ </slot>
90
+ </div>
91
+ </td>
92
+ <td v-if="usageFields.length > 0" class="num">
93
+ <div
94
+ v-for="(uf, i) in usageFields"
95
+ :key="i"
96
+ class="sa-tenants__usage"
97
+ >
98
+ <q-icon :name="uf.icon" size="11px" />
99
+ {{ usageValue(row, uf) }}
100
+ </div>
101
+ </td>
102
+ <td class="num sa-tenants__mono">
103
+ {{ formatDateDe(usageStr(row, 'createdAt')) }}
104
+ </td>
105
+ <td v-if="hasActions" class="num sa-tenants__actions">
106
+ <slot name="row-actions" :row="row" :actions="visibleActions(row)">
107
+ <component
108
+ :is="action.to ? 'a' : 'button'"
109
+ v-for="action in visibleActions(row)"
110
+ :key="action.id"
111
+ :href="action.to ? action.to(row) : undefined"
112
+ class="sa-tenants__icon-btn"
113
+ :class="
114
+ action.tone
115
+ ? `sa-tenants__icon-btn--${action.tone}`
116
+ : ''
117
+ "
118
+ :title="action.label"
119
+ @click="action.handler ? action.handler(row) : undefined"
120
+ >
121
+ <q-icon :name="action.icon" size="15px" />
122
+ </component>
123
+ </slot>
124
+ </td>
125
+ </tr>
126
+ </tbody>
127
+ </table>
128
+ <div v-if="items.length === 0 && !loading" class="sa-tenants__empty">
129
+ <q-icon name="search_off" size="32px" />
130
+ <div>Keine Mandanten mit diesen Filtern.</div>
131
+ </div>
132
+ <div v-if="loading" class="sa-tenants__loading">
133
+ <q-spinner size="20px" /> wird geladen…
134
+ </div>
135
+ </div>
136
+ </div>
137
+
138
+ <footer class="sa-tenants__foot">
139
+ {{ total }} Mandanten · Seite {{ page }} /
140
+ {{ Math.max(1, Math.ceil(total / pageSize)) }}
141
+ </footer>
142
+
143
+ <!-- Manifest-Driven Action Flow: only mounted when the `manifest` prop
144
+ is set AND `manifestActionsEnabled` is true (default when manifest
145
+ is set). Apps without a manifest flow do not need to duplicate the
146
+ dialog mounts. -->
147
+ <template v-if="manifestFlow">
148
+ <MfaPromptDialog
149
+ v-model="manifestFlow.mfa.value.show"
150
+ :description="manifestFlow.mfa.value.description"
151
+ :error="manifestFlow.mfa.value.error"
152
+ @update:model-value="manifestFlow.onMfaDialogVisibility"
153
+ @confirm="manifestFlow.onMfaConfirm"
154
+ />
155
+ <TenantActionConfirmDialog
156
+ v-model="manifestFlow.confirmDialog.value.show"
157
+ :def="manifestFlow.confirmDialog.value.def"
158
+ :row="manifestFlow.confirmDialog.value.row"
159
+ @update:model-value="manifestFlow.onConfirmDialogVisibility"
160
+ @submit="manifestFlow.onConfirmSubmit"
161
+ @cancel="manifestFlow.onConfirmCancel"
162
+ />
163
+ </template>
164
+ </div>
165
+ </template>
166
+
167
+ <script setup lang="ts">
168
+ import { computed, ref, watch } from 'vue';
169
+ import type {
170
+ AdminManifest,
171
+ TenantActionDef,
172
+ TenantDto,
173
+ TenantListFilter,
174
+ } from '@saasicat/types';
175
+ import type { HttpClient } from '../types.js';
176
+ import { useTenants } from '../use-tenants.js';
177
+ import {
178
+ usePlatformTenantActions,
179
+ type PlatformTenantActionTone,
180
+ } from '../use-platform-tenant-actions.js';
181
+ import MfaPromptDialog from '../components/MfaPromptDialog.vue';
182
+ import TenantActionConfirmDialog from '../components/TenantActionConfirmDialog.vue';
183
+ import StatusPill, { type PillTone } from './tenants/StatusPill.vue';
184
+ import {
185
+ DEFAULT_PLAN_ACCENTS,
186
+ formatDateDe,
187
+ planAccent,
188
+ tenantInitials,
189
+ } from './tenants/format.js';
190
+
191
+ // Platform standard page: tenant list.
192
+ //
193
+ // Default layout: avatar + slug subtitle, status pills, one
194
+ // configurable usage column (icon+field pairs), created-at date,
195
+ // optional actions column.
196
+ //
197
+ // App-specific bits via props:
198
+ // - `subtitle` : header subtitle
199
+ // - `statusOptions` : filter dropdown values (default: Aktiv/Suspendiert)
200
+ // - `planOptions` : filter dropdown values for plan; omitted = hides dropdown
201
+ // - `planAccents` : plan id → accent color (avatar + plan dot)
202
+ // - `planLabelField` : field for the plan display (e.g. "plan" or "bundleKey")
203
+ // - `usageFields` : usage columns (icon + field), e.g.
204
+ // [{icon:'person',field:'users'},{icon:'directions_car',field:'vehicles'}]
205
+ // - `pillsForRow` : (row) => pills (Pilot/Trial/etc.)
206
+ // - `actions` : per-row action buttons with optional `tone` and
207
+ // `condition`
208
+ //
209
+ // Slots:
210
+ // - `#filters-extra` : additional filters
211
+ // - `#status-pills` : override the pills entirely
212
+ // - `#row-actions` : override the actions column (e.g. to render
213
+ // only a detail link and process the manifest
214
+ // actions alongside via the `manifestActions`
215
+ // slot prop)
216
+ //
217
+ // Manifest-Driven Action Flow (optional):
218
+ // - When the `manifest` prop is set AND `manifestActionsEnabled` (default
219
+ // `true` when `manifest` is set), the page takes over the full
220
+ // Confirm→MFA→handler orchestration (`useTenantActionFlow`) incl.
221
+ // dialog mounts (`MfaPromptDialog` + `TenantActionConfirmDialog`).
222
+ // Apps then no longer need to write the state/resolver boilerplate
223
+ // themselves.
224
+ // - The resulting manifest actions are APPENDED to `props.actions`. If the
225
+ // same `actionKey` is declared both as a custom action and in the
226
+ // manifest, the manifest action wins — the custom one is filtered out
227
+ // + a warning in the console.
228
+ // - In the `#row-actions` slot the combined list is provided via the
229
+ // `actions` slot prop; apps with a custom slot render it themselves.
230
+
231
+ /**
232
+ * Row type accepted by all configuration functions (pillsForRow,
233
+ * action.handler, etc.). Apps pass their own tenant row shapes through —
234
+ * `TenantDto` forms the platform minimum, app fields are added via an index
235
+ * signature (plan, usage, isPilot, etc.).
236
+ */
237
+ export type TenantRow = TenantDto & Record<string, unknown>;
238
+
239
+ export interface StatusPillDef {
240
+ label: string;
241
+ icon?: string;
242
+ tone: PillTone;
243
+ }
244
+
245
+ export interface UsageField {
246
+ icon: string;
247
+ field: string;
248
+ /** Optional: format function (default: String(val)). */
249
+ format?: (val: unknown, row: TenantRow) => string;
250
+ }
251
+
252
+ export interface TenantRowAction {
253
+ id: string;
254
+ label: string;
255
+ icon: string;
256
+ tone?: 'positive' | 'negative' | 'muted' | 'primary' | 'warning' | 'accent';
257
+ /**
258
+ * Optional, but recommended for custom actions that have the same behavior
259
+ * as a manifest action (e.g. `tenants.suspend`). Used for the dedup logic
260
+ * against manifest actions.
261
+ */
262
+ actionKey?: string;
263
+ /** If set, renders an anchor with href instead of a button. */
264
+ to?: (row: TenantRow) => string;
265
+ handler?: (row: TenantRow) => void;
266
+ /** If set, the action is only rendered when truthy. */
267
+ condition?: (row: TenantRow) => boolean;
268
+ }
269
+
270
+ // `endpoint` is mandatory — the platform does not know the app's globalPrefix
271
+ // (e.g. `/api/admin/tenants` or `/api/v1/admin/tenants`).
272
+ // A hardcoded default would ALWAYS serve some app incorrectly.
273
+ const props = withDefaults(
274
+ defineProps<{
275
+ endpoint: string;
276
+ getAuthToken?: () => string | null;
277
+ http?: HttpClient;
278
+ pageSize?: number;
279
+ subtitle?: string;
280
+ statusOptions?: ReadonlyArray<{ value: string; label: string }>;
281
+ planOptions?: readonly string[];
282
+ planFilterLabel?: string;
283
+ planColumnLabel?: string;
284
+ showPlanColumn?: boolean;
285
+ planLabelField?: string;
286
+ planAccents?: Record<string, string>;
287
+ usageFields?: readonly UsageField[];
288
+ pillsForRow?: (row: TenantRow) => StatusPillDef[];
289
+ actions?: readonly TenantRowAction[];
290
+ /**
291
+ * Manifest source for the Manifest-Driven Action Flow. When set, the
292
+ * page internally mounts MfaPromptDialog + TenantActionConfirmDialog
293
+ * and appends manifest actions to `actions`.
294
+ */
295
+ manifest?: AdminManifest | null;
296
+ /**
297
+ * Toggles the manifest-driven orchestration on/off. Default `true`
298
+ * when `manifest` is set — apps can explicitly disable the flow.
299
+ */
300
+ manifestActionsEnabled?: boolean;
301
+ /**
302
+ * Row-specific filter for manifest actions. Default: `suspend` only
303
+ * for active tenants, `reactivate` only for inactive ones, otherwise
304
+ * visible.
305
+ */
306
+ visibleForRow?: (def: TenantActionDef, row: TenantRow) => boolean;
307
+ /**
308
+ * Manifest action → icon. For the default mapping see
309
+ * `defaultIconForActionKey` in `use-platform-tenant-actions.ts`.
310
+ */
311
+ iconForActionKey?: (actionKey: string) => string;
312
+ /**
313
+ * Manifest action → tone. Default: suspend/revoke→negative,
314
+ * reactivate/grant→positive, otherwise→primary.
315
+ */
316
+ toneForActionKey?: (actionKey: string) => PlatformTenantActionTone['tone'];
317
+ /** Optional: MFA dialog description. */
318
+ mfaDescription?: (def: TenantActionDef, row: TenantRow) => string;
319
+ /**
320
+ * Success hook after action dispatch. Default: page.reload(). Apps with
321
+ * additional refresh needs can override the hook.
322
+ */
323
+ onActionSuccess?: () => Promise<void> | void;
324
+ /**
325
+ * Notify provider (toast/snackbar) for success/failure of the manifest
326
+ * actions. Default is a no-op — apps with Quasar typically pass in
327
+ * `(k, m) => $q.notify({ type: k, message: m, position: 'top' })`.
328
+ * Without notify, errors are only visible as a promise rejection.
329
+ */
330
+ actionNotify?: (kind: 'positive' | 'negative', message: string) => void;
331
+ }>(),
332
+ {
333
+ pageSize: 25,
334
+ statusOptions: () => [
335
+ { value: 'ACTIVE', label: 'Aktiv' },
336
+ { value: 'INACTIVE', label: 'Suspendiert' },
337
+ ],
338
+ planFilterLabel: 'Alle Pläne',
339
+ planColumnLabel: 'Plan',
340
+ showPlanColumn: true,
341
+ planLabelField: 'plan',
342
+ planAccents: () => DEFAULT_PLAN_ACCENTS,
343
+ usageFields: () => [],
344
+ manifest: null,
345
+ },
346
+ );
347
+
348
+ const filter = ref<TenantListFilter>({});
349
+ const searchInput = ref('');
350
+ const statusFilter = ref<string | null>(null);
351
+ const planFilter = ref<string | null>(null);
352
+
353
+ const list = useTenants<TenantRow>({
354
+ endpoint: props.endpoint,
355
+ filter,
356
+ http: props.http,
357
+ getAuthToken: props.getAuthToken,
358
+ });
359
+ const { items, page, total, loading, error, goToPage, setPageSize } = list;
360
+
361
+ setPageSize(props.pageSize);
362
+
363
+ let searchTimer: ReturnType<typeof setTimeout> | null = null;
364
+ function reloadDebounced(): void {
365
+ if (searchTimer) clearTimeout(searchTimer);
366
+ searchTimer = setTimeout(applyFilter, 250);
367
+ }
368
+
369
+ function onClearSearch(): void {
370
+ searchInput.value = '';
371
+ applyFilter();
372
+ }
373
+
374
+ function applyFilter(): void {
375
+ // The status value is passed through unchanged. Apps configure the values
376
+ // their backend expects via `statusOptions` (e.g. 'ACTIVE'/'INACTIVE';
377
+ // platform default: 'active'/'suspended').
378
+ filter.value = {
379
+ ...filter.value,
380
+ search: searchInput.value || undefined,
381
+ status: (statusFilter.value || undefined) as TenantListFilter['status'],
382
+ plan: planFilter.value ?? undefined,
383
+ };
384
+ void goToPage(1);
385
+ }
386
+
387
+ defineExpose({ reload: applyFilter });
388
+
389
+ watch(
390
+ () => props.endpoint,
391
+ () => void goToPage(1),
392
+ );
393
+
394
+ // ── Manifest-Driven Action Flow (optional) ────────────────────────
395
+ // We derive all reactive defaults from the props so that apps can change the
396
+ // props dynamically later (e.g. manifest reload) without the page needing to
397
+ // be remounted.
398
+ const manifestActionsEnabled = computed(
399
+ () => props.manifest != null && props.manifestActionsEnabled !== false,
400
+ );
401
+ const manifestRef = computed<AdminManifest | null>(() => props.manifest ?? null);
402
+
403
+ const manifestFlow = manifestActionsEnabled.value
404
+ ? usePlatformTenantActions<TenantRow>({
405
+ manifest: manifestRef,
406
+ notify: (kind, message) => {
407
+ props.actionNotify?.(kind, message);
408
+ },
409
+ onSuccess: async () => {
410
+ if (props.onActionSuccess) {
411
+ await props.onActionSuccess();
412
+ } else {
413
+ applyFilter();
414
+ }
415
+ },
416
+ visibleForRow: props.visibleForRow,
417
+ mfaDescription: props.mfaDescription,
418
+ iconForActionKey: props.iconForActionKey,
419
+ toneForActionKey: props.toneForActionKey,
420
+ })
421
+ : null;
422
+
423
+ if (manifestFlow && typeof window !== 'undefined') {
424
+ watch(
425
+ manifestFlow.realOrphans,
426
+ (orphans) => {
427
+ if (orphans.length > 0) {
428
+ // eslint-disable-next-line no-console
429
+ console.warn(
430
+ '[PlatformTenantsPage] Manifest-Actions ohne Handler in createSuperAdminApp({ actions }):',
431
+ orphans,
432
+ );
433
+ }
434
+ },
435
+ { immediate: true },
436
+ );
437
+ }
438
+
439
+ // Combined action list: custom actions (via the `actions` prop) + manifest
440
+ // actions. On a duplicate `actionKey` the manifest action wins — the custom
441
+ // one is filtered out + a warning, so app devs see the conflict.
442
+ const combinedActions = computed<TenantRowAction[]>(() => {
443
+ const custom = (props.actions ?? []).slice();
444
+ const manifestList = manifestFlow ? manifestFlow.manifestActions.value : [];
445
+ if (manifestList.length === 0) return custom;
446
+ const manifestKeys = new Set(manifestList.map((a) => a.actionKey));
447
+ const filteredCustom = custom.filter((a) => {
448
+ if (a.actionKey && manifestKeys.has(a.actionKey)) {
449
+ if (typeof window !== 'undefined') {
450
+ // eslint-disable-next-line no-console
451
+ console.warn(
452
+ `[PlatformTenantsPage] Custom-Action "${a.id}" (actionKey="${a.actionKey}") ` +
453
+ `kollidiert mit Manifest-Action — Manifest gewinnt.`,
454
+ );
455
+ }
456
+ return false;
457
+ }
458
+ return true;
459
+ });
460
+ return [...filteredCustom, ...manifestList];
461
+ });
462
+
463
+ const hasActions = computed(() => combinedActions.value.length > 0);
464
+
465
+ function avatarStyle(row: TenantRow): Record<string, string> {
466
+ const accent = planAccentFor(row);
467
+ return { background: `${accent}18`, color: accent };
468
+ }
469
+
470
+ function planAccentFor(row: TenantRow): string {
471
+ const planId = row[props.planLabelField] as string | null | undefined;
472
+ return planAccent(planId, props.planAccents);
473
+ }
474
+
475
+ function planLabel(row: TenantRow): string {
476
+ const v = row[props.planLabelField];
477
+ return v != null ? String(v) : '—';
478
+ }
479
+
480
+ function usageValue(row: TenantRow, uf: UsageField): string {
481
+ const v = row[uf.field];
482
+ if (uf.format) return uf.format(v, row);
483
+ return v == null ? '—' : String(v);
484
+ }
485
+
486
+ function usageStr(row: TenantRow, key: string): string | undefined {
487
+ const v = row[key];
488
+ return typeof v === 'string' ? v : undefined;
489
+ }
490
+
491
+ function resolvedPills(row: TenantRow): StatusPillDef[] {
492
+ if (props.pillsForRow) return props.pillsForRow(row);
493
+ // Default: only Aktiv/Suspendiert.
494
+ const isActive = (row as TenantDto).isActive;
495
+ return [
496
+ isActive
497
+ ? { label: 'Aktiv', icon: 'check_circle', tone: 'positive' }
498
+ : { label: 'Suspendiert', icon: 'block', tone: 'negative' },
499
+ ];
500
+ }
501
+
502
+ function visibleActions(row: TenantRow): TenantRowAction[] {
503
+ return combinedActions.value.filter((a) => !a.condition || a.condition(row));
504
+ }
505
+ </script>
506
+
507
+ <style scoped>
508
+ .sa-tenants {
509
+ min-height: calc(100vh - 56px);
510
+ background: var(--sa-bg-app, #f1f5f9);
511
+ }
512
+ .sa-tenants__head {
513
+ padding: 20px 28px 8px;
514
+ }
515
+ .sa-tenants__title {
516
+ margin: 0;
517
+ font-family: var(--sa-font-head, system-ui, sans-serif);
518
+ font-weight: 700;
519
+ font-size: 22px;
520
+ color: var(--sa-heading, #0f172a);
521
+ letter-spacing: -0.01em;
522
+ }
523
+ .sa-tenants__sub {
524
+ margin: 4px 0 0;
525
+ color: var(--sa-muted-dark, #475569);
526
+ font-size: 13.5px;
527
+ line-height: 1.5;
528
+ }
529
+
530
+ .sa-tenants__filter {
531
+ padding: 12px 28px;
532
+ display: flex;
533
+ gap: 10px;
534
+ flex-wrap: wrap;
535
+ }
536
+ .sa-tenants__search {
537
+ flex: 1;
538
+ min-width: 240px;
539
+ display: flex;
540
+ align-items: center;
541
+ gap: 6px;
542
+ background: #fff;
543
+ border: 1px solid var(--sa-border, #e2e8f0);
544
+ border-radius: 8px;
545
+ padding: 6px 10px;
546
+ }
547
+ .sa-tenants__search :deep(.q-icon) {
548
+ color: var(--sa-muted, #64748b);
549
+ }
550
+ .sa-tenants__search input {
551
+ flex: 1;
552
+ border: none;
553
+ outline: none;
554
+ background: transparent;
555
+ font-size: 13px;
556
+ font-family: inherit;
557
+ color: var(--sa-body, #1e293b);
558
+ min-width: 0;
559
+ }
560
+ .sa-tenants__clear {
561
+ background: transparent;
562
+ border: none;
563
+ cursor: pointer;
564
+ padding: 2px;
565
+ }
566
+ .sa-tenants__select {
567
+ padding: 7px 10px;
568
+ font-size: 12.5px;
569
+ font-family: inherit;
570
+ border: 1px solid var(--sa-border, #e2e8f0);
571
+ border-radius: 8px;
572
+ background: #fff;
573
+ cursor: pointer;
574
+ }
575
+
576
+ .sa-tenants__card {
577
+ margin: 0 28px 28px;
578
+ background: #fff;
579
+ border: 1px solid var(--sa-border, #e2e8f0);
580
+ border-radius: 12px;
581
+ overflow: hidden;
582
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
583
+ }
584
+ .sa-tenants__wrap {
585
+ overflow-x: auto;
586
+ }
587
+ table {
588
+ width: 100%;
589
+ border-collapse: collapse;
590
+ font-size: 13px;
591
+ min-width: 800px;
592
+ }
593
+ thead tr {
594
+ background: #fafbfc;
595
+ }
596
+ th {
597
+ padding: 10px 14px;
598
+ font-size: 10.5px;
599
+ font-weight: 700;
600
+ letter-spacing: 0.08em;
601
+ color: var(--sa-muted, #64748b);
602
+ text-transform: uppercase;
603
+ text-align: left;
604
+ }
605
+ td {
606
+ padding: 11px 14px;
607
+ vertical-align: middle;
608
+ border-top: 1px solid var(--sa-border-soft, #f1f5f9);
609
+ }
610
+ .num {
611
+ text-align: right;
612
+ }
613
+ .sa-tenants__mono {
614
+ font-family: var(--sa-font-mono, ui-monospace, monospace);
615
+ font-size: 12px;
616
+ }
617
+
618
+ .sa-tenants__tenant {
619
+ display: flex;
620
+ align-items: center;
621
+ gap: 10px;
622
+ }
623
+ .sa-tenants__avatar {
624
+ width: 32px;
625
+ height: 32px;
626
+ border-radius: 8px;
627
+ font-weight: 800;
628
+ font-size: 12px;
629
+ font-family: var(--sa-font-head, system-ui, sans-serif);
630
+ display: flex;
631
+ align-items: center;
632
+ justify-content: center;
633
+ flex-shrink: 0;
634
+ }
635
+ .sa-tenants__name {
636
+ font-size: 13px;
637
+ font-weight: 600;
638
+ color: var(--sa-heading, #0f172a);
639
+ }
640
+ .sa-tenants__slug {
641
+ font-size: 11px;
642
+ color: var(--sa-muted, #64748b);
643
+ font-family: var(--sa-font-mono, ui-monospace, monospace);
644
+ }
645
+
646
+ .sa-tenants__plan {
647
+ display: inline-flex;
648
+ align-items: center;
649
+ gap: 6px;
650
+ }
651
+ .sa-tenants__plan-dot {
652
+ width: 6px;
653
+ height: 6px;
654
+ border-radius: 3px;
655
+ }
656
+
657
+ .sa-tenants__pills {
658
+ display: flex;
659
+ gap: 4px;
660
+ flex-wrap: wrap;
661
+ }
662
+
663
+ .sa-tenants__usage {
664
+ display: inline-flex;
665
+ align-items: center;
666
+ gap: 4px;
667
+ font-size: 12px;
668
+ color: var(--sa-muted-dark, #475569);
669
+ margin-left: 8px;
670
+ }
671
+ .sa-tenants__usage :deep(.q-icon) {
672
+ color: var(--sa-muted, #64748b);
673
+ }
674
+
675
+ .sa-tenants__actions {
676
+ white-space: nowrap;
677
+ }
678
+ .sa-tenants__icon-btn {
679
+ background: transparent;
680
+ border: none;
681
+ cursor: pointer;
682
+ width: 28px;
683
+ height: 28px;
684
+ border-radius: 6px;
685
+ display: inline-flex;
686
+ align-items: center;
687
+ justify-content: center;
688
+ text-decoration: none;
689
+ color: var(--sa-muted, #64748b);
690
+ }
691
+ .sa-tenants__icon-btn:hover {
692
+ background: #f1f5f9;
693
+ }
694
+ .sa-tenants__icon-btn :deep(.q-icon) {
695
+ color: inherit;
696
+ }
697
+ .sa-tenants__icon-btn--negative {
698
+ color: var(--sa-negative, #dc2626);
699
+ }
700
+ .sa-tenants__icon-btn--positive {
701
+ color: var(--sa-positive, #047857);
702
+ }
703
+ .sa-tenants__icon-btn--primary {
704
+ color: var(--sa-primary, #3f6bff);
705
+ }
706
+ .sa-tenants__icon-btn--warning {
707
+ color: var(--sa-warning, #d97706);
708
+ }
709
+ .sa-tenants__icon-btn--accent {
710
+ color: var(--sa-accent, #7c3aed);
711
+ }
712
+
713
+ .sa-tenants__empty {
714
+ padding: 40px 16px;
715
+ text-align: center;
716
+ color: var(--sa-muted, #64748b);
717
+ }
718
+ .sa-tenants__loading {
719
+ padding: 24px;
720
+ display: flex;
721
+ align-items: center;
722
+ gap: 12px;
723
+ color: var(--sa-muted, #64748b);
724
+ }
725
+
726
+ .sa-tenants__foot {
727
+ padding: 0 28px 28px;
728
+ text-align: right;
729
+ font-size: 12px;
730
+ color: var(--sa-muted, #64748b);
731
+ }
732
+ </style>