@open-mercato/core 0.6.8-develop.7072.1.19c2a8bbe0 → 0.6.8-develop.7077.1.94f1126c13
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/modules/auth/api/login.js +4 -1
- package/dist/modules/auth/api/login.js.map +2 -2
- package/dist/modules/auth/api/session/refresh.js +25 -1
- package/dist/modules/auth/api/session/refresh.js.map +2 -2
- package/dist/modules/customers/ai-tools/deals-pack.js +48 -5
- package/dist/modules/customers/ai-tools/deals-pack.js.map +2 -2
- package/dist/modules/customers/api/deals/aggregate/route.js +11 -4
- package/dist/modules/customers/api/deals/aggregate/route.js.map +2 -2
- package/dist/modules/customers/api/deals/route.js +38 -1
- package/dist/modules/customers/api/deals/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +4 -13
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +85 -14
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/toneClasses.js +18 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/toneClasses.js.map +7 -0
- package/dist/modules/customers/commands/deals.js +21 -34
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/lib/closureStage.js +45 -0
- package/dist/modules/customers/lib/closureStage.js.map +7 -0
- package/dist/modules/customers/lib/dealStatus.js +30 -0
- package/dist/modules/customers/lib/dealStatus.js.map +2 -2
- package/dist/modules/query_index/lib/search-entity-policy.js +14 -0
- package/dist/modules/query_index/lib/search-entity-policy.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/auth/api/login.ts +9 -1
- package/src/modules/auth/api/session/refresh.ts +32 -1
- package/src/modules/customers/ai-tools/deals-pack.ts +62 -0
- package/src/modules/customers/api/deals/aggregate/route.ts +14 -4
- package/src/modules/customers/api/deals/route.ts +50 -1
- package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +5 -14
- package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +109 -27
- package/src/modules/customers/backend/customers/deals/pipeline/components/toneClasses.ts +21 -0
- package/src/modules/customers/commands/deals.ts +34 -59
- package/src/modules/customers/i18n/de.json +3 -0
- package/src/modules/customers/i18n/en.json +3 -0
- package/src/modules/customers/i18n/es.json +3 -0
- package/src/modules/customers/i18n/ko.json +3 -0
- package/src/modules/customers/i18n/pl.json +3 -0
- package/src/modules/customers/lib/closureStage.ts +76 -0
- package/src/modules/customers/lib/dealStatus.ts +42 -0
- package/src/modules/query_index/lib/search-entity-policy.ts +46 -0
|
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
3
3
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
4
4
|
import { AuthService } from '@open-mercato/core/modules/auth/services/authService'
|
|
5
|
-
import { signJwt } from '@open-mercato/shared/lib/auth/jwt'
|
|
5
|
+
import { isMfaPendingJwtPayload, signJwt, verifyJwt } from '@open-mercato/shared/lib/auth/jwt'
|
|
6
6
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
7
7
|
import { refreshSessionRequestSchema } from '@open-mercato/core/modules/auth/data/validators'
|
|
8
8
|
import { checkAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'
|
|
@@ -25,6 +25,23 @@ function parseCookie(req: Request, name: string): string | null {
|
|
|
25
25
|
return m ? decodeURIComponent(m[1]) : null
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Both handlers are `requireAuth: false`, so the dispatcher's MFA-pending gate never inspects
|
|
29
|
+
// this route's caller. Minting a full staff JWT for a browser that is still holding a provisional
|
|
30
|
+
// `mfa_pending` token would hand it the access the outstanding second factor is meant to withhold,
|
|
31
|
+
// so the pending credential is checked here directly. `refreshFromSessionToken` itself has no MFA
|
|
32
|
+
// awareness — it validates only the token hash and expiry.
|
|
33
|
+
function carriesMfaPendingToken(req: Request): boolean {
|
|
34
|
+
const authHeader = (req.headers.get('authorization') || '').trim()
|
|
35
|
+
const bearer = authHeader.toLowerCase().startsWith('bearer ') ? authHeader.slice(7).trim() : null
|
|
36
|
+
const token = bearer || parseCookie(req, 'auth_token')
|
|
37
|
+
if (!token) return false
|
|
38
|
+
try {
|
|
39
|
+
return isMfaPendingJwtPayload(verifyJwt(token))
|
|
40
|
+
} catch {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
type RefreshedSession = NonNullable<Awaited<ReturnType<AuthService['refreshFromSessionToken']>>>
|
|
29
46
|
|
|
30
47
|
// Scope claims must stay absent rather than stringified when the user has no tenant/org:
|
|
@@ -64,6 +81,11 @@ export async function GET(req: Request) {
|
|
|
64
81
|
const url = new URL(req.url)
|
|
65
82
|
const baseUrl = resolveTrustedRedirectBase(req) ?? url.origin
|
|
66
83
|
const redirectTo = sanitizeRedirectPath(url.searchParams.get('redirect'), baseUrl, '/')
|
|
84
|
+
if (carriesMfaPendingToken(req)) {
|
|
85
|
+
return clearStaffAuthCookies(
|
|
86
|
+
buildSafeRedirectResponse(req, '/login?redirect=' + encodeURIComponent(redirectTo))
|
|
87
|
+
)
|
|
88
|
+
}
|
|
67
89
|
const token = parseCookie(req, 'session_token')
|
|
68
90
|
if (!token) {
|
|
69
91
|
return clearStaffAuthCookies(
|
|
@@ -106,6 +128,15 @@ export async function POST(req: Request) {
|
|
|
106
128
|
})
|
|
107
129
|
if (rateLimitError) return rateLimitError
|
|
108
130
|
|
|
131
|
+
if (carriesMfaPendingToken(req)) {
|
|
132
|
+
return clearStaffAuthCookies(
|
|
133
|
+
NextResponse.json({
|
|
134
|
+
ok: false,
|
|
135
|
+
error: translate('auth.session.refresh.errors.invalidToken', 'Invalid or expired refresh token'),
|
|
136
|
+
}, { status: 401 })
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
109
140
|
if (!token) {
|
|
110
141
|
return clearStaffAuthCookies(
|
|
111
142
|
NextResponse.json({
|
|
@@ -27,6 +27,11 @@ import {
|
|
|
27
27
|
CustomerDeal,
|
|
28
28
|
CustomerPipelineStage,
|
|
29
29
|
} from '../data/entities'
|
|
30
|
+
import {
|
|
31
|
+
dealClosureOutcomeFromStatus,
|
|
32
|
+
loadClosurePipelineStageSnapshot,
|
|
33
|
+
} from '../lib/closureStage'
|
|
34
|
+
import { canonicalDealStatus, isClosedDealStatus } from '../lib/dealStatus'
|
|
30
35
|
import {
|
|
31
36
|
assertTenantScope,
|
|
32
37
|
type CustomersAiToolDefinition,
|
|
@@ -469,10 +474,48 @@ const updateDealStageTool: CustomersAiToolDefinition = {
|
|
|
469
474
|
afterPipelineStageLabel = stage?.label ?? input.toPipelineStageId
|
|
470
475
|
} else if (input.toStage) {
|
|
471
476
|
afterStatus = input.toStage
|
|
477
|
+
// The update command derives a closure outcome from terminal status spellings and
|
|
478
|
+
// relocates the deal to the pipeline's terminal stage (#5107) — preview that same
|
|
479
|
+
// projection so the approval card states the full blast radius.
|
|
480
|
+
const organizationId = deal.organizationId ?? ctx.organizationId ?? null
|
|
481
|
+
const outcome = dealClosureOutcomeFromStatus(input.toStage)
|
|
482
|
+
if (outcome && organizationId) {
|
|
483
|
+
const terminalStage = await loadClosurePipelineStageSnapshot(em, {
|
|
484
|
+
pipelineId: deal.pipelineId ?? null,
|
|
485
|
+
closureOutcome: outcome,
|
|
486
|
+
tenantId,
|
|
487
|
+
organizationId,
|
|
488
|
+
})
|
|
489
|
+
if (terminalStage) {
|
|
490
|
+
afterPipelineStageId = terminalStage.id
|
|
491
|
+
afterPipelineStageLabel = terminalStage.label
|
|
492
|
+
}
|
|
493
|
+
}
|
|
472
494
|
}
|
|
473
495
|
const beforeStatus = deal.status ?? null
|
|
474
496
|
const beforePipelineStageId = deal.pipelineStageId ?? null
|
|
475
497
|
const beforePipelineStageLabel = deal.pipelineStage ?? beforePipelineStageId
|
|
498
|
+
const beforeClosureOutcome = deal.closureOutcome ?? null
|
|
499
|
+
const beforeLossReasonId = deal.lossReasonId ?? null
|
|
500
|
+
const beforeLossNotes = deal.lossNotes ?? null
|
|
501
|
+
// Mirror the update command exactly (#5107): a status-only write derives the closure
|
|
502
|
+
// outcome for terminal spellings, clears outcome plus loss columns for non-closed
|
|
503
|
+
// non-terminal ones (reopen), and leaves `closed` and stage-only writes untouched.
|
|
504
|
+
// `toStage` is free-form model text, so canonicalize before the `closed` check — the
|
|
505
|
+
// command does the same, and the two must stay in lockstep or the approval card would
|
|
506
|
+
// preview a different write than the one that lands.
|
|
507
|
+
const requestedOutcome = dealClosureOutcomeFromStatus(input.toStage)
|
|
508
|
+
const clearsClosure =
|
|
509
|
+
input.toPipelineStageId === undefined &&
|
|
510
|
+
input.toStage !== undefined &&
|
|
511
|
+
!requestedOutcome &&
|
|
512
|
+
!isClosedDealStatus(canonicalDealStatus(input.toStage))
|
|
513
|
+
const afterClosureOutcome = clearsClosure
|
|
514
|
+
? null
|
|
515
|
+
: requestedOutcome ?? beforeClosureOutcome
|
|
516
|
+
const closureOutcomeCleared = beforeClosureOutcome !== null && afterClosureOutcome === null
|
|
517
|
+
const afterLossReasonId = closureOutcomeCleared ? null : beforeLossReasonId
|
|
518
|
+
const afterLossNotes = closureOutcomeCleared ? null : beforeLossNotes
|
|
476
519
|
return {
|
|
477
520
|
recordId: deal.id,
|
|
478
521
|
entityType: 'customers.deal',
|
|
@@ -480,23 +523,42 @@ const updateDealStageTool: CustomersAiToolDefinition = {
|
|
|
480
523
|
before: {
|
|
481
524
|
status: beforeStatus,
|
|
482
525
|
pipelineStageId: beforePipelineStageId,
|
|
526
|
+
closureOutcome: beforeClosureOutcome,
|
|
527
|
+
lossReasonId: beforeLossReasonId,
|
|
528
|
+
lossNotes: beforeLossNotes,
|
|
483
529
|
},
|
|
484
530
|
after: {
|
|
485
531
|
status: afterStatus,
|
|
486
532
|
pipelineStageId: afterPipelineStageId,
|
|
533
|
+
closureOutcome: afterClosureOutcome,
|
|
534
|
+
lossReasonId: afterLossReasonId,
|
|
535
|
+
lossNotes: afterLossNotes,
|
|
487
536
|
},
|
|
488
537
|
display: {
|
|
489
538
|
fieldLabels: {
|
|
490
539
|
status: 'Status',
|
|
491
540
|
pipelineStageId: 'Pipeline stage',
|
|
541
|
+
closureOutcome: 'Closure outcome',
|
|
542
|
+
lossReasonId: 'Loss reason',
|
|
543
|
+
lossNotes: 'Loss notes',
|
|
492
544
|
},
|
|
493
545
|
before: {
|
|
494
546
|
...(beforeStatus ? { status: titleStatus(beforeStatus) } : {}),
|
|
495
547
|
...(beforePipelineStageLabel ? { pipelineStageId: beforePipelineStageLabel } : {}),
|
|
548
|
+
...(beforeClosureOutcome ? { closureOutcome: titleStatus(beforeClosureOutcome) } : {}),
|
|
549
|
+
...(beforeLossReasonId ? { lossReasonId: beforeLossReasonId } : {}),
|
|
550
|
+
...(beforeLossNotes ? { lossNotes: beforeLossNotes } : {}),
|
|
496
551
|
},
|
|
497
552
|
after: {
|
|
498
553
|
...(afterStatus ? { status: titleStatus(afterStatus) } : {}),
|
|
499
554
|
...(afterPipelineStageLabel ? { pipelineStageId: afterPipelineStageLabel } : {}),
|
|
555
|
+
...(afterClosureOutcome
|
|
556
|
+
? { closureOutcome: titleStatus(afterClosureOutcome) }
|
|
557
|
+
: closureOutcomeCleared
|
|
558
|
+
? { closureOutcome: '—' }
|
|
559
|
+
: {}),
|
|
560
|
+
...(afterLossReasonId ? { lossReasonId: afterLossReasonId } : {}),
|
|
561
|
+
...(afterLossNotes ? { lossNotes: afterLossNotes } : {}),
|
|
500
562
|
},
|
|
501
563
|
},
|
|
502
564
|
}
|
|
@@ -14,6 +14,7 @@ import type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'
|
|
|
14
14
|
import { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'
|
|
15
15
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
16
16
|
import { fetchStuckDealIds } from '../../../lib/stuckDeals'
|
|
17
|
+
import { expandDealStatusAliases } from '../../../lib/dealStatus'
|
|
17
18
|
import { findMatchingEntityIdsBySearchTokensAcrossSources } from '../../utils'
|
|
18
19
|
import { E } from '#generated/entities.ids.generated'
|
|
19
20
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
@@ -27,7 +28,7 @@ export const metadata = {
|
|
|
27
28
|
const querySchema = z.object({
|
|
28
29
|
pipelineId: z.string().uuid().optional(),
|
|
29
30
|
search: z.string().optional(),
|
|
30
|
-
status: z.array(z.
|
|
31
|
+
status: z.array(z.string().max(50)).max(20).optional(),
|
|
31
32
|
ownerUserId: z.array(z.string().uuid()).optional(),
|
|
32
33
|
personId: z.array(z.string().uuid()).optional(),
|
|
33
34
|
companyId: z.array(z.string().uuid()).optional(),
|
|
@@ -247,9 +248,11 @@ export async function GET(req: Request) {
|
|
|
247
248
|
}
|
|
248
249
|
}
|
|
249
250
|
if (parsed.data.status && parsed.data.status.length) {
|
|
250
|
-
|
|
251
|
+
// Non-empty input always expands to a non-empty set, so this always narrows.
|
|
252
|
+
const expandedStatuses = expandDealStatusAliases(parsed.data.status)
|
|
253
|
+
const placeholders = expandedStatuses.map(() => '?').join(',')
|
|
251
254
|
where.push(`status IN (${placeholders})`)
|
|
252
|
-
values.push(...
|
|
255
|
+
values.push(...expandedStatuses)
|
|
253
256
|
}
|
|
254
257
|
if (parsed.data.ownerUserId && parsed.data.ownerUserId.length) {
|
|
255
258
|
const placeholders = parsed.data.ownerUserId.map(() => '?').join(',')
|
|
@@ -265,7 +268,14 @@ export async function GET(req: Request) {
|
|
|
265
268
|
values.push(parsed.data.expectedCloseAtTo)
|
|
266
269
|
}
|
|
267
270
|
if (parsed.data.isOverdue) {
|
|
268
|
-
|
|
271
|
+
// Mirror the list route's precedence: the caller-supplied status filter wins, and
|
|
272
|
+
// status='open' is injected only when none was provided.
|
|
273
|
+
const hasCallerStatus = !!parsed.data.status?.length
|
|
274
|
+
if (hasCallerStatus) {
|
|
275
|
+
where.push('expected_close_at < CURRENT_DATE')
|
|
276
|
+
} else {
|
|
277
|
+
where.push("expected_close_at < CURRENT_DATE AND status = 'open'")
|
|
278
|
+
}
|
|
269
279
|
}
|
|
270
280
|
if (parsed.data.isStuck) {
|
|
271
281
|
// Reuse the list endpoint's stuck-deal lookup so kanban headers, lane counts, and the
|
|
@@ -22,7 +22,9 @@ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
|
22
22
|
import { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'
|
|
23
23
|
import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
|
|
24
24
|
import { consumeAdvancedFilterState, mergeAdvancedFilterTree } from '@open-mercato/shared/lib/crud/advanced-filter-integration'
|
|
25
|
+
import type { FilterGroup, FilterRule } from '@open-mercato/shared/lib/query/advanced-filter-tree'
|
|
25
26
|
import { fetchStuckDealIds } from '../../lib/stuckDeals'
|
|
27
|
+
import { expandDealStatusAliases } from '../../lib/dealStatus'
|
|
26
28
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
27
29
|
|
|
28
30
|
const logger = createLogger('customers')
|
|
@@ -31,6 +33,19 @@ const rawBodySchema = z.object({}).passthrough()
|
|
|
31
33
|
|
|
32
34
|
const stringOrStringArray = z.union([z.string(), z.array(z.string())])
|
|
33
35
|
const OPEN_DEAL_STATUSES = ['open', 'in_progress'] as const
|
|
36
|
+
// Tree operators whose `status` values are rewritten through the canonical vocabulary.
|
|
37
|
+
// Text (`contains`/`starts_with`/…) and range (`between`) operators are left untouched
|
|
38
|
+
// so a URL-supplied rule keeps its scalar semantics.
|
|
39
|
+
const STATUS_TREE_OPERATORS = new Set([
|
|
40
|
+
'is',
|
|
41
|
+
'equals',
|
|
42
|
+
'is_not',
|
|
43
|
+
'not_equals',
|
|
44
|
+
'is_any_of',
|
|
45
|
+
'has_any_of',
|
|
46
|
+
'is_none_of',
|
|
47
|
+
'has_none_of',
|
|
48
|
+
])
|
|
34
49
|
const booleanQueryParam = z.preprocess((value) => {
|
|
35
50
|
const parsed = parseBooleanFromUnknown(value)
|
|
36
51
|
return parsed === null ? value : parsed
|
|
@@ -245,6 +260,39 @@ function normalizeUuidList(values: Array<unknown>): string[] {
|
|
|
245
260
|
|
|
246
261
|
export async function buildDealListFilters(query: DealListQuery, ctx?: import('@open-mercato/shared/lib/crud/factory').CrudCtx) {
|
|
247
262
|
const advancedFilterTree = consumeAdvancedFilterState(query)
|
|
263
|
+
if (advancedFilterTree) {
|
|
264
|
+
// Expand status aliases in the advanced-filter tree so List (tree path) and
|
|
265
|
+
// Kanban (plain ?status=) share the same canonical vocabulary (#5107). Only
|
|
266
|
+
// set-membership operators are rewritten — text/range operators on `status`
|
|
267
|
+
// keep their pre-existing scalar semantics instead of being corrupted.
|
|
268
|
+
const walk = (node: FilterGroup | FilterRule): void => {
|
|
269
|
+
if (node.type === 'rule' && node.field === 'status' && STATUS_TREE_OPERATORS.has(node.operator)) {
|
|
270
|
+
const listOperators = new Set(['is_any_of', 'has_any_of', 'is_none_of', 'has_none_of'])
|
|
271
|
+
const rawValues: string[] = Array.isArray(node.value)
|
|
272
|
+
? (node.value as unknown[]).filter((v): v is string => typeof v === 'string')
|
|
273
|
+
: typeof node.value === 'string'
|
|
274
|
+
? [node.value]
|
|
275
|
+
: []
|
|
276
|
+
if (rawValues.length === 0) return
|
|
277
|
+
const expanded = expandDealStatusAliases(rawValues)
|
|
278
|
+
if (listOperators.has(node.operator)) {
|
|
279
|
+
node.value = expanded
|
|
280
|
+
} else if (expanded.length === 1) {
|
|
281
|
+
node.value = expanded[0]
|
|
282
|
+
} else {
|
|
283
|
+
node.value = expanded
|
|
284
|
+
if (node.operator === 'is' || node.operator === 'equals') {
|
|
285
|
+
node.operator = 'is_any_of'
|
|
286
|
+
} else if (node.operator === 'is_not' || node.operator === 'not_equals') {
|
|
287
|
+
node.operator = 'is_none_of'
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} else if (node.type === 'group') {
|
|
291
|
+
for (const child of node.children) walk(child)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
walk(advancedFilterTree.root)
|
|
295
|
+
}
|
|
248
296
|
const filters: Record<string, unknown> = {}
|
|
249
297
|
let restrictedIds: string[] | null = null
|
|
250
298
|
|
|
@@ -300,7 +348,8 @@ export async function buildDealListFilters(query: DealListQuery, ctx?: import('@
|
|
|
300
348
|
}
|
|
301
349
|
}
|
|
302
350
|
|
|
303
|
-
const
|
|
351
|
+
const rawStatusList = query.status ? normalizeStringList(query.status) : []
|
|
352
|
+
const statusList = expandDealStatusAliases(rawStatusList)
|
|
304
353
|
if (statusList.length > 0) {
|
|
305
354
|
filters.status = statusList.length === 1 ? { $eq: statusList[0] } : { $in: statusList }
|
|
306
355
|
}
|
|
@@ -7,6 +7,7 @@ import type { RowActionItem } from '@open-mercato/ui/backend/RowActions'
|
|
|
7
7
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
8
8
|
import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
|
|
9
9
|
import type { FilterOptionTone } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
10
|
+
import { toneToDotClass } from './toneClasses'
|
|
10
11
|
import { DealCard, type DealCardData } from './DealCard'
|
|
11
12
|
import { DashedTileButton } from './DashedTileButton'
|
|
12
13
|
import { LANE_WIDTH_PX } from './constants'
|
|
@@ -52,15 +53,10 @@ type LaneProps = {
|
|
|
52
53
|
onResetWidth?: (stageId: string) => void
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
// 4px color bar tone —
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
warning: 'bg-status-warning-icon',
|
|
60
|
-
info: 'bg-status-info-icon',
|
|
61
|
-
neutral: 'bg-status-neutral-icon',
|
|
62
|
-
brand: 'bg-brand-violet',
|
|
63
|
-
pink: 'bg-status-pink-icon',
|
|
56
|
+
// 4px color bar tone — saturated icon tokens shared with the status filter pills
|
|
57
|
+
// (toneClasses.ts) so the lane bar and the filter dots stay in lockstep (#5107 review).
|
|
58
|
+
function getAccentClass(tone: FilterOptionTone | null): string {
|
|
59
|
+
return toneToDotClass(tone, 'bg-border')
|
|
64
60
|
}
|
|
65
61
|
|
|
66
62
|
// Count pill bg uses very-light status background, text uses status text color
|
|
@@ -74,11 +70,6 @@ const COUNT_BADGE_TONE_CLASS: Record<FilterOptionTone, string> = {
|
|
|
74
70
|
pink: 'bg-status-pink-bg text-status-pink-text',
|
|
75
71
|
}
|
|
76
72
|
|
|
77
|
-
function getAccentClass(tone: FilterOptionTone | null): string {
|
|
78
|
-
if (tone && tone in ACCENT_TONE_CLASS) return ACCENT_TONE_CLASS[tone]
|
|
79
|
-
return 'bg-border'
|
|
80
|
-
}
|
|
81
|
-
|
|
82
73
|
function getCountBadgeClass(tone: FilterOptionTone | null): string {
|
|
83
74
|
if (tone && tone in COUNT_BADGE_TONE_CLASS) return COUNT_BADGE_TONE_CLASS[tone]
|
|
84
75
|
return 'bg-muted text-muted-foreground'
|
package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx
CHANGED
|
@@ -10,45 +10,70 @@ import {
|
|
|
10
10
|
} from '@open-mercato/ui/primitives/popover'
|
|
11
11
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
12
12
|
import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
|
|
13
|
+
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
14
|
+
import { mapDictionaryColorToTone } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
15
|
+
import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
16
|
+
import { useCustomerDictionary } from '../../../../../components/detail/hooks/useCustomerDictionary'
|
|
17
|
+
import { canonicalDealStatus } from '../../../../../lib/dealStatus'
|
|
18
|
+
import { toneToDotClass } from './toneClasses'
|
|
13
19
|
import { ChipButton } from './ChipButton'
|
|
14
20
|
import { FilterPopoverShell } from './FilterPopoverShell'
|
|
15
21
|
|
|
16
22
|
/**
|
|
17
23
|
* Filter options exposed to the operator.
|
|
18
24
|
*
|
|
19
|
-
* The deal `status` column
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
25
|
+
* The deal `status` column is dictionary-driven (`deal-statuses`); the seeded dictionary
|
|
26
|
+
* carries five values (`open`, `closed`, `win`, `loose`, `in_progress`) plus any
|
|
27
|
+
* tenant-custom entries. We render every dictionary entry de-duplicated by canonical
|
|
28
|
+
* spelling so the kanban Status pill stays aligned with the list page's advanced filter
|
|
29
|
+
* (which also uses the dictionary). A hard-coded fallback keeps the popover usable while
|
|
30
|
+
* the dictionary is loading or when a tenant has no entries.
|
|
31
|
+
*
|
|
32
|
+
* `won` / `lost` are accepted as aliases for `win` / `loose` at the API layer (see
|
|
33
|
+
* `lib/dealStatus.ts:expandDealStatusAliases`, shared by the deals list route and the
|
|
34
|
+
* kanban aggregate route). The UI only exposes the canonical values to avoid duplicate
|
|
35
|
+
* pills, but `canonicalDealStatus` normalizes any alias passed in through `values` so
|
|
36
|
+
* the chip and draft selection render the correct label.
|
|
26
37
|
*/
|
|
27
|
-
const
|
|
38
|
+
const FALLBACK_STATUS_OPTIONS: Array<{
|
|
28
39
|
value: string
|
|
29
40
|
labelKey: string
|
|
30
41
|
labelFallback: string
|
|
31
|
-
/** Tone selects the small ●-dot color on the pill (per Figma: green / amber / gray) */
|
|
32
42
|
dotClass: string
|
|
33
43
|
}> = [
|
|
34
44
|
{
|
|
35
45
|
value: 'open',
|
|
36
46
|
labelKey: 'customers.deals.kanban.filter.status.open',
|
|
37
47
|
labelFallback: 'Open',
|
|
38
|
-
|
|
48
|
+
// Tones mirror mapDictionaryColorToTone over the seeded dictionary colors
|
|
49
|
+
// (#2563eb → info, #22c55e → success, #ef4444 → error) so the fallback pills
|
|
50
|
+
// render identically to the dictionary-driven ones.
|
|
51
|
+
dotClass: 'bg-status-info-icon',
|
|
39
52
|
},
|
|
40
53
|
{
|
|
41
54
|
value: 'win',
|
|
42
55
|
labelKey: 'customers.deals.kanban.filter.status.won',
|
|
43
56
|
labelFallback: 'Won',
|
|
44
|
-
dotClass: 'bg-status-
|
|
57
|
+
dotClass: 'bg-status-success-icon',
|
|
45
58
|
},
|
|
46
59
|
{
|
|
47
60
|
value: 'loose',
|
|
48
61
|
labelKey: 'customers.deals.kanban.filter.status.lost',
|
|
49
62
|
labelFallback: 'Lost',
|
|
63
|
+
dotClass: 'bg-status-error-icon',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
value: 'closed',
|
|
67
|
+
labelKey: 'customers.deals.kanban.filter.status.closed',
|
|
68
|
+
labelFallback: 'Closed',
|
|
50
69
|
dotClass: 'bg-status-neutral-icon',
|
|
51
70
|
},
|
|
71
|
+
{
|
|
72
|
+
value: 'in_progress',
|
|
73
|
+
labelKey: 'customers.deals.kanban.filter.status.inProgress',
|
|
74
|
+
labelFallback: 'In progress',
|
|
75
|
+
dotClass: 'bg-status-warning-icon',
|
|
76
|
+
},
|
|
52
77
|
]
|
|
53
78
|
|
|
54
79
|
type StatusFilterPopoverProps = {
|
|
@@ -58,29 +83,75 @@ type StatusFilterPopoverProps = {
|
|
|
58
83
|
|
|
59
84
|
export function StatusFilterPopover({ values, onApply }: StatusFilterPopoverProps): React.ReactElement {
|
|
60
85
|
const t = useT()
|
|
86
|
+
const scopeVersion = useOrganizationScopeVersion()
|
|
87
|
+
const { data: dictionaryData, isLoading: dictionaryLoading } = useCustomerDictionary(
|
|
88
|
+
'deal-statuses',
|
|
89
|
+
scopeVersion,
|
|
90
|
+
)
|
|
61
91
|
const [open, setOpen] = React.useState(false)
|
|
62
|
-
const
|
|
92
|
+
const normalizedValues = React.useMemo(
|
|
93
|
+
() => Array.from(new Set(values.map(canonicalDealStatus))),
|
|
94
|
+
[values],
|
|
95
|
+
)
|
|
96
|
+
const [draft, setDraft] = React.useState<string[]>(normalizedValues)
|
|
63
97
|
|
|
64
98
|
React.useEffect(() => {
|
|
65
|
-
if (open) setDraft(
|
|
66
|
-
}, [open,
|
|
99
|
+
if (open) setDraft(normalizedValues)
|
|
100
|
+
}, [open, normalizedValues])
|
|
101
|
+
|
|
102
|
+
const statusOptions = React.useMemo(() => {
|
|
103
|
+
const entries = dictionaryData?.entries
|
|
104
|
+
if (entries && entries.length > 0) {
|
|
105
|
+
const byCanonical = new Map<string, { value: string; label: string; dotClass: string }>()
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const canonical = canonicalDealStatus(entry.value)
|
|
108
|
+
if (byCanonical.has(canonical)) continue
|
|
109
|
+
const tone = mapDictionaryColorToTone(entry.color ?? null)
|
|
110
|
+
byCanonical.set(canonical, {
|
|
111
|
+
value: canonical,
|
|
112
|
+
label: entry.label,
|
|
113
|
+
dotClass: toneToDotClass(tone),
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
// Sort by label exactly like the List page's advanced filter
|
|
117
|
+
// (backend/customers/deals/page.tsx dictionaryOptions) so both surfaces render
|
|
118
|
+
// the same pills in the same order.
|
|
119
|
+
return Array.from(byCanonical.values()).sort((a, b) =>
|
|
120
|
+
a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }),
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
return FALLBACK_STATUS_OPTIONS.map((entry) => ({
|
|
124
|
+
value: entry.value,
|
|
125
|
+
label: translateWithFallback(t, entry.labelKey, entry.labelFallback),
|
|
126
|
+
dotClass: entry.dotClass,
|
|
127
|
+
}))
|
|
128
|
+
}, [dictionaryData, t])
|
|
129
|
+
|
|
130
|
+
const labelByValue = React.useMemo(() => {
|
|
131
|
+
const map = new Map<string, string>()
|
|
132
|
+
for (const option of statusOptions) {
|
|
133
|
+
map.set(option.value, option.label)
|
|
134
|
+
}
|
|
135
|
+
for (const fallback of FALLBACK_STATUS_OPTIONS) {
|
|
136
|
+
if (!map.has(fallback.value)) {
|
|
137
|
+
map.set(fallback.value, translateWithFallback(t, fallback.labelKey, fallback.labelFallback))
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return map
|
|
141
|
+
}, [statusOptions, t])
|
|
67
142
|
|
|
68
143
|
const chipLabel = translateWithFallback(t, 'customers.deals.kanban.filter.status', 'Status')
|
|
69
144
|
const chipValue =
|
|
70
|
-
|
|
145
|
+
normalizedValues.length === 0
|
|
71
146
|
? translateWithFallback(t, 'customers.deals.kanban.filter.all', 'All')
|
|
72
|
-
:
|
|
73
|
-
.map((value) =>
|
|
74
|
-
const option = STATUS_OPTIONS.find((entry) => entry.value === value)
|
|
75
|
-
return option
|
|
76
|
-
? translateWithFallback(t, option.labelKey, option.labelFallback)
|
|
77
|
-
: value
|
|
78
|
-
})
|
|
147
|
+
: normalizedValues
|
|
148
|
+
.map((value) => labelByValue.get(value) ?? value)
|
|
79
149
|
.join(', ')
|
|
80
150
|
|
|
81
151
|
const toggleDraft = (value: string) => {
|
|
152
|
+
const normalized = canonicalDealStatus(value)
|
|
82
153
|
setDraft((prev) =>
|
|
83
|
-
prev.includes(
|
|
154
|
+
prev.includes(normalized) ? prev.filter((entry) => entry !== normalized) : [...prev, normalized],
|
|
84
155
|
)
|
|
85
156
|
}
|
|
86
157
|
|
|
@@ -101,7 +172,7 @@ export function StatusFilterPopover({ values, onApply }: StatusFilterPopoverProp
|
|
|
101
172
|
return (
|
|
102
173
|
<Popover open={open} onOpenChange={setOpen}>
|
|
103
174
|
<PopoverTrigger asChild>
|
|
104
|
-
<ChipButton label={chipLabel} value={chipValue} active={
|
|
175
|
+
<ChipButton label={chipLabel} value={chipValue} active={normalizedValues.length > 0} />
|
|
105
176
|
</PopoverTrigger>
|
|
106
177
|
<PopoverContent
|
|
107
178
|
className="w-96 rounded-2xl border-border bg-transparent p-0 shadow-xl"
|
|
@@ -133,9 +204,19 @@ export function StatusFilterPopover({ values, onApply }: StatusFilterPopoverProp
|
|
|
133
204
|
{translateWithFallback(t, 'customers.deals.kanban.filter.status', 'Status')}
|
|
134
205
|
</span>
|
|
135
206
|
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-2">
|
|
136
|
-
{
|
|
207
|
+
{dictionaryLoading ? (
|
|
208
|
+
<span className="flex items-center gap-2 text-xs text-muted-foreground" role="status" aria-live="polite">
|
|
209
|
+
<Spinner className="size-3" />
|
|
210
|
+
{translateWithFallback(
|
|
211
|
+
t,
|
|
212
|
+
'customers.deals.kanban.filter.status.loading',
|
|
213
|
+
'Loading statuses…',
|
|
214
|
+
)}
|
|
215
|
+
</span>
|
|
216
|
+
) : (
|
|
217
|
+
statusOptions.map((option) => {
|
|
137
218
|
const isSelected = draft.includes(option.value)
|
|
138
|
-
const label =
|
|
219
|
+
const label = option.label
|
|
139
220
|
return (
|
|
140
221
|
<Button
|
|
141
222
|
key={option.value}
|
|
@@ -160,7 +241,8 @@ export function StatusFilterPopover({ values, onApply }: StatusFilterPopoverProp
|
|
|
160
241
|
) : null}
|
|
161
242
|
</Button>
|
|
162
243
|
)
|
|
163
|
-
|
|
244
|
+
})
|
|
245
|
+
)}
|
|
164
246
|
</div>
|
|
165
247
|
</FilterPopoverShell>
|
|
166
248
|
</PopoverContent>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FilterOptionTone } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
2
|
+
|
|
3
|
+
// Saturated icon tokens shared by the kanban lane accent bar and the status filter
|
|
4
|
+
// pills, so both surfaces render the same tone identically (#5107 review).
|
|
5
|
+
export const TONE_DOT_CLASS: Record<FilterOptionTone, string> = {
|
|
6
|
+
success: 'bg-status-success-icon',
|
|
7
|
+
error: 'bg-status-error-icon',
|
|
8
|
+
warning: 'bg-status-warning-icon',
|
|
9
|
+
info: 'bg-status-info-icon',
|
|
10
|
+
neutral: 'bg-status-neutral-icon',
|
|
11
|
+
brand: 'bg-brand-violet',
|
|
12
|
+
pink: 'bg-status-pink-icon',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function toneToDotClass(
|
|
16
|
+
tone: FilterOptionTone | null | undefined,
|
|
17
|
+
fallback = 'bg-status-neutral-icon',
|
|
18
|
+
): string {
|
|
19
|
+
if (tone && tone in TONE_DOT_CLASS) return TONE_DOT_CLASS[tone]
|
|
20
|
+
return fallback
|
|
21
|
+
}
|