@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.
Files changed (200) hide show
  1. package/dist/api/resource.d.ts +1 -1
  2. package/dist/api/resource.d.ts.map +1 -1
  3. package/dist/api/resource.js.map +1 -1
  4. package/dist/api/resources/consentPurposes.d.ts +13 -0
  5. package/dist/api/resources/consentPurposes.d.ts.map +1 -0
  6. package/dist/api/resources/consentPurposes.js +39 -0
  7. package/dist/api/resources/consentPurposes.js.map +1 -0
  8. package/dist/api/resources/customFields.d.ts +15 -0
  9. package/dist/api/resources/customFields.d.ts.map +1 -0
  10. package/dist/api/resources/customFields.js +41 -0
  11. package/dist/api/resources/customFields.js.map +1 -0
  12. package/dist/api/resources/enquiries.d.ts +39 -0
  13. package/dist/api/resources/enquiries.d.ts.map +1 -0
  14. package/dist/api/resources/enquiries.js +68 -0
  15. package/dist/api/resources/enquiries.js.map +1 -0
  16. package/dist/api/resources/forms.d.ts +21 -1
  17. package/dist/api/resources/forms.d.ts.map +1 -1
  18. package/dist/api/resources/forms.js +41 -0
  19. package/dist/api/resources/forms.js.map +1 -1
  20. package/dist/api/resources/importJobs.d.ts +5 -0
  21. package/dist/api/resources/importJobs.d.ts.map +1 -1
  22. package/dist/api/resources/importJobs.js +3 -0
  23. package/dist/api/resources/importJobs.js.map +1 -1
  24. package/dist/api/resources/opportunities.d.ts +2 -0
  25. package/dist/api/resources/opportunities.d.ts.map +1 -1
  26. package/dist/api/resources/opportunities.js +1 -0
  27. package/dist/api/resources/opportunities.js.map +1 -1
  28. package/dist/api/resources/sampleData.d.ts.map +1 -1
  29. package/dist/api/resources/sampleData.js +1 -0
  30. package/dist/api/resources/sampleData.js.map +1 -1
  31. package/dist/app/KelpieApp.d.ts.map +1 -1
  32. package/dist/app/KelpieApp.js +7 -1
  33. package/dist/app/KelpieApp.js.map +1 -1
  34. package/dist/components/ActivitiesPanel.d.ts.map +1 -1
  35. package/dist/components/ActivitiesPanel.js +1 -0
  36. package/dist/components/ActivitiesPanel.js.map +1 -1
  37. package/dist/components/CustomFieldsPanel.d.ts +23 -0
  38. package/dist/components/CustomFieldsPanel.d.ts.map +1 -0
  39. package/dist/components/CustomFieldsPanel.js +116 -0
  40. package/dist/components/CustomFieldsPanel.js.map +1 -0
  41. package/dist/components/FormsPanel.d.ts +18 -0
  42. package/dist/components/FormsPanel.d.ts.map +1 -0
  43. package/dist/components/FormsPanel.js +37 -0
  44. package/dist/components/FormsPanel.js.map +1 -0
  45. package/dist/components/PlanAttention.d.ts +3 -2
  46. package/dist/components/PlanAttention.d.ts.map +1 -1
  47. package/dist/components/PlanAttention.js +7 -3
  48. package/dist/components/PlanAttention.js.map +1 -1
  49. package/dist/components/Shell.d.ts.map +1 -1
  50. package/dist/components/Shell.js +3 -0
  51. package/dist/components/Shell.js.map +1 -1
  52. package/dist/components/useHasCustomFields.d.ts +13 -0
  53. package/dist/components/useHasCustomFields.d.ts.map +1 -0
  54. package/dist/components/useHasCustomFields.js +16 -0
  55. package/dist/components/useHasCustomFields.js.map +1 -0
  56. package/dist/pages/CompanyDetail.d.ts.map +1 -1
  57. package/dist/pages/CompanyDetail.js +24 -2
  58. package/dist/pages/CompanyDetail.js.map +1 -1
  59. package/dist/pages/DealDetail.d.ts.map +1 -1
  60. package/dist/pages/DealDetail.js +18 -1
  61. package/dist/pages/DealDetail.js.map +1 -1
  62. package/dist/pages/DecisionsPage.d.ts.map +1 -1
  63. package/dist/pages/DecisionsPage.js +2 -0
  64. package/dist/pages/DecisionsPage.js.map +1 -1
  65. package/dist/pages/EnquiriesPage.d.ts +2 -0
  66. package/dist/pages/EnquiriesPage.d.ts.map +1 -0
  67. package/dist/pages/EnquiriesPage.js +251 -0
  68. package/dist/pages/EnquiriesPage.js.map +1 -0
  69. package/dist/pages/EnquiryDetail.d.ts +2 -0
  70. package/dist/pages/EnquiryDetail.d.ts.map +1 -0
  71. package/dist/pages/EnquiryDetail.js +207 -0
  72. package/dist/pages/EnquiryDetail.js.map +1 -0
  73. package/dist/pages/EnquiryStageSettingsPage.d.ts +3 -0
  74. package/dist/pages/EnquiryStageSettingsPage.d.ts.map +1 -0
  75. package/dist/pages/EnquiryStageSettingsPage.js +34 -0
  76. package/dist/pages/EnquiryStageSettingsPage.js.map +1 -0
  77. package/dist/pages/FormDetail.d.ts.map +1 -1
  78. package/dist/pages/FormDetail.js +19 -1
  79. package/dist/pages/FormDetail.js.map +1 -1
  80. package/dist/pages/ListDetail.d.ts.map +1 -1
  81. package/dist/pages/ListDetail.js +27 -1
  82. package/dist/pages/ListDetail.js.map +1 -1
  83. package/dist/pages/OpportunityDetail.d.ts.map +1 -1
  84. package/dist/pages/OpportunityDetail.js +62 -3
  85. package/dist/pages/OpportunityDetail.js.map +1 -1
  86. package/dist/pages/PartnershipDetail.d.ts.map +1 -1
  87. package/dist/pages/PartnershipDetail.js +17 -1
  88. package/dist/pages/PartnershipDetail.js.map +1 -1
  89. package/dist/pages/PeoplePage.d.ts.map +1 -1
  90. package/dist/pages/PeoplePage.js +17 -0
  91. package/dist/pages/PeoplePage.js.map +1 -1
  92. package/dist/pages/PersonDetail.d.ts.map +1 -1
  93. package/dist/pages/PersonDetail.js +63 -3
  94. package/dist/pages/PersonDetail.js.map +1 -1
  95. package/dist/pages/PlanningPage.d.ts.map +1 -1
  96. package/dist/pages/PlanningPage.js +3 -0
  97. package/dist/pages/PlanningPage.js.map +1 -1
  98. package/dist/pages/RaiseDetail.d.ts.map +1 -1
  99. package/dist/pages/RaiseDetail.js +11 -1
  100. package/dist/pages/RaiseDetail.js.map +1 -1
  101. package/dist/pages/RoleDetail.js +1 -1
  102. package/dist/pages/RoleDetail.js.map +1 -1
  103. package/dist/pages/SearchPage.d.ts.map +1 -1
  104. package/dist/pages/SearchPage.js +1 -0
  105. package/dist/pages/SearchPage.js.map +1 -1
  106. package/dist/pages/admin/DataPage.d.ts.map +1 -1
  107. package/dist/pages/admin/DataPage.js +18 -3
  108. package/dist/pages/admin/DataPage.js.map +1 -1
  109. package/dist/pages/admin/FieldsPage.d.ts +2 -0
  110. package/dist/pages/admin/FieldsPage.d.ts.map +1 -0
  111. package/dist/pages/admin/FieldsPage.js +164 -0
  112. package/dist/pages/admin/FieldsPage.js.map +1 -0
  113. package/dist/pages/admin/PrivacyPage.d.ts +9 -0
  114. package/dist/pages/admin/PrivacyPage.d.ts.map +1 -0
  115. package/dist/pages/admin/PrivacyPage.js +170 -0
  116. package/dist/pages/admin/PrivacyPage.js.map +1 -0
  117. package/dist/pages/dashboard/attention.d.ts.map +1 -1
  118. package/dist/pages/dashboard/attention.js +3 -0
  119. package/dist/pages/dashboard/attention.js.map +1 -1
  120. package/dist/pages/forms/AddFieldMenu.d.ts +17 -0
  121. package/dist/pages/forms/AddFieldMenu.d.ts.map +1 -0
  122. package/dist/pages/forms/AddFieldMenu.js +43 -0
  123. package/dist/pages/forms/AddFieldMenu.js.map +1 -0
  124. package/dist/pages/forms/EmbedPanel.d.ts +4 -0
  125. package/dist/pages/forms/EmbedPanel.d.ts.map +1 -1
  126. package/dist/pages/forms/EmbedPanel.js +62 -3
  127. package/dist/pages/forms/EmbedPanel.js.map +1 -1
  128. package/dist/pages/forms/FieldsEditor.d.ts.map +1 -1
  129. package/dist/pages/forms/FieldsEditor.js +179 -36
  130. package/dist/pages/forms/FieldsEditor.js.map +1 -1
  131. package/dist/pages/forms/FormActions.d.ts +6 -0
  132. package/dist/pages/forms/FormActions.d.ts.map +1 -0
  133. package/dist/pages/forms/FormActions.js +149 -0
  134. package/dist/pages/forms/FormActions.js.map +1 -0
  135. package/dist/pages/forms/FormSettings.d.ts.map +1 -1
  136. package/dist/pages/forms/FormSettings.js +15 -19
  137. package/dist/pages/forms/FormSettings.js.map +1 -1
  138. package/dist/pages/forms/SubmissionDetail.d.ts +2 -0
  139. package/dist/pages/forms/SubmissionDetail.d.ts.map +1 -0
  140. package/dist/pages/forms/SubmissionDetail.js +76 -0
  141. package/dist/pages/forms/SubmissionDetail.js.map +1 -0
  142. package/dist/pages/forms/SubmissionsTable.d.ts.map +1 -1
  143. package/dist/pages/forms/SubmissionsTable.js +56 -8
  144. package/dist/pages/forms/SubmissionsTable.js.map +1 -1
  145. package/dist/pages/forms/fieldList.d.ts +11 -5
  146. package/dist/pages/forms/fieldList.d.ts.map +1 -1
  147. package/dist/pages/forms/fieldList.js +66 -6
  148. package/dist/pages/forms/fieldList.js.map +1 -1
  149. package/dist/pages/forms/template.d.ts +17 -0
  150. package/dist/pages/forms/template.d.ts.map +1 -1
  151. package/dist/pages/forms/template.js +93 -0
  152. package/dist/pages/forms/template.js.map +1 -1
  153. package/dist/pages/onboarding/WorkspaceStepPage.js +1 -1
  154. package/dist/pages/onboarding/WorkspaceStepPage.js.map +1 -1
  155. package/package.json +2 -2
  156. package/src/api/resource.ts +6 -1
  157. package/src/api/resources/consentPurposes.ts +83 -0
  158. package/src/api/resources/customFields.ts +88 -0
  159. package/src/api/resources/enquiries.ts +118 -0
  160. package/src/api/resources/forms.ts +58 -0
  161. package/src/api/resources/importJobs.ts +9 -0
  162. package/src/api/resources/opportunities.ts +3 -0
  163. package/src/api/resources/sampleData.ts +1 -0
  164. package/src/app/KelpieApp.tsx +12 -0
  165. package/src/components/ActivitiesPanel.tsx +1 -0
  166. package/src/components/CustomFieldsPanel.tsx +330 -0
  167. package/src/components/FormsPanel.tsx +86 -0
  168. package/src/components/PlanAttention.tsx +20 -3
  169. package/src/components/Shell.tsx +3 -0
  170. package/src/components/useHasCustomFields.ts +18 -0
  171. package/src/pages/CompanyDetail.tsx +36 -1
  172. package/src/pages/DealDetail.tsx +31 -0
  173. package/src/pages/DecisionsPage.tsx +2 -0
  174. package/src/pages/EnquiriesPage.tsx +434 -0
  175. package/src/pages/EnquiryDetail.tsx +554 -0
  176. package/src/pages/EnquiryStageSettingsPage.tsx +63 -0
  177. package/src/pages/FormDetail.tsx +23 -1
  178. package/src/pages/ListDetail.tsx +62 -0
  179. package/src/pages/OpportunityDetail.tsx +165 -2
  180. package/src/pages/PartnershipDetail.tsx +32 -0
  181. package/src/pages/PeoplePage.tsx +20 -0
  182. package/src/pages/PersonDetail.tsx +152 -3
  183. package/src/pages/PlanningPage.tsx +3 -0
  184. package/src/pages/RaiseDetail.tsx +21 -0
  185. package/src/pages/RoleDetail.tsx +1 -1
  186. package/src/pages/SearchPage.tsx +1 -0
  187. package/src/pages/admin/DataPage.tsx +51 -3
  188. package/src/pages/admin/FieldsPage.tsx +455 -0
  189. package/src/pages/admin/PrivacyPage.tsx +461 -0
  190. package/src/pages/dashboard/attention.ts +3 -0
  191. package/src/pages/forms/AddFieldMenu.tsx +121 -0
  192. package/src/pages/forms/EmbedPanel.tsx +163 -2
  193. package/src/pages/forms/FieldsEditor.tsx +539 -165
  194. package/src/pages/forms/FormActions.tsx +552 -0
  195. package/src/pages/forms/FormSettings.tsx +29 -70
  196. package/src/pages/forms/SubmissionDetail.tsx +249 -0
  197. package/src/pages/forms/SubmissionsTable.tsx +109 -16
  198. package/src/pages/forms/fieldList.ts +68 -5
  199. package/src/pages/forms/template.ts +103 -0
  200. 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&rsquo;t need consent don&rsquo;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
+ }