@kelpie/ui 0.8.0 → 0.10.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
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import { CONSENT_PURPOSE_STATUS_LABELS, CONSENT_PURPOSE_STATUSES } from '@kelpie/schemas'
|
|
2
|
+
import type { ConsentPurpose, ConsentPurposeStatus } from '@kelpie/schemas'
|
|
3
|
+
import { useMemo, useState } from 'react'
|
|
4
|
+
import type { FormEvent } from 'react'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
useConsentPurposes,
|
|
8
|
+
useCreateConsentPurpose,
|
|
9
|
+
useDeleteConsentPurpose,
|
|
10
|
+
useUpdateConsentPurpose,
|
|
11
|
+
} from '../../api/resources/consentPurposes.ts'
|
|
12
|
+
import { useSession } from '../../api/resources/session.ts'
|
|
13
|
+
import { AddButton } from '../../components/SectionHeader.tsx'
|
|
14
|
+
import { PageHeader } from '../../components/PageHeader.tsx'
|
|
15
|
+
import { ErrorPanel, LoadingPanel } from '../../components/QueryState.tsx'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Admin surface for workspace consent purposes.
|
|
19
|
+
*
|
|
20
|
+
* Admin-only, matching Custom fields: a purpose change touches every form,
|
|
21
|
+
* list, and import job that names it, and is team-wide config. A purpose has
|
|
22
|
+
* a default status that every Person without an explicit override inherits.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export function PrivacyPage(): React.JSX.Element {
|
|
26
|
+
const { session } = useSession()
|
|
27
|
+
const isAdmin = session?.role === 'owner' || session?.role === 'admin'
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="animate-slide-in mx-auto max-w-4xl space-y-6">
|
|
31
|
+
<PageHeader
|
|
32
|
+
title="Privacy"
|
|
33
|
+
description="Consent purposes for the things you contact people about. A form's consent checkbox, a list you add signups to, and an import can each grant one of these — and every Person's record reflects it."
|
|
34
|
+
/>
|
|
35
|
+
{isAdmin ? <PurposesAdmin /> : <MemberNotice />}
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function MemberNotice(): React.JSX.Element {
|
|
41
|
+
return (
|
|
42
|
+
<p className="rounded-md border border-border px-4 py-3 text-[13px] text-ink-muted">
|
|
43
|
+
Consent purposes are managed by workspace admins.
|
|
44
|
+
</p>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function PurposesAdmin(): React.JSX.Element {
|
|
49
|
+
const [adding, setAdding] = useState(false)
|
|
50
|
+
const [seeding, setSeeding] = useState(false)
|
|
51
|
+
const purposes = useConsentPurposes({ sort: 'sort_order', limit: 200 })
|
|
52
|
+
const create = useCreateConsentPurpose()
|
|
53
|
+
|
|
54
|
+
async function seedStarters(): Promise<void> {
|
|
55
|
+
setSeeding(true)
|
|
56
|
+
try {
|
|
57
|
+
await create.runAsync({
|
|
58
|
+
slug: 'contact',
|
|
59
|
+
label: 'Contact',
|
|
60
|
+
description: 'Being contacted by the workspace about our work together.',
|
|
61
|
+
defaultStatus: 'unknown',
|
|
62
|
+
})
|
|
63
|
+
await create.runAsync({
|
|
64
|
+
slug: 'marketing',
|
|
65
|
+
label: 'Marketing',
|
|
66
|
+
description: 'Marketing communications — newsletters, product updates, and campaigns.',
|
|
67
|
+
defaultStatus: 'unknown',
|
|
68
|
+
})
|
|
69
|
+
} catch {
|
|
70
|
+
// Error rendered inline via the ErrorPanel below.
|
|
71
|
+
} finally {
|
|
72
|
+
setSeeding(false)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
<div className="flex justify-end">
|
|
79
|
+
<AddButton
|
|
80
|
+
onClick={() => {
|
|
81
|
+
setAdding(true)
|
|
82
|
+
}}
|
|
83
|
+
label="Add a purpose"
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{adding && (
|
|
88
|
+
<AddPurposeForm
|
|
89
|
+
onDone={() => {
|
|
90
|
+
setAdding(false)
|
|
91
|
+
}}
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
{purposes.error !== null && <ErrorPanel error={purposes.error} />}
|
|
96
|
+
{create.error !== null && <ErrorPanel error={create.error} />}
|
|
97
|
+
{purposes.isLoading ? (
|
|
98
|
+
<LoadingPanel label="Loading purposes…" />
|
|
99
|
+
) : purposes.records.length === 0 ? (
|
|
100
|
+
<EmptyState
|
|
101
|
+
onAdd={() => {
|
|
102
|
+
setAdding(true)
|
|
103
|
+
}}
|
|
104
|
+
onSeedStarters={() => {
|
|
105
|
+
void seedStarters()
|
|
106
|
+
}}
|
|
107
|
+
isSeeding={seeding}
|
|
108
|
+
/>
|
|
109
|
+
) : (
|
|
110
|
+
<ul className="space-y-3">
|
|
111
|
+
{purposes.records.map((purpose) => (
|
|
112
|
+
<li key={purpose.id}>
|
|
113
|
+
<PurposeRow purpose={purpose} />
|
|
114
|
+
</li>
|
|
115
|
+
))}
|
|
116
|
+
</ul>
|
|
117
|
+
)}
|
|
118
|
+
</>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface EmptyStateProps {
|
|
123
|
+
readonly onAdd: () => void
|
|
124
|
+
readonly onSeedStarters: () => void
|
|
125
|
+
readonly isSeeding: boolean
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function EmptyState({ onAdd, onSeedStarters, isSeeding }: EmptyStateProps): React.JSX.Element {
|
|
129
|
+
return (
|
|
130
|
+
<div className="rounded-md border border-dashed border-border p-6 text-[13px]">
|
|
131
|
+
<p className="font-medium text-ink">You have no consent purposes yet.</p>
|
|
132
|
+
<p className="mt-1 text-ink-muted">
|
|
133
|
+
A purpose is what a person is agreeing to — being contacted at all, marketing, research.
|
|
134
|
+
Every workspace usually has at least a general <span className="font-medium">Contact</span>{' '}
|
|
135
|
+
purpose. Forms, lists, and imports that don’t need consent don’t need one.
|
|
136
|
+
</p>
|
|
137
|
+
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
onClick={onSeedStarters}
|
|
141
|
+
disabled={isSeeding}
|
|
142
|
+
className="rounded-md bg-accent px-3 py-1.5 text-[12px] font-medium text-white hover:bg-accent-hover disabled:opacity-50"
|
|
143
|
+
>
|
|
144
|
+
{isSeeding ? 'Adding…' : 'Add Contact & Marketing'}
|
|
145
|
+
</button>
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
onClick={onAdd}
|
|
149
|
+
className="rounded-md border border-border px-3 py-1.5 text-[12px] font-medium text-ink hover:border-ink-muted"
|
|
150
|
+
>
|
|
151
|
+
Add my own
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface AddPurposeFormProps {
|
|
159
|
+
readonly onDone: () => void
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function AddPurposeForm({ onDone }: AddPurposeFormProps): React.JSX.Element {
|
|
163
|
+
const create = useCreateConsentPurpose()
|
|
164
|
+
const [label, setLabel] = useState('')
|
|
165
|
+
const [slug, setSlug] = useState('')
|
|
166
|
+
const [slugEdited, setSlugEdited] = useState(false)
|
|
167
|
+
const [description, setDescription] = useState('')
|
|
168
|
+
const [defaultStatus, setDefaultStatus] = useState<ConsentPurposeStatus>('unknown')
|
|
169
|
+
|
|
170
|
+
const derivedSlug = useMemo(
|
|
171
|
+
() => (slugEdited ? slug : slugify(label)),
|
|
172
|
+
[slugEdited, slug, label],
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
function submit(event: FormEvent): void {
|
|
176
|
+
event.preventDefault()
|
|
177
|
+
const trimmedLabel = label.trim()
|
|
178
|
+
if (trimmedLabel.length === 0) {
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
create
|
|
182
|
+
.runAsync({
|
|
183
|
+
slug: derivedSlug,
|
|
184
|
+
label: trimmedLabel,
|
|
185
|
+
description: description.trim(),
|
|
186
|
+
defaultStatus,
|
|
187
|
+
})
|
|
188
|
+
.then(() => {
|
|
189
|
+
setLabel('')
|
|
190
|
+
setSlug('')
|
|
191
|
+
setSlugEdited(false)
|
|
192
|
+
setDescription('')
|
|
193
|
+
setDefaultStatus('unknown')
|
|
194
|
+
onDone()
|
|
195
|
+
})
|
|
196
|
+
.catch(() => {
|
|
197
|
+
// Error rendered below.
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<form
|
|
203
|
+
onSubmit={submit}
|
|
204
|
+
className="space-y-3 rounded-md border border-border bg-surface-raised p-4"
|
|
205
|
+
>
|
|
206
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
207
|
+
<Field label="Label">
|
|
208
|
+
<input
|
|
209
|
+
className="w-full rounded-md border border-border bg-transparent px-2 py-1 text-[13px] outline-none focus:border-accent"
|
|
210
|
+
value={label}
|
|
211
|
+
onChange={(event) => {
|
|
212
|
+
setLabel(event.target.value)
|
|
213
|
+
}}
|
|
214
|
+
required
|
|
215
|
+
/>
|
|
216
|
+
</Field>
|
|
217
|
+
<Field label="Slug">
|
|
218
|
+
<input
|
|
219
|
+
className="w-full rounded-md border border-border bg-transparent px-2 py-1 font-mono text-[12px] outline-none focus:border-accent"
|
|
220
|
+
value={derivedSlug}
|
|
221
|
+
onChange={(event) => {
|
|
222
|
+
setSlug(event.target.value)
|
|
223
|
+
setSlugEdited(true)
|
|
224
|
+
}}
|
|
225
|
+
placeholder="lowercase_snake_case"
|
|
226
|
+
pattern="^[a-z][a-z0-9_]*$"
|
|
227
|
+
required
|
|
228
|
+
/>
|
|
229
|
+
</Field>
|
|
230
|
+
<Field label="Default status">
|
|
231
|
+
<select
|
|
232
|
+
className="w-full rounded-md border border-border bg-transparent px-2 py-1 text-[13px] outline-none focus:border-accent"
|
|
233
|
+
value={defaultStatus}
|
|
234
|
+
onChange={(event) => {
|
|
235
|
+
setDefaultStatus(event.target.value as ConsentPurposeStatus)
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
{CONSENT_PURPOSE_STATUSES.map((option) => (
|
|
239
|
+
<option key={option} value={option}>
|
|
240
|
+
{CONSENT_PURPOSE_STATUS_LABELS[option]}
|
|
241
|
+
</option>
|
|
242
|
+
))}
|
|
243
|
+
</select>
|
|
244
|
+
</Field>
|
|
245
|
+
</div>
|
|
246
|
+
<Field label="Description (shown on capture surfaces and to agents)">
|
|
247
|
+
<textarea
|
|
248
|
+
className="min-h-[60px] w-full rounded-md border border-border bg-transparent px-2 py-1 text-[13px] outline-none focus:border-accent"
|
|
249
|
+
value={description}
|
|
250
|
+
onChange={(event) => {
|
|
251
|
+
setDescription(event.target.value)
|
|
252
|
+
}}
|
|
253
|
+
/>
|
|
254
|
+
</Field>
|
|
255
|
+
|
|
256
|
+
{create.error !== null && <ErrorPanel error={create.error} />}
|
|
257
|
+
|
|
258
|
+
<div className="flex justify-end gap-2">
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
onClick={() => {
|
|
262
|
+
onDone()
|
|
263
|
+
}}
|
|
264
|
+
className="rounded-md border border-border px-3 py-1 text-[12px] text-ink-muted hover:text-ink"
|
|
265
|
+
>
|
|
266
|
+
Cancel
|
|
267
|
+
</button>
|
|
268
|
+
<button
|
|
269
|
+
type="submit"
|
|
270
|
+
disabled={create.isPending}
|
|
271
|
+
className="rounded-md bg-accent px-3 py-1 text-[12px] font-medium text-white hover:bg-accent-hover disabled:opacity-50"
|
|
272
|
+
>
|
|
273
|
+
{create.isPending ? 'Adding…' : 'Add purpose'}
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
276
|
+
</form>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
interface PurposeRowProps {
|
|
281
|
+
readonly purpose: ConsentPurpose
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function PurposeRow({ purpose }: PurposeRowProps): React.JSX.Element {
|
|
285
|
+
const update = useUpdateConsentPurpose()
|
|
286
|
+
const remove = useDeleteConsentPurpose()
|
|
287
|
+
const [editing, setEditing] = useState(false)
|
|
288
|
+
const [label, setLabel] = useState(purpose.label)
|
|
289
|
+
const [description, setDescription] = useState(purpose.description)
|
|
290
|
+
const [defaultStatus, setDefaultStatus] = useState<ConsentPurposeStatus>(purpose.defaultStatus)
|
|
291
|
+
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
|
292
|
+
|
|
293
|
+
function save(event: FormEvent): void {
|
|
294
|
+
event.preventDefault()
|
|
295
|
+
update
|
|
296
|
+
.runAsync({
|
|
297
|
+
id: purpose.id,
|
|
298
|
+
changes: {
|
|
299
|
+
label: label.trim(),
|
|
300
|
+
description: description.trim(),
|
|
301
|
+
defaultStatus,
|
|
302
|
+
},
|
|
303
|
+
})
|
|
304
|
+
.then(() => {
|
|
305
|
+
setEditing(false)
|
|
306
|
+
})
|
|
307
|
+
.catch(() => {
|
|
308
|
+
// Error rendered inline.
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<div className="rounded-md border border-border p-3">
|
|
314
|
+
<div className="flex items-start justify-between gap-3">
|
|
315
|
+
<div className="min-w-0 flex-1">
|
|
316
|
+
<div className="flex flex-wrap items-baseline gap-2">
|
|
317
|
+
<div className="text-[14px] font-semibold text-ink">{purpose.label}</div>
|
|
318
|
+
<code className="rounded bg-surface-raised px-1.5 py-0.5 font-mono text-[11px] text-ink-muted">
|
|
319
|
+
{purpose.slug}
|
|
320
|
+
</code>
|
|
321
|
+
<span className="text-[11px] text-ink-muted">
|
|
322
|
+
Default: {CONSENT_PURPOSE_STATUS_LABELS[purpose.defaultStatus]}
|
|
323
|
+
</span>
|
|
324
|
+
</div>
|
|
325
|
+
{purpose.description.length > 0 && (
|
|
326
|
+
<p className="mt-1 text-[12px] text-ink-muted">{purpose.description}</p>
|
|
327
|
+
)}
|
|
328
|
+
</div>
|
|
329
|
+
<div className="flex shrink-0 gap-2">
|
|
330
|
+
<button
|
|
331
|
+
type="button"
|
|
332
|
+
onClick={() => {
|
|
333
|
+
setEditing((current) => !current)
|
|
334
|
+
}}
|
|
335
|
+
className="text-[12px] text-ink-muted hover:text-ink"
|
|
336
|
+
>
|
|
337
|
+
{editing ? 'Cancel' : 'Edit'}
|
|
338
|
+
</button>
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
onClick={() => {
|
|
342
|
+
setConfirmingDelete(true)
|
|
343
|
+
}}
|
|
344
|
+
className="text-[12px] text-red-600 hover:text-red-700"
|
|
345
|
+
>
|
|
346
|
+
Delete
|
|
347
|
+
</button>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
{editing && (
|
|
352
|
+
<form onSubmit={save} className="mt-3 space-y-2">
|
|
353
|
+
<Field label="Label">
|
|
354
|
+
<input
|
|
355
|
+
className="w-full rounded-md border border-border bg-transparent px-2 py-1 text-[13px] outline-none focus:border-accent"
|
|
356
|
+
value={label}
|
|
357
|
+
onChange={(event) => {
|
|
358
|
+
setLabel(event.target.value)
|
|
359
|
+
}}
|
|
360
|
+
required
|
|
361
|
+
/>
|
|
362
|
+
</Field>
|
|
363
|
+
<Field label="Default status">
|
|
364
|
+
<select
|
|
365
|
+
className="w-full rounded-md border border-border bg-transparent px-2 py-1 text-[13px] outline-none focus:border-accent"
|
|
366
|
+
value={defaultStatus}
|
|
367
|
+
onChange={(event) => {
|
|
368
|
+
setDefaultStatus(event.target.value as ConsentPurposeStatus)
|
|
369
|
+
}}
|
|
370
|
+
>
|
|
371
|
+
{CONSENT_PURPOSE_STATUSES.map((option) => (
|
|
372
|
+
<option key={option} value={option}>
|
|
373
|
+
{CONSENT_PURPOSE_STATUS_LABELS[option]}
|
|
374
|
+
</option>
|
|
375
|
+
))}
|
|
376
|
+
</select>
|
|
377
|
+
</Field>
|
|
378
|
+
<Field label="Description">
|
|
379
|
+
<textarea
|
|
380
|
+
className="min-h-[60px] w-full rounded-md border border-border bg-transparent px-2 py-1 text-[13px] outline-none focus:border-accent"
|
|
381
|
+
value={description}
|
|
382
|
+
onChange={(event) => {
|
|
383
|
+
setDescription(event.target.value)
|
|
384
|
+
}}
|
|
385
|
+
/>
|
|
386
|
+
</Field>
|
|
387
|
+
{update.error !== null && <ErrorPanel error={update.error} />}
|
|
388
|
+
<div className="flex justify-end">
|
|
389
|
+
<button
|
|
390
|
+
type="submit"
|
|
391
|
+
disabled={update.isPending}
|
|
392
|
+
className="rounded-md bg-accent px-3 py-1 text-[12px] font-medium text-white hover:bg-accent-hover disabled:opacity-50"
|
|
393
|
+
>
|
|
394
|
+
{update.isPending ? 'Saving…' : 'Save'}
|
|
395
|
+
</button>
|
|
396
|
+
</div>
|
|
397
|
+
</form>
|
|
398
|
+
)}
|
|
399
|
+
|
|
400
|
+
{confirmingDelete && (
|
|
401
|
+
<div className="mt-3 rounded-md border border-red-300 bg-red-50 p-3 text-[13px] text-red-800 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200">
|
|
402
|
+
<p>
|
|
403
|
+
Delete the {purpose.label} purpose? Remove it from every form field and list that
|
|
404
|
+
names it first — the delete refuses otherwise.
|
|
405
|
+
</p>
|
|
406
|
+
{remove.error !== null && <ErrorPanel error={remove.error} />}
|
|
407
|
+
<div className="mt-2 flex justify-end gap-2">
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
onClick={() => {
|
|
411
|
+
setConfirmingDelete(false)
|
|
412
|
+
}}
|
|
413
|
+
className="rounded-md border border-red-300 px-3 py-1 text-[12px] hover:bg-red-100 dark:border-red-800 dark:hover:bg-red-900/40"
|
|
414
|
+
>
|
|
415
|
+
Cancel
|
|
416
|
+
</button>
|
|
417
|
+
<button
|
|
418
|
+
type="button"
|
|
419
|
+
onClick={() => {
|
|
420
|
+
remove
|
|
421
|
+
.runAsync(purpose.id)
|
|
422
|
+
.then(() => {
|
|
423
|
+
setConfirmingDelete(false)
|
|
424
|
+
})
|
|
425
|
+
.catch(() => {
|
|
426
|
+
// Error rendered inline.
|
|
427
|
+
})
|
|
428
|
+
}}
|
|
429
|
+
disabled={remove.isPending}
|
|
430
|
+
className="rounded-md bg-red-600 px-3 py-1 text-[12px] font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
|
431
|
+
>
|
|
432
|
+
{remove.isPending ? 'Deleting…' : 'Delete'}
|
|
433
|
+
</button>
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
)}
|
|
437
|
+
</div>
|
|
438
|
+
)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
interface FieldProps {
|
|
442
|
+
readonly label: string
|
|
443
|
+
readonly children: React.ReactNode
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function Field({ label, children }: FieldProps): React.JSX.Element {
|
|
447
|
+
return (
|
|
448
|
+
<label className="block text-[12px]">
|
|
449
|
+
<span className="mb-1 block font-medium text-ink-muted">{label}</span>
|
|
450
|
+
{children}
|
|
451
|
+
</label>
|
|
452
|
+
)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function slugify(label: string): string {
|
|
456
|
+
return label
|
|
457
|
+
.toLowerCase()
|
|
458
|
+
.replace(/[^a-z0-9]+/gu, '_')
|
|
459
|
+
.replace(/^_+|_+$/gu, '')
|
|
460
|
+
.replace(/^([0-9])/u, '_$1')
|
|
461
|
+
}
|
|
@@ -28,6 +28,7 @@ const ROUTES: Readonly<Record<RecordTargetType, string | undefined>> = {
|
|
|
28
28
|
opportunity: '/opportunities',
|
|
29
29
|
partnership: '/partnerships',
|
|
30
30
|
raise: '/fundraising',
|
|
31
|
+
enquiry: '/enquiries',
|
|
31
32
|
candidate: undefined,
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -38,6 +39,7 @@ const TARGET_TYPE_LABELS: Readonly<Record<RecordTargetType, string>> = {
|
|
|
38
39
|
opportunity: 'Opportunity',
|
|
39
40
|
partnership: 'Partnership',
|
|
40
41
|
raise: 'Fundraising',
|
|
42
|
+
enquiry: 'Enquiry',
|
|
41
43
|
candidate: 'Candidate',
|
|
42
44
|
}
|
|
43
45
|
|
|
@@ -147,6 +149,7 @@ function plural(count: number, singular: string, pluralForm = `${singular}s`): s
|
|
|
147
149
|
const OPEN_PIPELINE_NOUNS: Readonly<
|
|
148
150
|
Record<PipelineKind, { readonly one: string; readonly many: string }>
|
|
149
151
|
> = {
|
|
152
|
+
enquiry: { one: 'enquiry', many: 'enquiries' },
|
|
150
153
|
deal: { one: 'deal', many: 'deals' },
|
|
151
154
|
opportunity: { one: 'opportunity', many: 'opportunities' },
|
|
152
155
|
raise: { one: 'raise', many: 'raises' },
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { FORM_FIELD_MAP_TARGET_LABELS } from '@kelpie/schemas'
|
|
2
|
+
import type { FormFieldInput } from '@kelpie/schemas'
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { unusedCrmPresets } from './fieldList.ts'
|
|
6
|
+
import type { EditableField } from './fieldList.ts'
|
|
7
|
+
import { SUBMISSION_FIELD_PRESETS } from './template.ts'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The "Add field" button and its menu of ready-made fields.
|
|
11
|
+
*
|
|
12
|
+
* Two groups. CRM presets carry a mapping and appear only while that mapping is
|
|
13
|
+
* free, because a form may carry each CRM target once. Submission-only fields
|
|
14
|
+
* always appear and may repeat. The menu owns nothing but its own open state:
|
|
15
|
+
* the field list lives with the builder, which is what `onAdd` hands the chosen
|
|
16
|
+
* preset back to.
|
|
17
|
+
*/
|
|
18
|
+
export interface AddFieldMenuProps {
|
|
19
|
+
readonly fields: readonly EditableField[]
|
|
20
|
+
readonly onAdd: (field: FormFieldInput) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function AddFieldMenu({ fields, onAdd }: AddFieldMenuProps): React.JSX.Element {
|
|
24
|
+
const [open, setOpen] = useState(false)
|
|
25
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
26
|
+
const crmPresets = unusedCrmPresets(fields)
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!open) {
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function onPointerDown(event: MouseEvent): void {
|
|
34
|
+
if (containerRef.current?.contains(event.target as Node) !== true) {
|
|
35
|
+
setOpen(false)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function onKeyDown(event: KeyboardEvent): void {
|
|
40
|
+
if (event.key === 'Escape') {
|
|
41
|
+
setOpen(false)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
document.addEventListener('mousedown', onPointerDown)
|
|
46
|
+
document.addEventListener('keydown', onKeyDown)
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
document.removeEventListener('mousedown', onPointerDown)
|
|
50
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
51
|
+
}
|
|
52
|
+
}, [open])
|
|
53
|
+
|
|
54
|
+
function add(field: FormFieldInput): void {
|
|
55
|
+
onAdd(field)
|
|
56
|
+
setOpen(false)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="relative" ref={containerRef}>
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
onClick={() => {
|
|
64
|
+
setOpen((current) => !current)
|
|
65
|
+
}}
|
|
66
|
+
aria-expanded={open}
|
|
67
|
+
aria-haspopup="menu"
|
|
68
|
+
className="rounded-md bg-accent px-3 py-1.5 text-[12px] font-semibold text-accent-fg transition hover:bg-accent-hover"
|
|
69
|
+
>
|
|
70
|
+
+ Add field
|
|
71
|
+
</button>
|
|
72
|
+
{open && (
|
|
73
|
+
<div
|
|
74
|
+
role="menu"
|
|
75
|
+
aria-label="Field to add"
|
|
76
|
+
className="absolute right-0 z-20 mt-1.5 max-h-80 w-64 overflow-y-auto rounded-md border border-border bg-surface-raised py-1"
|
|
77
|
+
>
|
|
78
|
+
{crmPresets.length > 0 && (
|
|
79
|
+
<>
|
|
80
|
+
<p className="px-3 pb-1 pt-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-faint">
|
|
81
|
+
Writes to the CRM
|
|
82
|
+
</p>
|
|
83
|
+
{crmPresets.map((preset) => (
|
|
84
|
+
<button
|
|
85
|
+
key={preset.mapTo}
|
|
86
|
+
type="button"
|
|
87
|
+
role="menuitem"
|
|
88
|
+
onClick={() => {
|
|
89
|
+
add(preset)
|
|
90
|
+
}}
|
|
91
|
+
className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-[13px] text-ink transition hover:bg-surface-sunken"
|
|
92
|
+
>
|
|
93
|
+
<span>{preset.label}</span>
|
|
94
|
+
<span className="shrink-0 text-[11px] text-ink-faint">
|
|
95
|
+
{FORM_FIELD_MAP_TARGET_LABELS[preset.mapTo]}
|
|
96
|
+
</span>
|
|
97
|
+
</button>
|
|
98
|
+
))}
|
|
99
|
+
</>
|
|
100
|
+
)}
|
|
101
|
+
<p className="px-3 pb-1 pt-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-faint">
|
|
102
|
+
Submission only
|
|
103
|
+
</p>
|
|
104
|
+
{SUBMISSION_FIELD_PRESETS.map((preset) => (
|
|
105
|
+
<button
|
|
106
|
+
key={preset.menuLabel}
|
|
107
|
+
type="button"
|
|
108
|
+
role="menuitem"
|
|
109
|
+
onClick={() => {
|
|
110
|
+
add(preset.field)
|
|
111
|
+
}}
|
|
112
|
+
className="w-full px-3 py-1.5 text-left text-[13px] text-ink transition hover:bg-surface-sunken"
|
|
113
|
+
>
|
|
114
|
+
{preset.menuLabel}
|
|
115
|
+
</button>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
)
|
|
121
|
+
}
|