@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
@@ -22,10 +22,12 @@ import type {
22
22
  ImportRowError,
23
23
  ImportSource,
24
24
  OnMissingCompany,
25
+ SampleDataCounts,
25
26
  } from '@kelpie/schemas'
26
- import { useState } from 'react'
27
- import type { ChangeEvent, FormEvent, ReactNode } from 'react'
27
+ import { useEffect, useState } from 'react'
28
+ import type { ChangeEvent, DragEvent, FormEvent, ReactNode } from 'react'
28
29
 
30
+ import { useRelinkEmailDomains } from '../../api/resources/emailDomainLinker.ts'
29
31
  import {
30
32
  saveCsv,
31
33
  useCommitImportJob,
@@ -34,6 +36,8 @@ import {
34
36
  useExportCsv,
35
37
  useImportJob,
36
38
  } from '../../api/resources/importJobs.ts'
39
+ import { useInstallSampleData } from '../../api/resources/sampleData.ts'
40
+ import { useSession } from '../../api/resources/session.ts'
37
41
  import { PageHeader } from '../../components/PageHeader.tsx'
38
42
 
39
43
  /**
@@ -49,13 +53,14 @@ import { PageHeader } from '../../components/PageHeader.tsx'
49
53
  * `public/fixtures`, and none of those are part of the application.
50
54
  */
51
55
 
52
- type WizardStep = 'source' | 'upload' | 'map' | 'result'
56
+ type WizardStep = 'source' | 'upload' | 'map' | 'result' | 'done'
53
57
 
54
58
  const STEPS: readonly { readonly id: WizardStep; readonly label: string }[] = [
55
59
  { id: 'source', label: 'Source' },
56
60
  { id: 'upload', label: 'Upload' },
57
61
  { id: 'map', label: 'Map' },
58
62
  { id: 'result', label: 'Dry-run' },
63
+ { id: 'done', label: 'Done' },
59
64
  ]
60
65
 
61
66
  export function DataPage(): ReactNode {
@@ -153,6 +158,19 @@ export function DataPage(): ReactNode {
153
158
  return created
154
159
  }
155
160
 
161
+ async function processChosenFile(chosen: File): Promise<void> {
162
+ setProblem(null)
163
+ setFile(chosen)
164
+
165
+ const created = await runDryRun(chosen, undefined)
166
+
167
+ // The first upload takes the server's own reading of the file, which is what
168
+ // the mapping table is then built from.
169
+ setColumnMap(created.columnMap)
170
+ setMatchKeyId(created.matchKey)
171
+ setStep('map')
172
+ }
173
+
156
174
  async function onFileChange(event: ChangeEvent<HTMLInputElement>): Promise<void> {
157
175
  const chosen = event.target.files?.[0]
158
176
 
@@ -164,16 +182,22 @@ export function DataPage(): ReactNode {
164
182
  return
165
183
  }
166
184
 
167
- setProblem(null)
168
- setFile(chosen)
185
+ await processChosenFile(chosen)
186
+ }
169
187
 
170
- const created = await runDryRun(chosen, undefined)
188
+ async function onFileDrop(event: DragEvent<HTMLLabelElement>): Promise<void> {
189
+ event.preventDefault()
171
190
 
172
- // The first upload takes the server's own reading of the file, which is what
173
- // the mapping table is then built from.
174
- setColumnMap(created.columnMap)
175
- setMatchKeyId(created.matchKey)
176
- setStep('map')
191
+ if (createJob.isPending) {
192
+ return
193
+ }
194
+
195
+ const chosen = event.dataTransfer.files[0]
196
+ if (chosen === undefined) {
197
+ return
198
+ }
199
+
200
+ await processChosenFile(chosen)
177
201
  }
178
202
 
179
203
  async function onRerun(event: FormEvent): Promise<void> {
@@ -199,6 +223,19 @@ export function DataPage(): ReactNode {
199
223
  setStep('result')
200
224
  }
201
225
 
226
+ // Commit finishes on the server, not the click, so the wizard waits for the
227
+ // job to reach `completed` before showing the outcome step. `failed` lands
228
+ // here too — the outcome is that the commit did not go through.
229
+ useEffect(() => {
230
+ if (step !== 'result' || job === undefined) {
231
+ return
232
+ }
233
+
234
+ if (job.status === 'completed' || job.status === 'failed') {
235
+ setStep('done')
236
+ }
237
+ }, [step, job])
238
+
202
239
  function onCommit(): void {
203
240
  // The file goes back with the commit: the server kept its digest, not its
204
241
  // bytes. This is the same `File` every dry run was run against.
@@ -223,7 +260,11 @@ export function DataPage(): ReactNode {
223
260
  </p>
224
261
  )}
225
262
 
226
- <section className="space-y-4">
263
+ <SampleDataSection />
264
+
265
+ <RelinkEmailDomainsSection />
266
+
267
+ <section className="space-y-4 border-t border-border pt-8">
227
268
  <div>
228
269
  <h2 className="text-[15px] font-semibold text-ink">Export</h2>
229
270
  <p className="mt-1 text-[13px] text-ink-muted">
@@ -348,7 +389,14 @@ export function DataPage(): ReactNode {
348
389
  <p className="text-[13px] text-ink-muted">
349
390
  {SOURCE_LABELS[source]} → {OBJECT_LABELS[object]}
350
391
  </p>
351
- <label className="flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-border bg-surface-raised px-6 py-10 transition hover:border-accent">
392
+ <label
393
+ className="flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-border bg-surface-raised px-6 py-10 transition hover:border-accent"
394
+ onDragOver={(event) => {
395
+ event.preventDefault()
396
+ event.dataTransfer.dropEffect = 'copy'
397
+ }}
398
+ onDrop={(event) => runStep(onFileDrop(event))}
399
+ >
352
400
  <span className="text-[13px] font-medium text-ink">
353
401
  {createJob.isPending ? 'Reading the file…' : 'Drop a CSV here or click to browse'}
354
402
  </span>
@@ -405,6 +453,10 @@ export function DataPage(): ReactNode {
405
453
  onBack={() => setStep('map')}
406
454
  />
407
455
  ) : null}
456
+
457
+ {step === 'done' && job !== undefined ? (
458
+ <DonePanel job={job} object={object} onAgain={resetWizard} />
459
+ ) : null}
408
460
  </section>
409
461
  </div>
410
462
  )
@@ -661,6 +713,81 @@ function ResultPanel({ job, object, isCommitting, onCommit, onBack }: ResultPane
661
713
  )
662
714
  }
663
715
 
716
+ interface DonePanelProps {
717
+ readonly job: ImportJob
718
+ readonly object: ImportObject
719
+ readonly onAgain: () => void
720
+ }
721
+
722
+ function DonePanel({ job, object, onAgain }: DonePanelProps): ReactNode {
723
+ const failed = job.status === 'failed'
724
+ const wrote = job.counts.create + job.counts.update
725
+
726
+ return (
727
+ <div className="space-y-4">
728
+ <div>
729
+ <h3 className="text-[15px] font-semibold text-ink">
730
+ {failed ? 'Import failed' : 'Import complete'}
731
+ </h3>
732
+ <p className="mt-1 text-[13px] text-ink-muted">
733
+ {failed
734
+ ? 'The server refused the commit. Nothing was written.'
735
+ : `${wrote} ${OBJECT_LABELS[object].toLowerCase()} row${wrote === 1 ? '' : 's'} written to this workspace.`}
736
+ </p>
737
+ </div>
738
+ <p className="text-[12px] text-ink-muted">
739
+ Job <span className="font-mono text-[12px] text-ink">{job.id}</span> · status {job.status}
740
+ </p>
741
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
742
+ <Stat label="Total" value={job.counts.total} />
743
+ <Stat label="Created" value={job.counts.create} />
744
+ <Stat label="Updated" value={job.counts.update} />
745
+ <Stat label="Skipped" value={job.counts.skip} />
746
+ <Stat label="Errors" value={job.counts.error} danger />
747
+ </div>
748
+
749
+ {job.errors.length > 0 ? (
750
+ <div className="space-y-2">
751
+ <div>
752
+ <h3 className="text-[13px] font-semibold text-ink">Row errors</h3>
753
+ <p className="mt-0.5 text-[12px] text-ink-muted">
754
+ These rows were not written. Fix them in the CSV and import again.
755
+ </p>
756
+ </div>
757
+ <IssueTable rows={job.errors} />
758
+ {job.counts.error > job.errors.length ? (
759
+ <p className="text-[12px] text-ink-muted">
760
+ Showing {job.errors.length} of {job.counts.error} failing rows.
761
+ </p>
762
+ ) : null}
763
+ </div>
764
+ ) : null}
765
+
766
+ {job.warnings.length > 0 ? (
767
+ <div className="space-y-2">
768
+ <div>
769
+ <h3 className="text-[13px] font-semibold text-ink">Warnings</h3>
770
+ <p className="mt-0.5 text-[12px] text-ink-muted">
771
+ These rows were written. Each note says what was left out.
772
+ </p>
773
+ </div>
774
+ <IssueTable rows={job.warnings} />
775
+ </div>
776
+ ) : null}
777
+
778
+ <div className="flex flex-wrap gap-3">
779
+ <button
780
+ type="button"
781
+ onClick={onAgain}
782
+ className="rounded-md bg-accent px-3.5 py-2 text-[12px] font-semibold text-accent-fg transition hover:bg-accent-hover"
783
+ >
784
+ Import another
785
+ </button>
786
+ </div>
787
+ </div>
788
+ )
789
+ }
790
+
664
791
  /** The row / field / message table shared by the error and warning lists. */
665
792
  function IssueTable({ rows }: { readonly rows: readonly ImportRowError[] }): ReactNode {
666
793
  return (
@@ -830,3 +957,139 @@ function Field({
830
957
  function messageOf(error: Error | null | undefined): string | null {
831
958
  return error === null || error === undefined ? null : error.message
832
959
  }
960
+
961
+ /**
962
+ * One button that fills the workspace with a small demo fixture.
963
+ *
964
+ * The endpoint refuses on a workspace that already has data, so the button
965
+ * never doubles the seed and the "already has data" answer surfaces here as
966
+ * a plain error line.
967
+ */
968
+ function SampleDataSection(): ReactNode {
969
+ const { session } = useSession()
970
+ const workspaceId = session?.workspaceId ?? null
971
+ const install = useInstallSampleData()
972
+ const [counts, setCounts] = useState<SampleDataCounts | null>(null)
973
+
974
+ function onInstall(): void {
975
+ if (workspaceId === null) {
976
+ return
977
+ }
978
+
979
+ install
980
+ .runAsync({ workspaceId })
981
+ .then((result) => {
982
+ setCounts(result)
983
+ })
984
+ .catch(() => undefined)
985
+ }
986
+
987
+ const failure = messageOf(install.error)
988
+
989
+ return (
990
+ <section className="space-y-4">
991
+ <div>
992
+ <h2 className="text-[15px] font-semibold text-ink">Install sample data</h2>
993
+ <p className="mt-1 text-[13px] text-ink-muted">
994
+ A small set of companies, people, and deals so a fresh workspace has something
995
+ to look at. Refuses if this workspace already has CRM data.
996
+ </p>
997
+ </div>
998
+ <button
999
+ type="button"
1000
+ onClick={onInstall}
1001
+ disabled={install.isPending || workspaceId === null || counts !== null}
1002
+ className="rounded-md bg-accent px-3.5 py-2 text-[12px] font-semibold text-accent-fg transition hover:bg-accent-hover disabled:opacity-50"
1003
+ >
1004
+ {counts !== null
1005
+ ? 'Installed'
1006
+ : install.isPending
1007
+ ? 'Installing…'
1008
+ : 'Install sample data'}
1009
+ </button>
1010
+ {counts !== null ? (
1011
+ <p className="text-[12px] text-ink-muted">
1012
+ Added {counts.companies} companies, {counts.people} people, {counts.positions}{' '}
1013
+ positions, {counts.deals} deals, {counts.opportunities} opportunities,{' '}
1014
+ {counts.raises} raises, {counts.partnerships} partnerships, {counts.roles} roles,{' '}
1015
+ {counts.candidates} candidates, {counts.planItems} plan items, and{' '}
1016
+ {counts.notes} notes.
1017
+ </p>
1018
+ ) : null}
1019
+ {failure === null ? null : (
1020
+ <p role="alert" className="text-[12px] font-medium text-danger">
1021
+ {failure}
1022
+ </p>
1023
+ )}
1024
+ </section>
1025
+ )
1026
+ }
1027
+
1028
+ /**
1029
+ * Sweep every workspace Company that carries a domain and attach each Person
1030
+ * whose email is at that domain.
1031
+ *
1032
+ * Backfills links for records that predate the auto-linker or arrived through
1033
+ * a bulk import. Idempotent: a follow-up run adds nothing. Never removes.
1034
+ */
1035
+ function RelinkEmailDomainsSection(): ReactNode {
1036
+ const { session } = useSession()
1037
+ const workspaceId = session?.workspaceId ?? null
1038
+ const relink = useRelinkEmailDomains()
1039
+ const [outcome, setOutcome] = useState<{
1040
+ readonly companiesScanned: number
1041
+ readonly positionsCreated: number
1042
+ } | null>(null)
1043
+
1044
+ function onRun(): void {
1045
+ if (workspaceId === null) {
1046
+ return
1047
+ }
1048
+
1049
+ relink
1050
+ .runAsync({ workspaceId })
1051
+ .then((result) => {
1052
+ setOutcome({
1053
+ companiesScanned: result.companiesScanned,
1054
+ positionsCreated: result.positionsCreated,
1055
+ })
1056
+ })
1057
+ .catch(() => undefined)
1058
+ }
1059
+
1060
+ const failure = messageOf(relink.error)
1061
+
1062
+ return (
1063
+ <section className="space-y-4 border-t border-border pt-8">
1064
+ <div>
1065
+ <h2 className="text-[15px] font-semibold text-ink">Rebuild email-domain links</h2>
1066
+ <p className="mt-1 text-[13px] text-ink-muted">
1067
+ Attach every workspace Person to a Company whose domain matches their email. Safe to run
1068
+ more than once — a Person already at a Company is left alone, and consumer email hosts
1069
+ (gmail, hotmail, …) are skipped.
1070
+ </p>
1071
+ </div>
1072
+ <button
1073
+ type="button"
1074
+ onClick={onRun}
1075
+ disabled={relink.isPending || workspaceId === null}
1076
+ className="rounded-md bg-accent px-3.5 py-2 text-[12px] font-semibold text-accent-fg transition hover:bg-accent-hover disabled:opacity-50"
1077
+ >
1078
+ {relink.isPending ? 'Rebuilding…' : 'Rebuild links'}
1079
+ </button>
1080
+ {outcome !== null ? (
1081
+ <p className="text-[12px] text-ink-muted">
1082
+ Scanned {outcome.companiesScanned}{' '}
1083
+ {outcome.companiesScanned === 1 ? 'company' : 'companies'}, added{' '}
1084
+ {outcome.positionsCreated}{' '}
1085
+ {outcome.positionsCreated === 1 ? 'position' : 'positions'}.
1086
+ </p>
1087
+ ) : null}
1088
+ {failure === null ? null : (
1089
+ <p role="alert" className="text-[12px] font-medium text-danger">
1090
+ {failure}
1091
+ </p>
1092
+ )}
1093
+ </section>
1094
+ )
1095
+ }
@@ -7,6 +7,7 @@ import { Chip } from '../../components/Chip.tsx'
7
7
  import type { ChipTone } from '../../components/Chip.tsx'
8
8
  import { DataTable } from '../../components/DataTable.tsx'
9
9
  import type { Column } from '../../components/DataTable.tsx'
10
+ import { Paginator } from '../../components/Paginator.tsx'
10
11
  import { ErrorPanel, LoadingPanel } from '../../components/QueryState.tsx'
11
12
  import { SegmentedControl } from '../../components/SegmentedControl.tsx'
12
13
  import { formatDateTime } from '../../lib/dates.ts'
@@ -138,25 +139,19 @@ export function WebhookDeliveries({ webhookId }: { readonly webhookId: string })
138
139
  {status === 'all' ? 'Nothing delivered yet.' : `No ${status} deliveries.`}
139
140
  </p>
140
141
  ) : (
141
- <DataTable
142
- columns={columns}
143
- rows={deliveries.records}
144
- getRowId={(delivery) => delivery.id}
145
- />
142
+ <>
143
+ <Paginator list={deliveries} placement="top" />
144
+ <DataTable
145
+ columns={columns}
146
+ rows={deliveries.records}
147
+ getRowId={(delivery) => delivery.id}
148
+ />
149
+ </>
146
150
  )}
147
151
 
148
152
  {open !== undefined && <PayloadPanel delivery={open} />}
149
153
 
150
- {deliveries.hasMore && (
151
- <button
152
- type="button"
153
- onClick={deliveries.loadMore}
154
- disabled={deliveries.isLoadingMore}
155
- className="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"
156
- >
157
- {deliveries.isLoadingMore ? 'Loading…' : 'Load more'}
158
- </button>
159
- )}
154
+ <Paginator list={deliveries} />
160
155
  </div>
161
156
  )
162
157
  }
@@ -19,6 +19,7 @@ import {
19
19
  import { Chip } from '../../components/Chip.tsx'
20
20
  import type { ChipTone } from '../../components/Chip.tsx'
21
21
  import { PageHeader } from '../../components/PageHeader.tsx'
22
+ import { Paginator } from '../../components/Paginator.tsx'
22
23
  import { ErrorPanel, LoadingPanel } from '../../components/QueryState.tsx'
23
24
  import { formatDateTime } from '../../lib/dates.ts'
24
25
  import { WebhookDeliveries } from './WebhookDeliveries.tsx'
@@ -68,7 +69,7 @@ function MemberNotice(): React.JSX.Element {
68
69
  }
69
70
 
70
71
  function WebhookAdmin(): React.JSX.Element {
71
- const { records, isLoading, error, hasMore, isLoadingMore, loadMore } = useWebhooks()
72
+ const webhooks = useWebhooks()
72
73
  const [minted, setMinted] = useState<CreatedWebhook | null>(null)
73
74
 
74
75
  return (
@@ -88,30 +89,24 @@ function WebhookAdmin(): React.JSX.Element {
88
89
  />
89
90
  )}
90
91
 
91
- {error !== null && <ErrorPanel error={error} />}
92
+ {webhooks.error !== null && <ErrorPanel error={webhooks.error} />}
92
93
 
93
- {isLoading ? (
94
+ {webhooks.isLoading ? (
94
95
  <LoadingPanel label="Loading webhooks…" />
95
- ) : records.length === 0 ? (
96
+ ) : webhooks.records.length === 0 ? (
96
97
  <p className="text-[13px] text-ink-muted">No webhooks yet.</p>
97
98
  ) : (
98
- <ul className="space-y-3">
99
- {records.map((webhook) => (
100
- <WebhookRow key={webhook.id} webhook={webhook} onRotated={setMinted} />
101
- ))}
102
- </ul>
99
+ <>
100
+ <Paginator list={webhooks} placement="top" />
101
+ <ul className="space-y-3">
102
+ {webhooks.records.map((webhook) => (
103
+ <WebhookRow key={webhook.id} webhook={webhook} onRotated={setMinted} />
104
+ ))}
105
+ </ul>
106
+ </>
103
107
  )}
104
108
 
105
- {hasMore && (
106
- <button
107
- type="button"
108
- disabled={isLoadingMore}
109
- onClick={loadMore}
110
- className="rounded-md border border-border px-3 py-1.5 text-[12px] font-medium text-ink-muted transition hover:text-ink disabled:opacity-50"
111
- >
112
- {isLoadingMore ? 'Loading…' : 'Load more'}
113
- </button>
114
- )}
109
+ <Paginator list={webhooks} />
115
110
  </>
116
111
  )
117
112
  }
@@ -1,14 +1,21 @@
1
- import type { Workspace } from '@kelpie/schemas'
2
- import { useState } from 'react'
1
+ import type { Company, Workspace } from '@kelpie/schemas'
2
+ import { useMemo, useState } from 'react'
3
3
  import type { FormEvent, ReactNode } from 'react'
4
4
  import { useNavigate } from 'react-router'
5
5
 
6
+ import {
7
+ useCompanies,
8
+ useCreateCompany,
9
+ useUpdateCompany,
10
+ } from '../../api/resources/companies.ts'
6
11
  import { useSession } from '../../api/resources/session.ts'
7
12
  import {
8
13
  useDeleteWorkspace,
9
14
  useUpdateWorkspace,
10
15
  useWorkspace,
11
16
  } from '../../api/resources/workspace.ts'
17
+ import { EntitySearch } from '../../components/EntitySearch.tsx'
18
+ import type { SearchOption } from '../../components/EntitySearch.tsx'
12
19
  import { PageHeader } from '../../components/PageHeader.tsx'
13
20
  import { ErrorPanel, LoadingPanel } from '../../components/QueryState.tsx'
14
21
 
@@ -190,11 +197,136 @@ function WorkspaceSettingsForm({
190
197
  )}
191
198
  </form>
192
199
 
200
+ <OwnCompanySection canEdit={canEdit} />
201
+
193
202
  {session?.role === 'owner' && <DangerZone slug={workspace.slug} />}
194
203
  </div>
195
204
  )
196
205
  }
197
206
 
207
+ /**
208
+ * Mark one or more Companies as "us" — the workspace's own organisation.
209
+ *
210
+ * Backed by `companies.is_own`. Zero or many rows may carry it (a parent and a
211
+ * subsidiary is the intended case), so the surface is a list plus an add
212
+ * picker rather than a single-select. Picking a name that does not exist
213
+ * creates the Company with `is_own: true` in one call.
214
+ */
215
+ function OwnCompanySection({ canEdit }: { readonly canEdit: boolean }): React.JSX.Element {
216
+ const own = useCompanies({ isOwn: true })
217
+ const [search, setSearch] = useState('')
218
+ // The picker's options: workspace companies that are NOT already marked.
219
+ // Server-side search on the term the user types.
220
+ const searchable = useCompanies({
221
+ isOwn: false,
222
+ term: search.trim().length > 0 ? search.trim() : undefined,
223
+ })
224
+ const update = useUpdateCompany()
225
+ const create = useCreateCompany()
226
+
227
+ const options: SearchOption[] = useMemo(
228
+ () =>
229
+ searchable.records.map((company) => ({
230
+ id: company.id,
231
+ label: company.name,
232
+ meta: company.domain ?? undefined,
233
+ })),
234
+ [searchable.records],
235
+ )
236
+
237
+ function mark(companyId: string): void {
238
+ update.run({ id: companyId, changes: { isOwn: true } })
239
+ }
240
+
241
+ function unmark(companyId: string): void {
242
+ update.run({ id: companyId, changes: { isOwn: false } })
243
+ }
244
+
245
+ function createAndMark(name: string): void {
246
+ // The API defaults every other field; `is_own: true` lands the row in the
247
+ // list this section shows and nowhere else.
248
+ create.run({ name, isOwn: true })
249
+ setSearch('')
250
+ }
251
+
252
+ const busy = update.isPending || create.isPending
253
+
254
+ return (
255
+ <section className="space-y-3 rounded-lg border border-border bg-surface p-4">
256
+ <div>
257
+ <h2 className="text-[13px] font-semibold text-ink">This company</h2>
258
+ <p className="mt-1 max-w-xl text-[12px] text-ink-muted">
259
+ Mark the company (or companies) in the CRM that represent this workspace itself. Used
260
+ later to separate own records from prospects.
261
+ </p>
262
+ </div>
263
+
264
+ {own.isLoading ? (
265
+ <p className="text-[12px] text-ink-faint">Loading…</p>
266
+ ) : own.records.length === 0 ? (
267
+ <p className="text-[12px] text-ink-faint">Nothing marked yet.</p>
268
+ ) : (
269
+ <ul className="divide-y divide-border rounded-md border border-border">
270
+ {own.records.map((company: Company) => (
271
+ <li
272
+ key={company.id}
273
+ className="flex items-center justify-between gap-3 px-3 py-2.5"
274
+ >
275
+ <div className="min-w-0">
276
+ <div className="truncate text-[13px] font-medium text-ink">{company.name}</div>
277
+ {company.domain !== null && company.domain.length > 0 && (
278
+ <div className="truncate text-[11px] text-ink-faint">{company.domain}</div>
279
+ )}
280
+ </div>
281
+ {canEdit && (
282
+ <button
283
+ type="button"
284
+ onClick={() => {
285
+ unmark(company.id)
286
+ }}
287
+ disabled={busy}
288
+ className="shrink-0 rounded-md border border-border bg-surface-raised px-2.5 py-1 text-[11px] font-medium text-ink hover:border-danger hover:text-danger disabled:opacity-50"
289
+ >
290
+ Unmark
291
+ </button>
292
+ )}
293
+ </li>
294
+ ))}
295
+ </ul>
296
+ )}
297
+
298
+ {canEdit && (
299
+ <div className="max-w-md">
300
+ <label className="mb-1.5 block text-[12px] font-medium text-ink">
301
+ {own.records.length === 0 ? 'Pick or create' : 'Add another'}
302
+ </label>
303
+ <EntitySearch
304
+ options={options}
305
+ value=""
306
+ onChange={mark}
307
+ onQueryChange={setSearch}
308
+ onCreate={createAndMark}
309
+ createLabel={(query) => `Create “${query}” and mark it`}
310
+ placeholder="Search companies…"
311
+ emptyMessage="No matches — type a new name to create one"
312
+ />
313
+ </div>
314
+ )}
315
+
316
+ {update.error !== null && (
317
+ <div className="max-w-xl">
318
+ <ErrorPanel error={update.error} />
319
+ </div>
320
+ )}
321
+ {create.error !== null && (
322
+ <div className="max-w-xl">
323
+ <ErrorPanel error={create.error} />
324
+ </div>
325
+ )}
326
+ </section>
327
+ )
328
+ }
329
+
198
330
  /**
199
331
  * Deleting the workspace, behind the workspace's own name.
200
332
  *
@@ -8,6 +8,7 @@ import { useCompanies } from '../../api/resources/companies.ts'
8
8
  import { usePeople } from '../../api/resources/people.ts'
9
9
  import { DataTable } from '../../components/DataTable.tsx'
10
10
  import type { Column } from '../../components/DataTable.tsx'
11
+ import { Paginator } from '../../components/Paginator.tsx'
11
12
  import { ErrorPanel, LoadingPanel } from '../../components/QueryState.tsx'
12
13
  import { SectionHeader } from '../../components/SectionHeader.tsx'
13
14
  import { formatDateTime } from '../../lib/dates.ts'
@@ -100,22 +101,14 @@ export function SubmissionsTable({ form, submissions }: SubmissionsTableProps):
100
101
  title="Submissions"
101
102
  description="Inbound answers, and the records each one created or matched."
102
103
  />
104
+ <Paginator list={submissions} placement="top" />
103
105
  <DataTable
104
106
  columns={columns}
105
107
  rows={submissions.records}
106
108
  getRowId={(submission) => submission.id}
107
109
  emptyMessage="No submissions yet"
108
110
  />
109
- {submissions.hasMore && (
110
- <button
111
- type="button"
112
- onClick={submissions.loadMore}
113
- disabled={submissions.isLoadingMore}
114
- 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"
115
- >
116
- {submissions.isLoadingMore ? 'Loading…' : 'Load more'}
117
- </button>
118
- )}
111
+ <Paginator list={submissions} />
119
112
  </div>
120
113
  )
121
114
  }
@@ -234,7 +234,7 @@ export function HandbookLayout(): React.JSX.Element {
234
234
  {flat.length === 0 && (
235
235
  <p className="px-2 py-6 text-center text-[12px] text-ink-faint">No pages yet.</p>
236
236
  )}
237
- {pages.hasMore && (
237
+ {pages.hasNext && (
238
238
  <p className="px-2 py-3 text-center text-[11px] text-ink-faint">
239
239
  This handbook has more pages than one request returns, so the tree below is
240
240
  incomplete.