@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
@@ -30,6 +30,7 @@ import { DecisionsPanel } from '../components/DecisionsPanel.tsx'
30
30
  import { DeleteRecord } from '../components/DeleteRecord.tsx'
31
31
  import { EntitySearch } from '../components/EntitySearch.tsx'
32
32
  import { InlineEdit } from '../components/InlineEdit.tsx'
33
+ import { ListsPanel } from '../components/ListsPanel.tsx'
33
34
  import { NotesPanel } from '../components/NotesPanel.tsx'
34
35
  import { RelatedPlanAttention } from '../components/PlanAttention.tsx'
35
36
  import { ErrorPanel, LoadingPanel, NotFoundPanel } from '../components/QueryState.tsx'
@@ -45,7 +46,7 @@ import { toOptions, toTags } from './fields.ts'
45
46
  /**
46
47
  * One company.
47
48
  *
48
- * Overview, Activity, Notes and Decisions render today. As with People, the
49
+ * Overview, Activity, Notes, Decisions, Lists and People render today. The
49
50
  * tabs for Deals, Opportunities, Partnerships and Raises wait for their
50
51
  * endpoints. A UI module can add its own through the `company` record-tab slot.
51
52
  */
@@ -85,8 +86,10 @@ export function CompanyDetail(): React.JSX.Element {
85
86
  const tabs: readonly RecordTabDescriptor<string>[] = [
86
87
  { id: 'overview', label: 'Overview' },
87
88
  { id: 'activity', label: 'Activity' },
89
+ { id: 'people', label: 'People' },
88
90
  { id: 'notes', label: 'Notes' },
89
91
  { id: 'decisions', label: 'Decisions' },
92
+ { id: 'lists', label: 'Lists' },
90
93
  ...moduleTabs.map((tab) => ({ id: tab.id, label: tab.label })),
91
94
  ]
92
95
  const active = tabs.some((tab) => tab.id === activeTab) ? activeTab : 'overview'
@@ -129,15 +132,16 @@ export function CompanyDetail(): React.JSX.Element {
129
132
  >
130
133
  {active === 'overview' && <CompanyOverview company={record} />}
131
134
  {active === 'activity' && <ActivitiesPanel targetType="company" targetId={record.id} />}
135
+ {active === 'people' && <CompanyPeople company={record} />}
132
136
  {active === 'notes' && <NotesPanel targetType="company" targetId={record.id} />}
133
137
  {active === 'decisions' && <DecisionsPanel targetType="company" targetId={record.id} />}
138
+ {active === 'lists' && <ListsPanel targetType="company" targetId={record.id} />}
134
139
  {moduleTab?.render({ objectType: 'company', recordId: record.id })}
135
140
  </RecordTabs>
136
141
  </div>
137
142
 
138
143
  <aside className="space-y-4 text-[12px] lg:sticky lg:top-6">
139
144
  <CompanySidebar company={record} />
140
- <CompanyPeople company={record} />
141
145
  </aside>
142
146
  </div>
143
147
  </div>
@@ -380,7 +384,7 @@ function CompanyPeople({ company }: { readonly company: Company }): React.JSX.El
380
384
  function submit(event: FormEvent): void {
381
385
  event.preventDefault()
382
386
 
383
- if (personId.length === 0 || title.trim().length === 0) {
387
+ if (personId.length === 0) {
384
388
  return
385
389
  }
386
390
 
@@ -389,73 +393,28 @@ function CompanyPeople({ company }: { readonly company: Company }): React.JSX.El
389
393
  }
390
394
 
391
395
  return (
392
- <section className="rounded-md border border-border">
393
- <div className="border-b border-border px-3.5 py-2.5">
394
- <SectionHeader
395
- title="People"
396
- onAdd={() => {
397
- setAdding((current) => !current)
398
- }}
399
- addLabel="Add person"
400
- compact
401
- />
402
- </div>
396
+ <section>
397
+ <SectionHeader
398
+ title="People"
399
+ onAdd={() => {
400
+ setAdding((current) => !current)
401
+ }}
402
+ addLabel="Add person"
403
+ />
403
404
 
404
405
  {createPosition.error !== null && (
405
- <div className="px-3.5 py-2">
406
+ <div className="mb-3">
406
407
  <ErrorPanel error={createPosition.error} />
407
408
  </div>
408
409
  )}
409
410
  {deletePosition.error !== null && (
410
- <div className="px-3.5 py-2">
411
+ <div className="mb-3">
411
412
  <ErrorPanel error={deletePosition.error} />
412
413
  </div>
413
414
  )}
414
415
 
415
- <ul className="divide-y divide-border">
416
- {positions.isLoading && (
417
- <li className="px-3.5 py-4 text-[12px] text-ink-faint">Loading people…</li>
418
- )}
419
- {!positions.isLoading && positions.records.length === 0 && !adding && (
420
- <li className="px-3.5 py-4 text-[12px] text-ink-faint">No people yet.</li>
421
- )}
422
- {positions.records.map((position) => {
423
- const person = personById.get(position.personId)
424
-
425
- return (
426
- <li key={position.id} className="space-y-1 px-3.5 py-2.5">
427
- <div className="flex items-start justify-between gap-2">
428
- <Link
429
- to={`/people/${position.personId}`}
430
- className="text-[13px] font-medium text-ink hover:text-accent"
431
- >
432
- {person?.name ?? 'Unknown'}
433
- </Link>
434
- <button
435
- type="button"
436
- onClick={() => {
437
- deletePosition.run(position.id)
438
- }}
439
- className="text-[11px] font-medium text-danger hover:underline"
440
- >
441
- Remove
442
- </button>
443
- </div>
444
- <InlineEdit
445
- value={position.title}
446
- onChange={(next) => {
447
- updateTitle.run({ id: position.id, changes: { title: next } })
448
- }}
449
- displayClassName="not-italic text-[12px]"
450
- emptyLabel="Add position title…"
451
- />
452
- </li>
453
- )
454
- })}
455
- </ul>
456
-
457
416
  {adding && (
458
- <form onSubmit={submit} className="space-y-2 border-t border-border bg-surface px-3.5 py-3">
417
+ <form onSubmit={submit} className="mb-4 space-y-2 rounded-md border border-border bg-surface p-3">
459
418
  <EntitySearch
460
419
  options={searchable.records
461
420
  .filter((person) => !held.has(person.id))
@@ -476,9 +435,8 @@ function CompanyPeople({ company }: { readonly company: Company }): React.JSX.El
476
435
  onChange={(event) => {
477
436
  setTitle(event.target.value)
478
437
  }}
479
- placeholder="Position title"
480
- className="w-full rounded-md border border-border bg-surface-raised px-2 py-1.5 text-[12px] outline-none focus:border-accent"
481
- required
438
+ placeholder="Position title (optional)"
439
+ className="w-full rounded-md border border-border bg-surface-raised px-2 py-1.5 text-[13px] outline-none focus:border-accent"
482
440
  />
483
441
  <div className="flex justify-end gap-2">
484
442
  <button
@@ -497,6 +455,48 @@ function CompanyPeople({ company }: { readonly company: Company }): React.JSX.El
497
455
  </div>
498
456
  </form>
499
457
  )}
458
+
459
+ {positions.isLoading && <p className="text-[13px] text-ink-faint">Loading people…</p>}
460
+
461
+ {!positions.isLoading && positions.records.length === 0 && !adding ? (
462
+ <p className="text-[13px] text-ink-faint">No people yet.</p>
463
+ ) : (
464
+ <ul className="divide-y divide-border rounded-md border border-border">
465
+ {positions.records.map((position) => {
466
+ const person = personById.get(position.personId)
467
+
468
+ return (
469
+ <li key={position.id} className="space-y-1 px-3.5 py-3">
470
+ <div className="flex items-start justify-between gap-2">
471
+ <Link
472
+ to={`/people/${position.personId}`}
473
+ className="text-[13px] font-medium text-ink hover:text-accent"
474
+ >
475
+ {person?.name ?? 'Unknown'}
476
+ </Link>
477
+ <button
478
+ type="button"
479
+ onClick={() => {
480
+ deletePosition.run(position.id)
481
+ }}
482
+ className="text-[11px] font-medium text-danger hover:underline"
483
+ >
484
+ Remove
485
+ </button>
486
+ </div>
487
+ <InlineEdit
488
+ value={position.title}
489
+ onChange={(next) => {
490
+ updateTitle.run({ id: position.id, changes: { title: next } })
491
+ }}
492
+ displayClassName="not-italic text-[12px] text-ink-muted"
493
+ emptyLabel="Add position title…"
494
+ />
495
+ </li>
496
+ )
497
+ })}
498
+ </ul>
499
+ )}
500
500
  </section>
501
501
  )
502
502
  }
@@ -19,6 +19,7 @@ import { DecisionsPanel } from '../components/DecisionsPanel.tsx'
19
19
  import { DeleteRecord } from '../components/DeleteRecord.tsx'
20
20
  import { EntitySearch } from '../components/EntitySearch.tsx'
21
21
  import { InlineEdit } from '../components/InlineEdit.tsx'
22
+ import { ListsPanel } from '../components/ListsPanel.tsx'
22
23
  import { NotesPanel } from '../components/NotesPanel.tsx'
23
24
  import { PlanAttention } from '../components/PlanAttention.tsx'
24
25
  import { PlanPanel } from '../components/PlanPanel.tsx'
@@ -73,6 +74,7 @@ export function DealDetail(): React.JSX.Element {
73
74
  { id: 'activity', label: 'Activity' },
74
75
  { id: 'notes', label: 'Notes' },
75
76
  { id: 'decisions', label: 'Decisions' },
77
+ { id: 'lists', label: 'Lists' },
76
78
  ...moduleTabs.map((tab) => ({ id: tab.id, label: tab.label })),
77
79
  ]
78
80
  const active = tabs.some((tab) => tab.id === activeTab) ? activeTab : 'overview'
@@ -113,6 +115,7 @@ export function DealDetail(): React.JSX.Element {
113
115
  {active === 'activity' && <ActivitiesPanel targetType="deal" targetId={record.id} />}
114
116
  {active === 'notes' && <NotesPanel targetType="deal" targetId={record.id} />}
115
117
  {active === 'decisions' && <DecisionsPanel targetType="deal" targetId={record.id} />}
118
+ {active === 'lists' && <ListsPanel targetType="deal" targetId={record.id} />}
116
119
  {moduleTab?.render({ objectType: 'deal', recordId: record.id })}
117
120
  </RecordTabs>
118
121
  </div>
@@ -1,5 +1,4 @@
1
1
  import type { Deal, 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'
@@ -7,17 +6,22 @@ import { useDeals, useCreateDeal, useUpdateDeal } from '../api/resources/deals.t
7
6
  import { useMembers } from '../api/resources/members.ts'
8
7
  import { usePipelineStages } from '../api/resources/pipelineStages.ts'
9
8
  import { MAX_PAGE_SIZE, usePlanItems } from '../api/resources/planItems.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 Deals pipeline: a board and a list over the same records, grouped by stage
@@ -31,6 +35,17 @@ type BoardView = 'list' | 'columns'
31
35
  type PipelineScope = 'open' | 'all'
32
36
  type ListGrouping = 'stage' | 'due'
33
37
 
38
+ const SCOPES: readonly PipelineScope[] = ['open', 'all']
39
+ const GROUPINGS: readonly ListGrouping[] = ['stage', 'due']
40
+
41
+ function readScope(stored: string | undefined): PipelineScope {
42
+ return SCOPES.includes(stored as PipelineScope) ? (stored as PipelineScope) : 'open'
43
+ }
44
+
45
+ function readGrouping(stored: string | undefined): ListGrouping {
46
+ return GROUPINGS.includes(stored as ListGrouping) ? (stored as ListGrouping) : 'stage'
47
+ }
48
+
34
49
  /** Tones keyed by the seeded slugs. A renamed stage keeps its slug and its tone. */
35
50
  const STAGE_TONES: Readonly<Record<string, ChipTone>> = {
36
51
  won: 'success',
@@ -43,19 +58,54 @@ function stageTone(stage: PipelineStage): ChipTone {
43
58
  return STAGE_TONES[stage.slug] ?? 'neutral'
44
59
  }
45
60
 
61
+ const DEFAULT_VISIBLE_KEYS: readonly string[] = [
62
+ 'name',
63
+ 'company',
64
+ 'value',
65
+ 'nextPlan',
66
+ 'owner',
67
+ 'close',
68
+ ]
69
+
70
+ const SERVER_SORT_KEYS: readonly string[] = ['name', 'created_at', 'updated_at']
71
+
46
72
  export function DealsPage(): React.JSX.Element {
47
73
  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)
52
74
 
53
75
  const stages = usePipelineStages('deal')
54
- const deals = useDeals({ sort })
55
76
  const companies = useCompanies({ limit: 200 })
77
+ // The list-view controller holds every persisted knob, so it is declared
78
+ // before the deals query — the sort it stores decides which page comes
79
+ // back. Board vs list defaults to the board here; the other three read
80
+ // their own default from the readers above.
81
+ const supportedKeys: readonly string[] = [
82
+ 'name',
83
+ 'company',
84
+ 'stage',
85
+ 'value',
86
+ 'currency',
87
+ 'nextPlan',
88
+ 'owner',
89
+ 'close',
90
+ 'competitors',
91
+ 'tags',
92
+ 'summary',
93
+ 'risks',
94
+ 'whyWin',
95
+ 'externalId',
96
+ 'createdAt',
97
+ 'updatedAt',
98
+ ]
99
+ const listView = useListView('deals', supportedKeys, DEFAULT_VISIBLE_KEYS)
100
+ const view: BoardView = listView.mode ?? 'columns'
101
+ const scope = readScope(listView.scope)
102
+ const grouping = readGrouping(listView.grouping)
103
+ const sort = listView.sort
104
+ const deals = useDeals({ sort: serverSortOnly(sort, SERVER_SORT_KEYS) })
56
105
  const members = useMembers()
57
106
  const createDeal = useCreateDeal()
58
107
  const updateDeal = useUpdateDeal()
108
+ const timezone = useTimezone()
59
109
 
60
110
  const allStages = [...stages.records].sort((a, b) => a.sortOrder - b.sortOrder)
61
111
  const visibleStages = scope === 'open' ? allStages.filter((stage) => stage.open) : allStages
@@ -71,7 +121,7 @@ export function DealsPage(): React.JSX.Element {
71
121
  { enabled: askedDealIds.length > 0 },
72
122
  )
73
123
  const nextPlanByDeal = nextOpenByTarget(planItems.records)
74
- const plansTruncated = visibleDeals.length > MAX_PAGE_SIZE || planItems.hasMore
124
+ const plansTruncated = visibleDeals.length > MAX_PAGE_SIZE || planItems.hasNext
75
125
 
76
126
  const companyNameById = new Map(companies.records.map((company) => [company.id, company.name]))
77
127
 
@@ -87,6 +137,8 @@ export function DealsPage(): React.JSX.Element {
87
137
  await navigate(`/deals/${deal.id}`)
88
138
  }
89
139
 
140
+ const stageLabelById = new Map(allStages.map((stage) => [stage.id, stage.label]))
141
+
90
142
  const columns: readonly Column<Deal>[] = [
91
143
  {
92
144
  key: 'name',
@@ -97,21 +149,36 @@ export function DealsPage(): React.JSX.Element {
97
149
  {
98
150
  key: 'company',
99
151
  header: 'Company',
152
+ getSortValue: (deal) => companyNameById.get(deal.companyId) ?? null,
100
153
  render: (deal) => companyNameById.get(deal.companyId) ?? '—',
101
154
  },
155
+ {
156
+ key: 'stage',
157
+ header: 'Stage',
158
+ getSortValue: (deal) => stageLabelById.get(deal.stageId) ?? null,
159
+ render: (deal) => stageLabelById.get(deal.stageId) ?? '—',
160
+ },
102
161
  {
103
162
  key: 'value',
104
163
  header: 'Value',
105
164
  className: 'w-28',
165
+ getSortValue: (deal) => deal.valueCents,
106
166
  render: (deal) => (
107
167
  <span className="font-mono text-[12px]">
108
168
  {deal.valueCents === null ? '—' : formatMoney(deal.valueCents, deal.currency)}
109
169
  </span>
110
170
  ),
111
171
  },
172
+ {
173
+ key: 'currency',
174
+ header: 'Currency',
175
+ getSortValue: (deal) => deal.currency,
176
+ render: (deal) => deal.currency ?? '—',
177
+ },
112
178
  {
113
179
  key: 'nextPlan',
114
180
  header: 'Next plan',
181
+ getSortValue: (deal) => nextPlanByDeal.get(deal.id)?.date ?? null,
115
182
  render: (deal) => {
116
183
  const next = nextPlanByDeal.get(deal.id)
117
184
 
@@ -132,6 +199,8 @@ export function DealsPage(): React.JSX.Element {
132
199
  {
133
200
  key: 'owner',
134
201
  header: 'Owner',
202
+ getSortValue: (deal) =>
203
+ deal.ownerId === null ? null : (members.nameById.get(deal.ownerId) ?? 'Unknown'),
135
204
  render: (deal) =>
136
205
  deal.ownerId === null ? '—' : (members.nameById.get(deal.ownerId) ?? 'Unknown'),
137
206
  },
@@ -139,14 +208,91 @@ export function DealsPage(): React.JSX.Element {
139
208
  key: 'close',
140
209
  header: 'Close',
141
210
  className: 'w-28',
211
+ getSortValue: (deal) => deal.expectedClose,
142
212
  render: (deal) => (
143
213
  <span className="font-mono text-[12px] text-ink-muted">
144
214
  {deal.expectedClose === null ? '—' : formatDay(deal.expectedClose)}
145
215
  </span>
146
216
  ),
147
217
  },
218
+ {
219
+ key: 'competitors',
220
+ header: 'Competitors',
221
+ getSortValue: (deal) => deal.competitors.join(', ') || null,
222
+ render: (deal) =>
223
+ deal.competitors.length === 0 ? '—' : (
224
+ <span className="flex flex-wrap gap-1">
225
+ {deal.competitors.map((entry) => (
226
+ <Chip key={entry}>
227
+ <span className="text-[10px]">{entry}</span>
228
+ </Chip>
229
+ ))}
230
+ </span>
231
+ ),
232
+ },
233
+ {
234
+ key: 'tags',
235
+ header: 'Tags',
236
+ getSortValue: (deal) => deal.tags.join(', ') || null,
237
+ render: (deal) =>
238
+ deal.tags.length === 0 ? '—' : (
239
+ <span className="flex flex-wrap gap-1">
240
+ {deal.tags.map((tag) => (
241
+ <Chip key={tag}>
242
+ <span className="text-[10px]">{tag}</span>
243
+ </Chip>
244
+ ))}
245
+ </span>
246
+ ),
247
+ },
248
+ {
249
+ key: 'summary',
250
+ header: 'Summary',
251
+ getSortValue: (deal) => deal.summary || null,
252
+ render: (deal) =>
253
+ deal.summary.length === 0 ? '—' : <span className="text-ink-muted">{deal.summary}</span>,
254
+ },
255
+ {
256
+ key: 'risks',
257
+ header: 'Risks',
258
+ getSortValue: (deal) => deal.risks || null,
259
+ render: (deal) =>
260
+ deal.risks.length === 0 ? '—' : <span className="text-ink-muted">{deal.risks}</span>,
261
+ },
262
+ {
263
+ key: 'whyWin',
264
+ header: 'Why win',
265
+ getSortValue: (deal) => deal.whyWin || null,
266
+ render: (deal) =>
267
+ deal.whyWin.length === 0 ? '—' : <span className="text-ink-muted">{deal.whyWin}</span>,
268
+ },
269
+ {
270
+ key: 'externalId',
271
+ header: 'External ID',
272
+ getSortValue: (deal) => deal.externalId,
273
+ render: (deal) =>
274
+ deal.externalId === null ? (
275
+ '—'
276
+ ) : (
277
+ <span className="font-mono text-[12px] text-ink-muted">{deal.externalId}</span>
278
+ ),
279
+ },
280
+ {
281
+ key: 'createdAt',
282
+ header: 'Created',
283
+ sortKey: 'created_at',
284
+ render: (deal) => formatDate(deal.createdAt, timezone),
285
+ },
286
+ {
287
+ key: 'updatedAt',
288
+ header: 'Updated',
289
+ sortKey: 'updated_at',
290
+ render: (deal) => formatDate(deal.updatedAt, timezone),
291
+ },
148
292
  ]
149
293
 
294
+ const pickerOptions = columns.map((column) => ({ key: column.key, label: column.header }))
295
+
150
296
  const stageGroups: readonly DataTableGroup<Deal>[] = visibleStages.map((stage) => ({
151
297
  id: stage.id,
152
298
  label: <Chip tone={stageTone(stage)}>{stage.label}</Chip>,
@@ -208,7 +354,9 @@ export function DealsPage(): React.JSX.Element {
208
354
  <SegmentedControl
209
355
  ariaLabel="Stage scope"
210
356
  value={scope}
211
- onChange={setScope}
357
+ onChange={(next) => {
358
+ listView.setScope(next)
359
+ }}
212
360
  options={[
213
361
  { id: 'open', label: 'Open' },
214
362
  { id: 'all', label: 'All' },
@@ -218,7 +366,9 @@ export function DealsPage(): React.JSX.Element {
218
366
  <SegmentedControl
219
367
  ariaLabel="Group the list"
220
368
  value={grouping}
221
- onChange={setGrouping}
369
+ onChange={(next) => {
370
+ listView.setGrouping(next)
371
+ }}
222
372
  options={[
223
373
  { id: 'stage', label: 'By stage' },
224
374
  { id: 'due', label: 'By due' },
@@ -228,12 +378,21 @@ export function DealsPage(): React.JSX.Element {
228
378
  <SegmentedControl
229
379
  ariaLabel="Board or list"
230
380
  value={view}
231
- onChange={setView}
381
+ onChange={(next) => {
382
+ listView.setMode(next)
383
+ }}
232
384
  options={[
233
385
  { id: 'list', label: 'List' },
234
386
  { id: 'columns', label: 'Board' },
235
387
  ]}
236
388
  />
389
+ {view === 'list' && (
390
+ <ColumnPicker
391
+ options={pickerOptions}
392
+ visibleKeys={listView.visibleKeys}
393
+ onChange={listView.setVisibleKeys}
394
+ />
395
+ )}
237
396
  </>
238
397
  }
239
398
  />
@@ -255,6 +414,7 @@ export function DealsPage(): React.JSX.Element {
255
414
  <LoadingPanel label="Loading deals…" />
256
415
  ) : (
257
416
  <>
417
+ {view === 'list' && <Paginator list={deals} placement="top" />}
258
418
  {view === 'columns' ? (
259
419
  <KanbanBoard
260
420
  stages={visibleStages.map((stage) => ({ id: stage.id, label: stage.label }))}
@@ -301,13 +461,16 @@ export function DealsPage(): React.JSX.Element {
301
461
  ? {
302
462
  label: 'Show all deals',
303
463
  onClick: () => {
304
- setScope('all')
464
+ listView.setScope('all')
305
465
  },
306
466
  }
307
467
  : undefined
308
468
  }
309
469
  sort={sort}
310
- onSortChange={setSort}
470
+ onSortChange={(next) => {
471
+ listView.setSort(next)
472
+ }}
473
+ visibleColumnKeys={listView.visibleKeys}
311
474
  />
312
475
  )}
313
476
  {updateDeal.error !== null && (
@@ -315,17 +478,8 @@ export function DealsPage(): React.JSX.Element {
315
478
  <ErrorPanel error={updateDeal.error} />
316
479
  </div>
317
480
  )}
318
- {deals.hasMore && (
319
- <button
320
- type="button"
321
- onClick={deals.loadMore}
322
- disabled={deals.isLoadingMore}
323
- 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"
324
- >
325
- {deals.isLoadingMore ? 'Loading…' : 'Load more'}
326
- </button>
327
- )}
328
- {companies.hasMore && (
481
+ {view === 'list' && <Paginator list={deals} />}
482
+ {companies.hasNext && (
329
483
  <p className="mt-2 text-[11px] text-ink-faint">
330
484
  More companies exist than one page returns, so some company names may show as “—”.
331
485
  </p>