@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.
Files changed (43) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/login.js +4 -1
  3. package/dist/modules/auth/api/login.js.map +2 -2
  4. package/dist/modules/auth/api/session/refresh.js +25 -1
  5. package/dist/modules/auth/api/session/refresh.js.map +2 -2
  6. package/dist/modules/customers/ai-tools/deals-pack.js +48 -5
  7. package/dist/modules/customers/ai-tools/deals-pack.js.map +2 -2
  8. package/dist/modules/customers/api/deals/aggregate/route.js +11 -4
  9. package/dist/modules/customers/api/deals/aggregate/route.js.map +2 -2
  10. package/dist/modules/customers/api/deals/route.js +38 -1
  11. package/dist/modules/customers/api/deals/route.js.map +2 -2
  12. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +4 -13
  13. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +2 -2
  14. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +85 -14
  15. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +2 -2
  16. package/dist/modules/customers/backend/customers/deals/pipeline/components/toneClasses.js +18 -0
  17. package/dist/modules/customers/backend/customers/deals/pipeline/components/toneClasses.js.map +7 -0
  18. package/dist/modules/customers/commands/deals.js +21 -34
  19. package/dist/modules/customers/commands/deals.js.map +2 -2
  20. package/dist/modules/customers/lib/closureStage.js +45 -0
  21. package/dist/modules/customers/lib/closureStage.js.map +7 -0
  22. package/dist/modules/customers/lib/dealStatus.js +30 -0
  23. package/dist/modules/customers/lib/dealStatus.js.map +2 -2
  24. package/dist/modules/query_index/lib/search-entity-policy.js +14 -0
  25. package/dist/modules/query_index/lib/search-entity-policy.js.map +7 -0
  26. package/package.json +7 -7
  27. package/src/modules/auth/api/login.ts +9 -1
  28. package/src/modules/auth/api/session/refresh.ts +32 -1
  29. package/src/modules/customers/ai-tools/deals-pack.ts +62 -0
  30. package/src/modules/customers/api/deals/aggregate/route.ts +14 -4
  31. package/src/modules/customers/api/deals/route.ts +50 -1
  32. package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +5 -14
  33. package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +109 -27
  34. package/src/modules/customers/backend/customers/deals/pipeline/components/toneClasses.ts +21 -0
  35. package/src/modules/customers/commands/deals.ts +34 -59
  36. package/src/modules/customers/i18n/de.json +3 -0
  37. package/src/modules/customers/i18n/en.json +3 -0
  38. package/src/modules/customers/i18n/es.json +3 -0
  39. package/src/modules/customers/i18n/ko.json +3 -0
  40. package/src/modules/customers/i18n/pl.json +3 -0
  41. package/src/modules/customers/lib/closureStage.ts +76 -0
  42. package/src/modules/customers/lib/dealStatus.ts +42 -0
  43. 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.enum(['open', 'closed', 'win', 'loose'])).optional(),
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
- const placeholders = parsed.data.status.map(() => '?').join(',')
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(...parsed.data.status)
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
- where.push("expected_close_at < CURRENT_DATE AND status = 'open'")
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 statusList = query.status ? normalizeStringList(query.status) : []
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 — uses saturated icon tokens so the bar is visibly colored, not pale
56
- const ACCENT_TONE_CLASS: Record<FilterOptionTone, string> = {
57
- success: 'bg-status-success-icon',
58
- error: 'bg-status-error-icon',
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'
@@ -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 accepts the historical 4-value enum (`open` | `closed` | `win` |
20
- * `loose`), but the codebase only ever **writes** `loose` for a lost deal — `closed` was a
21
- * latent unused state from an earlier iteration. Exposing a separate "Lost (closed)" filter
22
- * option therefore filtered to nothing and confused operators. We display a single "Lost"
23
- * choice and intentionally map it to the canonical wire value `loose` so the filter matches
24
- * what the rest of the app actually persists. (Renaming the column / migrating the data to
25
- * `lost` is a deeper data-model change tracked separately.)
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 STATUS_OPTIONS: Array<{
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
- dotClass: 'bg-status-success-icon',
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-warning-icon',
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 [draft, setDraft] = React.useState<string[]>(values)
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(values)
66
- }, [open, values])
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
- values.length === 0
145
+ normalizedValues.length === 0
71
146
  ? translateWithFallback(t, 'customers.deals.kanban.filter.all', 'All')
72
- : values
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(value) ? prev.filter((entry) => entry !== value) : [...prev, value],
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={values.length > 0} />
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
- {STATUS_OPTIONS.map((option) => {
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 = translateWithFallback(t, option.labelKey, option.labelFallback)
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
+ }