@kelpie/ui 0.10.0 → 0.12.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 (150) 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/index.d.ts +4 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +5 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/pages/DealDetail.d.ts.map +1 -1
  38. package/dist/pages/DealDetail.js +15 -2
  39. package/dist/pages/DealDetail.js.map +1 -1
  40. package/dist/pages/DecisionsPage.d.ts.map +1 -1
  41. package/dist/pages/DecisionsPage.js +1 -7
  42. package/dist/pages/DecisionsPage.js.map +1 -1
  43. package/dist/pages/EnquiryDetail.d.ts.map +1 -1
  44. package/dist/pages/EnquiryDetail.js +17 -25
  45. package/dist/pages/EnquiryDetail.js.map +1 -1
  46. package/dist/pages/ListDetail.d.ts.map +1 -1
  47. package/dist/pages/ListDetail.js +1 -14
  48. package/dist/pages/ListDetail.js.map +1 -1
  49. package/dist/pages/OpportunityDetail.d.ts.map +1 -1
  50. package/dist/pages/OpportunityDetail.js +15 -2
  51. package/dist/pages/OpportunityDetail.js.map +1 -1
  52. package/dist/pages/PartnershipDetail.d.ts.map +1 -1
  53. package/dist/pages/PartnershipDetail.js +15 -2
  54. package/dist/pages/PartnershipDetail.js.map +1 -1
  55. package/dist/pages/RaiseDetail.d.ts.map +1 -1
  56. package/dist/pages/RaiseDetail.js +15 -2
  57. package/dist/pages/RaiseDetail.js.map +1 -1
  58. package/dist/pages/account/SecurityPage.js +2 -2
  59. package/dist/pages/account/SecurityPage.js.map +1 -1
  60. package/dist/pages/admin/DataPage.d.ts.map +1 -1
  61. package/dist/pages/admin/DataPage.js +3 -3
  62. package/dist/pages/admin/DataPage.js.map +1 -1
  63. package/dist/pages/admin/PrivacyPage.js +3 -3
  64. package/dist/pages/admin/PrivacyPage.js.map +1 -1
  65. package/dist/pages/admin/TeamPage.js +1 -1
  66. package/dist/pages/admin/TeamPage.js.map +1 -1
  67. package/dist/pages/admin/WorkspaceSettingsPage.js +2 -12
  68. package/dist/pages/admin/WorkspaceSettingsPage.js.map +1 -1
  69. package/dist/pages/auth/AuthForm.d.ts +18 -0
  70. package/dist/pages/auth/AuthForm.d.ts.map +1 -1
  71. package/dist/pages/auth/AuthForm.js +17 -0
  72. package/dist/pages/auth/AuthForm.js.map +1 -1
  73. package/dist/pages/auth/AuthMethods.d.ts +12 -0
  74. package/dist/pages/auth/AuthMethods.d.ts.map +1 -0
  75. package/dist/pages/auth/AuthMethods.js +20 -0
  76. package/dist/pages/auth/AuthMethods.js.map +1 -0
  77. package/dist/pages/auth/SignInPage.d.ts +7 -0
  78. package/dist/pages/auth/SignInPage.d.ts.map +1 -1
  79. package/dist/pages/auth/SignInPage.js +3 -8
  80. package/dist/pages/auth/SignInPage.js.map +1 -1
  81. package/dist/pages/auth/SignUpPage.d.ts.map +1 -1
  82. package/dist/pages/auth/SignUpPage.js +2 -1
  83. package/dist/pages/auth/SignUpPage.js.map +1 -1
  84. package/dist/pages/auth/nextPath.d.ts +15 -0
  85. package/dist/pages/auth/nextPath.d.ts.map +1 -0
  86. package/dist/pages/auth/nextPath.js +17 -0
  87. package/dist/pages/auth/nextPath.js.map +1 -0
  88. package/dist/pages/forms/AddFieldMenu.js +2 -2
  89. package/dist/pages/forms/AddFieldMenu.js.map +1 -1
  90. package/dist/pages/forms/FieldsEditor.d.ts.map +1 -1
  91. package/dist/pages/forms/FieldsEditor.js +22 -10
  92. package/dist/pages/forms/FieldsEditor.js.map +1 -1
  93. package/dist/pages/forms/MapTargetSearch.d.ts +7 -0
  94. package/dist/pages/forms/MapTargetSearch.d.ts.map +1 -0
  95. package/dist/pages/forms/MapTargetSearch.js +32 -0
  96. package/dist/pages/forms/MapTargetSearch.js.map +1 -0
  97. package/dist/pages/forms/fieldList.d.ts +6 -7
  98. package/dist/pages/forms/fieldList.d.ts.map +1 -1
  99. package/dist/pages/forms/fieldList.js +14 -16
  100. package/dist/pages/forms/fieldList.js.map +1 -1
  101. package/dist/pages/forms/template.d.ts.map +1 -1
  102. package/dist/pages/forms/template.js +7 -0
  103. package/dist/pages/forms/template.js.map +1 -1
  104. package/dist/registry/context.d.ts +3 -1
  105. package/dist/registry/context.d.ts.map +1 -1
  106. package/dist/registry/context.js +4 -0
  107. package/dist/registry/context.js.map +1 -1
  108. package/dist/registry/contributions.d.ts +24 -0
  109. package/dist/registry/contributions.d.ts.map +1 -1
  110. package/dist/registry/contributions.js.map +1 -1
  111. package/dist/registry/registry.d.ts +4 -1
  112. package/dist/registry/registry.d.ts.map +1 -1
  113. package/dist/registry/registry.js +6 -0
  114. package/dist/registry/registry.js.map +1 -1
  115. package/package.json +2 -2
  116. package/src/api/resources/activities.ts +10 -2
  117. package/src/api/resources/conversions.ts +125 -0
  118. package/src/api/resources/enquiries.ts +9 -35
  119. package/src/api/resources/importJobs.ts +3 -2
  120. package/src/app/KelpieApp.tsx +1 -0
  121. package/src/components/ActivitiesPanel.tsx +20 -14
  122. package/src/components/ApiKeysPanel.tsx +206 -21
  123. package/src/components/ConvertRecordDialog.tsx +210 -0
  124. package/src/components/Shell.tsx +91 -36
  125. package/src/index.ts +7 -0
  126. package/src/pages/DealDetail.tsx +35 -0
  127. package/src/pages/DecisionsPage.tsx +0 -14
  128. package/src/pages/EnquiryDetail.tsx +37 -90
  129. package/src/pages/ListDetail.tsx +0 -47
  130. package/src/pages/OpportunityDetail.tsx +35 -0
  131. package/src/pages/PartnershipDetail.tsx +35 -0
  132. package/src/pages/RaiseDetail.tsx +35 -0
  133. package/src/pages/account/SecurityPage.tsx +6 -1
  134. package/src/pages/admin/DataPage.tsx +14 -9
  135. package/src/pages/admin/PrivacyPage.tsx +4 -4
  136. package/src/pages/admin/TeamPage.tsx +1 -2
  137. package/src/pages/admin/WorkspaceSettingsPage.tsx +1 -31
  138. package/src/pages/auth/AuthForm.tsx +38 -0
  139. package/src/pages/auth/AuthMethods.tsx +29 -0
  140. package/src/pages/auth/SignInPage.tsx +3 -8
  141. package/src/pages/auth/SignUpPage.tsx +7 -0
  142. package/src/pages/auth/nextPath.ts +17 -0
  143. package/src/pages/forms/AddFieldMenu.tsx +2 -2
  144. package/src/pages/forms/FieldsEditor.tsx +45 -20
  145. package/src/pages/forms/MapTargetSearch.tsx +69 -0
  146. package/src/pages/forms/fieldList.ts +34 -16
  147. package/src/pages/forms/template.ts +7 -0
  148. package/src/registry/context.ts +6 -0
  149. package/src/registry/contributions.ts +26 -0
  150. package/src/registry/registry.ts +12 -0
@@ -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}
@@ -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 PartnershipDetail(): React.JSX.Element {
61
66
  const { id } = useParams()
62
67
  const navigate = useNavigate()
63
68
  const { record, isLoading, isNotFound, error } = usePartnership(id)
69
+ const convertRecord = useConvertPipelineRecord('partnership')
64
70
  const deletePartnership = useDeletePartnership()
65
71
  const moduleTabs = inSlotOrder(useRecordTabs('partnership'))
66
72
  const hasCustomFields = useHasCustomFields('partnership')
67
73
  const [activeTab, setActiveTab] = useState('overview')
74
+ const [showConvert, setShowConvert] = useState(false)
68
75
  const formSubmissions = useFormSubmissionsForRecord('partnership', id)
69
76
 
70
77
  if (isNotFound) {
@@ -94,6 +101,7 @@ export function PartnershipDetail(): 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 PartnershipDetail(): React.JSX.Element {
109
117
  <PartnershipHeading partnership={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="partnership" targetId={record.id} targetLabel={record.name} />
113
128
  <DeleteRecord
114
129
  recordLabel="Partnership"
@@ -124,6 +139,26 @@ export function PartnershipDetail(): React.JSX.Element {
124
139
  />
125
140
  </div>
126
141
 
142
+ {convertRecord.error !== null && <ErrorPanel error={convertRecord.error} />}
143
+ <ConvertRecordAction
144
+ sourceKind="partnership"
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}
@@ -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 { useMembers } from '../api/resources/members.ts'
10
14
  import { usePeople } from '../api/resources/people.ts'
11
15
  import { usePipelineStages } from '../api/resources/pipelineStages.ts'
@@ -15,6 +19,7 @@ import { ActivitiesPanel, LatestActivity } from '../components/ActivitiesPanel.t
15
19
  import { AgentTasks } from '../components/AgentTasks.tsx'
16
20
  import { Chip } from '../components/Chip.tsx'
17
21
  import type { ChipTone } from '../components/Chip.tsx'
22
+ import { ConvertRecordAction, ConvertRecordButton } from '../components/ConvertRecordDialog.tsx'
18
23
  import { DecisionsPanel } from '../components/DecisionsPanel.tsx'
19
24
  import { DeleteRecord } from '../components/DeleteRecord.tsx'
20
25
  import { EntitySearch } from '../components/EntitySearch.tsx'
@@ -60,10 +65,12 @@ export function RaiseDetail(): React.JSX.Element {
60
65
  const { id } = useParams()
61
66
  const navigate = useNavigate()
62
67
  const { record, isLoading, isNotFound, error } = useRaise(id)
68
+ const convertRecord = useConvertPipelineRecord('raise')
63
69
  const deleteRaise = useDeleteRaise()
64
70
  const moduleTabs = inSlotOrder(useRecordTabs('raise'))
65
71
  const hasCustomFields = useHasCustomFields('raise')
66
72
  const [activeTab, setActiveTab] = useState('overview')
73
+ const [showConvert, setShowConvert] = useState(false)
67
74
 
68
75
  if (isNotFound) {
69
76
  return <NotFoundPanel label="Raise" backTo="/fundraising" />
@@ -89,6 +96,7 @@ export function RaiseDetail(): 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 RaiseDetail(): React.JSX.Element {
104
112
  <RaiseHeading raise={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="raise" targetId={record.id} targetLabel={record.name} />
108
123
  <DeleteRecord
109
124
  recordLabel="Raise"
@@ -119,6 +134,26 @@ export function RaiseDetail(): React.JSX.Element {
119
134
  />
120
135
  </div>
121
136
 
137
+ {convertRecord.error !== null && <ErrorPanel error={convertRecord.error} />}
138
+ <ConvertRecordAction
139
+ sourceKind="raise"
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="Raise sections">
123
158
  {active === 'overview' && <RaiseOverview raise={record} />}
124
159
  {active === 'fields' && <RaiseFields raise={record} />}
@@ -88,7 +88,8 @@ function ChangePasswordForm(): React.JSX.Element {
88
88
  <form onSubmit={submit} className="space-y-3">
89
89
  <h2 className="text-[14px] font-semibold text-ink">Change password</h2>
90
90
  <p className="max-w-md text-[12px] text-ink-muted">
91
- Changing your password signs you out everywhere except this browser.
91
+ Changing your password signs you out everywhere except this browser. If you have never set
92
+ one, use <span className="font-medium text-ink">Forgot password</span> on the sign-in page.
92
93
  </p>
93
94
 
94
95
  <label className="block max-w-md">
@@ -216,6 +217,10 @@ function SessionRow({
216
217
  <td className="px-4 py-3">
217
218
  <div className="font-medium break-all text-ink">{session.device ?? 'Unknown device'}</div>
218
219
  {session.current && <span className="text-[11px] font-medium text-success">This device</span>}
220
+ {/* Null for a password sign-in, which is the ordinary case and needs no label. */}
221
+ {session.signedInVia !== null && (
222
+ <div className="text-[11px] text-ink-faint">via {session.signedInVia}</div>
223
+ )}
219
224
  </td>
220
225
  {/* Always unknown today: the service records the User-Agent and nothing
221
226
  derives a location from the request. The column stays because the field
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  CONFLICT_MODE_LABELS,
3
+ EXPORT_OBJECT_LABELS,
4
+ EXPORT_OBJECTS,
3
5
  IMPORT_OBJECTS,
4
6
  IMPORT_SOURCES,
5
7
  MATCH_KEYS,
@@ -13,6 +15,7 @@ import {
13
15
  requiredColumns,
14
16
  } from '@kelpie/schemas'
15
17
  import type {
18
+ ExportObject,
16
19
  ImportColumnMap,
17
20
  ImportConflictMode,
18
21
  ImportJob,
@@ -65,7 +68,7 @@ const STEPS: readonly { readonly id: WizardStep; readonly label: string }[] = [
65
68
  ]
66
69
 
67
70
  export function DataPage(): ReactNode {
68
- const [exportObject, setExportObject] = useState<ImportObject>('companies')
71
+ const [exportObject, setExportObject] = useState<ExportObject>('companies')
69
72
 
70
73
  const [step, setStep] = useState<WizardStep>('source')
71
74
  const [source, setSource] = useState<ImportSource>('custom')
@@ -133,7 +136,7 @@ export function DataPage(): ReactNode {
133
136
  setProblem(null)
134
137
  }
135
138
 
136
- async function onDownload(target: ImportObject, template: boolean): Promise<void> {
139
+ async function onDownload(target: ExportObject, template: boolean): Promise<void> {
137
140
  setProblem(null)
138
141
  saveCsv(await exportCsv.runAsync({ object: target, template }))
139
142
  }
@@ -255,7 +258,7 @@ export function DataPage(): ReactNode {
255
258
  <div className="animate-slide-in space-y-10">
256
259
  <PageHeader
257
260
  title="Import & export"
258
- description="CSV for People, Companies, Positions, and Deals. Custom files or HubSpot / Salesforce packs."
261
+ description="CSV for People, Companies, Positions, Deals, and other CRM objects. Custom field values export with each record; definitions export separately."
259
262
  />
260
263
 
261
264
  {failure === null ? null : (
@@ -272,20 +275,20 @@ export function DataPage(): ReactNode {
272
275
  <div>
273
276
  <h2 className="text-[15px] font-semibold text-ink">Export</h2>
274
277
  <p className="mt-1 text-[13px] text-ink-muted">
275
- Download this workspace as CSV. A Kelpie export reads straight back in with no column
276
- mapping.
278
+ Download this workspace as CSV. Importable objects round-trip through the wizard; other
279
+ objects and custom field definitions export for backup and analysis.
277
280
  </p>
278
281
  </div>
279
282
  <div className="flex flex-wrap items-end gap-3">
280
283
  <Field label="Object">
281
284
  <select
282
285
  value={exportObject}
283
- onChange={(event) => setExportObject(event.target.value as ImportObject)}
286
+ onChange={(event) => setExportObject(event.target.value as ExportObject)}
284
287
  className="w-48 rounded-md border border-border bg-surface-raised px-3 py-2 text-[13px] outline-none focus:border-accent"
285
288
  >
286
- {IMPORT_OBJECTS.map((item) => (
289
+ {EXPORT_OBJECTS.map((item) => (
287
290
  <option key={item} value={item}>
288
- {OBJECT_LABELS[item]}
291
+ {EXPORT_OBJECT_LABELS[item]}
289
292
  </option>
290
293
  ))}
291
294
  </select>
@@ -377,7 +380,9 @@ export function DataPage(): ReactNode {
377
380
  </button>
378
381
  </div>
379
382
  <p className="text-[12px] text-ink-muted">
380
- Import order for linked data: Companies → People → Positions → Deals.
383
+ Import order for linked data: Companies → People → Positions → pipeline records (Deals,
384
+ Opportunities, Enquiries, Partnerships, Fundraising). Custom field definitions can be
385
+ imported before the records that use them.
381
386
  </p>
382
387
  <button
383
388
  type="submit"
@@ -132,14 +132,14 @@ function EmptyState({ onAdd, onSeedStarters, isSeeding }: EmptyStateProps): Reac
132
132
  <p className="mt-1 text-ink-muted">
133
133
  A purpose is what a person is agreeing to — being contacted at all, marketing, research.
134
134
  Every workspace usually has at least a general <span className="font-medium">Contact</span>{' '}
135
- purpose. Forms, lists, and imports that don&rsquo;t need consent don&rsquo;t need one.
135
+ purpose. Forms and imports that don&rsquo;t need consent don&rsquo;t need one.
136
136
  </p>
137
137
  <div className="mt-4 flex flex-wrap items-center gap-2">
138
138
  <button
139
139
  type="button"
140
140
  onClick={onSeedStarters}
141
141
  disabled={isSeeding}
142
- className="rounded-md bg-accent px-3 py-1.5 text-[12px] font-medium text-white hover:bg-accent-hover disabled:opacity-50"
142
+ className="rounded-md bg-accent px-3 py-1.5 text-[12px] font-semibold text-accent-fg transition hover:bg-accent-hover disabled:opacity-50"
143
143
  >
144
144
  {isSeeding ? 'Adding…' : 'Add Contact & Marketing'}
145
145
  </button>
@@ -268,7 +268,7 @@ function AddPurposeForm({ onDone }: AddPurposeFormProps): React.JSX.Element {
268
268
  <button
269
269
  type="submit"
270
270
  disabled={create.isPending}
271
- className="rounded-md bg-accent px-3 py-1 text-[12px] font-medium text-white hover:bg-accent-hover disabled:opacity-50"
271
+ className="rounded-md bg-accent px-3 py-1 text-[12px] font-semibold text-accent-fg transition hover:bg-accent-hover disabled:opacity-50"
272
272
  >
273
273
  {create.isPending ? 'Adding…' : 'Add purpose'}
274
274
  </button>
@@ -389,7 +389,7 @@ function PurposeRow({ purpose }: PurposeRowProps): React.JSX.Element {
389
389
  <button
390
390
  type="submit"
391
391
  disabled={update.isPending}
392
- className="rounded-md bg-accent px-3 py-1 text-[12px] font-medium text-white hover:bg-accent-hover disabled:opacity-50"
392
+ className="rounded-md bg-accent px-3 py-1 text-[12px] font-semibold text-accent-fg transition hover:bg-accent-hover disabled:opacity-50"
393
393
  >
394
394
  {update.isPending ? 'Saving…' : 'Save'}
395
395
  </button>
@@ -114,8 +114,7 @@ function InviteForm(): React.JSX.Element {
114
114
  </div>
115
115
  {sendInvite.error !== null && <ErrorPanel error={sendInvite.error} />}
116
116
  <p className="text-[11px] text-ink-faint">
117
- The invitation is emailed and lasts seven days. Ownership is not invited; it is given to a
118
- member who has already joined.
117
+ The invitation is emailed and lasts seven days.
119
118
  </p>
120
119
  </form>
121
120
  )