@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,595 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="sa-dashboard">
|
|
3
|
+
<header class="sa-page-head sa-dashboard__head">
|
|
4
|
+
<div>
|
|
5
|
+
<h1 class="sa-page-head__title">Dashboard</h1>
|
|
6
|
+
<p v-if="subtitle" class="sa-page-head__sub">{{ subtitle }}</p>
|
|
7
|
+
</div>
|
|
8
|
+
<div class="sa-page-head__actions">
|
|
9
|
+
<q-btn
|
|
10
|
+
flat
|
|
11
|
+
dense
|
|
12
|
+
icon="refresh"
|
|
13
|
+
:loading="loading"
|
|
14
|
+
aria-label="Neu laden"
|
|
15
|
+
@click="reload"
|
|
16
|
+
/>
|
|
17
|
+
</div>
|
|
18
|
+
</header>
|
|
19
|
+
|
|
20
|
+
<q-banner v-if="error" class="bg-red-1 text-red-9 q-mb-md" rounded>
|
|
21
|
+
<strong>Fehler:</strong> {{ error.message }}
|
|
22
|
+
</q-banner>
|
|
23
|
+
|
|
24
|
+
<div v-if="loading && !cards.length" class="sa-dashboard__loading">
|
|
25
|
+
<q-spinner size="32px" /> Daten werden geladen…
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div v-else-if="!cards.length" class="sa-dashboard__empty">
|
|
29
|
+
Keine KPI-Cards im Manifest deklariert.
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div v-else class="sa-dashboard__strip">
|
|
33
|
+
<article v-for="card in cards" :key="card.id" class="sa-kpi" :data-card-id="card.id">
|
|
34
|
+
<div class="sa-kpi__icon" v-if="card.displayHint?.icon">
|
|
35
|
+
<q-icon :name="card.displayHint.icon" size="20px" />
|
|
36
|
+
</div>
|
|
37
|
+
<div class="sa-kpi__body">
|
|
38
|
+
<div class="sa-kpi__label">{{ card.label }}</div>
|
|
39
|
+
<div class="sa-kpi__value">
|
|
40
|
+
<q-spinner v-if="card.loading" size="18px" />
|
|
41
|
+
<span v-else-if="card.error" class="sa-kpi__error">—</span>
|
|
42
|
+
<span v-else>{{ formatValue(card) }}</span>
|
|
43
|
+
</div>
|
|
44
|
+
<div v-if="card.sub" class="sa-kpi__sub">{{ card.sub }}</div>
|
|
45
|
+
</div>
|
|
46
|
+
</article>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div v-if="distributions && distributions.length > 0" class="sa-dashboard__rows">
|
|
50
|
+
<section
|
|
51
|
+
v-for="dist in distributions"
|
|
52
|
+
:key="dist.id"
|
|
53
|
+
class="sa-dashboard__card sa-dashboard__row-card"
|
|
54
|
+
>
|
|
55
|
+
<header class="sa-dashboard__row-head">
|
|
56
|
+
<h2>{{ dist.label }}</h2>
|
|
57
|
+
<span v-if="dist.total" class="sa-dashboard__count">{{ dist.total }}</span>
|
|
58
|
+
</header>
|
|
59
|
+
<ul class="sa-dashboard__bar-list">
|
|
60
|
+
<li
|
|
61
|
+
v-for="entry in dist.entries"
|
|
62
|
+
:key="entry.label"
|
|
63
|
+
class="sa-dashboard__bar-row"
|
|
64
|
+
>
|
|
65
|
+
<span class="sa-dashboard__bar-name">{{ entry.label }}</span>
|
|
66
|
+
<span class="sa-dashboard__bar-track">
|
|
67
|
+
<span
|
|
68
|
+
class="sa-dashboard__bar-fill"
|
|
69
|
+
:style="{
|
|
70
|
+
width: `${barWidth(entry.value, dist.maxValue)}%`,
|
|
71
|
+
background: entry.color ?? dist.barColor ?? defaultBarColor,
|
|
72
|
+
}"
|
|
73
|
+
/>
|
|
74
|
+
</span>
|
|
75
|
+
<span class="sa-dashboard__bar-count">{{ entry.value }}</span>
|
|
76
|
+
</li>
|
|
77
|
+
</ul>
|
|
78
|
+
</section>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<section
|
|
82
|
+
v-if="resolvedShortcuts.length > 0"
|
|
83
|
+
class="sa-dashboard__card sa-dashboard__shortcuts"
|
|
84
|
+
>
|
|
85
|
+
<header class="sa-dashboard__row-head">
|
|
86
|
+
<h2>Shortcuts</h2>
|
|
87
|
+
</header>
|
|
88
|
+
<div class="sa-dashboard__shortcut-grid">
|
|
89
|
+
<a
|
|
90
|
+
v-for="s in resolvedShortcuts"
|
|
91
|
+
:key="s.id"
|
|
92
|
+
:href="s.to"
|
|
93
|
+
class="sa-dashboard__shortcut"
|
|
94
|
+
>
|
|
95
|
+
<q-icon :name="s.icon ?? 'circle'" size="22px" />
|
|
96
|
+
<div>
|
|
97
|
+
<div class="sa-dashboard__shortcut-title">{{ s.label }}</div>
|
|
98
|
+
<div v-if="s.sub" class="sa-dashboard__shortcut-sub">{{ s.sub }}</div>
|
|
99
|
+
</div>
|
|
100
|
+
</a>
|
|
101
|
+
</div>
|
|
102
|
+
</section>
|
|
103
|
+
|
|
104
|
+
<slot name="after-kpis" />
|
|
105
|
+
</div>
|
|
106
|
+
</template>
|
|
107
|
+
|
|
108
|
+
<script setup lang="ts">
|
|
109
|
+
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
|
110
|
+
import type { AdminManifest, KpiCardDef } from '@saasicat/types';
|
|
111
|
+
import type { HttpClient } from '../types.js';
|
|
112
|
+
import { buildRoutes } from '../nav-builder.js';
|
|
113
|
+
|
|
114
|
+
// Platform standard page: Dashboard.
|
|
115
|
+
//
|
|
116
|
+
// Reads the KPI cards from the admin manifest (`dashboard.kpiCards`) and
|
|
117
|
+
// fetches the declared `endpoint` for each card. App-specific response shapes
|
|
118
|
+
// are projected into `{ value, sub }` by the optional `formatKpi` prop.
|
|
119
|
+
//
|
|
120
|
+
// Additionally:
|
|
121
|
+
// - `distributions` : list of bar charts (e.g. subscriptions per plan,
|
|
122
|
+
// promo status). Apps pass the data straight through — the platform
|
|
123
|
+
// renders the bar-chart layout.
|
|
124
|
+
// - `shortcuts` : list of shortcut cards. Default `'auto'` derives them
|
|
125
|
+
// from `manifest.navigation.standardPages` + `projectPages`. Apps
|
|
126
|
+
// may set an explicit override.
|
|
127
|
+
//
|
|
128
|
+
// Slots:
|
|
129
|
+
// - `#after-kpis` : additional free sections below the structures.
|
|
130
|
+
|
|
131
|
+
export interface KpiFormatted {
|
|
132
|
+
value: string | number;
|
|
133
|
+
sub?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface DistributionEntry {
|
|
137
|
+
label: string;
|
|
138
|
+
value: number;
|
|
139
|
+
/** Optional override color per entry (otherwise the distribution default). */
|
|
140
|
+
color?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface DistributionDef {
|
|
144
|
+
id: string;
|
|
145
|
+
label: string;
|
|
146
|
+
entries: readonly DistributionEntry[];
|
|
147
|
+
/** Optional: total sum for the header badge. */
|
|
148
|
+
total?: number | string;
|
|
149
|
+
/** Default color of the bar fills (can be overridden per entry). */
|
|
150
|
+
barColor?: string;
|
|
151
|
+
/** Maximum value for scaling; default = max(entries.value, 1). */
|
|
152
|
+
maxValue?: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface ShortcutDef {
|
|
156
|
+
id: string;
|
|
157
|
+
label: string;
|
|
158
|
+
sub?: string;
|
|
159
|
+
icon?: string;
|
|
160
|
+
to: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface Props {
|
|
164
|
+
/** Preloaded manifest. If null/undefined, loaded via `loadManifest`. */
|
|
165
|
+
manifest?: AdminManifest | null;
|
|
166
|
+
/** Custom loader for the manifest (fallback when `manifest` is not set). */
|
|
167
|
+
loadManifest?: () => Promise<AdminManifest>;
|
|
168
|
+
/** Custom HttpClient. Default: global `fetch`. */
|
|
169
|
+
http?: HttpClient;
|
|
170
|
+
/** Token provider (when the HttpClient default fetch is used). */
|
|
171
|
+
getAuthToken?: () => string | null;
|
|
172
|
+
/** Optional subtitle below the H1. */
|
|
173
|
+
subtitle?: string;
|
|
174
|
+
/**
|
|
175
|
+
* App-specific formatter for KPI responses. Default extracts
|
|
176
|
+
* `value`/`count`/`total` and uses `displayHint.type` for `sub`.
|
|
177
|
+
*/
|
|
178
|
+
formatKpi?: (card: KpiCardDef, body: unknown) => KpiFormatted;
|
|
179
|
+
/** Bar-chart sections (subscriptions/promos/...). */
|
|
180
|
+
distributions?: readonly DistributionDef[];
|
|
181
|
+
/**
|
|
182
|
+
* Shortcuts section.
|
|
183
|
+
* - `'auto'` (default): derived from the manifest navigation
|
|
184
|
+
* - `'none'`: no shortcuts section
|
|
185
|
+
* - list: explicit definitions
|
|
186
|
+
*/
|
|
187
|
+
shortcuts?: 'auto' | 'none' | readonly ShortcutDef[];
|
|
188
|
+
/** Shortcut sub-texts per StandardPage key (for `shortcuts: 'auto'`). */
|
|
189
|
+
shortcutDescriptions?: Partial<Record<string, string>>;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
193
|
+
shortcuts: 'auto',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
interface KpiCardState {
|
|
197
|
+
id: string;
|
|
198
|
+
label: string;
|
|
199
|
+
sub?: string;
|
|
200
|
+
displayHint: KpiCardDef['displayHint'];
|
|
201
|
+
endpoint: string;
|
|
202
|
+
loading: boolean;
|
|
203
|
+
error: Error | null;
|
|
204
|
+
value: string | number | null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const manifestRef = ref<AdminManifest | null>(props.manifest ?? null);
|
|
208
|
+
const cards = reactive<KpiCardState[]>([]);
|
|
209
|
+
const loading = ref(false);
|
|
210
|
+
const error = ref<Error | null>(null);
|
|
211
|
+
|
|
212
|
+
const defaultBarColor = 'linear-gradient(90deg, #3f6bff, #1d4ed8)';
|
|
213
|
+
|
|
214
|
+
watch(
|
|
215
|
+
() => props.manifest,
|
|
216
|
+
(m) => {
|
|
217
|
+
if (m) {
|
|
218
|
+
manifestRef.value = m;
|
|
219
|
+
void buildAndFetch();
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
onMounted(() => {
|
|
225
|
+
if (manifestRef.value) {
|
|
226
|
+
void buildAndFetch();
|
|
227
|
+
} else if (props.loadManifest) {
|
|
228
|
+
void reload();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const resolvedShortcuts = computed<ShortcutDef[]>(() => {
|
|
233
|
+
if (props.shortcuts === 'none') return [];
|
|
234
|
+
const currentPath = typeof window !== 'undefined' ? window.location.pathname : '';
|
|
235
|
+
let list: ShortcutDef[];
|
|
236
|
+
if (Array.isArray(props.shortcuts)) {
|
|
237
|
+
list = [...props.shortcuts];
|
|
238
|
+
} else if (!manifestRef.value) {
|
|
239
|
+
list = [];
|
|
240
|
+
} else {
|
|
241
|
+
const routes = buildRoutes(manifestRef.value);
|
|
242
|
+
list = routes.map((r) => ({
|
|
243
|
+
id: r.id,
|
|
244
|
+
label: r.label,
|
|
245
|
+
icon: r.icon,
|
|
246
|
+
to: r.path,
|
|
247
|
+
sub: props.shortcutDescriptions?.[r.id],
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
// Do not show the current page as a shortcut — linking from the dashboard
|
|
251
|
+
// to the dashboard is confusing.
|
|
252
|
+
return list.filter((s) => s.to !== currentPath && s.id !== 'dashboard');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
async function reload(): Promise<void> {
|
|
256
|
+
error.value = null;
|
|
257
|
+
if (!manifestRef.value && props.loadManifest) {
|
|
258
|
+
loading.value = true;
|
|
259
|
+
try {
|
|
260
|
+
manifestRef.value = await props.loadManifest();
|
|
261
|
+
} catch (err) {
|
|
262
|
+
error.value = err instanceof Error ? err : new Error(String(err));
|
|
263
|
+
loading.value = false;
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
await buildAndFetch();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function buildAndFetch(): Promise<void> {
|
|
271
|
+
if (!manifestRef.value) return;
|
|
272
|
+
const defs = manifestRef.value.dashboard?.kpiCards ?? [];
|
|
273
|
+
const sorted = [...defs].sort((a, b) => (b.slotPriority ?? 0) - (a.slotPriority ?? 0));
|
|
274
|
+
cards.splice(0, cards.length);
|
|
275
|
+
for (const def of sorted) {
|
|
276
|
+
cards.push({
|
|
277
|
+
id: def.id,
|
|
278
|
+
label: def.label,
|
|
279
|
+
displayHint: def.displayHint,
|
|
280
|
+
endpoint: def.endpoint,
|
|
281
|
+
loading: true,
|
|
282
|
+
error: null,
|
|
283
|
+
value: null,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
loading.value = false;
|
|
287
|
+
await Promise.all(cards.map((c, i) => fetchOne(i)));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function fetchOne(index: number): Promise<void> {
|
|
291
|
+
const card = cards[index];
|
|
292
|
+
if (!card) return;
|
|
293
|
+
card.loading = true;
|
|
294
|
+
card.error = null;
|
|
295
|
+
try {
|
|
296
|
+
const resp = await callHttp(card.endpoint);
|
|
297
|
+
if (resp.status >= 400) {
|
|
298
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
299
|
+
}
|
|
300
|
+
const body = await resp.json();
|
|
301
|
+
const formatter = props.formatKpi ?? defaultFormat;
|
|
302
|
+
const def: KpiCardDef = {
|
|
303
|
+
id: card.id,
|
|
304
|
+
label: card.label,
|
|
305
|
+
endpoint: card.endpoint,
|
|
306
|
+
displayHint: card.displayHint,
|
|
307
|
+
};
|
|
308
|
+
const out = formatter(def, body);
|
|
309
|
+
card.value = out.value;
|
|
310
|
+
card.sub = out.sub;
|
|
311
|
+
} catch (err) {
|
|
312
|
+
card.error = err instanceof Error ? err : new Error(String(err));
|
|
313
|
+
} finally {
|
|
314
|
+
card.loading = false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function defaultFormat(card: KpiCardDef, body: unknown): KpiFormatted {
|
|
319
|
+
const obj = (body ?? {}) as Record<string, unknown>;
|
|
320
|
+
const value = extractValue(obj);
|
|
321
|
+
return {
|
|
322
|
+
value: value ?? '—',
|
|
323
|
+
sub: extractSub(obj, card.displayHint.type),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function callHttp(url: string): Promise<{ status: number; json: () => Promise<unknown> }> {
|
|
328
|
+
if (props.http) {
|
|
329
|
+
return props.http(url, { method: 'GET' });
|
|
330
|
+
}
|
|
331
|
+
const headers: Record<string, string> = {};
|
|
332
|
+
const token = props.getAuthToken?.();
|
|
333
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
334
|
+
const resp = await fetch(url, { method: 'GET', headers });
|
|
335
|
+
return {
|
|
336
|
+
status: resp.status,
|
|
337
|
+
json: () => resp.json(),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function extractValue(body: Record<string, unknown>): string | number | null {
|
|
342
|
+
if (typeof body.value === 'number' || typeof body.value === 'string') return body.value;
|
|
343
|
+
if (typeof body.count === 'number') return body.count;
|
|
344
|
+
if (typeof body.total === 'number') return body.total;
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function extractSub(
|
|
349
|
+
body: Record<string, unknown>,
|
|
350
|
+
hintType: KpiCardDef['displayHint']['type'],
|
|
351
|
+
): string | undefined {
|
|
352
|
+
if (hintType === 'value+timestamp' && typeof body.timestamp === 'string') {
|
|
353
|
+
return formatTimestamp(body.timestamp);
|
|
354
|
+
}
|
|
355
|
+
if (hintType === 'value+delta' && typeof body.delta === 'number') {
|
|
356
|
+
const sign = body.delta > 0 ? '+' : '';
|
|
357
|
+
return `${sign}${body.delta} ggü. Vorperiode`;
|
|
358
|
+
}
|
|
359
|
+
if (typeof body.sub === 'string') return body.sub;
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function formatValue(card: KpiCardState): string {
|
|
364
|
+
const v = card.value;
|
|
365
|
+
if (v === null || v === undefined || v === '') return '—';
|
|
366
|
+
if (typeof v === 'number') return v.toLocaleString('de-DE');
|
|
367
|
+
if (typeof v === 'string') return v;
|
|
368
|
+
return String(v);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function formatTimestamp(iso: string): string {
|
|
372
|
+
try {
|
|
373
|
+
return new Date(iso).toLocaleString('de-DE', {
|
|
374
|
+
day: '2-digit',
|
|
375
|
+
month: 'short',
|
|
376
|
+
year: 'numeric',
|
|
377
|
+
hour: '2-digit',
|
|
378
|
+
minute: '2-digit',
|
|
379
|
+
});
|
|
380
|
+
} catch {
|
|
381
|
+
return iso;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function barWidth(value: number, max?: number): number {
|
|
386
|
+
const m = max && max > 0 ? max : 1;
|
|
387
|
+
return Math.round((value / m) * 100);
|
|
388
|
+
}
|
|
389
|
+
</script>
|
|
390
|
+
|
|
391
|
+
<style scoped>
|
|
392
|
+
.sa-dashboard {
|
|
393
|
+
min-height: calc(100vh - 56px);
|
|
394
|
+
background: var(--sa-bg-app, #f1f5f9);
|
|
395
|
+
padding: 20px 28px 28px;
|
|
396
|
+
}
|
|
397
|
+
/* Header look comes from the global .sa-page-head — only a margin tweak here. */
|
|
398
|
+
.sa-dashboard__head {
|
|
399
|
+
margin-bottom: 4px;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.sa-dashboard__strip {
|
|
403
|
+
display: grid;
|
|
404
|
+
/* Responsive: 4 cards while there is space, then automatically 3 → 2 → 1. */
|
|
405
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
406
|
+
gap: 12px;
|
|
407
|
+
margin-bottom: 14px;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
.sa-kpi {
|
|
411
|
+
background: #fff;
|
|
412
|
+
border: 1px solid var(--sa-border, #e2e8f0);
|
|
413
|
+
border-radius: 12px;
|
|
414
|
+
padding: 14px 16px;
|
|
415
|
+
display: flex;
|
|
416
|
+
align-items: center;
|
|
417
|
+
gap: 12px;
|
|
418
|
+
transition:
|
|
419
|
+
border-color 0.12s,
|
|
420
|
+
box-shadow 0.12s;
|
|
421
|
+
}
|
|
422
|
+
.sa-kpi:hover {
|
|
423
|
+
border-color: #94a3b8;
|
|
424
|
+
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
|
|
425
|
+
}
|
|
426
|
+
.sa-kpi__icon {
|
|
427
|
+
width: 36px;
|
|
428
|
+
height: 36px;
|
|
429
|
+
border-radius: 9px;
|
|
430
|
+
background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
|
|
431
|
+
display: flex;
|
|
432
|
+
align-items: center;
|
|
433
|
+
justify-content: center;
|
|
434
|
+
}
|
|
435
|
+
.sa-kpi__icon :deep(.q-icon) {
|
|
436
|
+
color: var(--sa-primary, #3f6bff);
|
|
437
|
+
}
|
|
438
|
+
.sa-kpi__body {
|
|
439
|
+
flex: 1;
|
|
440
|
+
min-width: 0;
|
|
441
|
+
}
|
|
442
|
+
.sa-kpi__label {
|
|
443
|
+
font-size: 11.5px;
|
|
444
|
+
font-weight: 600;
|
|
445
|
+
letter-spacing: 0.06em;
|
|
446
|
+
color: var(--sa-muted, #64748b);
|
|
447
|
+
text-transform: uppercase;
|
|
448
|
+
}
|
|
449
|
+
.sa-kpi__value {
|
|
450
|
+
font-family: var(--sa-font-head, system-ui, sans-serif);
|
|
451
|
+
font-weight: 700;
|
|
452
|
+
font-size: 28px;
|
|
453
|
+
color: var(--sa-heading, #0f172a);
|
|
454
|
+
line-height: 1;
|
|
455
|
+
letter-spacing: -0.02em;
|
|
456
|
+
margin-top: 4px;
|
|
457
|
+
min-height: 30px;
|
|
458
|
+
display: flex;
|
|
459
|
+
align-items: center;
|
|
460
|
+
}
|
|
461
|
+
.sa-kpi__sub {
|
|
462
|
+
font-size: 11.5px;
|
|
463
|
+
color: #94a3b8;
|
|
464
|
+
margin-top: 4px;
|
|
465
|
+
}
|
|
466
|
+
.sa-kpi__error {
|
|
467
|
+
color: var(--sa-negative, #dc2626);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
.sa-dashboard__rows {
|
|
471
|
+
display: grid;
|
|
472
|
+
grid-template-columns: 1fr 1fr;
|
|
473
|
+
gap: 14px;
|
|
474
|
+
margin-bottom: 14px;
|
|
475
|
+
align-items: start;
|
|
476
|
+
}
|
|
477
|
+
@media (max-width: 1280px) {
|
|
478
|
+
.sa-dashboard__rows {
|
|
479
|
+
grid-template-columns: 1fr;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
.sa-dashboard__card {
|
|
484
|
+
background: #fff;
|
|
485
|
+
border: 1px solid var(--sa-border, #e2e8f0);
|
|
486
|
+
border-radius: 12px;
|
|
487
|
+
padding: 16px 18px;
|
|
488
|
+
}
|
|
489
|
+
.sa-dashboard__row-head {
|
|
490
|
+
display: flex;
|
|
491
|
+
align-items: center;
|
|
492
|
+
gap: 10px;
|
|
493
|
+
margin-bottom: 12px;
|
|
494
|
+
}
|
|
495
|
+
.sa-dashboard__row-head h2 {
|
|
496
|
+
margin: 0;
|
|
497
|
+
font-family: var(--sa-font-head, system-ui, sans-serif);
|
|
498
|
+
font-size: 14px;
|
|
499
|
+
font-weight: 700;
|
|
500
|
+
color: var(--sa-heading, #0f172a);
|
|
501
|
+
}
|
|
502
|
+
.sa-dashboard__count {
|
|
503
|
+
background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
|
|
504
|
+
color: var(--sa-primary, #3f6bff);
|
|
505
|
+
font-size: 11px;
|
|
506
|
+
font-weight: 700;
|
|
507
|
+
padding: 2px 7px;
|
|
508
|
+
border-radius: 5px;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
.sa-dashboard__bar-list {
|
|
512
|
+
list-style: none;
|
|
513
|
+
padding: 0;
|
|
514
|
+
margin: 0;
|
|
515
|
+
display: flex;
|
|
516
|
+
flex-direction: column;
|
|
517
|
+
gap: 8px;
|
|
518
|
+
}
|
|
519
|
+
.sa-dashboard__bar-row {
|
|
520
|
+
display: grid;
|
|
521
|
+
grid-template-columns: 130px 1fr 50px;
|
|
522
|
+
align-items: center;
|
|
523
|
+
gap: 8px;
|
|
524
|
+
font-size: 13px;
|
|
525
|
+
}
|
|
526
|
+
.sa-dashboard__bar-name {
|
|
527
|
+
font-weight: 600;
|
|
528
|
+
color: var(--sa-heading, #0f172a);
|
|
529
|
+
}
|
|
530
|
+
.sa-dashboard__bar-track {
|
|
531
|
+
background: #f1f5f9;
|
|
532
|
+
height: 8px;
|
|
533
|
+
border-radius: 999px;
|
|
534
|
+
overflow: hidden;
|
|
535
|
+
}
|
|
536
|
+
.sa-dashboard__bar-fill {
|
|
537
|
+
display: block;
|
|
538
|
+
height: 100%;
|
|
539
|
+
border-radius: 999px;
|
|
540
|
+
transition: width 0.18s;
|
|
541
|
+
}
|
|
542
|
+
.sa-dashboard__bar-count {
|
|
543
|
+
font-variant-numeric: tabular-nums;
|
|
544
|
+
text-align: right;
|
|
545
|
+
color: var(--sa-muted-dark, #475569);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
.sa-dashboard__shortcuts {
|
|
549
|
+
margin: 0;
|
|
550
|
+
}
|
|
551
|
+
.sa-dashboard__shortcut-grid {
|
|
552
|
+
display: grid;
|
|
553
|
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
554
|
+
gap: 10px;
|
|
555
|
+
}
|
|
556
|
+
.sa-dashboard__shortcut {
|
|
557
|
+
display: flex;
|
|
558
|
+
gap: 12px;
|
|
559
|
+
align-items: flex-start;
|
|
560
|
+
padding: 12px 14px;
|
|
561
|
+
border: 1px solid var(--sa-border, #e2e8f0);
|
|
562
|
+
border-radius: 10px;
|
|
563
|
+
text-decoration: none;
|
|
564
|
+
color: inherit;
|
|
565
|
+
background: #fafbfc;
|
|
566
|
+
transition: all 0.12s;
|
|
567
|
+
}
|
|
568
|
+
.sa-dashboard__shortcut:hover {
|
|
569
|
+
background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
|
|
570
|
+
border-color: var(--sa-primary-border, rgba(63, 107, 255, 0.18));
|
|
571
|
+
}
|
|
572
|
+
.sa-dashboard__shortcut :deep(.q-icon) {
|
|
573
|
+
color: var(--sa-primary, #3f6bff);
|
|
574
|
+
flex-shrink: 0;
|
|
575
|
+
}
|
|
576
|
+
.sa-dashboard__shortcut-title {
|
|
577
|
+
font-weight: 700;
|
|
578
|
+
color: var(--sa-heading, #0f172a);
|
|
579
|
+
font-family: var(--sa-font-head, system-ui, sans-serif);
|
|
580
|
+
}
|
|
581
|
+
.sa-dashboard__shortcut-sub {
|
|
582
|
+
font-size: 12px;
|
|
583
|
+
color: var(--sa-muted, #64748b);
|
|
584
|
+
margin-top: 2px;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
.sa-dashboard__loading,
|
|
588
|
+
.sa-dashboard__empty {
|
|
589
|
+
padding: 32px 28px;
|
|
590
|
+
color: var(--sa-muted, #64748b);
|
|
591
|
+
display: flex;
|
|
592
|
+
align-items: center;
|
|
593
|
+
gap: 12px;
|
|
594
|
+
}
|
|
595
|
+
</style>
|