@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,104 @@
1
+ <template>
2
+ <q-dialog
3
+ :model-value="modelValue"
4
+ @update:model-value="emit('update:modelValue', $event)"
5
+ persistent
6
+ >
7
+ <q-card class="mfa-card">
8
+ <q-card-section class="header">
9
+ <q-icon name="lock" size="22px" color="amber-9" />
10
+ <div class="text-h6">Multi-Faktor-Bestätigung</div>
11
+ </q-card-section>
12
+ <q-card-section>
13
+ <p class="text-body2 q-mb-sm">
14
+ {{
15
+ description ??
16
+ 'Diese Aktion ist sicherheitskritisch. Bitte 6-stelligen TOTP-Code aus Authenticator eingeben.'
17
+ }}
18
+ </p>
19
+ <q-input
20
+ v-model="code"
21
+ autofocus
22
+ mask="######"
23
+ maxlength="6"
24
+ outlined
25
+ dense
26
+ label="TOTP-Code"
27
+ :error="!!error"
28
+ :error-message="error"
29
+ />
30
+ <slot name="hint">
31
+ <div
32
+ v-if="setupHint"
33
+ class="text-caption text-grey-7 q-mt-xs"
34
+ v-html="setupHint"
35
+ />
36
+ </slot>
37
+ </q-card-section>
38
+ <q-card-actions align="right">
39
+ <q-btn flat label="Abbrechen" v-close-popup />
40
+ <q-btn
41
+ unelevated
42
+ color="amber-9"
43
+ text-color="black"
44
+ label="Bestätigen"
45
+ :disable="code.length !== 6"
46
+ @click="onConfirm"
47
+ />
48
+ </q-card-actions>
49
+ </q-card>
50
+ </q-dialog>
51
+ </template>
52
+
53
+ <script setup lang="ts">
54
+ import { ref, watch } from 'vue';
55
+
56
+ // Cross-cutting MFA confirmation dialog. An app-specific setup hint
57
+ // (e.g. "MFA setup via CLI: ...") is shown via the `setupHint` prop or
58
+ // the `#hint` slot — the platform stays out of app CLIs.
59
+
60
+ const props = defineProps<{
61
+ modelValue: boolean;
62
+ description?: string;
63
+ error?: string;
64
+ /** Optional HTML string with an app-specific setup hint. */
65
+ setupHint?: string;
66
+ }>();
67
+ const emit = defineEmits<{
68
+ (e: 'update:modelValue', v: boolean): void;
69
+ (e: 'confirm', code: string): void;
70
+ }>();
71
+
72
+ const code = ref('');
73
+
74
+ watch(
75
+ () => props.modelValue,
76
+ (open) => {
77
+ if (open) code.value = '';
78
+ },
79
+ );
80
+
81
+ function onConfirm() {
82
+ if (code.value.length !== 6) return;
83
+ emit('confirm', code.value);
84
+ }
85
+ </script>
86
+
87
+ <style scoped>
88
+ .mfa-card {
89
+ width: 380px;
90
+ max-width: 92vw;
91
+ }
92
+ .header {
93
+ display: flex;
94
+ align-items: center;
95
+ gap: 10px;
96
+ padding-bottom: 0;
97
+ }
98
+ code {
99
+ background: rgba(15, 23, 42, 0.06);
100
+ padding: 1px 6px;
101
+ border-radius: 4px;
102
+ font-size: 11px;
103
+ }
104
+ </style>
@@ -0,0 +1,252 @@
1
+ <template>
2
+ <q-dialog
3
+ :model-value="modelValue"
4
+ @update:model-value="emit('update:modelValue', $event)"
5
+ persistent
6
+ >
7
+ <q-card class="tenant-action-confirm">
8
+ <q-card-section class="header">
9
+ <q-icon
10
+ :name="iconForAction"
11
+ size="22px"
12
+ :color="isDangerous ? 'negative' : 'primary'"
13
+ />
14
+ <div class="text-h6">{{ def?.label ?? 'Aktion bestätigen' }}</div>
15
+ </q-card-section>
16
+
17
+ <q-card-section v-if="def">
18
+ <p class="tenant-action-confirm__lead">
19
+ Aktion <strong>„{{ def.label }}"</strong> für Tenant
20
+ <strong>„{{ row?.name ?? row?.slug ?? '–' }}"</strong>.
21
+ </p>
22
+
23
+ <!-- typed-slug / typed-production: slug confirmation as a safety
24
+ gate against accidentally clicking destructive actions. -->
25
+ <template v-if="needsTypedSlug">
26
+ <p class="tenant-action-confirm__hint">
27
+ Bestätige durch Eintippen des Slugs
28
+ <code>{{ row?.slug }}</code
29
+ >:
30
+ </p>
31
+ <q-input
32
+ v-model="slugInput"
33
+ outlined
34
+ dense
35
+ autofocus
36
+ :error="slugError"
37
+ :error-message="slugErrorMessage"
38
+ :placeholder="row?.slug"
39
+ />
40
+ </template>
41
+
42
+ <!-- confirmType='date': ISO date, passed through to the handler
43
+ via `extras.until` (e.g. pilots.extend). -->
44
+ <template v-if="needsDate">
45
+ <q-input
46
+ v-model="dateInput"
47
+ outlined
48
+ dense
49
+ type="date"
50
+ class="q-mt-md"
51
+ :min="dateMin"
52
+ :label="dateLabel"
53
+ :error="dateError"
54
+ :error-message="
55
+ dateError ? 'Bitte ein Datum in der Zukunft wählen.' : undefined
56
+ "
57
+ />
58
+ </template>
59
+
60
+ <!-- Reason is always required — the platform backends demand it
61
+ as MinLength(5) for AuditLog. Apps can switch it off via
62
+ `reasonRequired: false`, but the default is required. -->
63
+ <q-input
64
+ v-if="reasonRequired"
65
+ v-model="reasonInput"
66
+ outlined
67
+ dense
68
+ type="textarea"
69
+ autogrow
70
+ class="q-mt-md"
71
+ :autofocus="!needsTypedSlug && !needsDate"
72
+ label="Grund (Pflicht für Audit-Log, mindestens 5 Zeichen)"
73
+ :error="reasonError"
74
+ :error-message="
75
+ reasonError
76
+ ? 'Bitte einen Grund mit mindestens 5 Zeichen angeben.'
77
+ : undefined
78
+ "
79
+ />
80
+ </q-card-section>
81
+
82
+ <q-card-actions align="right">
83
+ <q-btn flat label="Abbrechen" @click="onCancel" />
84
+ <q-btn
85
+ unelevated
86
+ :color="isDangerous ? 'negative' : 'primary'"
87
+ :label="def?.label ?? 'Bestätigen'"
88
+ :disable="!canSubmit"
89
+ @click="onConfirm"
90
+ />
91
+ </q-card-actions>
92
+ </q-card>
93
+ </q-dialog>
94
+ </template>
95
+
96
+ <script setup lang="ts">
97
+ import { computed, ref, watch } from 'vue';
98
+ import type { TenantActionDef, TenantDto } from '@saasicat/types';
99
+
100
+ // Platform-standard confirm dialog for tenant actions. Covers both confirm
101
+ // paths from the manifest in a single UI:
102
+ // - 'simple': reason required only (or reasonRequired=false)
103
+ // - 'typed-slug' / 'typed-production': additionally slug confirmation
104
+ //
105
+ // Apps don't need to handle the Quasar prompts themselves and can use the
106
+ // dialog directly as the `confirm` provider in
107
+ // `useTenantActionFlow({ confirm })`.
108
+
109
+ const props = withDefaults(
110
+ defineProps<{
111
+ modelValue: boolean;
112
+ def: TenantActionDef | null;
113
+ row: (TenantDto & Record<string, unknown>) | null;
114
+ /**
115
+ * Reason required? Defaults to `true`, because platform backends
116
+ * enforce the reason as MinLength(5) for AuditLog. Apps with actions
117
+ * that don't need a reason (e.g. read/export actions) can set
118
+ * `false`.
119
+ */
120
+ reasonRequired?: boolean;
121
+ }>(),
122
+ { reasonRequired: true },
123
+ );
124
+
125
+ const emit = defineEmits<{
126
+ (e: 'update:modelValue', value: boolean): void;
127
+ (e: 'submit', payload: { reason: string | null; extras?: Record<string, unknown> }): void;
128
+ (e: 'cancel'): void;
129
+ }>();
130
+
131
+ const slugInput = ref('');
132
+ const reasonInput = ref('');
133
+ const dateInput = ref('');
134
+ const slugError = ref(false);
135
+ const reasonError = ref(false);
136
+ const dateError = ref(false);
137
+
138
+ const slugErrorMessage = computed(() =>
139
+ slugError.value ? `Muss exakt „${props.row?.slug}“ sein` : undefined,
140
+ );
141
+
142
+ watch(
143
+ () => props.modelValue,
144
+ (open) => {
145
+ if (open) {
146
+ slugInput.value = '';
147
+ reasonInput.value = '';
148
+ dateInput.value = '';
149
+ slugError.value = false;
150
+ reasonError.value = false;
151
+ dateError.value = false;
152
+ }
153
+ },
154
+ );
155
+
156
+ const needsTypedSlug = computed(
157
+ () => props.def?.confirmType === 'typed-slug' || props.def?.confirmType === 'typed-production',
158
+ );
159
+ const needsDate = computed(() => props.def?.confirmType === 'date');
160
+
161
+ const dateMin = computed(() => new Date().toISOString().slice(0, 10));
162
+ const dateLabel = computed(() => {
163
+ const key = props.def?.actionKey ?? '';
164
+ if (key.endsWith('.extend')) return 'Verlängern bis (Datum)';
165
+ return 'Datum';
166
+ });
167
+
168
+ const isDangerous = computed(() => {
169
+ const key = props.def?.actionKey ?? '';
170
+ return key.endsWith('.suspend') || key.endsWith('.revoke') || key.endsWith('.cancel');
171
+ });
172
+
173
+ const iconForAction = computed(() => {
174
+ const key = props.def?.actionKey ?? '';
175
+ if (key.endsWith('.suspend')) return 'block';
176
+ if (key.endsWith('.reactivate')) return 'play_arrow';
177
+ if (key.endsWith('.revoke')) return 'cancel';
178
+ return 'check';
179
+ });
180
+
181
+ const canSubmit = computed(() => {
182
+ if (needsTypedSlug.value && slugInput.value.trim() !== (props.row?.slug ?? '')) {
183
+ return false;
184
+ }
185
+ if (props.reasonRequired && reasonInput.value.trim().length < 5) {
186
+ return false;
187
+ }
188
+ if (needsDate.value && !isFutureIsoDate(dateInput.value)) {
189
+ return false;
190
+ }
191
+ return true;
192
+ });
193
+
194
+ function isFutureIsoDate(iso: string): boolean {
195
+ if (!iso) return false;
196
+ const parsed = new Date(iso);
197
+ if (Number.isNaN(parsed.getTime())) return false;
198
+ const today = new Date();
199
+ today.setHours(0, 0, 0, 0);
200
+ return parsed.getTime() >= today.getTime();
201
+ }
202
+
203
+ function onConfirm(): void {
204
+ slugError.value = needsTypedSlug.value && slugInput.value.trim() !== (props.row?.slug ?? '');
205
+ reasonError.value = props.reasonRequired && reasonInput.value.trim().length < 5;
206
+ dateError.value = needsDate.value && !isFutureIsoDate(dateInput.value);
207
+ if (slugError.value || reasonError.value || dateError.value) return;
208
+ const payload: { reason: string | null; extras?: Record<string, unknown> } = {
209
+ reason: reasonInput.value.trim() || null,
210
+ };
211
+ if (needsDate.value) {
212
+ // Normalize the ISO date to the UTC day boundary — the backend gets a
213
+ // sub-day-precise date, while the UI has only day resolution.
214
+ payload.extras = { until: new Date(dateInput.value).toISOString() };
215
+ }
216
+ emit('submit', payload);
217
+ }
218
+
219
+ function onCancel(): void {
220
+ emit('cancel');
221
+ emit('update:modelValue', false);
222
+ }
223
+ </script>
224
+
225
+ <style scoped>
226
+ .tenant-action-confirm {
227
+ min-width: 400px;
228
+ max-width: 520px;
229
+ }
230
+ .header {
231
+ display: flex;
232
+ align-items: center;
233
+ gap: 12px;
234
+ }
235
+ .tenant-action-confirm__lead {
236
+ margin: 0 0 12px;
237
+ color: #475569;
238
+ line-height: 1.5;
239
+ }
240
+ .tenant-action-confirm__hint {
241
+ margin: 0 0 8px;
242
+ color: #475569;
243
+ font-size: 13px;
244
+ }
245
+ .tenant-action-confirm__hint code {
246
+ background: #f1f5f9;
247
+ padding: 1px 6px;
248
+ border-radius: 4px;
249
+ font-family: ui-monospace, SFMono-Regular, monospace;
250
+ font-size: 13px;
251
+ }
252
+ </style>
@@ -0,0 +1,150 @@
1
+ <template>
2
+ <div v-if="!changes || changes.length === 0" class="diff-empty">
3
+ Keine Änderungen gegenüber der Vorgängerversion.
4
+ </div>
5
+ <div v-else class="diff-list">
6
+ <div v-for="(c, idx) in changes" :key="idx" class="diff-row" :class="rowClass(c.direction)">
7
+ <div class="diff-direction">
8
+ <q-icon :name="iconFor(c.direction)" size="16px" />
9
+ <span>{{ labelFor(c.direction) }}</span>
10
+ </div>
11
+ <div class="diff-field">{{ humanFieldLabel(c.field) }}</div>
12
+ <div class="diff-values">
13
+ <span class="old" v-if="c.field !== 'features.added'">{{
14
+ formatValue(c.oldValue)
15
+ }}</span>
16
+ <q-icon
17
+ v-if="c.field !== 'features.added' && c.field !== 'features.removed'"
18
+ name="arrow_forward"
19
+ size="14px"
20
+ />
21
+ <span class="new" v-if="c.field !== 'features.removed'">{{
22
+ formatValue(c.newValue)
23
+ }}</span>
24
+ </div>
25
+ </div>
26
+ </div>
27
+ </template>
28
+
29
+ <script setup lang="ts">
30
+ import type { VersionChange } from '@saasicat/types';
31
+
32
+ // Platform default field labels (plan-catalog quotas + pricing).
33
+ // Consumer apps can add or override app-specific labels
34
+ // (e.g. maxVehicles or maxMembers) via the `fieldLabels` prop.
35
+ const PLATFORM_FIELD_LABELS: Readonly<Record<string, string>> = Object.freeze({
36
+ 'features.added': 'Hinzugefügte Features',
37
+ 'features.removed': 'Entfernte Features',
38
+ maxUsers: 'Max. Benutzer',
39
+ maxStorageGb: 'Speicher (GB)',
40
+ monthlyNet: 'Preis monatlich (netto)',
41
+ yearlyNet: 'Preis jährlich (netto)',
42
+ unitSize: 'Einheitsgröße',
43
+ });
44
+
45
+ const props = defineProps<{
46
+ changes?: VersionChange[] | null;
47
+ /** App-specific field labels; merged with the platform defaults. */
48
+ fieldLabels?: Record<string, string>;
49
+ }>();
50
+
51
+ function iconFor(direction: VersionChange['direction']): string {
52
+ if (direction === 'IMPROVEMENT') return 'trending_up';
53
+ if (direction === 'REGRESSION') return 'trending_down';
54
+ return 'remove';
55
+ }
56
+
57
+ function labelFor(direction: VersionChange['direction']): string {
58
+ if (direction === 'IMPROVEMENT') return 'Verbesserung';
59
+ if (direction === 'REGRESSION') return 'Verschlechterung';
60
+ return 'Neutral';
61
+ }
62
+
63
+ function rowClass(direction: VersionChange['direction']): string {
64
+ if (direction === 'IMPROVEMENT') return 'diff-row--improvement';
65
+ if (direction === 'REGRESSION') return 'diff-row--regression';
66
+ return 'diff-row--neutral';
67
+ }
68
+
69
+ function humanFieldLabel(field: string): string {
70
+ return props.fieldLabels?.[field] ?? PLATFORM_FIELD_LABELS[field] ?? field;
71
+ }
72
+
73
+ function formatValue(value: unknown): string {
74
+ if (value === null || value === undefined) return '–';
75
+ if (Array.isArray(value)) {
76
+ if (value.length === 0) return '–';
77
+ return value.join(', ');
78
+ }
79
+ if (typeof value === 'string' && /^\d+\.\d{2}$/.test(value)) {
80
+ return `${value} €`;
81
+ }
82
+ return String(value);
83
+ }
84
+ </script>
85
+
86
+ <style scoped>
87
+ .diff-empty {
88
+ color: #64748b;
89
+ font-style: italic;
90
+ padding: 12px;
91
+ background: #f8fafc;
92
+ border-radius: 8px;
93
+ border: 1px dashed #cbd5e1;
94
+ }
95
+ .diff-list {
96
+ display: flex;
97
+ flex-direction: column;
98
+ gap: 6px;
99
+ }
100
+ .diff-row {
101
+ display: grid;
102
+ grid-template-columns: 160px 200px 1fr;
103
+ gap: 12px;
104
+ padding: 8px 12px;
105
+ border-radius: 8px;
106
+ border: 1px solid;
107
+ font-size: 13px;
108
+ align-items: center;
109
+ }
110
+ .diff-row--improvement {
111
+ background: #f0fdf4;
112
+ border-color: #bbf7d0;
113
+ color: #166534;
114
+ }
115
+ .diff-row--regression {
116
+ background: #fef2f2;
117
+ border-color: #fecaca;
118
+ color: #991b1b;
119
+ }
120
+ .diff-row--neutral {
121
+ background: #f8fafc;
122
+ border-color: #e2e8f0;
123
+ color: #475569;
124
+ }
125
+ .diff-direction {
126
+ display: flex;
127
+ align-items: center;
128
+ gap: 6px;
129
+ font-weight: 600;
130
+ font-size: 12px;
131
+ text-transform: uppercase;
132
+ letter-spacing: 0.04em;
133
+ }
134
+ .diff-field {
135
+ color: #0f172a;
136
+ font-weight: 500;
137
+ }
138
+ .diff-values {
139
+ display: flex;
140
+ align-items: center;
141
+ gap: 8px;
142
+ }
143
+ .diff-values .old {
144
+ text-decoration: line-through;
145
+ opacity: 0.7;
146
+ }
147
+ .diff-values .new {
148
+ font-weight: 600;
149
+ }
150
+ </style>