@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,859 @@
1
+ <template>
2
+ <q-dialog
3
+ :model-value="modelValue"
4
+ @update:model-value="emit('update:modelValue', $event)"
5
+ persistent
6
+ >
7
+ <q-card class="pc-dlg">
8
+ <q-card-section class="pc-dlg__head">
9
+ <div>
10
+ <div class="pc-dlg__title">Promo-Code bearbeiten</div>
11
+ <div class="pc-dlg__sub" v-if="row">
12
+ Code <strong>{{ row.code }}</strong> ·
13
+ {{ row.redemptionsCount }} Einlösungen bisher
14
+ </div>
15
+ </div>
16
+ <q-btn
17
+ class="pc-dlg__close"
18
+ flat
19
+ dense
20
+ round
21
+ icon="close"
22
+ v-close-popup
23
+ :disable="loading"
24
+ />
25
+ </q-card-section>
26
+
27
+ <q-card-section class="pc-dlg__body">
28
+ <!-- Section: Code & discount -->
29
+ <div class="pc-section">
30
+ <div class="pc-section__title">Code &amp; Rabatt</div>
31
+ <div class="pc-grid pc-grid--2">
32
+ <div class="pc-field">
33
+ <div class="pc-field__label">Code</div>
34
+ <input
35
+ :value="row?.code ?? ''"
36
+ class="pc-input pc-input--code"
37
+ disabled
38
+ />
39
+ <div class="pc-field__hint">Nach Anlage stabil.</div>
40
+ </div>
41
+
42
+ <div class="pc-field">
43
+ <div class="pc-field__label">Rabatt-Typ</div>
44
+ <div class="pc-type-grid">
45
+ <button
46
+ v-for="o in TYPE_OPTIONS"
47
+ :key="o.k"
48
+ type="button"
49
+ class="pc-type-opt"
50
+ :class="{ 'pc-type-opt--active': form.valueType === o.k }"
51
+ @click="form.valueType = o.k"
52
+ >
53
+ <div class="pc-type-opt__label">{{ o.label }}</div>
54
+ <div class="pc-type-opt__sub">{{ o.sub }}</div>
55
+ </button>
56
+ </div>
57
+ </div>
58
+ </div>
59
+
60
+ <div class="pc-field" style="max-width: 280px">
61
+ <div class="pc-field__label">
62
+ {{ form.valueType === 'PERCENT' ? 'Rabatt in %' : 'Rabatt in €' }}
63
+ </div>
64
+ <input
65
+ v-model.number="form.value"
66
+ class="pc-input"
67
+ type="number"
68
+ min="1"
69
+ :max="form.valueType === 'PERCENT' ? 100 : undefined"
70
+ />
71
+ </div>
72
+ </div>
73
+
74
+ <!-- Section: Validity & duration -->
75
+ <div class="pc-section">
76
+ <div class="pc-section__title">Gültigkeit &amp; Laufzeit</div>
77
+
78
+ <div v-if="plans.length > 0" class="pc-field">
79
+ <div class="pc-field__label">Anwendbar auf Pläne</div>
80
+ <div class="pc-plan-pick">
81
+ <button
82
+ v-for="p in plans"
83
+ :key="p.key"
84
+ type="button"
85
+ class="pc-plan-opt"
86
+ :class="{ 'pc-plan-opt--on': isPlanSelected(p.key) }"
87
+ :style="planChipStyle(p)"
88
+ @click="togglePlan(p.key)"
89
+ >
90
+ <span
91
+ class="pc-plan-opt__mark"
92
+ :style="{ background: p.color ?? '#64748b' }"
93
+ />
94
+ {{ p.label }}
95
+ </button>
96
+ </div>
97
+ <div class="pc-field__hint">
98
+ Leer = alle Pläne ({{ form.appliesToPlans.length }} ausgewählt)
99
+ </div>
100
+ </div>
101
+
102
+ <div class="pc-grid pc-grid--2">
103
+ <div class="pc-field">
104
+ <div class="pc-field__label">Laufzeit des Rabatts</div>
105
+ <div class="pc-dur">
106
+ <button
107
+ v-for="o in DURATION_OPTIONS"
108
+ :key="o.k"
109
+ type="button"
110
+ class="pc-dur-opt"
111
+ :class="{ 'pc-dur-opt--active': form.durationType === o.k }"
112
+ @click="form.durationType = o.k"
113
+ >
114
+ {{ o.label }}
115
+ </button>
116
+ </div>
117
+ <input
118
+ v-if="form.durationType !== 'ONCE'"
119
+ v-model.number="form.durationValue"
120
+ class="pc-input"
121
+ type="number"
122
+ min="1"
123
+ style="margin-top: 8px; max-width: 120px"
124
+ :placeholder="
125
+ form.durationType === 'MONTHS' ? 'Monate' : 'Perioden'
126
+ "
127
+ />
128
+ </div>
129
+
130
+ <div class="pc-field">
131
+ <div class="pc-field__label">Max. Einlösungen</div>
132
+ <input
133
+ v-model.number="form.maxRedemptions"
134
+ class="pc-input"
135
+ type="number"
136
+ min="1"
137
+ placeholder="leer = ∞"
138
+ />
139
+ <div class="pc-field__hint">
140
+ Nur erhöhbar, nicht senkbar (bestehende Einlösungen bleiben gültig).
141
+ </div>
142
+ </div>
143
+ </div>
144
+
145
+ <div class="pc-grid pc-grid--2">
146
+ <div class="pc-field">
147
+ <div class="pc-field__label">Gültig ab</div>
148
+ <input v-model="form.validFrom" class="pc-input" type="date" />
149
+ </div>
150
+ <div class="pc-field">
151
+ <div class="pc-field__label">Gültig bis</div>
152
+ <input v-model="form.validUntil" class="pc-input" type="date" />
153
+ </div>
154
+ </div>
155
+
156
+ <div class="pc-grid pc-grid--2">
157
+ <div class="pc-field">
158
+ <div class="pc-field__label">Status</div>
159
+ <div class="pc-status">
160
+ <button
161
+ v-for="o in STATUS_OPTIONS"
162
+ :key="o.k"
163
+ type="button"
164
+ class="pc-status-opt"
165
+ :class="{
166
+ 'pc-status-opt--active': form.status === o.k,
167
+ }"
168
+ @click="form.status = o.k"
169
+ >
170
+ <q-icon :name="o.icon" size="14px" />
171
+ {{ o.label }}
172
+ </button>
173
+ </div>
174
+ <div class="pc-field__hint">
175
+ Bestehende Redemptions bleiben unverändert.
176
+ </div>
177
+ </div>
178
+ </div>
179
+ </div>
180
+
181
+ <!-- Section: Campaign & note -->
182
+ <div class="pc-section">
183
+ <div class="pc-section__title">Kampagne &amp; Notizen</div>
184
+ <div v-if="showCampaignTag" class="pc-field">
185
+ <div class="pc-field__label">Kampagne (optional)</div>
186
+ <input
187
+ v-model="form.campaignTag"
188
+ class="pc-input"
189
+ placeholder="z. B. Frühjahrs-Aktion 2026"
190
+ />
191
+ <div class="pc-field__hint">Gruppiert mehrere Codes optisch</div>
192
+ </div>
193
+ <div class="pc-field">
194
+ <div class="pc-field__label">Notiz (intern)</div>
195
+ <textarea
196
+ v-model="form.description"
197
+ class="pc-input"
198
+ rows="2"
199
+ placeholder="Kontext, Quelle, Sales-Bezug — wird nicht an Kunden ausgespielt."
200
+ />
201
+ </div>
202
+ </div>
203
+
204
+ <!-- Section: Advanced -->
205
+ <div class="pc-section">
206
+ <button
207
+ type="button"
208
+ class="pc-section__toggle"
209
+ @click="advancedOpen = !advancedOpen"
210
+ >
211
+ <q-icon
212
+ :name="advancedOpen ? 'expand_more' : 'chevron_right'"
213
+ size="16px"
214
+ />
215
+ Erweiterte Einschränkungen
216
+ </button>
217
+ <div v-if="advancedOpen" class="pc-advanced">
218
+ <div class="pc-grid pc-grid--2">
219
+ <div class="pc-field">
220
+ <div class="pc-field__label">Nur für Abrechnungs-Zyklus</div>
221
+ <select v-model="form.appliesToBilling" class="pc-input">
222
+ <option :value="null">Beide</option>
223
+ <option value="MONTHLY">Monatlich</option>
224
+ <option value="YEARLY">Jährlich</option>
225
+ </select>
226
+ </div>
227
+ <div class="pc-field">
228
+ <div class="pc-field__label">Mindest-Plan-Betrag (€ brutto)</div>
229
+ <input
230
+ v-model.number="form.minimumPlanAmountGross"
231
+ class="pc-input"
232
+ type="number"
233
+ min="0"
234
+ placeholder="leer = keine Schwelle"
235
+ />
236
+ </div>
237
+ </div>
238
+ <div class="pc-grid pc-grid--2">
239
+ <label class="pc-check">
240
+ <input v-model="form.firstTimeCustomersOnly" type="checkbox" />
241
+ <span>Nur Neukunden</span>
242
+ </label>
243
+ <label class="pc-check">
244
+ <input v-model="form.allowZeroInvoice" type="checkbox" />
245
+ <span>0-€-Rechnung zulassen</span>
246
+ </label>
247
+ </div>
248
+ <div class="pc-field">
249
+ <div class="pc-field__label">Erlös-Minderungs-Konto (Buchhaltung)</div>
250
+ <input
251
+ v-model="form.revenueDeductionAccount"
252
+ class="pc-input"
253
+ placeholder="z. B. 8736"
254
+ />
255
+ </div>
256
+ </div>
257
+ </div>
258
+
259
+ <!-- Live preview -->
260
+ <div class="pc-preview">
261
+ <div class="pc-preview__eyebrow">Vorschau im Catalog</div>
262
+ <div class="pc-preview__body">
263
+ <code class="pc-preview__code">{{ row?.code || 'CODE' }}</code>
264
+ <span class="pc-preview__disc">{{ previewValue }}</span>
265
+ <span class="pc-preview__meta">{{ previewMeta }}</span>
266
+ </div>
267
+ </div>
268
+
269
+ <p v-if="error" class="pc-error">{{ error }}</p>
270
+ </q-card-section>
271
+
272
+ <q-card-actions align="right" class="pc-dlg__foot">
273
+ <q-btn flat label="Abbrechen" v-close-popup :disable="loading" />
274
+ <q-btn
275
+ unelevated
276
+ color="primary"
277
+ label="Speichern"
278
+ :loading="loading"
279
+ :disable="!isValid || !hasChanges"
280
+ @click="onSubmit"
281
+ />
282
+ </q-card-actions>
283
+ </q-card>
284
+ </q-dialog>
285
+ </template>
286
+
287
+ <script setup lang="ts">
288
+ import { computed, reactive, ref, watch } from 'vue';
289
+ import type {
290
+ PromoCodeDurationType,
291
+ PromoCodePlanOption,
292
+ PromoCodeUpdatePayload,
293
+ PromoCodeValueType,
294
+ } from './types.js';
295
+
296
+ // Platform PromoCode edit dialog. Layout identical to PromoCodeCreateDialog
297
+ // (sections, type grid, plan picker), only `code` is disabled — the code
298
+ // stays stable after creation, because already-issued codes would otherwise
299
+ // no longer be traceable. All other fields can be maintained afterwards;
300
+ // existing redemptions are frozen via `appliedValue` snapshots and remain
301
+ // untouched by changes.
302
+
303
+ export interface PromoCodeEditRow {
304
+ id: string;
305
+ code: string;
306
+ status: 'ACTIVE' | 'PAUSED' | 'EXHAUSTED' | 'EXPIRED' | string;
307
+ valueType?: PromoCodeValueType;
308
+ value?: number;
309
+ durationType?: PromoCodeDurationType;
310
+ durationValue?: number | null;
311
+ validFrom?: string | null;
312
+ validUntil: string | null;
313
+ maxRedemptions: number | null;
314
+ redemptionsCount: number;
315
+ appliesToPlans?: string[];
316
+ appliesToBilling?: 'MONTHLY' | 'YEARLY' | null;
317
+ firstTimeCustomersOnly?: boolean;
318
+ minimumPlanAmountGross?: number | null;
319
+ allowZeroInvoice?: boolean;
320
+ campaignTag?: string | null;
321
+ revenueDeductionAccount?: string | null;
322
+ description?: string | null;
323
+ }
324
+
325
+ const props = withDefaults(
326
+ defineProps<{
327
+ modelValue: boolean;
328
+ row: PromoCodeEditRow | null;
329
+ showCampaignTag?: boolean;
330
+ plans?: readonly PromoCodePlanOption[];
331
+ submit: (id: string, payload: PromoCodeUpdatePayload) => Promise<void>;
332
+ }>(),
333
+ {
334
+ showCampaignTag: true,
335
+ plans: () => [],
336
+ },
337
+ );
338
+
339
+ const emit = defineEmits<{
340
+ (e: 'update:modelValue', v: boolean): void;
341
+ (e: 'updated'): void;
342
+ }>();
343
+
344
+ const STATUS_OPTIONS: ReadonlyArray<{ k: 'ACTIVE' | 'PAUSED'; label: string; icon: string }> = [
345
+ { k: 'ACTIVE', label: 'Aktiv', icon: 'play_arrow' },
346
+ { k: 'PAUSED', label: 'Pausiert', icon: 'pause' },
347
+ ];
348
+
349
+ const TYPE_OPTIONS: ReadonlyArray<{ k: PromoCodeValueType; label: string; sub: string }> = [
350
+ { k: 'PERCENT', label: 'Prozent', sub: '−x %' },
351
+ { k: 'ABSOLUTE', label: 'Fester €', sub: '−x €' },
352
+ ];
353
+
354
+ const DURATION_OPTIONS: ReadonlyArray<{ k: PromoCodeDurationType; label: string }> = [
355
+ { k: 'ONCE', label: 'Einmalig' },
356
+ { k: 'MONTHS', label: 'N Monate' },
357
+ { k: 'BILLING_CYCLES', label: 'N Perioden' },
358
+ ];
359
+
360
+ interface EditForm {
361
+ status: 'ACTIVE' | 'PAUSED';
362
+ valueType: PromoCodeValueType;
363
+ value: number;
364
+ durationType: PromoCodeDurationType;
365
+ durationValue: number | null;
366
+ maxRedemptions: number | null;
367
+ validFrom: string;
368
+ validUntil: string;
369
+ appliesToPlans: string[];
370
+ appliesToBilling: 'MONTHLY' | 'YEARLY' | null;
371
+ firstTimeCustomersOnly: boolean;
372
+ minimumPlanAmountGross: number | null;
373
+ allowZeroInvoice: boolean;
374
+ campaignTag: string;
375
+ revenueDeductionAccount: string;
376
+ description: string;
377
+ }
378
+
379
+ function emptyForm(): EditForm {
380
+ return {
381
+ status: 'ACTIVE',
382
+ valueType: 'PERCENT',
383
+ value: 0,
384
+ durationType: 'ONCE',
385
+ durationValue: null,
386
+ maxRedemptions: null,
387
+ validFrom: '',
388
+ validUntil: '',
389
+ appliesToPlans: [],
390
+ appliesToBilling: null,
391
+ firstTimeCustomersOnly: false,
392
+ minimumPlanAmountGross: null,
393
+ allowZeroInvoice: false,
394
+ campaignTag: '',
395
+ revenueDeductionAccount: '',
396
+ description: '',
397
+ };
398
+ }
399
+
400
+ function fromRow(row: PromoCodeEditRow): EditForm {
401
+ return {
402
+ status: row.status === 'PAUSED' ? 'PAUSED' : 'ACTIVE',
403
+ valueType: row.valueType ?? 'PERCENT',
404
+ value: row.value ?? 0,
405
+ durationType: row.durationType ?? 'ONCE',
406
+ durationValue: row.durationValue ?? null,
407
+ maxRedemptions: row.maxRedemptions,
408
+ validFrom: row.validFrom ? row.validFrom.slice(0, 10) : '',
409
+ validUntil: row.validUntil ? row.validUntil.slice(0, 10) : '',
410
+ appliesToPlans: row.appliesToPlans ? [...row.appliesToPlans] : [],
411
+ appliesToBilling: row.appliesToBilling ?? null,
412
+ firstTimeCustomersOnly: row.firstTimeCustomersOnly ?? false,
413
+ minimumPlanAmountGross: row.minimumPlanAmountGross ?? null,
414
+ allowZeroInvoice: row.allowZeroInvoice ?? false,
415
+ campaignTag: row.campaignTag ?? '',
416
+ revenueDeductionAccount: row.revenueDeductionAccount ?? '',
417
+ description: row.description ?? '',
418
+ };
419
+ }
420
+
421
+ const form = reactive<EditForm>(emptyForm());
422
+ const initial = ref<EditForm>(emptyForm());
423
+ const loading = ref(false);
424
+ const error = ref('');
425
+ const advancedOpen = ref(false);
426
+
427
+ function isPlanSelected(key: string): boolean {
428
+ return form.appliesToPlans.includes(key);
429
+ }
430
+ function togglePlan(key: string): void {
431
+ if (form.appliesToPlans.includes(key)) {
432
+ form.appliesToPlans = form.appliesToPlans.filter((k) => k !== key);
433
+ } else {
434
+ form.appliesToPlans = [...form.appliesToPlans, key];
435
+ }
436
+ }
437
+ function planChipStyle(p: PromoCodePlanOption): Record<string, string> {
438
+ if (!isPlanSelected(p.key) || !p.color) return {};
439
+ return {
440
+ borderColor: p.color,
441
+ background: `${p.color}12`,
442
+ color: p.color,
443
+ };
444
+ }
445
+
446
+ const previewValue = computed(() => {
447
+ if (form.valueType === 'PERCENT') return `−${form.value || 0}%`;
448
+ return `−${form.value || 0} €`;
449
+ });
450
+
451
+ const previewMeta = computed(() => {
452
+ const parts: string[] = [];
453
+ if (form.durationType === 'ONCE') parts.push('einmalig');
454
+ else if (form.durationType === 'MONTHS') parts.push(`${form.durationValue || 0} Monate`);
455
+ else parts.push(`${form.durationValue || 0} Perioden`);
456
+ parts.push(
457
+ form.appliesToPlans.length > 0
458
+ ? form.appliesToPlans.join(', ')
459
+ : props.plans.length > 0
460
+ ? 'alle Pläne'
461
+ : 'kein Plan-Filter',
462
+ );
463
+ if (form.maxRedemptions) parts.push(`max. ${form.maxRedemptions}`);
464
+ return parts.join(' · ');
465
+ });
466
+
467
+ const isValid = computed(() => {
468
+ if (!form.value || form.value <= 0) return false;
469
+ if (form.valueType === 'PERCENT' && form.value > 100) return false;
470
+ if (form.durationType !== 'ONCE' && (!form.durationValue || form.durationValue < 1))
471
+ return false;
472
+ return true;
473
+ });
474
+
475
+ function plansEqual(a: string[], b: string[]): boolean {
476
+ if (a.length !== b.length) return false;
477
+ const sortedA = [...a].sort();
478
+ const sortedB = [...b].sort();
479
+ return sortedA.every((k, i) => k === sortedB[i]);
480
+ }
481
+
482
+ const hasChanges = computed(() => {
483
+ const i = initial.value;
484
+ return (
485
+ form.status !== i.status ||
486
+ form.valueType !== i.valueType ||
487
+ form.value !== i.value ||
488
+ form.durationType !== i.durationType ||
489
+ form.durationValue !== i.durationValue ||
490
+ form.maxRedemptions !== i.maxRedemptions ||
491
+ form.validFrom !== i.validFrom ||
492
+ form.validUntil !== i.validUntil ||
493
+ form.appliesToBilling !== i.appliesToBilling ||
494
+ form.firstTimeCustomersOnly !== i.firstTimeCustomersOnly ||
495
+ form.minimumPlanAmountGross !== i.minimumPlanAmountGross ||
496
+ form.allowZeroInvoice !== i.allowZeroInvoice ||
497
+ !plansEqual(form.appliesToPlans, i.appliesToPlans) ||
498
+ (form.campaignTag ?? '') !== (i.campaignTag ?? '') ||
499
+ (form.revenueDeductionAccount ?? '') !== (i.revenueDeductionAccount ?? '') ||
500
+ (form.description ?? '') !== (i.description ?? '')
501
+ );
502
+ });
503
+
504
+ watch(
505
+ () => [props.modelValue, props.row] as const,
506
+ ([open, row]) => {
507
+ if (!open) return;
508
+ const next = row ? fromRow(row) : emptyForm();
509
+ Object.assign(form, next);
510
+ initial.value = { ...next, appliesToPlans: [...next.appliesToPlans] };
511
+ error.value = '';
512
+ advancedOpen.value = false;
513
+ },
514
+ { immediate: true },
515
+ );
516
+
517
+ async function onSubmit() {
518
+ if (!props.row || !hasChanges.value || !isValid.value) return;
519
+ loading.value = true;
520
+ error.value = '';
521
+ try {
522
+ const i = initial.value;
523
+ const payload: PromoCodeUpdatePayload = {};
524
+ if (form.status !== i.status) payload.status = form.status;
525
+ if (form.valueType !== i.valueType) payload.valueType = form.valueType;
526
+ if (form.value !== i.value) payload.value = form.value;
527
+ if (form.durationType !== i.durationType) payload.durationType = form.durationType;
528
+ if (form.durationValue !== i.durationValue)
529
+ payload.durationValue = form.durationType === 'ONCE' ? null : form.durationValue;
530
+ if (form.maxRedemptions !== i.maxRedemptions)
531
+ payload.maxRedemptions = form.maxRedemptions ?? null;
532
+ if (form.validFrom !== i.validFrom) payload.validFrom = form.validFrom || null;
533
+ if (form.validUntil !== i.validUntil) payload.validUntil = form.validUntil || null;
534
+ if (!plansEqual(form.appliesToPlans, i.appliesToPlans))
535
+ payload.appliesToPlans = [...form.appliesToPlans];
536
+ if (form.appliesToBilling !== i.appliesToBilling)
537
+ payload.appliesToBilling = form.appliesToBilling;
538
+ if (form.firstTimeCustomersOnly !== i.firstTimeCustomersOnly)
539
+ payload.firstTimeCustomersOnly = form.firstTimeCustomersOnly;
540
+ if (form.minimumPlanAmountGross !== i.minimumPlanAmountGross)
541
+ payload.minimumPlanAmountGross = form.minimumPlanAmountGross ?? null;
542
+ if (form.allowZeroInvoice !== i.allowZeroInvoice)
543
+ payload.allowZeroInvoice = form.allowZeroInvoice;
544
+ if ((form.campaignTag ?? '') !== (i.campaignTag ?? ''))
545
+ payload.campaignTag = form.campaignTag || null;
546
+ if ((form.revenueDeductionAccount ?? '') !== (i.revenueDeductionAccount ?? ''))
547
+ payload.revenueDeductionAccount = form.revenueDeductionAccount || null;
548
+ if ((form.description ?? '') !== (i.description ?? ''))
549
+ payload.description = form.description || null;
550
+ await props.submit(props.row.id, payload);
551
+ emit('updated');
552
+ emit('update:modelValue', false);
553
+ } catch (err) {
554
+ error.value =
555
+ (err as { response?: { data?: { message?: string } } }).response?.data?.message ??
556
+ (err as Error).message ??
557
+ 'Speichern fehlgeschlagen';
558
+ } finally {
559
+ loading.value = false;
560
+ }
561
+ }
562
+ </script>
563
+
564
+ <style scoped>
565
+ .pc-dlg {
566
+ min-width: 720px;
567
+ max-width: 96vw;
568
+ }
569
+ .pc-dlg__head {
570
+ display: flex;
571
+ align-items: flex-start;
572
+ gap: 16px;
573
+ padding-bottom: 8px;
574
+ }
575
+ .pc-dlg__close {
576
+ margin-left: auto;
577
+ }
578
+ .pc-dlg__title {
579
+ font-family: var(--sa-font-head, system-ui, sans-serif);
580
+ font-weight: 700;
581
+ font-size: 18px;
582
+ color: var(--sa-heading, #0f172a);
583
+ }
584
+ .pc-dlg__sub {
585
+ font-size: 12.5px;
586
+ color: var(--sa-muted, #64748b);
587
+ margin-top: 2px;
588
+ }
589
+ .pc-dlg__body {
590
+ display: flex;
591
+ flex-direction: column;
592
+ gap: 16px;
593
+ padding-top: 4px;
594
+ }
595
+ .pc-dlg__foot {
596
+ border-top: 1px solid var(--sa-border, #e2e8f0);
597
+ }
598
+
599
+ .pc-section {
600
+ border: 1px solid var(--sa-border, #e2e8f0);
601
+ border-radius: 10px;
602
+ padding: 14px 16px;
603
+ background: #fafbfc;
604
+ }
605
+ .pc-section__title {
606
+ font-family: var(--sa-font-head, system-ui, sans-serif);
607
+ font-weight: 700;
608
+ font-size: 13px;
609
+ color: var(--sa-heading, #0f172a);
610
+ margin-bottom: 12px;
611
+ letter-spacing: -0.005em;
612
+ }
613
+ .pc-section__toggle {
614
+ border: 0;
615
+ background: transparent;
616
+ cursor: pointer;
617
+ font: 600 12.5px var(--sa-font-body, system-ui, sans-serif);
618
+ color: var(--sa-muted-dark, #475569);
619
+ display: inline-flex;
620
+ align-items: center;
621
+ gap: 4px;
622
+ padding: 0;
623
+ }
624
+ .pc-advanced {
625
+ display: flex;
626
+ flex-direction: column;
627
+ gap: 12px;
628
+ margin-top: 12px;
629
+ }
630
+
631
+ .pc-grid {
632
+ display: grid;
633
+ gap: 12px;
634
+ margin-bottom: 12px;
635
+ }
636
+ .pc-grid--2 {
637
+ grid-template-columns: 1fr 1fr;
638
+ }
639
+ @media (max-width: 600px) {
640
+ .pc-grid--2 {
641
+ grid-template-columns: 1fr;
642
+ }
643
+ }
644
+ .pc-grid:last-child {
645
+ margin-bottom: 0;
646
+ }
647
+
648
+ .pc-field {
649
+ display: flex;
650
+ flex-direction: column;
651
+ gap: 4px;
652
+ }
653
+ .pc-field__label {
654
+ font-size: 11.5px;
655
+ font-weight: 600;
656
+ color: var(--sa-muted, #64748b);
657
+ text-transform: uppercase;
658
+ letter-spacing: 0.04em;
659
+ }
660
+ .pc-field__hint {
661
+ font-size: 11.5px;
662
+ color: #94a3b8;
663
+ }
664
+
665
+ .pc-input {
666
+ width: 100%;
667
+ border: 1px solid var(--sa-border, #e2e8f0);
668
+ background: #fff;
669
+ border-radius: 7px;
670
+ padding: 8px 10px;
671
+ font: 13.5px var(--sa-font-body, system-ui, sans-serif);
672
+ color: var(--sa-body, #1e293b);
673
+ outline: 0;
674
+ }
675
+ .pc-input:disabled {
676
+ background: #f1f5f9;
677
+ color: var(--sa-muted, #64748b);
678
+ cursor: not-allowed;
679
+ }
680
+ .pc-input:focus {
681
+ border-color: var(--sa-primary, #3f6bff);
682
+ box-shadow: 0 0 0 3px rgba(63, 107, 255, 0.12);
683
+ }
684
+ .pc-input--code {
685
+ font: 600 14px var(--sa-font-mono, ui-monospace, monospace);
686
+ letter-spacing: 0.04em;
687
+ text-transform: uppercase;
688
+ }
689
+ textarea.pc-input {
690
+ font: 13.5px var(--sa-font-body, system-ui, sans-serif);
691
+ resize: vertical;
692
+ }
693
+
694
+ .pc-status {
695
+ display: flex;
696
+ gap: 6px;
697
+ }
698
+ .pc-status-opt {
699
+ display: inline-flex;
700
+ align-items: center;
701
+ gap: 6px;
702
+ border: 1px solid var(--sa-border, #e2e8f0);
703
+ background: #fff;
704
+ border-radius: 7px;
705
+ padding: 6px 12px;
706
+ font: 500 12.5px var(--sa-font-body, system-ui, sans-serif);
707
+ color: var(--sa-muted-dark, #475569);
708
+ cursor: pointer;
709
+ }
710
+ .pc-status-opt--active {
711
+ border-color: var(--sa-primary, #3f6bff);
712
+ background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
713
+ color: var(--sa-primary, #3f6bff);
714
+ }
715
+
716
+ .pc-type-grid {
717
+ display: grid;
718
+ grid-template-columns: 1fr 1fr;
719
+ gap: 6px;
720
+ }
721
+ .pc-type-opt {
722
+ border: 1px solid var(--sa-border, #e2e8f0);
723
+ background: #fff;
724
+ border-radius: 8px;
725
+ padding: 8px 10px;
726
+ text-align: left;
727
+ cursor: pointer;
728
+ transition:
729
+ border-color 0.1s,
730
+ background 0.1s;
731
+ }
732
+ .pc-type-opt:hover {
733
+ border-color: #cbd5e1;
734
+ }
735
+ .pc-type-opt--active {
736
+ border-color: var(--sa-primary, #3f6bff);
737
+ background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
738
+ }
739
+ .pc-type-opt__label {
740
+ font: 600 12.5px var(--sa-font-body, system-ui, sans-serif);
741
+ color: var(--sa-heading, #0f172a);
742
+ }
743
+ .pc-type-opt__sub {
744
+ font: 11.5px var(--sa-font-mono, ui-monospace, monospace);
745
+ color: var(--sa-muted, #64748b);
746
+ margin-top: 1px;
747
+ }
748
+
749
+ .pc-plan-pick {
750
+ display: flex;
751
+ flex-wrap: wrap;
752
+ gap: 6px;
753
+ }
754
+ .pc-plan-opt {
755
+ display: inline-flex;
756
+ align-items: center;
757
+ gap: 8px;
758
+ background: #fff;
759
+ border: 1px solid var(--sa-border, #e2e8f0);
760
+ border-radius: 999px;
761
+ padding: 5px 12px 5px 8px;
762
+ font: 600 12px var(--sa-font-body, system-ui, sans-serif);
763
+ cursor: pointer;
764
+ color: var(--sa-muted-dark, #475569);
765
+ transition:
766
+ border-color 0.1s,
767
+ background 0.1s;
768
+ }
769
+ .pc-plan-opt:hover {
770
+ border-color: #cbd5e1;
771
+ }
772
+ .pc-plan-opt--on {
773
+ border-color: var(--sa-primary, #3f6bff);
774
+ background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
775
+ color: var(--sa-primary, #3f6bff);
776
+ }
777
+ .pc-plan-opt__mark {
778
+ width: 8px;
779
+ height: 8px;
780
+ border-radius: 50%;
781
+ }
782
+
783
+ .pc-dur {
784
+ display: flex;
785
+ gap: 4px;
786
+ }
787
+ .pc-dur-opt {
788
+ flex: 1;
789
+ border: 1px solid var(--sa-border, #e2e8f0);
790
+ background: #fff;
791
+ border-radius: 7px;
792
+ padding: 6px 10px;
793
+ font: 500 12px var(--sa-font-body, system-ui, sans-serif);
794
+ cursor: pointer;
795
+ color: var(--sa-muted-dark, #475569);
796
+ }
797
+ .pc-dur-opt--active {
798
+ border-color: var(--sa-primary, #3f6bff);
799
+ background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
800
+ color: var(--sa-primary, #3f6bff);
801
+ }
802
+
803
+ .pc-check {
804
+ display: inline-flex;
805
+ align-items: center;
806
+ gap: 8px;
807
+ font: 13px var(--sa-font-body, system-ui, sans-serif);
808
+ color: var(--sa-body, #1e293b);
809
+ cursor: pointer;
810
+ }
811
+
812
+ .pc-preview {
813
+ background: var(--sa-primary-soft, rgba(63, 107, 255, 0.06));
814
+ border: 1px solid var(--sa-primary-border, rgba(63, 107, 255, 0.18));
815
+ border-radius: 10px;
816
+ padding: 12px 14px;
817
+ }
818
+ .pc-preview__eyebrow {
819
+ font-size: 11px;
820
+ font-weight: 700;
821
+ color: var(--sa-primary, #3f6bff);
822
+ text-transform: uppercase;
823
+ letter-spacing: 0.06em;
824
+ margin-bottom: 6px;
825
+ }
826
+ .pc-preview__body {
827
+ display: flex;
828
+ align-items: center;
829
+ gap: 10px;
830
+ flex-wrap: wrap;
831
+ }
832
+ .pc-preview__code {
833
+ background: #fff;
834
+ border: 1px solid var(--sa-border, #e2e8f0);
835
+ border-radius: 6px;
836
+ padding: 3px 8px;
837
+ font: 600 13px var(--sa-font-mono, ui-monospace, monospace);
838
+ letter-spacing: 0.04em;
839
+ color: var(--sa-heading, #0f172a);
840
+ }
841
+ .pc-preview__disc {
842
+ font: 700 13px var(--sa-font-body, system-ui, sans-serif);
843
+ color: var(--sa-positive, #047857);
844
+ }
845
+ .pc-preview__meta {
846
+ font-size: 12px;
847
+ color: var(--sa-muted, #64748b);
848
+ }
849
+
850
+ .pc-error {
851
+ background: #fef2f2;
852
+ border: 1px solid #fecaca;
853
+ color: #b91c1c;
854
+ font-size: 13px;
855
+ margin: 8px 0 0;
856
+ padding: 8px 12px;
857
+ border-radius: 8px;
858
+ }
859
+ </style>