@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.
- package/LICENSE +202 -0
- package/README.md +47 -0
- package/dist/index.cjs +3776 -0
- package/dist/index.d.cts +2395 -0
- package/dist/index.d.ts +2395 -0
- package/dist/index.js +3659 -0
- package/dist/testing-e2e/admin-pages-suite.cjs +134 -0
- package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
- package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
- package/dist/testing-e2e/admin-pages-suite.js +109 -0
- package/package.json +87 -0
- package/src/action-registry.ts +110 -0
- package/src/batch-column-fetcher.ts +152 -0
- package/src/boot-loader.ts +60 -0
- package/src/components/BundleVersionPublishDialog.vue +359 -0
- package/src/components/BusinessTypeVersionEditorDialog.vue +417 -0
- package/src/components/BusinessTypeVersionPublishDialog.vue +258 -0
- package/src/components/FeatureGate.vue +56 -0
- package/src/components/KpiCard.vue +53 -0
- package/src/components/KvBlock.vue +36 -0
- package/src/components/MarketingPromotionsTab.vue +861 -0
- package/src/components/MfaPromptDialog.vue +104 -0
- package/src/components/TenantActionConfirmDialog.vue +252 -0
- package/src/components/VersionDiffPreview.vue +150 -0
- package/src/components/bundle-editor/BundleCreatePanel.vue +681 -0
- package/src/components/bundle-editor/BundleFeaturesEditor.vue +215 -0
- package/src/components/bundle-editor/BundlePlanCompatPicker.vue +316 -0
- package/src/components/bundle-editor/BundleQuotasEditor.vue +219 -0
- package/src/components/bundle-editor/BundleStatusBanner.vue +189 -0
- package/src/components/bundle-editor/BundleVersionInlineEditor.vue +659 -0
- package/src/components/bundle-editor/BundleVersionStrip.vue +207 -0
- package/src/components/bundle-editor/bundle-version-status.ts +164 -0
- package/src/components/bundle-editor/catalog-i18n.ts +85 -0
- package/src/components/dialogs/PilotCreateDialog.vue +780 -0
- package/src/components/dialogs/PilotEditDialog.vue +548 -0
- package/src/components/dialogs/PromoCodeCreateDialog.vue +737 -0
- package/src/components/dialogs/PromoCodeEditDialog.vue +859 -0
- package/src/components/dialogs/types.ts +126 -0
- package/src/components/plan/PlanCycleToggle.vue +99 -0
- package/src/components/plan/PlanGrid.vue +211 -0
- package/src/components/plan/PriceSummary.vue +293 -0
- package/src/components/plan/PromoCodeInput.vue +192 -0
- package/src/components/plan/PublicBundleGrid.vue +287 -0
- package/src/components/plan-cockpit/PlanCockpit.vue +993 -0
- package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +65 -0
- package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +91 -0
- package/src/components/plan-cockpit/PlanCockpitHeader.vue +98 -0
- package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +99 -0
- package/src/components/plan-cockpit/PlanCockpitKpis.vue +57 -0
- package/src/components/plan-cockpit/PlanCockpitVersions.vue +226 -0
- package/src/components/plan-cockpit/types.ts +72 -0
- package/src/components/plan-create-dialog/PlanCreateDialog.vue +508 -0
- package/src/components/plan-detail/PlanAuditLog.vue +59 -0
- package/src/components/plan-detail/PlanDetail.vue +1195 -0
- package/src/components/plan-detail/PlanDetailHeader.vue +185 -0
- package/src/components/plan-detail/PlanDetailKpis.vue +57 -0
- package/src/components/plan-detail/PlanTerminateDialog.vue +76 -0
- package/src/components/plan-detail/PlanVersionDiffPanel.vue +149 -0
- package/src/components/plan-detail/PlanVersionsPanel.vue +255 -0
- package/src/components/plan-detail/types.ts +59 -0
- package/src/components/plan-list/PlanList.vue +1067 -0
- package/src/components/plan-matrix/PlanMatrix.vue +1113 -0
- package/src/components/plan-review/PlanReview.vue +742 -0
- package/src/components/plan-version-editor/PlanCatalogPreview.vue +184 -0
- package/src/components/plan-version-editor/PlanComponentPool.vue +272 -0
- package/src/components/plan-version-editor/PlanVersionBasket.vue +281 -0
- package/src/components/plan-version-editor/PlanVersionDiffDialog.vue +84 -0
- package/src/components/plan-version-editor/PlanVersionEditor.vue +1728 -0
- package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +94 -0
- package/src/components/plan-version-editor/types.ts +96 -0
- package/src/components/wizard-stepper/WizardStepper.vue +107 -0
- package/src/create-admin-routes.ts +68 -0
- package/src/create-super-admin-app.ts +345 -0
- package/src/entitlement-provider.ts +36 -0
- package/src/feature-router-guard.ts +77 -0
- package/src/http-json.ts +62 -0
- package/src/index.ts +98 -0
- package/src/manifest-loader.ts +139 -0
- package/src/manifest-store-factory.ts +114 -0
- package/src/nav-builder.ts +274 -0
- package/src/pages-standard/AdminLayout.vue +411 -0
- package/src/pages-standard/AdminManifestErrorPage.vue +79 -0
- package/src/pages-standard/AuditPage.vue +223 -0
- package/src/pages-standard/BundlesPage.vue +910 -0
- package/src/pages-standard/BusinessTypesPage.vue +662 -0
- package/src/pages-standard/DashboardPage.vue +595 -0
- package/src/pages-standard/DiscoveryPage.vue +663 -0
- package/src/pages-standard/EmailHistoryPage.vue +576 -0
- package/src/pages-standard/MarketingCatalogPage.vue +1771 -0
- package/src/pages-standard/PilotsPage.vue +577 -0
- package/src/pages-standard/PlanVersionsPage.vue +216 -0
- package/src/pages-standard/PlansPage.vue +1222 -0
- package/src/pages-standard/PlatformEmailPage.vue +434 -0
- package/src/pages-standard/PromoCodeDetailPage.vue +331 -0
- package/src/pages-standard/PromoCodesPage.vue +589 -0
- package/src/pages-standard/SubscriptionsPage.vue +159 -0
- package/src/pages-standard/SuperAdminLoginPage.vue +299 -0
- package/src/pages-standard/SuperAdminSetupWizard.vue +405 -0
- package/src/pages-standard/TenantDetailPage.vue +432 -0
- package/src/pages-standard/TenantsPage.vue +732 -0
- package/src/pages-standard/UsersPage.vue +504 -0
- package/src/pages-standard/bundles-page/BundleAccordionList.vue +116 -0
- package/src/pages-standard/bundles-page/BundleDetailPanel.vue +193 -0
- package/src/pages-standard/bundles-page/BundlesFilterBar.vue +48 -0
- package/src/pages-standard/bundles-page/BundlesHeader.vue +67 -0
- package/src/pages-standard/bundles-page/BundlesKpis.vue +39 -0
- package/src/pages-standard/bundles-page/types.ts +15 -0
- package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +246 -0
- package/src/pages-standard/discovery-page/DiscoveryCapList.vue +171 -0
- package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +410 -0
- package/src/pages-standard/discovery-page/DiscoveryHeader.vue +31 -0
- package/src/pages-standard/discovery-page/DiscoveryKpis.vue +47 -0
- package/src/pages-standard/discovery-page/DiscoveryMetaBanner.vue +26 -0
- package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +247 -0
- package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +73 -0
- package/src/pages-standard/discovery-page/discovery-ui.ts +166 -0
- package/src/pages-standard/email-history.types.ts +48 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +448 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +93 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +138 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +58 -0
- package/src/pages-standard/marketing-catalog/types.ts +43 -0
- package/src/pages-standard/plan-versions/PlanDiffCard.vue +132 -0
- package/src/pages-standard/plan-versions/PlanVersionHeader.vue +293 -0
- package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +48 -0
- package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +307 -0
- package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +269 -0
- package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +65 -0
- package/src/pages-standard/plan-versions/PlanVersionsList.vue +292 -0
- package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +261 -0
- package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +300 -0
- package/src/pages-standard/plan-versions/VersionDiffPreview.vue +167 -0
- package/src/pages-standard/plan-versions/format.ts +53 -0
- package/src/pages-standard/plan-versions/types.ts +33 -0
- package/src/pages-standard/plans-page/PlanArchiveDialog.vue +50 -0
- package/src/pages-standard/plans-page/PlanBundleOverview.vue +147 -0
- package/src/pages-standard/plans-page/PlanDiscardDraftDialog.vue +46 -0
- package/src/pages-standard/plans-page/PlanPublishDialog.vue +106 -0
- package/src/pages-standard/plans-page/PlansPageToast.vue +69 -0
- package/src/pages-standard/plans-page/types.ts +18 -0
- package/src/pages-standard/platform-email.types.ts +41 -0
- package/src/pages-standard/sa-theme.css +223 -0
- package/src/pages-standard/tenants/StatusPill.vue +62 -0
- package/src/pages-standard/tenants/format.ts +38 -0
- package/src/pages-tenant/LimitsRow.vue +67 -0
- package/src/pages-tenant/MySubscriptionBundlesPage.vue +604 -0
- package/src/pages-tenant/OnboardingConfigurator.vue +417 -0
- package/src/pages-tenant/PackageSnapshotPanel.vue +241 -0
- package/src/pages-tenant/PendingVersionBanner.vue +126 -0
- package/src/pages-tenant/PlanChangeWizard.vue +630 -0
- package/src/pages-tenant/TenantPlanSection.vue +729 -0
- package/src/pages-tenant/UsageBar.vue +107 -0
- package/src/pages-tenant/default-i18n.ts +280 -0
- package/src/pages-tenant/tenant-plan-section/BundlePreviewDialog.vue +304 -0
- package/src/pages-tenant/tenant-plan-section/TenantBundleStore.vue +323 -0
- package/src/pages-tenant/tenant-plan-section/TenantFeatureMatrix.vue +122 -0
- package/src/pages-tenant/tenant-plan-section/TenantPlanCardHeader.vue +67 -0
- package/src/pages-tenant/tenant-plan-section/TenantUsageGrid.vue +31 -0
- package/src/plan-versions-catalog.ts +368 -0
- package/src/platform-loaders.ts +68 -0
- package/src/project-page-host.ts +108 -0
- package/src/testing-e2e/admin-pages-suite.ts +187 -0
- package/src/types.ts +58 -0
- package/src/use-actions.ts +25 -0
- package/src/use-api-list.ts +151 -0
- package/src/use-audit-entries.ts +48 -0
- package/src/use-batch-columns.ts +59 -0
- package/src/use-bulk-publish.ts +150 -0
- package/src/use-bundle-versions-map.ts +112 -0
- package/src/use-bundles.ts +297 -0
- package/src/use-business-types.ts +269 -0
- package/src/use-catalog-entries.ts +243 -0
- package/src/use-discovery.ts +158 -0
- package/src/use-entitlement.ts +87 -0
- package/src/use-live-plan-versions.ts +126 -0
- package/src/use-manifest.ts +57 -0
- package/src/use-marketing-projections.ts +160 -0
- package/src/use-nav.ts +33 -0
- package/src/use-plan-editor.ts +162 -0
- package/src/use-plan-versions.ts +83 -0
- package/src/use-plans.ts +375 -0
- package/src/use-platform-tenant-actions.ts +286 -0
- package/src/use-promotions.ts +126 -0
- package/src/use-public-boot.ts +40 -0
- package/src/use-subscription-draft.ts +355 -0
- package/src/use-super-admin-context.ts +93 -0
- package/src/use-tenant-action-flow.ts +238 -0
- package/src/use-tenant-billing-catalog.ts +160 -0
- package/src/use-tenant-billing.ts +453 -0
- package/src/use-tenant-manifest.ts +94 -0
- package/src/use-tenant-subscription-bundles.ts +148 -0
- package/src/use-tenants.ts +61 -0
- 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>
|