@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,144 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ManualProducts: the org's own product catalogue for quoting without Stripe: list (including inactive and not-quotable),
|
|
5
|
+
* add, edit and retire.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in order:
|
|
8
|
+
* 1. listManualProducts GET /manual-products?include_inactive&include_non_quotable&page&limit read, paged `{ items, total, page, limit }`
|
|
9
|
+
* 2. createManualProduct POST /manual-products write (org admin); amounts are cents, so the form takes a price and converts
|
|
10
|
+
* 3. updateManualProduct PATCH /manual-products/{id} write (org admin); only the fields that changed are sent
|
|
11
|
+
* 4. deleteManualProduct DELETE /manual-products/{id} DESTRUCTIVE (org admin) -> ConfirmSpendDialog; a soft delete
|
|
12
|
+
*
|
|
13
|
+
* Only a product that is active AND quotable appears in the quote builder's picker; the table shows both flags. Retiring
|
|
14
|
+
* a product removes it from the catalogue and the picker, and the line items of quotes that already used it keep it.
|
|
15
|
+
* Create and edit store data and contact nobody, so they run on one click.
|
|
16
|
+
*/
|
|
17
|
+
import { useState } from "react";
|
|
18
|
+
import { Package, Pencil, Plus, Trash2 } from "lucide-react";
|
|
19
|
+
import { Button, Input, Label, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Textarea } from "@graph8/sdk/ui";
|
|
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 { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
24
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
25
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
26
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
27
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
28
|
+
import { g8Delete, g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
29
|
+
import { body, money, text, type Rec } from "../lib/quotes";
|
|
30
|
+
|
|
31
|
+
const FREQUENCIES: Array<[string, string]> = [["one_time", "One time"], ["month", "Monthly"], ["quarter", "Quarterly"], ["year", "Yearly"]];
|
|
32
|
+
const PAGE_SIZE = 25;
|
|
33
|
+
|
|
34
|
+
export interface ManualProductDraft { name: string; description: string; price: string; currency: string; billing_frequency: string; category: string; active: boolean; quotable: boolean }
|
|
35
|
+
const blank = (): ManualProductDraft => ({ name: "", description: "", price: "", currency: "USD", billing_frequency: "one_time", category: "", active: true, quotable: true });
|
|
36
|
+
const fromRow = (r: Rec): ManualProductDraft => ({
|
|
37
|
+
name: text(r.name), description: text(r.description), price: typeof r.default_unit_amount === "number" ? (r.default_unit_amount / 100).toFixed(2) : "", currency: text(r.currency, "USD").toUpperCase(),
|
|
38
|
+
billing_frequency: text(r.billing_frequency, "one_time"), category: text(r.category), active: r.active !== false, quotable: r.quotable !== false,
|
|
39
|
+
});
|
|
40
|
+
const cents = (price: string): number | null => {
|
|
41
|
+
const n = Number(price);
|
|
42
|
+
return price.trim() !== "" && Number.isFinite(n) && n >= 0 && n <= 10_000_000 ? Math.round(n * 100) : null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function draftProblems(d: ManualProductDraft): string[] {
|
|
46
|
+
const out: string[] = [];
|
|
47
|
+
if (!d.name.trim()) out.push("Give the product a name.");
|
|
48
|
+
if (cents(d.price) == null) out.push("Enter a price of 0 or more.");
|
|
49
|
+
if (!/^[A-Za-z]{3}$/.test(d.currency.trim())) out.push("The currency is a 3-letter code such as USD.");
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The PATCH body: only fields that differ from the stored product. A cleared description or category is sent as null. */
|
|
54
|
+
export function productChanges(was: ManualProductDraft, now: ManualProductDraft): Rec {
|
|
55
|
+
const out: Rec = {};
|
|
56
|
+
if (now.name.trim() !== was.name.trim()) out.name = now.name.trim();
|
|
57
|
+
if (now.description.trim() !== was.description.trim()) out.description = now.description.trim() || null;
|
|
58
|
+
if (now.category.trim() !== was.category.trim()) out.category = now.category.trim() || null;
|
|
59
|
+
if (cents(now.price) !== cents(was.price)) out.default_unit_amount = cents(now.price);
|
|
60
|
+
if (now.currency.trim().toUpperCase() !== was.currency.trim().toUpperCase()) out.currency = now.currency.trim().toUpperCase();
|
|
61
|
+
if (now.billing_frequency !== was.billing_frequency) out.billing_frequency = now.billing_frequency;
|
|
62
|
+
if (now.active !== was.active) out.active = now.active;
|
|
63
|
+
if (now.quotable !== was.quotable) out.quotable = now.quotable;
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ManualProducts({ className, refreshKey }: { className?: string; refreshKey?: number }) {
|
|
68
|
+
const [page, setPage] = useState(1);
|
|
69
|
+
const q = useG8Query((signal) => g8Get("/manual-products", { include_inactive: true, include_non_quotable: true, page, limit: PAGE_SIZE }, { signal }), [page, refreshKey]);
|
|
70
|
+
const data = body(q.data);
|
|
71
|
+
const rows = (Array.isArray(data.items) ? data.items : []) as Rec[];
|
|
72
|
+
const total = typeof data.total === "number" ? data.total : rows.length;
|
|
73
|
+
const [editing, setEditing] = useState<{ row: Rec | null; was: ManualProductDraft } | null>(null);
|
|
74
|
+
const [draft, setDraft] = useState<ManualProductDraft>(blank());
|
|
75
|
+
const [tried, setTried] = useState(false);
|
|
76
|
+
const [retiring, setRetiring] = useState<Rec | null>(null);
|
|
77
|
+
const problems = draftProblems(draft);
|
|
78
|
+
const changes = editing?.row ? productChanges(editing.was, draft) : {};
|
|
79
|
+
|
|
80
|
+
const open = (row: Rec | null) => { const d = row ? fromRow(row) : blank(); setDraft(d); setTried(false); setEditing({ row, was: d }); };
|
|
81
|
+
const save = useG8Mutation(async () => {
|
|
82
|
+
if (editing?.row) return g8Patch(`/manual-products/${encodeURIComponent(text(editing.row.id))}`, changes);
|
|
83
|
+
return g8Post("/manual-products", {
|
|
84
|
+
name: draft.name.trim(), default_unit_amount: cents(draft.price), currency: draft.currency.trim().toUpperCase(), billing_frequency: draft.billing_frequency,
|
|
85
|
+
active: draft.active, quotable: draft.quotable, ...(draft.description.trim() ? { description: draft.description.trim() } : {}), ...(draft.category.trim() ? { category: draft.category.trim() } : {}),
|
|
86
|
+
});
|
|
87
|
+
}, { onSuccess: () => { setEditing(null); void q.refetch(); } });
|
|
88
|
+
const retire = useG8Mutation((id: string) => g8Delete(`/manual-products/${encodeURIComponent(id)}`), { onSuccess: () => { setRetiring(null); void q.refetch(); } });
|
|
89
|
+
const submit = () => { setTried(true); if (problems.length === 0 && (!editing?.row || Object.keys(changes).length > 0)) void save.mutate(undefined); };
|
|
90
|
+
const up = (patch: Partial<ManualProductDraft>) => setDraft({ ...draft, ...patch });
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<SectionCard className={className} title="Manual products" icon={<Package />} description="Your own catalogue for quoting without Stripe. Needs an organization admin to change."
|
|
94
|
+
actions={<Button size="sm" onClick={() => open(null)}><Plus className="size-4" aria-hidden />New product</Button>} flush
|
|
95
|
+
footer={<div className="flex items-center justify-between"><span>{total} {total === 1 ? "product" : "products"}</span><span className="flex gap-2"><Button size="sm" variant="outline" disabled={page === 1} onClick={() => setPage(page - 1)}>Previous</Button><Button size="sm" variant="outline" disabled={page * PAGE_SIZE >= total} onClick={() => setPage(page + 1)}>Next</Button></span></div>}>
|
|
96
|
+
{q.error ? <div className="p-4"><ApiError error={q.error} title="Could not load manual products" onRetry={() => void q.refetch()} /></div> : null}
|
|
97
|
+
{q.loading && !q.data ? <div className="p-4"><Skeleton className="h-20 w-full" aria-label="Loading manual products" /></div> : !q.error && rows.length === 0 ? (
|
|
98
|
+
<EmptyState compact title="No manual products" description="Add the products you sell, with a price, and they appear in the quote builder." action={<Button onClick={() => open(null)}>New product</Button>} />
|
|
99
|
+
) : rows.length > 0 ? (
|
|
100
|
+
<Table>
|
|
101
|
+
<TableHeader><TableRow><TableHead>Product</TableHead><TableHead>Billing</TableHead><TableHead className="text-right">Price</TableHead><TableHead>In quotes</TableHead><TableHead><span className="sr-only">Actions</span></TableHead></TableRow></TableHeader>
|
|
102
|
+
<TableBody>
|
|
103
|
+
{rows.map((r) => (
|
|
104
|
+
<TableRow key={text(r.id)}>
|
|
105
|
+
<TableCell><span className="font-medium">{text(r.name)}</span><div className="text-xs text-muted-foreground">{text(r.category)}</div></TableCell>
|
|
106
|
+
<TableCell>{(FREQUENCIES.find(([v]) => v === r.billing_frequency)?.[1]) ?? text(r.billing_frequency)}</TableCell>
|
|
107
|
+
<TableCell className="text-right tabular-nums">{money(r.default_unit_amount, text(r.currency, "USD").toUpperCase())}</TableCell>
|
|
108
|
+
<TableCell>{r.active === false ? <StatusPill tone="neutral">Inactive</StatusPill> : r.quotable === false ? <StatusPill tone="warning">Not quotable</StatusPill> : <StatusPill tone="positive">Quotable</StatusPill>}</TableCell>
|
|
109
|
+
<TableCell className="whitespace-nowrap text-right">
|
|
110
|
+
<Button size="sm" variant="ghost" aria-label={`Edit ${text(r.name)}`} onClick={() => open(r)}><Pencil className="size-4" aria-hidden /></Button>
|
|
111
|
+
<Button size="sm" variant="ghost" aria-label={`Retire ${text(r.name)}`} onClick={() => setRetiring(r)}><Trash2 className="size-4" aria-hidden /></Button>
|
|
112
|
+
</TableCell>
|
|
113
|
+
</TableRow>
|
|
114
|
+
))}
|
|
115
|
+
</TableBody>
|
|
116
|
+
</Table>
|
|
117
|
+
) : null}
|
|
118
|
+
{retire.error ? <div className="p-4"><ApiError error={retire.error} title="Could not retire the product" /></div> : null}
|
|
119
|
+
|
|
120
|
+
<FormSheet open={editing != null} onOpenChange={(o) => { if (!o) setEditing(null); }} title={editing?.row ? "Edit product" : "New manual product"} onSubmit={submit}
|
|
121
|
+
footer={<><Button type="button" variant="outline" onClick={() => setEditing(null)}>Cancel</Button><Button type="submit" loading={save.loading} disabled={Boolean(editing?.row) && Object.keys(changes).length === 0}>{editing?.row ? "Save product" : "Add product"}</Button></>}>
|
|
122
|
+
<div className="space-y-1"><Label htmlFor="mp-name">Name</Label><Input id="mp-name" value={draft.name} maxLength={500} onChange={(e) => up({ name: e.target.value })} /></div>
|
|
123
|
+
<div className="space-y-1"><Label htmlFor="mp-desc">Description</Label><Textarea id="mp-desc" rows={3} value={draft.description} onChange={(e) => up({ description: e.target.value })} /></div>
|
|
124
|
+
<div className="grid grid-cols-2 gap-3">
|
|
125
|
+
<div className="space-y-1"><Label htmlFor="mp-price">Price</Label><Input id="mp-price" inputMode="decimal" value={draft.price} onChange={(e) => up({ price: e.target.value })} /></div>
|
|
126
|
+
<div className="space-y-1"><Label htmlFor="mp-cur">Currency</Label><Input id="mp-cur" maxLength={3} value={draft.currency} onChange={(e) => up({ currency: e.target.value })} /></div>
|
|
127
|
+
<div className="space-y-1"><Label htmlFor="mp-freq">Billing</Label>
|
|
128
|
+
<select id="mp-freq" className="h-9 w-full rounded-md border bg-background px-2 text-sm" value={draft.billing_frequency} onChange={(e) => up({ billing_frequency: e.target.value })}>{FREQUENCIES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select></div>
|
|
129
|
+
<div className="space-y-1"><Label htmlFor="mp-cat">Category</Label><Input id="mp-cat" maxLength={100} value={draft.category} onChange={(e) => up({ category: e.target.value })} /></div>
|
|
130
|
+
</div>
|
|
131
|
+
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={draft.active} onChange={(e) => up({ active: e.target.checked })} />Active</label>
|
|
132
|
+
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={draft.quotable} onChange={(e) => up({ quotable: e.target.checked })} />Offer on quotes</label>
|
|
133
|
+
{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}
|
|
134
|
+
{save.error ? <ApiError error={save.error} title="Could not save the product" /> : null}
|
|
135
|
+
</FormSheet>
|
|
136
|
+
|
|
137
|
+
{retiring ? (
|
|
138
|
+
<ConfirmSpendDialog open tier="destructive" title={`Retire "${text(retiring.name)}"?`} confirmLabel="Retire product" affected={1} confirming={retire.loading}
|
|
139
|
+
description="The product leaves your catalogue and the quote builder's picker. Quotes that already include it keep the line item. To keep the product but stop offering it, switch off Active or Offer on quotes instead."
|
|
140
|
+
onConfirm={() => void retire.mutate(text(retiring.id))} onCancel={() => setRetiring(null)} />
|
|
141
|
+
) : null}
|
|
142
|
+
</SectionCard>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* QuoteBatchActions: archive, expire or revoke the quotes ticked in a list, in one request.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. batchQuoteAction POST /quotes/batch { action: "archive" | "expire" | "revoke", quote_ids: [...] } external tier,
|
|
8
|
+
* DESTRUCTIVE in effect for expire and revoke (both final) -> ConfirmSpendDialog for every action
|
|
9
|
+
*
|
|
10
|
+
* The request is addressed by quote id only, never by a filter: the ids sent are exactly the ticked quotes that the action
|
|
11
|
+
* applies to (expire and revoke only apply to a quote that is out for signature, archive to one that is not archived), and
|
|
12
|
+
* that same array is what the confirm counts. Quotes the action does not apply to are left out and said so, not sent.
|
|
13
|
+
* The API answers a row per quote (`success`, `skipped`, `error`) and a 200 only means the batch ran, so the result is read
|
|
14
|
+
* row by row: "N of M changed", with the reason for each that did not. The caller refetches afterwards (`onDone`).
|
|
15
|
+
*/
|
|
16
|
+
import { useState } from "react";
|
|
17
|
+
import { Button } from "@graph8/sdk/ui";
|
|
18
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
19
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
20
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
21
|
+
import { g8Post } from "@/g8/lib/g8-client";
|
|
22
|
+
import { BATCH_MAX, BATCH_REASON, batchEligible, batchOutcomes, type BatchAction, type BatchOutcome } from "../lib/quote-admin";
|
|
23
|
+
import { text, type Rec } from "../lib/quotes";
|
|
24
|
+
|
|
25
|
+
export interface QuoteBatchActionsProps {
|
|
26
|
+
/** The ticked quotes, loaded (the list rows). */
|
|
27
|
+
selected: Rec[];
|
|
28
|
+
/** Called after the request, so the caller reloads the list and prunes its selection. */
|
|
29
|
+
onDone: () => void;
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const COPY: Record<BatchAction, { button: string; title: (n: number) => string; confirm: string; effect: string }> = {
|
|
34
|
+
archive: { button: "Archive", title: (n) => `Archive ${n} ${n === 1 ? "quote" : "quotes"}?`, confirm: "Archive quotes", effect: "The quotes leave the active list. Nothing is sent and each can be unarchived later." },
|
|
35
|
+
expire: { button: "Expire", title: (n) => `Expire ${n} ${n === 1 ? "quote" : "quotes"}?`, confirm: "Expire quotes", effect: "Each customer's link stops working. This is final: create a new quote to offer it again." },
|
|
36
|
+
revoke: { button: "Revoke", title: (n) => `Revoke ${n} ${n === 1 ? "quote" : "quotes"}?`, confirm: "Revoke quotes", effect: "Withdraws the quotes you already sent, so customers can no longer sign them. This is final." },
|
|
37
|
+
};
|
|
38
|
+
interface Job { action: BatchAction; ids: string[]; names: Record<string, string> }
|
|
39
|
+
const ACTIONS: BatchAction[] = ["archive", "expire", "revoke"];
|
|
40
|
+
|
|
41
|
+
export function QuoteBatchActions({ selected, onDone, className }: QuoteBatchActionsProps) {
|
|
42
|
+
const [pending, setPending] = useState<Job | null>(null);
|
|
43
|
+
const [outcome, setOutcome] = useState<{ action: BatchAction; asked: number; rows: BatchOutcome[]; names: Record<string, string> } | null>(null);
|
|
44
|
+
|
|
45
|
+
const run = useG8Mutation(async (job: Job) => ({ job, res: await g8Post("/quotes/batch", { action: job.action, quote_ids: job.ids }) }), {
|
|
46
|
+
invalidates: [["quotes"]],
|
|
47
|
+
onSuccess: ({ job, res }) => { setPending(null); setOutcome({ action: job.action, asked: job.ids.length, rows: batchOutcomes(res), names: job.names }); onDone(); },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// The ids a click would send: filtered ONCE here, and this same array is both counted in the confirm and sent.
|
|
51
|
+
const idsFor = (action: BatchAction): string[] => selected.filter((r) => batchEligible(action, r)).map((r) => text(r.id)).filter(Boolean);
|
|
52
|
+
const open = (action: BatchAction) => { setOutcome(null); setPending({ action, ids: idsFor(action), names: Object.fromEntries(selected.map((r) => [text(r.id), text(r.title, text(r.quote_number, "Untitled quote"))])) }); };
|
|
53
|
+
|
|
54
|
+
const done = outcome ? outcome.rows.filter((r) => r.status === "success").length : 0;
|
|
55
|
+
const notDone = outcome ? outcome.rows.filter((r) => r.status !== "success") : [];
|
|
56
|
+
return (
|
|
57
|
+
<div className={className}>
|
|
58
|
+
{selected.length > 0 ? (
|
|
59
|
+
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm" role="group" aria-label="Batch actions">
|
|
60
|
+
<span className="font-medium tabular-nums">{selected.length} selected</span>
|
|
61
|
+
{ACTIONS.map((a) => {
|
|
62
|
+
const n = idsFor(a).length;
|
|
63
|
+
const tooMany = n > BATCH_MAX;
|
|
64
|
+
return <Button key={a} size="sm" variant="outline" disabled={n === 0 || tooMany} title={n === 0 ? `None of the selected quotes can be ${a === "archive" ? "archived" : a === "expire" ? "expired" : "revoked"}` : tooMany ? `At most ${BATCH_MAX} at a time` : undefined} onClick={() => open(a)}>{COPY[a].button} ({n})</Button>;
|
|
65
|
+
})}
|
|
66
|
+
</div>
|
|
67
|
+
) : null}
|
|
68
|
+
{run.error ? <ApiError error={run.error} title="The batch action failed" /> : null}
|
|
69
|
+
{outcome ? (
|
|
70
|
+
<div role="status" className="mb-2 rounded-md border border-border px-3 py-2 text-sm">
|
|
71
|
+
<p className="font-medium">{done} of {outcome.asked} {outcome.asked === 1 ? "quote" : "quotes"} {outcome.action === "archive" ? "archived" : outcome.action === "expire" ? "expired" : "revoked"}.</p>
|
|
72
|
+
{notDone.length > 0 ? (
|
|
73
|
+
<ul className="mt-1 list-disc pl-5 text-muted-foreground">
|
|
74
|
+
{notDone.map((r) => <li key={r.quote_id}>{outcome.names[r.quote_id] ?? r.quote_id}: {r.status === "skipped" ? `skipped (${(r.detail ?? "not applicable").replace(/_/g, " ")})` : `failed (${BATCH_REASON[r.detail ?? ""] ?? (r.detail ?? "error").replace(/_/g, " ")})`}</li>)}
|
|
75
|
+
</ul>
|
|
76
|
+
) : null}
|
|
77
|
+
</div>
|
|
78
|
+
) : null}
|
|
79
|
+
{pending ? (
|
|
80
|
+
<ConfirmSpendDialog open tier="destructive" title={COPY[pending.action].title(pending.ids.length)} confirmLabel={COPY[pending.action].confirm} affected={pending.ids.length} confirming={run.loading}
|
|
81
|
+
description={<div className="space-y-1"><p>{COPY[pending.action].effect}</p>{selected.length > pending.ids.length ? <p>{selected.length - pending.ids.length} of the {selected.length} selected {selected.length - pending.ids.length === 1 ? "quote does" : "quotes do"} not qualify and {selected.length - pending.ids.length === 1 ? "is" : "are"} left out.</p> : null}</div>}
|
|
82
|
+
onConfirm={() => void run.mutate(pending)} onCancel={() => setPending(null)} />
|
|
83
|
+
) : null}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* QuoteDocuments: the org's reusable legal documents (NDA, MSA, other PDFs) that a quote template can attach.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. listQuoteDocuments GET /quote-settings/documents read
|
|
8
|
+
* 2. uploadQuoteDocument POST /quote-settings/documents external tier, stores a PDF (org admin); multipart: file, title, doc_type
|
|
9
|
+
* 3. deleteQuoteDocument DELETE /quote-settings/documents/{id} DESTRUCTIVE (org admin) -> ConfirmSpendDialog
|
|
10
|
+
*
|
|
11
|
+
* Uploading sends nobody anything, so it runs on one click. The file is checked here first: PDF only, and under the app
|
|
12
|
+
* proxy's request cap (about 5 MB; the API itself allows 10 MB). The API silently files an unknown type as `other`, so the
|
|
13
|
+
* form only offers nda, msa and other. Delete removes the library entry: it can no longer be attached to a template or a quote.
|
|
14
|
+
*/
|
|
15
|
+
import { useRef, useState } from "react";
|
|
16
|
+
import { FileText, Trash2, Upload } from "lucide-react";
|
|
17
|
+
import { Button, Input, Label, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
|
|
18
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
19
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
20
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
21
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
22
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
23
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
24
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
25
|
+
import { g8Delete, g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
26
|
+
import { UPLOAD_LIMIT_LABEL, fileSize, pdfProblem } from "../lib/quote-admin";
|
|
27
|
+
import { body, text, type Rec } from "../lib/quotes";
|
|
28
|
+
|
|
29
|
+
const DOC_TYPES: Array<[string, string]> = [["nda", "NDA"], ["msa", "MSA"], ["other", "Other"]];
|
|
30
|
+
|
|
31
|
+
export function QuoteDocuments({ className }: { className?: string }) {
|
|
32
|
+
const q = useG8Query((signal) => g8Get("/quote-settings/documents", undefined, { signal }), []);
|
|
33
|
+
const rows = (Array.isArray(body(q.data).items) ? body(q.data).items : []) as Rec[];
|
|
34
|
+
const [title, setTitle] = useState("");
|
|
35
|
+
const [docType, setDocType] = useState("other");
|
|
36
|
+
const [file, setFile] = useState<File | null>(null);
|
|
37
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
38
|
+
const [removing, setRemoving] = useState<Rec | null>(null);
|
|
39
|
+
const input = useRef<HTMLInputElement>(null);
|
|
40
|
+
|
|
41
|
+
const upload = useG8Mutation(async () => {
|
|
42
|
+
const form = new FormData();
|
|
43
|
+
form.append("file", file as File);
|
|
44
|
+
form.append("title", title.trim());
|
|
45
|
+
form.append("doc_type", docType);
|
|
46
|
+
return g8Post("/quote-settings/documents", form);
|
|
47
|
+
}, { onSuccess: () => { setTitle(""); setFile(null); setDocType("other"); if (input.current) input.current.value = ""; void q.refetch(); } });
|
|
48
|
+
const remove = useG8Mutation((id: string) => g8Delete(`/quote-settings/documents/${encodeURIComponent(id)}`), { onSuccess: () => { setRemoving(null); void q.refetch(); } });
|
|
49
|
+
|
|
50
|
+
const submit = () => {
|
|
51
|
+
const bad = pdfProblem(file) ?? (title.trim() ? null : "Give the document a title.");
|
|
52
|
+
setProblem(bad);
|
|
53
|
+
if (!bad) void upload.mutate(undefined);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<SectionCard className={className} title="Document library" icon={<FileText />} description={`PDFs a template can attach to its quotes, up to ${UPLOAD_LIMIT_LABEL} each. Needs an organization admin.`} flush>
|
|
58
|
+
{q.error ? <div className="p-4"><ApiError error={q.error} title="Could not load documents" onRetry={() => void q.refetch()} /></div> : null}
|
|
59
|
+
{q.loading && !q.data ? <div className="p-4"><Skeleton className="h-16 w-full" aria-label="Loading documents" /></div> : !q.error && rows.length === 0 ? (
|
|
60
|
+
<EmptyState compact bordered title="No documents yet" description="Upload an NDA or MSA to attach it to quote templates." />
|
|
61
|
+
) : rows.length > 0 ? (
|
|
62
|
+
<Table>
|
|
63
|
+
<TableHeader><TableRow><TableHead>Title</TableHead><TableHead>Type</TableHead><TableHead>File</TableHead><TableHead><span className="sr-only">Actions</span></TableHead></TableRow></TableHeader>
|
|
64
|
+
<TableBody>
|
|
65
|
+
{rows.map((d) => (
|
|
66
|
+
<TableRow key={text(d.id)}>
|
|
67
|
+
<TableCell className="font-medium">{text(d.title, "Untitled")}</TableCell>
|
|
68
|
+
<TableCell><StatusPill tone="neutral">{text(d.doc_type, "other").toUpperCase()}</StatusPill></TableCell>
|
|
69
|
+
<TableCell className="text-muted-foreground">{text(d.file_name)} {fileSize(d.file_size)}</TableCell>
|
|
70
|
+
<TableCell className="text-right"><Button size="sm" variant="ghost" aria-label={`Delete ${text(d.title, "document")}`} onClick={() => setRemoving(d)}><Trash2 className="size-4" aria-hidden /></Button></TableCell>
|
|
71
|
+
</TableRow>
|
|
72
|
+
))}
|
|
73
|
+
</TableBody>
|
|
74
|
+
</Table>
|
|
75
|
+
) : null}
|
|
76
|
+
<form className="grid gap-3 border-t border-border p-4 sm:grid-cols-[1fr_8rem]" onSubmit={(e) => { e.preventDefault(); submit(); }}>
|
|
77
|
+
<div className="space-y-1"><Label htmlFor="doc-title">Document title</Label><Input id="doc-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Mutual NDA" /></div>
|
|
78
|
+
<div className="space-y-1"><Label htmlFor="doc-type">Type</Label>
|
|
79
|
+
<select id="doc-type" className="h-9 w-full rounded-md border bg-background px-2 text-sm" value={docType} onChange={(e) => setDocType(e.target.value)}>{DOC_TYPES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select></div>
|
|
80
|
+
<div className="space-y-1 sm:col-span-2"><Label htmlFor="doc-file">PDF file</Label>
|
|
81
|
+
<input id="doc-file" ref={input} type="file" accept="application/pdf,.pdf" className="block w-full text-sm" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setProblem(null); }} /></div>
|
|
82
|
+
<div className="sm:col-span-2"><Button type="submit" loading={upload.loading}><Upload className="size-4" aria-hidden />Upload document</Button></div>
|
|
83
|
+
{problem ? <p role="alert" className="text-sm text-destructive sm:col-span-2">{problem}</p> : null}
|
|
84
|
+
{upload.error ? <div className="sm:col-span-2"><ApiError error={upload.error} title="Could not upload the document" /></div> : null}
|
|
85
|
+
</form>
|
|
86
|
+
{remove.error ? <div className="p-4"><ApiError error={remove.error} title="Could not delete the document" /></div> : null}
|
|
87
|
+
{removing ? (
|
|
88
|
+
<ConfirmSpendDialog open tier="destructive" title={`Delete "${text(removing.title, "this document")}"?`} confirmLabel="Delete document" affected={1} confirming={remove.loading}
|
|
89
|
+
description="The document leaves the library, so it can no longer be attached to a template or a quote. Check your templates after: one that listed it keeps pointing at a document that is gone."
|
|
90
|
+
onConfirm={() => void remove.mutate(text(removing.id))} onCancel={() => setRemoving(null)} />
|
|
91
|
+
) : null}
|
|
92
|
+
</SectionCard>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* QuoteEvidence: the PDFs attached to a quote as internal evidence (a countersigned copy received outside the product,
|
|
5
|
+
* a purchase order). They are for your team only: the customer never sees them and nothing is sent.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in order:
|
|
8
|
+
* 1. listQuoteEvidence GET /quotes/{id}/evidence read - `{ items: [{ id, file_name, created_at, uploaded_by }] }`
|
|
9
|
+
* 2. uploadQuoteEvidence POST /quotes/{id}/evidence write - multipart `file`, PDF only; answers `{ id }`
|
|
10
|
+
* 3. downloadQuoteEvidence GET /quotes/{id}/evidence/{evidence_id} read - answers `{ url }`, a link that expires after 60 seconds
|
|
11
|
+
*
|
|
12
|
+
* The API has no delete for evidence, so an attached PDF stays: the form says so before the upload. The file is checked
|
|
13
|
+
* here first (PDF header and end marker, which the API also demands, and the app proxy's request cap) so a bad file is
|
|
14
|
+
* refused with a reason instead of a 400 or 413.
|
|
15
|
+
*/
|
|
16
|
+
import { useRef, useState } from "react";
|
|
17
|
+
import { Download, Paperclip, Upload } from "lucide-react";
|
|
18
|
+
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
19
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
20
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
21
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
22
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
23
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
24
|
+
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
25
|
+
import { UPLOAD_LIMIT_LABEL, pdfProblem } from "../lib/quote-admin";
|
|
26
|
+
import { body, text, type Rec } from "../lib/quotes";
|
|
27
|
+
|
|
28
|
+
/** The API rejects a file that does not start with %PDF- or has no %%EOF in its last KB. Check both before uploading. */
|
|
29
|
+
export async function pdfStructureProblem(file: File): Promise<string | null> {
|
|
30
|
+
const bytes = (blob: Blob) => new Promise<Uint8Array>((resolve, reject) => {
|
|
31
|
+
const reader = new FileReader();
|
|
32
|
+
reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer));
|
|
33
|
+
reader.onerror = () => reject(reader.error);
|
|
34
|
+
reader.readAsArrayBuffer(blob);
|
|
35
|
+
});
|
|
36
|
+
const head = new TextDecoder().decode(await bytes(file.slice(0, 5)));
|
|
37
|
+
const tail = new TextDecoder("latin1").decode(await bytes(file.slice(Math.max(0, file.size - 1024))));
|
|
38
|
+
if (!head.startsWith("%PDF-")) return "That file is not a PDF.";
|
|
39
|
+
if (!tail.includes("%%EOF")) return "That PDF looks incomplete (no end marker). Export it again and retry.";
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function QuoteEvidence({ quoteId, className }: { quoteId: string; className?: string }) {
|
|
44
|
+
const base = `/quotes/${encodeURIComponent(quoteId)}/evidence`;
|
|
45
|
+
const q = useG8Query((signal) => g8Get(base, undefined, { signal }), [quoteId]);
|
|
46
|
+
const rows = (Array.isArray(body(q.data).items) ? body(q.data).items : []) as Rec[];
|
|
47
|
+
const [file, setFile] = useState<File | null>(null);
|
|
48
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
49
|
+
const input = useRef<HTMLInputElement>(null);
|
|
50
|
+
|
|
51
|
+
const upload = useG8Mutation(async () => {
|
|
52
|
+
const form = new FormData();
|
|
53
|
+
form.append("file", file as File);
|
|
54
|
+
return g8Post(base, form);
|
|
55
|
+
}, { onSuccess: () => { setFile(null); if (input.current) input.current.value = ""; void q.refetch(); } });
|
|
56
|
+
const download = useG8Mutation(async (id: string) => {
|
|
57
|
+
const res = body(await g8Get(`${base}/${encodeURIComponent(id)}`));
|
|
58
|
+
const url = text(res.url);
|
|
59
|
+
if (!/^https?:\/\//.test(url)) throw new Error("The API returned no download link for this file.");
|
|
60
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const submit = async () => {
|
|
64
|
+
const bad = pdfProblem(file) ?? (file ? await pdfStructureProblem(file) : null);
|
|
65
|
+
setProblem(bad);
|
|
66
|
+
if (!bad) void upload.mutate(undefined);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<SectionCard className={className} title="Evidence" icon={<Paperclip />} description="Internal PDFs for this quote. The customer does not see them." flush>
|
|
71
|
+
{q.error ? <div className="p-4"><ApiError error={q.error} title="Could not load evidence" onRetry={() => void q.refetch()} /></div> : null}
|
|
72
|
+
{q.loading && !q.data ? <div className="p-4"><Skeleton className="h-12 w-full" aria-label="Loading evidence" /></div> : !q.error && rows.length === 0 ? (
|
|
73
|
+
<EmptyState compact title="No evidence attached" description="Attach a countersigned copy or a purchase order." />
|
|
74
|
+
) : (
|
|
75
|
+
<ul className="divide-y divide-border">
|
|
76
|
+
{rows.map((r) => (
|
|
77
|
+
<li key={text(r.id)} className="flex items-center justify-between gap-2 px-4 py-2 text-sm">
|
|
78
|
+
<span><span className="font-medium">{text(r.file_name, "evidence.pdf")}</span><span className="block text-xs text-muted-foreground">{r.created_at ? new Date(text(r.created_at)).toLocaleString() : ""}{r.uploaded_by ? ` · ${text(r.uploaded_by)}` : ""}</span></span>
|
|
79
|
+
<Button size="sm" variant="outline" loading={download.loading} aria-label={`Download ${text(r.file_name, "evidence")}`} onClick={() => void download.mutate(text(r.id))}><Download className="size-4" aria-hidden />Download</Button>
|
|
80
|
+
</li>
|
|
81
|
+
))}
|
|
82
|
+
</ul>
|
|
83
|
+
)}
|
|
84
|
+
{download.error ? <div className="px-4 pb-3"><ApiError error={download.error} title="Could not get the file" /></div> : null}
|
|
85
|
+
<form className="space-y-2 border-t border-border p-4" onSubmit={(e) => { e.preventDefault(); void submit(); }}>
|
|
86
|
+
<label className="block text-sm font-medium" htmlFor={`ev-file-${quoteId}`}>Attach a PDF</label>
|
|
87
|
+
<input id={`ev-file-${quoteId}`} ref={input} type="file" accept="application/pdf,.pdf" className="block w-full text-sm" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setProblem(null); }} />
|
|
88
|
+
<p className="text-xs text-muted-foreground">PDF only, up to {UPLOAD_LIMIT_LABEL}. An attached file cannot be removed afterwards.</p>
|
|
89
|
+
<Button type="submit" loading={upload.loading}><Upload className="size-4" aria-hidden />Attach PDF</Button>
|
|
90
|
+
{problem ? <p role="alert" className="text-sm text-destructive">{problem}</p> : null}
|
|
91
|
+
{upload.error ? <ApiError error={upload.error} title="Could not attach the PDF" /> : null}
|
|
92
|
+
</form>
|
|
93
|
+
</SectionCard>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
* SDK operations, in the order the screen uses them:
|
|
7
7
|
* 1. listQuotes GET /quotes?status&include_archived&mashup_company_id&deal_id&page&limit (read; `{ items, total, page, limit }`)
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Reading needs no confirmation. Pass `dealId` or `companyId` to show the quotes of one deal or company (for a tab on a
|
|
10
|
+
* deal page). `onOpen` receives the quote id for QuoteViewer.
|
|
11
|
+
*
|
|
12
|
+
* With `selectable`, each row gets a checkbox and the ticked quotes can be archived, expired or revoked together
|
|
13
|
+
* (QuoteBatchActions: id-addressed, confirmed, result read row by row). The selection is cleared when the filter or page
|
|
14
|
+
* changes and pruned to the quotes still on the page after every reload. `onChanged` tells the caller a batch ran.
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
|
-
import { useState } from "react";
|
|
17
|
+
import { useEffect, useState } from "react";
|
|
14
18
|
import { Button, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
|
|
15
19
|
import { g8Get } from "@/g8/lib/g8-client";
|
|
16
20
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
@@ -18,6 +22,7 @@ import { ApiError } from "@/g8/components/api-error";
|
|
|
18
22
|
import { EmptyState } from "@/g8/components/empty-state";
|
|
19
23
|
import { body, money, statusLabel, text } from "../lib/quotes";
|
|
20
24
|
import type { Rec } from "../lib/quotes";
|
|
25
|
+
import { QuoteBatchActions } from "./quote-batch-actions";
|
|
21
26
|
|
|
22
27
|
export interface QuoteListProps {
|
|
23
28
|
dealId?: string;
|
|
@@ -29,12 +34,16 @@ export interface QuoteListProps {
|
|
|
29
34
|
refreshKey?: number;
|
|
30
35
|
/** Mark this quote row as the open one. */
|
|
31
36
|
selectedId?: string | null;
|
|
37
|
+
/** Tick quotes to archive, expire or revoke them together. */
|
|
38
|
+
selectable?: boolean;
|
|
39
|
+
/** Called after a batch action ran, so the screen can reload its numbers. */
|
|
40
|
+
onChanged?: () => void;
|
|
32
41
|
className?: string;
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
const STATUSES = ["", "draft", "sent", "viewed", "accepted", "declined", "voided", "expired", "archived"];
|
|
36
45
|
|
|
37
|
-
export function QuoteList({ dealId, companyId, onOpen, onNew, pageSize = 25, refreshKey, selectedId, className }: QuoteListProps) {
|
|
46
|
+
export function QuoteList({ dealId, companyId, onOpen, onNew, pageSize = 25, refreshKey, selectedId, selectable, onChanged, className }: QuoteListProps) {
|
|
38
47
|
const [status, setStatus] = useState("");
|
|
39
48
|
const [archived, setArchived] = useState(false);
|
|
40
49
|
const [page, setPage] = useState(1);
|
|
@@ -45,6 +54,13 @@ export function QuoteList({ dealId, companyId, onOpen, onNew, pageSize = 25, ref
|
|
|
45
54
|
const data = body(q.data);
|
|
46
55
|
const rows = (Array.isArray(data.items) ? data.items : []) as Rec[];
|
|
47
56
|
const total = typeof data.total === "number" ? data.total : rows.length;
|
|
57
|
+
const [ticked, setTicked] = useState<string[]>([]);
|
|
58
|
+
// A new filter or page is a different set of rows: forget the ticks. After a reload keep only quotes still on the page.
|
|
59
|
+
useEffect(() => { setTicked([]); }, [status, archived, page, dealId, companyId]);
|
|
60
|
+
const idsOnPage = rows.map((r) => text(r.id)).join(",");
|
|
61
|
+
useEffect(() => { setTicked((cur) => { const here = new Set(idsOnPage.split(",")); const next = cur.filter((id) => here.has(id)); return next.length === cur.length ? cur : next; }); }, [idsOnPage]);
|
|
62
|
+
const tickedRows = rows.filter((r) => ticked.includes(text(r.id)));
|
|
63
|
+
const allTicked = rows.length > 0 && tickedRows.length === rows.length;
|
|
48
64
|
|
|
49
65
|
return (
|
|
50
66
|
<div className={className}>
|
|
@@ -56,14 +72,16 @@ export function QuoteList({ dealId, companyId, onOpen, onNew, pageSize = 25, ref
|
|
|
56
72
|
{onNew ? <Button className="ml-auto" onClick={onNew}>New quote</Button> : null}
|
|
57
73
|
</div>
|
|
58
74
|
{q.error ? <ApiError error={q.error} onRetry={() => void q.refetch()} /> : null}
|
|
75
|
+
{selectable ? <QuoteBatchActions selected={tickedRows} onDone={() => { void q.refetch(); onChanged?.(); }} /> : null}
|
|
59
76
|
{q.loading && !q.data ? <div className="space-y-2" aria-busy="true">{[0, 1, 2].map((i) => <Skeleton key={i} className="h-10 w-full" />)}</div> : !q.error && rows.length === 0 ? (
|
|
60
77
|
<EmptyState title="No quotes" description="Create a quote from a deal, pick products, and send it for signature." action={onNew ? <Button onClick={onNew}>New quote</Button> : undefined} />
|
|
61
78
|
) : (
|
|
62
79
|
<Table>
|
|
63
|
-
<TableHeader><TableRow><TableHead>Quote</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Total</TableHead><TableHead>Sent</TableHead></TableRow></TableHeader>
|
|
80
|
+
<TableHeader><TableRow>{selectable ? <TableHead className="w-8"><input type="checkbox" aria-label="Select all quotes on this page" checked={allTicked} onChange={(e) => setTicked(e.target.checked ? rows.map((r) => text(r.id)) : [])} /></TableHead> : null}<TableHead>Quote</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Total</TableHead><TableHead>Sent</TableHead></TableRow></TableHeader>
|
|
64
81
|
<TableBody>
|
|
65
82
|
{rows.map((r) => (
|
|
66
83
|
<TableRow key={text(r.id)} data-state={selectedId === text(r.id) ? "selected" : undefined}>
|
|
84
|
+
{selectable ? <TableCell><input type="checkbox" aria-label={`Select ${text(r.title, "Untitled quote")}`} checked={ticked.includes(text(r.id))} onChange={(e) => setTicked(e.target.checked ? [...ticked, text(r.id)] : ticked.filter((x) => x !== text(r.id)))} /></TableCell> : null}
|
|
67
85
|
<TableCell><button type="button" className="font-medium hover:underline" onClick={() => onOpen?.(text(r.id))}>{text(r.title, "Untitled quote")}</button><div className="text-muted-foreground text-xs">{text(r.quote_number)}</div></TableCell>
|
|
68
86
|
<TableCell>{statusLabel(r.status)}</TableCell>
|
|
69
87
|
<TableCell className="text-right">{money(r.total, text(r.currency, "USD"))}</TableCell>
|