@open-mercato/core 0.6.3-develop.3651.1.d3a548a5f1 → 0.6.3-develop.3653.1.b8558737ae
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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/customer_settings/index.js +2 -0
- package/dist/generated/entities/customer_settings/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/customers/api/deals/aggregate/route.js +332 -0
- package/dist/modules/customers/api/deals/aggregate/route.js.map +7 -0
- package/dist/modules/customers/api/deals/bulk-update-owner/route.js +188 -0
- package/dist/modules/customers/api/deals/bulk-update-owner/route.js.map +7 -0
- package/dist/modules/customers/api/deals/bulk-update-stage/route.js +188 -0
- package/dist/modules/customers/api/deals/bulk-update-stage/route.js.map +7 -0
- package/dist/modules/customers/api/deals/route.js +255 -122
- package/dist/modules/customers/api/deals/route.js.map +2 -2
- package/dist/modules/customers/api/settings/stuck-threshold/route.js +169 -0
- package/dist/modules/customers/api/settings/stuck-threshold/route.js.map +7 -0
- package/dist/modules/customers/backend/config/customers/deals/page.js +181 -0
- package/dist/modules/customers/backend/config/customers/deals/page.js.map +7 -0
- package/dist/modules/customers/backend/config/customers/deals/page.meta.js +38 -0
- package/dist/modules/customers/backend/config/customers/deals/page.meta.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/create/page.js +17 -6
- package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/page.js +2 -0
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js +217 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js +335 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js +29 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js +160 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js +157 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js +114 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js +36 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js +199 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js +71 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js +233 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js +126 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js +31 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js +389 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js +181 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js +199 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +44 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js +70 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +278 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js +135 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js +83 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js +155 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +318 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js +138 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +126 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +71 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +2330 -415
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +3 -3
- package/dist/modules/customers/commands/deals.js +6 -0
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/commands/pipeline-stages.js +48 -13
- package/dist/modules/customers/commands/pipeline-stages.js.map +3 -3
- package/dist/modules/customers/commands/settings.js +45 -5
- package/dist/modules/customers/commands/settings.js.map +2 -2
- package/dist/modules/customers/data/enrichers.js +130 -1
- package/dist/modules/customers/data/enrichers.js.map +2 -2
- package/dist/modules/customers/data/entities.js +4 -0
- package/dist/modules/customers/data/entities.js.map +2 -2
- package/dist/modules/customers/data/validators.js +29 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/bulkDeals.js +150 -0
- package/dist/modules/customers/lib/bulkDeals.js.map +7 -0
- package/dist/modules/customers/lib/kysely.js +12 -0
- package/dist/modules/customers/lib/kysely.js.map +7 -0
- package/dist/modules/customers/lib/stuckDeals.js +32 -0
- package/dist/modules/customers/lib/stuckDeals.js.map +7 -0
- package/dist/modules/customers/migrations/Migration20260513203311_customers.js +13 -0
- package/dist/modules/customers/migrations/Migration20260513203311_customers.js.map +7 -0
- package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js +52 -0
- package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +7 -0
- package/dist/modules/customers/workers/deals-bulk-update-owner.js +41 -0
- package/dist/modules/customers/workers/deals-bulk-update-owner.js.map +7 -0
- package/dist/modules/customers/workers/deals-bulk-update-stage.js +41 -0
- package/dist/modules/customers/workers/deals-bulk-update-stage.js.map +7 -0
- package/generated/entities/customer_settings/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/modules/customers/api/deals/aggregate/route.ts +438 -0
- package/src/modules/customers/api/deals/bulk-update-owner/route.ts +216 -0
- package/src/modules/customers/api/deals/bulk-update-stage/route.ts +228 -0
- package/src/modules/customers/api/deals/route.ts +330 -135
- package/src/modules/customers/api/settings/stuck-threshold/route.ts +195 -0
- package/src/modules/customers/backend/config/customers/deals/page.meta.ts +36 -0
- package/src/modules/customers/backend/config/customers/deals/page.tsx +205 -0
- package/src/modules/customers/backend/customers/deals/create/page.tsx +25 -6
- package/src/modules/customers/backend/customers/deals/page.tsx +2 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.tsx +277 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.tsx +425 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.tsx +31 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.tsx +171 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.tsx +196 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.tsx +138 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChipButton.tsx +47 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.tsx +232 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.tsx +110 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.tsx +310 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.tsx +169 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.tsx +54 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DealCard.tsx +562 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.tsx +214 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.tsx +257 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +62 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.tsx +101 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +378 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.tsx +192 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.tsx +107 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.tsx +196 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +408 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.tsx +164 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +171 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +84 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/constants.ts +16 -0
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2841 -550
- package/src/modules/customers/commands/deals.ts +19 -0
- package/src/modules/customers/commands/pipeline-stages.ts +68 -13
- package/src/modules/customers/commands/settings.ts +64 -5
- package/src/modules/customers/data/enrichers.ts +209 -2
- package/src/modules/customers/data/entities.ts +4 -1
- package/src/modules/customers/data/validators.ts +36 -2
- package/src/modules/customers/i18n/de.json +244 -0
- package/src/modules/customers/i18n/en.json +244 -0
- package/src/modules/customers/i18n/es.json +244 -0
- package/src/modules/customers/i18n/pl.json +244 -0
- package/src/modules/customers/lib/bulkDeals.ts +231 -0
- package/src/modules/customers/lib/kysely.ts +60 -0
- package/src/modules/customers/lib/stuckDeals.ts +88 -0
- package/src/modules/customers/migrations/.snapshot-open-mercato.json +16 -0
- package/src/modules/customers/migrations/Migration20260513203311_customers.ts +13 -0
- package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +71 -0
- package/src/modules/customers/workers/deals-bulk-update-owner.ts +46 -0
- 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(
|
|
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(
|
|
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=
|
|
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
|