@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,589 @@
1
+ <template>
2
+ <div class="sa-promo-codes">
3
+ <header class="sa-page-head">
4
+ <div>
5
+ <h1 class="sa-page-head__title">Promo-Codes</h1>
6
+ <p v-if="subtitle" class="sa-page-head__sub">{{ subtitle }}</p>
7
+ </div>
8
+ <div class="sa-page-head__actions">
9
+ <slot name="head-actions">
10
+ <q-btn
11
+ v-if="enableCreate"
12
+ unelevated
13
+ color="primary"
14
+ icon="add"
15
+ :label="createLabel"
16
+ @click="showCreate = true"
17
+ />
18
+ </slot>
19
+ </div>
20
+ </header>
21
+
22
+ <div class="sa-stats">
23
+ <button
24
+ v-for="tile in statTiles"
25
+ :key="tile.id"
26
+ class="sa-stat"
27
+ :class="[
28
+ tile.tone ? `sa-stat--${tile.tone}` : null,
29
+ statusFilter === tile.id ? 'sa-stat--active' : null,
30
+ ]"
31
+ @click="onStatusTileClick(tile.id)"
32
+ >
33
+ <span class="sa-stat__num">{{ tile.count }}</span>
34
+ <span class="sa-stat__label">{{ tile.label }}</span>
35
+ <span v-if="tile.hint" class="sa-stat__hint">{{ tile.hint }}</span>
36
+ </button>
37
+ </div>
38
+
39
+ <div class="sa-promo-codes__filter">
40
+ <q-input
41
+ v-model="filter.search"
42
+ outlined
43
+ dense
44
+ label="Suche (Code)"
45
+ clearable
46
+ @update:model-value="reload"
47
+ />
48
+ <q-select
49
+ v-model="filter.status"
50
+ outlined
51
+ dense
52
+ emit-value
53
+ map-options
54
+ clearable
55
+ label="Status"
56
+ :options="statusOptions"
57
+ @update:model-value="reload"
58
+ />
59
+ </div>
60
+
61
+ <div class="sa-promo-codes__card">
62
+ <q-table
63
+ flat
64
+ :rows="filteredRows"
65
+ :columns="effectiveColumns"
66
+ row-key="id"
67
+ :pagination="{ rowsPerPage: 0 }"
68
+ :loading="loading"
69
+ hide-pagination
70
+ >
71
+ <template #body-cell-status="{ row }">
72
+ <q-td>
73
+ <q-badge :color="statusColor(row.status)" :label="row.status" />
74
+ </q-td>
75
+ </template>
76
+ <template #body-cell-actions="{ row }">
77
+ <q-td>
78
+ <slot name="row-actions" :row="row" :reload="reload">
79
+ <q-btn
80
+ v-for="action in visibleActions(row)"
81
+ :key="action.id"
82
+ flat
83
+ dense
84
+ :icon="action.icon"
85
+ :title="action.label"
86
+ :color="action.color ?? 'grey-7'"
87
+ @click="action.handler(row)"
88
+ />
89
+ </slot>
90
+ </q-td>
91
+ </template>
92
+ </q-table>
93
+ </div>
94
+
95
+ <PromoCodeCreateDialog
96
+ v-if="enableCreate && submitCreate"
97
+ v-model="showCreate"
98
+ :submit="submitCreate"
99
+ :plans="planOptions"
100
+ @created="onCreated"
101
+ />
102
+
103
+ <PromoCodeEditDialog
104
+ v-if="enableEdit && submitEdit"
105
+ v-model="showEdit"
106
+ :row="editingRow"
107
+ :plans="planOptions"
108
+ :submit="submitEdit"
109
+ @updated="onUpdated"
110
+ />
111
+ </div>
112
+ </template>
113
+
114
+ <script lang="ts">
115
+ // Module-level exports — Vue 3 RFC #227 does NOT allow `export function` in
116
+ // `<script setup>` (the whole setup section is wrapped in setup()).
117
+ // Pure helpers + constants therefore live here in the regular `<script>` block.
118
+
119
+ import type { PromoCodePlanOption } from '../components/dialogs/types.js';
120
+
121
+ const PLAN_COLOR_PALETTE = ['#0ea5e9', '#10b981', '#f59e0b', '#7c3aed', '#dc2626', '#64748b'];
122
+
123
+ /**
124
+ * Heuristic from a consumer wrapper: assigns each plan a stable color so plan
125
+ * chips stay visually distinguishable. Consumers can use the util standalone.
126
+ */
127
+ export function computePlanColors(
128
+ plans: ReadonlyArray<{ key: string; label?: string }>,
129
+ ): PromoCodePlanOption[] {
130
+ return plans.map((p, idx) => ({
131
+ key: p.key,
132
+ label: p.label ?? p.key,
133
+ color: PLAN_COLOR_PALETTE[idx % PLAN_COLOR_PALETTE.length],
134
+ }));
135
+ }
136
+ </script>
137
+
138
+ <script setup lang="ts">
139
+ import { computed, onMounted, reactive, ref } from 'vue';
140
+ import { useQuasar } from 'quasar';
141
+ import PromoCodeCreateDialog from '../components/dialogs/PromoCodeCreateDialog.vue';
142
+ import PromoCodeEditDialog, {
143
+ type PromoCodeEditRow,
144
+ } from '../components/dialogs/PromoCodeEditDialog.vue';
145
+ import type {
146
+ PromoCodeCreatePayload,
147
+ PromoCodeDurationType,
148
+ PromoCodeUpdatePayload,
149
+ PromoCodeValueType,
150
+ } from '../components/dialogs/types.js';
151
+
152
+ // Platform standard page: promo codes. Data-agnostic.
153
+ //
154
+ // Optional baked-in flows: enableCreate/Edit/StatusToggle/Delete + submit*
155
+ // callbacks. Default actions are APPENDED to the consumer actions.
156
+
157
+ export interface PromoRow {
158
+ id: string;
159
+ code: string;
160
+ valueType: 'PERCENT' | 'ABSOLUTE' | string;
161
+ value: string | number;
162
+ durationType?: PromoCodeDurationType | string;
163
+ durationValue?: number | string | null;
164
+ validFrom?: string | Date | null;
165
+ status: 'ACTIVE' | 'PAUSED' | 'EXHAUSTED' | 'EXPIRED' | string;
166
+ redemptionsCount: number;
167
+ maxRedemptions: number | string | null;
168
+ validUntil: string | Date | null;
169
+ appliesToPlans?: string[];
170
+ appliesToBilling?: 'MONTHLY' | 'YEARLY' | string | null;
171
+ firstTimeCustomersOnly?: boolean;
172
+ minimumPlanAmountGross?: number | string | null;
173
+ allowZeroInvoice?: boolean;
174
+ campaignTag: string | null;
175
+ revenueDeductionAccount?: string | null;
176
+ description?: string | null;
177
+ [extra: string]: unknown;
178
+ }
179
+
180
+ export interface PromoListFilter {
181
+ search?: string;
182
+ status?: string | null;
183
+ }
184
+
185
+ export interface PromoRowAction {
186
+ id: string;
187
+ label: string;
188
+ icon: string;
189
+ color?: string;
190
+ condition?: (row: PromoRow) => boolean;
191
+ handler: (row: PromoRow) => void;
192
+ }
193
+
194
+ const props = withDefaults(
195
+ defineProps<{
196
+ loadPromos: (filter: PromoListFilter) => Promise<PromoRow[]>;
197
+ subtitle?: string;
198
+ statusOptions?: readonly string[];
199
+ actions?: readonly PromoRowAction[];
200
+ enableCreate?: boolean;
201
+ enableEdit?: boolean;
202
+ enableStatusToggle?: boolean;
203
+ enableDelete?: boolean;
204
+ submitCreate?: (payload: PromoCodeCreatePayload) => Promise<void>;
205
+ submitEdit?: (id: string, payload: PromoCodeUpdatePayload) => Promise<void>;
206
+ submitDelete?: (id: string) => Promise<void>;
207
+ planOptions?: readonly PromoCodePlanOption[];
208
+ createLabel?: string;
209
+ }>(),
210
+ {
211
+ statusOptions: () => ['ACTIVE', 'PAUSED', 'EXHAUSTED', 'EXPIRED'],
212
+ createLabel: 'Promo-Code anlegen',
213
+ planOptions: () => [],
214
+ },
215
+ );
216
+
217
+ const q = useQuasar();
218
+ const rows = ref<PromoRow[]>([]);
219
+ const loading = ref(false);
220
+ const filter = reactive({ search: '', status: null as string | null });
221
+ const showCreate = ref(false);
222
+ const showEdit = ref(false);
223
+ const editingRow = ref<PromoCodeEditRow | null>(null);
224
+
225
+ // Stat-pill filter (analogous to the plan simulation promo-codes.jsx):
226
+ // all | active | scheduled | paused | expired.
227
+ // 'scheduled' = ACTIVE + validFrom in the future; without a validFrom field in
228
+ // PromoRow we use the PENDING status as fallback. Consumers may pass the
229
+ // `scheduledAt` or `validFrom` field on-the-fly in the row.
230
+ type StatusFilter = 'all' | 'active' | 'scheduled' | 'paused' | 'expired';
231
+ const statusFilter = ref<StatusFilter>('all');
232
+
233
+ function classifyRow(row: PromoRow): Exclude<StatusFilter, 'all'> | null {
234
+ const status = String(row.status ?? '').toUpperCase();
235
+ if (status === 'PAUSED') return 'paused';
236
+ if (status === 'EXPIRED' || status === 'EXHAUSTED') return 'expired';
237
+ const validFrom =
238
+ typeof row.validFrom === 'string' || row.validFrom instanceof Date ? row.validFrom : null;
239
+ if (validFrom) {
240
+ const t = new Date(validFrom as string | Date).getTime();
241
+ if (!Number.isNaN(t) && t > Date.now()) return 'scheduled';
242
+ }
243
+ if (status === 'SCHEDULED' || status === 'PENDING') return 'scheduled';
244
+ if (status === 'ACTIVE') return 'active';
245
+ return null;
246
+ }
247
+
248
+ const filteredRows = computed(() => {
249
+ if (statusFilter.value === 'all') return rows.value;
250
+ return rows.value.filter((r) => classifyRow(r) === statusFilter.value);
251
+ });
252
+
253
+ const statTiles = computed<
254
+ Array<{
255
+ id: StatusFilter;
256
+ label: string;
257
+ count: number;
258
+ tone?: 'positive' | 'info' | 'warn' | 'muted';
259
+ hint?: string;
260
+ }>
261
+ >(() => {
262
+ const counts = { active: 0, scheduled: 0, paused: 0, expired: 0 };
263
+ for (const r of rows.value) {
264
+ const c = classifyRow(r);
265
+ if (c) counts[c]++;
266
+ }
267
+ return [
268
+ { id: 'all', label: 'Alle', count: rows.value.length },
269
+ {
270
+ id: 'active',
271
+ label: 'Aktiv',
272
+ count: counts.active,
273
+ tone: 'positive',
274
+ hint: 'einlösbar',
275
+ },
276
+ {
277
+ id: 'scheduled',
278
+ label: 'Geplant',
279
+ count: counts.scheduled,
280
+ tone: 'info',
281
+ hint: 'startet künftig',
282
+ },
283
+ { id: 'paused', label: 'Pausiert', count: counts.paused, tone: 'warn' },
284
+ { id: 'expired', label: 'Abgelaufen', count: counts.expired, tone: 'muted' },
285
+ ];
286
+ });
287
+
288
+ // Tile click: set the filter AND, if a server status match exists, pass it
289
+ // through to the search so `loadPromos` can pre-filter server-side if needed.
290
+ function onStatusTileClick(id: StatusFilter): void {
291
+ statusFilter.value = id;
292
+ const serverStatus =
293
+ id === 'active'
294
+ ? 'ACTIVE'
295
+ : id === 'paused'
296
+ ? 'PAUSED'
297
+ : id === 'expired'
298
+ ? 'EXPIRED'
299
+ : null;
300
+ if (serverStatus !== filter.status) {
301
+ filter.status = serverStatus;
302
+ reload();
303
+ }
304
+ }
305
+
306
+ const baseColumns = [
307
+ {
308
+ name: 'code',
309
+ label: 'Code',
310
+ field: 'code',
311
+ align: 'left' as const,
312
+ sortable: true,
313
+ },
314
+ { name: 'valueType', label: 'Typ', field: 'valueType', align: 'left' as const },
315
+ { name: 'value', label: 'Wert', field: 'value', align: 'right' as const },
316
+ { name: 'status', label: 'Status', field: 'status', align: 'left' as const },
317
+ {
318
+ name: 'redemptions',
319
+ label: 'Einlösungen',
320
+ field: (r: PromoRow) => `${r.redemptionsCount} / ${r.maxRedemptions ?? '∞'}`,
321
+ align: 'right' as const,
322
+ },
323
+ {
324
+ name: 'campaign',
325
+ label: 'Kampagne',
326
+ field: (r: PromoRow) => r.campaignTag ?? '—',
327
+ align: 'left' as const,
328
+ },
329
+ {
330
+ name: 'validUntil',
331
+ label: 'Gültig bis',
332
+ field: (r: PromoRow) => formatDate(r.validUntil) ?? '—',
333
+ align: 'left' as const,
334
+ },
335
+ ];
336
+
337
+ // Built-in default actions — APPENDED to the consumer actions, not replacing them.
338
+ const bakedActions = computed<PromoRowAction[]>(() => {
339
+ const out: PromoRowAction[] = [];
340
+ if (props.enableEdit && props.submitEdit) {
341
+ out.push({
342
+ id: 'edit',
343
+ label: 'Bearbeiten',
344
+ icon: 'edit',
345
+ color: 'primary',
346
+ handler: (row) => openEdit(row),
347
+ });
348
+ }
349
+ if (props.enableStatusToggle && props.submitEdit) {
350
+ out.push({
351
+ id: 'pause',
352
+ label: 'Pausieren',
353
+ icon: 'pause',
354
+ color: 'warning',
355
+ condition: (row) => row.status === 'ACTIVE',
356
+ handler: (row) => onPatch(row, { status: 'PAUSED' }),
357
+ });
358
+ out.push({
359
+ id: 'activate',
360
+ label: 'Aktivieren',
361
+ icon: 'play_arrow',
362
+ color: 'primary',
363
+ condition: (row) => row.status === 'PAUSED',
364
+ handler: (row) => onPatch(row, { status: 'ACTIVE' }),
365
+ });
366
+ }
367
+ if (props.enableDelete && props.submitDelete) {
368
+ out.push({
369
+ id: 'delete',
370
+ label: 'Löschen',
371
+ icon: 'delete',
372
+ color: 'negative',
373
+ handler: (row) => onDeleteClick(row),
374
+ });
375
+ }
376
+ return out;
377
+ });
378
+
379
+ const mergedActions = computed<readonly PromoRowAction[]>(() => [
380
+ ...(props.actions ?? []),
381
+ ...bakedActions.value,
382
+ ]);
383
+
384
+ const effectiveColumns = computed(() => {
385
+ const cols = [...baseColumns];
386
+ if (mergedActions.value.length > 0) {
387
+ cols.push({
388
+ name: 'actions',
389
+ label: '',
390
+ field: 'id' as never,
391
+ align: 'right' as 'left',
392
+ });
393
+ }
394
+ return cols;
395
+ });
396
+
397
+ function visibleActions(row: PromoRow): PromoRowAction[] {
398
+ return mergedActions.value.filter((a) => !a.condition || a.condition(row));
399
+ }
400
+
401
+ function statusColor(status: string): string {
402
+ if (status === 'ACTIVE') return 'positive';
403
+ if (status === 'PAUSED') return 'amber-7';
404
+ if (status === 'EXPIRED') return 'grey';
405
+ return 'negative';
406
+ }
407
+
408
+ async function reload() {
409
+ loading.value = true;
410
+ try {
411
+ rows.value = await props.loadPromos({
412
+ search: filter.search || undefined,
413
+ status: filter.status || undefined,
414
+ });
415
+ } catch (err) {
416
+ rows.value = [];
417
+ console.warn('[PromoCodesPage] loadPromos failed:', err);
418
+ } finally {
419
+ loading.value = false;
420
+ }
421
+ }
422
+
423
+ defineExpose({ reload });
424
+
425
+ onMounted(reload);
426
+
427
+ function openEdit(row: PromoRow): void {
428
+ editingRow.value = {
429
+ id: row.id,
430
+ code: row.code,
431
+ status: row.status,
432
+ valueType: normalizeValueType(row.valueType),
433
+ value: normalizeNumber(row.value),
434
+ durationType: normalizeDurationType(row.durationType),
435
+ durationValue: normalizeNullableNumber(row.durationValue),
436
+ validFrom: normalizeDateString(row.validFrom),
437
+ validUntil: normalizeDateString(row.validUntil),
438
+ maxRedemptions: normalizeNullableNumber(row.maxRedemptions),
439
+ redemptionsCount: normalizeNumber(row.redemptionsCount) ?? 0,
440
+ appliesToPlans: normalizeStringArray(row.appliesToPlans),
441
+ appliesToBilling: normalizeBilling(row.appliesToBilling),
442
+ firstTimeCustomersOnly:
443
+ typeof row.firstTimeCustomersOnly === 'boolean' ? row.firstTimeCustomersOnly : false,
444
+ minimumPlanAmountGross: normalizeNullableNumber(row.minimumPlanAmountGross),
445
+ allowZeroInvoice: typeof row.allowZeroInvoice === 'boolean' ? row.allowZeroInvoice : false,
446
+ campaignTag: typeof row.campaignTag === 'string' ? row.campaignTag : null,
447
+ revenueDeductionAccount:
448
+ typeof row.revenueDeductionAccount === 'string' ? row.revenueDeductionAccount : null,
449
+ description: typeof row.description === 'string' ? row.description : null,
450
+ };
451
+ showEdit.value = true;
452
+ }
453
+
454
+ function normalizeValueType(value: unknown): PromoCodeValueType | undefined {
455
+ return value === 'PERCENT' || value === 'ABSOLUTE' ? value : undefined;
456
+ }
457
+
458
+ function normalizeDurationType(value: unknown): PromoCodeDurationType | undefined {
459
+ return value === 'ONCE' || value === 'MONTHS' || value === 'BILLING_CYCLES' ? value : undefined;
460
+ }
461
+
462
+ function normalizeBilling(value: unknown): 'MONTHLY' | 'YEARLY' | null {
463
+ return value === 'MONTHLY' || value === 'YEARLY' ? value : null;
464
+ }
465
+
466
+ function normalizeNumber(value: unknown): number | undefined {
467
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
468
+ if (typeof value === 'string' && value.trim()) {
469
+ const parsed = Number(value);
470
+ if (Number.isFinite(parsed)) return parsed;
471
+ }
472
+ return undefined;
473
+ }
474
+
475
+ function normalizeNullableNumber(value: unknown): number | null {
476
+ return normalizeNumber(value) ?? null;
477
+ }
478
+
479
+ function normalizeDateString(value: unknown): string | null {
480
+ if (typeof value === 'string') return value;
481
+ if (value instanceof Date) return value.toISOString();
482
+ return null;
483
+ }
484
+
485
+ function normalizeStringArray(value: unknown): string[] {
486
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [];
487
+ }
488
+
489
+ async function onPatch(row: PromoRow, data: PromoCodeUpdatePayload): Promise<void> {
490
+ if (!props.submitEdit) return;
491
+ try {
492
+ await props.submitEdit(row.id, data);
493
+ q.notify({
494
+ type: 'positive',
495
+ message: `${row.code} → ${data.status}`,
496
+ position: 'top',
497
+ });
498
+ await reload();
499
+ } catch (err) {
500
+ q.notify({ type: 'negative', message: errMsg(err), position: 'top' });
501
+ }
502
+ }
503
+
504
+ function onDeleteClick(row: PromoRow): void {
505
+ if (!props.submitDelete) return;
506
+ const submit = props.submitDelete;
507
+ q.dialog({
508
+ title: `Promo-Code "${row.code}" löschen`,
509
+ message: 'Soft-Delete via Status=EXPIRED. Bestehende Redemptions bleiben.',
510
+ cancel: 'Abbrechen',
511
+ ok: { label: 'Löschen', color: 'negative' },
512
+ }).onOk(async () => {
513
+ try {
514
+ await submit(row.id);
515
+ q.notify({
516
+ type: 'positive',
517
+ message: `${row.code} gelöscht.`,
518
+ position: 'top',
519
+ });
520
+ await reload();
521
+ } catch (err) {
522
+ q.notify({ type: 'negative', message: errMsg(err), position: 'top' });
523
+ }
524
+ });
525
+ }
526
+
527
+ function onCreated(): void {
528
+ q.notify({
529
+ type: 'positive',
530
+ message: 'Promo-Code angelegt.',
531
+ position: 'top',
532
+ });
533
+ void reload();
534
+ }
535
+
536
+ function onUpdated(): void {
537
+ q.notify({
538
+ type: 'positive',
539
+ message: 'Promo-Code aktualisiert.',
540
+ position: 'top',
541
+ });
542
+ void reload();
543
+ }
544
+
545
+ function errMsg(err: unknown): string {
546
+ return (
547
+ (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
548
+ (err as Error)?.message ??
549
+ 'Aktion fehlgeschlagen'
550
+ );
551
+ }
552
+
553
+ function formatDate(iso: string | Date | null | undefined): string | null {
554
+ if (!iso) return null;
555
+ try {
556
+ return new Date(iso).toLocaleDateString('de-DE', {
557
+ day: '2-digit',
558
+ month: 'short',
559
+ year: 'numeric',
560
+ });
561
+ } catch {
562
+ return String(iso);
563
+ }
564
+ }
565
+ </script>
566
+
567
+ <style scoped>
568
+ .sa-promo-codes {
569
+ min-height: calc(100vh - 56px);
570
+ background: var(--sa-bg-app, #f1f5f9);
571
+ padding: 20px 28px 28px;
572
+ }
573
+ .sa-promo-codes__filter {
574
+ display: flex;
575
+ gap: 10px;
576
+ margin-bottom: 12px;
577
+ flex-wrap: wrap;
578
+ }
579
+ .sa-promo-codes__filter > * {
580
+ flex: 1;
581
+ min-width: 200px;
582
+ }
583
+ .sa-promo-codes__card {
584
+ background: #fff;
585
+ border: 1px solid var(--sa-border, #e2e8f0);
586
+ border-radius: 12px;
587
+ overflow: hidden;
588
+ }
589
+ </style>