@open-mercato/core 0.6.3-develop.3651.1.d3a548a5f1 → 0.6.3-develop.3660.1.53b73d5aa4

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 (160) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/customer_settings/index.js +2 -0
  3. package/dist/generated/entities/customer_settings/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/customers/api/deals/aggregate/route.js +332 -0
  7. package/dist/modules/customers/api/deals/aggregate/route.js.map +7 -0
  8. package/dist/modules/customers/api/deals/bulk-update-owner/route.js +188 -0
  9. package/dist/modules/customers/api/deals/bulk-update-owner/route.js.map +7 -0
  10. package/dist/modules/customers/api/deals/bulk-update-stage/route.js +188 -0
  11. package/dist/modules/customers/api/deals/bulk-update-stage/route.js.map +7 -0
  12. package/dist/modules/customers/api/deals/route.js +255 -122
  13. package/dist/modules/customers/api/deals/route.js.map +2 -2
  14. package/dist/modules/customers/api/settings/stuck-threshold/route.js +169 -0
  15. package/dist/modules/customers/api/settings/stuck-threshold/route.js.map +7 -0
  16. package/dist/modules/customers/backend/config/customers/deals/page.js +181 -0
  17. package/dist/modules/customers/backend/config/customers/deals/page.js.map +7 -0
  18. package/dist/modules/customers/backend/config/customers/deals/page.meta.js +38 -0
  19. package/dist/modules/customers/backend/config/customers/deals/page.meta.js.map +7 -0
  20. package/dist/modules/customers/backend/customers/deals/create/page.js +17 -6
  21. package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
  22. package/dist/modules/customers/backend/customers/deals/page.js +2 -0
  23. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  24. package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js +217 -0
  25. package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js.map +7 -0
  26. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js +335 -0
  27. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js.map +7 -0
  28. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js +29 -0
  29. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js.map +7 -0
  30. package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js +160 -0
  31. package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js.map +7 -0
  32. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js +157 -0
  33. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js.map +7 -0
  34. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js +114 -0
  35. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js.map +7 -0
  36. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js +36 -0
  37. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js.map +7 -0
  38. package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js +199 -0
  39. package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js.map +7 -0
  40. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js +71 -0
  41. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js.map +7 -0
  42. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js +233 -0
  43. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js.map +7 -0
  44. package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js +126 -0
  45. package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js.map +7 -0
  46. package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js +31 -0
  47. package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js.map +7 -0
  48. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js +389 -0
  49. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js.map +7 -0
  50. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js +181 -0
  51. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js.map +7 -0
  52. package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js +199 -0
  53. package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js.map +7 -0
  54. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +44 -0
  55. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +7 -0
  56. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js +70 -0
  57. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js.map +7 -0
  58. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +278 -0
  59. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +7 -0
  60. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js +135 -0
  61. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js.map +7 -0
  62. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js +83 -0
  63. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js.map +7 -0
  64. package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js +155 -0
  65. package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js.map +7 -0
  66. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +318 -0
  67. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +7 -0
  68. package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js +138 -0
  69. package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js.map +7 -0
  70. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +126 -0
  71. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +7 -0
  72. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +71 -0
  73. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +7 -0
  74. package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js +7 -0
  75. package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js.map +7 -0
  76. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +2330 -415
  77. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +3 -3
  78. package/dist/modules/customers/commands/deals.js +6 -0
  79. package/dist/modules/customers/commands/deals.js.map +2 -2
  80. package/dist/modules/customers/commands/pipeline-stages.js +48 -13
  81. package/dist/modules/customers/commands/pipeline-stages.js.map +3 -3
  82. package/dist/modules/customers/commands/settings.js +45 -5
  83. package/dist/modules/customers/commands/settings.js.map +2 -2
  84. package/dist/modules/customers/data/enrichers.js +130 -1
  85. package/dist/modules/customers/data/enrichers.js.map +2 -2
  86. package/dist/modules/customers/data/entities.js +4 -0
  87. package/dist/modules/customers/data/entities.js.map +2 -2
  88. package/dist/modules/customers/data/validators.js +29 -2
  89. package/dist/modules/customers/data/validators.js.map +2 -2
  90. package/dist/modules/customers/lib/bulkDeals.js +150 -0
  91. package/dist/modules/customers/lib/bulkDeals.js.map +7 -0
  92. package/dist/modules/customers/lib/kysely.js +12 -0
  93. package/dist/modules/customers/lib/kysely.js.map +7 -0
  94. package/dist/modules/customers/lib/stuckDeals.js +32 -0
  95. package/dist/modules/customers/lib/stuckDeals.js.map +7 -0
  96. package/dist/modules/customers/migrations/Migration20260513203311_customers.js +13 -0
  97. package/dist/modules/customers/migrations/Migration20260513203311_customers.js.map +7 -0
  98. package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js +52 -0
  99. package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +7 -0
  100. package/dist/modules/customers/workers/deals-bulk-update-owner.js +41 -0
  101. package/dist/modules/customers/workers/deals-bulk-update-owner.js.map +7 -0
  102. package/dist/modules/customers/workers/deals-bulk-update-stage.js +41 -0
  103. package/dist/modules/customers/workers/deals-bulk-update-stage.js.map +7 -0
  104. package/generated/entities/customer_settings/index.ts +1 -0
  105. package/generated/entity-fields-registry.ts +1 -0
  106. package/package.json +7 -7
  107. package/src/modules/customers/api/deals/aggregate/route.ts +438 -0
  108. package/src/modules/customers/api/deals/bulk-update-owner/route.ts +216 -0
  109. package/src/modules/customers/api/deals/bulk-update-stage/route.ts +228 -0
  110. package/src/modules/customers/api/deals/route.ts +330 -135
  111. package/src/modules/customers/api/settings/stuck-threshold/route.ts +195 -0
  112. package/src/modules/customers/backend/config/customers/deals/page.meta.ts +36 -0
  113. package/src/modules/customers/backend/config/customers/deals/page.tsx +205 -0
  114. package/src/modules/customers/backend/customers/deals/create/page.tsx +25 -6
  115. package/src/modules/customers/backend/customers/deals/page.tsx +2 -0
  116. package/src/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.tsx +277 -0
  117. package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.tsx +425 -0
  118. package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.tsx +31 -0
  119. package/src/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.tsx +171 -0
  120. package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.tsx +196 -0
  121. package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.tsx +138 -0
  122. package/src/modules/customers/backend/customers/deals/pipeline/components/ChipButton.tsx +47 -0
  123. package/src/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.tsx +232 -0
  124. package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.tsx +110 -0
  125. package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.tsx +310 -0
  126. package/src/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.tsx +169 -0
  127. package/src/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.tsx +54 -0
  128. package/src/modules/customers/backend/customers/deals/pipeline/components/DealCard.tsx +562 -0
  129. package/src/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.tsx +214 -0
  130. package/src/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.tsx +257 -0
  131. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +62 -0
  132. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.tsx +101 -0
  133. package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +378 -0
  134. package/src/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.tsx +192 -0
  135. package/src/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.tsx +107 -0
  136. package/src/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.tsx +196 -0
  137. package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +408 -0
  138. package/src/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.tsx +164 -0
  139. package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +171 -0
  140. package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +84 -0
  141. package/src/modules/customers/backend/customers/deals/pipeline/components/constants.ts +16 -0
  142. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2841 -550
  143. package/src/modules/customers/commands/deals.ts +19 -0
  144. package/src/modules/customers/commands/pipeline-stages.ts +68 -13
  145. package/src/modules/customers/commands/settings.ts +64 -5
  146. package/src/modules/customers/data/enrichers.ts +209 -2
  147. package/src/modules/customers/data/entities.ts +4 -1
  148. package/src/modules/customers/data/validators.ts +36 -2
  149. package/src/modules/customers/i18n/de.json +244 -0
  150. package/src/modules/customers/i18n/en.json +244 -0
  151. package/src/modules/customers/i18n/es.json +244 -0
  152. package/src/modules/customers/i18n/pl.json +244 -0
  153. package/src/modules/customers/lib/bulkDeals.ts +231 -0
  154. package/src/modules/customers/lib/kysely.ts +60 -0
  155. package/src/modules/customers/lib/stuckDeals.ts +88 -0
  156. package/src/modules/customers/migrations/.snapshot-open-mercato.json +16 -0
  157. package/src/modules/customers/migrations/Migration20260513203311_customers.ts +13 -0
  158. package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +71 -0
  159. package/src/modules/customers/workers/deals-bulk-update-owner.ts +46 -0
  160. package/src/modules/customers/workers/deals-bulk-update-stage.ts +46 -0
@@ -0,0 +1,195 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
6
+ import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
7
+ import type { EntityManager } from '@mikro-orm/postgresql'
8
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
9
+ import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
+ import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
11
+ import {
12
+ runCrudMutationGuardAfterSuccess,
13
+ validateCrudMutationGuard,
14
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
15
+ import {
16
+ customerStuckThresholdUpsertSchema,
17
+ type CustomerStuckThresholdUpsertInput,
18
+ } from '../../../data/validators'
19
+ import { loadCustomerSettings } from '../../../commands/settings'
20
+ import { withScopedPayload } from '../../utils'
21
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
22
+
23
+ const DEFAULT_THRESHOLD = 14
24
+
25
+ export const metadata = {
26
+ GET: { requireAuth: true, requireFeatures: ['customers.deals.manage'] },
27
+ PUT: { requireAuth: true, requireFeatures: ['customers.deals.manage'] },
28
+ }
29
+
30
+ type SettingsRouteContext = {
31
+ ctx: CommandRuntimeContext
32
+ tenantId: string
33
+ organizationId: string
34
+ translate: (key: string, fallback?: string) => string
35
+ em: EntityManager
36
+ }
37
+
38
+ async function resolveSettingsContext(req: Request): Promise<SettingsRouteContext> {
39
+ const container = await createRequestContainer()
40
+ const auth = await getAuthFromRequest(req)
41
+ const { translate } = await resolveTranslations()
42
+ if (!auth || !auth.tenantId) {
43
+ throw new CrudHttpError(401, {
44
+ error: translate('customers.errors.unauthorized', 'Unauthorized'),
45
+ })
46
+ }
47
+
48
+ const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
49
+ const organizationId = scope?.selectedId ?? auth.orgId ?? null
50
+ if (!organizationId) {
51
+ throw new CrudHttpError(400, {
52
+ error: translate('customers.errors.organization_required', 'Organization context is required'),
53
+ })
54
+ }
55
+
56
+ const ctx: CommandRuntimeContext = {
57
+ container,
58
+ auth,
59
+ organizationScope: scope,
60
+ selectedOrganizationId: organizationId,
61
+ organizationIds: scope?.filterIds ?? (auth.orgId ? [auth.orgId] : null),
62
+ request: req,
63
+ }
64
+
65
+ const em = container.resolve('em') as EntityManager
66
+ return {
67
+ ctx,
68
+ tenantId: auth.tenantId,
69
+ organizationId,
70
+ translate,
71
+ em,
72
+ }
73
+ }
74
+
75
+ export async function GET(req: Request) {
76
+ try {
77
+ const { em, tenantId, organizationId } = await resolveSettingsContext(req)
78
+ const record = await loadCustomerSettings(em, { tenantId, organizationId })
79
+ return NextResponse.json({
80
+ stuckThresholdDays: record?.stuckThresholdDays ?? DEFAULT_THRESHOLD,
81
+ })
82
+ } catch (err) {
83
+ if (isCrudHttpError(err)) {
84
+ return NextResponse.json(err.body, { status: err.status })
85
+ }
86
+ const { translate } = await resolveTranslations()
87
+ console.error('customers.settings.stuck-threshold.get failed', err)
88
+ return NextResponse.json(
89
+ { error: translate('customers.errors.lookup_failed', 'Failed to load settings') },
90
+ { status: 400 },
91
+ )
92
+ }
93
+ }
94
+
95
+ export async function PUT(req: Request) {
96
+ try {
97
+ const { ctx, tenantId, organizationId, translate } = await resolveSettingsContext(req)
98
+ const payload = await readJsonSafe(req, {})
99
+ const scoped = withScopedPayload(payload, ctx, translate)
100
+ const input = customerStuckThresholdUpsertSchema.parse(scoped)
101
+
102
+ // Mutation-guard contract for custom write routes. The resource is the customer
103
+ // settings row scoped to the organization; we use the organizationId as the
104
+ // resourceId because the (tenant, organization) pair uniquely identifies it.
105
+ const guardResult = await validateCrudMutationGuard(ctx.container, {
106
+ tenantId,
107
+ organizationId,
108
+ // `resolveSettingsContext` throws 401 when auth is missing, so by the time we reach
109
+ // here `ctx.auth.sub` is guaranteed to be a string per `AuthContext`.
110
+ userId: ctx.auth!.sub,
111
+ resourceKind: 'customers.settings',
112
+ resourceId: organizationId,
113
+ operation: 'update',
114
+ requestMethod: req.method,
115
+ requestHeaders: req.headers,
116
+ mutationPayload: input,
117
+ })
118
+ if (guardResult && !guardResult.ok) {
119
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
120
+ }
121
+
122
+ const commandBus = ctx.container.resolve('commandBus') as CommandBus
123
+ const { result } = await commandBus.execute<
124
+ CustomerStuckThresholdUpsertInput,
125
+ { settingsId: string; stuckThresholdDays: number }
126
+ >('customers.settings.save_stuck_threshold', { input, ctx })
127
+
128
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
129
+ await runCrudMutationGuardAfterSuccess(ctx.container, {
130
+ tenantId,
131
+ organizationId,
132
+ // `resolveSettingsContext` throws 401 when auth is missing, so by the time we reach
133
+ // here `ctx.auth.sub` is guaranteed to be a string per `AuthContext`.
134
+ userId: ctx.auth!.sub,
135
+ resourceKind: 'customers.settings',
136
+ resourceId: organizationId,
137
+ operation: 'update',
138
+ requestMethod: req.method,
139
+ requestHeaders: req.headers,
140
+ metadata: guardResult.metadata ?? null,
141
+ })
142
+ }
143
+
144
+ return NextResponse.json({
145
+ stuckThresholdDays: result?.stuckThresholdDays ?? input.stuckThresholdDays,
146
+ })
147
+ } catch (err) {
148
+ if (isCrudHttpError(err)) {
149
+ return NextResponse.json(err.body, { status: err.status })
150
+ }
151
+ const { translate } = await resolveTranslations()
152
+ console.error('customers.settings.stuck-threshold.put failed', err)
153
+ return NextResponse.json(
154
+ { error: translate('customers.errors.save_failed', 'Failed to save settings') },
155
+ { status: 400 },
156
+ )
157
+ }
158
+ }
159
+
160
+ const stuckThresholdResponseSchema = z.object({
161
+ stuckThresholdDays: z.number(),
162
+ })
163
+
164
+ const stuckThresholdErrorSchema = z.object({
165
+ error: z.string(),
166
+ })
167
+
168
+ export const openApi: OpenApiRouteDoc = {
169
+ tag: 'Customers',
170
+ summary: 'Customer deals stuck-threshold setting',
171
+ methods: {
172
+ GET: {
173
+ summary: 'Retrieve stuck-threshold days',
174
+ description: 'Returns the current stuck-deal threshold (in days) for the selected organization.',
175
+ responses: [
176
+ { status: 200, description: 'Current threshold', schema: stuckThresholdResponseSchema },
177
+ { status: 401, description: 'Unauthorized', schema: stuckThresholdErrorSchema },
178
+ { status: 400, description: 'Organization context missing', schema: stuckThresholdErrorSchema },
179
+ ],
180
+ },
181
+ PUT: {
182
+ summary: 'Update stuck-threshold days',
183
+ description: 'Updates the stuck-deal threshold for the selected organization.',
184
+ requestBody: {
185
+ contentType: 'application/json',
186
+ schema: customerStuckThresholdUpsertSchema,
187
+ },
188
+ responses: [
189
+ { status: 200, description: 'Updated threshold', schema: stuckThresholdResponseSchema },
190
+ { status: 401, description: 'Unauthorized', schema: stuckThresholdErrorSchema },
191
+ { status: 400, description: 'Invalid payload', schema: stuckThresholdErrorSchema },
192
+ ],
193
+ },
194
+ },
195
+ }
@@ -0,0 +1,36 @@
1
+ import React from 'react'
2
+
3
+ const settingsIcon = React.createElement(
4
+ 'svg',
5
+ {
6
+ width: 16,
7
+ height: 16,
8
+ viewBox: '0 0 24 24',
9
+ fill: 'none',
10
+ stroke: 'currentColor',
11
+ strokeWidth: 2,
12
+ strokeLinecap: 'round',
13
+ strokeLinejoin: 'round',
14
+ },
15
+ React.createElement('circle', { cx: 12, cy: 12, r: 3 }),
16
+ React.createElement('path', {
17
+ d: 'M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z',
18
+ }),
19
+ )
20
+
21
+ export const metadata = {
22
+ requireAuth: true,
23
+ requireFeatures: ['customers.deals.manage'],
24
+ pageTitle: 'Deal settings',
25
+ pageTitleKey: 'customers.deals.settings.pageTitle',
26
+ pageGroup: 'Customers',
27
+ pageGroupKey: 'customers.nav.group',
28
+ pagePriority: 50,
29
+ pageOrder: 350,
30
+ icon: settingsIcon,
31
+ breadcrumb: [
32
+ { label: 'Settings', labelKey: 'config.nav.group' },
33
+ { label: 'Customers', labelKey: 'customers.nav.group' },
34
+ { label: 'Deal settings', labelKey: 'customers.deals.settings.pageTitle' },
35
+ ],
36
+ }
@@ -0,0 +1,205 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { useQuery } from '@tanstack/react-query'
5
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
6
+ import { Input } from '@open-mercato/ui/primitives/input'
7
+ import { Button } from '@open-mercato/ui/primitives/button'
8
+ import { FormField } from '@open-mercato/ui/primitives/form-field'
9
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
10
+ import { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
11
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
12
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
13
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
14
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
15
+ import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
16
+
17
+ const SAVE_CONTEXT_ID = 'customers-deals-settings:stuck-threshold'
18
+ const MIN_DAYS = 1
19
+ const MAX_DAYS = 365
20
+
21
+ export default function DealsSettingsPage(): React.ReactElement {
22
+ const t = useT()
23
+ const scopeVersion = useOrganizationScopeVersion()
24
+ const [value, setValue] = React.useState<string>('14')
25
+ const [validationError, setValidationError] = React.useState<string | null>(null)
26
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
27
+
28
+ const settingsQuery = useQuery<{ stuckThresholdDays: number }>({
29
+ queryKey: ['customers', 'settings', 'stuck-threshold', `scope:${scopeVersion}`],
30
+ staleTime: 60_000,
31
+ queryFn: async () => {
32
+ const payload = await readApiResultOrThrow<{ stuckThresholdDays?: number }>(
33
+ '/api/customers/settings/stuck-threshold',
34
+ undefined,
35
+ {
36
+ errorMessage: translateWithFallback(
37
+ t,
38
+ 'customers.deals.settings.loadError',
39
+ 'Failed to load deal settings.',
40
+ ),
41
+ },
42
+ )
43
+ return { stuckThresholdDays: payload?.stuckThresholdDays ?? 14 }
44
+ },
45
+ })
46
+
47
+ React.useEffect(() => {
48
+ if (settingsQuery.data) {
49
+ setValue(String(settingsQuery.data.stuckThresholdDays))
50
+ }
51
+ }, [settingsQuery.data])
52
+
53
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
54
+ formId: string
55
+ resourceKind: string
56
+ retryLastMutation: () => Promise<boolean>
57
+ }>({
58
+ contextId: SAVE_CONTEXT_ID,
59
+ blockedMessage: translateWithFallback(
60
+ t,
61
+ 'ui.forms.flash.saveBlocked',
62
+ 'Save blocked by validation',
63
+ ),
64
+ })
65
+
66
+ const handleSave = React.useCallback(async () => {
67
+ const parsed = Number(value)
68
+ if (!Number.isInteger(parsed) || parsed < MIN_DAYS || parsed > MAX_DAYS) {
69
+ setValidationError(
70
+ translateWithFallback(
71
+ t,
72
+ 'customers.deals.settings.range',
73
+ 'Enter a whole number between {min} and {max}.',
74
+ { min: MIN_DAYS, max: MAX_DAYS },
75
+ ),
76
+ )
77
+ return
78
+ }
79
+ setValidationError(null)
80
+ setIsSubmitting(true)
81
+ try {
82
+ await runMutation({
83
+ operation: async () => {
84
+ await apiCallOrThrow(
85
+ '/api/customers/settings/stuck-threshold',
86
+ {
87
+ method: 'PUT',
88
+ headers: { 'content-type': 'application/json' },
89
+ body: JSON.stringify({ stuckThresholdDays: parsed }),
90
+ },
91
+ {
92
+ errorMessage: translateWithFallback(
93
+ t,
94
+ 'customers.deals.settings.saveError',
95
+ 'Failed to save deal settings.',
96
+ ),
97
+ },
98
+ )
99
+ },
100
+ context: {
101
+ formId: SAVE_CONTEXT_ID,
102
+ resourceKind: 'customers.settings',
103
+ retryLastMutation,
104
+ },
105
+ })
106
+ flash(
107
+ translateWithFallback(
108
+ t,
109
+ 'customers.deals.settings.saveSuccess',
110
+ 'Deal settings saved.',
111
+ ),
112
+ 'success',
113
+ )
114
+ settingsQuery.refetch().catch(() => {})
115
+ } catch (error) {
116
+ const message =
117
+ error instanceof Error && error.message
118
+ ? error.message
119
+ : translateWithFallback(
120
+ t,
121
+ 'customers.deals.settings.saveError',
122
+ 'Failed to save deal settings.',
123
+ )
124
+ flash(message, 'error')
125
+ } finally {
126
+ setIsSubmitting(false)
127
+ }
128
+ }, [retryLastMutation, runMutation, settingsQuery, t, value])
129
+
130
+ return (
131
+ <Page>
132
+ <PageBody>
133
+ <div className="flex flex-col gap-6">
134
+ <header className="flex flex-col gap-1">
135
+ <h1 className="text-2xl font-bold tracking-tight text-foreground">
136
+ {translateWithFallback(
137
+ t,
138
+ 'customers.deals.settings.pageTitle',
139
+ 'Deal settings',
140
+ )}
141
+ </h1>
142
+ <p className="text-sm text-muted-foreground">
143
+ {translateWithFallback(
144
+ t,
145
+ 'customers.deals.settings.pageDescription',
146
+ 'Tune how the deals kanban surfaces stale opportunities.',
147
+ )}
148
+ </p>
149
+ </header>
150
+
151
+ <section className="flex max-w-md flex-col gap-4 rounded-lg border border-border bg-card p-6">
152
+ {settingsQuery.isLoading ? (
153
+ <div className="flex h-24 items-center justify-center">
154
+ <Spinner />
155
+ </div>
156
+ ) : (
157
+ <>
158
+ <FormField
159
+ label={translateWithFallback(
160
+ t,
161
+ 'customers.deals.settings.stuckThreshold',
162
+ 'Stuck threshold (days)',
163
+ )}
164
+ description={translateWithFallback(
165
+ t,
166
+ 'customers.deals.settings.stuckThreshold.help',
167
+ 'A deal is flagged as stuck after this many days in its current stage.',
168
+ )}
169
+ error={validationError ?? undefined}
170
+ required
171
+ >
172
+ <Input
173
+ type="number"
174
+ inputMode="numeric"
175
+ min={MIN_DAYS}
176
+ max={MAX_DAYS}
177
+ value={value}
178
+ onChange={(event) => {
179
+ setValue(event.target.value)
180
+ if (validationError) setValidationError(null)
181
+ }}
182
+ />
183
+ </FormField>
184
+
185
+ <div className="flex justify-end">
186
+ <Button
187
+ onClick={() => void handleSave()}
188
+ type="button"
189
+ disabled={isSubmitting || settingsQuery.isLoading}
190
+ >
191
+ {translateWithFallback(
192
+ t,
193
+ 'customers.deals.settings.save',
194
+ 'Save settings',
195
+ )}
196
+ </Button>
197
+ </div>
198
+ </>
199
+ )}
200
+ </section>
201
+ </div>
202
+ </PageBody>
203
+ </Page>
204
+ )
205
+ }
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
- import { useRouter } from 'next/navigation'
4
+ import { useRouter, useSearchParams } from 'next/navigation'
5
5
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
6
6
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
7
7
  import { createCrud } from '@open-mercato/ui/backend/utils/crud'
@@ -9,15 +9,34 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
9
9
  import { DealForm, type DealFormSubmitPayload } from '../../../../components/detail/DealForm'
10
10
  import { useCurrencyDictionary } from '../../../../components/detail/hooks/useCurrencyDictionary'
11
11
 
12
+ const DEFAULT_RETURN_TO = '/backend/customers/deals'
13
+
14
+ /**
15
+ * Only honor `returnTo` when it points back into the deals area of the backoffice.
16
+ * Without the prefix guard, this would be a textbook open-redirect: an attacker could
17
+ * craft `/backend/customers/deals/create?returnTo=https://evil.example.com` and the
18
+ * page would happily navigate the operator off-product after a successful save.
19
+ */
20
+ function resolveReturnTo(value: string | null | undefined): string {
21
+ if (!value) return DEFAULT_RETURN_TO
22
+ if (!value.startsWith('/backend/customers/deals')) return DEFAULT_RETURN_TO
23
+ return value
24
+ }
25
+
12
26
  export default function CreateDealPage() {
13
27
  const t = useT()
14
28
  const router = useRouter()
29
+ const searchParams = useSearchParams()
30
+ const returnTo = React.useMemo(
31
+ () => resolveReturnTo(searchParams?.get('returnTo') ?? null),
32
+ [searchParams],
33
+ )
15
34
  const [isSubmitting, setIsSubmitting] = React.useState(false)
16
35
  useCurrencyDictionary()
17
36
 
18
37
  const handleCancel = React.useCallback(() => {
19
- router.push('/backend/customers/deals')
20
- }, [router])
38
+ router.push(returnTo)
39
+ }, [router, returnTo])
21
40
 
22
41
  const handleSubmit = React.useCallback(
23
42
  async ({ base, custom }: DealFormSubmitPayload) => {
@@ -44,7 +63,7 @@ export default function CreateDealPage() {
44
63
  errorMessage: t('customers.deals.create.error', 'Failed to create deal.'),
45
64
  })
46
65
  flash(t('customers.people.detail.deals.success', 'Deal created.'), 'success')
47
- router.push('/backend/customers/deals')
66
+ router.push(returnTo)
48
67
  } catch (err) {
49
68
  const message =
50
69
  err instanceof Error
@@ -56,7 +75,7 @@ export default function CreateDealPage() {
56
75
  setIsSubmitting(false)
57
76
  }
58
77
  },
59
- [isSubmitting, router, t],
78
+ [isSubmitting, returnTo, router, t],
60
79
  )
61
80
 
62
81
  return (
@@ -70,7 +89,7 @@ export default function CreateDealPage() {
70
89
  submitLabel={t('customers.deals.create.submit', 'Create deal')}
71
90
  embedded={false}
72
91
  title={t('customers.deals.create.title', 'Create deal')}
73
- backHref="/backend/customers/deals"
92
+ backHref={returnTo}
74
93
  />
75
94
  </PageBody>
76
95
  </Page>
@@ -19,6 +19,7 @@ import { coalesceLastOperations } from '@open-mercato/ui/backend/operations/stor
19
19
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
20
20
  import { RowActions } from '@open-mercato/ui/backend/RowActions'
21
21
  import { Button } from '@open-mercato/ui/primitives/button'
22
+ import { ViewTabsRow } from './pipeline/components/ViewTabsRow'
22
23
  import { E } from '#generated/entities.ids.generated'
23
24
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
24
25
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -905,6 +906,7 @@ export default function CustomersDealsPage() {
905
906
  return (
906
907
  <Page>
907
908
  <PageBody>
909
+ <ViewTabsRow active="list" className="mb-4" />
908
910
  <DataTable<DealRow>
909
911
  stickyFirstColumn
910
912
  stickyActionsColumn