@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.
- package/README.md +3 -3
- package/blocks/_foundation/README.md +47 -0
- package/blocks/_foundation/block.json +13 -0
- package/blocks/_foundation/components/auth-gate.tsx +363 -0
- package/blocks/_foundation/server/auth.ts +815 -0
- package/blocks/campaigns/README.md +1 -1
- package/blocks/campaigns/block.json +34 -1
- package/blocks/campaigns/components/campaign-links.tsx +180 -0
- package/blocks/company-intelligence/components/research-run.tsx +6 -2
- package/blocks/crm/README.md +19 -0
- package/blocks/crm/block.json +205 -6
- package/blocks/crm/components/bulk-update.tsx +119 -0
- package/blocks/crm/components/contact-documents-tab.tsx +144 -0
- package/blocks/crm/components/contact-insights-tab.tsx +118 -0
- package/blocks/crm/components/contact-lookup-link.tsx +47 -6
- package/blocks/crm/components/contact-row-actions.tsx +43 -0
- package/blocks/crm/components/contact-tag-chips.tsx +113 -0
- package/blocks/crm/components/contacts-screen.tsx +46 -5
- package/blocks/crm/components/owner-assign.tsx +132 -0
- package/blocks/crm/components/record-open-links.tsx +79 -0
- package/blocks/crm/components/tag-assign.tsx +112 -0
- package/blocks/crm/components/tag-manager.tsx +178 -0
- package/blocks/crm/components/unlock-info-button.tsx +82 -0
- package/blocks/crm/components/verify-email-button.tsx +93 -0
- package/blocks/crm/hooks/use-contact-tags.ts +29 -0
- package/blocks/crm/lib/record-actions.ts +102 -0
- package/blocks/deals/README.md +1 -0
- package/blocks/deals/block.json +17 -0
- package/blocks/deals/components/create-deal-button.tsx +109 -0
- package/blocks/deals/components/deal-detail.tsx +5 -3
- package/blocks/dialer/components/call-log-screen.tsx +6 -1
- package/blocks/documents/README.md +86 -0
- package/blocks/documents/block.json +378 -0
- package/blocks/documents/components/account-bar.tsx +48 -0
- package/blocks/documents/components/correspondence-form.tsx +95 -0
- package/blocks/documents/components/correspondence-import.tsx +106 -0
- package/blocks/documents/components/correspondence-panel.tsx +191 -0
- package/blocks/documents/components/documents-screen.tsx +76 -0
- package/blocks/documents/components/library-document-detail.tsx +175 -0
- package/blocks/documents/components/library-document-form.tsx +105 -0
- package/blocks/documents/components/library-folder-form.tsx +74 -0
- package/blocks/documents/components/library-panel.tsx +235 -0
- package/blocks/documents/components/library-upload.tsx +160 -0
- package/blocks/documents/components/question-row.tsx +130 -0
- package/blocks/documents/components/questionnaire-detail.tsx +197 -0
- package/blocks/documents/components/questionnaires-panel.tsx +173 -0
- package/blocks/documents/components/record-documents-tab.tsx +109 -0
- package/blocks/documents/lib/documents-types.ts +252 -0
- package/blocks/documents/lib/download.ts +64 -0
- package/blocks/documents/recipes/follow-a-questionnaire.ts +26 -0
- package/blocks/documents/recipes/log-account-correspondence.ts +27 -0
- package/blocks/documents/recipes/organize-the-library.ts +25 -0
- package/blocks/email-signatures/README.md +123 -0
- package/blocks/email-signatures/block.json +341 -0
- package/blocks/email-signatures/components/email-signatures-screen.tsx +166 -0
- package/blocks/email-signatures/components/signature-assign-template.tsx +119 -0
- package/blocks/email-signatures/components/signature-block-editor.tsx +87 -0
- package/blocks/email-signatures/components/signature-campaign-form.tsx +165 -0
- package/blocks/email-signatures/components/signature-campaigns.tsx +75 -0
- package/blocks/email-signatures/components/signature-identity-form.tsx +131 -0
- package/blocks/email-signatures/components/signature-mailboxes.tsx +195 -0
- package/blocks/email-signatures/components/signature-policy.tsx +130 -0
- package/blocks/email-signatures/components/signature-preview.tsx +119 -0
- package/blocks/email-signatures/components/signature-report.tsx +77 -0
- package/blocks/email-signatures/components/signature-template-details.tsx +112 -0
- package/blocks/email-signatures/components/signature-template-form.tsx +109 -0
- package/blocks/email-signatures/hooks/use-signatures-data.ts +93 -0
- package/blocks/email-signatures/lib/signatures-types.ts +392 -0
- package/blocks/email-signatures/recipes/assign-a-signature-to-a-new-hire.ts +32 -0
- package/blocks/email-signatures/recipes/create-a-template-and-make-it-default.ts +35 -0
- package/blocks/email-signatures/recipes/signature-engagement-report.ts +16 -0
- package/blocks/enrichment/README.md +22 -4
- package/blocks/enrichment/block.json +145 -5
- package/blocks/enrichment/components/auto-enrichment-configs.tsx +134 -0
- package/blocks/enrichment/components/auto-enrichment-settings.tsx +82 -0
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +7 -7
- package/blocks/enrichment/components/enrichment-activity.tsx +93 -0
- package/blocks/enrichment/components/enrichment-job-detail.tsx +18 -0
- package/blocks/enrichment/components/enrichment-screen.tsx +34 -3
- package/blocks/enrichment/components/list-pipelines.tsx +97 -0
- package/blocks/enrichment/components/pipeline-detail.tsx +214 -0
- package/blocks/enrichment/components/pipeline-editor.tsx +144 -0
- package/blocks/enrichment/components/pipeline-templates.tsx +90 -0
- package/blocks/enrichment/lib/enrichment-types.ts +12 -0
- package/blocks/enrichment/lib/pipeline-types.ts +234 -0
- package/blocks/forms/README.md +2 -0
- package/blocks/forms/components/forms-screen.tsx +6 -3
- package/blocks/inbox/README.md +40 -4
- package/blocks/inbox/block.json +130 -8
- package/blocks/inbox/components/blocked-senders.tsx +103 -0
- package/blocks/inbox/components/compose-message.tsx +193 -0
- package/blocks/inbox/components/inbox-screen.tsx +54 -6
- package/blocks/inbox/components/inbox-search.tsx +106 -0
- package/blocks/inbox/components/tag-manager.tsx +136 -0
- package/blocks/inbox/components/thread-actions.tsx +133 -0
- package/blocks/inbox/components/thread-view.tsx +2 -0
- package/blocks/inbox/hooks/use-inbox-mailboxes.ts +31 -0
- package/blocks/inbox/lib/inbox-manage.ts +227 -0
- package/blocks/newsletter/components/newsletter-screen.tsx +3 -1
- package/blocks/pages/block.json +10 -1
- package/blocks/pages/hooks/use-landing-page-paths.ts +60 -0
- package/blocks/programmatic-pages/README.md +82 -0
- package/blocks/programmatic-pages/block.json +292 -0
- package/blocks/programmatic-pages/components/programmatic-campaign-form.tsx +136 -0
- package/blocks/programmatic-pages/components/programmatic-generate.tsx +112 -0
- package/blocks/programmatic-pages/components/programmatic-generated-pages.tsx +210 -0
- package/blocks/programmatic-pages/components/programmatic-overview.tsx +84 -0
- package/blocks/programmatic-pages/components/programmatic-pages-screen.tsx +221 -0
- package/blocks/programmatic-pages/components/programmatic-preview.tsx +66 -0
- package/blocks/programmatic-pages/components/programmatic-publish.tsx +102 -0
- package/blocks/programmatic-pages/components/programmatic-settings.tsx +96 -0
- package/blocks/programmatic-pages/components/programmatic-source-fields.tsx +75 -0
- package/blocks/programmatic-pages/components/programmatic-source.tsx +106 -0
- package/blocks/programmatic-pages/components/programmatic-variables.tsx +84 -0
- package/blocks/programmatic-pages/hooks/use-programmatic-workspace.ts +19 -0
- package/blocks/programmatic-pages/lib/programmatic-types.ts +330 -0
- package/blocks/programmatic-pages/recipes/create-a-programmatic-campaign.ts +23 -0
- package/blocks/programmatic-pages/recipes/render-and-follow.ts +21 -0
- package/blocks/programmatic-pages/recipes/review-then-publish.ts +17 -0
- package/blocks/quotes/README.md +25 -1
- package/blocks/quotes/block.json +123 -6
- package/blocks/quotes/components/manual-products.tsx +144 -0
- package/blocks/quotes/components/quote-batch-actions.tsx +86 -0
- package/blocks/quotes/components/quote-documents.tsx +94 -0
- package/blocks/quotes/components/quote-evidence.tsx +95 -0
- package/blocks/quotes/components/quote-list.tsx +23 -5
- package/blocks/quotes/components/quote-settings.tsx +184 -0
- package/blocks/quotes/components/quote-templates.tsx +189 -0
- package/blocks/quotes/components/quote-viewer.tsx +16 -3
- package/blocks/quotes/components/quotes-screen.tsx +15 -5
- package/blocks/quotes/lib/quote-admin.ts +264 -0
- package/blocks/sequencer/README.md +55 -0
- package/blocks/sequencer/block.json +172 -5
- package/blocks/sequencer/components/enrollment-withdraw.tsx +71 -0
- package/blocks/sequencer/components/sequence-builder.tsx +13 -3
- package/blocks/sequencer/components/sequence-campaign-link.tsx +78 -0
- package/blocks/sequencer/components/sequence-detail.tsx +6 -0
- package/blocks/sequencer/components/sequence-edit-form.tsx +111 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +2 -1
- package/blocks/sequencer/components/sequence-manage-actions.tsx +119 -0
- package/blocks/sequencer/components/sequence-senders-panel.tsx +84 -0
- package/blocks/sequencer/components/sequence-templates.tsx +160 -0
- package/blocks/sequencer/components/sequences-screen.tsx +36 -6
- package/blocks/sequencer/components/step-ai-assist.tsx +196 -0
- package/blocks/sequencer/components/step-editor.tsx +10 -1
- package/blocks/sequencer/components/step-templates.tsx +224 -0
- package/blocks/sequencer/lib/sequencer-manage.ts +235 -0
- package/blocks/sequencer/lib/sequencer-utils.ts +4 -0
- package/blocks/signals/README.md +39 -0
- package/blocks/signals/block.json +287 -0
- package/blocks/signals/components/auto-seed-panel.tsx +111 -0
- package/blocks/signals/components/listener-form.tsx +184 -0
- package/blocks/signals/components/matched-posts.tsx +195 -0
- package/blocks/signals/components/post-engagements.tsx +114 -0
- package/blocks/signals/components/record-social-tab.tsx +49 -0
- package/blocks/signals/components/social-listeners-screen.tsx +250 -0
- package/blocks/signals/components/social-settings.tsx +128 -0
- package/blocks/signals/components/source-lists.tsx +83 -0
- package/blocks/signals/components/top-engagers.tsx +68 -0
- package/blocks/signals/components/x-integration.tsx +129 -0
- package/blocks/signals/lib/signals-types.ts +228 -0
- package/blocks/signals/recipes/preview-and-create-a-listener.ts +16 -0
- package/blocks/signals/recipes/spend-against-caps.ts +14 -0
- package/blocks/signals/recipes/triage-matched-posts.ts +18 -0
- package/blocks/visitors/components/traffic-chart.tsx +3 -2
- package/blocks/web-analytics/README.md +102 -0
- package/blocks/web-analytics/block.json +292 -0
- package/blocks/web-analytics/components/acquisition-tab.tsx +154 -0
- package/blocks/web-analytics/components/analytics-filters.tsx +161 -0
- package/blocks/web-analytics/components/audience-tab.tsx +135 -0
- package/blocks/web-analytics/components/conversions-tab.tsx +128 -0
- package/blocks/web-analytics/components/engagement-tab.tsx +123 -0
- package/blocks/web-analytics/components/overview-tab.tsx +102 -0
- package/blocks/web-analytics/components/pages-tab.tsx +148 -0
- package/blocks/web-analytics/components/ranked-table.tsx +103 -0
- package/blocks/web-analytics/components/realtime-tab.tsx +103 -0
- package/blocks/web-analytics/components/report-card.tsx +71 -0
- package/blocks/web-analytics/components/web-analytics-screen.tsx +90 -0
- package/blocks/web-analytics/hooks/use-report.ts +48 -0
- package/blocks/web-analytics/lib/analytics-scope.ts +155 -0
- package/blocks/web-analytics/lib/page-path.ts +14 -0
- package/blocks/web-analytics/lib/web-analytics-types.ts +187 -0
- package/blocks/web-analytics/recipes/best-landing-pages.ts +26 -0
- package/blocks/web-analytics/recipes/form-conversion-check.ts +19 -0
- package/blocks/web-analytics/recipes/weekly-traffic-report.ts +37 -0
- package/blocks/workbench/README.md +54 -2
- package/blocks/workbench/block.json +234 -5
- package/blocks/workbench/components/workbench-auto-enrich.tsx +88 -0
- package/blocks/workbench/components/workbench-automation.tsx +36 -0
- package/blocks/workbench/components/workbench-connections.tsx +136 -0
- package/blocks/workbench/components/workbench-crm-import.tsx +91 -0
- package/blocks/workbench/components/workbench-datafile-import.tsx +154 -0
- package/blocks/workbench/components/workbench-grid.tsx +70 -7
- package/blocks/workbench/components/workbench-pipelines.tsx +173 -0
- package/blocks/workbench/components/workbench-row-actions.tsx +8 -1
- package/blocks/workbench/components/workbench-schedule.tsx +191 -0
- package/blocks/workbench/components/workbench-screen.tsx +50 -17
- package/blocks/workbench/components/workbench-send-to-sequence.tsx +116 -0
- package/blocks/workbench/components/workbench-table-settings.tsx +53 -0
- package/blocks/workbench/components/workbench-triggers.tsx +178 -0
- package/blocks/workbench/hooks/use-connected-crms.ts +29 -0
- package/blocks/workbench/lib/api-status.ts +8 -0
- package/blocks/workbench/lib/automation-types.ts +257 -0
- package/dist/cli.js +257 -51
- package/dist/index.browser.js +1 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +1 -1
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +1 -1
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +1 -1
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.js +1 -1
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +1 -1
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/auth.d.ts +45 -0
- package/dist/types/blocks/create.d.ts +5 -2
- package/dist/types/version.d.ts +2 -2
- package/docs/api/forms.md +1 -1
- package/package.json +1 -1
- package/stacks/6sense/pages/contacts.tsx.tmpl +2 -0
- package/stacks/6sense.json +20 -0
- package/stacks/apollo/pages/calls.tsx.tmpl +9 -9
- package/stacks/apollo/pages/campaigns.tsx.tmpl +27 -0
- package/stacks/apollo/pages/contacts.tsx.tmpl +6 -0
- package/stacks/apollo/pages/inbox-contact.tsx.tmpl +3 -3
- package/stacks/apollo/pages/lists.tsx.tmpl +14 -2
- package/stacks/apollo/pages/sequences.tsx.tmpl +8 -2
- package/stacks/apollo.json +33 -2
- package/stacks/clay/pages/contacts.tsx.tmpl +2 -0
- package/stacks/clay/pages/lists.tsx.tmpl +14 -2
- package/stacks/gong/pages/calls.tsx.tmpl +9 -9
- package/stacks/gong/pages/contacts.tsx.tmpl +2 -0
- package/stacks/hootsuite/pages/contacts.tsx.tmpl +2 -0
- package/stacks/hootsuite.json +10 -0
- package/stacks/hubspot/pages/campaigns.tsx.tmpl +30 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +2 -0
- package/stacks/hubspot/pages/contacts.tsx.tmpl +6 -0
- package/stacks/hubspot/pages/deals.tsx.tmpl +4 -0
- package/stacks/hubspot/pages/forms.tsx.tmpl +36 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +2 -2
- package/stacks/hubspot/pages/sequences.tsx.tmpl +8 -2
- package/stacks/hubspot/pages/web-analytics.tsx.tmpl +14 -0
- package/stacks/hubspot.json +45 -2
- package/stacks/intercom/pages/inbox-contact.tsx.tmpl +3 -3
- package/stacks/intercom.json +10 -0
- package/stacks/mailchimp/pages/contacts.tsx.tmpl +2 -0
- package/stacks/mailchimp/pages/forms.tsx.tmpl +36 -0
- package/stacks/mailchimp.json +2 -1
- package/stacks/outreach/pages/calls.tsx.tmpl +9 -9
- package/stacks/outreach/pages/campaigns.tsx.tmpl +27 -0
- package/stacks/outreach/pages/contacts.tsx.tmpl +2 -0
- package/stacks/outreach/pages/inbox-contact.tsx.tmpl +3 -3
- package/stacks/outreach/pages/sequences.tsx.tmpl +8 -2
- package/stacks/outreach.json +13 -2
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +2 -0
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +2 -2
- package/stacks/pipedrive.json +10 -0
- package/stacks/salesforce/pages/companies.tsx.tmpl +2 -0
- package/stacks/salesforce/pages/contacts.tsx.tmpl +6 -0
- package/stacks/salesforce/pages/deals.tsx.tmpl +4 -0
- package/stacks/salesforce.json +10 -0
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +4 -0
- package/stacks/zoominfo/pages/lists.tsx.tmpl +14 -2
- package/stacks/zoominfo.json +20 -0
- package/styles/styles.css +1 -1
- package/templates/auth/auth-route.ts.tmpl +16 -0
- package/templates/auth/g8.auth.anonymous.ts.tmpl +15 -0
- package/templates/auth/g8.auth.basic.ts.tmpl +17 -0
- package/templates/auth/g8.auth.jwt.ts.tmpl +23 -0
- package/templates/auth/g8.auth.password.ts.tmpl +18 -0
- package/templates/auth/g8.auth.session.ts.tmpl +23 -0
- package/templates/auth/middleware.ts.tmpl +14 -0
- package/templates/next-app/README.md.tmpl +5 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* QuestionnairesPanel: an account's security questionnaires as a finished page: the list, upload a new one, and the
|
|
5
|
+
* selected questionnaire's review (QuestionnaireDetail).
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in order:
|
|
8
|
+
* 1. listSecurityQuestionnaires (GET /accounts/{account_id}/security-questionnaires) read the account's questionnaires, newest first, paged 50 at a time with Load more
|
|
9
|
+
* 2. uploadSecurityQuestionnaire (POST /accounts/{account_id}/security-questionnaires) BILLABLE uploads an XLSX or PDF and starts answering it in the background; behind ConfirmSpendDialog
|
|
10
|
+
* Everything on one questionnaire (read, progress, review actions, workbook download, rename, delete) is QuestionnaireDetail.
|
|
11
|
+
* Answering uses credits, but the API publishes no price for it, so the confirmation names the file and says so instead of
|
|
12
|
+
* showing a made-up estimate. Pass `accountId` (a company id); without one the panel asks for an account.
|
|
13
|
+
* URL: `?qid=` the open questionnaire, `&view=full` as a page.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useState } from "react";
|
|
17
|
+
import type { ReactNode } from "react";
|
|
18
|
+
import { FileSpreadsheet, Upload } from "lucide-react";
|
|
19
|
+
import { Button, Label, Skeleton } from "@graph8/sdk/ui";
|
|
20
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
21
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
22
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
23
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
24
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
25
|
+
import { SplitView } from "@/g8/components/screen/split-view";
|
|
26
|
+
import { StatStrip } from "@/g8/components/screen/stat-strip";
|
|
27
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
28
|
+
import { Toolbar } from "@/g8/components/screen/toolbar";
|
|
29
|
+
import { LoadMore } from "@/g8/components/paging-notices";
|
|
30
|
+
import { useLoadMoreList } from "@/g8/hooks/use-load-more-list";
|
|
31
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
32
|
+
import { byOffset } from "@/g8/lib/paging";
|
|
33
|
+
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
34
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
35
|
+
import { plural } from "@/g8/lib/plural";
|
|
36
|
+
import { DEFAULT_MAX_BYTES, QUESTIONNAIRE_EXTENSIONS, QUESTIONNAIRE_STATUS, checkFiles, fileLimit, fmtSize, rowsOf, statusInfo, totalOf, when } from "../lib/documents-types";
|
|
37
|
+
import type { Questionnaire, Rec } from "../lib/documents-types";
|
|
38
|
+
import { QuestionnaireDetail } from "./questionnaire-detail";
|
|
39
|
+
|
|
40
|
+
export interface QuestionnairesPanelProps {
|
|
41
|
+
/** The account (company id) whose questionnaires to show. */
|
|
42
|
+
accountId: number | null;
|
|
43
|
+
renderRowActions?: (q: Questionnaire) => ReactNode;
|
|
44
|
+
toolbarActions?: ReactNode;
|
|
45
|
+
/** Largest upload in bytes. Default 5 MiB, the /api/g8 proxy's body limit. */
|
|
46
|
+
maxBytes?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const PAGE = 50;
|
|
50
|
+
const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
|
|
51
|
+
const nameOf = (q: Questionnaire): string => q.display_name || q.original_file_name || "Untitled questionnaire";
|
|
52
|
+
|
|
53
|
+
export function QuestionnairesPanel({ accountId, renderRowActions, toolbarActions, maxBytes = DEFAULT_MAX_BYTES }: QuestionnairesPanelProps) {
|
|
54
|
+
const brand = useBrand();
|
|
55
|
+
const [qid, setQid] = useUrlState("qid", "");
|
|
56
|
+
const [view, setView] = useUrlState("view", "");
|
|
57
|
+
const [search, setSearch] = useState("");
|
|
58
|
+
const [status, setStatus] = useState("");
|
|
59
|
+
const [uploading, setUploading] = useState(false);
|
|
60
|
+
const [file, setFile] = useState<File | null>(null);
|
|
61
|
+
const [problems, setProblems] = useState<string[]>([]);
|
|
62
|
+
const [confirming, setConfirming] = useState(false);
|
|
63
|
+
|
|
64
|
+
const list = useLoadMoreList<Questionnaire>(
|
|
65
|
+
byOffset(PAGE, async (offset, signal) => {
|
|
66
|
+
if (accountId == null) return { rows: [], hasNext: false };
|
|
67
|
+
const env = await g8Get<Rec>(`/accounts/${encodeURIComponent(String(accountId))}/security-questionnaires`, { status: status || undefined, limit: PAGE, offset }, { signal });
|
|
68
|
+
const rows = rowsOf<Questionnaire>(env.data);
|
|
69
|
+
const total = totalOf(env.data);
|
|
70
|
+
return { rows, hasNext: total != null ? offset + rows.length < total : rows.length >= PAGE };
|
|
71
|
+
}),
|
|
72
|
+
[accountId, status],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const send = useG8Mutation(
|
|
76
|
+
async (f: File): Promise<Questionnaire> => {
|
|
77
|
+
const form = new FormData();
|
|
78
|
+
form.append("file", f);
|
|
79
|
+
return (await g8Post<Questionnaire>(`/accounts/${encodeURIComponent(String(accountId))}/security-questionnaires`, form)).data;
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
onSuccess: (created) => { setConfirming(false); setUploading(false); setFile(null); void list.refetch(); setQid(created.id); },
|
|
83
|
+
invalidates: [["documents", "questionnaire"]],
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const rows = list.data ?? [];
|
|
88
|
+
const q = search.trim().toLowerCase();
|
|
89
|
+
const shown = rows.filter((r) => !q || `${nameOf(r)} ${r.original_file_name ?? ""}`.toLowerCase().includes(q));
|
|
90
|
+
const selected = rows.find((r) => r.id === qid) ?? null;
|
|
91
|
+
const closeDetail = (): void => { setQid(null); setView(null); };
|
|
92
|
+
const count = (s: string): string => (list.data ? `${rows.filter((r) => r.status === s).length.toLocaleString()}${list.hasMore ? "+" : ""}` : "...");
|
|
93
|
+
|
|
94
|
+
if (accountId == null) {
|
|
95
|
+
return <EmptyState icon={<FileSpreadsheet className="size-8" />} title="Choose an account" description="Pick a company above to see the security questionnaires it sent you, or to upload a new one." />;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const body = list.error ? <ApiError error={list.error} title="Could not load questionnaires" onRetry={() => void list.refetch()} /> : list.loading && !list.data ? (
|
|
99
|
+
<div aria-busy="true" className="grid gap-2"><Skeleton className="h-16 w-full" /><Skeleton className="h-16 w-full" /></div>
|
|
100
|
+
) : shown.length === 0 ? (
|
|
101
|
+
<EmptyState icon={<FileSpreadsheet className="size-8" />} title={rows.length === 0 && !status ? "No questionnaires yet" : "No questionnaires match"} description={rows.length === 0 && !status ? `Upload the spreadsheet or PDF a buyer sent. ${brand.name} answers it from your answer library and policy documents, and you review the answers here.` : "Try another search or status."} action={rows.length === 0 && !status ? <Button onClick={() => setUploading(true)}>Upload a questionnaire</Button> : undefined} />
|
|
102
|
+
) : (
|
|
103
|
+
<>
|
|
104
|
+
<ul className="grid gap-2">
|
|
105
|
+
{shown.map((r) => {
|
|
106
|
+
const info = statusInfo(r.status);
|
|
107
|
+
return (
|
|
108
|
+
<li key={r.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-md border p-3 ${r.id === qid ? "bg-muted" : ""}`}>
|
|
109
|
+
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setQid(r.id)}>
|
|
110
|
+
<span className="block truncate font-medium">{nameOf(r)}</span>
|
|
111
|
+
<span className="text-muted-foreground block truncate text-xs">{r.total_questions != null ? `${r.answered_questions ?? "-"} of ${plural(r.total_questions, "question")} answered` : "Questions not counted yet"} · {when(r.created_at) || "-"}</span>
|
|
112
|
+
</button>
|
|
113
|
+
<StatusPill tone={info.tone}>{info.label}</StatusPill>
|
|
114
|
+
{renderRowActions?.(r)}
|
|
115
|
+
</li>
|
|
116
|
+
);
|
|
117
|
+
})}
|
|
118
|
+
</ul>
|
|
119
|
+
<LoadMore shown={rows.length} noun="questionnaires" hasMore={list.hasMore} loading={list.loadingMore} onLoadMore={list.loadMore} />
|
|
120
|
+
</>
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const chosen = file ? checkFiles([file], QUESTIONNAIRE_EXTENSIONS, fileLimit(maxBytes)) : null;
|
|
124
|
+
return (
|
|
125
|
+
<div className="grid gap-4">
|
|
126
|
+
<StatStrip items={[
|
|
127
|
+
{ label: "Questionnaires", value: list.data ? `${rows.length.toLocaleString()}${list.hasMore ? "+" : ""}` : "..." },
|
|
128
|
+
{ label: "Needs review", value: count("needs_review"), tone: "warning" },
|
|
129
|
+
{ label: "Answering", value: count("processing") },
|
|
130
|
+
{ label: "Completed", value: count("completed"), tone: "positive" },
|
|
131
|
+
]} />
|
|
132
|
+
<SplitView
|
|
133
|
+
list={(
|
|
134
|
+
<div className="grid gap-3">
|
|
135
|
+
<Toolbar
|
|
136
|
+
search={{ value: search, onChange: setSearch, placeholder: "Search questionnaires" }}
|
|
137
|
+
filters={<select aria-label="Filter by status" className={SELECT} value={status} onChange={(e) => { setStatus(e.target.value); closeDetail(); }}><option value="">All statuses</option>{Object.entries(QUESTIONNAIRE_STATUS).map(([v, s]) => <option key={v} value={v}>{s.label}</option>)}</select>}
|
|
138
|
+
actions={<>{toolbarActions}<Button size="sm" onClick={() => setUploading(true)}><Upload className="size-4" />Upload questionnaire</Button></>}
|
|
139
|
+
/>
|
|
140
|
+
{body}
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
detail={selected ? <QuestionnaireDetail key={selected.id} accountId={accountId} questionnaire={selected} onChanged={() => void list.refetch()} onClose={closeDetail} onDeleted={closeDetail} onExpand={() => setView("full")} onCollapse={view === "full" ? () => setView(null) : undefined} /> : null}
|
|
144
|
+
detailOpen={Boolean(selected)}
|
|
145
|
+
onCloseDetail={closeDetail}
|
|
146
|
+
expanded={view === "full"}
|
|
147
|
+
onExpandedChange={(e) => setView(e ? "full" : null)}
|
|
148
|
+
detailWidth="xl"
|
|
149
|
+
/>
|
|
150
|
+
<FormSheet open={uploading} onOpenChange={(o) => { setUploading(o); if (!o) { setFile(null); setProblems([]); } }} title="Upload a questionnaire" description={`${brand.name} reads the questions and answers each one in the background. Nothing is sent to the buyer.`}>
|
|
151
|
+
<form className="grid gap-3" onSubmit={(e) => { e.preventDefault(); if (chosen && chosen.ok.length === 1) setConfirming(true); }}>
|
|
152
|
+
<div>
|
|
153
|
+
<Label htmlFor="g8-qp-file" className="mb-1">Questionnaire file</Label>
|
|
154
|
+
<input id="g8-qp-file" type="file" accept={QUESTIONNAIRE_EXTENSIONS.map((x) => `.${x}`).join(",")} className="border-input bg-background w-full rounded-md border p-2 text-sm" onChange={(e) => { const f = e.target.files?.[0] ?? null; setFile(f); setProblems(f ? checkFiles([f], QUESTIONNAIRE_EXTENSIONS, fileLimit(maxBytes)).problems : []); }} />
|
|
155
|
+
<p className="text-muted-foreground mt-1 text-xs">{QUESTIONNAIRE_EXTENSIONS.join(" or ")}, up to {fmtSize(fileLimit(maxBytes))}. A spreadsheet keeps its layout; a PDF is answered into a new spreadsheet.</p>
|
|
156
|
+
</div>
|
|
157
|
+
{problems.length > 0 ? <ul role="alert" className="text-error list-disc pl-5 text-xs">{problems.map((p) => <li key={p}>{p}</li>)}</ul> : null}
|
|
158
|
+
<div className="flex gap-2"><Button type="submit" disabled={!chosen || chosen.ok.length !== 1}>Upload and answer</Button><Button type="button" variant="outline" onClick={() => setUploading(false)}>Cancel</Button></div>
|
|
159
|
+
</form>
|
|
160
|
+
</FormSheet>
|
|
161
|
+
<ConfirmSpendDialog
|
|
162
|
+
open={confirming && file !== null}
|
|
163
|
+
tier="billable"
|
|
164
|
+
title={`Upload and answer ${file?.name ?? "this file"}?`}
|
|
165
|
+
description={<><p>{brand.name} reads every question in the file and writes an answer to each from your answer library and policy documents. Answering uses credits. The API does not publish a price for it, so no estimate is shown here.</p>{send.error ? <ApiError error={send.error} title="Could not upload the questionnaire" /> : null}</>}
|
|
166
|
+
confirmLabel="Upload and answer"
|
|
167
|
+
confirming={send.loading}
|
|
168
|
+
onCancel={() => setConfirming(false)}
|
|
169
|
+
onConfirm={() => { if (file) void send.mutate(file); }}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RecordDocumentsTab: the paperwork behind one account, on a company, contact or deal page. Use it as an `extraDetailTabs`
|
|
5
|
+
* entry of CompaniesScreen / ContactsScreen / DealsScreen:
|
|
6
|
+
* { id: "documents", label: "Documents", render: (c) => <RecordDocumentsTab companyId={c.id} documentsPath="/documents" /> }
|
|
7
|
+
* { id: "documents", label: "Documents", render: (c) => <RecordDocumentsTab contactId={c.id} /> } (a contact: their company's paperwork, messages with them first)
|
|
8
|
+
* { id: "documents", label: "Documents", render: (d) => <RecordDocumentsTab contactId={d.primary_contact?.id} scope="account" /> } (a deal: the primary contact finds the company; all its messages show)
|
|
9
|
+
*
|
|
10
|
+
* SDK operations, in order:
|
|
11
|
+
* 1. getContact (GET /contacts/{contact_id}) read only when only a contactId is given: finds the contact's company
|
|
12
|
+
* 2. listAccountCorrespondence (GET /accounts/{account_id}/correspondence) read the latest messages (with that person only when a contactId is given)
|
|
13
|
+
* 3. listSecurityQuestionnaires (GET /accounts/{account_id}/security-questionnaires) read the latest questionnaires for the account
|
|
14
|
+
* Both lists are the newest five; "Open in documents" goes to the full screen with the account chosen. Read only: recording,
|
|
15
|
+
* uploading and reviewing happen in DocumentsScreen. A contact with no company has no account to read, and the tab says so.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Mail } from "lucide-react";
|
|
19
|
+
import { Skeleton } from "@graph8/sdk/ui";
|
|
20
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
21
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
22
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
23
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
24
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
25
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
26
|
+
import { plural } from "@/g8/lib/plural";
|
|
27
|
+
import { CORRESPONDENCE_CHANNELS, rowsOf, statusInfo, totalOf, when } from "../lib/documents-types";
|
|
28
|
+
import type { Correspondence, Questionnaire, Rec } from "../lib/documents-types";
|
|
29
|
+
|
|
30
|
+
export interface RecordDocumentsTabProps {
|
|
31
|
+
/** The company (account) id. */
|
|
32
|
+
companyId?: number | null;
|
|
33
|
+
/** A contact: narrows the messages to this person and, without a companyId, finds the company. */
|
|
34
|
+
contactId?: number | null;
|
|
35
|
+
/** "person" (default with a contactId) shows only the messages with that contact; "account" shows the whole account's, using the contact only to find the company (a deal page). */
|
|
36
|
+
scope?: "person" | "account";
|
|
37
|
+
/** Path of your documents screen for the links. Default "/documents". */
|
|
38
|
+
documentsPath?: string;
|
|
39
|
+
/** Route to the documents screen yourself (for example with the router) instead of a plain link. */
|
|
40
|
+
onOpenDocuments?: (href: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const LIMIT = 5;
|
|
44
|
+
|
|
45
|
+
export function RecordDocumentsTab({ companyId, contactId, scope = "person", documentsPath = "/documents", onOpenDocuments }: RecordDocumentsTabProps) {
|
|
46
|
+
const personal = contactId != null && scope !== "account";
|
|
47
|
+
const needContact = companyId == null && contactId != null;
|
|
48
|
+
const contact = useG8Query((signal) => g8Get<{ company_id?: number | null }>(`/contacts/${contactId}`, {}, { signal }).then((r) => r.data), [contactId], { enabled: needContact, key: ["documents", "contact-company", contactId ?? ""] });
|
|
49
|
+
const account = companyId ?? contact.data?.company_id ?? null;
|
|
50
|
+
const base = `/accounts/${account}`;
|
|
51
|
+
const mail = useG8Query(
|
|
52
|
+
(signal) => g8Get<Rec>(`${base}/correspondence`, { stakeholder_id: personal ? String(contactId) : undefined, limit: LIMIT }, { signal }).then((r) => ({ rows: rowsOf<Correspondence>(r.data), total: totalOf(r.data) })),
|
|
53
|
+
[account, personal ? contactId : null],
|
|
54
|
+
{ enabled: account != null, key: ["documents", "correspondence", "tab", account ?? "", personal ? contactId ?? "" : ""] },
|
|
55
|
+
);
|
|
56
|
+
const forms = useG8Query(
|
|
57
|
+
(signal) => g8Get<Rec>(`${base}/security-questionnaires`, { limit: LIMIT }, { signal }).then((r) => ({ rows: rowsOf<Questionnaire>(r.data), total: totalOf(r.data) })),
|
|
58
|
+
[account],
|
|
59
|
+
{ enabled: account != null, key: ["documents", "questionnaire", "tab", account ?? ""] },
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (companyId == null && contactId == null) return <EmptyState compact icon={<Mail className="size-8" />} title="No account to show" description="Documents are kept per company. Add a contact or a company to this record to see them." />;
|
|
63
|
+
if (needContact && contact.error) return <ApiError error={contact.error} title="Could not find this person's company" onRetry={() => void contact.refetch()} />;
|
|
64
|
+
if (needContact && contact.loading && !contact.data) return <Skeleton className="h-24 w-full" aria-label="Loading documents" />;
|
|
65
|
+
if (account == null) return <EmptyState compact icon={<Mail className="size-8" />} title="No company on this contact" description="Messages and questionnaires are kept per company, so there is nothing to show until this person has one." />;
|
|
66
|
+
|
|
67
|
+
const href = (tab: string): string => `${documentsPath}?tab=${tab}&account=${account}`;
|
|
68
|
+
const link = (tab: string, text: string) => (
|
|
69
|
+
<a href={href(tab)} className="text-sm underline" onClick={(e) => { if (!onOpenDocuments || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; e.preventDefault(); onOpenDocuments(href(tab)); }}>{text}</a>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="grid gap-4">
|
|
74
|
+
<SectionCard title={personal ? "Messages with this person" : "Correspondence"} actions={link("correspondence", "Open in documents")}>
|
|
75
|
+
{mail.error ? <ApiError error={mail.error} title="Could not load messages" compact onRetry={() => void mail.refetch()} /> : !mail.data ? <Skeleton className="h-16 w-full" aria-label="Loading messages" /> : mail.data.rows.length === 0 ? (
|
|
76
|
+
<EmptyState compact title="No messages recorded" description="Emails, LinkedIn messages and call notes recorded for this account show up here." />
|
|
77
|
+
) : (
|
|
78
|
+
<ul className="divide-y rounded-md border" aria-label="Messages">
|
|
79
|
+
{mail.data.rows.map((m) => (
|
|
80
|
+
<li key={m.id} className="flex items-center justify-between gap-3 p-3 text-sm">
|
|
81
|
+
<span className="min-w-0"><span className="block truncate font-medium">{m.subject || (m.content ? m.content.slice(0, 80) : "No subject")}</span><span className="text-muted-foreground block text-xs">{CORRESPONDENCE_CHANNELS.find((c) => c.value === m.channel)?.label ?? m.channel ?? "-"} · {when(m.correspondence_date) || "-"}</span></span>
|
|
82
|
+
{m.direction ? <StatusPill tone={m.direction === "inbound" ? "info" : "neutral"}>{m.direction === "inbound" ? "Inbound" : "Outbound"}</StatusPill> : null}
|
|
83
|
+
</li>
|
|
84
|
+
))}
|
|
85
|
+
</ul>
|
|
86
|
+
)}
|
|
87
|
+
{mail.data && mail.data.total != null && mail.data.total > mail.data.rows.length ? <p className="text-muted-foreground mt-2 text-xs">Showing the newest {mail.data.rows.length} of {plural(mail.data.total, "message")}.</p> : null}
|
|
88
|
+
</SectionCard>
|
|
89
|
+
<SectionCard title="Security questionnaires" description="Kept for the company, not the person." actions={link("questionnaires", "Open in documents")}>
|
|
90
|
+
{forms.error ? <ApiError error={forms.error} title="Could not load questionnaires" compact onRetry={() => void forms.refetch()} /> : !forms.data ? <Skeleton className="h-16 w-full" aria-label="Loading questionnaires" /> : forms.data.rows.length === 0 ? (
|
|
91
|
+
<EmptyState compact title="No questionnaires" description="Questionnaires uploaded for this account show up here." />
|
|
92
|
+
) : (
|
|
93
|
+
<ul className="divide-y rounded-md border" aria-label="Questionnaires">
|
|
94
|
+
{forms.data.rows.map((f) => {
|
|
95
|
+
const info = statusInfo(f.status);
|
|
96
|
+
return (
|
|
97
|
+
<li key={f.id} className="flex items-center justify-between gap-3 p-3 text-sm">
|
|
98
|
+
<span className="min-w-0"><span className="block truncate font-medium">{f.display_name || f.original_file_name || "Untitled questionnaire"}</span><span className="text-muted-foreground block text-xs">{f.total_questions != null ? `${f.answered_questions ?? "-"} of ${plural(f.total_questions, "question")} answered` : "-"}</span></span>
|
|
99
|
+
<StatusPill tone={info.tone}>{info.label}</StatusPill>
|
|
100
|
+
</li>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
</ul>
|
|
104
|
+
)}
|
|
105
|
+
{forms.data && forms.data.total != null && forms.data.total > forms.data.rows.length ? <p className="text-muted-foreground mt-2 text-xs">Showing the newest {forms.data.rows.length} of {plural(forms.data.total, "questionnaire")}.</p> : null}
|
|
106
|
+
</SectionCard>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shapes, vocabularies and helpers the documents block shares.
|
|
5
|
+
*
|
|
6
|
+
* The documents responses are OPEN in the contract (`DocumentPayload` / `DocumentListPayload`), so every field here is
|
|
7
|
+
* optional and read defensively: a missing value is shown as a dash, never replaced by a guess. A list read always carries
|
|
8
|
+
* its rows under `items` (the contract's promise), whichever key the app used underneath.
|
|
9
|
+
*
|
|
10
|
+
* SDK operations: none.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Tone } from "@/g8/components/screen/status-pill";
|
|
14
|
+
|
|
15
|
+
export type Rec = Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
/** The Library root; a folder's `parent_path` for a top-level folder. */
|
|
18
|
+
export const ROOT = "Library";
|
|
19
|
+
/** Largest file the /api/g8 proxy forwards by default (5 MiB). The app itself accepts 50 MB: raise `maxBodyBytes` in the proxy route first. */
|
|
20
|
+
export const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
|
21
|
+
/** Bytes a multipart body adds around its files: the boundary lines and headers (about 200 per file) plus the other fields. */
|
|
22
|
+
export const BASE_OVERHEAD = 4096;
|
|
23
|
+
export const FILE_OVERHEAD = 512;
|
|
24
|
+
/** The largest single file that fits one request under the proxy's body cap (`maxBytes`). */
|
|
25
|
+
export const fileLimit = (maxBytes: number): number => maxBytes - BASE_OVERHEAD - FILE_OVERHEAD;
|
|
26
|
+
/** The extensions the Library accepts (contract text of uploadLibraryDocument). */
|
|
27
|
+
export const LIBRARY_EXTENSIONS = ["pdf", "docx", "doc", "txt", "md", "mdx", "csv", "json", "png", "jpg", "jpeg", "xls", "xlsx", "ppt", "pptx"] as const;
|
|
28
|
+
/** Security questionnaires are read from a spreadsheet or a PDF. */
|
|
29
|
+
export const QUESTIONNAIRE_EXTENSIONS = ["xlsx", "pdf"] as const;
|
|
30
|
+
/** The types whose text the Library can extract again. */
|
|
31
|
+
export const REEXTRACT_TYPES = ["pdf", "docx"] as const;
|
|
32
|
+
|
|
33
|
+
export const CORRESPONDENCE_CHANNELS = [
|
|
34
|
+
{ value: "email", label: "Email" },
|
|
35
|
+
{ value: "linkedin", label: "LinkedIn" },
|
|
36
|
+
{ value: "call_notes", label: "Call notes" },
|
|
37
|
+
] as const;
|
|
38
|
+
export const DIRECTIONS = [
|
|
39
|
+
{ value: "inbound", label: "Inbound" },
|
|
40
|
+
{ value: "outbound", label: "Outbound" },
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
export interface LibraryDoc {
|
|
44
|
+
id: string;
|
|
45
|
+
folder_path: string;
|
|
46
|
+
file_name?: string | null;
|
|
47
|
+
display_name?: string | null;
|
|
48
|
+
file_type?: string | null;
|
|
49
|
+
content?: string | null;
|
|
50
|
+
has_file_data?: boolean;
|
|
51
|
+
file_size_bytes?: number | null;
|
|
52
|
+
mime_type?: string | null;
|
|
53
|
+
priority?: string | null;
|
|
54
|
+
source?: string | null;
|
|
55
|
+
indexed_at?: string | null;
|
|
56
|
+
deleted_at?: string | null;
|
|
57
|
+
created_at?: string | null;
|
|
58
|
+
updated_at?: string | null;
|
|
59
|
+
created_by?: string | null;
|
|
60
|
+
owner_name?: string | null;
|
|
61
|
+
current_version?: number | null;
|
|
62
|
+
meta_data?: Rec | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface LibraryFolder {
|
|
66
|
+
id: string;
|
|
67
|
+
name: string;
|
|
68
|
+
parent_path: string;
|
|
69
|
+
full_path: string;
|
|
70
|
+
created_at?: string | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface Questionnaire {
|
|
74
|
+
id: string;
|
|
75
|
+
account_id?: string | null;
|
|
76
|
+
original_file_name?: string | null;
|
|
77
|
+
original_file_type?: string | null;
|
|
78
|
+
display_name?: string | null;
|
|
79
|
+
status?: string | null;
|
|
80
|
+
total_questions?: number | null;
|
|
81
|
+
answered_questions?: number | null;
|
|
82
|
+
escalated_questions?: number | null;
|
|
83
|
+
error_message?: string | null;
|
|
84
|
+
created_at?: string | null;
|
|
85
|
+
completed_at?: string | null;
|
|
86
|
+
questions?: Question[] | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface Question {
|
|
90
|
+
id: string;
|
|
91
|
+
question_text?: string | null;
|
|
92
|
+
question_topic?: string | null;
|
|
93
|
+
cell_reference?: string | null;
|
|
94
|
+
sheet_name?: string | null;
|
|
95
|
+
answer_text?: string | null;
|
|
96
|
+
final_answer?: string | null;
|
|
97
|
+
confidence?: string | null;
|
|
98
|
+
status?: string | null;
|
|
99
|
+
review_status?: string | null;
|
|
100
|
+
escalation_notes?: string | null;
|
|
101
|
+
guardrail_warnings?: string[] | null;
|
|
102
|
+
citations?: Rec[] | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface QuestionnaireProgress {
|
|
106
|
+
id?: string;
|
|
107
|
+
status?: string | null;
|
|
108
|
+
total_questions?: number | null;
|
|
109
|
+
answered_questions?: number | null;
|
|
110
|
+
escalated_questions?: number | null;
|
|
111
|
+
error_message?: string | null;
|
|
112
|
+
progress?: number | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface Correspondence {
|
|
116
|
+
id: string;
|
|
117
|
+
account_id?: string | null;
|
|
118
|
+
stakeholder_id?: string | null;
|
|
119
|
+
channel?: string | null;
|
|
120
|
+
direction?: string | null;
|
|
121
|
+
subject?: string | null;
|
|
122
|
+
content?: string | null;
|
|
123
|
+
source?: string | null;
|
|
124
|
+
external_id?: string | null;
|
|
125
|
+
correspondence_date?: string | null;
|
|
126
|
+
indexed_in_rag?: boolean;
|
|
127
|
+
created_at?: string | null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface ImportResult {
|
|
131
|
+
imported?: number;
|
|
132
|
+
skipped?: number;
|
|
133
|
+
errors?: string[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The rows of an open list payload: its `items` array (the contract always sends it). */
|
|
137
|
+
export function rowsOf<T>(data: unknown): T[] {
|
|
138
|
+
if (Array.isArray(data)) return data as T[];
|
|
139
|
+
const items = (data as { items?: unknown } | null | undefined)?.items;
|
|
140
|
+
return Array.isArray(items) ? (items as T[]) : [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The `total` an open list payload may carry, or null when it sends none. */
|
|
144
|
+
export function totalOf(data: unknown): number | null {
|
|
145
|
+
const t = (data as { total?: unknown } | null | undefined)?.total;
|
|
146
|
+
return typeof t === "number" ? t : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const extOf = (name: string): string => (name.includes(".") ? name.slice(name.lastIndexOf(".") + 1).toLowerCase() : "");
|
|
150
|
+
|
|
151
|
+
/** "12 KB", "1.4 MB"; an empty string when the size is unknown (callers show a dash). */
|
|
152
|
+
export function fmtSize(bytes: number | null | undefined): string {
|
|
153
|
+
if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0) return "";
|
|
154
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
155
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024).toLocaleString("en-US")} KB`;
|
|
156
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const when = (v: string | null | undefined): string => {
|
|
160
|
+
if (!v) return "";
|
|
161
|
+
const d = new Date(v);
|
|
162
|
+
return Number.isNaN(d.getTime()) ? "" : d.toLocaleString();
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Keep the files that may go up and say why the others cannot. The same files are counted in the confirm and sent. */
|
|
166
|
+
export function checkFiles(files: readonly File[], allowed: readonly string[], maxBytes: number): { ok: File[]; problems: string[] } {
|
|
167
|
+
const ok: File[] = [];
|
|
168
|
+
const problems: string[] = [];
|
|
169
|
+
for (const f of files) {
|
|
170
|
+
if (!allowed.includes(extOf(f.name))) problems.push(`${f.name}: only ${allowed.join(", ")} files can be added.`);
|
|
171
|
+
else if (f.size === 0) problems.push(`${f.name}: the file is empty.`);
|
|
172
|
+
else if (f.size > maxBytes) problems.push(`${f.name}: ${fmtSize(f.size)} is over the ${fmtSize(maxBytes)} limit.`);
|
|
173
|
+
else ok.push(f);
|
|
174
|
+
}
|
|
175
|
+
return { ok, problems };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The API's folder-name rule (letters, digits, spaces, hyphens, underscores; at most 50) and its three-level depth limit. */
|
|
179
|
+
export function folderNameProblem(name: string, parentPath: string): string | null {
|
|
180
|
+
const n = name.trim();
|
|
181
|
+
if (!n) return "Give the folder a name.";
|
|
182
|
+
if (n.length > 50) return "A folder name is at most 50 characters.";
|
|
183
|
+
if (!/^[\w\s-]+$/.test(n)) return "Use letters, numbers, spaces, hyphens and underscores only.";
|
|
184
|
+
if (`${parentPath}/${n}`.split("/").length > 3) return "Folders go three levels deep at most.";
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A stored file name proposed from a title: lower case, hyphens, `.md`. The person can edit it. */
|
|
189
|
+
export function fileNameFor(title: string): string {
|
|
190
|
+
const base = title.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
191
|
+
return base ? `${base}.md` : "";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const docName = (d: Pick<LibraryDoc, "display_name" | "file_name">): string => d.display_name || d.file_name || "Untitled document";
|
|
195
|
+
export const folderLabel = (path: string): string => (path === ROOT ? "Library" : path.replace(/^Library\//, ""));
|
|
196
|
+
|
|
197
|
+
export const QUESTIONNAIRE_STATUS: Record<string, { label: string; tone: Tone }> = {
|
|
198
|
+
pending: { label: "Waiting", tone: "neutral" },
|
|
199
|
+
processing: { label: "Answering", tone: "info" },
|
|
200
|
+
completed: { label: "Completed", tone: "positive" },
|
|
201
|
+
needs_review: { label: "Needs review", tone: "warning" },
|
|
202
|
+
failed: { label: "Failed", tone: "negative" },
|
|
203
|
+
};
|
|
204
|
+
export const statusInfo = (s: string | null | undefined): { label: string; tone: Tone } =>
|
|
205
|
+
QUESTIONNAIRE_STATUS[s ?? ""] ?? { label: s ? s.replace(/_/g, " ") : "Unknown", tone: "neutral" };
|
|
206
|
+
/** The filled workbook is only available in these states (the API refuses earlier). */
|
|
207
|
+
export const canDownload = (s: string | null | undefined): boolean => s === "completed" || s === "needs_review";
|
|
208
|
+
export const isWorking = (s: string | null | undefined): boolean => s === "pending" || s === "processing";
|
|
209
|
+
|
|
210
|
+
/** What a person should read as the state of one question. */
|
|
211
|
+
export function reviewState(q: Question): { label: string; tone: Tone } {
|
|
212
|
+
if (q.review_status === "skip") return { label: "Not applicable", tone: "neutral" };
|
|
213
|
+
if (q.review_status === "wrong") return { label: "Marked wrong", tone: "negative" };
|
|
214
|
+
if (q.status === "escalated") return { label: "Escalated", tone: "warning" };
|
|
215
|
+
if (q.status === "approved" || q.confidence === "confirmed") return { label: "Approved", tone: "positive" };
|
|
216
|
+
if (q.final_answer) return { label: "Edited", tone: "info" };
|
|
217
|
+
if (q.answer_text) return { label: "Answered", tone: "neutral" };
|
|
218
|
+
return { label: "No answer", tone: "warning" };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The answer the filled workbook carries: the person's edit when there is one, else the AI's. */
|
|
222
|
+
export const answerOf = (q: Question): string => q.final_answer || q.answer_text || "";
|
|
223
|
+
|
|
224
|
+
/** Whole-number percent, or null when there is nothing to divide. */
|
|
225
|
+
export function percentOf(done: number | null | undefined, total: number | null | undefined): number | null {
|
|
226
|
+
if (typeof done !== "number" || typeof total !== "number" || total <= 0) return null;
|
|
227
|
+
return Math.min(100, Math.round((done / total) * 100));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** One piece of correspondence as the import call takes it, or the reason it cannot be sent. */
|
|
231
|
+
export function parseImportItems(text: string): { items: Rec[]; problem: string | null } {
|
|
232
|
+
let parsed: unknown;
|
|
233
|
+
try {
|
|
234
|
+
parsed = JSON.parse(text);
|
|
235
|
+
} catch {
|
|
236
|
+
return { items: [], problem: "That is not valid JSON." };
|
|
237
|
+
}
|
|
238
|
+
const list = Array.isArray(parsed) ? parsed : (parsed as { items?: unknown } | null)?.items;
|
|
239
|
+
if (!Array.isArray(list) || list.length === 0) return { items: [], problem: "Paste a JSON array with at least one message." };
|
|
240
|
+
const channels = CORRESPONDENCE_CHANNELS.map((c) => c.value as string);
|
|
241
|
+
const directions = DIRECTIONS.map((d) => d.value as string);
|
|
242
|
+
for (const [i, raw] of list.entries()) {
|
|
243
|
+
const it = raw as Rec;
|
|
244
|
+
const at = `Message ${i + 1}`;
|
|
245
|
+
if (!it || typeof it !== "object") return { items: [], problem: `${at} is not an object.` };
|
|
246
|
+
if (typeof it.content !== "string" || !it.content.trim()) return { items: [], problem: `${at} needs content.` };
|
|
247
|
+
if (typeof it.channel !== "string" || !channels.includes(it.channel)) return { items: [], problem: `${at}: channel must be ${channels.join(", ")}.` };
|
|
248
|
+
if (typeof it.direction !== "string" || !directions.includes(it.direction)) return { items: [], problem: `${at}: direction must be inbound or outbound.` };
|
|
249
|
+
if (typeof it.correspondence_date !== "string" || Number.isNaN(Date.parse(it.correspondence_date))) return { items: [], problem: `${at}: correspondence_date must be a date and time, for example 2026-09-30T14:00:00Z.` };
|
|
250
|
+
}
|
|
251
|
+
return { items: list as Rec[], problem: null };
|
|
252
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Save a file the API returns as raw bytes (a Library document in its original format, a filled questionnaire workbook).
|
|
5
|
+
* These two routes are NOT the JSON envelope, so `g8Get` (which parses JSON) cannot read them; this goes through the same
|
|
6
|
+
* /api/g8 proxy and keeps the key on the server.
|
|
7
|
+
*
|
|
8
|
+
* SDK operations: none directly. The callers declare downloadLibraryDocument / downloadSecurityQuestionnaire.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { G8Error } from "@graph8/sdk/errors";
|
|
12
|
+
import { buildG8Url, transport } from "@/g8/lib/g8-client";
|
|
13
|
+
|
|
14
|
+
/** @internal Swappable in tests (jsdom has no object URLs). */
|
|
15
|
+
export const saver = {
|
|
16
|
+
save(blob: Blob, name: string): void {
|
|
17
|
+
const url = URL.createObjectURL(blob);
|
|
18
|
+
const a = document.createElement("a");
|
|
19
|
+
a.href = url;
|
|
20
|
+
a.download = name;
|
|
21
|
+
document.body.appendChild(a);
|
|
22
|
+
a.click();
|
|
23
|
+
a.remove();
|
|
24
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The file name a Content-Disposition header names (RFC 5987 `filename*` first), or null. */
|
|
29
|
+
export function nameFromDisposition(header: string | null): string | null {
|
|
30
|
+
if (!header) return null;
|
|
31
|
+
const star = /filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)/.exec(header);
|
|
32
|
+
if (star) {
|
|
33
|
+
try {
|
|
34
|
+
return decodeURIComponent(star[1].trim());
|
|
35
|
+
} catch {
|
|
36
|
+
/* fall through to the plain form */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const plain = /filename\s*=\s*"?([^";]+)"?/.exec(header);
|
|
40
|
+
return plain ? plain[1].trim() : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Fetch `path` (relative to /api/v1) and hand the bytes to the browser as a download. Throws `G8Error` on any failure. */
|
|
44
|
+
export async function downloadFile(path: string, fallbackName: string): Promise<string> {
|
|
45
|
+
let resp: Response;
|
|
46
|
+
try {
|
|
47
|
+
resp = await transport.fetch(buildG8Url(path), { method: "GET", credentials: "same-origin" });
|
|
48
|
+
} catch (err) {
|
|
49
|
+
throw new G8Error({ message: `Network error: ${(err as Error)?.message ?? "request failed"}`, status: 0, type: "network_error", retryable: true });
|
|
50
|
+
}
|
|
51
|
+
if (!resp.ok) {
|
|
52
|
+
let message = `Request failed with status ${resp.status}`;
|
|
53
|
+
try {
|
|
54
|
+
const env = JSON.parse(await resp.text()) as { message?: string; detail?: unknown };
|
|
55
|
+
message = env.message || (typeof env.detail === "string" ? env.detail : message);
|
|
56
|
+
} catch {
|
|
57
|
+
/* keep the status line */
|
|
58
|
+
}
|
|
59
|
+
throw new G8Error({ message, status: resp.status, type: "api_error", code: String(resp.status), retryable: resp.status === 429 || resp.status >= 500 });
|
|
60
|
+
}
|
|
61
|
+
const name = nameFromDisposition(resp.headers.get("content-disposition")) || fallbackName;
|
|
62
|
+
saver.save(await resp.blob(), name);
|
|
63
|
+
return name;
|
|
64
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @title Follow a security questionnaire until it is answered and list the questions that need a person
|
|
3
|
+
* @when After a questionnaire was uploaded in the app (answering uses credits, so a person confirms the upload; upload is
|
|
4
|
+
* multipart and not part of this typed client). Poll the cheap status read, not the full detail, until the status is no longer
|
|
5
|
+
* pending or processing. This only READS: it approves nothing, because an approval is a person's judgement. The filled workbook
|
|
6
|
+
* is available only once the status is completed or needs_review.
|
|
7
|
+
*/
|
|
8
|
+
import { g8 } from "@graph8/sdk";
|
|
9
|
+
|
|
10
|
+
g8.init({ apiKey: process.env.G8_API_KEY! });
|
|
11
|
+
|
|
12
|
+
const accountId = process.env.G8_COMPANY_ID!;
|
|
13
|
+
const questionnaireId = process.env.G8_QUESTIONNAIRE_ID!;
|
|
14
|
+
const path = { account_id: accountId, questionnaire_id: questionnaireId };
|
|
15
|
+
|
|
16
|
+
let status = "pending";
|
|
17
|
+
for (let i = 0; i < 60 && (status === "pending" || status === "processing"); i++) {
|
|
18
|
+
const s = (await g8.api.documents.getSecurityQuestionnaireStatus({ path })).data as { status?: string; answered_questions?: number; total_questions?: number };
|
|
19
|
+
status = s.status ?? "unknown";
|
|
20
|
+
console.log(status, `${s.answered_questions ?? "-"} of ${s.total_questions ?? "-"} answered`);
|
|
21
|
+
if (status === "pending" || status === "processing") await new Promise((r) => setTimeout(r, 5000));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const detail = (await g8.api.documents.getSecurityQuestionnaire({ path })).data as { questions?: Array<{ id: string; question_text?: string; status?: string; review_status?: string; answer_text?: string | null }> };
|
|
25
|
+
const needsAPerson = (detail.questions ?? []).filter((q) => q.status !== "approved" && q.review_status !== "skip");
|
|
26
|
+
console.log(`${needsAPerson.length} questions still need review`, needsAPerson.slice(0, 5).map((q) => q.question_text));
|