@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
@@ -1,5 +1,11 @@
1
- import { IN_PROCESS, PREFERRED_CHANNELS } from '@kelpie/schemas'
2
- import type { Candidate, Person, PersonInput, PreferredChannel } from '@kelpie/schemas'
1
+ import { CONSENT_PURPOSE_STATUS_LABELS, IN_PROCESS, PREFERRED_CHANNELS } from '@kelpie/schemas'
2
+ import type {
3
+ Candidate,
4
+ ConsentStatus,
5
+ Person,
6
+ PersonInput,
7
+ PreferredChannel,
8
+ } from '@kelpie/schemas'
3
9
  import { useState } from 'react'
4
10
  import type { FormEvent } from 'react'
5
11
  import { Link, useNavigate, useParams } from 'react-router'
@@ -9,6 +15,8 @@ import type { PatchResult } from '../api/resource.ts'
9
15
  import { useCandidates } from '../api/resources/candidates.ts'
10
16
  import { useCompanies } from '../api/resources/companies.ts'
11
17
  import { useDeals } from '../api/resources/deals.ts'
18
+ import { useEnquiries } from '../api/resources/enquiries.ts'
19
+ import { useFormSubmissionsForRecord } from '../api/resources/forms.ts'
12
20
  import { usePartnerships } from '../api/resources/partnerships.ts'
13
21
  import { useDeletePerson, usePerson, useUpdatePerson } from '../api/resources/people.ts'
14
22
  import {
@@ -21,8 +29,11 @@ import { ActivitiesPanel, LatestActivity } from '../components/ActivitiesPanel.t
21
29
  import { AgentTasks } from '../components/AgentTasks.tsx'
22
30
  import { Chip } from '../components/Chip.tsx'
23
31
  import { DecisionsPanel } from '../components/DecisionsPanel.tsx'
32
+ import { FormsPanel } from '../components/FormsPanel.tsx'
24
33
  import { DeleteRecord } from '../components/DeleteRecord.tsx'
25
34
  import { EntitySearch } from '../components/EntitySearch.tsx'
35
+ import { CustomFieldsPanel } from '../components/CustomFieldsPanel.tsx'
36
+ import { useHasCustomFields } from '../components/useHasCustomFields.ts'
26
37
  import { InlineEdit } from '../components/InlineEdit.tsx'
27
38
  import { NotesPanel } from '../components/NotesPanel.tsx'
28
39
  import { RelatedPlanAttention } from '../components/PlanAttention.tsx'
@@ -70,10 +81,12 @@ export function PersonDetail(): React.JSX.Element {
70
81
  const { record, isLoading, isNotFound, error } = usePerson(id)
71
82
  const deletePerson = useDeletePerson()
72
83
  const moduleTabs = inSlotOrder(useRecordTabs('person'))
84
+ const hasCustomFields = useHasCustomFields('person')
73
85
  const [activeTab, setActiveTab] = useState('overview')
74
86
  const candidacies = useCandidates({ personIds: id === undefined ? [] : [id] }, {
75
87
  enabled: id !== undefined,
76
88
  })
89
+ const formSubmissions = useFormSubmissionsForRecord('person', id)
77
90
 
78
91
  if (isNotFound) {
79
92
  return <NotFoundPanel label="Person" backTo="/people" />
@@ -89,6 +102,7 @@ export function PersonDetail(): React.JSX.Element {
89
102
 
90
103
  const tabs: readonly RecordTabDescriptor<string>[] = [
91
104
  { id: 'overview', label: 'Overview' },
105
+ ...(hasCustomFields ? [{ id: 'fields', label: 'Fields' }] : []),
92
106
  { id: 'activity', label: 'Activity' },
93
107
  ...(candidacies.records.length === 0
94
108
  ? []
@@ -96,6 +110,9 @@ export function PersonDetail(): React.JSX.Element {
96
110
  { id: 'notes', label: 'Notes' },
97
111
  { id: 'decisions', label: 'Decisions' },
98
112
  { id: 'lists', label: 'Lists' },
113
+ ...(formSubmissions.records.length === 0
114
+ ? []
115
+ : [{ id: 'forms', label: 'Forms', count: formSubmissions.records.length }]),
99
116
  ...moduleTabs.map((tab) => ({ id: tab.id, label: tab.label })),
100
117
  ]
101
118
  const active = tabs.some((tab) => tab.id === activeTab) ? activeTab : 'overview'
@@ -132,6 +149,7 @@ export function PersonDetail(): React.JSX.Element {
132
149
 
133
150
  <RecordTabs tabs={tabs} active={active} onChange={setActiveTab} ariaLabel="Person sections">
134
151
  {active === 'overview' && <PersonOverview person={record} />}
152
+ {active === 'fields' && <PersonFields person={record} />}
135
153
  {active === 'activity' && <ActivitiesPanel targetType="person" targetId={record.id} />}
136
154
  {active === 'hiring' && (
137
155
  <PersonHiring candidacies={candidacies.records} personName={record.name} />
@@ -139,12 +157,15 @@ export function PersonDetail(): React.JSX.Element {
139
157
  {active === 'notes' && <NotesPanel targetType="person" targetId={record.id} />}
140
158
  {active === 'decisions' && <DecisionsPanel targetType="person" targetId={record.id} />}
141
159
  {active === 'lists' && <ListsPanel targetType="person" targetId={record.id} />}
160
+ {active === 'forms' && <FormsPanel targetType="person" targetId={record.id} />}
142
161
  {moduleTab?.render({ objectType: 'person', recordId: record.id })}
143
162
  </RecordTabs>
144
163
  </div>
145
164
 
146
165
  <aside className="space-y-4 text-[12px] lg:sticky lg:top-6">
166
+ <PersonNameParts person={record} />
147
167
  <PersonSidebar person={record} />
168
+ <PersonConsents person={record} />
148
169
  <PersonPositions person={record} />
149
170
  </aside>
150
171
  </div>
@@ -200,6 +221,7 @@ function PersonOverview({ person }: { readonly person: Person }): React.JSX.Elem
200
221
  const { patch, error } = usePersonPatch(person)
201
222
  const deals = useDeals({ personIds: [person.id] })
202
223
  const partnerships = usePartnerships({ personIds: [person.id] })
224
+ const enquiries = useEnquiries({ personIds: [person.id] })
203
225
 
204
226
  return (
205
227
  <div className="space-y-8">
@@ -213,7 +235,8 @@ function PersonOverview({ person }: { readonly person: Person }): React.JSX.Elem
213
235
  <RelatedPlanAttention
214
236
  deals={deals.records}
215
237
  partnerships={partnerships.records}
216
- isLoading={deals.isLoading || partnerships.isLoading}
238
+ enquiries={enquiries.records}
239
+ isLoading={deals.isLoading || partnerships.isLoading || enquiries.isLoading}
217
240
  />
218
241
  <LatestActivity targetType="person" targetId={person.id} />
219
242
  </div>
@@ -300,6 +323,46 @@ function PersonHiring({
300
323
  )
301
324
  }
302
325
 
326
+ /**
327
+ * The parts of the name, beside the name itself rather than instead of it.
328
+ *
329
+ * Editing one of these does not rename the record: the heading is `name`, and
330
+ * `name` is only ever what somebody put there. Both halves are on this page at
331
+ * once so that is visible rather than surprising — a first name corrected here
332
+ * leaves the heading alone, and the heading is one click away when it should
333
+ * change too.
334
+ */
335
+ function PersonNameParts({ person }: { readonly person: Person }): React.JSX.Element {
336
+ const { patch, error } = usePersonPatch(person)
337
+ const fields = [
338
+ { label: 'Salutation', value: person.salutation, key: 'salutation' },
339
+ { label: 'First name', value: person.firstName, key: 'firstName' },
340
+ { label: 'Last name', value: person.lastName, key: 'lastName' },
341
+ { label: 'Suffix', value: person.suffix, key: 'suffix' },
342
+ ] as const
343
+
344
+ return (
345
+ <section className="rounded-md border border-border p-3">
346
+ {error !== null && (
347
+ <div className="mb-2">
348
+ <ErrorPanel error={error} />
349
+ </div>
350
+ )}
351
+ {fields.map((field) => (
352
+ <SidebarField key={field.key} label={field.label}>
353
+ <InlineEdit
354
+ value={field.value ?? ''}
355
+ onChange={(next) => {
356
+ patch({ [field.key]: next.length > 0 ? next : null })
357
+ }}
358
+ displayClassName="not-italic normal-case text-[12px]"
359
+ />
360
+ </SidebarField>
361
+ ))}
362
+ </section>
363
+ )
364
+ }
365
+
303
366
  function PersonSidebar({ person }: { readonly person: Person }): React.JSX.Element {
304
367
  const { patch, error } = usePersonPatch(person)
305
368
 
@@ -379,6 +442,22 @@ function PersonSidebar({ person }: { readonly person: Person }): React.JSX.Eleme
379
442
  )
380
443
  }
381
444
 
445
+ function PersonFields({ person }: { readonly person: Person }): React.JSX.Element {
446
+ const { patch, error } = usePersonPatch(person)
447
+ return (
448
+ <div className="space-y-4">
449
+ {error !== null && <ErrorPanel error={error} />}
450
+ <CustomFieldsPanel
451
+ objectType="person"
452
+ values={person.customFields}
453
+ onPatch={(customFields) => {
454
+ patch({ customFields })
455
+ }}
456
+ />
457
+ </div>
458
+ )
459
+ }
460
+
382
461
  /**
383
462
  * The companies this person holds a title at.
384
463
  *
@@ -526,3 +605,73 @@ function PersonPositions({ person }: { readonly person: Person }): React.JSX.Ele
526
605
  </section>
527
606
  )
528
607
  }
608
+
609
+ /**
610
+ * The person's effective consent for every workspace purpose, plus the
611
+ * global `do_not_contact` toggle.
612
+ *
613
+ * An `inherited: true` entry has no explicit `person_consents` row and reads
614
+ * the purpose's default; picking a status writes an override, and the
615
+ * "Inherit" choice clears the override again. Do-not-contact is the global
616
+ * Article 21 objection, independent of per-purpose consent.
617
+ */
618
+ function PersonConsents({ person }: { readonly person: Person }): React.JSX.Element {
619
+ const { patch, error } = usePersonPatch(person)
620
+
621
+ function setConsent(purposeSlug: string, status: ConsentStatus | null): void {
622
+ patch({ consents: [{ purposeSlug, status }] })
623
+ }
624
+
625
+ return (
626
+ <section className="rounded-md border border-border p-3">
627
+ <h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-ink-faint">
628
+ Privacy and marketing
629
+ </h3>
630
+ {error !== null && (
631
+ <div className="mb-2">
632
+ <ErrorPanel error={error} />
633
+ </div>
634
+ )}
635
+ <label className="flex items-center gap-2 py-1 text-[12px] text-ink">
636
+ <input
637
+ type="checkbox"
638
+ checked={person.doNotContact}
639
+ onChange={(event) => {
640
+ patch({ doNotContact: event.target.checked })
641
+ }}
642
+ />
643
+ Do not contact
644
+ </label>
645
+ {person.consents.length === 0 ? (
646
+ <p className="mt-2 text-[11px] text-ink-muted">
647
+ No consent purposes yet. Define them on /admin/privacy.
648
+ </p>
649
+ ) : (
650
+ person.consents.map((consent) => (
651
+ <SidebarField key={consent.purposeSlug} label={consent.purposeLabel}>
652
+ <div className="flex items-center gap-2">
653
+ <select
654
+ value={consent.inherited ? '' : consent.status}
655
+ onChange={(event) => {
656
+ const value = event.target.value
657
+ setConsent(
658
+ consent.purposeSlug,
659
+ value === '' ? null : (value as ConsentStatus),
660
+ )
661
+ }}
662
+ className="rounded-md border border-border bg-transparent px-2 py-1 text-[12px] outline-none focus:border-accent"
663
+ >
664
+ <option value="">Inherit ({CONSENT_PURPOSE_STATUS_LABELS[consent.status]})</option>
665
+ <option value="granted">Granted</option>
666
+ <option value="withdrawn">Withdrawn</option>
667
+ </select>
668
+ {!consent.inherited && consent.source !== null && (
669
+ <span className="text-[10px] text-ink-faint">via {consent.source}</span>
670
+ )}
671
+ </div>
672
+ </SidebarField>
673
+ ))
674
+ )}
675
+ </section>
676
+ )
677
+ }
@@ -9,6 +9,7 @@ import { useState } from 'react'
9
9
  import type { FormEvent } from 'react'
10
10
 
11
11
  import { useDeals } from '../api/resources/deals.ts'
12
+ import { useEnquiries } from '../api/resources/enquiries.ts'
12
13
  import { useMembers } from '../api/resources/members.ts'
13
14
  import { useOpportunities } from '../api/resources/opportunities.ts'
14
15
  import { usePartnerships } from '../api/resources/partnerships.ts'
@@ -213,8 +214,10 @@ function usePipelineTargets(): {
213
214
  const opportunities = useOpportunities({ limit: MAX_PAGE_SIZE })
214
215
  const raises = useRaises({ limit: MAX_PAGE_SIZE })
215
216
  const partnerships = usePartnerships({ limit: MAX_PAGE_SIZE })
217
+ const enquiries = useEnquiries({ limit: MAX_PAGE_SIZE })
216
218
 
217
219
  const byKind: Readonly<Record<PipelineKind, readonly PipelineTarget[]>> = {
220
+ enquiry: enquiries.records.map((record) => ({ id: record.id, name: record.name })),
218
221
  deal: deals.records.map((record) => ({ id: record.id, name: record.name })),
219
222
  opportunity: opportunities.records.map((record) => ({ id: record.id, name: record.name })),
220
223
  raise: raises.records.map((record) => ({ id: record.id, name: record.name })),
@@ -18,6 +18,8 @@ import type { ChipTone } from '../components/Chip.tsx'
18
18
  import { DecisionsPanel } from '../components/DecisionsPanel.tsx'
19
19
  import { DeleteRecord } from '../components/DeleteRecord.tsx'
20
20
  import { EntitySearch } from '../components/EntitySearch.tsx'
21
+ import { CustomFieldsPanel } from '../components/CustomFieldsPanel.tsx'
22
+ import { useHasCustomFields } from '../components/useHasCustomFields.ts'
21
23
  import { InlineEdit } from '../components/InlineEdit.tsx'
22
24
  import { ListsPanel } from '../components/ListsPanel.tsx'
23
25
  import { NotesPanel } from '../components/NotesPanel.tsx'
@@ -60,6 +62,7 @@ export function RaiseDetail(): React.JSX.Element {
60
62
  const { record, isLoading, isNotFound, error } = useRaise(id)
61
63
  const deleteRaise = useDeleteRaise()
62
64
  const moduleTabs = inSlotOrder(useRecordTabs('raise'))
65
+ const hasCustomFields = useHasCustomFields('raise')
63
66
  const [activeTab, setActiveTab] = useState('overview')
64
67
 
65
68
  if (isNotFound) {
@@ -76,6 +79,7 @@ export function RaiseDetail(): React.JSX.Element {
76
79
 
77
80
  const tabs: readonly RecordTabDescriptor<string>[] = [
78
81
  { id: 'overview', label: 'Overview' },
82
+ ...(hasCustomFields ? [{ id: 'fields', label: 'Fields' }] : []),
79
83
  { id: 'plan', label: 'Plan' },
80
84
  { id: 'activity', label: 'Activity' },
81
85
  { id: 'notes', label: 'Notes' },
@@ -117,6 +121,7 @@ export function RaiseDetail(): React.JSX.Element {
117
121
 
118
122
  <RecordTabs tabs={tabs} active={active} onChange={setActiveTab} ariaLabel="Raise sections">
119
123
  {active === 'overview' && <RaiseOverview raise={record} />}
124
+ {active === 'fields' && <RaiseFields raise={record} />}
120
125
  {active === 'plan' && <PlanPanel targetType="raise" targetId={record.id} />}
121
126
  {active === 'activity' && <ActivitiesPanel targetType="raise" targetId={record.id} />}
122
127
  {active === 'notes' && <NotesPanel targetType="raise" targetId={record.id} />}
@@ -384,6 +389,22 @@ function RaiseSidebar({ raise }: { readonly raise: Raise }): React.JSX.Element {
384
389
  )
385
390
  }
386
391
 
392
+ function RaiseFields({ raise }: { readonly raise: Raise }): React.JSX.Element {
393
+ const { patch, error } = useRaisePatch(raise)
394
+ return (
395
+ <div className="space-y-4">
396
+ {error !== null && <ErrorPanel error={error} />}
397
+ <CustomFieldsPanel
398
+ objectType="raise"
399
+ values={raise.customFields}
400
+ onPatch={(customFields) => {
401
+ patch({ customFields })
402
+ }}
403
+ />
404
+ </div>
405
+ )
406
+ }
407
+
387
408
  /** The key people on the raise, linked and unlinked by replacing `person_ids`. */
388
409
  function RaiseKeyPeople({ raise }: { readonly raise: Raise }): React.JSX.Element {
389
410
  const { patch, error } = useRaisePatch(raise)
@@ -243,7 +243,7 @@ function RoleCandidates({ role }: { readonly role: Role }): React.JSX.Element {
243
243
  ) : (
244
244
  <>
245
245
  <Paginator list={candidates} placement="top" />
246
- <ul className="overflow-hidden rounded-md border border-border">
246
+ <ul className="rounded-md border border-border">
247
247
  {candidates.records.map((candidate) => (
248
248
  <CandidateRow
249
249
  key={candidate.id}
@@ -19,6 +19,7 @@ const COLLECTIONS: Readonly<Record<SearchCollection, { label: string; path: (id:
19
19
  person: { label: 'People', path: (id) => `/people/${id}` },
20
20
  role: { label: 'Roles', path: (id) => `/hiring/${id}` },
21
21
  company: { label: 'Companies', path: (id) => `/companies/${id}` },
22
+ enquiry: { label: 'Enquiries', path: (id) => `/enquiries/${id}` },
22
23
  deal: { label: 'Deals', path: (id) => `/deals/${id}` },
23
24
  opportunity: { label: 'Opportunities', path: (id) => `/opportunities/${id}` },
24
25
  raise: { label: 'Fundraising', path: (id) => `/fundraising/${id}` },
@@ -27,6 +27,7 @@ import type {
27
27
  import { useEffect, useState } from 'react'
28
28
  import type { ChangeEvent, DragEvent, FormEvent, ReactNode } from 'react'
29
29
 
30
+ import { useConsentPurposes } from '../../api/resources/consentPurposes.ts'
30
31
  import { useRelinkEmailDomains } from '../../api/resources/emailDomainLinker.ts'
31
32
  import {
32
33
  saveCsv,
@@ -73,6 +74,7 @@ export function DataPage(): ReactNode {
73
74
  const [columnMap, setColumnMap] = useState<ImportColumnMap>({})
74
75
  const [conflictMode, setConflictMode] = useState<ImportConflictMode>('skip')
75
76
  const [onMissingCompany, setOnMissingCompany] = useState<OnMissingCompany>('skip')
77
+ const [consentPurposeId, setConsentPurposeId] = useState<string | null>(null)
76
78
  const [matchKeyId, setMatchKeyId] = useState<string>(() => defaultMatchKeyId('companies'))
77
79
  const [jobId, setJobId] = useState<string | undefined>(undefined)
78
80
  const [problem, setProblem] = useState<string | null>(null)
@@ -125,6 +127,7 @@ export function DataPage(): ReactNode {
125
127
  setColumnMap({})
126
128
  setConflictMode('skip')
127
129
  setOnMissingCompany('skip')
130
+ setConsentPurposeId(null)
128
131
  setMatchKeyId(defaultMatchKeyId(object))
129
132
  setJobId(undefined)
130
133
  setProblem(null)
@@ -145,6 +148,7 @@ export function DataPage(): ReactNode {
145
148
  conflictMode,
146
149
  onMissingCompany,
147
150
  matchKeyId,
151
+ consentPurposeId,
148
152
  ...(map === undefined ? {} : { columnMap: map }),
149
153
  })
150
154
 
@@ -428,11 +432,13 @@ export function DataPage(): ReactNode {
428
432
  columnMap={columnMap}
429
433
  conflictMode={conflictMode}
430
434
  onMissingCompany={onMissingCompany}
435
+ consentPurposeId={consentPurposeId}
431
436
  matchKeyId={matchKeyId}
432
437
  isPending={createJob.isPending}
433
438
  onColumnMapChange={setColumnMap}
434
439
  onConflictModeChange={setConflictMode}
435
440
  onMissingCompanyChange={setOnMissingCompany}
441
+ onConsentPurposeChange={setConsentPurposeId}
436
442
  onMatchKeyChange={setMatchKeyId}
437
443
  onSubmit={(event) => runStep(onRerun(event))}
438
444
  onBack={() => {
@@ -468,11 +474,13 @@ interface MappingFormProps {
468
474
  readonly columnMap: ImportColumnMap
469
475
  readonly conflictMode: ImportConflictMode
470
476
  readonly onMissingCompany: OnMissingCompany
477
+ readonly consentPurposeId: string | null
471
478
  readonly matchKeyId: string
472
479
  readonly isPending: boolean
473
480
  readonly onColumnMapChange: (map: ImportColumnMap) => void
474
481
  readonly onConflictModeChange: (mode: ImportConflictMode) => void
475
482
  readonly onMissingCompanyChange: (mode: OnMissingCompany) => void
483
+ readonly onConsentPurposeChange: (id: string | null) => void
476
484
  readonly onMatchKeyChange: (id: string) => void
477
485
  readonly onSubmit: (event: FormEvent) => void
478
486
  readonly onBack: () => void
@@ -535,6 +543,12 @@ function MappingForm(props: MappingFormProps): ReactNode {
535
543
  </select>
536
544
  </Field>
537
545
  ) : null}
546
+ {showMissingCompany ? (
547
+ <ConsentPurposeField
548
+ value={props.consentPurposeId}
549
+ onChange={props.onConsentPurposeChange}
550
+ />
551
+ ) : null}
538
552
  </div>
539
553
  <p className="text-[12px] text-ink-muted">
540
554
  {props.conflictMode === 'update'
@@ -954,6 +968,40 @@ function Field({
954
968
  )
955
969
  }
956
970
 
971
+ /**
972
+ * People imports only. Required whenever the map names `consent_status` or
973
+ * `consent_at` — the server refuses the job otherwise, so the picker is next
974
+ * to the columns rather than tucked away.
975
+ */
976
+ function ConsentPurposeField({
977
+ value,
978
+ onChange,
979
+ }: {
980
+ readonly value: string | null
981
+ readonly onChange: (id: string | null) => void
982
+ }): ReactNode {
983
+ const purposes = useConsentPurposes({ sort: 'sort_order', limit: 200 })
984
+
985
+ return (
986
+ <Field label="Consent purpose">
987
+ <select
988
+ value={value ?? ''}
989
+ onChange={(event) => {
990
+ onChange(event.target.value.length === 0 ? null : event.target.value)
991
+ }}
992
+ className="w-full rounded-md border border-border bg-surface-raised px-3 py-2 text-[13px] outline-none focus:border-accent"
993
+ >
994
+ <option value="">— none —</option>
995
+ {purposes.records.map((purpose) => (
996
+ <option key={purpose.id} value={purpose.id}>
997
+ {purpose.label}
998
+ </option>
999
+ ))}
1000
+ </select>
1001
+ </Field>
1002
+ )
1003
+ }
1004
+
957
1005
  function messageOf(error: Error | null | undefined): string | null {
958
1006
  return error === null || error === undefined ? null : error.message
959
1007
  }
@@ -1011,9 +1059,9 @@ function SampleDataSection(): ReactNode {
1011
1059
  <p className="text-[12px] text-ink-muted">
1012
1060
  Added {counts.companies} companies, {counts.people} people, {counts.positions}{' '}
1013
1061
  positions, {counts.deals} deals, {counts.opportunities} opportunities,{' '}
1014
- {counts.raises} raises, {counts.partnerships} partnerships, {counts.roles} roles,{' '}
1015
- {counts.candidates} candidates, {counts.planItems} plan items, and{' '}
1016
- {counts.notes} notes.
1062
+ {counts.raises} raises, {counts.partnerships} partnerships, {counts.enquiries}{' '}
1063
+ enquiries, {counts.roles} roles, {counts.candidates} candidates,{' '}
1064
+ {counts.planItems} plan items, and {counts.notes} notes.
1017
1065
  </p>
1018
1066
  ) : null}
1019
1067
  {failure === null ? null : (