@open-mercato/core 0.6.3-develop.3651.1.d3a548a5f1 → 0.6.3-develop.3653.1.b8558737ae
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/customer_settings/index.js +2 -0
- package/dist/generated/entities/customer_settings/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/customers/api/deals/aggregate/route.js +332 -0
- package/dist/modules/customers/api/deals/aggregate/route.js.map +7 -0
- package/dist/modules/customers/api/deals/bulk-update-owner/route.js +188 -0
- package/dist/modules/customers/api/deals/bulk-update-owner/route.js.map +7 -0
- package/dist/modules/customers/api/deals/bulk-update-stage/route.js +188 -0
- package/dist/modules/customers/api/deals/bulk-update-stage/route.js.map +7 -0
- package/dist/modules/customers/api/deals/route.js +255 -122
- package/dist/modules/customers/api/deals/route.js.map +2 -2
- package/dist/modules/customers/api/settings/stuck-threshold/route.js +169 -0
- package/dist/modules/customers/api/settings/stuck-threshold/route.js.map +7 -0
- package/dist/modules/customers/backend/config/customers/deals/page.js +181 -0
- package/dist/modules/customers/backend/config/customers/deals/page.js.map +7 -0
- package/dist/modules/customers/backend/config/customers/deals/page.meta.js +38 -0
- package/dist/modules/customers/backend/config/customers/deals/page.meta.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/create/page.js +17 -6
- package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/page.js +2 -0
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js +217 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js +335 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js +29 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js +160 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js +157 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js +114 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js +36 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js +199 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js +71 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js +233 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js +126 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js +31 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js +389 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js +181 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js +199 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +44 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js +70 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +278 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js +135 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js +83 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js +155 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +318 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js +138 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +126 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +71 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +2330 -415
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +3 -3
- package/dist/modules/customers/commands/deals.js +6 -0
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/commands/pipeline-stages.js +48 -13
- package/dist/modules/customers/commands/pipeline-stages.js.map +3 -3
- package/dist/modules/customers/commands/settings.js +45 -5
- package/dist/modules/customers/commands/settings.js.map +2 -2
- package/dist/modules/customers/data/enrichers.js +130 -1
- package/dist/modules/customers/data/enrichers.js.map +2 -2
- package/dist/modules/customers/data/entities.js +4 -0
- package/dist/modules/customers/data/entities.js.map +2 -2
- package/dist/modules/customers/data/validators.js +29 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/bulkDeals.js +150 -0
- package/dist/modules/customers/lib/bulkDeals.js.map +7 -0
- package/dist/modules/customers/lib/kysely.js +12 -0
- package/dist/modules/customers/lib/kysely.js.map +7 -0
- package/dist/modules/customers/lib/stuckDeals.js +32 -0
- package/dist/modules/customers/lib/stuckDeals.js.map +7 -0
- package/dist/modules/customers/migrations/Migration20260513203311_customers.js +13 -0
- package/dist/modules/customers/migrations/Migration20260513203311_customers.js.map +7 -0
- package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js +52 -0
- package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +7 -0
- package/dist/modules/customers/workers/deals-bulk-update-owner.js +41 -0
- package/dist/modules/customers/workers/deals-bulk-update-owner.js.map +7 -0
- package/dist/modules/customers/workers/deals-bulk-update-stage.js +41 -0
- package/dist/modules/customers/workers/deals-bulk-update-stage.js.map +7 -0
- package/generated/entities/customer_settings/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/modules/customers/api/deals/aggregate/route.ts +438 -0
- package/src/modules/customers/api/deals/bulk-update-owner/route.ts +216 -0
- package/src/modules/customers/api/deals/bulk-update-stage/route.ts +228 -0
- package/src/modules/customers/api/deals/route.ts +330 -135
- package/src/modules/customers/api/settings/stuck-threshold/route.ts +195 -0
- package/src/modules/customers/backend/config/customers/deals/page.meta.ts +36 -0
- package/src/modules/customers/backend/config/customers/deals/page.tsx +205 -0
- package/src/modules/customers/backend/customers/deals/create/page.tsx +25 -6
- package/src/modules/customers/backend/customers/deals/page.tsx +2 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.tsx +277 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.tsx +425 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.tsx +31 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.tsx +171 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.tsx +196 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.tsx +138 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChipButton.tsx +47 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.tsx +232 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.tsx +110 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.tsx +310 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.tsx +169 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.tsx +54 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DealCard.tsx +562 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.tsx +214 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.tsx +257 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +62 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.tsx +101 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +378 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.tsx +192 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.tsx +107 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.tsx +196 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +408 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.tsx +164 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +171 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +84 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/constants.ts +16 -0
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2841 -550
- package/src/modules/customers/commands/deals.ts +19 -0
- package/src/modules/customers/commands/pipeline-stages.ts +68 -13
- package/src/modules/customers/commands/settings.ts +64 -5
- package/src/modules/customers/data/enrichers.ts +209 -2
- package/src/modules/customers/data/entities.ts +4 -1
- package/src/modules/customers/data/validators.ts +36 -2
- package/src/modules/customers/i18n/de.json +244 -0
- package/src/modules/customers/i18n/en.json +244 -0
- package/src/modules/customers/i18n/es.json +244 -0
- package/src/modules/customers/i18n/pl.json +244 -0
- package/src/modules/customers/lib/bulkDeals.ts +231 -0
- package/src/modules/customers/lib/kysely.ts +60 -0
- package/src/modules/customers/lib/stuckDeals.ts +88 -0
- package/src/modules/customers/migrations/.snapshot-open-mercato.json +16 -0
- package/src/modules/customers/migrations/Migration20260513203311_customers.ts +13 -0
- package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +71 -0
- package/src/modules/customers/workers/deals-bulk-update-owner.ts +46 -0
- package/src/modules/customers/workers/deals-bulk-update-stage.ts +46 -0
|
@@ -1,9 +1,36 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
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
|
+
} from "@dnd-kit/core";
|
|
17
|
+
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
|
18
|
+
import { ChevronLeft, ChevronRight, Layers, Plus, SlidersHorizontal, Workflow } from "lucide-react";
|
|
6
19
|
import { Page, PageBody } from "@open-mercato/ui/backend/Page";
|
|
20
|
+
import {
|
|
21
|
+
Breadcrumb,
|
|
22
|
+
BreadcrumbItem,
|
|
23
|
+
BreadcrumbLink,
|
|
24
|
+
BreadcrumbList,
|
|
25
|
+
BreadcrumbPage,
|
|
26
|
+
BreadcrumbSeparator
|
|
27
|
+
} from "@open-mercato/ui/primitives/breadcrumb";
|
|
28
|
+
import { Button } from "@open-mercato/ui/primitives/button";
|
|
29
|
+
import { IconButton } from "@open-mercato/ui/primitives/icon-button";
|
|
30
|
+
import { SearchInput } from "@open-mercato/ui/primitives/search-input";
|
|
31
|
+
import { Spinner } from "@open-mercato/ui/primitives/spinner";
|
|
32
|
+
import { ErrorNotice } from "@open-mercato/ui/primitives/ErrorNotice";
|
|
33
|
+
import { EmptyState } from "@open-mercato/ui/primitives/empty-state";
|
|
7
34
|
import {
|
|
8
35
|
Select,
|
|
9
36
|
SelectContent,
|
|
@@ -11,24 +38,86 @@ import {
|
|
|
11
38
|
SelectTrigger,
|
|
12
39
|
SelectValue
|
|
13
40
|
} 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";
|
|
41
|
+
import { apiCall, apiCallOrThrow, readApiResultOrThrow } from "@open-mercato/ui/backend/utils/apiCall";
|
|
42
|
+
import { useCurrentUserId } from "@open-mercato/ui/backend/utils/useCurrentUserId";
|
|
19
43
|
import { flash } from "@open-mercato/ui/backend/FlashMessages";
|
|
44
|
+
import { useConfirmDialog } from "@open-mercato/ui/backend/confirm-dialog";
|
|
45
|
+
import { useGuardedMutation } from "@open-mercato/ui/backend/injection/useGuardedMutation";
|
|
46
|
+
import { useAppEvent } from "@open-mercato/ui/backend/injection/useAppEvent";
|
|
20
47
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
21
48
|
import { translateWithFallback } from "@open-mercato/shared/lib/i18n/translate";
|
|
22
49
|
import { useOrganizationScopeVersion } from "@open-mercato/shared/lib/frontend/useOrganizationScope";
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
50
|
+
import { ViewTabsRow } from "./components/ViewTabsRow.js";
|
|
51
|
+
import { LANE_WIDTH_CLASS } from "./components/constants.js";
|
|
52
|
+
import { useCurrencyDictionary } from "../../../../components/detail/hooks/useCurrencyDictionary.js";
|
|
53
|
+
import { FilterBarRow } from "./components/FilterBarRow.js";
|
|
54
|
+
import { Lane } from "./components/Lane.js";
|
|
55
|
+
import { LaneCurrencyBreakdown } from "./components/LaneCurrencyBreakdown.js";
|
|
56
|
+
import { CurrencyFilterPopover } from "./components/CurrencyFilterPopover.js";
|
|
57
|
+
import { AddStageLane } from "./components/AddStageLane.js";
|
|
58
|
+
import {
|
|
59
|
+
QuickDealDialog
|
|
60
|
+
} from "./components/QuickDealDialog.js";
|
|
61
|
+
import { AddStageDialog } from "./components/AddStageDialog.js";
|
|
62
|
+
import { StatusFilterPopover } from "./components/StatusFilterPopover.js";
|
|
63
|
+
import { PipelineFilterPopover } from "./components/PipelineFilterPopover.js";
|
|
64
|
+
import { SortByPopover } from "./components/SortByPopover.js";
|
|
65
|
+
import { EntityFilterPopover } from "./components/EntityFilterPopover.js";
|
|
66
|
+
import { CloseDateFilterPopover } from "./components/CloseDateFilterPopover.js";
|
|
67
|
+
import { CustomizeViewDialog } from "./components/CustomizeViewDialog.js";
|
|
68
|
+
import {
|
|
69
|
+
ActivityComposerDialog
|
|
70
|
+
} from "./components/ActivityComposerDialog.js";
|
|
71
|
+
import { BulkActionsBar } from "./components/BulkActionsBar.js";
|
|
72
|
+
import { ChangeStageDialog } from "./components/ChangeStageDialog.js";
|
|
73
|
+
import { ChangeOwnerDialog } from "./components/ChangeOwnerDialog.js";
|
|
74
|
+
import { buildCrudExportUrl, deleteCrud } from "@open-mercato/ui/backend/utils/crud";
|
|
75
|
+
import { runBulkDelete, groupBulkDeleteFailures } from "@open-mercato/ui/backend/utils/bulkDelete";
|
|
76
|
+
import {
|
|
77
|
+
fetchAssignableStaffMembers
|
|
78
|
+
} from "../../../../components/detail/assignableStaff.js";
|
|
79
|
+
const DEFAULT_SORT = "updated_desc";
|
|
80
|
+
const LANE_PAGE_SIZE = 25;
|
|
81
|
+
const FALLBACK_TONES = ["success", "warning", "error", "info", "neutral", "brand"];
|
|
82
|
+
const EMPTY_DEAL_ARRAY = [];
|
|
83
|
+
const DEFAULT_LANE_WIDTH = 308;
|
|
84
|
+
const MIN_LANE_WIDTH = 240;
|
|
85
|
+
const MAX_LANE_WIDTH = 576;
|
|
86
|
+
const LANE_GAP = 14;
|
|
87
|
+
const LANE_WIDTHS_STORAGE_KEY_PREFIX = "kanban-lane-widths-v2";
|
|
88
|
+
function loadLaneWidths(scopeKey) {
|
|
89
|
+
if (typeof window === "undefined") return {};
|
|
90
|
+
try {
|
|
91
|
+
const raw = window.localStorage.getItem(`${LANE_WIDTHS_STORAGE_KEY_PREFIX}:${scopeKey}`);
|
|
92
|
+
if (!raw) return {};
|
|
93
|
+
const parsed = JSON.parse(raw);
|
|
94
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
95
|
+
const out = {};
|
|
96
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
97
|
+
if (typeof v === "number" && Number.isFinite(v) && v >= MIN_LANE_WIDTH && v <= MAX_LANE_WIDTH) {
|
|
98
|
+
out[k] = v;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
} catch {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function saveLaneWidths(scopeKey, widths) {
|
|
107
|
+
if (typeof window === "undefined") return;
|
|
108
|
+
try {
|
|
109
|
+
if (Object.keys(widths).length === 0) {
|
|
110
|
+
window.localStorage.removeItem(`${LANE_WIDTHS_STORAGE_KEY_PREFIX}:${scopeKey}`);
|
|
111
|
+
} else {
|
|
112
|
+
window.localStorage.setItem(`${LANE_WIDTHS_STORAGE_KEY_PREFIX}:${scopeKey}`, JSON.stringify(widths));
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
}
|
|
116
|
+
}
|
|
26
117
|
function normalizeAmount(value) {
|
|
27
118
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
28
|
-
if (typeof value === "string") {
|
|
29
|
-
const
|
|
30
|
-
if (!trimmed.length) return null;
|
|
31
|
-
const parsed = Number(trimmed);
|
|
119
|
+
if (typeof value === "string" && value.trim().length) {
|
|
120
|
+
const parsed = Number(value);
|
|
32
121
|
return Number.isFinite(parsed) ? parsed : null;
|
|
33
122
|
}
|
|
34
123
|
return null;
|
|
@@ -36,116 +125,189 @@ function normalizeAmount(value) {
|
|
|
36
125
|
function normalizeProbability(value) {
|
|
37
126
|
const parsed = normalizeAmount(value);
|
|
38
127
|
if (parsed === null) return null;
|
|
39
|
-
|
|
40
|
-
if (parsed > 100) return 100;
|
|
41
|
-
return Math.round(parsed);
|
|
128
|
+
return Math.min(Math.max(Math.round(parsed), 0), 100);
|
|
42
129
|
}
|
|
43
|
-
function
|
|
44
|
-
if (typeof value !== "string" || !value.trim().length) return
|
|
130
|
+
function normalizeIso(value) {
|
|
131
|
+
if (typeof value !== "string" || !value.trim().length) return null;
|
|
45
132
|
const date = new Date(value);
|
|
46
|
-
|
|
47
|
-
|
|
133
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
134
|
+
}
|
|
135
|
+
function normalizeAssociation(entry) {
|
|
136
|
+
if (!entry || typeof entry !== "object") return null;
|
|
137
|
+
const ref = entry;
|
|
138
|
+
const id = typeof ref.id === "string" ? ref.id : null;
|
|
139
|
+
if (!id) return null;
|
|
140
|
+
const label = typeof ref.label === "string" && ref.label.trim().length ? ref.label.trim() : id;
|
|
141
|
+
return { id, label };
|
|
48
142
|
}
|
|
49
|
-
function
|
|
50
|
-
const
|
|
143
|
+
function mapDealRecord(item, fallbackTitle) {
|
|
144
|
+
const id = typeof item.id === "string" ? item.id : null;
|
|
145
|
+
if (!id) return null;
|
|
146
|
+
const title = typeof item.title === "string" && item.title.trim().length ? item.title.trim() : fallbackTitle;
|
|
147
|
+
const status = typeof item.status === "string" && item.status.trim().length ? item.status.trim() : null;
|
|
148
|
+
const valueAmount = normalizeAmount(item.value_amount);
|
|
149
|
+
const valueCurrency = typeof item.value_currency === "string" && item.value_currency.trim().length ? item.value_currency.trim().toUpperCase() : null;
|
|
150
|
+
const probability = normalizeProbability(item.probability);
|
|
151
|
+
const expectedCloseAt = normalizeIso(item.expected_close_at);
|
|
152
|
+
const createdAt = normalizeIso(item.created_at);
|
|
153
|
+
const updatedAt = normalizeIso(item.updated_at);
|
|
154
|
+
const ownerUserId = typeof item.owner_user_id === "string" && item.owner_user_id.trim().length ? item.owner_user_id : null;
|
|
155
|
+
const companies = Array.isArray(item.companies) ? item.companies.map(normalizeAssociation).filter(Boolean) : [];
|
|
156
|
+
const primaryCompany = companies[0] ?? null;
|
|
157
|
+
const pipelineState = {
|
|
158
|
+
openActivitiesCount: typeof item._pipeline?.openActivitiesCount === "number" ? item._pipeline.openActivitiesCount : 0,
|
|
159
|
+
daysInCurrentStage: typeof item._pipeline?.daysInCurrentStage === "number" ? item._pipeline.daysInCurrentStage : 0,
|
|
160
|
+
isStuck: !!item._pipeline?.isStuck,
|
|
161
|
+
isOverdue: !!item._pipeline?.isOverdue
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
id,
|
|
165
|
+
title,
|
|
166
|
+
status,
|
|
167
|
+
valueAmount,
|
|
168
|
+
valueCurrency,
|
|
169
|
+
probability,
|
|
170
|
+
expectedCloseAt,
|
|
171
|
+
createdAt,
|
|
172
|
+
updatedAt,
|
|
173
|
+
owner: ownerUserId ? { userId: ownerUserId, label: "" } : null,
|
|
174
|
+
primaryCompany,
|
|
175
|
+
pipelineState
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const KNOWN_STAGE_TONES = /* @__PURE__ */ new Set([
|
|
179
|
+
"success",
|
|
180
|
+
"warning",
|
|
181
|
+
"info",
|
|
182
|
+
"error",
|
|
183
|
+
"neutral",
|
|
184
|
+
"brand",
|
|
185
|
+
"pink"
|
|
186
|
+
]);
|
|
187
|
+
function buildLaneStages(stages, unassignedLabel, hasUnassigned) {
|
|
188
|
+
const sorted = stages.slice().sort((a, b) => a.order - b.order);
|
|
189
|
+
const lanes = sorted.map((stage, index) => ({
|
|
51
190
|
id: stage.id,
|
|
52
|
-
value: stage.id,
|
|
53
191
|
label: stage.label,
|
|
54
|
-
|
|
55
|
-
|
|
192
|
+
// Prefer the stage's persisted tone (now stored as a canonical identifier post-migration)
|
|
193
|
+
// so colors picked by operators in AddStageDialog actually show up on the kanban. Falls
|
|
194
|
+
// back to the position-based rotation for stages with no color set or unmapped legacy
|
|
195
|
+
// values that survived the migration's neutral-fallback arm.
|
|
196
|
+
tone: stage.color && KNOWN_STAGE_TONES.has(stage.color) ? stage.color : FALLBACK_TONES[index % FALLBACK_TONES.length]
|
|
56
197
|
}));
|
|
57
|
-
const knownIds = new Set(pipelineStages.map((s) => s.id));
|
|
58
|
-
const hasUnassigned = deals.some((deal) => !deal.pipelineStageId || !knownIds.has(deal.pipelineStageId));
|
|
59
198
|
if (hasUnassigned) {
|
|
60
|
-
|
|
61
|
-
id: "stage:__unassigned",
|
|
62
|
-
value: null,
|
|
63
|
-
label: translateWithFallback(t, "customers.deals.pipeline.unassigned", "No stage"),
|
|
64
|
-
color: null,
|
|
65
|
-
icon: null
|
|
66
|
-
});
|
|
199
|
+
lanes.push({ id: "__unassigned", label: unassignedLabel, tone: "neutral" });
|
|
67
200
|
}
|
|
68
|
-
return
|
|
69
|
-
}
|
|
70
|
-
function createDealMap(deals) {
|
|
71
|
-
return deals.reduce((acc, deal) => acc.set(deal.id, deal), /* @__PURE__ */ new Map());
|
|
201
|
+
return lanes;
|
|
72
202
|
}
|
|
73
|
-
function groupDealsByStageId(deals) {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
const stageKey = deal.
|
|
77
|
-
const bucket =
|
|
203
|
+
function groupDealsByStageId(deals, stageIdByDealId) {
|
|
204
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
205
|
+
for (const deal of deals) {
|
|
206
|
+
const stageKey = stageIdByDealId.get(deal.id) ?? "__unassigned";
|
|
207
|
+
const bucket = grouped.get(stageKey) ?? [];
|
|
78
208
|
bucket.push(deal);
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
return
|
|
209
|
+
grouped.set(stageKey, bucket);
|
|
210
|
+
}
|
|
211
|
+
return grouped;
|
|
212
|
+
}
|
|
213
|
+
function mapSortOptionToApi(option) {
|
|
214
|
+
switch (option) {
|
|
215
|
+
case "updated_desc":
|
|
216
|
+
return { sortField: "updatedAt", sortDir: "desc" };
|
|
217
|
+
case "updated_asc":
|
|
218
|
+
return { sortField: "updatedAt", sortDir: "asc" };
|
|
219
|
+
case "created_desc":
|
|
220
|
+
return { sortField: "createdAt", sortDir: "desc" };
|
|
221
|
+
case "value_desc":
|
|
222
|
+
return { sortField: "value", sortDir: "desc" };
|
|
223
|
+
case "value_asc":
|
|
224
|
+
return { sortField: "value", sortDir: "asc" };
|
|
225
|
+
case "probability_desc":
|
|
226
|
+
return { sortField: "probability", sortDir: "desc" };
|
|
227
|
+
case "close_asc":
|
|
228
|
+
return { sortField: "expectedCloseAt", sortDir: "asc" };
|
|
229
|
+
case "owner_asc":
|
|
230
|
+
default:
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
82
233
|
}
|
|
83
234
|
function sortDeals(deals, option) {
|
|
84
|
-
const sorted =
|
|
235
|
+
const sorted = deals.slice();
|
|
85
236
|
sorted.sort((a, b) => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
237
|
+
switch (option) {
|
|
238
|
+
case "value_desc":
|
|
239
|
+
case "value_asc": {
|
|
240
|
+
const aValue = typeof a.valueAmount === "number" ? a.valueAmount : Number.NEGATIVE_INFINITY;
|
|
241
|
+
const bValue = typeof b.valueAmount === "number" ? b.valueAmount : Number.NEGATIVE_INFINITY;
|
|
242
|
+
return option === "value_desc" ? bValue - aValue : aValue - bValue;
|
|
243
|
+
}
|
|
244
|
+
case "probability_desc": {
|
|
245
|
+
const aProb = typeof a.probability === "number" ? a.probability : -1;
|
|
246
|
+
const bProb = typeof b.probability === "number" ? b.probability : -1;
|
|
247
|
+
return bProb - aProb;
|
|
248
|
+
}
|
|
249
|
+
case "close_asc": {
|
|
250
|
+
const aTs = a.expectedCloseAt ? new Date(a.expectedCloseAt).getTime() : Number.POSITIVE_INFINITY;
|
|
251
|
+
const bTs = b.expectedCloseAt ? new Date(b.expectedCloseAt).getTime() : Number.POSITIVE_INFINITY;
|
|
252
|
+
return aTs - bTs;
|
|
253
|
+
}
|
|
254
|
+
case "created_desc": {
|
|
255
|
+
const aTs = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
|
256
|
+
const bTs = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
|
257
|
+
return bTs - aTs;
|
|
258
|
+
}
|
|
259
|
+
case "owner_asc": {
|
|
260
|
+
const aLabel = a.owner?.label ?? a.owner?.userId ?? "";
|
|
261
|
+
const bLabel = b.owner?.label ?? b.owner?.userId ?? "";
|
|
262
|
+
return aLabel.localeCompare(bLabel);
|
|
263
|
+
}
|
|
264
|
+
case "updated_asc":
|
|
265
|
+
return (a.updatedAt ?? a.createdAt ?? "").localeCompare(b.updatedAt ?? b.createdAt ?? "");
|
|
266
|
+
case "updated_desc":
|
|
267
|
+
default:
|
|
268
|
+
return (b.updatedAt ?? b.createdAt ?? "").localeCompare(a.updatedAt ?? a.createdAt ?? "");
|
|
102
269
|
}
|
|
103
|
-
return a.title.localeCompare(b.title, void 0, { sensitivity: "base" });
|
|
104
270
|
});
|
|
105
271
|
return sorted;
|
|
106
272
|
}
|
|
107
|
-
function
|
|
108
|
-
if (amount === null || Number.isNaN(amount)) return fallback;
|
|
109
|
-
const code = currency && currency.length === 3 ? currency.toUpperCase() : "USD";
|
|
110
|
-
try {
|
|
111
|
-
return new Intl.NumberFormat(void 0, {
|
|
112
|
-
style: "currency",
|
|
113
|
-
currency: code,
|
|
114
|
-
maximumFractionDigits: 2
|
|
115
|
-
}).format(amount);
|
|
116
|
-
} catch {
|
|
117
|
-
return `${code} ${amount.toFixed(2)}`;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
function formatProbability(probability, fallback) {
|
|
121
|
-
if (typeof probability !== "number" || Number.isNaN(probability)) return fallback;
|
|
122
|
-
return `${probability}%`;
|
|
123
|
-
}
|
|
124
|
-
function SalesPipelinePage() {
|
|
273
|
+
function DealsKanbanPage() {
|
|
125
274
|
const t = useT();
|
|
126
|
-
const
|
|
127
|
-
(key, fallback, params) => {
|
|
128
|
-
const value = translateWithFallback(t, key, fallback, params);
|
|
129
|
-
if (value === fallback && params) {
|
|
130
|
-
return fallback.replace(/\{\{(\w+)\}\}|\{(\w+)\}/g, (match, doubleToken, singleToken) => {
|
|
131
|
-
const token = doubleToken ?? singleToken;
|
|
132
|
-
if (!token) return match;
|
|
133
|
-
const replacement = params[token];
|
|
134
|
-
if (replacement === void 0) {
|
|
135
|
-
return doubleToken ? `{{${token}}}` : `{${token}}`;
|
|
136
|
-
}
|
|
137
|
-
return String(replacement);
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
return value;
|
|
141
|
-
},
|
|
142
|
-
[t]
|
|
143
|
-
);
|
|
275
|
+
const router = useRouter();
|
|
144
276
|
const scopeVersion = useOrganizationScopeVersion();
|
|
145
277
|
const queryClient = useQueryClient();
|
|
146
|
-
const [sortBy, setSortBy] = React.useState("probability");
|
|
147
|
-
const [pendingDealId, setPendingDealId] = React.useState(null);
|
|
148
278
|
const [selectedPipelineId, setSelectedPipelineId] = React.useState(null);
|
|
279
|
+
const [search, setSearch] = React.useState("");
|
|
280
|
+
const [sortBy, setSortBy] = React.useState(DEFAULT_SORT);
|
|
281
|
+
const [statusFilters, setStatusFilters] = React.useState([]);
|
|
282
|
+
const [ownerFilters, setOwnerFilters] = React.useState([]);
|
|
283
|
+
const [peopleFilters, setPeopleFilters] = React.useState([]);
|
|
284
|
+
const [companyFilters, setCompanyFilters] = React.useState([]);
|
|
285
|
+
const [closeDateFilter, setCloseDateFilter] = React.useState({ from: null, to: null });
|
|
286
|
+
const [currencyFilter, setCurrencyFilter] = React.useState(null);
|
|
287
|
+
const [ownerLabels, setOwnerLabels] = React.useState({});
|
|
288
|
+
const [peopleLabels, setPeopleLabels] = React.useState({});
|
|
289
|
+
const [companyLabels, setCompanyLabels] = React.useState({});
|
|
290
|
+
const [selectedDealIds, setSelectedDealIds] = React.useState(/* @__PURE__ */ new Set());
|
|
291
|
+
const [pendingDealId, setPendingDealId] = React.useState(null);
|
|
292
|
+
const [quickDealContext, setQuickDealContext] = React.useState(null);
|
|
293
|
+
const [addStageContext, setAddStageContext] = React.useState(null);
|
|
294
|
+
const [customizeOpen, setCustomizeOpen] = React.useState(false);
|
|
295
|
+
const [activityContext, setActivityContext] = React.useState(null);
|
|
296
|
+
const [changeStageOpen, setChangeStageOpen] = React.useState(false);
|
|
297
|
+
const [changeOwnerOpen, setChangeOwnerOpen] = React.useState(false);
|
|
298
|
+
const [isBulkMutating, setIsBulkMutating] = React.useState(false);
|
|
299
|
+
const [singleMoveStageContext, setSingleMoveStageContext] = React.useState(null);
|
|
300
|
+
const { confirm, ConfirmDialogElement } = useConfirmDialog();
|
|
301
|
+
const fallbackTitle = translateWithFallback(
|
|
302
|
+
t,
|
|
303
|
+
"customers.deals.pipeline.untitled",
|
|
304
|
+
"Untitled deal"
|
|
305
|
+
);
|
|
306
|
+
const unassignedLabel = translateWithFallback(
|
|
307
|
+
t,
|
|
308
|
+
"customers.deals.pipeline.unassigned",
|
|
309
|
+
"No stage"
|
|
310
|
+
);
|
|
149
311
|
const pipelinesQuery = useQuery({
|
|
150
312
|
queryKey: ["customers", "pipelines", `scope:${scopeVersion}`],
|
|
151
313
|
staleTime: 6e4,
|
|
@@ -153,7 +315,13 @@ function SalesPipelinePage() {
|
|
|
153
315
|
const payload = await readApiResultOrThrow(
|
|
154
316
|
"/api/customers/pipelines",
|
|
155
317
|
void 0,
|
|
156
|
-
{
|
|
318
|
+
{
|
|
319
|
+
errorMessage: translateWithFallback(
|
|
320
|
+
t,
|
|
321
|
+
"customers.deals.pipeline.loadError",
|
|
322
|
+
"Failed to load pipelines."
|
|
323
|
+
)
|
|
324
|
+
}
|
|
157
325
|
);
|
|
158
326
|
return payload?.items ?? [];
|
|
159
327
|
}
|
|
@@ -162,381 +330,2128 @@ function SalesPipelinePage() {
|
|
|
162
330
|
if (selectedPipelineId) return;
|
|
163
331
|
const pipelines = pipelinesQuery.data;
|
|
164
332
|
if (!pipelines || !pipelines.length) return;
|
|
165
|
-
const defaultPipeline = pipelines.find((
|
|
333
|
+
const defaultPipeline = pipelines.find((pipeline) => pipeline.isDefault) ?? pipelines[0];
|
|
166
334
|
if (defaultPipeline) setSelectedPipelineId(defaultPipeline.id);
|
|
167
335
|
}, [pipelinesQuery.data, selectedPipelineId]);
|
|
336
|
+
const staffQuery = useQuery({
|
|
337
|
+
queryKey: ["customers", "deals", "kanban", "staff", `scope:${scopeVersion}`],
|
|
338
|
+
staleTime: 3e5,
|
|
339
|
+
queryFn: async () => fetchAssignableStaffMembers("", { pageSize: 100 })
|
|
340
|
+
});
|
|
341
|
+
const ownerNamesById = React.useMemo(() => {
|
|
342
|
+
const map = /* @__PURE__ */ new Map();
|
|
343
|
+
for (const member of staffQuery.data ?? []) {
|
|
344
|
+
if (member.userId && member.displayName) map.set(member.userId, member.displayName);
|
|
345
|
+
}
|
|
346
|
+
return map;
|
|
347
|
+
}, [staffQuery.data]);
|
|
348
|
+
const currentUserId = useCurrentUserId();
|
|
349
|
+
const currentUserLabel = React.useMemo(() => {
|
|
350
|
+
if (!currentUserId) return void 0;
|
|
351
|
+
return ownerNamesById.get(currentUserId);
|
|
352
|
+
}, [currentUserId, ownerNamesById]);
|
|
353
|
+
const laneWidthsScopeKey = React.useMemo(
|
|
354
|
+
() => `${currentUserId ?? "anon"}:${selectedPipelineId ?? "none"}`,
|
|
355
|
+
[currentUserId, selectedPipelineId]
|
|
356
|
+
);
|
|
357
|
+
const [laneWidths, setLaneWidths] = React.useState({});
|
|
358
|
+
React.useEffect(() => {
|
|
359
|
+
setLaneWidths(loadLaneWidths(laneWidthsScopeKey));
|
|
360
|
+
}, [laneWidthsScopeKey]);
|
|
361
|
+
React.useEffect(() => {
|
|
362
|
+
const handle = window.setTimeout(() => saveLaneWidths(laneWidthsScopeKey, laneWidths), 250);
|
|
363
|
+
return () => window.clearTimeout(handle);
|
|
364
|
+
}, [laneWidthsScopeKey, laneWidths]);
|
|
365
|
+
const handleLaneResize = React.useCallback(
|
|
366
|
+
(stageId, deltaPx) => {
|
|
367
|
+
setLaneWidths((prev) => {
|
|
368
|
+
const current = prev[stageId] ?? DEFAULT_LANE_WIDTH;
|
|
369
|
+
const next = Math.max(MIN_LANE_WIDTH, Math.min(MAX_LANE_WIDTH, current + deltaPx));
|
|
370
|
+
if (next === current) return prev;
|
|
371
|
+
return { ...prev, [stageId]: next };
|
|
372
|
+
});
|
|
373
|
+
},
|
|
374
|
+
[]
|
|
375
|
+
);
|
|
376
|
+
const handleResetLaneWidth = React.useCallback((stageId) => {
|
|
377
|
+
setLaneWidths((prev) => {
|
|
378
|
+
if (!(stageId in prev)) return prev;
|
|
379
|
+
const { [stageId]: _omit, ...rest } = prev;
|
|
380
|
+
return rest;
|
|
381
|
+
});
|
|
382
|
+
}, []);
|
|
383
|
+
const handleResetAllLaneWidths = React.useCallback(() => {
|
|
384
|
+
setLaneWidths({});
|
|
385
|
+
}, []);
|
|
386
|
+
const currencyDictionary = useCurrencyDictionary();
|
|
387
|
+
const companiesQuery = useQuery({
|
|
388
|
+
queryKey: ["customers", "companies", "kanban-quick-deal", `scope:${scopeVersion}`],
|
|
389
|
+
staleTime: 3e5,
|
|
390
|
+
queryFn: async () => {
|
|
391
|
+
const call = await apiCall(
|
|
392
|
+
"/api/customers/companies?page=1&pageSize=100&sortField=display_name&sortDir=asc"
|
|
393
|
+
);
|
|
394
|
+
if (!call.ok) return [];
|
|
395
|
+
const items = Array.isArray(call.result?.items) ? call.result.items : [];
|
|
396
|
+
const options = [];
|
|
397
|
+
for (const item of items) {
|
|
398
|
+
const id = typeof item.id === "string" ? item.id : null;
|
|
399
|
+
const displayName = typeof item.display_name === "string" && item.display_name.trim().length ? item.display_name.trim() : null;
|
|
400
|
+
if (id && displayName) options.push({ id, label: displayName });
|
|
401
|
+
}
|
|
402
|
+
return options;
|
|
403
|
+
}
|
|
404
|
+
});
|
|
168
405
|
const stagesQuery = useQuery({
|
|
169
406
|
queryKey: ["customers", "pipeline-stages", `scope:${scopeVersion}`, `pipeline:${selectedPipelineId}`],
|
|
170
407
|
enabled: !!selectedPipelineId,
|
|
171
408
|
staleTime: 3e4,
|
|
172
409
|
queryFn: async () => {
|
|
410
|
+
if (!selectedPipelineId) return [];
|
|
173
411
|
const payload = await readApiResultOrThrow(
|
|
174
412
|
`/api/customers/pipeline-stages?pipelineId=${encodeURIComponent(selectedPipelineId)}`,
|
|
175
413
|
void 0,
|
|
176
|
-
{
|
|
414
|
+
{
|
|
415
|
+
errorMessage: translateWithFallback(
|
|
416
|
+
t,
|
|
417
|
+
"customers.deals.pipeline.loadError",
|
|
418
|
+
"Failed to load stages."
|
|
419
|
+
)
|
|
420
|
+
}
|
|
177
421
|
);
|
|
178
422
|
return payload?.items ?? [];
|
|
179
423
|
}
|
|
180
424
|
});
|
|
181
|
-
const
|
|
182
|
-
const
|
|
183
|
-
|
|
425
|
+
const [extraCardsByStage, setExtraCardsByStage] = React.useState({});
|
|
426
|
+
const [loadingMoreByStage, setLoadingMoreByStage] = React.useState({});
|
|
427
|
+
React.useEffect(() => {
|
|
428
|
+
setExtraCardsByStage({});
|
|
429
|
+
setLoadingMoreByStage({});
|
|
430
|
+
}, [selectedPipelineId, search, statusFilters, ownerFilters, peopleFilters, companyFilters, closeDateFilter, currencyFilter]);
|
|
431
|
+
const filterSignature = React.useMemo(
|
|
432
|
+
() => ({
|
|
433
|
+
pipelineId: selectedPipelineId,
|
|
434
|
+
search: search.trim(),
|
|
435
|
+
status: statusFilters.slice().sort().join(","),
|
|
436
|
+
owners: ownerFilters.slice().sort().join(","),
|
|
437
|
+
people: peopleFilters.slice().sort().join(","),
|
|
438
|
+
companies: companyFilters.slice().sort().join(","),
|
|
439
|
+
closeFrom: closeDateFilter.from ?? "",
|
|
440
|
+
closeTo: closeDateFilter.to ?? "",
|
|
441
|
+
currency: currencyFilter ?? ""
|
|
442
|
+
}),
|
|
443
|
+
[selectedPipelineId, search, statusFilters, ownerFilters, peopleFilters, companyFilters, closeDateFilter, currencyFilter]
|
|
444
|
+
);
|
|
445
|
+
const aggregateQuery = useQuery({
|
|
446
|
+
queryKey: [
|
|
447
|
+
"customers",
|
|
448
|
+
"deals",
|
|
449
|
+
"kanban-aggregate",
|
|
450
|
+
`scope:${scopeVersion}`,
|
|
451
|
+
`pipeline:${selectedPipelineId ?? "none"}`,
|
|
452
|
+
`search:${filterSignature.search}`,
|
|
453
|
+
`status:${filterSignature.status}`,
|
|
454
|
+
`owners:${filterSignature.owners}`,
|
|
455
|
+
`people:${filterSignature.people}`,
|
|
456
|
+
`companies:${filterSignature.companies}`,
|
|
457
|
+
`close:${filterSignature.closeFrom}-${filterSignature.closeTo}`
|
|
458
|
+
],
|
|
184
459
|
enabled: !!selectedPipelineId,
|
|
185
460
|
staleTime: 3e4,
|
|
186
461
|
queryFn: async () => {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
search.set("
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
462
|
+
if (!selectedPipelineId) return null;
|
|
463
|
+
const params = new URLSearchParams();
|
|
464
|
+
params.set("pipelineId", selectedPipelineId);
|
|
465
|
+
if (filterSignature.search.length) params.set("search", filterSignature.search);
|
|
466
|
+
for (const status of statusFilters) params.append("status", status);
|
|
467
|
+
for (const ownerId of ownerFilters) params.append("ownerUserId", ownerId);
|
|
468
|
+
for (const personId of peopleFilters) params.append("personId", personId);
|
|
469
|
+
for (const companyId of companyFilters) params.append("companyId", companyId);
|
|
470
|
+
if (closeDateFilter.from) params.set("expectedCloseAtFrom", closeDateFilter.from);
|
|
471
|
+
if (closeDateFilter.to) params.set("expectedCloseAtTo", closeDateFilter.to);
|
|
472
|
+
const call = await apiCall(
|
|
473
|
+
`/api/customers/deals/aggregate?${params.toString()}`
|
|
197
474
|
);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if (!entry || typeof entry !== "object") return null;
|
|
217
|
-
const ref = entry;
|
|
218
|
-
const personId = typeof ref.id === "string" ? ref.id : null;
|
|
219
|
-
if (!personId) return null;
|
|
220
|
-
const label = typeof ref.label === "string" && ref.label.trim().length ? ref.label.trim() : personId;
|
|
221
|
-
return { id: personId, label };
|
|
222
|
-
}).filter((entry) => !!entry);
|
|
223
|
-
const rawCompanies = Array.isArray(data.companies) ? data.companies : [];
|
|
224
|
-
const companies = rawCompanies.map((entry) => {
|
|
225
|
-
if (!entry || typeof entry !== "object") return null;
|
|
226
|
-
const ref = entry;
|
|
227
|
-
const companyId = typeof ref.id === "string" ? ref.id : null;
|
|
228
|
-
if (!companyId) return null;
|
|
229
|
-
const label = typeof ref.label === "string" && ref.label.trim().length ? ref.label.trim() : companyId;
|
|
230
|
-
return { id: companyId, label };
|
|
231
|
-
}).filter((entry) => !!entry);
|
|
232
|
-
deals2.push({
|
|
233
|
-
id,
|
|
234
|
-
title,
|
|
235
|
-
status,
|
|
236
|
-
pipelineStage: stage,
|
|
237
|
-
pipelineId: typeof data.pipeline_id === "string" ? data.pipeline_id : null,
|
|
238
|
-
pipelineStageId: typeof data.pipeline_stage_id === "string" ? data.pipeline_stage_id : null,
|
|
239
|
-
valueAmount: amount,
|
|
240
|
-
valueCurrency: currency,
|
|
241
|
-
probability,
|
|
242
|
-
expectedCloseAt: expected.iso,
|
|
243
|
-
expectedCloseAtTs: expected.ts,
|
|
244
|
-
createdAt: created.iso,
|
|
245
|
-
createdAtTs: created.ts,
|
|
246
|
-
updatedAt: updated.iso,
|
|
247
|
-
people,
|
|
248
|
-
companies
|
|
249
|
-
});
|
|
475
|
+
return call.ok ? call.result ?? null : null;
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
const aggregateByStage = React.useMemo(() => {
|
|
479
|
+
const map = /* @__PURE__ */ new Map();
|
|
480
|
+
const data = aggregateQuery.data;
|
|
481
|
+
if (!data) return map;
|
|
482
|
+
for (const row of data.perStage) {
|
|
483
|
+
map.set(row.stageId, {
|
|
484
|
+
count: row.count,
|
|
485
|
+
totalInBaseCurrency: row.totalInBaseCurrency,
|
|
486
|
+
byCurrency: row.byCurrency,
|
|
487
|
+
baseCurrencyCode: data.baseCurrencyCode,
|
|
488
|
+
// Default to "complete conversion" when the field is absent (older response, or no
|
|
489
|
+
// base currency case where the route still returns the field). The aggregate route
|
|
490
|
+
// explicitly sets `convertedAll: false` when at least one currency was excluded.
|
|
491
|
+
convertedAll: row.convertedAll ?? true,
|
|
492
|
+
missingRateCurrencies: row.missingRateCurrencies ?? []
|
|
250
493
|
});
|
|
251
|
-
const total2 = typeof payload?.total === "number" ? payload.total : deals2.length;
|
|
252
|
-
return { deals: deals2, total: total2 };
|
|
253
494
|
}
|
|
495
|
+
return map;
|
|
496
|
+
}, [aggregateQuery.data]);
|
|
497
|
+
const stagesData = stagesQuery.data ?? [];
|
|
498
|
+
const aggregateData = aggregateQuery.data;
|
|
499
|
+
const lanes = React.useMemo(() => {
|
|
500
|
+
const knownStageIds = new Set(stagesData.map((stage) => stage.id));
|
|
501
|
+
const hasUnassigned = (aggregateData?.perStage ?? []).some(
|
|
502
|
+
(row) => row.stageId === "__unassigned" || !!row.stageId && !knownStageIds.has(row.stageId)
|
|
503
|
+
);
|
|
504
|
+
return buildLaneStages(stagesData, unassignedLabel, hasUnassigned);
|
|
505
|
+
}, [stagesData, aggregateData, unassignedLabel]);
|
|
506
|
+
const apiSort = React.useMemo(() => {
|
|
507
|
+
const mapped = mapSortOptionToApi(sortBy);
|
|
508
|
+
return mapped ?? { sortField: "updatedAt", sortDir: "desc" };
|
|
509
|
+
}, [sortBy]);
|
|
510
|
+
const laneQueries = useQueries({
|
|
511
|
+
queries: lanes.map((stage) => {
|
|
512
|
+
return {
|
|
513
|
+
queryKey: [
|
|
514
|
+
"customers",
|
|
515
|
+
"deals",
|
|
516
|
+
"kanban-lane",
|
|
517
|
+
`scope:${scopeVersion}`,
|
|
518
|
+
`pipeline:${filterSignature.pipelineId ?? "none"}`,
|
|
519
|
+
`search:${filterSignature.search}`,
|
|
520
|
+
`status:${filterSignature.status}`,
|
|
521
|
+
`owners:${filterSignature.owners}`,
|
|
522
|
+
`people:${filterSignature.people}`,
|
|
523
|
+
`companies:${filterSignature.companies}`,
|
|
524
|
+
`close:${filterSignature.closeFrom}-${filterSignature.closeTo}`,
|
|
525
|
+
// Sort affects which 25 deals come back on page 1, so it MUST be in the key —
|
|
526
|
+
// otherwise switching from "Value (high to low)" to "Close (soonest)" reuses
|
|
527
|
+
// the old (wrong-sort) cache and silently keeps the wrong slice on screen.
|
|
528
|
+
`sort:${apiSort.sortField}:${apiSort.sortDir}`,
|
|
529
|
+
`currency:${filterSignature.currency}`,
|
|
530
|
+
`stage:${stage.id}`
|
|
531
|
+
],
|
|
532
|
+
enabled: !!selectedPipelineId,
|
|
533
|
+
staleTime: 3e4,
|
|
534
|
+
queryFn: async () => {
|
|
535
|
+
if (!selectedPipelineId) return { items: [], total: 0 };
|
|
536
|
+
const params = new URLSearchParams();
|
|
537
|
+
params.set("page", "1");
|
|
538
|
+
params.set("pageSize", String(LANE_PAGE_SIZE));
|
|
539
|
+
params.set("pipelineId", selectedPipelineId);
|
|
540
|
+
params.set("pipelineStageId", stage.id);
|
|
541
|
+
params.set("sortField", apiSort.sortField);
|
|
542
|
+
params.set("sortDir", apiSort.sortDir);
|
|
543
|
+
if (filterSignature.search.length) params.set("search", filterSignature.search);
|
|
544
|
+
for (const status of statusFilters) params.append("status", status);
|
|
545
|
+
for (const ownerId of ownerFilters) params.append("ownerUserId", ownerId);
|
|
546
|
+
for (const personId of peopleFilters) params.append("personId", personId);
|
|
547
|
+
for (const companyId of companyFilters) params.append("companyId", companyId);
|
|
548
|
+
if (closeDateFilter.from) params.set("expectedCloseAtFrom", closeDateFilter.from);
|
|
549
|
+
if (closeDateFilter.to) params.set("expectedCloseAtTo", closeDateFilter.to);
|
|
550
|
+
if (currencyFilter) params.set("valueCurrency", currencyFilter);
|
|
551
|
+
const payload = await readApiResultOrThrow(
|
|
552
|
+
`/api/customers/deals?${params.toString()}`,
|
|
553
|
+
void 0,
|
|
554
|
+
{
|
|
555
|
+
errorMessage: translateWithFallback(
|
|
556
|
+
t,
|
|
557
|
+
"customers.deals.pipeline.loadError",
|
|
558
|
+
"Failed to load deals."
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
);
|
|
562
|
+
const items = Array.isArray(payload?.items) ? payload.items : [];
|
|
563
|
+
return { items, total: typeof payload?.total === "number" ? payload.total : items.length };
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
})
|
|
254
567
|
});
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
() =>
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
568
|
+
const laneState = React.useMemo(() => {
|
|
569
|
+
const dealsByStage2 = /* @__PURE__ */ new Map();
|
|
570
|
+
const stageIdByDealId2 = /* @__PURE__ */ new Map();
|
|
571
|
+
const allDeals = [];
|
|
572
|
+
let total2 = 0;
|
|
573
|
+
laneQueries.forEach((query, idx) => {
|
|
574
|
+
const stage = lanes[idx];
|
|
575
|
+
if (!stage) return;
|
|
576
|
+
const firstPage = query.data?.items ?? [];
|
|
577
|
+
const extra = extraCardsByStage[stage.id] ?? [];
|
|
578
|
+
const seen = /* @__PURE__ */ new Set();
|
|
579
|
+
const merged = [];
|
|
580
|
+
for (const item of [...firstPage, ...extra]) {
|
|
581
|
+
if (!item.id || seen.has(item.id)) continue;
|
|
582
|
+
seen.add(item.id);
|
|
583
|
+
merged.push(item);
|
|
584
|
+
}
|
|
585
|
+
const list = [];
|
|
586
|
+
for (const item of merged) {
|
|
587
|
+
const mapped = mapDealRecord(item, fallbackTitle);
|
|
588
|
+
if (!mapped) continue;
|
|
589
|
+
list.push(mapped);
|
|
590
|
+
const stageId = typeof item.pipeline_stage_id === "string" && item.pipeline_stage_id.trim().length ? item.pipeline_stage_id : "__unassigned";
|
|
591
|
+
stageIdByDealId2.set(mapped.id, stageId);
|
|
592
|
+
allDeals.push(mapped);
|
|
593
|
+
}
|
|
594
|
+
dealsByStage2.set(stage.id, list);
|
|
595
|
+
total2 += list.length;
|
|
596
|
+
});
|
|
597
|
+
return { dealsByStage: dealsByStage2, stageIdByDealId: stageIdByDealId2, allDeals, total: total2 };
|
|
598
|
+
}, [laneQueries, lanes, fallbackTitle, extraCardsByStage]);
|
|
599
|
+
const rawDeals = laneState.allDeals;
|
|
600
|
+
const stageIdByDealId = laneState.stageIdByDealId;
|
|
601
|
+
const total = aggregateData?.perStage?.reduce((sum, row) => sum + row.count, 0) ?? laneState.total;
|
|
602
|
+
const boardSummary = React.useMemo(() => {
|
|
603
|
+
const perStage = aggregateData?.perStage ?? [];
|
|
604
|
+
if (perStage.length === 0) {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
const totalsByCurrency = /* @__PURE__ */ new Map();
|
|
608
|
+
let totalInBaseCurrency = 0;
|
|
609
|
+
let convertedAll = true;
|
|
610
|
+
const missingRateCurrencies = /* @__PURE__ */ new Set();
|
|
611
|
+
for (const row of perStage) {
|
|
612
|
+
totalInBaseCurrency += row.totalInBaseCurrency;
|
|
613
|
+
if (!(row.convertedAll ?? true)) convertedAll = false;
|
|
614
|
+
for (const c of row.missingRateCurrencies ?? []) missingRateCurrencies.add(c);
|
|
615
|
+
for (const cur of row.byCurrency) {
|
|
616
|
+
const entry = totalsByCurrency.get(cur.currency) ?? { total: 0, count: 0 };
|
|
617
|
+
entry.total += cur.total;
|
|
618
|
+
entry.count += cur.count;
|
|
619
|
+
totalsByCurrency.set(cur.currency, entry);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
const rows = Array.from(totalsByCurrency.entries()).map(([currency, value]) => ({ currency, total: value.total, count: value.count })).sort((a, b) => b.total - a.total);
|
|
623
|
+
return {
|
|
624
|
+
baseCurrencyCode: aggregateData?.baseCurrencyCode ?? null,
|
|
625
|
+
totalInBaseCurrency,
|
|
626
|
+
convertedAll,
|
|
627
|
+
missingRateCurrencies: Array.from(missingRateCurrencies),
|
|
628
|
+
rows
|
|
629
|
+
};
|
|
630
|
+
}, [aggregateData]);
|
|
631
|
+
const deals = React.useMemo(() => {
|
|
632
|
+
if (ownerNamesById.size === 0) return rawDeals;
|
|
633
|
+
return rawDeals.map((deal) => {
|
|
634
|
+
if (!deal.owner) return deal;
|
|
635
|
+
const resolvedLabel = ownerNamesById.get(deal.owner.userId);
|
|
636
|
+
if (!resolvedLabel || resolvedLabel === deal.owner.label) return deal;
|
|
637
|
+
return { ...deal, owner: { ...deal.owner, label: resolvedLabel } };
|
|
638
|
+
});
|
|
639
|
+
}, [ownerNamesById, rawDeals]);
|
|
640
|
+
const dealsByStage = React.useMemo(() => {
|
|
641
|
+
const map = /* @__PURE__ */ new Map();
|
|
642
|
+
if (ownerNamesById.size === 0) {
|
|
643
|
+
for (const [stageId, list] of laneState.dealsByStage) map.set(stageId, list);
|
|
644
|
+
return map;
|
|
645
|
+
}
|
|
646
|
+
for (const [stageId, list] of laneState.dealsByStage) {
|
|
647
|
+
map.set(
|
|
648
|
+
stageId,
|
|
649
|
+
list.map((deal) => {
|
|
650
|
+
if (!deal.owner) return deal;
|
|
651
|
+
const resolvedLabel = ownerNamesById.get(deal.owner.userId);
|
|
652
|
+
if (!resolvedLabel || resolvedLabel === deal.owner.label) return deal;
|
|
653
|
+
return { ...deal, owner: { ...deal.owner, label: resolvedLabel } };
|
|
654
|
+
})
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
return map;
|
|
658
|
+
}, [laneState.dealsByStage, ownerNamesById]);
|
|
659
|
+
const sortedDealsByStage = React.useMemo(() => {
|
|
660
|
+
const map = /* @__PURE__ */ new Map();
|
|
661
|
+
for (const [stageId, list] of dealsByStage) {
|
|
662
|
+
map.set(stageId, sortDeals(list, sortBy));
|
|
663
|
+
}
|
|
664
|
+
return map;
|
|
665
|
+
}, [dealsByStage, sortBy]);
|
|
666
|
+
const handleLoadMoreInLane = React.useCallback(
|
|
667
|
+
async (stageId) => {
|
|
668
|
+
if (!selectedPipelineId) return;
|
|
669
|
+
if (loadingMoreByStage[stageId]) return;
|
|
670
|
+
const already = extraCardsByStage[stageId]?.length ?? 0;
|
|
671
|
+
const nextPage = Math.floor((LANE_PAGE_SIZE + already) / LANE_PAGE_SIZE) + 1;
|
|
672
|
+
setLoadingMoreByStage((prev) => ({ ...prev, [stageId]: true }));
|
|
673
|
+
try {
|
|
674
|
+
const params = new URLSearchParams();
|
|
675
|
+
params.set("page", String(nextPage));
|
|
676
|
+
params.set("pageSize", String(LANE_PAGE_SIZE));
|
|
677
|
+
params.set("pipelineId", selectedPipelineId);
|
|
678
|
+
params.set("pipelineStageId", stageId);
|
|
679
|
+
params.set("sortField", apiSort.sortField);
|
|
680
|
+
params.set("sortDir", apiSort.sortDir);
|
|
681
|
+
if (filterSignature.search.length) params.set("search", filterSignature.search);
|
|
682
|
+
for (const status of statusFilters) params.append("status", status);
|
|
683
|
+
for (const ownerId of ownerFilters) params.append("ownerUserId", ownerId);
|
|
684
|
+
for (const personId of peopleFilters) params.append("personId", personId);
|
|
685
|
+
for (const companyId of companyFilters) params.append("companyId", companyId);
|
|
686
|
+
if (closeDateFilter.from) params.set("expectedCloseAtFrom", closeDateFilter.from);
|
|
687
|
+
if (closeDateFilter.to) params.set("expectedCloseAtTo", closeDateFilter.to);
|
|
688
|
+
if (currencyFilter) params.set("valueCurrency", currencyFilter);
|
|
689
|
+
const call = await apiCall(
|
|
690
|
+
`/api/customers/deals?${params.toString()}`
|
|
691
|
+
);
|
|
692
|
+
if (!call.ok) return;
|
|
693
|
+
const items = Array.isArray(call.result?.items) ? call.result.items : [];
|
|
694
|
+
setExtraCardsByStage((prev) => ({
|
|
695
|
+
...prev,
|
|
696
|
+
[stageId]: [...prev[stageId] ?? [], ...items]
|
|
697
|
+
}));
|
|
698
|
+
} finally {
|
|
699
|
+
setLoadingMoreByStage((prev) => {
|
|
700
|
+
const next = { ...prev };
|
|
701
|
+
delete next[stageId];
|
|
702
|
+
return next;
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
[
|
|
707
|
+
selectedPipelineId,
|
|
708
|
+
loadingMoreByStage,
|
|
709
|
+
extraCardsByStage,
|
|
710
|
+
filterSignature.search,
|
|
711
|
+
statusFilters,
|
|
712
|
+
ownerFilters,
|
|
713
|
+
peopleFilters,
|
|
714
|
+
companyFilters,
|
|
715
|
+
closeDateFilter.from,
|
|
716
|
+
closeDateFilter.to,
|
|
717
|
+
apiSort
|
|
718
|
+
]
|
|
719
|
+
);
|
|
720
|
+
const isInitialLoading = !!selectedPipelineId && lanes.length === 0 ? stagesQuery.isLoading : laneQueries.some((laneQuery) => laneQuery.isLoading && !laneQuery.data);
|
|
721
|
+
const firstError = laneQueries.find((laneQuery) => laneQuery.isError)?.error ?? null;
|
|
722
|
+
const invalidateKanbanData = React.useCallback(() => {
|
|
723
|
+
queryClient.invalidateQueries({ queryKey: ["customers", "deals", "kanban-lane"] }).catch(() => {
|
|
724
|
+
});
|
|
725
|
+
queryClient.invalidateQueries({ queryKey: ["customers", "deals", "kanban-aggregate"] }).catch(() => {
|
|
726
|
+
});
|
|
727
|
+
setExtraCardsByStage({});
|
|
728
|
+
}, [queryClient]);
|
|
729
|
+
const trackedBulkProgressJobIdsRef = React.useRef(/* @__PURE__ */ new Set());
|
|
730
|
+
const trackBulkProgressJob = React.useCallback((jobId) => {
|
|
731
|
+
if (!jobId) return;
|
|
732
|
+
trackedBulkProgressJobIdsRef.current.add(jobId);
|
|
733
|
+
}, []);
|
|
734
|
+
const clearTrackedBulkProgressJob = React.useCallback((jobId) => {
|
|
735
|
+
if (!jobId) return false;
|
|
736
|
+
return trackedBulkProgressJobIdsRef.current.delete(jobId);
|
|
737
|
+
}, []);
|
|
738
|
+
useAppEvent(
|
|
739
|
+
"progress.job.completed",
|
|
740
|
+
(event) => {
|
|
741
|
+
const payload = event.payload;
|
|
742
|
+
const jobId = typeof payload?.jobId === "string" ? payload.jobId : null;
|
|
743
|
+
if (!clearTrackedBulkProgressJob(jobId)) return;
|
|
744
|
+
invalidateKanbanData();
|
|
745
|
+
},
|
|
746
|
+
[clearTrackedBulkProgressJob, invalidateKanbanData]
|
|
747
|
+
);
|
|
748
|
+
useAppEvent(
|
|
749
|
+
"progress.job.failed",
|
|
750
|
+
(event) => {
|
|
751
|
+
const payload = event.payload;
|
|
752
|
+
const jobId = typeof payload?.jobId === "string" ? payload.jobId : null;
|
|
753
|
+
clearTrackedBulkProgressJob(jobId);
|
|
754
|
+
},
|
|
755
|
+
[clearTrackedBulkProgressJob]
|
|
756
|
+
);
|
|
757
|
+
useAppEvent(
|
|
758
|
+
"progress.job.cancelled",
|
|
759
|
+
(event) => {
|
|
760
|
+
const payload = event.payload;
|
|
761
|
+
const jobId = typeof payload?.jobId === "string" ? payload.jobId : null;
|
|
762
|
+
clearTrackedBulkProgressJob(jobId);
|
|
763
|
+
},
|
|
764
|
+
[clearTrackedBulkProgressJob]
|
|
765
|
+
);
|
|
766
|
+
const sensors = useSensors(
|
|
767
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
768
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
|
769
|
+
);
|
|
770
|
+
const [activeDragDealId, setActiveDragDealId] = React.useState(null);
|
|
771
|
+
const activeDragDeal = React.useMemo(
|
|
772
|
+
() => activeDragDealId ? deals.find((deal) => deal.id === activeDragDealId) ?? null : null,
|
|
773
|
+
[activeDragDealId, deals]
|
|
774
|
+
);
|
|
775
|
+
const collisionDetection = React.useCallback((args) => pointerWithin(args), []);
|
|
776
|
+
const measuringConfig = React.useMemo(
|
|
777
|
+
() => ({ droppable: { strategy: MeasuringStrategy.BeforeDragging } }),
|
|
267
778
|
[]
|
|
268
779
|
);
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
780
|
+
const moveMutationContextId = "customers-deals-kanban:stage-move";
|
|
781
|
+
const { runMutation: runMoveMutation, retryLastMutation } = useGuardedMutation({
|
|
782
|
+
contextId: moveMutationContextId,
|
|
783
|
+
blockedMessage: translateWithFallback(
|
|
784
|
+
t,
|
|
785
|
+
"ui.forms.flash.saveBlocked",
|
|
786
|
+
"Save blocked by validation"
|
|
787
|
+
)
|
|
788
|
+
});
|
|
789
|
+
const dealMutationContextId = "customers-deals-kanban:deal-mutation";
|
|
790
|
+
const { runMutation: runDealMutation, retryLastMutation: retryDealMutation } = useGuardedMutation({
|
|
791
|
+
contextId: dealMutationContextId,
|
|
792
|
+
blockedMessage: translateWithFallback(
|
|
793
|
+
t,
|
|
794
|
+
"ui.forms.flash.saveBlocked",
|
|
795
|
+
"Save blocked by validation"
|
|
796
|
+
)
|
|
797
|
+
});
|
|
798
|
+
const moveDealToStage = React.useCallback(
|
|
799
|
+
(dealId, targetStageId) => {
|
|
800
|
+
if (!targetStageId || targetStageId === "__unassigned") return;
|
|
801
|
+
const currentStageId = stageIdByDealId.get(dealId);
|
|
802
|
+
if (currentStageId === targetStageId) return;
|
|
803
|
+
const laneCachePredicate = { queryKey: ["customers", "deals", "kanban-lane"] };
|
|
804
|
+
const snapshot = queryClient.getQueriesData(laneCachePredicate);
|
|
805
|
+
const extraSnapshot = extraCardsByStage;
|
|
806
|
+
let movingItem = null;
|
|
807
|
+
for (const [key, data] of snapshot) {
|
|
808
|
+
if (!data) continue;
|
|
809
|
+
const idx = data.items.findIndex((it) => it.id === dealId);
|
|
810
|
+
if (idx < 0) continue;
|
|
811
|
+
const stageKey = key.find((part) => typeof part === "string" && part.startsWith("stage:"));
|
|
812
|
+
if (typeof stageKey !== "string") continue;
|
|
813
|
+
const stageId = stageKey.slice("stage:".length);
|
|
814
|
+
if (stageId === currentStageId) {
|
|
815
|
+
queryClient.setQueryData(key, {
|
|
816
|
+
...data,
|
|
817
|
+
items: data.items.filter((it) => it.id !== dealId),
|
|
818
|
+
total: Math.max(0, data.total - 1)
|
|
819
|
+
});
|
|
820
|
+
movingItem = { ...data.items[idx], pipeline_stage_id: targetStageId };
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (!movingItem && currentStageId) {
|
|
824
|
+
const extras = extraCardsByStage[currentStageId] ?? [];
|
|
825
|
+
const found = extras.find((it) => it.id === dealId);
|
|
826
|
+
if (found) movingItem = { ...found, pipeline_stage_id: targetStageId };
|
|
827
|
+
}
|
|
828
|
+
if (currentStageId && extraCardsByStage[currentStageId]?.some((it) => it.id === dealId)) {
|
|
829
|
+
setExtraCardsByStage((prev) => {
|
|
830
|
+
const list = prev[currentStageId] ?? [];
|
|
831
|
+
return { ...prev, [currentStageId]: list.filter((it) => it.id !== dealId) };
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
if (movingItem) {
|
|
835
|
+
for (const [key, data] of snapshot) {
|
|
836
|
+
if (!data) continue;
|
|
837
|
+
const stageKey = key.find((part) => typeof part === "string" && part.startsWith("stage:"));
|
|
838
|
+
if (typeof stageKey !== "string") continue;
|
|
839
|
+
const stageId = stageKey.slice("stage:".length);
|
|
840
|
+
if (stageId !== targetStageId) continue;
|
|
841
|
+
queryClient.setQueryData(key, {
|
|
842
|
+
...data,
|
|
843
|
+
items: [movingItem, ...data.items],
|
|
844
|
+
total: data.total + 1
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
setPendingDealId(dealId);
|
|
849
|
+
runMoveMutation({
|
|
850
|
+
operation: async () => {
|
|
851
|
+
await apiCallOrThrow(
|
|
852
|
+
"/api/customers/deals",
|
|
853
|
+
{
|
|
854
|
+
method: "PUT",
|
|
855
|
+
headers: { "content-type": "application/json" },
|
|
856
|
+
body: JSON.stringify({ id: dealId, pipelineStageId: targetStageId })
|
|
857
|
+
},
|
|
858
|
+
{
|
|
859
|
+
errorMessage: translateWithFallback(
|
|
860
|
+
t,
|
|
861
|
+
"customers.deals.pipeline.moveError",
|
|
862
|
+
"Failed to update deal stage."
|
|
863
|
+
)
|
|
864
|
+
}
|
|
865
|
+
);
|
|
277
866
|
},
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
867
|
+
context: {
|
|
868
|
+
formId: moveMutationContextId,
|
|
869
|
+
resourceKind: "customers.deal",
|
|
870
|
+
resourceId: dealId,
|
|
871
|
+
retryLastMutation
|
|
872
|
+
}
|
|
873
|
+
}).then(() => {
|
|
874
|
+
flash(
|
|
875
|
+
translateWithFallback(t, "customers.deals.pipeline.moveSuccess", "Deal updated."),
|
|
876
|
+
"success"
|
|
877
|
+
);
|
|
878
|
+
}).catch((error) => {
|
|
879
|
+
for (const [key, data] of snapshot) {
|
|
880
|
+
queryClient.setQueryData(key, data);
|
|
881
|
+
}
|
|
882
|
+
setExtraCardsByStage(extraSnapshot);
|
|
883
|
+
const message = error instanceof Error && error.message ? error.message : translateWithFallback(
|
|
884
|
+
t,
|
|
885
|
+
"customers.deals.pipeline.moveError",
|
|
886
|
+
"Failed to update deal stage."
|
|
887
|
+
);
|
|
888
|
+
flash(message, "error");
|
|
889
|
+
}).finally(() => {
|
|
890
|
+
setPendingDealId(null);
|
|
891
|
+
invalidateKanbanData();
|
|
892
|
+
});
|
|
281
893
|
},
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (
|
|
287
|
-
|
|
288
|
-
|
|
894
|
+
[invalidateKanbanData, queryClient, retryLastMutation, runMoveMutation, stageIdByDealId, t]
|
|
895
|
+
);
|
|
896
|
+
const bulkMoveDealsToStage = React.useCallback(
|
|
897
|
+
async (dealIds, targetStageId) => {
|
|
898
|
+
if (!targetStageId || targetStageId === "__unassigned" || dealIds.length === 0) return;
|
|
899
|
+
const uniqueIds = Array.from(new Set(dealIds));
|
|
900
|
+
const idsToMove = uniqueIds.filter((id) => stageIdByDealId.get(id) !== targetStageId);
|
|
901
|
+
if (idsToMove.length === 0) return;
|
|
902
|
+
const laneCachePredicate = { queryKey: ["customers", "deals", "kanban-lane"] };
|
|
903
|
+
const snapshot = queryClient.getQueriesData(laneCachePredicate);
|
|
904
|
+
const extraSnapshot = extraCardsByStage;
|
|
905
|
+
const newExtras = { ...extraCardsByStage };
|
|
906
|
+
for (const dealId of idsToMove) {
|
|
907
|
+
const currentStageId = stageIdByDealId.get(dealId);
|
|
908
|
+
if (!currentStageId) continue;
|
|
909
|
+
let movingItem = null;
|
|
910
|
+
for (const [key, data] of snapshot) {
|
|
911
|
+
if (!data) continue;
|
|
912
|
+
const stageKey = key.find(
|
|
913
|
+
(part) => typeof part === "string" && part.startsWith("stage:")
|
|
914
|
+
);
|
|
915
|
+
if (typeof stageKey !== "string") continue;
|
|
916
|
+
const stageId = stageKey.slice("stage:".length);
|
|
917
|
+
if (stageId !== currentStageId) continue;
|
|
918
|
+
const current = queryClient.getQueryData(
|
|
919
|
+
key
|
|
920
|
+
);
|
|
921
|
+
if (!current) continue;
|
|
922
|
+
const idx = current.items.findIndex((it) => it.id === dealId);
|
|
923
|
+
if (idx < 0) continue;
|
|
924
|
+
movingItem = { ...current.items[idx], pipeline_stage_id: targetStageId };
|
|
925
|
+
queryClient.setQueryData(key, {
|
|
926
|
+
...current,
|
|
927
|
+
items: current.items.filter((it) => it.id !== dealId),
|
|
928
|
+
total: Math.max(0, current.total - 1)
|
|
929
|
+
});
|
|
930
|
+
break;
|
|
931
|
+
}
|
|
932
|
+
if (!movingItem) {
|
|
933
|
+
const extras = newExtras[currentStageId] ?? [];
|
|
934
|
+
const found = extras.find((it) => it.id === dealId);
|
|
935
|
+
if (found) {
|
|
936
|
+
movingItem = { ...found, pipeline_stage_id: targetStageId };
|
|
937
|
+
newExtras[currentStageId] = extras.filter((it) => it.id !== dealId);
|
|
938
|
+
}
|
|
939
|
+
} else if (newExtras[currentStageId]?.some((it) => it.id === dealId)) {
|
|
940
|
+
newExtras[currentStageId] = (newExtras[currentStageId] ?? []).filter((it) => it.id !== dealId);
|
|
941
|
+
}
|
|
942
|
+
if (!movingItem) continue;
|
|
943
|
+
for (const [key, data] of snapshot) {
|
|
944
|
+
if (!data) continue;
|
|
945
|
+
const stageKey = key.find(
|
|
946
|
+
(part) => typeof part === "string" && part.startsWith("stage:")
|
|
947
|
+
);
|
|
948
|
+
if (typeof stageKey !== "string") continue;
|
|
949
|
+
const stageId = stageKey.slice("stage:".length);
|
|
950
|
+
if (stageId !== targetStageId) continue;
|
|
951
|
+
const current = queryClient.getQueryData(
|
|
952
|
+
key
|
|
953
|
+
);
|
|
954
|
+
if (!current) continue;
|
|
955
|
+
queryClient.setQueryData(key, {
|
|
956
|
+
...current,
|
|
957
|
+
items: [movingItem, ...current.items],
|
|
958
|
+
total: current.total + 1
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
setExtraCardsByStage(newExtras);
|
|
963
|
+
setIsBulkMutating(true);
|
|
964
|
+
let progressJobId = null;
|
|
965
|
+
try {
|
|
966
|
+
await runDealMutation({
|
|
967
|
+
operation: async () => {
|
|
968
|
+
const call = await apiCallOrThrow(
|
|
969
|
+
"/api/customers/deals/bulk-update-stage",
|
|
970
|
+
{
|
|
971
|
+
method: "POST",
|
|
972
|
+
headers: { "content-type": "application/json" },
|
|
973
|
+
body: JSON.stringify({
|
|
974
|
+
ids: idsToMove,
|
|
975
|
+
pipelineStageId: targetStageId
|
|
976
|
+
})
|
|
977
|
+
},
|
|
978
|
+
{
|
|
979
|
+
errorMessage: translateWithFallback(
|
|
980
|
+
t,
|
|
981
|
+
"customers.deals.kanban.bulk.changeStage.error",
|
|
982
|
+
"Failed to start bulk stage update."
|
|
983
|
+
)
|
|
984
|
+
}
|
|
985
|
+
);
|
|
986
|
+
progressJobId = call.result?.progressJobId ?? null;
|
|
987
|
+
},
|
|
988
|
+
context: {
|
|
989
|
+
formId: dealMutationContextId,
|
|
990
|
+
resourceKind: "customers.deals.bulk_stage",
|
|
991
|
+
resourceId: idsToMove.join(","),
|
|
992
|
+
retryLastMutation: retryDealMutation
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
flash(
|
|
996
|
+
translateWithFallback(
|
|
997
|
+
t,
|
|
998
|
+
"customers.deals.kanban.bulk.changeStage.queued",
|
|
999
|
+
"Bulk stage update started ({count} deals).",
|
|
1000
|
+
{ count: idsToMove.length }
|
|
1001
|
+
),
|
|
1002
|
+
"success"
|
|
289
1003
|
);
|
|
290
|
-
|
|
1004
|
+
if (progressJobId) {
|
|
1005
|
+
trackBulkProgressJob(progressJobId);
|
|
1006
|
+
}
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
for (const [key, data] of snapshot) {
|
|
1009
|
+
queryClient.setQueryData(key, data);
|
|
1010
|
+
}
|
|
1011
|
+
setExtraCardsByStage(extraSnapshot);
|
|
1012
|
+
const message = error instanceof Error && error.message ? error.message : translateWithFallback(
|
|
1013
|
+
t,
|
|
1014
|
+
"customers.deals.kanban.bulk.changeStage.error",
|
|
1015
|
+
"Failed to start bulk stage update."
|
|
1016
|
+
);
|
|
1017
|
+
flash(message, "error");
|
|
1018
|
+
} finally {
|
|
1019
|
+
setIsBulkMutating(false);
|
|
291
1020
|
}
|
|
292
|
-
return { previous };
|
|
293
1021
|
},
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
1022
|
+
[
|
|
1023
|
+
dealMutationContextId,
|
|
1024
|
+
extraCardsByStage,
|
|
1025
|
+
queryClient,
|
|
1026
|
+
retryDealMutation,
|
|
1027
|
+
runDealMutation,
|
|
1028
|
+
stageIdByDealId,
|
|
1029
|
+
t,
|
|
1030
|
+
trackBulkProgressJob
|
|
1031
|
+
]
|
|
1032
|
+
);
|
|
1033
|
+
const boardScrollerRef = React.useRef(null);
|
|
1034
|
+
const LANE_STEP = 322;
|
|
1035
|
+
const [scrollEdges, setScrollEdges] = React.useState({
|
|
1036
|
+
atStart: true,
|
|
1037
|
+
atEnd: false
|
|
1038
|
+
});
|
|
1039
|
+
const EDGE_TOLERANCE = 4;
|
|
1040
|
+
const updateScrollEdges = React.useCallback(() => {
|
|
1041
|
+
const el = boardScrollerRef.current;
|
|
1042
|
+
if (!el) return;
|
|
1043
|
+
const maxScroll = el.scrollWidth - el.clientWidth;
|
|
1044
|
+
const nextAtStart = el.scrollLeft <= EDGE_TOLERANCE;
|
|
1045
|
+
const nextAtEnd = maxScroll <= EDGE_TOLERANCE || el.scrollLeft >= maxScroll - EDGE_TOLERANCE;
|
|
1046
|
+
setScrollEdges(
|
|
1047
|
+
(prev) => prev.atStart === nextAtStart && prev.atEnd === nextAtEnd ? prev : { atStart: nextAtStart, atEnd: nextAtEnd }
|
|
1048
|
+
);
|
|
1049
|
+
}, []);
|
|
1050
|
+
const cleanupScrollerRef = React.useRef(null);
|
|
1051
|
+
const setBoardScroller = React.useCallback(
|
|
1052
|
+
(el) => {
|
|
1053
|
+
cleanupScrollerRef.current?.();
|
|
1054
|
+
cleanupScrollerRef.current = null;
|
|
1055
|
+
boardScrollerRef.current = el;
|
|
1056
|
+
if (!el) return;
|
|
1057
|
+
updateScrollEdges();
|
|
1058
|
+
el.addEventListener("scroll", updateScrollEdges, { passive: true });
|
|
1059
|
+
const ro = new ResizeObserver(updateScrollEdges);
|
|
1060
|
+
ro.observe(el);
|
|
1061
|
+
cleanupScrollerRef.current = () => {
|
|
1062
|
+
el.removeEventListener("scroll", updateScrollEdges);
|
|
1063
|
+
ro.disconnect();
|
|
1064
|
+
};
|
|
1065
|
+
},
|
|
1066
|
+
[updateScrollEdges]
|
|
1067
|
+
);
|
|
1068
|
+
React.useEffect(() => {
|
|
1069
|
+
updateScrollEdges();
|
|
1070
|
+
}, [updateScrollEdges, lanes.length]);
|
|
1071
|
+
const activeAnimRef = React.useRef(null);
|
|
1072
|
+
const animateScrollTo = React.useCallback(
|
|
1073
|
+
(el, target) => {
|
|
1074
|
+
activeAnimRef.current?.cancel();
|
|
1075
|
+
const startLeft = el.scrollLeft;
|
|
1076
|
+
const clampedTarget = Math.max(0, Math.min(el.scrollWidth - el.clientWidth, target));
|
|
1077
|
+
const distance = clampedTarget - startLeft;
|
|
1078
|
+
if (Math.abs(distance) < 1) {
|
|
1079
|
+
updateScrollEdges();
|
|
1080
|
+
return;
|
|
297
1081
|
}
|
|
298
|
-
const
|
|
299
|
-
|
|
1082
|
+
const duration = 200;
|
|
1083
|
+
const hasRaf = typeof window !== "undefined" && typeof window.requestAnimationFrame === "function";
|
|
1084
|
+
const now = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
1085
|
+
const startTime = now();
|
|
1086
|
+
let handle = 0;
|
|
1087
|
+
let cancelled = false;
|
|
1088
|
+
const step = () => {
|
|
1089
|
+
if (cancelled) return;
|
|
1090
|
+
const t2 = Math.min(1, (now() - startTime) / duration);
|
|
1091
|
+
const eased = 1 - Math.pow(1 - t2, 3);
|
|
1092
|
+
el.scrollLeft = startLeft + distance * eased;
|
|
1093
|
+
if (t2 < 1) {
|
|
1094
|
+
handle = hasRaf ? window.requestAnimationFrame(step) : window.setTimeout(step, 16);
|
|
1095
|
+
} else {
|
|
1096
|
+
updateScrollEdges();
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
handle = hasRaf ? window.requestAnimationFrame(step) : window.setTimeout(step, 16);
|
|
1100
|
+
activeAnimRef.current = {
|
|
1101
|
+
cancel: () => {
|
|
1102
|
+
cancelled = true;
|
|
1103
|
+
if (hasRaf) window.cancelAnimationFrame(handle);
|
|
1104
|
+
else window.clearTimeout(handle);
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
300
1107
|
},
|
|
301
|
-
|
|
302
|
-
|
|
1108
|
+
[updateScrollEdges]
|
|
1109
|
+
);
|
|
1110
|
+
const laneOffsets = React.useMemo(() => {
|
|
1111
|
+
const offsets = [];
|
|
1112
|
+
let left = 0;
|
|
1113
|
+
for (const stage of lanes) {
|
|
1114
|
+
offsets.push(left);
|
|
1115
|
+
const w = laneWidths[stage.id] ?? DEFAULT_LANE_WIDTH;
|
|
1116
|
+
left += w + LANE_GAP;
|
|
1117
|
+
}
|
|
1118
|
+
return offsets;
|
|
1119
|
+
}, [lanes, laneWidths]);
|
|
1120
|
+
const stepScroll = React.useCallback(
|
|
1121
|
+
(direction) => {
|
|
1122
|
+
const el = boardScrollerRef.current;
|
|
1123
|
+
if (!el) return false;
|
|
1124
|
+
const sl = el.scrollLeft;
|
|
1125
|
+
const maxScroll = Math.max(0, el.scrollWidth - el.clientWidth);
|
|
1126
|
+
if (maxScroll <= EDGE_TOLERANCE) return false;
|
|
1127
|
+
if (direction < 0 && sl <= EDGE_TOLERANCE) return false;
|
|
1128
|
+
if (direction > 0 && sl >= maxScroll - EDGE_TOLERANCE) return false;
|
|
1129
|
+
let target;
|
|
1130
|
+
if (direction > 0) {
|
|
1131
|
+
const next = laneOffsets.find((off) => off > sl + EDGE_TOLERANCE);
|
|
1132
|
+
target = next ?? maxScroll;
|
|
1133
|
+
} else {
|
|
1134
|
+
let prev = 0;
|
|
1135
|
+
for (const off of laneOffsets) {
|
|
1136
|
+
if (off >= sl - EDGE_TOLERANCE) break;
|
|
1137
|
+
prev = off;
|
|
1138
|
+
}
|
|
1139
|
+
target = prev;
|
|
1140
|
+
}
|
|
1141
|
+
if (target > maxScroll - EDGE_TOLERANCE) target = maxScroll;
|
|
1142
|
+
if (target < EDGE_TOLERANCE) target = 0;
|
|
1143
|
+
if (Math.abs(target - sl) < 1) return false;
|
|
1144
|
+
animateScrollTo(el, target);
|
|
1145
|
+
window.setTimeout(() => {
|
|
1146
|
+
if (!boardScrollerRef.current) return;
|
|
1147
|
+
const cur = boardScrollerRef.current.scrollLeft;
|
|
1148
|
+
if (Math.abs(cur - target) > EDGE_TOLERANCE) {
|
|
1149
|
+
boardScrollerRef.current.scrollLeft = target;
|
|
1150
|
+
}
|
|
1151
|
+
updateScrollEdges();
|
|
1152
|
+
}, 240);
|
|
1153
|
+
return true;
|
|
303
1154
|
},
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
1155
|
+
[animateScrollTo, updateScrollEdges, laneOffsets]
|
|
1156
|
+
);
|
|
1157
|
+
const holdTimerRef = React.useRef(null);
|
|
1158
|
+
const stopContinuousScroll = React.useCallback(() => {
|
|
1159
|
+
if (holdTimerRef.current !== null) {
|
|
1160
|
+
window.clearInterval(holdTimerRef.current);
|
|
1161
|
+
holdTimerRef.current = null;
|
|
308
1162
|
}
|
|
309
|
-
});
|
|
310
|
-
const [draggingId, setDraggingId] = React.useState(null);
|
|
311
|
-
const [activeLane, setActiveLane] = React.useState(null);
|
|
312
|
-
const handleActionClick = React.useCallback((event) => {
|
|
313
|
-
event.stopPropagation();
|
|
314
|
-
}, []);
|
|
315
|
-
const handleSortChange = React.useCallback((value) => {
|
|
316
|
-
if (sortOptions.includes(value)) setSortBy(value);
|
|
317
1163
|
}, []);
|
|
318
|
-
const
|
|
319
|
-
|
|
1164
|
+
const startContinuousScroll = React.useCallback(
|
|
1165
|
+
(direction) => {
|
|
1166
|
+
stopContinuousScroll();
|
|
1167
|
+
const moved = stepScroll(direction);
|
|
1168
|
+
if (!moved) return;
|
|
1169
|
+
const HOLD_DELAY = 320;
|
|
1170
|
+
const REPEAT_INTERVAL = 240;
|
|
1171
|
+
holdTimerRef.current = window.setTimeout(function repeat() {
|
|
1172
|
+
const more = stepScroll(direction);
|
|
1173
|
+
if (!more) {
|
|
1174
|
+
stopContinuousScroll();
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
holdTimerRef.current = window.setTimeout(repeat, REPEAT_INTERVAL);
|
|
1178
|
+
}, HOLD_DELAY);
|
|
1179
|
+
},
|
|
1180
|
+
[stepScroll, stopContinuousScroll]
|
|
1181
|
+
);
|
|
1182
|
+
React.useEffect(() => {
|
|
1183
|
+
return () => stopContinuousScroll();
|
|
1184
|
+
}, [stopContinuousScroll]);
|
|
1185
|
+
const lastPointerDownAtRef = React.useRef(0);
|
|
1186
|
+
const handleScrollPrev = React.useCallback(() => {
|
|
1187
|
+
if (Date.now() - lastPointerDownAtRef.current < 500) return;
|
|
1188
|
+
stepScroll(-1);
|
|
1189
|
+
}, [stepScroll]);
|
|
1190
|
+
const handleScrollNext = React.useCallback(() => {
|
|
1191
|
+
if (Date.now() - lastPointerDownAtRef.current < 500) return;
|
|
1192
|
+
stepScroll(1);
|
|
1193
|
+
}, [stepScroll]);
|
|
1194
|
+
const handleHoldPrevStart = React.useCallback(() => {
|
|
1195
|
+
lastPointerDownAtRef.current = Date.now();
|
|
1196
|
+
startContinuousScroll(-1);
|
|
1197
|
+
}, [startContinuousScroll]);
|
|
1198
|
+
const handleHoldNextStart = React.useCallback(() => {
|
|
1199
|
+
lastPointerDownAtRef.current = Date.now();
|
|
1200
|
+
startContinuousScroll(1);
|
|
1201
|
+
}, [startContinuousScroll]);
|
|
1202
|
+
React.useEffect(() => {
|
|
1203
|
+
const onKey = (event) => {
|
|
1204
|
+
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
|
|
1205
|
+
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
|
1206
|
+
const target = event.target;
|
|
1207
|
+
if (target) {
|
|
1208
|
+
const tag = target.tagName;
|
|
1209
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
|
1210
|
+
if (target.isContentEditable) return;
|
|
1211
|
+
}
|
|
1212
|
+
if (typeof document !== "undefined") {
|
|
1213
|
+
if (document.querySelector('[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]')) return;
|
|
1214
|
+
if (document.querySelector('[data-radix-popper-content-wrapper] [data-state="open"]')) return;
|
|
1215
|
+
}
|
|
1216
|
+
if (!boardScrollerRef.current) return;
|
|
1217
|
+
event.preventDefault();
|
|
1218
|
+
stepScroll(event.key === "ArrowRight" ? 1 : -1);
|
|
1219
|
+
};
|
|
1220
|
+
window.addEventListener("keydown", onKey);
|
|
1221
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
1222
|
+
}, [stepScroll]);
|
|
1223
|
+
const handleDragStart = React.useCallback((event) => {
|
|
1224
|
+
const id = typeof event.active.id === "string" ? event.active.id : null;
|
|
1225
|
+
setActiveDragDealId(id);
|
|
320
1226
|
}, []);
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
setActiveLane(null);
|
|
1227
|
+
const handleDragCancel = React.useCallback(() => {
|
|
1228
|
+
setActiveDragDealId(null);
|
|
324
1229
|
}, []);
|
|
325
|
-
const
|
|
326
|
-
(
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const dealId = event.dataTransfer.getData("text/plain") || draggingId;
|
|
1230
|
+
const handleDragEnd = React.useCallback(
|
|
1231
|
+
(event) => {
|
|
1232
|
+
setActiveDragDealId(null);
|
|
1233
|
+
const dealId = typeof event.active.id === "string" ? event.active.id : null;
|
|
330
1234
|
if (!dealId) return;
|
|
331
|
-
const
|
|
1235
|
+
const overId = event.over?.id;
|
|
1236
|
+
if (typeof overId !== "string") return;
|
|
1237
|
+
let targetStageId = null;
|
|
1238
|
+
if (overId.startsWith("lane:")) {
|
|
1239
|
+
targetStageId = overId.slice("lane:".length);
|
|
1240
|
+
} else {
|
|
1241
|
+
targetStageId = stageIdByDealId.get(overId) ?? null;
|
|
1242
|
+
}
|
|
1243
|
+
if (!targetStageId) return;
|
|
1244
|
+
const isBulkDrag = selectedDealIds.has(dealId) && selectedDealIds.size > 1;
|
|
1245
|
+
if (isBulkDrag) {
|
|
1246
|
+
void bulkMoveDealsToStage(Array.from(selectedDealIds), targetStageId);
|
|
1247
|
+
} else {
|
|
1248
|
+
moveDealToStage(dealId, targetStageId);
|
|
1249
|
+
}
|
|
1250
|
+
},
|
|
1251
|
+
[bulkMoveDealsToStage, moveDealToStage, selectedDealIds, stageIdByDealId]
|
|
1252
|
+
);
|
|
1253
|
+
const bulkDragActive = React.useMemo(
|
|
1254
|
+
() => activeDragDealId !== null && selectedDealIds.has(activeDragDealId) && selectedDealIds.size > 1,
|
|
1255
|
+
[activeDragDealId, selectedDealIds]
|
|
1256
|
+
);
|
|
1257
|
+
const handleToggleSelect = React.useCallback((dealId) => {
|
|
1258
|
+
setSelectedDealIds((prev) => {
|
|
1259
|
+
const next = new Set(prev);
|
|
1260
|
+
if (next.has(dealId)) next.delete(dealId);
|
|
1261
|
+
else next.add(dealId);
|
|
1262
|
+
return next;
|
|
1263
|
+
});
|
|
1264
|
+
}, []);
|
|
1265
|
+
const handleOpenDetail = React.useCallback(
|
|
1266
|
+
(dealId) => {
|
|
1267
|
+
router.push(`/backend/customers/deals/${dealId}`);
|
|
1268
|
+
},
|
|
1269
|
+
[router]
|
|
1270
|
+
);
|
|
1271
|
+
const handleComingSoon = React.useCallback(
|
|
1272
|
+
(label) => {
|
|
1273
|
+
flash(
|
|
1274
|
+
translateWithFallback(
|
|
1275
|
+
t,
|
|
1276
|
+
"customers.deals.kanban.comingSoon",
|
|
1277
|
+
"{feature} arrives in the next iteration.",
|
|
1278
|
+
{ feature: label }
|
|
1279
|
+
),
|
|
1280
|
+
"info"
|
|
1281
|
+
);
|
|
1282
|
+
},
|
|
1283
|
+
[t]
|
|
1284
|
+
);
|
|
1285
|
+
const handleChipClick = React.useCallback(
|
|
1286
|
+
(chipId) => {
|
|
1287
|
+
handleComingSoon(
|
|
1288
|
+
translateWithFallback(
|
|
1289
|
+
t,
|
|
1290
|
+
"customers.deals.kanban.filter.aria.chip",
|
|
1291
|
+
"Filter by {label}",
|
|
1292
|
+
{ label: chipId }
|
|
1293
|
+
)
|
|
1294
|
+
);
|
|
1295
|
+
},
|
|
1296
|
+
[handleComingSoon, t]
|
|
1297
|
+
);
|
|
1298
|
+
const handleCustomizeView = React.useCallback(() => {
|
|
1299
|
+
setCustomizeOpen(true);
|
|
1300
|
+
}, []);
|
|
1301
|
+
const handleResetView = React.useCallback(() => {
|
|
1302
|
+
setStatusFilters([]);
|
|
1303
|
+
setOwnerFilters([]);
|
|
1304
|
+
setPeopleFilters([]);
|
|
1305
|
+
setCompanyFilters([]);
|
|
1306
|
+
setCloseDateFilter({ from: null, to: null });
|
|
1307
|
+
setCurrencyFilter(null);
|
|
1308
|
+
setSearch("");
|
|
1309
|
+
setSortBy(DEFAULT_SORT);
|
|
1310
|
+
}, []);
|
|
1311
|
+
const activePipelineName = React.useMemo(() => {
|
|
1312
|
+
if (!selectedPipelineId) return "";
|
|
1313
|
+
return pipelinesQuery.data?.find((pipeline) => pipeline.id === selectedPipelineId)?.name ?? "";
|
|
1314
|
+
}, [pipelinesQuery.data, selectedPipelineId]);
|
|
1315
|
+
const stageLabelById = React.useMemo(() => {
|
|
1316
|
+
const map = /* @__PURE__ */ new Map();
|
|
1317
|
+
for (const lane of lanes) map.set(lane.id, lane.label);
|
|
1318
|
+
return map;
|
|
1319
|
+
}, [lanes]);
|
|
1320
|
+
const handleQuickAdd = React.useCallback(
|
|
1321
|
+
(stageId) => {
|
|
1322
|
+
if (!selectedPipelineId) return;
|
|
1323
|
+
const stageLabel = stageLabelById.get(stageId);
|
|
1324
|
+
if (!stageLabel || stageId === "__unassigned") return;
|
|
1325
|
+
setQuickDealContext({
|
|
1326
|
+
pipelineId: selectedPipelineId,
|
|
1327
|
+
pipelineName: activePipelineName,
|
|
1328
|
+
pipelineStageId: stageId,
|
|
1329
|
+
pipelineStageLabel: stageLabel
|
|
1330
|
+
});
|
|
1331
|
+
},
|
|
1332
|
+
[activePipelineName, selectedPipelineId, stageLabelById]
|
|
1333
|
+
);
|
|
1334
|
+
const handleAddStage = React.useCallback(() => {
|
|
1335
|
+
if (!selectedPipelineId) return;
|
|
1336
|
+
const orderedStages = stagesData.filter((stage) => stage.pipelineId === selectedPipelineId).slice().sort((a, b) => a.order - b.order).map((stage) => ({ id: stage.id, label: stage.label, order: stage.order }));
|
|
1337
|
+
setAddStageContext({
|
|
1338
|
+
pipelineId: selectedPipelineId,
|
|
1339
|
+
pipelineName: activePipelineName,
|
|
1340
|
+
existingStages: orderedStages
|
|
1341
|
+
});
|
|
1342
|
+
}, [activePipelineName, selectedPipelineId, stagesData]);
|
|
1343
|
+
const handleDialogCreated = React.useCallback(() => {
|
|
1344
|
+
invalidateKanbanData();
|
|
1345
|
+
queryClient.invalidateQueries({
|
|
1346
|
+
queryKey: ["customers", "pipeline-stages", `scope:${scopeVersion}`, `pipeline:${selectedPipelineId}`]
|
|
1347
|
+
}).catch(() => {
|
|
1348
|
+
});
|
|
1349
|
+
}, [invalidateKanbanData, scopeVersion, selectedPipelineId]);
|
|
1350
|
+
const updateDealStatus = React.useCallback(
|
|
1351
|
+
async (dealId, status) => {
|
|
1352
|
+
setPendingDealId(dealId);
|
|
1353
|
+
try {
|
|
1354
|
+
await runDealMutation({
|
|
1355
|
+
operation: async () => {
|
|
1356
|
+
await apiCallOrThrow(
|
|
1357
|
+
"/api/customers/deals",
|
|
1358
|
+
{
|
|
1359
|
+
method: "PUT",
|
|
1360
|
+
headers: { "content-type": "application/json" },
|
|
1361
|
+
body: JSON.stringify({ id: dealId, status })
|
|
1362
|
+
},
|
|
1363
|
+
{
|
|
1364
|
+
errorMessage: translateWithFallback(
|
|
1365
|
+
t,
|
|
1366
|
+
"customers.deals.kanban.menu.error.status",
|
|
1367
|
+
"Failed to update deal status."
|
|
1368
|
+
)
|
|
1369
|
+
}
|
|
1370
|
+
);
|
|
1371
|
+
},
|
|
1372
|
+
context: {
|
|
1373
|
+
formId: dealMutationContextId,
|
|
1374
|
+
resourceKind: "customers.deal",
|
|
1375
|
+
resourceId: dealId,
|
|
1376
|
+
retryLastMutation: retryDealMutation
|
|
1377
|
+
}
|
|
1378
|
+
});
|
|
1379
|
+
flash(
|
|
1380
|
+
status === "win" ? translateWithFallback(
|
|
1381
|
+
t,
|
|
1382
|
+
"customers.deals.kanban.menu.markWon.success",
|
|
1383
|
+
"Deal marked as won."
|
|
1384
|
+
) : translateWithFallback(
|
|
1385
|
+
t,
|
|
1386
|
+
"customers.deals.kanban.menu.markLost.success",
|
|
1387
|
+
"Deal marked as lost."
|
|
1388
|
+
),
|
|
1389
|
+
"success"
|
|
1390
|
+
);
|
|
1391
|
+
invalidateKanbanData();
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
const message = error instanceof Error && error.message ? error.message : translateWithFallback(
|
|
1394
|
+
t,
|
|
1395
|
+
"customers.deals.kanban.menu.error.status",
|
|
1396
|
+
"Failed to update deal status."
|
|
1397
|
+
);
|
|
1398
|
+
flash(message, "error");
|
|
1399
|
+
} finally {
|
|
1400
|
+
setPendingDealId(null);
|
|
1401
|
+
}
|
|
1402
|
+
},
|
|
1403
|
+
[invalidateKanbanData, retryDealMutation, runDealMutation, t]
|
|
1404
|
+
);
|
|
1405
|
+
const deleteDeal = React.useCallback(
|
|
1406
|
+
async (dealId, dealTitle) => {
|
|
1407
|
+
const confirmed = await confirm({
|
|
1408
|
+
title: translateWithFallback(
|
|
1409
|
+
t,
|
|
1410
|
+
"customers.deals.kanban.menu.delete.confirmTitle",
|
|
1411
|
+
"Delete deal \u201C{title}\u201D?",
|
|
1412
|
+
{ title: dealTitle }
|
|
1413
|
+
),
|
|
1414
|
+
text: translateWithFallback(
|
|
1415
|
+
t,
|
|
1416
|
+
"customers.deals.kanban.menu.delete.confirmText",
|
|
1417
|
+
"This action cannot be undone."
|
|
1418
|
+
),
|
|
1419
|
+
variant: "destructive",
|
|
1420
|
+
confirmText: translateWithFallback(t, "customers.deals.kanban.menu.delete", "Delete")
|
|
1421
|
+
});
|
|
1422
|
+
if (!confirmed) return;
|
|
1423
|
+
setPendingDealId(dealId);
|
|
1424
|
+
try {
|
|
1425
|
+
await runDealMutation({
|
|
1426
|
+
operation: async () => {
|
|
1427
|
+
await deleteCrud("customers/deals", {
|
|
1428
|
+
body: { id: dealId },
|
|
1429
|
+
errorMessage: translateWithFallback(
|
|
1430
|
+
t,
|
|
1431
|
+
"customers.deals.kanban.menu.delete.error",
|
|
1432
|
+
"Failed to delete deal."
|
|
1433
|
+
)
|
|
1434
|
+
});
|
|
1435
|
+
},
|
|
1436
|
+
context: {
|
|
1437
|
+
formId: dealMutationContextId,
|
|
1438
|
+
resourceKind: "customers.deal",
|
|
1439
|
+
resourceId: dealId,
|
|
1440
|
+
retryLastMutation: retryDealMutation
|
|
1441
|
+
}
|
|
1442
|
+
});
|
|
1443
|
+
flash(
|
|
1444
|
+
translateWithFallback(t, "customers.deals.kanban.menu.delete.success", "Deal deleted."),
|
|
1445
|
+
"success"
|
|
1446
|
+
);
|
|
1447
|
+
invalidateKanbanData();
|
|
1448
|
+
} catch (error) {
|
|
1449
|
+
const message = error instanceof Error && error.message ? error.message : translateWithFallback(
|
|
1450
|
+
t,
|
|
1451
|
+
"customers.deals.kanban.menu.delete.error",
|
|
1452
|
+
"Failed to delete deal."
|
|
1453
|
+
);
|
|
1454
|
+
flash(message, "error");
|
|
1455
|
+
} finally {
|
|
1456
|
+
setPendingDealId(null);
|
|
1457
|
+
}
|
|
1458
|
+
},
|
|
1459
|
+
[confirm, invalidateKanbanData, retryDealMutation, runDealMutation, t]
|
|
1460
|
+
);
|
|
1461
|
+
const bulkSelectionSummary = React.useMemo(() => {
|
|
1462
|
+
if (selectedDealIds.size === 0) return { count: 0, totalLabel: null, currency: null, ids: [] };
|
|
1463
|
+
const totalsByCurrency = /* @__PURE__ */ new Map();
|
|
1464
|
+
const ids = [];
|
|
1465
|
+
for (const deal of deals) {
|
|
1466
|
+
if (!selectedDealIds.has(deal.id)) continue;
|
|
1467
|
+
ids.push(deal.id);
|
|
1468
|
+
if (typeof deal.valueAmount === "number" && Number.isFinite(deal.valueAmount) && deal.valueAmount > 0) {
|
|
1469
|
+
const code = deal.valueCurrency && deal.valueCurrency.length === 3 ? deal.valueCurrency.toUpperCase() : "USD";
|
|
1470
|
+
totalsByCurrency.set(code, (totalsByCurrency.get(code) ?? 0) + deal.valueAmount);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
const rows = Array.from(totalsByCurrency.entries()).map(([code, amount]) => ({ code, amount })).sort((a, b) => b.amount - a.amount);
|
|
1474
|
+
const formatOne = (code, amount) => {
|
|
1475
|
+
try {
|
|
1476
|
+
return new Intl.NumberFormat(void 0, {
|
|
1477
|
+
style: "currency",
|
|
1478
|
+
currency: code,
|
|
1479
|
+
maximumFractionDigits: 0
|
|
1480
|
+
}).format(amount);
|
|
1481
|
+
} catch {
|
|
1482
|
+
return `${code} ${Math.round(amount)}`;
|
|
1483
|
+
}
|
|
1484
|
+
};
|
|
1485
|
+
let totalLabel = null;
|
|
1486
|
+
if (rows.length === 1) {
|
|
1487
|
+
totalLabel = formatOne(rows[0].code, rows[0].amount);
|
|
1488
|
+
} else if (rows.length >= 2) {
|
|
1489
|
+
const visible = rows.slice(0, 3).map((row) => formatOne(row.code, row.amount));
|
|
1490
|
+
const overflow = rows.length - visible.length;
|
|
1491
|
+
totalLabel = overflow > 0 ? `${visible.join(" + ")} +${overflow}` : visible.join(" + ");
|
|
1492
|
+
}
|
|
1493
|
+
const currency = rows.length === 1 ? rows[0].code : null;
|
|
1494
|
+
return { count: ids.length, totalLabel, currency, ids };
|
|
1495
|
+
}, [deals, selectedDealIds]);
|
|
1496
|
+
const handleBulkClear = React.useCallback(() => {
|
|
1497
|
+
setSelectedDealIds(/* @__PURE__ */ new Set());
|
|
1498
|
+
}, []);
|
|
1499
|
+
const stageOptions = React.useMemo(
|
|
1500
|
+
() => (stagesQuery.data ?? []).slice().sort((a, b) => a.order - b.order).map((stage) => ({ id: stage.id, label: stage.label })),
|
|
1501
|
+
[stagesQuery.data]
|
|
1502
|
+
);
|
|
1503
|
+
const handleBulkChangeStage = React.useCallback(
|
|
1504
|
+
async (stageId) => {
|
|
1505
|
+
if (bulkSelectionSummary.ids.length === 0) return;
|
|
1506
|
+
setIsBulkMutating(true);
|
|
1507
|
+
let progressJobId = null;
|
|
1508
|
+
try {
|
|
1509
|
+
await runDealMutation({
|
|
1510
|
+
operation: async () => {
|
|
1511
|
+
const call = await apiCallOrThrow(
|
|
1512
|
+
"/api/customers/deals/bulk-update-stage",
|
|
1513
|
+
{
|
|
1514
|
+
method: "POST",
|
|
1515
|
+
headers: { "content-type": "application/json" },
|
|
1516
|
+
body: JSON.stringify({
|
|
1517
|
+
ids: bulkSelectionSummary.ids,
|
|
1518
|
+
pipelineStageId: stageId
|
|
1519
|
+
})
|
|
1520
|
+
},
|
|
1521
|
+
{
|
|
1522
|
+
errorMessage: translateWithFallback(
|
|
1523
|
+
t,
|
|
1524
|
+
"customers.deals.kanban.bulk.changeStage.error",
|
|
1525
|
+
"Failed to start bulk stage update."
|
|
1526
|
+
)
|
|
1527
|
+
}
|
|
1528
|
+
);
|
|
1529
|
+
progressJobId = call.result?.progressJobId ?? null;
|
|
1530
|
+
},
|
|
1531
|
+
context: {
|
|
1532
|
+
formId: dealMutationContextId,
|
|
1533
|
+
resourceKind: "customers.deals.bulk_stage",
|
|
1534
|
+
resourceId: bulkSelectionSummary.ids.join(","),
|
|
1535
|
+
retryLastMutation: retryDealMutation
|
|
1536
|
+
}
|
|
1537
|
+
});
|
|
1538
|
+
flash(
|
|
1539
|
+
translateWithFallback(
|
|
1540
|
+
t,
|
|
1541
|
+
"customers.deals.kanban.bulk.changeStage.queued",
|
|
1542
|
+
"Bulk stage update started ({count} deals).",
|
|
1543
|
+
{ count: bulkSelectionSummary.count }
|
|
1544
|
+
),
|
|
1545
|
+
"success"
|
|
1546
|
+
);
|
|
1547
|
+
setChangeStageOpen(false);
|
|
1548
|
+
setSelectedDealIds(/* @__PURE__ */ new Set());
|
|
1549
|
+
if (progressJobId) {
|
|
1550
|
+
trackBulkProgressJob(progressJobId);
|
|
1551
|
+
}
|
|
1552
|
+
invalidateKanbanData();
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
const message = error instanceof Error && error.message ? error.message : translateWithFallback(
|
|
1555
|
+
t,
|
|
1556
|
+
"customers.deals.kanban.bulk.changeStage.error",
|
|
1557
|
+
"Failed to start bulk stage update."
|
|
1558
|
+
);
|
|
1559
|
+
flash(message, "error");
|
|
1560
|
+
} finally {
|
|
1561
|
+
setIsBulkMutating(false);
|
|
1562
|
+
}
|
|
1563
|
+
},
|
|
1564
|
+
[
|
|
1565
|
+
bulkSelectionSummary.count,
|
|
1566
|
+
bulkSelectionSummary.ids,
|
|
1567
|
+
dealMutationContextId,
|
|
1568
|
+
invalidateKanbanData,
|
|
1569
|
+
retryDealMutation,
|
|
1570
|
+
runDealMutation,
|
|
1571
|
+
t,
|
|
1572
|
+
trackBulkProgressJob
|
|
1573
|
+
]
|
|
1574
|
+
);
|
|
1575
|
+
const handleBulkChangeOwner = React.useCallback(
|
|
1576
|
+
async (ownerUserId) => {
|
|
1577
|
+
if (bulkSelectionSummary.ids.length === 0) return;
|
|
1578
|
+
setIsBulkMutating(true);
|
|
1579
|
+
let progressJobId = null;
|
|
1580
|
+
try {
|
|
1581
|
+
await runDealMutation({
|
|
1582
|
+
operation: async () => {
|
|
1583
|
+
const call = await apiCallOrThrow(
|
|
1584
|
+
"/api/customers/deals/bulk-update-owner",
|
|
1585
|
+
{
|
|
1586
|
+
method: "POST",
|
|
1587
|
+
headers: { "content-type": "application/json" },
|
|
1588
|
+
body: JSON.stringify({
|
|
1589
|
+
ids: bulkSelectionSummary.ids,
|
|
1590
|
+
ownerUserId
|
|
1591
|
+
})
|
|
1592
|
+
},
|
|
1593
|
+
{
|
|
1594
|
+
errorMessage: translateWithFallback(
|
|
1595
|
+
t,
|
|
1596
|
+
"customers.deals.kanban.bulk.changeOwner.error",
|
|
1597
|
+
"Failed to start bulk owner update."
|
|
1598
|
+
)
|
|
1599
|
+
}
|
|
1600
|
+
);
|
|
1601
|
+
progressJobId = call.result?.progressJobId ?? null;
|
|
1602
|
+
},
|
|
1603
|
+
context: {
|
|
1604
|
+
formId: dealMutationContextId,
|
|
1605
|
+
resourceKind: "customers.deals.bulk_owner",
|
|
1606
|
+
resourceId: bulkSelectionSummary.ids.join(","),
|
|
1607
|
+
retryLastMutation: retryDealMutation
|
|
1608
|
+
}
|
|
1609
|
+
});
|
|
1610
|
+
flash(
|
|
1611
|
+
translateWithFallback(
|
|
1612
|
+
t,
|
|
1613
|
+
"customers.deals.kanban.bulk.changeOwner.queued",
|
|
1614
|
+
"Bulk owner update started ({count} deals).",
|
|
1615
|
+
{ count: bulkSelectionSummary.count }
|
|
1616
|
+
),
|
|
1617
|
+
"success"
|
|
1618
|
+
);
|
|
1619
|
+
setChangeOwnerOpen(false);
|
|
1620
|
+
setSelectedDealIds(/* @__PURE__ */ new Set());
|
|
1621
|
+
if (progressJobId) {
|
|
1622
|
+
trackBulkProgressJob(progressJobId);
|
|
1623
|
+
}
|
|
1624
|
+
invalidateKanbanData();
|
|
1625
|
+
} catch (error) {
|
|
1626
|
+
const message = error instanceof Error && error.message ? error.message : translateWithFallback(
|
|
1627
|
+
t,
|
|
1628
|
+
"customers.deals.kanban.bulk.changeOwner.error",
|
|
1629
|
+
"Failed to start bulk owner update."
|
|
1630
|
+
);
|
|
1631
|
+
flash(message, "error");
|
|
1632
|
+
} finally {
|
|
1633
|
+
setIsBulkMutating(false);
|
|
1634
|
+
}
|
|
1635
|
+
},
|
|
1636
|
+
[
|
|
1637
|
+
bulkSelectionSummary.count,
|
|
1638
|
+
bulkSelectionSummary.ids,
|
|
1639
|
+
dealMutationContextId,
|
|
1640
|
+
invalidateKanbanData,
|
|
1641
|
+
retryDealMutation,
|
|
1642
|
+
runDealMutation,
|
|
1643
|
+
t,
|
|
1644
|
+
trackBulkProgressJob
|
|
1645
|
+
]
|
|
1646
|
+
);
|
|
1647
|
+
const handleBulkExport = React.useCallback(() => {
|
|
1648
|
+
if (bulkSelectionSummary.ids.length === 0) return;
|
|
1649
|
+
const url = buildCrudExportUrl(
|
|
1650
|
+
"customers/deals",
|
|
1651
|
+
{
|
|
1652
|
+
ids: bulkSelectionSummary.ids.join(","),
|
|
1653
|
+
exportScope: "view"
|
|
1654
|
+
},
|
|
1655
|
+
"csv"
|
|
1656
|
+
);
|
|
1657
|
+
if (typeof window !== "undefined") {
|
|
1658
|
+
window.open(url, "_blank", "noopener");
|
|
1659
|
+
}
|
|
1660
|
+
}, [bulkSelectionSummary.ids]);
|
|
1661
|
+
const handleBulkDelete = React.useCallback(async () => {
|
|
1662
|
+
if (bulkSelectionSummary.ids.length === 0) return;
|
|
1663
|
+
const confirmed = await confirm({
|
|
1664
|
+
title: translateWithFallback(
|
|
1665
|
+
t,
|
|
1666
|
+
"customers.deals.kanban.bulk.delete.title",
|
|
1667
|
+
"Delete {count} deals?",
|
|
1668
|
+
{ count: bulkSelectionSummary.count }
|
|
1669
|
+
),
|
|
1670
|
+
text: translateWithFallback(
|
|
1671
|
+
t,
|
|
1672
|
+
"customers.deals.kanban.bulk.delete.text",
|
|
1673
|
+
"This action cannot be undone."
|
|
1674
|
+
),
|
|
1675
|
+
variant: "destructive",
|
|
1676
|
+
confirmText: translateWithFallback(t, "customers.deals.kanban.bulk.delete", "Delete")
|
|
1677
|
+
});
|
|
1678
|
+
if (!confirmed) return;
|
|
1679
|
+
const rows = deals.filter((deal) => selectedDealIds.has(deal.id)).map((deal) => ({ id: deal.id }));
|
|
1680
|
+
setIsBulkMutating(true);
|
|
1681
|
+
try {
|
|
1682
|
+
const { succeeded, failures } = await runBulkDelete(
|
|
1683
|
+
rows,
|
|
1684
|
+
async (row) => {
|
|
1685
|
+
await deleteCrud("customers/deals", {
|
|
1686
|
+
body: { id: row.id },
|
|
1687
|
+
errorMessage: translateWithFallback(
|
|
1688
|
+
t,
|
|
1689
|
+
"customers.deals.kanban.menu.delete.error",
|
|
1690
|
+
"Failed to delete deal."
|
|
1691
|
+
)
|
|
1692
|
+
});
|
|
1693
|
+
},
|
|
1694
|
+
{
|
|
1695
|
+
fallbackErrorMessage: translateWithFallback(
|
|
1696
|
+
t,
|
|
1697
|
+
"customers.deals.kanban.menu.delete.error",
|
|
1698
|
+
"Failed to delete deal."
|
|
1699
|
+
),
|
|
1700
|
+
logTag: "customers.deals.kanban",
|
|
1701
|
+
progress: {
|
|
1702
|
+
jobType: "customers.deals.bulk_delete",
|
|
1703
|
+
name: translateWithFallback(
|
|
1704
|
+
t,
|
|
1705
|
+
"customers.deals.kanban.bulk.delete.progress.name",
|
|
1706
|
+
"Delete selected deals"
|
|
1707
|
+
),
|
|
1708
|
+
description: translateWithFallback(
|
|
1709
|
+
t,
|
|
1710
|
+
"customers.deals.kanban.bulk.delete.progress.description",
|
|
1711
|
+
"{count} deals queued for deletion",
|
|
1712
|
+
{ count: rows.length }
|
|
1713
|
+
),
|
|
1714
|
+
meta: { source: "customers.deals.kanban" }
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
);
|
|
1718
|
+
if (succeeded.length > 0) {
|
|
1719
|
+
flash(
|
|
1720
|
+
translateWithFallback(
|
|
1721
|
+
t,
|
|
1722
|
+
"customers.deals.kanban.bulk.delete.success",
|
|
1723
|
+
"{count} deals deleted.",
|
|
1724
|
+
{ count: succeeded.length }
|
|
1725
|
+
),
|
|
1726
|
+
failures.length === 0 ? "success" : "warning"
|
|
1727
|
+
);
|
|
1728
|
+
}
|
|
1729
|
+
for (const group of groupBulkDeleteFailures(failures)) {
|
|
1730
|
+
const message = group.count === 1 ? group.sampleMessage : translateWithFallback(
|
|
1731
|
+
t,
|
|
1732
|
+
"customers.deals.kanban.bulk.delete.failedGroup",
|
|
1733
|
+
"{count} deals could not be deleted: {message}",
|
|
1734
|
+
{ count: group.count, message: group.sampleMessage }
|
|
1735
|
+
);
|
|
1736
|
+
flash(message, "error");
|
|
1737
|
+
}
|
|
1738
|
+
setSelectedDealIds(/* @__PURE__ */ new Set());
|
|
1739
|
+
invalidateKanbanData();
|
|
1740
|
+
} finally {
|
|
1741
|
+
setIsBulkMutating(false);
|
|
1742
|
+
}
|
|
1743
|
+
}, [
|
|
1744
|
+
bulkSelectionSummary.count,
|
|
1745
|
+
bulkSelectionSummary.ids,
|
|
1746
|
+
confirm,
|
|
1747
|
+
deals,
|
|
1748
|
+
queryClient,
|
|
1749
|
+
selectedDealIds,
|
|
1750
|
+
t
|
|
1751
|
+
]);
|
|
1752
|
+
const duplicateDeal = React.useCallback(
|
|
1753
|
+
async (deal) => {
|
|
1754
|
+
setPendingDealId(deal.id);
|
|
1755
|
+
try {
|
|
1756
|
+
const stageId = stageIdByDealId.get(deal.id) ?? null;
|
|
1757
|
+
const payload = {
|
|
1758
|
+
title: translateWithFallback(
|
|
1759
|
+
t,
|
|
1760
|
+
"customers.deals.kanban.menu.duplicate.titleSuffix",
|
|
1761
|
+
"{title} (copy)",
|
|
1762
|
+
{ title: deal.title }
|
|
1763
|
+
),
|
|
1764
|
+
status: "open"
|
|
1765
|
+
};
|
|
1766
|
+
if (selectedPipelineId) payload.pipelineId = selectedPipelineId;
|
|
1767
|
+
if (stageId && stageId !== "__unassigned") payload.pipelineStageId = stageId;
|
|
1768
|
+
if (typeof deal.valueAmount === "number") payload.valueAmount = deal.valueAmount;
|
|
1769
|
+
if (deal.valueCurrency) payload.valueCurrency = deal.valueCurrency;
|
|
1770
|
+
if (typeof deal.probability === "number") payload.probability = deal.probability;
|
|
1771
|
+
if (deal.expectedCloseAt) payload.expectedCloseAt = deal.expectedCloseAt;
|
|
1772
|
+
if (deal.owner?.userId) payload.ownerUserId = deal.owner.userId;
|
|
1773
|
+
await runDealMutation({
|
|
1774
|
+
operation: async () => {
|
|
1775
|
+
await apiCallOrThrow(
|
|
1776
|
+
"/api/customers/deals",
|
|
1777
|
+
{
|
|
1778
|
+
method: "POST",
|
|
1779
|
+
headers: { "content-type": "application/json" },
|
|
1780
|
+
body: JSON.stringify(payload)
|
|
1781
|
+
},
|
|
1782
|
+
{
|
|
1783
|
+
errorMessage: translateWithFallback(
|
|
1784
|
+
t,
|
|
1785
|
+
"customers.deals.kanban.menu.duplicate.error",
|
|
1786
|
+
"Failed to duplicate deal."
|
|
1787
|
+
)
|
|
1788
|
+
}
|
|
1789
|
+
);
|
|
1790
|
+
},
|
|
1791
|
+
context: {
|
|
1792
|
+
formId: dealMutationContextId,
|
|
1793
|
+
resourceKind: "customers.deal",
|
|
1794
|
+
resourceId: deal.id,
|
|
1795
|
+
retryLastMutation: retryDealMutation
|
|
1796
|
+
}
|
|
1797
|
+
});
|
|
1798
|
+
flash(
|
|
1799
|
+
translateWithFallback(
|
|
1800
|
+
t,
|
|
1801
|
+
"customers.deals.kanban.menu.duplicate.success",
|
|
1802
|
+
"Deal duplicated."
|
|
1803
|
+
),
|
|
1804
|
+
"success"
|
|
1805
|
+
);
|
|
1806
|
+
invalidateKanbanData();
|
|
1807
|
+
} catch (error) {
|
|
1808
|
+
const message = error instanceof Error && error.message ? error.message : translateWithFallback(
|
|
1809
|
+
t,
|
|
1810
|
+
"customers.deals.kanban.menu.duplicate.error",
|
|
1811
|
+
"Failed to duplicate deal."
|
|
1812
|
+
);
|
|
1813
|
+
flash(message, "error");
|
|
1814
|
+
} finally {
|
|
1815
|
+
setPendingDealId(null);
|
|
1816
|
+
}
|
|
1817
|
+
},
|
|
1818
|
+
[
|
|
1819
|
+
queryClient,
|
|
1820
|
+
retryDealMutation,
|
|
1821
|
+
runDealMutation,
|
|
1822
|
+
selectedPipelineId,
|
|
1823
|
+
stageIdByDealId,
|
|
1824
|
+
t
|
|
1825
|
+
]
|
|
1826
|
+
);
|
|
1827
|
+
const buildMenuItems = React.useCallback(
|
|
1828
|
+
(deal) => [
|
|
1829
|
+
{
|
|
1830
|
+
id: "open",
|
|
1831
|
+
label: translateWithFallback(t, "customers.deals.kanban.menu.open", "Open deal"),
|
|
1832
|
+
onSelect: () => handleOpenDetail(deal.id)
|
|
1833
|
+
},
|
|
1834
|
+
{
|
|
1835
|
+
id: "edit",
|
|
1836
|
+
label: translateWithFallback(t, "customers.deals.kanban.menu.edit", "Edit"),
|
|
1837
|
+
onSelect: () => handleOpenDetail(deal.id)
|
|
1838
|
+
},
|
|
1839
|
+
{
|
|
1840
|
+
id: "duplicate",
|
|
1841
|
+
label: translateWithFallback(t, "customers.deals.kanban.menu.duplicate", "Duplicate"),
|
|
1842
|
+
onSelect: () => void duplicateDeal(deal)
|
|
1843
|
+
},
|
|
1844
|
+
{
|
|
1845
|
+
id: "move-stage",
|
|
1846
|
+
label: translateWithFallback(t, "customers.deals.kanban.menu.moveStage", "Move stage\u2026"),
|
|
1847
|
+
onSelect: () => setSingleMoveStageContext({
|
|
1848
|
+
dealId: deal.id,
|
|
1849
|
+
currentStageId: stageIdByDealId.get(deal.id) ?? null
|
|
1850
|
+
})
|
|
1851
|
+
},
|
|
1852
|
+
{
|
|
1853
|
+
id: "mark-won",
|
|
1854
|
+
label: translateWithFallback(t, "customers.deals.kanban.menu.markWon", "Mark as Won"),
|
|
1855
|
+
onSelect: () => void updateDealStatus(deal.id, "win")
|
|
1856
|
+
},
|
|
1857
|
+
{
|
|
1858
|
+
id: "mark-lost",
|
|
1859
|
+
label: translateWithFallback(t, "customers.deals.kanban.menu.markLost", "Mark as Lost"),
|
|
1860
|
+
onSelect: () => void updateDealStatus(deal.id, "loose")
|
|
1861
|
+
},
|
|
1862
|
+
{
|
|
1863
|
+
id: "delete",
|
|
1864
|
+
label: translateWithFallback(t, "customers.deals.kanban.menu.delete", "Delete"),
|
|
1865
|
+
destructive: true,
|
|
1866
|
+
onSelect: () => void deleteDeal(deal.id, deal.title)
|
|
1867
|
+
}
|
|
1868
|
+
],
|
|
1869
|
+
[
|
|
1870
|
+
deleteDeal,
|
|
1871
|
+
duplicateDeal,
|
|
1872
|
+
handleOpenDetail,
|
|
1873
|
+
stageIdByDealId,
|
|
1874
|
+
t,
|
|
1875
|
+
updateDealStatus
|
|
1876
|
+
]
|
|
1877
|
+
);
|
|
1878
|
+
const handleComposeActivity = React.useCallback(
|
|
1879
|
+
(dealId, type) => {
|
|
1880
|
+
const deal = deals.find((entry) => entry.id === dealId);
|
|
332
1881
|
if (!deal) return;
|
|
333
|
-
|
|
1882
|
+
const entityId = deal.primaryCompany?.id;
|
|
1883
|
+
if (!entityId) {
|
|
334
1884
|
flash(
|
|
335
|
-
|
|
1885
|
+
translateWithFallback(
|
|
1886
|
+
t,
|
|
1887
|
+
"customers.deals.kanban.activityComposer.noEntity",
|
|
1888
|
+
"Link a company to this deal before logging activities here."
|
|
1889
|
+
),
|
|
336
1890
|
"info"
|
|
337
1891
|
);
|
|
338
1892
|
return;
|
|
339
1893
|
}
|
|
340
|
-
|
|
341
|
-
|
|
1894
|
+
setActivityContext({
|
|
1895
|
+
dealId,
|
|
1896
|
+
dealTitle: deal.title,
|
|
1897
|
+
type,
|
|
1898
|
+
entityId
|
|
1899
|
+
});
|
|
342
1900
|
},
|
|
343
|
-
[
|
|
1901
|
+
[deals, t]
|
|
344
1902
|
);
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
1903
|
+
const filterChips = React.useMemo(() => [], []);
|
|
1904
|
+
const pipelineFilterOptions = React.useMemo(
|
|
1905
|
+
() => (pipelinesQuery.data ?? []).map((pipeline) => ({
|
|
1906
|
+
id: pipeline.id,
|
|
1907
|
+
name: pipeline.name
|
|
1908
|
+
})),
|
|
1909
|
+
[pipelinesQuery.data]
|
|
1910
|
+
);
|
|
1911
|
+
const loadOwnerOptions = React.useCallback(
|
|
1912
|
+
async (query, _signal) => {
|
|
1913
|
+
const items = await fetchAssignableStaffMembers(query ?? "", { pageSize: 100 });
|
|
1914
|
+
const opts = items.filter((user) => !!user.userId && !!user.displayName).map((user) => ({ value: user.userId, label: user.displayName }));
|
|
1915
|
+
setOwnerLabels((prev) => {
|
|
1916
|
+
const next = { ...prev };
|
|
1917
|
+
for (const option of opts) next[option.value] = option.label;
|
|
1918
|
+
return next;
|
|
1919
|
+
});
|
|
1920
|
+
return opts;
|
|
350
1921
|
},
|
|
351
|
-
[
|
|
1922
|
+
[]
|
|
352
1923
|
);
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
)
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
1924
|
+
const loadPeopleOptions = React.useCallback(
|
|
1925
|
+
async (query, signal) => {
|
|
1926
|
+
const params = new URLSearchParams();
|
|
1927
|
+
params.set("page", "1");
|
|
1928
|
+
params.set("pageSize", "50");
|
|
1929
|
+
if (query) params.set("search", query);
|
|
1930
|
+
params.set("sortField", "displayName");
|
|
1931
|
+
params.set("sortDir", "asc");
|
|
1932
|
+
const call = await apiCall(`/api/customers/people?${params.toString()}`, { signal });
|
|
1933
|
+
if (!call.ok) return [];
|
|
1934
|
+
const items = call.result?.items ?? [];
|
|
1935
|
+
const opts = [];
|
|
1936
|
+
for (const it of items) {
|
|
1937
|
+
if (!it.id) continue;
|
|
1938
|
+
const label = it.display_name && it.display_name.trim().length ? it.display_name.trim() : [it.first_name, it.last_name].filter(Boolean).join(" ").trim() || it.id.slice(0, 8);
|
|
1939
|
+
opts.push({ value: it.id, label });
|
|
1940
|
+
}
|
|
1941
|
+
setPeopleLabels((prev) => {
|
|
1942
|
+
const next = { ...prev };
|
|
1943
|
+
for (const o of opts) next[o.value] = o.label;
|
|
1944
|
+
return next;
|
|
1945
|
+
});
|
|
1946
|
+
return opts;
|
|
1947
|
+
},
|
|
1948
|
+
[]
|
|
1949
|
+
);
|
|
1950
|
+
const loadCompanyOptions = React.useCallback(
|
|
1951
|
+
async (query, signal) => {
|
|
1952
|
+
const params = new URLSearchParams();
|
|
1953
|
+
params.set("page", "1");
|
|
1954
|
+
params.set("pageSize", "50");
|
|
1955
|
+
if (query) params.set("search", query);
|
|
1956
|
+
params.set("sortField", "display_name");
|
|
1957
|
+
params.set("sortDir", "asc");
|
|
1958
|
+
const call = await apiCall(
|
|
1959
|
+
`/api/customers/companies?${params.toString()}`,
|
|
1960
|
+
{ signal }
|
|
1961
|
+
);
|
|
1962
|
+
if (!call.ok) return [];
|
|
1963
|
+
const items = call.result?.items ?? [];
|
|
1964
|
+
const opts = [];
|
|
1965
|
+
for (const it of items) {
|
|
1966
|
+
if (!it.id || !it.display_name) continue;
|
|
1967
|
+
opts.push({ value: it.id, label: it.display_name });
|
|
1968
|
+
}
|
|
1969
|
+
setCompanyLabels((prev) => {
|
|
1970
|
+
const next = { ...prev };
|
|
1971
|
+
for (const o of opts) next[o.value] = o.label;
|
|
1972
|
+
return next;
|
|
1973
|
+
});
|
|
1974
|
+
return opts;
|
|
1975
|
+
},
|
|
1976
|
+
[]
|
|
1977
|
+
);
|
|
1978
|
+
const leadingChipsNode = /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1979
|
+
/* @__PURE__ */ jsx(StatusFilterPopover, { values: statusFilters, onApply: setStatusFilters }),
|
|
1980
|
+
/* @__PURE__ */ jsx(
|
|
1981
|
+
PipelineFilterPopover,
|
|
1982
|
+
{
|
|
1983
|
+
pipelines: pipelineFilterOptions,
|
|
1984
|
+
selectedPipelineId,
|
|
1985
|
+
onApply: setSelectedPipelineId
|
|
1986
|
+
}
|
|
1987
|
+
),
|
|
1988
|
+
boardSummary && boardSummary.rows.length > 0 ? /* @__PURE__ */ jsx(
|
|
1989
|
+
CurrencyFilterPopover,
|
|
1990
|
+
{
|
|
1991
|
+
rows: boardSummary.rows,
|
|
1992
|
+
baseCurrencyCode: boardSummary.baseCurrencyCode,
|
|
1993
|
+
totalInBaseCurrency: boardSummary.totalInBaseCurrency,
|
|
1994
|
+
headingLabel: translateWithFallback(
|
|
1995
|
+
t,
|
|
1996
|
+
"customers.deals.kanban.boardSummary.headingLabel",
|
|
1997
|
+
"PIPELINE"
|
|
1998
|
+
),
|
|
1999
|
+
headingCount: total,
|
|
2000
|
+
selectedCurrency: currencyFilter,
|
|
2001
|
+
onApply: setCurrencyFilter
|
|
2002
|
+
}
|
|
2003
|
+
) : null,
|
|
2004
|
+
/* @__PURE__ */ jsx(
|
|
2005
|
+
EntityFilterPopover,
|
|
2006
|
+
{
|
|
2007
|
+
label: translateWithFallback(t, "customers.deals.kanban.filter.owner", "Owner"),
|
|
2008
|
+
anyLabel: translateWithFallback(t, "customers.deals.kanban.filter.all", "All"),
|
|
2009
|
+
values: ownerFilters,
|
|
2010
|
+
onApply: setOwnerFilters,
|
|
2011
|
+
loadOptions: loadOwnerOptions,
|
|
2012
|
+
labelById: ownerLabels
|
|
2013
|
+
}
|
|
2014
|
+
),
|
|
2015
|
+
/* @__PURE__ */ jsx(
|
|
2016
|
+
EntityFilterPopover,
|
|
2017
|
+
{
|
|
2018
|
+
label: translateWithFallback(t, "customers.deals.kanban.filter.people", "People"),
|
|
2019
|
+
values: peopleFilters,
|
|
2020
|
+
onApply: setPeopleFilters,
|
|
2021
|
+
loadOptions: loadPeopleOptions,
|
|
2022
|
+
labelById: peopleLabels
|
|
2023
|
+
}
|
|
2024
|
+
),
|
|
2025
|
+
/* @__PURE__ */ jsx(
|
|
2026
|
+
EntityFilterPopover,
|
|
2027
|
+
{
|
|
2028
|
+
label: translateWithFallback(t, "customers.deals.kanban.filter.companies", "Companies"),
|
|
2029
|
+
values: companyFilters,
|
|
2030
|
+
onApply: setCompanyFilters,
|
|
2031
|
+
loadOptions: loadCompanyOptions,
|
|
2032
|
+
labelById: companyLabels
|
|
2033
|
+
}
|
|
2034
|
+
),
|
|
2035
|
+
/* @__PURE__ */ jsx(CloseDateFilterPopover, { value: closeDateFilter, onApply: setCloseDateFilter })
|
|
2036
|
+
] });
|
|
2037
|
+
const sortNode = /* @__PURE__ */ jsx(SortByPopover, { value: sortBy, onApply: setSortBy });
|
|
2038
|
+
const activePipeline = React.useMemo(() => {
|
|
2039
|
+
if (!selectedPipelineId) return null;
|
|
2040
|
+
return pipelinesQuery.data?.find((pipeline) => pipeline.id === selectedPipelineId) ?? null;
|
|
2041
|
+
}, [pipelinesQuery.data, selectedPipelineId]);
|
|
2042
|
+
const dragHint = translateWithFallback(
|
|
2043
|
+
t,
|
|
2044
|
+
"customers.deals.kanban.helper.dragHint",
|
|
2045
|
+
"Drag cards between lanes to update stage"
|
|
2046
|
+
);
|
|
2047
|
+
const footerCount = translateWithFallback(
|
|
2048
|
+
t,
|
|
2049
|
+
"customers.deals.kanban.footer.count",
|
|
2050
|
+
"Showing {visible} of {total} deals in {pipeline} pipeline",
|
|
2051
|
+
{
|
|
2052
|
+
visible: deals.length,
|
|
2053
|
+
total,
|
|
2054
|
+
pipeline: activePipeline?.name ?? "\u2014"
|
|
2055
|
+
}
|
|
2056
|
+
);
|
|
2057
|
+
return /* @__PURE__ */ jsxs(Page, { children: [
|
|
2058
|
+
/* @__PURE__ */ jsxs(PageBody, { children: [
|
|
2059
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
|
|
2060
|
+
/* @__PURE__ */ jsx(Breadcrumb, { children: /* @__PURE__ */ jsxs(BreadcrumbList, { children: [
|
|
2061
|
+
/* @__PURE__ */ jsx(BreadcrumbItem, { children: /* @__PURE__ */ jsx(BreadcrumbLink, { asChild: true, children: /* @__PURE__ */ jsx(Link, { href: "/backend", children: translateWithFallback(t, "customers.deals.kanban.breadcrumb.dashboard", "Dashboard") }) }) }),
|
|
2062
|
+
/* @__PURE__ */ jsx(BreadcrumbSeparator, {}),
|
|
2063
|
+
/* @__PURE__ */ jsx(BreadcrumbItem, { children: /* @__PURE__ */ jsx(BreadcrumbPage, { children: translateWithFallback(t, "customers.deals.kanban.breadcrumb.deals", "Deals") }) })
|
|
2064
|
+
] }) }),
|
|
2065
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", children: [
|
|
2066
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1", children: [
|
|
2067
|
+
/* @__PURE__ */ jsx("h1", { className: "text-xl font-semibold leading-tight text-foreground sm:text-2xl", children: translateWithFallback(t, "customers.deals.kanban.pageTitle", "Deals") }),
|
|
2068
|
+
boardSummary && boardSummary.rows.length > 0 ? (
|
|
2069
|
+
/*
|
|
2070
|
+
* Pipeline-wide value summary. The headline number is the converted total in
|
|
2071
|
+
* the tenant's base currency, paired with the deal count for context. When at
|
|
2072
|
+
* least one currency lacks an FX rate, we show a `~` prefix and a "partial"
|
|
2073
|
+
* caveat — the `LaneCurrencyBreakdown` popover (anchored on the `+N` chip)
|
|
2074
|
+
* surfaces the full breakdown and missing-rate disclosure. Clicking the
|
|
2075
|
+
* popover trigger here gives the operator the same board-level detail view.
|
|
2076
|
+
*/
|
|
2077
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 text-sm text-muted-foreground", children: [
|
|
2078
|
+
/* @__PURE__ */ jsx("span", { children: translateWithFallback(
|
|
2079
|
+
t,
|
|
2080
|
+
"customers.deals.kanban.boardSummary.count",
|
|
2081
|
+
"{count} deals",
|
|
2082
|
+
{ count: total }
|
|
2083
|
+
) }),
|
|
2084
|
+
boardSummary.baseCurrencyCode && boardSummary.totalInBaseCurrency > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
2085
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\xB7" }),
|
|
2086
|
+
/* @__PURE__ */ jsxs("span", { className: "flex items-baseline gap-1 font-semibold text-foreground", children: [
|
|
2087
|
+
boardSummary.convertedAll ? null : /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", "aria-hidden": "true", children: "~" }),
|
|
2088
|
+
/* @__PURE__ */ jsx("span", { children: new Intl.NumberFormat(void 0, {
|
|
2089
|
+
style: "decimal",
|
|
2090
|
+
maximumFractionDigits: 0
|
|
2091
|
+
}).format(boardSummary.totalInBaseCurrency) }),
|
|
2092
|
+
/* @__PURE__ */ jsx("span", { className: "text-xs font-medium text-muted-foreground", children: boardSummary.baseCurrencyCode })
|
|
2093
|
+
] }),
|
|
2094
|
+
!boardSummary.convertedAll ? /* @__PURE__ */ jsx(
|
|
2095
|
+
"span",
|
|
2096
|
+
{
|
|
2097
|
+
className: "text-overline uppercase tracking-wide text-status-warning-text",
|
|
2098
|
+
title: translateWithFallback(
|
|
2099
|
+
t,
|
|
2100
|
+
"customers.deals.kanban.boardSummary.partialHint",
|
|
2101
|
+
"Missing FX rates for {currencies} \u2014 excluded from total",
|
|
2102
|
+
{ currencies: boardSummary.missingRateCurrencies.join(", ") }
|
|
2103
|
+
),
|
|
2104
|
+
children: translateWithFallback(
|
|
2105
|
+
t,
|
|
2106
|
+
"customers.deals.kanban.boardSummary.partial",
|
|
2107
|
+
"partial"
|
|
2108
|
+
)
|
|
2109
|
+
}
|
|
2110
|
+
) : null
|
|
2111
|
+
] }) : null,
|
|
2112
|
+
boardSummary.rows.length > 1 ? /* @__PURE__ */ jsx(
|
|
2113
|
+
LaneCurrencyBreakdown,
|
|
2114
|
+
{
|
|
2115
|
+
rows: boardSummary.rows,
|
|
2116
|
+
baseCurrencyCode: boardSummary.baseCurrencyCode,
|
|
2117
|
+
totalInBaseCurrency: boardSummary.totalInBaseCurrency,
|
|
2118
|
+
convertedAll: boardSummary.convertedAll,
|
|
2119
|
+
missingRateCurrencies: boardSummary.missingRateCurrencies,
|
|
2120
|
+
headingLabel: translateWithFallback(
|
|
2121
|
+
t,
|
|
2122
|
+
"customers.deals.kanban.boardSummary.headingLabel",
|
|
2123
|
+
"PIPELINE"
|
|
2124
|
+
),
|
|
2125
|
+
headingCount: total,
|
|
2126
|
+
triggerLabel: translateWithFallback(
|
|
2127
|
+
t,
|
|
2128
|
+
"customers.deals.kanban.boardSummary.breakdownTrigger",
|
|
2129
|
+
"Breakdown"
|
|
2130
|
+
),
|
|
2131
|
+
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"
|
|
2132
|
+
}
|
|
2133
|
+
) : null
|
|
2134
|
+
] })
|
|
2135
|
+
) : null
|
|
2136
|
+
] }),
|
|
2137
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2.5", children: [
|
|
2138
|
+
/* @__PURE__ */ jsx(
|
|
2139
|
+
SearchInput,
|
|
2140
|
+
{
|
|
2141
|
+
value: search,
|
|
2142
|
+
onChange: setSearch,
|
|
2143
|
+
placeholder: translateWithFallback(
|
|
2144
|
+
t,
|
|
2145
|
+
"customers.deals.kanban.search.placeholder",
|
|
2146
|
+
"Search deals\u2026"
|
|
2147
|
+
),
|
|
2148
|
+
className: "w-64"
|
|
2149
|
+
}
|
|
2150
|
+
),
|
|
2151
|
+
/* @__PURE__ */ jsxs(Button, { variant: "outline", type: "button", onClick: handleCustomizeView, children: [
|
|
2152
|
+
/* @__PURE__ */ jsx(SlidersHorizontal, { className: "size-4", "aria-hidden": "true" }),
|
|
2153
|
+
translateWithFallback(t, "customers.deals.kanban.cta.customize", "Customize view")
|
|
2154
|
+
] }),
|
|
2155
|
+
/* @__PURE__ */ jsxs(
|
|
2156
|
+
Button,
|
|
2157
|
+
{
|
|
2158
|
+
variant: "outline",
|
|
2159
|
+
type: "button",
|
|
2160
|
+
onClick: handleAddStage,
|
|
2161
|
+
disabled: !selectedPipelineId,
|
|
2162
|
+
children: [
|
|
2163
|
+
/* @__PURE__ */ jsx(Plus, { className: "size-4", "aria-hidden": "true" }),
|
|
2164
|
+
translateWithFallback(t, "customers.deals.kanban.cta.newStage", "New stage")
|
|
2165
|
+
]
|
|
2166
|
+
}
|
|
2167
|
+
),
|
|
2168
|
+
/* @__PURE__ */ jsx(Button, { asChild: true, children: /* @__PURE__ */ jsxs(Link, { href: "/backend/customers/deals/create?returnTo=/backend/customers/deals/pipeline", children: [
|
|
2169
|
+
/* @__PURE__ */ jsx(Plus, { className: "size-4", "aria-hidden": "true" }),
|
|
2170
|
+
translateWithFallback(t, "customers.deals.kanban.cta.newDeal", "New deal")
|
|
2171
|
+
] }) })
|
|
2172
|
+
] })
|
|
2173
|
+
] }),
|
|
2174
|
+
pipelinesQuery.data && pipelinesQuery.data.length > 1 ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 pb-1 text-sm", children: [
|
|
2175
|
+
/* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: translateWithFallback(t, "customers.deals.pipeline.switch.label", "Pipeline") }),
|
|
371
2176
|
/* @__PURE__ */ jsxs(
|
|
372
2177
|
Select,
|
|
373
2178
|
{
|
|
374
|
-
value: selectedPipelineId
|
|
2179
|
+
value: selectedPipelineId ?? void 0,
|
|
375
2180
|
onValueChange: (value) => setSelectedPipelineId(value || null),
|
|
376
2181
|
children: [
|
|
377
|
-
/* @__PURE__ */ jsx(SelectTrigger, { className: "w-auto min-w-
|
|
378
|
-
/* @__PURE__ */ jsx(SelectContent, { children: pipelinesQuery.data.map((
|
|
2182
|
+
/* @__PURE__ */ jsx(SelectTrigger, { className: "w-auto min-w-48", children: /* @__PURE__ */ jsx(SelectValue, {}) }),
|
|
2183
|
+
/* @__PURE__ */ jsx(SelectContent, { children: pipelinesQuery.data.map((pipeline) => /* @__PURE__ */ jsx(SelectItem, { value: pipeline.id, children: pipeline.name }, pipeline.id)) })
|
|
379
2184
|
]
|
|
380
2185
|
}
|
|
381
2186
|
)
|
|
382
|
-
] }) : null
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
/* @__PURE__ */ jsxs(SelectContent, { children: [
|
|
396
|
-
/* @__PURE__ */ jsx(SelectItem, { value: "probability", children: translate("customers.deals.pipeline.sort.probability", "Probability (high to low)") }),
|
|
397
|
-
/* @__PURE__ */ jsx(SelectItem, { value: "createdAt", children: translate("customers.deals.pipeline.sort.createdAt", "Created (newest first)") }),
|
|
398
|
-
/* @__PURE__ */ jsx(SelectItem, { value: "expectedCloseAt", children: translate("customers.deals.pipeline.sort.expectedCloseAt", "Expected close (soonest first)") })
|
|
399
|
-
] })
|
|
400
|
-
] })
|
|
401
|
-
] })
|
|
402
|
-
] })
|
|
403
|
-
] }),
|
|
404
|
-
!selectedPipelineId ? /* @__PURE__ */ jsx(
|
|
405
|
-
EmptyState,
|
|
406
|
-
{
|
|
407
|
-
icon: /* @__PURE__ */ jsx(Workflow, { className: "h-8 w-8", "aria-hidden": "true" }),
|
|
408
|
-
title: translate("customers.deals.pipeline.noPipeline", "No pipeline selected. Create a pipeline in settings."),
|
|
409
|
-
className: "h-[50vh] w-full"
|
|
410
|
-
}
|
|
411
|
-
) : dealsQuery.isLoading ? /* @__PURE__ */ jsx("div", { className: "flex h-[50vh] items-center justify-center", children: /* @__PURE__ */ jsx(Spinner, {}) }) : dealsQuery.isError ? /* @__PURE__ */ jsx("div", { className: "max-w-xl", children: /* @__PURE__ */ jsx(
|
|
412
|
-
ErrorNotice,
|
|
413
|
-
{
|
|
414
|
-
message: dealsQuery.error instanceof Error ? dealsQuery.error.message : translate("customers.deals.pipeline.loadError", "Failed to load deals.")
|
|
415
|
-
}
|
|
416
|
-
) }) : /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3", children: [
|
|
417
|
-
total > deals.length ? /* @__PURE__ */ jsx("div", { className: "rounded-md border border-border bg-muted/30 px-4 py-2 text-sm text-muted-foreground", children: translate(
|
|
418
|
-
"customers.deals.pipeline.limitNotice",
|
|
419
|
-
"Showing the first {count} deals. Refine your filters to see more.",
|
|
420
|
-
{ count: deals.length }
|
|
421
|
-
) }) : null,
|
|
422
|
-
/* @__PURE__ */ jsx("div", { className: "flex flex-col gap-4 pb-6 md:flex-row md:overflow-x-auto", children: stages.length === 0 ? /* @__PURE__ */ jsx(
|
|
2187
|
+
] }) : null
|
|
2188
|
+
] }),
|
|
2189
|
+
/* @__PURE__ */ jsx(ViewTabsRow, { active: "kanban", className: "mt-4" }),
|
|
2190
|
+
/* @__PURE__ */ jsx(
|
|
2191
|
+
FilterBarRow,
|
|
2192
|
+
{
|
|
2193
|
+
leadingChips: leadingChipsNode,
|
|
2194
|
+
chips: filterChips,
|
|
2195
|
+
sortNode,
|
|
2196
|
+
onChipClick: handleChipClick
|
|
2197
|
+
}
|
|
2198
|
+
),
|
|
2199
|
+
!selectedPipelineId ? /* @__PURE__ */ jsx(
|
|
423
2200
|
EmptyState,
|
|
424
2201
|
{
|
|
425
|
-
icon: /* @__PURE__ */ jsx(
|
|
426
|
-
title:
|
|
2202
|
+
icon: /* @__PURE__ */ jsx(Workflow, { className: "size-8", "aria-hidden": "true" }),
|
|
2203
|
+
title: translateWithFallback(
|
|
2204
|
+
t,
|
|
2205
|
+
"customers.deals.pipeline.noPipeline",
|
|
2206
|
+
"No pipeline selected. Create a pipeline in settings."
|
|
2207
|
+
),
|
|
427
2208
|
className: "h-[50vh] w-full"
|
|
428
2209
|
}
|
|
429
|
-
) :
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
2210
|
+
) : isInitialLoading ? /* @__PURE__ */ jsx("div", { className: "flex h-[50vh] items-center justify-center", children: /* @__PURE__ */ jsx(Spinner, {}) }) : firstError ? /* @__PURE__ */ jsx("div", { className: "max-w-xl", children: /* @__PURE__ */ jsx(
|
|
2211
|
+
ErrorNotice,
|
|
2212
|
+
{
|
|
2213
|
+
message: firstError instanceof Error ? firstError.message : translateWithFallback(
|
|
2214
|
+
t,
|
|
2215
|
+
"customers.deals.pipeline.loadError",
|
|
2216
|
+
"Failed to load deals."
|
|
2217
|
+
)
|
|
2218
|
+
}
|
|
2219
|
+
) }) : /* @__PURE__ */ jsxs(
|
|
2220
|
+
DndContext,
|
|
2221
|
+
{
|
|
2222
|
+
sensors,
|
|
2223
|
+
collisionDetection,
|
|
2224
|
+
measuring: measuringConfig,
|
|
2225
|
+
onDragStart: handleDragStart,
|
|
2226
|
+
onDragEnd: handleDragEnd,
|
|
2227
|
+
onDragCancel: handleDragCancel,
|
|
2228
|
+
children: [
|
|
2229
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-stretch", children: [
|
|
2230
|
+
/* @__PURE__ */ jsx("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)]", children: /* @__PURE__ */ jsx(
|
|
2231
|
+
IconButton,
|
|
444
2232
|
{
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
2233
|
+
variant: "outline",
|
|
2234
|
+
size: "lg",
|
|
2235
|
+
fullRadius: true,
|
|
2236
|
+
onClick: handleScrollPrev,
|
|
2237
|
+
onPointerDown: handleHoldPrevStart,
|
|
2238
|
+
onPointerUp: stopContinuousScroll,
|
|
2239
|
+
onPointerLeave: stopContinuousScroll,
|
|
2240
|
+
onPointerCancel: stopContinuousScroll,
|
|
2241
|
+
"aria-label": translateWithFallback(t, "customers.deals.kanban.board.scrollPrev", "Scroll to previous stage"),
|
|
2242
|
+
"aria-disabled": scrollEdges.atStart,
|
|
2243
|
+
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 ${scrollEdges.atStart ? "opacity-30" : ""}`,
|
|
2244
|
+
children: /* @__PURE__ */ jsx(ChevronLeft, { className: "size-5", "aria-hidden": "true" })
|
|
448
2245
|
}
|
|
449
|
-
)
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
children: [
|
|
473
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-start justify-between gap-2", children: [
|
|
474
|
-
/* @__PURE__ */ jsxs("div", { className: "flex flex-col", children: [
|
|
475
|
-
/* @__PURE__ */ jsx("span", { className: "line-clamp-2 text-sm font-medium text-foreground", children: deal.title }),
|
|
476
|
-
deal.status ? /* @__PURE__ */ jsx("span", { className: "text-xs uppercase tracking-wide text-muted-foreground", children: deal.status }) : null
|
|
477
|
-
] }),
|
|
478
|
-
pendingDealId === deal.id && updateStageMutation.isPending ? /* @__PURE__ */ jsx(Spinner, { className: "size-4" }) : null
|
|
479
|
-
] }),
|
|
480
|
-
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1 text-xs text-muted-foreground", children: [
|
|
481
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
482
|
-
/* @__PURE__ */ jsx("span", { children: translate("customers.deals.pipeline.card.value", "Value") }),
|
|
483
|
-
/* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: valueLabel })
|
|
484
|
-
] }),
|
|
485
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
486
|
-
/* @__PURE__ */ jsx("span", { children: translate("customers.deals.pipeline.card.probability", "Probability") }),
|
|
487
|
-
/* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: probabilityLabel })
|
|
488
|
-
] }),
|
|
489
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
490
|
-
/* @__PURE__ */ jsx("span", { children: translate("customers.deals.pipeline.card.expectedClose", "Expected close") }),
|
|
491
|
-
/* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: expectedLabel })
|
|
492
|
-
] })
|
|
493
|
-
] }),
|
|
494
|
-
/* @__PURE__ */ jsx("div", { className: "mt-1 flex flex-wrap gap-2 text-xs", children: /* @__PURE__ */ jsx(
|
|
495
|
-
Link,
|
|
496
|
-
{
|
|
497
|
-
href: `/backend/customers/deals/${deal.id}`,
|
|
498
|
-
className: "font-medium text-primary hover:underline",
|
|
499
|
-
draggable: false,
|
|
500
|
-
onClick: handleActionClick,
|
|
501
|
-
children: translate("customers.deals.pipeline.actions.openDeal", "Open deal")
|
|
502
|
-
}
|
|
503
|
-
) }),
|
|
504
|
-
deal.people.length ? /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2", children: deal.people.map((person) => /* @__PURE__ */ jsx(
|
|
505
|
-
Link,
|
|
506
|
-
{
|
|
507
|
-
className: "rounded-full bg-primary/5 px-3 py-1 text-xs text-primary transition-colors hover:bg-primary/10",
|
|
508
|
-
href: `/backend/customers/people-v2/${person.id}`,
|
|
509
|
-
draggable: false,
|
|
510
|
-
onClick: handleActionClick,
|
|
511
|
-
children: person.label
|
|
512
|
-
},
|
|
513
|
-
person.id
|
|
514
|
-
)) }) : null,
|
|
515
|
-
deal.companies.length ? /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2", children: deal.companies.map((company) => /* @__PURE__ */ jsx(
|
|
516
|
-
Link,
|
|
2246
|
+
) }),
|
|
2247
|
+
/* @__PURE__ */ jsx(
|
|
2248
|
+
"div",
|
|
2249
|
+
{
|
|
2250
|
+
ref: setBoardScroller,
|
|
2251
|
+
"data-kanban-scroller": true,
|
|
2252
|
+
className: `flex min-w-0 flex-1 gap-3.5 overflow-x-auto pb-6 ${activeDragDealId ? "cursor-grabbing select-none" : ""}`,
|
|
2253
|
+
children: lanes.length === 0 ? /* @__PURE__ */ jsx(
|
|
2254
|
+
EmptyState,
|
|
2255
|
+
{
|
|
2256
|
+
icon: /* @__PURE__ */ jsx(Layers, { className: "size-8", "aria-hidden": "true" }),
|
|
2257
|
+
title: translateWithFallback(
|
|
2258
|
+
t,
|
|
2259
|
+
"customers.deals.pipeline.noStages",
|
|
2260
|
+
"Define pipeline stages to start tracking deals."
|
|
2261
|
+
),
|
|
2262
|
+
className: "h-[50vh] w-full"
|
|
2263
|
+
}
|
|
2264
|
+
) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
2265
|
+
lanes.map((stage) => {
|
|
2266
|
+
const laneDeals = sortedDealsByStage.get(stage.id) ?? EMPTY_DEAL_ARRAY;
|
|
2267
|
+
return /* @__PURE__ */ jsx(
|
|
2268
|
+
Lane,
|
|
517
2269
|
{
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
2270
|
+
stage,
|
|
2271
|
+
deals: laneDeals,
|
|
2272
|
+
aggregate: aggregateByStage.get(stage.id) ?? null,
|
|
2273
|
+
selectedDealIds,
|
|
2274
|
+
buildMenuItems,
|
|
2275
|
+
activeDragDealId,
|
|
2276
|
+
isLoadingMore: !!loadingMoreByStage[stage.id],
|
|
2277
|
+
width: laneWidths[stage.id] ?? null,
|
|
2278
|
+
onToggleSelect: handleToggleSelect,
|
|
2279
|
+
onComposeActivity: handleComposeActivity,
|
|
2280
|
+
onOpenDetail: handleOpenDetail,
|
|
2281
|
+
onQuickAddClick: handleQuickAdd,
|
|
2282
|
+
onLoadMore: handleLoadMoreInLane,
|
|
2283
|
+
onResize: handleLaneResize,
|
|
2284
|
+
onResetWidth: handleResetLaneWidth
|
|
523
2285
|
},
|
|
524
|
-
|
|
525
|
-
)
|
|
2286
|
+
stage.id
|
|
2287
|
+
);
|
|
2288
|
+
}),
|
|
2289
|
+
/* @__PURE__ */ jsx(AddStageLane, { onClick: handleAddStage })
|
|
2290
|
+
] })
|
|
2291
|
+
}
|
|
2292
|
+
),
|
|
2293
|
+
/* @__PURE__ */ jsx("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)]", children: /* @__PURE__ */ jsx(
|
|
2294
|
+
IconButton,
|
|
2295
|
+
{
|
|
2296
|
+
variant: "outline",
|
|
2297
|
+
size: "lg",
|
|
2298
|
+
fullRadius: true,
|
|
2299
|
+
onClick: handleScrollNext,
|
|
2300
|
+
onPointerDown: handleHoldNextStart,
|
|
2301
|
+
onPointerUp: stopContinuousScroll,
|
|
2302
|
+
onPointerLeave: stopContinuousScroll,
|
|
2303
|
+
onPointerCancel: stopContinuousScroll,
|
|
2304
|
+
"aria-label": translateWithFallback(t, "customers.deals.kanban.board.scrollNext", "Scroll to next stage"),
|
|
2305
|
+
"aria-disabled": scrollEdges.atEnd,
|
|
2306
|
+
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 ${scrollEdges.atEnd ? "opacity-30" : ""}`,
|
|
2307
|
+
children: /* @__PURE__ */ jsx(ChevronRight, { className: "size-5", "aria-hidden": "true" })
|
|
2308
|
+
}
|
|
2309
|
+
) })
|
|
2310
|
+
] }),
|
|
2311
|
+
/* @__PURE__ */ jsx(DragOverlay, { dropAnimation: null, children: activeDragDeal ? /* @__PURE__ */ jsxs("div", { className: "relative", children: [
|
|
2312
|
+
/* @__PURE__ */ jsx("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`, children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
|
|
2313
|
+
/* @__PURE__ */ jsx("h3", { className: "line-clamp-2 text-base font-semibold leading-normal text-foreground", children: activeDragDeal.title }),
|
|
2314
|
+
typeof activeDragDeal.valueAmount === "number" ? /* @__PURE__ */ jsxs("div", { className: "flex items-baseline gap-1.5", children: [
|
|
2315
|
+
/* @__PURE__ */ jsx("span", { className: "text-lg font-bold leading-normal text-foreground", children: new Intl.NumberFormat(void 0, {
|
|
2316
|
+
style: "decimal",
|
|
2317
|
+
maximumFractionDigits: 0,
|
|
2318
|
+
useGrouping: true
|
|
2319
|
+
}).format(activeDragDeal.valueAmount) }),
|
|
2320
|
+
activeDragDeal.valueCurrency ? /* @__PURE__ */ jsx("span", { className: "text-sm font-semibold leading-normal text-muted-foreground", children: activeDragDeal.valueCurrency.toUpperCase() }) : null
|
|
2321
|
+
] }) : null,
|
|
2322
|
+
activeDragDeal.primaryCompany ? /* @__PURE__ */ jsx("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", children: /* @__PURE__ */ jsx("span", { className: "truncate", children: activeDragDeal.primaryCompany.label }) }) : null
|
|
2323
|
+
] }) }),
|
|
2324
|
+
bulkDragActive ? (
|
|
2325
|
+
// +N badge tells the operator how many cards are travelling with the dragged
|
|
2326
|
+
// one — round-2 UX review item 32. N excludes the dragged card itself, so a
|
|
2327
|
+
// selection of 5 shows "+4" floating above the dragged surface.
|
|
2328
|
+
/* @__PURE__ */ jsxs(
|
|
2329
|
+
"span",
|
|
2330
|
+
{
|
|
2331
|
+
"aria-label": translateWithFallback(
|
|
2332
|
+
t,
|
|
2333
|
+
"customers.deals.kanban.bulk.dragOverlayBadge",
|
|
2334
|
+
"{count} more selected",
|
|
2335
|
+
{ count: selectedDealIds.size - 1 }
|
|
2336
|
+
),
|
|
2337
|
+
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",
|
|
2338
|
+
children: [
|
|
2339
|
+
"+",
|
|
2340
|
+
selectedDealIds.size - 1
|
|
526
2341
|
]
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
2342
|
+
}
|
|
2343
|
+
)
|
|
2344
|
+
) : null
|
|
2345
|
+
] }) : null })
|
|
2346
|
+
]
|
|
2347
|
+
}
|
|
2348
|
+
),
|
|
2349
|
+
selectedPipelineId && !isInitialLoading ? /* @__PURE__ */ jsxs("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", children: [
|
|
2350
|
+
/* @__PURE__ */ jsx("span", { children: footerCount }),
|
|
2351
|
+
/* @__PURE__ */ jsx("span", { children: dragHint }),
|
|
2352
|
+
pendingDealId ? /* @__PURE__ */ jsx(Spinner, { className: "size-3" }) : null
|
|
2353
|
+
] }) : null
|
|
2354
|
+
] }),
|
|
2355
|
+
/* @__PURE__ */ jsx(
|
|
2356
|
+
QuickDealDialog,
|
|
2357
|
+
{
|
|
2358
|
+
open: !!quickDealContext,
|
|
2359
|
+
context: quickDealContext,
|
|
2360
|
+
onClose: () => setQuickDealContext(null),
|
|
2361
|
+
onCreated: handleDialogCreated,
|
|
2362
|
+
currentUserId: currentUserId || void 0,
|
|
2363
|
+
currentUserLabel,
|
|
2364
|
+
companies: companiesQuery.data ?? [],
|
|
2365
|
+
currencies: currencyDictionary.data ? currencyDictionary.data.entries.map((entry) => ({
|
|
2366
|
+
code: entry.value,
|
|
2367
|
+
label: entry.label,
|
|
2368
|
+
isBase: boardSummary?.baseCurrencyCode ? entry.value === boardSummary.baseCurrencyCode : false
|
|
2369
|
+
})) : void 0
|
|
2370
|
+
}
|
|
2371
|
+
),
|
|
2372
|
+
/* @__PURE__ */ jsx(
|
|
2373
|
+
AddStageDialog,
|
|
2374
|
+
{
|
|
2375
|
+
open: !!addStageContext,
|
|
2376
|
+
context: addStageContext,
|
|
2377
|
+
onClose: () => setAddStageContext(null),
|
|
2378
|
+
onCreated: handleDialogCreated
|
|
2379
|
+
}
|
|
2380
|
+
),
|
|
2381
|
+
/* @__PURE__ */ jsx(
|
|
2382
|
+
CustomizeViewDialog,
|
|
2383
|
+
{
|
|
2384
|
+
open: customizeOpen,
|
|
2385
|
+
resizedLanesCount: Object.keys(laneWidths).length,
|
|
2386
|
+
onClose: () => setCustomizeOpen(false),
|
|
2387
|
+
onResetToDefault: handleResetView,
|
|
2388
|
+
onResetColumnWidths: handleResetAllLaneWidths
|
|
2389
|
+
}
|
|
2390
|
+
),
|
|
2391
|
+
/* @__PURE__ */ jsx(
|
|
2392
|
+
ActivityComposerDialog,
|
|
2393
|
+
{
|
|
2394
|
+
open: !!activityContext,
|
|
2395
|
+
context: activityContext,
|
|
2396
|
+
onClose: () => setActivityContext(null),
|
|
2397
|
+
onCreated: handleDialogCreated
|
|
2398
|
+
}
|
|
2399
|
+
),
|
|
2400
|
+
/* @__PURE__ */ jsx(
|
|
2401
|
+
ChangeStageDialog,
|
|
2402
|
+
{
|
|
2403
|
+
open: changeStageOpen,
|
|
2404
|
+
selectedCount: bulkSelectionSummary.count,
|
|
2405
|
+
pipelineName: activePipelineName,
|
|
2406
|
+
stages: stageOptions,
|
|
2407
|
+
isSubmitting: isBulkMutating,
|
|
2408
|
+
onClose: () => setChangeStageOpen(false),
|
|
2409
|
+
onConfirm: (stageId) => void handleBulkChangeStage(stageId)
|
|
2410
|
+
}
|
|
2411
|
+
),
|
|
2412
|
+
/* @__PURE__ */ jsx(
|
|
2413
|
+
ChangeStageDialog,
|
|
2414
|
+
{
|
|
2415
|
+
open: !!singleMoveStageContext,
|
|
2416
|
+
selectedCount: 1,
|
|
2417
|
+
pipelineName: activePipelineName,
|
|
2418
|
+
stages: stageOptions,
|
|
2419
|
+
isSubmitting: false,
|
|
2420
|
+
onClose: () => setSingleMoveStageContext(null),
|
|
2421
|
+
onConfirm: (stageId) => {
|
|
2422
|
+
if (singleMoveStageContext) {
|
|
2423
|
+
moveDealToStage(singleMoveStageContext.dealId, stageId);
|
|
2424
|
+
}
|
|
2425
|
+
setSingleMoveStageContext(null);
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
),
|
|
2429
|
+
/* @__PURE__ */ jsx(
|
|
2430
|
+
ChangeOwnerDialog,
|
|
2431
|
+
{
|
|
2432
|
+
open: changeOwnerOpen,
|
|
2433
|
+
selectedCount: bulkSelectionSummary.count,
|
|
2434
|
+
isSubmitting: isBulkMutating,
|
|
2435
|
+
onClose: () => setChangeOwnerOpen(false),
|
|
2436
|
+
onConfirm: (userId) => void handleBulkChangeOwner(userId)
|
|
2437
|
+
}
|
|
2438
|
+
),
|
|
2439
|
+
/* @__PURE__ */ jsx(
|
|
2440
|
+
BulkActionsBar,
|
|
2441
|
+
{
|
|
2442
|
+
count: bulkSelectionSummary.count,
|
|
2443
|
+
totalLabel: bulkSelectionSummary.totalLabel,
|
|
2444
|
+
onChangeStage: () => setChangeStageOpen(true),
|
|
2445
|
+
onChangeOwner: () => setChangeOwnerOpen(true),
|
|
2446
|
+
onExportCsv: handleBulkExport,
|
|
2447
|
+
onDelete: () => void handleBulkDelete(),
|
|
2448
|
+
onClear: handleBulkClear
|
|
2449
|
+
}
|
|
2450
|
+
),
|
|
2451
|
+
ConfirmDialogElement
|
|
2452
|
+
] });
|
|
538
2453
|
}
|
|
539
2454
|
export {
|
|
540
|
-
|
|
2455
|
+
DealsKanbanPage as default
|
|
541
2456
|
};
|
|
542
2457
|
//# sourceMappingURL=page.js.map
|