@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.
- package/dist/api/resource.d.ts +58 -5
- package/dist/api/resource.d.ts.map +1 -1
- package/dist/api/resource.js +106 -24
- package/dist/api/resource.js.map +1 -1
- package/dist/api/resources/companies.d.ts +2 -0
- package/dist/api/resources/companies.d.ts.map +1 -1
- package/dist/api/resources/companies.js +5 -1
- package/dist/api/resources/companies.js.map +1 -1
- package/dist/api/resources/decisions.d.ts +2 -0
- package/dist/api/resources/decisions.d.ts.map +1 -1
- package/dist/api/resources/decisions.js +1 -0
- package/dist/api/resources/decisions.js.map +1 -1
- package/dist/api/resources/emailDomainLinker.d.ts +16 -0
- package/dist/api/resources/emailDomainLinker.d.ts.map +1 -0
- package/dist/api/resources/emailDomainLinker.js +16 -0
- package/dist/api/resources/emailDomainLinker.js.map +1 -0
- package/dist/api/resources/forms.d.ts.map +1 -1
- package/dist/api/resources/forms.js +6 -20
- package/dist/api/resources/forms.js.map +1 -1
- package/dist/api/resources/handbookPages.d.ts +1 -1
- package/dist/api/resources/handbookPages.js +1 -1
- package/dist/api/resources/listMembers.d.ts +27 -0
- package/dist/api/resources/listMembers.d.ts.map +1 -0
- package/dist/api/resources/listMembers.js +108 -0
- package/dist/api/resources/listMembers.js.map +1 -0
- package/dist/api/resources/lists.d.ts +14 -0
- package/dist/api/resources/lists.d.ts.map +1 -0
- package/dist/api/resources/lists.js +34 -0
- package/dist/api/resources/lists.js.map +1 -0
- package/dist/api/resources/people.d.ts.map +1 -1
- package/dist/api/resources/people.js +4 -1
- package/dist/api/resources/people.js.map +1 -1
- package/dist/api/resources/roles.d.ts +2 -0
- package/dist/api/resources/roles.d.ts.map +1 -1
- package/dist/api/resources/roles.js +1 -1
- package/dist/api/resources/roles.js.map +1 -1
- package/dist/api/resources/sampleData.d.ts +17 -0
- package/dist/api/resources/sampleData.d.ts.map +1 -0
- package/dist/api/resources/sampleData.js +30 -0
- package/dist/api/resources/sampleData.js.map +1 -0
- package/dist/api/resources/webhooks.d.ts.map +1 -1
- package/dist/api/resources/webhooks.js +7 -22
- package/dist/api/resources/webhooks.js.map +1 -1
- package/dist/app/KelpieApp.d.ts.map +1 -1
- package/dist/app/KelpieApp.js +3 -1
- package/dist/app/KelpieApp.js.map +1 -1
- package/dist/components/ActivitiesPanel.d.ts.map +1 -1
- package/dist/components/ActivitiesPanel.js +2 -1
- package/dist/components/ActivitiesPanel.js.map +1 -1
- package/dist/components/ColumnPicker.d.ts +24 -0
- package/dist/components/ColumnPicker.d.ts.map +1 -0
- package/dist/components/ColumnPicker.js +56 -0
- package/dist/components/ColumnPicker.js.map +1 -0
- package/dist/components/DataTable.d.ts +16 -1
- package/dist/components/DataTable.d.ts.map +1 -1
- package/dist/components/DataTable.js +51 -10
- package/dist/components/DataTable.js.map +1 -1
- package/dist/components/DecisionsPanel.d.ts.map +1 -1
- package/dist/components/DecisionsPanel.js +4 -3
- package/dist/components/DecisionsPanel.js.map +1 -1
- package/dist/components/EntitySearch.d.ts.map +1 -1
- package/dist/components/EntitySearch.js +11 -5
- package/dist/components/EntitySearch.js.map +1 -1
- package/dist/components/ListsPanel.d.ts +14 -0
- package/dist/components/ListsPanel.d.ts.map +1 -0
- package/dist/components/ListsPanel.js +56 -0
- package/dist/components/ListsPanel.js.map +1 -0
- package/dist/components/NotesPanel.d.ts.map +1 -1
- package/dist/components/NotesPanel.js +6 -5
- package/dist/components/NotesPanel.js.map +1 -1
- package/dist/components/Paginator.d.ts +26 -0
- package/dist/components/Paginator.d.ts.map +1 -0
- package/dist/components/Paginator.js +16 -0
- package/dist/components/Paginator.js.map +1 -0
- package/dist/components/PlanPanel.d.ts.map +1 -1
- package/dist/components/PlanPanel.js +2 -1
- package/dist/components/PlanPanel.js.map +1 -1
- package/dist/components/Shell.d.ts.map +1 -1
- package/dist/components/Shell.js +1 -0
- package/dist/components/Shell.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/listView.d.ts +15 -0
- package/dist/lib/listView.d.ts.map +1 -0
- package/dist/lib/listView.js +66 -0
- package/dist/lib/listView.js.map +1 -0
- package/dist/lib/sort.d.ts +43 -0
- package/dist/lib/sort.d.ts.map +1 -0
- package/dist/lib/sort.js +102 -0
- package/dist/lib/sort.js.map +1 -0
- package/dist/pages/CompaniesPage.d.ts.map +1 -1
- package/dist/pages/CompaniesPage.js +88 -3
- package/dist/pages/CompaniesPage.js.map +1 -1
- package/dist/pages/CompanyDetail.d.ts.map +1 -1
- package/dist/pages/CompanyDetail.js +17 -14
- package/dist/pages/CompanyDetail.js.map +1 -1
- package/dist/pages/DealDetail.d.ts.map +1 -1
- package/dist/pages/DealDetail.js +3 -1
- package/dist/pages/DealDetail.js.map +1 -1
- package/dist/pages/DealsPage.d.ts.map +1 -1
- package/dist/pages/DealsPage.js +134 -14
- package/dist/pages/DealsPage.js.map +1 -1
- package/dist/pages/DecisionsPage.d.ts.map +1 -1
- package/dist/pages/DecisionsPage.js +50 -8
- package/dist/pages/DecisionsPage.js.map +1 -1
- package/dist/pages/FormsPage.d.ts.map +1 -1
- package/dist/pages/FormsPage.js +53 -2
- package/dist/pages/FormsPage.js.map +1 -1
- package/dist/pages/FundraisingPage.d.ts.map +1 -1
- package/dist/pages/FundraisingPage.js +116 -14
- package/dist/pages/FundraisingPage.js.map +1 -1
- package/dist/pages/HiringPage.d.ts.map +1 -1
- package/dist/pages/HiringPage.js +27 -4
- package/dist/pages/HiringPage.js.map +1 -1
- package/dist/pages/ListDetail.d.ts +11 -0
- package/dist/pages/ListDetail.d.ts.map +1 -0
- package/dist/pages/ListDetail.js +188 -0
- package/dist/pages/ListDetail.js.map +1 -0
- package/dist/pages/ListsPage.d.ts +9 -0
- package/dist/pages/ListsPage.d.ts.map +1 -0
- package/dist/pages/ListsPage.js +125 -0
- package/dist/pages/ListsPage.js.map +1 -0
- package/dist/pages/OpportunitiesPage.d.ts.map +1 -1
- package/dist/pages/OpportunitiesPage.js +97 -14
- package/dist/pages/OpportunitiesPage.js.map +1 -1
- package/dist/pages/OpportunityDetail.d.ts.map +1 -1
- package/dist/pages/OpportunityDetail.js +3 -1
- package/dist/pages/OpportunityDetail.js.map +1 -1
- package/dist/pages/PartnershipDetail.d.ts.map +1 -1
- package/dist/pages/PartnershipDetail.js +3 -1
- package/dist/pages/PartnershipDetail.js.map +1 -1
- package/dist/pages/PartnershipsPage.d.ts.map +1 -1
- package/dist/pages/PartnershipsPage.js +111 -14
- package/dist/pages/PartnershipsPage.js.map +1 -1
- package/dist/pages/PeoplePage.d.ts.map +1 -1
- package/dist/pages/PeoplePage.js +102 -3
- package/dist/pages/PeoplePage.js.map +1 -1
- package/dist/pages/PersonDetail.d.ts.map +1 -1
- package/dist/pages/PersonDetail.js +5 -3
- package/dist/pages/PersonDetail.js.map +1 -1
- package/dist/pages/PlanningPage.d.ts.map +1 -1
- package/dist/pages/PlanningPage.js +85 -17
- package/dist/pages/PlanningPage.js.map +1 -1
- package/dist/pages/RaiseDetail.d.ts.map +1 -1
- package/dist/pages/RaiseDetail.js +3 -1
- package/dist/pages/RaiseDetail.js.map +1 -1
- package/dist/pages/RoleDetail.d.ts.map +1 -1
- package/dist/pages/RoleDetail.js +5 -4
- package/dist/pages/RoleDetail.js.map +1 -1
- package/dist/pages/admin/DataPage.d.ts.map +1 -1
- package/dist/pages/admin/DataPage.js +110 -12
- package/dist/pages/admin/DataPage.js.map +1 -1
- package/dist/pages/admin/WebhookDeliveries.d.ts.map +1 -1
- package/dist/pages/admin/WebhookDeliveries.js +3 -2
- package/dist/pages/admin/WebhookDeliveries.js.map +1 -1
- package/dist/pages/admin/WebhooksPage.d.ts.map +1 -1
- package/dist/pages/admin/WebhooksPage.js +3 -2
- package/dist/pages/admin/WebhooksPage.js.map +1 -1
- package/dist/pages/admin/WorkspaceSettingsPage.d.ts.map +1 -1
- package/dist/pages/admin/WorkspaceSettingsPage.js +45 -2
- package/dist/pages/admin/WorkspaceSettingsPage.js.map +1 -1
- package/dist/pages/forms/SubmissionsTable.d.ts.map +1 -1
- package/dist/pages/forms/SubmissionsTable.js +2 -1
- package/dist/pages/forms/SubmissionsTable.js.map +1 -1
- package/dist/pages/handbook/HandbookPage.js +1 -1
- package/dist/pages/hiringDirectory.js +2 -2
- package/dist/pages/onboarding/WorkspaceStepPage.d.ts.map +1 -1
- package/dist/pages/onboarding/WorkspaceStepPage.js +15 -2
- package/dist/pages/onboarding/WorkspaceStepPage.js.map +1 -1
- package/dist/pages/positionDirectory.js +2 -2
- package/package.json +2 -2
- package/src/api/resource.ts +149 -31
- package/src/api/resources/companies.ts +7 -1
- package/src/api/resources/decisions.ts +3 -0
- package/src/api/resources/emailDomainLinker.ts +43 -0
- package/src/api/resources/forms.ts +5 -23
- package/src/api/resources/handbookPages.ts +1 -1
- package/src/api/resources/listMembers.ts +173 -0
- package/src/api/resources/lists.ts +61 -0
- package/src/api/resources/people.ts +4 -1
- package/src/api/resources/roles.ts +3 -1
- package/src/api/resources/sampleData.ts +56 -0
- package/src/api/resources/webhooks.ts +8 -24
- package/src/app/KelpieApp.tsx +4 -0
- package/src/components/ActivitiesPanel.tsx +2 -10
- package/src/components/ColumnPicker.tsx +124 -0
- package/src/components/DataTable.tsx +89 -10
- package/src/components/DecisionsPanel.tsx +4 -12
- package/src/components/EntitySearch.tsx +12 -5
- package/src/components/ListsPanel.tsx +176 -0
- package/src/components/NotesPanel.tsx +6 -14
- package/src/components/Paginator.tsx +78 -0
- package/src/components/PlanPanel.tsx +2 -10
- package/src/components/Shell.tsx +1 -0
- package/src/index.ts +10 -1
- package/src/lib/listView.ts +149 -0
- package/src/lib/sort.ts +143 -0
- package/src/pages/CompaniesPage.tsx +125 -11
- package/src/pages/CompanyDetail.tsx +62 -62
- package/src/pages/DealDetail.tsx +3 -0
- package/src/pages/DealsPage.tsx +178 -24
- package/src/pages/DecisionsPage.tsx +73 -18
- package/src/pages/FormsPage.tsx +81 -17
- package/src/pages/FundraisingPage.tsx +152 -24
- package/src/pages/HiringPage.tsx +39 -11
- package/src/pages/ListDetail.tsx +408 -0
- package/src/pages/ListsPage.tsx +281 -0
- package/src/pages/OpportunitiesPage.tsx +130 -24
- package/src/pages/OpportunityDetail.tsx +3 -0
- package/src/pages/PartnershipDetail.tsx +3 -0
- package/src/pages/PartnershipsPage.tsx +149 -24
- package/src/pages/PeoplePage.tsx +137 -11
- package/src/pages/PersonDetail.tsx +5 -3
- package/src/pages/PlanningPage.tsx +256 -29
- package/src/pages/RaiseDetail.tsx +3 -0
- package/src/pages/RoleDetail.tsx +21 -26
- package/src/pages/admin/DataPage.tsx +276 -13
- package/src/pages/admin/WebhookDeliveries.tsx +10 -15
- package/src/pages/admin/WebhooksPage.tsx +14 -19
- package/src/pages/admin/WorkspaceSettingsPage.tsx +134 -2
- package/src/pages/forms/SubmissionsTable.tsx +3 -10
- package/src/pages/handbook/HandbookPage.tsx +1 -1
- package/src/pages/hiringDirectory.ts +2 -2
- package/src/pages/onboarding/WorkspaceStepPage.tsx +31 -3
- package/src/pages/positionDirectory.ts +2 -2
- 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
|
-
|
|
168
|
-
|
|
185
|
+
await processChosenFile(chosen)
|
|
186
|
+
}
|
|
169
187
|
|
|
170
|
-
|
|
188
|
+
async function onFileDrop(event: DragEvent<HTMLLabelElement>): Promise<void> {
|
|
189
|
+
event.preventDefault()
|
|
171
190
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
<
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
99
|
-
{
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
{
|
|
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
|
|
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.
|
|
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.
|