@graph8/sdk 0.358.0 → 0.362.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/blocks/_foundation/README.md +108 -0
  2. package/blocks/_foundation/block.json +160 -12
  3. package/blocks/_foundation/components/api-error.tsx +62 -18
  4. package/blocks/_foundation/components/empty-state.tsx +39 -7
  5. package/blocks/_foundation/components/screen/bar-list.tsx +71 -0
  6. package/blocks/_foundation/components/screen/detail-panel.tsx +114 -0
  7. package/blocks/_foundation/components/screen/entity-avatar.tsx +56 -0
  8. package/blocks/_foundation/components/screen/form-sheet.tsx +73 -0
  9. package/blocks/_foundation/components/screen/index.ts +21 -0
  10. package/blocks/_foundation/components/screen/key-value-list.tsx +65 -0
  11. package/blocks/_foundation/components/screen/screen-layout.tsx +124 -0
  12. package/blocks/_foundation/components/screen/section-card.tsx +47 -0
  13. package/blocks/_foundation/components/screen/sparkline.tsx +77 -0
  14. package/blocks/_foundation/components/screen/split-view.tsx +125 -0
  15. package/blocks/_foundation/components/screen/stat-strip.tsx +121 -0
  16. package/blocks/_foundation/components/screen/status-pill.tsx +104 -0
  17. package/blocks/_foundation/components/screen/time-series-chart.tsx +114 -0
  18. package/blocks/_foundation/components/screen/toolbar.tsx +144 -0
  19. package/blocks/_foundation/hooks/use-url-state.ts +74 -0
  20. package/blocks/appointments/README.md +21 -1
  21. package/blocks/appointments/block.json +39 -2
  22. package/blocks/appointments/components/event-type-related.tsx +75 -0
  23. package/blocks/appointments/components/meeting-list.tsx +8 -4
  24. package/blocks/appointments/components/meetings-screen.tsx +154 -0
  25. package/blocks/appointments/components/scheduling-screen.tsx +121 -0
  26. package/blocks/appointments/hooks/use-event-types.ts +17 -0
  27. package/blocks/block.types.ts +9 -0
  28. package/blocks/deals/README.md +34 -3
  29. package/blocks/deals/block.json +39 -4
  30. package/blocks/deals/components/deal-detail.tsx +4 -2
  31. package/blocks/deals/components/deal-related.tsx +87 -0
  32. package/blocks/deals/components/deals-screen.tsx +293 -0
  33. package/blocks/deals/components/pipeline-board.tsx +4 -2
  34. package/blocks/deals/hooks/use-default-owner.ts +60 -0
  35. package/blocks/deals/lib/deals-types.ts +14 -0
  36. package/blocks/forms/README.md +12 -0
  37. package/blocks/forms/block.json +9 -1
  38. package/blocks/forms/components/forms-screen.tsx +350 -0
  39. package/blocks/forms/components/submissions-table.tsx +13 -5
  40. package/blocks/lifecycle/README.md +16 -0
  41. package/blocks/lifecycle/block.json +23 -1
  42. package/blocks/lifecycle/components/account-related.tsx +66 -0
  43. package/blocks/lifecycle/components/lifecycle-screen.tsx +147 -0
  44. package/blocks/newsletter/README.md +13 -0
  45. package/blocks/newsletter/block.json +9 -1
  46. package/blocks/newsletter/components/newsletter-screen.tsx +402 -0
  47. package/blocks/newsletter/components/subscriber-table.tsx +12 -6
  48. package/blocks/nurture/README.md +12 -0
  49. package/blocks/nurture/block.json +9 -1
  50. package/blocks/nurture/components/nurture-screen.tsx +279 -0
  51. package/blocks/pages/README.md +12 -0
  52. package/blocks/pages/block.json +9 -1
  53. package/blocks/pages/components/content-library.tsx +37 -7
  54. package/blocks/pages/components/page-list.tsx +4 -2
  55. package/blocks/pages/components/pages-screen.tsx +154 -0
  56. package/blocks/quotes/README.md +17 -0
  57. package/blocks/quotes/block.json +26 -2
  58. package/blocks/quotes/components/quote-list.tsx +7 -3
  59. package/blocks/quotes/components/quote-related.tsx +120 -0
  60. package/blocks/quotes/components/quotes-screen.tsx +169 -0
  61. package/dist/cli.js +197 -37
  62. package/dist/index.browser.js +1 -1
  63. package/dist/index.browser.js.map +1 -1
  64. package/dist/index.browser.mjs +1 -1
  65. package/dist/index.browser.mjs.map +1 -1
  66. package/dist/index.d.mts +93 -7
  67. package/dist/index.d.ts +93 -7
  68. package/dist/index.js +1 -1
  69. package/dist/index.js.map +1 -1
  70. package/dist/index.mjs +1 -1
  71. package/dist/index.mjs.map +1 -1
  72. package/dist/react.browser.js +1 -1
  73. package/dist/react.browser.js.map +1 -1
  74. package/dist/react.browser.mjs +1 -1
  75. package/dist/react.browser.mjs.map +1 -1
  76. package/dist/react.d.mts +90 -4
  77. package/dist/react.d.ts +90 -4
  78. package/dist/react.js +1 -1
  79. package/dist/react.js.map +1 -1
  80. package/dist/react.mjs +1 -1
  81. package/dist/react.mjs.map +1 -1
  82. package/dist/types/blocks/create.d.ts +15 -1
  83. package/dist/types/blocks/doctor.d.ts +2 -1
  84. package/dist/types/blocks/flows.d.ts +20 -0
  85. package/dist/types/blocks/registry.d.ts +15 -0
  86. package/dist/types/core.d.ts +3 -2
  87. package/dist/types/react.d.ts +2 -0
  88. package/dist/types/studio.d.ts +48 -0
  89. package/dist/types/version.d.ts +2 -2
  90. package/docs/api/adCampaigns.d.ts +4 -0
  91. package/docs/api/adCampaigns.md +4 -0
  92. package/docs/api/gtmCampaigns.d.ts +4 -0
  93. package/docs/api/gtmCampaigns.md +2 -0
  94. package/docs/api/gtmContext.d.ts +5 -1
  95. package/docs/api/gtmContext.md +15 -7
  96. package/docs/api/integrations.d.ts +29 -4
  97. package/docs/api/integrations.md +14 -0
  98. package/docs/sdk-clients.md +1 -1
  99. package/package.json +1 -1
  100. package/stacks/stack.schema.json +6 -1
  101. package/styles/styles.css +1 -1
  102. package/templates/next-app/README.md.tmpl +16 -0
  103. package/templates/next-app/app/globals.css.tmpl +105 -0
  104. package/templates/next-app/app/layout.tsx.tmpl +11 -2
  105. package/templates/next-app/components/app-shell.tsx.tmpl +238 -23
  106. package/templates/next-app/components/shell/connection-status.tsx.tmpl +109 -0
  107. package/templates/next-app/components/shell/nav-model.ts.tmpl +65 -0
  108. package/templates/next-app/components/shell/quick-jump.tsx.tmpl +131 -0
  109. package/templates/next-app/components/shell/theme-toggle.tsx.tmpl +40 -0
  110. package/templates/next-app/lib/nav.ts.tmpl +1 -1
@@ -24,7 +24,9 @@
24
24
  "edit_quote_as_draft_quotes__quote_id__edit_as_draft_post",
25
25
  "duplicate_quote_quotes__quote_id__duplicate_post",
26
26
  "delete_quote_quotes__quote_id__delete",
27
- "resend_envelope_envelopes__envelope_id__resend_post"
27
+ "resend_envelope_envelopes__envelope_id__resend_post",
28
+ "list_deals_deals_get",
29
+ "get_deal_deals__deal_id__get"
28
30
  ],
29
31
  "components": [
30
32
  {
@@ -93,6 +95,26 @@
93
95
  "read",
94
96
  "external"
95
97
  ]
98
+ },
99
+ {
100
+ "name": "QuotesScreen",
101
+ "file": "components/quotes-screen.tsx",
102
+ "purpose": "Finished quotes product: status numbers, list, viewer with send and signature tracking, deal-first builder and draft editing.",
103
+ "operations": [],
104
+ "tiers": []
105
+ },
106
+ {
107
+ "name": "ProductCatalog",
108
+ "file": "components/quote-related.tsx",
109
+ "purpose": "Quotable products with a full product record (fields and prices); also exports QuoteDealTab, the deal a quote belongs to.",
110
+ "operations": [
111
+ "list_quotable_products_quotable_products_get",
112
+ "get_quote_quotes__quote_id__get",
113
+ "get_deal_deals__deal_id__get"
114
+ ],
115
+ "tiers": [
116
+ "read"
117
+ ]
96
118
  }
97
119
  ],
98
120
  "hooks": [
@@ -146,6 +168,7 @@
146
168
  }
147
169
  ],
148
170
  "scopes": [
171
+ "deals:read",
149
172
  "quotes:delete",
150
173
  "quotes:read",
151
174
  "quotes:run"
@@ -178,6 +201,7 @@
178
201
  }
179
202
  ],
180
203
  "peerDependencies": {
181
- "react": ">=18"
204
+ "react": ">=18",
205
+ "lucide-react": ">=0.400.0"
182
206
  }
183
207
  }
@@ -25,17 +25,21 @@ export interface QuoteListProps {
25
25
  onOpen?: (quoteId: string) => void;
26
26
  onNew?: () => void;
27
27
  pageSize?: number;
28
+ /** Change it to reload the current page after a save or delete elsewhere on the screen. */
29
+ refreshKey?: number;
30
+ /** Mark this quote row as the open one. */
31
+ selectedId?: string | null;
28
32
  className?: string;
29
33
  }
30
34
 
31
35
  const STATUSES = ["", "draft", "sent", "viewed", "accepted", "declined", "voided", "expired", "archived"];
32
36
 
33
- export function QuoteList({ dealId, companyId, onOpen, onNew, pageSize = 25, className }: QuoteListProps) {
37
+ export function QuoteList({ dealId, companyId, onOpen, onNew, pageSize = 25, refreshKey, selectedId, className }: QuoteListProps) {
34
38
  const [status, setStatus] = useState("");
35
39
  const [page, setPage] = useState(1);
36
40
  const q = useG8Query(
37
41
  (signal) => g8Get("/quotes", { status: status || undefined, deal_id: dealId, mashup_company_id: companyId, page, limit: pageSize }, { signal }),
38
- [status, dealId, companyId, page, pageSize],
42
+ [status, dealId, companyId, page, pageSize, refreshKey],
39
43
  );
40
44
  const data = body(q.data);
41
45
  const rows = (Array.isArray(data.items) ? data.items : []) as Rec[];
@@ -57,7 +61,7 @@ export function QuoteList({ dealId, companyId, onOpen, onNew, pageSize = 25, cla
57
61
  <TableHeader><TableRow><TableHead>Quote</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Total</TableHead><TableHead>Sent</TableHead></TableRow></TableHeader>
58
62
  <TableBody>
59
63
  {rows.map((r) => (
60
- <TableRow key={text(r.id)}>
64
+ <TableRow key={text(r.id)} data-state={selectedId === text(r.id) ? "selected" : undefined}>
61
65
  <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>
62
66
  <TableCell>{statusLabel(r.status)}</TableCell>
63
67
  <TableCell className="text-right">{money(r.total, text(r.currency, "USD"))}</TableCell>
@@ -0,0 +1,120 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Catalog and related-record views for the quotes screen.
5
+ *
6
+ * ProductCatalog: every quotable product (Stripe and manual) as a list, with a full product record (all fields and
7
+ * every price) in a detail panel, and a "Start a quote" action.
8
+ * QuoteDealTab: the deal a quote belongs to, as a tab of the quote's detail.
9
+ *
10
+ * SDK operations, in order:
11
+ * ProductCatalog: GET /quotable-products?page&limit (quotes.listQuotableProducts) read
12
+ * QuoteDealTab: GET /quotes/{id} (quotes.getQuote) read - finds the deal id
13
+ * GET /deals/{id} (deals.getDeal) read
14
+ * Nothing here is billable, external or destructive.
15
+ */
16
+ import { useState } from "react";
17
+ import { Package } from "lucide-react";
18
+ import { Button, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
19
+ import { ApiError } from "@/g8/components/api-error";
20
+ import { EmptyState } from "@/g8/components/empty-state";
21
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
22
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
23
+ import { SectionCard } from "@/g8/components/screen/section-card";
24
+ import { SplitView } from "@/g8/components/screen/split-view";
25
+ import { StatusPill } from "@/g8/components/screen/status-pill";
26
+ import { useUrlState } from "@/g8/hooks/use-url-state";
27
+ import { g8Get } from "@/g8/lib/g8-client";
28
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
29
+ import { formatMoney, type Deal } from "@/g8/deals/lib/deals-types";
30
+ import { body, frequencyOf, money, text, type QuotableProduct, type ProductPrice } from "../lib/quotes";
31
+
32
+ const productKey = (p: QuotableProduct) => p.stripe_product_id ?? p.manual_product_id ?? p.name;
33
+ const cadence = (p: ProductPrice) => (frequencyOf(p.recurring_interval) === "one_time" ? "One time" : `Per ${p.recurring_interval}`);
34
+
35
+ export interface ProductCatalogProps {
36
+ /** Show the Start a quote action; wire it to your New quote flow. */
37
+ onStartQuote?: () => void;
38
+ pageSize?: number;
39
+ }
40
+
41
+ export function ProductCatalog({ onStartQuote, pageSize = 50 }: ProductCatalogProps) {
42
+ const [selected, setSelected] = useUrlState("product");
43
+ const [mode, setMode] = useUrlState("view");
44
+ const q = useG8Query((signal) => g8Get("/quotable-products", { page: 1, limit: pageSize }, { signal }), [pageSize]);
45
+ const data = body(q.data);
46
+ const items = (Array.isArray(data.items) ? data.items : []) as QuotableProduct[];
47
+ const product = items.find((p) => productKey(p) === selected);
48
+ const close = () => { setSelected(null); setMode(null); };
49
+
50
+ const list = q.error ? <ApiError error={q.error} title="Could not load products" onRetry={() => void q.refetch()} /> : q.loading && !q.data ? <Skeleton className="h-40 w-full" aria-label="Loading products" /> : items.length === 0 ? (
51
+ <EmptyState icon={<Package className="size-8" />} title="No quotable products" description="Mark a Stripe product as quotable, or add a manual product, in graph8's quote settings." />
52
+ ) : (
53
+ <Table>
54
+ <TableHeader><TableRow><TableHead>Product</TableHead><TableHead>Source</TableHead><TableHead className="text-right">Prices</TableHead></TableRow></TableHeader>
55
+ <TableBody>
56
+ {items.map((p) => (
57
+ <TableRow key={productKey(p)} className="cursor-pointer" data-state={productKey(p) === selected ? "selected" : undefined} onClick={() => setSelected(productKey(p))}>
58
+ <TableCell><button type="button" className="font-medium hover:underline" onClick={(e) => { e.stopPropagation(); setSelected(productKey(p)); }}>{p.name}</button><div className="text-xs text-muted-foreground">{p.category}</div></TableCell>
59
+ <TableCell><StatusPill tone="neutral">{p.source === "stripe" ? "Stripe" : "Manual"}</StatusPill></TableCell>
60
+ <TableCell className="text-right tabular-nums">{p.prices.length}</TableCell>
61
+ </TableRow>
62
+ ))}
63
+ </TableBody>
64
+ </Table>
65
+ );
66
+
67
+ const detail = product ? (
68
+ <DetailPanel
69
+ title={product.name}
70
+ subtitle={product.category ?? undefined}
71
+ badges={<StatusPill tone="neutral">{product.source === "stripe" ? "Stripe" : "Manual"}</StatusPill>}
72
+ actions={onStartQuote ? <Button size="sm" onClick={onStartQuote}>Start a quote</Button> : undefined}
73
+ onClose={close}
74
+ onExpand={mode === "full" ? undefined : () => setMode("full")}
75
+ onCollapse={mode === "full" ? () => setMode(null) : undefined}
76
+ tabs={[{ id: "product", label: "Product", content: (
77
+ <div className="space-y-4">
78
+ <SectionCard title="Details">
79
+ <KeyValueList columns={2} items={[
80
+ { label: "Name", value: product.name }, { label: "Source", value: product.source }, { label: "Category", value: product.category },
81
+ { label: "Description", value: product.description }, { label: "Stripe product id", value: product.stripe_product_id, mono: true }, { label: "Manual product id", value: product.manual_product_id, mono: true },
82
+ ]} />
83
+ </SectionCard>
84
+ <SectionCard title="Prices" description="A quote line copies one of these prices.">
85
+ {product.prices.length === 0 ? <p className="text-sm text-muted-foreground">No flat price. Tiered Stripe prices cannot be quoted.</p> : (
86
+ <Table>
87
+ <TableHeader><TableRow><TableHead>Billing</TableHead><TableHead>Type</TableHead><TableHead className="text-right">Amount</TableHead></TableRow></TableHeader>
88
+ <TableBody>{product.prices.map((p, i) => <TableRow key={p.stripe_price_id ?? i}><TableCell>{cadence(p)}</TableCell><TableCell>{text(p.type)}</TableCell><TableCell className="text-right tabular-nums">{money(p.unit_amount, (p.currency ?? "usd").toUpperCase())}</TableCell></TableRow>)}</TableBody>
89
+ </Table>
90
+ )}
91
+ </SectionCard>
92
+ </div>
93
+ ) }]}
94
+ />
95
+ ) : null;
96
+
97
+ return <SplitView detailOpen={Boolean(product)} onCloseDetail={close} expanded={mode === "full" && Boolean(product)} onExpandedChange={(f) => setMode(f ? "full" : null)} list={list} detail={detail} />;
98
+ }
99
+
100
+ export function QuoteDealTab({ quoteId, onOpenDeal }: { quoteId: string; onOpenDeal?: (dealId: string) => void }) {
101
+ const q = useG8Query(async (signal) => {
102
+ const quote = body(await g8Get(`/quotes/${encodeURIComponent(quoteId)}`, undefined, { signal }));
103
+ const dealId = text(quote.deal_id);
104
+ if (!dealId) return null;
105
+ const env = await g8Get<Deal>(`/deals/${encodeURIComponent(dealId)}`, undefined, { signal });
106
+ return (env.data ?? env) as unknown as Deal;
107
+ }, [quoteId]);
108
+ if (q.error) return <ApiError error={q.error} title="Could not load the deal" onRetry={() => void q.refetch()} />;
109
+ if (q.loading && q.data === undefined) return <Skeleton className="h-24 w-full" aria-label="Loading deal" />;
110
+ const d = q.data;
111
+ if (!d) return <EmptyState title="No deal on this quote" />;
112
+ return (
113
+ <SectionCard title={d.name || "Deal"} actions={onOpenDeal ? <Button size="sm" variant="outline" onClick={() => onOpenDeal(d.id)}>Open deal</Button> : undefined}>
114
+ <KeyValueList columns={2} items={[
115
+ { label: "Stage", value: d.stage_name }, { label: "Amount", value: d.amount == null ? null : formatMoney(d.amount, d.currency) }, { label: "Owner", value: d.owner_name ?? d.owner_email },
116
+ { label: "Close date", value: d.close_date?.slice(0, 10) }, { label: "Pipeline", value: d.pipeline_name }, { label: "Contacts", value: d.contact_count },
117
+ ]} />
118
+ </SectionCard>
119
+ );
120
+ }
@@ -0,0 +1,169 @@
1
+ "use client";
2
+
3
+ /**
4
+ * QuotesScreen: the finished quotes product. Every quote with its status, a viewer for the open one, a builder for
5
+ * new and draft quotes, and e-signature tracking. No props are required.
6
+ *
7
+ * It composes: ProductCatalog (Products tab), QuoteDealTab, QuoteList (status filter, paging), QuoteViewer (totals, send, resend, edit as draft, duplicate,
8
+ * delete, e-signature status and reminder), QuoteBuilder (details, signer, line items from ProductPicker) and a deal
9
+ * picker, because graph8 attaches every quote to a deal.
10
+ *
11
+ * Flows:
12
+ * - Headline numbers by status: drafts, out for signature, accepted, declined.
13
+ * - Open a quote (deep link ?id=<quote id>, full page with &view=full) -> line items, totals, signer, payment
14
+ * terms, signature progress, and a Deal tab. The Products tab lists every quotable product; open one for all
15
+ * its fields and prices (?tab=products&product=<id>).
16
+ * - New quote: choose the deal (search by name; skipped when `dealId` is passed), then the builder. Saving a draft
17
+ * emails nobody, so it needs no confirmation.
18
+ * - Edit a draft from the viewer; duplicate; delete a draft (confirmed).
19
+ * - Send for signature, resend, send a signature reminder, and edit a sent quote as a new draft: all external
20
+ * actions, each behind ConfirmSpendDialog because they reach a real person.
21
+ *
22
+ * SDK operations (through the composed parts): listQuotes, getQuote, createQuote, updateQuotePatch,
23
+ * searchQuoteContacts, listQuotableProducts, sendQuote (external), editQuoteAsDraft (external), duplicateQuote,
24
+ * deleteQuote (destructive), resendEnvelope (external) and the deals block's listDeals (the deal picker).
25
+ *
26
+ * Slots (all optional): renderDetailActions, extraDetailTabs, toolbarActions.
27
+ */
28
+ import { useState, type ReactNode } from "react";
29
+ import { FileText, Plus, Search } from "lucide-react";
30
+ import { Button, Input } from "@graph8/sdk/ui";
31
+ import { ApiError } from "@/g8/components/api-error";
32
+ import { EmptyState } from "@/g8/components/empty-state";
33
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
34
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
35
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
36
+ import { SplitView } from "@/g8/components/screen/split-view";
37
+ import { useUrlState } from "@/g8/hooks/use-url-state";
38
+ import { g8Get } from "@/g8/lib/g8-client";
39
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
40
+ import { formatMoney, type Deal } from "@/g8/deals/lib/deals-types";
41
+ import { useDebounced } from "../hooks/use-debounced";
42
+ import { body } from "../lib/quotes";
43
+ import { QuoteBuilder } from "./quote-builder";
44
+ import { QuoteList } from "./quote-list";
45
+ import { ProductCatalog, QuoteDealTab } from "./quote-related";
46
+ import { QuoteViewer } from "./quote-viewer";
47
+
48
+ export interface QuotesScreenProps {
49
+ /** Show only the quotes of this deal and create new ones for it. */
50
+ dealId?: string;
51
+ /** Show only the quotes of this company (mashup company id). */
52
+ companyId?: number;
53
+ renderDetailActions?: (quoteId: string) => ReactNode;
54
+ extraDetailTabs?: Array<{ id: string; label: string; render: (quoteId: string) => ReactNode }>;
55
+ toolbarActions?: ReactNode;
56
+ /** Open the quote's deal in your deals screen; without it the Deal tab is read-only. */
57
+ onOpenDeal?: (dealId: string) => void;
58
+ }
59
+
60
+ const STATUS_STATS: Array<{ status: string; label: string; tone?: "positive" | "negative" | "info" }> = [
61
+ { status: "draft", label: "Drafts" },
62
+ { status: "sent", label: "Out for signature", tone: "info" },
63
+ { status: "accepted", label: "Accepted", tone: "positive" },
64
+ { status: "declined", label: "Declined", tone: "negative" },
65
+ ];
66
+
67
+ function useStatusTotals(dealId: string | undefined, companyId: number | undefined, refreshKey: number) {
68
+ return useG8Query(async (signal) => {
69
+ const totals = await Promise.all(STATUS_STATS.map(async (s) => {
70
+ const data = body(await g8Get("/quotes", { status: s.status, deal_id: dealId, mashup_company_id: companyId, page: 1, limit: 1 }, { signal }));
71
+ return typeof data.total === "number" ? data.total : 0;
72
+ }));
73
+ return totals;
74
+ }, [dealId, companyId, refreshKey]);
75
+ }
76
+
77
+ /** Step one of a new quote when no deal is given: find the deal the quote belongs to. */
78
+ function DealStep({ onPick }: { onPick: (deal: Deal) => void }) {
79
+ const [text, setText] = useState("");
80
+ const term = useDebounced(text);
81
+ const deals = useG8Query(async (signal) => {
82
+ const env = await g8Get<Deal[]>("/deals", { search: term || undefined, limit: 10, page: 1 }, { signal });
83
+ return env.data ?? [];
84
+ }, [term]);
85
+ return (
86
+ <div className="space-y-3">
87
+ <p className="text-sm text-muted-foreground">A quote belongs to a deal. Pick the deal first.</p>
88
+ <div className="relative">
89
+ <Search className="pointer-events-none absolute left-2 top-2.5 size-4 text-muted-foreground" aria-hidden />
90
+ <Input aria-label="Search deals" className="pl-8" placeholder="Search deals by name" value={text} onChange={(e) => setText(e.target.value)} />
91
+ </div>
92
+ {deals.error ? <ApiError error={deals.error} title="Could not load deals" onRetry={() => void deals.refetch()} /> : null}
93
+ {!deals.loading && !deals.error && (deals.data ?? []).length === 0 ? <EmptyState title={term ? "No deals match" : "No deals yet"} description="Create a deal first, then quote it." /> : null}
94
+ <ul className="divide-y rounded-md border empty:hidden" aria-label="Deals">
95
+ {(deals.data ?? []).map((d) => (
96
+ <li key={d.id}>
97
+ <button type="button" className="flex w-full items-center justify-between gap-2 p-3 text-left text-sm hover:bg-muted" onClick={() => onPick(d)}>
98
+ <span><span className="font-medium">{d.name || "Untitled deal"}</span><span className="block text-xs text-muted-foreground">{d.stage_name ?? ""}</span></span>
99
+ <span className="tabular-nums text-muted-foreground">{formatMoney(d.amount, d.currency)}</span>
100
+ </button>
101
+ </li>
102
+ ))}
103
+ </ul>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ export function QuotesScreen({ dealId, companyId, renderDetailActions, extraDetailTabs, toolbarActions, onOpenDeal }: QuotesScreenProps) {
109
+ const [selectedId, setSelectedId] = useUrlState("id");
110
+ const [mode, setMode] = useUrlState("view");
111
+ const [tabParam, setTab] = useUrlState("tab", "quotes");
112
+ const tab = tabParam === "products" ? "products" : "quotes";
113
+ const [version, setVersion] = useState(0);
114
+ const [creating, setCreating] = useState(false);
115
+ const [pickedDeal, setPickedDeal] = useState<Deal | null>(null);
116
+ const [editingId, setEditingId] = useState<string | null>(null);
117
+ const totals = useStatusTotals(dealId, companyId, version);
118
+ const refresh = () => setVersion((v) => v + 1);
119
+ const close = () => { setSelectedId(null); setMode(null); };
120
+ const openNew = () => { setPickedDeal(null); setCreating(true); };
121
+ const saved = (id: string) => { setCreating(false); setEditingId(null); refresh(); setSelectedId(id); };
122
+ const builderDeal = dealId ?? pickedDeal?.id;
123
+
124
+ const stats = STATUS_STATS.map((s, i) => ({ label: s.label, value: totals.data ? (totals.data[i] ?? 0).toLocaleString() : "-", tone: s.tone }));
125
+
126
+ const detail = selectedId ? (
127
+ <DetailPanel
128
+ title="Quote"
129
+ actions={renderDetailActions?.(selectedId)}
130
+ onClose={close}
131
+ onExpand={mode === "full" ? undefined : () => setMode("full")}
132
+ onCollapse={mode === "full" ? () => setMode(null) : undefined}
133
+ tabs={[
134
+ { id: "quote", label: "Quote", content: <QuoteViewer key={selectedId} quoteId={selectedId} onEdit={setEditingId} onOpenQuote={(id) => { refresh(); setSelectedId(id); }} onDeleted={() => { close(); refresh(); }} /> },
135
+ { id: "deal", label: "Deal", content: <QuoteDealTab key={selectedId} quoteId={selectedId} onOpenDeal={onOpenDeal} /> },
136
+ ...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(selectedId) })),
137
+ ]}
138
+ />
139
+ ) : null;
140
+
141
+ return (
142
+ <ScreenLayout
143
+ title="Quotes"
144
+ description="Build quotes from your products, send them for signature and track who has signed."
145
+ icon={<FileText className="size-5" />}
146
+ actions={<>{toolbarActions}<Button onClick={openNew}><Plus className="size-4" aria-hidden />New quote</Button></>}
147
+ stats={stats}
148
+ tabs={[{ id: "quotes", label: "Quotes" }, { id: "products", label: "Products" }]}
149
+ activeTab={tab}
150
+ onTabChange={(id) => { setTab(id); close(); }}
151
+ >
152
+ {totals.error ? <ApiError error={totals.error} title="Could not load quote numbers" onRetry={() => void totals.refetch()} /> : null}
153
+ {tab === "products" ? <ProductCatalog onStartQuote={openNew} /> : <SplitView
154
+ detailOpen={Boolean(selectedId)}
155
+ onCloseDetail={close}
156
+ expanded={mode === "full" && Boolean(selectedId)}
157
+ onExpandedChange={(full) => setMode(full ? "full" : null)}
158
+ detail={detail}
159
+ list={<QuoteList dealId={dealId} companyId={companyId} refreshKey={version} selectedId={selectedId} onOpen={setSelectedId} onNew={openNew} />}
160
+ />}
161
+ <FormSheet open={creating} onOpenChange={setCreating} title="New quote" description={pickedDeal ? `For ${pickedDeal.name ?? "this deal"}` : undefined}>
162
+ {creating ? (builderDeal ? <QuoteBuilder key={builderDeal} dealId={builderDeal} companyId={companyId} onSaved={saved} /> : <DealStep onPick={setPickedDeal} />) : null}
163
+ </FormSheet>
164
+ <FormSheet open={editingId != null} onOpenChange={(o) => { if (!o) setEditingId(null); }} title="Edit draft quote">
165
+ {editingId ? <QuoteBuilder key={editingId} quoteId={editingId} onSaved={saved} /> : null}
166
+ </FormSheet>
167
+ </ScreenLayout>
168
+ );
169
+ }