@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,552 @@
1
+ import { PIPELINE_KINDS } from '@kelpie/schemas'
2
+ import type { Form, FormAttachTarget, PipelineKind } from '@kelpie/schemas'
3
+ import { useMemo, useState } from 'react'
4
+
5
+ import { useDeals } from '../../api/resources/deals.ts'
6
+ import { useEnquiries } from '../../api/resources/enquiries.ts'
7
+ import { useUpdateForm } from '../../api/resources/forms.ts'
8
+ import { useLists } from '../../api/resources/lists.ts'
9
+ import { useMembers } from '../../api/resources/members.ts'
10
+ import { useOpportunities } from '../../api/resources/opportunities.ts'
11
+ import { usePartnerships } from '../../api/resources/partnerships.ts'
12
+ import { usePipelineStages } from '../../api/resources/pipelineStages.ts'
13
+ import { useRaises } from '../../api/resources/raises.ts'
14
+ import { Chip } from '../../components/Chip.tsx'
15
+ import { ErrorPanel } from '../../components/QueryState.tsx'
16
+ import { SectionHeader } from '../../components/SectionHeader.tsx'
17
+ import { toTags } from '../fields.ts'
18
+
19
+ /**
20
+ * What the form does with a submission, once the answers are captured.
21
+ *
22
+ * The three create-triggers sit side by side here. Deal moved off the
23
+ * Settings tab in the forms post-submit actions release: the form's identity
24
+ * (name, status, thank-you copy) is one thing, and what the form *produces*
25
+ * — a deal, an opportunity, a partnership, tagged records, list memberships,
26
+ * pipeline attachments — is another. Actions is where a builder answers
27
+ * "what happens next?".
28
+ *
29
+ * Every control commits on change; the optimistic update in
30
+ * `createResourceHooks` puts the old value back if the PATCH is refused.
31
+ */
32
+
33
+ const inputClass =
34
+ 'w-full rounded-md border border-border bg-surface-raised px-3 py-2 text-[13px] outline-none focus:border-accent focus:ring-2 focus:ring-accent/20'
35
+
36
+ export interface FormActionsProps {
37
+ readonly form: Form
38
+ }
39
+
40
+ export function FormActions({ form }: FormActionsProps): React.JSX.Element {
41
+ const updateForm = useUpdateForm()
42
+ const patch = (changes: Parameters<typeof updateForm.run>[0]['changes']): void => {
43
+ updateForm.run({ id: form.id, changes })
44
+ }
45
+ const hasCompanyField = form.fields.some((field) => field.mapTo.startsWith('company.'))
46
+
47
+ return (
48
+ <div className="max-w-2xl space-y-6">
49
+ <SectionHeader
50
+ title="Actions"
51
+ description="What the form creates, tags, lists, and links every submitter to."
52
+ />
53
+
54
+ {updateForm.error !== null && <ErrorPanel error={updateForm.error} />}
55
+
56
+ <TriggerBlock
57
+ form={form}
58
+ kind="deal"
59
+ toggle={form.createDeal}
60
+ kindValue={null}
61
+ stageId={form.dealStageId}
62
+ nameTemplate={form.dealNameTemplate}
63
+ ownerId={null}
64
+ requiresCompany
65
+ hasCompanyField={hasCompanyField}
66
+ onToggle={(next) => patch({ createDeal: next })}
67
+ onStage={(next) => patch({ dealStageId: next })}
68
+ onTemplate={(next) => patch({ dealNameTemplate: next })}
69
+ />
70
+
71
+ <TriggerBlock
72
+ form={form}
73
+ kind="opportunity"
74
+ toggle={form.createOpportunity}
75
+ kindValue={form.opportunityKind}
76
+ stageId={form.opportunityStageId}
77
+ nameTemplate={form.opportunityNameTemplate}
78
+ ownerId={form.opportunityOwnerId}
79
+ onToggle={(next) => patch({ createOpportunity: next })}
80
+ onKind={(next) => patch({ opportunityKind: next })}
81
+ onStage={(next) => patch({ opportunityStageId: next })}
82
+ onTemplate={(next) => patch({ opportunityNameTemplate: next })}
83
+ onOwner={(next) => patch({ opportunityOwnerId: next })}
84
+ />
85
+
86
+ <TriggerBlock
87
+ form={form}
88
+ kind="partnership"
89
+ toggle={form.createPartnership}
90
+ kindValue={form.partnershipKind}
91
+ stageId={form.partnershipStageId}
92
+ nameTemplate={form.partnershipNameTemplate}
93
+ ownerId={form.partnershipOwnerId}
94
+ requiresCompany
95
+ hasCompanyField={hasCompanyField}
96
+ onToggle={(next) => patch({ createPartnership: next })}
97
+ onKind={(next) => patch({ partnershipKind: next })}
98
+ onStage={(next) => patch({ partnershipStageId: next })}
99
+ onTemplate={(next) => patch({ partnershipNameTemplate: next })}
100
+ onOwner={(next) => patch({ partnershipOwnerId: next })}
101
+ />
102
+
103
+ <TriggerBlock
104
+ form={form}
105
+ kind="enquiry"
106
+ toggle={form.createEnquiry}
107
+ kindValue={null}
108
+ stageId={form.enquiryStageId}
109
+ nameTemplate={form.enquiryNameTemplate}
110
+ ownerId={form.enquiryOwnerId}
111
+ onToggle={(next) => patch({ createEnquiry: next })}
112
+ onStage={(next) => patch({ enquiryStageId: next })}
113
+ onTemplate={(next) => patch({ enquiryNameTemplate: next })}
114
+ onOwner={(next) => patch({ enquiryOwnerId: next })}
115
+ />
116
+
117
+ <TagsBlock
118
+ title="Tag the person"
119
+ hint="Merged into the submitter's tags. Never removes a tag someone set by hand."
120
+ value={form.personTags}
121
+ onChange={(next) => patch({ personTags: next })}
122
+ />
123
+ <TagsBlock
124
+ title="Tag the company"
125
+ hint="Merged into the resolved company's tags. Skipped when no company is resolved."
126
+ value={form.companyTags}
127
+ onChange={(next) => patch({ companyTags: next })}
128
+ />
129
+
130
+ <ListsBlock form={form} onChange={(next) => patch({ listIds: next })} />
131
+
132
+ <AttachTargetsBlock form={form} onChange={(next) => patch({ attachTargets: next })} />
133
+ </div>
134
+ )
135
+ }
136
+
137
+ // -------- Create-trigger block --------
138
+
139
+ interface TriggerBlockProps {
140
+ readonly form: Form
141
+ readonly kind: PipelineKind
142
+ readonly toggle: boolean
143
+ readonly kindValue: string | null
144
+ readonly stageId: string | null
145
+ readonly nameTemplate: string | null
146
+ readonly ownerId: string | null
147
+ readonly requiresCompany?: boolean
148
+ readonly hasCompanyField?: boolean
149
+ readonly onToggle: (next: boolean) => void
150
+ readonly onKind?: (next: string | null) => void
151
+ readonly onStage: (next: string | null) => void
152
+ readonly onTemplate: (next: string | null) => void
153
+ readonly onOwner?: (next: string | null) => void
154
+ }
155
+
156
+ const TRIGGER_LABEL: Readonly<Record<PipelineKind, string>> = {
157
+ enquiry: 'Create an Enquiry',
158
+ deal: 'Create a Deal',
159
+ opportunity: 'Create an Opportunity',
160
+ raise: 'Create a Raise',
161
+ partnership: 'Create a Partnership',
162
+ }
163
+
164
+ function TriggerBlock(props: TriggerBlockProps): React.JSX.Element {
165
+ const stages = usePipelineStages(props.kind)
166
+ const members = useMembers()
167
+ const disabled = props.requiresCompany === true && props.hasCompanyField !== true && !props.toggle
168
+ const showsKind = props.onKind !== undefined
169
+ const showsOwner = props.onOwner !== undefined
170
+
171
+ return (
172
+ <div className="space-y-3 rounded-md border border-border p-4">
173
+ <label className="flex items-center gap-2 text-[13px] font-medium text-ink">
174
+ <input
175
+ type="checkbox"
176
+ checked={props.toggle}
177
+ disabled={disabled}
178
+ onChange={(event) => props.onToggle(event.target.checked)}
179
+ />
180
+ {TRIGGER_LABEL[props.kind]}
181
+ </label>
182
+
183
+ {props.requiresCompany === true && props.hasCompanyField !== true && (
184
+ <p className="text-[12px] text-danger">
185
+ Add a field mapped to Company · name or Company · domain first. A {props.kind} belongs to
186
+ a company, and this form collects neither.
187
+ </p>
188
+ )}
189
+
190
+ {props.toggle && (
191
+ <>
192
+ {showsKind && (
193
+ <Labelled
194
+ label="Kind"
195
+ hint={`Optional. Free text: "Grant", "Accelerator", …`}
196
+ >
197
+ <input
198
+ className={inputClass}
199
+ defaultValue={props.kindValue ?? ''}
200
+ onBlur={(event) => {
201
+ const next = event.target.value.length === 0 ? null : event.target.value
202
+
203
+ if (next !== props.kindValue) {
204
+ props.onKind?.(next)
205
+ }
206
+ }}
207
+ />
208
+ </Labelled>
209
+ )}
210
+
211
+ <Labelled
212
+ label="Stage"
213
+ hint="Leaving this on the first open stage follows the board, so reordering it does not strand old forms on a column that moved."
214
+ >
215
+ <select
216
+ className={inputClass}
217
+ value={props.stageId ?? ''}
218
+ onChange={(event) => {
219
+ props.onStage(event.target.value.length === 0 ? null : event.target.value)
220
+ }}
221
+ >
222
+ <option value="">First open stage</option>
223
+ {stages.records.map((stage) => (
224
+ <option key={stage.id} value={stage.id}>
225
+ {stage.label}
226
+ </option>
227
+ ))}
228
+ </select>
229
+ </Labelled>
230
+
231
+ <Labelled
232
+ label="Name template"
233
+ hint="Tokens: {{company.name}} and {{person.name}}. A field mapped to the record's name wins over this."
234
+ >
235
+ <input
236
+ className={inputClass}
237
+ defaultValue={props.nameTemplate ?? ''}
238
+ onBlur={(event) => {
239
+ const next = event.target.value.length === 0 ? null : event.target.value
240
+
241
+ if (next !== props.nameTemplate) {
242
+ props.onTemplate(next)
243
+ }
244
+ }}
245
+ />
246
+ </Labelled>
247
+
248
+ {showsOwner && (
249
+ <Labelled label="Owner" hint="Empty falls back to the workspace default member.">
250
+ <select
251
+ className={inputClass}
252
+ value={props.ownerId ?? ''}
253
+ onChange={(event) => {
254
+ props.onOwner?.(event.target.value.length === 0 ? null : event.target.value)
255
+ }}
256
+ >
257
+ <option value="">Workspace default</option>
258
+ {members.members.map((member) => (
259
+ <option key={member.id} value={member.id}>
260
+ {member.name}
261
+ </option>
262
+ ))}
263
+ </select>
264
+ </Labelled>
265
+ )}
266
+ </>
267
+ )}
268
+ </div>
269
+ )
270
+ }
271
+
272
+ // -------- Tags --------
273
+
274
+ function TagsBlock({
275
+ title,
276
+ hint,
277
+ value,
278
+ onChange,
279
+ }: {
280
+ readonly title: string
281
+ readonly hint: string
282
+ readonly value: readonly string[]
283
+ readonly onChange: (next: readonly string[]) => void
284
+ }): React.JSX.Element {
285
+ return (
286
+ <div className="space-y-2 rounded-md border border-border p-4">
287
+ <div className="text-[13px] font-medium text-ink">{title}</div>
288
+ <input
289
+ className={inputClass}
290
+ defaultValue={value.join(', ')}
291
+ onBlur={(event) => {
292
+ const next = toTags(event.target.value)
293
+ const changed =
294
+ next.length !== value.length || next.some((tag, index) => tag !== value[index])
295
+
296
+ if (changed) {
297
+ onChange(next)
298
+ }
299
+ }}
300
+ />
301
+ <div className="flex flex-wrap gap-1">
302
+ {value.map((tag) => (
303
+ <Chip key={tag}>{tag}</Chip>
304
+ ))}
305
+ </div>
306
+ <p className="text-[11px] text-ink-faint">{hint}</p>
307
+ </div>
308
+ )
309
+ }
310
+
311
+ // -------- Lists --------
312
+
313
+ function ListsBlock({
314
+ form,
315
+ onChange,
316
+ }: {
317
+ readonly form: Form
318
+ readonly onChange: (next: readonly string[]) => void
319
+ }): React.JSX.Element {
320
+ const personLists = useLists({ targetType: 'person' })
321
+ const companyLists = useLists({ targetType: 'company' })
322
+ const eligible = useMemo(
323
+ () => [...personLists.records, ...companyLists.records],
324
+ [personLists.records, companyLists.records],
325
+ )
326
+ const eligibleById = new Map(eligible.map((list) => [list.id, list]))
327
+ const [pick, setPick] = useState('')
328
+ const chosen = form.listIds
329
+ const remaining = eligible.filter((list) => !chosen.includes(list.id))
330
+
331
+ function add(): void {
332
+ if (pick.length === 0 || chosen.includes(pick)) {
333
+ return
334
+ }
335
+
336
+ onChange([...chosen, pick])
337
+ setPick('')
338
+ }
339
+
340
+ return (
341
+ <div className="space-y-2 rounded-md border border-border p-4">
342
+ <div className="text-[13px] font-medium text-ink">Add to a list</div>
343
+ <p className="text-[11px] text-ink-faint">
344
+ Person lists get the submitter. Company lists get the resolved company, or are skipped when
345
+ no company is resolved.
346
+ </p>
347
+ <ul className="divide-y divide-border">
348
+ {chosen.length === 0 && (
349
+ <li className="py-2 text-[12px] text-ink-faint">No lists yet.</li>
350
+ )}
351
+ {chosen.map((listId) => {
352
+ const list = eligibleById.get(listId)
353
+
354
+ return (
355
+ <li key={listId} className="flex items-center justify-between gap-2 py-2">
356
+ <span className="text-[13px] text-ink">
357
+ {list === undefined ? listId : `${list.name} (${list.targetType})`}
358
+ </span>
359
+ <button
360
+ type="button"
361
+ onClick={() => onChange(chosen.filter((id) => id !== listId))}
362
+ className="text-[11px] font-medium text-danger hover:underline"
363
+ >
364
+ Remove
365
+ </button>
366
+ </li>
367
+ )
368
+ })}
369
+ </ul>
370
+ <div className="flex items-center gap-2">
371
+ <select
372
+ className={`${inputClass} min-w-0 flex-1`}
373
+ value={pick}
374
+ onChange={(event) => setPick(event.target.value)}
375
+ >
376
+ <option value="">Pick a list…</option>
377
+ {remaining.map((list) => (
378
+ <option key={list.id} value={list.id}>
379
+ {list.name} ({list.targetType})
380
+ </option>
381
+ ))}
382
+ </select>
383
+ <button
384
+ type="button"
385
+ onClick={add}
386
+ className="shrink-0 rounded-md bg-accent px-3 py-2 text-[12px] font-semibold text-accent-fg hover:bg-accent-hover disabled:opacity-50"
387
+ disabled={pick.length === 0}
388
+ >
389
+ Add
390
+ </button>
391
+ </div>
392
+ </div>
393
+ )
394
+ }
395
+
396
+ // -------- Attach targets --------
397
+
398
+ interface AttachRecord {
399
+ readonly id: string
400
+ readonly name: string
401
+ }
402
+
403
+ function useAttachOptions(kind: PipelineKind): readonly AttachRecord[] {
404
+ const deals = useDeals({}, { enabled: kind === 'deal' })
405
+ const opportunities = useOpportunities({}, { enabled: kind === 'opportunity' })
406
+ const raises = useRaises({}, { enabled: kind === 'raise' })
407
+ const partnerships = usePartnerships({}, { enabled: kind === 'partnership' })
408
+ const enquiries = useEnquiries({}, { enabled: kind === 'enquiry' })
409
+
410
+ switch (kind) {
411
+ case 'deal':
412
+ return deals.records.map((deal) => ({ id: deal.id, name: deal.name }))
413
+ case 'opportunity':
414
+ return opportunities.records.map((opp) => ({ id: opp.id, name: opp.name }))
415
+ case 'raise':
416
+ return raises.records.map((raise) => ({ id: raise.id, name: raise.name }))
417
+ case 'partnership':
418
+ return partnerships.records.map((partnership) => ({
419
+ id: partnership.id,
420
+ name: partnership.name,
421
+ }))
422
+ case 'enquiry':
423
+ return enquiries.records.map((enquiry) => ({ id: enquiry.id, name: enquiry.name }))
424
+ }
425
+ }
426
+
427
+ function AttachTargetsBlock({
428
+ form,
429
+ onChange,
430
+ }: {
431
+ readonly form: Form
432
+ readonly onChange: (next: readonly FormAttachTarget[]) => void
433
+ }): React.JSX.Element {
434
+ const [kind, setKind] = useState<PipelineKind>('deal')
435
+ const [pick, setPick] = useState('')
436
+ const options = useAttachOptions(kind)
437
+ const chosen = form.attachTargets
438
+ const chosenKey = new Set(chosen.map((target) => `${target.targetType}:${target.targetId}`))
439
+ const remaining = options.filter(
440
+ (record) => !chosenKey.has(`${kind}:${record.id}`),
441
+ )
442
+
443
+ function add(): void {
444
+ if (pick.length === 0) {
445
+ return
446
+ }
447
+
448
+ const key = `${kind}:${pick}`
449
+
450
+ if (chosenKey.has(key)) {
451
+ return
452
+ }
453
+
454
+ onChange([...chosen, { targetType: kind, targetId: pick }])
455
+ setPick('')
456
+ }
457
+
458
+ return (
459
+ <div className="space-y-2 rounded-md border border-border p-4">
460
+ <div className="text-[13px] font-medium text-ink">Attach the submitter to a record</div>
461
+ <p className="text-[11px] text-ink-faint">
462
+ Every submitter is linked into these records through <code>person_links</code>.
463
+ </p>
464
+ <ul className="divide-y divide-border">
465
+ {chosen.length === 0 && (
466
+ <li className="py-2 text-[12px] text-ink-faint">No records yet.</li>
467
+ )}
468
+ {chosen.map((target) => (
469
+ <li
470
+ key={`${target.targetType}:${target.targetId}`}
471
+ className="flex items-center justify-between gap-2 py-2"
472
+ >
473
+ <span className="text-[13px] text-ink">
474
+ {target.targetType} · {target.targetId}
475
+ </span>
476
+ <button
477
+ type="button"
478
+ onClick={() =>
479
+ onChange(
480
+ chosen.filter(
481
+ (candidate) =>
482
+ candidate.targetType !== target.targetType ||
483
+ candidate.targetId !== target.targetId,
484
+ ),
485
+ )
486
+ }
487
+ className="text-[11px] font-medium text-danger hover:underline"
488
+ >
489
+ Remove
490
+ </button>
491
+ </li>
492
+ ))}
493
+ </ul>
494
+ <div className="grid grid-cols-[auto_1fr_auto] items-center gap-2">
495
+ <select
496
+ className={inputClass}
497
+ value={kind}
498
+ onChange={(event) => {
499
+ setKind(event.target.value as PipelineKind)
500
+ setPick('')
501
+ }}
502
+ >
503
+ {PIPELINE_KINDS.map((option) => (
504
+ <option key={option} value={option}>
505
+ {option}
506
+ </option>
507
+ ))}
508
+ </select>
509
+ <select
510
+ className={inputClass}
511
+ value={pick}
512
+ onChange={(event) => setPick(event.target.value)}
513
+ >
514
+ <option value="">Pick a record…</option>
515
+ {remaining.map((record) => (
516
+ <option key={record.id} value={record.id}>
517
+ {record.name}
518
+ </option>
519
+ ))}
520
+ </select>
521
+ <button
522
+ type="button"
523
+ onClick={add}
524
+ className="rounded-md bg-accent px-3 py-2 text-[12px] font-semibold text-accent-fg hover:bg-accent-hover disabled:opacity-50"
525
+ disabled={pick.length === 0}
526
+ >
527
+ Add
528
+ </button>
529
+ </div>
530
+ </div>
531
+ )
532
+ }
533
+
534
+ // -------- Shared bits --------
535
+
536
+ function Labelled({
537
+ label,
538
+ hint,
539
+ children,
540
+ }: {
541
+ readonly label: string
542
+ readonly hint?: string
543
+ readonly children: React.ReactNode
544
+ }): React.JSX.Element {
545
+ return (
546
+ <label className="block">
547
+ <span className="mb-1.5 block text-[12px] font-medium text-ink">{label}</span>
548
+ {children}
549
+ {hint !== undefined && <p className="mt-1 text-[11px] text-ink-faint">{hint}</p>}
550
+ </label>
551
+ )
552
+ }
@@ -1,18 +1,18 @@
1
1
  import { FORM_STATUSES } from '@kelpie/schemas'
2
2
  import type { Form, FormStatus } from '@kelpie/schemas'
3
3
 
4
- import { usePipelineStages } from '../../api/resources/pipelineStages.ts'
5
4
  import { useUpdateForm } from '../../api/resources/forms.ts'
6
5
  import { ErrorPanel } from '../../components/QueryState.tsx'
7
6
  import { SectionHeader } from '../../components/SectionHeader.tsx'
8
7
 
9
8
  /**
10
- * What the form does with a submission.
9
+ * The form's identity: status, public heading, thank-you copy, and the public key.
11
10
  *
12
11
  * Every control commits on change, because each one is a single field and the
13
12
  * optimistic update in `createResourceHooks` puts the old value back if the
14
13
  * PATCH is refused. The field list is the exception and has its own Save; see
15
- * `FieldsEditor`.
14
+ * `FieldsEditor`. What the form *does* on submit — create records, tag them,
15
+ * add to lists, attach the submitter — lives on the Actions tab.
16
16
  */
17
17
 
18
18
  const inputClass =
@@ -24,17 +24,16 @@ export interface FormSettingsProps {
24
24
 
25
25
  export function FormSettings({ form }: FormSettingsProps): React.JSX.Element {
26
26
  const updateForm = useUpdateForm()
27
- const stages = usePipelineStages('deal')
28
27
  const patch = (changes: Parameters<typeof updateForm.run>[0]['changes']): void => {
29
28
  updateForm.run({ id: form.id, changes })
30
29
  }
31
30
 
32
- /** The server refuses `create_deal` without a company mapping, so say so before it does. */
33
- const hasCompanyField = form.fields.some((field) => field.mapTo.startsWith('company.'))
34
-
35
31
  return (
36
32
  <div className="max-w-xl space-y-4">
37
- <SectionHeader title="Settings" description="Status, thank-you copy, and deal creation." />
33
+ <SectionHeader
34
+ title="Settings"
35
+ description="Status, the public form title, thank-you copy, and the public key."
36
+ />
38
37
 
39
38
  {updateForm.error !== null && <ErrorPanel error={updateForm.error} />}
40
39
 
@@ -55,6 +54,24 @@ export function FormSettings({ form }: FormSettingsProps): React.JSX.Element {
55
54
  <Hint>A paused form still renders where it is embedded, and says it is closed.</Hint>
56
55
  </Labelled>
57
56
 
57
+ <Labelled label="Form title">
58
+ <input
59
+ className={inputClass}
60
+ defaultValue={form.title}
61
+ onBlur={(event) => {
62
+ const next = event.target.value.trim()
63
+ if (next.length > 0 && next !== form.title) {
64
+ patch({ title: next })
65
+ } else if (next.length === 0) {
66
+ event.target.value = form.title
67
+ }
68
+ }}
69
+ />
70
+ <Hint>
71
+ Shown as the heading on the hosted and embedded form. Defaults to the form name.
72
+ </Hint>
73
+ </Labelled>
74
+
58
75
  <Labelled label="Thank-you message">
59
76
  <textarea
60
77
  className={`${inputClass} min-h-[72px]`}
@@ -68,68 +85,10 @@ export function FormSettings({ form }: FormSettingsProps): React.JSX.Element {
68
85
  <Hint>Shown in place of the form once a submission lands.</Hint>
69
86
  </Labelled>
70
87
 
71
- <div className="space-y-3 rounded-md border border-border p-4">
72
- <label className="flex items-center gap-2 text-[13px] font-medium text-ink">
73
- <input
74
- type="checkbox"
75
- checked={form.createDeal}
76
- disabled={!hasCompanyField && !form.createDeal}
77
- onChange={(event) => {
78
- patch({ createDeal: event.target.checked })
79
- }}
80
- />
81
- Create a Deal on submit
82
- </label>
83
-
84
- {!hasCompanyField && (
85
- <p className="text-[12px] text-danger">
86
- Add a field mapped to Company · name or Company · domain first. A deal belongs to a
87
- company, and this form collects neither.
88
- </p>
89
- )}
90
-
91
- {form.createDeal && (
92
- <>
93
- <Labelled label="Deal stage">
94
- <select
95
- className={inputClass}
96
- value={form.dealStageId ?? ''}
97
- onChange={(event) => {
98
- patch({ dealStageId: event.target.value.length === 0 ? null : event.target.value })
99
- }}
100
- >
101
- <option value="">First open stage</option>
102
- {stages.records.map((stage) => (
103
- <option key={stage.id} value={stage.id}>
104
- {stage.label}
105
- </option>
106
- ))}
107
- </select>
108
- <Hint>
109
- Leaving this on the first open stage follows the board, so reordering it does not
110
- strand old forms on a column that moved.
111
- </Hint>
112
- </Labelled>
113
-
114
- <Labelled label="Deal name template">
115
- <input
116
- className={inputClass}
117
- defaultValue={form.dealNameTemplate ?? ''}
118
- onBlur={(event) => {
119
- const next = event.target.value.length === 0 ? null : event.target.value
120
-
121
- if (next !== form.dealNameTemplate) {
122
- patch({ dealNameTemplate: next })
123
- }
124
- }}
125
- />
126
- <Hint>
127
- {'Tokens: {{company.name}} and {{person.name}}. A field mapped to Deal · name wins over this.'}
128
- </Hint>
129
- </Labelled>
130
- </>
131
- )}
132
- </div>
88
+ <p className="rounded-md border border-border bg-surface px-3 py-2 text-[12px] text-ink-muted">
89
+ Deal, opportunity, partnership, tags, lists, and attached records live on the{' '}
90
+ <strong className="font-medium text-ink">Actions</strong> tab.
91
+ </p>
133
92
 
134
93
  <Labelled label="Public key">
135
94
  <input className={`${inputClass} font-mono text-[12px]`} value={form.publicKey} readOnly />