@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
@@ -22,6 +22,8 @@ import { ErrorBoundary } from './ErrorBoundary.tsx'
22
22
  const CORE_ADMIN_NAV: readonly NavItem[] = [
23
23
  { id: 'workspace', label: 'Workspace', to: '/admin/workspace', order: 100 },
24
24
  { id: 'team', label: 'Team', to: '/admin/team', order: 200 },
25
+ { id: 'fields', label: 'Fields', to: '/admin/fields', order: 250 },
26
+ { id: 'privacy', label: 'Privacy', to: '/admin/privacy', order: 275 },
25
27
  { id: 'data', label: 'Data', to: '/admin/data', order: 300 },
26
28
  { id: 'api-keys', label: 'API keys', to: '/admin/api-keys', order: 350 },
27
29
  { id: 'webhooks', label: 'Webhooks', to: '/admin/webhooks', order: 400 },
@@ -34,6 +36,7 @@ const CORE_NAV: readonly NavItem[] = [
34
36
  { id: 'people', label: 'People', to: '/people', order: 100 },
35
37
  { id: 'hiring', label: 'Hiring', to: '/hiring', order: 150 },
36
38
  { id: 'companies', label: 'Companies', to: '/companies', order: 200 },
39
+ { id: 'enquiries', label: 'Enquiries', to: '/enquiries', order: 250 },
37
40
  { id: 'deals', label: 'Deals', to: '/deals', order: 300 },
38
41
  { id: 'opportunities', label: 'Opportunities', to: '/opportunities', order: 350 },
39
42
  { id: 'fundraising', label: 'Fundraising', to: '/fundraising', order: 360 },
@@ -0,0 +1,18 @@
1
+ import type { CustomFieldObjectType } from '@kelpie/schemas'
2
+
3
+ import { useCustomFields } from '../api/resources/customFields.ts'
4
+
5
+ /**
6
+ * Whether this workspace has any custom fields for the given object type.
7
+ *
8
+ * A detail page reads this to decide whether to render its Fields tab at all —
9
+ * a workspace with no definitions should not see a Fields tab full of empty
10
+ * space with only a "go set one up" pointer.
11
+ *
12
+ * Lives in its own file so `CustomFieldsPanel.tsx` can stay Fast-Refresh clean
13
+ * (`react/only-export-components`).
14
+ */
15
+ export function useHasCustomFields(objectType: CustomFieldObjectType): boolean {
16
+ const definitions = useCustomFields({ objectType, sort: 'sort_order', limit: 200 })
17
+ return definitions.records.length > 0
18
+ }
@@ -12,6 +12,8 @@ import {
12
12
  useUpdateCompany,
13
13
  } from '../api/resources/companies.ts'
14
14
  import { useDeals } from '../api/resources/deals.ts'
15
+ import { useEnquiries } from '../api/resources/enquiries.ts'
16
+ import { useFormSubmissionsForRecord } from '../api/resources/forms.ts'
15
17
  import { useOpportunities } from '../api/resources/opportunities.ts'
16
18
  import { usePartnerships } from '../api/resources/partnerships.ts'
17
19
  import { usePeople } from '../api/resources/people.ts'
@@ -29,6 +31,9 @@ import type { ChipTone } from '../components/Chip.tsx'
29
31
  import { DecisionsPanel } from '../components/DecisionsPanel.tsx'
30
32
  import { DeleteRecord } from '../components/DeleteRecord.tsx'
31
33
  import { EntitySearch } from '../components/EntitySearch.tsx'
34
+ import { FormsPanel } from '../components/FormsPanel.tsx'
35
+ import { CustomFieldsPanel } from '../components/CustomFieldsPanel.tsx'
36
+ import { useHasCustomFields } from '../components/useHasCustomFields.ts'
32
37
  import { InlineEdit } from '../components/InlineEdit.tsx'
33
38
  import { ListsPanel } from '../components/ListsPanel.tsx'
34
39
  import { NotesPanel } from '../components/NotesPanel.tsx'
@@ -69,7 +74,9 @@ export function CompanyDetail(): React.JSX.Element {
69
74
  const { record, isLoading, isNotFound, error } = useCompany(id)
70
75
  const deleteCompany = useDeleteCompany()
71
76
  const moduleTabs = inSlotOrder(useRecordTabs('company'))
77
+ const hasCustomFields = useHasCustomFields('company')
72
78
  const [activeTab, setActiveTab] = useState('overview')
79
+ const formSubmissions = useFormSubmissionsForRecord('company', id)
73
80
 
74
81
  if (isNotFound) {
75
82
  return <NotFoundPanel label="Company" backTo="/companies" />
@@ -85,11 +92,15 @@ export function CompanyDetail(): React.JSX.Element {
85
92
 
86
93
  const tabs: readonly RecordTabDescriptor<string>[] = [
87
94
  { id: 'overview', label: 'Overview' },
95
+ ...(hasCustomFields ? [{ id: 'fields', label: 'Fields' }] : []),
88
96
  { id: 'activity', label: 'Activity' },
89
97
  { id: 'people', label: 'People' },
90
98
  { id: 'notes', label: 'Notes' },
91
99
  { id: 'decisions', label: 'Decisions' },
92
100
  { id: 'lists', label: 'Lists' },
101
+ ...(formSubmissions.records.length === 0
102
+ ? []
103
+ : [{ id: 'forms', label: 'Forms', count: formSubmissions.records.length }]),
93
104
  ...moduleTabs.map((tab) => ({ id: tab.id, label: tab.label })),
94
105
  ]
95
106
  const active = tabs.some((tab) => tab.id === activeTab) ? activeTab : 'overview'
@@ -131,11 +142,13 @@ export function CompanyDetail(): React.JSX.Element {
131
142
  ariaLabel="Company sections"
132
143
  >
133
144
  {active === 'overview' && <CompanyOverview company={record} />}
145
+ {active === 'fields' && <CompanyFields company={record} />}
134
146
  {active === 'activity' && <ActivitiesPanel targetType="company" targetId={record.id} />}
135
147
  {active === 'people' && <CompanyPeople company={record} />}
136
148
  {active === 'notes' && <NotesPanel targetType="company" targetId={record.id} />}
137
149
  {active === 'decisions' && <DecisionsPanel targetType="company" targetId={record.id} />}
138
150
  {active === 'lists' && <ListsPanel targetType="company" targetId={record.id} />}
151
+ {active === 'forms' && <FormsPanel targetType="company" targetId={record.id} />}
139
152
  {moduleTab?.render({ objectType: 'company', recordId: record.id })}
140
153
  </RecordTabs>
141
154
  </div>
@@ -196,6 +209,7 @@ function CompanyOverview({ company }: { readonly company: Company }): React.JSX.
196
209
  const opportunities = useOpportunities({ companyIds: [company.id] })
197
210
  const raises = useRaises({ companyIds: [company.id] })
198
211
  const partnerships = usePartnerships({ companyIds: [company.id] })
212
+ const enquiries = useEnquiries({ companyIds: [company.id] })
199
213
 
200
214
  return (
201
215
  <div className="space-y-8">
@@ -211,8 +225,13 @@ function CompanyOverview({ company }: { readonly company: Company }): React.JSX.
211
225
  opportunities={opportunities.records}
212
226
  raises={raises.records}
213
227
  partnerships={partnerships.records}
228
+ enquiries={enquiries.records}
214
229
  isLoading={
215
- deals.isLoading || opportunities.isLoading || raises.isLoading || partnerships.isLoading
230
+ deals.isLoading ||
231
+ opportunities.isLoading ||
232
+ raises.isLoading ||
233
+ partnerships.isLoading ||
234
+ enquiries.isLoading
216
235
  }
217
236
  />
218
237
  <LatestActivity targetType="company" targetId={company.id} />
@@ -357,6 +376,22 @@ function CompanySidebar({ company }: { readonly company: Company }): React.JSX.E
357
376
  )
358
377
  }
359
378
 
379
+ function CompanyFields({ company }: { readonly company: Company }): React.JSX.Element {
380
+ const { patch, error } = useCompanyPatch(company)
381
+ return (
382
+ <div className="space-y-4">
383
+ {error !== null && <ErrorPanel error={error} />}
384
+ <CustomFieldsPanel
385
+ objectType="company"
386
+ values={company.customFields}
387
+ onPatch={(customFields) => {
388
+ patch({ customFields })
389
+ }}
390
+ />
391
+ </div>
392
+ )
393
+ }
394
+
360
395
  /** The other side of the Position link: who holds a title here. */
361
396
  function CompanyPeople({ company }: { readonly company: Company }): React.JSX.Element {
362
397
  const positions = usePositions({ companyIds: [company.id] })
@@ -7,6 +7,7 @@ import { usePatch } from '../api/resource.ts'
7
7
  import type { PatchResult } from '../api/resource.ts'
8
8
  import { useCompanies, useCompany } from '../api/resources/companies.ts'
9
9
  import { useDeal, useDeleteDeal, useUpdateDeal } from '../api/resources/deals.ts'
10
+ import { useFormSubmissionsForRecord } from '../api/resources/forms.ts'
10
11
  import { useMembers } from '../api/resources/members.ts'
11
12
  import { usePeople } from '../api/resources/people.ts'
12
13
  import { usePipelineStages } from '../api/resources/pipelineStages.ts'
@@ -18,6 +19,9 @@ import type { ChipTone } from '../components/Chip.tsx'
18
19
  import { DecisionsPanel } from '../components/DecisionsPanel.tsx'
19
20
  import { DeleteRecord } from '../components/DeleteRecord.tsx'
20
21
  import { EntitySearch } from '../components/EntitySearch.tsx'
22
+ import { FormsPanel } from '../components/FormsPanel.tsx'
23
+ import { CustomFieldsPanel } from '../components/CustomFieldsPanel.tsx'
24
+ import { useHasCustomFields } from '../components/useHasCustomFields.ts'
21
25
  import { InlineEdit } from '../components/InlineEdit.tsx'
22
26
  import { ListsPanel } from '../components/ListsPanel.tsx'
23
27
  import { NotesPanel } from '../components/NotesPanel.tsx'
@@ -54,7 +58,9 @@ export function DealDetail(): React.JSX.Element {
54
58
  const { record, isLoading, isNotFound, error } = useDeal(id)
55
59
  const deleteDeal = useDeleteDeal()
56
60
  const moduleTabs = inSlotOrder(useRecordTabs('deal'))
61
+ const hasCustomFields = useHasCustomFields('deal')
57
62
  const [activeTab, setActiveTab] = useState('overview')
63
+ const formSubmissions = useFormSubmissionsForRecord('deal', id)
58
64
 
59
65
  if (isNotFound) {
60
66
  return <NotFoundPanel label="Deal" backTo="/deals" />
@@ -70,11 +76,15 @@ export function DealDetail(): React.JSX.Element {
70
76
 
71
77
  const tabs: readonly RecordTabDescriptor<string>[] = [
72
78
  { id: 'overview', label: 'Overview' },
79
+ ...(hasCustomFields ? [{ id: 'fields', label: 'Fields' }] : []),
73
80
  { id: 'plan', label: 'Plan' },
74
81
  { id: 'activity', label: 'Activity' },
75
82
  { id: 'notes', label: 'Notes' },
76
83
  { id: 'decisions', label: 'Decisions' },
77
84
  { id: 'lists', label: 'Lists' },
85
+ ...(formSubmissions.records.length === 0
86
+ ? []
87
+ : [{ id: 'forms', label: 'Forms', count: formSubmissions.records.length }]),
78
88
  ...moduleTabs.map((tab) => ({ id: tab.id, label: tab.label })),
79
89
  ]
80
90
  const active = tabs.some((tab) => tab.id === activeTab) ? activeTab : 'overview'
@@ -111,11 +121,15 @@ export function DealDetail(): React.JSX.Element {
111
121
 
112
122
  <RecordTabs tabs={tabs} active={active} onChange={setActiveTab} ariaLabel="Deal sections">
113
123
  {active === 'overview' && <DealOverview deal={record} />}
124
+ {active === 'fields' && (
125
+ <DealFields deal={record} />
126
+ )}
114
127
  {active === 'plan' && <PlanPanel targetType="deal" targetId={record.id} />}
115
128
  {active === 'activity' && <ActivitiesPanel targetType="deal" targetId={record.id} />}
116
129
  {active === 'notes' && <NotesPanel targetType="deal" targetId={record.id} />}
117
130
  {active === 'decisions' && <DecisionsPanel targetType="deal" targetId={record.id} />}
118
131
  {active === 'lists' && <ListsPanel targetType="deal" targetId={record.id} />}
132
+ {active === 'forms' && <FormsPanel targetType="deal" targetId={record.id} />}
119
133
  {moduleTab?.render({ objectType: 'deal', recordId: record.id })}
120
134
  </RecordTabs>
121
135
  </div>
@@ -242,6 +256,23 @@ function DealOverview({ deal }: { readonly deal: Deal }): React.JSX.Element {
242
256
  )
243
257
  }
244
258
 
259
+ /** The Fields tab: the workspace's custom fields for this deal. */
260
+ function DealFields({ deal }: { readonly deal: Deal }): React.JSX.Element {
261
+ const { patch, error } = useDealPatch(deal)
262
+ return (
263
+ <div className="space-y-4">
264
+ {error !== null && <ErrorPanel error={error} />}
265
+ <CustomFieldsPanel
266
+ objectType="deal"
267
+ values={deal.customFields}
268
+ onPatch={(customFields) => {
269
+ patch({ customFields })
270
+ }}
271
+ />
272
+ </div>
273
+ )
274
+ }
275
+
245
276
  /** Whole units in the box, cents on the wire. Blank clears the value. */
246
277
  function toValueCents(raw: string): number | null | undefined {
247
278
  const trimmed = raw.trim()
@@ -44,6 +44,7 @@ const TARGET_LABELS: Readonly<Record<RecordTargetType, string>> = {
44
44
  opportunity: 'Opportunity',
45
45
  partnership: 'Partnership',
46
46
  raise: 'Raise',
47
+ enquiry: 'Enquiry',
47
48
  candidate: 'Candidate',
48
49
  }
49
50
 
@@ -54,6 +55,7 @@ const TARGET_ROUTES: Readonly<Partial<Record<RecordTargetType, string>>> = {
54
55
  opportunity: '/opportunities',
55
56
  raise: '/fundraising',
56
57
  partnership: '/partnerships',
58
+ enquiry: '/enquiries',
57
59
  }
58
60
 
59
61
  interface TargetDirectory {
@@ -0,0 +1,434 @@
1
+ import type { Enquiry, PipelineStage } from '@kelpie/schemas'
2
+ import { Link, useNavigate } from 'react-router'
3
+
4
+ import { useCompanies } from '../api/resources/companies.ts'
5
+ import {
6
+ useCreateEnquiry,
7
+ useEnquiries,
8
+ useUpdateEnquiry,
9
+ } from '../api/resources/enquiries.ts'
10
+ import { useMembers } from '../api/resources/members.ts'
11
+ import { usePipelineStages } from '../api/resources/pipelineStages.ts'
12
+ import { MAX_PAGE_SIZE, usePlanItems } from '../api/resources/planItems.ts'
13
+ import { useTimezone } from '../api/resources/account.ts'
14
+ import { Chip } from '../components/Chip.tsx'
15
+ import type { ChipTone } from '../components/Chip.tsx'
16
+ import { ColumnPicker } from '../components/ColumnPicker.tsx'
17
+ import { DataTable } from '../components/DataTable.tsx'
18
+ import type { Column, DataTableGroup } from '../components/DataTable.tsx'
19
+ import { KanbanBoard } from '../components/KanbanBoard.tsx'
20
+ import { PageHeader } from '../components/PageHeader.tsx'
21
+ import { Paginator } from '../components/Paginator.tsx'
22
+ import { ErrorPanel, LoadingPanel } from '../components/QueryState.tsx'
23
+ import { SegmentedControl } from '../components/SegmentedControl.tsx'
24
+ import { formatDate, formatDay } from '../lib/dates.ts'
25
+ import { useListView } from '../lib/listView.ts'
26
+ import { DUE_BUCKETS, byDateThenTitle, dueBucketFor, nextOpenByTarget } from '../lib/plan.ts'
27
+ import { serverSortOnly } from '../lib/sort.ts'
28
+
29
+ /**
30
+ * The Enquiries pipeline: inbound requests that may become a Deal. The same
31
+ * board-and-list pair as Opportunities over the shared kanban, with `source`
32
+ * in place of `kind` and a company that may be absent.
33
+ */
34
+
35
+ type BoardView = 'list' | 'columns'
36
+ type PipelineScope = 'open' | 'all'
37
+ type ListGrouping = 'stage' | 'due'
38
+
39
+ const SCOPES: readonly PipelineScope[] = ['open', 'all']
40
+ const GROUPINGS: readonly ListGrouping[] = ['stage', 'due']
41
+
42
+ function readScope(stored: string | undefined): PipelineScope {
43
+ return SCOPES.includes(stored as PipelineScope) ? (stored as PipelineScope) : 'open'
44
+ }
45
+
46
+ function readGrouping(stored: string | undefined): ListGrouping {
47
+ return GROUPINGS.includes(stored as ListGrouping) ? (stored as ListGrouping) : 'stage'
48
+ }
49
+
50
+ const COLUMN_KEYS: readonly string[] = [
51
+ 'name',
52
+ 'source',
53
+ 'company',
54
+ 'stage',
55
+ 'nextPlan',
56
+ 'owner',
57
+ 'tags',
58
+ 'summary',
59
+ 'createdAt',
60
+ 'updatedAt',
61
+ ]
62
+
63
+ /** Tones keyed by the seeded slugs. A renamed stage keeps its slug and its tone. */
64
+ const STAGE_TONES: Readonly<Record<string, ChipTone>> = {
65
+ new: 'accent',
66
+ in_progress: 'warning',
67
+ closed: 'neutral',
68
+ }
69
+
70
+ function stageTone(stage: PipelineStage): ChipTone {
71
+ return STAGE_TONES[stage.slug] ?? 'neutral'
72
+ }
73
+
74
+ const DEFAULT_VISIBLE_KEYS: readonly string[] = [
75
+ 'name',
76
+ 'source',
77
+ 'company',
78
+ 'nextPlan',
79
+ 'owner',
80
+ ]
81
+
82
+ const SERVER_SORT_KEYS: readonly string[] = ['name', 'created_at', 'updated_at']
83
+
84
+ export function EnquiriesPage(): React.JSX.Element {
85
+ const navigate = useNavigate()
86
+
87
+ const listView = useListView('enquiries', COLUMN_KEYS, DEFAULT_VISIBLE_KEYS)
88
+ const view: BoardView = listView.mode ?? 'columns'
89
+ const scope = readScope(listView.scope)
90
+ const grouping = readGrouping(listView.grouping)
91
+ const sort = listView.sort
92
+
93
+ const stages = usePipelineStages('enquiry')
94
+ const enquiries = useEnquiries({ sort: serverSortOnly(sort, SERVER_SORT_KEYS) })
95
+ const companies = useCompanies({ limit: 200 })
96
+ const members = useMembers()
97
+ const createEnquiry = useCreateEnquiry()
98
+ const updateEnquiry = useUpdateEnquiry()
99
+ const timezone = useTimezone()
100
+
101
+ const allStages = [...stages.records].sort((a, b) => a.sortOrder - b.sortOrder)
102
+ const visibleStages = scope === 'open' ? allStages.filter((stage) => stage.open) : allStages
103
+ const visibleStageIds = new Set(visibleStages.map((stage) => stage.id))
104
+ const visibleEnquiries = enquiries.records.filter((enquiry) =>
105
+ visibleStageIds.has(enquiry.stageId),
106
+ )
107
+
108
+ const askedIds = visibleEnquiries.slice(0, MAX_PAGE_SIZE).map((enquiry) => enquiry.id)
109
+ const planItems = usePlanItems(
110
+ {
111
+ targetType: 'enquiry',
112
+ targetIds: askedIds,
113
+ statuses: ['todo', 'in_progress'],
114
+ limit: MAX_PAGE_SIZE,
115
+ },
116
+ { enabled: askedIds.length > 0 },
117
+ )
118
+ const nextPlanByEnquiry = nextOpenByTarget(planItems.records)
119
+ const plansTruncated = visibleEnquiries.length > MAX_PAGE_SIZE || planItems.hasNext
120
+
121
+ const companyNameById = new Map(companies.records.map((company) => [company.id, company.name]))
122
+
123
+ async function addEnquiry(): Promise<void> {
124
+ const enquiry = await createEnquiry.runAsync({ name: 'New enquiry' })
125
+
126
+ await navigate(`/enquiries/${enquiry.id}`)
127
+ }
128
+
129
+ const stageLabelById = new Map(allStages.map((stage) => [stage.id, stage.label]))
130
+
131
+ const columns: readonly Column<Enquiry>[] = [
132
+ {
133
+ key: 'name',
134
+ header: 'Enquiry',
135
+ sortKey: 'name',
136
+ render: (enquiry) => <span className="font-medium text-ink">{enquiry.name}</span>,
137
+ },
138
+ {
139
+ key: 'source',
140
+ header: 'Source',
141
+ getSortValue: (enquiry) => enquiry.source || null,
142
+ render: (enquiry) => (enquiry.source.length > 0 ? enquiry.source : '—'),
143
+ },
144
+ {
145
+ key: 'company',
146
+ header: 'Company',
147
+ getSortValue: (enquiry) =>
148
+ enquiry.companyId === null ? null : (companyNameById.get(enquiry.companyId) ?? null),
149
+ render: (enquiry) =>
150
+ enquiry.companyId === null
151
+ ? '—'
152
+ : (companyNameById.get(enquiry.companyId) ?? '—'),
153
+ },
154
+ {
155
+ key: 'stage',
156
+ header: 'Stage',
157
+ getSortValue: (enquiry) => stageLabelById.get(enquiry.stageId) ?? null,
158
+ render: (enquiry) => stageLabelById.get(enquiry.stageId) ?? '—',
159
+ },
160
+ {
161
+ key: 'nextPlan',
162
+ header: 'Next plan',
163
+ getSortValue: (enquiry) => nextPlanByEnquiry.get(enquiry.id)?.date ?? null,
164
+ render: (enquiry) => {
165
+ const next = nextPlanByEnquiry.get(enquiry.id)
166
+
167
+ if (next === undefined) {
168
+ return '—'
169
+ }
170
+
171
+ return (
172
+ <span className="text-ink-muted">
173
+ {next.title}
174
+ <span className="ml-1.5 font-mono text-[11px] text-ink-faint">
175
+ {formatDay(next.date)}
176
+ </span>
177
+ </span>
178
+ )
179
+ },
180
+ },
181
+ {
182
+ key: 'owner',
183
+ header: 'Owner',
184
+ getSortValue: (enquiry) =>
185
+ enquiry.ownerId === null ? null : (members.nameById.get(enquiry.ownerId) ?? 'Unknown'),
186
+ render: (enquiry) =>
187
+ enquiry.ownerId === null
188
+ ? '—'
189
+ : (members.nameById.get(enquiry.ownerId) ?? 'Unknown'),
190
+ },
191
+ {
192
+ key: 'tags',
193
+ header: 'Tags',
194
+ getSortValue: (enquiry) => enquiry.tags.join(', ') || null,
195
+ render: (enquiry) =>
196
+ enquiry.tags.length === 0 ? '—' : (
197
+ <span className="flex flex-wrap gap-1">
198
+ {enquiry.tags.map((tag) => (
199
+ <Chip key={tag}>
200
+ <span className="text-[10px]">{tag}</span>
201
+ </Chip>
202
+ ))}
203
+ </span>
204
+ ),
205
+ },
206
+ {
207
+ key: 'summary',
208
+ header: 'Summary',
209
+ getSortValue: (enquiry) => enquiry.summary || null,
210
+ render: (enquiry) =>
211
+ enquiry.summary.length === 0 ? '—' : (
212
+ <span className="text-ink-muted">{enquiry.summary}</span>
213
+ ),
214
+ },
215
+ {
216
+ key: 'createdAt',
217
+ header: 'Created',
218
+ sortKey: 'created_at',
219
+ render: (enquiry) => formatDate(enquiry.createdAt, timezone),
220
+ },
221
+ {
222
+ key: 'updatedAt',
223
+ header: 'Updated',
224
+ sortKey: 'updated_at',
225
+ render: (enquiry) => formatDate(enquiry.updatedAt, timezone),
226
+ },
227
+ ]
228
+
229
+ const pickerOptions = columns.map((column) => ({ key: column.key, label: column.header }))
230
+
231
+ const stageGroups: readonly DataTableGroup<Enquiry>[] = visibleStages.map((stage) => ({
232
+ id: stage.id,
233
+ label: <Chip tone={stageTone(stage)}>{stage.label}</Chip>,
234
+ rows: visibleEnquiries.filter((enquiry) => enquiry.stageId === stage.id),
235
+ }))
236
+
237
+ const dueGroups: readonly DataTableGroup<Enquiry>[] = DUE_BUCKETS.map((bucket) => ({
238
+ id: bucket.id,
239
+ label: bucket.label,
240
+ rows: visibleEnquiries
241
+ .filter(
242
+ (enquiry) => dueBucketFor(nextPlanByEnquiry.get(enquiry.id)?.date) === bucket.id,
243
+ )
244
+ .sort((left, right) => {
245
+ const leftNext = nextPlanByEnquiry.get(left.id)
246
+ const rightNext = nextPlanByEnquiry.get(right.id)
247
+
248
+ if (leftNext === undefined || rightNext === undefined) {
249
+ return left.name.localeCompare(right.name)
250
+ }
251
+
252
+ return byDateThenTitle(leftNext, rightNext) || left.name.localeCompare(right.name)
253
+ }),
254
+ }))
255
+
256
+ const cards = visibleEnquiries.map((enquiry) => {
257
+ const next = nextPlanByEnquiry.get(enquiry.id)
258
+ const companyName =
259
+ enquiry.companyId === null ? undefined : companyNameById.get(enquiry.companyId)
260
+ const meta = [
261
+ enquiry.source.length > 0 ? enquiry.source : undefined,
262
+ companyName,
263
+ next?.title,
264
+ ]
265
+ .filter((part) => part !== undefined)
266
+ .join(' · ')
267
+
268
+ return {
269
+ id: enquiry.id,
270
+ stage: enquiry.stageId,
271
+ title: enquiry.name,
272
+ meta,
273
+ href: `/enquiries/${enquiry.id}`,
274
+ }
275
+ })
276
+
277
+ const isLoading = enquiries.isLoading || stages.isLoading
278
+ const loadError = enquiries.error ?? stages.error
279
+
280
+ return (
281
+ <div className="animate-fade-in">
282
+ <PageHeader
283
+ title="Enquiries"
284
+ onAdd={() => {
285
+ void addEnquiry()
286
+ }}
287
+ addLabel="Add enquiry"
288
+ actions={
289
+ <>
290
+ <Link
291
+ to="/enquiries/settings"
292
+ className="rounded-md border border-border bg-surface-raised px-2.5 py-1 text-[12px] font-medium text-ink-muted hover:text-ink"
293
+ >
294
+ Settings
295
+ </Link>
296
+ <SegmentedControl
297
+ ariaLabel="Stage scope"
298
+ value={scope}
299
+ onChange={(next) => {
300
+ listView.setScope(next)
301
+ }}
302
+ options={[
303
+ { id: 'open', label: 'Open' },
304
+ { id: 'all', label: 'All' },
305
+ ]}
306
+ />
307
+ {view === 'list' && (
308
+ <SegmentedControl
309
+ ariaLabel="Group the list"
310
+ value={grouping}
311
+ onChange={(next) => {
312
+ listView.setGrouping(next)
313
+ }}
314
+ options={[
315
+ { id: 'stage', label: 'By stage' },
316
+ { id: 'due', label: 'By due' },
317
+ ]}
318
+ />
319
+ )}
320
+ <SegmentedControl
321
+ ariaLabel="Board or list"
322
+ value={view}
323
+ onChange={(next) => {
324
+ listView.setMode(next)
325
+ }}
326
+ options={[
327
+ { id: 'list', label: 'List' },
328
+ { id: 'columns', label: 'Board' },
329
+ ]}
330
+ />
331
+ {view === 'list' && (
332
+ <ColumnPicker
333
+ options={pickerOptions}
334
+ visibleKeys={listView.visibleKeys}
335
+ onChange={listView.setVisibleKeys}
336
+ />
337
+ )}
338
+ </>
339
+ }
340
+ />
341
+
342
+ {createEnquiry.error !== null && (
343
+ <div className="mb-3">
344
+ <ErrorPanel error={createEnquiry.error} />
345
+ </div>
346
+ )}
347
+
348
+ {loadError !== null ? (
349
+ <ErrorPanel error={loadError} />
350
+ ) : isLoading ? (
351
+ <LoadingPanel label="Loading enquiries…" />
352
+ ) : (
353
+ <>
354
+ {view === 'list' && <Paginator list={enquiries} placement="top" />}
355
+ {view === 'columns' ? (
356
+ <KanbanBoard
357
+ stages={visibleStages.map((stage) => ({ id: stage.id, label: stage.label }))}
358
+ cards={cards}
359
+ onMove={(enquiryId, stageId) => {
360
+ const enquiry = enquiries.records.find(
361
+ (candidate) => candidate.id === enquiryId,
362
+ )
363
+
364
+ if (enquiry !== undefined && enquiry.stageId !== stageId) {
365
+ updateEnquiry.run({ id: enquiryId, changes: { stageId } })
366
+ }
367
+ }}
368
+ />
369
+ ) : (
370
+ <DataTable
371
+ columns={columns}
372
+ groups={grouping === 'stage' ? stageGroups : dueGroups}
373
+ getRowId={(enquiry) => enquiry.id}
374
+ onRowClick={(enquiry) => {
375
+ void navigate(`/enquiries/${enquiry.id}`)
376
+ }}
377
+ emptyMessage={
378
+ enquiries.records.length === 0
379
+ ? 'No enquiries yet'
380
+ : scope === 'open'
381
+ ? 'No open enquiries'
382
+ : 'No enquiries in this view'
383
+ }
384
+ emptyDescription={
385
+ enquiries.records.length > 0 && scope === 'open'
386
+ ? 'You have enquiries, but none are open right now.'
387
+ : undefined
388
+ }
389
+ emptyAction={
390
+ enquiries.records.length === 0
391
+ ? {
392
+ label: 'Add enquiry',
393
+ onClick: () => {
394
+ void addEnquiry()
395
+ },
396
+ }
397
+ : scope === 'open'
398
+ ? {
399
+ label: 'Show all enquiries',
400
+ onClick: () => {
401
+ listView.setScope('all')
402
+ },
403
+ }
404
+ : undefined
405
+ }
406
+ sort={sort}
407
+ onSortChange={(next) => {
408
+ listView.setSort(next)
409
+ }}
410
+ visibleColumnKeys={listView.visibleKeys}
411
+ />
412
+ )}
413
+ {updateEnquiry.error !== null && (
414
+ <div className="mt-3">
415
+ <ErrorPanel error={updateEnquiry.error} />
416
+ </div>
417
+ )}
418
+ {view === 'list' && <Paginator list={enquiries} />}
419
+ {companies.hasNext && (
420
+ <p className="mt-2 text-[11px] text-ink-faint">
421
+ More companies exist than one page returns, so some company names may show as “—”.
422
+ </p>
423
+ )}
424
+ {plansTruncated && (
425
+ <p className="mt-2 text-[11px] text-ink-faint">
426
+ More plan items exist than one page returns, so some enquiries may show no next
427
+ plan.
428
+ </p>
429
+ )}
430
+ </>
431
+ )}
432
+ </div>
433
+ )
434
+ }