@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
@@ -5,6 +5,7 @@ import {
5
5
  MATCH_KEYS,
6
6
  OBJECT_COLUMNS,
7
7
  OBJECT_LABELS,
8
+ ON_MISSING_COMPANY_LABELS,
8
9
  SOURCE_LABELS,
9
10
  defaultMatchKeyId,
10
11
  findMatchKey,
@@ -18,11 +19,15 @@ import type {
18
19
  ImportObject,
19
20
  ImportPreviewRow,
20
21
  ImportRowAction,
22
+ ImportRowError,
21
23
  ImportSource,
24
+ OnMissingCompany,
25
+ SampleDataCounts,
22
26
  } from '@kelpie/schemas'
23
- import { useState } from 'react'
24
- import type { ChangeEvent, FormEvent, ReactNode } from 'react'
27
+ import { useEffect, useState } from 'react'
28
+ import type { ChangeEvent, DragEvent, FormEvent, ReactNode } from 'react'
25
29
 
30
+ import { useRelinkEmailDomains } from '../../api/resources/emailDomainLinker.ts'
26
31
  import {
27
32
  saveCsv,
28
33
  useCommitImportJob,
@@ -31,6 +36,8 @@ import {
31
36
  useExportCsv,
32
37
  useImportJob,
33
38
  } from '../../api/resources/importJobs.ts'
39
+ import { useInstallSampleData } from '../../api/resources/sampleData.ts'
40
+ import { useSession } from '../../api/resources/session.ts'
34
41
  import { PageHeader } from '../../components/PageHeader.tsx'
35
42
 
36
43
  /**
@@ -46,13 +53,14 @@ import { PageHeader } from '../../components/PageHeader.tsx'
46
53
  * `public/fixtures`, and none of those are part of the application.
47
54
  */
48
55
 
49
- type WizardStep = 'source' | 'upload' | 'map' | 'result'
56
+ type WizardStep = 'source' | 'upload' | 'map' | 'result' | 'done'
50
57
 
51
58
  const STEPS: readonly { readonly id: WizardStep; readonly label: string }[] = [
52
59
  { id: 'source', label: 'Source' },
53
60
  { id: 'upload', label: 'Upload' },
54
61
  { id: 'map', label: 'Map' },
55
62
  { id: 'result', label: 'Dry-run' },
63
+ { id: 'done', label: 'Done' },
56
64
  ]
57
65
 
58
66
  export function DataPage(): ReactNode {
@@ -64,6 +72,7 @@ export function DataPage(): ReactNode {
64
72
  const [file, setFile] = useState<File | null>(null)
65
73
  const [columnMap, setColumnMap] = useState<ImportColumnMap>({})
66
74
  const [conflictMode, setConflictMode] = useState<ImportConflictMode>('skip')
75
+ const [onMissingCompany, setOnMissingCompany] = useState<OnMissingCompany>('skip')
67
76
  const [matchKeyId, setMatchKeyId] = useState<string>(() => defaultMatchKeyId('companies'))
68
77
  const [jobId, setJobId] = useState<string | undefined>(undefined)
69
78
  const [problem, setProblem] = useState<string | null>(null)
@@ -115,6 +124,7 @@ export function DataPage(): ReactNode {
115
124
  setFile(null)
116
125
  setColumnMap({})
117
126
  setConflictMode('skip')
127
+ setOnMissingCompany('skip')
118
128
  setMatchKeyId(defaultMatchKeyId(object))
119
129
  setJobId(undefined)
120
130
  setProblem(null)
@@ -133,6 +143,7 @@ export function DataPage(): ReactNode {
133
143
  source,
134
144
  object,
135
145
  conflictMode,
146
+ onMissingCompany,
136
147
  matchKeyId,
137
148
  ...(map === undefined ? {} : { columnMap: map }),
138
149
  })
@@ -147,6 +158,19 @@ export function DataPage(): ReactNode {
147
158
  return created
148
159
  }
149
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
+
150
174
  async function onFileChange(event: ChangeEvent<HTMLInputElement>): Promise<void> {
151
175
  const chosen = event.target.files?.[0]
152
176
 
@@ -158,16 +182,22 @@ export function DataPage(): ReactNode {
158
182
  return
159
183
  }
160
184
 
161
- setProblem(null)
162
- setFile(chosen)
185
+ await processChosenFile(chosen)
186
+ }
163
187
 
164
- const created = await runDryRun(chosen, undefined)
188
+ async function onFileDrop(event: DragEvent<HTMLLabelElement>): Promise<void> {
189
+ event.preventDefault()
165
190
 
166
- // The first upload takes the server's own reading of the file, which is what
167
- // the mapping table is then built from.
168
- setColumnMap(created.columnMap)
169
- setMatchKeyId(created.matchKey)
170
- 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)
171
201
  }
172
202
 
173
203
  async function onRerun(event: FormEvent): Promise<void> {
@@ -193,6 +223,19 @@ export function DataPage(): ReactNode {
193
223
  setStep('result')
194
224
  }
195
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
+
196
239
  function onCommit(): void {
197
240
  // The file goes back with the commit: the server kept its digest, not its
198
241
  // bytes. This is the same `File` every dry run was run against.
@@ -217,7 +260,11 @@ export function DataPage(): ReactNode {
217
260
  </p>
218
261
  )}
219
262
 
220
- <section className="space-y-4">
263
+ <SampleDataSection />
264
+
265
+ <RelinkEmailDomainsSection />
266
+
267
+ <section className="space-y-4 border-t border-border pt-8">
221
268
  <div>
222
269
  <h2 className="text-[15px] font-semibold text-ink">Export</h2>
223
270
  <p className="mt-1 text-[13px] text-ink-muted">
@@ -342,7 +389,14 @@ export function DataPage(): ReactNode {
342
389
  <p className="text-[13px] text-ink-muted">
343
390
  {SOURCE_LABELS[source]} → {OBJECT_LABELS[object]}
344
391
  </p>
345
- <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
+ >
346
400
  <span className="text-[13px] font-medium text-ink">
347
401
  {createJob.isPending ? 'Reading the file…' : 'Drop a CSV here or click to browse'}
348
402
  </span>
@@ -373,10 +427,12 @@ export function DataPage(): ReactNode {
373
427
  object={object}
374
428
  columnMap={columnMap}
375
429
  conflictMode={conflictMode}
430
+ onMissingCompany={onMissingCompany}
376
431
  matchKeyId={matchKeyId}
377
432
  isPending={createJob.isPending}
378
433
  onColumnMapChange={setColumnMap}
379
434
  onConflictModeChange={setConflictMode}
435
+ onMissingCompanyChange={setOnMissingCompany}
380
436
  onMatchKeyChange={setMatchKeyId}
381
437
  onSubmit={(event) => runStep(onRerun(event))}
382
438
  onBack={() => {
@@ -397,6 +453,10 @@ export function DataPage(): ReactNode {
397
453
  onBack={() => setStep('map')}
398
454
  />
399
455
  ) : null}
456
+
457
+ {step === 'done' && job !== undefined ? (
458
+ <DonePanel job={job} object={object} onAgain={resetWizard} />
459
+ ) : null}
400
460
  </section>
401
461
  </div>
402
462
  )
@@ -407,10 +467,12 @@ interface MappingFormProps {
407
467
  readonly object: ImportObject
408
468
  readonly columnMap: ImportColumnMap
409
469
  readonly conflictMode: ImportConflictMode
470
+ readonly onMissingCompany: OnMissingCompany
410
471
  readonly matchKeyId: string
411
472
  readonly isPending: boolean
412
473
  readonly onColumnMapChange: (map: ImportColumnMap) => void
413
474
  readonly onConflictModeChange: (mode: ImportConflictMode) => void
475
+ readonly onMissingCompanyChange: (mode: OnMissingCompany) => void
414
476
  readonly onMatchKeyChange: (id: string) => void
415
477
  readonly onSubmit: (event: FormEvent) => void
416
478
  readonly onBack: () => void
@@ -419,6 +481,9 @@ interface MappingFormProps {
419
481
  function MappingForm(props: MappingFormProps): ReactNode {
420
482
  const matchKey = findMatchKey(props.object, props.matchKeyId)
421
483
  const keyColumns = new Set(matchKey?.columns ?? [])
484
+ // People rows can carry a company and title that drive a Position, so only a
485
+ // People import has a company to be missing.
486
+ const showMissingCompany = props.object === 'people'
422
487
 
423
488
  return (
424
489
  <form className="space-y-4" onSubmit={props.onSubmit}>
@@ -453,6 +518,23 @@ function MappingForm(props: MappingFormProps): ReactNode {
453
518
  ))}
454
519
  </select>
455
520
  </Field>
521
+ {showMissingCompany ? (
522
+ <Field label="Missing company">
523
+ <select
524
+ value={props.onMissingCompany}
525
+ onChange={(event) =>
526
+ props.onMissingCompanyChange(event.target.value as OnMissingCompany)
527
+ }
528
+ className="w-full rounded-md border border-border bg-surface-raised px-3 py-2 text-[13px] outline-none focus:border-accent"
529
+ >
530
+ {Object.entries(ON_MISSING_COMPANY_LABELS).map(([mode, label]) => (
531
+ <option key={mode} value={mode}>
532
+ {label}
533
+ </option>
534
+ ))}
535
+ </select>
536
+ </Field>
537
+ ) : null}
456
538
  </div>
457
539
  <p className="text-[12px] text-ink-muted">
458
540
  {props.conflictMode === 'update'
@@ -460,6 +542,16 @@ function MappingForm(props: MappingFormProps): ReactNode {
460
542
  : 'Rows matching an existing record on the key column are skipped.'}{' '}
461
543
  Key columns must be mapped.
462
544
  </p>
545
+ {showMissingCompany ? (
546
+ <p className="text-[12px] text-ink-muted">
547
+ Map <span className="font-mono text-[11px]">company_domain</span> or{' '}
548
+ <span className="font-mono text-[11px]">company_name</span> and{' '}
549
+ <span className="font-mono text-[11px]">title</span> to link a position for each person.{' '}
550
+ {props.onMissingCompany === 'create'
551
+ ? 'A company that is not here yet is created from the row.'
552
+ : 'A company that is not here yet is left unlinked and reported as a warning.'}
553
+ </p>
554
+ ) : null}
463
555
  <div className="overflow-hidden rounded-md border border-border">
464
556
  <table className="w-full text-left text-[13px]">
465
557
  <thead>
@@ -576,29 +668,7 @@ function ResultPanel({ job, object, isCommitting, onCommit, onBack }: ResultPane
576
668
 
577
669
  {job.errors.length > 0 ? (
578
670
  <div className="space-y-2">
579
- <div className="overflow-hidden rounded-md border border-border">
580
- <table className="w-full text-left text-[13px]">
581
- <thead>
582
- <tr className="border-b border-border bg-surface text-[11px] font-semibold tracking-wide text-ink-muted uppercase">
583
- <th className="px-4 py-2.5">Row</th>
584
- <th className="px-4 py-2.5">Field</th>
585
- <th className="px-4 py-2.5">Message</th>
586
- </tr>
587
- </thead>
588
- <tbody>
589
- {job.errors.map((error, index) => (
590
- <tr
591
- key={`${String(error.row)}-${error.field}-${String(index)}`}
592
- className="border-b border-border last:border-0"
593
- >
594
- <td className="px-4 py-2.5 font-mono text-[12px]">{error.row}</td>
595
- <td className="px-4 py-2.5 font-mono text-[12px]">{error.field}</td>
596
- <td className="px-4 py-2.5 text-ink-muted">{error.message}</td>
597
- </tr>
598
- ))}
599
- </tbody>
600
- </table>
601
- </div>
671
+ <IssueTable rows={job.errors} />
602
672
  {job.counts.error > job.errors.length ? (
603
673
  <p className="text-[12px] text-ink-muted">
604
674
  Showing {job.errors.length} of {job.counts.error} failing rows.
@@ -609,6 +679,19 @@ function ResultPanel({ job, object, isCommitting, onCommit, onBack }: ResultPane
609
679
  <p className="text-[13px] text-ink-muted">No row errors.</p>
610
680
  )}
611
681
 
682
+ {job.warnings.length > 0 ? (
683
+ <div className="space-y-2">
684
+ <div>
685
+ <h3 className="text-[13px] font-semibold text-ink">Warnings</h3>
686
+ <p className="mt-0.5 text-[12px] text-ink-muted">
687
+ These rows were imported. Each note says what was left out, such as a person whose
688
+ company was not here to link a position to.
689
+ </p>
690
+ </div>
691
+ <IssueTable rows={job.warnings} />
692
+ </div>
693
+ ) : null}
694
+
612
695
  <div className="flex flex-wrap gap-3">
613
696
  <button
614
697
  type="button"
@@ -630,6 +713,110 @@ function ResultPanel({ job, object, isCommitting, onCommit, onBack }: ResultPane
630
713
  )
631
714
  }
632
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
+
791
+ /** The row / field / message table shared by the error and warning lists. */
792
+ function IssueTable({ rows }: { readonly rows: readonly ImportRowError[] }): ReactNode {
793
+ return (
794
+ <div className="overflow-hidden rounded-md border border-border">
795
+ <table className="w-full text-left text-[13px]">
796
+ <thead>
797
+ <tr className="border-b border-border bg-surface text-[11px] font-semibold tracking-wide text-ink-muted uppercase">
798
+ <th className="px-4 py-2.5">Row</th>
799
+ <th className="px-4 py-2.5">Field</th>
800
+ <th className="px-4 py-2.5">Message</th>
801
+ </tr>
802
+ </thead>
803
+ <tbody>
804
+ {rows.map((issue, index) => (
805
+ <tr
806
+ key={`${String(issue.row)}-${issue.field}-${String(index)}`}
807
+ className="border-b border-border last:border-0"
808
+ >
809
+ <td className="px-4 py-2.5 font-mono text-[12px]">{issue.row}</td>
810
+ <td className="px-4 py-2.5 font-mono text-[12px]">{issue.field}</td>
811
+ <td className="px-4 py-2.5 text-ink-muted">{issue.message}</td>
812
+ </tr>
813
+ ))}
814
+ </tbody>
815
+ </table>
816
+ </div>
817
+ )
818
+ }
819
+
633
820
  function PreviewTable({
634
821
  object,
635
822
  rows,
@@ -770,3 +957,139 @@ function Field({
770
957
  function messageOf(error: Error | null | undefined): string | null {
771
958
  return error === null || error === undefined ? null : error.message
772
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
  }