@kelpie/ui 0.6.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 (243) 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/importJobs.d.ts +3 -1
  23. package/dist/api/resources/importJobs.d.ts.map +1 -1
  24. package/dist/api/resources/importJobs.js +1 -0
  25. package/dist/api/resources/importJobs.js.map +1 -1
  26. package/dist/api/resources/listMembers.d.ts +27 -0
  27. package/dist/api/resources/listMembers.d.ts.map +1 -0
  28. package/dist/api/resources/listMembers.js +108 -0
  29. package/dist/api/resources/listMembers.js.map +1 -0
  30. package/dist/api/resources/lists.d.ts +14 -0
  31. package/dist/api/resources/lists.d.ts.map +1 -0
  32. package/dist/api/resources/lists.js +34 -0
  33. package/dist/api/resources/lists.js.map +1 -0
  34. package/dist/api/resources/people.d.ts.map +1 -1
  35. package/dist/api/resources/people.js +4 -1
  36. package/dist/api/resources/people.js.map +1 -1
  37. package/dist/api/resources/publicConfig.d.ts +8 -0
  38. package/dist/api/resources/publicConfig.d.ts.map +1 -0
  39. package/dist/api/resources/publicConfig.js +22 -0
  40. package/dist/api/resources/publicConfig.js.map +1 -0
  41. package/dist/api/resources/roles.d.ts +2 -0
  42. package/dist/api/resources/roles.d.ts.map +1 -1
  43. package/dist/api/resources/roles.js +1 -1
  44. package/dist/api/resources/roles.js.map +1 -1
  45. package/dist/api/resources/sampleData.d.ts +17 -0
  46. package/dist/api/resources/sampleData.d.ts.map +1 -0
  47. package/dist/api/resources/sampleData.js +30 -0
  48. package/dist/api/resources/sampleData.js.map +1 -0
  49. package/dist/api/resources/webhooks.d.ts.map +1 -1
  50. package/dist/api/resources/webhooks.js +7 -22
  51. package/dist/api/resources/webhooks.js.map +1 -1
  52. package/dist/app/KelpieApp.d.ts.map +1 -1
  53. package/dist/app/KelpieApp.js +5 -2
  54. package/dist/app/KelpieApp.js.map +1 -1
  55. package/dist/components/ActivitiesPanel.d.ts.map +1 -1
  56. package/dist/components/ActivitiesPanel.js +2 -1
  57. package/dist/components/ActivitiesPanel.js.map +1 -1
  58. package/dist/components/ColumnPicker.d.ts +24 -0
  59. package/dist/components/ColumnPicker.d.ts.map +1 -0
  60. package/dist/components/ColumnPicker.js +56 -0
  61. package/dist/components/ColumnPicker.js.map +1 -0
  62. package/dist/components/DataTable.d.ts +16 -1
  63. package/dist/components/DataTable.d.ts.map +1 -1
  64. package/dist/components/DataTable.js +51 -10
  65. package/dist/components/DataTable.js.map +1 -1
  66. package/dist/components/DecisionsPanel.d.ts.map +1 -1
  67. package/dist/components/DecisionsPanel.js +4 -3
  68. package/dist/components/DecisionsPanel.js.map +1 -1
  69. package/dist/components/EntitySearch.d.ts.map +1 -1
  70. package/dist/components/EntitySearch.js +11 -5
  71. package/dist/components/EntitySearch.js.map +1 -1
  72. package/dist/components/EnvironmentBanner.d.ts +14 -0
  73. package/dist/components/EnvironmentBanner.d.ts.map +1 -0
  74. package/dist/components/EnvironmentBanner.js +23 -0
  75. package/dist/components/EnvironmentBanner.js.map +1 -0
  76. package/dist/components/ListsPanel.d.ts +14 -0
  77. package/dist/components/ListsPanel.d.ts.map +1 -0
  78. package/dist/components/ListsPanel.js +56 -0
  79. package/dist/components/ListsPanel.js.map +1 -0
  80. package/dist/components/NotesPanel.d.ts.map +1 -1
  81. package/dist/components/NotesPanel.js +6 -5
  82. package/dist/components/NotesPanel.js.map +1 -1
  83. package/dist/components/Paginator.d.ts +26 -0
  84. package/dist/components/Paginator.d.ts.map +1 -0
  85. package/dist/components/Paginator.js +16 -0
  86. package/dist/components/Paginator.js.map +1 -0
  87. package/dist/components/PlanPanel.d.ts.map +1 -1
  88. package/dist/components/PlanPanel.js +2 -1
  89. package/dist/components/PlanPanel.js.map +1 -1
  90. package/dist/components/Shell.d.ts.map +1 -1
  91. package/dist/components/Shell.js +1 -0
  92. package/dist/components/Shell.js.map +1 -1
  93. package/dist/index.d.ts +8 -2
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/index.js +4 -1
  96. package/dist/index.js.map +1 -1
  97. package/dist/lib/listView.d.ts +15 -0
  98. package/dist/lib/listView.d.ts.map +1 -0
  99. package/dist/lib/listView.js +66 -0
  100. package/dist/lib/listView.js.map +1 -0
  101. package/dist/lib/sort.d.ts +43 -0
  102. package/dist/lib/sort.d.ts.map +1 -0
  103. package/dist/lib/sort.js +102 -0
  104. package/dist/lib/sort.js.map +1 -0
  105. package/dist/pages/CompaniesPage.d.ts.map +1 -1
  106. package/dist/pages/CompaniesPage.js +88 -3
  107. package/dist/pages/CompaniesPage.js.map +1 -1
  108. package/dist/pages/CompanyDetail.d.ts.map +1 -1
  109. package/dist/pages/CompanyDetail.js +17 -14
  110. package/dist/pages/CompanyDetail.js.map +1 -1
  111. package/dist/pages/DealDetail.d.ts.map +1 -1
  112. package/dist/pages/DealDetail.js +3 -1
  113. package/dist/pages/DealDetail.js.map +1 -1
  114. package/dist/pages/DealsPage.d.ts.map +1 -1
  115. package/dist/pages/DealsPage.js +134 -14
  116. package/dist/pages/DealsPage.js.map +1 -1
  117. package/dist/pages/DecisionsPage.d.ts.map +1 -1
  118. package/dist/pages/DecisionsPage.js +50 -8
  119. package/dist/pages/DecisionsPage.js.map +1 -1
  120. package/dist/pages/FormsPage.d.ts.map +1 -1
  121. package/dist/pages/FormsPage.js +53 -2
  122. package/dist/pages/FormsPage.js.map +1 -1
  123. package/dist/pages/FundraisingPage.d.ts.map +1 -1
  124. package/dist/pages/FundraisingPage.js +116 -14
  125. package/dist/pages/FundraisingPage.js.map +1 -1
  126. package/dist/pages/HiringPage.d.ts.map +1 -1
  127. package/dist/pages/HiringPage.js +27 -4
  128. package/dist/pages/HiringPage.js.map +1 -1
  129. package/dist/pages/ListDetail.d.ts +11 -0
  130. package/dist/pages/ListDetail.d.ts.map +1 -0
  131. package/dist/pages/ListDetail.js +188 -0
  132. package/dist/pages/ListDetail.js.map +1 -0
  133. package/dist/pages/ListsPage.d.ts +9 -0
  134. package/dist/pages/ListsPage.d.ts.map +1 -0
  135. package/dist/pages/ListsPage.js +125 -0
  136. package/dist/pages/ListsPage.js.map +1 -0
  137. package/dist/pages/OpportunitiesPage.d.ts.map +1 -1
  138. package/dist/pages/OpportunitiesPage.js +97 -14
  139. package/dist/pages/OpportunitiesPage.js.map +1 -1
  140. package/dist/pages/OpportunityDetail.d.ts.map +1 -1
  141. package/dist/pages/OpportunityDetail.js +3 -1
  142. package/dist/pages/OpportunityDetail.js.map +1 -1
  143. package/dist/pages/PartnershipDetail.d.ts.map +1 -1
  144. package/dist/pages/PartnershipDetail.js +3 -1
  145. package/dist/pages/PartnershipDetail.js.map +1 -1
  146. package/dist/pages/PartnershipsPage.d.ts.map +1 -1
  147. package/dist/pages/PartnershipsPage.js +111 -14
  148. package/dist/pages/PartnershipsPage.js.map +1 -1
  149. package/dist/pages/PeoplePage.d.ts.map +1 -1
  150. package/dist/pages/PeoplePage.js +102 -3
  151. package/dist/pages/PeoplePage.js.map +1 -1
  152. package/dist/pages/PersonDetail.d.ts.map +1 -1
  153. package/dist/pages/PersonDetail.js +5 -3
  154. package/dist/pages/PersonDetail.js.map +1 -1
  155. package/dist/pages/PlanningPage.d.ts.map +1 -1
  156. package/dist/pages/PlanningPage.js +85 -17
  157. package/dist/pages/PlanningPage.js.map +1 -1
  158. package/dist/pages/RaiseDetail.d.ts.map +1 -1
  159. package/dist/pages/RaiseDetail.js +3 -1
  160. package/dist/pages/RaiseDetail.js.map +1 -1
  161. package/dist/pages/RoleDetail.d.ts.map +1 -1
  162. package/dist/pages/RoleDetail.js +5 -4
  163. package/dist/pages/RoleDetail.js.map +1 -1
  164. package/dist/pages/admin/DataPage.d.ts.map +1 -1
  165. package/dist/pages/admin/DataPage.js +126 -16
  166. package/dist/pages/admin/DataPage.js.map +1 -1
  167. package/dist/pages/admin/WebhookDeliveries.d.ts.map +1 -1
  168. package/dist/pages/admin/WebhookDeliveries.js +3 -2
  169. package/dist/pages/admin/WebhookDeliveries.js.map +1 -1
  170. package/dist/pages/admin/WebhooksPage.d.ts.map +1 -1
  171. package/dist/pages/admin/WebhooksPage.js +3 -2
  172. package/dist/pages/admin/WebhooksPage.js.map +1 -1
  173. package/dist/pages/admin/WorkspaceSettingsPage.d.ts.map +1 -1
  174. package/dist/pages/admin/WorkspaceSettingsPage.js +45 -2
  175. package/dist/pages/admin/WorkspaceSettingsPage.js.map +1 -1
  176. package/dist/pages/forms/SubmissionsTable.d.ts.map +1 -1
  177. package/dist/pages/forms/SubmissionsTable.js +2 -1
  178. package/dist/pages/forms/SubmissionsTable.js.map +1 -1
  179. package/dist/pages/handbook/HandbookPage.js +1 -1
  180. package/dist/pages/hiringDirectory.js +2 -2
  181. package/dist/pages/onboarding/WorkspaceStepPage.d.ts.map +1 -1
  182. package/dist/pages/onboarding/WorkspaceStepPage.js +15 -2
  183. package/dist/pages/onboarding/WorkspaceStepPage.js.map +1 -1
  184. package/dist/pages/positionDirectory.js +2 -2
  185. package/package.json +2 -2
  186. package/src/api/resource.ts +149 -31
  187. package/src/api/resources/companies.ts +7 -1
  188. package/src/api/resources/decisions.ts +3 -0
  189. package/src/api/resources/emailDomainLinker.ts +43 -0
  190. package/src/api/resources/forms.ts +5 -23
  191. package/src/api/resources/handbookPages.ts +1 -1
  192. package/src/api/resources/importJobs.ts +4 -0
  193. package/src/api/resources/listMembers.ts +173 -0
  194. package/src/api/resources/lists.ts +61 -0
  195. package/src/api/resources/people.ts +4 -1
  196. package/src/api/resources/publicConfig.ts +33 -0
  197. package/src/api/resources/roles.ts +3 -1
  198. package/src/api/resources/sampleData.ts +56 -0
  199. package/src/api/resources/webhooks.ts +8 -24
  200. package/src/app/KelpieApp.tsx +11 -0
  201. package/src/components/ActivitiesPanel.tsx +2 -10
  202. package/src/components/ColumnPicker.tsx +124 -0
  203. package/src/components/DataTable.tsx +89 -10
  204. package/src/components/DecisionsPanel.tsx +4 -12
  205. package/src/components/EntitySearch.tsx +12 -5
  206. package/src/components/EnvironmentBanner.tsx +30 -0
  207. package/src/components/ListsPanel.tsx +176 -0
  208. package/src/components/NotesPanel.tsx +6 -14
  209. package/src/components/Paginator.tsx +78 -0
  210. package/src/components/PlanPanel.tsx +2 -10
  211. package/src/components/Shell.tsx +1 -0
  212. package/src/index.ts +10 -1
  213. package/src/lib/listView.ts +149 -0
  214. package/src/lib/sort.ts +143 -0
  215. package/src/pages/CompaniesPage.tsx +125 -11
  216. package/src/pages/CompanyDetail.tsx +62 -62
  217. package/src/pages/DealDetail.tsx +3 -0
  218. package/src/pages/DealsPage.tsx +178 -24
  219. package/src/pages/DecisionsPage.tsx +73 -18
  220. package/src/pages/FormsPage.tsx +81 -17
  221. package/src/pages/FundraisingPage.tsx +152 -24
  222. package/src/pages/HiringPage.tsx +39 -11
  223. package/src/pages/ListDetail.tsx +408 -0
  224. package/src/pages/ListsPage.tsx +281 -0
  225. package/src/pages/OpportunitiesPage.tsx +130 -24
  226. package/src/pages/OpportunityDetail.tsx +3 -0
  227. package/src/pages/PartnershipDetail.tsx +3 -0
  228. package/src/pages/PartnershipsPage.tsx +149 -24
  229. package/src/pages/PeoplePage.tsx +137 -11
  230. package/src/pages/PersonDetail.tsx +5 -3
  231. package/src/pages/PlanningPage.tsx +256 -29
  232. package/src/pages/RaiseDetail.tsx +3 -0
  233. package/src/pages/RoleDetail.tsx +21 -26
  234. package/src/pages/admin/DataPage.tsx +359 -36
  235. package/src/pages/admin/WebhookDeliveries.tsx +10 -15
  236. package/src/pages/admin/WebhooksPage.tsx +14 -19
  237. package/src/pages/admin/WorkspaceSettingsPage.tsx +134 -2
  238. package/src/pages/forms/SubmissionsTable.tsx +3 -10
  239. package/src/pages/handbook/HandbookPage.tsx +1 -1
  240. package/src/pages/hiringDirectory.ts +2 -2
  241. package/src/pages/onboarding/WorkspaceStepPage.tsx +31 -3
  242. package/src/pages/positionDirectory.ts +2 -2
  243. package/src/styles.css +3 -3
@@ -1,5 +1,4 @@
1
1
  import type { Partnership, PipelineStage } from '@kelpie/schemas'
2
- import { useState } from 'react'
3
2
  import { Link, useNavigate } from 'react-router'
4
3
 
5
4
  import { useCompanies } from '../api/resources/companies.ts'
@@ -11,16 +10,21 @@ import {
11
10
  } from '../api/resources/partnerships.ts'
12
11
  import { usePipelineStages } from '../api/resources/pipelineStages.ts'
13
12
  import { MAX_PAGE_SIZE, usePlanItems } from '../api/resources/planItems.ts'
13
+ import { useTimezone } from '../api/resources/account.ts'
14
14
  import { Chip } from '../components/Chip.tsx'
15
15
  import type { ChipTone } from '../components/Chip.tsx'
16
+ import { ColumnPicker } from '../components/ColumnPicker.tsx'
16
17
  import { DataTable } from '../components/DataTable.tsx'
17
18
  import type { Column, DataTableGroup } from '../components/DataTable.tsx'
18
19
  import { KanbanBoard } from '../components/KanbanBoard.tsx'
19
20
  import { PageHeader } from '../components/PageHeader.tsx'
21
+ import { Paginator } from '../components/Paginator.tsx'
20
22
  import { ErrorPanel, LoadingPanel } from '../components/QueryState.tsx'
21
23
  import { SegmentedControl } from '../components/SegmentedControl.tsx'
22
- import { formatDay } from '../lib/dates.ts'
24
+ import { formatDate, formatDay } from '../lib/dates.ts'
25
+ import { useListView } from '../lib/listView.ts'
23
26
  import { DUE_BUCKETS, byDateThenTitle, dueBucketFor, nextOpenByTarget } from '../lib/plan.ts'
27
+ import { serverSortOnly } from '../lib/sort.ts'
24
28
 
25
29
  /**
26
30
  * The Partnerships board: ongoing two-way relationships, grouped by status. The
@@ -32,6 +36,33 @@ type BoardView = 'list' | 'columns'
32
36
  type PipelineScope = 'open' | 'all'
33
37
  type ListGrouping = 'stage' | 'due'
34
38
 
39
+ const SCOPES: readonly PipelineScope[] = ['open', 'all']
40
+ const GROUPINGS: readonly ListGrouping[] = ['stage', 'due']
41
+
42
+ function readScope(stored: string | undefined): PipelineScope {
43
+ return SCOPES.includes(stored as PipelineScope) ? (stored as PipelineScope) : 'open'
44
+ }
45
+
46
+ function readGrouping(stored: string | undefined): ListGrouping {
47
+ return GROUPINGS.includes(stored as ListGrouping) ? (stored as ListGrouping) : 'stage'
48
+ }
49
+
50
+ const COLUMN_KEYS: readonly string[] = [
51
+ 'name',
52
+ 'company',
53
+ 'stage',
54
+ 'kind',
55
+ 'nextPlan',
56
+ 'owner',
57
+ 'next',
58
+ 'goals',
59
+ 'successLooksLike',
60
+ 'tags',
61
+ 'summary',
62
+ 'createdAt',
63
+ 'updatedAt',
64
+ ]
65
+
35
66
  /** Tones keyed by the seeded slugs. A renamed stage keeps its slug and its tone. */
36
67
  const STAGE_TONES: Readonly<Record<string, ChipTone>> = {
37
68
  active: 'success',
@@ -43,19 +74,33 @@ function stageTone(stage: PipelineStage): ChipTone {
43
74
  return STAGE_TONES[stage.slug] ?? 'neutral'
44
75
  }
45
76
 
77
+ const DEFAULT_VISIBLE_KEYS: readonly string[] = [
78
+ 'name',
79
+ 'company',
80
+ 'kind',
81
+ 'nextPlan',
82
+ 'owner',
83
+ 'next',
84
+ ]
85
+
86
+ const SERVER_SORT_KEYS: readonly string[] = ['name', 'created_at', 'updated_at']
87
+
46
88
  export function PartnershipsPage(): React.JSX.Element {
47
89
  const navigate = useNavigate()
48
- const [view, setView] = useState<BoardView>('list')
49
- const [scope, setScope] = useState<PipelineScope>('open')
50
- const [grouping, setGrouping] = useState<ListGrouping>('stage')
51
- const [sort, setSort] = useState<string | undefined>(undefined)
90
+
91
+ const listView = useListView('partnerships', COLUMN_KEYS, DEFAULT_VISIBLE_KEYS)
92
+ const view: BoardView = listView.mode ?? 'columns'
93
+ const scope = readScope(listView.scope)
94
+ const grouping = readGrouping(listView.grouping)
95
+ const sort = listView.sort
52
96
 
53
97
  const stages = usePipelineStages('partnership')
54
- const partnerships = usePartnerships({ sort })
98
+ const partnerships = usePartnerships({ sort: serverSortOnly(sort, SERVER_SORT_KEYS) })
55
99
  const companies = useCompanies({ limit: 200 })
56
100
  const members = useMembers()
57
101
  const createPartnership = useCreatePartnership()
58
102
  const updatePartnership = useUpdatePartnership()
103
+ const timezone = useTimezone()
59
104
 
60
105
  const allStages = [...stages.records].sort((a, b) => a.sortOrder - b.sortOrder)
61
106
  const visibleStages = scope === 'open' ? allStages.filter((stage) => stage.open) : allStages
@@ -78,7 +123,7 @@ export function PartnershipsPage(): React.JSX.Element {
78
123
  { enabled: askedIds.length > 0 },
79
124
  )
80
125
  const nextPlanByPartnership = nextOpenByTarget(planItems.records)
81
- const plansTruncated = visiblePartnerships.length > MAX_PAGE_SIZE || planItems.hasMore
126
+ const plansTruncated = visiblePartnerships.length > MAX_PAGE_SIZE || planItems.hasNext
82
127
 
83
128
  const companyNameById = new Map(companies.records.map((company) => [company.id, company.name]))
84
129
 
@@ -97,6 +142,8 @@ export function PartnershipsPage(): React.JSX.Element {
97
142
  await navigate(`/partnerships/${partnership.id}`)
98
143
  }
99
144
 
145
+ const stageLabelById = new Map(allStages.map((stage) => [stage.id, stage.label]))
146
+
100
147
  const columns: readonly Column<Partnership>[] = [
101
148
  {
102
149
  key: 'name',
@@ -107,16 +154,25 @@ export function PartnershipsPage(): React.JSX.Element {
107
154
  {
108
155
  key: 'company',
109
156
  header: 'Company',
157
+ getSortValue: (partnership) => companyNameById.get(partnership.companyId) ?? null,
110
158
  render: (partnership) => companyNameById.get(partnership.companyId) ?? '—',
111
159
  },
160
+ {
161
+ key: 'stage',
162
+ header: 'Stage',
163
+ getSortValue: (partnership) => stageLabelById.get(partnership.stageId) ?? null,
164
+ render: (partnership) => stageLabelById.get(partnership.stageId) ?? '—',
165
+ },
112
166
  {
113
167
  key: 'kind',
114
168
  header: 'Kind',
169
+ getSortValue: (partnership) => partnership.kind || null,
115
170
  render: (partnership) => (partnership.kind.length > 0 ? partnership.kind : '—'),
116
171
  },
117
172
  {
118
173
  key: 'nextPlan',
119
174
  header: 'Next plan',
175
+ getSortValue: (partnership) => nextPlanByPartnership.get(partnership.id)?.date ?? null,
120
176
  render: (partnership) => {
121
177
  const next = nextPlanByPartnership.get(partnership.id)
122
178
 
@@ -137,6 +193,10 @@ export function PartnershipsPage(): React.JSX.Element {
137
193
  {
138
194
  key: 'owner',
139
195
  header: 'Owner',
196
+ getSortValue: (partnership) =>
197
+ partnership.ownerId === null
198
+ ? null
199
+ : (members.nameById.get(partnership.ownerId) ?? 'Unknown'),
140
200
  render: (partnership) =>
141
201
  partnership.ownerId === null
142
202
  ? '—'
@@ -146,14 +206,71 @@ export function PartnershipsPage(): React.JSX.Element {
146
206
  key: 'next',
147
207
  header: 'Next touch',
148
208
  className: 'w-28',
209
+ getSortValue: (partnership) => partnership.nextTouchpoint,
149
210
  render: (partnership) => (
150
211
  <span className="font-mono text-[12px] text-ink-muted">
151
212
  {partnership.nextTouchpoint === null ? '—' : formatDay(partnership.nextTouchpoint)}
152
213
  </span>
153
214
  ),
154
215
  },
216
+ {
217
+ key: 'goals',
218
+ header: 'Goals',
219
+ getSortValue: (partnership) => partnership.goals || null,
220
+ render: (partnership) =>
221
+ partnership.goals.length === 0 ? '—' : (
222
+ <span className="text-ink-muted">{partnership.goals}</span>
223
+ ),
224
+ },
225
+ {
226
+ key: 'successLooksLike',
227
+ header: 'Success looks like',
228
+ getSortValue: (partnership) => partnership.successLooksLike || null,
229
+ render: (partnership) =>
230
+ partnership.successLooksLike.length === 0 ? '—' : (
231
+ <span className="text-ink-muted">{partnership.successLooksLike}</span>
232
+ ),
233
+ },
234
+ {
235
+ key: 'tags',
236
+ header: 'Tags',
237
+ getSortValue: (partnership) => partnership.tags.join(', ') || null,
238
+ render: (partnership) =>
239
+ partnership.tags.length === 0 ? '—' : (
240
+ <span className="flex flex-wrap gap-1">
241
+ {partnership.tags.map((tag) => (
242
+ <Chip key={tag}>
243
+ <span className="text-[10px]">{tag}</span>
244
+ </Chip>
245
+ ))}
246
+ </span>
247
+ ),
248
+ },
249
+ {
250
+ key: 'summary',
251
+ header: 'Summary',
252
+ getSortValue: (partnership) => partnership.summary || null,
253
+ render: (partnership) =>
254
+ partnership.summary.length === 0 ? '—' : (
255
+ <span className="text-ink-muted">{partnership.summary}</span>
256
+ ),
257
+ },
258
+ {
259
+ key: 'createdAt',
260
+ header: 'Created',
261
+ sortKey: 'created_at',
262
+ render: (partnership) => formatDate(partnership.createdAt, timezone),
263
+ },
264
+ {
265
+ key: 'updatedAt',
266
+ header: 'Updated',
267
+ sortKey: 'updated_at',
268
+ render: (partnership) => formatDate(partnership.updatedAt, timezone),
269
+ },
155
270
  ]
156
271
 
272
+ const pickerOptions = columns.map((column) => ({ key: column.key, label: column.header }))
273
+
157
274
  const stageGroups: readonly DataTableGroup<Partnership>[] = visibleStages.map((stage) => ({
158
275
  id: stage.id,
159
276
  label: <Chip tone={stageTone(stage)}>{stage.label}</Chip>,
@@ -217,7 +334,9 @@ export function PartnershipsPage(): React.JSX.Element {
217
334
  <SegmentedControl
218
335
  ariaLabel="Status scope"
219
336
  value={scope}
220
- onChange={setScope}
337
+ onChange={(next) => {
338
+ listView.setScope(next)
339
+ }}
221
340
  options={[
222
341
  { id: 'open', label: 'Open' },
223
342
  { id: 'all', label: 'All' },
@@ -227,7 +346,9 @@ export function PartnershipsPage(): React.JSX.Element {
227
346
  <SegmentedControl
228
347
  ariaLabel="Group the list"
229
348
  value={grouping}
230
- onChange={setGrouping}
349
+ onChange={(next) => {
350
+ listView.setGrouping(next)
351
+ }}
231
352
  options={[
232
353
  { id: 'stage', label: 'By status' },
233
354
  { id: 'due', label: 'By due' },
@@ -237,12 +358,21 @@ export function PartnershipsPage(): React.JSX.Element {
237
358
  <SegmentedControl
238
359
  ariaLabel="Board or list"
239
360
  value={view}
240
- onChange={setView}
361
+ onChange={(next) => {
362
+ listView.setMode(next)
363
+ }}
241
364
  options={[
242
365
  { id: 'list', label: 'List' },
243
366
  { id: 'columns', label: 'Board' },
244
367
  ]}
245
368
  />
369
+ {view === 'list' && (
370
+ <ColumnPicker
371
+ options={pickerOptions}
372
+ visibleKeys={listView.visibleKeys}
373
+ onChange={listView.setVisibleKeys}
374
+ />
375
+ )}
246
376
  </>
247
377
  }
248
378
  />
@@ -265,6 +395,7 @@ export function PartnershipsPage(): React.JSX.Element {
265
395
  <LoadingPanel label="Loading partnerships…" />
266
396
  ) : (
267
397
  <>
398
+ {view === 'list' && <Paginator list={partnerships} placement="top" />}
268
399
  {view === 'columns' ? (
269
400
  <KanbanBoard
270
401
  stages={visibleStages.map((stage) => ({ id: stage.id, label: stage.label }))}
@@ -311,13 +442,16 @@ export function PartnershipsPage(): React.JSX.Element {
311
442
  ? {
312
443
  label: 'Show all partnerships',
313
444
  onClick: () => {
314
- setScope('all')
445
+ listView.setScope('all')
315
446
  },
316
447
  }
317
448
  : undefined
318
449
  }
319
450
  sort={sort}
320
- onSortChange={setSort}
451
+ onSortChange={(next) => {
452
+ listView.setSort(next)
453
+ }}
454
+ visibleColumnKeys={listView.visibleKeys}
321
455
  />
322
456
  )}
323
457
  {updatePartnership.error !== null && (
@@ -325,17 +459,8 @@ export function PartnershipsPage(): React.JSX.Element {
325
459
  <ErrorPanel error={updatePartnership.error} />
326
460
  </div>
327
461
  )}
328
- {partnerships.hasMore && (
329
- <button
330
- type="button"
331
- onClick={partnerships.loadMore}
332
- disabled={partnerships.isLoadingMore}
333
- 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"
334
- >
335
- {partnerships.isLoadingMore ? 'Loading…' : 'Load more'}
336
- </button>
337
- )}
338
- {companies.hasMore && (
462
+ {view === 'list' && <Paginator list={partnerships} />}
463
+ {companies.hasNext && (
339
464
  <p className="mt-2 text-[11px] text-ink-faint">
340
465
  More companies exist than one page returns, so some company names may show as “—”.
341
466
  </p>
@@ -2,14 +2,33 @@ import type { Person } from '@kelpie/schemas'
2
2
  import { useState } from 'react'
3
3
  import { useNavigate } from 'react-router'
4
4
 
5
+ import { useTimezone } from '../api/resources/account.ts'
5
6
  import { useCreatePerson, usePeople } from '../api/resources/people.ts'
6
7
  import { Chip } from '../components/Chip.tsx'
8
+ import { ColumnPicker } from '../components/ColumnPicker.tsx'
7
9
  import { DataTable } from '../components/DataTable.tsx'
8
10
  import type { Column } from '../components/DataTable.tsx'
9
11
  import { FilterBar, PageHeader } from '../components/PageHeader.tsx'
12
+ import { Paginator } from '../components/Paginator.tsx'
10
13
  import { ErrorPanel, LoadingPanel } from '../components/QueryState.tsx'
14
+ import { formatDate } from '../lib/dates.ts'
15
+ import { useListView } from '../lib/listView.ts'
16
+ import { serverSortOnly } from '../lib/sort.ts'
11
17
  import { usePeopleDirectory } from './positionDirectory.ts'
12
18
 
19
+ /**
20
+ * Column catalog for People. Every field the wire schema carries appears here,
21
+ * plus the two joined-in columns (position title, company names) computed from
22
+ * the position directory. `sortKey` names the server field; `getSortValue`
23
+ * enables the DataTable's page-local sort for columns the server does not
24
+ * index. Everything not in `DEFAULT_VISIBLE_KEYS` starts hidden and comes back
25
+ * through the Columns picker.
26
+ */
27
+ const DEFAULT_VISIBLE_KEYS: readonly string[] = ['name', 'email', 'phones', 'company', 'updatedAt']
28
+
29
+ /** The sort fields `/v1/people` accepts. Anything else is a client-only sort. */
30
+ const SERVER_SORT_KEYS: readonly string[] = ['name', 'created_at', 'updated_at']
31
+
13
32
  /**
14
33
  * The People list.
15
34
  *
@@ -22,8 +41,12 @@ export function PeoplePage(): React.JSX.Element {
22
41
  const [term, setTerm] = useState('')
23
42
  const [sort, setSort] = useState<string | undefined>(undefined)
24
43
  const navigate = useNavigate()
44
+ const timezone = useTimezone()
25
45
  const hasFilter = term.trim().length > 0
26
- const people = usePeople({ term: hasFilter ? term.trim() : undefined, sort })
46
+ const people = usePeople({
47
+ term: hasFilter ? term.trim() : undefined,
48
+ sort: serverSortOnly(sort, SERVER_SORT_KEYS),
49
+ })
27
50
  const directory = usePeopleDirectory(people.records.map((person) => person.id))
28
51
  const createPerson = useCreatePerson()
29
52
 
@@ -45,20 +68,100 @@ export function PeoplePage(): React.JSX.Element {
45
68
  {
46
69
  key: 'position',
47
70
  header: 'Position',
71
+ getSortValue: (person) => directory.titleFor(person.id) ?? null,
48
72
  render: (person) => directory.titleFor(person.id) ?? '—',
49
73
  },
50
74
  {
51
75
  key: 'company',
52
76
  header: 'Company',
77
+ getSortValue: (person) => directory.companyNamesFor(person.id).join(', ') || null,
53
78
  render: (person) => {
54
79
  const names = directory.companyNamesFor(person.id)
55
80
 
56
81
  return names.length > 0 ? names.join(', ') : '—'
57
82
  },
58
83
  },
84
+ {
85
+ key: 'email',
86
+ header: 'Email',
87
+ getSortValue: (person) => person.email,
88
+ render: (person) =>
89
+ person.email === null ? (
90
+ '—'
91
+ ) : (
92
+ <span className="font-mono text-[12px] text-ink-muted">{person.email}</span>
93
+ ),
94
+ },
95
+ {
96
+ key: 'phones',
97
+ header: 'Phones',
98
+ getSortValue: (person) => person.phones.join(', ') || null,
99
+ render: (person) =>
100
+ person.phones.length === 0 ? '—' : (
101
+ <span className="font-mono text-[12px] text-ink-muted">{person.phones.join(', ')}</span>
102
+ ),
103
+ },
104
+ {
105
+ key: 'socials',
106
+ header: 'Socials',
107
+ getSortValue: (person) => person.socialProfiles.map((entry) => entry.network).join(', ') || null,
108
+ render: (person) =>
109
+ person.socialProfiles.length === 0 ? '—' : (
110
+ <span className="flex flex-wrap gap-1">
111
+ {person.socialProfiles.map((entry) => (
112
+ <Chip key={entry.network}>
113
+ <span className="text-[10px]">{entry.network}</span>
114
+ </Chip>
115
+ ))}
116
+ </span>
117
+ ),
118
+ },
119
+ {
120
+ key: 'location',
121
+ header: 'Location',
122
+ getSortValue: (person) => person.location,
123
+ render: (person) => person.location ?? '—',
124
+ },
125
+ {
126
+ key: 'timezone',
127
+ header: 'Timezone',
128
+ getSortValue: (person) => person.timezone,
129
+ render: (person) =>
130
+ person.timezone === null ? '—' : (
131
+ <span className="font-mono text-[12px] text-ink-muted">{person.timezone}</span>
132
+ ),
133
+ },
134
+ {
135
+ key: 'preferredChannel',
136
+ header: 'Preferred channel',
137
+ getSortValue: (person) => person.preferredChannel,
138
+ render: (person) => <span className="capitalize">{person.preferredChannel}</span>,
139
+ },
140
+ {
141
+ key: 'influence',
142
+ header: 'Influence',
143
+ getSortValue: (person) => person.influence,
144
+ render: (person) => <span className="capitalize">{person.influence.replace('_', ' ')}</span>,
145
+ },
146
+ {
147
+ key: 'relationship',
148
+ header: 'Relationship',
149
+ getSortValue: (person) => person.relationship,
150
+ render: (person) => <span className="capitalize">{person.relationship}</span>,
151
+ },
152
+ {
153
+ key: 'summary',
154
+ header: 'Summary',
155
+ getSortValue: (person) => person.summary || null,
156
+ render: (person) =>
157
+ person.summary.length === 0 ? '—' : (
158
+ <span className="text-ink-muted">{person.summary}</span>
159
+ ),
160
+ },
59
161
  {
60
162
  key: 'tags',
61
163
  header: 'Tags',
164
+ getSortValue: (person) => person.tags.join(', ') || null,
62
165
  render: (person) =>
63
166
  person.tags.length > 0 ? (
64
167
  <span className="flex flex-wrap gap-1">
@@ -72,8 +175,31 @@ export function PeoplePage(): React.JSX.Element {
72
175
  '—'
73
176
  ),
74
177
  },
178
+ {
179
+ key: 'lastContactedAt',
180
+ header: 'Last contacted',
181
+ getSortValue: (person) => person.lastContactedAt,
182
+ render: (person) =>
183
+ person.lastContactedAt === null ? '—' : formatDate(person.lastContactedAt, timezone),
184
+ },
185
+ {
186
+ key: 'createdAt',
187
+ header: 'Created',
188
+ sortKey: 'created_at',
189
+ render: (person) => formatDate(person.createdAt, timezone),
190
+ },
191
+ {
192
+ key: 'updatedAt',
193
+ header: 'Updated',
194
+ sortKey: 'updated_at',
195
+ render: (person) => formatDate(person.updatedAt, timezone),
196
+ },
75
197
  ]
76
198
 
199
+ const supportedKeys = columns.map((column) => column.key)
200
+ const listView = useListView('people', supportedKeys, DEFAULT_VISIBLE_KEYS)
201
+ const pickerOptions = columns.map((column) => ({ key: column.key, label: column.header }))
202
+
77
203
  return (
78
204
  <div className="animate-fade-in">
79
205
  <PageHeader
@@ -82,6 +208,13 @@ export function PeoplePage(): React.JSX.Element {
82
208
  void addPerson()
83
209
  }}
84
210
  addLabel="Add person"
211
+ actions={
212
+ <ColumnPicker
213
+ options={pickerOptions}
214
+ visibleKeys={listView.visibleKeys}
215
+ onChange={listView.setVisibleKeys}
216
+ />
217
+ }
85
218
  />
86
219
  <FilterBar
87
220
  value={term}
@@ -101,6 +234,7 @@ export function PeoplePage(): React.JSX.Element {
101
234
  <LoadingPanel label="Loading people…" />
102
235
  ) : (
103
236
  <>
237
+ <Paginator list={people} placement="top" />
104
238
  <DataTable
105
239
  columns={columns}
106
240
  rows={people.records}
@@ -122,22 +256,14 @@ export function PeoplePage(): React.JSX.Element {
122
256
  }
123
257
  sort={sort}
124
258
  onSortChange={setSort}
259
+ visibleColumnKeys={listView.visibleKeys}
125
260
  />
126
261
  {!directory.isComplete && !directory.isLoading && (
127
262
  <p className="mt-2 text-[11px] text-ink-faint">
128
263
  These people hold more positions than one page returns, so some rows may read “—”.
129
264
  </p>
130
265
  )}
131
- {people.hasMore && (
132
- <button
133
- type="button"
134
- onClick={people.loadMore}
135
- disabled={people.isLoadingMore}
136
- 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"
137
- >
138
- {people.isLoadingMore ? 'Loading…' : 'Load more'}
139
- </button>
140
- )}
266
+ <Paginator list={people} />
141
267
  </>
142
268
  )}
143
269
  </div>
@@ -27,6 +27,7 @@ import { InlineEdit } from '../components/InlineEdit.tsx'
27
27
  import { NotesPanel } from '../components/NotesPanel.tsx'
28
28
  import { RelatedPlanAttention } from '../components/PlanAttention.tsx'
29
29
  import { ErrorPanel, LoadingPanel, NotFoundPanel } from '../components/QueryState.tsx'
30
+ import { ListsPanel } from '../components/ListsPanel.tsx'
30
31
  import { RecordTabs } from '../components/RecordTabs.tsx'
31
32
  import type { RecordTabDescriptor } from '../components/RecordTabs.tsx'
32
33
  import { SectionHeader } from '../components/SectionHeader.tsx'
@@ -94,6 +95,7 @@ export function PersonDetail(): React.JSX.Element {
94
95
  : [{ id: 'hiring', label: 'Hiring', count: candidacies.records.length }]),
95
96
  { id: 'notes', label: 'Notes' },
96
97
  { id: 'decisions', label: 'Decisions' },
98
+ { id: 'lists', label: 'Lists' },
97
99
  ...moduleTabs.map((tab) => ({ id: tab.id, label: tab.label })),
98
100
  ]
99
101
  const active = tabs.some((tab) => tab.id === activeTab) ? activeTab : 'overview'
@@ -136,6 +138,7 @@ export function PersonDetail(): React.JSX.Element {
136
138
  )}
137
139
  {active === 'notes' && <NotesPanel targetType="person" targetId={record.id} />}
138
140
  {active === 'decisions' && <DecisionsPanel targetType="person" targetId={record.id} />}
141
+ {active === 'lists' && <ListsPanel targetType="person" targetId={record.id} />}
139
142
  {moduleTab?.render({ objectType: 'person', recordId: record.id })}
140
143
  </RecordTabs>
141
144
  </div>
@@ -408,7 +411,7 @@ function PersonPositions({ person }: { readonly person: Person }): React.JSX.Ele
408
411
  function submit(event: FormEvent): void {
409
412
  event.preventDefault()
410
413
 
411
- if (companyId.length === 0 || title.trim().length === 0) {
414
+ if (companyId.length === 0) {
412
415
  return
413
416
  }
414
417
 
@@ -500,9 +503,8 @@ function PersonPositions({ person }: { readonly person: Person }): React.JSX.Ele
500
503
  onChange={(event) => {
501
504
  setTitle(event.target.value)
502
505
  }}
503
- placeholder="Position title"
506
+ placeholder="Position title (optional)"
504
507
  className="w-full rounded-md border border-border bg-surface-raised px-2 py-1.5 text-[12px] outline-none focus:border-accent"
505
- required
506
508
  />
507
509
  <div className="flex justify-end gap-2">
508
510
  <button