@open-mercato/core 0.6.3-develop.3651.1.d3a548a5f1 → 0.6.3-develop.3660.1.53b73d5aa4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/customer_settings/index.js +2 -0
  3. package/dist/generated/entities/customer_settings/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/customers/api/deals/aggregate/route.js +332 -0
  7. package/dist/modules/customers/api/deals/aggregate/route.js.map +7 -0
  8. package/dist/modules/customers/api/deals/bulk-update-owner/route.js +188 -0
  9. package/dist/modules/customers/api/deals/bulk-update-owner/route.js.map +7 -0
  10. package/dist/modules/customers/api/deals/bulk-update-stage/route.js +188 -0
  11. package/dist/modules/customers/api/deals/bulk-update-stage/route.js.map +7 -0
  12. package/dist/modules/customers/api/deals/route.js +255 -122
  13. package/dist/modules/customers/api/deals/route.js.map +2 -2
  14. package/dist/modules/customers/api/settings/stuck-threshold/route.js +169 -0
  15. package/dist/modules/customers/api/settings/stuck-threshold/route.js.map +7 -0
  16. package/dist/modules/customers/backend/config/customers/deals/page.js +181 -0
  17. package/dist/modules/customers/backend/config/customers/deals/page.js.map +7 -0
  18. package/dist/modules/customers/backend/config/customers/deals/page.meta.js +38 -0
  19. package/dist/modules/customers/backend/config/customers/deals/page.meta.js.map +7 -0
  20. package/dist/modules/customers/backend/customers/deals/create/page.js +17 -6
  21. package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
  22. package/dist/modules/customers/backend/customers/deals/page.js +2 -0
  23. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  24. package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js +217 -0
  25. package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js.map +7 -0
  26. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js +335 -0
  27. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js.map +7 -0
  28. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js +29 -0
  29. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js.map +7 -0
  30. package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js +160 -0
  31. package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js.map +7 -0
  32. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js +157 -0
  33. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js.map +7 -0
  34. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js +114 -0
  35. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js.map +7 -0
  36. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js +36 -0
  37. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js.map +7 -0
  38. package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js +199 -0
  39. package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js.map +7 -0
  40. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js +71 -0
  41. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js.map +7 -0
  42. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js +233 -0
  43. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js.map +7 -0
  44. package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js +126 -0
  45. package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js.map +7 -0
  46. package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js +31 -0
  47. package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js.map +7 -0
  48. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js +389 -0
  49. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js.map +7 -0
  50. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js +181 -0
  51. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js.map +7 -0
  52. package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js +199 -0
  53. package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js.map +7 -0
  54. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +44 -0
  55. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +7 -0
  56. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js +70 -0
  57. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js.map +7 -0
  58. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +278 -0
  59. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +7 -0
  60. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js +135 -0
  61. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js.map +7 -0
  62. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js +83 -0
  63. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js.map +7 -0
  64. package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js +155 -0
  65. package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js.map +7 -0
  66. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +318 -0
  67. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +7 -0
  68. package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js +138 -0
  69. package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js.map +7 -0
  70. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +126 -0
  71. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +7 -0
  72. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +71 -0
  73. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +7 -0
  74. package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js +7 -0
  75. package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js.map +7 -0
  76. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +2330 -415
  77. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +3 -3
  78. package/dist/modules/customers/commands/deals.js +6 -0
  79. package/dist/modules/customers/commands/deals.js.map +2 -2
  80. package/dist/modules/customers/commands/pipeline-stages.js +48 -13
  81. package/dist/modules/customers/commands/pipeline-stages.js.map +3 -3
  82. package/dist/modules/customers/commands/settings.js +45 -5
  83. package/dist/modules/customers/commands/settings.js.map +2 -2
  84. package/dist/modules/customers/data/enrichers.js +130 -1
  85. package/dist/modules/customers/data/enrichers.js.map +2 -2
  86. package/dist/modules/customers/data/entities.js +4 -0
  87. package/dist/modules/customers/data/entities.js.map +2 -2
  88. package/dist/modules/customers/data/validators.js +29 -2
  89. package/dist/modules/customers/data/validators.js.map +2 -2
  90. package/dist/modules/customers/lib/bulkDeals.js +150 -0
  91. package/dist/modules/customers/lib/bulkDeals.js.map +7 -0
  92. package/dist/modules/customers/lib/kysely.js +12 -0
  93. package/dist/modules/customers/lib/kysely.js.map +7 -0
  94. package/dist/modules/customers/lib/stuckDeals.js +32 -0
  95. package/dist/modules/customers/lib/stuckDeals.js.map +7 -0
  96. package/dist/modules/customers/migrations/Migration20260513203311_customers.js +13 -0
  97. package/dist/modules/customers/migrations/Migration20260513203311_customers.js.map +7 -0
  98. package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js +52 -0
  99. package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +7 -0
  100. package/dist/modules/customers/workers/deals-bulk-update-owner.js +41 -0
  101. package/dist/modules/customers/workers/deals-bulk-update-owner.js.map +7 -0
  102. package/dist/modules/customers/workers/deals-bulk-update-stage.js +41 -0
  103. package/dist/modules/customers/workers/deals-bulk-update-stage.js.map +7 -0
  104. package/generated/entities/customer_settings/index.ts +1 -0
  105. package/generated/entity-fields-registry.ts +1 -0
  106. package/package.json +7 -7
  107. package/src/modules/customers/api/deals/aggregate/route.ts +438 -0
  108. package/src/modules/customers/api/deals/bulk-update-owner/route.ts +216 -0
  109. package/src/modules/customers/api/deals/bulk-update-stage/route.ts +228 -0
  110. package/src/modules/customers/api/deals/route.ts +330 -135
  111. package/src/modules/customers/api/settings/stuck-threshold/route.ts +195 -0
  112. package/src/modules/customers/backend/config/customers/deals/page.meta.ts +36 -0
  113. package/src/modules/customers/backend/config/customers/deals/page.tsx +205 -0
  114. package/src/modules/customers/backend/customers/deals/create/page.tsx +25 -6
  115. package/src/modules/customers/backend/customers/deals/page.tsx +2 -0
  116. package/src/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.tsx +277 -0
  117. package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.tsx +425 -0
  118. package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.tsx +31 -0
  119. package/src/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.tsx +171 -0
  120. package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.tsx +196 -0
  121. package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.tsx +138 -0
  122. package/src/modules/customers/backend/customers/deals/pipeline/components/ChipButton.tsx +47 -0
  123. package/src/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.tsx +232 -0
  124. package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.tsx +110 -0
  125. package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.tsx +310 -0
  126. package/src/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.tsx +169 -0
  127. package/src/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.tsx +54 -0
  128. package/src/modules/customers/backend/customers/deals/pipeline/components/DealCard.tsx +562 -0
  129. package/src/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.tsx +214 -0
  130. package/src/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.tsx +257 -0
  131. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +62 -0
  132. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.tsx +101 -0
  133. package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +378 -0
  134. package/src/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.tsx +192 -0
  135. package/src/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.tsx +107 -0
  136. package/src/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.tsx +196 -0
  137. package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +408 -0
  138. package/src/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.tsx +164 -0
  139. package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +171 -0
  140. package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +84 -0
  141. package/src/modules/customers/backend/customers/deals/pipeline/components/constants.ts +16 -0
  142. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2841 -550
  143. package/src/modules/customers/commands/deals.ts +19 -0
  144. package/src/modules/customers/commands/pipeline-stages.ts +68 -13
  145. package/src/modules/customers/commands/settings.ts +64 -5
  146. package/src/modules/customers/data/enrichers.ts +209 -2
  147. package/src/modules/customers/data/entities.ts +4 -1
  148. package/src/modules/customers/data/validators.ts +36 -2
  149. package/src/modules/customers/i18n/de.json +244 -0
  150. package/src/modules/customers/i18n/en.json +244 -0
  151. package/src/modules/customers/i18n/es.json +244 -0
  152. package/src/modules/customers/i18n/pl.json +244 -0
  153. package/src/modules/customers/lib/bulkDeals.ts +231 -0
  154. package/src/modules/customers/lib/kysely.ts +60 -0
  155. package/src/modules/customers/lib/stuckDeals.ts +88 -0
  156. package/src/modules/customers/migrations/.snapshot-open-mercato.json +16 -0
  157. package/src/modules/customers/migrations/Migration20260513203311_customers.ts +13 -0
  158. package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +71 -0
  159. package/src/modules/customers/workers/deals-bulk-update-owner.ts +46 -0
  160. 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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
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 { Layers, MoveRight, Workflow } from "lucide-react";
15
- import { Spinner } from "@open-mercato/ui/primitives/spinner";
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
- const DEALS_QUERY_LIMIT = 100;
24
- const dealsQueryKey = (scopeVersion, pipelineId) => ["customers", "deals", "pipeline", `scope:${scopeVersion}`, `pipeline:${pipelineId ?? "none"}`];
25
- const sortOptions = ["probability", "createdAt", "expectedCloseAt"];
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 trimmed = value.trim();
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
- if (parsed < 0) return 0;
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 normalizeTimestamp(value) {
44
- if (typeof value !== "string" || !value.trim().length) return { iso: null, ts: null };
130
+ function normalizeIso(value) {
131
+ if (typeof value !== "string" || !value.trim().length) return null;
45
132
  const date = new Date(value);
46
- if (Number.isNaN(date.getTime())) return { iso: null, ts: null };
47
- return { iso: date.toISOString(), ts: date.getTime() };
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 buildStageDefinitionsFromPipelineStages(pipelineStages, deals, t) {
50
- const result = pipelineStages.slice().sort((a, b) => a.order - b.order).map((stage) => ({
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
- color: null,
55
- icon: null
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
- result.push({
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 result;
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 byStage = /* @__PURE__ */ new Map();
75
- deals.forEach((deal) => {
76
- const stageKey = deal.pipelineStageId ?? null;
77
- const bucket = byStage.get(stageKey) ?? [];
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
- byStage.set(stageKey, bucket);
80
- });
81
- return byStage;
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 = [...deals];
235
+ const sorted = deals.slice();
85
236
  sorted.sort((a, b) => {
86
- if (option === "probability") {
87
- const ap = typeof a.probability === "number" ? a.probability : -1;
88
- const bp = typeof b.probability === "number" ? b.probability : -1;
89
- if (ap !== bp) return bp - ap;
90
- }
91
- if (option === "expectedCloseAt") {
92
- const at2 = typeof a.expectedCloseAtTs === "number" ? a.expectedCloseAtTs : Number.POSITIVE_INFINITY;
93
- const bt2 = typeof b.expectedCloseAtTs === "number" ? b.expectedCloseAtTs : Number.POSITIVE_INFINITY;
94
- if (at2 !== bt2) return at2 - bt2;
95
- }
96
- const at = typeof a.createdAtTs === "number" ? a.createdAtTs : Number.NEGATIVE_INFINITY;
97
- const bt = typeof b.createdAtTs === "number" ? b.createdAtTs : Number.NEGATIVE_INFINITY;
98
- if (option === "createdAt") {
99
- if (at !== bt) return bt - at;
100
- } else if (option === "expectedCloseAt" || option === "probability") {
101
- if (at !== bt) return bt - at;
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 formatCurrency(amount, currency, fallback) {
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 translate = React.useCallback(
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
- { errorMessage: translate("customers.deals.pipeline.loadError", "Failed to load pipelines.") }
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((p) => p.isDefault) ?? pipelines[0];
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
- { errorMessage: translate("customers.deals.pipeline.loadError", "Failed to load stages.") }
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 dealsKey = React.useMemo(() => dealsQueryKey(scopeVersion, selectedPipelineId), [scopeVersion, selectedPipelineId]);
182
- const dealsQuery = useQuery({
183
- queryKey: dealsKey,
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
- const search = new URLSearchParams();
188
- search.set("page", "1");
189
- search.set("pageSize", String(DEALS_QUERY_LIMIT));
190
- search.set("sortField", "createdAt");
191
- search.set("sortDir", "desc");
192
- if (selectedPipelineId) search.set("pipelineId", selectedPipelineId);
193
- const payload = await readApiResultOrThrow(
194
- `/api/customers/deals?${search.toString()}`,
195
- void 0,
196
- { errorMessage: translate("customers.deals.pipeline.loadError", "Failed to load deals.") }
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
- const items = Array.isArray(payload?.items) ? payload.items : [];
199
- const deals2 = [];
200
- items.forEach((item) => {
201
- if (!item || typeof item !== "object") return;
202
- const data = item;
203
- const id = typeof data.id === "string" ? data.id : null;
204
- if (!id) return;
205
- const title = typeof data.title === "string" && data.title.trim().length ? data.title.trim() : translate("customers.deals.pipeline.untitled", "Untitled deal");
206
- const status = typeof data.status === "string" && data.status.trim().length ? data.status.trim() : null;
207
- const stage = typeof data.pipeline_stage === "string" && data.pipeline_stage.trim().length ? data.pipeline_stage.trim() : null;
208
- const amount = normalizeAmount(data.value_amount);
209
- const currency = typeof data.value_currency === "string" && data.value_currency.trim().length ? data.value_currency.trim().toUpperCase() : null;
210
- const probability = normalizeProbability(data.probability);
211
- const expected = normalizeTimestamp(data.expected_close_at);
212
- const created = normalizeTimestamp(data.created_at);
213
- const updated = normalizeTimestamp(data.updated_at);
214
- const rawPeople = Array.isArray(data.people) ? data.people : [];
215
- const people = rawPeople.map((entry) => {
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 deals = dealsQuery.data?.deals ?? [];
256
- const total = dealsQuery.data?.total ?? deals.length;
257
- const dealMap = React.useMemo(() => createDealMap(deals), [deals]);
258
- const groupedDeals = React.useMemo(() => groupDealsByStageId(deals), [deals]);
259
- const stages = React.useMemo(
260
- () => buildStageDefinitionsFromPipelineStages(stagesQuery.data ?? [], deals, t),
261
- [stagesQuery.data, deals, t]
262
- );
263
- const dateFormatter = React.useMemo(
264
- () => new Intl.DateTimeFormat(void 0, {
265
- dateStyle: "medium"
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 updateStageMutation = useMutation({
270
- mutationFn: async ({ id, pipelineStageId }) => {
271
- await apiCallOrThrow(
272
- "/api/customers/deals",
273
- {
274
- method: "PUT",
275
- headers: { "content-type": "application/json" },
276
- body: JSON.stringify({ id, pipelineStageId })
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
- { errorMessage: translate("customers.deals.pipeline.moveError", "Failed to update deal stage.") }
279
- );
280
- return { id, pipelineStageId };
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
- onMutate: async ({ id, pipelineStageId }) => {
283
- setPendingDealId(id);
284
- await queryClient.cancelQueries({ queryKey: dealsKey });
285
- const previous = queryClient.getQueryData(dealsKey);
286
- if (previous) {
287
- const nextDeals = previous.deals.map(
288
- (deal) => deal.id === id ? { ...deal, pipelineStageId } : deal
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
- queryClient.setQueryData(dealsKey, { ...previous, deals: nextDeals });
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
- onError: (error, _variables, context) => {
295
- if (context?.previous) {
296
- queryClient.setQueryData(dealsKey, context.previous);
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 message = error instanceof Error && error.message ? error.message : translate("customers.deals.pipeline.moveError", "Failed to update deal stage.");
299
- flash(message, "error");
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
- onSuccess: () => {
302
- flash(translate("customers.deals.pipeline.moveSuccess", "Deal updated."), "success");
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
- onSettled: () => {
305
- setPendingDealId(null);
306
- queryClient.invalidateQueries({ queryKey: dealsKey }).catch(() => {
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 handleDragStart = React.useCallback((dealId) => {
319
- setDraggingId(dealId);
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 handleDragEnd = React.useCallback(() => {
322
- setDraggingId(null);
323
- setActiveLane(null);
1227
+ const handleDragCancel = React.useCallback(() => {
1228
+ setActiveDragDealId(null);
324
1229
  }, []);
325
- const handleDrop = React.useCallback(
326
- (stage) => async (event) => {
327
- event.preventDefault();
328
- setActiveLane(null);
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 deal = dealMap.get(dealId);
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
- if (stage.value === null) {
1882
+ const entityId = deal.primaryCompany?.id;
1883
+ if (!entityId) {
334
1884
  flash(
335
- translate("customers.deals.pipeline.unassignedDisabled", 'Moving to "No stage" is not supported.'),
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
- if (deal.pipelineStageId === stage.value) return;
341
- updateStageMutation.mutate({ id: dealId, pipelineStageId: stage.value });
1894
+ setActivityContext({
1895
+ dealId,
1896
+ dealTitle: deal.title,
1897
+ type,
1898
+ entityId
1899
+ });
342
1900
  },
343
- [dealMap, draggingId, translate, updateStageMutation]
1901
+ [deals, t]
344
1902
  );
345
- const handleDragOver = React.useCallback(
346
- (stageId) => (event) => {
347
- event.preventDefault();
348
- event.dataTransfer.dropEffect = "move";
349
- if (activeLane !== stageId) setActiveLane(stageId);
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
- [activeLane]
1922
+ []
352
1923
  );
353
- const renderLaneHeader = (stage, count) => {
354
- return /* @__PURE__ */ jsx("div", { className: "flex items-center justify-between gap-3 border-b border-border px-4 py-3", children: /* @__PURE__ */ jsx("div", { className: "flex items-center gap-2", children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col", children: [
355
- /* @__PURE__ */ jsx("span", { className: "text-sm font-medium", children: stage.label }),
356
- /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: translate("customers.deals.pipeline.countLabel", "Deals: {count}", { count }) })
357
- ] }) }) });
358
- };
359
- return /* @__PURE__ */ jsx(Page, { children: /* @__PURE__ */ jsx(PageBody, { children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-4", children: [
360
- /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between", children: [
361
- /* @__PURE__ */ jsxs("div", { className: "flex flex-col", children: [
362
- /* @__PURE__ */ jsx("h1", { className: "text-xl font-semibold text-foreground", children: translate("customers.deals.pipeline.title", "Sales Pipeline") }),
363
- /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: translate(
364
- "customers.deals.pipeline.subtitle",
365
- "Track deals by pipeline stage and drag them between lanes to update progress."
366
- ) })
367
- ] }),
368
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4", children: [
369
- pipelinesQuery.data && pipelinesQuery.data.length > 0 ? /* @__PURE__ */ jsxs("label", { className: "flex items-center gap-2 text-sm font-medium text-muted-foreground", children: [
370
- /* @__PURE__ */ jsx("span", { children: translate("customers.deals.pipeline.switch.label", "Pipeline") }),
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 || void 0,
2179
+ value: selectedPipelineId ?? void 0,
375
2180
  onValueChange: (value) => setSelectedPipelineId(value || null),
376
2181
  children: [
377
- /* @__PURE__ */ jsx(SelectTrigger, { className: "w-auto min-w-[12rem]", children: /* @__PURE__ */ jsx(SelectValue, {}) }),
378
- /* @__PURE__ */ jsx(SelectContent, { children: pipelinesQuery.data.map((p) => /* @__PURE__ */ jsx(SelectItem, { value: p.id, children: p.name }, p.id)) })
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
- /* @__PURE__ */ jsx(
384
- Link,
385
- {
386
- href: "/backend/config/customers/pipeline-stages",
387
- className: "text-sm font-medium text-primary hover:underline",
388
- children: translate("customers.deals.pipeline.manageStages", "Manage stages")
389
- }
390
- ),
391
- /* @__PURE__ */ jsxs("label", { className: "flex items-center gap-2 text-sm font-medium text-muted-foreground", children: [
392
- /* @__PURE__ */ jsx("span", { children: translate("customers.deals.pipeline.sort.label", "Sort by") }),
393
- /* @__PURE__ */ jsxs(Select, { value: sortBy, onValueChange: handleSortChange, children: [
394
- /* @__PURE__ */ jsx(SelectTrigger, { className: "w-auto min-w-[14rem]", children: /* @__PURE__ */ jsx(SelectValue, {}) }),
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(Layers, { className: "h-8 w-8", "aria-hidden": "true" }),
426
- title: translate("customers.deals.pipeline.noStages", "Define pipeline stages to start tracking deals."),
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
- ) : stages.map((stage) => {
430
- const stageKey = stage.value ?? null;
431
- const laneDeals = groupedDeals.get(stageKey) ?? [];
432
- const sortedLaneDeals = sortDeals(laneDeals, sortBy);
433
- const isActive = activeLane === stage.id;
434
- return /* @__PURE__ */ jsxs(
435
- "div",
436
- {
437
- className: `flex min-h-[60vh] w-full flex-1 flex-col overflow-hidden rounded-lg border border-border bg-card shadow-sm transition-all md:w-72 md:flex-none ${isActive ? "ring-2 ring-ring/40" : ""}`,
438
- onDragOver: handleDragOver(stage.id),
439
- onDrop: handleDrop(stage),
440
- children: [
441
- renderLaneHeader(stage, laneDeals.length),
442
- /* @__PURE__ */ jsx("div", { className: "flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-3", children: sortedLaneDeals.length === 0 ? /* @__PURE__ */ jsx(
443
- EmptyState,
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
- size: "sm",
446
- icon: /* @__PURE__ */ jsx(MoveRight, { className: "h-6 w-6", "aria-hidden": "true" }),
447
- title: translate("customers.deals.pipeline.emptyLane", "No deals in this stage yet.")
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
- ) : sortedLaneDeals.map((deal) => {
450
- const isDragging = draggingId === deal.id || pendingDealId === deal.id && updateStageMutation.isPending;
451
- const valueLabel = formatCurrency(
452
- deal.valueAmount,
453
- deal.valueCurrency,
454
- translate("customers.deals.list.noValue", "No value assigned")
455
- );
456
- const probabilityLabel = formatProbability(
457
- deal.probability,
458
- translate("customers.deals.pipeline.noProbability", "N/A")
459
- );
460
- const expectedLabel = deal.expectedCloseAt ? dateFormatter.format(new Date(deal.expectedCloseAt)) : translate("customers.deals.pipeline.noExpectedClose", "No date");
461
- return /* @__PURE__ */ jsxs(
462
- "div",
463
- {
464
- className: `group flex cursor-grab flex-col gap-2 rounded-md border border-border bg-background p-4 shadow-xs transition ${isDragging ? "opacity-50" : "hover:shadow-sm"}`,
465
- draggable: true,
466
- onDragStart: (event) => {
467
- event.dataTransfer.effectAllowed = "move";
468
- event.dataTransfer.setData("text/plain", deal.id);
469
- handleDragStart(deal.id);
470
- },
471
- onDragEnd: handleDragEnd,
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
- className: "rounded-full bg-secondary/10 px-3 py-1 text-xs text-secondary-foreground transition-colors hover:bg-secondary/20",
519
- href: `/backend/customers/companies-v2/${company.id}`,
520
- draggable: false,
521
- onClick: handleActionClick,
522
- children: company.label
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
- company.id
525
- )) }) : null
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
- deal.id
529
- );
530
- }) })
531
- ]
532
- },
533
- stage.id
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
- SalesPipelinePage as default
2455
+ DealsKanbanPage as default
541
2456
  };
542
2457
  //# sourceMappingURL=page.js.map