@kelpie/ui 0.8.0 → 0.10.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 (200) hide show
  1. package/dist/api/resource.d.ts +1 -1
  2. package/dist/api/resource.d.ts.map +1 -1
  3. package/dist/api/resource.js.map +1 -1
  4. package/dist/api/resources/consentPurposes.d.ts +13 -0
  5. package/dist/api/resources/consentPurposes.d.ts.map +1 -0
  6. package/dist/api/resources/consentPurposes.js +39 -0
  7. package/dist/api/resources/consentPurposes.js.map +1 -0
  8. package/dist/api/resources/customFields.d.ts +15 -0
  9. package/dist/api/resources/customFields.d.ts.map +1 -0
  10. package/dist/api/resources/customFields.js +41 -0
  11. package/dist/api/resources/customFields.js.map +1 -0
  12. package/dist/api/resources/enquiries.d.ts +39 -0
  13. package/dist/api/resources/enquiries.d.ts.map +1 -0
  14. package/dist/api/resources/enquiries.js +68 -0
  15. package/dist/api/resources/enquiries.js.map +1 -0
  16. package/dist/api/resources/forms.d.ts +21 -1
  17. package/dist/api/resources/forms.d.ts.map +1 -1
  18. package/dist/api/resources/forms.js +41 -0
  19. package/dist/api/resources/forms.js.map +1 -1
  20. package/dist/api/resources/importJobs.d.ts +5 -0
  21. package/dist/api/resources/importJobs.d.ts.map +1 -1
  22. package/dist/api/resources/importJobs.js +3 -0
  23. package/dist/api/resources/importJobs.js.map +1 -1
  24. package/dist/api/resources/opportunities.d.ts +2 -0
  25. package/dist/api/resources/opportunities.d.ts.map +1 -1
  26. package/dist/api/resources/opportunities.js +1 -0
  27. package/dist/api/resources/opportunities.js.map +1 -1
  28. package/dist/api/resources/sampleData.d.ts.map +1 -1
  29. package/dist/api/resources/sampleData.js +1 -0
  30. package/dist/api/resources/sampleData.js.map +1 -1
  31. package/dist/app/KelpieApp.d.ts.map +1 -1
  32. package/dist/app/KelpieApp.js +7 -1
  33. package/dist/app/KelpieApp.js.map +1 -1
  34. package/dist/components/ActivitiesPanel.d.ts.map +1 -1
  35. package/dist/components/ActivitiesPanel.js +1 -0
  36. package/dist/components/ActivitiesPanel.js.map +1 -1
  37. package/dist/components/CustomFieldsPanel.d.ts +23 -0
  38. package/dist/components/CustomFieldsPanel.d.ts.map +1 -0
  39. package/dist/components/CustomFieldsPanel.js +116 -0
  40. package/dist/components/CustomFieldsPanel.js.map +1 -0
  41. package/dist/components/FormsPanel.d.ts +18 -0
  42. package/dist/components/FormsPanel.d.ts.map +1 -0
  43. package/dist/components/FormsPanel.js +37 -0
  44. package/dist/components/FormsPanel.js.map +1 -0
  45. package/dist/components/PlanAttention.d.ts +3 -2
  46. package/dist/components/PlanAttention.d.ts.map +1 -1
  47. package/dist/components/PlanAttention.js +7 -3
  48. package/dist/components/PlanAttention.js.map +1 -1
  49. package/dist/components/Shell.d.ts.map +1 -1
  50. package/dist/components/Shell.js +3 -0
  51. package/dist/components/Shell.js.map +1 -1
  52. package/dist/components/useHasCustomFields.d.ts +13 -0
  53. package/dist/components/useHasCustomFields.d.ts.map +1 -0
  54. package/dist/components/useHasCustomFields.js +16 -0
  55. package/dist/components/useHasCustomFields.js.map +1 -0
  56. package/dist/pages/CompanyDetail.d.ts.map +1 -1
  57. package/dist/pages/CompanyDetail.js +24 -2
  58. package/dist/pages/CompanyDetail.js.map +1 -1
  59. package/dist/pages/DealDetail.d.ts.map +1 -1
  60. package/dist/pages/DealDetail.js +18 -1
  61. package/dist/pages/DealDetail.js.map +1 -1
  62. package/dist/pages/DecisionsPage.d.ts.map +1 -1
  63. package/dist/pages/DecisionsPage.js +2 -0
  64. package/dist/pages/DecisionsPage.js.map +1 -1
  65. package/dist/pages/EnquiriesPage.d.ts +2 -0
  66. package/dist/pages/EnquiriesPage.d.ts.map +1 -0
  67. package/dist/pages/EnquiriesPage.js +251 -0
  68. package/dist/pages/EnquiriesPage.js.map +1 -0
  69. package/dist/pages/EnquiryDetail.d.ts +2 -0
  70. package/dist/pages/EnquiryDetail.d.ts.map +1 -0
  71. package/dist/pages/EnquiryDetail.js +207 -0
  72. package/dist/pages/EnquiryDetail.js.map +1 -0
  73. package/dist/pages/EnquiryStageSettingsPage.d.ts +3 -0
  74. package/dist/pages/EnquiryStageSettingsPage.d.ts.map +1 -0
  75. package/dist/pages/EnquiryStageSettingsPage.js +34 -0
  76. package/dist/pages/EnquiryStageSettingsPage.js.map +1 -0
  77. package/dist/pages/FormDetail.d.ts.map +1 -1
  78. package/dist/pages/FormDetail.js +19 -1
  79. package/dist/pages/FormDetail.js.map +1 -1
  80. package/dist/pages/ListDetail.d.ts.map +1 -1
  81. package/dist/pages/ListDetail.js +27 -1
  82. package/dist/pages/ListDetail.js.map +1 -1
  83. package/dist/pages/OpportunityDetail.d.ts.map +1 -1
  84. package/dist/pages/OpportunityDetail.js +62 -3
  85. package/dist/pages/OpportunityDetail.js.map +1 -1
  86. package/dist/pages/PartnershipDetail.d.ts.map +1 -1
  87. package/dist/pages/PartnershipDetail.js +17 -1
  88. package/dist/pages/PartnershipDetail.js.map +1 -1
  89. package/dist/pages/PeoplePage.d.ts.map +1 -1
  90. package/dist/pages/PeoplePage.js +17 -0
  91. package/dist/pages/PeoplePage.js.map +1 -1
  92. package/dist/pages/PersonDetail.d.ts.map +1 -1
  93. package/dist/pages/PersonDetail.js +63 -3
  94. package/dist/pages/PersonDetail.js.map +1 -1
  95. package/dist/pages/PlanningPage.d.ts.map +1 -1
  96. package/dist/pages/PlanningPage.js +3 -0
  97. package/dist/pages/PlanningPage.js.map +1 -1
  98. package/dist/pages/RaiseDetail.d.ts.map +1 -1
  99. package/dist/pages/RaiseDetail.js +11 -1
  100. package/dist/pages/RaiseDetail.js.map +1 -1
  101. package/dist/pages/RoleDetail.js +1 -1
  102. package/dist/pages/RoleDetail.js.map +1 -1
  103. package/dist/pages/SearchPage.d.ts.map +1 -1
  104. package/dist/pages/SearchPage.js +1 -0
  105. package/dist/pages/SearchPage.js.map +1 -1
  106. package/dist/pages/admin/DataPage.d.ts.map +1 -1
  107. package/dist/pages/admin/DataPage.js +18 -3
  108. package/dist/pages/admin/DataPage.js.map +1 -1
  109. package/dist/pages/admin/FieldsPage.d.ts +2 -0
  110. package/dist/pages/admin/FieldsPage.d.ts.map +1 -0
  111. package/dist/pages/admin/FieldsPage.js +164 -0
  112. package/dist/pages/admin/FieldsPage.js.map +1 -0
  113. package/dist/pages/admin/PrivacyPage.d.ts +9 -0
  114. package/dist/pages/admin/PrivacyPage.d.ts.map +1 -0
  115. package/dist/pages/admin/PrivacyPage.js +170 -0
  116. package/dist/pages/admin/PrivacyPage.js.map +1 -0
  117. package/dist/pages/dashboard/attention.d.ts.map +1 -1
  118. package/dist/pages/dashboard/attention.js +3 -0
  119. package/dist/pages/dashboard/attention.js.map +1 -1
  120. package/dist/pages/forms/AddFieldMenu.d.ts +17 -0
  121. package/dist/pages/forms/AddFieldMenu.d.ts.map +1 -0
  122. package/dist/pages/forms/AddFieldMenu.js +43 -0
  123. package/dist/pages/forms/AddFieldMenu.js.map +1 -0
  124. package/dist/pages/forms/EmbedPanel.d.ts +4 -0
  125. package/dist/pages/forms/EmbedPanel.d.ts.map +1 -1
  126. package/dist/pages/forms/EmbedPanel.js +62 -3
  127. package/dist/pages/forms/EmbedPanel.js.map +1 -1
  128. package/dist/pages/forms/FieldsEditor.d.ts.map +1 -1
  129. package/dist/pages/forms/FieldsEditor.js +179 -36
  130. package/dist/pages/forms/FieldsEditor.js.map +1 -1
  131. package/dist/pages/forms/FormActions.d.ts +6 -0
  132. package/dist/pages/forms/FormActions.d.ts.map +1 -0
  133. package/dist/pages/forms/FormActions.js +149 -0
  134. package/dist/pages/forms/FormActions.js.map +1 -0
  135. package/dist/pages/forms/FormSettings.d.ts.map +1 -1
  136. package/dist/pages/forms/FormSettings.js +15 -19
  137. package/dist/pages/forms/FormSettings.js.map +1 -1
  138. package/dist/pages/forms/SubmissionDetail.d.ts +2 -0
  139. package/dist/pages/forms/SubmissionDetail.d.ts.map +1 -0
  140. package/dist/pages/forms/SubmissionDetail.js +76 -0
  141. package/dist/pages/forms/SubmissionDetail.js.map +1 -0
  142. package/dist/pages/forms/SubmissionsTable.d.ts.map +1 -1
  143. package/dist/pages/forms/SubmissionsTable.js +56 -8
  144. package/dist/pages/forms/SubmissionsTable.js.map +1 -1
  145. package/dist/pages/forms/fieldList.d.ts +11 -5
  146. package/dist/pages/forms/fieldList.d.ts.map +1 -1
  147. package/dist/pages/forms/fieldList.js +66 -6
  148. package/dist/pages/forms/fieldList.js.map +1 -1
  149. package/dist/pages/forms/template.d.ts +17 -0
  150. package/dist/pages/forms/template.d.ts.map +1 -1
  151. package/dist/pages/forms/template.js +93 -0
  152. package/dist/pages/forms/template.js.map +1 -1
  153. package/dist/pages/onboarding/WorkspaceStepPage.js +1 -1
  154. package/dist/pages/onboarding/WorkspaceStepPage.js.map +1 -1
  155. package/package.json +2 -2
  156. package/src/api/resource.ts +6 -1
  157. package/src/api/resources/consentPurposes.ts +83 -0
  158. package/src/api/resources/customFields.ts +88 -0
  159. package/src/api/resources/enquiries.ts +118 -0
  160. package/src/api/resources/forms.ts +58 -0
  161. package/src/api/resources/importJobs.ts +9 -0
  162. package/src/api/resources/opportunities.ts +3 -0
  163. package/src/api/resources/sampleData.ts +1 -0
  164. package/src/app/KelpieApp.tsx +12 -0
  165. package/src/components/ActivitiesPanel.tsx +1 -0
  166. package/src/components/CustomFieldsPanel.tsx +330 -0
  167. package/src/components/FormsPanel.tsx +86 -0
  168. package/src/components/PlanAttention.tsx +20 -3
  169. package/src/components/Shell.tsx +3 -0
  170. package/src/components/useHasCustomFields.ts +18 -0
  171. package/src/pages/CompanyDetail.tsx +36 -1
  172. package/src/pages/DealDetail.tsx +31 -0
  173. package/src/pages/DecisionsPage.tsx +2 -0
  174. package/src/pages/EnquiriesPage.tsx +434 -0
  175. package/src/pages/EnquiryDetail.tsx +554 -0
  176. package/src/pages/EnquiryStageSettingsPage.tsx +63 -0
  177. package/src/pages/FormDetail.tsx +23 -1
  178. package/src/pages/ListDetail.tsx +62 -0
  179. package/src/pages/OpportunityDetail.tsx +165 -2
  180. package/src/pages/PartnershipDetail.tsx +32 -0
  181. package/src/pages/PeoplePage.tsx +20 -0
  182. package/src/pages/PersonDetail.tsx +152 -3
  183. package/src/pages/PlanningPage.tsx +3 -0
  184. package/src/pages/RaiseDetail.tsx +21 -0
  185. package/src/pages/RoleDetail.tsx +1 -1
  186. package/src/pages/SearchPage.tsx +1 -0
  187. package/src/pages/admin/DataPage.tsx +51 -3
  188. package/src/pages/admin/FieldsPage.tsx +455 -0
  189. package/src/pages/admin/PrivacyPage.tsx +461 -0
  190. package/src/pages/dashboard/attention.ts +3 -0
  191. package/src/pages/forms/AddFieldMenu.tsx +121 -0
  192. package/src/pages/forms/EmbedPanel.tsx +163 -2
  193. package/src/pages/forms/FieldsEditor.tsx +539 -165
  194. package/src/pages/forms/FormActions.tsx +552 -0
  195. package/src/pages/forms/FormSettings.tsx +29 -70
  196. package/src/pages/forms/SubmissionDetail.tsx +249 -0
  197. package/src/pages/forms/SubmissionsTable.tsx +109 -16
  198. package/src/pages/forms/fieldList.ts +68 -5
  199. package/src/pages/forms/template.ts +103 -0
  200. package/src/pages/onboarding/WorkspaceStepPage.tsx +1 -1
@@ -6,6 +6,7 @@ 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'
9
10
  import { useDeals } from '../api/resources/deals.ts'
10
11
  import { useDeleteList, useList, useUpdateList } from '../api/resources/lists.ts'
11
12
  import {
@@ -13,6 +14,7 @@ import {
13
14
  useListMembers,
14
15
  useRemoveListMember,
15
16
  } from '../api/resources/listMembers.ts'
17
+ import { useEnquiries } from '../api/resources/enquiries.ts'
16
18
  import { useOpportunities } from '../api/resources/opportunities.ts'
17
19
  import { usePartnerships } from '../api/resources/partnerships.ts'
18
20
  import { usePeople } from '../api/resources/people.ts'
@@ -84,11 +86,57 @@ export function ListDetail(): React.JSX.Element {
84
86
 
85
87
  <ListDescription list={record} />
86
88
 
89
+ {record.targetType === 'person' && <ListConsentPurpose list={record} />}
90
+
87
91
  <ListMembers listId={record.id} targetType={record.targetType} />
88
92
  </div>
89
93
  )
90
94
  }
91
95
 
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
+
92
140
  function useListPatch(list: List): PatchResult<ListInput> {
93
141
  return usePatch(useUpdateList, list)
94
142
  }
@@ -271,6 +319,8 @@ function detailPathFor(targetType: RecordTargetType, targetId: string): string {
271
319
  return `/partnerships/${targetId}`
272
320
  case 'raise':
273
321
  return `/fundraising/${targetId}`
322
+ case 'enquiry':
323
+ return `/enquiries/${targetId}`
274
324
  case 'candidate':
275
325
  // A candidate has no page of its own; the person's does.
276
326
  return `/hiring`
@@ -304,6 +354,8 @@ function MemberPicker({
304
354
  return <PartnershipsPicker attached={attached} onPick={onPick} />
305
355
  case 'raise':
306
356
  return <RaisesPicker attached={attached} onPick={onPick} />
357
+ case 'enquiry':
358
+ return <EnquiriesPicker attached={attached} onPick={onPick} />
307
359
  case 'candidate':
308
360
  return (
309
361
  <p className="text-[12px] text-ink-faint">
@@ -379,6 +431,16 @@ function RaisesPicker({ attached, onPick }: PickerProps): React.JSX.Element {
379
431
  return <PickerBox onQueryChange={setSearch} onPick={onPick} options={options} />
380
432
  }
381
433
 
434
+ function EnquiriesPicker({ attached, onPick }: PickerProps): React.JSX.Element {
435
+ const [search, setSearch] = useState('')
436
+ const records = useEnquiries({ term: trimmedOrUndefined(search) })
437
+ const options: readonly SearchOption[] = records.records
438
+ .filter((row) => !attached.has(row.id))
439
+ .map((row) => ({ id: row.id, label: row.name }))
440
+
441
+ return <PickerBox onQueryChange={setSearch} onPick={onPick} options={options} />
442
+ }
443
+
382
444
  function PickerBox({
383
445
  onQueryChange,
384
446
  onPick,
@@ -1,16 +1,19 @@
1
1
  import type { Opportunity, OpportunityInput, PipelineStage } from '@kelpie/schemas'
2
2
  import { useState } from 'react'
3
+ import type { FormEvent } from 'react'
3
4
  import { Link, useNavigate, useParams } from 'react-router'
4
5
 
5
6
  import { usePatch } from '../api/resource.ts'
6
7
  import type { PatchResult } from '../api/resource.ts'
7
8
  import { useCompanies, useCompany } from '../api/resources/companies.ts'
9
+ import { useFormSubmissionsForRecord } from '../api/resources/forms.ts'
8
10
  import { useMembers } from '../api/resources/members.ts'
9
11
  import {
10
12
  useDeleteOpportunity,
11
13
  useOpportunity,
12
14
  useUpdateOpportunity,
13
15
  } from '../api/resources/opportunities.ts'
16
+ import { usePeople } from '../api/resources/people.ts'
14
17
  import { usePipelineStages } from '../api/resources/pipelineStages.ts'
15
18
  import { useRecordPlanItems } from '../api/resources/planItems.ts'
16
19
  import { ActivitiesPanel, LatestActivity } from '../components/ActivitiesPanel.tsx'
@@ -20,6 +23,9 @@ import type { ChipTone } from '../components/Chip.tsx'
20
23
  import { DecisionsPanel } from '../components/DecisionsPanel.tsx'
21
24
  import { DeleteRecord } from '../components/DeleteRecord.tsx'
22
25
  import { EntitySearch } from '../components/EntitySearch.tsx'
26
+ import { FormsPanel } from '../components/FormsPanel.tsx'
27
+ import { CustomFieldsPanel } from '../components/CustomFieldsPanel.tsx'
28
+ import { useHasCustomFields } from '../components/useHasCustomFields.ts'
23
29
  import { InlineEdit } from '../components/InlineEdit.tsx'
24
30
  import { ListsPanel } from '../components/ListsPanel.tsx'
25
31
  import { NotesPanel } from '../components/NotesPanel.tsx'
@@ -28,6 +34,7 @@ import { PlanPanel } from '../components/PlanPanel.tsx'
28
34
  import { ErrorPanel, LoadingPanel, NotFoundPanel } from '../components/QueryState.tsx'
29
35
  import { RecordTabs } from '../components/RecordTabs.tsx'
30
36
  import type { RecordTabDescriptor } from '../components/RecordTabs.tsx'
37
+ import { SectionHeader } from '../components/SectionHeader.tsx'
31
38
  import { SidebarField } from '../components/SidebarField.tsx'
32
39
  import { SummaryBlock } from '../components/SummaryBlock.tsx'
33
40
  import { formatDay } from '../lib/dates.ts'
@@ -39,8 +46,10 @@ import { toTags } from './fields.ts'
39
46
  * One opportunity.
40
47
  *
41
48
  * The same shape as `DealDetail` minus what an opportunity does not have: no
42
- * value, no contacts, and a company that may be absent. A UI module can add its
43
- * own tab through the `opportunity` record-tab slot.
49
+ * value, and a company that may be absent. People attach through `person_links`
50
+ * the same way they do on a deal, and they are shown in the same contacts
51
+ * section in the aside. A UI module can add its own tab through the
52
+ * `opportunity` record-tab slot.
44
53
  */
45
54
 
46
55
  const STAGE_TONES: Readonly<Record<string, ChipTone>> = {
@@ -54,7 +63,9 @@ export function OpportunityDetail(): React.JSX.Element {
54
63
  const { record, isLoading, isNotFound, error } = useOpportunity(id)
55
64
  const deleteOpportunity = useDeleteOpportunity()
56
65
  const moduleTabs = inSlotOrder(useRecordTabs('opportunity'))
66
+ const hasCustomFields = useHasCustomFields('opportunity')
57
67
  const [activeTab, setActiveTab] = useState('overview')
68
+ const formSubmissions = useFormSubmissionsForRecord('opportunity', id)
58
69
 
59
70
  if (isNotFound) {
60
71
  return <NotFoundPanel label="Opportunity" backTo="/opportunities" />
@@ -70,11 +81,15 @@ export function OpportunityDetail(): React.JSX.Element {
70
81
 
71
82
  const tabs: readonly RecordTabDescriptor<string>[] = [
72
83
  { id: 'overview', label: 'Overview' },
84
+ ...(hasCustomFields ? [{ id: 'fields', label: 'Fields' }] : []),
73
85
  { id: 'plan', label: 'Plan' },
74
86
  { id: 'activity', label: 'Activity' },
75
87
  { id: 'notes', label: 'Notes' },
76
88
  { id: 'decisions', label: 'Decisions' },
77
89
  { id: 'lists', label: 'Lists' },
90
+ ...(formSubmissions.records.length === 0
91
+ ? []
92
+ : [{ id: 'forms', label: 'Forms', count: formSubmissions.records.length }]),
78
93
  ...moduleTabs.map((tab) => ({ id: tab.id, label: tab.label })),
79
94
  ]
80
95
  const active = tabs.some((tab) => tab.id === activeTab) ? activeTab : 'overview'
@@ -116,6 +131,7 @@ export function OpportunityDetail(): React.JSX.Element {
116
131
  ariaLabel="Opportunity sections"
117
132
  >
118
133
  {active === 'overview' && <OpportunityOverview opportunity={record} />}
134
+ {active === 'fields' && <OpportunityFields opportunity={record} />}
119
135
  {active === 'plan' && <PlanPanel targetType="opportunity" targetId={record.id} />}
120
136
  {active === 'activity' && (
121
137
  <ActivitiesPanel targetType="opportunity" targetId={record.id} />
@@ -125,12 +141,14 @@ export function OpportunityDetail(): React.JSX.Element {
125
141
  <DecisionsPanel targetType="opportunity" targetId={record.id} />
126
142
  )}
127
143
  {active === 'lists' && <ListsPanel targetType="opportunity" targetId={record.id} />}
144
+ {active === 'forms' && <FormsPanel targetType="opportunity" targetId={record.id} />}
128
145
  {moduleTab?.render({ objectType: 'opportunity', recordId: record.id })}
129
146
  </RecordTabs>
130
147
  </div>
131
148
 
132
149
  <aside className="space-y-4 text-[12px] lg:sticky lg:top-6">
133
150
  <OpportunitySidebar opportunity={record} />
151
+ <OpportunityContacts opportunity={record} />
134
152
  </aside>
135
153
  </div>
136
154
  </div>
@@ -338,3 +356,148 @@ function OpportunitySidebar({
338
356
  </section>
339
357
  )
340
358
  }
359
+
360
+ function OpportunityFields({
361
+ opportunity,
362
+ }: {
363
+ readonly opportunity: Opportunity
364
+ }): React.JSX.Element {
365
+ const { patch, error } = useOpportunityPatch(opportunity)
366
+ return (
367
+ <div className="space-y-4">
368
+ {error !== null && <ErrorPanel error={error} />}
369
+ <CustomFieldsPanel
370
+ objectType="opportunity"
371
+ values={opportunity.customFields}
372
+ onPatch={(customFields) => {
373
+ patch({ customFields })
374
+ }}
375
+ />
376
+ </div>
377
+ )
378
+ }
379
+
380
+ /** The people on the opportunity, linked and unlinked by replacing `person_ids`. */
381
+ function OpportunityContacts({
382
+ opportunity,
383
+ }: {
384
+ readonly opportunity: Opportunity
385
+ }): React.JSX.Element {
386
+ const { patch, error } = useOpportunityPatch(opportunity)
387
+
388
+ const [adding, setAdding] = useState(false)
389
+ const [personId, setPersonId] = useState('')
390
+ const [search, setSearch] = useState('')
391
+
392
+ // No `?id=` filter exists on people, so names come from the directory's first
393
+ // page. Past it, a contact renders by id rather than silently vanishing.
394
+ const directory = usePeople({ limit: 200 })
395
+ const searchable = usePeople({ term: search.trim().length > 0 ? search.trim() : undefined })
396
+ const nameById = new Map(directory.records.map((person) => [person.id, person.name]))
397
+ const linked = new Set(opportunity.personIds)
398
+
399
+ function reset(): void {
400
+ setAdding(false)
401
+ setPersonId('')
402
+ setSearch('')
403
+ }
404
+
405
+ function submit(event: FormEvent): void {
406
+ event.preventDefault()
407
+
408
+ if (personId.length === 0 || linked.has(personId)) {
409
+ return
410
+ }
411
+
412
+ patch({ personIds: [...opportunity.personIds, personId] })
413
+ reset()
414
+ }
415
+
416
+ return (
417
+ <section className="rounded-md border border-border">
418
+ <div className="border-b border-border px-3.5 py-2.5">
419
+ <SectionHeader
420
+ title="Contacts"
421
+ onAdd={() => {
422
+ setAdding((current) => !current)
423
+ }}
424
+ addLabel="Add contact"
425
+ compact
426
+ />
427
+ </div>
428
+
429
+ {error !== null && (
430
+ <div className="px-3.5 py-2">
431
+ <ErrorPanel error={error} />
432
+ </div>
433
+ )}
434
+
435
+ <ul className="divide-y divide-border">
436
+ {opportunity.personIds.length === 0 && !adding && (
437
+ <li className="px-3.5 py-4 text-[12px] text-ink-faint">No contacts yet.</li>
438
+ )}
439
+ {opportunity.personIds.map((linkedPersonId) => (
440
+ <li
441
+ key={linkedPersonId}
442
+ className="flex items-start justify-between gap-2 px-3.5 py-2.5"
443
+ >
444
+ <Link
445
+ to={`/people/${linkedPersonId}`}
446
+ className="text-[13px] font-medium text-ink hover:text-accent"
447
+ >
448
+ {nameById.get(linkedPersonId) ?? linkedPersonId}
449
+ </Link>
450
+ <button
451
+ type="button"
452
+ onClick={() => {
453
+ patch({
454
+ personIds: opportunity.personIds.filter(
455
+ (candidate) => candidate !== linkedPersonId,
456
+ ),
457
+ })
458
+ }}
459
+ className="text-[11px] font-medium text-danger hover:underline"
460
+ >
461
+ Remove
462
+ </button>
463
+ </li>
464
+ ))}
465
+ </ul>
466
+
467
+ {adding && (
468
+ <form onSubmit={submit} className="space-y-2 border-t border-border bg-surface px-3.5 py-3">
469
+ <EntitySearch
470
+ options={searchable.records
471
+ .filter((person) => !linked.has(person.id))
472
+ .map((person) => ({
473
+ id: person.id,
474
+ label: person.name,
475
+ meta: person.email ?? undefined,
476
+ }))}
477
+ value={personId}
478
+ onChange={setPersonId}
479
+ onQueryChange={setSearch}
480
+ placeholder="Search people…"
481
+ emptyMessage="No people match"
482
+ required
483
+ />
484
+ <div className="flex justify-end gap-2">
485
+ <button
486
+ type="button"
487
+ onClick={reset}
488
+ className="rounded-md px-2.5 py-1 text-[12px] font-medium text-ink-muted hover:text-ink"
489
+ >
490
+ Cancel
491
+ </button>
492
+ <button
493
+ type="submit"
494
+ className="rounded-md bg-accent px-3 py-1.5 text-[12px] font-semibold text-accent-fg hover:bg-accent-hover"
495
+ >
496
+ Add
497
+ </button>
498
+ </div>
499
+ </form>
500
+ )}
501
+ </section>
502
+ )
503
+ }
@@ -6,6 +6,7 @@ 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 { useFormSubmissionsForRecord } from '../api/resources/forms.ts'
9
10
  import { useMembers } from '../api/resources/members.ts'
10
11
  import {
11
12
  useDeletePartnership,
@@ -22,6 +23,9 @@ import type { ChipTone } from '../components/Chip.tsx'
22
23
  import { DecisionsPanel } from '../components/DecisionsPanel.tsx'
23
24
  import { DeleteRecord } from '../components/DeleteRecord.tsx'
24
25
  import { EntitySearch } from '../components/EntitySearch.tsx'
26
+ import { FormsPanel } from '../components/FormsPanel.tsx'
27
+ import { CustomFieldsPanel } from '../components/CustomFieldsPanel.tsx'
28
+ import { useHasCustomFields } from '../components/useHasCustomFields.ts'
25
29
  import { InlineEdit } from '../components/InlineEdit.tsx'
26
30
  import { ListsPanel } from '../components/ListsPanel.tsx'
27
31
  import { NotesPanel } from '../components/NotesPanel.tsx'
@@ -59,7 +63,9 @@ export function PartnershipDetail(): React.JSX.Element {
59
63
  const { record, isLoading, isNotFound, error } = usePartnership(id)
60
64
  const deletePartnership = useDeletePartnership()
61
65
  const moduleTabs = inSlotOrder(useRecordTabs('partnership'))
66
+ const hasCustomFields = useHasCustomFields('partnership')
62
67
  const [activeTab, setActiveTab] = useState('overview')
68
+ const formSubmissions = useFormSubmissionsForRecord('partnership', id)
63
69
 
64
70
  if (isNotFound) {
65
71
  return <NotFoundPanel label="Partnership" backTo="/partnerships" />
@@ -75,11 +81,15 @@ export function PartnershipDetail(): React.JSX.Element {
75
81
 
76
82
  const tabs: readonly RecordTabDescriptor<string>[] = [
77
83
  { id: 'overview', label: 'Overview' },
84
+ ...(hasCustomFields ? [{ id: 'fields', label: 'Fields' }] : []),
78
85
  { id: 'plan', label: 'Plan' },
79
86
  { id: 'activity', label: 'Activity' },
80
87
  { id: 'notes', label: 'Notes' },
81
88
  { id: 'decisions', label: 'Decisions' },
82
89
  { id: 'lists', label: 'Lists' },
90
+ ...(formSubmissions.records.length === 0
91
+ ? []
92
+ : [{ id: 'forms', label: 'Forms', count: formSubmissions.records.length }]),
83
93
  ...moduleTabs.map((tab) => ({ id: tab.id, label: tab.label })),
84
94
  ]
85
95
  const active = tabs.some((tab) => tab.id === activeTab) ? activeTab : 'overview'
@@ -121,6 +131,7 @@ export function PartnershipDetail(): React.JSX.Element {
121
131
  ariaLabel="Partnership sections"
122
132
  >
123
133
  {active === 'overview' && <PartnershipOverview partnership={record} />}
134
+ {active === 'fields' && <PartnershipFields partnership={record} />}
124
135
  {active === 'plan' && <PlanPanel targetType="partnership" targetId={record.id} />}
125
136
  {active === 'activity' && (
126
137
  <ActivitiesPanel targetType="partnership" targetId={record.id} />
@@ -130,6 +141,7 @@ export function PartnershipDetail(): React.JSX.Element {
130
141
  <DecisionsPanel targetType="partnership" targetId={record.id} />
131
142
  )}
132
143
  {active === 'lists' && <ListsPanel targetType="partnership" targetId={record.id} />}
144
+ {active === 'forms' && <FormsPanel targetType="partnership" targetId={record.id} />}
133
145
  {moduleTab?.render({ objectType: 'partnership', recordId: record.id })}
134
146
  </RecordTabs>
135
147
  </div>
@@ -367,6 +379,26 @@ function PartnershipSidebar({
367
379
  )
368
380
  }
369
381
 
382
+ function PartnershipFields({
383
+ partnership,
384
+ }: {
385
+ readonly partnership: Partnership
386
+ }): React.JSX.Element {
387
+ const { patch, error } = usePartnershipPatch(partnership)
388
+ return (
389
+ <div className="space-y-4">
390
+ {error !== null && <ErrorPanel error={error} />}
391
+ <CustomFieldsPanel
392
+ objectType="partnership"
393
+ values={partnership.customFields}
394
+ onPatch={(customFields) => {
395
+ patch({ customFields })
396
+ }}
397
+ />
398
+ </div>
399
+ )
400
+ }
401
+
370
402
  /** The key people on the partnership, linked and unlinked by replacing `person_ids`. */
371
403
  function PartnershipKeyPeople({
372
404
  partnership,
@@ -29,6 +29,14 @@ const DEFAULT_VISIBLE_KEYS: readonly string[] = ['name', 'email', 'phones', 'com
29
29
  /** The sort fields `/v1/people` accepts. Anything else is a client-only sort. */
30
30
  const SERVER_SORT_KEYS: readonly string[] = ['name', 'created_at', 'updated_at']
31
31
 
32
+ /** Written out because "Firstname" is what deriving these from the key would give. */
33
+ const NAME_PART_HEADERS = {
34
+ salutation: 'Salutation',
35
+ firstName: 'First name',
36
+ lastName: 'Last name',
37
+ suffix: 'Suffix',
38
+ } as const
39
+
32
40
  /**
33
41
  * The People list.
34
42
  *
@@ -65,6 +73,18 @@ export function PeoplePage(): React.JSX.Element {
65
73
  sortKey: 'name',
66
74
  render: (person) => <span className="font-medium text-ink">{person.name}</span>,
67
75
  },
76
+ // Hidden by default, and sorted page-locally rather than by the server: the
77
+ // parts are nullable, and `?sort=` is keyset paged, which cannot seek past a
78
+ // null. The catalog carries them so a workspace that imported them can put
79
+ // them back through the Columns picker.
80
+ ...(['salutation', 'firstName', 'lastName', 'suffix'] as const).map(
81
+ (key): Column<Person> => ({
82
+ key,
83
+ header: NAME_PART_HEADERS[key],
84
+ getSortValue: (person) => person[key],
85
+ render: (person) => person[key] ?? '—',
86
+ }),
87
+ ),
68
88
  {
69
89
  key: 'position',
70
90
  header: 'Position',