@kelpie/ui 0.7.0 → 0.8.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 (228) hide show
  1. package/dist/api/resource.d.ts +58 -5
  2. package/dist/api/resource.d.ts.map +1 -1
  3. package/dist/api/resource.js +106 -24
  4. package/dist/api/resource.js.map +1 -1
  5. package/dist/api/resources/companies.d.ts +2 -0
  6. package/dist/api/resources/companies.d.ts.map +1 -1
  7. package/dist/api/resources/companies.js +5 -1
  8. package/dist/api/resources/companies.js.map +1 -1
  9. package/dist/api/resources/decisions.d.ts +2 -0
  10. package/dist/api/resources/decisions.d.ts.map +1 -1
  11. package/dist/api/resources/decisions.js +1 -0
  12. package/dist/api/resources/decisions.js.map +1 -1
  13. package/dist/api/resources/emailDomainLinker.d.ts +16 -0
  14. package/dist/api/resources/emailDomainLinker.d.ts.map +1 -0
  15. package/dist/api/resources/emailDomainLinker.js +16 -0
  16. package/dist/api/resources/emailDomainLinker.js.map +1 -0
  17. package/dist/api/resources/forms.d.ts.map +1 -1
  18. package/dist/api/resources/forms.js +6 -20
  19. package/dist/api/resources/forms.js.map +1 -1
  20. package/dist/api/resources/handbookPages.d.ts +1 -1
  21. package/dist/api/resources/handbookPages.js +1 -1
  22. package/dist/api/resources/listMembers.d.ts +27 -0
  23. package/dist/api/resources/listMembers.d.ts.map +1 -0
  24. package/dist/api/resources/listMembers.js +108 -0
  25. package/dist/api/resources/listMembers.js.map +1 -0
  26. package/dist/api/resources/lists.d.ts +14 -0
  27. package/dist/api/resources/lists.d.ts.map +1 -0
  28. package/dist/api/resources/lists.js +34 -0
  29. package/dist/api/resources/lists.js.map +1 -0
  30. package/dist/api/resources/people.d.ts.map +1 -1
  31. package/dist/api/resources/people.js +4 -1
  32. package/dist/api/resources/people.js.map +1 -1
  33. package/dist/api/resources/roles.d.ts +2 -0
  34. package/dist/api/resources/roles.d.ts.map +1 -1
  35. package/dist/api/resources/roles.js +1 -1
  36. package/dist/api/resources/roles.js.map +1 -1
  37. package/dist/api/resources/sampleData.d.ts +17 -0
  38. package/dist/api/resources/sampleData.d.ts.map +1 -0
  39. package/dist/api/resources/sampleData.js +30 -0
  40. package/dist/api/resources/sampleData.js.map +1 -0
  41. package/dist/api/resources/webhooks.d.ts.map +1 -1
  42. package/dist/api/resources/webhooks.js +7 -22
  43. package/dist/api/resources/webhooks.js.map +1 -1
  44. package/dist/app/KelpieApp.d.ts.map +1 -1
  45. package/dist/app/KelpieApp.js +3 -1
  46. package/dist/app/KelpieApp.js.map +1 -1
  47. package/dist/components/ActivitiesPanel.d.ts.map +1 -1
  48. package/dist/components/ActivitiesPanel.js +2 -1
  49. package/dist/components/ActivitiesPanel.js.map +1 -1
  50. package/dist/components/ColumnPicker.d.ts +24 -0
  51. package/dist/components/ColumnPicker.d.ts.map +1 -0
  52. package/dist/components/ColumnPicker.js +56 -0
  53. package/dist/components/ColumnPicker.js.map +1 -0
  54. package/dist/components/DataTable.d.ts +16 -1
  55. package/dist/components/DataTable.d.ts.map +1 -1
  56. package/dist/components/DataTable.js +51 -10
  57. package/dist/components/DataTable.js.map +1 -1
  58. package/dist/components/DecisionsPanel.d.ts.map +1 -1
  59. package/dist/components/DecisionsPanel.js +4 -3
  60. package/dist/components/DecisionsPanel.js.map +1 -1
  61. package/dist/components/EntitySearch.d.ts.map +1 -1
  62. package/dist/components/EntitySearch.js +11 -5
  63. package/dist/components/EntitySearch.js.map +1 -1
  64. package/dist/components/ListsPanel.d.ts +14 -0
  65. package/dist/components/ListsPanel.d.ts.map +1 -0
  66. package/dist/components/ListsPanel.js +56 -0
  67. package/dist/components/ListsPanel.js.map +1 -0
  68. package/dist/components/NotesPanel.d.ts.map +1 -1
  69. package/dist/components/NotesPanel.js +6 -5
  70. package/dist/components/NotesPanel.js.map +1 -1
  71. package/dist/components/Paginator.d.ts +26 -0
  72. package/dist/components/Paginator.d.ts.map +1 -0
  73. package/dist/components/Paginator.js +16 -0
  74. package/dist/components/Paginator.js.map +1 -0
  75. package/dist/components/PlanPanel.d.ts.map +1 -1
  76. package/dist/components/PlanPanel.js +2 -1
  77. package/dist/components/PlanPanel.js.map +1 -1
  78. package/dist/components/Shell.d.ts.map +1 -1
  79. package/dist/components/Shell.js +1 -0
  80. package/dist/components/Shell.js.map +1 -1
  81. package/dist/index.d.ts +8 -2
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +4 -1
  84. package/dist/index.js.map +1 -1
  85. package/dist/lib/listView.d.ts +15 -0
  86. package/dist/lib/listView.d.ts.map +1 -0
  87. package/dist/lib/listView.js +66 -0
  88. package/dist/lib/listView.js.map +1 -0
  89. package/dist/lib/sort.d.ts +43 -0
  90. package/dist/lib/sort.d.ts.map +1 -0
  91. package/dist/lib/sort.js +102 -0
  92. package/dist/lib/sort.js.map +1 -0
  93. package/dist/pages/CompaniesPage.d.ts.map +1 -1
  94. package/dist/pages/CompaniesPage.js +88 -3
  95. package/dist/pages/CompaniesPage.js.map +1 -1
  96. package/dist/pages/CompanyDetail.d.ts.map +1 -1
  97. package/dist/pages/CompanyDetail.js +17 -14
  98. package/dist/pages/CompanyDetail.js.map +1 -1
  99. package/dist/pages/DealDetail.d.ts.map +1 -1
  100. package/dist/pages/DealDetail.js +3 -1
  101. package/dist/pages/DealDetail.js.map +1 -1
  102. package/dist/pages/DealsPage.d.ts.map +1 -1
  103. package/dist/pages/DealsPage.js +134 -14
  104. package/dist/pages/DealsPage.js.map +1 -1
  105. package/dist/pages/DecisionsPage.d.ts.map +1 -1
  106. package/dist/pages/DecisionsPage.js +50 -8
  107. package/dist/pages/DecisionsPage.js.map +1 -1
  108. package/dist/pages/FormsPage.d.ts.map +1 -1
  109. package/dist/pages/FormsPage.js +53 -2
  110. package/dist/pages/FormsPage.js.map +1 -1
  111. package/dist/pages/FundraisingPage.d.ts.map +1 -1
  112. package/dist/pages/FundraisingPage.js +116 -14
  113. package/dist/pages/FundraisingPage.js.map +1 -1
  114. package/dist/pages/HiringPage.d.ts.map +1 -1
  115. package/dist/pages/HiringPage.js +27 -4
  116. package/dist/pages/HiringPage.js.map +1 -1
  117. package/dist/pages/ListDetail.d.ts +11 -0
  118. package/dist/pages/ListDetail.d.ts.map +1 -0
  119. package/dist/pages/ListDetail.js +188 -0
  120. package/dist/pages/ListDetail.js.map +1 -0
  121. package/dist/pages/ListsPage.d.ts +9 -0
  122. package/dist/pages/ListsPage.d.ts.map +1 -0
  123. package/dist/pages/ListsPage.js +125 -0
  124. package/dist/pages/ListsPage.js.map +1 -0
  125. package/dist/pages/OpportunitiesPage.d.ts.map +1 -1
  126. package/dist/pages/OpportunitiesPage.js +97 -14
  127. package/dist/pages/OpportunitiesPage.js.map +1 -1
  128. package/dist/pages/OpportunityDetail.d.ts.map +1 -1
  129. package/dist/pages/OpportunityDetail.js +3 -1
  130. package/dist/pages/OpportunityDetail.js.map +1 -1
  131. package/dist/pages/PartnershipDetail.d.ts.map +1 -1
  132. package/dist/pages/PartnershipDetail.js +3 -1
  133. package/dist/pages/PartnershipDetail.js.map +1 -1
  134. package/dist/pages/PartnershipsPage.d.ts.map +1 -1
  135. package/dist/pages/PartnershipsPage.js +111 -14
  136. package/dist/pages/PartnershipsPage.js.map +1 -1
  137. package/dist/pages/PeoplePage.d.ts.map +1 -1
  138. package/dist/pages/PeoplePage.js +102 -3
  139. package/dist/pages/PeoplePage.js.map +1 -1
  140. package/dist/pages/PersonDetail.d.ts.map +1 -1
  141. package/dist/pages/PersonDetail.js +5 -3
  142. package/dist/pages/PersonDetail.js.map +1 -1
  143. package/dist/pages/PlanningPage.d.ts.map +1 -1
  144. package/dist/pages/PlanningPage.js +85 -17
  145. package/dist/pages/PlanningPage.js.map +1 -1
  146. package/dist/pages/RaiseDetail.d.ts.map +1 -1
  147. package/dist/pages/RaiseDetail.js +3 -1
  148. package/dist/pages/RaiseDetail.js.map +1 -1
  149. package/dist/pages/RoleDetail.d.ts.map +1 -1
  150. package/dist/pages/RoleDetail.js +5 -4
  151. package/dist/pages/RoleDetail.js.map +1 -1
  152. package/dist/pages/admin/DataPage.d.ts.map +1 -1
  153. package/dist/pages/admin/DataPage.js +110 -12
  154. package/dist/pages/admin/DataPage.js.map +1 -1
  155. package/dist/pages/admin/WebhookDeliveries.d.ts.map +1 -1
  156. package/dist/pages/admin/WebhookDeliveries.js +3 -2
  157. package/dist/pages/admin/WebhookDeliveries.js.map +1 -1
  158. package/dist/pages/admin/WebhooksPage.d.ts.map +1 -1
  159. package/dist/pages/admin/WebhooksPage.js +3 -2
  160. package/dist/pages/admin/WebhooksPage.js.map +1 -1
  161. package/dist/pages/admin/WorkspaceSettingsPage.d.ts.map +1 -1
  162. package/dist/pages/admin/WorkspaceSettingsPage.js +45 -2
  163. package/dist/pages/admin/WorkspaceSettingsPage.js.map +1 -1
  164. package/dist/pages/forms/SubmissionsTable.d.ts.map +1 -1
  165. package/dist/pages/forms/SubmissionsTable.js +2 -1
  166. package/dist/pages/forms/SubmissionsTable.js.map +1 -1
  167. package/dist/pages/handbook/HandbookPage.js +1 -1
  168. package/dist/pages/hiringDirectory.js +2 -2
  169. package/dist/pages/onboarding/WorkspaceStepPage.d.ts.map +1 -1
  170. package/dist/pages/onboarding/WorkspaceStepPage.js +15 -2
  171. package/dist/pages/onboarding/WorkspaceStepPage.js.map +1 -1
  172. package/dist/pages/positionDirectory.js +2 -2
  173. package/package.json +2 -2
  174. package/src/api/resource.ts +149 -31
  175. package/src/api/resources/companies.ts +7 -1
  176. package/src/api/resources/decisions.ts +3 -0
  177. package/src/api/resources/emailDomainLinker.ts +43 -0
  178. package/src/api/resources/forms.ts +5 -23
  179. package/src/api/resources/handbookPages.ts +1 -1
  180. package/src/api/resources/listMembers.ts +173 -0
  181. package/src/api/resources/lists.ts +61 -0
  182. package/src/api/resources/people.ts +4 -1
  183. package/src/api/resources/roles.ts +3 -1
  184. package/src/api/resources/sampleData.ts +56 -0
  185. package/src/api/resources/webhooks.ts +8 -24
  186. package/src/app/KelpieApp.tsx +4 -0
  187. package/src/components/ActivitiesPanel.tsx +2 -10
  188. package/src/components/ColumnPicker.tsx +124 -0
  189. package/src/components/DataTable.tsx +89 -10
  190. package/src/components/DecisionsPanel.tsx +4 -12
  191. package/src/components/EntitySearch.tsx +12 -5
  192. package/src/components/ListsPanel.tsx +176 -0
  193. package/src/components/NotesPanel.tsx +6 -14
  194. package/src/components/Paginator.tsx +78 -0
  195. package/src/components/PlanPanel.tsx +2 -10
  196. package/src/components/Shell.tsx +1 -0
  197. package/src/index.ts +10 -1
  198. package/src/lib/listView.ts +149 -0
  199. package/src/lib/sort.ts +143 -0
  200. package/src/pages/CompaniesPage.tsx +125 -11
  201. package/src/pages/CompanyDetail.tsx +62 -62
  202. package/src/pages/DealDetail.tsx +3 -0
  203. package/src/pages/DealsPage.tsx +178 -24
  204. package/src/pages/DecisionsPage.tsx +73 -18
  205. package/src/pages/FormsPage.tsx +81 -17
  206. package/src/pages/FundraisingPage.tsx +152 -24
  207. package/src/pages/HiringPage.tsx +39 -11
  208. package/src/pages/ListDetail.tsx +408 -0
  209. package/src/pages/ListsPage.tsx +281 -0
  210. package/src/pages/OpportunitiesPage.tsx +130 -24
  211. package/src/pages/OpportunityDetail.tsx +3 -0
  212. package/src/pages/PartnershipDetail.tsx +3 -0
  213. package/src/pages/PartnershipsPage.tsx +149 -24
  214. package/src/pages/PeoplePage.tsx +137 -11
  215. package/src/pages/PersonDetail.tsx +5 -3
  216. package/src/pages/PlanningPage.tsx +256 -29
  217. package/src/pages/RaiseDetail.tsx +3 -0
  218. package/src/pages/RoleDetail.tsx +21 -26
  219. package/src/pages/admin/DataPage.tsx +276 -13
  220. package/src/pages/admin/WebhookDeliveries.tsx +10 -15
  221. package/src/pages/admin/WebhooksPage.tsx +14 -19
  222. package/src/pages/admin/WorkspaceSettingsPage.tsx +134 -2
  223. package/src/pages/forms/SubmissionsTable.tsx +3 -10
  224. package/src/pages/handbook/HandbookPage.tsx +1 -1
  225. package/src/pages/hiringDirectory.ts +2 -2
  226. package/src/pages/onboarding/WorkspaceStepPage.tsx +31 -3
  227. package/src/pages/positionDirectory.ts +2 -2
  228. package/src/styles.css +3 -3
@@ -6,13 +6,21 @@ import { useNavigate } from 'react-router'
6
6
  import { useTimezone } from '../api/resources/account.ts'
7
7
  import { useCreateRole, useRoles } from '../api/resources/roles.ts'
8
8
  import { Chip } from '../components/Chip.tsx'
9
+ import { ColumnPicker } from '../components/ColumnPicker.tsx'
9
10
  import { DataTable } from '../components/DataTable.tsx'
10
11
  import type { Column } from '../components/DataTable.tsx'
11
12
  import { FilterBar, PageHeader } from '../components/PageHeader.tsx'
13
+ import { Paginator } from '../components/Paginator.tsx'
12
14
  import { ErrorPanel, LoadingPanel } from '../components/QueryState.tsx'
13
15
  import { formatDate } from '../lib/dates.ts'
16
+ import { useListView } from '../lib/listView.ts'
17
+ import { serverSortOnly } from '../lib/sort.ts'
14
18
  import { useRoleCandidateCounts } from './hiringDirectory.ts'
15
19
 
20
+ const DEFAULT_VISIBLE_KEYS: readonly string[] = ['title', 'status', 'candidates', 'created']
21
+
22
+ const SERVER_SORT_KEYS: readonly string[] = ['title', 'created_at', 'updated_at']
23
+
16
24
  /**
17
25
  * The Hiring list: every opening, open or closed.
18
26
  *
@@ -24,8 +32,12 @@ export function HiringPage(): React.JSX.Element {
24
32
  const [term, setTerm] = useState('')
25
33
  const [adding, setAdding] = useState(false)
26
34
  const [title, setTitle] = useState('')
35
+ const [sort, setSort] = useState<string | undefined>(undefined)
27
36
  const navigate = useNavigate()
28
- const roles = useRoles({ term: term.trim().length > 0 ? term.trim() : undefined })
37
+ const roles = useRoles({
38
+ term: term.trim().length > 0 ? term.trim() : undefined,
39
+ sort: serverSortOnly(sort, SERVER_SORT_KEYS),
40
+ })
29
41
  const counts = useRoleCandidateCounts(roles.records.map((role) => role.id))
30
42
  const createRole = useCreateRole()
31
43
  const timezone = useTimezone()
@@ -54,11 +66,13 @@ export function HiringPage(): React.JSX.Element {
54
66
  {
55
67
  key: 'title',
56
68
  header: 'Role',
69
+ sortKey: 'title',
57
70
  render: (role) => <span className="font-medium text-ink">{role.title}</span>,
58
71
  },
59
72
  {
60
73
  key: 'status',
61
74
  header: 'Status',
75
+ getSortValue: (role) => role.status,
62
76
  render: (role) => (
63
77
  <Chip tone={role.status === 'open' ? 'accent' : 'neutral'}>
64
78
  {role.status === 'open' ? 'Open' : 'Closed'}
@@ -69,6 +83,7 @@ export function HiringPage(): React.JSX.Element {
69
83
  key: 'candidates',
70
84
  header: 'Candidates',
71
85
  className: 'w-28',
86
+ getSortValue: (role) => (counts.isLoading ? null : counts.countFor(role.id)),
72
87
  render: (role) => (
73
88
  <span className="font-mono text-[12px]">
74
89
  {counts.isLoading ? '—' : counts.countFor(role.id)}
@@ -78,10 +93,21 @@ export function HiringPage(): React.JSX.Element {
78
93
  {
79
94
  key: 'created',
80
95
  header: 'Created',
96
+ sortKey: 'created_at',
81
97
  render: (role) => formatDate(role.createdAt, timezone),
82
98
  },
99
+ {
100
+ key: 'updated',
101
+ header: 'Updated',
102
+ sortKey: 'updated_at',
103
+ render: (role) => formatDate(role.updatedAt, timezone),
104
+ },
83
105
  ]
84
106
 
107
+ const supportedKeys = columns.map((column) => column.key)
108
+ const listView = useListView('roles', supportedKeys, DEFAULT_VISIBLE_KEYS)
109
+ const pickerOptions = columns.map((column) => ({ key: column.key, label: column.header }))
110
+
85
111
  return (
86
112
  <div className="animate-fade-in">
87
113
  <PageHeader
@@ -90,6 +116,13 @@ export function HiringPage(): React.JSX.Element {
90
116
  setAdding((current) => !current)
91
117
  }}
92
118
  addLabel="Add role"
119
+ actions={
120
+ <ColumnPicker
121
+ options={pickerOptions}
122
+ visibleKeys={listView.visibleKeys}
123
+ onChange={listView.setVisibleKeys}
124
+ />
125
+ }
93
126
  />
94
127
 
95
128
  {adding && (
@@ -140,6 +173,7 @@ export function HiringPage(): React.JSX.Element {
140
173
  <LoadingPanel label="Loading roles…" />
141
174
  ) : (
142
175
  <>
176
+ <Paginator list={roles} placement="top" />
143
177
  <DataTable
144
178
  columns={columns}
145
179
  rows={roles.records}
@@ -148,22 +182,16 @@ export function HiringPage(): React.JSX.Element {
148
182
  void navigate(`/hiring/${role.id}`)
149
183
  }}
150
184
  emptyMessage="No roles yet"
185
+ sort={sort}
186
+ onSortChange={setSort}
187
+ visibleColumnKeys={listView.visibleKeys}
151
188
  />
152
189
  {!counts.isComplete && !counts.isLoading && (
153
190
  <p className="mt-2 text-[11px] text-ink-faint">
154
191
  These roles hold more candidates than one page returns, so some counts read low.
155
192
  </p>
156
193
  )}
157
- {roles.hasMore && (
158
- <button
159
- type="button"
160
- onClick={roles.loadMore}
161
- disabled={roles.isLoadingMore}
162
- className="mt-3 rounded-md border border-border px-3 py-1.5 text-[12px] font-medium text-ink transition hover:border-border-strong hover:bg-surface-sunken disabled:opacity-50"
163
- >
164
- {roles.isLoadingMore ? 'Loading…' : 'Load more'}
165
- </button>
166
- )}
194
+ <Paginator list={roles} />
167
195
  </>
168
196
  )}
169
197
  </div>
@@ -0,0 +1,408 @@
1
+ import { RECORD_TARGET_TYPE_LABELS } from '@kelpie/schemas'
2
+ import type { ListMember, RecordTargetType } from '@kelpie/schemas'
3
+ import { useState } from 'react'
4
+ import { Link, useNavigate, useParams } from 'react-router'
5
+
6
+ import { usePatch } from '../api/resource.ts'
7
+ import type { PatchResult } from '../api/resource.ts'
8
+ import { useCompanies } from '../api/resources/companies.ts'
9
+ import { useDeals } from '../api/resources/deals.ts'
10
+ import { useDeleteList, useList, useUpdateList } from '../api/resources/lists.ts'
11
+ import {
12
+ useAddListMember,
13
+ useListMembers,
14
+ useRemoveListMember,
15
+ } from '../api/resources/listMembers.ts'
16
+ import { useOpportunities } from '../api/resources/opportunities.ts'
17
+ import { usePartnerships } from '../api/resources/partnerships.ts'
18
+ import { usePeople } from '../api/resources/people.ts'
19
+ import { useRaises } from '../api/resources/raises.ts'
20
+ import { Chip } from '../components/Chip.tsx'
21
+ import { DeleteRecord } from '../components/DeleteRecord.tsx'
22
+ import { EntitySearch } from '../components/EntitySearch.tsx'
23
+ import type { SearchOption } from '../components/EntitySearch.tsx'
24
+ import { InlineEdit } from '../components/InlineEdit.tsx'
25
+ import { Paginator } from '../components/Paginator.tsx'
26
+ import { ErrorPanel, LoadingPanel, NotFoundPanel } from '../components/QueryState.tsx'
27
+ import { SectionHeader } from '../components/SectionHeader.tsx'
28
+ import type { List, ListInput } from '@kelpie/schemas'
29
+
30
+ /**
31
+ * One list, and the records on it.
32
+ *
33
+ * The type is fixed at creation, so the header shows it as a badge and the
34
+ * member picker below dispatches on it: a `person` list picks from people, a
35
+ * `company` list from companies, and so on. A candidate list has no picker in
36
+ * v1 because candidates carry no name of their own; add candidacies from the
37
+ * hiring pages.
38
+ */
39
+ export function ListDetail(): React.JSX.Element {
40
+ const { id } = useParams()
41
+ const navigate = useNavigate()
42
+ const { record, isLoading, isNotFound, error } = useList(id)
43
+ const deleteList = useDeleteList()
44
+
45
+ if (isNotFound) {
46
+ return <NotFoundPanel label="List" backTo="/lists" />
47
+ }
48
+
49
+ if (error !== null) {
50
+ return <ErrorPanel error={error} />
51
+ }
52
+
53
+ if (isLoading || record === undefined || id === undefined) {
54
+ return <LoadingPanel label="Loading list…" />
55
+ }
56
+
57
+ return (
58
+ <div className="animate-fade-in mx-auto max-w-4xl">
59
+ <Link
60
+ to="/lists"
61
+ className="mb-4 inline-flex text-[12px] font-medium text-ink-muted transition hover:text-accent"
62
+ >
63
+ ← Lists
64
+ </Link>
65
+
66
+ <div className="mb-6 flex flex-wrap items-start justify-between gap-3">
67
+ <ListHeading list={record} />
68
+ <div className="flex shrink-0 flex-wrap items-center gap-3">
69
+ <Chip>{RECORD_TARGET_TYPE_LABELS[record.targetType]}</Chip>
70
+ <DeleteRecord
71
+ recordLabel="List"
72
+ recordName={record.name}
73
+ isPending={deleteList.isPending}
74
+ error={deleteList.error}
75
+ onConfirm={() => {
76
+ deleteList
77
+ .runAsync(record.id)
78
+ .then(() => navigate('/lists'))
79
+ .catch(() => undefined)
80
+ }}
81
+ />
82
+ </div>
83
+ </div>
84
+
85
+ <ListDescription list={record} />
86
+
87
+ <ListMembers listId={record.id} targetType={record.targetType} />
88
+ </div>
89
+ )
90
+ }
91
+
92
+ function useListPatch(list: List): PatchResult<ListInput> {
93
+ return usePatch(useUpdateList, list)
94
+ }
95
+
96
+ function ListHeading({ list }: { readonly list: List }): React.JSX.Element {
97
+ const { patch, error } = useListPatch(list)
98
+
99
+ return (
100
+ <div className="min-w-0 flex-1">
101
+ {error !== null && (
102
+ <div className="mb-2">
103
+ <ErrorPanel error={error} />
104
+ </div>
105
+ )}
106
+ <InlineEdit
107
+ value={list.name}
108
+ onChange={(name) => {
109
+ patch({ name })
110
+ }}
111
+ displayClassName="text-[22px] font-semibold tracking-tight text-ink not-italic"
112
+ emptyLabel="Untitled list"
113
+ />
114
+ </div>
115
+ )
116
+ }
117
+
118
+ function ListDescription({ list }: { readonly list: List }): React.JSX.Element {
119
+ const { patch, error } = useListPatch(list)
120
+
121
+ return (
122
+ <div className="mb-6">
123
+ {error !== null && (
124
+ <div className="mb-2">
125
+ <ErrorPanel error={error} />
126
+ </div>
127
+ )}
128
+ <InlineEdit
129
+ value={list.description ?? ''}
130
+ onChange={(description) => {
131
+ patch({ description: description.length > 0 ? description : null })
132
+ }}
133
+ displayClassName="text-[13px] text-ink-muted not-italic"
134
+ emptyLabel="Add a description"
135
+ multiline
136
+ />
137
+ </div>
138
+ )
139
+ }
140
+
141
+ function ListMembers({
142
+ listId,
143
+ targetType,
144
+ }: {
145
+ readonly listId: string
146
+ readonly targetType: RecordTargetType
147
+ }): React.JSX.Element {
148
+ const members = useListMembers(listId)
149
+ const addMember = useAddListMember()
150
+ const [adding, setAdding] = useState(false)
151
+
152
+ const attached = new Set(members.records.map((member) => member.targetId))
153
+
154
+ function add(targetId: string): void {
155
+ if (attached.has(targetId)) {
156
+ return
157
+ }
158
+
159
+ addMember.run({ listId, input: { targetType, targetId } })
160
+ setAdding(false)
161
+ }
162
+
163
+ return (
164
+ <section>
165
+ <SectionHeader
166
+ title="Members"
167
+ onAdd={() => {
168
+ setAdding((current) => !current)
169
+ }}
170
+ addLabel={`Add ${RECORD_TARGET_TYPE_LABELS[targetType].toLowerCase()}`}
171
+ />
172
+
173
+ {adding && (
174
+ <div className="mb-3 flex gap-2">
175
+ <div className="min-w-0 flex-1">
176
+ <MemberPicker
177
+ targetType={targetType}
178
+ attached={attached}
179
+ onPick={add}
180
+ />
181
+ </div>
182
+ <button
183
+ type="button"
184
+ onClick={() => {
185
+ setAdding(false)
186
+ }}
187
+ className="shrink-0 rounded-md px-2.5 py-1.5 text-[12px] font-medium text-ink-muted hover:text-ink"
188
+ >
189
+ Cancel
190
+ </button>
191
+ </div>
192
+ )}
193
+
194
+ {addMember.error !== null && (
195
+ <div className="mb-3">
196
+ <ErrorPanel error={addMember.error} />
197
+ </div>
198
+ )}
199
+ {members.error !== null && <ErrorPanel error={members.error} />}
200
+
201
+ {members.isLoading ? (
202
+ <LoadingPanel label="Loading members…" />
203
+ ) : members.records.length === 0 && !adding ? (
204
+ <p className="text-[13px] text-ink-faint">No members yet.</p>
205
+ ) : (
206
+ <>
207
+ <Paginator list={members} placement="top" />
208
+ <ul className="overflow-hidden rounded-md border border-border">
209
+ {members.records.map((member) => (
210
+ <MemberRow key={member.id} listId={listId} member={member} />
211
+ ))}
212
+ </ul>
213
+ </>
214
+ )}
215
+
216
+ <Paginator list={members} />
217
+ </section>
218
+ )
219
+ }
220
+
221
+ function MemberRow({
222
+ listId,
223
+ member,
224
+ }: {
225
+ readonly listId: string
226
+ readonly member: ListMember
227
+ }): React.JSX.Element {
228
+ const remove = useRemoveListMember()
229
+
230
+ return (
231
+ <li className="border-b border-border px-4 py-3 last:border-0">
232
+ <div className="flex flex-wrap items-center justify-between gap-3">
233
+ <Link
234
+ to={detailPathFor(member.targetType, member.targetId)}
235
+ className="text-left text-[13px] font-medium text-ink hover:text-accent"
236
+ >
237
+ {member.targetName ?? member.targetId}
238
+ </Link>
239
+ <button
240
+ type="button"
241
+ onClick={() => {
242
+ remove.run({ listId, id: member.id })
243
+ }}
244
+ className="text-[11px] font-medium text-danger hover:underline"
245
+ >
246
+ Remove
247
+ </button>
248
+ </div>
249
+
250
+ {remove.error !== null && (
251
+ <div className="mt-2">
252
+ <ErrorPanel error={remove.error} />
253
+ </div>
254
+ )}
255
+ </li>
256
+ )
257
+ }
258
+
259
+ /** Where a link on a member row should go. Every type has its own detail path. */
260
+ function detailPathFor(targetType: RecordTargetType, targetId: string): string {
261
+ switch (targetType) {
262
+ case 'person':
263
+ return `/people/${targetId}`
264
+ case 'company':
265
+ return `/companies/${targetId}`
266
+ case 'deal':
267
+ return `/deals/${targetId}`
268
+ case 'opportunity':
269
+ return `/opportunities/${targetId}`
270
+ case 'partnership':
271
+ return `/partnerships/${targetId}`
272
+ case 'raise':
273
+ return `/fundraising/${targetId}`
274
+ case 'candidate':
275
+ // A candidate has no page of its own; the person's does.
276
+ return `/hiring`
277
+ }
278
+ }
279
+
280
+ /**
281
+ * A "search or pick" combobox that queries the collection matching the list's
282
+ * type. Each type gets its own component so React sees a stable set of hook
283
+ * calls per render.
284
+ */
285
+ function MemberPicker({
286
+ targetType,
287
+ attached,
288
+ onPick,
289
+ }: {
290
+ readonly targetType: RecordTargetType
291
+ readonly attached: ReadonlySet<string>
292
+ readonly onPick: (id: string) => void
293
+ }): React.JSX.Element {
294
+ switch (targetType) {
295
+ case 'person':
296
+ return <PeoplePicker attached={attached} onPick={onPick} />
297
+ case 'company':
298
+ return <CompaniesPicker attached={attached} onPick={onPick} />
299
+ case 'deal':
300
+ return <DealsPicker attached={attached} onPick={onPick} />
301
+ case 'opportunity':
302
+ return <OpportunitiesPicker attached={attached} onPick={onPick} />
303
+ case 'partnership':
304
+ return <PartnershipsPicker attached={attached} onPick={onPick} />
305
+ case 'raise':
306
+ return <RaisesPicker attached={attached} onPick={onPick} />
307
+ case 'candidate':
308
+ return (
309
+ <p className="text-[12px] text-ink-faint">
310
+ Candidate lists have no inline picker in this release. Add candidacies from a role's
311
+ page, then attach them here through the API.
312
+ </p>
313
+ )
314
+ }
315
+ }
316
+
317
+ interface PickerProps {
318
+ readonly attached: ReadonlySet<string>
319
+ readonly onPick: (id: string) => void
320
+ }
321
+
322
+ function PeoplePicker({ attached, onPick }: PickerProps): React.JSX.Element {
323
+ const [search, setSearch] = useState('')
324
+ const records = usePeople({ term: trimmedOrUndefined(search) })
325
+ const options: readonly SearchOption[] = records.records
326
+ .filter((row) => !attached.has(row.id))
327
+ .map((row) => ({ id: row.id, label: row.name, meta: row.email ?? undefined }))
328
+
329
+ return <PickerBox onQueryChange={setSearch} onPick={onPick} options={options} />
330
+ }
331
+
332
+ function CompaniesPicker({ attached, onPick }: PickerProps): React.JSX.Element {
333
+ const [search, setSearch] = useState('')
334
+ const records = useCompanies({ term: trimmedOrUndefined(search) })
335
+ const options: readonly SearchOption[] = records.records
336
+ .filter((row) => !attached.has(row.id))
337
+ .map((row) => ({ id: row.id, label: row.name, meta: row.domain ?? undefined }))
338
+
339
+ return <PickerBox onQueryChange={setSearch} onPick={onPick} options={options} />
340
+ }
341
+
342
+ function DealsPicker({ attached, onPick }: PickerProps): React.JSX.Element {
343
+ const [search, setSearch] = useState('')
344
+ const records = useDeals({ term: trimmedOrUndefined(search) })
345
+ const options: readonly SearchOption[] = records.records
346
+ .filter((row) => !attached.has(row.id))
347
+ .map((row) => ({ id: row.id, label: row.name }))
348
+
349
+ return <PickerBox onQueryChange={setSearch} onPick={onPick} options={options} />
350
+ }
351
+
352
+ function OpportunitiesPicker({ attached, onPick }: PickerProps): React.JSX.Element {
353
+ const [search, setSearch] = useState('')
354
+ const records = useOpportunities({ term: trimmedOrUndefined(search) })
355
+ const options: readonly SearchOption[] = records.records
356
+ .filter((row) => !attached.has(row.id))
357
+ .map((row) => ({ id: row.id, label: row.name }))
358
+
359
+ return <PickerBox onQueryChange={setSearch} onPick={onPick} options={options} />
360
+ }
361
+
362
+ function PartnershipsPicker({ attached, onPick }: PickerProps): React.JSX.Element {
363
+ const [search, setSearch] = useState('')
364
+ const records = usePartnerships({ term: trimmedOrUndefined(search) })
365
+ const options: readonly SearchOption[] = records.records
366
+ .filter((row) => !attached.has(row.id))
367
+ .map((row) => ({ id: row.id, label: row.name }))
368
+
369
+ return <PickerBox onQueryChange={setSearch} onPick={onPick} options={options} />
370
+ }
371
+
372
+ function RaisesPicker({ attached, onPick }: PickerProps): React.JSX.Element {
373
+ const [search, setSearch] = useState('')
374
+ const records = useRaises({ term: trimmedOrUndefined(search) })
375
+ const options: readonly SearchOption[] = records.records
376
+ .filter((row) => !attached.has(row.id))
377
+ .map((row) => ({ id: row.id, label: row.name }))
378
+
379
+ return <PickerBox onQueryChange={setSearch} onPick={onPick} options={options} />
380
+ }
381
+
382
+ function PickerBox({
383
+ onQueryChange,
384
+ onPick,
385
+ options,
386
+ }: {
387
+ readonly onQueryChange: (value: string) => void
388
+ readonly onPick: (id: string) => void
389
+ readonly options: readonly SearchOption[]
390
+ }): React.JSX.Element {
391
+ return (
392
+ <EntitySearch
393
+ options={options}
394
+ value=""
395
+ onChange={onPick}
396
+ onQueryChange={onQueryChange}
397
+ placeholder="Search…"
398
+ emptyMessage="No matches"
399
+ size="md"
400
+ />
401
+ )
402
+ }
403
+
404
+ function trimmedOrUndefined(value: string): string | undefined {
405
+ const trimmed = value.trim()
406
+
407
+ return trimmed.length > 0 ? trimmed : undefined
408
+ }