@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
@@ -0,0 +1,149 @@
1
+ import type { ListViewPreference } from '@kelpie/schemas'
2
+ import { useCallback, useMemo } from 'react'
3
+
4
+ import { useAccountPreferences, useUpdateAccountPreferences } from '../api/resources/account.ts'
5
+
6
+ /**
7
+ * The saved view state a list or board page shows the signed-in person.
8
+ *
9
+ * `defaultVisible` is the columns a person sees before they have ever
10
+ * customised this view. `useListView` hides no column until preferences load —
11
+ * a request that has not answered yet is not the same as "the person chose to
12
+ * hide everything," and drawing an empty table under a spinner reads as
13
+ * broken.
14
+ *
15
+ * `mode`, `grouping`, `scope`, and `sort` mirror the four knobs a pipeline
16
+ * page carries: the board-vs-list toggle, the list grouping, the pipeline
17
+ * scope, and the current sort. Each returns `undefined` when the person has
18
+ * not made a choice; the page decides its own default and passes it to the
19
+ * relevant control. A setter called with `undefined` clears the stored
20
+ * value, which is how a person returns a knob to "let the page decide".
21
+ *
22
+ * The set of column ids the page supports is the source of truth for what is
23
+ * saveable. A stored id the page no longer recognises is ignored, and a page
24
+ * column added since the last save falls back to `defaultVisible`.
25
+ */
26
+ /**
27
+ * A partial update to the stored preference. A key set to `undefined` removes
28
+ * it, so a setter can return a knob to "use the page's default".
29
+ */
30
+ interface ListViewPatch {
31
+ readonly columns?: readonly string[] | undefined
32
+ readonly mode?: 'list' | 'columns' | undefined
33
+ readonly grouping?: string | undefined
34
+ readonly scope?: string | undefined
35
+ readonly sort?: string | undefined
36
+ }
37
+
38
+ export interface ListViewController {
39
+ readonly visibleKeys: readonly string[]
40
+ readonly setVisibleKeys: (keys: readonly string[]) => void
41
+ readonly mode: 'list' | 'columns' | undefined
42
+ readonly setMode: (mode: 'list' | 'columns' | undefined) => void
43
+ readonly grouping: string | undefined
44
+ readonly setGrouping: (grouping: string | undefined) => void
45
+ readonly scope: string | undefined
46
+ readonly setScope: (scope: string | undefined) => void
47
+ readonly sort: string | undefined
48
+ readonly setSort: (sort: string | undefined) => void
49
+ readonly isLoading: boolean
50
+ }
51
+
52
+ export function useListView(
53
+ viewId: string,
54
+ supportedKeys: readonly string[],
55
+ defaultVisible: readonly string[],
56
+ ): ListViewController {
57
+ const { preferences, isLoading } = useAccountPreferences()
58
+ const update = useUpdateAccountPreferences()
59
+ const stored = preferences?.listViews[viewId]
60
+
61
+ const visibleKeys = useMemo(() => {
62
+ const cols = stored?.columns
63
+
64
+ if (cols === undefined) {
65
+ return defaultVisible
66
+ }
67
+
68
+ // A stored id the page no longer knows about is dropped, and if the filter
69
+ // strips the whole list the default fills in — a page that lost every
70
+ // recognised column to a rename should not render as empty.
71
+ const kept = cols.filter((key) => supportedKeys.includes(key))
72
+
73
+ return kept.length === 0 ? defaultVisible : kept
74
+ }, [stored, supportedKeys, defaultVisible])
75
+
76
+ const patchView = useCallback(
77
+ (patch: ListViewPatch): void => {
78
+ // Merge into the existing entry so a change to one knob does not wipe
79
+ // the other four. A key set to `undefined` in `patch` is removed from
80
+ // the stored entry, so a setter can return a knob to "use the default".
81
+ const merged: Record<string, unknown> = { ...(stored ?? {}) }
82
+
83
+ for (const [key, value] of Object.entries(patch)) {
84
+ if (value === undefined) {
85
+ delete merged[key]
86
+ } else {
87
+ merged[key] = value
88
+ }
89
+ }
90
+
91
+ const nextViews: Record<string, ListViewPreference> = {
92
+ ...(preferences?.listViews ?? {}),
93
+ [viewId]: merged as ListViewPreference,
94
+ }
95
+
96
+ update.run({ listViews: nextViews })
97
+ },
98
+ [preferences, stored, update, viewId],
99
+ )
100
+
101
+ const setVisibleKeys = useCallback(
102
+ (keys: readonly string[]) => {
103
+ patchView({ columns: keys })
104
+ },
105
+ [patchView],
106
+ )
107
+
108
+ const setMode = useCallback(
109
+ (mode: 'list' | 'columns' | undefined) => {
110
+ patchView({ mode })
111
+ },
112
+ [patchView],
113
+ )
114
+
115
+ const setGrouping = useCallback(
116
+ (grouping: string | undefined) => {
117
+ patchView({ grouping })
118
+ },
119
+ [patchView],
120
+ )
121
+
122
+ const setScope = useCallback(
123
+ (scope: string | undefined) => {
124
+ patchView({ scope })
125
+ },
126
+ [patchView],
127
+ )
128
+
129
+ const setSort = useCallback(
130
+ (sort: string | undefined) => {
131
+ patchView({ sort })
132
+ },
133
+ [patchView],
134
+ )
135
+
136
+ return {
137
+ visibleKeys,
138
+ setVisibleKeys,
139
+ mode: stored?.mode,
140
+ setMode,
141
+ grouping: stored?.grouping,
142
+ setGrouping,
143
+ scope: stored?.scope,
144
+ setScope,
145
+ sort: stored?.sort,
146
+ setSort,
147
+ isLoading,
148
+ }
149
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Comparators for the DataTable's client-side sort path.
3
+ *
4
+ * The server-side path is a keyset over an indexed column, so a page-local sort
5
+ * is different in kind: it only orders the records the current page holds.
6
+ * Anything on another page keeps the resource's own default order — which is
7
+ * why a "sorted on this page" hint sits under any table sorted this way.
8
+ */
9
+
10
+ export type SortDirection = 'asc' | 'desc'
11
+
12
+ /**
13
+ * Returns the sort string that should reach the server, dropping any that
14
+ * names a client-only column. A page holds the active sort as a single string;
15
+ * this lets it pass an unfiltered `sort` to `<DataTable>` (so the header shows
16
+ * an arrow either way) while only forwarding server-sortable choices to its
17
+ * `useX({ sort: serverSortOnly(sort, sortKeys) })` call.
18
+ *
19
+ * `sortKeys` is the set of `Column.sortKey` values on the page — the fields
20
+ * the resource's `_SORTS` map accepts. A `sort` naming anything else answers
21
+ * `undefined`, which drops back to the resource's own default order.
22
+ */
23
+ export function serverSortOnly(
24
+ sort: string | undefined,
25
+ sortKeys: readonly string[],
26
+ ): string | undefined {
27
+ if (sort === undefined) {
28
+ return undefined
29
+ }
30
+
31
+ const bare = sort.startsWith('-') ? sort.slice(1) : sort
32
+
33
+ return sortKeys.includes(bare) ? sort : undefined
34
+ }
35
+
36
+ /**
37
+ * A value the sort knows how to compare. `null` (and `undefined`) always sort
38
+ * to the bottom, regardless of direction — a missing value is not "less than"
39
+ * or "greater than" anything, and pinning it to one end reads better than
40
+ * scattering the blanks through the middle.
41
+ */
42
+ export type SortValue = string | number | Date | boolean | readonly string[] | null | undefined
43
+
44
+ function typeRank(value: SortValue): number {
45
+ if (value === null || value === undefined) {
46
+ return 0
47
+ }
48
+
49
+ if (typeof value === 'string') {
50
+ return value.length === 0 ? 0 : 1
51
+ }
52
+
53
+ if (Array.isArray(value)) {
54
+ return value.length === 0 ? 0 : 1
55
+ }
56
+
57
+ return 1
58
+ }
59
+
60
+ /** Returns a negative number if `a` sorts before `b`, positive if after, 0 for equal. */
61
+ export function compareValues(a: SortValue, b: SortValue): number {
62
+ const rankA = typeRank(a)
63
+ const rankB = typeRank(b)
64
+
65
+ // Missing values sink to the bottom of the ascending order and stay there
66
+ // when reversed. The direction reversal is the caller's job — a null being
67
+ // "always last" is a property of the value, not of the direction.
68
+ if (rankA === 0 && rankB === 0) {
69
+ return 0
70
+ }
71
+
72
+ if (rankA === 0) {
73
+ return 1
74
+ }
75
+
76
+ if (rankB === 0) {
77
+ return -1
78
+ }
79
+
80
+ if (a instanceof Date && b instanceof Date) {
81
+ return a.getTime() - b.getTime()
82
+ }
83
+
84
+ if (typeof a === 'number' && typeof b === 'number') {
85
+ return a - b
86
+ }
87
+
88
+ if (typeof a === 'boolean' && typeof b === 'boolean') {
89
+ return a === b ? 0 : a ? -1 : 1
90
+ }
91
+
92
+ if (Array.isArray(a) && Array.isArray(b)) {
93
+ return a.join(', ').localeCompare(b.join(', '), undefined, { sensitivity: 'base', numeric: true })
94
+ }
95
+
96
+ return String(a).localeCompare(String(b), undefined, { sensitivity: 'base', numeric: true })
97
+ }
98
+
99
+ /**
100
+ * Stable sort by a caller-supplied accessor. `Array.prototype.sort` is not
101
+ * guaranteed stable across every engine JS runs on, so ties fall back to the
102
+ * original index — otherwise switching direction on a column with many ties
103
+ * would reshuffle equal rows on every click.
104
+ *
105
+ * Missing values (null, undefined, empty string, empty array) pin to the
106
+ * bottom in both directions. Reversing "asc null-last" as a signed compare
107
+ * would put nulls on top under desc, which reads as broken — the direction
108
+ * flip is applied only after the null pin is resolved.
109
+ */
110
+ export function sortRowsBy<TRow>(
111
+ rows: readonly TRow[],
112
+ getValue: (row: TRow) => SortValue,
113
+ direction: SortDirection,
114
+ ): TRow[] {
115
+ const indexed = rows.map((row, index) => ({ row, index, value: getValue(row) }))
116
+
117
+ indexed.sort((left, right) => {
118
+ const leftMissing = typeRank(left.value) === 0
119
+ const rightMissing = typeRank(right.value) === 0
120
+
121
+ if (leftMissing && rightMissing) {
122
+ return left.index - right.index
123
+ }
124
+
125
+ if (leftMissing) {
126
+ return 1
127
+ }
128
+
129
+ if (rightMissing) {
130
+ return -1
131
+ }
132
+
133
+ const compared = compareValues(left.value, right.value)
134
+
135
+ if (compared !== 0) {
136
+ return direction === 'asc' ? compared : -compared
137
+ }
138
+
139
+ return left.index - right.index
140
+ })
141
+
142
+ return indexed.map((entry) => entry.row)
143
+ }
@@ -2,13 +2,19 @@ import type { Company, IcpFit } 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 { useCompanies, useCreateCompany } from '../api/resources/companies.ts'
6
7
  import { Chip } from '../components/Chip.tsx'
7
8
  import type { ChipTone } from '../components/Chip.tsx'
9
+ import { ColumnPicker } from '../components/ColumnPicker.tsx'
8
10
  import { DataTable } from '../components/DataTable.tsx'
9
11
  import type { Column } from '../components/DataTable.tsx'
10
12
  import { FilterBar, PageHeader } from '../components/PageHeader.tsx'
13
+ import { Paginator } from '../components/Paginator.tsx'
11
14
  import { ErrorPanel, LoadingPanel } from '../components/QueryState.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 { useCompanyHeadcounts } from './positionDirectory.ts'
13
19
 
14
20
  /**
@@ -26,12 +32,20 @@ const ICP_TONES: Readonly<Record<IcpFit, ChipTone>> = {
26
32
  unknown: 'neutral',
27
33
  }
28
34
 
35
+ const DEFAULT_VISIBLE_KEYS: readonly string[] = ['name', 'domain', 'hq', 'type', 'updatedAt']
36
+
37
+ const SERVER_SORT_KEYS: readonly string[] = ['name', 'created_at', 'updated_at']
38
+
29
39
  export function CompaniesPage(): React.JSX.Element {
30
40
  const [term, setTerm] = useState('')
31
41
  const [sort, setSort] = useState<string | undefined>(undefined)
32
42
  const navigate = useNavigate()
43
+ const timezone = useTimezone()
33
44
  const hasFilter = term.trim().length > 0
34
- const companies = useCompanies({ term: hasFilter ? term.trim() : undefined, sort })
45
+ const companies = useCompanies({
46
+ term: hasFilter ? term.trim() : undefined,
47
+ sort: serverSortOnly(sort, SERVER_SORT_KEYS),
48
+ })
35
49
  const headcounts = useCompanyHeadcounts(companies.records.map((company) => company.id))
36
50
  const createCompany = useCreateCompany()
37
51
 
@@ -51,30 +65,130 @@ export function CompaniesPage(): React.JSX.Element {
51
65
  {
52
66
  key: 'domain',
53
67
  header: 'Domain',
68
+ getSortValue: (company) => company.domain,
54
69
  render: (company) => (
55
70
  <span className="font-mono text-[12px] text-ink-muted">{company.domain ?? '—'}</span>
56
71
  ),
57
72
  },
73
+ {
74
+ key: 'website',
75
+ header: 'Website',
76
+ getSortValue: (company) => company.website,
77
+ render: (company) => (
78
+ <span className="font-mono text-[12px] text-ink-muted">{company.website ?? '—'}</span>
79
+ ),
80
+ },
81
+ {
82
+ key: 'industry',
83
+ header: 'Industry',
84
+ getSortValue: (company) => company.industry,
85
+ render: (company) => company.industry ?? '—',
86
+ },
87
+ {
88
+ key: 'hq',
89
+ header: 'HQ',
90
+ getSortValue: (company) => company.hq,
91
+ render: (company) => company.hq ?? '—',
92
+ },
58
93
  {
59
94
  key: 'type',
60
95
  header: 'Type',
96
+ getSortValue: (company) => company.accountType,
61
97
  render: (company) => <span className="capitalize">{company.accountType}</span>,
62
98
  },
99
+ {
100
+ key: 'stage',
101
+ header: 'Stage',
102
+ getSortValue: (company) => company.stage,
103
+ render: (company) => <span className="capitalize">{company.stage}</span>,
104
+ },
105
+ {
106
+ key: 'sizeBand',
107
+ header: 'Size',
108
+ getSortValue: (company) => company.sizeBand,
109
+ render: (company) => company.sizeBand,
110
+ },
63
111
  {
64
112
  key: 'icp',
65
113
  header: 'ICP fit',
114
+ getSortValue: (company) => company.icpFit,
66
115
  render: (company) => <Chip tone={ICP_TONES[company.icpFit]}>{company.icpFit}</Chip>,
67
116
  },
117
+ {
118
+ key: 'techStack',
119
+ header: 'Tech stack',
120
+ getSortValue: (company) => company.techStack.join(', ') || null,
121
+ render: (company) =>
122
+ company.techStack.length === 0 ? '—' : (
123
+ <span className="flex flex-wrap gap-1">
124
+ {company.techStack.map((entry) => (
125
+ <Chip key={entry}>
126
+ <span className="text-[10px]">{entry}</span>
127
+ </Chip>
128
+ ))}
129
+ </span>
130
+ ),
131
+ },
132
+ {
133
+ key: 'description',
134
+ header: 'Description',
135
+ getSortValue: (company) => company.description || null,
136
+ render: (company) =>
137
+ company.description.length === 0 ? '—' : (
138
+ <span className="text-ink-muted">{company.description}</span>
139
+ ),
140
+ },
141
+ {
142
+ key: 'summary',
143
+ header: 'Summary',
144
+ getSortValue: (company) => company.summary || null,
145
+ render: (company) =>
146
+ company.summary.length === 0 ? '—' : (
147
+ <span className="text-ink-muted">{company.summary}</span>
148
+ ),
149
+ },
150
+ {
151
+ key: 'tags',
152
+ header: 'Tags',
153
+ getSortValue: (company) => company.tags.join(', ') || null,
154
+ render: (company) =>
155
+ company.tags.length === 0 ? '—' : (
156
+ <span className="flex flex-wrap gap-1">
157
+ {company.tags.map((tag) => (
158
+ <Chip key={tag}>
159
+ <span className="text-[10px]">{tag}</span>
160
+ </Chip>
161
+ ))}
162
+ </span>
163
+ ),
164
+ },
68
165
  {
69
166
  key: 'people',
70
167
  header: 'People',
71
168
  className: 'w-20',
169
+ getSortValue: (company) => headcounts.countFor(company.id),
72
170
  render: (company) => (
73
171
  <span className="font-mono text-[12px]">{headcounts.countFor(company.id)}</span>
74
172
  ),
75
173
  },
174
+ {
175
+ key: 'createdAt',
176
+ header: 'Created',
177
+ sortKey: 'created_at',
178
+ render: (company) => formatDate(company.createdAt, timezone),
179
+ },
180
+ {
181
+ key: 'updatedAt',
182
+ header: 'Updated',
183
+ sortKey: 'updated_at',
184
+ render: (company) => formatDate(company.updatedAt, timezone),
185
+ },
76
186
  ]
77
187
 
188
+ const supportedKeys = columns.map((column) => column.key)
189
+ const listView = useListView('companies', supportedKeys, DEFAULT_VISIBLE_KEYS)
190
+ const pickerOptions = columns.map((column) => ({ key: column.key, label: column.header }))
191
+
78
192
  return (
79
193
  <div className="animate-fade-in">
80
194
  <PageHeader
@@ -83,6 +197,13 @@ export function CompaniesPage(): React.JSX.Element {
83
197
  void addCompany()
84
198
  }}
85
199
  addLabel="Add company"
200
+ actions={
201
+ <ColumnPicker
202
+ options={pickerOptions}
203
+ visibleKeys={listView.visibleKeys}
204
+ onChange={listView.setVisibleKeys}
205
+ />
206
+ }
86
207
  />
87
208
  <FilterBar value={term} onChange={setTerm} placeholder="Filter by name, type, tags…" />
88
209
 
@@ -98,6 +219,7 @@ export function CompaniesPage(): React.JSX.Element {
98
219
  <LoadingPanel label="Loading companies…" />
99
220
  ) : (
100
221
  <>
222
+ <Paginator list={companies} placement="top" />
101
223
  <DataTable
102
224
  columns={columns}
103
225
  rows={companies.records}
@@ -119,6 +241,7 @@ export function CompaniesPage(): React.JSX.Element {
119
241
  }
120
242
  sort={sort}
121
243
  onSortChange={setSort}
244
+ visibleColumnKeys={listView.visibleKeys}
122
245
  />
123
246
  {!headcounts.isComplete && !headcounts.isLoading && (
124
247
  <p className="mt-2 text-[11px] text-ink-faint">
@@ -126,16 +249,7 @@ export function CompaniesPage(): React.JSX.Element {
126
249
  a floor rather than a total.
127
250
  </p>
128
251
  )}
129
- {companies.hasMore && (
130
- <button
131
- type="button"
132
- onClick={companies.loadMore}
133
- disabled={companies.isLoadingMore}
134
- 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"
135
- >
136
- {companies.isLoadingMore ? 'Loading…' : 'Load more'}
137
- </button>
138
- )}
252
+ <Paginator list={companies} />
139
253
  </>
140
254
  )}
141
255
  </div>