@graph8/sdk 0.373.0 → 0.374.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 (279) hide show
  1. package/README.md +3 -3
  2. package/blocks/_foundation/README.md +47 -0
  3. package/blocks/_foundation/block.json +13 -0
  4. package/blocks/_foundation/components/auth-gate.tsx +363 -0
  5. package/blocks/_foundation/server/auth.ts +815 -0
  6. package/blocks/campaigns/README.md +1 -1
  7. package/blocks/campaigns/block.json +34 -1
  8. package/blocks/campaigns/components/campaign-links.tsx +180 -0
  9. package/blocks/company-intelligence/components/research-run.tsx +6 -2
  10. package/blocks/crm/README.md +19 -0
  11. package/blocks/crm/block.json +205 -6
  12. package/blocks/crm/components/bulk-update.tsx +119 -0
  13. package/blocks/crm/components/contact-documents-tab.tsx +144 -0
  14. package/blocks/crm/components/contact-insights-tab.tsx +118 -0
  15. package/blocks/crm/components/contact-lookup-link.tsx +47 -6
  16. package/blocks/crm/components/contact-row-actions.tsx +43 -0
  17. package/blocks/crm/components/contact-tag-chips.tsx +113 -0
  18. package/blocks/crm/components/contacts-screen.tsx +46 -5
  19. package/blocks/crm/components/owner-assign.tsx +132 -0
  20. package/blocks/crm/components/record-open-links.tsx +79 -0
  21. package/blocks/crm/components/tag-assign.tsx +112 -0
  22. package/blocks/crm/components/tag-manager.tsx +178 -0
  23. package/blocks/crm/components/unlock-info-button.tsx +82 -0
  24. package/blocks/crm/components/verify-email-button.tsx +93 -0
  25. package/blocks/crm/hooks/use-contact-tags.ts +29 -0
  26. package/blocks/crm/lib/record-actions.ts +102 -0
  27. package/blocks/deals/README.md +1 -0
  28. package/blocks/deals/block.json +17 -0
  29. package/blocks/deals/components/create-deal-button.tsx +109 -0
  30. package/blocks/deals/components/deal-detail.tsx +5 -3
  31. package/blocks/dialer/components/call-log-screen.tsx +6 -1
  32. package/blocks/documents/README.md +86 -0
  33. package/blocks/documents/block.json +378 -0
  34. package/blocks/documents/components/account-bar.tsx +48 -0
  35. package/blocks/documents/components/correspondence-form.tsx +95 -0
  36. package/blocks/documents/components/correspondence-import.tsx +106 -0
  37. package/blocks/documents/components/correspondence-panel.tsx +191 -0
  38. package/blocks/documents/components/documents-screen.tsx +76 -0
  39. package/blocks/documents/components/library-document-detail.tsx +175 -0
  40. package/blocks/documents/components/library-document-form.tsx +105 -0
  41. package/blocks/documents/components/library-folder-form.tsx +74 -0
  42. package/blocks/documents/components/library-panel.tsx +235 -0
  43. package/blocks/documents/components/library-upload.tsx +160 -0
  44. package/blocks/documents/components/question-row.tsx +130 -0
  45. package/blocks/documents/components/questionnaire-detail.tsx +197 -0
  46. package/blocks/documents/components/questionnaires-panel.tsx +173 -0
  47. package/blocks/documents/components/record-documents-tab.tsx +109 -0
  48. package/blocks/documents/lib/documents-types.ts +252 -0
  49. package/blocks/documents/lib/download.ts +64 -0
  50. package/blocks/documents/recipes/follow-a-questionnaire.ts +26 -0
  51. package/blocks/documents/recipes/log-account-correspondence.ts +27 -0
  52. package/blocks/documents/recipes/organize-the-library.ts +25 -0
  53. package/blocks/email-signatures/README.md +123 -0
  54. package/blocks/email-signatures/block.json +341 -0
  55. package/blocks/email-signatures/components/email-signatures-screen.tsx +166 -0
  56. package/blocks/email-signatures/components/signature-assign-template.tsx +119 -0
  57. package/blocks/email-signatures/components/signature-block-editor.tsx +87 -0
  58. package/blocks/email-signatures/components/signature-campaign-form.tsx +165 -0
  59. package/blocks/email-signatures/components/signature-campaigns.tsx +75 -0
  60. package/blocks/email-signatures/components/signature-identity-form.tsx +131 -0
  61. package/blocks/email-signatures/components/signature-mailboxes.tsx +195 -0
  62. package/blocks/email-signatures/components/signature-policy.tsx +130 -0
  63. package/blocks/email-signatures/components/signature-preview.tsx +119 -0
  64. package/blocks/email-signatures/components/signature-report.tsx +77 -0
  65. package/blocks/email-signatures/components/signature-template-details.tsx +112 -0
  66. package/blocks/email-signatures/components/signature-template-form.tsx +109 -0
  67. package/blocks/email-signatures/hooks/use-signatures-data.ts +93 -0
  68. package/blocks/email-signatures/lib/signatures-types.ts +392 -0
  69. package/blocks/email-signatures/recipes/assign-a-signature-to-a-new-hire.ts +32 -0
  70. package/blocks/email-signatures/recipes/create-a-template-and-make-it-default.ts +35 -0
  71. package/blocks/email-signatures/recipes/signature-engagement-report.ts +16 -0
  72. package/blocks/enrichment/README.md +22 -4
  73. package/blocks/enrichment/block.json +145 -5
  74. package/blocks/enrichment/components/auto-enrichment-configs.tsx +134 -0
  75. package/blocks/enrichment/components/auto-enrichment-settings.tsx +82 -0
  76. package/blocks/enrichment/components/bulk-enrich-panel.tsx +7 -7
  77. package/blocks/enrichment/components/enrichment-activity.tsx +93 -0
  78. package/blocks/enrichment/components/enrichment-job-detail.tsx +18 -0
  79. package/blocks/enrichment/components/enrichment-screen.tsx +34 -3
  80. package/blocks/enrichment/components/list-pipelines.tsx +97 -0
  81. package/blocks/enrichment/components/pipeline-detail.tsx +214 -0
  82. package/blocks/enrichment/components/pipeline-editor.tsx +144 -0
  83. package/blocks/enrichment/components/pipeline-templates.tsx +90 -0
  84. package/blocks/enrichment/lib/enrichment-types.ts +12 -0
  85. package/blocks/enrichment/lib/pipeline-types.ts +234 -0
  86. package/blocks/forms/README.md +2 -0
  87. package/blocks/forms/components/forms-screen.tsx +6 -3
  88. package/blocks/inbox/README.md +40 -4
  89. package/blocks/inbox/block.json +130 -8
  90. package/blocks/inbox/components/blocked-senders.tsx +103 -0
  91. package/blocks/inbox/components/compose-message.tsx +193 -0
  92. package/blocks/inbox/components/inbox-screen.tsx +54 -6
  93. package/blocks/inbox/components/inbox-search.tsx +106 -0
  94. package/blocks/inbox/components/tag-manager.tsx +136 -0
  95. package/blocks/inbox/components/thread-actions.tsx +133 -0
  96. package/blocks/inbox/components/thread-view.tsx +2 -0
  97. package/blocks/inbox/hooks/use-inbox-mailboxes.ts +31 -0
  98. package/blocks/inbox/lib/inbox-manage.ts +227 -0
  99. package/blocks/newsletter/components/newsletter-screen.tsx +3 -1
  100. package/blocks/pages/block.json +10 -1
  101. package/blocks/pages/hooks/use-landing-page-paths.ts +60 -0
  102. package/blocks/programmatic-pages/README.md +82 -0
  103. package/blocks/programmatic-pages/block.json +292 -0
  104. package/blocks/programmatic-pages/components/programmatic-campaign-form.tsx +136 -0
  105. package/blocks/programmatic-pages/components/programmatic-generate.tsx +112 -0
  106. package/blocks/programmatic-pages/components/programmatic-generated-pages.tsx +210 -0
  107. package/blocks/programmatic-pages/components/programmatic-overview.tsx +84 -0
  108. package/blocks/programmatic-pages/components/programmatic-pages-screen.tsx +221 -0
  109. package/blocks/programmatic-pages/components/programmatic-preview.tsx +66 -0
  110. package/blocks/programmatic-pages/components/programmatic-publish.tsx +102 -0
  111. package/blocks/programmatic-pages/components/programmatic-settings.tsx +96 -0
  112. package/blocks/programmatic-pages/components/programmatic-source-fields.tsx +75 -0
  113. package/blocks/programmatic-pages/components/programmatic-source.tsx +106 -0
  114. package/blocks/programmatic-pages/components/programmatic-variables.tsx +84 -0
  115. package/blocks/programmatic-pages/hooks/use-programmatic-workspace.ts +19 -0
  116. package/blocks/programmatic-pages/lib/programmatic-types.ts +330 -0
  117. package/blocks/programmatic-pages/recipes/create-a-programmatic-campaign.ts +23 -0
  118. package/blocks/programmatic-pages/recipes/render-and-follow.ts +21 -0
  119. package/blocks/programmatic-pages/recipes/review-then-publish.ts +17 -0
  120. package/blocks/quotes/README.md +25 -1
  121. package/blocks/quotes/block.json +123 -6
  122. package/blocks/quotes/components/manual-products.tsx +144 -0
  123. package/blocks/quotes/components/quote-batch-actions.tsx +86 -0
  124. package/blocks/quotes/components/quote-documents.tsx +94 -0
  125. package/blocks/quotes/components/quote-evidence.tsx +95 -0
  126. package/blocks/quotes/components/quote-list.tsx +23 -5
  127. package/blocks/quotes/components/quote-settings.tsx +184 -0
  128. package/blocks/quotes/components/quote-templates.tsx +189 -0
  129. package/blocks/quotes/components/quote-viewer.tsx +16 -3
  130. package/blocks/quotes/components/quotes-screen.tsx +15 -5
  131. package/blocks/quotes/lib/quote-admin.ts +264 -0
  132. package/blocks/sequencer/README.md +55 -0
  133. package/blocks/sequencer/block.json +172 -5
  134. package/blocks/sequencer/components/enrollment-withdraw.tsx +71 -0
  135. package/blocks/sequencer/components/sequence-builder.tsx +13 -3
  136. package/blocks/sequencer/components/sequence-campaign-link.tsx +78 -0
  137. package/blocks/sequencer/components/sequence-detail.tsx +6 -0
  138. package/blocks/sequencer/components/sequence-edit-form.tsx +111 -0
  139. package/blocks/sequencer/components/sequence-enrollments.tsx +2 -1
  140. package/blocks/sequencer/components/sequence-manage-actions.tsx +119 -0
  141. package/blocks/sequencer/components/sequence-senders-panel.tsx +84 -0
  142. package/blocks/sequencer/components/sequence-templates.tsx +160 -0
  143. package/blocks/sequencer/components/sequences-screen.tsx +36 -6
  144. package/blocks/sequencer/components/step-ai-assist.tsx +196 -0
  145. package/blocks/sequencer/components/step-editor.tsx +10 -1
  146. package/blocks/sequencer/components/step-templates.tsx +224 -0
  147. package/blocks/sequencer/lib/sequencer-manage.ts +235 -0
  148. package/blocks/sequencer/lib/sequencer-utils.ts +4 -0
  149. package/blocks/signals/README.md +39 -0
  150. package/blocks/signals/block.json +287 -0
  151. package/blocks/signals/components/auto-seed-panel.tsx +111 -0
  152. package/blocks/signals/components/listener-form.tsx +184 -0
  153. package/blocks/signals/components/matched-posts.tsx +195 -0
  154. package/blocks/signals/components/post-engagements.tsx +114 -0
  155. package/blocks/signals/components/record-social-tab.tsx +49 -0
  156. package/blocks/signals/components/social-listeners-screen.tsx +250 -0
  157. package/blocks/signals/components/social-settings.tsx +128 -0
  158. package/blocks/signals/components/source-lists.tsx +83 -0
  159. package/blocks/signals/components/top-engagers.tsx +68 -0
  160. package/blocks/signals/components/x-integration.tsx +129 -0
  161. package/blocks/signals/lib/signals-types.ts +228 -0
  162. package/blocks/signals/recipes/preview-and-create-a-listener.ts +16 -0
  163. package/blocks/signals/recipes/spend-against-caps.ts +14 -0
  164. package/blocks/signals/recipes/triage-matched-posts.ts +18 -0
  165. package/blocks/visitors/components/traffic-chart.tsx +3 -2
  166. package/blocks/web-analytics/README.md +102 -0
  167. package/blocks/web-analytics/block.json +292 -0
  168. package/blocks/web-analytics/components/acquisition-tab.tsx +154 -0
  169. package/blocks/web-analytics/components/analytics-filters.tsx +161 -0
  170. package/blocks/web-analytics/components/audience-tab.tsx +135 -0
  171. package/blocks/web-analytics/components/conversions-tab.tsx +128 -0
  172. package/blocks/web-analytics/components/engagement-tab.tsx +123 -0
  173. package/blocks/web-analytics/components/overview-tab.tsx +102 -0
  174. package/blocks/web-analytics/components/pages-tab.tsx +148 -0
  175. package/blocks/web-analytics/components/ranked-table.tsx +103 -0
  176. package/blocks/web-analytics/components/realtime-tab.tsx +103 -0
  177. package/blocks/web-analytics/components/report-card.tsx +71 -0
  178. package/blocks/web-analytics/components/web-analytics-screen.tsx +90 -0
  179. package/blocks/web-analytics/hooks/use-report.ts +48 -0
  180. package/blocks/web-analytics/lib/analytics-scope.ts +155 -0
  181. package/blocks/web-analytics/lib/page-path.ts +14 -0
  182. package/blocks/web-analytics/lib/web-analytics-types.ts +187 -0
  183. package/blocks/web-analytics/recipes/best-landing-pages.ts +26 -0
  184. package/blocks/web-analytics/recipes/form-conversion-check.ts +19 -0
  185. package/blocks/web-analytics/recipes/weekly-traffic-report.ts +37 -0
  186. package/blocks/workbench/README.md +54 -2
  187. package/blocks/workbench/block.json +234 -5
  188. package/blocks/workbench/components/workbench-auto-enrich.tsx +88 -0
  189. package/blocks/workbench/components/workbench-automation.tsx +36 -0
  190. package/blocks/workbench/components/workbench-connections.tsx +136 -0
  191. package/blocks/workbench/components/workbench-crm-import.tsx +91 -0
  192. package/blocks/workbench/components/workbench-datafile-import.tsx +154 -0
  193. package/blocks/workbench/components/workbench-grid.tsx +70 -7
  194. package/blocks/workbench/components/workbench-pipelines.tsx +173 -0
  195. package/blocks/workbench/components/workbench-row-actions.tsx +8 -1
  196. package/blocks/workbench/components/workbench-schedule.tsx +191 -0
  197. package/blocks/workbench/components/workbench-screen.tsx +50 -17
  198. package/blocks/workbench/components/workbench-send-to-sequence.tsx +116 -0
  199. package/blocks/workbench/components/workbench-table-settings.tsx +53 -0
  200. package/blocks/workbench/components/workbench-triggers.tsx +178 -0
  201. package/blocks/workbench/hooks/use-connected-crms.ts +29 -0
  202. package/blocks/workbench/lib/api-status.ts +8 -0
  203. package/blocks/workbench/lib/automation-types.ts +257 -0
  204. package/dist/cli.js +257 -51
  205. package/dist/index.browser.js +1 -1
  206. package/dist/index.browser.js.map +1 -1
  207. package/dist/index.browser.mjs +1 -1
  208. package/dist/index.browser.mjs.map +1 -1
  209. package/dist/index.js +1 -1
  210. package/dist/index.js.map +1 -1
  211. package/dist/index.mjs +1 -1
  212. package/dist/index.mjs.map +1 -1
  213. package/dist/react.browser.js +1 -1
  214. package/dist/react.browser.js.map +1 -1
  215. package/dist/react.browser.mjs +1 -1
  216. package/dist/react.browser.mjs.map +1 -1
  217. package/dist/react.js +1 -1
  218. package/dist/react.js.map +1 -1
  219. package/dist/react.mjs +1 -1
  220. package/dist/react.mjs.map +1 -1
  221. package/dist/types/blocks/auth.d.ts +45 -0
  222. package/dist/types/blocks/create.d.ts +5 -2
  223. package/dist/types/version.d.ts +2 -2
  224. package/docs/api/forms.md +1 -1
  225. package/package.json +1 -1
  226. package/stacks/6sense/pages/contacts.tsx.tmpl +2 -0
  227. package/stacks/6sense.json +20 -0
  228. package/stacks/apollo/pages/calls.tsx.tmpl +9 -9
  229. package/stacks/apollo/pages/campaigns.tsx.tmpl +27 -0
  230. package/stacks/apollo/pages/contacts.tsx.tmpl +6 -0
  231. package/stacks/apollo/pages/inbox-contact.tsx.tmpl +3 -3
  232. package/stacks/apollo/pages/lists.tsx.tmpl +14 -2
  233. package/stacks/apollo/pages/sequences.tsx.tmpl +8 -2
  234. package/stacks/apollo.json +33 -2
  235. package/stacks/clay/pages/contacts.tsx.tmpl +2 -0
  236. package/stacks/clay/pages/lists.tsx.tmpl +14 -2
  237. package/stacks/gong/pages/calls.tsx.tmpl +9 -9
  238. package/stacks/gong/pages/contacts.tsx.tmpl +2 -0
  239. package/stacks/hootsuite/pages/contacts.tsx.tmpl +2 -0
  240. package/stacks/hootsuite.json +10 -0
  241. package/stacks/hubspot/pages/campaigns.tsx.tmpl +30 -0
  242. package/stacks/hubspot/pages/companies.tsx.tmpl +2 -0
  243. package/stacks/hubspot/pages/contacts.tsx.tmpl +6 -0
  244. package/stacks/hubspot/pages/deals.tsx.tmpl +4 -0
  245. package/stacks/hubspot/pages/forms.tsx.tmpl +36 -0
  246. package/stacks/hubspot/pages/inbox.tsx.tmpl +2 -2
  247. package/stacks/hubspot/pages/sequences.tsx.tmpl +8 -2
  248. package/stacks/hubspot/pages/web-analytics.tsx.tmpl +14 -0
  249. package/stacks/hubspot.json +45 -2
  250. package/stacks/intercom/pages/inbox-contact.tsx.tmpl +3 -3
  251. package/stacks/intercom.json +10 -0
  252. package/stacks/mailchimp/pages/contacts.tsx.tmpl +2 -0
  253. package/stacks/mailchimp/pages/forms.tsx.tmpl +36 -0
  254. package/stacks/mailchimp.json +2 -1
  255. package/stacks/outreach/pages/calls.tsx.tmpl +9 -9
  256. package/stacks/outreach/pages/campaigns.tsx.tmpl +27 -0
  257. package/stacks/outreach/pages/contacts.tsx.tmpl +2 -0
  258. package/stacks/outreach/pages/inbox-contact.tsx.tmpl +3 -3
  259. package/stacks/outreach/pages/sequences.tsx.tmpl +8 -2
  260. package/stacks/outreach.json +13 -2
  261. package/stacks/pipedrive/pages/contacts.tsx.tmpl +2 -0
  262. package/stacks/pipedrive/pages/inbox.tsx.tmpl +2 -2
  263. package/stacks/pipedrive.json +10 -0
  264. package/stacks/salesforce/pages/companies.tsx.tmpl +2 -0
  265. package/stacks/salesforce/pages/contacts.tsx.tmpl +6 -0
  266. package/stacks/salesforce/pages/deals.tsx.tmpl +4 -0
  267. package/stacks/salesforce.json +10 -0
  268. package/stacks/zoominfo/pages/contacts.tsx.tmpl +4 -0
  269. package/stacks/zoominfo/pages/lists.tsx.tmpl +14 -2
  270. package/stacks/zoominfo.json +20 -0
  271. package/styles/styles.css +1 -1
  272. package/templates/auth/auth-route.ts.tmpl +16 -0
  273. package/templates/auth/g8.auth.anonymous.ts.tmpl +15 -0
  274. package/templates/auth/g8.auth.basic.ts.tmpl +17 -0
  275. package/templates/auth/g8.auth.jwt.ts.tmpl +23 -0
  276. package/templates/auth/g8.auth.password.ts.tmpl +18 -0
  277. package/templates/auth/g8.auth.session.ts.tmpl +23 -0
  278. package/templates/auth/middleware.ts.tmpl +14 -0
  279. package/templates/next-app/README.md.tmpl +5 -0
@@ -0,0 +1,95 @@
1
+ "use client";
2
+
3
+ /**
4
+ * CorrespondenceForm: record ONE message, call or note against an account.
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. listContacts, through ContactPicker read find the person at the account (optional)
8
+ * 2. createAccountCorrespondence (POST /accounts/{account_id}/correspondence) write stores the record and indexes it so generated documents can read it
9
+ * `correspondence_date` is when it HAPPENED, not when it was recorded: generated documents read it as the relationship's
10
+ * timeline, so it is required and sent as the exact moment (never as a date alone). Channel and direction have no default:
11
+ * a wrong guess would put the message on the wrong side of the timeline. Nothing here is billable, external or destructive.
12
+ */
13
+
14
+ import { useState } from "react";
15
+ import { Button, Input, Label, Textarea } from "@graph8/sdk/ui";
16
+ import { ApiError } from "@/g8/components/api-error";
17
+ import { g8Post } from "@/g8/lib/g8-client";
18
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
19
+ import { ContactPicker } from "@/g8/pickers/components/contact-picker";
20
+ import type { ContactOption } from "@/g8/pickers/components/contact-picker";
21
+ import { CORRESPONDENCE_CHANNELS, DIRECTIONS } from "../lib/documents-types";
22
+ import type { Correspondence } from "../lib/documents-types";
23
+
24
+ export interface CorrespondenceFormProps {
25
+ accountId: number;
26
+ /** Pre-select the person (for example from a contact page). */
27
+ stakeholder?: ContactOption | null;
28
+ onSaved: (record: Correspondence) => void;
29
+ onCancel?: () => void;
30
+ }
31
+
32
+ const SELECT = "border-input bg-background h-9 w-full rounded-md border px-2 text-sm";
33
+
34
+ export function CorrespondenceForm({ accountId, stakeholder, onSaved, onCancel }: CorrespondenceFormProps) {
35
+ const [channel, setChannel] = useState("");
36
+ const [direction, setDirection] = useState("");
37
+ const [when, setWhen] = useState("");
38
+ const [subject, setSubject] = useState("");
39
+ const [content, setContent] = useState("");
40
+ const [person, setPerson] = useState<ContactOption | null>(stakeholder ?? null);
41
+ const [externalId, setExternalId] = useState("");
42
+ const date = when ? new Date(when) : null;
43
+ const valid = channel !== "" && direction !== "" && content.trim() !== "" && date !== null && !Number.isNaN(date.getTime());
44
+
45
+ const save = useG8Mutation(
46
+ async (): Promise<Correspondence> => {
47
+ const body: Record<string, unknown> = { channel, direction, content: content.trim(), correspondence_date: date!.toISOString() };
48
+ if (subject.trim()) body.subject = subject.trim();
49
+ if (person) body.stakeholder_id = String(person.id);
50
+ if (externalId.trim()) body.external_id = externalId.trim();
51
+ return (await g8Post<Correspondence>(`/accounts/${encodeURIComponent(String(accountId))}/correspondence`, body)).data;
52
+ },
53
+ { onSuccess: onSaved, invalidates: [["documents", "correspondence"]] },
54
+ );
55
+
56
+ return (
57
+ <form className="grid gap-3" onSubmit={(e) => { e.preventDefault(); if (valid) void save.mutate(undefined); }}>
58
+ <div className="grid gap-3 sm:grid-cols-2">
59
+ <div>
60
+ <Label htmlFor="g8-dc-channel" className="mb-1">Channel</Label>
61
+ <select id="g8-dc-channel" className={SELECT} value={channel} onChange={(e) => setChannel(e.target.value)}>
62
+ <option value="">Choose a channel</option>
63
+ {CORRESPONDENCE_CHANNELS.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
64
+ </select>
65
+ </div>
66
+ <div>
67
+ <Label htmlFor="g8-dc-direction" className="mb-1">Direction</Label>
68
+ <select id="g8-dc-direction" className={SELECT} value={direction} onChange={(e) => setDirection(e.target.value)}>
69
+ <option value="">Choose a direction</option>
70
+ {DIRECTIONS.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
71
+ </select>
72
+ </div>
73
+ </div>
74
+ <div>
75
+ <Label htmlFor="g8-dc-when" className="mb-1">When it happened</Label>
76
+ <Input id="g8-dc-when" type="datetime-local" value={when} onChange={(e) => setWhen(e.target.value)} />
77
+ </div>
78
+ <div><Label htmlFor="g8-dc-subject" className="mb-1">Subject (optional)</Label><Input id="g8-dc-subject" value={subject} onChange={(e) => setSubject(e.target.value)} /></div>
79
+ <div><Label htmlFor="g8-dc-content" className="mb-1">What was said</Label><Textarea id="g8-dc-content" rows={8} value={content} onChange={(e) => setContent(e.target.value)} /></div>
80
+ <div>
81
+ <Label htmlFor="g8-dc-person" className="mb-1">Person at the account (optional)</Label>
82
+ <div className="flex items-center gap-2">
83
+ <ContactPicker id="g8-dc-person" label="Person" value={person} onChange={setPerson} />
84
+ {person ? <Button type="button" variant="ghost" size="sm" onClick={() => setPerson(null)}>Clear</Button> : null}
85
+ </div>
86
+ </div>
87
+ <div><Label htmlFor="g8-dc-ext" className="mb-1">Your id for it (optional)</Label><Input id="g8-dc-ext" value={externalId} onChange={(e) => setExternalId(e.target.value)} /></div>
88
+ {save.error ? <ApiError error={save.error} title="Could not record the message" /> : null}
89
+ <div className="flex gap-2">
90
+ <Button type="submit" loading={save.loading} disabled={!valid}>Record message</Button>
91
+ {onCancel ? <Button type="button" variant="outline" onClick={onCancel}>Cancel</Button> : null}
92
+ </div>
93
+ </form>
94
+ );
95
+ }
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ /**
4
+ * CorrespondenceImport: bring existing correspondence in, as a batch or as one file.
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. importAccountCorrespondence (POST /accounts/{account_id}/correspondence/import) write a JSON array of messages; PARTIAL SUCCESS is normal (the status is 200 even when rows failed), so the imported, skipped and error counts are shown
8
+ * 2. uploadAccountCorrespondence (POST /accounts/{account_id}/correspondence/upload) write one text or .eml file becomes one record (multipart)
9
+ * A message with an `external_id` that is already stored is SKIPPED, which makes a re-run safe; messages without one are
10
+ * added again on every import, and the form says how many of those the batch holds. An uploaded file is dated NOW (the API
11
+ * has no date for it): to date a message when it happened, record it with CorrespondenceForm instead. Files are read as text,
12
+ * so only text types are offered. Nothing here is billable, external or destructive.
13
+ */
14
+
15
+ import { useState } from "react";
16
+ import { Button, Label, Textarea } from "@graph8/sdk/ui";
17
+ import { ApiError } from "@/g8/components/api-error";
18
+ import { g8Post } from "@/g8/lib/g8-client";
19
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
20
+ import { plural } from "@/g8/lib/plural";
21
+ import { CORRESPONDENCE_CHANNELS, DEFAULT_MAX_BYTES, DIRECTIONS, checkFiles, fileLimit, fmtSize, parseImportItems } from "../lib/documents-types";
22
+ import type { ImportResult, Rec } from "../lib/documents-types";
23
+
24
+ export interface CorrespondenceImportProps {
25
+ accountId: number;
26
+ maxBytes?: number;
27
+ onImported?: () => void;
28
+ onDone?: () => void;
29
+ }
30
+
31
+ const TEXT_TYPES = ["eml", "txt", "md", "csv", "json"] as const;
32
+ const SELECT = "border-input bg-background h-9 w-full rounded-md border px-2 text-sm";
33
+ const EXAMPLE = '[{"channel":"email","direction":"inbound","subject":"Re: pricing","content":"Thanks for the call...","correspondence_date":"2026-09-30T14:00:00Z","external_id":"msg-1001"}]';
34
+
35
+ export function CorrespondenceImport({ accountId, maxBytes = DEFAULT_MAX_BYTES, onImported, onDone }: CorrespondenceImportProps) {
36
+ const [mode, setMode] = useState<"batch" | "file">("batch");
37
+ const [text, setText] = useState("");
38
+ const [file, setFile] = useState<File | null>(null);
39
+ const [channel, setChannel] = useState("");
40
+ const [direction, setDirection] = useState("");
41
+ const [result, setResult] = useState<ImportResult | null>(null);
42
+ const [saved, setSaved] = useState(false);
43
+ const base = `/accounts/${encodeURIComponent(String(accountId))}/correspondence`;
44
+ const parsed = text.trim() ? parseImportItems(text) : null;
45
+ const noId = parsed && !parsed.problem ? parsed.items.filter((i) => !i.external_id).length : 0;
46
+ const fileCheck = file ? checkFiles([file], TEXT_TYPES, fileLimit(maxBytes)) : null;
47
+
48
+ const importBatch = useG8Mutation(async (items: Rec[]): Promise<ImportResult> => (await g8Post<ImportResult>(`${base}/import`, { items })).data ?? {}, {
49
+ onSuccess: (r) => { setResult(r); onImported?.(); },
50
+ invalidates: [["documents", "correspondence"]],
51
+ });
52
+ const uploadOne = useG8Mutation(
53
+ async (): Promise<Rec> => {
54
+ const form = new FormData();
55
+ form.append("file", file!);
56
+ form.append("channel", channel);
57
+ form.append("direction", direction);
58
+ return (await g8Post<Rec>(`${base}/upload`, form)).data;
59
+ },
60
+ { onSuccess: () => { setSaved(true); setFile(null); onImported?.(); }, invalidates: [["documents", "correspondence"]] },
61
+ );
62
+
63
+ const err = importBatch.error ?? uploadOne.error;
64
+ return (
65
+ <div className="grid gap-3">
66
+ <div role="group" aria-label="How to bring messages in" className="flex gap-1">
67
+ <Button size="sm" variant={mode === "batch" ? "default" : "outline"} aria-pressed={mode === "batch"} onClick={() => setMode("batch")}>Paste a batch</Button>
68
+ <Button size="sm" variant={mode === "file" ? "default" : "outline"} aria-pressed={mode === "file"} onClick={() => setMode("file")}>Upload a file</Button>
69
+ </div>
70
+ {mode === "batch" ? (
71
+ <form className="grid gap-3" onSubmit={(e) => { e.preventDefault(); if (parsed && !parsed.problem) void importBatch.mutate(parsed.items); }}>
72
+ <div>
73
+ <Label htmlFor="g8-ci-json" className="mb-1">Messages as JSON</Label>
74
+ <Textarea id="g8-ci-json" rows={10} value={text} onChange={(e) => { setText(e.target.value); setResult(null); }} placeholder={EXAMPLE} className="font-mono text-xs" />
75
+ <p className="text-muted-foreground mt-1 text-xs">Each message needs channel (email, linkedin or call_notes), direction (inbound or outbound), content and correspondence_date. Add external_id to make a re-run skip what is already there.</p>
76
+ </div>
77
+ {parsed?.problem ? <p role="alert" className="text-error text-xs">{parsed.problem}</p> : null}
78
+ {parsed && !parsed.problem ? <p className="text-xs">{plural(parsed.items.length, "message")} ready.{noId > 0 ? ` ${plural(noId, "message")} ${noId === 1 ? "has" : "have"} no external_id and would be added again if you import this twice.` : ""}</p> : null}
79
+ {result ? (
80
+ <div role="status" className="grid gap-1 rounded-md border p-3 text-sm">
81
+ <p className="font-medium">{result.imported ?? 0} imported, {result.skipped ?? 0} skipped{(result.errors ?? []).length > 0 ? `, ${(result.errors ?? []).length} failed` : ""}.</p>
82
+ {(result.errors ?? []).map((m) => <p key={m} className="text-error text-xs">{m}</p>)}
83
+ </div>
84
+ ) : null}
85
+ <div className="flex gap-2"><Button type="submit" loading={importBatch.loading} disabled={!parsed || Boolean(parsed.problem)}>Import messages</Button>{onDone ? <Button type="button" variant="outline" onClick={onDone}>Close</Button> : null}</div>
86
+ </form>
87
+ ) : (
88
+ <form className="grid gap-3" onSubmit={(e) => { e.preventDefault(); if (file && fileCheck?.ok.length === 1 && channel && direction) void uploadOne.mutate(undefined); }}>
89
+ <div>
90
+ <Label htmlFor="g8-ci-file" className="mb-1">File</Label>
91
+ <input id="g8-ci-file" type="file" accept={TEXT_TYPES.map((x) => `.${x}`).join(",")} className="border-input bg-background w-full rounded-md border p-2 text-sm" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setSaved(false); }} />
92
+ <p className="text-muted-foreground mt-1 text-xs">A forwarded .eml or an exported thread, as text ({TEXT_TYPES.join(", ")}), up to {fmtSize(fileLimit(maxBytes))}. It is recorded with today&apos;s date; to date it when it happened, record the message instead.</p>
93
+ </div>
94
+ {fileCheck?.problems.length ? <ul role="alert" className="text-error list-disc pl-5 text-xs">{fileCheck.problems.map((p) => <li key={p}>{p}</li>)}</ul> : null}
95
+ <div className="grid gap-3 sm:grid-cols-2">
96
+ <div><Label htmlFor="g8-ci-channel" className="mb-1">Channel</Label><select id="g8-ci-channel" className={SELECT} value={channel} onChange={(e) => setChannel(e.target.value)}><option value="">Choose a channel</option>{CORRESPONDENCE_CHANNELS.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}</select></div>
97
+ <div><Label htmlFor="g8-ci-direction" className="mb-1">Direction</Label><select id="g8-ci-direction" className={SELECT} value={direction} onChange={(e) => setDirection(e.target.value)}><option value="">Choose a direction</option>{DIRECTIONS.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}</select></div>
98
+ </div>
99
+ {saved ? <p role="status" className="text-sm">The file was recorded.</p> : null}
100
+ <div className="flex gap-2"><Button type="submit" loading={uploadOne.loading} disabled={!file || fileCheck?.ok.length !== 1 || !channel || !direction}>Record file</Button>{onDone ? <Button type="button" variant="outline" onClick={onDone}>Close</Button> : null}</div>
101
+ </form>
102
+ )}
103
+ {err ? <ApiError error={err} title="Could not bring the messages in" /> : null}
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,191 @@
1
+ "use client";
2
+
3
+ /**
4
+ * CorrespondencePanel: what has been said to one account, across channels, as a finished page: the list with a channel and
5
+ * person filter, the selected message, record one, bring a batch in, delete.
6
+ *
7
+ * SDK operations, in order:
8
+ * 1. listAccountCorrespondence (GET /accounts/{account_id}/correspondence) read newest first, paged 50 at a time with Load more; filtered by channel and person on the server
9
+ * 2. deleteAccountCorrespondence (DELETE .../correspondence/{correspondence_id}) DESTRUCTIVE removes the record; documents already generated from it are NOT rewritten; behind ConfirmSpendDialog
10
+ * Recording one message (CorrespondenceForm) and importing (CorrespondenceImport) are the composed parts. Pass `accountId`
11
+ * (a company id); without one the panel asks for an account.
12
+ * URL: `?cid=` the open message, `&view=full` as a page.
13
+ */
14
+
15
+ import { useState } from "react";
16
+ import type { ReactNode } from "react";
17
+ import { Mail, MessagesSquare, Plus, Trash2, Upload } from "lucide-react";
18
+ import { Button, Skeleton } from "@graph8/sdk/ui";
19
+ import { useBrand } from "@/g8/hooks/use-brand";
20
+ import { ApiError } from "@/g8/components/api-error";
21
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
22
+ import { EmptyState } from "@/g8/components/empty-state";
23
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
24
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
25
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
26
+ import { SplitView } from "@/g8/components/screen/split-view";
27
+ import { StatStrip } from "@/g8/components/screen/stat-strip";
28
+ import { StatusPill } from "@/g8/components/screen/status-pill";
29
+ import { Toolbar } from "@/g8/components/screen/toolbar";
30
+ import { LoadMore } from "@/g8/components/paging-notices";
31
+ import { useLoadMoreList } from "@/g8/hooks/use-load-more-list";
32
+ import { useUrlState } from "@/g8/hooks/use-url-state";
33
+ import { byOffset } from "@/g8/lib/paging";
34
+ import { g8Delete, g8Get } from "@/g8/lib/g8-client";
35
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
36
+ import { ContactPicker } from "@/g8/pickers/components/contact-picker";
37
+ import type { ContactOption } from "@/g8/pickers/components/contact-picker";
38
+ import { CORRESPONDENCE_CHANNELS, DEFAULT_MAX_BYTES, rowsOf, totalOf, when } from "../lib/documents-types";
39
+ import type { Correspondence, Rec } from "../lib/documents-types";
40
+ import { CorrespondenceForm } from "./correspondence-form";
41
+ import { CorrespondenceImport } from "./correspondence-import";
42
+
43
+ export interface CorrespondencePanelProps {
44
+ accountId: number | null;
45
+ renderRowActions?: (c: Correspondence) => ReactNode;
46
+ toolbarActions?: ReactNode;
47
+ maxBytes?: number;
48
+ }
49
+
50
+ const PAGE = 50;
51
+ const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
52
+ const label = (list: readonly { value: string; label: string }[], v: string | null | undefined): string => list.find((x) => x.value === v)?.label ?? (v ? v.replace(/_/g, " ") : "");
53
+ const headline = (c: Correspondence): string => c.subject || (c.content ? `${c.content.slice(0, 80)}${c.content.length > 80 ? "..." : ""}` : "No subject");
54
+
55
+ export function CorrespondencePanel({ accountId, renderRowActions, toolbarActions, maxBytes = DEFAULT_MAX_BYTES }: CorrespondencePanelProps) {
56
+ const brand = useBrand();
57
+ const [cid, setCid] = useUrlState("cid", "");
58
+ const [view, setView] = useUrlState("view", "");
59
+ const [search, setSearch] = useState("");
60
+ const [channel, setChannel] = useState("");
61
+ const [person, setPerson] = useState<ContactOption | null>(null);
62
+ const [sheet, setSheet] = useState<null | "record" | "import">(null);
63
+ const [toDelete, setToDelete] = useState<Correspondence | null>(null);
64
+
65
+ const list = useLoadMoreList<Correspondence>(
66
+ byOffset(PAGE, async (offset, signal) => {
67
+ if (accountId == null) return { rows: [], hasNext: false };
68
+ const env = await g8Get<Rec>(`/accounts/${encodeURIComponent(String(accountId))}/correspondence`, { channel: channel || undefined, stakeholder_id: person ? String(person.id) : undefined, limit: PAGE, offset }, { signal });
69
+ const rows = rowsOf<Correspondence>(env.data);
70
+ const total = totalOf(env.data);
71
+ return { rows, hasNext: total != null ? offset + rows.length < total : rows.length >= PAGE };
72
+ }),
73
+ [accountId, channel, person?.id],
74
+ );
75
+
76
+ const remove = useG8Mutation(
77
+ (c: Correspondence) => g8Delete(`/accounts/${encodeURIComponent(String(accountId))}/correspondence/${encodeURIComponent(c.id)}`),
78
+ { onSuccess: (_r, c) => { setToDelete(null); if (c.id === cid) { setCid(null); setView(null); } void list.refetch(); }, invalidates: [["documents", "correspondence"]] },
79
+ );
80
+
81
+ const rows = list.data ?? [];
82
+ const q = search.trim().toLowerCase();
83
+ const shown = rows.filter((r) => !q || `${r.subject ?? ""} ${r.content ?? ""}`.toLowerCase().includes(q));
84
+ const selected = rows.find((r) => r.id === cid) ?? null;
85
+ const closeDetail = (): void => { setCid(null); setView(null); };
86
+ const n = (f: (r: Correspondence) => boolean): string => (list.data ? `${rows.filter(f).length.toLocaleString()}${list.hasMore ? "+" : ""}` : "...");
87
+
88
+ if (accountId == null) {
89
+ return <EmptyState icon={<MessagesSquare className="size-8" />} title="Choose an account" description="Pick a company above to see what has been said to it, or to record a message." />;
90
+ }
91
+
92
+ const body = list.error ? <ApiError error={list.error} title="Could not load correspondence" onRetry={() => void list.refetch()} /> : list.loading && !list.data ? (
93
+ <div aria-busy="true" className="grid gap-2"><Skeleton className="h-14 w-full" /><Skeleton className="h-14 w-full" /></div>
94
+ ) : shown.length === 0 ? (
95
+ <EmptyState icon={<Mail className="size-8" />} title={rows.length === 0 && !channel && !person ? "Nothing recorded yet" : "No messages match"} description={rows.length === 0 && !channel && !person ? `Record the emails, LinkedIn messages and call notes that happened off ${brand.name}, so every generated document knows the relationship.` : "Try another search or filter."} action={rows.length === 0 && !channel && !person ? <Button onClick={() => setSheet("record")}>Record a message</Button> : undefined} />
96
+ ) : (
97
+ <>
98
+ <ul className="grid gap-2">
99
+ {shown.map((r) => (
100
+ <li key={r.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-md border p-3 ${r.id === cid ? "bg-muted" : ""}`}>
101
+ <button type="button" className="min-w-0 flex-1 text-left" onClick={() => setCid(r.id)}>
102
+ <span className="block truncate font-medium">{headline(r)}</span>
103
+ <span className="text-muted-foreground block truncate text-xs">{label(CORRESPONDENCE_CHANNELS, r.channel) || "-"} · {when(r.correspondence_date) || "-"}</span>
104
+ </button>
105
+ {r.direction ? <StatusPill tone={r.direction === "inbound" ? "info" : "neutral"}>{label([{ value: "inbound", label: "Inbound" }, { value: "outbound", label: "Outbound" }], r.direction)}</StatusPill> : null}
106
+ {renderRowActions?.(r)}
107
+ </li>
108
+ ))}
109
+ </ul>
110
+ <LoadMore shown={rows.length} noun="messages" hasMore={list.hasMore} loading={list.loadingMore} onLoadMore={list.loadMore} />
111
+ </>
112
+ );
113
+
114
+ return (
115
+ <div className="grid gap-4">
116
+ <StatStrip items={[
117
+ { label: "Messages", value: list.data ? `${rows.length.toLocaleString()}${list.hasMore ? "+" : ""}` : "..." },
118
+ { label: "Inbound", value: n((r) => r.direction === "inbound") },
119
+ { label: "Outbound", value: n((r) => r.direction === "outbound") },
120
+ { label: "Latest", value: rows[0]?.correspondence_date ? new Date(rows[0].correspondence_date).toLocaleDateString() : "-", hint: "The newest message loaded." },
121
+ ]} />
122
+ <SplitView
123
+ list={(
124
+ <div className="grid gap-3">
125
+ <Toolbar
126
+ search={{ value: search, onChange: setSearch, placeholder: "Search messages" }}
127
+ filters={(
128
+ <>
129
+ <select aria-label="Filter by channel" className={SELECT} value={channel} onChange={(e) => { setChannel(e.target.value); closeDetail(); }}><option value="">All channels</option>{CORRESPONDENCE_CHANNELS.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}</select>
130
+ <span className="flex items-center gap-1"><ContactPicker label="Person" value={person} onChange={(p) => { setPerson(p); closeDetail(); }} />{person ? <Button size="sm" variant="ghost" onClick={() => { setPerson(null); closeDetail(); }}>Clear person</Button> : null}</span>
131
+ </>
132
+ )}
133
+ actions={<>{toolbarActions}<Button size="sm" variant="outline" onClick={() => setSheet("import")}><Upload className="size-4" />Import</Button><Button size="sm" onClick={() => setSheet("record")}><Plus className="size-4" />Record message</Button></>}
134
+ />
135
+ {body}
136
+ </div>
137
+ )}
138
+ detail={selected ? (
139
+ <DetailPanel
140
+ key={selected.id}
141
+ title={headline(selected)}
142
+ subtitle={`${label(CORRESPONDENCE_CHANNELS, selected.channel)} · ${when(selected.correspondence_date)}`}
143
+ badges={selected.direction ? <StatusPill tone={selected.direction === "inbound" ? "info" : "neutral"}>{selected.direction === "inbound" ? "Inbound" : "Outbound"}</StatusPill> : undefined}
144
+ actions={<Button size="sm" variant="outline" onClick={() => setToDelete(selected)}><Trash2 className="size-4" />Delete</Button>}
145
+ onClose={closeDetail}
146
+ onExpand={() => setView("full")}
147
+ onCollapse={view === "full" ? () => setView(null) : undefined}
148
+ tabs={[
149
+ { id: "message", label: "Message", content: selected.content ? <pre aria-label="Message text" className="bg-muted max-h-[28rem] overflow-auto whitespace-pre-wrap rounded-md p-3 text-sm">{selected.content}</pre> : <EmptyState compact title="No text" /> },
150
+ { id: "details", label: "Details", content: (
151
+ <KeyValueList columns={2} items={[
152
+ { label: "Channel", value: label(CORRESPONDENCE_CHANNELS, selected.channel) },
153
+ { label: "Direction", value: selected.direction ? (selected.direction === "inbound" ? "Inbound" : "Outbound") : "" },
154
+ { label: "Happened", value: when(selected.correspondence_date) },
155
+ { label: "Recorded", value: when(selected.created_at) },
156
+ { label: "Source", value: selected.source },
157
+ { label: "Read by the AI", value: selected.indexed_in_rag == null ? "" : selected.indexed_in_rag ? "Yes" : "Not yet" },
158
+ { label: "Person id", value: selected.stakeholder_id, mono: true },
159
+ { label: "Your id", value: selected.external_id, mono: true },
160
+ { label: "Record id", value: selected.id, mono: true },
161
+ ]} />
162
+ ) },
163
+ ]}
164
+ />
165
+ ) : null}
166
+ detailOpen={Boolean(selected)}
167
+ onCloseDetail={closeDetail}
168
+ expanded={view === "full"}
169
+ onExpandedChange={(e) => setView(e ? "full" : null)}
170
+ detailWidth="xl"
171
+ />
172
+ <FormSheet open={sheet === "record"} onOpenChange={(o) => setSheet(o ? "record" : null)} title="Record a message" description={`An email, LinkedIn message or call note that happened off ${brand.name}.`} width="lg">
173
+ {sheet === "record" ? <CorrespondenceForm accountId={accountId} stakeholder={person} onCancel={() => setSheet(null)} onSaved={(r) => { setSheet(null); void list.refetch(); setCid(r.id); }} /> : null}
174
+ </FormSheet>
175
+ <FormSheet open={sheet === "import"} onOpenChange={(o) => setSheet(o ? "import" : null)} title="Import messages" description="Bring a batch or a file in." width="lg">
176
+ {sheet === "import" ? <CorrespondenceImport accountId={accountId} maxBytes={maxBytes} onImported={() => void list.refetch()} onDone={() => setSheet(null)} /> : null}
177
+ </FormSheet>
178
+ <ConfirmSpendDialog
179
+ open={toDelete !== null}
180
+ tier="destructive"
181
+ title="Delete this message?"
182
+ affected={1}
183
+ description={<><p>The record is removed. Documents already generated from it are not rewritten.</p>{remove.error ? <ApiError error={remove.error} title="Could not delete the message" /> : null}</>}
184
+ confirmLabel="Delete"
185
+ confirming={remove.loading}
186
+ onCancel={() => setToDelete(null)}
187
+ onConfirm={() => { if (toDelete) void remove.mutate(toDelete); }}
188
+ />
189
+ </div>
190
+ );
191
+ }
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ /**
4
+ * DocumentsScreen: the finished documents workspace. No props needed.
5
+ *
6
+ * Composes: ScreenLayout (title, tabs), LibraryPanel (the organization's document Library), AccountBar + QuestionnairesPanel
7
+ * (an account's security questionnaires and their review) and AccountBar + CorrespondencePanel (what has been said to an account).
8
+ *
9
+ * Tabs (`?tab=`): Library, Security questionnaires, Correspondence. The two account tabs share one account (`?account=<company id>`),
10
+ * so a link opens straight to a company's paperwork.
11
+ *
12
+ * User flows: browse the Library by folder; search and filter it; open a document to read its text, its facts, download the
13
+ * original, edit it, read a PDF or Word file's text again, restore a removed one, or delete it (confirmed); make, rename and
14
+ * delete folders; write a text document; upload one or many files (checked against the Library's types and the proxy's size
15
+ * limit first). For an account: upload a buyer's questionnaire (confirmed, billable), follow it while it is answered, review every
16
+ * answer (edit, approve, escalate, mark not applicable, mark wrong, save to the answer library), download the filled workbook,
17
+ * rename or delete it (confirmed); record a message, import a batch or a file, filter by channel and person, delete (confirmed).
18
+ *
19
+ * SDK operations: none directly; every call lives in the composed parts, which confirm each billable and destructive action.
20
+ */
21
+
22
+ import { FileText } from "lucide-react";
23
+ import type { ReactNode } from "react";
24
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
25
+ import { useUrlState } from "@/g8/hooks/use-url-state";
26
+ import { DEFAULT_MAX_BYTES } from "../lib/documents-types";
27
+ import type { LibraryDoc } from "../lib/documents-types";
28
+ import { AccountBar } from "./account-bar";
29
+ import { CorrespondencePanel } from "./correspondence-panel";
30
+ import { LibraryPanel } from "./library-panel";
31
+ import { QuestionnairesPanel } from "./questionnaires-panel";
32
+
33
+ export interface DocumentsScreenProps {
34
+ renderRowActions?: (doc: LibraryDoc) => ReactNode;
35
+ renderDetailActions?: (doc: LibraryDoc) => ReactNode;
36
+ extraDetailTabs?: { id: string; label: string; render: (doc: LibraryDoc) => ReactNode }[];
37
+ toolbarActions?: ReactNode;
38
+ /** Largest upload in bytes. Default 5 MiB, the /api/g8 proxy's body limit; raise `maxBodyBytes` in the proxy route first. */
39
+ maxBytes?: number;
40
+ className?: string;
41
+ }
42
+
43
+ type Tab = "library" | "questionnaires" | "correspondence";
44
+ const TABS: Tab[] = ["library", "questionnaires", "correspondence"];
45
+
46
+ export function DocumentsScreen({ renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, maxBytes = DEFAULT_MAX_BYTES, className }: DocumentsScreenProps) {
47
+ const [tabParam, setTabParam] = useUrlState("tab", "library");
48
+ const [account, setAccount] = useUrlState("account", "");
49
+ const [, setId] = useUrlState("id", "");
50
+ const [, setView] = useUrlState("view", "");
51
+ const [, setQid] = useUrlState("qid", "");
52
+ const [, setCid] = useUrlState("cid", "");
53
+ const tab = (TABS.includes(tabParam as Tab) ? tabParam : "library") as Tab;
54
+ const accountId = account && Number.isInteger(Number(account)) && Number(account) > 0 ? Number(account) : null;
55
+
56
+ return (
57
+ <ScreenLayout
58
+ className={className}
59
+ title="Documents"
60
+ description="Your document library, the security questionnaires buyers send, and the correspondence behind each account."
61
+ icon={<FileText className="size-5" />}
62
+ tabs={[{ id: "library", label: "Library" }, { id: "questionnaires", label: "Security questionnaires" }, { id: "correspondence", label: "Correspondence" }]}
63
+ activeTab={tab}
64
+ onTabChange={(next) => { setTabParam(next === "library" ? null : next); setId(null); setQid(null); setCid(null); setView(null); }}
65
+ >
66
+ {tab === "library" ? (
67
+ <LibraryPanel renderRowActions={renderRowActions} renderDetailActions={renderDetailActions} extraDetailTabs={extraDetailTabs} toolbarActions={toolbarActions} maxBytes={maxBytes} />
68
+ ) : (
69
+ <div className="grid gap-4">
70
+ <AccountBar className="max-w-sm" accountId={accountId} onChange={(id) => { setAccount(String(id)); setQid(null); setCid(null); setView(null); }} />
71
+ {tab === "questionnaires" ? <QuestionnairesPanel accountId={accountId} toolbarActions={toolbarActions} maxBytes={maxBytes} /> : <CorrespondencePanel accountId={accountId} toolbarActions={toolbarActions} maxBytes={maxBytes} />}
72
+ </div>
73
+ )}
74
+ </ScreenLayout>
75
+ );
76
+ }