@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,160 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LibraryUpload: add files to a Library folder.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. uploadLibraryDocument (POST /library/documents/upload) write one file (multipart)
|
|
8
|
+
* 2. uploadLibraryDocuments (POST /library/documents/upload-batch) write several files at once; PARTIAL SUCCESS is normal
|
|
9
|
+
* Files are checked here before anything is sent: only the Library's file types, nothing empty, no file over `maxBytes`
|
|
10
|
+
* (default 5 MiB, the largest BODY the /api/g8 proxy forwards; the app itself takes 50 MB, so raise `maxBodyBytes` in the
|
|
11
|
+
* proxy route first), and at most 20 at a time. The proxy caps the whole request, not each file, so the files are sent in
|
|
12
|
+
* consecutive requests whose combined size (plus multipart overhead) stays under `maxBytes`; a lone file goes through the
|
|
13
|
+
* single route. Results are merged. A request that fails (a 413, a network error) marks only ITS files as failed and the later
|
|
14
|
+
* ones are still tried. The files that failed stay selected so a retry sends only them, never the ones that already landed. Text-bearing files are read for their text; images and
|
|
15
|
+
* spreadsheets are stored as bytes. Nothing here is billable, external or destructive.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useRef, useState } from "react";
|
|
19
|
+
import { Button, Label } from "@graph8/sdk/ui";
|
|
20
|
+
import { g8Post } from "@/g8/lib/g8-client";
|
|
21
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
22
|
+
import { plural } from "@/g8/lib/plural";
|
|
23
|
+
import { BASE_OVERHEAD, DEFAULT_MAX_BYTES, FILE_OVERHEAD, LIBRARY_EXTENSIONS, fileLimit, ROOT, checkFiles, fmtSize, folderLabel } from "../lib/documents-types";
|
|
24
|
+
import type { LibraryFolder, Rec } from "../lib/documents-types";
|
|
25
|
+
|
|
26
|
+
export const MAX_BATCH = 20;
|
|
27
|
+
|
|
28
|
+
/** Consecutive groups of files whose combined size and multipart overhead stay within `maxBytes` (a file over the limit on its own still gets a group of its own, to be refused by the proxy with a reason). */
|
|
29
|
+
export function requestGroups(files: readonly File[], maxBytes: number): File[][] {
|
|
30
|
+
const groups: File[][] = [];
|
|
31
|
+
let current: File[] = [];
|
|
32
|
+
let size = BASE_OVERHEAD;
|
|
33
|
+
for (const f of files) {
|
|
34
|
+
const cost = f.size + FILE_OVERHEAD;
|
|
35
|
+
if (current.length > 0 && size + cost > maxBytes) {
|
|
36
|
+
groups.push(current);
|
|
37
|
+
current = [];
|
|
38
|
+
size = BASE_OVERHEAD;
|
|
39
|
+
}
|
|
40
|
+
current.push(f);
|
|
41
|
+
size += cost;
|
|
42
|
+
}
|
|
43
|
+
if (current.length > 0) groups.push(current);
|
|
44
|
+
return groups;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LibraryUploadProps {
|
|
48
|
+
folders: LibraryFolder[];
|
|
49
|
+
/** Pre-select the destination folder. */
|
|
50
|
+
folderPath?: string;
|
|
51
|
+
/** Largest file accepted, in bytes. Default 5 MiB: the /api/g8 proxy rejects bigger bodies. */
|
|
52
|
+
maxBytes?: number;
|
|
53
|
+
onUploaded?: () => void;
|
|
54
|
+
onDone?: () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Outcome { added: number; failed: { filename: string; error: string }[]; warnings: string[] }
|
|
58
|
+
const reasonOf = (err: unknown): string => (err instanceof Error && err.message ? err.message : "The request failed.");
|
|
59
|
+
|
|
60
|
+
const SELECT = "border-input bg-background h-9 w-full rounded-md border px-2 text-sm";
|
|
61
|
+
|
|
62
|
+
export function LibraryUpload({ folders, folderPath, maxBytes = DEFAULT_MAX_BYTES, onUploaded, onDone }: LibraryUploadProps) {
|
|
63
|
+
const [folder, setFolder] = useState(folderPath || ROOT);
|
|
64
|
+
const [picked, setPicked] = useState<File[]>([]);
|
|
65
|
+
const [notes, setNotes] = useState<string[]>([]);
|
|
66
|
+
const [outcome, setOutcome] = useState<Outcome | null>(null);
|
|
67
|
+
const input = useRef<HTMLInputElement>(null);
|
|
68
|
+
const choices = [...new Set([ROOT, ...folders.map((f) => f.full_path), folder])];
|
|
69
|
+
|
|
70
|
+
const choose = (list: FileList | null): void => {
|
|
71
|
+
const { ok, problems } = checkFiles(Array.from(list ?? []), LIBRARY_EXTENSIONS, fileLimit(maxBytes));
|
|
72
|
+
const over = ok.length > MAX_BATCH ? [`Add at most ${MAX_BATCH} files at a time; the first ${MAX_BATCH} are kept.`] : [];
|
|
73
|
+
setPicked(ok.slice(0, MAX_BATCH));
|
|
74
|
+
setNotes([...problems, ...over]);
|
|
75
|
+
setOutcome(null);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const send = useG8Mutation(
|
|
79
|
+
async (files: File[]): Promise<Outcome> => {
|
|
80
|
+
const total: Outcome = { added: 0, failed: [], warnings: [] };
|
|
81
|
+
for (const group of requestGroups(files, maxBytes)) {
|
|
82
|
+
try {
|
|
83
|
+
const part = await sendGroup(group, folder);
|
|
84
|
+
total.added += part.added;
|
|
85
|
+
total.failed.push(...part.failed);
|
|
86
|
+
total.warnings.push(...part.warnings);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// One request failing (a 413, a network error) fails only its own files; the next group is still tried.
|
|
89
|
+
total.failed.push(...group.map((f) => ({ filename: f.name, error: reasonOf(err) })));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return total;
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
onSuccess: (r, files) => {
|
|
96
|
+
setOutcome(r);
|
|
97
|
+
const failedNames = new Set(r.failed.map((f) => f.filename));
|
|
98
|
+
setPicked(files.filter((f) => failedNames.has(f.name)));
|
|
99
|
+
if (input.current) input.current.value = "";
|
|
100
|
+
onUploaded?.();
|
|
101
|
+
},
|
|
102
|
+
invalidates: [["documents"]],
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const finished = outcome !== null && outcome.failed.length === 0;
|
|
107
|
+
return (
|
|
108
|
+
<form className="grid gap-3" onSubmit={(e) => { e.preventDefault(); if (picked.length > 0) void send.mutate(picked); }}>
|
|
109
|
+
<div>
|
|
110
|
+
<Label htmlFor="g8-du-folder" className="mb-1">Add to folder</Label>
|
|
111
|
+
<select id="g8-du-folder" className={SELECT} value={folder} onChange={(e) => setFolder(e.target.value)}>
|
|
112
|
+
{choices.map((p) => <option key={p} value={p}>{folderLabel(p)}</option>)}
|
|
113
|
+
</select>
|
|
114
|
+
</div>
|
|
115
|
+
<div>
|
|
116
|
+
<Label htmlFor="g8-du-files" className="mb-1">Files</Label>
|
|
117
|
+
<input ref={input} id="g8-du-files" type="file" multiple accept={LIBRARY_EXTENSIONS.map((e) => `.${e}`).join(",")} onChange={(e) => choose(e.target.files)} className="border-input bg-background w-full rounded-md border p-2 text-sm" />
|
|
118
|
+
<p className="text-muted-foreground mt-1 text-xs">{LIBRARY_EXTENSIONS.join(", ")}. Up to {fmtSize(fileLimit(maxBytes))} each. The server takes {fmtSize(maxBytes)} per request, so several files are sent in several requests. {MAX_BATCH} files at a time.</p>
|
|
119
|
+
</div>
|
|
120
|
+
{notes.length > 0 ? <ul role="alert" className="text-error list-disc pl-5 text-xs">{notes.map((n) => <li key={n}>{n}</li>)}</ul> : null}
|
|
121
|
+
{picked.length > 0 ? (
|
|
122
|
+
<ul aria-label="Files to add" className="divide-y rounded-md border text-sm">
|
|
123
|
+
{picked.map((f) => <li key={f.name} className="flex justify-between gap-2 p-2"><span className="truncate">{f.name}</span><span className="text-muted-foreground shrink-0">{fmtSize(f.size)}</span></li>)}
|
|
124
|
+
</ul>
|
|
125
|
+
) : null}
|
|
126
|
+
{outcome ? (
|
|
127
|
+
<div role="status" className="grid gap-1 rounded-md border p-3 text-sm">
|
|
128
|
+
<p className="font-medium">{outcome.failed.length === 0 ? `${plural(outcome.added, "file")} added.` : `${outcome.added} added, ${outcome.failed.length} not added.`}</p>
|
|
129
|
+
{outcome.failed.map((f) => <p key={f.filename} className="text-error text-xs">{f.filename}: {f.error}</p>)}
|
|
130
|
+
{outcome.warnings.map((w) => <p key={w} className="text-muted-foreground text-xs">{w}</p>)}
|
|
131
|
+
{outcome.failed.length > 0 ? <p className="text-muted-foreground text-xs">Only the files that failed are still selected, so adding again will not duplicate the others.</p> : null}
|
|
132
|
+
</div>
|
|
133
|
+
) : null}
|
|
134
|
+
<div className="flex gap-2">
|
|
135
|
+
{finished ? <Button type="button" onClick={onDone}>Done</Button> : <Button type="submit" loading={send.loading} disabled={picked.length === 0}>{picked.length > 1 ? `Add ${picked.length} files` : "Add file"}</Button>}
|
|
136
|
+
{!finished && onDone ? <Button type="button" variant="outline" onClick={onDone}>Close</Button> : null}
|
|
137
|
+
</div>
|
|
138
|
+
</form>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One request: a lone file through the single route, several through the batch route (partial success is normal there). */
|
|
143
|
+
async function sendGroup(files: File[], folder: string): Promise<Outcome> {
|
|
144
|
+
const form = new FormData();
|
|
145
|
+
form.append("folder_path", folder);
|
|
146
|
+
if (files.length === 1) {
|
|
147
|
+
form.append("file", files[0]);
|
|
148
|
+
await g8Post("/library/documents/upload", form);
|
|
149
|
+
return { added: 1, failed: [], warnings: [] };
|
|
150
|
+
}
|
|
151
|
+
for (const f of files) form.append("files", f);
|
|
152
|
+
const data = (await g8Post<Rec>("/library/documents/upload-batch", form)).data ?? {};
|
|
153
|
+
const ok = Array.isArray(data.success) ? (data.success as Rec[]) : [];
|
|
154
|
+
const bad = Array.isArray(data.failed) ? (data.failed as Rec[]) : [];
|
|
155
|
+
return {
|
|
156
|
+
added: ok.length,
|
|
157
|
+
failed: bad.map((b) => ({ filename: String(b.filename ?? "A file"), error: String(b.error ?? "It could not be added.") })),
|
|
158
|
+
warnings: ok.filter((o) => o.warning).map((o) => `${String(o.filename ?? "A file")}: ${String(o.warning)}`),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* QuestionRow: one question of a security questionnaire with the AI's answer and every review action.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order (each is one button; the row shows its own spinner and error):
|
|
7
|
+
* 1. updateQuestionnaireAnswer (PATCH .../questions/{question_id}) write replace the answer with a human one; the AI's original is kept beside it
|
|
8
|
+
* 2. approveQuestionnaireAnswer (POST .../questions/{question_id}/approve) write confirm the answer is right (no text changes)
|
|
9
|
+
* 3. escalateQuestionnaireQuestion (POST .../escalate) write hand it to the security team, with notes
|
|
10
|
+
* 4. skipQuestionnaireQuestion (POST .../mark-skip) write not applicable to this organization; the workbook says so
|
|
11
|
+
* 5. flagQuestionnaireAnswer (POST .../mark-wrong) write say the AI's answer is wrong so a person rewrites it
|
|
12
|
+
* 6. saveQuestionnaireAnswerToLibrary (POST .../save-to-library) write keep the answer as org knowledge: the NEXT questionnaire is answered from it
|
|
13
|
+
* Nothing here is billable, external or destructive. After any action the questionnaire is read again, so the row shows the
|
|
14
|
+
* state the API holds, never a guess.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useState } from "react";
|
|
18
|
+
import { BookmarkPlus, Check, Flag, MinusCircle, Pencil, UserRound } from "lucide-react";
|
|
19
|
+
import { Button, Textarea } from "@graph8/sdk/ui";
|
|
20
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
21
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
22
|
+
import { g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
23
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
24
|
+
import { answerOf, reviewState } from "../lib/documents-types";
|
|
25
|
+
import type { Question, Rec } from "../lib/documents-types";
|
|
26
|
+
|
|
27
|
+
export interface QuestionRowProps {
|
|
28
|
+
accountId: number | string;
|
|
29
|
+
questionnaireId: string;
|
|
30
|
+
question: Question;
|
|
31
|
+
/** Called after any action succeeds, so the questionnaire is read again. */
|
|
32
|
+
onChanged: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Mode = null | "edit" | "escalate" | "skip" | "wrong";
|
|
36
|
+
type Action = { kind: "edit"; text: string } | { kind: "approve" } | { kind: "escalate" | "skip" | "wrong"; notes: string } | { kind: "library" };
|
|
37
|
+
|
|
38
|
+
const NOTE_COPY: Record<"escalate" | "skip" | "wrong", { label: string; confirm: string; hint: string }> = {
|
|
39
|
+
escalate: { label: "Notes for the security team", confirm: "Escalate", hint: "What the person picking this up needs to know." },
|
|
40
|
+
skip: { label: "Why it does not apply (optional)", confirm: "Mark not applicable", hint: "The filled workbook says this question does not apply." },
|
|
41
|
+
wrong: { label: "What is wrong with the answer (optional)", confirm: "Mark wrong", hint: "A person rewrites it; this does not change the text." },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function QuestionRow({ accountId, questionnaireId, question: q, onChanged }: QuestionRowProps) {
|
|
45
|
+
const [mode, setMode] = useState<Mode>(null);
|
|
46
|
+
const [text, setText] = useState("");
|
|
47
|
+
const [saved, setSaved] = useState<string | null>(null);
|
|
48
|
+
const base = `/accounts/${encodeURIComponent(String(accountId))}/security-questionnaires/${encodeURIComponent(questionnaireId)}/questions/${encodeURIComponent(q.id)}`;
|
|
49
|
+
const state = reviewState(q);
|
|
50
|
+
const current = answerOf(q);
|
|
51
|
+
|
|
52
|
+
const run = useG8Mutation(
|
|
53
|
+
async (a: Action): Promise<Rec> => {
|
|
54
|
+
if (a.kind === "edit") return (await g8Patch<Rec>(base, { final_answer: a.text })).data;
|
|
55
|
+
if (a.kind === "approve") return (await g8Post<Rec>(`${base}/approve`)).data;
|
|
56
|
+
if (a.kind === "library") return (await g8Post<Rec>(`${base}/save-to-library`)).data;
|
|
57
|
+
const notes = a.notes.trim() || null;
|
|
58
|
+
if (a.kind === "escalate") return (await g8Post<Rec>(`${base}/escalate`, { notes })).data;
|
|
59
|
+
if (a.kind === "skip") return (await g8Post<Rec>(`${base}/mark-skip`, { notes })).data;
|
|
60
|
+
return (await g8Post<Rec>(`${base}/mark-wrong`, { notes })).data;
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
onSuccess: (_r, a) => {
|
|
64
|
+
setMode(null);
|
|
65
|
+
setSaved(a.kind === "library" ? "Saved to the answer library." : null);
|
|
66
|
+
onChanged();
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
const open = (m: Exclude<Mode, null>): void => { setMode(m); setText(m === "edit" ? current : ""); setSaved(null); };
|
|
71
|
+
const busy = run.loading;
|
|
72
|
+
const [pending, setPending] = useState<Action["kind"] | null>(null);
|
|
73
|
+
const go = async (a: Action): Promise<void> => {
|
|
74
|
+
setPending(a.kind);
|
|
75
|
+
await run.mutate(a);
|
|
76
|
+
setPending(null);
|
|
77
|
+
};
|
|
78
|
+
const spin = (k: Action["kind"]): boolean => busy && pending === k;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<li className="grid gap-2 rounded-md border p-3" aria-label={q.question_text ?? "Question"}>
|
|
82
|
+
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
83
|
+
<div className="min-w-0">
|
|
84
|
+
<p className="font-medium">{q.question_text || "Untitled question"}</p>
|
|
85
|
+
<p className="text-muted-foreground text-xs">{[q.question_topic, q.sheet_name, q.cell_reference].filter(Boolean).join(" · ") || "-"}</p>
|
|
86
|
+
</div>
|
|
87
|
+
<div className="flex shrink-0 gap-1">
|
|
88
|
+
<StatusPill tone={state.tone}>{state.label}</StatusPill>
|
|
89
|
+
{q.confidence && q.confidence !== "confirmed" && q.confidence !== "pending" ? <StatusPill tone="neutral" quiet>{q.confidence} confidence</StatusPill> : null}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
{mode === "edit" ? (
|
|
93
|
+
<div className="grid gap-2">
|
|
94
|
+
<Textarea aria-label="Your answer" rows={5} value={text} onChange={(e) => setText(e.target.value)} />
|
|
95
|
+
<div className="flex gap-2">
|
|
96
|
+
<Button size="sm" loading={spin("edit")} disabled={busy || text.trim() === "" || text === current} onClick={() => void go({ kind: "edit", text })}>Save answer</Button>
|
|
97
|
+
<Button size="sm" variant="outline" disabled={busy} onClick={() => setMode(null)}>Cancel</Button>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
) : (
|
|
101
|
+
<p className="whitespace-pre-wrap text-sm">{current || <span className="text-muted-foreground">No answer yet.</span>}</p>
|
|
102
|
+
)}
|
|
103
|
+
{q.final_answer && q.answer_text ? <details className="text-xs"><summary className="text-muted-foreground cursor-pointer">AI answer before your edit</summary><p className="mt-1 whitespace-pre-wrap">{q.answer_text}</p></details> : null}
|
|
104
|
+
{(q.guardrail_warnings ?? []).length > 0 ? <ul className="text-warning-foreground list-disc pl-5 text-xs">{(q.guardrail_warnings ?? []).map((w) => <li key={w}>{w}</li>)}</ul> : null}
|
|
105
|
+
{q.escalation_notes ? <p className="text-muted-foreground text-xs">Note: {q.escalation_notes}</p> : null}
|
|
106
|
+
{mode === "escalate" || mode === "skip" || mode === "wrong" ? (
|
|
107
|
+
<div className="grid gap-2">
|
|
108
|
+
<Textarea aria-label={NOTE_COPY[mode].label} rows={3} value={text} placeholder={NOTE_COPY[mode].hint} onChange={(e) => setText(e.target.value)} />
|
|
109
|
+
<p className="text-muted-foreground text-xs">{NOTE_COPY[mode].label}</p>
|
|
110
|
+
<div className="flex gap-2">
|
|
111
|
+
<Button size="sm" loading={spin(mode)} disabled={busy} onClick={() => void go({ kind: mode, notes: text })}>{NOTE_COPY[mode].confirm}</Button>
|
|
112
|
+
<Button size="sm" variant="outline" disabled={busy} onClick={() => setMode(null)}>Cancel</Button>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
) : null}
|
|
116
|
+
{mode === null ? (
|
|
117
|
+
<div className="flex flex-wrap gap-1">
|
|
118
|
+
<Button size="sm" variant="ghost" disabled={busy} onClick={() => open("edit")}><Pencil className="size-4" />Edit answer</Button>
|
|
119
|
+
<Button size="sm" variant="ghost" loading={spin("approve")} disabled={busy || state.label === "Approved" || !current} onClick={() => void go({ kind: "approve" })}><Check className="size-4" />Approve</Button>
|
|
120
|
+
<Button size="sm" variant="ghost" disabled={busy} onClick={() => open("escalate")}><UserRound className="size-4" />Escalate</Button>
|
|
121
|
+
<Button size="sm" variant="ghost" disabled={busy} onClick={() => open("skip")}><MinusCircle className="size-4" />Not applicable</Button>
|
|
122
|
+
<Button size="sm" variant="ghost" disabled={busy || !q.answer_text} onClick={() => open("wrong")}><Flag className="size-4" />Wrong answer</Button>
|
|
123
|
+
<Button size="sm" variant="ghost" loading={spin("library")} disabled={busy || !current} onClick={() => void go({ kind: "library" })}><BookmarkPlus className="size-4" />Save to answer library</Button>
|
|
124
|
+
</div>
|
|
125
|
+
) : null}
|
|
126
|
+
{saved ? <p role="status" className="text-xs">{saved}</p> : null}
|
|
127
|
+
{run.error ? <ApiError error={run.error} title="Could not update this question" compact /> : null}
|
|
128
|
+
</li>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* QuestionnaireDetail: one security questionnaire, the review loop on its questions, and its file actions.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. getSecurityQuestionnaire (GET .../security-questionnaires/{questionnaire_id}) read the questionnaire with EVERY question and its answer
|
|
8
|
+
* 2. getSecurityQuestionnaireStatus (GET .../{questionnaire_id}/status) read the cheap progress read, polled while it is still being answered
|
|
9
|
+
* 3. QuestionRow (update, approve, escalate, skip, flag, save to library) write the review actions, one per button
|
|
10
|
+
* 4. downloadSecurityQuestionnaire (GET .../{questionnaire_id}/download) read the FILLED workbook (XLSX bytes, not the JSON envelope); the API refuses it until the questionnaire is completed or needs review
|
|
11
|
+
* 5. updateSecurityQuestionnaire (PATCH .../{questionnaire_id}) write rename only; never touches an answer
|
|
12
|
+
* 6. deleteSecurityQuestionnaire (DELETE .../{questionnaire_id}) DESTRUCTIVE removes it AND every question; behind ConfirmSpendDialog
|
|
13
|
+
* Uploading a questionnaire (the billable step) lives in QuestionnairesPanel.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useState } from "react";
|
|
17
|
+
import { Download, Pencil, Trash2 } from "lucide-react";
|
|
18
|
+
import { Button, Input, Label, 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 { StatusPill } from "@/g8/components/screen/status-pill";
|
|
27
|
+
import { g8Delete, g8Get, g8Patch } from "@/g8/lib/g8-client";
|
|
28
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
29
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
30
|
+
import { useJobPoll } from "@/g8/hooks/use-job-poll";
|
|
31
|
+
import { plural } from "@/g8/lib/plural";
|
|
32
|
+
import { downloadFile } from "../lib/download";
|
|
33
|
+
import { canDownload, isWorking, percentOf, reviewState, statusInfo, when } from "../lib/documents-types";
|
|
34
|
+
import type { Question, Questionnaire, QuestionnaireProgress, Rec } from "../lib/documents-types";
|
|
35
|
+
import { QuestionRow } from "./question-row";
|
|
36
|
+
|
|
37
|
+
export interface QuestionnaireDetailProps {
|
|
38
|
+
accountId: number;
|
|
39
|
+
questionnaire: Questionnaire;
|
|
40
|
+
/** The list should be read again (a rename, a delete, a status change). */
|
|
41
|
+
onChanged: () => void;
|
|
42
|
+
onClose: () => void;
|
|
43
|
+
onDeleted?: (q: Questionnaire) => void;
|
|
44
|
+
onExpand?: () => void;
|
|
45
|
+
onCollapse?: () => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const PAGE = 25;
|
|
49
|
+
const FILTERS = [
|
|
50
|
+
{ id: "all", label: "All" },
|
|
51
|
+
{ id: "attention", label: "Needs attention" },
|
|
52
|
+
{ id: "approved", label: "Approved" },
|
|
53
|
+
{ id: "skipped", label: "Not applicable" },
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
const nameOf = (q: Questionnaire): string => q.display_name || q.original_file_name || "Untitled questionnaire";
|
|
57
|
+
|
|
58
|
+
export function QuestionnaireDetail({ accountId, questionnaire, onChanged, onClose, onDeleted, onExpand, onCollapse }: QuestionnaireDetailProps) {
|
|
59
|
+
const brand = useBrand();
|
|
60
|
+
const [filter, setFilter] = useState<(typeof FILTERS)[number]["id"]>("all");
|
|
61
|
+
const [shown, setShown] = useState(PAGE);
|
|
62
|
+
const [renaming, setRenaming] = useState(false);
|
|
63
|
+
const [name, setName] = useState("");
|
|
64
|
+
const [deleting, setDeleting] = useState(false);
|
|
65
|
+
const [downloadError, setDownloadError] = useState<unknown>(undefined);
|
|
66
|
+
const [downloading, setDownloading] = useState(false);
|
|
67
|
+
const base = `/accounts/${encodeURIComponent(String(accountId))}/security-questionnaires/${encodeURIComponent(questionnaire.id)}`;
|
|
68
|
+
|
|
69
|
+
const full = useG8Query((signal) => g8Get<Questionnaire>(base, {}, { signal }).then((r) => r.data), [base], { key: ["documents", "questionnaire", accountId, questionnaire.id], placeholderData: questionnaire });
|
|
70
|
+
const q = full.data && full.data.id === questionnaire.id ? full.data : questionnaire;
|
|
71
|
+
const loaded = !full.isPlaceholderData && full.data?.id === questionnaire.id;
|
|
72
|
+
const poll = useJobPoll<QuestionnaireProgress>((signal) => g8Get<QuestionnaireProgress>(`${base}/status`, {}, { signal }).then((r) => r.data), {
|
|
73
|
+
enabled: isWorking(q.status),
|
|
74
|
+
intervalMs: 4000,
|
|
75
|
+
isTerminal: (s) => !isWorking(s.status),
|
|
76
|
+
onDone: () => { void full.refetch(); onChanged(); },
|
|
77
|
+
});
|
|
78
|
+
const live = poll.status ?? q;
|
|
79
|
+
const info = statusInfo(live.status ?? q.status);
|
|
80
|
+
const total = live.total_questions ?? q.total_questions ?? null;
|
|
81
|
+
const answered = live.answered_questions ?? q.answered_questions ?? null;
|
|
82
|
+
const pct = percentOf(answered, total);
|
|
83
|
+
|
|
84
|
+
const rename = useG8Mutation((next: string) => g8Patch<Rec>(base, { display_name: next.trim() || null }), {
|
|
85
|
+
onSuccess: () => { setRenaming(false); void full.refetch(); onChanged(); },
|
|
86
|
+
});
|
|
87
|
+
const remove = useG8Mutation(() => g8Delete(base), { onSuccess: () => { setDeleting(false); onChanged(); onDeleted?.(questionnaire); } });
|
|
88
|
+
|
|
89
|
+
const save = async (): Promise<void> => {
|
|
90
|
+
setDownloading(true);
|
|
91
|
+
setDownloadError(undefined);
|
|
92
|
+
try {
|
|
93
|
+
await downloadFile(`${base}/download`, `${(q.original_file_name || nameOf(q)).replace(/\.[a-z0-9]+$/i, "")}_filled.xlsx`);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
setDownloadError(err);
|
|
96
|
+
} finally {
|
|
97
|
+
setDownloading(false);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const all = q.questions ?? [];
|
|
102
|
+
const keep = (x: Question): boolean => {
|
|
103
|
+
const s = reviewState(x).label;
|
|
104
|
+
if (filter === "approved") return s === "Approved";
|
|
105
|
+
if (filter === "skipped") return s === "Not applicable";
|
|
106
|
+
if (filter === "attention") return s !== "Approved" && s !== "Not applicable";
|
|
107
|
+
return true;
|
|
108
|
+
};
|
|
109
|
+
const visible = all.filter(keep);
|
|
110
|
+
|
|
111
|
+
const review = full.error && !loaded ? <ApiError error={full.error} title="Could not load this questionnaire" onRetry={() => void full.refetch()} /> : !loaded ? (
|
|
112
|
+
<Skeleton className="h-40 w-full" aria-label="Loading questions" />
|
|
113
|
+
) : (
|
|
114
|
+
<div className="grid gap-3">
|
|
115
|
+
<div role="group" aria-label="Show questions" className="flex flex-wrap gap-1">
|
|
116
|
+
{FILTERS.map((f) => <Button key={f.id} size="sm" variant={filter === f.id ? "default" : "outline"} aria-pressed={filter === f.id} onClick={() => { setFilter(f.id); setShown(PAGE); }}>{f.label}</Button>)}
|
|
117
|
+
</div>
|
|
118
|
+
{all.length === 0 ? (
|
|
119
|
+
<EmptyState compact title={isWorking(live.status ?? q.status) ? "Questions are still being read" : "No questions"} description={q.status === "failed" ? "No questions could be read from this file." : `Questions appear here as ${brand.name} reads the file.`} />
|
|
120
|
+
) : visible.length === 0 ? (
|
|
121
|
+
<EmptyState compact title="No questions here" description="Try another view." />
|
|
122
|
+
) : (
|
|
123
|
+
<>
|
|
124
|
+
<p role="status" className="text-muted-foreground text-xs">{visible.length === all.length ? plural(all.length, "question") : `${visible.length} of ${plural(all.length, "question")}`}</p>
|
|
125
|
+
<ul className="grid gap-2" aria-label="Questions">
|
|
126
|
+
{visible.slice(0, shown).map((x) => <QuestionRow key={x.id} accountId={accountId} questionnaireId={questionnaire.id} question={x} onChanged={() => { void full.refetch(); onChanged(); }} />)}
|
|
127
|
+
</ul>
|
|
128
|
+
{visible.length > shown ? <Button variant="outline" onClick={() => setShown((n) => n + PAGE)}>Show {Math.min(PAGE, visible.length - shown)} more</Button> : null}
|
|
129
|
+
</>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<>
|
|
136
|
+
<DetailPanel
|
|
137
|
+
title={nameOf(q)}
|
|
138
|
+
subtitle={q.original_file_name && q.original_file_name !== nameOf(q) ? q.original_file_name : undefined}
|
|
139
|
+
badges={<StatusPill tone={info.tone}>{info.label}</StatusPill>}
|
|
140
|
+
actions={(
|
|
141
|
+
<>
|
|
142
|
+
<Button size="sm" variant="outline" disabled={!loaded} onClick={() => { setName(q.display_name ?? ""); setRenaming(true); }}><Pencil className="size-4" />Rename</Button>
|
|
143
|
+
<Button size="sm" variant="outline" loading={downloading} disabled={!canDownload(q.status)} title={canDownload(q.status) ? undefined : "The filled workbook is available once the questionnaire is completed or needs review."} onClick={() => void save()}><Download className="size-4" />Download filled workbook</Button>
|
|
144
|
+
<Button size="sm" variant="outline" onClick={() => setDeleting(true)}><Trash2 className="size-4" />Delete</Button>
|
|
145
|
+
</>
|
|
146
|
+
)}
|
|
147
|
+
onClose={onClose}
|
|
148
|
+
onExpand={onExpand}
|
|
149
|
+
onCollapse={onCollapse}
|
|
150
|
+
summary={(
|
|
151
|
+
<div className="grid gap-2 px-4 pt-3">
|
|
152
|
+
<p className="text-sm" role="status">{total != null ? `${answered ?? "-"} of ${plural(total, "question")} answered${pct != null ? ` (${pct}%)` : ""}.` : "Reading the file."}{live.escalated_questions ? ` ${live.escalated_questions} escalated.` : ""}</p>
|
|
153
|
+
{pct != null ? <div role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-label="Answered" className="bg-muted h-1.5 overflow-hidden rounded-full"><div className="bg-foreground h-full" style={{ width: `${pct}%` }} /></div> : null}
|
|
154
|
+
{q.status === "failed" && (live.error_message || q.error_message) ? <p role="alert" className="text-error text-sm">{live.error_message || q.error_message}</p> : null}
|
|
155
|
+
{poll.error ? <ApiError error={poll.error} title="Could not check progress" compact onRetry={poll.start} /> : null}
|
|
156
|
+
{downloadError ? <ApiError error={downloadError} title="Could not download the workbook" compact /> : null}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
tabs={[
|
|
160
|
+
{ id: "review", label: "Review", content: review, count: loaded ? all.length : undefined },
|
|
161
|
+
{ id: "details", label: "Details", content: (
|
|
162
|
+
<KeyValueList columns={2} items={[
|
|
163
|
+
{ label: "Name", value: q.display_name },
|
|
164
|
+
{ label: "File", value: q.original_file_name },
|
|
165
|
+
{ label: "File type", value: q.original_file_type ? q.original_file_type.toUpperCase() : "" },
|
|
166
|
+
{ label: "Status", value: info.label },
|
|
167
|
+
{ label: "Questions", value: total != null ? String(total) : "" },
|
|
168
|
+
{ label: "Answered", value: answered != null ? String(answered) : "" },
|
|
169
|
+
{ label: "Escalated", value: q.escalated_questions != null ? String(q.escalated_questions) : "" },
|
|
170
|
+
{ label: "Uploaded", value: when(q.created_at) },
|
|
171
|
+
{ label: "Completed", value: when(q.completed_at) },
|
|
172
|
+
{ label: "Questionnaire id", value: q.id, mono: true },
|
|
173
|
+
]} />
|
|
174
|
+
) },
|
|
175
|
+
]}
|
|
176
|
+
/>
|
|
177
|
+
<FormSheet open={renaming} onOpenChange={setRenaming} title="Rename questionnaire" description="The name is only a label. No answer changes.">
|
|
178
|
+
<form className="grid gap-3" onSubmit={(e) => { e.preventDefault(); void rename.mutate(name); }}>
|
|
179
|
+
<div><Label htmlFor="g8-qd-name" className="mb-1">Name</Label><Input id="g8-qd-name" value={name} onChange={(e) => setName(e.target.value)} placeholder={q.original_file_name ?? "Questionnaire"} /><p className="text-muted-foreground mt-1 text-xs">Left blank, the file name is shown.</p></div>
|
|
180
|
+
{rename.error ? <ApiError error={rename.error} title="Could not rename the questionnaire" /> : null}
|
|
181
|
+
<div className="flex gap-2"><Button type="submit" loading={rename.loading} disabled={name.trim() === (q.display_name ?? "")}>Save name</Button><Button type="button" variant="outline" onClick={() => setRenaming(false)}>Cancel</Button></div>
|
|
182
|
+
</form>
|
|
183
|
+
</FormSheet>
|
|
184
|
+
<ConfirmSpendDialog
|
|
185
|
+
open={deleting}
|
|
186
|
+
tier="destructive"
|
|
187
|
+
title={`Delete ${nameOf(q)}?`}
|
|
188
|
+
affected={1}
|
|
189
|
+
description={<><p>The questionnaire and {total != null ? plural(total, "question") : "every question"} with their answers are removed. This cannot be undone. Answers you saved to the answer library stay there.</p>{remove.error ? <ApiError error={remove.error} title="Could not delete the questionnaire" /> : null}</>}
|
|
190
|
+
confirmLabel="Delete"
|
|
191
|
+
confirming={remove.loading}
|
|
192
|
+
onCancel={() => setDeleting(false)}
|
|
193
|
+
onConfirm={() => void remove.mutate(undefined)}
|
|
194
|
+
/>
|
|
195
|
+
</>
|
|
196
|
+
);
|
|
197
|
+
}
|