@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
@@ -11,11 +11,15 @@ import { useOpportunities } from '../api/resources/opportunities.ts'
11
11
  import { usePartnerships } from '../api/resources/partnerships.ts'
12
12
  import { usePeople } from '../api/resources/people.ts'
13
13
  import { useRaises } from '../api/resources/raises.ts'
14
+ import { ColumnPicker } from '../components/ColumnPicker.tsx'
14
15
  import { DataTable } from '../components/DataTable.tsx'
15
16
  import type { Column } from '../components/DataTable.tsx'
16
17
  import { FilterBar, PageHeader } from '../components/PageHeader.tsx'
18
+ import { Paginator } from '../components/Paginator.tsx'
17
19
  import { ErrorPanel, LoadingPanel } from '../components/QueryState.tsx'
18
20
  import { formatDate } from '../lib/dates.ts'
21
+ import { serverSortOnly } from '../lib/sort.ts'
22
+ import { useListView } from '../lib/listView.ts'
19
23
 
20
24
  /**
21
25
  * Every decision in the workspace.
@@ -90,12 +94,12 @@ function useTargetDirectory(): TargetDirectory {
90
94
  return {
91
95
  nameFor: (decision) => nameById.get(decision.targetId),
92
96
  isComplete:
93
- !people.hasMore &&
94
- !companies.hasMore &&
95
- !deals.hasMore &&
96
- !opportunities.hasMore &&
97
- !raises.hasMore &&
98
- !partnerships.hasMore,
97
+ !people.hasNext &&
98
+ !companies.hasNext &&
99
+ !deals.hasNext &&
100
+ !opportunities.hasNext &&
101
+ !raises.hasNext &&
102
+ !partnerships.hasNext,
99
103
  }
100
104
  }
101
105
 
@@ -123,9 +127,17 @@ function LinkedTo({
123
127
  )
124
128
  }
125
129
 
130
+ const DEFAULT_VISIBLE_KEYS: readonly string[] = ['decision', 'target', 'decided', 'due', 'owner']
131
+
132
+ const SERVER_SORT_KEYS: readonly string[] = ['decided_at', 'created_at', 'updated_at']
133
+
126
134
  export function DecisionsPage(): React.JSX.Element {
127
135
  const [term, setTerm] = useState('')
128
- const decisions = useDecisions({ term: term.trim().length > 0 ? term.trim() : undefined })
136
+ const [sort, setSort] = useState<string | undefined>(undefined)
137
+ const decisions = useDecisions({
138
+ term: term.trim().length > 0 ? term.trim() : undefined,
139
+ sort: serverSortOnly(sort, SERVER_SORT_KEYS),
140
+ })
129
141
  const directory = useTargetDirectory()
130
142
  const members = useMembers()
131
143
  const timezone = useTimezone()
@@ -134,6 +146,7 @@ export function DecisionsPage(): React.JSX.Element {
134
146
  {
135
147
  key: 'decision',
136
148
  header: 'Decision',
149
+ getSortValue: (decision) => decision.body,
137
150
  render: (decision) => (
138
151
  <>
139
152
  <div className="font-medium text-ink">{decision.body}</div>
@@ -143,14 +156,33 @@ export function DecisionsPage(): React.JSX.Element {
143
156
  </>
144
157
  ),
145
158
  },
159
+ {
160
+ key: 'rationale',
161
+ header: 'Rationale',
162
+ getSortValue: (decision) => decision.rationale,
163
+ render: (decision) =>
164
+ decision.rationale === null ? (
165
+ '—'
166
+ ) : (
167
+ <span className="text-ink-muted">{decision.rationale}</span>
168
+ ),
169
+ },
146
170
  {
147
171
  key: 'target',
148
172
  header: 'Linked to',
173
+ getSortValue: (decision) => directory.nameFor(decision) ?? TARGET_LABELS[decision.targetType],
149
174
  render: (decision) => <LinkedTo decision={decision} name={directory.nameFor(decision)} />,
150
175
  },
176
+ {
177
+ key: 'targetType',
178
+ header: 'Type',
179
+ getSortValue: (decision) => TARGET_LABELS[decision.targetType],
180
+ render: (decision) => TARGET_LABELS[decision.targetType],
181
+ },
151
182
  {
152
183
  key: 'decided',
153
184
  header: 'Decided',
185
+ sortKey: 'decided_at',
154
186
  render: (decision) => (
155
187
  <span className="text-ink-muted">{formatDate(decision.decidedAt, timezone)}</span>
156
188
  ),
@@ -158,6 +190,7 @@ export function DecisionsPage(): React.JSX.Element {
158
190
  {
159
191
  key: 'due',
160
192
  header: 'By',
193
+ getSortValue: (decision) => decision.dueAt,
161
194
  render: (decision) => (
162
195
  <span className="text-ink-muted">
163
196
  {decision.dueAt === null ? '—' : formatDate(decision.dueAt, timezone)}
@@ -167,17 +200,44 @@ export function DecisionsPage(): React.JSX.Element {
167
200
  {
168
201
  key: 'owner',
169
202
  header: 'Owner',
203
+ getSortValue: (decision) =>
204
+ decision.ownerId === null ? null : (members.nameById.get(decision.ownerId) ?? null),
170
205
  render: (decision) => (
171
206
  <span className="text-ink-muted">
172
207
  {decision.ownerId === null ? '—' : (members.nameById.get(decision.ownerId) ?? '—')}
173
208
  </span>
174
209
  ),
175
210
  },
211
+ {
212
+ key: 'createdAt',
213
+ header: 'Created',
214
+ sortKey: 'created_at',
215
+ render: (decision) => formatDate(decision.createdAt, timezone),
216
+ },
217
+ {
218
+ key: 'updatedAt',
219
+ header: 'Updated',
220
+ sortKey: 'updated_at',
221
+ render: (decision) => formatDate(decision.updatedAt, timezone),
222
+ },
176
223
  ]
177
224
 
225
+ const supportedKeys = columns.map((column) => column.key)
226
+ const listView = useListView('decisions', supportedKeys, DEFAULT_VISIBLE_KEYS)
227
+ const pickerOptions = columns.map((column) => ({ key: column.key, label: column.header }))
228
+
178
229
  return (
179
230
  <div className="animate-fade-in">
180
- <PageHeader title="Decisions" />
231
+ <PageHeader
232
+ title="Decisions"
233
+ actions={
234
+ <ColumnPicker
235
+ options={pickerOptions}
236
+ visibleKeys={listView.visibleKeys}
237
+ onChange={listView.setVisibleKeys}
238
+ />
239
+ }
240
+ />
181
241
  <FilterBar value={term} onChange={setTerm} placeholder="Filter decisions…" />
182
242
 
183
243
  {decisions.error !== null ? (
@@ -186,11 +246,15 @@ export function DecisionsPage(): React.JSX.Element {
186
246
  <LoadingPanel label="Loading decisions…" />
187
247
  ) : (
188
248
  <>
249
+ <Paginator list={decisions} placement="top" />
189
250
  <DataTable
190
251
  columns={columns}
191
252
  rows={decisions.records}
192
253
  getRowId={(decision) => decision.id}
193
254
  emptyMessage="No decisions match"
255
+ sort={sort}
256
+ onSortChange={setSort}
257
+ visibleColumnKeys={listView.visibleKeys}
194
258
  />
195
259
  {!directory.isComplete && (
196
260
  <p className="mt-2 text-[11px] text-ink-faint">
@@ -198,16 +262,7 @@ export function DecisionsPage(): React.JSX.Element {
198
262
  record type.
199
263
  </p>
200
264
  )}
201
- {decisions.hasMore && (
202
- <button
203
- type="button"
204
- onClick={decisions.loadMore}
205
- disabled={decisions.isLoadingMore}
206
- 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"
207
- >
208
- {decisions.isLoadingMore ? 'Loading…' : 'Load more'}
209
- </button>
210
- )}
265
+ <Paginator list={decisions} />
211
266
  </>
212
267
  )}
213
268
  </div>
@@ -2,13 +2,19 @@ import type { Form } 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 { useCreateForm, useForms } from '../api/resources/forms.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'
11
14
  import { SegmentedControl } from '../components/SegmentedControl.tsx'
15
+ import { formatDate } from '../lib/dates.ts'
16
+ import { useListView } from '../lib/listView.ts'
17
+ import { serverSortOnly } from '../lib/sort.ts'
12
18
  import { CONTACT_FORM_FIELDS } from './forms/template.ts'
13
19
 
14
20
  /**
@@ -31,6 +37,10 @@ const STATUS_OPTIONS = [
31
37
  { id: 'paused' as const, label: 'Paused' },
32
38
  ]
33
39
 
40
+ const DEFAULT_VISIBLE_KEYS: readonly string[] = ['name', 'status', 'fields', 'deal']
41
+
42
+ const SERVER_SORT_KEYS: readonly string[] = ['name', 'created_at', 'updated_at']
43
+
34
44
  export function FormsPage(): React.JSX.Element {
35
45
  const [term, setTerm] = useState('')
36
46
  const [status, setStatus] = useState<StatusFilter>('all')
@@ -38,10 +48,11 @@ export function FormsPage(): React.JSX.Element {
38
48
  const navigate = useNavigate()
39
49
  const hasFilter = term.trim().length > 0 || status !== 'all'
40
50
  const createForm = useCreateForm()
51
+ const timezone = useTimezone()
41
52
  const forms = useForms({
42
53
  term: term.trim().length > 0 ? term.trim() : undefined,
43
54
  ...(status === 'all' ? {} : { status }),
44
- sort,
55
+ sort: serverSortOnly(sort, SERVER_SORT_KEYS),
45
56
  })
46
57
 
47
58
  /**
@@ -70,10 +81,17 @@ export function FormsPage(): React.JSX.Element {
70
81
  </>
71
82
  ),
72
83
  },
84
+ {
85
+ key: 'description',
86
+ header: 'Description',
87
+ getSortValue: (form) => form.description,
88
+ render: (form) => form.description ?? '—',
89
+ },
73
90
  {
74
91
  key: 'status',
75
92
  header: 'Status',
76
93
  className: 'w-28',
94
+ getSortValue: (form) => form.status,
77
95
  render: (form) => (
78
96
  <Chip tone={form.status === 'active' ? 'success' : 'neutral'}>{form.status}</Chip>
79
97
  ),
@@ -82,12 +100,14 @@ export function FormsPage(): React.JSX.Element {
82
100
  key: 'fields',
83
101
  header: 'Fields',
84
102
  className: 'w-20',
103
+ getSortValue: (form) => form.fields.length,
85
104
  render: (form) => <span className="font-mono text-[12px]">{form.fields.length}</span>,
86
105
  },
87
106
  {
88
107
  key: 'deal',
89
108
  header: 'Deal',
90
109
  className: 'w-32',
110
+ getSortValue: (form) => form.createDeal,
91
111
  render: (form) =>
92
112
  form.createDeal ? (
93
113
  <Chip tone="accent">Creates deal</Chip>
@@ -95,8 +115,52 @@ export function FormsPage(): React.JSX.Element {
95
115
  <span className="text-ink-faint">—</span>
96
116
  ),
97
117
  },
118
+ {
119
+ key: 'dealNameTemplate',
120
+ header: 'Deal name template',
121
+ getSortValue: (form) => form.dealNameTemplate,
122
+ render: (form) =>
123
+ form.dealNameTemplate === null ? (
124
+ '—'
125
+ ) : (
126
+ <span className="font-mono text-[12px] text-ink-muted">{form.dealNameTemplate}</span>
127
+ ),
128
+ },
129
+ {
130
+ key: 'thankYouMessage',
131
+ header: 'Thank-you',
132
+ getSortValue: (form) => form.thankYouMessage || null,
133
+ render: (form) =>
134
+ form.thankYouMessage.length === 0 ? '—' : (
135
+ <span className="text-ink-muted">{form.thankYouMessage}</span>
136
+ ),
137
+ },
138
+ {
139
+ key: 'publicKey',
140
+ header: 'Public key',
141
+ getSortValue: (form) => form.publicKey,
142
+ render: (form) => (
143
+ <span className="font-mono text-[12px] text-ink-muted">{form.publicKey}</span>
144
+ ),
145
+ },
146
+ {
147
+ key: 'createdAt',
148
+ header: 'Created',
149
+ sortKey: 'created_at',
150
+ render: (form) => formatDate(form.createdAt, timezone),
151
+ },
152
+ {
153
+ key: 'updatedAt',
154
+ header: 'Updated',
155
+ sortKey: 'updated_at',
156
+ render: (form) => formatDate(form.updatedAt, timezone),
157
+ },
98
158
  ]
99
159
 
160
+ const supportedKeys = columns.map((column) => column.key)
161
+ const listView = useListView('forms', supportedKeys, DEFAULT_VISIBLE_KEYS)
162
+ const pickerOptions = columns.map((column) => ({ key: column.key, label: column.header }))
163
+
100
164
  return (
101
165
  <div className="animate-fade-in">
102
166
  <PageHeader
@@ -105,12 +169,19 @@ export function FormsPage(): React.JSX.Element {
105
169
  onAdd={addForm}
106
170
  addLabel="New form"
107
171
  actions={
108
- <SegmentedControl
109
- value={status}
110
- onChange={setStatus}
111
- options={STATUS_OPTIONS}
112
- ariaLabel="Filter by status"
113
- />
172
+ <>
173
+ <SegmentedControl
174
+ value={status}
175
+ onChange={setStatus}
176
+ options={STATUS_OPTIONS}
177
+ ariaLabel="Filter by status"
178
+ />
179
+ <ColumnPicker
180
+ options={pickerOptions}
181
+ visibleKeys={listView.visibleKeys}
182
+ onChange={listView.setVisibleKeys}
183
+ />
184
+ </>
114
185
  }
115
186
  />
116
187
  <FilterBar value={term} onChange={setTerm} placeholder="Filter forms…" />
@@ -123,6 +194,7 @@ export function FormsPage(): React.JSX.Element {
123
194
  <LoadingPanel label="Loading forms…" />
124
195
  ) : (
125
196
  <>
197
+ <Paginator list={forms} placement="top" />
126
198
  <DataTable
127
199
  columns={columns}
128
200
  rows={forms.records}
@@ -133,17 +205,9 @@ export function FormsPage(): React.JSX.Element {
133
205
  emptyAction={hasFilter ? undefined : { label: 'New form', onClick: addForm }}
134
206
  sort={sort}
135
207
  onSortChange={setSort}
208
+ visibleColumnKeys={listView.visibleKeys}
136
209
  />
137
- {forms.hasMore && (
138
- <button
139
- type="button"
140
- onClick={forms.loadMore}
141
- disabled={forms.isLoadingMore}
142
- 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"
143
- >
144
- {forms.isLoadingMore ? 'Loading…' : 'Load more'}
145
- </button>
146
- )}
210
+ <Paginator list={forms} />
147
211
  </>
148
212
  )}
149
213
  </div>
@@ -1,5 +1,4 @@
1
1
  import type { PipelineStage, Raise } 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'
@@ -7,17 +6,22 @@ import { useMembers } from '../api/resources/members.ts'
7
6
  import { usePipelineStages } from '../api/resources/pipelineStages.ts'
8
7
  import { MAX_PAGE_SIZE, usePlanItems } from '../api/resources/planItems.ts'
9
8
  import { useCreateRaise, useRaises, useUpdateRaise } from '../api/resources/raises.ts'
9
+ import { useTimezone } from '../api/resources/account.ts'
10
10
  import { Chip } from '../components/Chip.tsx'
11
11
  import type { ChipTone } from '../components/Chip.tsx'
12
+ import { ColumnPicker } from '../components/ColumnPicker.tsx'
12
13
  import { DataTable } from '../components/DataTable.tsx'
13
14
  import type { Column, DataTableGroup } from '../components/DataTable.tsx'
14
15
  import { KanbanBoard } from '../components/KanbanBoard.tsx'
15
16
  import { PageHeader } from '../components/PageHeader.tsx'
17
+ import { Paginator } from '../components/Paginator.tsx'
16
18
  import { ErrorPanel, LoadingPanel } from '../components/QueryState.tsx'
17
19
  import { SegmentedControl } from '../components/SegmentedControl.tsx'
18
- import { formatDay } from '../lib/dates.ts'
20
+ import { formatDate, formatDay } from '../lib/dates.ts'
21
+ import { useListView } from '../lib/listView.ts'
19
22
  import { formatMoney } from '../lib/money.ts'
20
23
  import { DUE_BUCKETS, byDateThenTitle, dueBucketFor, nextOpenByTarget } from '../lib/plan.ts'
24
+ import { serverSortOnly } from '../lib/sort.ts'
21
25
 
22
26
  /**
23
27
  * The Fundraising board: one raise per firm per round, grouped by stage. The
@@ -29,6 +33,34 @@ type BoardView = 'list' | 'columns'
29
33
  type PipelineScope = 'open' | 'all'
30
34
  type ListGrouping = 'stage' | 'due'
31
35
 
36
+ const SCOPES: readonly PipelineScope[] = ['open', 'all']
37
+ const GROUPINGS: readonly ListGrouping[] = ['stage', 'due']
38
+
39
+ function readScope(stored: string | undefined): PipelineScope {
40
+ return SCOPES.includes(stored as PipelineScope) ? (stored as PipelineScope) : 'open'
41
+ }
42
+
43
+ function readGrouping(stored: string | undefined): ListGrouping {
44
+ return GROUPINGS.includes(stored as ListGrouping) ? (stored as ListGrouping) : 'stage'
45
+ }
46
+
47
+ const COLUMN_KEYS: readonly string[] = [
48
+ 'name',
49
+ 'company',
50
+ 'stage',
51
+ 'check',
52
+ 'currency',
53
+ 'thesisFit',
54
+ 'passReason',
55
+ 'nextPlan',
56
+ 'owner',
57
+ 'close',
58
+ 'tags',
59
+ 'summary',
60
+ 'createdAt',
61
+ 'updatedAt',
62
+ ]
63
+
32
64
  /** Tones keyed by the seeded slugs. A renamed stage keeps its slug and its tone. */
33
65
  const STAGE_TONES: Readonly<Record<string, ChipTone>> = {
34
66
  closed: 'success',
@@ -43,19 +75,33 @@ function stageTone(stage: PipelineStage): ChipTone {
43
75
  return STAGE_TONES[stage.slug] ?? 'neutral'
44
76
  }
45
77
 
78
+ const DEFAULT_VISIBLE_KEYS: readonly string[] = [
79
+ 'name',
80
+ 'company',
81
+ 'check',
82
+ 'nextPlan',
83
+ 'owner',
84
+ 'close',
85
+ ]
86
+
87
+ const SERVER_SORT_KEYS: readonly string[] = ['name', 'created_at', 'updated_at']
88
+
46
89
  export function FundraisingPage(): React.JSX.Element {
47
90
  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)
91
+
92
+ const listView = useListView('raises', COLUMN_KEYS, DEFAULT_VISIBLE_KEYS)
93
+ const view: BoardView = listView.mode ?? 'columns'
94
+ const scope = readScope(listView.scope)
95
+ const grouping = readGrouping(listView.grouping)
96
+ const sort = listView.sort
52
97
 
53
98
  const stages = usePipelineStages('raise')
54
- const raises = useRaises({ sort })
99
+ const raises = useRaises({ sort: serverSortOnly(sort, SERVER_SORT_KEYS) })
55
100
  const companies = useCompanies({ limit: 200 })
56
101
  const members = useMembers()
57
102
  const createRaise = useCreateRaise()
58
103
  const updateRaise = useUpdateRaise()
104
+ const timezone = useTimezone()
59
105
 
60
106
  const allStages = [...stages.records].sort((a, b) => a.sortOrder - b.sortOrder)
61
107
  const visibleStages = scope === 'open' ? allStages.filter((stage) => stage.open) : allStages
@@ -76,7 +122,7 @@ export function FundraisingPage(): React.JSX.Element {
76
122
  { enabled: askedIds.length > 0 },
77
123
  )
78
124
  const nextPlanByRaise = nextOpenByTarget(planItems.records)
79
- const plansTruncated = visibleRaises.length > MAX_PAGE_SIZE || planItems.hasMore
125
+ const plansTruncated = visibleRaises.length > MAX_PAGE_SIZE || planItems.hasNext
80
126
 
81
127
  const companyNameById = new Map(companies.records.map((company) => [company.id, company.name]))
82
128
 
@@ -95,6 +141,8 @@ export function FundraisingPage(): React.JSX.Element {
95
141
  await navigate(`/fundraising/${raise.id}`)
96
142
  }
97
143
 
144
+ const stageLabelById = new Map(allStages.map((stage) => [stage.id, stage.label]))
145
+
98
146
  const columns: readonly Column<Raise>[] = [
99
147
  {
100
148
  key: 'name',
@@ -105,12 +153,20 @@ export function FundraisingPage(): React.JSX.Element {
105
153
  {
106
154
  key: 'company',
107
155
  header: 'Firm',
156
+ getSortValue: (raise) => companyNameById.get(raise.companyId) ?? null,
108
157
  render: (raise) => companyNameById.get(raise.companyId) ?? '—',
109
158
  },
159
+ {
160
+ key: 'stage',
161
+ header: 'Stage',
162
+ getSortValue: (raise) => stageLabelById.get(raise.stageId) ?? null,
163
+ render: (raise) => stageLabelById.get(raise.stageId) ?? '—',
164
+ },
110
165
  {
111
166
  key: 'check',
112
167
  header: 'Check',
113
168
  className: 'w-28',
169
+ getSortValue: (raise) => raise.checkSizeCents,
114
170
  render: (raise) =>
115
171
  raise.checkSizeCents === null ? (
116
172
  '—'
@@ -120,9 +176,34 @@ export function FundraisingPage(): React.JSX.Element {
120
176
  </span>
121
177
  ),
122
178
  },
179
+ {
180
+ key: 'currency',
181
+ header: 'Currency',
182
+ getSortValue: (raise) => raise.currency,
183
+ render: (raise) => raise.currency ?? '—',
184
+ },
185
+ {
186
+ key: 'thesisFit',
187
+ header: 'Thesis fit',
188
+ getSortValue: (raise) => raise.thesisFit || null,
189
+ render: (raise) =>
190
+ raise.thesisFit.length === 0 ? '—' : (
191
+ <span className="text-ink-muted">{raise.thesisFit}</span>
192
+ ),
193
+ },
194
+ {
195
+ key: 'passReason',
196
+ header: 'Pass reason',
197
+ getSortValue: (raise) => raise.passReason,
198
+ render: (raise) =>
199
+ raise.passReason === null ? '—' : (
200
+ <span className="text-ink-muted">{raise.passReason}</span>
201
+ ),
202
+ },
123
203
  {
124
204
  key: 'nextPlan',
125
205
  header: 'Next plan',
206
+ getSortValue: (raise) => nextPlanByRaise.get(raise.id)?.date ?? null,
126
207
  render: (raise) => {
127
208
  const next = nextPlanByRaise.get(raise.id)
128
209
 
@@ -143,6 +224,8 @@ export function FundraisingPage(): React.JSX.Element {
143
224
  {
144
225
  key: 'owner',
145
226
  header: 'Owner',
227
+ getSortValue: (raise) =>
228
+ raise.ownerId === null ? null : (members.nameById.get(raise.ownerId) ?? 'Unknown'),
146
229
  render: (raise) =>
147
230
  raise.ownerId === null ? '—' : (members.nameById.get(raise.ownerId) ?? 'Unknown'),
148
231
  },
@@ -150,14 +233,51 @@ export function FundraisingPage(): React.JSX.Element {
150
233
  key: 'close',
151
234
  header: 'Close',
152
235
  className: 'w-28',
236
+ getSortValue: (raise) => raise.expectedClose,
153
237
  render: (raise) => (
154
238
  <span className="font-mono text-[12px] text-ink-muted">
155
239
  {raise.expectedClose === null ? '—' : formatDay(raise.expectedClose)}
156
240
  </span>
157
241
  ),
158
242
  },
243
+ {
244
+ key: 'tags',
245
+ header: 'Tags',
246
+ getSortValue: (raise) => raise.tags.join(', ') || null,
247
+ render: (raise) =>
248
+ raise.tags.length === 0 ? '—' : (
249
+ <span className="flex flex-wrap gap-1">
250
+ {raise.tags.map((tag) => (
251
+ <Chip key={tag}>
252
+ <span className="text-[10px]">{tag}</span>
253
+ </Chip>
254
+ ))}
255
+ </span>
256
+ ),
257
+ },
258
+ {
259
+ key: 'summary',
260
+ header: 'Summary',
261
+ getSortValue: (raise) => raise.summary || null,
262
+ render: (raise) =>
263
+ raise.summary.length === 0 ? '—' : <span className="text-ink-muted">{raise.summary}</span>,
264
+ },
265
+ {
266
+ key: 'createdAt',
267
+ header: 'Created',
268
+ sortKey: 'created_at',
269
+ render: (raise) => formatDate(raise.createdAt, timezone),
270
+ },
271
+ {
272
+ key: 'updatedAt',
273
+ header: 'Updated',
274
+ sortKey: 'updated_at',
275
+ render: (raise) => formatDate(raise.updatedAt, timezone),
276
+ },
159
277
  ]
160
278
 
279
+ const pickerOptions = columns.map((column) => ({ key: column.key, label: column.header }))
280
+
161
281
  const stageGroups: readonly DataTableGroup<Raise>[] = visibleStages.map((stage) => ({
162
282
  id: stage.id,
163
283
  label: <Chip tone={stageTone(stage)}>{stage.label}</Chip>,
@@ -221,7 +341,9 @@ export function FundraisingPage(): React.JSX.Element {
221
341
  <SegmentedControl
222
342
  ariaLabel="Stage scope"
223
343
  value={scope}
224
- onChange={setScope}
344
+ onChange={(next) => {
345
+ listView.setScope(next)
346
+ }}
225
347
  options={[
226
348
  { id: 'open', label: 'Open' },
227
349
  { id: 'all', label: 'All' },
@@ -231,7 +353,9 @@ export function FundraisingPage(): React.JSX.Element {
231
353
  <SegmentedControl
232
354
  ariaLabel="Group the list"
233
355
  value={grouping}
234
- onChange={setGrouping}
356
+ onChange={(next) => {
357
+ listView.setGrouping(next)
358
+ }}
235
359
  options={[
236
360
  { id: 'stage', label: 'By stage' },
237
361
  { id: 'due', label: 'By due' },
@@ -241,12 +365,21 @@ export function FundraisingPage(): React.JSX.Element {
241
365
  <SegmentedControl
242
366
  ariaLabel="Board or list"
243
367
  value={view}
244
- onChange={setView}
368
+ onChange={(next) => {
369
+ listView.setMode(next)
370
+ }}
245
371
  options={[
246
372
  { id: 'list', label: 'List' },
247
373
  { id: 'columns', label: 'Board' },
248
374
  ]}
249
375
  />
376
+ {view === 'list' && (
377
+ <ColumnPicker
378
+ options={pickerOptions}
379
+ visibleKeys={listView.visibleKeys}
380
+ onChange={listView.setVisibleKeys}
381
+ />
382
+ )}
250
383
  </>
251
384
  }
252
385
  />
@@ -269,6 +402,7 @@ export function FundraisingPage(): React.JSX.Element {
269
402
  <LoadingPanel label="Loading raises…" />
270
403
  ) : (
271
404
  <>
405
+ {view === 'list' && <Paginator list={raises} placement="top" />}
272
406
  {view === 'columns' ? (
273
407
  <KanbanBoard
274
408
  stages={visibleStages.map((stage) => ({ id: stage.id, label: stage.label }))}
@@ -313,13 +447,16 @@ export function FundraisingPage(): React.JSX.Element {
313
447
  ? {
314
448
  label: 'Show all raises',
315
449
  onClick: () => {
316
- setScope('all')
450
+ listView.setScope('all')
317
451
  },
318
452
  }
319
453
  : undefined
320
454
  }
321
455
  sort={sort}
322
- onSortChange={setSort}
456
+ onSortChange={(next) => {
457
+ listView.setSort(next)
458
+ }}
459
+ visibleColumnKeys={listView.visibleKeys}
323
460
  />
324
461
  )}
325
462
  {updateRaise.error !== null && (
@@ -327,17 +464,8 @@ export function FundraisingPage(): React.JSX.Element {
327
464
  <ErrorPanel error={updateRaise.error} />
328
465
  </div>
329
466
  )}
330
- {raises.hasMore && (
331
- <button
332
- type="button"
333
- onClick={raises.loadMore}
334
- disabled={raises.isLoadingMore}
335
- 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"
336
- >
337
- {raises.isLoadingMore ? 'Loading…' : 'Load more'}
338
- </button>
339
- )}
340
- {companies.hasMore && (
467
+ {view === 'list' && <Paginator list={raises} />}
468
+ {companies.hasNext && (
341
469
  <p className="mt-2 text-[11px] text-ink-faint">
342
470
  More companies exist than one page returns, so some firm names may show as “—”.
343
471
  </p>