@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,1728 @@
1
+ <template>
2
+ <div class="pve">
3
+ <PlanVersionEditorHeader
4
+ :plan-key="planKey"
5
+ :editing-id="editingId"
6
+ :version="form.version"
7
+ :predecessor-valid-until-hint="predecessorValidUntilHint"
8
+ :has-predecessor="!!predecessorVersion"
9
+ :can-save="canSave"
10
+ @show-diff="showDiff = true"
11
+ @cancel="emit('cancel')"
12
+ @save="emitSave"
13
+ />
14
+
15
+ <!-- Save error banner (e.g. "Plan hat bereits eine Draft-Version") -->
16
+ <div v-if="saveError" class="pve-error" role="alert">
17
+ <span class="pve-error-ico" aria-hidden="true">
18
+ <svg
19
+ width="16"
20
+ height="16"
21
+ viewBox="0 0 24 24"
22
+ fill="none"
23
+ stroke="currentColor"
24
+ stroke-width="2"
25
+ >
26
+ <path
27
+ d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
28
+ />
29
+ <path d="M12 9v4M12 17h.01" />
30
+ </svg>
31
+ </span>
32
+ <span>{{ saveError }}</span>
33
+ </div>
34
+
35
+ <!-- 3-Col Body -->
36
+ <div class="pve-body">
37
+ <PlanComponentPool
38
+ v-model:search-term="searchTerm"
39
+ v-model:active-tab="activeTab"
40
+ :pool-tabs="poolTabs"
41
+ :filtered-feature-groups="filteredFeatureGroups"
42
+ :filtered-quotas="filteredQuotas"
43
+ :filtered-bundles="filteredBundles"
44
+ :feature-label="featureLabel"
45
+ :feature-group-label="featureGroupLabel"
46
+ :is-feature-on="isFeatureOn"
47
+ :is-quota-on="isQuotaOn"
48
+ :is-bundle-fully-on="isBundleFullyOn"
49
+ :is-bundle-partially-on="isBundlePartiallyOn"
50
+ @toggle-feature="toggleFeature"
51
+ @pool-quota-click="onPoolQuotaClick"
52
+ @toggle-bundle="toggleBundle"
53
+ @drag-start="onDragStart"
54
+ @drag-end="onDragEnd"
55
+ />
56
+
57
+ <PlanVersionBasket
58
+ :form="form"
59
+ :drag-over="dragOver"
60
+ :change-count="changeCount"
61
+ :min-valid-from="minValidFrom"
62
+ :valid-from-error="validFromError"
63
+ :selected-quota-list="selectedQuotaList"
64
+ :sorted-selected-features="sortedSelectedFeatures"
65
+ :active-bundles="activeBundles"
66
+ :feature-label="featureLabel"
67
+ @update:valid-from="onValidFromInput($event)"
68
+ @update:valid-until="form.validUntil = $event"
69
+ @update:monthly-net="form.monthlyNet = $event"
70
+ @update:yearly-net="form.yearlyNet = $event"
71
+ @update:marketed="form.marketed = $event"
72
+ @update:change-note="form.changeNote = $event"
73
+ @set-quota-value="setQuotaValue"
74
+ @toggle-quota="toggleQuota"
75
+ @toggle-feature="toggleFeature"
76
+ @toggle-bundle="toggleBundle"
77
+ @drag-over="onDragOver"
78
+ @drag-leave="onDragLeave"
79
+ @drop="onDrop"
80
+ />
81
+
82
+ <PlanCatalogPreview
83
+ v-model:preview-mode="previewMode"
84
+ :catalog-url="catalogUrl"
85
+ :plan-key="planKey"
86
+ :version="form.version"
87
+ :change-note="form.changeNote"
88
+ :quotas="form.quotas"
89
+ :plan-display-name="planDisplayName"
90
+ :formatted-monthly="formattedMonthly"
91
+ :formatted-yearly="formattedYearly"
92
+ :yearly-savings-label="yearlySavingsLabel"
93
+ :selected-quota-list="selectedQuotaList"
94
+ :sorted-selected-features="sortedSelectedFeatures"
95
+ :checklist="checklist"
96
+ :checklist-ok-count="checklistOkCount"
97
+ :feature-label="featureLabel"
98
+ />
99
+ </div>
100
+
101
+ <PlanVersionDiffDialog
102
+ v-model="showDiff"
103
+ :predecessor-version="predecessorVersion"
104
+ :version="form.version"
105
+ :diff-rows="diffRows"
106
+ />
107
+ </div>
108
+ </template>
109
+
110
+ <script setup lang="ts">
111
+ import { computed, onMounted, reactive, ref } from 'vue';
112
+ import PlanComponentPool from './PlanComponentPool.vue';
113
+ import PlanCatalogPreview from './PlanCatalogPreview.vue';
114
+ import PlanVersionBasket from './PlanVersionBasket.vue';
115
+ import PlanVersionDiffDialog from './PlanVersionDiffDialog.vue';
116
+ import PlanVersionEditorHeader from './PlanVersionEditorHeader.vue';
117
+ import type {
118
+ BundleEntry,
119
+ ChecklistItem,
120
+ DiscoveryFeature,
121
+ DiscoveryQuota,
122
+ DraftForm,
123
+ EditorDiffRow,
124
+ FeatureGroup,
125
+ FeatureMeta,
126
+ PlanVersionSavePayload,
127
+ PoolKind,
128
+ PoolTab,
129
+ PoolTabItem,
130
+ PredecessorVersion,
131
+ PreviewMode,
132
+ SelectedQuotaRow,
133
+ } from './types.js';
134
+
135
+ // PlanVersionEditor — V2 splitview editor: library pool → plan basket → live
136
+ // public catalog preview. Single screen for "create plan + version +
137
+ // assign". Drag-and-drop from the left pool into the middle basket;
138
+ // on the right the public catalog preview runs live with the form state.
139
+ //
140
+ // Data sources:
141
+ // - Quotas + features: from the discovery snapshot (code = SoT). Passed
142
+ // through by the consumer via `availableFeatures`/`availableQuotas`.
143
+ // - Bundles: from the DB table bundles (live published). Passed through by
144
+ // the consumer via `availableBundles`.
145
+ // - Feature label + optional group bucket: from `featureRegistry`.
146
+ //
147
+ // Bundle switch semantics: ON activates all of the bundle's features
148
+ // (set union with form.features); OFF removes them again
149
+ // (set difference). A bundle counts as "ON" when ALL of its features are
150
+ // active in the plan, "PARTIAL" for some.
151
+
152
+ const props = withDefaults(
153
+ defineProps<{
154
+ planKey: string;
155
+ editingId: string | null;
156
+ initialForm: DraftForm;
157
+ saving: boolean;
158
+ availableFeatures: DiscoveryFeature[];
159
+ availableQuotas: DiscoveryQuota[];
160
+ availableBundles: BundleEntry[];
161
+ featureRegistry?: Record<string, FeatureMeta>;
162
+ /** Display name of the plan for the live preview (default: planKey). */
163
+ planDisplayName?: string;
164
+ /** URL of the public catalog for the browser-chrome display. */
165
+ catalogUrl?: string;
166
+ /** Estimated number of affected Tenants — shown in the publish checklist. */
167
+ tenantImpactCount?: number;
168
+ /** Error message from the last save attempt (e.g. "Plan hat bereits eine Draft"). */
169
+ saveError?: string | null;
170
+ /**
171
+ * Predecessor version (currently live) that "Diff vs. Vorgänger"
172
+ * compares against. `null` for v1 — the button is then disabled.
173
+ */
174
+ predecessorVersion?: PredecessorVersion | null;
175
+ }>(),
176
+ {
177
+ planDisplayName: undefined,
178
+ catalogUrl: 'app.local/preise',
179
+ tenantImpactCount: 0,
180
+ saveError: null,
181
+ predecessorVersion: null,
182
+ },
183
+ );
184
+
185
+ const emit = defineEmits<{
186
+ (e: 'save', payload: PlanVersionSavePayload): void;
187
+ (e: 'cancel'): void;
188
+ }>();
189
+
190
+ const form = reactive<DraftForm>({
191
+ version: props.initialForm.version,
192
+ features: [...props.initialForm.features],
193
+ quotas: { ...props.initialForm.quotas },
194
+ monthlyNet: props.initialForm.monthlyNet,
195
+ yearlyNet: props.initialForm.yearlyNet,
196
+ changeNote: props.initialForm.changeNote,
197
+ marketed: props.initialForm.marketed,
198
+ validFrom: props.initialForm.validFrom,
199
+ validUntil: props.initialForm.validUntil,
200
+ });
201
+
202
+ // initialForm snapshot for change counting. The editor is a fullscreen
203
+ // page that the consumer mounts via `v-if` — on every mount, form +
204
+ // baseline are freshly populated from `initialForm`. No modelValue watch needed.
205
+ const baseline: DraftForm = cloneForm(props.initialForm);
206
+
207
+ onMounted(() => {
208
+ searchTerm.value = '';
209
+ activeTab.value = 'features';
210
+ // Always prefill "Gültig ab": if missing (e.g. legacy data without a start date)
211
+ // → default start date; if before the predecessor → first permitted day.
212
+ if (!form.validFrom || form.validFrom.slice(0, 10) <= prevValidFromDay.value) {
213
+ form.validFrom = defaultValidFrom.value;
214
+ }
215
+ });
216
+
217
+ // "Gültig ab" day of the predecessor version (YYYY-MM-DD) or '' if none.
218
+ const prevValidFromDay = computed(() =>
219
+ props.predecessorVersion?.validFrom ? props.predecessorVersion.validFrom.slice(0, 10) : '',
220
+ );
221
+
222
+ function cloneForm(f: DraftForm): DraftForm {
223
+ return {
224
+ version: f.version,
225
+ features: [...f.features],
226
+ quotas: { ...f.quotas },
227
+ monthlyNet: f.monthlyNet,
228
+ yearlyNet: f.yearlyNet,
229
+ changeNote: f.changeNote,
230
+ marketed: f.marketed,
231
+ validFrom: f.validFrom,
232
+ validUntil: f.validUntil,
233
+ };
234
+ }
235
+
236
+ // ── Pool: search + tabs + grouping ──────────────────────────────────
237
+ const searchTerm = ref('');
238
+ const activeTab = ref<PoolTab>('features');
239
+
240
+ const poolTabs = computed<PoolTabItem[]>(() => [
241
+ { id: 'features' as PoolTab, label: 'Features', count: filteredFeatureCount.value },
242
+ { id: 'quotas' as PoolTab, label: 'Quotas', count: filteredQuotas.value.length },
243
+ { id: 'bundles' as PoolTab, label: 'Bundles', count: filteredBundles.value.length },
244
+ ]);
245
+
246
+ function normalize(s: string): string {
247
+ return s.toLocaleLowerCase('de-DE');
248
+ }
249
+
250
+ function matchesSearch(haystack: string[], needle: string): boolean {
251
+ if (!needle) return true;
252
+ const n = normalize(needle);
253
+ return haystack.some((h) => normalize(h).includes(n));
254
+ }
255
+
256
+ function featureLabel(key: string): string {
257
+ return props.featureRegistry?.[key]?.label ?? key;
258
+ }
259
+
260
+ function featureGroupLabel(key: string): string {
261
+ return props.featureRegistry?.[key]?.group ?? 'Allgemein';
262
+ }
263
+
264
+ // Pool lists sorted by display label (not by key/snapshot order),
265
+ // so that planning stays predictable — same order as in the basket.
266
+ const byLabel = (a: string, b: string) => a.localeCompare(b, 'de-DE');
267
+
268
+ const filteredFeatures = computed(() =>
269
+ props.availableFeatures
270
+ .filter((f) => matchesSearch([f.featureKey, featureLabel(f.featureKey)], searchTerm.value))
271
+ .sort((a, b) => byLabel(featureLabel(a.featureKey), featureLabel(b.featureKey))),
272
+ );
273
+
274
+ const filteredFeatureCount = computed(() => filteredFeatures.value.length);
275
+
276
+ const filteredFeatureGroups = computed<FeatureGroup[]>(() => {
277
+ const byGroup = new Map<string, DiscoveryFeature[]>();
278
+ for (const f of filteredFeatures.value) {
279
+ const g = featureGroupLabel(f.featureKey);
280
+ const list = byGroup.get(g) ?? [];
281
+ list.push(f);
282
+ byGroup.set(g, list);
283
+ }
284
+ return [...byGroup.entries()]
285
+ .sort(([a], [b]) => a.localeCompare(b, 'de-DE'))
286
+ .map(([key, rows]) => ({ key, label: key, rows }));
287
+ });
288
+
289
+ const filteredQuotas = computed(() =>
290
+ props.availableQuotas
291
+ .filter((q) => matchesSearch([q.quotaKey, q.label ?? '', q.unit ?? ''], searchTerm.value))
292
+ .sort((a, b) => byLabel(a.label || a.quotaKey, b.label || b.quotaKey)),
293
+ );
294
+
295
+ const filteredBundles = computed(() =>
296
+ props.availableBundles
297
+ .filter((b) => matchesSearch([b.bundleKey, b.label ?? ''], searchTerm.value))
298
+ .sort((a, b) => byLabel(a.label || a.bundleKey, b.label || b.bundleKey)),
299
+ );
300
+
301
+ // ── Selection logic ─────────────────────────────────────────────────
302
+ function isFeatureOn(key: string): boolean {
303
+ return form.features.includes(key);
304
+ }
305
+
306
+ function toggleFeature(key: string, on: boolean): void {
307
+ if (on && !form.features.includes(key)) {
308
+ form.features.push(key);
309
+ form.features.sort();
310
+ } else if (!on) {
311
+ const idx = form.features.indexOf(key);
312
+ if (idx >= 0) form.features.splice(idx, 1);
313
+ }
314
+ }
315
+
316
+ function isQuotaOn(key: string): boolean {
317
+ return Object.prototype.hasOwnProperty.call(form.quotas, key);
318
+ }
319
+
320
+ function toggleQuota(key: string, on: boolean): void {
321
+ if (on) {
322
+ if (!Object.prototype.hasOwnProperty.call(form.quotas, key)) {
323
+ form.quotas[key] = 0;
324
+ }
325
+ } else {
326
+ delete form.quotas[key];
327
+ }
328
+ }
329
+
330
+ function setQuotaValue(key: string, value: number): void {
331
+ form.quotas[key] = Number.isFinite(value) ? value : 0;
332
+ }
333
+
334
+ function onPoolQuotaClick(q: DiscoveryQuota): void {
335
+ toggleQuota(q.quotaKey, !isQuotaOn(q.quotaKey));
336
+ }
337
+
338
+ function isBundleFullyOn(b: BundleEntry): boolean {
339
+ if (b.features.length === 0) return false;
340
+ return b.features.every((f) => form.features.includes(f));
341
+ }
342
+
343
+ function isBundlePartiallyOn(b: BundleEntry): boolean {
344
+ if (isBundleFullyOn(b)) return false;
345
+ return b.features.some((f) => form.features.includes(f));
346
+ }
347
+
348
+ function toggleBundle(b: BundleEntry, on: boolean): void {
349
+ if (on) {
350
+ for (const f of b.features) {
351
+ if (!form.features.includes(f)) form.features.push(f);
352
+ }
353
+ } else {
354
+ for (const f of b.features) {
355
+ const idx = form.features.indexOf(f);
356
+ if (idx >= 0) form.features.splice(idx, 1);
357
+ }
358
+ }
359
+ form.features.sort();
360
+ }
361
+
362
+ // ── Selected lists for the basket (sorted by display label) ─────────
363
+ const sortedSelectedFeatures = computed(() =>
364
+ [...form.features].sort((a, b) => byLabel(featureLabel(a), featureLabel(b))),
365
+ );
366
+
367
+ const selectedQuotaList = computed<SelectedQuotaRow[]>(() => {
368
+ return Object.keys(form.quotas)
369
+ .map((quotaKey) => {
370
+ const def = props.availableQuotas.find((q) => q.quotaKey === quotaKey);
371
+ return {
372
+ quotaKey,
373
+ label: def?.label || quotaKey,
374
+ unit: def?.unit || '',
375
+ sub: def?.unit || quotaKey,
376
+ };
377
+ })
378
+ .sort((a, b) => byLabel(a.label, b.label));
379
+ });
380
+
381
+ const activeBundles = computed(() => props.availableBundles.filter(isBundleFullyOn));
382
+
383
+ // ── Change count (against baseline) ─────────────────────────────────
384
+ const changeCount = computed(() => {
385
+ let n = 0;
386
+ const addedF = form.features.filter((f) => !baseline.features.includes(f));
387
+ const removedF = baseline.features.filter((f) => !form.features.includes(f));
388
+ n += addedF.length + removedF.length;
389
+ const addedQ = Object.keys(form.quotas).filter((q) => !(q in baseline.quotas));
390
+ const removedQ = Object.keys(baseline.quotas).filter((q) => !(q in form.quotas));
391
+ const changedQ = Object.keys(form.quotas).filter(
392
+ (q) => q in baseline.quotas && baseline.quotas[q] !== form.quotas[q],
393
+ );
394
+ n += addedQ.length + removedQ.length + changedQ.length;
395
+ if (form.monthlyNet !== baseline.monthlyNet) n++;
396
+ if (form.yearlyNet !== baseline.yearlyNet) n++;
397
+ if (form.changeNote !== baseline.changeNote) n++;
398
+ if (form.marketed !== baseline.marketed) n++;
399
+ if (form.validFrom !== baseline.validFrom) n++;
400
+ if (form.validUntil !== baseline.validUntil) n++;
401
+ return n;
402
+ });
403
+
404
+ // ── Preview ─────────────────────────────────────────────────────────
405
+ const previewMode = ref<PreviewMode>('desktop');
406
+
407
+ const planDisplayName = computed(() => props.planDisplayName ?? toTitleCase(props.planKey));
408
+
409
+ function toTitleCase(s: string): string {
410
+ if (!s) return s;
411
+ return s.charAt(0) + s.slice(1).toLowerCase();
412
+ }
413
+
414
+ function formatMoney(raw: string): string {
415
+ if (!/^\d+(\.\d{1,2})?$/.test(raw)) return raw + ' €';
416
+ const num = Number(raw);
417
+ if (Number.isInteger(num)) return `${num} €`;
418
+ return `${num.toFixed(2).replace('.', ',')} €`;
419
+ }
420
+
421
+ const formattedMonthly = computed(() => formatMoney(form.monthlyNet));
422
+ const formattedYearly = computed(() => formatMoney(form.yearlyNet));
423
+
424
+ const yearlySavingsLabel = computed(() => {
425
+ const m = Number(form.monthlyNet);
426
+ const y = Number(form.yearlyNet);
427
+ if (!Number.isFinite(m) || !Number.isFinite(y) || m <= 0 || y <= 0) return null;
428
+ const fullYear = m * 12;
429
+ if (y >= fullYear) return null;
430
+ const pct = Math.round(((fullYear - y) / fullYear) * 100);
431
+ if (pct <= 0) return null;
432
+ return `${pct} % günstiger`;
433
+ });
434
+
435
+ // ── Validation ──────────────────────────────────────────────────────
436
+
437
+ // "Gültig ab" must be strictly after the "Gültig ab" of the predecessor
438
+ // version — otherwise the version timeline is wrong (SPEC_V2 §4.2.1).
439
+ // ISO date strings (YYYY-MM-DD) are lexicographically comparable.
440
+ const validFromError = computed<string | null>(() => {
441
+ const prev = props.predecessorVersion;
442
+ if (!prev?.validFrom || !form.validFrom) return null;
443
+ const draftDay = form.validFrom.slice(0, 10);
444
+ const prevDay = prev.validFrom.slice(0, 10);
445
+ if (draftDay <= prevDay) {
446
+ return (
447
+ `„Gültig ab" (${draftDay}) muss nach dem „Gültig ab" der ` +
448
+ `Vorgänger-Version v${prev.version} (${prevDay}) liegen.`
449
+ );
450
+ }
451
+ return null;
452
+ });
453
+
454
+ // First valid start day = day after the "Gültig ab" of the predecessor version.
455
+ // Bound as `min` to the date field → earlier days are greyed out in the
456
+ // native date picker.
457
+ const minValidFrom = computed<string | undefined>(() => {
458
+ const prev = props.predecessorVersion;
459
+ if (!prev?.validFrom) return undefined;
460
+ const d = new Date(prev.validFrom.slice(0, 10) + 'T00:00:00');
461
+ if (Number.isNaN(d.getTime())) return undefined;
462
+ d.setDate(d.getDate() + 1);
463
+ return d.toISOString().slice(0, 10);
464
+ });
465
+
466
+ // Default start date when "Gültig ab" is missing or cleared: first
467
+ // permitted day (successor) or today (initial version). This keeps validFrom
468
+ // from ever being NULL — no new legacy-data gap (an invisible plan in the catalog) arises.
469
+ const defaultValidFrom = computed<string>(
470
+ () => minValidFrom.value ?? new Date().toISOString().slice(0, 10),
471
+ );
472
+
473
+ // Clearing the date field falls back to the default instead of emitting NULL.
474
+ function onValidFromInput(value: string | null): void {
475
+ form.validFrom = value || defaultValidFrom.value;
476
+ }
477
+
478
+ const canSave = computed(() => {
479
+ return (
480
+ /^\d+(\.\d{1,2})?$/.test(form.monthlyNet) &&
481
+ /^\d+(\.\d{1,2})?$/.test(form.yearlyNet) &&
482
+ validFromError.value === null
483
+ );
484
+ });
485
+
486
+ const predecessorValidUntilHint = computed(() => {
487
+ if (!form.validFrom) return 'ab-Datum − 1 Tag';
488
+ const d = new Date(form.validFrom);
489
+ if (Number.isNaN(d.getTime())) return 'ab-Datum − 1 Tag';
490
+ d.setDate(d.getDate() - 1);
491
+ return d.toISOString().slice(0, 10);
492
+ });
493
+
494
+ const checklist = computed<ChecklistItem[]>(() => {
495
+ const items: ChecklistItem[] = [
496
+ {
497
+ id: 'prices',
498
+ label: 'Preise gesetzt (Mo + J)',
499
+ ok:
500
+ /^\d+(\.\d{1,2})?$/.test(form.monthlyNet) &&
501
+ /^\d+(\.\d{1,2})?$/.test(form.yearlyNet),
502
+ },
503
+ {
504
+ id: 'note',
505
+ label: 'Change-Note vorhanden',
506
+ ok: form.changeNote.trim().length > 0,
507
+ },
508
+ {
509
+ id: 'valid-from',
510
+ label: 'Gültigkeit gesetzt',
511
+ ok: !!form.validFrom,
512
+ },
513
+ {
514
+ id: 'valid-from-order',
515
+ label: 'Gültig-ab liegt nach der Vorgänger-Version',
516
+ ok: validFromError.value === null,
517
+ },
518
+ {
519
+ id: 'min-feature',
520
+ label: 'Mindestens 1 Feature zugewiesen',
521
+ ok: form.features.length > 0,
522
+ },
523
+ ];
524
+ if (props.tenantImpactCount > 0) {
525
+ items.push({
526
+ id: 'tenant-impact',
527
+ label: `Tenant-Impact: <b>${props.tenantImpactCount} Mandanten</b> erhalten v${form.version} automatisch`,
528
+ ok: false,
529
+ });
530
+ }
531
+ return items;
532
+ });
533
+
534
+ const checklistOkCount = computed(() => checklist.value.filter((c) => c.ok).length);
535
+
536
+ // ── Diff vs. predecessor ────────────────────────────────────────────
537
+ const showDiff = ref(false);
538
+
539
+ const DIFF_STYLE = {
540
+ added: { bg: '#ecfdf5', border: '#a7f3d0', color: '#047857', sign: '+', tag: 'neu' },
541
+ removed: { bg: '#fef2f2', border: '#fecaca', color: '#b91c1c', sign: '−', tag: 'entfernt' },
542
+ changed: { bg: '#fffbeb', border: '#fde68a', color: '#b45309', sign: '~', tag: 'geändert' },
543
+ } as const;
544
+
545
+ const diffRows = computed<EditorDiffRow[]>(() => {
546
+ const prev = props.predecessorVersion;
547
+ if (!prev) return [];
548
+ const out: EditorDiffRow[] = [];
549
+
550
+ // Features
551
+ const prevFeatures = new Set(prev.features);
552
+ const curFeatures = new Set(form.features);
553
+ for (const f of form.features) {
554
+ if (!prevFeatures.has(f)) {
555
+ out.push({
556
+ id: `add-f-${f}`,
557
+ section: 'Features',
558
+ label: featureLabel(f),
559
+ sub: f,
560
+ ...DIFF_STYLE.added,
561
+ });
562
+ }
563
+ }
564
+ for (const f of prev.features) {
565
+ if (!curFeatures.has(f)) {
566
+ out.push({
567
+ id: `rem-f-${f}`,
568
+ section: 'Features',
569
+ label: featureLabel(f),
570
+ sub: f,
571
+ ...DIFF_STYLE.removed,
572
+ });
573
+ }
574
+ }
575
+
576
+ // Quotas
577
+ const prevQ = prev.quotas ?? {};
578
+ const keys = new Set([...Object.keys(prevQ), ...Object.keys(form.quotas)]);
579
+ for (const k of [...keys].sort()) {
580
+ const before = prevQ[k];
581
+ const after = form.quotas[k];
582
+ const unit = props.availableQuotas.find((q) => q.quotaKey === k)?.unit ?? '';
583
+ const label = props.availableQuotas.find((q) => q.quotaKey === k)?.label || k;
584
+ const fmt = (v: number) => `${v}${unit ? ' ' + unit : ''}`;
585
+ if (before === undefined && after !== undefined) {
586
+ out.push({
587
+ id: `add-q-${k}`,
588
+ section: 'Quotas',
589
+ label,
590
+ sub: k,
591
+ to: fmt(after),
592
+ ...DIFF_STYLE.added,
593
+ });
594
+ } else if (before !== undefined && after === undefined) {
595
+ out.push({
596
+ id: `rem-q-${k}`,
597
+ section: 'Quotas',
598
+ label,
599
+ sub: k,
600
+ from: fmt(before),
601
+ ...DIFF_STYLE.removed,
602
+ });
603
+ } else if (before !== undefined && after !== undefined && before !== after) {
604
+ out.push({
605
+ id: `chg-q-${k}`,
606
+ section: 'Quotas',
607
+ label,
608
+ sub: k,
609
+ from: fmt(before),
610
+ to: fmt(after),
611
+ ...DIFF_STYLE.changed,
612
+ });
613
+ }
614
+ }
615
+
616
+ // Price
617
+ if (prev.monthlyNet !== form.monthlyNet || prev.yearlyNet !== form.yearlyNet) {
618
+ out.push({
619
+ id: 'chg-price',
620
+ section: 'Preis',
621
+ label: 'Monats- + Jahrespreis',
622
+ from: `${formatMoney(prev.monthlyNet)} / Mo · ${formatMoney(prev.yearlyNet)} / J`,
623
+ to: `${formattedMonthly.value} / Mo · ${formattedYearly.value} / J`,
624
+ ...DIFF_STYLE.changed,
625
+ });
626
+ }
627
+
628
+ return out;
629
+ });
630
+
631
+ // ── Drag-and-drop from pool → basket ────────────────────────────────
632
+ const dragOver = ref(false);
633
+ let dragPayload: { kind: PoolKind; key: string } | null = null;
634
+
635
+ function onDragStart(kind: PoolKind, key: string, e: DragEvent): void {
636
+ dragPayload = { kind, key };
637
+ if (e.dataTransfer) {
638
+ e.dataTransfer.effectAllowed = 'copy';
639
+ e.dataTransfer.setData('text/plain', `${kind}:${key}`);
640
+ }
641
+ }
642
+
643
+ function onDragEnd(): void {
644
+ dragPayload = null;
645
+ dragOver.value = false;
646
+ }
647
+
648
+ function onDragOver(e: DragEvent): void {
649
+ if (!dragPayload) return;
650
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
651
+ dragOver.value = true;
652
+ }
653
+
654
+ function onDragLeave(e: DragEvent): void {
655
+ // Only clear when leaving the basket entirely (not when entering nested children).
656
+ if (e.currentTarget instanceof HTMLElement && e.relatedTarget instanceof Node) {
657
+ if (e.currentTarget.contains(e.relatedTarget)) return;
658
+ }
659
+ dragOver.value = false;
660
+ }
661
+
662
+ function onDrop(): void {
663
+ if (!dragPayload) {
664
+ dragOver.value = false;
665
+ return;
666
+ }
667
+ const { kind, key } = dragPayload;
668
+ if (kind === 'feature') {
669
+ toggleFeature(key, true);
670
+ } else if (kind === 'quota') {
671
+ toggleQuota(key, true);
672
+ } else if (kind === 'bundle') {
673
+ const b = props.availableBundles.find((x) => x.bundleKey === key);
674
+ if (b) toggleBundle(b, true);
675
+ }
676
+ dragPayload = null;
677
+ dragOver.value = false;
678
+ }
679
+
680
+ // ── Save ────────────────────────────────────────────────────────────
681
+ function emitSave(): void {
682
+ if (!canSave.value || props.saving) return;
683
+ emit('save', {
684
+ version: form.version,
685
+ features: [...form.features],
686
+ // Persisted bundle selection = all fully active bundles. Derived from
687
+ // the features so that `bundles` is always consistent with `features`
688
+ // (SPEC_V2 §6.1 / PlanVersionRow.bundles).
689
+ bundles: activeBundles.value.map((b) => b.bundleKey),
690
+ quotas: { ...form.quotas },
691
+ monthlyNet: form.monthlyNet,
692
+ yearlyNet: form.yearlyNet,
693
+ changeNote: form.changeNote,
694
+ marketed: form.marketed,
695
+ validFrom: form.validFrom,
696
+ validUntil: form.validUntil,
697
+ });
698
+ }
699
+ </script>
700
+
701
+ <style>
702
+ .pve {
703
+ --pve-nav-bg: #0b1929;
704
+ --pve-bg: #f6f7f9;
705
+ --pve-surface: #ffffff;
706
+ --pve-surface-2: #f8fafc;
707
+ --pve-border: #e5e7eb;
708
+ --pve-border-strong: #d1d5db;
709
+ --pve-text: #0f172a;
710
+ --pve-text-2: #475569;
711
+ --pve-text-3: #94a3b8;
712
+ --pve-primary: #2563eb;
713
+ --pve-primary-700: #1d4ed8;
714
+ --pve-live: #10b981;
715
+ --pve-draft: #f59e0b;
716
+ --pve-draft-bg: #fffbeb;
717
+ --pve-font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
718
+ --pve-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
719
+
720
+ display: flex;
721
+ flex-direction: column;
722
+ background: var(--pve-bg);
723
+ color: var(--pve-text);
724
+ font-family: var(--pve-font-sans);
725
+ box-sizing: border-box;
726
+ /* Fullscreen screen instead of a modal: fills the content area of the
727
+ hosting AdminLayout page (topbar 56px subtracted). */
728
+ height: 100%;
729
+ min-height: calc(100vh - 56px);
730
+ }
731
+ .pve :deep(*) {
732
+ box-sizing: border-box;
733
+ }
734
+
735
+ /* ── Editor bar ─────────────────────────────────────────────────── */
736
+ .pve-bar {
737
+ background: #fff;
738
+ border-bottom: 1px solid var(--pve-border);
739
+ padding: 12px 22px;
740
+ display: flex;
741
+ align-items: center;
742
+ gap: 16px;
743
+ flex: 0 0 auto;
744
+ }
745
+
746
+ /* ── Save error banner ──────────────────────────────────────────── */
747
+ .pve-error {
748
+ flex: 0 0 auto;
749
+ display: flex;
750
+ align-items: center;
751
+ gap: 10px;
752
+ padding: 10px 22px;
753
+ background: #fef2f2;
754
+ border-bottom: 1px solid #fecaca;
755
+ color: #b91c1c;
756
+ font-size: 13px;
757
+ font-weight: 500;
758
+ }
759
+ .pve-error-ico {
760
+ display: inline-flex;
761
+ flex: 0 0 auto;
762
+ }
763
+ .pve-bar-left {
764
+ display: flex;
765
+ align-items: center;
766
+ gap: 14px;
767
+ min-width: 0;
768
+ }
769
+ .pve-bar-right {
770
+ display: flex;
771
+ align-items: center;
772
+ gap: 8px;
773
+ margin-left: auto;
774
+ }
775
+ .pve-titlechip {
776
+ display: flex;
777
+ align-items: center;
778
+ gap: 6px;
779
+ }
780
+ .pve-titlechip-kicker {
781
+ font-size: 10px;
782
+ font-weight: 700;
783
+ letter-spacing: 0.1em;
784
+ color: #94a3b8;
785
+ }
786
+ .pve-title {
787
+ font-size: 18px;
788
+ font-weight: 700;
789
+ margin: 0;
790
+ letter-spacing: -0.02em;
791
+ }
792
+ .pve-bar-note {
793
+ font-size: 11.5px;
794
+ color: #64748b;
795
+ }
796
+ .pve-mono {
797
+ font: 500 11px var(--pve-font-mono);
798
+ }
799
+ .pve-mono--xs {
800
+ font-size: 10px;
801
+ }
802
+
803
+ /* ── Buttons & Chips ───────────────────────────────────────────── */
804
+ .pve-btn {
805
+ display: inline-flex;
806
+ align-items: center;
807
+ gap: 7px;
808
+ padding: 8px 14px;
809
+ border-radius: 7px;
810
+ font: 500 13px var(--pve-font-sans);
811
+ cursor: pointer;
812
+ border: 1px solid var(--pve-border-strong);
813
+ background: #fff;
814
+ color: var(--pve-text);
815
+ transition:
816
+ background 0.12s,
817
+ border-color 0.12s;
818
+ }
819
+ .pve-btn:hover {
820
+ background: var(--pve-surface-2);
821
+ }
822
+ .pve-btn:disabled {
823
+ cursor: not-allowed;
824
+ opacity: 0.5;
825
+ }
826
+ .pve-btn--primary {
827
+ background: var(--pve-primary);
828
+ border-color: var(--pve-primary);
829
+ color: #fff;
830
+ }
831
+ .pve-btn--primary:hover {
832
+ background: var(--pve-primary-700);
833
+ }
834
+ .pve-btn--ghost {
835
+ border-color: transparent;
836
+ background: transparent;
837
+ }
838
+ .pve-btn--ghost:hover {
839
+ background: rgba(15, 23, 42, 0.05);
840
+ }
841
+ .pve-btn--sm {
842
+ padding: 5px 9px;
843
+ font-size: 12px;
844
+ gap: 5px;
845
+ }
846
+ .pve-ico {
847
+ display: inline-flex;
848
+ }
849
+ .pve-ico--rot180 {
850
+ transform: rotate(180deg);
851
+ }
852
+
853
+ .pve-chip {
854
+ display: inline-flex;
855
+ align-items: center;
856
+ gap: 5px;
857
+ padding: 2px 8px;
858
+ border-radius: 999px;
859
+ font-size: 11px;
860
+ font-weight: 600;
861
+ letter-spacing: 0.01em;
862
+ background: var(--pve-surface-2);
863
+ color: var(--pve-text-2);
864
+ border: 1px solid var(--pve-border);
865
+ }
866
+ .pve-chip--plan {
867
+ background: #dbeafe;
868
+ color: #1e40af;
869
+ border-color: #bfdbfe;
870
+ }
871
+ .pve-chip--draft {
872
+ background: var(--pve-draft-bg);
873
+ color: #b45309;
874
+ border-color: #fde68a;
875
+ }
876
+ .pve-chip--dot::before {
877
+ content: '';
878
+ width: 6px;
879
+ height: 6px;
880
+ border-radius: 50%;
881
+ background: currentColor;
882
+ }
883
+ .pve-chip--changes {
884
+ background: #fffbeb;
885
+ color: #b45309;
886
+ border-color: #fde68a;
887
+ }
888
+
889
+ .pve-kbd {
890
+ font: 600 10.5px var(--pve-font-mono);
891
+ background: #f1f5f9;
892
+ color: #475569;
893
+ padding: 2px 6px;
894
+ border-radius: 4px;
895
+ border: 1px solid #e2e8f0;
896
+ }
897
+
898
+ /* ── Body Grid ──────────────────────────────────────────────────── */
899
+ .pve-body {
900
+ flex: 1 1 auto;
901
+ min-height: 0;
902
+ overflow: hidden;
903
+ display: grid;
904
+ grid-template-columns: 380px 1fr 400px;
905
+ background: var(--pve-bg);
906
+ }
907
+
908
+ .pve-col {
909
+ display: flex;
910
+ flex-direction: column;
911
+ border-right: 1px solid var(--pve-border);
912
+ background: #fff;
913
+ min-width: 0;
914
+ overflow: hidden;
915
+ }
916
+ .pve-col:last-child {
917
+ border-right: 0;
918
+ }
919
+ .pve-col-header {
920
+ padding: 16px 18px 12px;
921
+ border-bottom: 1px solid var(--pve-border);
922
+ display: flex;
923
+ align-items: flex-start;
924
+ gap: 8px;
925
+ flex: 0 0 auto;
926
+ }
927
+ .pve-col-header--basket {
928
+ padding: 16px 22px 12px;
929
+ }
930
+ .pve-col-header--preview {
931
+ padding: 16px 18px 12px;
932
+ }
933
+ .pve-col-title {
934
+ font-size: 14px;
935
+ font-weight: 700;
936
+ letter-spacing: -0.01em;
937
+ }
938
+ .pve-col-sub {
939
+ font-size: 11.5px;
940
+ color: #64748b;
941
+ margin-top: 2px;
942
+ }
943
+
944
+ /* ── LEFT — Pool ───────────────────────────────────────────────── */
945
+ .pve-pool {
946
+ background: #fbfbfd;
947
+ }
948
+ .pve-search {
949
+ display: flex;
950
+ align-items: center;
951
+ gap: 8px;
952
+ padding: 10px 14px;
953
+ margin: 10px 14px 0;
954
+ background: #fff;
955
+ border: 1px solid var(--pve-border);
956
+ border-radius: 8px;
957
+ }
958
+ .pve-search input {
959
+ flex: 1;
960
+ border: 0;
961
+ outline: 0;
962
+ background: transparent;
963
+ font: 13px var(--pve-font-sans);
964
+ color: #0f172a;
965
+ }
966
+ .pve-search-ico {
967
+ color: #94a3b8;
968
+ display: inline-flex;
969
+ }
970
+ .pve-tabs {
971
+ display: flex;
972
+ gap: 4px;
973
+ padding: 12px 14px 6px;
974
+ }
975
+ .pve-tab {
976
+ background: transparent;
977
+ border: 0;
978
+ padding: 5px 10px;
979
+ border-radius: 6px;
980
+ font: 500 12.5px var(--pve-font-sans);
981
+ color: #475569;
982
+ cursor: pointer;
983
+ display: flex;
984
+ align-items: center;
985
+ gap: 6px;
986
+ }
987
+ .pve-tab--active {
988
+ background: #0f172a;
989
+ color: #fff;
990
+ }
991
+ .pve-tab-count {
992
+ font-size: 10.5px;
993
+ padding: 1px 5px;
994
+ border-radius: 4px;
995
+ background: #e2e8f0;
996
+ color: #475569;
997
+ }
998
+ .pve-tab--active .pve-tab-count {
999
+ background: rgba(255, 255, 255, 0.15);
1000
+ color: #fff;
1001
+ }
1002
+ .pve-pool-list {
1003
+ flex: 1;
1004
+ min-height: 0;
1005
+ overflow: auto;
1006
+ padding: 6px 14px 18px;
1007
+ }
1008
+ .pve-pool-group {
1009
+ font-size: 10px;
1010
+ text-transform: uppercase;
1011
+ letter-spacing: 0.1em;
1012
+ color: #94a3b8;
1013
+ font-weight: 700;
1014
+ padding: 12px 4px 6px;
1015
+ }
1016
+ .pve-pool-card {
1017
+ display: flex;
1018
+ align-items: flex-start;
1019
+ gap: 10px;
1020
+ background: #fff;
1021
+ border: 1px solid var(--pve-border);
1022
+ border-radius: 8px;
1023
+ padding: 9px 10px;
1024
+ margin-bottom: 6px;
1025
+ cursor: grab;
1026
+ transition:
1027
+ box-shadow 0.12s,
1028
+ border-color 0.12s;
1029
+ user-select: none;
1030
+ }
1031
+ .pve-pool-card:hover {
1032
+ border-color: #c7d2fe;
1033
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
1034
+ }
1035
+ .pve-pool-card:active {
1036
+ cursor: grabbing;
1037
+ }
1038
+ .pve-pool-card--selected {
1039
+ background: var(--pve-surface-2);
1040
+ opacity: 0.7;
1041
+ }
1042
+ .pve-pool-card--partial {
1043
+ border-color: #fde68a;
1044
+ background: #fffbeb;
1045
+ }
1046
+ .pve-pool-grip {
1047
+ color: #cbd5e1;
1048
+ padding-top: 3px;
1049
+ flex: 0 0 auto;
1050
+ }
1051
+ .pve-pool-card-main {
1052
+ flex: 1;
1053
+ min-width: 0;
1054
+ }
1055
+ .pve-pool-card-row {
1056
+ display: flex;
1057
+ align-items: center;
1058
+ gap: 8px;
1059
+ }
1060
+ .pve-pool-card-label {
1061
+ font-size: 13px;
1062
+ font-weight: 600;
1063
+ color: #0f172a;
1064
+ }
1065
+ .pve-pool-card-check {
1066
+ display: inline-grid;
1067
+ place-items: center;
1068
+ width: 16px;
1069
+ height: 16px;
1070
+ border-radius: 4px;
1071
+ background: #d1fae5;
1072
+ color: #047857;
1073
+ margin-left: auto;
1074
+ }
1075
+ .pve-pool-card-meta {
1076
+ display: flex;
1077
+ gap: 5px;
1078
+ align-items: center;
1079
+ flex-wrap: wrap;
1080
+ font-size: 10.5px;
1081
+ color: #94a3b8;
1082
+ margin-top: 2px;
1083
+ }
1084
+ .pve-pool-card-key {
1085
+ font: 500 10px var(--pve-font-mono);
1086
+ color: #64748b;
1087
+ }
1088
+ .pve-pool-card-dot {
1089
+ color: #cbd5e1;
1090
+ }
1091
+
1092
+ .pve-empty {
1093
+ padding: 24px 4px;
1094
+ text-align: center;
1095
+ color: #94a3b8;
1096
+ font-size: 12.5px;
1097
+ font-style: italic;
1098
+ }
1099
+
1100
+ /* ── MIDDLE — Basket ───────────────────────────────────────────── */
1101
+ .pve-basket {
1102
+ padding: 0;
1103
+ overflow: auto;
1104
+ transition:
1105
+ background 0.15s,
1106
+ box-shadow 0.15s;
1107
+ }
1108
+ .pve-basket--dragover {
1109
+ background: #f0f6ff;
1110
+ box-shadow: inset 0 0 0 2px var(--pve-primary);
1111
+ }
1112
+ .pve-basket-settings {
1113
+ background: linear-gradient(180deg, var(--pve-surface-2) 0%, #fff 100%);
1114
+ border-bottom: 1px solid var(--pve-border);
1115
+ padding: 14px 22px 16px;
1116
+ display: flex;
1117
+ flex-direction: column;
1118
+ gap: 8px;
1119
+ }
1120
+ .pve-bs-row {
1121
+ display: flex;
1122
+ align-items: center;
1123
+ gap: 10px;
1124
+ }
1125
+ .pve-bs-label {
1126
+ font-size: 12px;
1127
+ color: #475569;
1128
+ font-weight: 500;
1129
+ min-width: 88px;
1130
+ }
1131
+ .pve-bs-label--inline {
1132
+ min-width: 0;
1133
+ margin-left: 8px;
1134
+ }
1135
+ .pve-bs-input {
1136
+ background: #fff;
1137
+ border: 1px solid var(--pve-border);
1138
+ border-radius: 6px;
1139
+ padding: 6px 10px;
1140
+ font: 13px var(--pve-font-sans);
1141
+ color: #0f172a;
1142
+ outline: none;
1143
+ }
1144
+ .pve-bs-input:focus {
1145
+ border-color: var(--pve-primary);
1146
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
1147
+ }
1148
+ .pve-bs-input--error {
1149
+ border-color: #ef4444;
1150
+ background: #fef2f2;
1151
+ }
1152
+ .pve-bs-input--error:focus {
1153
+ border-color: #ef4444;
1154
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
1155
+ }
1156
+ .pve-bs-error {
1157
+ display: flex;
1158
+ align-items: center;
1159
+ gap: 6px;
1160
+ margin: -2px 0 2px;
1161
+ font-size: 11.5px;
1162
+ font-weight: 500;
1163
+ color: #b91c1c;
1164
+ }
1165
+ .pve-bs-error svg {
1166
+ flex: 0 0 auto;
1167
+ }
1168
+ .pve-bs-input--grow {
1169
+ flex: 1;
1170
+ margin-left: 10px;
1171
+ }
1172
+ .pve-bs-input-grp {
1173
+ display: flex;
1174
+ align-items: stretch;
1175
+ border: 1px solid var(--pve-border);
1176
+ border-radius: 6px;
1177
+ background: #fff;
1178
+ overflow: hidden;
1179
+ }
1180
+ .pve-bs-input-grp--gap {
1181
+ margin-left: 6px;
1182
+ }
1183
+ .pve-bs-input--flush {
1184
+ border: 0 !important;
1185
+ border-radius: 0 !important;
1186
+ padding: 6px 8px !important;
1187
+ width: 90px;
1188
+ }
1189
+ .pve-bs-prefix,
1190
+ .pve-bs-suffix {
1191
+ background: var(--pve-surface-2);
1192
+ padding: 6px 10px;
1193
+ font-size: 12px;
1194
+ color: #64748b;
1195
+ display: grid;
1196
+ place-items: center;
1197
+ border-right: 1px solid var(--pve-border);
1198
+ }
1199
+ .pve-bs-suffix {
1200
+ border-right: 0;
1201
+ border-left: 1px solid var(--pve-border);
1202
+ }
1203
+
1204
+ .pve-toggle {
1205
+ position: relative;
1206
+ width: 36px;
1207
+ height: 20px;
1208
+ display: inline-block;
1209
+ cursor: pointer;
1210
+ }
1211
+ .pve-toggle input {
1212
+ opacity: 0;
1213
+ width: 0;
1214
+ height: 0;
1215
+ }
1216
+ .pve-toggle span {
1217
+ position: absolute;
1218
+ inset: 0;
1219
+ background: #cbd5e1;
1220
+ border-radius: 999px;
1221
+ transition: background 0.15s;
1222
+ }
1223
+ .pve-toggle span::before {
1224
+ content: '';
1225
+ position: absolute;
1226
+ top: 2px;
1227
+ left: 2px;
1228
+ width: 16px;
1229
+ height: 16px;
1230
+ border-radius: 50%;
1231
+ background: #fff;
1232
+ transition: transform 0.15s;
1233
+ }
1234
+ .pve-toggle input:checked + span {
1235
+ background: var(--pve-primary);
1236
+ }
1237
+ .pve-toggle input:checked + span::before {
1238
+ transform: translateX(16px);
1239
+ }
1240
+
1241
+ .pve-basket-group {
1242
+ padding: 14px 22px;
1243
+ border-bottom: 1px solid #f1f5f9;
1244
+ }
1245
+ .pve-basket-group:last-child {
1246
+ border-bottom: 0;
1247
+ }
1248
+ .pve-bg-header {
1249
+ display: flex;
1250
+ align-items: center;
1251
+ gap: 8px;
1252
+ margin-bottom: 8px;
1253
+ }
1254
+ .pve-bg-dot {
1255
+ width: 8px;
1256
+ height: 8px;
1257
+ border-radius: 50%;
1258
+ }
1259
+ .pve-bg-dot--quota {
1260
+ background: #0ea5e9;
1261
+ }
1262
+ .pve-bg-dot--feature {
1263
+ background: #8b5cf6;
1264
+ }
1265
+ .pve-bg-dot--bundle {
1266
+ background: #f59e0b;
1267
+ }
1268
+ .pve-bg-title {
1269
+ font-size: 12px;
1270
+ text-transform: uppercase;
1271
+ letter-spacing: 0.1em;
1272
+ font-weight: 700;
1273
+ color: #334155;
1274
+ }
1275
+ .pve-bg-count {
1276
+ margin-left: 6px;
1277
+ font-size: 11px;
1278
+ color: #94a3b8;
1279
+ }
1280
+
1281
+ .pve-dz {
1282
+ border: 1.5px dashed var(--pve-border-strong);
1283
+ border-radius: 10px;
1284
+ padding: 12px;
1285
+ background: #fafbfc;
1286
+ min-height: 60px;
1287
+ }
1288
+ .pve-basket--dragover .pve-dz {
1289
+ border-color: var(--pve-primary);
1290
+ background: #f0f6ff;
1291
+ }
1292
+ .pve-dz-empty {
1293
+ font-size: 12px;
1294
+ color: #94a3b8;
1295
+ text-align: center;
1296
+ padding: 4px 0;
1297
+ }
1298
+ .pve-dz-empty--center {
1299
+ padding: 8px 0;
1300
+ }
1301
+
1302
+ .pve-sel-row {
1303
+ display: flex;
1304
+ align-items: center;
1305
+ gap: 10px;
1306
+ padding: 8px 10px;
1307
+ background: #fff;
1308
+ border: 1px solid var(--pve-border);
1309
+ border-radius: 6px;
1310
+ margin-bottom: 4px;
1311
+ }
1312
+ .pve-sel-row:last-child {
1313
+ margin-bottom: 0;
1314
+ }
1315
+ .pve-sel-dot {
1316
+ width: 6px;
1317
+ height: 6px;
1318
+ border-radius: 50%;
1319
+ flex: 0 0 auto;
1320
+ }
1321
+ .pve-sel-dot--quota {
1322
+ background: #0ea5e9;
1323
+ }
1324
+ .pve-sel-dot--feature {
1325
+ background: #8b5cf6;
1326
+ }
1327
+ .pve-sel-dot--bundle {
1328
+ background: #f59e0b;
1329
+ }
1330
+ .pve-sel-body {
1331
+ flex: 1;
1332
+ min-width: 0;
1333
+ }
1334
+ .pve-sel-label {
1335
+ font-size: 13px;
1336
+ font-weight: 600;
1337
+ color: #0f172a;
1338
+ }
1339
+ .pve-sel-sub {
1340
+ font: 500 10.5px var(--pve-font-mono);
1341
+ color: #94a3b8;
1342
+ }
1343
+ .pve-sel-val-edit {
1344
+ display: flex;
1345
+ align-items: center;
1346
+ gap: 4px;
1347
+ background: var(--pve-surface-2);
1348
+ padding: 3px 8px;
1349
+ border-radius: 5px;
1350
+ border: 1px solid var(--pve-border);
1351
+ }
1352
+ .pve-sel-val-input {
1353
+ width: 64px;
1354
+ border: 0;
1355
+ outline: 0;
1356
+ background: transparent;
1357
+ font: 600 13px var(--pve-font-sans);
1358
+ color: #0f172a;
1359
+ text-align: right;
1360
+ -moz-appearance: textfield;
1361
+ }
1362
+ .pve-sel-val-input::-webkit-outer-spin-button,
1363
+ .pve-sel-val-input::-webkit-inner-spin-button {
1364
+ -webkit-appearance: none;
1365
+ margin: 0;
1366
+ }
1367
+ .pve-sel-val-unit {
1368
+ font-size: 10.5px;
1369
+ color: #94a3b8;
1370
+ }
1371
+ .pve-sel-x {
1372
+ background: transparent;
1373
+ border: 0;
1374
+ color: #94a3b8;
1375
+ cursor: pointer;
1376
+ padding: 4px;
1377
+ border-radius: 4px;
1378
+ display: grid;
1379
+ place-items: center;
1380
+ }
1381
+ .pve-sel-x:hover {
1382
+ background: #fef2f2;
1383
+ color: #ef4444;
1384
+ }
1385
+
1386
+ /* ── RIGHT — Preview ───────────────────────────────────────────── */
1387
+ .pve-preview {
1388
+ background: var(--pve-surface-2);
1389
+ overflow: auto;
1390
+ }
1391
+ .pve-preview-toggle {
1392
+ display: flex;
1393
+ gap: 4px;
1394
+ margin-left: auto;
1395
+ }
1396
+ .pve-prev-window {
1397
+ margin: 14px 18px;
1398
+ background: #fff;
1399
+ border-radius: 10px;
1400
+ border: 1px solid var(--pve-border);
1401
+ overflow: hidden;
1402
+ transition: max-width 0.18s;
1403
+ }
1404
+ .pve-prev-window--mobile {
1405
+ max-width: 320px;
1406
+ }
1407
+ .pve-prev-chrome {
1408
+ display: flex;
1409
+ align-items: center;
1410
+ gap: 6px;
1411
+ padding: 8px 12px;
1412
+ background: #f1f5f9;
1413
+ border-bottom: 1px solid var(--pve-border);
1414
+ }
1415
+ .pve-prev-dot {
1416
+ width: 9px;
1417
+ height: 9px;
1418
+ border-radius: 50%;
1419
+ }
1420
+ .pve-prev-url {
1421
+ flex: 1;
1422
+ background: #fff;
1423
+ border-radius: 4px;
1424
+ padding: 3px 10px;
1425
+ margin-left: 10px;
1426
+ font: 500 11px var(--pve-font-mono);
1427
+ color: #64748b;
1428
+ border: 1px solid var(--pve-border);
1429
+ }
1430
+ .pve-prev-body {
1431
+ padding: 18px 18px 16px;
1432
+ }
1433
+ .pve-prev-eyebrow {
1434
+ font-size: 10.5px;
1435
+ text-transform: uppercase;
1436
+ letter-spacing: 0.12em;
1437
+ color: #64748b;
1438
+ font-weight: 700;
1439
+ }
1440
+ .pve-prev-title {
1441
+ font-size: 28px;
1442
+ font-weight: 700;
1443
+ letter-spacing: -0.03em;
1444
+ margin-top: 6px;
1445
+ color: #0f172a;
1446
+ }
1447
+ .pve-prev-desc {
1448
+ font-size: 12.5px;
1449
+ color: #475569;
1450
+ margin-top: 6px;
1451
+ line-height: 1.5;
1452
+ }
1453
+ .pve-prev-price {
1454
+ display: flex;
1455
+ align-items: baseline;
1456
+ gap: 4px;
1457
+ margin-top: 14px;
1458
+ }
1459
+ .pve-prev-price-big {
1460
+ font-size: 38px;
1461
+ font-weight: 700;
1462
+ letter-spacing: -0.03em;
1463
+ color: #0f172a;
1464
+ }
1465
+ .pve-prev-price-unit {
1466
+ font-size: 13px;
1467
+ color: #64748b;
1468
+ }
1469
+ .pve-prev-price-yearly {
1470
+ font-size: 11.5px;
1471
+ color: #047857;
1472
+ margin-top: 2px;
1473
+ font-weight: 500;
1474
+ }
1475
+ .pve-prev-cta {
1476
+ display: block;
1477
+ width: 100%;
1478
+ margin-top: 14px;
1479
+ padding: 10px;
1480
+ background: #0f172a;
1481
+ color: #fff;
1482
+ border: 0;
1483
+ border-radius: 7px;
1484
+ font: 600 13px var(--pve-font-sans);
1485
+ cursor: pointer;
1486
+ }
1487
+ .pve-prev-sep {
1488
+ margin-top: 18px;
1489
+ padding-top: 14px;
1490
+ border-top: 1px solid #f1f5f9;
1491
+ font-size: 10.5px;
1492
+ text-transform: uppercase;
1493
+ letter-spacing: 0.12em;
1494
+ color: #94a3b8;
1495
+ font-weight: 700;
1496
+ }
1497
+ .pve-prev-list {
1498
+ list-style: none;
1499
+ padding: 0;
1500
+ margin: 8px 0 0;
1501
+ display: flex;
1502
+ flex-direction: column;
1503
+ gap: 6px;
1504
+ }
1505
+ .pve-prev-list li {
1506
+ display: flex;
1507
+ align-items: center;
1508
+ gap: 8px;
1509
+ font-size: 12.5px;
1510
+ color: #0f172a;
1511
+ }
1512
+ .pve-prev-list li b {
1513
+ font-weight: 600;
1514
+ }
1515
+ .pve-prev-tick {
1516
+ display: inline-grid;
1517
+ place-items: center;
1518
+ width: 16px;
1519
+ height: 16px;
1520
+ border-radius: 50%;
1521
+ background: #d1fae5;
1522
+ color: #047857;
1523
+ flex: 0 0 auto;
1524
+ }
1525
+ .pve-prev-empty {
1526
+ color: #94a3b8;
1527
+ font-style: italic;
1528
+ }
1529
+ .pve-prev-foot {
1530
+ margin-top: 16px;
1531
+ padding-top: 14px;
1532
+ border-top: 1px solid #f1f5f9;
1533
+ font-size: 10.5px;
1534
+ color: #94a3b8;
1535
+ line-height: 1.5;
1536
+ }
1537
+
1538
+ .pve-prev-validate {
1539
+ margin: 0 18px 18px;
1540
+ background: #fff;
1541
+ border: 1px solid var(--pve-border);
1542
+ border-radius: 8px;
1543
+ padding: 12px;
1544
+ }
1545
+ .pve-prev-validate-head {
1546
+ display: flex;
1547
+ align-items: center;
1548
+ gap: 8px;
1549
+ margin-bottom: 6px;
1550
+ }
1551
+ .pve-prev-validate-tick {
1552
+ color: var(--pve-live);
1553
+ display: inline-flex;
1554
+ }
1555
+ .pve-prev-validate-title {
1556
+ font-size: 12.5px;
1557
+ font-weight: 600;
1558
+ }
1559
+ .pve-prev-validate-count {
1560
+ margin-left: auto;
1561
+ font-size: 11px;
1562
+ color: #64748b;
1563
+ }
1564
+ .pve-vchk {
1565
+ display: flex;
1566
+ align-items: center;
1567
+ gap: 6px;
1568
+ font-size: 11.5px;
1569
+ padding: 3px 0;
1570
+ }
1571
+ .pve-vchk--ok {
1572
+ color: #475569;
1573
+ }
1574
+ .pve-vchk--ok span:first-child {
1575
+ color: var(--pve-live);
1576
+ display: inline-flex;
1577
+ }
1578
+ .pve-vchk--warn {
1579
+ color: #b45309;
1580
+ }
1581
+ .pve-vchk--warn span:first-child {
1582
+ color: var(--pve-draft);
1583
+ display: inline-flex;
1584
+ }
1585
+
1586
+ /* ── Responsive ────────────────────────────────────────────────── */
1587
+ @media (max-width: 1280px) {
1588
+ .pve-body {
1589
+ grid-template-columns: 320px 1fr 360px;
1590
+ }
1591
+ }
1592
+ @media (max-width: 1100px) {
1593
+ .pve-body {
1594
+ grid-template-columns: 1fr;
1595
+ overflow-y: auto;
1596
+ }
1597
+ .pve-col {
1598
+ border-right: 0;
1599
+ border-bottom: 1px solid var(--pve-border);
1600
+ }
1601
+ }
1602
+
1603
+ /* ── Diff dialog ────────────────────────────────────────────────── */
1604
+ .pve-diff-modal {
1605
+ background: #fff;
1606
+ width: 560px;
1607
+ max-width: 92vw;
1608
+ border-radius: 14px;
1609
+ box-shadow: 0 24px 60px rgba(15, 23, 42, 0.3);
1610
+ overflow: hidden;
1611
+ font-family: var(--pve-font-sans);
1612
+ color: var(--pve-text);
1613
+ }
1614
+ .pve-diff-modal * {
1615
+ box-sizing: border-box;
1616
+ }
1617
+ .pve-diff-head {
1618
+ padding: 16px 20px;
1619
+ border-bottom: 1px solid var(--pve-border);
1620
+ display: flex;
1621
+ align-items: flex-start;
1622
+ gap: 12px;
1623
+ }
1624
+ .pve-diff-title {
1625
+ font-size: 15px;
1626
+ font-weight: 700;
1627
+ letter-spacing: -0.01em;
1628
+ }
1629
+ .pve-diff-sub {
1630
+ font-size: 11.5px;
1631
+ color: #64748b;
1632
+ margin-top: 3px;
1633
+ }
1634
+ .pve-diff-close {
1635
+ margin-left: auto;
1636
+ width: 28px;
1637
+ height: 28px;
1638
+ border-radius: 6px;
1639
+ background: transparent;
1640
+ border: 0;
1641
+ color: var(--pve-text-3);
1642
+ cursor: pointer;
1643
+ display: grid;
1644
+ place-items: center;
1645
+ flex: 0 0 auto;
1646
+ }
1647
+ .pve-diff-close:hover {
1648
+ background: rgba(15, 23, 42, 0.05);
1649
+ color: var(--pve-text);
1650
+ }
1651
+ .pve-diff-body {
1652
+ padding: 14px 20px 18px;
1653
+ display: flex;
1654
+ flex-direction: column;
1655
+ gap: 6px;
1656
+ max-height: 60vh;
1657
+ overflow-y: auto;
1658
+ }
1659
+ .pve-diff-row {
1660
+ display: flex;
1661
+ align-items: center;
1662
+ gap: 10px;
1663
+ padding: 9px 11px;
1664
+ border: 1px solid;
1665
+ border-radius: 7px;
1666
+ }
1667
+ .pve-diff-sign {
1668
+ width: 20px;
1669
+ height: 20px;
1670
+ border-radius: 5px;
1671
+ color: #fff;
1672
+ font-weight: 800;
1673
+ font-size: 13px;
1674
+ display: grid;
1675
+ place-items: center;
1676
+ flex: 0 0 auto;
1677
+ }
1678
+ .pve-diff-main {
1679
+ flex: 1;
1680
+ min-width: 0;
1681
+ }
1682
+ .pve-diff-headline {
1683
+ display: flex;
1684
+ align-items: baseline;
1685
+ gap: 8px;
1686
+ flex-wrap: wrap;
1687
+ }
1688
+ .pve-diff-section {
1689
+ font-size: 10.5px;
1690
+ text-transform: uppercase;
1691
+ letter-spacing: 0.08em;
1692
+ font-weight: 700;
1693
+ }
1694
+ .pve-diff-label {
1695
+ font-size: 13px;
1696
+ font-weight: 600;
1697
+ color: var(--pve-text);
1698
+ }
1699
+ .pve-diff-change {
1700
+ display: flex;
1701
+ align-items: center;
1702
+ gap: 8px;
1703
+ margin-top: 3px;
1704
+ font-size: 12px;
1705
+ }
1706
+ .pve-diff-from {
1707
+ text-decoration: line-through;
1708
+ color: var(--pve-text-3);
1709
+ }
1710
+ .pve-diff-to {
1711
+ color: var(--pve-text);
1712
+ font-weight: 600;
1713
+ }
1714
+ .pve-diff-arrow {
1715
+ font-weight: 700;
1716
+ }
1717
+ .pve-diff-tag {
1718
+ font-size: 10px;
1719
+ background: #fff;
1720
+ }
1721
+ .pve-diff-empty {
1722
+ padding: 24px 8px;
1723
+ text-align: center;
1724
+ color: var(--pve-text-3);
1725
+ font-style: italic;
1726
+ font-size: 13px;
1727
+ }
1728
+ </style>