@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
@@ -4,6 +4,29 @@ A kanban of deals by stage with drag and drop, a deal detail with history, a sta
4
4
  Install with `npx @graph8/sdk add deals` (depends on `crm`, whose notes, tasks and activity timeline you can
5
5
  compose under `DealDetail`'s `children`). Drag and drop needs `npm i @dnd-kit/core`.
6
6
 
7
+ ## Screens
8
+
9
+ `DealsScreen` (`components/deals-screen.tsx`) is the finished product; mount it on a route with no props:
10
+ `<DealsScreen />`. Everything below is reachable from it, no extra code.
11
+
12
+ - Board and List views, search by name, a pipeline switcher when there is more than one pipeline.
13
+ - Headline numbers: open deals, open value, estimated weighted value, won value.
14
+ - Open a deal (board card or list row, deep link `?id=<deal id>`): Details (edit, stage history, confirmed delete) and
15
+ Activity tabs. Moving a card into a won or lost stage asks for the close date and amount first.
16
+ - **New deal**: name, amount, close date, stage, an **owner** and **at least one contact**. The API requires both
17
+ (`owner_id`, and `contact_ids` with one or more mashup contact ids that all work at one company; the older note that
18
+ the list may be empty was wrong). A company that already has a deal is refused with 409 unless "Allow another deal"
19
+ is ticked.
20
+ - Forecast tab (totals, win rate, stuck deals, estimated weighted pipeline) and Pipeline settings tab (stage names,
21
+ probabilities, won/lost types, order, delete).
22
+ - Slots for cross-block glue, all optional: `renderRowActions(deal)`, `renderDetailActions(deal)`,
23
+ `extraDetailTabs`, `toolbarActions`. Example: add a Quotes tab with `<QuoteList dealId={deal.id} />`.
24
+
25
+ Scopes the screen needs on top of the lists below: `team:read` (owner default and picker) and `contacts:read`
26
+ (contact search). Proxy `allowedPrefixes` must include `deals`, `pipelines`, `team-members`, `contacts` (and `activity`
27
+ for the timeline).
28
+
29
+
7
30
  ## The flow, in order
8
31
 
9
32
  1. **Pipelines** `GET /deals/pipelines` (`usePipelines`): `[{ id, name, is_default, stages: [{ id, name,
@@ -17,8 +40,8 @@ compose under `DealDetail`'s `children`). Drag and drop needs `npm i @dnd-kit/co
17
40
  won, and an owner. The board prompts for the date and amount first; if the API still answers 422 with
18
41
  `close_date_required_for_won_stage`, `amount_required_for_won_stage` or `owner_required_for_closed_stage`,
19
42
  the board shows what is missing and rolls back.
20
- 5. **Create / edit** (`DealDetail`): `POST /deals` needs `name`, `owner_id` and `contact_ids` (a list, may be
21
- empty); `stage_id` defaults to the first stage of the default pipeline. Edits send `expected_revision`;
43
+ 5. **Create / edit** (`DealDetail`): `POST /deals` needs `name`, `owner_id` and `contact_ids` (at least ONE mashup contact id;
44
+ all of them must work at the same company); `stage_id` defaults to the first stage of the default pipeline. Edits send `expected_revision`;
22
45
  a stale revision is refused, so reload and retry. `DELETE /deals/{id}` is destructive and confirmed.
23
46
  6. **Configure** (`StageEditor`): `GET /pipelines/{id}`, `POST .../stages`, `PUT .../stages/{stage_id}`,
24
47
  `PUT .../stages/reorder` (send every stage id in the new order), `DELETE .../stages/{stage_id}` (destructive,
@@ -40,7 +63,12 @@ create), `deal.id` + `deal.revision` (card -> detail -> update/delete).
40
63
 
41
64
  - The board shows the default pipeline. Moves save immediately; no confirm dialog (nobody is contacted).
42
65
  - Every card has a "Move to" select, the keyboard and screen-reader path, doing the same PATCH as dragging.
43
- - Ask the human only who should **own deals created in the UI** (the API requires an owner).
66
+ - **Deal owner.** graph8 requires an owner on create, so `DealsScreen` never asks: it defaults to the API key's own
67
+ user (`GET /team-members/me`, the roster record of the person who minted a personal key, using their identity-provider
68
+ user id and falling back to their email). An organisation key has no person (that route answers 403), so the owner
69
+ picker preselects the first active team member of `GET /team-members`. The person can change the owner in the picker
70
+ before saving. `DealDetail` alone still takes `ownerId` as a prop.
71
+ - Nothing about deals needs to be asked of the human. If the product has its own user model, pass that user's graph8 id as `ownerId` instead.
44
72
 
45
73
  ## Gotchas
46
74
 
@@ -70,6 +98,9 @@ create), `deal.id` + `deal.revision` (card -> detail -> update/delete).
70
98
  | `cliPipelines.deleteStage` | DELETE /pipelines/{id}/stages/{stage_id} | destructive | StageEditor |
71
99
  | `deals.getDealAnalytics` | GET /deals/analytics | read | ForecastView |
72
100
  | `deals.getDealStageVelocity` | GET /deals/stage-velocity | read | ForecastView |
101
+ | `teamMembers.getMyTeamMember` | GET /team-members/me | read | useDefaultOwner (DealsScreen) |
102
+ | `teamMembers.listTeamMembers` | GET /team-members | read | useDefaultOwner (DealsScreen) |
103
+ | `contacts.listContacts` | GET /contacts | read | DealsScreen contact search |
73
104
 
74
105
  ## Recipes (server side)
75
106
 
@@ -21,10 +21,14 @@
21
21
  "get_deal_analytics_deals_analytics_get",
22
22
  "get_deal_deals__deal_id__get",
23
23
  "get_deal_stage_velocity_deals_stage_velocity_get",
24
+ "get_my_team_member_team_members_me_get",
24
25
  "get_pipeline_pipelines__pipeline_id__get",
26
+ "list_contacts_contacts_get",
25
27
  "list_deal_history_deals__deal_id__history_get",
26
28
  "list_deals_deals_get",
27
29
  "list_pipelines_deals_pipelines_get",
30
+ "list_quotes_quotes_get",
31
+ "list_team_members_team_members_get",
28
32
  "reorder_stages_pipelines__pipeline_id__stages_reorder_put",
29
33
  "update_deal_deals__deal_id__patch",
30
34
  "update_stage_pipelines__pipeline_id__stages__stage_id__put"
@@ -92,6 +96,24 @@
92
96
  "tiers": [
93
97
  "read"
94
98
  ]
99
+ },
100
+ {
101
+ "name": "DealsScreen",
102
+ "file": "components/deals-screen.tsx",
103
+ "purpose": "Finished deals product: board and list, new deal with the owner defaulted to the API key user, deal detail with activity, forecast and pipeline settings.",
104
+ "operations": [],
105
+ "tiers": []
106
+ },
107
+ {
108
+ "name": "DealContactsTab",
109
+ "file": "components/deal-related.tsx",
110
+ "purpose": "A deal's contacts with roles; open one for the full CRM record. Also exports DealQuotesTab, a read-only list of the deal's quotes.",
111
+ "operations": [
112
+ "get_deal_deals__deal_id__get"
113
+ ],
114
+ "tiers": [
115
+ "read"
116
+ ]
95
117
  }
96
118
  ],
97
119
  "hooks": [
@@ -111,6 +133,15 @@
111
133
  "list_deals_deals_get",
112
134
  "update_deal_deals__deal_id__patch"
113
135
  ]
136
+ },
137
+ {
138
+ "name": "useDefaultOwner",
139
+ "file": "hooks/use-default-owner.ts",
140
+ "purpose": "The default deal owner: the API key user, else the first team member, plus the owner picker options.",
141
+ "operations": [
142
+ "get_my_team_member_team_members_me_get",
143
+ "list_team_members_team_members_get"
144
+ ]
114
145
  }
115
146
  ],
116
147
  "lib": [
@@ -160,9 +191,12 @@
160
191
  "campaigns:delete",
161
192
  "campaigns:read",
162
193
  "campaigns:write",
194
+ "contacts:read",
163
195
  "deals:delete",
164
196
  "deals:read",
165
- "deals:write"
197
+ "deals:write",
198
+ "quotes:read",
199
+ "team:read"
166
200
  ],
167
201
  "defaults": [
168
202
  {
@@ -182,8 +216,8 @@
182
216
  },
183
217
  {
184
218
  "decision": "Who owns deals created in the UI",
185
- "default": "The signed-in user of your app: pass their graph8 user id as ownerId (the API requires an owner)",
186
- "askHuman": true
219
+ "default": "The API key's own user (GET /team-members/me). With an organisation key there is no person, so the first team member is preselected in an owner picker the person can change. Pass ownerId to DealDetail to override",
220
+ "askHuman": false
187
221
  },
188
222
  {
189
223
  "decision": "Weighted forecast",
@@ -193,6 +227,7 @@
193
227
  ],
194
228
  "peerDependencies": {
195
229
  "react": ">=18",
196
- "@dnd-kit/core": "^6.3.1"
230
+ "@dnd-kit/core": "^6.3.1",
231
+ "lucide-react": ">=0.400.0"
197
232
  }
198
233
  }
@@ -30,6 +30,8 @@ export interface DealDetailProps {
30
30
  /** Create mode: contact ids to link (the API requires the list; it may be empty). */
31
31
  contactIds?: number[];
32
32
  pipelineId?: string;
33
+ /** Create mode: allow a second deal for a company that already has one (the API refuses it with 409 otherwise). */
34
+ allowDuplicate?: boolean;
33
35
  onSaved?: (deal: Deal) => void;
34
36
  onDeleted?: (dealId: string) => void;
35
37
  children?: ReactNode;
@@ -50,7 +52,7 @@ function fromDeal(d: Deal): Form {
50
52
  return { name: d.name ?? "", amount: d.amount != null ? String(d.amount) : "", closeDate: d.close_date?.slice(0, 10) ?? "", stageId: d.stage_id ?? "", description: d.description ?? "" };
51
53
  }
52
54
 
53
- export function DealDetail({ dealId, ownerId, contactIds = [], pipelineId, onSaved, onDeleted, children, className }: DealDetailProps) {
55
+ export function DealDetail({ dealId, ownerId, contactIds = [], pipelineId, allowDuplicate, onSaved, onDeleted, children, className }: DealDetailProps) {
54
56
  const { pipelines, defaultPipeline } = usePipelines();
55
57
  const [form, setForm] = useState<Form>(EMPTY);
56
58
  const [confirmDelete, setConfirmDelete] = useState(false);
@@ -77,7 +79,7 @@ export function DealDetail({ dealId, ownerId, contactIds = [], pipelineId, onSav
77
79
  };
78
80
  const env = effectiveId
79
81
  ? await g8Patch(`/deals/${encodeURIComponent(effectiveId)}`, { ...body, expected_revision: (dealId ? deal.data?.revision : createdRevision) ?? undefined })
80
- : await g8Post("/deals", { ...body, owner_id: ownerId, contact_ids: contactIds, pipeline_id: pipeline?.id, amount: body.amount ?? undefined, close_date: body.close_date ?? undefined });
82
+ : await g8Post("/deals", { ...body, owner_id: ownerId, contact_ids: contactIds, allow_duplicate: allowDuplicate || undefined, pipeline_id: pipeline?.id, amount: body.amount ?? undefined, close_date: body.close_date ?? undefined });
81
83
  return dataOf<Deal>(env);
82
84
  }, { onSuccess: (saved) => { if (!dealId && saved.id) { setCreatedId(saved.id); setCreatedRevision(saved.revision ?? null); } if (dealId) { void deal.refetch(); void history.refetch(); } onSaved?.(saved); } });
83
85
  const remove = useG8Mutation(async () => { await g8Delete(`/deals/${encodeURIComponent(dealId as string)}`); }, { onSuccess: () => { setConfirmDelete(false); onDeleted?.(dealId as string); }, onError: () => setConfirmDelete(false) });
@@ -0,0 +1,87 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Related records of one deal, as tabs of the deal's full detail: its contacts (open one for the full CRM record)
5
+ * and its quotes (read-only list; open one in your quotes screen through `onOpenQuote`).
6
+ *
7
+ * SDK operations, in order:
8
+ * DealContactsTab: GET /deals/{id} (deals.getDeal) read - the deal's contacts and roles
9
+ * GET /contacts/{id} ... (crm ContactDetail, in a side sheet)
10
+ * DealQuotesTab: GET /quotes?deal_id=&page=&limit (quotes.listQuotes) read - needs the quotes:read scope
11
+ * Nothing here is billable, external or destructive.
12
+ */
13
+ import { useState } from "react";
14
+ import { FileText, Users } from "lucide-react";
15
+ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
16
+ import { ApiError } from "@/g8/components/api-error";
17
+ import { EmptyState } from "@/g8/components/empty-state";
18
+ import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
19
+ import { StatusPill, toneForStatus, humanizeStatus } from "@/g8/components/screen/status-pill";
20
+ import { g8Get } from "@/g8/lib/g8-client";
21
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
22
+ import { ContactDetail } from "@/g8/crm/components/contact-detail";
23
+ import { dataOf, type Deal } from "../lib/deals-types";
24
+
25
+ export function DealContactsTab({ dealId }: { dealId: string }) {
26
+ const [open, setOpen] = useState<number | null>(null);
27
+ const q = useG8Query(async (signal) => dataOf<Deal>(await g8Get(`/deals/${encodeURIComponent(dealId)}`, undefined, { signal })), [dealId]);
28
+ if (q.error) return <ApiError error={q.error} title="Could not load the deal's contacts" onRetry={() => void q.refetch()} />;
29
+ if (!q.data) return <Skeleton className="h-24 w-full" aria-label="Loading contacts" />;
30
+ const contacts = q.data.contacts ?? [];
31
+ if (contacts.length === 0) return <EmptyState icon={<Users className="size-8" />} title="No contacts on this deal" description="Contacts are attached when the deal is created." />;
32
+ return (
33
+ <>
34
+ <ul className="divide-y rounded-md border" aria-label="Deal contacts">
35
+ {contacts.map((c) => (
36
+ <li key={c.id}>
37
+ <button type="button" className="flex w-full items-center gap-3 p-3 text-left hover:bg-muted" onClick={() => setOpen(c.id)}>
38
+ <EntityAvatar name={c.name || c.email || "Contact"} size="md" />
39
+ <span className="min-w-0 flex-1">
40
+ <span className="block truncate text-sm font-medium">{c.name || c.email || `Contact ${c.id}`}</span>
41
+ <span className="block truncate text-xs text-muted-foreground">{[c.title, c.email].filter(Boolean).join(" · ")}</span>
42
+ </span>
43
+ {c.role ? <StatusPill tone="neutral">{humanizeStatus(c.role)}</StatusPill> : null}
44
+ {q.data?.primary_contact?.id === c.id ? <StatusPill tone="info">Primary</StatusPill> : null}
45
+ </button>
46
+ </li>
47
+ ))}
48
+ </ul>
49
+ <Sheet open={open != null} onOpenChange={(o) => { if (!o) setOpen(null); }}>
50
+ <SheetContent className="w-full overflow-y-auto sm:max-w-2xl">
51
+ <SheetHeader><SheetTitle>Contact</SheetTitle><SheetDescription>The full CRM record.</SheetDescription></SheetHeader>
52
+ {open != null ? <ContactDetail contactId={open} /> : null}
53
+ </SheetContent>
54
+ </Sheet>
55
+ </>
56
+ );
57
+ }
58
+
59
+ interface QuoteRow { id: string; title?: string | null; quote_number?: string | null; status?: string | null; total?: number | null; currency?: string | null }
60
+
61
+ export function DealQuotesTab({ dealId, onOpenQuote }: { dealId: string; onOpenQuote?: (quoteId: string) => void }) {
62
+ const q = useG8Query(async (signal) => {
63
+ const env = await g8Get("/quotes", { deal_id: dealId, page: 1, limit: 50 }, { signal });
64
+ const inner = dataOf<{ items?: QuoteRow[] } | QuoteRow[]>(env);
65
+ return Array.isArray(inner) ? inner : (inner.items ?? []);
66
+ }, [dealId]);
67
+ if (q.error) return <ApiError error={q.error} title="Could not load quotes" onRetry={() => void q.refetch()} />;
68
+ if (!q.data) return <Skeleton className="h-24 w-full" aria-label="Loading quotes" />;
69
+ if (q.data.length === 0) return <EmptyState icon={<FileText className="size-8" />} title="No quotes for this deal" description="Create one from the Quotes screen." />;
70
+ return (
71
+ <Table>
72
+ <TableHeader><TableRow><TableHead>Quote</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Total</TableHead></TableRow></TableHeader>
73
+ <TableBody>
74
+ {q.data.map((r) => (
75
+ <TableRow key={r.id}>
76
+ <TableCell>
77
+ {onOpenQuote ? <button type="button" className="font-medium hover:underline" onClick={() => onOpenQuote(r.id)}>{r.title || "Untitled quote"}</button> : <span className="font-medium">{r.title || "Untitled quote"}</span>}
78
+ <div className="text-xs text-muted-foreground">{r.quote_number}</div>
79
+ </TableCell>
80
+ <TableCell><StatusPill tone={toneForStatus(r.status)} dot>{humanizeStatus(r.status)}</StatusPill></TableCell>
81
+ <TableCell className="text-right tabular-nums">{r.total == null ? "" : (r.total / 100).toLocaleString(undefined, { style: "currency", currency: r.currency || "USD" })}</TableCell>
82
+ </TableRow>
83
+ ))}
84
+ </TableBody>
85
+ </Table>
86
+ );
87
+ }
@@ -0,0 +1,293 @@
1
+ "use client";
2
+
3
+ /**
4
+ * DealsScreen: the finished deals product. Board and list of the pipeline, a deal detail, new deal, forecast and
5
+ * pipeline stage settings in one screen. No props are required.
6
+ *
7
+ * It composes: PipelineBoard (kanban, drag or "Move to"), a deals table, DealDetail (edit, stage history, delete),
8
+ * ForecastView, StageEditor, DealContactsTab, DealQuotesTab and the crm block's ActivityTimeline and ContactDetail.
9
+ *
10
+ * Flows:
11
+ * - Board / List toggle, search by deal name, switch pipeline (when there is more than one).
12
+ * - Headline numbers: open deals, open value, estimated weighted value, won value (from the loaded deals).
13
+ * - Open a deal (board card or list row; deep link ?id=, full page with &view=full) -> Details (every field,
14
+ * edit, stage history, confirmed delete), Contacts (open the full CRM record), Quotes and Activity tabs. Moving a card into a won/lost stage asks for the close date and amount.
15
+ * - New deal: name, amount, close date, stage, plus the two things the API requires. The OWNER defaults to the API
16
+ * key's own user (GET /team-members/me); with an organisation key it defaults to the first team member and an
17
+ * owner picker lets the person change it. At least ONE CONTACT is required by the API (`contact_ids`), found with a
18
+ * search box. A company that already has a deal is refused (409) unless "Allow another deal" is ticked.
19
+ * - Forecast tab (ForecastView) and Pipeline settings tab (stage summary with deal counts and values, plus StageEditor: names, probabilities, order, delete).
20
+ *
21
+ * SDK operations (all through the composed parts): listPipelines, listDeals, getDeal, listDealHistory, createDeal,
22
+ * updateDeal, deleteDeal (confirmed), getDealAnalytics, getDealStageVelocity, the pipeline-stage writes,
23
+ * listTeamMembers + getMyTeamMember (the owner default) and the crm block's listContacts + activity feed.
24
+ * Nothing here is billable or external; delete is destructive and goes through ConfirmSpendDialog inside DealDetail.
25
+ *
26
+ * Slots for cross-block glue (all optional): renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions.
27
+ */
28
+ import { useMemo, useState, type ReactNode } from "react";
29
+ import { Handshake, Plus, Search, X } from "lucide-react";
30
+ import { Button, Input, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } 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 { EntityAvatar } from "@/g8/components/screen/entity-avatar";
35
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
36
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
37
+ import { SplitView } from "@/g8/components/screen/split-view";
38
+ import { StatusPill } from "@/g8/components/screen/status-pill";
39
+ import { Toolbar } from "@/g8/components/screen/toolbar";
40
+ import { useUrlState } from "@/g8/hooks/use-url-state";
41
+ import { g8Get } from "@/g8/lib/g8-client";
42
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
43
+ import { ActivityTimeline } from "@/g8/crm/components/activity-timeline";
44
+ import { useDebouncedValue } from "@/g8/crm/hooks/use-debounced";
45
+ import { contactName, type ContactRecord } from "@/g8/crm/lib/crm-types";
46
+ import { useDefaultOwner } from "../hooks/use-default-owner";
47
+ import { usePipelineDeals } from "../hooks/use-pipeline-deals";
48
+ import { usePipelines } from "../hooks/use-pipelines";
49
+ import { dataOf, formatDate, formatMoney, stageKind, sumMoney, weightedPipeline, type Deal, type Stage } from "../lib/deals-types";
50
+ import { DealDetail } from "./deal-detail";
51
+ import { DealContactsTab, DealQuotesTab } from "./deal-related";
52
+ import { ForecastView } from "./forecast-view";
53
+ import { PipelineBoard } from "./pipeline-board";
54
+ import { StageEditor } from "./stage-editor";
55
+
56
+ export interface DealsScreenProps {
57
+ /** Start on this pipeline; defaults to the org's default one. */
58
+ pipelineId?: string;
59
+ /** Extra buttons per list row (for example "Create quote" from the quotes block). */
60
+ renderRowActions?: (deal: Deal) => ReactNode;
61
+ /** Extra buttons in the detail header. */
62
+ renderDetailActions?: (deal: Deal) => ReactNode;
63
+ /** Extra tabs in the detail panel, for example a Quotes tab. */
64
+ extraDetailTabs?: Array<{ id: string; label: string; render: (deal: Deal) => ReactNode }>;
65
+ /** Extra controls at the right of the toolbar. */
66
+ toolbarActions?: ReactNode;
67
+ /** Open a quote of the deal in your quotes screen; without it the Quotes tab is read-only. */
68
+ onOpenQuote?: (quoteId: string) => void;
69
+ }
70
+
71
+ type View = "board" | "list" | "forecast" | "stages";
72
+ const VIEWS: Array<{ id: View; label: string }> = [
73
+ { id: "board", label: "Board" },
74
+ { id: "list", label: "List" },
75
+ { id: "forecast", label: "Forecast" },
76
+ { id: "stages", label: "Pipeline settings" },
77
+ ];
78
+ const asView = (v: string | null | undefined): View => (VIEWS.some((x) => x.id === v) ? (v as View) : "board");
79
+
80
+ function stageTone(stage: Stage | undefined): "neutral" | "positive" | "negative" | "info" {
81
+ const kind = stageKind(stage);
82
+ return kind === "won" ? "positive" : kind === "lost" ? "negative" : "info";
83
+ }
84
+
85
+ /** Contact search + chips for the new-deal sheet. The API requires at least one contact. */
86
+ function ContactPicker({ value, onChange }: { value: ContactRecord[]; onChange: (next: ContactRecord[]) => void }) {
87
+ const [text, setText] = useState("");
88
+ const term = useDebouncedValue(text.trim());
89
+ const found = useG8Query(async (signal) => {
90
+ const env = await g8Get<ContactRecord[]>("/contacts", { name: term, limit: 8, page: 1 }, { signal });
91
+ return env.data ?? [];
92
+ }, [term], { enabled: term.length > 1 });
93
+ const choices = (found.data ?? []).filter((c) => !value.some((v) => v.id === c.id));
94
+ return (
95
+ <div className="space-y-2">
96
+ <label htmlFor="deal-contact-search" className="text-sm font-medium">Contacts</label>
97
+ <div className="flex flex-wrap gap-1">
98
+ {value.map((c) => (
99
+ <span key={c.id} className="inline-flex items-center gap-1 rounded-full border bg-muted px-2 py-0.5 text-xs">
100
+ {contactName(c)}
101
+ <button type="button" aria-label={`Remove ${contactName(c)}`} onClick={() => onChange(value.filter((v) => v.id !== c.id))}><X className="size-3" /></button>
102
+ </span>
103
+ ))}
104
+ </div>
105
+ <div className="relative">
106
+ <Search className="pointer-events-none absolute left-2 top-2.5 size-4 text-muted-foreground" aria-hidden />
107
+ <Input id="deal-contact-search" className="pl-8" placeholder="Search contacts by name" value={text} onChange={(e) => setText(e.target.value)} />
108
+ </div>
109
+ {found.error ? <ApiError error={found.error} title="Could not search contacts" /> : null}
110
+ {term.length > 1 && !found.loading && choices.length === 0 && !found.error ? <p className="text-xs text-muted-foreground">No other contacts match.</p> : null}
111
+ <ul className="divide-y rounded-md border empty:hidden" aria-label="Matching contacts">
112
+ {choices.map((c) => (
113
+ <li key={c.id}>
114
+ <button type="button" className="flex w-full items-center gap-2 p-2 text-left text-sm hover:bg-muted" onClick={() => { onChange([...value, c]); setText(""); }}>
115
+ <EntityAvatar name={contactName(c)} size="sm" />
116
+ <span className="font-medium">{contactName(c)}</span>
117
+ <span className="text-xs text-muted-foreground">{[c.job_title, c.company_name].filter(Boolean).join(" at ")}</span>
118
+ </button>
119
+ </li>
120
+ ))}
121
+ </ul>
122
+ <p className="text-xs text-muted-foreground">graph8 needs at least one contact on a deal. Every contact must work at the same company.</p>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ function NewDealForm({ pipelineId, onCreated }: { pipelineId?: string; onCreated: (deal: Deal) => void }) {
128
+ const owner = useDefaultOwner();
129
+ const [ownerId, setOwnerId] = useState<string | null>(null);
130
+ const [contacts, setContacts] = useState<ContactRecord[]>([]);
131
+ const [duplicate, setDuplicate] = useState(false);
132
+ const effectiveOwner = ownerId ?? owner.defaultOwner?.id ?? "";
133
+ if (owner.loading) return <Skeleton className="h-40 w-full" aria-label="Loading owners" />;
134
+ if (!effectiveOwner) return <ApiError error={owner.error ?? new Error("No team member found to own this deal.")} title="A deal needs an owner" onRetry={() => void owner.reload()} />;
135
+ return (
136
+ <div className="space-y-4">
137
+ <div className="space-y-1">
138
+ <label htmlFor="deal-owner" className="text-sm font-medium">Owner</label>
139
+ <select id="deal-owner" className="h-9 w-full rounded-md border bg-background px-2 text-sm" value={effectiveOwner} onChange={(e) => setOwnerId(e.target.value)}>
140
+ {owner.owners.map((o) => <option key={o.id} value={o.id}>{o.name}{o.email && o.email !== o.name ? ` (${o.email})` : ""}</option>)}
141
+ </select>
142
+ <p className="text-xs text-muted-foreground">{owner.source === "key" && ownerId === null ? "Defaults to you, the owner of this API key." : "Defaults to the first team member because this key has no person."}</p>
143
+ </div>
144
+ <ContactPicker value={contacts} onChange={setContacts} />
145
+ <label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={duplicate} onChange={(e) => setDuplicate(e.target.checked)} />Allow another deal for a company that already has one</label>
146
+ {contacts.length === 0 ? <p className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">Pick a contact to continue.</p> : (
147
+ <DealDetail key={contacts.map((c) => c.id).join(",")} ownerId={effectiveOwner} contactIds={contacts.map((c) => c.id)} pipelineId={pipelineId} allowDuplicate={duplicate} onSaved={onCreated} />
148
+ )}
149
+ </div>
150
+ );
151
+ }
152
+
153
+ /** Every stage of the pipeline with its type, probability, deal count and value: the read side of pipeline settings. */
154
+ function StageSummary({ stages, deals }: { stages: Stage[]; deals: Deal[] }) {
155
+ return (
156
+ <Table aria-label="Stages">
157
+ <TableHeader><TableRow><TableHead>Stage</TableHead><TableHead>Type</TableHead><TableHead className="text-right">Probability</TableHead><TableHead className="text-right">Deals</TableHead><TableHead className="text-right">Value</TableHead></TableRow></TableHeader>
158
+ <TableBody>
159
+ {stages.map((s) => {
160
+ const here = deals.filter((d) => d.stage_id === s.id);
161
+ return (
162
+ <TableRow key={s.id}>
163
+ <TableCell className="font-medium">{s.name}</TableCell>
164
+ <TableCell><StatusPill tone={stageTone(s)} dot>{stageKind(s) === "won" ? "Closed won" : stageKind(s) === "lost" ? "Closed lost" : "Open"}</StatusPill></TableCell>
165
+ <TableCell className="text-right tabular-nums">{s.probability == null ? "" : `${s.probability}%`}</TableCell>
166
+ <TableCell className="text-right tabular-nums">{here.length.toLocaleString()}</TableCell>
167
+ <TableCell className="text-right tabular-nums">{sumMoney(here)}</TableCell>
168
+ </TableRow>
169
+ );
170
+ })}
171
+ </TableBody>
172
+ </Table>
173
+ );
174
+ }
175
+
176
+ function DealRows({ deals, stages, selectedId, onOpen, renderRowActions }: { deals: Deal[]; stages: Stage[]; selectedId: string | null; onOpen: (d: Deal) => void; renderRowActions?: (d: Deal) => ReactNode }) {
177
+ const byId = new Map(stages.map((s) => [s.id, s]));
178
+ return (
179
+ <Table>
180
+ <TableHeader><TableRow><TableHead>Deal</TableHead><TableHead>Stage</TableHead><TableHead className="text-right">Amount</TableHead><TableHead>Owner</TableHead><TableHead>Close date</TableHead>{renderRowActions ? <TableHead /> : null}</TableRow></TableHeader>
181
+ <TableBody>
182
+ {deals.map((d) => {
183
+ const stage = d.stage_id ? byId.get(d.stage_id) : undefined;
184
+ return (
185
+ <TableRow key={d.id} data-state={selectedId === d.id ? "selected" : undefined} className="cursor-pointer" onClick={() => onOpen(d)}>
186
+ <TableCell><button type="button" className="font-medium hover:underline" onClick={(e) => { e.stopPropagation(); onOpen(d); }}>{d.name || "Untitled deal"}</button></TableCell>
187
+ <TableCell><StatusPill tone={stageTone(stage)} dot>{stage?.name ?? d.stage_name ?? "Unknown"}</StatusPill></TableCell>
188
+ <TableCell className="text-right tabular-nums">{formatMoney(d.amount, d.currency)}</TableCell>
189
+ <TableCell><span className="inline-flex items-center gap-2"><EntityAvatar name={d.owner_name ?? d.owner_email ?? "?"} size="sm" />{d.owner_name ?? d.owner_email ?? "No owner"}</span></TableCell>
190
+ <TableCell className="tabular-nums">{formatDate(d.close_date)}</TableCell>
191
+ {renderRowActions ? <TableCell onClick={(e) => e.stopPropagation()}>{renderRowActions(d)}</TableCell> : null}
192
+ </TableRow>
193
+ );
194
+ })}
195
+ </TableBody>
196
+ </Table>
197
+ );
198
+ }
199
+
200
+ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, onOpenQuote }: DealsScreenProps) {
201
+ const [viewParam, setViewParam] = useUrlState("tab", "board");
202
+ const [selectedId, setSelectedId] = useUrlState("id");
203
+ const [mode, setMode] = useUrlState("view");
204
+ const [pipelineParam, setPipelineParam] = useUrlState("pipeline", initialPipelineId ?? "");
205
+ const [searchText, setSearchText] = useState("");
206
+ const search = useDebouncedValue(searchText.trim());
207
+ const [creating, setCreating] = useState(false);
208
+ const [version, setVersion] = useState(0);
209
+ const view = asView(viewParam);
210
+ const pipelines = usePipelines();
211
+ const pipeline = pipelines.pipelines.find((p) => p.id === pipelineParam) ?? pipelines.defaultPipeline;
212
+ const data = usePipelineDeals(pipeline?.id, { search });
213
+ const stages = pipeline?.stages ?? [];
214
+ const refresh = () => { setVersion((v) => v + 1); void data.reload(); };
215
+
216
+ const stats = useMemo(() => {
217
+ const open = data.deals.filter((d) => stageKind(stages.find((s) => s.id === d.stage_id)) === "open");
218
+ const won = data.deals.filter((d) => stageKind(stages.find((s) => s.id === d.stage_id)) === "won");
219
+ const cur = data.deals[0]?.currency;
220
+ const weighted = (group: ReadonlyArray<Deal>) => Math.round(weightedPipeline(group, stages).value);
221
+ return [
222
+ { label: "Open deals", value: open.length.toLocaleString() },
223
+ { label: "Open value", value: sumMoney(open, undefined, cur) },
224
+ { label: "Weighted (estimate)", value: sumMoney(data.deals, weighted, cur), hint: "Open deals times stage probability" },
225
+ { label: "Won", value: sumMoney(won, undefined, cur), tone: "positive" as const },
226
+ ];
227
+ }, [data.deals, stages]);
228
+
229
+ const listed = data.deals.find((d) => d.id === selectedId);
230
+ // A deep link can point at a deal in another pipeline or hidden by the search: resolve it directly.
231
+ const fetched = useG8Query(async (signal) => dataOf<Deal>(await g8Get(`/deals/${encodeURIComponent(selectedId ?? "")}`, undefined, { signal })), [selectedId, version], { enabled: Boolean(selectedId) && !listed && !data.loading });
232
+ const selected = listed ?? (fetched.data?.id === selectedId ? fetched.data : undefined);
233
+ const open = (id: string) => setSelectedId(id);
234
+ const close = () => { setSelectedId(null); setMode(null); };
235
+ const showToolbar = view === "board" || view === "list";
236
+
237
+ const detail = selectedId ? (
238
+ <DetailPanel
239
+ title={selected?.name || "Deal"}
240
+ subtitle={selected ? [selected.stage_name, formatMoney(selected.amount, selected.currency)].join(" · ") : undefined}
241
+ badges={selected ? <StatusPill tone={stageTone(stages.find((s) => s.id === selected.stage_id))} dot>{selected.stage_name ?? "Open"}</StatusPill> : undefined}
242
+ actions={selected && renderDetailActions ? renderDetailActions(selected) : undefined}
243
+ onClose={close}
244
+ onExpand={mode === "full" ? undefined : () => setMode("full")}
245
+ onCollapse={mode === "full" ? () => setMode(null) : undefined}
246
+ tabs={[
247
+ { id: "details", label: "Details", content: <DealDetail key={selectedId} dealId={selectedId} onSaved={refresh} onDeleted={() => { close(); refresh(); }} /> },
248
+ { id: "contacts", label: "Contacts", content: <DealContactsTab key={selectedId} dealId={selectedId} /> },
249
+ { id: "quotes", label: "Quotes", content: <DealQuotesTab key={selectedId} dealId={selectedId} onOpenQuote={onOpenQuote} /> },
250
+ { id: "activity", label: "Activity", content: <ActivityTimeline entityType="deal" entityId={selectedId} /> },
251
+ ...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: selected ? t.render(selected) : null })),
252
+ ]}
253
+ />
254
+ ) : null;
255
+
256
+ const list = data.error ? <ApiError error={data.error} title="Could not load deals" onRetry={() => void data.reload()} /> : data.loading && data.deals.length === 0 ? <Skeleton className="h-48 w-full" aria-label="Loading deals" /> : data.deals.length === 0 ? (
257
+ <EmptyState icon={<Handshake className="size-8" />} title={search ? "No deals match your search" : "No deals yet"} description={search ? "Try a different name." : "Create your first deal. It lands in the first stage of the pipeline."} action={search ? undefined : <Button onClick={() => setCreating(true)}>New deal</Button>} />
258
+ ) : <DealRows deals={data.deals} stages={stages} selectedId={selectedId} onOpen={(d) => open(d.id)} renderRowActions={renderRowActions} />;
259
+
260
+ let body: ReactNode;
261
+ if (view === "forecast") body = <ForecastView pipelineId={pipeline?.id} />;
262
+ else if (view === "stages") body = pipeline ? <div className="space-y-6"><StageSummary stages={stages} deals={data.deals} /><StageEditor pipelineId={pipeline.id} onChanged={refresh} /></div> : <EmptyState title="No pipeline yet" description="Create a pipeline in graph8 first." />;
263
+ else body = <SplitView detailOpen={Boolean(selectedId)} onCloseDetail={close} expanded={mode === "full" && Boolean(selectedId)} onExpandedChange={(full) => setMode(full ? "full" : null)} detailWidth="lg" detail={detail} list={view === "board" ? <PipelineBoard key={`${pipeline?.id}-${version}`} pipelineId={pipeline?.id} search={search} onSelectDeal={(d) => open(d.id)} onChanged={() => void data.reload()} /> : list} />;
264
+
265
+ return (
266
+ <ScreenLayout
267
+ title="Deals"
268
+ description="Move deals through your pipeline and see what is likely to close."
269
+ icon={<Handshake className="size-5" />}
270
+ actions={<Button onClick={() => setCreating(true)}><Plus className="size-4" aria-hidden />New deal</Button>}
271
+ tabs={VIEWS}
272
+ activeTab={view}
273
+ onTabChange={(id) => { setViewParam(id); if (id !== "board" && id !== "list") setSearchText(""); }}
274
+ stats={stats}
275
+ toolbar={showToolbar ? (
276
+ <Toolbar
277
+ search={{ value: searchText, onChange: setSearchText, placeholder: "Search deals" }}
278
+ filters={pipelines.pipelines.length > 1 ? (
279
+ <select aria-label="Pipeline" className="h-9 rounded-md border bg-background px-2 text-sm" value={pipeline?.id ?? ""} onChange={(e) => { setPipelineParam(e.target.value); close(); }}>
280
+ {pipelines.pipelines.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
281
+ </select>
282
+ ) : undefined}
283
+ actions={toolbarActions}
284
+ />
285
+ ) : undefined}
286
+ >
287
+ {pipelines.error ? <ApiError error={pipelines.error} title="Could not load pipelines" onRetry={() => void pipelines.refetch()} /> : body}
288
+ <FormSheet open={creating} onOpenChange={setCreating} title="New deal" description="The deal lands in the first stage unless you pick another.">
289
+ {creating ? <NewDealForm pipelineId={pipeline?.id} onCreated={(deal) => { setCreating(false); refresh(); if (deal.id) { setViewParam("board"); open(deal.id); } }} /> : null}
290
+ </FormSheet>
291
+ </ScreenLayout>
292
+ );
293
+ }
@@ -29,6 +29,8 @@ export interface PipelineBoardProps {
29
29
  search?: string;
30
30
  ownerId?: string;
31
31
  onSelectDeal?: (deal: Deal) => void;
32
+ /** Called after a stage move was saved, so a screen can refresh its totals. */
33
+ onChanged?: () => void;
32
34
  className?: string;
33
35
  }
34
36
 
@@ -92,7 +94,7 @@ function CloseDialog({ stage, deal, onConfirm, onCancel }: { stage: Stage; deal:
92
94
  );
93
95
  }
94
96
 
95
- export function PipelineBoard({ pipelineId, search, ownerId, onSelectDeal, className }: PipelineBoardProps) {
97
+ export function PipelineBoard({ pipelineId, search, ownerId, onSelectDeal, onChanged, className }: PipelineBoardProps) {
96
98
  const pipelines = usePipelines();
97
99
  const pipeline = pipelines.pipelines.find((p) => p.id === pipelineId) ?? pipelines.defaultPipeline;
98
100
  const { deals, loading, error, reload, moveDeal, movingIds } = usePipelineDeals(pipeline?.id, { search, ownerId });
@@ -110,7 +112,7 @@ export function PipelineBoard({ pipelineId, search, ownerId, onSelectDeal, class
110
112
  if (deal.stage_id === stage.id || movingIds.has(deal.id)) return;
111
113
  setMoveError(null);
112
114
  if (stageKind(stage) !== "open" && !extra) { setClosing({ deal, stage }); return; }
113
- try { await moveDeal(deal, stage.id, extra); } catch (err) {
115
+ try { await moveDeal(deal, stage.id, extra); onChanged?.(); } catch (err) {
114
116
  const marker = closedStageMarker(err);
115
117
  setMoveError(marker ? new Error(`The API needs more before this deal can close (${marker.replace(/_/g, " ")}).`) : err);
116
118
  }