@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.
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
@@ -5,6 +5,7 @@ import type {
5
5
  FormFieldInput,
6
6
  FormInput,
7
7
  FormSubmission,
8
+ FormSubmissionLinkTarget,
8
9
  } from '@kelpie/schemas'
9
10
  import {
10
11
  useMutation,
@@ -14,6 +15,7 @@ import {
14
15
 
15
16
  import { isRecord } from '../json.ts'
16
17
  import type { QueryParameters } from '../client.ts'
18
+ import { ApiError } from '../client.ts'
17
19
  import { useApiClient } from '../context.ts'
18
20
  import { toError } from '../errors.ts'
19
21
  import { createResourceHooks, usePagedList } from '../resource.ts'
@@ -146,9 +148,63 @@ export function useFormSubmissions(formId: string | undefined): RecordListResult
146
148
  })
147
149
  }
148
150
 
151
+ /**
152
+ * Submissions that touched one record, newest first.
153
+ *
154
+ * Powers the Forms tab on every record type a submission can name (person,
155
+ * company, position, deal, opportunity, partnership, enquiry). Returns an
156
+ * empty page for a record no submission has ever named — the server does no
157
+ * per-target existence check, so a stale id reads the same as a real one.
158
+ */
159
+ export function useFormSubmissionsForRecord(
160
+ targetType: FormSubmissionLinkTarget | undefined,
161
+ targetId: string | undefined,
162
+ ): RecordListResult<FormSubmission> {
163
+ return usePagedList<FormSubmission>({
164
+ queryKey: ['form-submissions', 'by-target', targetType ?? '', targetId ?? ''],
165
+ path: '/form-submissions',
166
+ decode: formSubmissionSchema.parse,
167
+ query: { target_type: targetType, target_id: targetId },
168
+ enabled: targetType !== undefined && targetId !== undefined,
169
+ })
170
+ }
171
+
172
+ /**
173
+ * One submission on a form.
174
+ *
175
+ * Nested under the form rather than a top-level collection: a submission id is
176
+ * only meaningful with its form (the field labels live there), and the path
177
+ * mirrors the list endpoint above it.
178
+ */
179
+ export function useFormSubmission(
180
+ formId: string | undefined,
181
+ submissionId: string | undefined,
182
+ ): RecordResult<FormSubmission> {
183
+ const client = useApiClient()
184
+ const result = useQuery({
185
+ queryKey: ['forms', 'submissions', formId ?? '', submissionId ?? ''],
186
+ queryFn: () =>
187
+ client.get(
188
+ `/forms/${formId ?? ''}/submissions/${submissionId ?? ''}`,
189
+ formSubmissionSchema.parse,
190
+ ),
191
+ enabled: formId !== undefined && submissionId !== undefined,
192
+ })
193
+
194
+ return {
195
+ record: result.data,
196
+ isLoading: result.isPending && formId !== undefined && submissionId !== undefined,
197
+ isNotFound: result.error instanceof ApiError && result.error.status === 404,
198
+ error: toError(result.error),
199
+ }
200
+ }
201
+
149
202
  /** What a customer pastes into their site, built server-side from the request origin. */
150
203
  export interface EmbedSnippets {
204
+ /** Standalone hosted page (brand chrome). */
151
205
  readonly url: string
206
+ /** Bare document the iframe snippets load (fields only). */
207
+ readonly embedUrl: string
152
208
  readonly iframeSnippet: string
153
209
  readonly scriptSnippet: string
154
210
  }
@@ -157,6 +213,7 @@ function decodeSnippets(value: unknown): EmbedSnippets {
157
213
  if (
158
214
  !isRecord(value) ||
159
215
  typeof value.url !== 'string' ||
216
+ typeof value.embed_url !== 'string' ||
160
217
  typeof value.iframe_snippet !== 'string' ||
161
218
  typeof value.script_snippet !== 'string'
162
219
  ) {
@@ -165,6 +222,7 @@ function decodeSnippets(value: unknown): EmbedSnippets {
165
222
 
166
223
  return {
167
224
  url: value.url,
225
+ embedUrl: value.embed_url,
168
226
  iframeSnippet: value.iframe_snippet,
169
227
  scriptSnippet: value.script_snippet,
170
228
  }
@@ -34,6 +34,11 @@ export interface CreateImportJobInput {
34
34
  /** People import only: what to do with a row naming a company that is not here yet. */
35
35
  readonly onMissingCompany: OnMissingCompany
36
36
  readonly matchKeyId: string
37
+ /**
38
+ * People import only: the consent purpose a row's `consent_status` grants.
39
+ * Required whenever the map names `consent_status` or `consent_at`.
40
+ */
41
+ readonly consentPurposeId?: string | null
37
42
  /**
38
43
  * Absent on the first upload, so the server derives one from the source preset
39
44
  * and the file's own headers. The mapping screen sends the corrected map back
@@ -57,6 +62,10 @@ function jobForm(input: CreateImportJobInput): FormData {
57
62
  form.set('column_map', JSON.stringify(input.columnMap))
58
63
  }
59
64
 
65
+ if (input.consentPurposeId != null && input.consentPurposeId.length > 0) {
66
+ form.set('consent_purpose_id', input.consentPurposeId)
67
+ }
68
+
60
69
  return form
61
70
  }
62
71
 
@@ -41,6 +41,8 @@ export interface OpportunityFilters {
41
41
  readonly companyIds?: readonly string[] | undefined
42
42
  /** Opportunities sitting in any of these stages. Repeats on the wire. */
43
43
  readonly stageIds?: readonly string[] | undefined
44
+ /** Opportunities any of these people are on. Repeats on the wire. */
45
+ readonly personIds?: readonly string[] | undefined
44
46
  readonly limit?: number | undefined
45
47
  /** `field` ascending, `-field` descending. Only `name`, `created_at`, `updated_at` are sortable. */
46
48
  readonly sort?: string | undefined
@@ -52,6 +54,7 @@ function opportunityQuery(filters: OpportunityFilters): QueryParameters {
52
54
  kind: filters.kinds,
53
55
  company_id: filters.companyIds,
54
56
  stage_id: filters.stageIds,
57
+ person_id: filters.personIds,
55
58
  limit: filters.limit,
56
59
  sort: filters.sort,
57
60
  }
@@ -45,6 +45,7 @@ export function useInstallSampleData(): MutationResult<InstallSampleDataInput, S
45
45
  'opportunities',
46
46
  'raises',
47
47
  'partnerships',
48
+ 'enquiries',
48
49
  'roles',
49
50
  'candidates',
50
51
  ].map((name) => cache.invalidateQueries({ queryKey: [name] })),
@@ -11,12 +11,16 @@ import { DealsPage } from '../pages/DealsPage.tsx'
11
11
  import { DecisionsPage } from '../pages/DecisionsPage.tsx'
12
12
  import { SearchPage } from '../pages/SearchPage.tsx'
13
13
  import { FormDetail } from '../pages/FormDetail.tsx'
14
+ import { SubmissionDetail } from '../pages/forms/SubmissionDetail.tsx'
14
15
  import { FormsPage } from '../pages/FormsPage.tsx'
15
16
  import { FundraisingPage } from '../pages/FundraisingPage.tsx'
16
17
  import { HiringPage } from '../pages/HiringPage.tsx'
17
18
  import { ListDetail } from '../pages/ListDetail.tsx'
18
19
  import { ListsPage } from '../pages/ListsPage.tsx'
19
20
  import { NotFoundPage } from '../pages/NotFoundPage.tsx'
21
+ import { EnquiriesPage } from '../pages/EnquiriesPage.tsx'
22
+ import { EnquiryDetail } from '../pages/EnquiryDetail.tsx'
23
+ import { EnquiryStageSettingsPage } from '../pages/EnquiryStageSettingsPage.tsx'
20
24
  import { OpportunitiesPage } from '../pages/OpportunitiesPage.tsx'
21
25
  import { OpportunityDetail } from '../pages/OpportunityDetail.tsx'
22
26
  import { OpportunityStageSettingsPage } from '../pages/OpportunityStageSettingsPage.tsx'
@@ -36,6 +40,8 @@ import { ProfilePage } from '../pages/account/ProfilePage.tsx'
36
40
  import { SecurityPage } from '../pages/account/SecurityPage.tsx'
37
41
  import { ApiKeysPage } from '../pages/admin/ApiKeysPage.tsx'
38
42
  import { DataPage } from '../pages/admin/DataPage.tsx'
43
+ import { FieldsPage } from '../pages/admin/FieldsPage.tsx'
44
+ import { PrivacyPage } from '../pages/admin/PrivacyPage.tsx'
39
45
  import { McpPage } from '../pages/admin/McpPage.tsx'
40
46
  import { ModulesPage } from '../pages/admin/ModulesPage.tsx'
41
47
  import { TeamPage } from '../pages/admin/TeamPage.tsx'
@@ -155,6 +161,9 @@ function AppRoutes(): React.JSX.Element {
155
161
  <Route path="opportunities" element={<OpportunitiesPage />} />
156
162
  <Route path="opportunities/settings" element={<OpportunityStageSettingsPage />} />
157
163
  <Route path="opportunities/:id" element={<OpportunityDetail />} />
164
+ <Route path="enquiries" element={<EnquiriesPage />} />
165
+ <Route path="enquiries/settings" element={<EnquiryStageSettingsPage />} />
166
+ <Route path="enquiries/:id" element={<EnquiryDetail />} />
158
167
  <Route path="fundraising" element={<FundraisingPage />} />
159
168
  <Route path="fundraising/settings" element={<RaiseStageSettingsPage />} />
160
169
  <Route path="fundraising/:id" element={<RaiseDetail />} />
@@ -166,12 +175,15 @@ function AppRoutes(): React.JSX.Element {
166
175
  <Route path="planning" element={<PlanningPage />} />
167
176
  <Route path="decisions" element={<DecisionsPage />} />
168
177
  <Route path="forms" element={<FormsPage />} />
178
+ <Route path="forms/:id/submissions/:submissionId" element={<SubmissionDetail />} />
169
179
  <Route path="forms/:id" element={<FormDetail />} />
170
180
  {/* The optional segment is the mockup's: /handbook opens the first page. */}
171
181
  <Route path="handbook" element={<HandbookLayout />} />
172
182
  <Route path="handbook/:pageId" element={<HandbookLayout />} />
173
183
  <Route path="admin/workspace" element={<WorkspaceSettingsPage />} />
174
184
  <Route path="admin/team" element={<TeamPage />} />
185
+ <Route path="admin/fields" element={<FieldsPage />} />
186
+ <Route path="admin/privacy" element={<PrivacyPage />} />
175
187
  <Route path="admin/data" element={<DataPage />} />
176
188
  <Route path="admin/api-keys" element={<ApiKeysPage />} />
177
189
  <Route path="admin/mcp" element={<McpPage />} />
@@ -48,6 +48,7 @@ const TARGET_TYPE_LABELS: Readonly<Record<RecordTargetType, string>> = {
48
48
  opportunity: 'Opportunity',
49
49
  partnership: 'Partnership',
50
50
  raise: 'Raise',
51
+ enquiry: 'Enquiry',
51
52
  candidate: 'Candidate',
52
53
  }
53
54
 
@@ -0,0 +1,330 @@
1
+ import { CUSTOM_FIELD_TYPES_WITH_OPTIONS } from '@kelpie/schemas'
2
+ import type {
3
+ CustomFieldDefinition,
4
+ CustomFieldObjectType,
5
+ CustomFieldType,
6
+ CustomFieldValue,
7
+ CustomFieldValues,
8
+ } from '@kelpie/schemas'
9
+ import { useMemo, useState } from 'react'
10
+ import { Link } from 'react-router'
11
+
12
+ import { useCustomFields } from '../api/resources/customFields.ts'
13
+ import { InlineEdit } from './InlineEdit.tsx'
14
+
15
+ /**
16
+ * Renders the workspace's custom fields for one record as a full-width panel,
17
+ * dropped into a detail page's Fields tab.
18
+ *
19
+ * Each field patches only its own key; the server merges the sent object into
20
+ * stored values in the same transaction.
21
+ *
22
+ * Currency is edited in major units in the box and stored as integer cents,
23
+ * mirroring how DealDetail handles `value_cents`. Multi-select uses a compact
24
+ * checkbox list because InlineEdit only handles one string in and out.
25
+ *
26
+ * `useHasCustomFields` lets a caller decide whether to render the tab at all —
27
+ * a workspace with no definitions should not see a Fields tab full of empty
28
+ * space with only a "go set one up" pointer.
29
+ */
30
+
31
+ export interface CustomFieldsPanelProps {
32
+ readonly objectType: CustomFieldObjectType
33
+ readonly values: CustomFieldValues
34
+ readonly onPatch: (change: Readonly<Record<string, CustomFieldValue | null>>) => void
35
+ }
36
+
37
+ const OPTIONS_TYPES: ReadonlySet<CustomFieldType> = new Set(CUSTOM_FIELD_TYPES_WITH_OPTIONS)
38
+
39
+ export function CustomFieldsPanel({
40
+ objectType,
41
+ values,
42
+ onPatch,
43
+ }: CustomFieldsPanelProps): React.JSX.Element {
44
+ const definitions = useCustomFields({ objectType, sort: 'sort_order', limit: 200 })
45
+
46
+ if (definitions.isLoading) {
47
+ return <p className="text-[13px] text-ink-muted">Loading fields…</p>
48
+ }
49
+
50
+ if (definitions.records.length === 0) {
51
+ return (
52
+ <p className="text-[13px] text-ink-muted">
53
+ No custom fields yet. Add them at{' '}
54
+ <Link to="/admin/fields" className="text-accent hover:text-accent-hover">
55
+ Admin → Fields
56
+ </Link>
57
+ .
58
+ </p>
59
+ )
60
+ }
61
+
62
+ return (
63
+ <dl className="grid gap-x-8 gap-y-5 sm:grid-cols-2">
64
+ {definitions.records.map((definition) => (
65
+ <CustomFieldControl
66
+ key={definition.id}
67
+ definition={definition}
68
+ value={values[definition.key]}
69
+ onChange={(next) => {
70
+ onPatch({ [definition.key]: next })
71
+ }}
72
+ />
73
+ ))}
74
+ </dl>
75
+ )
76
+ }
77
+
78
+ interface CustomFieldControlProps {
79
+ readonly definition: CustomFieldDefinition
80
+ readonly value: CustomFieldValue | undefined
81
+ readonly onChange: (value: CustomFieldValue | null) => void
82
+ }
83
+
84
+ function CustomFieldControl({
85
+ definition,
86
+ value,
87
+ onChange,
88
+ }: CustomFieldControlProps): React.JSX.Element {
89
+ const type = definition.type as CustomFieldType
90
+
91
+ return (
92
+ <div>
93
+ <dt className="text-[10px] font-semibold tracking-wide text-ink-faint uppercase">
94
+ {definition.label}
95
+ </dt>
96
+ {definition.description.length > 0 && (
97
+ <p className="mt-0.5 text-[11px] text-ink-muted">{definition.description}</p>
98
+ )}
99
+ <dd className="mt-1 text-[13px] text-ink">
100
+ <ControlFor definition={definition} type={type} value={value} onChange={onChange} />
101
+ </dd>
102
+ </div>
103
+ )
104
+ }
105
+
106
+ interface ControlForProps {
107
+ readonly definition: CustomFieldDefinition
108
+ readonly type: CustomFieldType
109
+ readonly value: CustomFieldValue | undefined
110
+ readonly onChange: (value: CustomFieldValue | null) => void
111
+ }
112
+
113
+ function ControlFor({
114
+ definition,
115
+ type,
116
+ value,
117
+ onChange,
118
+ }: ControlForProps): React.JSX.Element {
119
+ if (type === 'checkbox') {
120
+ return (
121
+ <label className="inline-flex cursor-pointer items-center gap-2">
122
+ <input
123
+ type="checkbox"
124
+ checked={value === true}
125
+ onChange={(event) => {
126
+ onChange(event.target.checked)
127
+ }}
128
+ className="rounded border-border"
129
+ />
130
+ <span>{value === true ? 'Yes' : 'No'}</span>
131
+ </label>
132
+ )
133
+ }
134
+
135
+ if (type === 'multi_select') {
136
+ return (
137
+ <MultiSelectField
138
+ options={definition.options}
139
+ selected={Array.isArray(value) ? (value as readonly string[]) : []}
140
+ onChange={onChange}
141
+ />
142
+ )
143
+ }
144
+
145
+ if (type === 'currency') {
146
+ const currentCents =
147
+ typeof value === 'object' &&
148
+ value !== null &&
149
+ !Array.isArray(value) &&
150
+ 'amountCents' in value
151
+ ? value.amountCents
152
+ : null
153
+ const currentCurrency =
154
+ typeof value === 'object' &&
155
+ value !== null &&
156
+ !Array.isArray(value) &&
157
+ 'currency' in value
158
+ ? value.currency
159
+ : 'USD'
160
+
161
+ return (
162
+ <InlineEdit
163
+ type="number"
164
+ value={currentCents === null ? '' : String(currentCents / 100)}
165
+ onChange={(raw) => {
166
+ const trimmed = raw.trim()
167
+ if (trimmed.length === 0) {
168
+ onChange(null)
169
+ return
170
+ }
171
+ const parsed = Number(trimmed)
172
+ if (!Number.isFinite(parsed)) {
173
+ return
174
+ }
175
+ onChange({
176
+ amountCents: Math.round(parsed * 100),
177
+ currency: currentCurrency,
178
+ })
179
+ }}
180
+ emptyLabel="Add…"
181
+ />
182
+ )
183
+ }
184
+
185
+ if (OPTIONS_TYPES.has(type)) {
186
+ // A stored value that is no longer in the definition's options must still
187
+ // be visible until the reader chooses another; keep it in the list.
188
+ const stored = typeof value === 'string' ? value : ''
189
+ const options = optionsWithStale(definition.options, stored)
190
+ return (
191
+ <InlineEdit
192
+ value={stored}
193
+ options={options.map((option) => ({ value: option, label: option }))}
194
+ onChange={(next) => {
195
+ onChange(next.length === 0 ? null : next)
196
+ }}
197
+ emptyLabel="Choose…"
198
+ />
199
+ )
200
+ }
201
+
202
+ if (type === 'number') {
203
+ const current = typeof value === 'number' ? String(value) : ''
204
+ return (
205
+ <InlineEdit
206
+ type="number"
207
+ value={current}
208
+ onChange={(raw) => {
209
+ const trimmed = raw.trim()
210
+ if (trimmed.length === 0) {
211
+ onChange(null)
212
+ return
213
+ }
214
+ const parsed = Number(trimmed)
215
+ if (Number.isFinite(parsed)) {
216
+ onChange(parsed)
217
+ }
218
+ }}
219
+ emptyLabel="Add…"
220
+ />
221
+ )
222
+ }
223
+
224
+ const inputType: 'text' | 'date' | 'url' | 'email' =
225
+ type === 'date' ? 'date' : type === 'url' ? 'url' : 'text'
226
+ const multiline = type === 'long_text'
227
+ const current = typeof value === 'string' ? value : ''
228
+
229
+ return (
230
+ <InlineEdit
231
+ type={inputType}
232
+ multiline={multiline}
233
+ value={current}
234
+ onChange={(next) => {
235
+ onChange(next.length === 0 ? null : next)
236
+ }}
237
+ emptyLabel="Add…"
238
+ />
239
+ )
240
+ }
241
+
242
+ interface MultiSelectFieldProps {
243
+ readonly options: readonly string[]
244
+ readonly selected: readonly string[]
245
+ readonly onChange: (value: readonly string[] | null) => void
246
+ }
247
+
248
+ function MultiSelectField({
249
+ options,
250
+ selected,
251
+ onChange,
252
+ }: MultiSelectFieldProps): React.JSX.Element {
253
+ const [open, setOpen] = useState(false)
254
+ const allOptions = useMemo(
255
+ () => optionsWithStale(options, ...selected),
256
+ [options, selected],
257
+ )
258
+
259
+ const chips = selected.length === 0 ? null : (
260
+ <div className="flex flex-wrap gap-1">
261
+ {selected.map((entry) => (
262
+ <span
263
+ key={entry}
264
+ className="inline-flex items-center gap-1 rounded-full bg-surface-raised px-2 py-0.5 text-[11px]"
265
+ >
266
+ {entry}
267
+ <button
268
+ type="button"
269
+ onClick={() => {
270
+ const next = selected.filter((existing) => existing !== entry)
271
+ onChange(next.length === 0 ? null : next)
272
+ }}
273
+ className="text-ink-muted hover:text-ink"
274
+ aria-label={`Remove ${entry}`}
275
+ >
276
+ ×
277
+ </button>
278
+ </span>
279
+ ))}
280
+ </div>
281
+ )
282
+
283
+ return (
284
+ <div className="space-y-1">
285
+ {chips ?? <span className="text-ink-faint">None</span>}
286
+ <button
287
+ type="button"
288
+ onClick={() => {
289
+ setOpen((current) => !current)
290
+ }}
291
+ className="text-[12px] text-ink-muted hover:text-ink"
292
+ >
293
+ {open ? 'Close' : 'Edit'}
294
+ </button>
295
+ {open && (
296
+ <ul className="space-y-1 rounded-md border border-border bg-surface-raised p-2">
297
+ {allOptions.map((option) => {
298
+ const chosen = selected.includes(option)
299
+ return (
300
+ <li key={option}>
301
+ <label className="flex cursor-pointer items-center gap-2 text-[12px]">
302
+ <input
303
+ type="checkbox"
304
+ checked={chosen}
305
+ onChange={() => {
306
+ const next = chosen
307
+ ? selected.filter((existing) => existing !== option)
308
+ : [...selected, option]
309
+ onChange(next.length === 0 ? null : next)
310
+ }}
311
+ />
312
+ <span>{option}</span>
313
+ </label>
314
+ </li>
315
+ )
316
+ })}
317
+ </ul>
318
+ )}
319
+ </div>
320
+ )
321
+ }
322
+
323
+ function optionsWithStale(
324
+ options: readonly string[],
325
+ ...stored: readonly string[]
326
+ ): readonly string[] {
327
+ const seen = new Set(options)
328
+ const extras = stored.filter((entry) => entry.length > 0 && !seen.has(entry))
329
+ return extras.length === 0 ? options : [...options, ...extras]
330
+ }
@@ -0,0 +1,86 @@
1
+ import type { FormSubmissionLinkTarget } from '@kelpie/schemas'
2
+ import { useMemo } from 'react'
3
+ import { Link } from 'react-router'
4
+
5
+ import { useTimezone } from '../api/resources/account.ts'
6
+ import { useForms, useFormSubmissionsForRecord } from '../api/resources/forms.ts'
7
+ import { formatDateTime } from '../lib/dates.ts'
8
+ import { ErrorPanel, LoadingPanel } from './QueryState.tsx'
9
+ import { SectionHeader } from './SectionHeader.tsx'
10
+
11
+ /**
12
+ * "Which forms touched this record?" panel, for any record's detail page.
13
+ *
14
+ * A submission's FK to this record is what puts it here — the record was
15
+ * either created by the form, or matched from an earlier upsert. The panel
16
+ * does not distinguish the two (nothing on the row tells them apart), and
17
+ * the section header reflects that.
18
+ *
19
+ * Form names are pulled from `useForms`, which the sidebar already caches;
20
+ * an unresolved id (deleted form) falls back to the id itself so the row
21
+ * stays clickable.
22
+ */
23
+ export function FormsPanel({
24
+ targetType,
25
+ targetId,
26
+ }: {
27
+ readonly targetType: FormSubmissionLinkTarget
28
+ readonly targetId: string
29
+ }): React.JSX.Element {
30
+ const submissions = useFormSubmissionsForRecord(targetType, targetId)
31
+ const forms = useForms({})
32
+ const timezone = useTimezone()
33
+ const formNameById = useMemo(
34
+ () => new Map(forms.records.map((form) => [form.id, form.name])),
35
+ [forms.records],
36
+ )
37
+
38
+ if (submissions.error !== null) {
39
+ return <ErrorPanel error={submissions.error} />
40
+ }
41
+
42
+ if (submissions.isLoading) {
43
+ return <LoadingPanel label="Loading form submissions…" />
44
+ }
45
+
46
+ return (
47
+ <section>
48
+ <SectionHeader
49
+ title="Forms"
50
+ description="Every form submission that named this record."
51
+ />
52
+
53
+ {submissions.records.length === 0 ? (
54
+ <p className="text-[13px] text-ink-faint">No form submissions yet.</p>
55
+ ) : (
56
+ <ul className="divide-y divide-border rounded-md border border-border">
57
+ {submissions.records.map((submission) => {
58
+ const formName = formNameById.get(submission.formId) ?? submission.formId
59
+
60
+ return (
61
+ <li key={submission.id} className="flex items-center justify-between gap-3 p-3">
62
+ <div className="min-w-0">
63
+ <Link
64
+ to={`/forms/${submission.formId}/submissions/${submission.id}`}
65
+ className="text-[13px] font-medium text-ink hover:text-accent"
66
+ >
67
+ {formName}
68
+ </Link>
69
+ <div className="text-[11px] text-ink-faint">
70
+ Submitted {formatDateTime(submission.submittedAt, timezone)}
71
+ </div>
72
+ </div>
73
+ <Link
74
+ to={`/forms/${submission.formId}`}
75
+ className="shrink-0 text-[11px] font-medium text-ink-muted hover:text-accent"
76
+ >
77
+ Open form →
78
+ </Link>
79
+ </li>
80
+ )
81
+ })}
82
+ </ul>
83
+ )}
84
+ </section>
85
+ )
86
+ }
@@ -1,5 +1,13 @@
1
1
  import { PIPELINE_KIND_LABELS, PLAN_ITEM_STATUS_LABELS } from '@kelpie/schemas'
2
- import type { Deal, Opportunity, Partnership, PipelineKind, PlanItem, Raise } from '@kelpie/schemas'
2
+ import type {
3
+ Deal,
4
+ Enquiry,
5
+ Opportunity,
6
+ Partnership,
7
+ PipelineKind,
8
+ PlanItem,
9
+ Raise,
10
+ } from '@kelpie/schemas'
3
11
  import { Link } from 'react-router'
4
12
 
5
13
  import { useMembers } from '../api/resources/members.ts'
@@ -20,6 +28,7 @@ import { SectionHeader } from './SectionHeader.tsx'
20
28
 
21
29
  /** Where a plan item's record lives. */
22
30
  const ROUTES: Readonly<Record<PipelineKind, string | undefined>> = {
31
+ enquiry: '/enquiries',
23
32
  deal: '/deals',
24
33
  opportunity: '/opportunities',
25
34
  raise: '/fundraising',
@@ -93,12 +102,14 @@ export function RelatedPlanAttention({
93
102
  opportunities = [],
94
103
  raises = [],
95
104
  partnerships = [],
105
+ enquiries = [],
96
106
  isLoading,
97
107
  }: {
98
108
  readonly deals: readonly Deal[]
99
109
  readonly opportunities?: readonly Opportunity[]
100
110
  readonly raises?: readonly Raise[]
101
111
  readonly partnerships?: readonly Partnership[]
112
+ readonly enquiries?: readonly Enquiry[]
102
113
  readonly isLoading: boolean
103
114
  }): React.JSX.Element {
104
115
  const dealItems = usePlanItemsForRecords(
@@ -117,6 +128,10 @@ export function RelatedPlanAttention({
117
128
  'partnership',
118
129
  partnerships.map((partnership) => partnership.id),
119
130
  )
131
+ const enquiryItems = usePlanItemsForRecords(
132
+ 'enquiry',
133
+ enquiries.map((enquiry) => enquiry.id),
134
+ )
120
135
 
121
136
  return (
122
137
  <PlanAttention
@@ -125,11 +140,12 @@ export function RelatedPlanAttention({
125
140
  ...opportunityItems.records,
126
141
  ...raiseItems.records,
127
142
  ...partnershipItems.records,
143
+ ...enquiryItems.records,
128
144
  ]}
129
145
  showTarget
130
146
  targetNames={
131
147
  new Map(
132
- [...deals, ...opportunities, ...raises, ...partnerships].map((record) => [
148
+ [...deals, ...opportunities, ...raises, ...partnerships, ...enquiries].map((record) => [
133
149
  record.id,
134
150
  record.name,
135
151
  ]),
@@ -140,7 +156,8 @@ export function RelatedPlanAttention({
140
156
  dealItems.isLoading ||
141
157
  opportunityItems.isLoading ||
142
158
  raiseItems.isLoading ||
143
- partnershipItems.isLoading
159
+ partnershipItems.isLoading ||
160
+ enquiryItems.isLoading
144
161
  }
145
162
  />
146
163
  )