@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,184 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* QuoteSettings: the org-wide quote defaults: company details and branding, terms, numbering, who quotes are sent as,
|
|
5
|
+
* the billing provider, deal automation, required billing fields and saved terms. The document library sits under it.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in order:
|
|
8
|
+
* 1. getQuoteSettings GET /quote-settings read - the values plus `inherited_branding` (what a blank field falls back to)
|
|
9
|
+
* 2. updateQuoteSettings PUT /quote-settings external tier (org admin) -> ConfirmSpendDialog when it changes who quotes are sent as,
|
|
10
|
+
* the billing provider or the deal automation
|
|
11
|
+
* QuoteDocuments (child) owns the library calls.
|
|
12
|
+
*
|
|
13
|
+
* The API only writes values that are not null, so a field that has a value CANNOT be cleared through it: the form
|
|
14
|
+
* says so and holds Save until that field has a value again, instead of reporting a save that changed nothing. Only the
|
|
15
|
+
* fields that changed are sent, and the form reloads from the API after a save. Only Manual and Stripe are offered as
|
|
16
|
+
* billing providers: QuickBooks, Xero and Chargebee are registered but not built, so choosing one would fail.
|
|
17
|
+
*/
|
|
18
|
+
import { useEffect, useRef, useState } from "react";
|
|
19
|
+
import { Settings2, Trash2 } from "lucide-react";
|
|
20
|
+
import { Button, Input, Label, Skeleton, Textarea } from "@graph8/sdk/ui";
|
|
21
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
22
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
23
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
24
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
25
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
26
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
27
|
+
import { g8Get, g8Put } from "@/g8/lib/g8-client";
|
|
28
|
+
import {
|
|
29
|
+
BILLING_FIELDS, BILLING_PROVIDERS, FIELD_LABEL, draftFromSettings, settingsChanges, settingsEffects, settingsProblems, unclearableSettings,
|
|
30
|
+
type SettingsBool, type SettingsDraft, type SettingsField,
|
|
31
|
+
} from "../lib/quote-admin";
|
|
32
|
+
import { PAYMENT_TERMS, asRec, body, text } from "../lib/quotes";
|
|
33
|
+
import { QuoteDocuments } from "./quote-documents";
|
|
34
|
+
|
|
35
|
+
const BOOL_LABEL: Record<SettingsBool, string> = {
|
|
36
|
+
auto_close_deal_on_acceptance: "Move the deal to Closed Won when a quote is accepted",
|
|
37
|
+
auto_advance_deal_on_quote_send: "Move an earlier open deal to Proposal Sent when a quote is sent",
|
|
38
|
+
quote_acceptance_roam_notifications_enabled: "Post to a Roam channel when a quote is accepted",
|
|
39
|
+
};
|
|
40
|
+
const words = (s: string) => s.replace(/_/g, " ");
|
|
41
|
+
|
|
42
|
+
export function QuoteSettings({ className }: { className?: string }) {
|
|
43
|
+
const brand = useBrand();
|
|
44
|
+
const q = useG8Query((signal) => g8Get("/quote-settings", undefined, { signal }), []);
|
|
45
|
+
const row = body(q.data);
|
|
46
|
+
const [was, setWas] = useState<SettingsDraft | null>(null);
|
|
47
|
+
const [now, setNow] = useState<SettingsDraft | null>(null);
|
|
48
|
+
const [confirm, setConfirm] = useState(false);
|
|
49
|
+
const inherited = asRec(row.inherited_branding);
|
|
50
|
+
|
|
51
|
+
// Take the API's answer as the new baseline whenever it lands (first load, and after a save). Typing that happened while a
|
|
52
|
+
// reload was in flight is kept: the form is only replaced when the user had not changed anything since the last baseline.
|
|
53
|
+
const baseline = useRef<SettingsDraft | null>(null);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!q.data) return;
|
|
56
|
+
const d = draftFromSettings(body(q.data));
|
|
57
|
+
const old = baseline.current;
|
|
58
|
+
baseline.current = d;
|
|
59
|
+
setWas(d);
|
|
60
|
+
setNow((cur) => (cur && old && JSON.stringify(cur) !== JSON.stringify(old) ? cur : d));
|
|
61
|
+
}, [q.data]);
|
|
62
|
+
|
|
63
|
+
const changes = was && now ? settingsChanges(was, now) : {};
|
|
64
|
+
const blocked = was && now ? unclearableSettings(was, now) : [];
|
|
65
|
+
const problems = now ? settingsProblems(now) : [];
|
|
66
|
+
const effects = was ? settingsEffects(changes, was) : [];
|
|
67
|
+
const save = useG8Mutation(() => g8Put("/quote-settings", changes), { onSuccess: () => { setConfirm(false); void q.refetch(); } });
|
|
68
|
+
|
|
69
|
+
if (q.error && !q.data) return <ApiError error={q.error} title="Could not load quote settings" onRetry={() => void q.refetch()} />;
|
|
70
|
+
if (!now || !was) return <Skeleton className="h-64 w-full" aria-label="Loading quote settings" />;
|
|
71
|
+
|
|
72
|
+
const text_ = (k: SettingsField, v: string) => setNow({ ...now, text: { ...now.text, [k]: v } });
|
|
73
|
+
const dirty = Object.keys(changes).length > 0;
|
|
74
|
+
// Anything typed counts for Discard, including a blanked field that Save will refuse (it is not a change the API can take).
|
|
75
|
+
const edited = JSON.stringify(now) !== JSON.stringify(was);
|
|
76
|
+
const canSave = dirty && blocked.length === 0 && problems.length === 0;
|
|
77
|
+
const submit = () => (effects.length > 0 ? setConfirm(true) : void save.mutate(undefined));
|
|
78
|
+
|
|
79
|
+
// A render function, not a component: a component defined here would remount (and drop focus) on every keystroke.
|
|
80
|
+
const field = (k: SettingsField, { area, placeholder, type }: { area?: boolean; placeholder?: string; type?: string } = {}) => (
|
|
81
|
+
<div className="space-y-1" key={k}>
|
|
82
|
+
<Label htmlFor={`qs-${k}`}>{FIELD_LABEL[k]}</Label>
|
|
83
|
+
{area ? <Textarea id={`qs-${k}`} rows={3} value={now.text[k]} placeholder={placeholder} onChange={(e) => text_(k, e.target.value)} /> : <Input id={`qs-${k}`} type={type} value={now.text[k]} placeholder={placeholder} onChange={(e) => text_(k, e.target.value)} />}
|
|
84
|
+
{blocked.includes(k) ? <p role="alert" className="text-xs text-destructive">The API cannot clear this field. Enter a new value, or clear it in {brand.linkLabel}.</p> : null}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className={`space-y-4 ${className ?? ""}`}>
|
|
90
|
+
{q.error ? <ApiError error={q.error} title="Could not refresh quote settings" onRetry={() => void q.refetch()} /> : null}
|
|
91
|
+
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); if (canSave) submit(); }}>
|
|
92
|
+
<SectionCard title="Company and branding" icon={<Settings2 />} description="Shown on every quote. A blank field falls back to your company profile.">
|
|
93
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
94
|
+
{field("company_name", { placeholder: text(inherited.company_name) })}
|
|
95
|
+
{field("company_email", { placeholder: text(inherited.company_email) })}
|
|
96
|
+
{field("company_phone", { placeholder: text(inherited.company_phone) })}
|
|
97
|
+
{field("accent_color", { placeholder: "#111111" })}
|
|
98
|
+
<div className="sm:col-span-2">{field("company_address", { area: true, placeholder: text(inherited.company_address) })}</div>
|
|
99
|
+
<div className="sm:col-span-2">{field("company_logo_url", { placeholder: text(inherited.company_logo_url) })}</div>
|
|
100
|
+
<div className="sm:col-span-2">{field("footer_text", { area: true })}</div>
|
|
101
|
+
</div>
|
|
102
|
+
</SectionCard>
|
|
103
|
+
|
|
104
|
+
<SectionCard title="Terms and numbering">
|
|
105
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
106
|
+
{field("quote_number_prefix")}
|
|
107
|
+
<div className="space-y-1"><Label htmlFor="qs-valid">Default valid days</Label>
|
|
108
|
+
<Input id="qs-valid" type="number" min={1} value={now.default_valid_days} onChange={(e) => setNow({ ...now, default_valid_days: e.target.value })} />
|
|
109
|
+
{blocked.includes("default_valid_days") ? <p role="alert" className="text-xs text-destructive">The API cannot clear this field. Enter a number.</p> : null}</div>
|
|
110
|
+
<div className="space-y-1"><Label htmlFor="qs-pay">{FIELD_LABEL.default_payment_terms}</Label>
|
|
111
|
+
<select id="qs-pay" className="h-9 w-full rounded-md border bg-background px-2 text-sm" value={now.text.default_payment_terms} onChange={(e) => text_("default_payment_terms", e.target.value)}>
|
|
112
|
+
{now.text.default_payment_terms === "" ? <option value="">Not set</option> : null}
|
|
113
|
+
{!(PAYMENT_TERMS as readonly string[]).includes(now.text.default_payment_terms) && now.text.default_payment_terms ? <option value={now.text.default_payment_terms}>{words(now.text.default_payment_terms)}</option> : null}
|
|
114
|
+
{PAYMENT_TERMS.map((t) => <option key={t} value={t}>{words(t)}</option>)}
|
|
115
|
+
</select></div>
|
|
116
|
+
<div className="sm:col-span-2">{field("default_terms", { area: true })}</div>
|
|
117
|
+
<div className="sm:col-span-2">{field("default_privacy", { area: true })}</div>
|
|
118
|
+
{field("terms_url", { type: "url" })}
|
|
119
|
+
{field("privacy_url", { type: "url" })}
|
|
120
|
+
</div>
|
|
121
|
+
<fieldset className="mt-4 space-y-2">
|
|
122
|
+
<legend className="text-sm font-medium">Saved terms</legend>
|
|
123
|
+
{now.terms_library.length === 0 ? <p className="text-xs text-muted-foreground">No saved terms. Add the clauses reps pick from when they build a quote.</p> : null}
|
|
124
|
+
{now.terms_library.map((t, i) => {
|
|
125
|
+
const set = (patch: Partial<typeof t>) => setNow({ ...now, terms_library: now.terms_library.map((x, j) => (j === i ? { ...x, ...patch } : x)) });
|
|
126
|
+
return (
|
|
127
|
+
<div key={i} className="grid gap-2 rounded-md border border-border p-3 sm:grid-cols-[1fr_9rem_auto]">
|
|
128
|
+
<Input aria-label={`Terms name ${i + 1}`} placeholder="Name" value={t.name} onChange={(e) => set({ name: e.target.value })} />
|
|
129
|
+
<select aria-label={`Terms payment terms ${i + 1}`} className="h-9 rounded-md border bg-background px-2 text-sm" value={t.payment_terms} onChange={(e) => set({ payment_terms: e.target.value })}>
|
|
130
|
+
<option value="">Any</option>{PAYMENT_TERMS.map((p) => <option key={p} value={p}>{words(p)}</option>)}</select>
|
|
131
|
+
<Button type="button" variant="ghost" size="sm" aria-label={`Remove terms ${i + 1}`} onClick={() => setNow({ ...now, terms_library: now.terms_library.filter((_, j) => j !== i) })}><Trash2 className="size-4" aria-hidden /></Button>
|
|
132
|
+
<Textarea className="sm:col-span-3" aria-label={`Terms text ${i + 1}`} rows={3} placeholder="Clause text" value={t.content} onChange={(e) => set({ content: e.target.value })} />
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
})}
|
|
136
|
+
<Button type="button" size="sm" variant="outline" onClick={() => setNow({ ...now, terms_library: [...now.terms_library, { name: "", payment_terms: "", content: "" }] })}>Add terms</Button>
|
|
137
|
+
</fieldset>
|
|
138
|
+
</SectionCard>
|
|
139
|
+
|
|
140
|
+
<SectionCard title="Who quotes are sent as" description="Quote emails use this identity and reply address.">
|
|
141
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
142
|
+
<div className="space-y-1 sm:col-span-2"><Label htmlFor="qs-mode">{FIELD_LABEL.sender_mode}</Label>
|
|
143
|
+
<select id="qs-mode" className="h-9 w-full rounded-md border bg-background px-2 text-sm" value={now.text.sender_mode} onChange={(e) => text_("sender_mode", e.target.value)}>
|
|
144
|
+
{now.text.sender_mode === "" ? <option value="">Not set</option> : null}
|
|
145
|
+
<option value="fixed">The sender below, on every quote</option><option value="creator">The person who sends each quote</option></select></div>
|
|
146
|
+
{field("sender_name")}{field("sender_email", { type: "email" })}
|
|
147
|
+
</div>
|
|
148
|
+
</SectionCard>
|
|
149
|
+
|
|
150
|
+
<SectionCard title="Billing and deals" description="What happens when a quote is signed.">
|
|
151
|
+
<div className="space-y-3">
|
|
152
|
+
<div className="space-y-1"><Label htmlFor="qs-provider">{FIELD_LABEL.default_billing_provider}</Label>
|
|
153
|
+
<select id="qs-provider" className="h-9 w-full rounded-md border bg-background px-2 text-sm" value={now.text.default_billing_provider} onChange={(e) => text_("default_billing_provider", e.target.value)}>
|
|
154
|
+
{!BILLING_PROVIDERS.some(([v]) => v === now.text.default_billing_provider) ? <option value={now.text.default_billing_provider}>{now.text.default_billing_provider ? `${words(now.text.default_billing_provider)} (not available here)` : "Not set"}</option> : null}
|
|
155
|
+
{BILLING_PROVIDERS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select></div>
|
|
156
|
+
<fieldset className="space-y-1"><legend className="text-sm font-medium">Required billing details</legend>
|
|
157
|
+
{BILLING_FIELDS.map((f) => (
|
|
158
|
+
<label key={f} className="flex items-center gap-2 text-sm"><input type="checkbox" checked={now.required_billing_fields.includes(f)}
|
|
159
|
+
onChange={(e) => setNow({ ...now, required_billing_fields: e.target.checked ? [...now.required_billing_fields, f] : now.required_billing_fields.filter((x) => x !== f) })} />{words(f)}</label>
|
|
160
|
+
))}</fieldset>
|
|
161
|
+
{(Object.keys(BOOL_LABEL) as SettingsBool[]).map((k) => (
|
|
162
|
+
<label key={k} className="flex items-center gap-2 text-sm"><input type="checkbox" checked={now.flags[k]} onChange={(e) => setNow({ ...now, flags: { ...now.flags, [k]: e.target.checked } })} />{BOOL_LABEL[k]}</label>
|
|
163
|
+
))}
|
|
164
|
+
{now.flags.quote_acceptance_roam_notifications_enabled ? <div className="grid gap-3 sm:grid-cols-2">{field("quote_acceptance_roam_channel_name")}{field("quote_acceptance_roam_channel_id")}</div> : null}
|
|
165
|
+
</div>
|
|
166
|
+
</SectionCard>
|
|
167
|
+
|
|
168
|
+
{problems.length ? <ul role="alert" className="list-disc pl-5 text-sm text-destructive">{problems.map((p) => <li key={p}>{p}</li>)}</ul> : null}
|
|
169
|
+
{save.error ? <ApiError error={save.error} title="Could not save quote settings" /> : null}
|
|
170
|
+
<div className="flex items-center justify-end gap-2">
|
|
171
|
+
<Button type="button" variant="outline" disabled={!edited} onClick={() => setNow(was)}>Discard changes</Button>
|
|
172
|
+
<Button type="submit" disabled={!canSave} loading={save.loading && !confirm}>Save settings</Button>
|
|
173
|
+
</div>
|
|
174
|
+
</form>
|
|
175
|
+
<QuoteDocuments />
|
|
176
|
+
|
|
177
|
+
{confirm ? (
|
|
178
|
+
<ConfirmSpendDialog open tier="external" title="Save these quote settings?" confirmLabel="Save settings" confirming={save.loading}
|
|
179
|
+
description={<ul className="list-disc space-y-1 pl-5">{effects.map((e) => <li key={e}>{e}</li>)}</ul>}
|
|
180
|
+
onConfirm={() => void save.mutate(undefined)} onCancel={() => setConfirm(false)} />
|
|
181
|
+
) : null}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* QuoteTemplates: the reusable starting points for a quote (payment terms, validity, contract length, terms text,
|
|
5
|
+
* attached legal documents and preset line items), listed, created, edited and deleted.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in order:
|
|
8
|
+
* 1. listQuoteTemplates GET /quote-templates read
|
|
9
|
+
* 2. listQuoteDocuments GET /quote-settings/documents read - the library a template attaches from
|
|
10
|
+
* 3. createQuoteTemplate POST /quote-templates external tier, stores data only (org admin)
|
|
11
|
+
* 4. updateQuoteTemplate PUT /quote-templates/{id} external tier (org admin): REPLACES the template
|
|
12
|
+
* 5. deleteQuoteTemplate DELETE /quote-templates/{id} DESTRUCTIVE (org admin) -> ConfirmSpendDialog
|
|
13
|
+
* Plus listQuotableProducts (read) inside ProductPicker, for the preset line items.
|
|
14
|
+
*
|
|
15
|
+
* Creating and saving a template send nobody anything, so they run on one click, like saving a draft quote. Two things
|
|
16
|
+
* still go through ConfirmSpendDialog: Delete, and a Save that would reset template settings the API cannot carry
|
|
17
|
+
* (payment methods, marketing rights, contract start on signature: the confirm names exactly which ones this template
|
|
18
|
+
* has). Writes need an organization admin; a refusal is shown as the API's own message.
|
|
19
|
+
*/
|
|
20
|
+
import { useState } from "react";
|
|
21
|
+
import { FileStack, Pencil, Plus, Trash2 } from "lucide-react";
|
|
22
|
+
import { Button, Input, Label, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Textarea } from "@graph8/sdk/ui";
|
|
23
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
24
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
25
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
26
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
27
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
28
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
29
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
30
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
31
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
32
|
+
import { g8Delete, g8Get, g8Post, g8Put } from "@/g8/lib/g8-client";
|
|
33
|
+
import { DURATION_UNITS, draftFromTemplate, emptyTemplateDraft, fieldsLostOnTemplateSave, templateBody, templateProblems, type TemplateDraft } from "../lib/quote-admin";
|
|
34
|
+
import { PAYMENT_TERMS, body, linesFromQuote, text, toRequestLines, type LineItem, type Rec } from "../lib/quotes";
|
|
35
|
+
import { ProductPicker } from "./product-picker";
|
|
36
|
+
|
|
37
|
+
const label = (s: string) => s.replace(/_/g, " ");
|
|
38
|
+
const plain = (cents: number) => (cents / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
39
|
+
|
|
40
|
+
function LinesEditor({ lines, onChange }: { lines: LineItem[]; onChange: (next: LineItem[]) => void }) {
|
|
41
|
+
const [picking, setPicking] = useState(false);
|
|
42
|
+
const set = (key: string, patch: Partial<LineItem>) => onChange(lines.map((l) => (l.key === key ? { ...l, ...patch } : l)));
|
|
43
|
+
return (
|
|
44
|
+
<div className="space-y-2">
|
|
45
|
+
<div className="flex items-center justify-between"><Label>Preset line items</Label><Button type="button" size="sm" variant="outline" onClick={() => setPicking(!picking)}>{picking ? "Hide products" : "Add a product"}</Button></div>
|
|
46
|
+
{picking ? <ProductPicker onPick={(l) => onChange([...lines, l])} pageSize={10} /> : null}
|
|
47
|
+
{lines.length === 0 ? <p className="text-xs text-muted-foreground">No preset lines. A quote built from this template starts empty.</p> : (
|
|
48
|
+
<Table>
|
|
49
|
+
<TableHeader><TableRow><TableHead>Product</TableHead><TableHead>Qty</TableHead><TableHead className="text-right">Unit</TableHead><TableHead><span className="sr-only">Remove</span></TableHead></TableRow></TableHeader>
|
|
50
|
+
<TableBody>
|
|
51
|
+
{lines.map((l) => (
|
|
52
|
+
<TableRow key={l.key}>
|
|
53
|
+
<TableCell>{l.product_name}</TableCell>
|
|
54
|
+
<TableCell><Input aria-label={`Quantity for ${l.product_name}`} type="number" min={1} max={10000} className="h-8 w-20" value={l.quantity} onChange={(e) => set(l.key, { quantity: Math.max(1, Math.min(10000, Math.floor(Number(e.target.value) || 1))) })} /></TableCell>
|
|
55
|
+
<TableCell className="text-right tabular-nums">{plain(l.unit_amount)}</TableCell>
|
|
56
|
+
<TableCell><Button type="button" size="sm" variant="ghost" aria-label={`Remove ${l.product_name}`} onClick={() => onChange(lines.filter((x) => x.key !== l.key))}><Trash2 className="size-4" aria-hidden /></Button></TableCell>
|
|
57
|
+
</TableRow>
|
|
58
|
+
))}
|
|
59
|
+
</TableBody>
|
|
60
|
+
</Table>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function DocumentsPicker({ selected, onChange }: { selected: string[]; onChange: (ids: string[]) => void }) {
|
|
67
|
+
const docs = useG8Query((signal) => g8Get("/quote-settings/documents", undefined, { signal }), []);
|
|
68
|
+
const items = (Array.isArray(body(docs.data).items) ? body(docs.data).items : []) as Rec[];
|
|
69
|
+
if (docs.error) return <ApiError error={docs.error} title="Could not load the document library" onRetry={() => void docs.refetch()} />;
|
|
70
|
+
const known = new Set(items.map((d) => text(d.id)));
|
|
71
|
+
const missing = selected.filter((id) => !known.has(id));
|
|
72
|
+
return (
|
|
73
|
+
<fieldset className="space-y-1">
|
|
74
|
+
<legend className="text-sm font-medium">Attached documents</legend>
|
|
75
|
+
{docs.loading && !docs.data ? <Skeleton className="h-8 w-full" /> : items.length === 0 ? <p className="text-xs text-muted-foreground">The document library is empty. Upload an NDA or MSA in Settings.</p> : items.map((d) => (
|
|
76
|
+
<label key={text(d.id)} className="flex items-center gap-2 text-sm">
|
|
77
|
+
<input type="checkbox" checked={selected.includes(text(d.id))} onChange={(e) => onChange(e.target.checked ? [...selected, text(d.id)] : selected.filter((x) => x !== text(d.id)))} />
|
|
78
|
+
{text(d.title, text(d.file_name))} <span className="text-xs text-muted-foreground">{text(d.doc_type).toUpperCase()}</span>
|
|
79
|
+
</label>
|
|
80
|
+
))}
|
|
81
|
+
{missing.length > 0 ? <p className="text-xs text-muted-foreground">{missing.length} attached {missing.length === 1 ? "document is" : "documents are"} no longer in the library. Saving keeps {missing.length === 1 ? "it" : "them"} attached.</p> : null}
|
|
82
|
+
</fieldset>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function TemplateForm({ draft, setDraft }: { draft: TemplateDraft; setDraft: (d: TemplateDraft) => void }) {
|
|
87
|
+
const up = (patch: Partial<TemplateDraft>) => setDraft({ ...draft, ...patch });
|
|
88
|
+
return (
|
|
89
|
+
<div className="space-y-4">
|
|
90
|
+
<div className="space-y-1"><Label htmlFor="tpl-name">Name</Label><Input id="tpl-name" value={draft.name} maxLength={255} onChange={(e) => up({ name: e.target.value })} /></div>
|
|
91
|
+
<div className="space-y-1"><Label htmlFor="tpl-desc">Description</Label><Textarea id="tpl-desc" rows={2} value={draft.description} onChange={(e) => up({ description: e.target.value })} /></div>
|
|
92
|
+
<div className="grid grid-cols-2 gap-3">
|
|
93
|
+
<div className="space-y-1"><Label htmlFor="tpl-terms">Payment terms</Label>
|
|
94
|
+
<select id="tpl-terms" className="h-9 w-full rounded-md border bg-background px-2 text-sm" value={draft.default_payment_terms} onChange={(e) => up({ default_payment_terms: e.target.value })}>
|
|
95
|
+
<option value="">Use the workspace default</option>{PAYMENT_TERMS.map((t) => <option key={t} value={t}>{label(t)}</option>)}
|
|
96
|
+
</select></div>
|
|
97
|
+
<div className="space-y-1"><Label htmlFor="tpl-valid">Valid for (days)</Label><Input id="tpl-valid" type="number" min={1} max={365} value={draft.default_valid_days} onChange={(e) => up({ default_valid_days: e.target.value })} /></div>
|
|
98
|
+
<div className="space-y-1"><Label htmlFor="tpl-len">Contract length</Label><Input id="tpl-len" type="number" min={0.1} step="any" value={draft.contract_duration_value} onChange={(e) => up({ contract_duration_value: e.target.value })} /></div>
|
|
99
|
+
<div className="space-y-1"><Label htmlFor="tpl-unit">Length unit</Label>
|
|
100
|
+
<select id="tpl-unit" className="h-9 w-full rounded-md border bg-background px-2 text-sm" value={draft.contract_duration_unit} onChange={(e) => up({ contract_duration_unit: e.target.value as TemplateDraft["contract_duration_unit"] })}>
|
|
101
|
+
<option value="">No fixed length</option>{DURATION_UNITS.map((u) => <option key={u} value={u}>{u}</option>)}
|
|
102
|
+
</select></div>
|
|
103
|
+
</div>
|
|
104
|
+
<div className="space-y-1"><Label htmlFor="tpl-override">Terms text for this template</Label><Textarea id="tpl-override" rows={4} placeholder="Leave blank to use the workspace default terms" value={draft.terms_override} onChange={(e) => up({ terms_override: e.target.value })} /></div>
|
|
105
|
+
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={draft.is_default} onChange={(e) => up({ is_default: e.target.checked })} />Use as the default template for new quotes</label>
|
|
106
|
+
<DocumentsPicker selected={draft.document_ids} onChange={(document_ids) => up({ document_ids })} />
|
|
107
|
+
<LinesEditor lines={draft.lines} onChange={(lines) => up({ lines })} />
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type Confirm = { kind: "delete"; row: Rec } | { kind: "lost"; fields: string[] };
|
|
113
|
+
|
|
114
|
+
export function QuoteTemplates({ className }: { className?: string }) {
|
|
115
|
+
const brand = useBrand();
|
|
116
|
+
const q = useG8Query((signal) => g8Get("/quote-templates", undefined, { signal }), []);
|
|
117
|
+
const rows = (Array.isArray(body(q.data).items) ? body(q.data).items : []) as Rec[];
|
|
118
|
+
const [editing, setEditing] = useState<{ row: Rec | null } | null>(null);
|
|
119
|
+
const [draft, setDraft] = useState<TemplateDraft>(emptyTemplateDraft());
|
|
120
|
+
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
|
121
|
+
const [tried, setTried] = useState(false);
|
|
122
|
+
const problems = templateProblems(draft);
|
|
123
|
+
|
|
124
|
+
const open = (row: Rec | null) => { setDraft(row ? draftFromTemplate(row, linesFromQuote(row.default_line_items)) : emptyTemplateDraft()); setTried(false); setEditing({ row }); };
|
|
125
|
+
const close = () => { setEditing(null); setConfirm(null); };
|
|
126
|
+
const save = useG8Mutation(async () => {
|
|
127
|
+
const payload = templateBody(draft, toRequestLines(draft.lines));
|
|
128
|
+
return editing?.row ? g8Put(`/quote-templates/${encodeURIComponent(text(editing.row.id))}`, payload) : g8Post("/quote-templates", payload);
|
|
129
|
+
}, { onSuccess: () => { close(); void q.refetch(); } });
|
|
130
|
+
const remove = useG8Mutation((id: string) => g8Delete(`/quote-templates/${encodeURIComponent(id)}`), { onSuccess: () => { setConfirm(null); void q.refetch(); } });
|
|
131
|
+
|
|
132
|
+
const submit = () => {
|
|
133
|
+
setTried(true);
|
|
134
|
+
if (problems.length) return;
|
|
135
|
+
const lost = editing?.row ? fieldsLostOnTemplateSave(editing.row) : [];
|
|
136
|
+
if (lost.length) setConfirm({ kind: "lost", fields: lost });
|
|
137
|
+
else void save.mutate(undefined);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const rowName = (r: Rec) => text(r.name, "Untitled template");
|
|
141
|
+
return (
|
|
142
|
+
<SectionCard className={className} title="Quote templates" icon={<FileStack />} description="Starting points for new quotes: terms, validity, contract length, documents and preset lines."
|
|
143
|
+
actions={<Button size="sm" onClick={() => open(null)}><Plus className="size-4" aria-hidden />New template</Button>} flush>
|
|
144
|
+
{q.error ? <div className="p-4"><ApiError error={q.error} title="Could not load templates" onRetry={() => void q.refetch()} /></div> : null}
|
|
145
|
+
{q.loading && !q.data ? <div className="p-4"><Skeleton className="h-24 w-full" aria-label="Loading templates" /></div> : !q.error && rows.length === 0 ? (
|
|
146
|
+
<EmptyState compact title="No quote templates yet" description="Save the terms and lines you reuse, then start a quote from them." action={<Button onClick={() => open(null)}>New template</Button>} />
|
|
147
|
+
) : rows.length > 0 ? (
|
|
148
|
+
<Table>
|
|
149
|
+
<TableHeader><TableRow><TableHead>Template</TableHead><TableHead>Payment terms</TableHead><TableHead>Valid</TableHead><TableHead className="text-right">Lines</TableHead><TableHead><span className="sr-only">Actions</span></TableHead></TableRow></TableHeader>
|
|
150
|
+
<TableBody>
|
|
151
|
+
{rows.map((r) => (
|
|
152
|
+
<TableRow key={text(r.id)}>
|
|
153
|
+
<TableCell><span className="font-medium">{rowName(r)}</span>{r.is_default === true ? <StatusPill tone="info" className="ml-2">Default</StatusPill> : null}{r.description ? <div className="text-xs text-muted-foreground">{text(r.description)}</div> : null}</TableCell>
|
|
154
|
+
<TableCell>{label(text(r.default_payment_terms, "workspace default"))}</TableCell>
|
|
155
|
+
<TableCell>{r.default_valid_days ? `${text(r.default_valid_days)} days` : "-"}</TableCell>
|
|
156
|
+
<TableCell className="text-right tabular-nums">{Array.isArray(r.default_line_items) ? r.default_line_items.length : 0}</TableCell>
|
|
157
|
+
<TableCell className="whitespace-nowrap text-right">
|
|
158
|
+
<Button size="sm" variant="ghost" aria-label={`Edit ${rowName(r)}`} onClick={() => open(r)}><Pencil className="size-4" aria-hidden /></Button>
|
|
159
|
+
<Button size="sm" variant="ghost" aria-label={`Delete ${rowName(r)}`} onClick={() => setConfirm({ kind: "delete", row: r })}><Trash2 className="size-4" aria-hidden /></Button>
|
|
160
|
+
</TableCell>
|
|
161
|
+
</TableRow>
|
|
162
|
+
))}
|
|
163
|
+
</TableBody>
|
|
164
|
+
</Table>
|
|
165
|
+
) : null}
|
|
166
|
+
{remove.error ? <div className="p-4"><ApiError error={remove.error} title="Could not delete the template" /></div> : null}
|
|
167
|
+
|
|
168
|
+
<FormSheet open={editing != null} onOpenChange={(o) => { if (!o) close(); }} width="lg" title={editing?.row ? "Edit template" : "New template"}
|
|
169
|
+
description={editing?.row ? "Saving replaces the whole template with what is in this form." : "Needs an organization admin."}
|
|
170
|
+
onSubmit={submit}
|
|
171
|
+
footer={<><Button type="button" variant="outline" onClick={close}>Cancel</Button><Button type="submit" loading={save.loading}>{editing?.row ? "Save template" : "Create template"}</Button></>}>
|
|
172
|
+
{editing ? <TemplateForm draft={draft} setDraft={setDraft} /> : null}
|
|
173
|
+
{tried && problems.length ? <ul role="alert" className="list-disc pl-5 text-sm text-destructive">{problems.map((p) => <li key={p}>{p}</li>)}</ul> : null}
|
|
174
|
+
{save.error ? <ApiError error={save.error} title="Could not save the template" /> : null}
|
|
175
|
+
</FormSheet>
|
|
176
|
+
|
|
177
|
+
{confirm?.kind === "delete" ? (
|
|
178
|
+
<ConfirmSpendDialog open tier="destructive" title={`Delete "${rowName(confirm.row)}"?`} confirmLabel="Delete template" affected={1} confirming={remove.loading}
|
|
179
|
+
description="The template is removed and can no longer start a quote. Quotes already built from it are not changed."
|
|
180
|
+
onConfirm={() => void remove.mutate(text(confirm.row.id))} onCancel={() => setConfirm(null)} />
|
|
181
|
+
) : null}
|
|
182
|
+
{confirm?.kind === "lost" ? (
|
|
183
|
+
<ConfirmSpendDialog open tier="destructive" title="Saving here resets some template settings" confirmLabel="Save and reset" confirming={save.loading}
|
|
184
|
+
description={<p>This template has {confirm.fields.join(", ")}. The API cannot carry {confirm.fields.length === 1 ? "that setting" : "those settings"}, so saving from here resets {confirm.fields.length === 1 ? "it" : "them"}. Edit the template in {brand.linkLabel} to keep {confirm.fields.length === 1 ? "it" : "them"}.</p>}
|
|
185
|
+
onConfirm={() => void save.mutate(undefined)} onCancel={() => setConfirm(null)} />
|
|
186
|
+
) : null}
|
|
187
|
+
</SectionCard>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* 3. editQuoteAsDraft POST /quotes/{id}/edit-as-draft (external; `sent` quotes only, returns `new_quote_id`: voids the sent copy, signing link dies, new draft)
|
|
11
11
|
* 4. duplicateQuote POST /quotes/{id}/duplicate (external tier, but only copies into a new draft)
|
|
12
12
|
* 5. deleteQuote DELETE /quotes/{id} (destructive; drafts only)
|
|
13
|
+
* 6. previewQuoteSend POST /quotes/{id}/send-preview (read; renders the exact email and sends nothing: "Preview email" in the send confirm)
|
|
13
14
|
*
|
|
14
15
|
* The lifecycle row (QuoteLifecycleActions): download the PDF, archive or unarchive, and for a sent quote expire, revoke or
|
|
15
16
|
* mark accepted. Those operations live in that component and each risky one is confirmed there.
|
|
@@ -62,6 +63,8 @@ export function QuoteViewer({ quoteId, onEdit, onOpenQuote, onDeleted, className
|
|
|
62
63
|
void q.refetch();
|
|
63
64
|
},
|
|
64
65
|
});
|
|
66
|
+
// What the customer would get, rendered by the API from the same subject and message the confirm holds. Sends nothing.
|
|
67
|
+
const preview = useG8Mutation(async () => body(await g8Post(`${base}/send-preview`, { ...(subject ? { subject } : {}), ...(message ? { message } : {}) })));
|
|
65
68
|
const copy = useG8Mutation(async () => body(await g8Post(`${base}/duplicate`)), { onSuccess: (res) => { if (text(res.id)) onOpenQuote?.(text(res.id)); } });
|
|
66
69
|
|
|
67
70
|
if (q.loading && !q.data) return <div className={className} aria-busy="true"><Skeleton className="h-48 w-full" /></div>;
|
|
@@ -86,7 +89,7 @@ export function QuoteViewer({ quoteId, onEdit, onOpenQuote, onDeleted, className
|
|
|
86
89
|
{status === "sent" ? <Button variant="outline" onClick={() => setPending("edit")}>Edit as draft</Button> : null}
|
|
87
90
|
<Button variant="outline" loading={copy.loading} onClick={() => void copy.mutate(undefined)}>Duplicate</Button>
|
|
88
91
|
{draft ? <Button variant="ghost" onClick={() => setPending("delete")}>Delete</Button> : null}
|
|
89
|
-
{draft || sent ? <Button disabled={!hasSigner} title={hasSigner ? undefined : "Add a signer in the builder first"} onClick={() => setPending("send")}>{sent ? "Resend" : "Send for signature"}</Button> : null}
|
|
92
|
+
{draft || sent ? <Button disabled={!hasSigner} title={hasSigner ? undefined : "Add a signer in the builder first"} onClick={() => { preview.reset(); setPending("send"); }}>{sent ? "Resend" : "Send for signature"}</Button> : null}
|
|
90
93
|
</div>
|
|
91
94
|
</div>
|
|
92
95
|
<QuoteLifecycleActions className="mb-4" quoteId={quoteId} status={status} archived={Boolean(d.archived_at || d.is_archived || d.archived === true)} title={text(d.title)} quoteNumber={text(d.quote_number)} onChanged={() => void q.refetch()} />
|
|
@@ -123,8 +126,18 @@ export function QuoteViewer({ quoteId, onEdit, onOpenQuote, onDeleted, className
|
|
|
123
126
|
{pending === "send" ? (
|
|
124
127
|
<ConfirmSpendDialog open tier="external" title={`${sent ? "Resend" : "Send"} "${name}" for signature`} confirmLabel={sent ? "Resend" : "Send"} recipients={1} confirming={run.loading}
|
|
125
128
|
description={<div className="space-y-2 text-left"><p>Emails {text(d.signer_contact_email ?? d.signer_email)} a link to review and sign.</p>
|
|
126
|
-
<Input aria-label="Email subject" placeholder="Subject (optional)" value={subject} onChange={(e) => setSubject(e.target.value)} />
|
|
127
|
-
<Textarea aria-label="Email message" rows={3} placeholder="Message (optional)" value={message} onChange={(e) => setMessage(e.target.value)}
|
|
129
|
+
<Input aria-label="Email subject" placeholder="Subject (optional)" value={subject} onChange={(e) => { setSubject(e.target.value); preview.reset(); }} />
|
|
130
|
+
<Textarea aria-label="Email message" rows={3} placeholder="Message (optional)" value={message} onChange={(e) => { setMessage(e.target.value); preview.reset(); }} />
|
|
131
|
+
<Button type="button" variant="outline" size="sm" loading={preview.loading} onClick={() => void preview.mutate(undefined)}>Preview email</Button>
|
|
132
|
+
{preview.error ? <ApiError error={preview.error} title="Could not render the preview" /> : null}
|
|
133
|
+
{preview.data ? (
|
|
134
|
+
<div className="space-y-1 rounded-md border border-border p-2" data-testid="send-preview">
|
|
135
|
+
<p className="text-xs text-muted-foreground">From {text(preview.data.sender_name)}{preview.data.reply_to ? ` (replies to ${text(preview.data.reply_to)})` : ""} to {text(preview.data.recipient_name)} {text(preview.data.recipient_email)}</p>
|
|
136
|
+
<p className="text-sm font-medium">{text(preview.data.email_subject, text(preview.data.subject))}</p>
|
|
137
|
+
{preview.data.email_html ? <iframe title="Email preview" sandbox="" srcDoc={text(preview.data.email_html)} className="h-48 w-full rounded border-0 bg-white" /> : null}
|
|
138
|
+
{Array.isArray(preview.data.attached_documents) && preview.data.attached_documents.length ? <p className="text-xs text-muted-foreground">Attached: {preview.data.attached_documents.map((d) => text(asRec(d).title, text(asRec(d).file_name))).join(", ")}</p> : null}
|
|
139
|
+
</div>
|
|
140
|
+
) : null}</div>}
|
|
128
141
|
onConfirm={() => void run.mutate("send")} onCancel={() => setPending(null)} />
|
|
129
142
|
) : null}
|
|
130
143
|
{pending === "edit" ? (
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* QuotesScreen: the finished quotes product. Every quote with its status, a viewer for the open one, a builder for
|
|
5
5
|
* new and draft quotes, and e-signature tracking. No props are required.
|
|
6
6
|
*
|
|
7
|
-
* It composes: ProductCatalog (Products tab),
|
|
7
|
+
* It composes: ProductCatalog and ManualProducts (Products tab), QuoteTemplates (Templates tab), QuoteSettings with the
|
|
8
|
+
* document library (Settings tab), QuoteEvidence (Evidence tab of an open quote), QuoteBatchActions (inside QuoteList), QuoteDealTab, QuoteList (status filter, paging), QuoteViewer (totals, send, resend, edit as draft, duplicate,
|
|
8
9
|
* delete, e-signature status and reminder), QuoteBuilder (details, signer, line items from ProductPicker) and a deal
|
|
9
10
|
* picker, because graph8 attaches every quote to a deal.
|
|
10
11
|
*
|
|
@@ -16,6 +17,10 @@
|
|
|
16
17
|
* - New quote: choose the deal (search by name; skipped when `dealId` is passed), then the builder. Saving a draft
|
|
17
18
|
* emails nobody, so it needs no confirmation.
|
|
18
19
|
* - Edit a draft from the viewer; duplicate; delete a draft (confirmed).
|
|
20
|
+
* - Tick quotes in the list to archive, expire or revoke them together (confirmed, id-addressed, result read per quote).
|
|
21
|
+
* - Templates tab: create, edit and delete quote templates. Settings tab: org-wide quote settings and the legal document library.
|
|
22
|
+
* - Products tab: the quotable catalogue, plus the manual products you add, edit and retire.
|
|
23
|
+
* - Evidence tab of an open quote: attach and download internal PDFs.
|
|
19
24
|
* - Send for signature, resend, send a signature reminder, and edit a sent quote as a new draft: all external
|
|
20
25
|
* actions, each behind ConfirmSpendDialog because they reach a real person.
|
|
21
26
|
*
|
|
@@ -40,9 +45,13 @@ import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
|
40
45
|
import { formatMoney, type Deal } from "@/g8/deals/lib/deals-types";
|
|
41
46
|
import { useDebounced } from "../hooks/use-debounced";
|
|
42
47
|
import { body } from "../lib/quotes";
|
|
48
|
+
import { ManualProducts } from "./manual-products";
|
|
43
49
|
import { QuoteBuilder } from "./quote-builder";
|
|
50
|
+
import { QuoteEvidence } from "./quote-evidence";
|
|
44
51
|
import { QuoteList } from "./quote-list";
|
|
45
52
|
import { ProductCatalog, QuoteDealTab } from "./quote-related";
|
|
53
|
+
import { QuoteSettings } from "./quote-settings";
|
|
54
|
+
import { QuoteTemplates } from "./quote-templates";
|
|
46
55
|
import { QuoteViewer } from "./quote-viewer";
|
|
47
56
|
|
|
48
57
|
export interface QuotesScreenProps {
|
|
@@ -109,7 +118,7 @@ export function QuotesScreen({ dealId, companyId, renderDetailActions, extraDeta
|
|
|
109
118
|
const [selectedId, setSelectedId] = useUrlState("id");
|
|
110
119
|
const [mode, setMode] = useUrlState("view");
|
|
111
120
|
const [tabParam, setTab] = useUrlState("tab", "quotes");
|
|
112
|
-
const tab = tabParam === "products"
|
|
121
|
+
const tab = tabParam === "products" || tabParam === "templates" || tabParam === "settings" ? tabParam : "quotes";
|
|
113
122
|
const [version, setVersion] = useState(0);
|
|
114
123
|
const [creating, setCreating] = useState(false);
|
|
115
124
|
const [pickedDeal, setPickedDeal] = useState<Deal | null>(null);
|
|
@@ -132,6 +141,7 @@ export function QuotesScreen({ dealId, companyId, renderDetailActions, extraDeta
|
|
|
132
141
|
onCollapse={mode === "full" ? () => setMode(null) : undefined}
|
|
133
142
|
tabs={[
|
|
134
143
|
{ id: "quote", label: "Quote", content: <QuoteViewer key={selectedId} quoteId={selectedId} onEdit={setEditingId} onOpenQuote={(id) => { refresh(); setSelectedId(id); }} onDeleted={() => { close(); refresh(); }} /> },
|
|
144
|
+
{ id: "evidence", label: "Evidence", content: <QuoteEvidence key={selectedId} quoteId={selectedId} /> },
|
|
135
145
|
{ id: "deal", label: "Deal", content: <QuoteDealTab key={selectedId} quoteId={selectedId} onOpenDeal={onOpenDeal} /> },
|
|
136
146
|
...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(selectedId) })),
|
|
137
147
|
]}
|
|
@@ -145,18 +155,18 @@ export function QuotesScreen({ dealId, companyId, renderDetailActions, extraDeta
|
|
|
145
155
|
icon={<FileText className="size-5" />}
|
|
146
156
|
actions={<>{toolbarActions}<Button onClick={openNew}><Plus className="size-4" aria-hidden />New quote</Button></>}
|
|
147
157
|
stats={stats}
|
|
148
|
-
tabs={[{ id: "quotes", label: "Quotes" }, { id: "products", label: "Products" }]}
|
|
158
|
+
tabs={[{ id: "quotes", label: "Quotes" }, { id: "products", label: "Products" }, { id: "templates", label: "Templates" }, { id: "settings", label: "Settings" }]}
|
|
149
159
|
activeTab={tab}
|
|
150
160
|
onTabChange={(id) => { setTab(id); close(); }}
|
|
151
161
|
>
|
|
152
162
|
{totals.error ? <ApiError error={totals.error} title="Could not load quote numbers" onRetry={() => void totals.refetch()} /> : null}
|
|
153
|
-
{tab === "products" ? <ProductCatalog onStartQuote={openNew} /> : <SplitView
|
|
163
|
+
{tab === "products" ? <div className="space-y-4"><ProductCatalog onStartQuote={openNew} /><ManualProducts /></div> : tab === "templates" ? <QuoteTemplates /> : tab === "settings" ? <QuoteSettings /> : <SplitView
|
|
154
164
|
detailOpen={Boolean(selectedId)}
|
|
155
165
|
onCloseDetail={close}
|
|
156
166
|
expanded={mode === "full" && Boolean(selectedId)}
|
|
157
167
|
onExpandedChange={(full) => setMode(full ? "full" : null)}
|
|
158
168
|
detail={detail}
|
|
159
|
-
list={<QuoteList dealId={dealId} companyId={companyId} refreshKey={version} selectedId={selectedId} onOpen={setSelectedId} onNew={openNew} />}
|
|
169
|
+
list={<QuoteList dealId={dealId} companyId={companyId} refreshKey={version} selectedId={selectedId} onOpen={setSelectedId} onNew={openNew} selectable onChanged={refresh} />}
|
|
160
170
|
/>}
|
|
161
171
|
<FormSheet open={creating} onOpenChange={setCreating} title="New quote" description={pickedDeal ? `For ${pickedDeal.name ?? "this deal"}` : undefined}>
|
|
162
172
|
{creating ? (builderDeal ? <QuoteBuilder key={builderDeal} dealId={builderDeal} companyId={companyId} onSaved={saved} /> : <DealStep onPick={setPickedDeal} />) : null}
|