@kelpie/ui 0.8.0 → 0.9.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 +1 -1
- package/dist/api/resource.d.ts.map +1 -1
- package/dist/api/resource.js.map +1 -1
- package/dist/api/resources/consentPurposes.d.ts +13 -0
- package/dist/api/resources/consentPurposes.d.ts.map +1 -0
- package/dist/api/resources/consentPurposes.js +39 -0
- package/dist/api/resources/consentPurposes.js.map +1 -0
- package/dist/api/resources/customFields.d.ts +15 -0
- package/dist/api/resources/customFields.d.ts.map +1 -0
- package/dist/api/resources/customFields.js +41 -0
- package/dist/api/resources/customFields.js.map +1 -0
- package/dist/api/resources/enquiries.d.ts +39 -0
- package/dist/api/resources/enquiries.d.ts.map +1 -0
- package/dist/api/resources/enquiries.js +68 -0
- package/dist/api/resources/enquiries.js.map +1 -0
- package/dist/api/resources/forms.d.ts +21 -1
- package/dist/api/resources/forms.d.ts.map +1 -1
- package/dist/api/resources/forms.js +41 -0
- package/dist/api/resources/forms.js.map +1 -1
- package/dist/api/resources/importJobs.d.ts +5 -0
- package/dist/api/resources/importJobs.d.ts.map +1 -1
- package/dist/api/resources/importJobs.js +3 -0
- package/dist/api/resources/importJobs.js.map +1 -1
- package/dist/api/resources/opportunities.d.ts +2 -0
- package/dist/api/resources/opportunities.d.ts.map +1 -1
- package/dist/api/resources/opportunities.js +1 -0
- package/dist/api/resources/opportunities.js.map +1 -1
- package/dist/api/resources/sampleData.d.ts.map +1 -1
- package/dist/api/resources/sampleData.js +1 -0
- package/dist/api/resources/sampleData.js.map +1 -1
- package/dist/app/KelpieApp.d.ts.map +1 -1
- package/dist/app/KelpieApp.js +7 -1
- package/dist/app/KelpieApp.js.map +1 -1
- package/dist/components/ActivitiesPanel.d.ts.map +1 -1
- package/dist/components/ActivitiesPanel.js +1 -0
- package/dist/components/ActivitiesPanel.js.map +1 -1
- package/dist/components/CustomFieldsPanel.d.ts +23 -0
- package/dist/components/CustomFieldsPanel.d.ts.map +1 -0
- package/dist/components/CustomFieldsPanel.js +116 -0
- package/dist/components/CustomFieldsPanel.js.map +1 -0
- package/dist/components/FormsPanel.d.ts +18 -0
- package/dist/components/FormsPanel.d.ts.map +1 -0
- package/dist/components/FormsPanel.js +37 -0
- package/dist/components/FormsPanel.js.map +1 -0
- package/dist/components/PlanAttention.d.ts +3 -2
- package/dist/components/PlanAttention.d.ts.map +1 -1
- package/dist/components/PlanAttention.js +7 -3
- package/dist/components/PlanAttention.js.map +1 -1
- package/dist/components/Shell.d.ts.map +1 -1
- package/dist/components/Shell.js +3 -0
- package/dist/components/Shell.js.map +1 -1
- package/dist/components/useHasCustomFields.d.ts +13 -0
- package/dist/components/useHasCustomFields.d.ts.map +1 -0
- package/dist/components/useHasCustomFields.js +16 -0
- package/dist/components/useHasCustomFields.js.map +1 -0
- package/dist/pages/CompanyDetail.d.ts.map +1 -1
- package/dist/pages/CompanyDetail.js +24 -2
- package/dist/pages/CompanyDetail.js.map +1 -1
- package/dist/pages/DealDetail.d.ts.map +1 -1
- package/dist/pages/DealDetail.js +18 -1
- package/dist/pages/DealDetail.js.map +1 -1
- package/dist/pages/DecisionsPage.d.ts.map +1 -1
- package/dist/pages/DecisionsPage.js +2 -0
- package/dist/pages/DecisionsPage.js.map +1 -1
- package/dist/pages/EnquiriesPage.d.ts +2 -0
- package/dist/pages/EnquiriesPage.d.ts.map +1 -0
- package/dist/pages/EnquiriesPage.js +251 -0
- package/dist/pages/EnquiriesPage.js.map +1 -0
- package/dist/pages/EnquiryDetail.d.ts +2 -0
- package/dist/pages/EnquiryDetail.d.ts.map +1 -0
- package/dist/pages/EnquiryDetail.js +207 -0
- package/dist/pages/EnquiryDetail.js.map +1 -0
- package/dist/pages/EnquiryStageSettingsPage.d.ts +3 -0
- package/dist/pages/EnquiryStageSettingsPage.d.ts.map +1 -0
- package/dist/pages/EnquiryStageSettingsPage.js +34 -0
- package/dist/pages/EnquiryStageSettingsPage.js.map +1 -0
- package/dist/pages/FormDetail.d.ts.map +1 -1
- package/dist/pages/FormDetail.js +19 -1
- package/dist/pages/FormDetail.js.map +1 -1
- package/dist/pages/ListDetail.d.ts.map +1 -1
- package/dist/pages/ListDetail.js +27 -1
- package/dist/pages/ListDetail.js.map +1 -1
- package/dist/pages/OpportunityDetail.d.ts.map +1 -1
- package/dist/pages/OpportunityDetail.js +62 -3
- package/dist/pages/OpportunityDetail.js.map +1 -1
- package/dist/pages/PartnershipDetail.d.ts.map +1 -1
- package/dist/pages/PartnershipDetail.js +17 -1
- package/dist/pages/PartnershipDetail.js.map +1 -1
- package/dist/pages/PeoplePage.d.ts.map +1 -1
- package/dist/pages/PeoplePage.js +17 -0
- package/dist/pages/PeoplePage.js.map +1 -1
- package/dist/pages/PersonDetail.d.ts.map +1 -1
- package/dist/pages/PersonDetail.js +63 -3
- package/dist/pages/PersonDetail.js.map +1 -1
- package/dist/pages/PlanningPage.d.ts.map +1 -1
- package/dist/pages/PlanningPage.js +3 -0
- package/dist/pages/PlanningPage.js.map +1 -1
- package/dist/pages/RaiseDetail.d.ts.map +1 -1
- package/dist/pages/RaiseDetail.js +11 -1
- package/dist/pages/RaiseDetail.js.map +1 -1
- package/dist/pages/RoleDetail.js +1 -1
- package/dist/pages/RoleDetail.js.map +1 -1
- package/dist/pages/SearchPage.d.ts.map +1 -1
- package/dist/pages/SearchPage.js +1 -0
- package/dist/pages/SearchPage.js.map +1 -1
- package/dist/pages/admin/DataPage.d.ts.map +1 -1
- package/dist/pages/admin/DataPage.js +18 -3
- package/dist/pages/admin/DataPage.js.map +1 -1
- package/dist/pages/admin/FieldsPage.d.ts +2 -0
- package/dist/pages/admin/FieldsPage.d.ts.map +1 -0
- package/dist/pages/admin/FieldsPage.js +164 -0
- package/dist/pages/admin/FieldsPage.js.map +1 -0
- package/dist/pages/admin/PrivacyPage.d.ts +9 -0
- package/dist/pages/admin/PrivacyPage.d.ts.map +1 -0
- package/dist/pages/admin/PrivacyPage.js +170 -0
- package/dist/pages/admin/PrivacyPage.js.map +1 -0
- package/dist/pages/dashboard/attention.d.ts.map +1 -1
- package/dist/pages/dashboard/attention.js +3 -0
- package/dist/pages/dashboard/attention.js.map +1 -1
- package/dist/pages/forms/AddFieldMenu.d.ts +17 -0
- package/dist/pages/forms/AddFieldMenu.d.ts.map +1 -0
- package/dist/pages/forms/AddFieldMenu.js +43 -0
- package/dist/pages/forms/AddFieldMenu.js.map +1 -0
- package/dist/pages/forms/EmbedPanel.d.ts +4 -0
- package/dist/pages/forms/EmbedPanel.d.ts.map +1 -1
- package/dist/pages/forms/EmbedPanel.js +62 -3
- package/dist/pages/forms/EmbedPanel.js.map +1 -1
- package/dist/pages/forms/FieldsEditor.d.ts.map +1 -1
- package/dist/pages/forms/FieldsEditor.js +179 -36
- package/dist/pages/forms/FieldsEditor.js.map +1 -1
- package/dist/pages/forms/FormActions.d.ts +6 -0
- package/dist/pages/forms/FormActions.d.ts.map +1 -0
- package/dist/pages/forms/FormActions.js +149 -0
- package/dist/pages/forms/FormActions.js.map +1 -0
- package/dist/pages/forms/FormSettings.d.ts.map +1 -1
- package/dist/pages/forms/FormSettings.js +15 -19
- package/dist/pages/forms/FormSettings.js.map +1 -1
- package/dist/pages/forms/SubmissionDetail.d.ts +2 -0
- package/dist/pages/forms/SubmissionDetail.d.ts.map +1 -0
- package/dist/pages/forms/SubmissionDetail.js +76 -0
- package/dist/pages/forms/SubmissionDetail.js.map +1 -0
- package/dist/pages/forms/SubmissionsTable.d.ts.map +1 -1
- package/dist/pages/forms/SubmissionsTable.js +56 -8
- package/dist/pages/forms/SubmissionsTable.js.map +1 -1
- package/dist/pages/forms/fieldList.d.ts +11 -5
- package/dist/pages/forms/fieldList.d.ts.map +1 -1
- package/dist/pages/forms/fieldList.js +66 -6
- package/dist/pages/forms/fieldList.js.map +1 -1
- package/dist/pages/forms/template.d.ts +17 -0
- package/dist/pages/forms/template.d.ts.map +1 -1
- package/dist/pages/forms/template.js +93 -0
- package/dist/pages/forms/template.js.map +1 -1
- package/dist/pages/onboarding/WorkspaceStepPage.js +1 -1
- package/dist/pages/onboarding/WorkspaceStepPage.js.map +1 -1
- package/package.json +2 -2
- package/src/api/resource.ts +6 -1
- package/src/api/resources/consentPurposes.ts +83 -0
- package/src/api/resources/customFields.ts +88 -0
- package/src/api/resources/enquiries.ts +118 -0
- package/src/api/resources/forms.ts +58 -0
- package/src/api/resources/importJobs.ts +9 -0
- package/src/api/resources/opportunities.ts +3 -0
- package/src/api/resources/sampleData.ts +1 -0
- package/src/app/KelpieApp.tsx +12 -0
- package/src/components/ActivitiesPanel.tsx +1 -0
- package/src/components/CustomFieldsPanel.tsx +330 -0
- package/src/components/FormsPanel.tsx +86 -0
- package/src/components/PlanAttention.tsx +20 -3
- package/src/components/Shell.tsx +3 -0
- package/src/components/useHasCustomFields.ts +18 -0
- package/src/pages/CompanyDetail.tsx +36 -1
- package/src/pages/DealDetail.tsx +31 -0
- package/src/pages/DecisionsPage.tsx +2 -0
- package/src/pages/EnquiriesPage.tsx +434 -0
- package/src/pages/EnquiryDetail.tsx +554 -0
- package/src/pages/EnquiryStageSettingsPage.tsx +63 -0
- package/src/pages/FormDetail.tsx +23 -1
- package/src/pages/ListDetail.tsx +62 -0
- package/src/pages/OpportunityDetail.tsx +165 -2
- package/src/pages/PartnershipDetail.tsx +32 -0
- package/src/pages/PeoplePage.tsx +20 -0
- package/src/pages/PersonDetail.tsx +152 -3
- package/src/pages/PlanningPage.tsx +3 -0
- package/src/pages/RaiseDetail.tsx +21 -0
- package/src/pages/RoleDetail.tsx +1 -1
- package/src/pages/SearchPage.tsx +1 -0
- package/src/pages/admin/DataPage.tsx +51 -3
- package/src/pages/admin/FieldsPage.tsx +455 -0
- package/src/pages/admin/PrivacyPage.tsx +461 -0
- package/src/pages/dashboard/attention.ts +3 -0
- package/src/pages/forms/AddFieldMenu.tsx +121 -0
- package/src/pages/forms/EmbedPanel.tsx +163 -2
- package/src/pages/forms/FieldsEditor.tsx +539 -165
- package/src/pages/forms/FormActions.tsx +552 -0
- package/src/pages/forms/FormSettings.tsx +29 -70
- package/src/pages/forms/SubmissionDetail.tsx +249 -0
- package/src/pages/forms/SubmissionsTable.tsx +109 -16
- package/src/pages/forms/fieldList.ts +68 -5
- package/src/pages/forms/template.ts +103 -0
- package/src/pages/onboarding/WorkspaceStepPage.tsx +1 -1
package/src/pages/ListDetail.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { Link, useNavigate, useParams } from 'react-router'
|
|
|
6
6
|
import { usePatch } from '../api/resource.ts'
|
|
7
7
|
import type { PatchResult } from '../api/resource.ts'
|
|
8
8
|
import { useCompanies } from '../api/resources/companies.ts'
|
|
9
|
+
import { useConsentPurposes } from '../api/resources/consentPurposes.ts'
|
|
9
10
|
import { useDeals } from '../api/resources/deals.ts'
|
|
10
11
|
import { useDeleteList, useList, useUpdateList } from '../api/resources/lists.ts'
|
|
11
12
|
import {
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
useListMembers,
|
|
14
15
|
useRemoveListMember,
|
|
15
16
|
} from '../api/resources/listMembers.ts'
|
|
17
|
+
import { useEnquiries } from '../api/resources/enquiries.ts'
|
|
16
18
|
import { useOpportunities } from '../api/resources/opportunities.ts'
|
|
17
19
|
import { usePartnerships } from '../api/resources/partnerships.ts'
|
|
18
20
|
import { usePeople } from '../api/resources/people.ts'
|
|
@@ -84,11 +86,57 @@ export function ListDetail(): React.JSX.Element {
|
|
|
84
86
|
|
|
85
87
|
<ListDescription list={record} />
|
|
86
88
|
|
|
89
|
+
{record.targetType === 'person' && <ListConsentPurpose list={record} />}
|
|
90
|
+
|
|
87
91
|
<ListMembers listId={record.id} targetType={record.targetType} />
|
|
88
92
|
</div>
|
|
89
93
|
)
|
|
90
94
|
}
|
|
91
95
|
|
|
96
|
+
function ListConsentPurpose({ list }: { readonly list: List }): React.JSX.Element {
|
|
97
|
+
const { patch, error } = useListPatch(list)
|
|
98
|
+
const purposes = useConsentPurposes({ sort: 'sort_order', limit: 200 })
|
|
99
|
+
const options = [
|
|
100
|
+
{ value: '', label: '— none —' },
|
|
101
|
+
...purposes.records.map((purpose) => ({ value: purpose.id, label: purpose.label })),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className="mb-6 rounded-md border border-border bg-surface-raised p-4 text-[13px]">
|
|
106
|
+
{error !== null && (
|
|
107
|
+
<div className="mb-2">
|
|
108
|
+
<ErrorPanel error={error} />
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
|
112
|
+
<div>
|
|
113
|
+
<div className="text-[12px] font-medium text-ink">Consent purpose</div>
|
|
114
|
+
<p className="mt-0.5 text-[12px] text-ink-muted">
|
|
115
|
+
A form submit that adds a person to this list also grants this purpose. Manual and
|
|
116
|
+
API adds capture nothing.
|
|
117
|
+
</p>
|
|
118
|
+
</div>
|
|
119
|
+
<label className="shrink-0">
|
|
120
|
+
<select
|
|
121
|
+
value={list.consentPurposeId ?? ''}
|
|
122
|
+
onChange={(event) => {
|
|
123
|
+
const value = event.target.value
|
|
124
|
+
patch({ consentPurposeId: value.length === 0 ? null : value })
|
|
125
|
+
}}
|
|
126
|
+
className="rounded-md border border-border bg-transparent px-2 py-1 text-[12px] outline-none focus:border-accent"
|
|
127
|
+
>
|
|
128
|
+
{options.map((option) => (
|
|
129
|
+
<option key={option.value} value={option.value}>
|
|
130
|
+
{option.label}
|
|
131
|
+
</option>
|
|
132
|
+
))}
|
|
133
|
+
</select>
|
|
134
|
+
</label>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
92
140
|
function useListPatch(list: List): PatchResult<ListInput> {
|
|
93
141
|
return usePatch(useUpdateList, list)
|
|
94
142
|
}
|
|
@@ -271,6 +319,8 @@ function detailPathFor(targetType: RecordTargetType, targetId: string): string {
|
|
|
271
319
|
return `/partnerships/${targetId}`
|
|
272
320
|
case 'raise':
|
|
273
321
|
return `/fundraising/${targetId}`
|
|
322
|
+
case 'enquiry':
|
|
323
|
+
return `/enquiries/${targetId}`
|
|
274
324
|
case 'candidate':
|
|
275
325
|
// A candidate has no page of its own; the person's does.
|
|
276
326
|
return `/hiring`
|
|
@@ -304,6 +354,8 @@ function MemberPicker({
|
|
|
304
354
|
return <PartnershipsPicker attached={attached} onPick={onPick} />
|
|
305
355
|
case 'raise':
|
|
306
356
|
return <RaisesPicker attached={attached} onPick={onPick} />
|
|
357
|
+
case 'enquiry':
|
|
358
|
+
return <EnquiriesPicker attached={attached} onPick={onPick} />
|
|
307
359
|
case 'candidate':
|
|
308
360
|
return (
|
|
309
361
|
<p className="text-[12px] text-ink-faint">
|
|
@@ -379,6 +431,16 @@ function RaisesPicker({ attached, onPick }: PickerProps): React.JSX.Element {
|
|
|
379
431
|
return <PickerBox onQueryChange={setSearch} onPick={onPick} options={options} />
|
|
380
432
|
}
|
|
381
433
|
|
|
434
|
+
function EnquiriesPicker({ attached, onPick }: PickerProps): React.JSX.Element {
|
|
435
|
+
const [search, setSearch] = useState('')
|
|
436
|
+
const records = useEnquiries({ term: trimmedOrUndefined(search) })
|
|
437
|
+
const options: readonly SearchOption[] = records.records
|
|
438
|
+
.filter((row) => !attached.has(row.id))
|
|
439
|
+
.map((row) => ({ id: row.id, label: row.name }))
|
|
440
|
+
|
|
441
|
+
return <PickerBox onQueryChange={setSearch} onPick={onPick} options={options} />
|
|
442
|
+
}
|
|
443
|
+
|
|
382
444
|
function PickerBox({
|
|
383
445
|
onQueryChange,
|
|
384
446
|
onPick,
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import type { Opportunity, OpportunityInput, PipelineStage } from '@kelpie/schemas'
|
|
2
2
|
import { useState } from 'react'
|
|
3
|
+
import type { FormEvent } from 'react'
|
|
3
4
|
import { Link, useNavigate, useParams } from 'react-router'
|
|
4
5
|
|
|
5
6
|
import { usePatch } from '../api/resource.ts'
|
|
6
7
|
import type { PatchResult } from '../api/resource.ts'
|
|
7
8
|
import { useCompanies, useCompany } from '../api/resources/companies.ts'
|
|
9
|
+
import { useFormSubmissionsForRecord } from '../api/resources/forms.ts'
|
|
8
10
|
import { useMembers } from '../api/resources/members.ts'
|
|
9
11
|
import {
|
|
10
12
|
useDeleteOpportunity,
|
|
11
13
|
useOpportunity,
|
|
12
14
|
useUpdateOpportunity,
|
|
13
15
|
} from '../api/resources/opportunities.ts'
|
|
16
|
+
import { usePeople } from '../api/resources/people.ts'
|
|
14
17
|
import { usePipelineStages } from '../api/resources/pipelineStages.ts'
|
|
15
18
|
import { useRecordPlanItems } from '../api/resources/planItems.ts'
|
|
16
19
|
import { ActivitiesPanel, LatestActivity } from '../components/ActivitiesPanel.tsx'
|
|
@@ -20,6 +23,9 @@ import type { ChipTone } from '../components/Chip.tsx'
|
|
|
20
23
|
import { DecisionsPanel } from '../components/DecisionsPanel.tsx'
|
|
21
24
|
import { DeleteRecord } from '../components/DeleteRecord.tsx'
|
|
22
25
|
import { EntitySearch } from '../components/EntitySearch.tsx'
|
|
26
|
+
import { FormsPanel } from '../components/FormsPanel.tsx'
|
|
27
|
+
import { CustomFieldsPanel } from '../components/CustomFieldsPanel.tsx'
|
|
28
|
+
import { useHasCustomFields } from '../components/useHasCustomFields.ts'
|
|
23
29
|
import { InlineEdit } from '../components/InlineEdit.tsx'
|
|
24
30
|
import { ListsPanel } from '../components/ListsPanel.tsx'
|
|
25
31
|
import { NotesPanel } from '../components/NotesPanel.tsx'
|
|
@@ -28,6 +34,7 @@ import { PlanPanel } from '../components/PlanPanel.tsx'
|
|
|
28
34
|
import { ErrorPanel, LoadingPanel, NotFoundPanel } from '../components/QueryState.tsx'
|
|
29
35
|
import { RecordTabs } from '../components/RecordTabs.tsx'
|
|
30
36
|
import type { RecordTabDescriptor } from '../components/RecordTabs.tsx'
|
|
37
|
+
import { SectionHeader } from '../components/SectionHeader.tsx'
|
|
31
38
|
import { SidebarField } from '../components/SidebarField.tsx'
|
|
32
39
|
import { SummaryBlock } from '../components/SummaryBlock.tsx'
|
|
33
40
|
import { formatDay } from '../lib/dates.ts'
|
|
@@ -39,8 +46,10 @@ import { toTags } from './fields.ts'
|
|
|
39
46
|
* One opportunity.
|
|
40
47
|
*
|
|
41
48
|
* The same shape as `DealDetail` minus what an opportunity does not have: no
|
|
42
|
-
* value,
|
|
43
|
-
*
|
|
49
|
+
* value, and a company that may be absent. People attach through `person_links`
|
|
50
|
+
* the same way they do on a deal, and they are shown in the same contacts
|
|
51
|
+
* section in the aside. A UI module can add its own tab through the
|
|
52
|
+
* `opportunity` record-tab slot.
|
|
44
53
|
*/
|
|
45
54
|
|
|
46
55
|
const STAGE_TONES: Readonly<Record<string, ChipTone>> = {
|
|
@@ -54,7 +63,9 @@ export function OpportunityDetail(): React.JSX.Element {
|
|
|
54
63
|
const { record, isLoading, isNotFound, error } = useOpportunity(id)
|
|
55
64
|
const deleteOpportunity = useDeleteOpportunity()
|
|
56
65
|
const moduleTabs = inSlotOrder(useRecordTabs('opportunity'))
|
|
66
|
+
const hasCustomFields = useHasCustomFields('opportunity')
|
|
57
67
|
const [activeTab, setActiveTab] = useState('overview')
|
|
68
|
+
const formSubmissions = useFormSubmissionsForRecord('opportunity', id)
|
|
58
69
|
|
|
59
70
|
if (isNotFound) {
|
|
60
71
|
return <NotFoundPanel label="Opportunity" backTo="/opportunities" />
|
|
@@ -70,11 +81,15 @@ export function OpportunityDetail(): React.JSX.Element {
|
|
|
70
81
|
|
|
71
82
|
const tabs: readonly RecordTabDescriptor<string>[] = [
|
|
72
83
|
{ id: 'overview', label: 'Overview' },
|
|
84
|
+
...(hasCustomFields ? [{ id: 'fields', label: 'Fields' }] : []),
|
|
73
85
|
{ id: 'plan', label: 'Plan' },
|
|
74
86
|
{ id: 'activity', label: 'Activity' },
|
|
75
87
|
{ id: 'notes', label: 'Notes' },
|
|
76
88
|
{ id: 'decisions', label: 'Decisions' },
|
|
77
89
|
{ id: 'lists', label: 'Lists' },
|
|
90
|
+
...(formSubmissions.records.length === 0
|
|
91
|
+
? []
|
|
92
|
+
: [{ id: 'forms', label: 'Forms', count: formSubmissions.records.length }]),
|
|
78
93
|
...moduleTabs.map((tab) => ({ id: tab.id, label: tab.label })),
|
|
79
94
|
]
|
|
80
95
|
const active = tabs.some((tab) => tab.id === activeTab) ? activeTab : 'overview'
|
|
@@ -116,6 +131,7 @@ export function OpportunityDetail(): React.JSX.Element {
|
|
|
116
131
|
ariaLabel="Opportunity sections"
|
|
117
132
|
>
|
|
118
133
|
{active === 'overview' && <OpportunityOverview opportunity={record} />}
|
|
134
|
+
{active === 'fields' && <OpportunityFields opportunity={record} />}
|
|
119
135
|
{active === 'plan' && <PlanPanel targetType="opportunity" targetId={record.id} />}
|
|
120
136
|
{active === 'activity' && (
|
|
121
137
|
<ActivitiesPanel targetType="opportunity" targetId={record.id} />
|
|
@@ -125,12 +141,14 @@ export function OpportunityDetail(): React.JSX.Element {
|
|
|
125
141
|
<DecisionsPanel targetType="opportunity" targetId={record.id} />
|
|
126
142
|
)}
|
|
127
143
|
{active === 'lists' && <ListsPanel targetType="opportunity" targetId={record.id} />}
|
|
144
|
+
{active === 'forms' && <FormsPanel targetType="opportunity" targetId={record.id} />}
|
|
128
145
|
{moduleTab?.render({ objectType: 'opportunity', recordId: record.id })}
|
|
129
146
|
</RecordTabs>
|
|
130
147
|
</div>
|
|
131
148
|
|
|
132
149
|
<aside className="space-y-4 text-[12px] lg:sticky lg:top-6">
|
|
133
150
|
<OpportunitySidebar opportunity={record} />
|
|
151
|
+
<OpportunityContacts opportunity={record} />
|
|
134
152
|
</aside>
|
|
135
153
|
</div>
|
|
136
154
|
</div>
|
|
@@ -338,3 +356,148 @@ function OpportunitySidebar({
|
|
|
338
356
|
</section>
|
|
339
357
|
)
|
|
340
358
|
}
|
|
359
|
+
|
|
360
|
+
function OpportunityFields({
|
|
361
|
+
opportunity,
|
|
362
|
+
}: {
|
|
363
|
+
readonly opportunity: Opportunity
|
|
364
|
+
}): React.JSX.Element {
|
|
365
|
+
const { patch, error } = useOpportunityPatch(opportunity)
|
|
366
|
+
return (
|
|
367
|
+
<div className="space-y-4">
|
|
368
|
+
{error !== null && <ErrorPanel error={error} />}
|
|
369
|
+
<CustomFieldsPanel
|
|
370
|
+
objectType="opportunity"
|
|
371
|
+
values={opportunity.customFields}
|
|
372
|
+
onPatch={(customFields) => {
|
|
373
|
+
patch({ customFields })
|
|
374
|
+
}}
|
|
375
|
+
/>
|
|
376
|
+
</div>
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The people on the opportunity, linked and unlinked by replacing `person_ids`. */
|
|
381
|
+
function OpportunityContacts({
|
|
382
|
+
opportunity,
|
|
383
|
+
}: {
|
|
384
|
+
readonly opportunity: Opportunity
|
|
385
|
+
}): React.JSX.Element {
|
|
386
|
+
const { patch, error } = useOpportunityPatch(opportunity)
|
|
387
|
+
|
|
388
|
+
const [adding, setAdding] = useState(false)
|
|
389
|
+
const [personId, setPersonId] = useState('')
|
|
390
|
+
const [search, setSearch] = useState('')
|
|
391
|
+
|
|
392
|
+
// No `?id=` filter exists on people, so names come from the directory's first
|
|
393
|
+
// page. Past it, a contact renders by id rather than silently vanishing.
|
|
394
|
+
const directory = usePeople({ limit: 200 })
|
|
395
|
+
const searchable = usePeople({ term: search.trim().length > 0 ? search.trim() : undefined })
|
|
396
|
+
const nameById = new Map(directory.records.map((person) => [person.id, person.name]))
|
|
397
|
+
const linked = new Set(opportunity.personIds)
|
|
398
|
+
|
|
399
|
+
function reset(): void {
|
|
400
|
+
setAdding(false)
|
|
401
|
+
setPersonId('')
|
|
402
|
+
setSearch('')
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function submit(event: FormEvent): void {
|
|
406
|
+
event.preventDefault()
|
|
407
|
+
|
|
408
|
+
if (personId.length === 0 || linked.has(personId)) {
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
patch({ personIds: [...opportunity.personIds, personId] })
|
|
413
|
+
reset()
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return (
|
|
417
|
+
<section className="rounded-md border border-border">
|
|
418
|
+
<div className="border-b border-border px-3.5 py-2.5">
|
|
419
|
+
<SectionHeader
|
|
420
|
+
title="Contacts"
|
|
421
|
+
onAdd={() => {
|
|
422
|
+
setAdding((current) => !current)
|
|
423
|
+
}}
|
|
424
|
+
addLabel="Add contact"
|
|
425
|
+
compact
|
|
426
|
+
/>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
{error !== null && (
|
|
430
|
+
<div className="px-3.5 py-2">
|
|
431
|
+
<ErrorPanel error={error} />
|
|
432
|
+
</div>
|
|
433
|
+
)}
|
|
434
|
+
|
|
435
|
+
<ul className="divide-y divide-border">
|
|
436
|
+
{opportunity.personIds.length === 0 && !adding && (
|
|
437
|
+
<li className="px-3.5 py-4 text-[12px] text-ink-faint">No contacts yet.</li>
|
|
438
|
+
)}
|
|
439
|
+
{opportunity.personIds.map((linkedPersonId) => (
|
|
440
|
+
<li
|
|
441
|
+
key={linkedPersonId}
|
|
442
|
+
className="flex items-start justify-between gap-2 px-3.5 py-2.5"
|
|
443
|
+
>
|
|
444
|
+
<Link
|
|
445
|
+
to={`/people/${linkedPersonId}`}
|
|
446
|
+
className="text-[13px] font-medium text-ink hover:text-accent"
|
|
447
|
+
>
|
|
448
|
+
{nameById.get(linkedPersonId) ?? linkedPersonId}
|
|
449
|
+
</Link>
|
|
450
|
+
<button
|
|
451
|
+
type="button"
|
|
452
|
+
onClick={() => {
|
|
453
|
+
patch({
|
|
454
|
+
personIds: opportunity.personIds.filter(
|
|
455
|
+
(candidate) => candidate !== linkedPersonId,
|
|
456
|
+
),
|
|
457
|
+
})
|
|
458
|
+
}}
|
|
459
|
+
className="text-[11px] font-medium text-danger hover:underline"
|
|
460
|
+
>
|
|
461
|
+
Remove
|
|
462
|
+
</button>
|
|
463
|
+
</li>
|
|
464
|
+
))}
|
|
465
|
+
</ul>
|
|
466
|
+
|
|
467
|
+
{adding && (
|
|
468
|
+
<form onSubmit={submit} className="space-y-2 border-t border-border bg-surface px-3.5 py-3">
|
|
469
|
+
<EntitySearch
|
|
470
|
+
options={searchable.records
|
|
471
|
+
.filter((person) => !linked.has(person.id))
|
|
472
|
+
.map((person) => ({
|
|
473
|
+
id: person.id,
|
|
474
|
+
label: person.name,
|
|
475
|
+
meta: person.email ?? undefined,
|
|
476
|
+
}))}
|
|
477
|
+
value={personId}
|
|
478
|
+
onChange={setPersonId}
|
|
479
|
+
onQueryChange={setSearch}
|
|
480
|
+
placeholder="Search people…"
|
|
481
|
+
emptyMessage="No people match"
|
|
482
|
+
required
|
|
483
|
+
/>
|
|
484
|
+
<div className="flex justify-end gap-2">
|
|
485
|
+
<button
|
|
486
|
+
type="button"
|
|
487
|
+
onClick={reset}
|
|
488
|
+
className="rounded-md px-2.5 py-1 text-[12px] font-medium text-ink-muted hover:text-ink"
|
|
489
|
+
>
|
|
490
|
+
Cancel
|
|
491
|
+
</button>
|
|
492
|
+
<button
|
|
493
|
+
type="submit"
|
|
494
|
+
className="rounded-md bg-accent px-3 py-1.5 text-[12px] font-semibold text-accent-fg hover:bg-accent-hover"
|
|
495
|
+
>
|
|
496
|
+
Add
|
|
497
|
+
</button>
|
|
498
|
+
</div>
|
|
499
|
+
</form>
|
|
500
|
+
)}
|
|
501
|
+
</section>
|
|
502
|
+
)
|
|
503
|
+
}
|
|
@@ -6,6 +6,7 @@ import { Link, useNavigate, useParams } from 'react-router'
|
|
|
6
6
|
import { usePatch } from '../api/resource.ts'
|
|
7
7
|
import type { PatchResult } from '../api/resource.ts'
|
|
8
8
|
import { useCompanies, useCompany } from '../api/resources/companies.ts'
|
|
9
|
+
import { useFormSubmissionsForRecord } from '../api/resources/forms.ts'
|
|
9
10
|
import { useMembers } from '../api/resources/members.ts'
|
|
10
11
|
import {
|
|
11
12
|
useDeletePartnership,
|
|
@@ -22,6 +23,9 @@ import type { ChipTone } from '../components/Chip.tsx'
|
|
|
22
23
|
import { DecisionsPanel } from '../components/DecisionsPanel.tsx'
|
|
23
24
|
import { DeleteRecord } from '../components/DeleteRecord.tsx'
|
|
24
25
|
import { EntitySearch } from '../components/EntitySearch.tsx'
|
|
26
|
+
import { FormsPanel } from '../components/FormsPanel.tsx'
|
|
27
|
+
import { CustomFieldsPanel } from '../components/CustomFieldsPanel.tsx'
|
|
28
|
+
import { useHasCustomFields } from '../components/useHasCustomFields.ts'
|
|
25
29
|
import { InlineEdit } from '../components/InlineEdit.tsx'
|
|
26
30
|
import { ListsPanel } from '../components/ListsPanel.tsx'
|
|
27
31
|
import { NotesPanel } from '../components/NotesPanel.tsx'
|
|
@@ -59,7 +63,9 @@ export function PartnershipDetail(): React.JSX.Element {
|
|
|
59
63
|
const { record, isLoading, isNotFound, error } = usePartnership(id)
|
|
60
64
|
const deletePartnership = useDeletePartnership()
|
|
61
65
|
const moduleTabs = inSlotOrder(useRecordTabs('partnership'))
|
|
66
|
+
const hasCustomFields = useHasCustomFields('partnership')
|
|
62
67
|
const [activeTab, setActiveTab] = useState('overview')
|
|
68
|
+
const formSubmissions = useFormSubmissionsForRecord('partnership', id)
|
|
63
69
|
|
|
64
70
|
if (isNotFound) {
|
|
65
71
|
return <NotFoundPanel label="Partnership" backTo="/partnerships" />
|
|
@@ -75,11 +81,15 @@ export function PartnershipDetail(): React.JSX.Element {
|
|
|
75
81
|
|
|
76
82
|
const tabs: readonly RecordTabDescriptor<string>[] = [
|
|
77
83
|
{ id: 'overview', label: 'Overview' },
|
|
84
|
+
...(hasCustomFields ? [{ id: 'fields', label: 'Fields' }] : []),
|
|
78
85
|
{ id: 'plan', label: 'Plan' },
|
|
79
86
|
{ id: 'activity', label: 'Activity' },
|
|
80
87
|
{ id: 'notes', label: 'Notes' },
|
|
81
88
|
{ id: 'decisions', label: 'Decisions' },
|
|
82
89
|
{ id: 'lists', label: 'Lists' },
|
|
90
|
+
...(formSubmissions.records.length === 0
|
|
91
|
+
? []
|
|
92
|
+
: [{ id: 'forms', label: 'Forms', count: formSubmissions.records.length }]),
|
|
83
93
|
...moduleTabs.map((tab) => ({ id: tab.id, label: tab.label })),
|
|
84
94
|
]
|
|
85
95
|
const active = tabs.some((tab) => tab.id === activeTab) ? activeTab : 'overview'
|
|
@@ -121,6 +131,7 @@ export function PartnershipDetail(): React.JSX.Element {
|
|
|
121
131
|
ariaLabel="Partnership sections"
|
|
122
132
|
>
|
|
123
133
|
{active === 'overview' && <PartnershipOverview partnership={record} />}
|
|
134
|
+
{active === 'fields' && <PartnershipFields partnership={record} />}
|
|
124
135
|
{active === 'plan' && <PlanPanel targetType="partnership" targetId={record.id} />}
|
|
125
136
|
{active === 'activity' && (
|
|
126
137
|
<ActivitiesPanel targetType="partnership" targetId={record.id} />
|
|
@@ -130,6 +141,7 @@ export function PartnershipDetail(): React.JSX.Element {
|
|
|
130
141
|
<DecisionsPanel targetType="partnership" targetId={record.id} />
|
|
131
142
|
)}
|
|
132
143
|
{active === 'lists' && <ListsPanel targetType="partnership" targetId={record.id} />}
|
|
144
|
+
{active === 'forms' && <FormsPanel targetType="partnership" targetId={record.id} />}
|
|
133
145
|
{moduleTab?.render({ objectType: 'partnership', recordId: record.id })}
|
|
134
146
|
</RecordTabs>
|
|
135
147
|
</div>
|
|
@@ -367,6 +379,26 @@ function PartnershipSidebar({
|
|
|
367
379
|
)
|
|
368
380
|
}
|
|
369
381
|
|
|
382
|
+
function PartnershipFields({
|
|
383
|
+
partnership,
|
|
384
|
+
}: {
|
|
385
|
+
readonly partnership: Partnership
|
|
386
|
+
}): React.JSX.Element {
|
|
387
|
+
const { patch, error } = usePartnershipPatch(partnership)
|
|
388
|
+
return (
|
|
389
|
+
<div className="space-y-4">
|
|
390
|
+
{error !== null && <ErrorPanel error={error} />}
|
|
391
|
+
<CustomFieldsPanel
|
|
392
|
+
objectType="partnership"
|
|
393
|
+
values={partnership.customFields}
|
|
394
|
+
onPatch={(customFields) => {
|
|
395
|
+
patch({ customFields })
|
|
396
|
+
}}
|
|
397
|
+
/>
|
|
398
|
+
</div>
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
|
|
370
402
|
/** The key people on the partnership, linked and unlinked by replacing `person_ids`. */
|
|
371
403
|
function PartnershipKeyPeople({
|
|
372
404
|
partnership,
|
package/src/pages/PeoplePage.tsx
CHANGED
|
@@ -29,6 +29,14 @@ const DEFAULT_VISIBLE_KEYS: readonly string[] = ['name', 'email', 'phones', 'com
|
|
|
29
29
|
/** The sort fields `/v1/people` accepts. Anything else is a client-only sort. */
|
|
30
30
|
const SERVER_SORT_KEYS: readonly string[] = ['name', 'created_at', 'updated_at']
|
|
31
31
|
|
|
32
|
+
/** Written out because "Firstname" is what deriving these from the key would give. */
|
|
33
|
+
const NAME_PART_HEADERS = {
|
|
34
|
+
salutation: 'Salutation',
|
|
35
|
+
firstName: 'First name',
|
|
36
|
+
lastName: 'Last name',
|
|
37
|
+
suffix: 'Suffix',
|
|
38
|
+
} as const
|
|
39
|
+
|
|
32
40
|
/**
|
|
33
41
|
* The People list.
|
|
34
42
|
*
|
|
@@ -65,6 +73,18 @@ export function PeoplePage(): React.JSX.Element {
|
|
|
65
73
|
sortKey: 'name',
|
|
66
74
|
render: (person) => <span className="font-medium text-ink">{person.name}</span>,
|
|
67
75
|
},
|
|
76
|
+
// Hidden by default, and sorted page-locally rather than by the server: the
|
|
77
|
+
// parts are nullable, and `?sort=` is keyset paged, which cannot seek past a
|
|
78
|
+
// null. The catalog carries them so a workspace that imported them can put
|
|
79
|
+
// them back through the Columns picker.
|
|
80
|
+
...(['salutation', 'firstName', 'lastName', 'suffix'] as const).map(
|
|
81
|
+
(key): Column<Person> => ({
|
|
82
|
+
key,
|
|
83
|
+
header: NAME_PART_HEADERS[key],
|
|
84
|
+
getSortValue: (person) => person[key],
|
|
85
|
+
render: (person) => person[key] ?? '—',
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
68
88
|
{
|
|
69
89
|
key: 'position',
|
|
70
90
|
header: 'Position',
|