@open-mercato/core 0.6.3-develop.3651.1.d3a548a5f1 → 0.6.3-develop.3660.1.53b73d5aa4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -2,8 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import Link from 'next/link'
|
|
5
|
-
import {
|
|
5
|
+
import { useRouter } from 'next/navigation'
|
|
6
|
+
import { useQueries, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
7
|
+
import {
|
|
8
|
+
DndContext,
|
|
9
|
+
DragOverlay,
|
|
10
|
+
KeyboardSensor,
|
|
11
|
+
MeasuringStrategy,
|
|
12
|
+
PointerSensor,
|
|
13
|
+
pointerWithin,
|
|
14
|
+
useSensor,
|
|
15
|
+
useSensors,
|
|
16
|
+
type CollisionDetection,
|
|
17
|
+
type DragEndEvent,
|
|
18
|
+
type DragStartEvent,
|
|
19
|
+
} from '@dnd-kit/core'
|
|
20
|
+
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'
|
|
21
|
+
import { ChevronLeft, ChevronRight, Layers, Plus, SlidersHorizontal, Workflow } from 'lucide-react'
|
|
6
22
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
23
|
+
import {
|
|
24
|
+
Breadcrumb,
|
|
25
|
+
BreadcrumbItem,
|
|
26
|
+
BreadcrumbLink,
|
|
27
|
+
BreadcrumbList,
|
|
28
|
+
BreadcrumbPage,
|
|
29
|
+
BreadcrumbSeparator,
|
|
30
|
+
} from '@open-mercato/ui/primitives/breadcrumb'
|
|
31
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
32
|
+
import { IconButton } from '@open-mercato/ui/primitives/icon-button'
|
|
33
|
+
import { SearchInput } from '@open-mercato/ui/primitives/search-input'
|
|
34
|
+
import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
35
|
+
import { ErrorNotice } from '@open-mercato/ui/primitives/ErrorNotice'
|
|
36
|
+
import { EmptyState } from '@open-mercato/ui/primitives/empty-state'
|
|
7
37
|
import {
|
|
8
38
|
Select,
|
|
9
39
|
SelectContent,
|
|
@@ -11,68 +41,168 @@ import {
|
|
|
11
41
|
SelectTrigger,
|
|
12
42
|
SelectValue,
|
|
13
43
|
} from '@open-mercato/ui/primitives/select'
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { ErrorNotice } from '@open-mercato/ui/primitives/ErrorNotice'
|
|
17
|
-
import { EmptyState } from '@open-mercato/ui/primitives/empty-state'
|
|
18
|
-
import { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
44
|
+
import { apiCall, apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
45
|
+
import { useCurrentUserId } from '@open-mercato/ui/backend/utils/useCurrentUserId'
|
|
19
46
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
47
|
+
import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
|
|
48
|
+
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
49
|
+
import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent'
|
|
50
|
+
import type { RowActionItem } from '@open-mercato/ui/backend/RowActions'
|
|
20
51
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
21
52
|
import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
|
|
22
53
|
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
54
|
+
import type { FilterOptionTone } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
55
|
+
import { ViewTabsRow } from './components/ViewTabsRow'
|
|
56
|
+
import { LANE_WIDTH_CLASS } from './components/constants'
|
|
57
|
+
import { useCurrencyDictionary } from '../../../../components/detail/hooks/useCurrencyDictionary'
|
|
58
|
+
import { FilterBarRow, type KanbanFilterChip } from './components/FilterBarRow'
|
|
59
|
+
import { Lane, type LaneStage } from './components/Lane'
|
|
60
|
+
import { LaneCurrencyBreakdown } from './components/LaneCurrencyBreakdown'
|
|
61
|
+
import { CurrencyFilterPopover } from './components/CurrencyFilterPopover'
|
|
62
|
+
import { AddStageLane } from './components/AddStageLane'
|
|
63
|
+
import type { DealCardData } from './components/DealCard'
|
|
64
|
+
import {
|
|
65
|
+
QuickDealDialog,
|
|
66
|
+
type QuickDealContext,
|
|
67
|
+
type QuickDealCompanyOption,
|
|
68
|
+
} from './components/QuickDealDialog'
|
|
69
|
+
import { AddStageDialog, type AddStageContext } from './components/AddStageDialog'
|
|
70
|
+
import { StatusFilterPopover } from './components/StatusFilterPopover'
|
|
71
|
+
import { PipelineFilterPopover } from './components/PipelineFilterPopover'
|
|
72
|
+
import { SortByPopover, type SortOption } from './components/SortByPopover'
|
|
73
|
+
import { EntityFilterPopover, type EntityFilterOption } from './components/EntityFilterPopover'
|
|
74
|
+
import { CloseDateFilterPopover, type CloseDateRange } from './components/CloseDateFilterPopover'
|
|
75
|
+
import { CustomizeViewDialog } from './components/CustomizeViewDialog'
|
|
76
|
+
import {
|
|
77
|
+
ActivityComposerDialog,
|
|
78
|
+
type ActivityComposerContext,
|
|
79
|
+
} from './components/ActivityComposerDialog'
|
|
80
|
+
import { BulkActionsBar } from './components/BulkActionsBar'
|
|
81
|
+
import { ChangeStageDialog } from './components/ChangeStageDialog'
|
|
82
|
+
import { ChangeOwnerDialog } from './components/ChangeOwnerDialog'
|
|
83
|
+
import { buildCrudExportUrl, deleteCrud } from '@open-mercato/ui/backend/utils/crud'
|
|
84
|
+
import { runBulkDelete, groupBulkDeleteFailures } from '@open-mercato/ui/backend/utils/bulkDelete'
|
|
85
|
+
import {
|
|
86
|
+
fetchAssignableStaffMembers,
|
|
87
|
+
type AssignableStaffMember,
|
|
88
|
+
} from '../../../../components/detail/assignableStaff'
|
|
23
89
|
|
|
24
|
-
type
|
|
90
|
+
type PipelineRecord = { id: string; name: string; isDefault: boolean }
|
|
25
91
|
|
|
26
|
-
type
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
valueCurrency: string | null
|
|
35
|
-
probability: number | null
|
|
36
|
-
expectedCloseAt: string | null
|
|
37
|
-
expectedCloseAtTs: number | null
|
|
38
|
-
createdAt: string | null
|
|
39
|
-
createdAtTs: number | null
|
|
40
|
-
updatedAt: string | null
|
|
41
|
-
people: DealAssociation[]
|
|
42
|
-
companies: DealAssociation[]
|
|
92
|
+
type StageAggregateRow = {
|
|
93
|
+
stageId: string
|
|
94
|
+
count: number
|
|
95
|
+
openCount: number
|
|
96
|
+
totalInBaseCurrency: number
|
|
97
|
+
byCurrency: Array<{ currency: string; total: number; count: number }>
|
|
98
|
+
convertedAll?: boolean
|
|
99
|
+
missingRateCurrencies?: string[]
|
|
43
100
|
}
|
|
44
101
|
|
|
45
|
-
type
|
|
46
|
-
|
|
47
|
-
|
|
102
|
+
type StageAggregateResponse = {
|
|
103
|
+
baseCurrencyCode: string | null
|
|
104
|
+
perStage: StageAggregateRow[]
|
|
48
105
|
}
|
|
49
106
|
|
|
50
|
-
type
|
|
107
|
+
type BulkJobResponse = {
|
|
108
|
+
ok: boolean
|
|
109
|
+
progressJobId: string | null
|
|
110
|
+
message?: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type LaneAggregate = {
|
|
114
|
+
count: number
|
|
115
|
+
totalInBaseCurrency: number
|
|
116
|
+
byCurrency: Array<{ currency: string; total: number; count: number }>
|
|
117
|
+
baseCurrencyCode: string | null
|
|
118
|
+
// Both fields default to "best case" so an older aggregate response (without these
|
|
119
|
+
// fields) doesn't trigger spurious "partial conversion" warnings in the UI.
|
|
120
|
+
convertedAll: boolean
|
|
121
|
+
missingRateCurrencies: string[]
|
|
122
|
+
}
|
|
123
|
+
type PipelineStageRecord = {
|
|
51
124
|
id: string
|
|
52
|
-
value: string | null
|
|
53
125
|
label: string
|
|
54
|
-
|
|
55
|
-
|
|
126
|
+
order: number
|
|
127
|
+
pipelineId: string
|
|
128
|
+
// `color` is sourced from the `customer_dictionary_entries` row that backs each stage
|
|
129
|
+
// (kind = 'pipeline_stage'). After the 2026-05-19 hex→tone migration this stores a
|
|
130
|
+
// canonical tone identifier ('success', 'warning', etc.) rather than a hex string.
|
|
131
|
+
color?: string | null
|
|
56
132
|
}
|
|
57
133
|
|
|
58
|
-
type
|
|
134
|
+
type DealApiRecord = Record<string, unknown> & {
|
|
135
|
+
id: string
|
|
136
|
+
title?: string | null
|
|
137
|
+
status?: string | null
|
|
138
|
+
pipeline_stage?: string | null
|
|
139
|
+
pipeline_id?: string | null
|
|
140
|
+
pipeline_stage_id?: string | null
|
|
141
|
+
value_amount?: number | string | null
|
|
142
|
+
value_currency?: string | null
|
|
143
|
+
probability?: number | string | null
|
|
144
|
+
expected_close_at?: string | null
|
|
145
|
+
created_at?: string | null
|
|
146
|
+
updated_at?: string | null
|
|
147
|
+
owner_user_id?: string | null
|
|
148
|
+
companies?: Array<{ id?: unknown; label?: unknown } | null> | null
|
|
149
|
+
people?: Array<{ id?: unknown; label?: unknown } | null> | null
|
|
150
|
+
_pipeline?: {
|
|
151
|
+
openActivitiesCount?: number
|
|
152
|
+
daysInCurrentStage?: number
|
|
153
|
+
isStuck?: boolean
|
|
154
|
+
isOverdue?: boolean
|
|
155
|
+
} | null
|
|
156
|
+
}
|
|
59
157
|
|
|
60
|
-
|
|
61
|
-
|
|
158
|
+
const DEFAULT_SORT: SortOption = 'updated_desc'
|
|
159
|
+
const LANE_PAGE_SIZE = 25
|
|
160
|
+
const FALLBACK_TONES: FilterOptionTone[] = ['success', 'warning', 'error', 'info', 'neutral', 'brand']
|
|
161
|
+
// Module-level singleton so empty lanes get the same array reference every render —
|
|
162
|
+
// React.memo's identity check on the `deals` prop holds across renders.
|
|
163
|
+
const EMPTY_DEAL_ARRAY: DealCardData[] = []
|
|
62
164
|
|
|
63
|
-
|
|
165
|
+
// Lane width / resize configuration (~20% larger than original Figma spec for readability)
|
|
166
|
+
const DEFAULT_LANE_WIDTH = 308
|
|
167
|
+
const MIN_LANE_WIDTH = 240
|
|
168
|
+
const MAX_LANE_WIDTH = 576
|
|
169
|
+
const LANE_GAP = 14
|
|
170
|
+
const LANE_WIDTHS_STORAGE_KEY_PREFIX = 'kanban-lane-widths-v2'
|
|
64
171
|
|
|
65
|
-
|
|
66
|
-
|
|
172
|
+
function loadLaneWidths(scopeKey: string): Record<string, number> {
|
|
173
|
+
if (typeof window === 'undefined') return {}
|
|
174
|
+
try {
|
|
175
|
+
const raw = window.localStorage.getItem(`${LANE_WIDTHS_STORAGE_KEY_PREFIX}:${scopeKey}`)
|
|
176
|
+
if (!raw) return {}
|
|
177
|
+
const parsed = JSON.parse(raw)
|
|
178
|
+
if (!parsed || typeof parsed !== 'object') return {}
|
|
179
|
+
const out: Record<string, number> = {}
|
|
180
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
181
|
+
if (typeof v === 'number' && Number.isFinite(v) && v >= MIN_LANE_WIDTH && v <= MAX_LANE_WIDTH) {
|
|
182
|
+
out[k] = v
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out
|
|
186
|
+
} catch {
|
|
187
|
+
return {}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
67
190
|
|
|
68
|
-
|
|
191
|
+
function saveLaneWidths(scopeKey: string, widths: Record<string, number>) {
|
|
192
|
+
if (typeof window === 'undefined') return
|
|
193
|
+
try {
|
|
194
|
+
if (Object.keys(widths).length === 0) {
|
|
195
|
+
window.localStorage.removeItem(`${LANE_WIDTHS_STORAGE_KEY_PREFIX}:${scopeKey}`)
|
|
196
|
+
} else {
|
|
197
|
+
window.localStorage.setItem(`${LANE_WIDTHS_STORAGE_KEY_PREFIX}:${scopeKey}`, JSON.stringify(widths))
|
|
198
|
+
}
|
|
199
|
+
} catch {}
|
|
200
|
+
}
|
|
69
201
|
|
|
70
202
|
function normalizeAmount(value: unknown): number | null {
|
|
71
203
|
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
72
|
-
if (typeof value === 'string') {
|
|
73
|
-
const
|
|
74
|
-
if (!trimmed.length) return null
|
|
75
|
-
const parsed = Number(trimmed)
|
|
204
|
+
if (typeof value === 'string' && value.trim().length) {
|
|
205
|
+
const parsed = Number(value)
|
|
76
206
|
return Number.isFinite(parsed) ? parsed : null
|
|
77
207
|
}
|
|
78
208
|
return null
|
|
@@ -81,133 +211,241 @@ function normalizeAmount(value: unknown): number | null {
|
|
|
81
211
|
function normalizeProbability(value: unknown): number | null {
|
|
82
212
|
const parsed = normalizeAmount(value)
|
|
83
213
|
if (parsed === null) return null
|
|
84
|
-
|
|
85
|
-
if (parsed > 100) return 100
|
|
86
|
-
return Math.round(parsed)
|
|
214
|
+
return Math.min(Math.max(Math.round(parsed), 0), 100)
|
|
87
215
|
}
|
|
88
216
|
|
|
89
|
-
function
|
|
90
|
-
if (typeof value !== 'string' || !value.trim().length) return
|
|
217
|
+
function normalizeIso(value: unknown): string | null {
|
|
218
|
+
if (typeof value !== 'string' || !value.trim().length) return null
|
|
91
219
|
const date = new Date(value)
|
|
92
|
-
|
|
93
|
-
return { iso: date.toISOString(), ts: date.getTime() }
|
|
220
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString()
|
|
94
221
|
}
|
|
95
222
|
|
|
96
|
-
function
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
)
|
|
101
|
-
const
|
|
102
|
-
.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
id: stage.id,
|
|
106
|
-
value: stage.id,
|
|
107
|
-
label: stage.label,
|
|
108
|
-
color: null,
|
|
109
|
-
icon: null,
|
|
110
|
-
}))
|
|
111
|
-
|
|
112
|
-
const knownIds = new Set(pipelineStages.map((s) => s.id))
|
|
113
|
-
const hasUnassigned = deals.some((deal) => !deal.pipelineStageId || !knownIds.has(deal.pipelineStageId))
|
|
114
|
-
if (hasUnassigned) {
|
|
115
|
-
result.push({
|
|
116
|
-
id: 'stage:__unassigned',
|
|
117
|
-
value: null,
|
|
118
|
-
label: translateWithFallback(t, 'customers.deals.pipeline.unassigned', 'No stage'),
|
|
119
|
-
color: null,
|
|
120
|
-
icon: null,
|
|
121
|
-
})
|
|
122
|
-
}
|
|
223
|
+
function normalizeAssociation(entry: unknown): { id: string; label: string } | null {
|
|
224
|
+
if (!entry || typeof entry !== 'object') return null
|
|
225
|
+
const ref = entry as Record<string, unknown>
|
|
226
|
+
const id = typeof ref.id === 'string' ? ref.id : null
|
|
227
|
+
if (!id) return null
|
|
228
|
+
const label =
|
|
229
|
+
typeof ref.label === 'string' && ref.label.trim().length ? ref.label.trim() : id
|
|
230
|
+
return { id, label }
|
|
231
|
+
}
|
|
123
232
|
|
|
124
|
-
|
|
233
|
+
function mapDealRecord(item: DealApiRecord, fallbackTitle: string): DealCardData | null {
|
|
234
|
+
const id = typeof item.id === 'string' ? item.id : null
|
|
235
|
+
if (!id) return null
|
|
236
|
+
const title =
|
|
237
|
+
typeof item.title === 'string' && item.title.trim().length ? item.title.trim() : fallbackTitle
|
|
238
|
+
const status =
|
|
239
|
+
typeof item.status === 'string' && item.status.trim().length ? item.status.trim() : null
|
|
240
|
+
const valueAmount = normalizeAmount(item.value_amount)
|
|
241
|
+
const valueCurrency =
|
|
242
|
+
typeof item.value_currency === 'string' && item.value_currency.trim().length
|
|
243
|
+
? item.value_currency.trim().toUpperCase()
|
|
244
|
+
: null
|
|
245
|
+
const probability = normalizeProbability(item.probability)
|
|
246
|
+
const expectedCloseAt = normalizeIso(item.expected_close_at)
|
|
247
|
+
const createdAt = normalizeIso(item.created_at)
|
|
248
|
+
const updatedAt = normalizeIso(item.updated_at)
|
|
249
|
+
const ownerUserId =
|
|
250
|
+
typeof item.owner_user_id === 'string' && item.owner_user_id.trim().length
|
|
251
|
+
? item.owner_user_id
|
|
252
|
+
: null
|
|
253
|
+
const companies = Array.isArray(item.companies)
|
|
254
|
+
? (item.companies.map(normalizeAssociation).filter(Boolean) as { id: string; label: string }[])
|
|
255
|
+
: []
|
|
256
|
+
const primaryCompany = companies[0] ?? null
|
|
257
|
+
const pipelineState = {
|
|
258
|
+
openActivitiesCount:
|
|
259
|
+
typeof item._pipeline?.openActivitiesCount === 'number' ? item._pipeline.openActivitiesCount : 0,
|
|
260
|
+
daysInCurrentStage:
|
|
261
|
+
typeof item._pipeline?.daysInCurrentStage === 'number' ? item._pipeline.daysInCurrentStage : 0,
|
|
262
|
+
isStuck: !!item._pipeline?.isStuck,
|
|
263
|
+
isOverdue: !!item._pipeline?.isOverdue,
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
id,
|
|
267
|
+
title,
|
|
268
|
+
status,
|
|
269
|
+
valueAmount,
|
|
270
|
+
valueCurrency,
|
|
271
|
+
probability,
|
|
272
|
+
expectedCloseAt,
|
|
273
|
+
createdAt,
|
|
274
|
+
updatedAt,
|
|
275
|
+
owner: ownerUserId ? { userId: ownerUserId, label: '' } : null,
|
|
276
|
+
primaryCompany,
|
|
277
|
+
pipelineState,
|
|
278
|
+
}
|
|
125
279
|
}
|
|
126
280
|
|
|
127
|
-
|
|
128
|
-
|
|
281
|
+
// Canonical tone identifiers stored in `customer_dictionary_entries.color` after the
|
|
282
|
+
// 2026-05-19 hex→tone migration. Keep in sync with `TONE_OPTIONS` in `AddStageDialog.tsx`.
|
|
283
|
+
const KNOWN_STAGE_TONES: ReadonlySet<string> = new Set([
|
|
284
|
+
'success',
|
|
285
|
+
'warning',
|
|
286
|
+
'info',
|
|
287
|
+
'error',
|
|
288
|
+
'neutral',
|
|
289
|
+
'brand',
|
|
290
|
+
'pink',
|
|
291
|
+
])
|
|
292
|
+
|
|
293
|
+
function buildLaneStages(
|
|
294
|
+
stages: PipelineStageRecord[],
|
|
295
|
+
unassignedLabel: string,
|
|
296
|
+
hasUnassigned: boolean,
|
|
297
|
+
): LaneStage[] {
|
|
298
|
+
const sorted = stages.slice().sort((a, b) => a.order - b.order)
|
|
299
|
+
const lanes: LaneStage[] = sorted.map((stage, index) => ({
|
|
300
|
+
id: stage.id,
|
|
301
|
+
label: stage.label,
|
|
302
|
+
// Prefer the stage's persisted tone (now stored as a canonical identifier post-migration)
|
|
303
|
+
// so colors picked by operators in AddStageDialog actually show up on the kanban. Falls
|
|
304
|
+
// back to the position-based rotation for stages with no color set or unmapped legacy
|
|
305
|
+
// values that survived the migration's neutral-fallback arm.
|
|
306
|
+
tone:
|
|
307
|
+
stage.color && KNOWN_STAGE_TONES.has(stage.color)
|
|
308
|
+
? (stage.color as FilterOptionTone)
|
|
309
|
+
: FALLBACK_TONES[index % FALLBACK_TONES.length],
|
|
310
|
+
}))
|
|
311
|
+
if (hasUnassigned) {
|
|
312
|
+
lanes.push({ id: '__unassigned', label: unassignedLabel, tone: 'neutral' })
|
|
313
|
+
}
|
|
314
|
+
return lanes
|
|
129
315
|
}
|
|
130
316
|
|
|
131
|
-
function groupDealsByStageId(deals:
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
const stageKey = deal.
|
|
135
|
-
const bucket =
|
|
317
|
+
function groupDealsByStageId(deals: DealCardData[], stageIdByDealId: Map<string, string>): Map<string, DealCardData[]> {
|
|
318
|
+
const grouped = new Map<string, DealCardData[]>()
|
|
319
|
+
for (const deal of deals) {
|
|
320
|
+
const stageKey = stageIdByDealId.get(deal.id) ?? '__unassigned'
|
|
321
|
+
const bucket = grouped.get(stageKey) ?? []
|
|
136
322
|
bucket.push(deal)
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
return
|
|
323
|
+
grouped.set(stageKey, bucket)
|
|
324
|
+
}
|
|
325
|
+
return grouped
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Translate the UI SortOption into the deals CRUD API's `sortField` / `sortDir` query params.
|
|
330
|
+
*
|
|
331
|
+
* Returns `null` for `owner_asc` because the deals table only stores `owner_user_id` (a UUID);
|
|
332
|
+
* the UI displays a resolved owner name, so a UUID-alphabetical server sort would be misleading.
|
|
333
|
+
* For that case the caller falls back to a sensible default sort server-side and re-sorts the
|
|
334
|
+
* page client-side via `sortDeals`. All other options sort server-side over the full result set,
|
|
335
|
+
* so paging (25/lane → Show more) keeps a globally correct order.
|
|
336
|
+
*/
|
|
337
|
+
function mapSortOptionToApi(option: SortOption): { sortField: string; sortDir: 'asc' | 'desc' } | null {
|
|
338
|
+
switch (option) {
|
|
339
|
+
case 'updated_desc':
|
|
340
|
+
return { sortField: 'updatedAt', sortDir: 'desc' }
|
|
341
|
+
case 'updated_asc':
|
|
342
|
+
return { sortField: 'updatedAt', sortDir: 'asc' }
|
|
343
|
+
case 'created_desc':
|
|
344
|
+
return { sortField: 'createdAt', sortDir: 'desc' }
|
|
345
|
+
case 'value_desc':
|
|
346
|
+
return { sortField: 'value', sortDir: 'desc' }
|
|
347
|
+
case 'value_asc':
|
|
348
|
+
return { sortField: 'value', sortDir: 'asc' }
|
|
349
|
+
case 'probability_desc':
|
|
350
|
+
return { sortField: 'probability', sortDir: 'desc' }
|
|
351
|
+
case 'close_asc':
|
|
352
|
+
return { sortField: 'expectedCloseAt', sortDir: 'asc' }
|
|
353
|
+
case 'owner_asc':
|
|
354
|
+
default:
|
|
355
|
+
return null
|
|
356
|
+
}
|
|
140
357
|
}
|
|
141
358
|
|
|
142
|
-
function sortDeals(deals:
|
|
143
|
-
const sorted =
|
|
359
|
+
function sortDeals(deals: DealCardData[], option: SortOption): DealCardData[] {
|
|
360
|
+
const sorted = deals.slice()
|
|
144
361
|
sorted.sort((a, b) => {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
362
|
+
switch (option) {
|
|
363
|
+
case 'value_desc':
|
|
364
|
+
case 'value_asc': {
|
|
365
|
+
const aValue = typeof a.valueAmount === 'number' ? a.valueAmount : Number.NEGATIVE_INFINITY
|
|
366
|
+
const bValue = typeof b.valueAmount === 'number' ? b.valueAmount : Number.NEGATIVE_INFINITY
|
|
367
|
+
return option === 'value_desc' ? bValue - aValue : aValue - bValue
|
|
368
|
+
}
|
|
369
|
+
case 'probability_desc': {
|
|
370
|
+
const aProb = typeof a.probability === 'number' ? a.probability : -1
|
|
371
|
+
const bProb = typeof b.probability === 'number' ? b.probability : -1
|
|
372
|
+
return bProb - aProb
|
|
373
|
+
}
|
|
374
|
+
case 'close_asc': {
|
|
375
|
+
const aTs = a.expectedCloseAt ? new Date(a.expectedCloseAt).getTime() : Number.POSITIVE_INFINITY
|
|
376
|
+
const bTs = b.expectedCloseAt ? new Date(b.expectedCloseAt).getTime() : Number.POSITIVE_INFINITY
|
|
377
|
+
return aTs - bTs
|
|
378
|
+
}
|
|
379
|
+
case 'created_desc': {
|
|
380
|
+
const aTs = a.createdAt ? new Date(a.createdAt).getTime() : 0
|
|
381
|
+
const bTs = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
|
382
|
+
return bTs - aTs
|
|
383
|
+
}
|
|
384
|
+
case 'owner_asc': {
|
|
385
|
+
// Owner sort is client-side only: deals carry `owner.userId` (UUID), and the human-readable
|
|
386
|
+
// label is resolved separately via ownerNamesById. Compare on the resolved label that was
|
|
387
|
+
// already merged into deal.owner.label by `dealsByStage`'s ownerNamesById pass.
|
|
388
|
+
const aLabel = a.owner?.label ?? a.owner?.userId ?? ''
|
|
389
|
+
const bLabel = b.owner?.label ?? b.owner?.userId ?? ''
|
|
390
|
+
return aLabel.localeCompare(bLabel)
|
|
391
|
+
}
|
|
392
|
+
case 'updated_asc':
|
|
393
|
+
return (a.updatedAt ?? a.createdAt ?? '').localeCompare(b.updatedAt ?? b.createdAt ?? '')
|
|
394
|
+
case 'updated_desc':
|
|
395
|
+
default:
|
|
396
|
+
return (b.updatedAt ?? b.createdAt ?? '').localeCompare(a.updatedAt ?? a.createdAt ?? '')
|
|
161
397
|
}
|
|
162
|
-
return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })
|
|
163
398
|
})
|
|
164
399
|
return sorted
|
|
165
400
|
}
|
|
166
401
|
|
|
167
|
-
function
|
|
168
|
-
if (amount === null || Number.isNaN(amount)) return fallback
|
|
169
|
-
const code = currency && currency.length === 3 ? currency.toUpperCase() : 'USD'
|
|
170
|
-
try {
|
|
171
|
-
return new Intl.NumberFormat(undefined, {
|
|
172
|
-
style: 'currency',
|
|
173
|
-
currency: code,
|
|
174
|
-
maximumFractionDigits: 2,
|
|
175
|
-
}).format(amount)
|
|
176
|
-
} catch {
|
|
177
|
-
return `${code} ${amount.toFixed(2)}`
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function formatProbability(probability: number | null, fallback: string): string {
|
|
182
|
-
if (typeof probability !== 'number' || Number.isNaN(probability)) return fallback
|
|
183
|
-
return `${probability}%`
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export default function SalesPipelinePage(): React.ReactElement {
|
|
402
|
+
export default function DealsKanbanPage(): React.ReactElement {
|
|
187
403
|
const t = useT()
|
|
188
|
-
const
|
|
189
|
-
(key: string, fallback: string, params?: Record<string, string | number>) => {
|
|
190
|
-
const value = translateWithFallback(t, key, fallback, params)
|
|
191
|
-
if (value === fallback && params) {
|
|
192
|
-
return fallback.replace(/\{\{(\w+)\}\}|\{(\w+)\}/g, (match, doubleToken, singleToken) => {
|
|
193
|
-
const token = (doubleToken ?? singleToken) as string | undefined
|
|
194
|
-
if (!token) return match
|
|
195
|
-
const replacement = params[token]
|
|
196
|
-
if (replacement === undefined) {
|
|
197
|
-
return doubleToken ? `{{${token}}}` : `{${token}}`
|
|
198
|
-
}
|
|
199
|
-
return String(replacement)
|
|
200
|
-
})
|
|
201
|
-
}
|
|
202
|
-
return value
|
|
203
|
-
},
|
|
204
|
-
[t],
|
|
205
|
-
)
|
|
404
|
+
const router = useRouter()
|
|
206
405
|
const scopeVersion = useOrganizationScopeVersion()
|
|
207
406
|
const queryClient = useQueryClient()
|
|
208
|
-
|
|
209
|
-
const [pendingDealId, setPendingDealId] = React.useState<string | null>(null)
|
|
407
|
+
|
|
210
408
|
const [selectedPipelineId, setSelectedPipelineId] = React.useState<string | null>(null)
|
|
409
|
+
const [search, setSearch] = React.useState('')
|
|
410
|
+
const [sortBy, setSortBy] = React.useState<SortOption>(DEFAULT_SORT)
|
|
411
|
+
const [statusFilters, setStatusFilters] = React.useState<string[]>([])
|
|
412
|
+
const [ownerFilters, setOwnerFilters] = React.useState<string[]>([])
|
|
413
|
+
const [peopleFilters, setPeopleFilters] = React.useState<string[]>([])
|
|
414
|
+
const [companyFilters, setCompanyFilters] = React.useState<string[]>([])
|
|
415
|
+
const [closeDateFilter, setCloseDateFilter] = React.useState<CloseDateRange>({ from: null, to: null })
|
|
416
|
+
// Currency filter narrows visible deal cards (list query) but DOES NOT narrow the aggregate
|
|
417
|
+
// query — lane headers + the Currency popover keep the full breakdown so the operator can
|
|
418
|
+
// see what they're filtering for and change selection without losing context. `null` = "All
|
|
419
|
+
// currencies" sentinel from the Figma popover (1251:699).
|
|
420
|
+
const [currencyFilter, setCurrencyFilter] = React.useState<string | null>(null)
|
|
421
|
+
// Cache labels for selected ids so the chip can show readable text without re-fetching
|
|
422
|
+
const [ownerLabels, setOwnerLabels] = React.useState<Record<string, string>>({})
|
|
423
|
+
const [peopleLabels, setPeopleLabels] = React.useState<Record<string, string>>({})
|
|
424
|
+
const [companyLabels, setCompanyLabels] = React.useState<Record<string, string>>({})
|
|
425
|
+
const [selectedDealIds, setSelectedDealIds] = React.useState<Set<string>>(new Set())
|
|
426
|
+
const [pendingDealId, setPendingDealId] = React.useState<string | null>(null)
|
|
427
|
+
const [quickDealContext, setQuickDealContext] = React.useState<QuickDealContext | null>(null)
|
|
428
|
+
const [addStageContext, setAddStageContext] = React.useState<AddStageContext | null>(null)
|
|
429
|
+
const [customizeOpen, setCustomizeOpen] = React.useState(false)
|
|
430
|
+
const [activityContext, setActivityContext] = React.useState<ActivityComposerContext | null>(null)
|
|
431
|
+
const [changeStageOpen, setChangeStageOpen] = React.useState(false)
|
|
432
|
+
const [changeOwnerOpen, setChangeOwnerOpen] = React.useState(false)
|
|
433
|
+
const [isBulkMutating, setIsBulkMutating] = React.useState(false)
|
|
434
|
+
const [singleMoveStageContext, setSingleMoveStageContext] = React.useState<
|
|
435
|
+
{ dealId: string; currentStageId: string | null } | null
|
|
436
|
+
>(null)
|
|
437
|
+
const { confirm, ConfirmDialogElement } = useConfirmDialog()
|
|
438
|
+
|
|
439
|
+
const fallbackTitle = translateWithFallback(
|
|
440
|
+
t,
|
|
441
|
+
'customers.deals.pipeline.untitled',
|
|
442
|
+
'Untitled deal',
|
|
443
|
+
)
|
|
444
|
+
const unassignedLabel = translateWithFallback(
|
|
445
|
+
t,
|
|
446
|
+
'customers.deals.pipeline.unassigned',
|
|
447
|
+
'No stage',
|
|
448
|
+
)
|
|
211
449
|
|
|
212
450
|
const pipelinesQuery = useQuery<PipelineRecord[]>({
|
|
213
451
|
queryKey: ['customers', 'pipelines', `scope:${scopeVersion}`],
|
|
@@ -216,7 +454,13 @@ export default function SalesPipelinePage(): React.ReactElement {
|
|
|
216
454
|
const payload = await readApiResultOrThrow<{ items: PipelineRecord[] }>(
|
|
217
455
|
'/api/customers/pipelines',
|
|
218
456
|
undefined,
|
|
219
|
-
{
|
|
457
|
+
{
|
|
458
|
+
errorMessage: translateWithFallback(
|
|
459
|
+
t,
|
|
460
|
+
'customers.deals.pipeline.loadError',
|
|
461
|
+
'Failed to load pipelines.',
|
|
462
|
+
),
|
|
463
|
+
},
|
|
220
464
|
)
|
|
221
465
|
return payload?.items ?? []
|
|
222
466
|
},
|
|
@@ -226,484 +470,2531 @@ export default function SalesPipelinePage(): React.ReactElement {
|
|
|
226
470
|
if (selectedPipelineId) return
|
|
227
471
|
const pipelines = pipelinesQuery.data
|
|
228
472
|
if (!pipelines || !pipelines.length) return
|
|
229
|
-
const defaultPipeline = pipelines.find((
|
|
473
|
+
const defaultPipeline = pipelines.find((pipeline) => pipeline.isDefault) ?? pipelines[0]
|
|
230
474
|
if (defaultPipeline) setSelectedPipelineId(defaultPipeline.id)
|
|
231
475
|
}, [pipelinesQuery.data, selectedPipelineId])
|
|
232
476
|
|
|
477
|
+
const staffQuery = useQuery<AssignableStaffMember[]>({
|
|
478
|
+
queryKey: ['customers', 'deals', 'kanban', 'staff', `scope:${scopeVersion}`],
|
|
479
|
+
staleTime: 300_000,
|
|
480
|
+
queryFn: async () => fetchAssignableStaffMembers('', { pageSize: 100 }),
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
const ownerNamesById = React.useMemo(() => {
|
|
484
|
+
const map = new Map<string, string>()
|
|
485
|
+
for (const member of staffQuery.data ?? []) {
|
|
486
|
+
if (member.userId && member.displayName) map.set(member.userId, member.displayName)
|
|
487
|
+
}
|
|
488
|
+
return map
|
|
489
|
+
}, [staffQuery.data])
|
|
490
|
+
|
|
491
|
+
const currentUserId = useCurrentUserId()
|
|
492
|
+
const currentUserLabel = React.useMemo(() => {
|
|
493
|
+
if (!currentUserId) return undefined
|
|
494
|
+
return ownerNamesById.get(currentUserId)
|
|
495
|
+
}, [currentUserId, ownerNamesById])
|
|
496
|
+
|
|
497
|
+
// Per-user, per-pipeline persisted lane widths. localStorage is per-browser per-user — exactly
|
|
498
|
+
// matches the "remember my preferred column widths" UX from Monday / Asana / ClickUp.
|
|
499
|
+
const laneWidthsScopeKey = React.useMemo(
|
|
500
|
+
() => `${currentUserId ?? 'anon'}:${selectedPipelineId ?? 'none'}`,
|
|
501
|
+
[currentUserId, selectedPipelineId],
|
|
502
|
+
)
|
|
503
|
+
const [laneWidths, setLaneWidths] = React.useState<Record<string, number>>({})
|
|
504
|
+
// Hydrate from localStorage when the scope (user / pipeline) changes
|
|
505
|
+
React.useEffect(() => {
|
|
506
|
+
setLaneWidths(loadLaneWidths(laneWidthsScopeKey))
|
|
507
|
+
}, [laneWidthsScopeKey])
|
|
508
|
+
// Persist on change (debounced via setTimeout to coalesce drag-frame updates)
|
|
509
|
+
React.useEffect(() => {
|
|
510
|
+
const handle = window.setTimeout(() => saveLaneWidths(laneWidthsScopeKey, laneWidths), 250)
|
|
511
|
+
return () => window.clearTimeout(handle)
|
|
512
|
+
}, [laneWidthsScopeKey, laneWidths])
|
|
513
|
+
|
|
514
|
+
const handleLaneResize = React.useCallback(
|
|
515
|
+
(stageId: string, deltaPx: number) => {
|
|
516
|
+
setLaneWidths((prev) => {
|
|
517
|
+
const current = prev[stageId] ?? DEFAULT_LANE_WIDTH
|
|
518
|
+
const next = Math.max(MIN_LANE_WIDTH, Math.min(MAX_LANE_WIDTH, current + deltaPx))
|
|
519
|
+
if (next === current) return prev
|
|
520
|
+
return { ...prev, [stageId]: next }
|
|
521
|
+
})
|
|
522
|
+
},
|
|
523
|
+
[],
|
|
524
|
+
)
|
|
525
|
+
const handleResetLaneWidth = React.useCallback((stageId: string) => {
|
|
526
|
+
setLaneWidths((prev) => {
|
|
527
|
+
if (!(stageId in prev)) return prev
|
|
528
|
+
const { [stageId]: _omit, ...rest } = prev
|
|
529
|
+
return rest
|
|
530
|
+
})
|
|
531
|
+
}, [])
|
|
532
|
+
const handleResetAllLaneWidths = React.useCallback(() => {
|
|
533
|
+
setLaneWidths({})
|
|
534
|
+
}, [])
|
|
535
|
+
|
|
536
|
+
// Tenant currency list for the Quick deal dialog. The currencies module is the source
|
|
537
|
+
// of truth (it backs `LaneAggregateProp.baseCurrencyCode` already); we used to hardcode
|
|
538
|
+
// `['PLN','EUR','USD','GBP']` in the dialog which excluded any tenant operating in CZK,
|
|
539
|
+
// SEK, HUF, BRL, etc. from quick-add.
|
|
540
|
+
const currencyDictionary = useCurrencyDictionary()
|
|
541
|
+
|
|
542
|
+
const companiesQuery = useQuery<QuickDealCompanyOption[]>({
|
|
543
|
+
queryKey: ['customers', 'companies', 'kanban-quick-deal', `scope:${scopeVersion}`],
|
|
544
|
+
staleTime: 300_000,
|
|
545
|
+
queryFn: async () => {
|
|
546
|
+
const call = await apiCall<{ items?: Array<{ id?: unknown; display_name?: unknown }> }>(
|
|
547
|
+
'/api/customers/companies?page=1&pageSize=100&sortField=display_name&sortDir=asc',
|
|
548
|
+
)
|
|
549
|
+
if (!call.ok) return []
|
|
550
|
+
const items = Array.isArray(call.result?.items) ? call.result!.items! : []
|
|
551
|
+
const options: QuickDealCompanyOption[] = []
|
|
552
|
+
for (const item of items) {
|
|
553
|
+
const id = typeof item.id === 'string' ? item.id : null
|
|
554
|
+
const displayName =
|
|
555
|
+
typeof item.display_name === 'string' && item.display_name.trim().length
|
|
556
|
+
? item.display_name.trim()
|
|
557
|
+
: null
|
|
558
|
+
if (id && displayName) options.push({ id, label: displayName })
|
|
559
|
+
}
|
|
560
|
+
return options
|
|
561
|
+
},
|
|
562
|
+
})
|
|
563
|
+
|
|
233
564
|
const stagesQuery = useQuery<PipelineStageRecord[]>({
|
|
234
565
|
queryKey: ['customers', 'pipeline-stages', `scope:${scopeVersion}`, `pipeline:${selectedPipelineId}`],
|
|
235
566
|
enabled: !!selectedPipelineId,
|
|
236
567
|
staleTime: 30_000,
|
|
237
568
|
queryFn: async () => {
|
|
569
|
+
if (!selectedPipelineId) return []
|
|
238
570
|
const payload = await readApiResultOrThrow<{ items: PipelineStageRecord[] }>(
|
|
239
|
-
`/api/customers/pipeline-stages?pipelineId=${encodeURIComponent(selectedPipelineId
|
|
571
|
+
`/api/customers/pipeline-stages?pipelineId=${encodeURIComponent(selectedPipelineId)}`,
|
|
240
572
|
undefined,
|
|
241
|
-
{
|
|
573
|
+
{
|
|
574
|
+
errorMessage: translateWithFallback(
|
|
575
|
+
t,
|
|
576
|
+
'customers.deals.pipeline.loadError',
|
|
577
|
+
'Failed to load stages.',
|
|
578
|
+
),
|
|
579
|
+
},
|
|
242
580
|
)
|
|
243
581
|
return payload?.items ?? []
|
|
244
582
|
},
|
|
245
583
|
})
|
|
246
584
|
|
|
247
|
-
|
|
585
|
+
// Per-lane "Show more" state. Each lane's first 25 are loaded via useQueries; subsequent pages are
|
|
586
|
+
// fetched on demand and appended here, so the cached page-1 cards stay in place (no full lane refetch).
|
|
587
|
+
const [extraCardsByStage, setExtraCardsByStage] = React.useState<Record<string, DealApiRecord[]>>({})
|
|
588
|
+
const [loadingMoreByStage, setLoadingMoreByStage] = React.useState<Record<string, boolean>>({})
|
|
589
|
+
// Reset extra-cards + loading state when any filter changes
|
|
590
|
+
React.useEffect(() => {
|
|
591
|
+
setExtraCardsByStage({})
|
|
592
|
+
setLoadingMoreByStage({})
|
|
593
|
+
}, [selectedPipelineId, search, statusFilters, ownerFilters, peopleFilters, companyFilters, closeDateFilter, currencyFilter])
|
|
248
594
|
|
|
249
|
-
|
|
250
|
-
|
|
595
|
+
// Filter signature shared by every per-lane query so all lanes invalidate together when filters change
|
|
596
|
+
const filterSignature = React.useMemo(
|
|
597
|
+
() => ({
|
|
598
|
+
pipelineId: selectedPipelineId,
|
|
599
|
+
search: search.trim(),
|
|
600
|
+
status: statusFilters.slice().sort().join(','),
|
|
601
|
+
owners: ownerFilters.slice().sort().join(','),
|
|
602
|
+
people: peopleFilters.slice().sort().join(','),
|
|
603
|
+
companies: companyFilters.slice().sort().join(','),
|
|
604
|
+
closeFrom: closeDateFilter.from ?? '',
|
|
605
|
+
closeTo: closeDateFilter.to ?? '',
|
|
606
|
+
currency: currencyFilter ?? '',
|
|
607
|
+
}),
|
|
608
|
+
[selectedPipelineId, search, statusFilters, ownerFilters, peopleFilters, companyFilters, closeDateFilter, currencyFilter],
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
// Aggregate query — gives accurate per-stage counts + totals (in base currency) across the entire pipeline,
|
|
612
|
+
// not just the loaded slice. Drives both lane headers and pagination decisions.
|
|
613
|
+
const aggregateQuery = useQuery<StageAggregateResponse | null>({
|
|
614
|
+
queryKey: [
|
|
615
|
+
'customers',
|
|
616
|
+
'deals',
|
|
617
|
+
'kanban-aggregate',
|
|
618
|
+
`scope:${scopeVersion}`,
|
|
619
|
+
`pipeline:${selectedPipelineId ?? 'none'}`,
|
|
620
|
+
`search:${filterSignature.search}`,
|
|
621
|
+
`status:${filterSignature.status}`,
|
|
622
|
+
`owners:${filterSignature.owners}`,
|
|
623
|
+
`people:${filterSignature.people}`,
|
|
624
|
+
`companies:${filterSignature.companies}`,
|
|
625
|
+
`close:${filterSignature.closeFrom}-${filterSignature.closeTo}`,
|
|
626
|
+
],
|
|
251
627
|
enabled: !!selectedPipelineId,
|
|
252
628
|
staleTime: 30_000,
|
|
253
629
|
queryFn: async () => {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
search.set('
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
630
|
+
if (!selectedPipelineId) return null
|
|
631
|
+
const params = new URLSearchParams()
|
|
632
|
+
params.set('pipelineId', selectedPipelineId)
|
|
633
|
+
if (filterSignature.search.length) params.set('search', filterSignature.search)
|
|
634
|
+
for (const status of statusFilters) params.append('status', status)
|
|
635
|
+
for (const ownerId of ownerFilters) params.append('ownerUserId', ownerId)
|
|
636
|
+
for (const personId of peopleFilters) params.append('personId', personId)
|
|
637
|
+
for (const companyId of companyFilters) params.append('companyId', companyId)
|
|
638
|
+
if (closeDateFilter.from) params.set('expectedCloseAtFrom', closeDateFilter.from)
|
|
639
|
+
if (closeDateFilter.to) params.set('expectedCloseAtTo', closeDateFilter.to)
|
|
640
|
+
const call = await apiCall<StageAggregateResponse>(
|
|
641
|
+
`/api/customers/deals/aggregate?${params.toString()}`,
|
|
264
642
|
)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
typeof data.value_currency === 'string' && data.value_currency.trim().length
|
|
285
|
-
? data.value_currency.trim().toUpperCase()
|
|
286
|
-
: null
|
|
287
|
-
const probability = normalizeProbability(data.probability)
|
|
288
|
-
const expected = normalizeTimestamp(data.expected_close_at)
|
|
289
|
-
const created = normalizeTimestamp(data.created_at)
|
|
290
|
-
const updated = normalizeTimestamp(data.updated_at)
|
|
291
|
-
const rawPeople = Array.isArray(data.people) ? data.people : []
|
|
292
|
-
const people: DealAssociation[] = rawPeople
|
|
293
|
-
.map((entry) => {
|
|
294
|
-
if (!entry || typeof entry !== 'object') return null
|
|
295
|
-
const ref = entry as Record<string, unknown>
|
|
296
|
-
const personId = typeof ref.id === 'string' ? ref.id : null
|
|
297
|
-
if (!personId) return null
|
|
298
|
-
const label =
|
|
299
|
-
typeof ref.label === 'string' && ref.label.trim().length
|
|
300
|
-
? ref.label.trim()
|
|
301
|
-
: personId
|
|
302
|
-
return { id: personId, label }
|
|
303
|
-
})
|
|
304
|
-
.filter((entry): entry is DealAssociation => !!entry)
|
|
305
|
-
const rawCompanies = Array.isArray(data.companies) ? data.companies : []
|
|
306
|
-
const companies: DealAssociation[] = rawCompanies
|
|
307
|
-
.map((entry) => {
|
|
308
|
-
if (!entry || typeof entry !== 'object') return null
|
|
309
|
-
const ref = entry as Record<string, unknown>
|
|
310
|
-
const companyId = typeof ref.id === 'string' ? ref.id : null
|
|
311
|
-
if (!companyId) return null
|
|
312
|
-
const label =
|
|
313
|
-
typeof ref.label === 'string' && ref.label.trim().length
|
|
314
|
-
? ref.label.trim()
|
|
315
|
-
: companyId
|
|
316
|
-
return { id: companyId, label }
|
|
317
|
-
})
|
|
318
|
-
.filter((entry): entry is DealAssociation => !!entry)
|
|
319
|
-
deals.push({
|
|
320
|
-
id,
|
|
321
|
-
title,
|
|
322
|
-
status,
|
|
323
|
-
pipelineStage: stage,
|
|
324
|
-
pipelineId: typeof data.pipeline_id === 'string' ? data.pipeline_id : null,
|
|
325
|
-
pipelineStageId: typeof data.pipeline_stage_id === 'string' ? data.pipeline_stage_id : null,
|
|
326
|
-
valueAmount: amount,
|
|
327
|
-
valueCurrency: currency,
|
|
328
|
-
probability,
|
|
329
|
-
expectedCloseAt: expected.iso,
|
|
330
|
-
expectedCloseAtTs: expected.ts,
|
|
331
|
-
createdAt: created.iso,
|
|
332
|
-
createdAtTs: created.ts,
|
|
333
|
-
updatedAt: updated.iso,
|
|
334
|
-
people,
|
|
335
|
-
companies,
|
|
336
|
-
})
|
|
643
|
+
return call.ok ? call.result ?? null : null
|
|
644
|
+
},
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
const aggregateByStage = React.useMemo(() => {
|
|
648
|
+
const map = new Map<string, LaneAggregate>()
|
|
649
|
+
const data = aggregateQuery.data
|
|
650
|
+
if (!data) return map
|
|
651
|
+
for (const row of data.perStage) {
|
|
652
|
+
map.set(row.stageId, {
|
|
653
|
+
count: row.count,
|
|
654
|
+
totalInBaseCurrency: row.totalInBaseCurrency,
|
|
655
|
+
byCurrency: row.byCurrency,
|
|
656
|
+
baseCurrencyCode: data.baseCurrencyCode,
|
|
657
|
+
// Default to "complete conversion" when the field is absent (older response, or no
|
|
658
|
+
// base currency case where the route still returns the field). The aggregate route
|
|
659
|
+
// explicitly sets `convertedAll: false` when at least one currency was excluded.
|
|
660
|
+
convertedAll: row.convertedAll ?? true,
|
|
661
|
+
missingRateCurrencies: row.missingRateCurrencies ?? [],
|
|
337
662
|
})
|
|
663
|
+
}
|
|
664
|
+
return map
|
|
665
|
+
}, [aggregateQuery.data])
|
|
338
666
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
667
|
+
const stagesData = stagesQuery.data ?? []
|
|
668
|
+
const aggregateData = aggregateQuery.data
|
|
669
|
+
|
|
670
|
+
// Pre-compute the lane shape from stages + aggregate so unassigned cards still show up if any
|
|
671
|
+
const lanes = React.useMemo<LaneStage[]>(() => {
|
|
672
|
+
const knownStageIds = new Set(stagesData.map((stage) => stage.id))
|
|
673
|
+
const hasUnassigned = (aggregateData?.perStage ?? []).some(
|
|
674
|
+
(row) => row.stageId === '__unassigned' || (!!row.stageId && !knownStageIds.has(row.stageId)),
|
|
675
|
+
)
|
|
676
|
+
return buildLaneStages(stagesData, unassignedLabel, hasUnassigned)
|
|
677
|
+
}, [stagesData, aggregateData, unassignedLabel])
|
|
678
|
+
|
|
679
|
+
// Resolve the active SortOption to an API sort once per change. Owner sort has no server
|
|
680
|
+
// representation (only owner_user_id exists in the deals table; the displayed name is resolved
|
|
681
|
+
// client-side), so it falls back to `updatedAt desc` server-side and is re-sorted client-side.
|
|
682
|
+
const apiSort = React.useMemo(() => {
|
|
683
|
+
const mapped = mapSortOptionToApi(sortBy)
|
|
684
|
+
return mapped ?? { sortField: 'updatedAt' as const, sortDir: 'desc' as const }
|
|
685
|
+
}, [sortBy])
|
|
686
|
+
|
|
687
|
+
const laneQueries = useQueries({
|
|
688
|
+
queries: lanes.map((stage) => {
|
|
689
|
+
return {
|
|
690
|
+
queryKey: [
|
|
691
|
+
'customers',
|
|
692
|
+
'deals',
|
|
693
|
+
'kanban-lane',
|
|
694
|
+
`scope:${scopeVersion}`,
|
|
695
|
+
`pipeline:${filterSignature.pipelineId ?? 'none'}`,
|
|
696
|
+
`search:${filterSignature.search}`,
|
|
697
|
+
`status:${filterSignature.status}`,
|
|
698
|
+
`owners:${filterSignature.owners}`,
|
|
699
|
+
`people:${filterSignature.people}`,
|
|
700
|
+
`companies:${filterSignature.companies}`,
|
|
701
|
+
`close:${filterSignature.closeFrom}-${filterSignature.closeTo}`,
|
|
702
|
+
// Sort affects which 25 deals come back on page 1, so it MUST be in the key —
|
|
703
|
+
// otherwise switching from "Value (high to low)" to "Close (soonest)" reuses
|
|
704
|
+
// the old (wrong-sort) cache and silently keeps the wrong slice on screen.
|
|
705
|
+
`sort:${apiSort.sortField}:${apiSort.sortDir}`,
|
|
706
|
+
`currency:${filterSignature.currency}`,
|
|
707
|
+
`stage:${stage.id}`,
|
|
708
|
+
],
|
|
709
|
+
enabled: !!selectedPipelineId,
|
|
710
|
+
staleTime: 30_000,
|
|
711
|
+
queryFn: async () => {
|
|
712
|
+
if (!selectedPipelineId) return { items: [] as DealApiRecord[], total: 0 }
|
|
713
|
+
const params = new URLSearchParams()
|
|
714
|
+
params.set('page', '1')
|
|
715
|
+
params.set('pageSize', String(LANE_PAGE_SIZE))
|
|
716
|
+
params.set('pipelineId', selectedPipelineId)
|
|
717
|
+
params.set('pipelineStageId', stage.id)
|
|
718
|
+
params.set('sortField', apiSort.sortField)
|
|
719
|
+
params.set('sortDir', apiSort.sortDir)
|
|
720
|
+
if (filterSignature.search.length) params.set('search', filterSignature.search)
|
|
721
|
+
for (const status of statusFilters) params.append('status', status)
|
|
722
|
+
for (const ownerId of ownerFilters) params.append('ownerUserId', ownerId)
|
|
723
|
+
for (const personId of peopleFilters) params.append('personId', personId)
|
|
724
|
+
for (const companyId of companyFilters) params.append('companyId', companyId)
|
|
725
|
+
if (closeDateFilter.from) params.set('expectedCloseAtFrom', closeDateFilter.from)
|
|
726
|
+
if (closeDateFilter.to) params.set('expectedCloseAtTo', closeDateFilter.to)
|
|
727
|
+
if (currencyFilter) params.set('valueCurrency', currencyFilter)
|
|
728
|
+
const payload = await readApiResultOrThrow<{ items?: DealApiRecord[]; total?: number }>(
|
|
729
|
+
`/api/customers/deals?${params.toString()}`,
|
|
730
|
+
undefined,
|
|
731
|
+
{
|
|
732
|
+
errorMessage: translateWithFallback(
|
|
733
|
+
t,
|
|
734
|
+
'customers.deals.pipeline.loadError',
|
|
735
|
+
'Failed to load deals.',
|
|
736
|
+
),
|
|
737
|
+
},
|
|
738
|
+
)
|
|
739
|
+
const items = Array.isArray(payload?.items) ? (payload!.items as DealApiRecord[]) : []
|
|
740
|
+
return { items, total: typeof payload?.total === 'number' ? payload.total : items.length }
|
|
741
|
+
},
|
|
742
|
+
}
|
|
743
|
+
}),
|
|
342
744
|
})
|
|
343
745
|
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
() =>
|
|
350
|
-
|
|
746
|
+
const laneState = React.useMemo(() => {
|
|
747
|
+
const dealsByStage = new Map<string, DealCardData[]>()
|
|
748
|
+
const stageIdByDealId = new Map<string, string>()
|
|
749
|
+
const allDeals: DealCardData[] = []
|
|
750
|
+
let total = 0
|
|
751
|
+
laneQueries.forEach((query, idx) => {
|
|
752
|
+
const stage = lanes[idx]
|
|
753
|
+
if (!stage) return
|
|
754
|
+
const firstPage = query.data?.items ?? []
|
|
755
|
+
const extra = extraCardsByStage[stage.id] ?? []
|
|
756
|
+
// Merge first page + appended pages, deduping by id (in case of races/optimistic moves)
|
|
757
|
+
const seen = new Set<string>()
|
|
758
|
+
const merged: DealApiRecord[] = []
|
|
759
|
+
for (const item of [...firstPage, ...extra]) {
|
|
760
|
+
if (!item.id || seen.has(item.id)) continue
|
|
761
|
+
seen.add(item.id)
|
|
762
|
+
merged.push(item)
|
|
763
|
+
}
|
|
764
|
+
const list: DealCardData[] = []
|
|
765
|
+
for (const item of merged) {
|
|
766
|
+
const mapped = mapDealRecord(item, fallbackTitle)
|
|
767
|
+
if (!mapped) continue
|
|
768
|
+
list.push(mapped)
|
|
769
|
+
const stageId =
|
|
770
|
+
typeof item.pipeline_stage_id === 'string' && item.pipeline_stage_id.trim().length
|
|
771
|
+
? item.pipeline_stage_id
|
|
772
|
+
: '__unassigned'
|
|
773
|
+
stageIdByDealId.set(mapped.id, stageId)
|
|
774
|
+
allDeals.push(mapped)
|
|
775
|
+
}
|
|
776
|
+
dealsByStage.set(stage.id, list)
|
|
777
|
+
total += list.length
|
|
778
|
+
})
|
|
779
|
+
return { dealsByStage, stageIdByDealId, allDeals, total }
|
|
780
|
+
}, [laneQueries, lanes, fallbackTitle, extraCardsByStage])
|
|
781
|
+
|
|
782
|
+
const rawDeals = laneState.allDeals
|
|
783
|
+
const stageIdByDealId = laneState.stageIdByDealId
|
|
784
|
+
const total = aggregateData?.perStage?.reduce((sum, row) => sum + row.count, 0) ?? laneState.total
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Sum of `totalInBaseCurrency` across every visible lane — gives the operator one
|
|
788
|
+
* authoritative "what's this pipeline worth?" number at the top of the kanban. Also
|
|
789
|
+
* aggregates the per-currency breakdown so the same `LaneCurrencyBreakdown` popover
|
|
790
|
+
* can show the board-wide split. We compute `convertedAll` as the AND of every lane's
|
|
791
|
+
* flag: any single missing-rate currency drops the board to "partial" so the operator
|
|
792
|
+
* can't read the headline as authoritative.
|
|
793
|
+
*/
|
|
794
|
+
const boardSummary = React.useMemo(() => {
|
|
795
|
+
const perStage = aggregateData?.perStage ?? []
|
|
796
|
+
if (perStage.length === 0) {
|
|
797
|
+
return null
|
|
798
|
+
}
|
|
799
|
+
const totalsByCurrency = new Map<string, { total: number; count: number }>()
|
|
800
|
+
let totalInBaseCurrency = 0
|
|
801
|
+
let convertedAll = true
|
|
802
|
+
const missingRateCurrencies = new Set<string>()
|
|
803
|
+
for (const row of perStage) {
|
|
804
|
+
totalInBaseCurrency += row.totalInBaseCurrency
|
|
805
|
+
if (!(row.convertedAll ?? true)) convertedAll = false
|
|
806
|
+
for (const c of row.missingRateCurrencies ?? []) missingRateCurrencies.add(c)
|
|
807
|
+
for (const cur of row.byCurrency) {
|
|
808
|
+
const entry = totalsByCurrency.get(cur.currency) ?? { total: 0, count: 0 }
|
|
809
|
+
entry.total += cur.total
|
|
810
|
+
entry.count += cur.count
|
|
811
|
+
totalsByCurrency.set(cur.currency, entry)
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
const rows = Array.from(totalsByCurrency.entries())
|
|
815
|
+
.map(([currency, value]) => ({ currency, total: value.total, count: value.count }))
|
|
816
|
+
.sort((a, b) => b.total - a.total)
|
|
817
|
+
return {
|
|
818
|
+
baseCurrencyCode: aggregateData?.baseCurrencyCode ?? null,
|
|
819
|
+
totalInBaseCurrency,
|
|
820
|
+
convertedAll,
|
|
821
|
+
missingRateCurrencies: Array.from(missingRateCurrencies),
|
|
822
|
+
rows,
|
|
823
|
+
}
|
|
824
|
+
}, [aggregateData])
|
|
825
|
+
|
|
826
|
+
const deals = React.useMemo(() => {
|
|
827
|
+
if (ownerNamesById.size === 0) return rawDeals
|
|
828
|
+
return rawDeals.map((deal) => {
|
|
829
|
+
if (!deal.owner) return deal
|
|
830
|
+
const resolvedLabel = ownerNamesById.get(deal.owner.userId)
|
|
831
|
+
if (!resolvedLabel || resolvedLabel === deal.owner.label) return deal
|
|
832
|
+
return { ...deal, owner: { ...deal.owner, label: resolvedLabel } }
|
|
833
|
+
})
|
|
834
|
+
}, [ownerNamesById, rawDeals])
|
|
835
|
+
|
|
836
|
+
const dealsByStage = React.useMemo(() => {
|
|
837
|
+
const map = new Map<string, DealCardData[]>()
|
|
838
|
+
if (ownerNamesById.size === 0) {
|
|
839
|
+
for (const [stageId, list] of laneState.dealsByStage) map.set(stageId, list)
|
|
840
|
+
return map
|
|
841
|
+
}
|
|
842
|
+
for (const [stageId, list] of laneState.dealsByStage) {
|
|
843
|
+
map.set(
|
|
844
|
+
stageId,
|
|
845
|
+
list.map((deal) => {
|
|
846
|
+
if (!deal.owner) return deal
|
|
847
|
+
const resolvedLabel = ownerNamesById.get(deal.owner.userId)
|
|
848
|
+
if (!resolvedLabel || resolvedLabel === deal.owner.label) return deal
|
|
849
|
+
return { ...deal, owner: { ...deal.owner, label: resolvedLabel } }
|
|
850
|
+
}),
|
|
851
|
+
)
|
|
852
|
+
}
|
|
853
|
+
return map
|
|
854
|
+
}, [laneState.dealsByStage, ownerNamesById])
|
|
855
|
+
|
|
856
|
+
// Pre-sort each lane's deals once per data/sort change. This keeps the array reference
|
|
857
|
+
// stable across page re-renders (e.g. drag-start, filter changes that don't touch this
|
|
858
|
+
// lane), so Lane.memo's `prev.deals !== next.deals` check stays cheap — without this
|
|
859
|
+
// the inline `sortDeals(...)` in the JSX allocated a new array every render and busted
|
|
860
|
+
// every Lane's memo, cascading reconciliation through every memoized DealCard.
|
|
861
|
+
const sortedDealsByStage = React.useMemo(() => {
|
|
862
|
+
const map = new Map<string, DealCardData[]>()
|
|
863
|
+
for (const [stageId, list] of dealsByStage) {
|
|
864
|
+
map.set(stageId, sortDeals(list, sortBy))
|
|
865
|
+
}
|
|
866
|
+
return map
|
|
867
|
+
}, [dealsByStage, sortBy])
|
|
868
|
+
|
|
869
|
+
const handleLoadMoreInLane = React.useCallback(
|
|
870
|
+
async (stageId: string) => {
|
|
871
|
+
if (!selectedPipelineId) return
|
|
872
|
+
if (loadingMoreByStage[stageId]) return
|
|
873
|
+
// Page 1 = first 25 (initial useQueries fetch). Subsequent pages come from extraCardsByStage.
|
|
874
|
+
const already = extraCardsByStage[stageId]?.length ?? 0
|
|
875
|
+
const nextPage = Math.floor((LANE_PAGE_SIZE + already) / LANE_PAGE_SIZE) + 1
|
|
876
|
+
setLoadingMoreByStage((prev) => ({ ...prev, [stageId]: true }))
|
|
877
|
+
try {
|
|
878
|
+
const params = new URLSearchParams()
|
|
879
|
+
params.set('page', String(nextPage))
|
|
880
|
+
params.set('pageSize', String(LANE_PAGE_SIZE))
|
|
881
|
+
params.set('pipelineId', selectedPipelineId)
|
|
882
|
+
params.set('pipelineStageId', stageId)
|
|
883
|
+
// Show-more MUST request the same sort as page 1; otherwise the appended cards land
|
|
884
|
+
// in an arbitrary order relative to the cards already on screen.
|
|
885
|
+
params.set('sortField', apiSort.sortField)
|
|
886
|
+
params.set('sortDir', apiSort.sortDir)
|
|
887
|
+
if (filterSignature.search.length) params.set('search', filterSignature.search)
|
|
888
|
+
for (const status of statusFilters) params.append('status', status)
|
|
889
|
+
for (const ownerId of ownerFilters) params.append('ownerUserId', ownerId)
|
|
890
|
+
for (const personId of peopleFilters) params.append('personId', personId)
|
|
891
|
+
for (const companyId of companyFilters) params.append('companyId', companyId)
|
|
892
|
+
if (closeDateFilter.from) params.set('expectedCloseAtFrom', closeDateFilter.from)
|
|
893
|
+
if (closeDateFilter.to) params.set('expectedCloseAtTo', closeDateFilter.to)
|
|
894
|
+
if (currencyFilter) params.set('valueCurrency', currencyFilter)
|
|
895
|
+
const call = await apiCall<{ items?: DealApiRecord[] }>(
|
|
896
|
+
`/api/customers/deals?${params.toString()}`,
|
|
897
|
+
)
|
|
898
|
+
if (!call.ok) return
|
|
899
|
+
const items = Array.isArray(call.result?.items) ? (call.result!.items as DealApiRecord[]) : []
|
|
900
|
+
setExtraCardsByStage((prev) => ({
|
|
901
|
+
...prev,
|
|
902
|
+
[stageId]: [...(prev[stageId] ?? []), ...items],
|
|
903
|
+
}))
|
|
904
|
+
} finally {
|
|
905
|
+
setLoadingMoreByStage((prev) => {
|
|
906
|
+
const next = { ...prev }
|
|
907
|
+
delete next[stageId]
|
|
908
|
+
return next
|
|
909
|
+
})
|
|
910
|
+
}
|
|
911
|
+
},
|
|
912
|
+
[
|
|
913
|
+
selectedPipelineId,
|
|
914
|
+
loadingMoreByStage,
|
|
915
|
+
extraCardsByStage,
|
|
916
|
+
filterSignature.search,
|
|
917
|
+
statusFilters,
|
|
918
|
+
ownerFilters,
|
|
919
|
+
peopleFilters,
|
|
920
|
+
companyFilters,
|
|
921
|
+
closeDateFilter.from,
|
|
922
|
+
closeDateFilter.to,
|
|
923
|
+
apiSort,
|
|
924
|
+
],
|
|
351
925
|
)
|
|
352
926
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
927
|
+
// Page chrome loading/error indicators derived from per-lane queries
|
|
928
|
+
const isInitialLoading =
|
|
929
|
+
!!selectedPipelineId && lanes.length === 0
|
|
930
|
+
? stagesQuery.isLoading
|
|
931
|
+
: laneQueries.some((laneQuery) => laneQuery.isLoading && !laneQuery.data)
|
|
932
|
+
const firstError: unknown = laneQueries.find((laneQuery) => laneQuery.isError)?.error ?? null
|
|
933
|
+
|
|
934
|
+
// Centralized invalidation so writes refresh per-lane card queries, the aggregate, and drop stale extras
|
|
935
|
+
const invalidateKanbanData = React.useCallback(() => {
|
|
936
|
+
queryClient
|
|
937
|
+
.invalidateQueries({ queryKey: ['customers', 'deals', 'kanban-lane'] })
|
|
938
|
+
.catch(() => {})
|
|
939
|
+
queryClient
|
|
940
|
+
.invalidateQueries({ queryKey: ['customers', 'deals', 'kanban-aggregate'] })
|
|
941
|
+
.catch(() => {})
|
|
942
|
+
// Clear appended pages — they'll be re-fetched on demand by the user
|
|
943
|
+
setExtraCardsByStage({})
|
|
944
|
+
}, [queryClient])
|
|
945
|
+
|
|
946
|
+
const trackedBulkProgressJobIdsRef = React.useRef(new Set<string>())
|
|
947
|
+
|
|
948
|
+
const trackBulkProgressJob = React.useCallback((jobId: string | null | undefined) => {
|
|
949
|
+
if (!jobId) return
|
|
950
|
+
trackedBulkProgressJobIdsRef.current.add(jobId)
|
|
951
|
+
}, [])
|
|
952
|
+
|
|
953
|
+
const clearTrackedBulkProgressJob = React.useCallback((jobId: string | null): boolean => {
|
|
954
|
+
if (!jobId) return false
|
|
955
|
+
return trackedBulkProgressJobIdsRef.current.delete(jobId)
|
|
956
|
+
}, [])
|
|
957
|
+
|
|
958
|
+
useAppEvent(
|
|
959
|
+
'progress.job.completed',
|
|
960
|
+
(event) => {
|
|
961
|
+
const payload = event.payload as { jobId?: unknown } | null | undefined
|
|
962
|
+
const jobId = typeof payload?.jobId === 'string' ? payload.jobId : null
|
|
963
|
+
if (!clearTrackedBulkProgressJob(jobId)) return
|
|
964
|
+
invalidateKanbanData()
|
|
965
|
+
},
|
|
966
|
+
[clearTrackedBulkProgressJob, invalidateKanbanData],
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
useAppEvent(
|
|
970
|
+
'progress.job.failed',
|
|
971
|
+
(event) => {
|
|
972
|
+
const payload = event.payload as { jobId?: unknown } | null | undefined
|
|
973
|
+
const jobId = typeof payload?.jobId === 'string' ? payload.jobId : null
|
|
974
|
+
clearTrackedBulkProgressJob(jobId)
|
|
975
|
+
},
|
|
976
|
+
[clearTrackedBulkProgressJob],
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
useAppEvent(
|
|
980
|
+
'progress.job.cancelled',
|
|
981
|
+
(event) => {
|
|
982
|
+
const payload = event.payload as { jobId?: unknown } | null | undefined
|
|
983
|
+
const jobId = typeof payload?.jobId === 'string' ? payload.jobId : null
|
|
984
|
+
clearTrackedBulkProgressJob(jobId)
|
|
985
|
+
},
|
|
986
|
+
[clearTrackedBulkProgressJob],
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
const sensors = useSensors(
|
|
990
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
991
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
const [activeDragDealId, setActiveDragDealId] = React.useState<string | null>(null)
|
|
995
|
+
const activeDragDeal = React.useMemo(
|
|
996
|
+
() => (activeDragDealId ? deals.find((deal) => deal.id === activeDragDealId) ?? null : null),
|
|
997
|
+
[activeDragDealId, deals],
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
// Cards are draggable but NOT droppable; only lanes are droppable. That means collision
|
|
1001
|
+
// detection iterates only ~7 lane rects per pointer move. `pointerWithin` is the right
|
|
1002
|
+
// semantic — return the lane whose rect contains the pointer. We DROPPED the
|
|
1003
|
+
// `rectIntersection` fallback because it was running on every move alongside `pointerWithin`
|
|
1004
|
+
// (2× the rect math), and the only case it covered (cursor outside every lane rect) is
|
|
1005
|
+
// already handled by dnd-kit returning empty collisions → no drop target shown.
|
|
1006
|
+
const collisionDetection = React.useCallback<CollisionDetection>((args) => pointerWithin(args), [])
|
|
1007
|
+
|
|
1008
|
+
// Measure droppables once at drag start (not on every pointer move). Kanban lanes don't
|
|
1009
|
+
// resize during a drag, so re-measuring on every move is pure overhead — this single line
|
|
1010
|
+
// is the biggest single-shot perf win for dnd-kit kanbans.
|
|
1011
|
+
// dnd-kit will still automatically re-measure if a layout-affecting change happens (e.g.
|
|
1012
|
+
// the scroller scrolls or a new lane appears).
|
|
1013
|
+
const measuringConfig = React.useMemo(
|
|
1014
|
+
() => ({ droppable: { strategy: MeasuringStrategy.BeforeDragging } }),
|
|
358
1015
|
[],
|
|
359
1016
|
)
|
|
360
1017
|
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
1018
|
+
const moveMutationContextId = 'customers-deals-kanban:stage-move'
|
|
1019
|
+
const { runMutation: runMoveMutation, retryLastMutation } = useGuardedMutation<{
|
|
1020
|
+
formId: string
|
|
1021
|
+
resourceKind: string
|
|
1022
|
+
resourceId: string
|
|
1023
|
+
retryLastMutation: () => Promise<boolean>
|
|
1024
|
+
}>({
|
|
1025
|
+
contextId: moveMutationContextId,
|
|
1026
|
+
blockedMessage: translateWithFallback(
|
|
1027
|
+
t,
|
|
1028
|
+
'ui.forms.flash.saveBlocked',
|
|
1029
|
+
'Save blocked by validation',
|
|
1030
|
+
),
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
// Deal mutation context is declared here (not next to the dialog/bulk handlers further
|
|
1034
|
+
// down) because `bulkMoveDealsToStage` — declared right after `moveDealToStage` so
|
|
1035
|
+
// `handleDragEnd` can dispatch to it — depends on `runDealMutation`/`retryDealMutation`.
|
|
1036
|
+
// Hoisting the hook keeps the lexical ordering valid for `useCallback` dependency arrays.
|
|
1037
|
+
const dealMutationContextId = 'customers-deals-kanban:deal-mutation'
|
|
1038
|
+
const { runMutation: runDealMutation, retryLastMutation: retryDealMutation } = useGuardedMutation<{
|
|
1039
|
+
formId: string
|
|
1040
|
+
resourceKind: string
|
|
1041
|
+
resourceId: string
|
|
1042
|
+
retryLastMutation: () => Promise<boolean>
|
|
1043
|
+
}>({
|
|
1044
|
+
contextId: dealMutationContextId,
|
|
1045
|
+
blockedMessage: translateWithFallback(
|
|
1046
|
+
t,
|
|
1047
|
+
'ui.forms.flash.saveBlocked',
|
|
1048
|
+
'Save blocked by validation',
|
|
1049
|
+
),
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
const moveDealToStage = React.useCallback(
|
|
1053
|
+
(dealId: string, targetStageId: string) => {
|
|
1054
|
+
if (!targetStageId || targetStageId === '__unassigned') return
|
|
1055
|
+
const currentStageId = stageIdByDealId.get(dealId)
|
|
1056
|
+
if (currentStageId === targetStageId) return
|
|
1057
|
+
|
|
1058
|
+
// Optimistic update: move the card between the per-lane query caches
|
|
1059
|
+
const laneCachePredicate = { queryKey: ['customers', 'deals', 'kanban-lane'] as const }
|
|
1060
|
+
const snapshot = queryClient.getQueriesData<{ items: DealApiRecord[]; total: number }>(laneCachePredicate)
|
|
1061
|
+
const extraSnapshot = extraCardsByStage
|
|
1062
|
+
let movingItem: DealApiRecord | null = null
|
|
1063
|
+
for (const [key, data] of snapshot) {
|
|
1064
|
+
if (!data) continue
|
|
1065
|
+
const idx = data.items.findIndex((it) => it.id === dealId)
|
|
1066
|
+
if (idx < 0) continue
|
|
1067
|
+
const stageKey = (key as readonly unknown[]).find((part) => typeof part === 'string' && (part as string).startsWith('stage:'))
|
|
1068
|
+
if (typeof stageKey !== 'string') continue
|
|
1069
|
+
const stageId = stageKey.slice('stage:'.length)
|
|
1070
|
+
// remove from source lane's page 1 cache
|
|
1071
|
+
if (stageId === currentStageId) {
|
|
1072
|
+
queryClient.setQueryData(key, {
|
|
1073
|
+
...data,
|
|
1074
|
+
items: data.items.filter((it) => it.id !== dealId),
|
|
1075
|
+
total: Math.max(0, data.total - 1),
|
|
1076
|
+
})
|
|
1077
|
+
movingItem = { ...data.items[idx], pipeline_stage_id: targetStageId }
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
// Also check extra pages for the card if it wasn't on page 1
|
|
1081
|
+
if (!movingItem && currentStageId) {
|
|
1082
|
+
const extras = extraCardsByStage[currentStageId] ?? []
|
|
1083
|
+
const found = extras.find((it) => it.id === dealId)
|
|
1084
|
+
if (found) movingItem = { ...found, pipeline_stage_id: targetStageId }
|
|
1085
|
+
}
|
|
1086
|
+
// Remove from source lane's extra-pages cache
|
|
1087
|
+
if (currentStageId && extraCardsByStage[currentStageId]?.some((it) => it.id === dealId)) {
|
|
1088
|
+
setExtraCardsByStage((prev) => {
|
|
1089
|
+
const list = prev[currentStageId] ?? []
|
|
1090
|
+
return { ...prev, [currentStageId]: list.filter((it) => it.id !== dealId) }
|
|
1091
|
+
})
|
|
1092
|
+
}
|
|
1093
|
+
if (movingItem) {
|
|
1094
|
+
// add to target lane caches (if they are loaded)
|
|
1095
|
+
for (const [key, data] of snapshot) {
|
|
1096
|
+
if (!data) continue
|
|
1097
|
+
const stageKey = (key as readonly unknown[]).find((part) => typeof part === 'string' && (part as string).startsWith('stage:'))
|
|
1098
|
+
if (typeof stageKey !== 'string') continue
|
|
1099
|
+
const stageId = stageKey.slice('stage:'.length)
|
|
1100
|
+
if (stageId !== targetStageId) continue
|
|
1101
|
+
queryClient.setQueryData(key, {
|
|
1102
|
+
...data,
|
|
1103
|
+
items: [movingItem!, ...data.items],
|
|
1104
|
+
total: data.total + 1,
|
|
1105
|
+
})
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
setPendingDealId(dealId)
|
|
1109
|
+
|
|
1110
|
+
runMoveMutation({
|
|
1111
|
+
operation: async () => {
|
|
1112
|
+
await apiCallOrThrow(
|
|
1113
|
+
'/api/customers/deals',
|
|
1114
|
+
{
|
|
1115
|
+
method: 'PUT',
|
|
1116
|
+
headers: { 'content-type': 'application/json' },
|
|
1117
|
+
body: JSON.stringify({ id: dealId, pipelineStageId: targetStageId }),
|
|
1118
|
+
},
|
|
1119
|
+
{
|
|
1120
|
+
errorMessage: translateWithFallback(
|
|
1121
|
+
t,
|
|
1122
|
+
'customers.deals.pipeline.moveError',
|
|
1123
|
+
'Failed to update deal stage.',
|
|
1124
|
+
),
|
|
1125
|
+
},
|
|
1126
|
+
)
|
|
369
1127
|
},
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
1128
|
+
context: {
|
|
1129
|
+
formId: moveMutationContextId,
|
|
1130
|
+
resourceKind: 'customers.deal',
|
|
1131
|
+
resourceId: dealId,
|
|
1132
|
+
retryLastMutation,
|
|
1133
|
+
},
|
|
1134
|
+
})
|
|
1135
|
+
.then(() => {
|
|
1136
|
+
flash(
|
|
1137
|
+
translateWithFallback(t, 'customers.deals.pipeline.moveSuccess', 'Deal updated.'),
|
|
1138
|
+
'success',
|
|
1139
|
+
)
|
|
1140
|
+
})
|
|
1141
|
+
.catch((error: unknown) => {
|
|
1142
|
+
// Roll back optimistic update by restoring the prior cache state
|
|
1143
|
+
for (const [key, data] of snapshot) {
|
|
1144
|
+
queryClient.setQueryData(key, data)
|
|
1145
|
+
}
|
|
1146
|
+
// Restore extra-pages cache to pre-move snapshot
|
|
1147
|
+
setExtraCardsByStage(extraSnapshot)
|
|
1148
|
+
const message =
|
|
1149
|
+
error instanceof Error && error.message
|
|
1150
|
+
? error.message
|
|
1151
|
+
: translateWithFallback(
|
|
1152
|
+
t,
|
|
1153
|
+
'customers.deals.pipeline.moveError',
|
|
1154
|
+
'Failed to update deal stage.',
|
|
1155
|
+
)
|
|
1156
|
+
flash(message, 'error')
|
|
1157
|
+
})
|
|
1158
|
+
.finally(() => {
|
|
1159
|
+
setPendingDealId(null)
|
|
1160
|
+
invalidateKanbanData()
|
|
1161
|
+
})
|
|
373
1162
|
},
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
1163
|
+
[invalidateKanbanData, queryClient, retryLastMutation, runMoveMutation, stageIdByDealId, t],
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
// Round-2 UX review item 32: when the operator drags a card that is part of a multi-card
|
|
1167
|
+
// selection, every selected card must travel with it. We mirror `moveDealToStage`'s
|
|
1168
|
+
// optimistic-update pattern but apply it across every id in `dealIds`, capture a single
|
|
1169
|
+
// pre-batch snapshot of every lane cache (so rollback restores the full pre-batch state
|
|
1170
|
+
// atomically on failure), and dispatch the same `/api/customers/deals/bulk-update-stage`
|
|
1171
|
+
// endpoint that powers the `BulkActionsBar` "Change stage" menu. The worker is async, so
|
|
1172
|
+
// we intentionally do NOT call `invalidateKanbanData()` after a successful POST — that
|
|
1173
|
+
// would refetch the pre-move server state and flicker the optimistic UI back. The
|
|
1174
|
+
// `progress.job.completed` listener already triggers an invalidate when the worker is
|
|
1175
|
+
// actually done, which is what we want.
|
|
1176
|
+
const bulkMoveDealsToStage = React.useCallback(
|
|
1177
|
+
async (dealIds: string[], targetStageId: string) => {
|
|
1178
|
+
if (!targetStageId || targetStageId === '__unassigned' || dealIds.length === 0) return
|
|
1179
|
+
const uniqueIds = Array.from(new Set(dealIds))
|
|
1180
|
+
const idsToMove = uniqueIds.filter((id) => stageIdByDealId.get(id) !== targetStageId)
|
|
1181
|
+
if (idsToMove.length === 0) return
|
|
1182
|
+
|
|
1183
|
+
const laneCachePredicate = { queryKey: ['customers', 'deals', 'kanban-lane'] as const }
|
|
1184
|
+
const snapshot = queryClient.getQueriesData<{ items: DealApiRecord[]; total: number }>(laneCachePredicate)
|
|
1185
|
+
const extraSnapshot = extraCardsByStage
|
|
1186
|
+
const newExtras: Record<string, DealApiRecord[]> = { ...extraCardsByStage }
|
|
1187
|
+
|
|
1188
|
+
for (const dealId of idsToMove) {
|
|
1189
|
+
const currentStageId = stageIdByDealId.get(dealId)
|
|
1190
|
+
if (!currentStageId) continue
|
|
1191
|
+
let movingItem: DealApiRecord | null = null
|
|
1192
|
+
for (const [key, data] of snapshot) {
|
|
1193
|
+
if (!data) continue
|
|
1194
|
+
const stageKey = (key as readonly unknown[]).find(
|
|
1195
|
+
(part) => typeof part === 'string' && (part as string).startsWith('stage:'),
|
|
1196
|
+
)
|
|
1197
|
+
if (typeof stageKey !== 'string') continue
|
|
1198
|
+
const stageId = stageKey.slice('stage:'.length)
|
|
1199
|
+
if (stageId !== currentStageId) continue
|
|
1200
|
+
const current = queryClient.getQueryData<{ items: DealApiRecord[]; total: number }>(
|
|
1201
|
+
key as readonly unknown[],
|
|
1202
|
+
)
|
|
1203
|
+
if (!current) continue
|
|
1204
|
+
const idx = current.items.findIndex((it) => it.id === dealId)
|
|
1205
|
+
if (idx < 0) continue
|
|
1206
|
+
movingItem = { ...current.items[idx], pipeline_stage_id: targetStageId }
|
|
1207
|
+
queryClient.setQueryData(key, {
|
|
1208
|
+
...current,
|
|
1209
|
+
items: current.items.filter((it) => it.id !== dealId),
|
|
1210
|
+
total: Math.max(0, current.total - 1),
|
|
1211
|
+
})
|
|
1212
|
+
break
|
|
1213
|
+
}
|
|
1214
|
+
if (!movingItem) {
|
|
1215
|
+
const extras = newExtras[currentStageId] ?? []
|
|
1216
|
+
const found = extras.find((it) => it.id === dealId)
|
|
1217
|
+
if (found) {
|
|
1218
|
+
movingItem = { ...found, pipeline_stage_id: targetStageId }
|
|
1219
|
+
newExtras[currentStageId] = extras.filter((it) => it.id !== dealId)
|
|
1220
|
+
}
|
|
1221
|
+
} else if (newExtras[currentStageId]?.some((it) => it.id === dealId)) {
|
|
1222
|
+
newExtras[currentStageId] = (newExtras[currentStageId] ?? []).filter((it) => it.id !== dealId)
|
|
1223
|
+
}
|
|
1224
|
+
if (!movingItem) continue
|
|
1225
|
+
for (const [key, data] of snapshot) {
|
|
1226
|
+
if (!data) continue
|
|
1227
|
+
const stageKey = (key as readonly unknown[]).find(
|
|
1228
|
+
(part) => typeof part === 'string' && (part as string).startsWith('stage:'),
|
|
1229
|
+
)
|
|
1230
|
+
if (typeof stageKey !== 'string') continue
|
|
1231
|
+
const stageId = stageKey.slice('stage:'.length)
|
|
1232
|
+
if (stageId !== targetStageId) continue
|
|
1233
|
+
const current = queryClient.getQueryData<{ items: DealApiRecord[]; total: number }>(
|
|
1234
|
+
key as readonly unknown[],
|
|
1235
|
+
)
|
|
1236
|
+
if (!current) continue
|
|
1237
|
+
queryClient.setQueryData(key, {
|
|
1238
|
+
...current,
|
|
1239
|
+
items: [movingItem, ...current.items],
|
|
1240
|
+
total: current.total + 1,
|
|
1241
|
+
})
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
setExtraCardsByStage(newExtras)
|
|
1245
|
+
|
|
1246
|
+
setIsBulkMutating(true)
|
|
1247
|
+
let progressJobId: string | null = null
|
|
1248
|
+
try {
|
|
1249
|
+
await runDealMutation({
|
|
1250
|
+
operation: async () => {
|
|
1251
|
+
const call = await apiCallOrThrow<BulkJobResponse>(
|
|
1252
|
+
'/api/customers/deals/bulk-update-stage',
|
|
1253
|
+
{
|
|
1254
|
+
method: 'POST',
|
|
1255
|
+
headers: { 'content-type': 'application/json' },
|
|
1256
|
+
body: JSON.stringify({
|
|
1257
|
+
ids: idsToMove,
|
|
1258
|
+
pipelineStageId: targetStageId,
|
|
1259
|
+
}),
|
|
1260
|
+
},
|
|
1261
|
+
{
|
|
1262
|
+
errorMessage: translateWithFallback(
|
|
1263
|
+
t,
|
|
1264
|
+
'customers.deals.kanban.bulk.changeStage.error',
|
|
1265
|
+
'Failed to start bulk stage update.',
|
|
1266
|
+
),
|
|
1267
|
+
},
|
|
1268
|
+
)
|
|
1269
|
+
progressJobId = call.result?.progressJobId ?? null
|
|
1270
|
+
},
|
|
1271
|
+
context: {
|
|
1272
|
+
formId: dealMutationContextId,
|
|
1273
|
+
resourceKind: 'customers.deals.bulk_stage',
|
|
1274
|
+
resourceId: idsToMove.join(','),
|
|
1275
|
+
retryLastMutation: retryDealMutation,
|
|
1276
|
+
},
|
|
1277
|
+
})
|
|
1278
|
+
flash(
|
|
1279
|
+
translateWithFallback(
|
|
1280
|
+
t,
|
|
1281
|
+
'customers.deals.kanban.bulk.changeStage.queued',
|
|
1282
|
+
'Bulk stage update started ({count} deals).',
|
|
1283
|
+
{ count: idsToMove.length },
|
|
1284
|
+
),
|
|
1285
|
+
'success',
|
|
381
1286
|
)
|
|
382
|
-
|
|
1287
|
+
if (progressJobId) {
|
|
1288
|
+
trackBulkProgressJob(progressJobId)
|
|
1289
|
+
}
|
|
1290
|
+
// Selection persists after a successful bulk drag — Asana convention; lets the
|
|
1291
|
+
// operator drag the same set into another stage without re-selecting.
|
|
1292
|
+
} catch (error) {
|
|
1293
|
+
for (const [key, data] of snapshot) {
|
|
1294
|
+
queryClient.setQueryData(key, data)
|
|
1295
|
+
}
|
|
1296
|
+
setExtraCardsByStage(extraSnapshot)
|
|
1297
|
+
const message =
|
|
1298
|
+
error instanceof Error && error.message
|
|
1299
|
+
? error.message
|
|
1300
|
+
: translateWithFallback(
|
|
1301
|
+
t,
|
|
1302
|
+
'customers.deals.kanban.bulk.changeStage.error',
|
|
1303
|
+
'Failed to start bulk stage update.',
|
|
1304
|
+
)
|
|
1305
|
+
flash(message, 'error')
|
|
1306
|
+
} finally {
|
|
1307
|
+
setIsBulkMutating(false)
|
|
1308
|
+
}
|
|
1309
|
+
},
|
|
1310
|
+
[
|
|
1311
|
+
dealMutationContextId,
|
|
1312
|
+
extraCardsByStage,
|
|
1313
|
+
queryClient,
|
|
1314
|
+
retryDealMutation,
|
|
1315
|
+
runDealMutation,
|
|
1316
|
+
stageIdByDealId,
|
|
1317
|
+
t,
|
|
1318
|
+
trackBulkProgressJob,
|
|
1319
|
+
],
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
// Horizontal scroll arrows — let the user step lane-by-lane instead of using only the bottom slider.
|
|
1323
|
+
const boardScrollerRef = React.useRef<HTMLDivElement | null>(null)
|
|
1324
|
+
const LANE_STEP = 322 // 308px lane width + 14px gap (scaled 1.2x for readability)
|
|
1325
|
+
const [scrollEdges, setScrollEdges] = React.useState<{ atStart: boolean; atEnd: boolean }>({
|
|
1326
|
+
atStart: true,
|
|
1327
|
+
atEnd: false,
|
|
1328
|
+
})
|
|
1329
|
+
|
|
1330
|
+
// Edge-detection tolerance in CSS px. Chrome may report fractional scrollLeft / scrollWidth
|
|
1331
|
+
// values when the page DPI is non-integer or the layout has sub-pixel sizing; a too-tight
|
|
1332
|
+
// threshold here disables the arrow before the user actually reaches the end.
|
|
1333
|
+
const EDGE_TOLERANCE = 4
|
|
1334
|
+
|
|
1335
|
+
const updateScrollEdges = React.useCallback(() => {
|
|
1336
|
+
const el = boardScrollerRef.current
|
|
1337
|
+
if (!el) return
|
|
1338
|
+
const maxScroll = el.scrollWidth - el.clientWidth
|
|
1339
|
+
const nextAtStart = el.scrollLeft <= EDGE_TOLERANCE
|
|
1340
|
+
const nextAtEnd = maxScroll <= EDGE_TOLERANCE || el.scrollLeft >= maxScroll - EDGE_TOLERANCE
|
|
1341
|
+
// Functional update with equality check: avoids a full page re-render every frame of the
|
|
1342
|
+
// scroll animation when atStart/atEnd haven't actually flipped. Without this, the entire
|
|
1343
|
+
// 125+ card tree reconciled on every tick → effective frame rate collapsed.
|
|
1344
|
+
setScrollEdges((prev) =>
|
|
1345
|
+
prev.atStart === nextAtStart && prev.atEnd === nextAtEnd ? prev : { atStart: nextAtStart, atEnd: nextAtEnd },
|
|
1346
|
+
)
|
|
1347
|
+
}, [])
|
|
1348
|
+
|
|
1349
|
+
// Combined ref + listener attachment. Using a callback ref guarantees we wire scroll/resize
|
|
1350
|
+
// listeners the moment the scroller div mounts (even when it's conditionally rendered behind
|
|
1351
|
+
// a loading guard), and cleanly tears them down when it unmounts.
|
|
1352
|
+
const cleanupScrollerRef = React.useRef<(() => void) | null>(null)
|
|
1353
|
+
const setBoardScroller = React.useCallback(
|
|
1354
|
+
(el: HTMLDivElement | null) => {
|
|
1355
|
+
cleanupScrollerRef.current?.()
|
|
1356
|
+
cleanupScrollerRef.current = null
|
|
1357
|
+
boardScrollerRef.current = el
|
|
1358
|
+
if (!el) return
|
|
1359
|
+
updateScrollEdges()
|
|
1360
|
+
el.addEventListener('scroll', updateScrollEdges, { passive: true })
|
|
1361
|
+
const ro = new ResizeObserver(updateScrollEdges)
|
|
1362
|
+
ro.observe(el)
|
|
1363
|
+
cleanupScrollerRef.current = () => {
|
|
1364
|
+
el.removeEventListener('scroll', updateScrollEdges)
|
|
1365
|
+
ro.disconnect()
|
|
1366
|
+
}
|
|
1367
|
+
},
|
|
1368
|
+
[updateScrollEdges],
|
|
1369
|
+
)
|
|
1370
|
+
|
|
1371
|
+
// Also recompute when the lane set changes (cards loaded → scrollWidth grows)
|
|
1372
|
+
React.useEffect(() => {
|
|
1373
|
+
updateScrollEdges()
|
|
1374
|
+
}, [updateScrollEdges, lanes.length])
|
|
1375
|
+
|
|
1376
|
+
// Smooth-scroll helper: animate scrollLeft over ~200ms with cubic easing.
|
|
1377
|
+
// Uses requestAnimationFrame so the animation is synced to the display refresh rate
|
|
1378
|
+
// (handles 60Hz, 120Hz, 144Hz, ProMotion etc.). performance.now() gives sub-ms timing.
|
|
1379
|
+
// We do NOT call updateScrollEdges() inside the step — programmatic scrollLeft writes
|
|
1380
|
+
// DO fire a `scroll` event (which the listener we attached in setBoardScroller handles).
|
|
1381
|
+
// Skipping the redundant per-frame call avoids cascading the whole page tree to re-render.
|
|
1382
|
+
const activeAnimRef = React.useRef<{ cancel: () => void } | null>(null)
|
|
1383
|
+
const animateScrollTo = React.useCallback(
|
|
1384
|
+
(el: HTMLDivElement, target: number) => {
|
|
1385
|
+
activeAnimRef.current?.cancel()
|
|
1386
|
+
const startLeft = el.scrollLeft
|
|
1387
|
+
const clampedTarget = Math.max(0, Math.min(el.scrollWidth - el.clientWidth, target))
|
|
1388
|
+
const distance = clampedTarget - startLeft
|
|
1389
|
+
if (Math.abs(distance) < 1) {
|
|
1390
|
+
updateScrollEdges()
|
|
1391
|
+
return
|
|
1392
|
+
}
|
|
1393
|
+
const duration = 200
|
|
1394
|
+
const hasRaf = typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function'
|
|
1395
|
+
const now = () =>
|
|
1396
|
+
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
1397
|
+
? performance.now()
|
|
1398
|
+
: Date.now()
|
|
1399
|
+
const startTime = now()
|
|
1400
|
+
let handle = 0
|
|
1401
|
+
let cancelled = false
|
|
1402
|
+
const step = () => {
|
|
1403
|
+
if (cancelled) return
|
|
1404
|
+
const t = Math.min(1, (now() - startTime) / duration)
|
|
1405
|
+
const eased = 1 - Math.pow(1 - t, 3)
|
|
1406
|
+
el.scrollLeft = startLeft + distance * eased
|
|
1407
|
+
if (t < 1) {
|
|
1408
|
+
handle = hasRaf
|
|
1409
|
+
? window.requestAnimationFrame(step)
|
|
1410
|
+
: (window.setTimeout(step, 16) as unknown as number)
|
|
1411
|
+
} else {
|
|
1412
|
+
// Final edge-sync once the animation lands — the scroll listener also fires,
|
|
1413
|
+
// but on some browsers the last `scroll` event is debounced behind the final write.
|
|
1414
|
+
updateScrollEdges()
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
handle = hasRaf
|
|
1418
|
+
? window.requestAnimationFrame(step)
|
|
1419
|
+
: (window.setTimeout(step, 16) as unknown as number)
|
|
1420
|
+
activeAnimRef.current = {
|
|
1421
|
+
cancel: () => {
|
|
1422
|
+
cancelled = true
|
|
1423
|
+
if (hasRaf) window.cancelAnimationFrame(handle)
|
|
1424
|
+
else window.clearTimeout(handle)
|
|
1425
|
+
},
|
|
383
1426
|
}
|
|
384
|
-
return { previous }
|
|
385
1427
|
},
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
1428
|
+
[updateScrollEdges],
|
|
1429
|
+
)
|
|
1430
|
+
|
|
1431
|
+
// Pre-compute lane left-edge offsets (in scrollLeft coords) so resize-aware "step one lane"
|
|
1432
|
+
// works even when lanes have different widths.
|
|
1433
|
+
const laneOffsets = React.useMemo(() => {
|
|
1434
|
+
const offsets: number[] = []
|
|
1435
|
+
let left = 0
|
|
1436
|
+
for (const stage of lanes) {
|
|
1437
|
+
offsets.push(left)
|
|
1438
|
+
const w = laneWidths[stage.id] ?? DEFAULT_LANE_WIDTH
|
|
1439
|
+
left += w + LANE_GAP
|
|
1440
|
+
}
|
|
1441
|
+
return offsets
|
|
1442
|
+
}, [lanes, laneWidths])
|
|
1443
|
+
|
|
1444
|
+
// Per-direction step: pick the next lane boundary based on the live scrollLeft.
|
|
1445
|
+
// Variable-width-aware: uses laneOffsets instead of a fixed LANE_STEP so resized columns still
|
|
1446
|
+
// snap cleanly. Chrome's scrollLeft can be fractional; we add EDGE_TOLERANCE to dodge that.
|
|
1447
|
+
const stepScroll = React.useCallback(
|
|
1448
|
+
(direction: -1 | 1) => {
|
|
1449
|
+
const el = boardScrollerRef.current
|
|
1450
|
+
if (!el) return false
|
|
1451
|
+
const sl = el.scrollLeft
|
|
1452
|
+
const maxScroll = Math.max(0, el.scrollWidth - el.clientWidth)
|
|
1453
|
+
if (maxScroll <= EDGE_TOLERANCE) return false
|
|
1454
|
+
if (direction < 0 && sl <= EDGE_TOLERANCE) return false
|
|
1455
|
+
if (direction > 0 && sl >= maxScroll - EDGE_TOLERANCE) return false
|
|
1456
|
+
|
|
1457
|
+
let target: number
|
|
1458
|
+
if (direction > 0) {
|
|
1459
|
+
// First lane offset that is meaningfully ahead of the current scroll position
|
|
1460
|
+
const next = laneOffsets.find((off) => off > sl + EDGE_TOLERANCE)
|
|
1461
|
+
target = next ?? maxScroll
|
|
1462
|
+
} else {
|
|
1463
|
+
// Last lane offset that is meaningfully behind the current scroll position
|
|
1464
|
+
let prev = 0
|
|
1465
|
+
for (const off of laneOffsets) {
|
|
1466
|
+
if (off >= sl - EDGE_TOLERANCE) break
|
|
1467
|
+
prev = off
|
|
1468
|
+
}
|
|
1469
|
+
target = prev
|
|
1470
|
+
}
|
|
1471
|
+
if (target > maxScroll - EDGE_TOLERANCE) target = maxScroll
|
|
1472
|
+
if (target < EDGE_TOLERANCE) target = 0
|
|
1473
|
+
|
|
1474
|
+
// No-op safety: if target is essentially the current position, do nothing
|
|
1475
|
+
if (Math.abs(target - sl) < 1) return false
|
|
1476
|
+
|
|
1477
|
+
animateScrollTo(el, target)
|
|
1478
|
+
// Belt-and-suspenders: if the animation library couldn't progress in this browser, force
|
|
1479
|
+
// the target after the animation duration so we never "stick" mid-scroll.
|
|
1480
|
+
window.setTimeout(() => {
|
|
1481
|
+
if (!boardScrollerRef.current) return
|
|
1482
|
+
const cur = boardScrollerRef.current.scrollLeft
|
|
1483
|
+
if (Math.abs(cur - target) > EDGE_TOLERANCE) {
|
|
1484
|
+
boardScrollerRef.current.scrollLeft = target
|
|
1485
|
+
}
|
|
1486
|
+
updateScrollEdges()
|
|
1487
|
+
}, 240)
|
|
1488
|
+
return true
|
|
395
1489
|
},
|
|
396
|
-
|
|
397
|
-
|
|
1490
|
+
[animateScrollTo, updateScrollEdges, laneOffsets],
|
|
1491
|
+
)
|
|
1492
|
+
|
|
1493
|
+
// Press-and-hold continuous scroll. First step fires immediately on press; if the user keeps
|
|
1494
|
+
// the button held, additional steps fire every ~280ms (matched to animation duration) until they
|
|
1495
|
+
// release or the scroller reaches the edge.
|
|
1496
|
+
const holdTimerRef = React.useRef<number | null>(null)
|
|
1497
|
+
const stopContinuousScroll = React.useCallback(() => {
|
|
1498
|
+
if (holdTimerRef.current !== null) {
|
|
1499
|
+
window.clearInterval(holdTimerRef.current)
|
|
1500
|
+
holdTimerRef.current = null
|
|
1501
|
+
}
|
|
1502
|
+
}, [])
|
|
1503
|
+
const startContinuousScroll = React.useCallback(
|
|
1504
|
+
(direction: -1 | 1) => {
|
|
1505
|
+
stopContinuousScroll()
|
|
1506
|
+
const moved = stepScroll(direction)
|
|
1507
|
+
if (!moved) return
|
|
1508
|
+
const HOLD_DELAY = 320
|
|
1509
|
+
const REPEAT_INTERVAL = 240
|
|
1510
|
+
holdTimerRef.current = window.setTimeout(function repeat() {
|
|
1511
|
+
const more = stepScroll(direction)
|
|
1512
|
+
if (!more) {
|
|
1513
|
+
stopContinuousScroll()
|
|
1514
|
+
return
|
|
1515
|
+
}
|
|
1516
|
+
holdTimerRef.current = window.setTimeout(repeat, REPEAT_INTERVAL) as unknown as number
|
|
1517
|
+
}, HOLD_DELAY) as unknown as number
|
|
398
1518
|
},
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
1519
|
+
[stepScroll, stopContinuousScroll],
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
React.useEffect(() => {
|
|
1523
|
+
// Safety: stop any running hold timer on unmount
|
|
1524
|
+
return () => stopContinuousScroll()
|
|
1525
|
+
}, [stopContinuousScroll])
|
|
1526
|
+
|
|
1527
|
+
// Dedup pointerdown + click: real browsers fire both when the user clicks a button.
|
|
1528
|
+
// We start the hold sequence on pointerdown (so press-and-hold feels immediate). The click
|
|
1529
|
+
// event that follows would otherwise scroll an EXTRA lane — we suppress it here.
|
|
1530
|
+
// Keyboard activation (Enter/Space) only fires click, no pointerdown, so it still works.
|
|
1531
|
+
const lastPointerDownAtRef = React.useRef(0)
|
|
1532
|
+
const handleScrollPrev = React.useCallback(() => {
|
|
1533
|
+
if (Date.now() - lastPointerDownAtRef.current < 500) return
|
|
1534
|
+
stepScroll(-1)
|
|
1535
|
+
}, [stepScroll])
|
|
1536
|
+
const handleScrollNext = React.useCallback(() => {
|
|
1537
|
+
if (Date.now() - lastPointerDownAtRef.current < 500) return
|
|
1538
|
+
stepScroll(1)
|
|
1539
|
+
}, [stepScroll])
|
|
1540
|
+
const handleHoldPrevStart = React.useCallback(() => {
|
|
1541
|
+
lastPointerDownAtRef.current = Date.now()
|
|
1542
|
+
startContinuousScroll(-1)
|
|
1543
|
+
}, [startContinuousScroll])
|
|
1544
|
+
const handleHoldNextStart = React.useCallback(() => {
|
|
1545
|
+
lastPointerDownAtRef.current = Date.now()
|
|
1546
|
+
startContinuousScroll(1)
|
|
1547
|
+
}, [startContinuousScroll])
|
|
1548
|
+
|
|
1549
|
+
// Keyboard navigation: ArrowLeft / ArrowRight scroll one lane at a time, mirroring the
|
|
1550
|
+
// on-screen arrow buttons. We intentionally:
|
|
1551
|
+
// - skip when the user is typing inside an input / textarea / select / contenteditable
|
|
1552
|
+
// - skip when modifier keys are held (Cmd/Ctrl/Alt/Shift) so we don't fight browser shortcuts
|
|
1553
|
+
// - skip when any dialog or popover (Radix `[role="dialog"]` / `[data-state="open"]` portals)
|
|
1554
|
+
// is open, so popover keyboard nav (Apply, Tab, Escape, etc.) keeps working
|
|
1555
|
+
// - skip when no kanban scroller is mounted yet (initial loading state)
|
|
1556
|
+
React.useEffect(() => {
|
|
1557
|
+
const onKey = (event: KeyboardEvent) => {
|
|
1558
|
+
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
|
|
1559
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
|
1560
|
+
const target = event.target as HTMLElement | null
|
|
1561
|
+
if (target) {
|
|
1562
|
+
const tag = target.tagName
|
|
1563
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
|
|
1564
|
+
if (target.isContentEditable) return
|
|
1565
|
+
}
|
|
1566
|
+
// Don't steal arrow keys while a dialog/popover is open — they own keyboard nav.
|
|
1567
|
+
if (typeof document !== 'undefined') {
|
|
1568
|
+
if (document.querySelector('[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]')) return
|
|
1569
|
+
if (document.querySelector('[data-radix-popper-content-wrapper] [data-state="open"]')) return
|
|
1570
|
+
}
|
|
1571
|
+
if (!boardScrollerRef.current) return
|
|
1572
|
+
event.preventDefault()
|
|
1573
|
+
stepScroll(event.key === 'ArrowRight' ? 1 : -1)
|
|
1574
|
+
}
|
|
1575
|
+
window.addEventListener('keydown', onKey)
|
|
1576
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
1577
|
+
}, [stepScroll])
|
|
1578
|
+
|
|
1579
|
+
const handleDragStart = React.useCallback((event: DragStartEvent) => {
|
|
1580
|
+
const id = typeof event.active.id === 'string' ? event.active.id : null
|
|
1581
|
+
setActiveDragDealId(id)
|
|
1582
|
+
}, [])
|
|
1583
|
+
|
|
1584
|
+
const handleDragCancel = React.useCallback(() => {
|
|
1585
|
+
setActiveDragDealId(null)
|
|
1586
|
+
}, [])
|
|
1587
|
+
|
|
1588
|
+
const handleDragEnd = React.useCallback(
|
|
1589
|
+
(event: DragEndEvent) => {
|
|
1590
|
+
setActiveDragDealId(null)
|
|
1591
|
+
const dealId = typeof event.active.id === 'string' ? event.active.id : null
|
|
1592
|
+
if (!dealId) return
|
|
1593
|
+
const overId = event.over?.id
|
|
1594
|
+
if (typeof overId !== 'string') return
|
|
1595
|
+
|
|
1596
|
+
let targetStageId: string | null = null
|
|
1597
|
+
if (overId.startsWith('lane:')) {
|
|
1598
|
+
targetStageId = overId.slice('lane:'.length)
|
|
1599
|
+
} else {
|
|
1600
|
+
targetStageId = stageIdByDealId.get(overId) ?? null
|
|
1601
|
+
}
|
|
1602
|
+
if (!targetStageId) return
|
|
1603
|
+
|
|
1604
|
+
// Round-2 UX review item 32: bulk-drag dispatch. If the dragged card is part of a
|
|
1605
|
+
// multi-card selection, route through `bulkMoveDealsToStage` so every selected card
|
|
1606
|
+
// travels with it. Dragging a NON-selected card is treated as a single-card move
|
|
1607
|
+
// and leaves the existing selection intact (the operator may have selected cards
|
|
1608
|
+
// intentionally and just be moving an unrelated one).
|
|
1609
|
+
const isBulkDrag = selectedDealIds.has(dealId) && selectedDealIds.size > 1
|
|
1610
|
+
if (isBulkDrag) {
|
|
1611
|
+
void bulkMoveDealsToStage(Array.from(selectedDealIds), targetStageId)
|
|
1612
|
+
} else {
|
|
1613
|
+
moveDealToStage(dealId, targetStageId)
|
|
1614
|
+
}
|
|
402
1615
|
},
|
|
403
|
-
|
|
1616
|
+
[bulkMoveDealsToStage, moveDealToStage, selectedDealIds, stageIdByDealId],
|
|
1617
|
+
)
|
|
1618
|
+
|
|
1619
|
+
const bulkDragActive = React.useMemo(
|
|
1620
|
+
() => activeDragDealId !== null && selectedDealIds.has(activeDragDealId) && selectedDealIds.size > 1,
|
|
1621
|
+
[activeDragDealId, selectedDealIds],
|
|
1622
|
+
)
|
|
404
1623
|
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
1624
|
+
const handleToggleSelect = React.useCallback((dealId: string) => {
|
|
1625
|
+
setSelectedDealIds((prev) => {
|
|
1626
|
+
const next = new Set(prev)
|
|
1627
|
+
if (next.has(dealId)) next.delete(dealId)
|
|
1628
|
+
else next.add(dealId)
|
|
1629
|
+
return next
|
|
1630
|
+
})
|
|
409
1631
|
}, [])
|
|
410
1632
|
|
|
411
|
-
const
|
|
412
|
-
|
|
1633
|
+
const handleOpenDetail = React.useCallback(
|
|
1634
|
+
(dealId: string) => {
|
|
1635
|
+
router.push(`/backend/customers/deals/${dealId}`)
|
|
1636
|
+
},
|
|
1637
|
+
[router],
|
|
1638
|
+
)
|
|
1639
|
+
|
|
1640
|
+
const handleComingSoon = React.useCallback(
|
|
1641
|
+
(label: string) => {
|
|
1642
|
+
flash(
|
|
1643
|
+
translateWithFallback(
|
|
1644
|
+
t,
|
|
1645
|
+
'customers.deals.kanban.comingSoon',
|
|
1646
|
+
'{feature} arrives in the next iteration.',
|
|
1647
|
+
{ feature: label },
|
|
1648
|
+
),
|
|
1649
|
+
'info',
|
|
1650
|
+
)
|
|
1651
|
+
},
|
|
1652
|
+
[t],
|
|
1653
|
+
)
|
|
1654
|
+
|
|
1655
|
+
const handleChipClick = React.useCallback(
|
|
1656
|
+
(chipId: KanbanFilterChip['id']) => {
|
|
1657
|
+
handleComingSoon(
|
|
1658
|
+
translateWithFallback(
|
|
1659
|
+
t,
|
|
1660
|
+
'customers.deals.kanban.filter.aria.chip',
|
|
1661
|
+
'Filter by {label}',
|
|
1662
|
+
{ label: chipId },
|
|
1663
|
+
),
|
|
1664
|
+
)
|
|
1665
|
+
},
|
|
1666
|
+
[handleComingSoon, t],
|
|
1667
|
+
)
|
|
1668
|
+
|
|
1669
|
+
const handleCustomizeView = React.useCallback(() => {
|
|
1670
|
+
setCustomizeOpen(true)
|
|
413
1671
|
}, [])
|
|
414
1672
|
|
|
415
|
-
|
|
416
|
-
|
|
1673
|
+
// "Reset to default" must clear every filter chip the operator may have set, otherwise
|
|
1674
|
+
// the menu copy ("Clear filters and restore columns") is a lie. Previously only the
|
|
1675
|
+
// status filter and sort were reset, which left the operator confused when other chips
|
|
1676
|
+
// (pipeline, owner, people, companies, close date, currency, search) stayed populated.
|
|
1677
|
+
const handleResetView = React.useCallback(() => {
|
|
1678
|
+
setStatusFilters([])
|
|
1679
|
+
setOwnerFilters([])
|
|
1680
|
+
setPeopleFilters([])
|
|
1681
|
+
setCompanyFilters([])
|
|
1682
|
+
setCloseDateFilter({ from: null, to: null })
|
|
1683
|
+
setCurrencyFilter(null)
|
|
1684
|
+
setSearch('')
|
|
1685
|
+
setSortBy(DEFAULT_SORT)
|
|
417
1686
|
}, [])
|
|
418
1687
|
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
1688
|
+
const activePipelineName = React.useMemo(() => {
|
|
1689
|
+
if (!selectedPipelineId) return ''
|
|
1690
|
+
return pipelinesQuery.data?.find((pipeline) => pipeline.id === selectedPipelineId)?.name ?? ''
|
|
1691
|
+
}, [pipelinesQuery.data, selectedPipelineId])
|
|
1692
|
+
|
|
1693
|
+
const stageLabelById = React.useMemo(() => {
|
|
1694
|
+
const map = new Map<string, string>()
|
|
1695
|
+
for (const lane of lanes) map.set(lane.id, lane.label)
|
|
1696
|
+
return map
|
|
1697
|
+
}, [lanes])
|
|
1698
|
+
|
|
1699
|
+
const handleQuickAdd = React.useCallback(
|
|
1700
|
+
(stageId: string) => {
|
|
1701
|
+
if (!selectedPipelineId) return
|
|
1702
|
+
const stageLabel = stageLabelById.get(stageId)
|
|
1703
|
+
if (!stageLabel || stageId === '__unassigned') return
|
|
1704
|
+
setQuickDealContext({
|
|
1705
|
+
pipelineId: selectedPipelineId,
|
|
1706
|
+
pipelineName: activePipelineName,
|
|
1707
|
+
pipelineStageId: stageId,
|
|
1708
|
+
pipelineStageLabel: stageLabel,
|
|
1709
|
+
})
|
|
1710
|
+
},
|
|
1711
|
+
[activePipelineName, selectedPipelineId, stageLabelById],
|
|
1712
|
+
)
|
|
1713
|
+
|
|
1714
|
+
const handleAddStage = React.useCallback(() => {
|
|
1715
|
+
if (!selectedPipelineId) return
|
|
1716
|
+
// Snapshot the current pipeline's stages (sorted by `order`) so the position picker
|
|
1717
|
+
// can offer "After {label}" entries. We sort here rather than relying on stagesData's
|
|
1718
|
+
// ordering because that array's order is technically determined by the API response —
|
|
1719
|
+
// a defensive in-place sort is cheap and survives any future API change.
|
|
1720
|
+
const orderedStages = stagesData
|
|
1721
|
+
.filter((stage) => stage.pipelineId === selectedPipelineId)
|
|
1722
|
+
.slice()
|
|
1723
|
+
.sort((a, b) => a.order - b.order)
|
|
1724
|
+
.map((stage) => ({ id: stage.id, label: stage.label, order: stage.order }))
|
|
1725
|
+
setAddStageContext({
|
|
1726
|
+
pipelineId: selectedPipelineId,
|
|
1727
|
+
pipelineName: activePipelineName,
|
|
1728
|
+
existingStages: orderedStages,
|
|
1729
|
+
})
|
|
1730
|
+
}, [activePipelineName, selectedPipelineId, stagesData])
|
|
1731
|
+
|
|
1732
|
+
const handleDialogCreated = React.useCallback(() => {
|
|
1733
|
+
invalidateKanbanData()
|
|
1734
|
+
queryClient
|
|
1735
|
+
.invalidateQueries({
|
|
1736
|
+
queryKey: ['customers', 'pipeline-stages', `scope:${scopeVersion}`, `pipeline:${selectedPipelineId}`],
|
|
1737
|
+
})
|
|
1738
|
+
.catch(() => {})
|
|
1739
|
+
}, [invalidateKanbanData, scopeVersion, selectedPipelineId])
|
|
1740
|
+
|
|
1741
|
+
const updateDealStatus = React.useCallback(
|
|
1742
|
+
async (dealId: string, status: 'win' | 'loose') => {
|
|
1743
|
+
setPendingDealId(dealId)
|
|
1744
|
+
try {
|
|
1745
|
+
await runDealMutation({
|
|
1746
|
+
operation: async () => {
|
|
1747
|
+
await apiCallOrThrow(
|
|
1748
|
+
'/api/customers/deals',
|
|
1749
|
+
{
|
|
1750
|
+
method: 'PUT',
|
|
1751
|
+
headers: { 'content-type': 'application/json' },
|
|
1752
|
+
body: JSON.stringify({ id: dealId, status }),
|
|
1753
|
+
},
|
|
1754
|
+
{
|
|
1755
|
+
errorMessage: translateWithFallback(
|
|
1756
|
+
t,
|
|
1757
|
+
'customers.deals.kanban.menu.error.status',
|
|
1758
|
+
'Failed to update deal status.',
|
|
1759
|
+
),
|
|
1760
|
+
},
|
|
1761
|
+
)
|
|
1762
|
+
},
|
|
1763
|
+
context: {
|
|
1764
|
+
formId: dealMutationContextId,
|
|
1765
|
+
resourceKind: 'customers.deal',
|
|
1766
|
+
resourceId: dealId,
|
|
1767
|
+
retryLastMutation: retryDealMutation,
|
|
1768
|
+
},
|
|
1769
|
+
})
|
|
1770
|
+
flash(
|
|
1771
|
+
status === 'win'
|
|
1772
|
+
? translateWithFallback(
|
|
1773
|
+
t,
|
|
1774
|
+
'customers.deals.kanban.menu.markWon.success',
|
|
1775
|
+
'Deal marked as won.',
|
|
1776
|
+
)
|
|
1777
|
+
: translateWithFallback(
|
|
1778
|
+
t,
|
|
1779
|
+
'customers.deals.kanban.menu.markLost.success',
|
|
1780
|
+
'Deal marked as lost.',
|
|
1781
|
+
),
|
|
1782
|
+
'success',
|
|
1783
|
+
)
|
|
1784
|
+
invalidateKanbanData()
|
|
1785
|
+
} catch (error) {
|
|
1786
|
+
const message =
|
|
1787
|
+
error instanceof Error && error.message
|
|
1788
|
+
? error.message
|
|
1789
|
+
: translateWithFallback(
|
|
1790
|
+
t,
|
|
1791
|
+
'customers.deals.kanban.menu.error.status',
|
|
1792
|
+
'Failed to update deal status.',
|
|
1793
|
+
)
|
|
1794
|
+
flash(message, 'error')
|
|
1795
|
+
} finally {
|
|
1796
|
+
setPendingDealId(null)
|
|
1797
|
+
}
|
|
1798
|
+
},
|
|
1799
|
+
[invalidateKanbanData, retryDealMutation, runDealMutation, t],
|
|
1800
|
+
)
|
|
1801
|
+
|
|
1802
|
+
const deleteDeal = React.useCallback(
|
|
1803
|
+
async (dealId: string, dealTitle: string) => {
|
|
1804
|
+
const confirmed = await confirm({
|
|
1805
|
+
title: translateWithFallback(
|
|
1806
|
+
t,
|
|
1807
|
+
'customers.deals.kanban.menu.delete.confirmTitle',
|
|
1808
|
+
'Delete deal “{title}”?',
|
|
1809
|
+
{ title: dealTitle },
|
|
1810
|
+
),
|
|
1811
|
+
text: translateWithFallback(
|
|
1812
|
+
t,
|
|
1813
|
+
'customers.deals.kanban.menu.delete.confirmText',
|
|
1814
|
+
'This action cannot be undone.',
|
|
1815
|
+
),
|
|
1816
|
+
variant: 'destructive',
|
|
1817
|
+
confirmText: translateWithFallback(t, 'customers.deals.kanban.menu.delete', 'Delete'),
|
|
1818
|
+
})
|
|
1819
|
+
if (!confirmed) return
|
|
1820
|
+
|
|
1821
|
+
setPendingDealId(dealId)
|
|
1822
|
+
try {
|
|
1823
|
+
await runDealMutation({
|
|
1824
|
+
operation: async () => {
|
|
1825
|
+
await deleteCrud('customers/deals', {
|
|
1826
|
+
body: { id: dealId },
|
|
1827
|
+
errorMessage: translateWithFallback(
|
|
1828
|
+
t,
|
|
1829
|
+
'customers.deals.kanban.menu.delete.error',
|
|
1830
|
+
'Failed to delete deal.',
|
|
1831
|
+
),
|
|
1832
|
+
})
|
|
1833
|
+
},
|
|
1834
|
+
context: {
|
|
1835
|
+
formId: dealMutationContextId,
|
|
1836
|
+
resourceKind: 'customers.deal',
|
|
1837
|
+
resourceId: dealId,
|
|
1838
|
+
retryLastMutation: retryDealMutation,
|
|
1839
|
+
},
|
|
1840
|
+
})
|
|
1841
|
+
flash(
|
|
1842
|
+
translateWithFallback(t, 'customers.deals.kanban.menu.delete.success', 'Deal deleted.'),
|
|
1843
|
+
'success',
|
|
1844
|
+
)
|
|
1845
|
+
invalidateKanbanData()
|
|
1846
|
+
} catch (error) {
|
|
1847
|
+
const message =
|
|
1848
|
+
error instanceof Error && error.message
|
|
1849
|
+
? error.message
|
|
1850
|
+
: translateWithFallback(
|
|
1851
|
+
t,
|
|
1852
|
+
'customers.deals.kanban.menu.delete.error',
|
|
1853
|
+
'Failed to delete deal.',
|
|
1854
|
+
)
|
|
1855
|
+
flash(message, 'error')
|
|
1856
|
+
} finally {
|
|
1857
|
+
setPendingDealId(null)
|
|
1858
|
+
}
|
|
1859
|
+
},
|
|
1860
|
+
[confirm, invalidateKanbanData, retryDealMutation, runDealMutation, t],
|
|
1861
|
+
)
|
|
1862
|
+
|
|
1863
|
+
const bulkSelectionSummary = React.useMemo(() => {
|
|
1864
|
+
if (selectedDealIds.size === 0) return { count: 0, totalLabel: null, currency: null as string | null, ids: [] as string[] }
|
|
1865
|
+
// Group totals by currency so the label is HONEST when the selection mixes currencies.
|
|
1866
|
+
// The previous implementation summed `valueAmount` across every selected deal and labeled
|
|
1867
|
+
// the result with whichever currency happened to appear first — e.g. selecting €100k + $50k
|
|
1868
|
+
// displayed as "€150k", which is meaningless. We now keep one bucket per currency.
|
|
1869
|
+
const totalsByCurrency = new Map<string, number>()
|
|
1870
|
+
const ids: string[] = []
|
|
1871
|
+
for (const deal of deals) {
|
|
1872
|
+
if (!selectedDealIds.has(deal.id)) continue
|
|
1873
|
+
ids.push(deal.id)
|
|
1874
|
+
if (typeof deal.valueAmount === 'number' && Number.isFinite(deal.valueAmount) && deal.valueAmount > 0) {
|
|
1875
|
+
const code = deal.valueCurrency && deal.valueCurrency.length === 3
|
|
1876
|
+
? deal.valueCurrency.toUpperCase()
|
|
1877
|
+
: 'USD'
|
|
1878
|
+
totalsByCurrency.set(code, (totalsByCurrency.get(code) ?? 0) + deal.valueAmount)
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
const rows = Array.from(totalsByCurrency.entries())
|
|
1882
|
+
.map(([code, amount]) => ({ code, amount }))
|
|
1883
|
+
.sort((a, b) => b.amount - a.amount)
|
|
1884
|
+
const formatOne = (code: string, amount: number) => {
|
|
1885
|
+
try {
|
|
1886
|
+
return new Intl.NumberFormat(undefined, {
|
|
1887
|
+
style: 'currency',
|
|
1888
|
+
currency: code,
|
|
1889
|
+
maximumFractionDigits: 0,
|
|
1890
|
+
}).format(amount)
|
|
1891
|
+
} catch {
|
|
1892
|
+
return `${code} ${Math.round(amount)}`
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
let totalLabel: string | null = null
|
|
1896
|
+
if (rows.length === 1) {
|
|
1897
|
+
totalLabel = formatOne(rows[0].code, rows[0].amount)
|
|
1898
|
+
} else if (rows.length >= 2) {
|
|
1899
|
+
// Concatenate per-currency totals with " + " so multi-currency selections stay readable.
|
|
1900
|
+
// Capping at 3 visible rows keeps the bar narrow; the rest collapses into "+N more"
|
|
1901
|
+
// so the operator sees the dominant currencies and an explicit "mixed" hint without
|
|
1902
|
+
// misleading aggregation across rates.
|
|
1903
|
+
const visible = rows.slice(0, 3).map((row) => formatOne(row.code, row.amount))
|
|
1904
|
+
const overflow = rows.length - visible.length
|
|
1905
|
+
totalLabel = overflow > 0 ? `${visible.join(' + ')} +${overflow}` : visible.join(' + ')
|
|
1906
|
+
}
|
|
1907
|
+
// `currency` (singular) is intentionally null when 2+ currencies are present so downstream
|
|
1908
|
+
// consumers (CSV export, change-stage dialog) don't assume a single canonical currency.
|
|
1909
|
+
const currency = rows.length === 1 ? rows[0].code : null
|
|
1910
|
+
return { count: ids.length, totalLabel, currency, ids }
|
|
1911
|
+
}, [deals, selectedDealIds])
|
|
1912
|
+
|
|
1913
|
+
const handleBulkClear = React.useCallback(() => {
|
|
1914
|
+
setSelectedDealIds(new Set())
|
|
422
1915
|
}, [])
|
|
423
1916
|
|
|
424
|
-
const
|
|
425
|
-
(
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
1917
|
+
const stageOptions = React.useMemo(
|
|
1918
|
+
() =>
|
|
1919
|
+
(stagesQuery.data ?? [])
|
|
1920
|
+
.slice()
|
|
1921
|
+
.sort((a, b) => a.order - b.order)
|
|
1922
|
+
.map((stage) => ({ id: stage.id, label: stage.label })),
|
|
1923
|
+
[stagesQuery.data],
|
|
1924
|
+
)
|
|
1925
|
+
|
|
1926
|
+
const handleBulkChangeStage = React.useCallback(
|
|
1927
|
+
async (stageId: string) => {
|
|
1928
|
+
if (bulkSelectionSummary.ids.length === 0) return
|
|
1929
|
+
setIsBulkMutating(true)
|
|
1930
|
+
let progressJobId: string | null = null
|
|
1931
|
+
try {
|
|
1932
|
+
// Bulk writes MUST go through useGuardedMutation so injection modules (record-lock
|
|
1933
|
+
// conflict handling, retry chains, scoped headers) run the same `onBeforeSave`/
|
|
1934
|
+
// `onAfterSave` lifecycle as single-deal writes — see UI AGENTS.md and customers
|
|
1935
|
+
// module MUST rules. `runMutation` throws on guard rejection; the catch block below
|
|
1936
|
+
// surfaces the error message via flash().
|
|
1937
|
+
await runDealMutation({
|
|
1938
|
+
operation: async () => {
|
|
1939
|
+
const call = await apiCallOrThrow<BulkJobResponse>(
|
|
1940
|
+
'/api/customers/deals/bulk-update-stage',
|
|
1941
|
+
{
|
|
1942
|
+
method: 'POST',
|
|
1943
|
+
headers: { 'content-type': 'application/json' },
|
|
1944
|
+
body: JSON.stringify({
|
|
1945
|
+
ids: bulkSelectionSummary.ids,
|
|
1946
|
+
pipelineStageId: stageId,
|
|
1947
|
+
}),
|
|
1948
|
+
},
|
|
1949
|
+
{
|
|
1950
|
+
errorMessage: translateWithFallback(
|
|
1951
|
+
t,
|
|
1952
|
+
'customers.deals.kanban.bulk.changeStage.error',
|
|
1953
|
+
'Failed to start bulk stage update.',
|
|
1954
|
+
),
|
|
1955
|
+
},
|
|
1956
|
+
)
|
|
1957
|
+
progressJobId = call.result?.progressJobId ?? null
|
|
1958
|
+
},
|
|
1959
|
+
context: {
|
|
1960
|
+
formId: dealMutationContextId,
|
|
1961
|
+
resourceKind: 'customers.deals.bulk_stage',
|
|
1962
|
+
resourceId: bulkSelectionSummary.ids.join(','),
|
|
1963
|
+
retryLastMutation: retryDealMutation,
|
|
1964
|
+
},
|
|
1965
|
+
})
|
|
1966
|
+
flash(
|
|
1967
|
+
translateWithFallback(
|
|
1968
|
+
t,
|
|
1969
|
+
'customers.deals.kanban.bulk.changeStage.queued',
|
|
1970
|
+
'Bulk stage update started ({count} deals).',
|
|
1971
|
+
{ count: bulkSelectionSummary.count },
|
|
1972
|
+
),
|
|
1973
|
+
'success',
|
|
1974
|
+
)
|
|
1975
|
+
setChangeStageOpen(false)
|
|
1976
|
+
setSelectedDealIds(new Set())
|
|
1977
|
+
if (progressJobId) {
|
|
1978
|
+
trackBulkProgressJob(progressJobId)
|
|
1979
|
+
}
|
|
1980
|
+
invalidateKanbanData()
|
|
1981
|
+
} catch (error) {
|
|
1982
|
+
const message =
|
|
1983
|
+
error instanceof Error && error.message
|
|
1984
|
+
? error.message
|
|
1985
|
+
: translateWithFallback(
|
|
1986
|
+
t,
|
|
1987
|
+
'customers.deals.kanban.bulk.changeStage.error',
|
|
1988
|
+
'Failed to start bulk stage update.',
|
|
1989
|
+
)
|
|
1990
|
+
flash(message, 'error')
|
|
1991
|
+
} finally {
|
|
1992
|
+
setIsBulkMutating(false)
|
|
1993
|
+
}
|
|
1994
|
+
},
|
|
1995
|
+
[
|
|
1996
|
+
bulkSelectionSummary.count,
|
|
1997
|
+
bulkSelectionSummary.ids,
|
|
1998
|
+
dealMutationContextId,
|
|
1999
|
+
invalidateKanbanData,
|
|
2000
|
+
retryDealMutation,
|
|
2001
|
+
runDealMutation,
|
|
2002
|
+
t,
|
|
2003
|
+
trackBulkProgressJob,
|
|
2004
|
+
],
|
|
2005
|
+
)
|
|
2006
|
+
|
|
2007
|
+
const handleBulkChangeOwner = React.useCallback(
|
|
2008
|
+
async (ownerUserId: string | null) => {
|
|
2009
|
+
if (bulkSelectionSummary.ids.length === 0) return
|
|
2010
|
+
setIsBulkMutating(true)
|
|
2011
|
+
let progressJobId: string | null = null
|
|
2012
|
+
try {
|
|
2013
|
+
await runDealMutation({
|
|
2014
|
+
operation: async () => {
|
|
2015
|
+
const call = await apiCallOrThrow<BulkJobResponse>(
|
|
2016
|
+
'/api/customers/deals/bulk-update-owner',
|
|
2017
|
+
{
|
|
2018
|
+
method: 'POST',
|
|
2019
|
+
headers: { 'content-type': 'application/json' },
|
|
2020
|
+
body: JSON.stringify({
|
|
2021
|
+
ids: bulkSelectionSummary.ids,
|
|
2022
|
+
ownerUserId,
|
|
2023
|
+
}),
|
|
2024
|
+
},
|
|
2025
|
+
{
|
|
2026
|
+
errorMessage: translateWithFallback(
|
|
2027
|
+
t,
|
|
2028
|
+
'customers.deals.kanban.bulk.changeOwner.error',
|
|
2029
|
+
'Failed to start bulk owner update.',
|
|
2030
|
+
),
|
|
2031
|
+
},
|
|
2032
|
+
)
|
|
2033
|
+
progressJobId = call.result?.progressJobId ?? null
|
|
2034
|
+
},
|
|
2035
|
+
context: {
|
|
2036
|
+
formId: dealMutationContextId,
|
|
2037
|
+
resourceKind: 'customers.deals.bulk_owner',
|
|
2038
|
+
resourceId: bulkSelectionSummary.ids.join(','),
|
|
2039
|
+
retryLastMutation: retryDealMutation,
|
|
2040
|
+
},
|
|
2041
|
+
})
|
|
2042
|
+
flash(
|
|
2043
|
+
translateWithFallback(
|
|
2044
|
+
t,
|
|
2045
|
+
'customers.deals.kanban.bulk.changeOwner.queued',
|
|
2046
|
+
'Bulk owner update started ({count} deals).',
|
|
2047
|
+
{ count: bulkSelectionSummary.count },
|
|
2048
|
+
),
|
|
2049
|
+
'success',
|
|
2050
|
+
)
|
|
2051
|
+
setChangeOwnerOpen(false)
|
|
2052
|
+
setSelectedDealIds(new Set())
|
|
2053
|
+
if (progressJobId) {
|
|
2054
|
+
trackBulkProgressJob(progressJobId)
|
|
2055
|
+
}
|
|
2056
|
+
invalidateKanbanData()
|
|
2057
|
+
} catch (error) {
|
|
2058
|
+
const message =
|
|
2059
|
+
error instanceof Error && error.message
|
|
2060
|
+
? error.message
|
|
2061
|
+
: translateWithFallback(
|
|
2062
|
+
t,
|
|
2063
|
+
'customers.deals.kanban.bulk.changeOwner.error',
|
|
2064
|
+
'Failed to start bulk owner update.',
|
|
2065
|
+
)
|
|
2066
|
+
flash(message, 'error')
|
|
2067
|
+
} finally {
|
|
2068
|
+
setIsBulkMutating(false)
|
|
2069
|
+
}
|
|
2070
|
+
},
|
|
2071
|
+
[
|
|
2072
|
+
bulkSelectionSummary.count,
|
|
2073
|
+
bulkSelectionSummary.ids,
|
|
2074
|
+
dealMutationContextId,
|
|
2075
|
+
invalidateKanbanData,
|
|
2076
|
+
retryDealMutation,
|
|
2077
|
+
runDealMutation,
|
|
2078
|
+
t,
|
|
2079
|
+
trackBulkProgressJob,
|
|
2080
|
+
],
|
|
2081
|
+
)
|
|
2082
|
+
|
|
2083
|
+
const handleBulkExport = React.useCallback(() => {
|
|
2084
|
+
if (bulkSelectionSummary.ids.length === 0) return
|
|
2085
|
+
const url = buildCrudExportUrl(
|
|
2086
|
+
'customers/deals',
|
|
2087
|
+
{
|
|
2088
|
+
ids: bulkSelectionSummary.ids.join(','),
|
|
2089
|
+
exportScope: 'view',
|
|
2090
|
+
},
|
|
2091
|
+
'csv',
|
|
2092
|
+
)
|
|
2093
|
+
if (typeof window !== 'undefined') {
|
|
2094
|
+
window.open(url, '_blank', 'noopener')
|
|
2095
|
+
}
|
|
2096
|
+
}, [bulkSelectionSummary.ids])
|
|
2097
|
+
|
|
2098
|
+
const handleBulkDelete = React.useCallback(async () => {
|
|
2099
|
+
if (bulkSelectionSummary.ids.length === 0) return
|
|
2100
|
+
const confirmed = await confirm({
|
|
2101
|
+
title: translateWithFallback(
|
|
2102
|
+
t,
|
|
2103
|
+
'customers.deals.kanban.bulk.delete.title',
|
|
2104
|
+
'Delete {count} deals?',
|
|
2105
|
+
{ count: bulkSelectionSummary.count },
|
|
2106
|
+
),
|
|
2107
|
+
text: translateWithFallback(
|
|
2108
|
+
t,
|
|
2109
|
+
'customers.deals.kanban.bulk.delete.text',
|
|
2110
|
+
'This action cannot be undone.',
|
|
2111
|
+
),
|
|
2112
|
+
variant: 'destructive',
|
|
2113
|
+
confirmText: translateWithFallback(t, 'customers.deals.kanban.bulk.delete', 'Delete'),
|
|
2114
|
+
})
|
|
2115
|
+
if (!confirmed) return
|
|
2116
|
+
|
|
2117
|
+
const rows = deals.filter((deal) => selectedDealIds.has(deal.id)).map((deal) => ({ id: deal.id }))
|
|
2118
|
+
|
|
2119
|
+
setIsBulkMutating(true)
|
|
2120
|
+
try {
|
|
2121
|
+
const { succeeded, failures } = await runBulkDelete(
|
|
2122
|
+
rows,
|
|
2123
|
+
async (row) => {
|
|
2124
|
+
await deleteCrud('customers/deals', {
|
|
2125
|
+
body: { id: row.id },
|
|
2126
|
+
errorMessage: translateWithFallback(
|
|
2127
|
+
t,
|
|
2128
|
+
'customers.deals.kanban.menu.delete.error',
|
|
2129
|
+
'Failed to delete deal.',
|
|
2130
|
+
),
|
|
2131
|
+
})
|
|
2132
|
+
},
|
|
2133
|
+
{
|
|
2134
|
+
fallbackErrorMessage: translateWithFallback(
|
|
2135
|
+
t,
|
|
2136
|
+
'customers.deals.kanban.menu.delete.error',
|
|
2137
|
+
'Failed to delete deal.',
|
|
2138
|
+
),
|
|
2139
|
+
logTag: 'customers.deals.kanban',
|
|
2140
|
+
progress: {
|
|
2141
|
+
jobType: 'customers.deals.bulk_delete',
|
|
2142
|
+
name: translateWithFallback(
|
|
2143
|
+
t,
|
|
2144
|
+
'customers.deals.kanban.bulk.delete.progress.name',
|
|
2145
|
+
'Delete selected deals',
|
|
2146
|
+
),
|
|
2147
|
+
description: translateWithFallback(
|
|
2148
|
+
t,
|
|
2149
|
+
'customers.deals.kanban.bulk.delete.progress.description',
|
|
2150
|
+
'{count} deals queued for deletion',
|
|
2151
|
+
{ count: rows.length },
|
|
2152
|
+
),
|
|
2153
|
+
meta: { source: 'customers.deals.kanban' },
|
|
2154
|
+
},
|
|
2155
|
+
},
|
|
2156
|
+
)
|
|
2157
|
+
|
|
2158
|
+
if (succeeded.length > 0) {
|
|
2159
|
+
flash(
|
|
2160
|
+
translateWithFallback(
|
|
2161
|
+
t,
|
|
2162
|
+
'customers.deals.kanban.bulk.delete.success',
|
|
2163
|
+
'{count} deals deleted.',
|
|
2164
|
+
{ count: succeeded.length },
|
|
2165
|
+
),
|
|
2166
|
+
failures.length === 0 ? 'success' : 'warning',
|
|
2167
|
+
)
|
|
2168
|
+
}
|
|
2169
|
+
for (const group of groupBulkDeleteFailures(failures)) {
|
|
2170
|
+
const message =
|
|
2171
|
+
group.count === 1
|
|
2172
|
+
? group.sampleMessage
|
|
2173
|
+
: translateWithFallback(
|
|
2174
|
+
t,
|
|
2175
|
+
'customers.deals.kanban.bulk.delete.failedGroup',
|
|
2176
|
+
'{count} deals could not be deleted: {message}',
|
|
2177
|
+
{ count: group.count, message: group.sampleMessage },
|
|
2178
|
+
)
|
|
2179
|
+
flash(message, 'error')
|
|
2180
|
+
}
|
|
2181
|
+
setSelectedDealIds(new Set())
|
|
2182
|
+
invalidateKanbanData()
|
|
2183
|
+
} finally {
|
|
2184
|
+
setIsBulkMutating(false)
|
|
2185
|
+
}
|
|
2186
|
+
}, [
|
|
2187
|
+
bulkSelectionSummary.count,
|
|
2188
|
+
bulkSelectionSummary.ids,
|
|
2189
|
+
confirm,
|
|
2190
|
+
deals,
|
|
2191
|
+
queryClient,
|
|
2192
|
+
selectedDealIds,
|
|
2193
|
+
t,
|
|
2194
|
+
])
|
|
2195
|
+
|
|
2196
|
+
const duplicateDeal = React.useCallback(
|
|
2197
|
+
async (deal: DealCardData) => {
|
|
2198
|
+
setPendingDealId(deal.id)
|
|
2199
|
+
try {
|
|
2200
|
+
const stageId = stageIdByDealId.get(deal.id) ?? null
|
|
2201
|
+
const payload: Record<string, unknown> = {
|
|
2202
|
+
title: translateWithFallback(
|
|
2203
|
+
t,
|
|
2204
|
+
'customers.deals.kanban.menu.duplicate.titleSuffix',
|
|
2205
|
+
'{title} (copy)',
|
|
2206
|
+
{ title: deal.title },
|
|
2207
|
+
),
|
|
2208
|
+
status: 'open',
|
|
2209
|
+
}
|
|
2210
|
+
if (selectedPipelineId) payload.pipelineId = selectedPipelineId
|
|
2211
|
+
if (stageId && stageId !== '__unassigned') payload.pipelineStageId = stageId
|
|
2212
|
+
if (typeof deal.valueAmount === 'number') payload.valueAmount = deal.valueAmount
|
|
2213
|
+
if (deal.valueCurrency) payload.valueCurrency = deal.valueCurrency
|
|
2214
|
+
if (typeof deal.probability === 'number') payload.probability = deal.probability
|
|
2215
|
+
if (deal.expectedCloseAt) payload.expectedCloseAt = deal.expectedCloseAt
|
|
2216
|
+
if (deal.owner?.userId) payload.ownerUserId = deal.owner.userId
|
|
2217
|
+
|
|
2218
|
+
await runDealMutation({
|
|
2219
|
+
operation: async () => {
|
|
2220
|
+
await apiCallOrThrow(
|
|
2221
|
+
'/api/customers/deals',
|
|
2222
|
+
{
|
|
2223
|
+
method: 'POST',
|
|
2224
|
+
headers: { 'content-type': 'application/json' },
|
|
2225
|
+
body: JSON.stringify(payload),
|
|
2226
|
+
},
|
|
2227
|
+
{
|
|
2228
|
+
errorMessage: translateWithFallback(
|
|
2229
|
+
t,
|
|
2230
|
+
'customers.deals.kanban.menu.duplicate.error',
|
|
2231
|
+
'Failed to duplicate deal.',
|
|
2232
|
+
),
|
|
2233
|
+
},
|
|
2234
|
+
)
|
|
2235
|
+
},
|
|
2236
|
+
context: {
|
|
2237
|
+
formId: dealMutationContextId,
|
|
2238
|
+
resourceKind: 'customers.deal',
|
|
2239
|
+
resourceId: deal.id,
|
|
2240
|
+
retryLastMutation: retryDealMutation,
|
|
2241
|
+
},
|
|
2242
|
+
})
|
|
2243
|
+
flash(
|
|
2244
|
+
translateWithFallback(
|
|
2245
|
+
t,
|
|
2246
|
+
'customers.deals.kanban.menu.duplicate.success',
|
|
2247
|
+
'Deal duplicated.',
|
|
2248
|
+
),
|
|
2249
|
+
'success',
|
|
2250
|
+
)
|
|
2251
|
+
invalidateKanbanData()
|
|
2252
|
+
} catch (error) {
|
|
2253
|
+
const message =
|
|
2254
|
+
error instanceof Error && error.message
|
|
2255
|
+
? error.message
|
|
2256
|
+
: translateWithFallback(
|
|
2257
|
+
t,
|
|
2258
|
+
'customers.deals.kanban.menu.duplicate.error',
|
|
2259
|
+
'Failed to duplicate deal.',
|
|
2260
|
+
)
|
|
2261
|
+
flash(message, 'error')
|
|
2262
|
+
} finally {
|
|
2263
|
+
setPendingDealId(null)
|
|
2264
|
+
}
|
|
2265
|
+
},
|
|
2266
|
+
[
|
|
2267
|
+
queryClient,
|
|
2268
|
+
retryDealMutation,
|
|
2269
|
+
runDealMutation,
|
|
2270
|
+
selectedPipelineId,
|
|
2271
|
+
stageIdByDealId,
|
|
2272
|
+
t,
|
|
2273
|
+
],
|
|
2274
|
+
)
|
|
2275
|
+
|
|
2276
|
+
const buildMenuItems = React.useCallback(
|
|
2277
|
+
(deal: DealCardData): RowActionItem[] => [
|
|
2278
|
+
{
|
|
2279
|
+
id: 'open',
|
|
2280
|
+
label: translateWithFallback(t, 'customers.deals.kanban.menu.open', 'Open deal'),
|
|
2281
|
+
onSelect: () => handleOpenDetail(deal.id),
|
|
2282
|
+
},
|
|
2283
|
+
{
|
|
2284
|
+
id: 'edit',
|
|
2285
|
+
label: translateWithFallback(t, 'customers.deals.kanban.menu.edit', 'Edit'),
|
|
2286
|
+
onSelect: () => handleOpenDetail(deal.id),
|
|
2287
|
+
},
|
|
2288
|
+
{
|
|
2289
|
+
id: 'duplicate',
|
|
2290
|
+
label: translateWithFallback(t, 'customers.deals.kanban.menu.duplicate', 'Duplicate'),
|
|
2291
|
+
onSelect: () => void duplicateDeal(deal),
|
|
2292
|
+
},
|
|
2293
|
+
{
|
|
2294
|
+
id: 'move-stage',
|
|
2295
|
+
label: translateWithFallback(t, 'customers.deals.kanban.menu.moveStage', 'Move stage…'),
|
|
2296
|
+
onSelect: () =>
|
|
2297
|
+
setSingleMoveStageContext({
|
|
2298
|
+
dealId: deal.id,
|
|
2299
|
+
currentStageId: stageIdByDealId.get(deal.id) ?? null,
|
|
2300
|
+
}),
|
|
2301
|
+
},
|
|
2302
|
+
{
|
|
2303
|
+
id: 'mark-won',
|
|
2304
|
+
label: translateWithFallback(t, 'customers.deals.kanban.menu.markWon', 'Mark as Won'),
|
|
2305
|
+
onSelect: () => void updateDealStatus(deal.id, 'win'),
|
|
2306
|
+
},
|
|
2307
|
+
{
|
|
2308
|
+
id: 'mark-lost',
|
|
2309
|
+
label: translateWithFallback(t, 'customers.deals.kanban.menu.markLost', 'Mark as Lost'),
|
|
2310
|
+
onSelect: () => void updateDealStatus(deal.id, 'loose'),
|
|
2311
|
+
},
|
|
2312
|
+
{
|
|
2313
|
+
id: 'delete',
|
|
2314
|
+
label: translateWithFallback(t, 'customers.deals.kanban.menu.delete', 'Delete'),
|
|
2315
|
+
destructive: true,
|
|
2316
|
+
onSelect: () => void deleteDeal(deal.id, deal.title),
|
|
2317
|
+
},
|
|
2318
|
+
],
|
|
2319
|
+
[
|
|
2320
|
+
deleteDeal,
|
|
2321
|
+
duplicateDeal,
|
|
2322
|
+
handleOpenDetail,
|
|
2323
|
+
stageIdByDealId,
|
|
2324
|
+
t,
|
|
2325
|
+
updateDealStatus,
|
|
2326
|
+
],
|
|
2327
|
+
)
|
|
2328
|
+
|
|
2329
|
+
const handleComposeActivity = React.useCallback(
|
|
2330
|
+
(dealId: string, type: 'call' | 'email' | 'note') => {
|
|
2331
|
+
const deal = deals.find((entry) => entry.id === dealId)
|
|
431
2332
|
if (!deal) return
|
|
432
|
-
|
|
2333
|
+
const entityId = deal.primaryCompany?.id
|
|
2334
|
+
if (!entityId) {
|
|
433
2335
|
flash(
|
|
434
|
-
|
|
2336
|
+
translateWithFallback(
|
|
2337
|
+
t,
|
|
2338
|
+
'customers.deals.kanban.activityComposer.noEntity',
|
|
2339
|
+
'Link a company to this deal before logging activities here.',
|
|
2340
|
+
),
|
|
435
2341
|
'info',
|
|
436
2342
|
)
|
|
437
2343
|
return
|
|
438
2344
|
}
|
|
439
|
-
|
|
440
|
-
|
|
2345
|
+
setActivityContext({
|
|
2346
|
+
dealId,
|
|
2347
|
+
dealTitle: deal.title,
|
|
2348
|
+
type,
|
|
2349
|
+
entityId,
|
|
2350
|
+
})
|
|
441
2351
|
},
|
|
442
|
-
[
|
|
2352
|
+
[deals, t],
|
|
443
2353
|
)
|
|
444
2354
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
2355
|
+
// The placeholder "stub" chips have been replaced by real popovers; pass an empty list.
|
|
2356
|
+
const filterChips = React.useMemo<KanbanFilterChip[]>(() => [], [])
|
|
2357
|
+
|
|
2358
|
+
const pipelineFilterOptions = React.useMemo(
|
|
2359
|
+
() =>
|
|
2360
|
+
(pipelinesQuery.data ?? []).map((pipeline) => ({
|
|
2361
|
+
id: pipeline.id,
|
|
2362
|
+
name: pipeline.name,
|
|
2363
|
+
})),
|
|
2364
|
+
[pipelinesQuery.data],
|
|
2365
|
+
)
|
|
2366
|
+
|
|
2367
|
+
// Async loaders for entity-filter popovers (Owner / People / Companies)
|
|
2368
|
+
const loadOwnerOptions = React.useCallback(
|
|
2369
|
+
async (query: string, _signal: AbortSignal): Promise<EntityFilterOption[]> => {
|
|
2370
|
+
const items = await fetchAssignableStaffMembers(query ?? '', { pageSize: 100 })
|
|
2371
|
+
const opts: EntityFilterOption[] = items
|
|
2372
|
+
.filter((user) => !!user.userId && !!user.displayName)
|
|
2373
|
+
.map((user) => ({ value: user.userId!, label: user.displayName! }))
|
|
2374
|
+
// Cache labels so chip can display readable text for already-selected ids
|
|
2375
|
+
setOwnerLabels((prev) => {
|
|
2376
|
+
const next: Record<string, string> = { ...prev }
|
|
2377
|
+
for (const option of opts) next[option.value] = option.label
|
|
2378
|
+
return next
|
|
2379
|
+
})
|
|
2380
|
+
return opts
|
|
450
2381
|
},
|
|
451
|
-
[
|
|
452
|
-
)
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
2382
|
+
[],
|
|
2383
|
+
)
|
|
2384
|
+
const loadPeopleOptions = React.useCallback(
|
|
2385
|
+
async (query: string, signal: AbortSignal): Promise<EntityFilterOption[]> => {
|
|
2386
|
+
const params = new URLSearchParams()
|
|
2387
|
+
params.set('page', '1')
|
|
2388
|
+
params.set('pageSize', '50')
|
|
2389
|
+
if (query) params.set('search', query)
|
|
2390
|
+
params.set('sortField', 'displayName')
|
|
2391
|
+
params.set('sortDir', 'asc')
|
|
2392
|
+
// Route the request through `apiCall` so scoped tenant/org headers are attached and the
|
|
2393
|
+
// JSON parser uses the shared safe-read helper. AbortSignal is forwarded through init.
|
|
2394
|
+
const call = await apiCall<{
|
|
2395
|
+
items?: Array<{ id?: string; display_name?: string; first_name?: string; last_name?: string }>
|
|
2396
|
+
}>(`/api/customers/people?${params.toString()}`, { signal })
|
|
2397
|
+
if (!call.ok) return []
|
|
2398
|
+
const items = call.result?.items ?? []
|
|
2399
|
+
const opts: EntityFilterOption[] = []
|
|
2400
|
+
for (const it of items) {
|
|
2401
|
+
if (!it.id) continue
|
|
2402
|
+
const label = (it.display_name && it.display_name.trim().length)
|
|
2403
|
+
? it.display_name.trim()
|
|
2404
|
+
: [it.first_name, it.last_name].filter(Boolean).join(' ').trim() || it.id.slice(0, 8)
|
|
2405
|
+
opts.push({ value: it.id, label })
|
|
2406
|
+
}
|
|
2407
|
+
setPeopleLabels((prev) => {
|
|
2408
|
+
const next: Record<string, string> = { ...prev }
|
|
2409
|
+
for (const o of opts) next[o.value] = o.label
|
|
2410
|
+
return next
|
|
2411
|
+
})
|
|
2412
|
+
return opts
|
|
2413
|
+
},
|
|
2414
|
+
[],
|
|
2415
|
+
)
|
|
2416
|
+
const loadCompanyOptions = React.useCallback(
|
|
2417
|
+
async (query: string, signal: AbortSignal): Promise<EntityFilterOption[]> => {
|
|
2418
|
+
const params = new URLSearchParams()
|
|
2419
|
+
params.set('page', '1')
|
|
2420
|
+
params.set('pageSize', '50')
|
|
2421
|
+
if (query) params.set('search', query)
|
|
2422
|
+
params.set('sortField', 'display_name')
|
|
2423
|
+
params.set('sortDir', 'asc')
|
|
2424
|
+
const call = await apiCall<{ items?: Array<{ id?: string; display_name?: string }> }>(
|
|
2425
|
+
`/api/customers/companies?${params.toString()}`,
|
|
2426
|
+
{ signal },
|
|
2427
|
+
)
|
|
2428
|
+
if (!call.ok) return []
|
|
2429
|
+
const items = call.result?.items ?? []
|
|
2430
|
+
const opts: EntityFilterOption[] = []
|
|
2431
|
+
for (const it of items) {
|
|
2432
|
+
if (!it.id || !it.display_name) continue
|
|
2433
|
+
opts.push({ value: it.id, label: it.display_name })
|
|
2434
|
+
}
|
|
2435
|
+
setCompanyLabels((prev) => {
|
|
2436
|
+
const next: Record<string, string> = { ...prev }
|
|
2437
|
+
for (const o of opts) next[o.value] = o.label
|
|
2438
|
+
return next
|
|
2439
|
+
})
|
|
2440
|
+
return opts
|
|
2441
|
+
},
|
|
2442
|
+
[],
|
|
2443
|
+
)
|
|
2444
|
+
|
|
2445
|
+
const leadingChipsNode = (
|
|
2446
|
+
<>
|
|
2447
|
+
<StatusFilterPopover values={statusFilters} onApply={setStatusFilters} />
|
|
2448
|
+
<PipelineFilterPopover
|
|
2449
|
+
pipelines={pipelineFilterOptions}
|
|
2450
|
+
selectedPipelineId={selectedPipelineId}
|
|
2451
|
+
onApply={setSelectedPipelineId}
|
|
2452
|
+
/>
|
|
2453
|
+
{boardSummary && boardSummary.rows.length > 0 ? (
|
|
2454
|
+
<CurrencyFilterPopover
|
|
2455
|
+
rows={boardSummary.rows}
|
|
2456
|
+
baseCurrencyCode={boardSummary.baseCurrencyCode}
|
|
2457
|
+
totalInBaseCurrency={boardSummary.totalInBaseCurrency}
|
|
2458
|
+
headingLabel={translateWithFallback(
|
|
2459
|
+
t,
|
|
2460
|
+
'customers.deals.kanban.boardSummary.headingLabel',
|
|
2461
|
+
'PIPELINE',
|
|
2462
|
+
)}
|
|
2463
|
+
headingCount={total}
|
|
2464
|
+
selectedCurrency={currencyFilter}
|
|
2465
|
+
onApply={setCurrencyFilter}
|
|
2466
|
+
/>
|
|
2467
|
+
) : null}
|
|
2468
|
+
<EntityFilterPopover
|
|
2469
|
+
label={translateWithFallback(t, 'customers.deals.kanban.filter.owner', 'Owner')}
|
|
2470
|
+
anyLabel={translateWithFallback(t, 'customers.deals.kanban.filter.all', 'All')}
|
|
2471
|
+
values={ownerFilters}
|
|
2472
|
+
onApply={setOwnerFilters}
|
|
2473
|
+
loadOptions={loadOwnerOptions}
|
|
2474
|
+
labelById={ownerLabels}
|
|
2475
|
+
/>
|
|
2476
|
+
<EntityFilterPopover
|
|
2477
|
+
label={translateWithFallback(t, 'customers.deals.kanban.filter.people', 'People')}
|
|
2478
|
+
values={peopleFilters}
|
|
2479
|
+
onApply={setPeopleFilters}
|
|
2480
|
+
loadOptions={loadPeopleOptions}
|
|
2481
|
+
labelById={peopleLabels}
|
|
2482
|
+
/>
|
|
2483
|
+
<EntityFilterPopover
|
|
2484
|
+
label={translateWithFallback(t, 'customers.deals.kanban.filter.companies', 'Companies')}
|
|
2485
|
+
values={companyFilters}
|
|
2486
|
+
onApply={setCompanyFilters}
|
|
2487
|
+
loadOptions={loadCompanyOptions}
|
|
2488
|
+
labelById={companyLabels}
|
|
2489
|
+
/>
|
|
2490
|
+
<CloseDateFilterPopover value={closeDateFilter} onApply={setCloseDateFilter} />
|
|
2491
|
+
</>
|
|
2492
|
+
)
|
|
2493
|
+
|
|
2494
|
+
const sortNode = <SortByPopover value={sortBy} onApply={setSortBy} />
|
|
2495
|
+
|
|
2496
|
+
const activePipeline = React.useMemo(() => {
|
|
2497
|
+
if (!selectedPipelineId) return null
|
|
2498
|
+
return pipelinesQuery.data?.find((pipeline) => pipeline.id === selectedPipelineId) ?? null
|
|
2499
|
+
}, [pipelinesQuery.data, selectedPipelineId])
|
|
2500
|
+
|
|
2501
|
+
const dragHint = translateWithFallback(
|
|
2502
|
+
t,
|
|
2503
|
+
'customers.deals.kanban.helper.dragHint',
|
|
2504
|
+
'Drag cards between lanes to update stage',
|
|
2505
|
+
)
|
|
2506
|
+
const footerCount = translateWithFallback(
|
|
2507
|
+
t,
|
|
2508
|
+
'customers.deals.kanban.footer.count',
|
|
2509
|
+
'Showing {visible} of {total} deals in {pipeline} pipeline',
|
|
2510
|
+
{
|
|
2511
|
+
visible: deals.length,
|
|
2512
|
+
total,
|
|
2513
|
+
pipeline: activePipeline?.name ?? '—',
|
|
2514
|
+
},
|
|
2515
|
+
)
|
|
468
2516
|
|
|
469
2517
|
return (
|
|
470
2518
|
<Page>
|
|
471
2519
|
<PageBody>
|
|
472
|
-
<div className="flex flex-col gap-
|
|
473
|
-
<
|
|
474
|
-
<
|
|
475
|
-
<
|
|
476
|
-
|
|
2520
|
+
<div className="flex flex-col gap-2">
|
|
2521
|
+
<Breadcrumb>
|
|
2522
|
+
<BreadcrumbList>
|
|
2523
|
+
<BreadcrumbItem>
|
|
2524
|
+
<BreadcrumbLink asChild>
|
|
2525
|
+
<Link href="/backend">
|
|
2526
|
+
{translateWithFallback(t, 'customers.deals.kanban.breadcrumb.dashboard', 'Dashboard')}
|
|
2527
|
+
</Link>
|
|
2528
|
+
</BreadcrumbLink>
|
|
2529
|
+
</BreadcrumbItem>
|
|
2530
|
+
<BreadcrumbSeparator />
|
|
2531
|
+
<BreadcrumbItem>
|
|
2532
|
+
<BreadcrumbPage>
|
|
2533
|
+
{translateWithFallback(t, 'customers.deals.kanban.breadcrumb.deals', 'Deals')}
|
|
2534
|
+
</BreadcrumbPage>
|
|
2535
|
+
</BreadcrumbItem>
|
|
2536
|
+
</BreadcrumbList>
|
|
2537
|
+
</Breadcrumb>
|
|
2538
|
+
|
|
2539
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
2540
|
+
<div className="flex flex-col gap-1">
|
|
2541
|
+
{/*
|
|
2542
|
+
Heading typography mirrors DS PageHeader (text-xl sm:text-2xl font-semibold
|
|
2543
|
+
leading-tight) so the kanban title matches /backend/customers/people and
|
|
2544
|
+
other backoffice pages. We don't use the PageHeader component itself because
|
|
2545
|
+
it can't host the rich board-summary content (LaneCurrencyBreakdown popover)
|
|
2546
|
+
that lives directly under the title.
|
|
2547
|
+
*/}
|
|
2548
|
+
<h1 className="text-xl font-semibold leading-tight text-foreground sm:text-2xl">
|
|
2549
|
+
{translateWithFallback(t, 'customers.deals.kanban.pageTitle', 'Deals')}
|
|
477
2550
|
</h1>
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
2551
|
+
{boardSummary && boardSummary.rows.length > 0 ? (
|
|
2552
|
+
/*
|
|
2553
|
+
* Pipeline-wide value summary. The headline number is the converted total in
|
|
2554
|
+
* the tenant's base currency, paired with the deal count for context. When at
|
|
2555
|
+
* least one currency lacks an FX rate, we show a `~` prefix and a "partial"
|
|
2556
|
+
* caveat — the `LaneCurrencyBreakdown` popover (anchored on the `+N` chip)
|
|
2557
|
+
* surfaces the full breakdown and missing-rate disclosure. Clicking the
|
|
2558
|
+
* popover trigger here gives the operator the same board-level detail view.
|
|
2559
|
+
*/
|
|
2560
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
2561
|
+
<span>
|
|
2562
|
+
{translateWithFallback(
|
|
2563
|
+
t,
|
|
2564
|
+
'customers.deals.kanban.boardSummary.count',
|
|
2565
|
+
'{count} deals',
|
|
2566
|
+
{ count: total },
|
|
2567
|
+
)}
|
|
2568
|
+
</span>
|
|
2569
|
+
{boardSummary.baseCurrencyCode && boardSummary.totalInBaseCurrency > 0 ? (
|
|
2570
|
+
<>
|
|
2571
|
+
<span aria-hidden="true">·</span>
|
|
2572
|
+
<span className="flex items-baseline gap-1 font-semibold text-foreground">
|
|
2573
|
+
{boardSummary.convertedAll ? null : (
|
|
2574
|
+
<span className="text-muted-foreground" aria-hidden="true">
|
|
2575
|
+
~
|
|
2576
|
+
</span>
|
|
2577
|
+
)}
|
|
2578
|
+
<span>
|
|
2579
|
+
{new Intl.NumberFormat(undefined, {
|
|
2580
|
+
style: 'decimal',
|
|
2581
|
+
maximumFractionDigits: 0,
|
|
2582
|
+
}).format(boardSummary.totalInBaseCurrency)}
|
|
2583
|
+
</span>
|
|
2584
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
2585
|
+
{boardSummary.baseCurrencyCode}
|
|
2586
|
+
</span>
|
|
2587
|
+
</span>
|
|
2588
|
+
{!boardSummary.convertedAll ? (
|
|
2589
|
+
<span
|
|
2590
|
+
className="text-overline uppercase tracking-wide text-status-warning-text"
|
|
2591
|
+
title={translateWithFallback(
|
|
2592
|
+
t,
|
|
2593
|
+
'customers.deals.kanban.boardSummary.partialHint',
|
|
2594
|
+
'Missing FX rates for {currencies} — excluded from total',
|
|
2595
|
+
{ currencies: boardSummary.missingRateCurrencies.join(', ') },
|
|
2596
|
+
)}
|
|
2597
|
+
>
|
|
2598
|
+
{translateWithFallback(
|
|
2599
|
+
t,
|
|
2600
|
+
'customers.deals.kanban.boardSummary.partial',
|
|
2601
|
+
'partial',
|
|
2602
|
+
)}
|
|
2603
|
+
</span>
|
|
2604
|
+
) : null}
|
|
2605
|
+
</>
|
|
2606
|
+
) : null}
|
|
2607
|
+
{boardSummary.rows.length > 1 ? (
|
|
2608
|
+
<LaneCurrencyBreakdown
|
|
2609
|
+
rows={boardSummary.rows}
|
|
2610
|
+
baseCurrencyCode={boardSummary.baseCurrencyCode}
|
|
2611
|
+
totalInBaseCurrency={boardSummary.totalInBaseCurrency}
|
|
2612
|
+
convertedAll={boardSummary.convertedAll}
|
|
2613
|
+
missingRateCurrencies={boardSummary.missingRateCurrencies}
|
|
2614
|
+
headingLabel={translateWithFallback(
|
|
2615
|
+
t,
|
|
2616
|
+
'customers.deals.kanban.boardSummary.headingLabel',
|
|
2617
|
+
'PIPELINE',
|
|
2618
|
+
)}
|
|
2619
|
+
headingCount={total}
|
|
2620
|
+
triggerLabel={translateWithFallback(
|
|
2621
|
+
t,
|
|
2622
|
+
'customers.deals.kanban.boardSummary.breakdownTrigger',
|
|
2623
|
+
'Breakdown',
|
|
2624
|
+
)}
|
|
2625
|
+
triggerClassName="inline-flex items-center rounded-md border border-border bg-card px-2 py-0.5 text-overline font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
2626
|
+
/>
|
|
2627
|
+
) : null}
|
|
2628
|
+
</div>
|
|
2629
|
+
) : null}
|
|
2630
|
+
</div>
|
|
2631
|
+
<div className="flex flex-wrap items-center gap-2.5">
|
|
2632
|
+
<SearchInput
|
|
2633
|
+
value={search}
|
|
2634
|
+
onChange={setSearch}
|
|
2635
|
+
placeholder={translateWithFallback(
|
|
2636
|
+
t,
|
|
2637
|
+
'customers.deals.kanban.search.placeholder',
|
|
2638
|
+
'Search deals…',
|
|
482
2639
|
)}
|
|
483
|
-
|
|
2640
|
+
className="w-64"
|
|
2641
|
+
/>
|
|
2642
|
+
<Button variant="outline" type="button" onClick={handleCustomizeView}>
|
|
2643
|
+
<SlidersHorizontal className="size-4" aria-hidden="true" />
|
|
2644
|
+
{translateWithFallback(t, 'customers.deals.kanban.cta.customize', 'Customize view')}
|
|
2645
|
+
</Button>
|
|
2646
|
+
<Button
|
|
2647
|
+
variant="outline"
|
|
2648
|
+
type="button"
|
|
2649
|
+
onClick={handleAddStage}
|
|
2650
|
+
disabled={!selectedPipelineId}
|
|
2651
|
+
>
|
|
2652
|
+
<Plus className="size-4" aria-hidden="true" />
|
|
2653
|
+
{translateWithFallback(t, 'customers.deals.kanban.cta.newStage', 'New stage')}
|
|
2654
|
+
</Button>
|
|
2655
|
+
<Button asChild>
|
|
2656
|
+
<Link href="/backend/customers/deals/create?returnTo=/backend/customers/deals/pipeline">
|
|
2657
|
+
<Plus className="size-4" aria-hidden="true" />
|
|
2658
|
+
{translateWithFallback(t, 'customers.deals.kanban.cta.newDeal', 'New deal')}
|
|
2659
|
+
</Link>
|
|
2660
|
+
</Button>
|
|
484
2661
|
</div>
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
</SelectTrigger>
|
|
496
|
-
<SelectContent>
|
|
497
|
-
{pipelinesQuery.data.map((p) => (
|
|
498
|
-
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
|
499
|
-
))}
|
|
500
|
-
</SelectContent>
|
|
501
|
-
</Select>
|
|
502
|
-
</label>
|
|
503
|
-
) : null}
|
|
504
|
-
<Link
|
|
505
|
-
href="/backend/config/customers/pipeline-stages"
|
|
506
|
-
className="text-sm font-medium text-primary hover:underline"
|
|
2662
|
+
</div>
|
|
2663
|
+
|
|
2664
|
+
{pipelinesQuery.data && pipelinesQuery.data.length > 1 ? (
|
|
2665
|
+
<div className="flex items-center gap-2 pb-1 text-sm">
|
|
2666
|
+
<span className="text-muted-foreground">
|
|
2667
|
+
{translateWithFallback(t, 'customers.deals.pipeline.switch.label', 'Pipeline')}
|
|
2668
|
+
</span>
|
|
2669
|
+
<Select
|
|
2670
|
+
value={selectedPipelineId ?? undefined}
|
|
2671
|
+
onValueChange={(value) => setSelectedPipelineId(value || null)}
|
|
507
2672
|
>
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
<
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
</SelectTrigger>
|
|
516
|
-
<SelectContent>
|
|
517
|
-
<SelectItem value="probability">
|
|
518
|
-
{translate('customers.deals.pipeline.sort.probability', 'Probability (high to low)')}
|
|
519
|
-
</SelectItem>
|
|
520
|
-
<SelectItem value="createdAt">
|
|
521
|
-
{translate('customers.deals.pipeline.sort.createdAt', 'Created (newest first)')}
|
|
522
|
-
</SelectItem>
|
|
523
|
-
<SelectItem value="expectedCloseAt">
|
|
524
|
-
{translate('customers.deals.pipeline.sort.expectedCloseAt', 'Expected close (soonest first)')}
|
|
2673
|
+
<SelectTrigger className="w-auto min-w-48">
|
|
2674
|
+
<SelectValue />
|
|
2675
|
+
</SelectTrigger>
|
|
2676
|
+
<SelectContent>
|
|
2677
|
+
{pipelinesQuery.data.map((pipeline) => (
|
|
2678
|
+
<SelectItem key={pipeline.id} value={pipeline.id}>
|
|
2679
|
+
{pipeline.name}
|
|
525
2680
|
</SelectItem>
|
|
526
|
-
|
|
527
|
-
</
|
|
528
|
-
</
|
|
2681
|
+
))}
|
|
2682
|
+
</SelectContent>
|
|
2683
|
+
</Select>
|
|
529
2684
|
</div>
|
|
530
|
-
|
|
2685
|
+
) : null}
|
|
2686
|
+
</div>
|
|
2687
|
+
|
|
2688
|
+
<ViewTabsRow active="kanban" className="mt-4" />
|
|
531
2689
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
2690
|
+
<FilterBarRow
|
|
2691
|
+
leadingChips={leadingChipsNode}
|
|
2692
|
+
chips={filterChips}
|
|
2693
|
+
sortNode={sortNode}
|
|
2694
|
+
onChipClick={handleChipClick}
|
|
2695
|
+
/>
|
|
2696
|
+
|
|
2697
|
+
{!selectedPipelineId ? (
|
|
2698
|
+
<EmptyState
|
|
2699
|
+
icon={<Workflow className="size-8" aria-hidden="true" />}
|
|
2700
|
+
title={translateWithFallback(
|
|
2701
|
+
t,
|
|
2702
|
+
'customers.deals.pipeline.noPipeline',
|
|
2703
|
+
'No pipeline selected. Create a pipeline in settings.',
|
|
2704
|
+
)}
|
|
2705
|
+
className="h-[50vh] w-full"
|
|
2706
|
+
/>
|
|
2707
|
+
) : isInitialLoading ? (
|
|
2708
|
+
<div className="flex h-[50vh] items-center justify-center">
|
|
2709
|
+
<Spinner />
|
|
2710
|
+
</div>
|
|
2711
|
+
) : firstError ? (
|
|
2712
|
+
<div className="max-w-xl">
|
|
2713
|
+
<ErrorNotice
|
|
2714
|
+
message={
|
|
2715
|
+
firstError instanceof Error
|
|
2716
|
+
? firstError.message
|
|
2717
|
+
: translateWithFallback(
|
|
2718
|
+
t,
|
|
2719
|
+
'customers.deals.pipeline.loadError',
|
|
2720
|
+
'Failed to load deals.',
|
|
2721
|
+
)
|
|
2722
|
+
}
|
|
537
2723
|
/>
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
2724
|
+
</div>
|
|
2725
|
+
) : (
|
|
2726
|
+
<DndContext
|
|
2727
|
+
sensors={sensors}
|
|
2728
|
+
collisionDetection={collisionDetection}
|
|
2729
|
+
measuring={measuringConfig}
|
|
2730
|
+
onDragStart={handleDragStart}
|
|
2731
|
+
onDragEnd={handleDragEnd}
|
|
2732
|
+
onDragCancel={handleDragCancel}
|
|
2733
|
+
>
|
|
2734
|
+
{/* Three-column flex: [36px gutter] [scroller flex-1] [36px gutter]
|
|
2735
|
+
Each gutter sized exactly to the 36px arrow button — no extra padding/gap so the
|
|
2736
|
+
scroller keeps as much horizontal room as possible (= more lanes fit). The buttons
|
|
2737
|
+
are absolute inside the gutter so they never overlap card content. */}
|
|
2738
|
+
<div className="flex items-stretch">
|
|
2739
|
+
{/*
|
|
2740
|
+
Lane rail reads as an elevated tab over the kanban scroller (round-2 UX item 31,
|
|
2741
|
+
revised): `bg-card shadow-lg` gives it a real surface and a soft drop-shadow;
|
|
2742
|
+
`z-10` keeps it above the scroller. `mr-2` adds an 8px gap between the rail and
|
|
2743
|
+
the first lane so card edges don't touch the rail (UX-designer round-3 feedback —
|
|
2744
|
+
the prior `-mr-2` tuck-under overlap was reverted; a small positive gap reads
|
|
2745
|
+
cleaner than the overlap).
|
|
2746
|
+
|
|
2747
|
+
`shadow-lg` glows in all four directions by default, but the LEFT side of the
|
|
2748
|
+
rail sits flush with the page chrome — without clipping, that shadow half spills
|
|
2749
|
+
onto the page background and reads as a halo (UX-designer feedback from Zielivia).
|
|
2750
|
+
`clip-path:inset(-40px -40px -40px 0)` extends the visible region 40px beyond
|
|
2751
|
+
top/right/bottom (so the soft drop-shadow can render in full on those three sides)
|
|
2752
|
+
and clips flush at the LEFT edge (the page-chrome side). Right rail mirrors with
|
|
2753
|
+
`inset(-40px 0 -40px -40px)`.
|
|
2754
|
+
*/}
|
|
2755
|
+
<div className="relative z-10 flex w-11 mr-2 shrink-0 items-center justify-center bg-card shadow-lg [clip-path:inset(-40px_-40px_-40px_0)]">
|
|
2756
|
+
<IconButton
|
|
2757
|
+
variant="outline"
|
|
2758
|
+
size="lg"
|
|
2759
|
+
fullRadius
|
|
2760
|
+
onClick={handleScrollPrev}
|
|
2761
|
+
onPointerDown={handleHoldPrevStart}
|
|
2762
|
+
onPointerUp={stopContinuousScroll}
|
|
2763
|
+
onPointerLeave={stopContinuousScroll}
|
|
2764
|
+
onPointerCancel={stopContinuousScroll}
|
|
2765
|
+
aria-label={translateWithFallback(t, 'customers.deals.kanban.board.scrollPrev', 'Scroll to previous stage')}
|
|
2766
|
+
aria-disabled={scrollEdges.atStart}
|
|
2767
|
+
className={`absolute left-0 top-28 flex size-9 items-center justify-center rounded-full border border-border bg-card text-foreground shadow-lg transition-opacity hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
|
|
2768
|
+
scrollEdges.atStart ? 'opacity-30' : ''
|
|
2769
|
+
}`}
|
|
2770
|
+
>
|
|
2771
|
+
<ChevronLeft className="size-5" aria-hidden="true" />
|
|
2772
|
+
</IconButton>
|
|
551
2773
|
</div>
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
2774
|
+
<div
|
|
2775
|
+
ref={setBoardScroller}
|
|
2776
|
+
data-kanban-scroller
|
|
2777
|
+
className={`flex min-w-0 flex-1 gap-3.5 overflow-x-auto pb-6 ${
|
|
2778
|
+
activeDragDealId ? 'cursor-grabbing select-none' : ''
|
|
2779
|
+
}`}
|
|
2780
|
+
>
|
|
2781
|
+
{lanes.length === 0 ? (
|
|
2782
|
+
<EmptyState
|
|
2783
|
+
icon={<Layers className="size-8" aria-hidden="true" />}
|
|
2784
|
+
title={translateWithFallback(
|
|
2785
|
+
t,
|
|
2786
|
+
'customers.deals.pipeline.noStages',
|
|
2787
|
+
'Define pipeline stages to start tracking deals.',
|
|
560
2788
|
)}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
icon={<Layers className="h-8 w-8" aria-hidden="true" />}
|
|
568
|
-
title={translate('customers.deals.pipeline.noStages', 'Define pipeline stages to start tracking deals.')}
|
|
569
|
-
className="h-[50vh] w-full"
|
|
570
|
-
/>
|
|
571
|
-
) : (
|
|
572
|
-
stages.map((stage) => {
|
|
573
|
-
const stageKey = stage.value ?? null
|
|
574
|
-
const laneDeals = groupedDeals.get(stageKey) ?? []
|
|
575
|
-
const sortedLaneDeals = sortDeals(laneDeals, sortBy)
|
|
576
|
-
const isActive = activeLane === stage.id
|
|
2789
|
+
className="h-[50vh] w-full"
|
|
2790
|
+
/>
|
|
2791
|
+
) : (
|
|
2792
|
+
<>
|
|
2793
|
+
{lanes.map((stage) => {
|
|
2794
|
+
const laneDeals = sortedDealsByStage.get(stage.id) ?? EMPTY_DEAL_ARRAY
|
|
577
2795
|
return (
|
|
578
|
-
<
|
|
2796
|
+
<Lane
|
|
579
2797
|
key={stage.id}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
{
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
const isDragging = draggingId === deal.id
|
|
597
|
-
|| (pendingDealId === deal.id && updateStageMutation.isPending)
|
|
598
|
-
const valueLabel = formatCurrency(
|
|
599
|
-
deal.valueAmount,
|
|
600
|
-
deal.valueCurrency,
|
|
601
|
-
translate('customers.deals.list.noValue', 'No value assigned'),
|
|
602
|
-
)
|
|
603
|
-
const probabilityLabel = formatProbability(
|
|
604
|
-
deal.probability,
|
|
605
|
-
translate('customers.deals.pipeline.noProbability', 'N/A'),
|
|
606
|
-
)
|
|
607
|
-
const expectedLabel = deal.expectedCloseAt
|
|
608
|
-
? dateFormatter.format(new Date(deal.expectedCloseAt))
|
|
609
|
-
: translate('customers.deals.pipeline.noExpectedClose', 'No date')
|
|
610
|
-
return (
|
|
611
|
-
<div
|
|
612
|
-
key={deal.id}
|
|
613
|
-
className={`group flex cursor-grab flex-col gap-2 rounded-md border border-border bg-background p-4 shadow-xs transition ${
|
|
614
|
-
isDragging ? 'opacity-50' : 'hover:shadow-sm'
|
|
615
|
-
}`}
|
|
616
|
-
draggable
|
|
617
|
-
onDragStart={(event) => {
|
|
618
|
-
event.dataTransfer.effectAllowed = 'move'
|
|
619
|
-
event.dataTransfer.setData('text/plain', deal.id)
|
|
620
|
-
handleDragStart(deal.id)
|
|
621
|
-
}}
|
|
622
|
-
onDragEnd={handleDragEnd}
|
|
623
|
-
>
|
|
624
|
-
<div className="flex items-start justify-between gap-2">
|
|
625
|
-
<div className="flex flex-col">
|
|
626
|
-
<span className="line-clamp-2 text-sm font-medium text-foreground">
|
|
627
|
-
{deal.title}
|
|
628
|
-
</span>
|
|
629
|
-
{deal.status ? (
|
|
630
|
-
<span className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
631
|
-
{deal.status}
|
|
632
|
-
</span>
|
|
633
|
-
) : null}
|
|
634
|
-
</div>
|
|
635
|
-
{pendingDealId === deal.id && updateStageMutation.isPending ? (
|
|
636
|
-
<Spinner className="size-4" />
|
|
637
|
-
) : null}
|
|
638
|
-
</div>
|
|
639
|
-
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
|
640
|
-
<div className="flex items-center justify-between gap-2">
|
|
641
|
-
<span>{translate('customers.deals.pipeline.card.value', 'Value')}</span>
|
|
642
|
-
<span className="font-medium text-foreground">{valueLabel}</span>
|
|
643
|
-
</div>
|
|
644
|
-
<div className="flex items-center justify-between gap-2">
|
|
645
|
-
<span>{translate('customers.deals.pipeline.card.probability', 'Probability')}</span>
|
|
646
|
-
<span className="font-medium text-foreground">{probabilityLabel}</span>
|
|
647
|
-
</div>
|
|
648
|
-
<div className="flex items-center justify-between gap-2">
|
|
649
|
-
<span>{translate('customers.deals.pipeline.card.expectedClose', 'Expected close')}</span>
|
|
650
|
-
<span className="font-medium text-foreground">{expectedLabel}</span>
|
|
651
|
-
</div>
|
|
652
|
-
</div>
|
|
653
|
-
<div className="mt-1 flex flex-wrap gap-2 text-xs">
|
|
654
|
-
<Link
|
|
655
|
-
href={`/backend/customers/deals/${deal.id}`}
|
|
656
|
-
className="font-medium text-primary hover:underline"
|
|
657
|
-
draggable={false}
|
|
658
|
-
onClick={handleActionClick}
|
|
659
|
-
>
|
|
660
|
-
{translate('customers.deals.pipeline.actions.openDeal', 'Open deal')}
|
|
661
|
-
</Link>
|
|
662
|
-
</div>
|
|
663
|
-
{deal.people.length ? (
|
|
664
|
-
<div className="flex flex-wrap gap-2">
|
|
665
|
-
{deal.people.map((person) => (
|
|
666
|
-
<Link
|
|
667
|
-
key={person.id}
|
|
668
|
-
className="rounded-full bg-primary/5 px-3 py-1 text-xs text-primary transition-colors hover:bg-primary/10"
|
|
669
|
-
href={`/backend/customers/people-v2/${person.id}`}
|
|
670
|
-
draggable={false}
|
|
671
|
-
onClick={handleActionClick}
|
|
672
|
-
>
|
|
673
|
-
{person.label}
|
|
674
|
-
</Link>
|
|
675
|
-
))}
|
|
676
|
-
</div>
|
|
677
|
-
) : null}
|
|
678
|
-
{deal.companies.length ? (
|
|
679
|
-
<div className="flex flex-wrap gap-2">
|
|
680
|
-
{deal.companies.map((company) => (
|
|
681
|
-
<Link
|
|
682
|
-
key={company.id}
|
|
683
|
-
className="rounded-full bg-secondary/10 px-3 py-1 text-xs text-secondary-foreground transition-colors hover:bg-secondary/20"
|
|
684
|
-
href={`/backend/customers/companies-v2/${company.id}`}
|
|
685
|
-
draggable={false}
|
|
686
|
-
onClick={handleActionClick}
|
|
687
|
-
>
|
|
688
|
-
{company.label}
|
|
689
|
-
</Link>
|
|
690
|
-
))}
|
|
691
|
-
</div>
|
|
692
|
-
) : null}
|
|
693
|
-
</div>
|
|
694
|
-
)
|
|
695
|
-
})
|
|
696
|
-
)}
|
|
697
|
-
</div>
|
|
698
|
-
</div>
|
|
2798
|
+
stage={stage}
|
|
2799
|
+
deals={laneDeals}
|
|
2800
|
+
aggregate={aggregateByStage.get(stage.id) ?? null}
|
|
2801
|
+
selectedDealIds={selectedDealIds}
|
|
2802
|
+
buildMenuItems={buildMenuItems}
|
|
2803
|
+
activeDragDealId={activeDragDealId}
|
|
2804
|
+
isLoadingMore={!!loadingMoreByStage[stage.id]}
|
|
2805
|
+
width={laneWidths[stage.id] ?? null}
|
|
2806
|
+
onToggleSelect={handleToggleSelect}
|
|
2807
|
+
onComposeActivity={handleComposeActivity}
|
|
2808
|
+
onOpenDetail={handleOpenDetail}
|
|
2809
|
+
onQuickAddClick={handleQuickAdd}
|
|
2810
|
+
onLoadMore={handleLoadMoreInLane}
|
|
2811
|
+
onResize={handleLaneResize}
|
|
2812
|
+
onResetWidth={handleResetLaneWidth}
|
|
2813
|
+
/>
|
|
699
2814
|
)
|
|
700
|
-
})
|
|
701
|
-
|
|
702
|
-
|
|
2815
|
+
})}
|
|
2816
|
+
{/*
|
|
2817
|
+
Trailing "Add stage" tile (round-2 UX review item 30, revised). Coexists
|
|
2818
|
+
with the page-header toolbar button by explicit design: the toolbar button
|
|
2819
|
+
is the always-visible primary CTA; this tile is the in-context affordance
|
|
2820
|
+
at the end of the stage row that operators have used to map onto from
|
|
2821
|
+
Trello/Asana muscle memory.
|
|
2822
|
+
*/}
|
|
2823
|
+
<AddStageLane onClick={handleAddStage} />
|
|
2824
|
+
</>
|
|
2825
|
+
)}
|
|
703
2826
|
</div>
|
|
704
|
-
|
|
705
|
-
|
|
2827
|
+
{/* Mirror of the left rail; `ml-3.5` adds a 14px gap between the last lane and the
|
|
2828
|
+
right rail. The right gap is intentionally wider than the left (`mr-2` = 8px)
|
|
2829
|
+
because the trailing tile here is `AddStageLane` — its dashed border reads more
|
|
2830
|
+
subtly than a regular lane's saturated color bar, so the same 8px on the right
|
|
2831
|
+
perceptually feels like a "tucked under" overlap (round-3 UX-designer feedback).
|
|
2832
|
+
`ml-3.5` matches the scroller's inter-lane `gap-3.5` so the visual rhythm stays
|
|
2833
|
+
consistent. `clip-path:inset(-40px 0 -40px -40px)` is the right-rail mirror of the
|
|
2834
|
+
left rail's clip — keeps the shadow on top/left/bottom (40px extend) and clips
|
|
2835
|
+
flush at the RIGHT edge so it doesn't spill onto the page background past the
|
|
2836
|
+
page chrome. */}
|
|
2837
|
+
<div className="relative z-10 flex w-11 ml-3.5 shrink-0 items-center justify-center bg-card shadow-lg [clip-path:inset(-40px_0_-40px_-40px)]">
|
|
2838
|
+
<IconButton
|
|
2839
|
+
variant="outline"
|
|
2840
|
+
size="lg"
|
|
2841
|
+
fullRadius
|
|
2842
|
+
onClick={handleScrollNext}
|
|
2843
|
+
onPointerDown={handleHoldNextStart}
|
|
2844
|
+
onPointerUp={stopContinuousScroll}
|
|
2845
|
+
onPointerLeave={stopContinuousScroll}
|
|
2846
|
+
onPointerCancel={stopContinuousScroll}
|
|
2847
|
+
aria-label={translateWithFallback(t, 'customers.deals.kanban.board.scrollNext', 'Scroll to next stage')}
|
|
2848
|
+
aria-disabled={scrollEdges.atEnd}
|
|
2849
|
+
className={`absolute right-0 top-28 flex size-9 items-center justify-center rounded-full border border-border bg-card text-foreground shadow-lg transition-opacity hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
|
|
2850
|
+
scrollEdges.atEnd ? 'opacity-30' : ''
|
|
2851
|
+
}`}
|
|
2852
|
+
>
|
|
2853
|
+
<ChevronRight className="size-5" aria-hidden="true" />
|
|
2854
|
+
</IconButton>
|
|
2855
|
+
</div>
|
|
2856
|
+
</div>
|
|
2857
|
+
<DragOverlay dropAnimation={null}>
|
|
2858
|
+
{activeDragDeal ? (
|
|
2859
|
+
<div className="relative">
|
|
2860
|
+
<div className={`pointer-events-none ${LANE_WIDTH_CLASS} rotate-2 cursor-grabbing select-none rounded-lg border border-border bg-card px-4 py-3.5 shadow-xl ring-2 ring-accent-indigo/40`}>
|
|
2861
|
+
<div className="flex flex-col gap-2">
|
|
2862
|
+
<h3 className="line-clamp-2 text-base font-semibold leading-normal text-foreground">
|
|
2863
|
+
{activeDragDeal.title}
|
|
2864
|
+
</h3>
|
|
2865
|
+
{typeof activeDragDeal.valueAmount === 'number' ? (
|
|
2866
|
+
<div className="flex items-baseline gap-1.5">
|
|
2867
|
+
<span className="text-lg font-bold leading-normal text-foreground">
|
|
2868
|
+
{new Intl.NumberFormat(undefined, {
|
|
2869
|
+
style: 'decimal',
|
|
2870
|
+
maximumFractionDigits: 0,
|
|
2871
|
+
useGrouping: true,
|
|
2872
|
+
}).format(activeDragDeal.valueAmount)}
|
|
2873
|
+
</span>
|
|
2874
|
+
{activeDragDeal.valueCurrency ? (
|
|
2875
|
+
<span className="text-sm font-semibold leading-normal text-muted-foreground">
|
|
2876
|
+
{activeDragDeal.valueCurrency.toUpperCase()}
|
|
2877
|
+
</span>
|
|
2878
|
+
) : null}
|
|
2879
|
+
</div>
|
|
2880
|
+
) : null}
|
|
2881
|
+
{activeDragDeal.primaryCompany ? (
|
|
2882
|
+
<span className="inline-flex w-fit max-w-full items-center gap-1.5 overflow-hidden rounded-md bg-muted px-2.5 py-1 text-sm font-semibold leading-normal text-foreground">
|
|
2883
|
+
<span className="truncate">{activeDragDeal.primaryCompany.label}</span>
|
|
2884
|
+
</span>
|
|
2885
|
+
) : null}
|
|
2886
|
+
</div>
|
|
2887
|
+
</div>
|
|
2888
|
+
{bulkDragActive ? (
|
|
2889
|
+
// +N badge tells the operator how many cards are travelling with the dragged
|
|
2890
|
+
// one — round-2 UX review item 32. N excludes the dragged card itself, so a
|
|
2891
|
+
// selection of 5 shows "+4" floating above the dragged surface.
|
|
2892
|
+
<span
|
|
2893
|
+
aria-label={translateWithFallback(
|
|
2894
|
+
t,
|
|
2895
|
+
'customers.deals.kanban.bulk.dragOverlayBadge',
|
|
2896
|
+
'{count} more selected',
|
|
2897
|
+
{ count: selectedDealIds.size - 1 },
|
|
2898
|
+
)}
|
|
2899
|
+
className="pointer-events-none absolute -right-3 -top-3 inline-flex h-7 min-w-7 rotate-2 items-center justify-center rounded-full border-2 border-card bg-foreground px-2 text-xs font-bold tabular-nums text-background shadow-lg"
|
|
2900
|
+
>
|
|
2901
|
+
+{selectedDealIds.size - 1}
|
|
2902
|
+
</span>
|
|
2903
|
+
) : null}
|
|
2904
|
+
</div>
|
|
2905
|
+
) : null}
|
|
2906
|
+
</DragOverlay>
|
|
2907
|
+
</DndContext>
|
|
2908
|
+
)}
|
|
2909
|
+
|
|
2910
|
+
{selectedPipelineId && !isInitialLoading ? (
|
|
2911
|
+
<div className="flex flex-col gap-1 border-t border-border pt-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
|
2912
|
+
<span>{footerCount}</span>
|
|
2913
|
+
<span>{dragHint}</span>
|
|
2914
|
+
{pendingDealId ? <Spinner className="size-3" /> : null}
|
|
2915
|
+
</div>
|
|
2916
|
+
) : null}
|
|
706
2917
|
</PageBody>
|
|
2918
|
+
|
|
2919
|
+
<QuickDealDialog
|
|
2920
|
+
open={!!quickDealContext}
|
|
2921
|
+
context={quickDealContext}
|
|
2922
|
+
onClose={() => setQuickDealContext(null)}
|
|
2923
|
+
onCreated={handleDialogCreated}
|
|
2924
|
+
currentUserId={currentUserId || undefined}
|
|
2925
|
+
currentUserLabel={currentUserLabel}
|
|
2926
|
+
companies={companiesQuery.data ?? []}
|
|
2927
|
+
currencies={
|
|
2928
|
+
currencyDictionary.data
|
|
2929
|
+
? currencyDictionary.data.entries.map((entry) => ({
|
|
2930
|
+
code: entry.value,
|
|
2931
|
+
label: entry.label,
|
|
2932
|
+
isBase: boardSummary?.baseCurrencyCode
|
|
2933
|
+
? entry.value === boardSummary.baseCurrencyCode
|
|
2934
|
+
: false,
|
|
2935
|
+
}))
|
|
2936
|
+
: undefined
|
|
2937
|
+
}
|
|
2938
|
+
/>
|
|
2939
|
+
<AddStageDialog
|
|
2940
|
+
open={!!addStageContext}
|
|
2941
|
+
context={addStageContext}
|
|
2942
|
+
onClose={() => setAddStageContext(null)}
|
|
2943
|
+
onCreated={handleDialogCreated}
|
|
2944
|
+
/>
|
|
2945
|
+
<CustomizeViewDialog
|
|
2946
|
+
open={customizeOpen}
|
|
2947
|
+
resizedLanesCount={Object.keys(laneWidths).length}
|
|
2948
|
+
onClose={() => setCustomizeOpen(false)}
|
|
2949
|
+
onResetToDefault={handleResetView}
|
|
2950
|
+
onResetColumnWidths={handleResetAllLaneWidths}
|
|
2951
|
+
/>
|
|
2952
|
+
<ActivityComposerDialog
|
|
2953
|
+
open={!!activityContext}
|
|
2954
|
+
context={activityContext}
|
|
2955
|
+
onClose={() => setActivityContext(null)}
|
|
2956
|
+
onCreated={handleDialogCreated}
|
|
2957
|
+
/>
|
|
2958
|
+
<ChangeStageDialog
|
|
2959
|
+
open={changeStageOpen}
|
|
2960
|
+
selectedCount={bulkSelectionSummary.count}
|
|
2961
|
+
pipelineName={activePipelineName}
|
|
2962
|
+
stages={stageOptions}
|
|
2963
|
+
isSubmitting={isBulkMutating}
|
|
2964
|
+
onClose={() => setChangeStageOpen(false)}
|
|
2965
|
+
onConfirm={(stageId) => void handleBulkChangeStage(stageId)}
|
|
2966
|
+
/>
|
|
2967
|
+
<ChangeStageDialog
|
|
2968
|
+
open={!!singleMoveStageContext}
|
|
2969
|
+
selectedCount={1}
|
|
2970
|
+
pipelineName={activePipelineName}
|
|
2971
|
+
stages={stageOptions}
|
|
2972
|
+
isSubmitting={false}
|
|
2973
|
+
onClose={() => setSingleMoveStageContext(null)}
|
|
2974
|
+
onConfirm={(stageId) => {
|
|
2975
|
+
if (singleMoveStageContext) {
|
|
2976
|
+
moveDealToStage(singleMoveStageContext.dealId, stageId)
|
|
2977
|
+
}
|
|
2978
|
+
setSingleMoveStageContext(null)
|
|
2979
|
+
}}
|
|
2980
|
+
/>
|
|
2981
|
+
<ChangeOwnerDialog
|
|
2982
|
+
open={changeOwnerOpen}
|
|
2983
|
+
selectedCount={bulkSelectionSummary.count}
|
|
2984
|
+
isSubmitting={isBulkMutating}
|
|
2985
|
+
onClose={() => setChangeOwnerOpen(false)}
|
|
2986
|
+
onConfirm={(userId) => void handleBulkChangeOwner(userId)}
|
|
2987
|
+
/>
|
|
2988
|
+
<BulkActionsBar
|
|
2989
|
+
count={bulkSelectionSummary.count}
|
|
2990
|
+
totalLabel={bulkSelectionSummary.totalLabel}
|
|
2991
|
+
onChangeStage={() => setChangeStageOpen(true)}
|
|
2992
|
+
onChangeOwner={() => setChangeOwnerOpen(true)}
|
|
2993
|
+
onExportCsv={handleBulkExport}
|
|
2994
|
+
onDelete={() => void handleBulkDelete()}
|
|
2995
|
+
onClear={handleBulkClear}
|
|
2996
|
+
/>
|
|
2997
|
+
{ConfirmDialogElement}
|
|
707
2998
|
</Page>
|
|
708
2999
|
)
|
|
709
3000
|
}
|