@kelpie/ui 0.9.0 → 0.11.0

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 (104) hide show
  1. package/dist/api/resources/activities.d.ts +2 -1
  2. package/dist/api/resources/activities.d.ts.map +1 -1
  3. package/dist/api/resources/activities.js +6 -2
  4. package/dist/api/resources/activities.js.map +1 -1
  5. package/dist/api/resources/conversions.d.ts +16 -0
  6. package/dist/api/resources/conversions.d.ts.map +1 -0
  7. package/dist/api/resources/conversions.js +81 -0
  8. package/dist/api/resources/conversions.js.map +1 -0
  9. package/dist/api/resources/enquiries.d.ts +2 -7
  10. package/dist/api/resources/enquiries.d.ts.map +1 -1
  11. package/dist/api/resources/enquiries.js +2 -30
  12. package/dist/api/resources/enquiries.js.map +1 -1
  13. package/dist/api/resources/importJobs.d.ts +2 -2
  14. package/dist/api/resources/importJobs.d.ts.map +1 -1
  15. package/dist/api/resources/importJobs.js.map +1 -1
  16. package/dist/app/KelpieApp.js +1 -1
  17. package/dist/app/KelpieApp.js.map +1 -1
  18. package/dist/components/ActivitiesPanel.d.ts +2 -2
  19. package/dist/components/ActivitiesPanel.d.ts.map +1 -1
  20. package/dist/components/ActivitiesPanel.js +7 -5
  21. package/dist/components/ActivitiesPanel.js.map +1 -1
  22. package/dist/components/ApiKeysPanel.d.ts +0 -7
  23. package/dist/components/ApiKeysPanel.d.ts.map +1 -1
  24. package/dist/components/ApiKeysPanel.js +83 -16
  25. package/dist/components/ApiKeysPanel.js.map +1 -1
  26. package/dist/components/ConvertRecordDialog.d.ts +30 -0
  27. package/dist/components/ConvertRecordDialog.d.ts.map +1 -0
  28. package/dist/components/ConvertRecordDialog.js +50 -0
  29. package/dist/components/ConvertRecordDialog.js.map +1 -0
  30. package/dist/components/Shell.d.ts.map +1 -1
  31. package/dist/components/Shell.js +19 -7
  32. package/dist/components/Shell.js.map +1 -1
  33. package/dist/pages/DealDetail.d.ts.map +1 -1
  34. package/dist/pages/DealDetail.js +15 -2
  35. package/dist/pages/DealDetail.js.map +1 -1
  36. package/dist/pages/DecisionsPage.d.ts.map +1 -1
  37. package/dist/pages/DecisionsPage.js +1 -7
  38. package/dist/pages/DecisionsPage.js.map +1 -1
  39. package/dist/pages/EnquiryDetail.d.ts.map +1 -1
  40. package/dist/pages/EnquiryDetail.js +17 -25
  41. package/dist/pages/EnquiryDetail.js.map +1 -1
  42. package/dist/pages/ListDetail.d.ts.map +1 -1
  43. package/dist/pages/ListDetail.js +1 -14
  44. package/dist/pages/ListDetail.js.map +1 -1
  45. package/dist/pages/OpportunityDetail.d.ts.map +1 -1
  46. package/dist/pages/OpportunityDetail.js +15 -2
  47. package/dist/pages/OpportunityDetail.js.map +1 -1
  48. package/dist/pages/PartnershipDetail.d.ts.map +1 -1
  49. package/dist/pages/PartnershipDetail.js +15 -2
  50. package/dist/pages/PartnershipDetail.js.map +1 -1
  51. package/dist/pages/RaiseDetail.d.ts.map +1 -1
  52. package/dist/pages/RaiseDetail.js +15 -2
  53. package/dist/pages/RaiseDetail.js.map +1 -1
  54. package/dist/pages/admin/DataPage.d.ts.map +1 -1
  55. package/dist/pages/admin/DataPage.js +3 -3
  56. package/dist/pages/admin/DataPage.js.map +1 -1
  57. package/dist/pages/admin/PrivacyPage.js +3 -3
  58. package/dist/pages/admin/PrivacyPage.js.map +1 -1
  59. package/dist/pages/admin/TeamPage.js +1 -1
  60. package/dist/pages/admin/TeamPage.js.map +1 -1
  61. package/dist/pages/admin/WorkspaceSettingsPage.js +2 -12
  62. package/dist/pages/admin/WorkspaceSettingsPage.js.map +1 -1
  63. package/dist/pages/forms/AddFieldMenu.js +2 -2
  64. package/dist/pages/forms/AddFieldMenu.js.map +1 -1
  65. package/dist/pages/forms/FieldsEditor.d.ts.map +1 -1
  66. package/dist/pages/forms/FieldsEditor.js +22 -10
  67. package/dist/pages/forms/FieldsEditor.js.map +1 -1
  68. package/dist/pages/forms/MapTargetSearch.d.ts +7 -0
  69. package/dist/pages/forms/MapTargetSearch.d.ts.map +1 -0
  70. package/dist/pages/forms/MapTargetSearch.js +32 -0
  71. package/dist/pages/forms/MapTargetSearch.js.map +1 -0
  72. package/dist/pages/forms/fieldList.d.ts +6 -7
  73. package/dist/pages/forms/fieldList.d.ts.map +1 -1
  74. package/dist/pages/forms/fieldList.js +14 -16
  75. package/dist/pages/forms/fieldList.js.map +1 -1
  76. package/dist/pages/forms/template.d.ts.map +1 -1
  77. package/dist/pages/forms/template.js +7 -0
  78. package/dist/pages/forms/template.js.map +1 -1
  79. package/package.json +2 -2
  80. package/src/api/resources/activities.ts +10 -2
  81. package/src/api/resources/conversions.ts +125 -0
  82. package/src/api/resources/enquiries.ts +9 -35
  83. package/src/api/resources/importJobs.ts +3 -2
  84. package/src/app/KelpieApp.tsx +1 -0
  85. package/src/components/ActivitiesPanel.tsx +20 -14
  86. package/src/components/ApiKeysPanel.tsx +206 -21
  87. package/src/components/ConvertRecordDialog.tsx +210 -0
  88. package/src/components/Shell.tsx +91 -36
  89. package/src/pages/DealDetail.tsx +35 -0
  90. package/src/pages/DecisionsPage.tsx +0 -14
  91. package/src/pages/EnquiryDetail.tsx +37 -90
  92. package/src/pages/ListDetail.tsx +0 -47
  93. package/src/pages/OpportunityDetail.tsx +35 -0
  94. package/src/pages/PartnershipDetail.tsx +35 -0
  95. package/src/pages/RaiseDetail.tsx +35 -0
  96. package/src/pages/admin/DataPage.tsx +14 -9
  97. package/src/pages/admin/PrivacyPage.tsx +4 -4
  98. package/src/pages/admin/TeamPage.tsx +1 -2
  99. package/src/pages/admin/WorkspaceSettingsPage.tsx +1 -31
  100. package/src/pages/forms/AddFieldMenu.tsx +2 -2
  101. package/src/pages/forms/FieldsEditor.tsx +45 -20
  102. package/src/pages/forms/MapTargetSearch.tsx +69 -0
  103. package/src/pages/forms/fieldList.ts +34 -16
  104. package/src/pages/forms/template.ts +7 -0
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
- import { NavLink, Outlet, useLocation, useNavigate } from 'react-router'
2
+ import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router'
3
3
 
4
4
  import { useAccount, useTheme } from '../api/resources/account.ts'
5
5
  import { useLogOut } from '../api/resources/session.ts'
@@ -49,11 +49,21 @@ const CORE_NAV: readonly NavItem[] = [
49
49
  ]
50
50
 
51
51
  /**
52
- * `nav.primary` is one flat slot (`modules.md`); the mockup renders it as two
53
- * visual groups, an unheaded top section and a headed "CRM" section. This is
54
- * presentation only, so it stays here rather than becoming a second slot.
52
+ * `nav.primary` is one flat slot (`modules.md`); the shell renders it as visual
53
+ * groups: an unheaded top section, an unheaded directory pair (People,
54
+ * Companies), a headed "CRM" section for pipeline objects, then a headed
55
+ * "Tools" section for Lists and Forms. Presentation only — it stays here rather
56
+ * than becoming extra slots.
55
57
  */
56
58
  const TOP_LEVEL_NAV_IDS: ReadonlySet<string> = new Set(['dashboard', 'handbook', 'planning', 'decisions'])
59
+ const DIRECTORY_NAV_IDS: ReadonlySet<string> = new Set(['people', 'companies'])
60
+ const TOOLS_NAV_IDS: ReadonlySet<string> = new Set(['lists', 'forms'])
61
+
62
+ const ADMIN_ENTRY_PATH = '/admin/workspace'
63
+
64
+ function isAdminRoute(pathname: string): boolean {
65
+ return pathname.startsWith('/admin/')
66
+ }
57
67
 
58
68
  function linkClass({ isActive }: { isActive: boolean }): string {
59
69
  return [
@@ -118,8 +128,14 @@ export function Shell(): React.JSX.Element {
118
128
 
119
129
  const navItems = useVisibleNavItems(CORE_NAV, moduleNav)
120
130
  const topLevelItems = navItems.filter((item) => TOP_LEVEL_NAV_IDS.has(item.id))
121
- const crmItems = navItems.filter((item) => !TOP_LEVEL_NAV_IDS.has(item.id))
131
+ const nonTopLevelItems = navItems.filter((item) => !TOP_LEVEL_NAV_IDS.has(item.id))
132
+ const directoryItems = nonTopLevelItems.filter((item) => DIRECTORY_NAV_IDS.has(item.id))
133
+ const toolsItems = nonTopLevelItems.filter((item) => TOOLS_NAV_IDS.has(item.id))
134
+ const crmItems = nonTopLevelItems.filter(
135
+ (item) => !DIRECTORY_NAV_IDS.has(item.id) && !TOOLS_NAV_IDS.has(item.id),
136
+ )
122
137
  const adminItems = useVisibleNavItems(CORE_ADMIN_NAV, moduleAdminNav)
138
+ const inAdminNav = isAdminRoute(location.pathname)
123
139
 
124
140
  return (
125
141
  <div className="flex min-h-screen bg-surface">
@@ -133,40 +149,79 @@ export function Shell(): React.JSX.Element {
133
149
  </div>
134
150
 
135
151
  <nav className="flex flex-1 flex-col gap-5 overflow-y-auto px-2 pb-4">
136
- <div className="flex flex-col gap-0.5">
137
- {topLevelItems.map((item) => (
138
- <NavLink key={item.id} to={item.to} className={linkClass}>
139
- {item.label}
140
- </NavLink>
141
- ))}
142
- </div>
152
+ {inAdminNav ? (
153
+ <>
154
+ <Link
155
+ to="/dashboard"
156
+ className="inline-flex px-2 text-[12px] font-medium text-sidebar-muted transition-colors hover:text-sidebar-ink"
157
+ >
158
+ ← Back
159
+ </Link>
143
160
 
144
- <div>
145
- <div className="mb-1 px-2 text-[11px] text-sidebar-muted">CRM</div>
146
- <div className="flex flex-col gap-0.5">
147
- {crmItems.map((item) => (
148
- <NavLink key={item.id} to={item.to} className={linkClass}>
149
- {item.label}
150
- </NavLink>
151
- ))}
152
- </div>
153
- </div>
161
+ <div>
162
+ <div className="mb-1 px-2 text-[11px] text-sidebar-muted">Admin</div>
163
+ <div className="flex flex-col gap-0.5">
164
+ {adminItems.map((item) => (
165
+ <NavLink key={item.id} to={item.to} className={linkClass}>
166
+ {item.label}
167
+ </NavLink>
168
+ ))}
169
+ </div>
170
+ </div>
171
+ </>
172
+ ) : (
173
+ <>
174
+ <div className="flex flex-col gap-0.5">
175
+ {topLevelItems.map((item) => (
176
+ <NavLink key={item.id} to={item.to} className={linkClass}>
177
+ {item.label}
178
+ </NavLink>
179
+ ))}
180
+ </div>
154
181
 
155
- {/*
156
- Shown to every member. Both pages read for anyone in the workspace
157
- and only their controls are admin-gated, so hiding the section would
158
- keep a member from seeing who else is on their team.
159
- */}
160
- <div>
161
- <div className="mb-1 px-2 text-[11px] text-sidebar-muted">Admin</div>
162
- <div className="flex flex-col gap-0.5">
163
- {adminItems.map((item) => (
164
- <NavLink key={item.id} to={item.to} className={linkClass}>
165
- {item.label}
182
+ {directoryItems.length > 0 && (
183
+ <div className="flex flex-col gap-0.5">
184
+ {directoryItems.map((item) => (
185
+ <NavLink key={item.id} to={item.to} className={linkClass}>
186
+ {item.label}
187
+ </NavLink>
188
+ ))}
189
+ </div>
190
+ )}
191
+
192
+ {crmItems.length > 0 && (
193
+ <div>
194
+ <div className="mb-1 px-2 text-[11px] text-sidebar-muted">CRM</div>
195
+ <div className="flex flex-col gap-0.5">
196
+ {crmItems.map((item) => (
197
+ <NavLink key={item.id} to={item.to} className={linkClass}>
198
+ {item.label}
199
+ </NavLink>
200
+ ))}
201
+ </div>
202
+ </div>
203
+ )}
204
+
205
+ {toolsItems.length > 0 && (
206
+ <div>
207
+ <div className="mb-1 px-2 text-[11px] text-sidebar-muted">Tools</div>
208
+ <div className="flex flex-col gap-0.5">
209
+ {toolsItems.map((item) => (
210
+ <NavLink key={item.id} to={item.to} className={linkClass}>
211
+ {item.label}
212
+ </NavLink>
213
+ ))}
214
+ </div>
215
+ </div>
216
+ )}
217
+
218
+ <div className="flex flex-col gap-0.5">
219
+ <NavLink to={ADMIN_ENTRY_PATH} className={linkClass}>
220
+ Admin
166
221
  </NavLink>
167
- ))}
168
- </div>
169
- </div>
222
+ </div>
223
+ </>
224
+ )}
170
225
  </nav>
171
226
  </aside>
172
227
 
@@ -6,6 +6,10 @@ import { Link, useNavigate, useParams } from 'react-router'
6
6
  import { usePatch } from '../api/resource.ts'
7
7
  import type { PatchResult } from '../api/resource.ts'
8
8
  import { useCompanies, useCompany } from '../api/resources/companies.ts'
9
+ import {
10
+ detailPathForPipelineKind,
11
+ useConvertPipelineRecord,
12
+ } from '../api/resources/conversions.ts'
9
13
  import { useDeal, useDeleteDeal, useUpdateDeal } from '../api/resources/deals.ts'
10
14
  import { useFormSubmissionsForRecord } from '../api/resources/forms.ts'
11
15
  import { useMembers } from '../api/resources/members.ts'
@@ -16,6 +20,7 @@ import { ActivitiesPanel, LatestActivity } from '../components/ActivitiesPanel.t
16
20
  import { AgentTasks } from '../components/AgentTasks.tsx'
17
21
  import { Chip } from '../components/Chip.tsx'
18
22
  import type { ChipTone } from '../components/Chip.tsx'
23
+ import { ConvertRecordAction, ConvertRecordButton } from '../components/ConvertRecordDialog.tsx'
19
24
  import { DecisionsPanel } from '../components/DecisionsPanel.tsx'
20
25
  import { DeleteRecord } from '../components/DeleteRecord.tsx'
21
26
  import { EntitySearch } from '../components/EntitySearch.tsx'
@@ -56,10 +61,12 @@ export function DealDetail(): React.JSX.Element {
56
61
  const { id } = useParams()
57
62
  const navigate = useNavigate()
58
63
  const { record, isLoading, isNotFound, error } = useDeal(id)
64
+ const convertRecord = useConvertPipelineRecord('deal')
59
65
  const deleteDeal = useDeleteDeal()
60
66
  const moduleTabs = inSlotOrder(useRecordTabs('deal'))
61
67
  const hasCustomFields = useHasCustomFields('deal')
62
68
  const [activeTab, setActiveTab] = useState('overview')
69
+ const [showConvert, setShowConvert] = useState(false)
63
70
  const formSubmissions = useFormSubmissionsForRecord('deal', id)
64
71
 
65
72
  if (isNotFound) {
@@ -89,6 +96,7 @@ export function DealDetail(): React.JSX.Element {
89
96
  ]
90
97
  const active = tabs.some((tab) => tab.id === activeTab) ? activeTab : 'overview'
91
98
  const moduleTab = moduleTabs.find((tab) => tab.id === active)
99
+ const convertedTo = record.convertedTo
92
100
 
93
101
  return (
94
102
  <div className="animate-fade-in mx-auto max-w-6xl">
@@ -104,6 +112,13 @@ export function DealDetail(): React.JSX.Element {
104
112
  <DealHeading deal={record} />
105
113
 
106
114
  <div className="flex justify-end gap-2">
115
+ <ConvertRecordButton
116
+ convertedTo={convertedTo}
117
+ convert={convertRecord}
118
+ onOpenDialog={() => {
119
+ setShowConvert(true)
120
+ }}
121
+ />
107
122
  <AgentTasks targetType="deal" targetId={record.id} targetLabel={record.name} />
108
123
  <DeleteRecord
109
124
  recordLabel="Deal"
@@ -119,6 +134,26 @@ export function DealDetail(): React.JSX.Element {
119
134
  />
120
135
  </div>
121
136
 
137
+ {convertRecord.error !== null && <ErrorPanel error={convertRecord.error} />}
138
+ <ConvertRecordAction
139
+ sourceKind="deal"
140
+ recordId={record.id}
141
+ recordName={record.name}
142
+ companyId={record.companyId}
143
+ convertedTo={convertedTo}
144
+ convert={convertRecord}
145
+ showDialog={showConvert}
146
+ onOpenDialog={() => {
147
+ setShowConvert(true)
148
+ }}
149
+ onCloseDialog={() => {
150
+ setShowConvert(false)
151
+ }}
152
+ onConverted={(created, targetType) => {
153
+ navigate(detailPathForPipelineKind(targetType, created.id))
154
+ }}
155
+ />
156
+
122
157
  <RecordTabs tabs={tabs} active={active} onChange={setActiveTab} ariaLabel="Deal sections">
123
158
  {active === 'overview' && <DealOverview deal={record} />}
124
159
  {active === 'fields' && (
@@ -60,7 +60,6 @@ const TARGET_ROUTES: Readonly<Partial<Record<RecordTargetType, string>>> = {
60
60
 
61
61
  interface TargetDirectory {
62
62
  nameFor(decision: Decision): string | undefined
63
- readonly isComplete: boolean
64
63
  }
65
64
 
66
65
  function useTargetDirectory(): TargetDirectory {
@@ -95,13 +94,6 @@ function useTargetDirectory(): TargetDirectory {
95
94
 
96
95
  return {
97
96
  nameFor: (decision) => nameById.get(decision.targetId),
98
- isComplete:
99
- !people.hasNext &&
100
- !companies.hasNext &&
101
- !deals.hasNext &&
102
- !opportunities.hasNext &&
103
- !raises.hasNext &&
104
- !partnerships.hasNext,
105
97
  }
106
98
  }
107
99
 
@@ -258,12 +250,6 @@ export function DecisionsPage(): React.JSX.Element {
258
250
  onSortChange={setSort}
259
251
  visibleColumnKeys={listView.visibleKeys}
260
252
  />
261
- {!directory.isComplete && (
262
- <p className="mt-2 text-[11px] text-ink-faint">
263
- The workspace holds more records than one page returns, so some rows name only their
264
- record type.
265
- </p>
266
- )}
267
253
  <Paginator list={decisions} />
268
254
  </>
269
255
  )}
@@ -7,11 +7,14 @@ import { usePatch } from '../api/resource.ts'
7
7
  import type { PatchResult } from '../api/resource.ts'
8
8
  import { useCompanies, useCompany } from '../api/resources/companies.ts'
9
9
  import {
10
- useConvertEnquiry,
11
10
  useDeleteEnquiry,
12
11
  useEnquiry,
13
12
  useUpdateEnquiry,
14
13
  } from '../api/resources/enquiries.ts'
14
+ import {
15
+ detailPathForPipelineKind,
16
+ useConvertPipelineRecord,
17
+ } from '../api/resources/conversions.ts'
15
18
  import { useFormSubmissionsForRecord } from '../api/resources/forms.ts'
16
19
  import { useMembers } from '../api/resources/members.ts'
17
20
  import { usePeople } from '../api/resources/people.ts'
@@ -21,6 +24,7 @@ import { ActivitiesPanel, LatestActivity } from '../components/ActivitiesPanel.t
21
24
  import { AgentTasks } from '../components/AgentTasks.tsx'
22
25
  import { Chip } from '../components/Chip.tsx'
23
26
  import type { ChipTone } from '../components/Chip.tsx'
27
+ import { ConvertRecordAction, ConvertRecordButton } from '../components/ConvertRecordDialog.tsx'
24
28
  import { DecisionsPanel } from '../components/DecisionsPanel.tsx'
25
29
  import { DeleteRecord } from '../components/DeleteRecord.tsx'
26
30
  import { EntitySearch } from '../components/EntitySearch.tsx'
@@ -60,8 +64,8 @@ export function EnquiryDetail(): React.JSX.Element {
60
64
  const { id } = useParams()
61
65
  const navigate = useNavigate()
62
66
  const { record, isLoading, isNotFound, error } = useEnquiry(id)
67
+ const convertRecord = useConvertPipelineRecord('enquiry')
63
68
  const deleteEnquiry = useDeleteEnquiry()
64
- const convertEnquiry = useConvertEnquiry()
65
69
  const moduleTabs = inSlotOrder(useRecordTabs('enquiry'))
66
70
  const hasCustomFields = useHasCustomFields('enquiry')
67
71
  const [activeTab, setActiveTab] = useState('overview')
@@ -96,12 +100,11 @@ export function EnquiryDetail(): React.JSX.Element {
96
100
  const active = tabs.some((tab) => tab.id === activeTab) ? activeTab : 'overview'
97
101
  const moduleTab = moduleTabs.find((tab) => tab.id === active)
98
102
 
99
- const convertBlockedReason =
100
- record.convertedDealId !== null
101
- ? null
102
- : record.companyId === null
103
- ? 'Link a company first'
104
- : null
103
+ const convertedTo =
104
+ record.convertedTo ??
105
+ (record.convertedDealId !== null
106
+ ? { targetType: 'deal' as const, targetId: record.convertedDealId }
107
+ : null)
105
108
 
106
109
  return (
107
110
  <div className="animate-fade-in mx-auto max-w-6xl">
@@ -117,28 +120,13 @@ export function EnquiryDetail(): React.JSX.Element {
117
120
  <EnquiryHeading enquiry={record} />
118
121
 
119
122
  <div className="flex justify-end gap-2">
120
- {record.convertedDealId === null ? (
121
- <button
122
- type="button"
123
- onClick={() => {
124
- if (convertBlockedReason === null) {
125
- setShowConvert(true)
126
- }
127
- }}
128
- disabled={convertBlockedReason !== null || convertEnquiry.isPending}
129
- title={convertBlockedReason ?? undefined}
130
- className="rounded-md border border-border px-2.5 py-1 text-[12px] font-medium text-ink-muted transition hover:border-accent hover:text-accent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border disabled:hover:text-ink-muted"
131
- >
132
- Convert to deal
133
- </button>
134
- ) : (
135
- <Link
136
- to={`/deals/${record.convertedDealId}`}
137
- className="rounded-md border border-border px-2.5 py-1 text-[12px] font-medium text-accent transition hover:border-accent hover:underline"
138
- >
139
- Converted — view deal
140
- </Link>
141
- )}
123
+ <ConvertRecordButton
124
+ convertedTo={convertedTo}
125
+ convert={convertRecord}
126
+ onOpenDialog={() => {
127
+ setShowConvert(true)
128
+ }}
129
+ />
142
130
  <AgentTasks targetType="enquiry" targetId={record.id} targetLabel={record.name} />
143
131
  <DeleteRecord
144
132
  recordLabel="Enquiry"
@@ -154,26 +142,25 @@ export function EnquiryDetail(): React.JSX.Element {
154
142
  />
155
143
  </div>
156
144
 
157
- {convertEnquiry.error !== null && <ErrorPanel error={convertEnquiry.error} />}
158
- {showConvert && (
159
- <ConvertConfirm
160
- enquiry={record}
161
- isPending={convertEnquiry.isPending}
162
- onCancel={() => {
163
- setShowConvert(false)
164
- }}
165
- onConfirm={() => {
166
- convertEnquiry
167
- .runAsync(record.id)
168
- .then((deal) => {
169
- setShowConvert(false)
170
-
171
- return navigate(`/deals/${deal.id}`)
172
- })
173
- .catch(() => undefined)
174
- }}
175
- />
176
- )}
145
+ {convertRecord.error !== null && <ErrorPanel error={convertRecord.error} />}
146
+ <ConvertRecordAction
147
+ sourceKind="enquiry"
148
+ recordId={record.id}
149
+ recordName={record.name}
150
+ companyId={record.companyId}
151
+ convertedTo={convertedTo}
152
+ convert={convertRecord}
153
+ showDialog={showConvert}
154
+ onOpenDialog={() => {
155
+ setShowConvert(true)
156
+ }}
157
+ onCloseDialog={() => {
158
+ setShowConvert(false)
159
+ }}
160
+ onConverted={(created, targetType) => {
161
+ navigate(detailPathForPipelineKind(targetType, created.id))
162
+ }}
163
+ />
177
164
 
178
165
  <RecordTabs
179
166
  tabs={tabs}
@@ -512,43 +499,3 @@ function EnquiryContacts({ enquiry }: { readonly enquiry: Enquiry }): React.JSX.
512
499
  </section>
513
500
  )
514
501
  }
515
-
516
- function ConvertConfirm({
517
- enquiry,
518
- isPending,
519
- onCancel,
520
- onConfirm,
521
- }: {
522
- readonly enquiry: Enquiry
523
- readonly isPending: boolean
524
- readonly onCancel: () => void
525
- readonly onConfirm: () => void
526
- }): React.JSX.Element {
527
- return (
528
- <div className="rounded-md border border-border bg-surface-raised p-4">
529
- <p className="text-[13px] text-ink">
530
- Convert <span className="font-semibold">{enquiry.name}</span> to a Deal. This creates a new
531
- deal (name, company, owner and linked people carry over) and moves this enquiry to the
532
- first closed stage.
533
- </p>
534
- <div className="mt-3 flex justify-end gap-2">
535
- <button
536
- type="button"
537
- onClick={onCancel}
538
- disabled={isPending}
539
- className="rounded-md px-2.5 py-1 text-[12px] font-medium text-ink-muted hover:text-ink disabled:opacity-50"
540
- >
541
- Cancel
542
- </button>
543
- <button
544
- type="button"
545
- onClick={onConfirm}
546
- disabled={isPending}
547
- className="rounded-md bg-accent px-3 py-1.5 text-[12px] font-semibold text-accent-fg hover:bg-accent-hover disabled:opacity-50"
548
- >
549
- {isPending ? 'Converting…' : 'Convert'}
550
- </button>
551
- </div>
552
- </div>
553
- )
554
- }
@@ -6,7 +6,6 @@ import { Link, useNavigate, useParams } from 'react-router'
6
6
  import { usePatch } from '../api/resource.ts'
7
7
  import type { PatchResult } from '../api/resource.ts'
8
8
  import { useCompanies } from '../api/resources/companies.ts'
9
- import { useConsentPurposes } from '../api/resources/consentPurposes.ts'
10
9
  import { useDeals } from '../api/resources/deals.ts'
11
10
  import { useDeleteList, useList, useUpdateList } from '../api/resources/lists.ts'
12
11
  import {
@@ -86,57 +85,11 @@ export function ListDetail(): React.JSX.Element {
86
85
 
87
86
  <ListDescription list={record} />
88
87
 
89
- {record.targetType === 'person' && <ListConsentPurpose list={record} />}
90
-
91
88
  <ListMembers listId={record.id} targetType={record.targetType} />
92
89
  </div>
93
90
  )
94
91
  }
95
92
 
96
- function ListConsentPurpose({ list }: { readonly list: List }): React.JSX.Element {
97
- const { patch, error } = useListPatch(list)
98
- const purposes = useConsentPurposes({ sort: 'sort_order', limit: 200 })
99
- const options = [
100
- { value: '', label: '— none —' },
101
- ...purposes.records.map((purpose) => ({ value: purpose.id, label: purpose.label })),
102
- ]
103
-
104
- return (
105
- <div className="mb-6 rounded-md border border-border bg-surface-raised p-4 text-[13px]">
106
- {error !== null && (
107
- <div className="mb-2">
108
- <ErrorPanel error={error} />
109
- </div>
110
- )}
111
- <div className="flex flex-wrap items-baseline justify-between gap-3">
112
- <div>
113
- <div className="text-[12px] font-medium text-ink">Consent purpose</div>
114
- <p className="mt-0.5 text-[12px] text-ink-muted">
115
- A form submit that adds a person to this list also grants this purpose. Manual and
116
- API adds capture nothing.
117
- </p>
118
- </div>
119
- <label className="shrink-0">
120
- <select
121
- value={list.consentPurposeId ?? ''}
122
- onChange={(event) => {
123
- const value = event.target.value
124
- patch({ consentPurposeId: value.length === 0 ? null : value })
125
- }}
126
- className="rounded-md border border-border bg-transparent px-2 py-1 text-[12px] outline-none focus:border-accent"
127
- >
128
- {options.map((option) => (
129
- <option key={option.value} value={option.value}>
130
- {option.label}
131
- </option>
132
- ))}
133
- </select>
134
- </label>
135
- </div>
136
- </div>
137
- )
138
- }
139
-
140
93
  function useListPatch(list: List): PatchResult<ListInput> {
141
94
  return usePatch(useUpdateList, list)
142
95
  }
@@ -6,6 +6,10 @@ import { Link, useNavigate, useParams } from 'react-router'
6
6
  import { usePatch } from '../api/resource.ts'
7
7
  import type { PatchResult } from '../api/resource.ts'
8
8
  import { useCompanies, useCompany } from '../api/resources/companies.ts'
9
+ import {
10
+ detailPathForPipelineKind,
11
+ useConvertPipelineRecord,
12
+ } from '../api/resources/conversions.ts'
9
13
  import { useFormSubmissionsForRecord } from '../api/resources/forms.ts'
10
14
  import { useMembers } from '../api/resources/members.ts'
11
15
  import {
@@ -20,6 +24,7 @@ import { ActivitiesPanel, LatestActivity } from '../components/ActivitiesPanel.t
20
24
  import { AgentTasks } from '../components/AgentTasks.tsx'
21
25
  import { Chip } from '../components/Chip.tsx'
22
26
  import type { ChipTone } from '../components/Chip.tsx'
27
+ import { ConvertRecordAction, ConvertRecordButton } from '../components/ConvertRecordDialog.tsx'
23
28
  import { DecisionsPanel } from '../components/DecisionsPanel.tsx'
24
29
  import { DeleteRecord } from '../components/DeleteRecord.tsx'
25
30
  import { EntitySearch } from '../components/EntitySearch.tsx'
@@ -61,10 +66,12 @@ export function OpportunityDetail(): React.JSX.Element {
61
66
  const { id } = useParams()
62
67
  const navigate = useNavigate()
63
68
  const { record, isLoading, isNotFound, error } = useOpportunity(id)
69
+ const convertRecord = useConvertPipelineRecord('opportunity')
64
70
  const deleteOpportunity = useDeleteOpportunity()
65
71
  const moduleTabs = inSlotOrder(useRecordTabs('opportunity'))
66
72
  const hasCustomFields = useHasCustomFields('opportunity')
67
73
  const [activeTab, setActiveTab] = useState('overview')
74
+ const [showConvert, setShowConvert] = useState(false)
68
75
  const formSubmissions = useFormSubmissionsForRecord('opportunity', id)
69
76
 
70
77
  if (isNotFound) {
@@ -94,6 +101,7 @@ export function OpportunityDetail(): React.JSX.Element {
94
101
  ]
95
102
  const active = tabs.some((tab) => tab.id === activeTab) ? activeTab : 'overview'
96
103
  const moduleTab = moduleTabs.find((tab) => tab.id === active)
104
+ const convertedTo = record.convertedTo
97
105
 
98
106
  return (
99
107
  <div className="animate-fade-in mx-auto max-w-6xl">
@@ -109,6 +117,13 @@ export function OpportunityDetail(): React.JSX.Element {
109
117
  <OpportunityHeading opportunity={record} />
110
118
 
111
119
  <div className="flex justify-end gap-2">
120
+ <ConvertRecordButton
121
+ convertedTo={convertedTo}
122
+ convert={convertRecord}
123
+ onOpenDialog={() => {
124
+ setShowConvert(true)
125
+ }}
126
+ />
112
127
  <AgentTasks targetType="opportunity" targetId={record.id} targetLabel={record.name} />
113
128
  <DeleteRecord
114
129
  recordLabel="Opportunity"
@@ -124,6 +139,26 @@ export function OpportunityDetail(): React.JSX.Element {
124
139
  />
125
140
  </div>
126
141
 
142
+ {convertRecord.error !== null && <ErrorPanel error={convertRecord.error} />}
143
+ <ConvertRecordAction
144
+ sourceKind="opportunity"
145
+ recordId={record.id}
146
+ recordName={record.name}
147
+ companyId={record.companyId}
148
+ convertedTo={convertedTo}
149
+ convert={convertRecord}
150
+ showDialog={showConvert}
151
+ onOpenDialog={() => {
152
+ setShowConvert(true)
153
+ }}
154
+ onCloseDialog={() => {
155
+ setShowConvert(false)
156
+ }}
157
+ onConverted={(created, targetType) => {
158
+ navigate(detailPathForPipelineKind(targetType, created.id))
159
+ }}
160
+ />
161
+
127
162
  <RecordTabs
128
163
  tabs={tabs}
129
164
  active={active}