@graph8/sdk 0.362.0 → 0.373.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 +33 -10
- package/blocks/_foundation/README.md +78 -1
- package/blocks/_foundation/block.json +123 -6
- package/blocks/_foundation/components/api-error.tsx +9 -7
- package/blocks/_foundation/components/brand-provider.tsx +23 -0
- package/blocks/_foundation/components/confirm-spend-dialog.tsx +15 -8
- package/blocks/_foundation/components/confirmed-action.tsx +90 -0
- package/blocks/_foundation/components/connect-graph8.tsx +280 -0
- package/blocks/_foundation/components/empty-state.tsx +5 -2
- package/blocks/_foundation/components/list-select.tsx +112 -0
- package/blocks/_foundation/components/paging-notices.tsx +55 -0
- package/blocks/_foundation/components/screen/detail-panel.tsx +2 -1
- package/blocks/_foundation/components/screen/filter-controls.tsx +92 -0
- package/blocks/_foundation/components/screen/index.ts +2 -0
- package/blocks/_foundation/components/screen/record-link.tsx +76 -0
- package/blocks/_foundation/components/screen/split-view.tsx +1 -1
- package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
- package/blocks/_foundation/components/screen/time-series-chart.tsx +15 -97
- package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
- package/blocks/_foundation/hooks/use-all-pages.ts +54 -0
- package/blocks/_foundation/hooks/use-brand.ts +52 -0
- package/blocks/_foundation/hooks/use-g8-mutation.ts +23 -0
- package/blocks/_foundation/hooks/use-g8-query.ts +127 -1
- package/blocks/_foundation/hooks/use-load-more-list.ts +61 -0
- package/blocks/_foundation/hooks/use-paginated.ts +120 -0
- package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
- package/blocks/_foundation/hooks/use-url-filters.ts +93 -0
- package/blocks/_foundation/lib/g8-cache.ts +454 -0
- package/blocks/_foundation/lib/g8-client.ts +1 -1
- package/blocks/_foundation/lib/paging.ts +57 -0
- package/blocks/_foundation/lib/plural.ts +23 -0
- package/blocks/_foundation/lib/use-debounced-value.ts +13 -0
- package/blocks/_foundation/server/connect-route.ts +15 -0
- package/blocks/_foundation/server/connect.ts +127 -0
- package/blocks/_foundation/server/local-key.ts +70 -0
- package/blocks/_foundation/server/proxy.ts +103 -3
- package/blocks/_foundation/server/route.ts +16 -4
- package/blocks/agents/README.md +93 -2
- package/blocks/agents/block.json +343 -2
- package/blocks/agents/components/agent-activity.tsx +112 -0
- package/blocks/agents/components/agent-approvals.tsx +135 -0
- package/blocks/agents/components/agent-builder.tsx +29 -10
- package/blocks/agents/components/agent-conversations.tsx +143 -0
- package/blocks/agents/components/agent-knowledge-panel.tsx +4 -3
- package/blocks/agents/components/agent-operators.tsx +164 -0
- package/blocks/agents/components/agent-run-detail.tsx +119 -0
- package/blocks/agents/components/agent-runs.tsx +19 -10
- package/blocks/agents/components/agent-skills-panel.tsx +7 -7
- package/blocks/agents/components/agent-test-history.tsx +121 -0
- package/blocks/agents/components/agents-screen.tsx +226 -0
- package/blocks/agents/components/assistant-sessions.tsx +150 -0
- package/blocks/agents/components/duplicate-agent-button.tsx +42 -0
- package/blocks/agents/components/from-template-button.tsx +65 -0
- package/blocks/agents/components/knowledge-browser.tsx +107 -0
- package/blocks/agents/components/knowledge-detail.tsx +156 -0
- package/blocks/agents/components/knowledge-form.tsx +69 -0
- package/blocks/agents/components/name-dialog.tsx +32 -0
- package/blocks/agents/components/receptionist-button.tsx +46 -0
- package/blocks/agents/components/save-as-template-button.tsx +32 -0
- package/blocks/agents/components/skill-editor.tsx +147 -0
- package/blocks/agents/components/skill-library.tsx +111 -0
- package/blocks/agents/lib/agent-types.ts +7 -1
- package/blocks/agents/lib/facts.tsx +13 -0
- package/blocks/agents/lib/knowledge-utils.ts +49 -0
- package/blocks/agents/lib/load-more.tsx +141 -0
- package/blocks/agents/lib/skill-utils.ts +91 -0
- package/blocks/agents/recipes/create-agent-and-attach-skill.ts +1 -1
- package/blocks/analytics/README.md +13 -0
- package/blocks/analytics/block.json +45 -1
- package/blocks/analytics/components/analytics-screen.tsx +222 -0
- package/blocks/analytics/components/kpi-tiles.tsx +1 -1
- package/blocks/analytics/components/leaderboard.tsx +14 -7
- package/blocks/analytics/components/report-builder.tsx +38 -11
- package/blocks/analytics/components/report-detail.tsx +84 -0
- package/blocks/analytics/components/trend-chart.tsx +62 -0
- package/blocks/appointments/README.md +6 -0
- package/blocks/appointments/block.json +33 -2
- package/blocks/appointments/components/availability-editor.tsx +17 -5
- package/blocks/appointments/components/booking-page.tsx +2 -2
- package/blocks/appointments/components/event-type-editor.tsx +2 -1
- package/blocks/appointments/components/event-type-related.tsx +4 -2
- package/blocks/appointments/components/meeting-contact-links.tsx +49 -0
- package/blocks/appointments/components/meeting-detail.tsx +2 -2
- package/blocks/appointments/components/meeting-list.tsx +38 -18
- package/blocks/appointments/components/meetings-screen.tsx +7 -4
- package/blocks/appointments/components/record-meetings-tab.tsx +92 -0
- package/blocks/appointments/components/scheduling-screen.tsx +2 -1
- package/blocks/block.schema.json +18 -0
- package/blocks/block.types.ts +17 -0
- package/blocks/campaigns/README.md +41 -11
- package/blocks/campaigns/block.json +105 -42
- package/blocks/campaigns/components/campaign-assets.tsx +17 -6
- package/blocks/campaigns/components/campaign-launch-step.tsx +114 -26
- package/blocks/campaigns/components/campaign-list.tsx +7 -3
- package/blocks/campaigns/components/campaign-metrics.tsx +1 -1
- package/blocks/campaigns/components/campaign-wizard.tsx +52 -14
- package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
- package/blocks/campaigns/components/icp-detail.tsx +163 -0
- package/blocks/campaigns/components/icp-editor.tsx +22 -17
- package/blocks/campaigns/components/persona-detail.tsx +181 -0
- package/blocks/campaigns/components/persona-editor.tsx +19 -11
- package/blocks/campaigns/lib/campaigns-utils.ts +138 -5
- package/blocks/campaigns/recipes/attach-audience-and-launch.ts +16 -3
- package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +8 -4
- package/blocks/coach/README.md +10 -0
- package/blocks/coach/block.json +16 -1
- package/blocks/coach/components/call-scorecard.tsx +7 -7
- package/blocks/coach/components/coach-screen.tsx +222 -0
- package/blocks/company-intelligence/README.md +132 -0
- package/blocks/company-intelligence/block.json +318 -0
- package/blocks/company-intelligence/components/company-intelligence-screen.tsx +114 -0
- package/blocks/company-intelligence/components/company-intelligence-tab.tsx +88 -0
- package/blocks/company-intelligence/components/company-profile.tsx +68 -0
- package/blocks/company-intelligence/components/intelligence-sources.tsx +63 -0
- package/blocks/company-intelligence/components/report-detail.tsx +189 -0
- package/blocks/company-intelligence/components/research-reports.tsx +136 -0
- package/blocks/company-intelligence/components/research-run.tsx +161 -0
- package/blocks/company-intelligence/components/source-detail.tsx +117 -0
- package/blocks/company-intelligence/components/source-view.tsx +274 -0
- package/blocks/company-intelligence/components/wiki-document-detail.tsx +116 -0
- package/blocks/company-intelligence/components/wiki-documents.tsx +66 -0
- package/blocks/company-intelligence/hooks/use-intelligence.ts +173 -0
- package/blocks/company-intelligence/lib/intelligence-types.ts +230 -0
- package/blocks/company-intelligence/lib/safe-markdown.tsx +150 -0
- package/blocks/company-intelligence/recipes/read-competitors-and-news.ts +33 -0
- package/blocks/company-intelligence/recipes/research-your-website.ts +43 -0
- package/blocks/company-intelligence/recipes/write-the-research-reports.ts +54 -0
- package/blocks/copilot/README.md +13 -0
- package/blocks/copilot/block.json +14 -1
- package/blocks/copilot/components/copilot-memory.tsx +6 -2
- package/blocks/copilot/components/copilot-panel.tsx +14 -5
- package/blocks/copilot/components/copilot-screen.tsx +148 -0
- package/blocks/copilot/components/copilot-team-chat.tsx +13 -6
- package/blocks/crm/README.md +34 -0
- package/blocks/crm/block.json +107 -2
- package/blocks/crm/components/companies-screen.tsx +359 -0
- package/blocks/crm/components/company-detail.tsx +10 -8
- package/blocks/crm/components/company-table.tsx +11 -4
- package/blocks/crm/components/contact-detail.tsx +10 -7
- package/blocks/crm/components/contact-lookup-link.tsx +44 -0
- package/blocks/crm/components/contact-table.tsx +10 -3
- package/blocks/crm/components/contacts-screen.tsx +377 -0
- package/blocks/crm/components/dedupe-review.tsx +13 -6
- package/blocks/crm/components/import-wizard.tsx +7 -9
- package/blocks/crm/components/list-select.tsx +20 -0
- package/blocks/crm/components/notes-panel.tsx +4 -4
- package/blocks/crm/components/record-engagement-tab.tsx +103 -0
- package/blocks/crm/components/record-form.tsx +6 -4
- package/blocks/crm/components/task-list.tsx +4 -4
- package/blocks/crm/components/tasks-screen.tsx +399 -0
- package/blocks/crm/hooks/use-all-lists.ts +18 -0
- package/blocks/crm/hooks/use-custom-fields.ts +6 -1
- package/blocks/crm/hooks/use-page-query.ts +9 -1
- package/blocks/crm/lib/crm-types.ts +26 -2
- package/blocks/crm/lib/read-lists.ts +16 -0
- package/blocks/crm/lib/screen-parts.tsx +105 -0
- package/blocks/deals/README.md +24 -6
- package/blocks/deals/block.json +160 -5
- package/blocks/deals/components/close-deal-dialog.tsx +169 -0
- package/blocks/deals/components/deal-contacts-editor.tsx +134 -0
- package/blocks/deals/components/deal-detail.tsx +25 -13
- package/blocks/deals/components/deal-filters.tsx +65 -0
- package/blocks/deals/components/deal-line-items.tsx +120 -0
- package/blocks/deals/components/deal-notes-tab.tsx +97 -0
- package/blocks/deals/components/deal-related.tsx +31 -10
- package/blocks/deals/components/deal-tasks-tab.tsx +88 -0
- package/blocks/deals/components/deals-screen.tsx +66 -26
- package/blocks/deals/components/forecast-view.tsx +4 -4
- package/blocks/deals/components/pipeline-board.tsx +8 -6
- package/blocks/deals/components/pipeline-editor.tsx +132 -0
- package/blocks/deals/components/record-deals-tab.tsx +62 -0
- package/blocks/deals/components/stage-editor.tsx +5 -5
- package/blocks/deals/hooks/use-pipeline-deals.ts +21 -4
- package/blocks/deals/hooks/use-pipelines.ts +1 -1
- package/blocks/deals/lib/deal-extras.ts +156 -0
- package/blocks/dialer/README.md +45 -4
- package/blocks/dialer/block.json +84 -3
- package/blocks/dialer/components/call-contact-button.tsx +269 -0
- package/blocks/dialer/components/call-log-screen.tsx +208 -0
- package/blocks/dialer/components/call-log.tsx +23 -7
- package/blocks/dialer/components/call-queue.tsx +4 -4
- package/blocks/dialer/components/dialer-screen.tsx +361 -0
- package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
- package/blocks/dialer/components/disposition-panel.tsx +1 -1
- package/blocks/dialer/components/live-calls-table.tsx +7 -3
- package/blocks/dialer/components/record-calls-tab.tsx +101 -0
- package/blocks/dialer/components/session-start-form.tsx +61 -12
- package/blocks/dialer/components/softphone.tsx +4 -2
- package/blocks/enrichment/README.md +29 -7
- package/blocks/enrichment/block.json +49 -9
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +83 -29
- package/blocks/enrichment/components/email-verify-badge.tsx +31 -11
- package/blocks/enrichment/components/enrich-button.tsx +32 -9
- package/blocks/enrichment/components/enrichment-job-detail.tsx +78 -0
- package/blocks/enrichment/components/enrichment-screen.tsx +341 -0
- package/blocks/enrichment/components/waterfall-config.tsx +66 -17
- package/blocks/enrichment/lib/enrichment-types.ts +39 -0
- package/blocks/enrichment/recipes/bulk-enrich-and-poll.ts +15 -4
- package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
- package/blocks/forms/README.md +8 -0
- package/blocks/forms/block.json +139 -18
- package/blocks/forms/components/form-activity.tsx +71 -0
- package/blocks/forms/components/form-backfill.tsx +108 -0
- package/blocks/forms/components/form-builder.tsx +4 -2
- package/blocks/forms/components/form-embed.tsx +4 -2
- package/blocks/forms/components/form-groups.tsx +151 -0
- package/blocks/forms/components/form-mapping-editor.tsx +6 -9
- package/blocks/forms/components/form-route-history.tsx +72 -0
- package/blocks/forms/components/form-saved-views.tsx +81 -0
- package/blocks/forms/components/forms-screen.tsx +56 -19
- package/blocks/forms/components/more-rows.tsx +30 -0
- package/blocks/forms/components/submissions-export.tsx +67 -0
- package/blocks/forms/components/submissions-table.tsx +17 -6
- package/blocks/forms/lib/forms-extra.ts +67 -0
- package/blocks/inbox/README.md +32 -3
- package/blocks/inbox/block.json +29 -2
- package/blocks/inbox/components/inbox-list.tsx +7 -3
- package/blocks/inbox/components/inbox-screen.tsx +294 -27
- package/blocks/inbox/components/record-conversations-tab.tsx +154 -0
- package/blocks/inbox/components/reply-composer.tsx +65 -9
- package/blocks/inbox/components/tag-and-assign.tsx +4 -2
- package/blocks/inbox/components/thread-badge.tsx +2 -2
- package/blocks/inbox/components/thread-view.tsx +1 -1
- package/blocks/inbox/hooks/use-inbox-thread.ts +1 -1
- package/blocks/inbox/hooks/use-inbox-threads.ts +1 -0
- package/blocks/inbox/lib/inbox-types.ts +47 -1
- package/blocks/inbox/recipes/01-triage-and-reply.ts +12 -2
- package/blocks/integrations/README.md +63 -1
- package/blocks/integrations/block.json +234 -15
- package/blocks/integrations/components/ad-accounts.tsx +82 -0
- package/blocks/integrations/components/connection-health.tsx +59 -0
- package/blocks/integrations/components/crm-detail.tsx +119 -0
- package/blocks/integrations/components/crm-records.tsx +103 -0
- package/blocks/integrations/components/field-mappings.tsx +61 -0
- package/blocks/integrations/components/integration-list.tsx +13 -8
- package/blocks/integrations/components/integrations-screen.tsx +152 -0
- package/blocks/integrations/components/migration-panel.tsx +133 -0
- package/blocks/integrations/components/sugarcrm-connect.tsx +95 -0
- package/blocks/integrations/components/sync-status.tsx +11 -9
- package/blocks/integrations/components/sync-triggers.tsx +134 -0
- package/blocks/integrations/components/webhook-detail.tsx +179 -0
- package/blocks/integrations/components/webhook-manager.tsx +43 -25
- package/blocks/integrations/components/workspace-tools.tsx +127 -0
- package/blocks/integrations/lib/crm-utils.ts +39 -0
- package/blocks/integrations/lib/facts.tsx +13 -0
- package/blocks/integrations/lib/integrations-utils.ts +11 -1
- package/blocks/integrations/lib/load-more.tsx +141 -0
- package/blocks/intent/README.md +14 -0
- package/blocks/intent/block.json +27 -1
- package/blocks/intent/components/account-intent-score.tsx +5 -5
- package/blocks/intent/components/intent-feed.tsx +13 -10
- package/blocks/intent/components/intent-keyword-manager.tsx +16 -12
- package/blocks/intent/components/intent-screen.tsx +265 -0
- package/blocks/intent/components/keyword-detail.tsx +124 -0
- package/blocks/intent/lib/intent-utils.ts +4 -4
- package/blocks/lifecycle/block.json +13 -8
- package/blocks/lifecycle/components/account-related.tsx +8 -1
- package/blocks/lifecycle/components/churn-list.tsx +5 -2
- package/blocks/lifecycle/components/customer-table.tsx +1 -1
- package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
- package/blocks/lifecycle/components/lifecycle-screen.tsx +3 -1
- package/blocks/lifecycle/lib/lifecycle.ts +1 -1
- package/blocks/lists/README.md +20 -1
- package/blocks/lists/block.json +69 -3
- package/blocks/lists/components/add-company-people-to-list-button.tsx +73 -0
- package/blocks/lists/components/add-to-list-button.tsx +20 -9
- package/blocks/lists/components/list-picker.tsx +17 -3
- package/blocks/lists/components/list-table.tsx +12 -6
- package/blocks/lists/components/lists-screen.tsx +346 -0
- package/blocks/lists/components/record-lists-tab.tsx +99 -0
- package/blocks/lists/components/suppression-manager.tsx +33 -8
- package/blocks/lists/components/suppressions-screen.tsx +264 -0
- package/blocks/mailboxes/README.md +17 -0
- package/blocks/mailboxes/block.json +69 -17
- package/blocks/mailboxes/components/connect-mailbox.tsx +32 -21
- package/blocks/mailboxes/components/domain-detail.tsx +148 -0
- package/blocks/mailboxes/components/domain-purchase.tsx +20 -14
- package/blocks/mailboxes/components/mailbox-detail.tsx +157 -0
- package/blocks/mailboxes/components/mailbox-list.tsx +8 -3
- package/blocks/mailboxes/components/mailboxes-screen.tsx +228 -0
- package/blocks/mailboxes/components/warmup-health.tsx +24 -22
- package/blocks/mailboxes/lib/mailboxes-utils.ts +26 -5
- package/blocks/mailboxes/lib/use-provider-domains.ts +23 -0
- package/blocks/newsletter/README.md +12 -0
- package/blocks/newsletter/block.json +157 -24
- package/blocks/newsletter/components/issue-scheduler.tsx +4 -3
- package/blocks/newsletter/components/issue-versions.tsx +93 -0
- package/blocks/newsletter/components/more-rows.tsx +30 -0
- package/blocks/newsletter/components/newsletter-editor.tsx +1 -1
- package/blocks/newsletter/components/newsletter-list-syncs.tsx +121 -0
- package/blocks/newsletter/components/newsletter-screen.tsx +39 -14
- package/blocks/newsletter/components/newsletter-settings.tsx +211 -0
- package/blocks/newsletter/components/newsletter-stats.tsx +2 -2
- package/blocks/newsletter/components/newsletter-templates.tsx +133 -0
- package/blocks/newsletter/components/subscriber-table.tsx +5 -7
- package/blocks/newsletter/components/subscriber-timeline.tsx +65 -0
- package/blocks/newsletter/lib/newsletter-extra.ts +60 -0
- package/blocks/nurture/block.json +28 -14
- package/blocks/nurture/components/nurture-builder.tsx +13 -3
- package/blocks/nurture/components/nurture-enroll.tsx +12 -10
- package/blocks/nurture/components/nurture-form.tsx +27 -10
- package/blocks/nurture/components/nurture-screen.tsx +26 -11
- package/blocks/nurture/components/nurture-stats.tsx +5 -4
- package/blocks/nurture/lib/nurture-types.ts +1 -1
- package/blocks/objects/README.md +18 -1
- package/blocks/objects/block.json +42 -21
- package/blocks/objects/components/attribute-field-input.tsx +14 -1
- package/blocks/objects/components/object-not-found.tsx +3 -1
- package/blocks/objects/components/object-record-detail.tsx +10 -2
- package/blocks/objects/components/object-schema-editor.tsx +4 -2
- package/blocks/objects/components/object-table.tsx +13 -3
- package/blocks/objects/components/objects-screen.tsx +147 -0
- package/blocks/objects/components/record-roles-panel.tsx +15 -5
- package/blocks/objects/lib/object-types.ts +1 -1
- package/blocks/pages/README.md +14 -0
- package/blocks/pages/block.json +140 -23
- package/blocks/pages/components/brand-kit-editor.tsx +5 -2
- package/blocks/pages/components/content-library.tsx +9 -4
- package/blocks/pages/components/more-rows.tsx +30 -0
- package/blocks/pages/components/page-ai-tools.tsx +196 -0
- package/blocks/pages/components/page-analytics.tsx +176 -0
- package/blocks/pages/components/page-clone.tsx +99 -0
- package/blocks/pages/components/page-domains.tsx +167 -0
- package/blocks/pages/components/page-editor.tsx +1 -1
- package/blocks/pages/components/page-generation-status.tsx +48 -0
- package/blocks/pages/components/page-list.tsx +2 -1
- package/blocks/pages/components/page-tracking.tsx +137 -0
- package/blocks/pages/components/pages-screen.tsx +57 -16
- package/blocks/pages/components/seo-report.tsx +6 -1
- package/blocks/pages/lib/studio.ts +7 -0
- package/blocks/phone-numbers/README.md +74 -0
- package/blocks/phone-numbers/block.json +262 -0
- package/blocks/phone-numbers/components/a2p-registration.tsx +230 -0
- package/blocks/phone-numbers/components/assign-number.tsx +104 -0
- package/blocks/phone-numbers/components/number-search.tsx +239 -0
- package/blocks/phone-numbers/components/number-settings.tsx +134 -0
- package/blocks/phone-numbers/components/number-usage.tsx +97 -0
- package/blocks/phone-numbers/components/phone-number-detail.tsx +84 -0
- package/blocks/phone-numbers/components/phone-number-list.tsx +110 -0
- package/blocks/phone-numbers/components/phone-numbers-screen.tsx +201 -0
- package/blocks/phone-numbers/components/register-owned-number.tsx +98 -0
- package/blocks/phone-numbers/components/release-number.tsx +126 -0
- package/blocks/phone-numbers/hooks/use-phone-numbers.ts +58 -0
- package/blocks/phone-numbers/lib/phone-numbers-utils.ts +202 -0
- package/blocks/phone-numbers/recipes/01-search-and-buy-number.ts +49 -0
- package/blocks/phone-numbers/recipes/02-assign-number-to-rep.ts +25 -0
- package/blocks/phone-numbers/recipes/03-audit-numbers.ts +36 -0
- package/blocks/phone-numbers/recipes/04-register-text-messaging.ts +39 -0
- package/blocks/pickers/README.md +23 -0
- package/blocks/pickers/block.json +103 -0
- package/blocks/pickers/components/company-picker.tsx +90 -0
- package/blocks/pickers/components/contact-picker.tsx +131 -0
- package/blocks/pickers/components/member-picker.tsx +99 -0
- package/blocks/pickers/components/search-picker.tsx +98 -0
- package/blocks/pickers/hooks/use-current-member.ts +104 -0
- package/blocks/pickers/hooks/use-picker-pages.ts +73 -0
- package/blocks/pickers/lib/use-debounced.ts +13 -0
- package/blocks/quotes/README.md +13 -0
- package/blocks/quotes/block.json +65 -13
- package/blocks/quotes/components/create-quote-from-deal.tsx +48 -0
- package/blocks/quotes/components/product-picker.tsx +3 -1
- package/blocks/quotes/components/quote-builder.tsx +1 -1
- package/blocks/quotes/components/quote-lifecycle-actions.tsx +146 -0
- package/blocks/quotes/components/quote-list.tsx +5 -3
- package/blocks/quotes/components/quote-related.tsx +8 -4
- package/blocks/quotes/components/quote-viewer.tsx +7 -2
- package/blocks/quotes/components/record-quotes-tab.tsx +90 -0
- package/blocks/radar/README.md +10 -0
- package/blocks/radar/block.json +41 -1
- package/blocks/radar/components/competitor-battle-card.tsx +1 -1
- package/blocks/radar/components/competitor-detail.tsx +117 -0
- package/blocks/radar/components/competitor-tracker.tsx +3 -1
- package/blocks/radar/components/radar-overview.tsx +4 -4
- package/blocks/radar/components/radar-report.tsx +4 -2
- package/blocks/radar/components/radar-screen.tsx +214 -0
- package/blocks/radar/components/report-detail.tsx +104 -0
- package/blocks/search/README.md +10 -0
- package/blocks/search/block.json +16 -1
- package/blocks/search/components/filter-builder.tsx +3 -3
- package/blocks/search/components/prospect-results.tsx +5 -3
- package/blocks/search/components/prospect-search-screen.tsx +235 -0
- package/blocks/search/components/saved-searches.tsx +4 -1
- package/blocks/search/hooks/use-filter-options.ts +20 -6
- package/blocks/search/lib/search-types.ts +2 -0
- package/blocks/sequencer/README.md +55 -9
- package/blocks/sequencer/block.json +126 -19
- package/blocks/sequencer/components/add-to-sequence-button.tsx +82 -0
- package/blocks/sequencer/components/enroll-dialog.tsx +11 -5
- package/blocks/sequencer/components/record-sequences-tab.tsx +126 -0
- package/blocks/sequencer/components/sequence-builder.tsx +224 -60
- package/blocks/sequencer/components/sequence-detail.tsx +261 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +116 -0
- package/blocks/sequencer/components/sequence-list.tsx +34 -9
- package/blocks/sequencer/components/sequence-stats.tsx +11 -10
- package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
- package/blocks/sequencer/components/sequences-screen.tsx +162 -0
- package/blocks/sequencer/components/step-editor.tsx +14 -12
- package/blocks/sequencer/lib/sequencer-utils.ts +133 -10
- package/blocks/sequencer/recipes/create-multichannel-sequence.ts +33 -5
- package/blocks/sequencer/recipes/launch-and-enroll.ts +19 -2
- package/blocks/social-ads/README.md +19 -0
- package/blocks/social-ads/block.json +233 -1
- package/blocks/social-ads/components/ad-campaign-list.tsx +9 -5
- package/blocks/social-ads/components/ads-screen.tsx +152 -0
- package/blocks/social-ads/components/audience-sync-detail.tsx +194 -0
- package/blocks/social-ads/components/audience-sync.tsx +17 -9
- package/blocks/social-ads/components/campaign-ads.tsx +166 -0
- package/blocks/social-ads/components/campaign-detail.tsx +138 -0
- package/blocks/social-ads/components/campaign-editor.tsx +134 -0
- package/blocks/social-ads/components/creative-concepts.tsx +74 -0
- package/blocks/social-ads/components/creative-detail.tsx +155 -0
- package/blocks/social-ads/components/creative-gallery.tsx +35 -7
- package/blocks/social-ads/components/creative-revisions.tsx +57 -0
- package/blocks/social-ads/components/creative-tools.tsx +176 -0
- package/blocks/social-ads/components/post-composer.tsx +7 -3
- package/blocks/social-ads/components/post-detail.tsx +163 -0
- package/blocks/social-ads/components/social-ideas.tsx +64 -0
- package/blocks/social-ads/components/social-performance.tsx +80 -0
- package/blocks/social-ads/components/social-post-list.tsx +11 -3
- package/blocks/social-ads/components/social-screen.tsx +187 -0
- package/blocks/social-ads/lib/ads-extra.ts +67 -0
- package/blocks/surveys/README.md +91 -0
- package/blocks/surveys/block.json +299 -0
- package/blocks/surveys/components/account-nps.tsx +69 -0
- package/blocks/surveys/components/contact-survey-history.tsx +52 -0
- package/blocks/surveys/components/org-summary.tsx +82 -0
- package/blocks/surveys/components/sending-domains.tsx +113 -0
- package/blocks/surveys/components/survey-form.tsx +126 -0
- package/blocks/surveys/components/survey-responses.tsx +145 -0
- package/blocks/surveys/components/survey-results.tsx +72 -0
- package/blocks/surveys/components/survey-send-history.tsx +58 -0
- package/blocks/surveys/components/survey-send.tsx +162 -0
- package/blocks/surveys/components/survey-triggers.tsx +166 -0
- package/blocks/surveys/components/surveys-screen.tsx +234 -0
- package/blocks/surveys/lib/surveys-types.ts +228 -0
- package/blocks/surveys/recipes/create-an-nps-survey.ts +22 -0
- package/blocks/surveys/recipes/send-a-survey-and-follow-it.ts +27 -0
- package/blocks/surveys/recipes/survey-score-report.ts +27 -0
- package/blocks/visitors/README.md +10 -0
- package/blocks/visitors/block.json +25 -1
- package/blocks/visitors/components/snippet-install.tsx +21 -10
- package/blocks/visitors/components/traffic-chart.tsx +4 -4
- package/blocks/visitors/components/visitor-company-card.tsx +26 -16
- package/blocks/visitors/components/visitor-detail.tsx +105 -0
- package/blocks/visitors/components/visitor-feed.tsx +16 -7
- package/blocks/visitors/components/visitors-screen.tsx +166 -0
- package/blocks/webchat/README.md +10 -0
- package/blocks/webchat/block.json +21 -7
- package/blocks/webchat/components/chat-widget-config.tsx +8 -5
- package/blocks/webchat/components/conversation-list.tsx +8 -4
- package/blocks/webchat/components/webchat-screen.tsx +245 -0
- package/blocks/workbench/README.md +11 -0
- package/blocks/workbench/block.json +48 -1
- package/blocks/workbench/components/column-recipe-editor.tsx +5 -3
- package/blocks/workbench/components/run-progress.tsx +1 -1
- package/blocks/workbench/components/workbench-grid.tsx +10 -4
- package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
- package/blocks/workbench/components/workbench-screen.tsx +202 -0
- package/blocks/workbench/components/workbench-sources.tsx +194 -0
- package/blocks/workbench/components/workbench-table-list.tsx +14 -2
- package/blocks/workflows/README.md +13 -0
- package/blocks/workflows/block.json +28 -9
- package/blocks/workflows/components/run-history.tsx +31 -9
- package/blocks/workflows/components/trigger-config.tsx +3 -1
- package/blocks/workflows/components/workflow-editor.tsx +4 -2
- package/blocks/workflows/components/workflow-list.tsx +13 -6
- package/blocks/workflows/components/workflows-screen.tsx +193 -0
- package/dist/cli.js +3109 -1977
- package/dist/errors.d.mts +34 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +46 -0
- package/dist/errors.js.map +1 -0
- package/dist/errors.mjs +21 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/index.browser.js +11 -5
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +11 -5
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +164 -25
- package/dist/index.d.ts +164 -25
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +11 -5
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +10 -4
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +10 -4
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +142 -5
- package/dist/react.d.ts +142 -5
- package/dist/react.js +10 -4
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +10 -4
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/add.d.ts +11 -4
- package/dist/types/blocks/brand.d.ts +122 -0
- package/dist/types/blocks/compose.d.ts +53 -0
- package/dist/types/blocks/create.d.ts +27 -20
- package/dist/types/blocks/doctor.d.ts +6 -1
- package/dist/types/blocks/flows.d.ts +7 -1
- package/dist/types/blocks/navfile.d.ts +35 -0
- package/dist/types/blocks/orgBrand.d.ts +34 -0
- package/dist/types/blocks/paths.d.ts +4 -0
- package/dist/types/blocks/prefixes.d.ts +37 -0
- package/dist/types/blocks/registry.d.ts +17 -0
- package/dist/types/blocks/screens.d.ts +53 -0
- package/dist/types/cli.d.ts +5 -0
- package/dist/types/errors.d.ts +33 -0
- package/dist/types/http.d.ts +2 -18
- package/dist/types/version.d.ts +2 -2
- package/docs/agents.md +15 -4
- package/docs/api/README.md +2 -2
- package/docs/api/by-path.md +1 -0
- package/docs/api/desk.d.ts +98 -2
- package/docs/api/desk.md +123 -1
- package/docs/api/inbox.d.ts +10 -2
- package/docs/api/inbox.md +10 -2
- package/docs/api/seo.d.ts +19 -1
- package/docs/api/seo.md +24 -1
- package/package.json +16 -2
- package/stacks/6sense/pages/companies.tsx.tmpl +21 -0
- package/stacks/6sense/pages/contacts.tsx.tmpl +19 -0
- package/stacks/6sense/pages/tasks.tsx.tmpl +19 -0
- package/stacks/6sense.json +55 -17
- package/stacks/apollo/pages/calls.tsx.tmpl +23 -0
- package/stacks/apollo/pages/companies.tsx.tmpl +33 -0
- package/stacks/apollo/pages/contacts.tsx.tmpl +51 -0
- package/stacks/apollo/pages/deals.tsx.tmpl +17 -0
- package/stacks/apollo/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/apollo/pages/lists.tsx.tmpl +12 -0
- package/stacks/apollo/pages/meetings.tsx.tmpl +13 -0
- package/stacks/apollo/pages/sequences.tsx.tmpl +12 -0
- package/stacks/apollo/pages/tasks.tsx.tmpl +20 -0
- package/stacks/apollo.json +147 -37
- package/stacks/calendly/pages/companies.tsx.tmpl +19 -0
- package/stacks/calendly/pages/contacts.tsx.tmpl +21 -0
- package/stacks/calendly/pages/meetings.tsx.tmpl +13 -0
- package/stacks/calendly/pages/tasks.tsx.tmpl +19 -0
- package/stacks/calendly.json +64 -0
- package/stacks/clay/pages/companies.tsx.tmpl +31 -0
- package/stacks/clay/pages/contacts.tsx.tmpl +31 -0
- package/stacks/clay/pages/lists.tsx.tmpl +12 -0
- package/stacks/clay/pages/tasks.tsx.tmpl +19 -0
- package/stacks/clay.json +73 -27
- package/stacks/gong/pages/calls.tsx.tmpl +23 -0
- package/stacks/gong/pages/companies.tsx.tmpl +22 -0
- package/stacks/gong/pages/contacts.tsx.tmpl +32 -0
- package/stacks/gong/pages/lists.tsx.tmpl +12 -0
- package/stacks/gong/pages/meetings.tsx.tmpl +13 -0
- package/stacks/gong/pages/tasks.tsx.tmpl +19 -0
- package/stacks/gong.json +98 -35
- package/stacks/hootsuite/pages/companies.tsx.tmpl +22 -0
- package/stacks/hootsuite/pages/contacts.tsx.tmpl +28 -0
- package/stacks/hootsuite/pages/lists.tsx.tmpl +12 -0
- package/stacks/hootsuite/pages/tasks.tsx.tmpl +19 -0
- package/stacks/hootsuite.json +104 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +26 -0
- package/stacks/hubspot/pages/contacts.tsx.tmpl +45 -0
- package/stacks/hubspot/pages/deals.tsx.tmpl +20 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +103 -0
- package/stacks/hubspot/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/hubspot/pages/lists.tsx.tmpl +12 -0
- package/stacks/hubspot/pages/meetings.tsx.tmpl +13 -0
- package/stacks/hubspot/pages/quotes.tsx.tmpl +11 -0
- package/stacks/hubspot/pages/sequences.tsx.tmpl +12 -0
- package/stacks/hubspot/pages/tasks.tsx.tmpl +20 -0
- package/stacks/hubspot.json +181 -64
- package/stacks/intercom/pages/companies.tsx.tmpl +19 -0
- package/stacks/intercom/pages/contacts.tsx.tmpl +21 -0
- package/stacks/intercom/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/intercom/pages/tasks.tsx.tmpl +19 -0
- package/stacks/intercom.json +52 -20
- package/stacks/mailchimp/pages/companies.tsx.tmpl +22 -0
- package/stacks/mailchimp/pages/contacts.tsx.tmpl +28 -0
- package/stacks/mailchimp/pages/lists.tsx.tmpl +12 -0
- package/stacks/mailchimp/pages/tasks.tsx.tmpl +19 -0
- package/stacks/mailchimp.json +68 -34
- package/stacks/outreach/pages/calls.tsx.tmpl +23 -0
- package/stacks/outreach/pages/companies.tsx.tmpl +22 -0
- package/stacks/outreach/pages/contacts.tsx.tmpl +46 -0
- package/stacks/outreach/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/outreach/pages/lists.tsx.tmpl +12 -0
- package/stacks/outreach/pages/meetings.tsx.tmpl +13 -0
- package/stacks/outreach/pages/sequences.tsx.tmpl +12 -0
- package/stacks/outreach/pages/tasks.tsx.tmpl +19 -0
- package/stacks/outreach.json +106 -25
- package/stacks/pipedrive/pages/companies.tsx.tmpl +23 -0
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +27 -0
- package/stacks/pipedrive/pages/deals.tsx.tmpl +20 -0
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +103 -0
- package/stacks/pipedrive/pages/meetings.tsx.tmpl +13 -0
- package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
- package/stacks/pipedrive/pages/tasks.tsx.tmpl +20 -0
- package/stacks/pipedrive.json +117 -0
- package/stacks/salesforce/pages/companies.tsx.tmpl +26 -0
- package/stacks/salesforce/pages/contacts.tsx.tmpl +32 -0
- package/stacks/salesforce/pages/deals.tsx.tmpl +20 -0
- package/stacks/salesforce/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/salesforce/pages/lists.tsx.tmpl +12 -0
- package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
- package/stacks/salesforce/pages/tasks.tsx.tmpl +20 -0
- package/stacks/salesforce.json +85 -31
- package/stacks/zoominfo/pages/companies.tsx.tmpl +31 -0
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +31 -0
- package/stacks/zoominfo/pages/lists.tsx.tmpl +12 -0
- package/stacks/zoominfo/pages/tasks.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +115 -0
- package/styles/styles.css +1 -1
- package/templates/next-app/README.md.tmpl +40 -9
- package/templates/next-app/app/globals.css.tmpl +349 -64
- package/templates/next-app/app/layout.tsx.tmpl +7 -2
- package/templates/next-app/components/app-shell.tsx.tmpl +104 -88
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +4 -0
- package/templates/next-app/dot-gitignore.tmpl +2 -0
- package/templates/next-app/package.json.tmpl +1 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DealContactsEditor: the buying committee of one deal. Add a contact (with an optional role), change a role, make
|
|
5
|
+
* a contact the primary one, and remove a contact. The list itself is DealContactsTab; open a row for the full CRM record.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in order:
|
|
8
|
+
* 1. GET /deals/{id} (deals.getDeal) read - the contacts, roles and primary (via DealContactsTab)
|
|
9
|
+
* 2. GET /contacts?name= (crm contacts.listContacts) read - find a contact to add
|
|
10
|
+
* 3. POST /deals/{id}/contacts { person_id, role } (deals.addDealContact) write - the contact must work at the deal's company
|
|
11
|
+
* 4. PUT /deals/{id}/contacts/{contact_id} { role } (deals.setDealContactRole) write - null clears the role
|
|
12
|
+
* 5. PUT /deals/{id}/contacts/{person_id}/primary (deals.setPrimaryDealContact) write
|
|
13
|
+
* 6. DELETE /deals/{id}/contacts/{person_id} (deals.removeDealContact) DESTRUCTIVE -> ConfirmSpendDialog first
|
|
14
|
+
* Roles are champion, decision_maker, influencer, blocker, coach and end_user. A deal keeps at least one contact:
|
|
15
|
+
* the API refuses to remove the last one and the message is shown.
|
|
16
|
+
*/
|
|
17
|
+
import { useState } from "react";
|
|
18
|
+
import { Search } from "lucide-react";
|
|
19
|
+
import { Button, Input } from "@graph8/sdk/ui";
|
|
20
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
21
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
22
|
+
import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
|
|
23
|
+
import { g8Delete, g8Get, g8Post, g8Put } from "@/g8/lib/g8-client";
|
|
24
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
25
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
26
|
+
import { useDebouncedValue } from "@/g8/crm/hooks/use-debounced";
|
|
27
|
+
import { contactName, type ContactRecord } from "@/g8/crm/lib/crm-types";
|
|
28
|
+
import { dataOf, type Deal, type DealContact } from "../lib/deals-types";
|
|
29
|
+
import { CONTACT_ROLES, roleLabel } from "../lib/deal-extras";
|
|
30
|
+
import { DealContactsTab } from "./deal-related";
|
|
31
|
+
|
|
32
|
+
export interface DealContactsEditorProps {
|
|
33
|
+
dealId: string;
|
|
34
|
+
/** Called after any change, so a screen can refresh its numbers. */
|
|
35
|
+
onChanged?: () => void;
|
|
36
|
+
/** Open a contact in your contacts screen (see DealContactsTab). */
|
|
37
|
+
onOpenContact?: (contactId: number) => void;
|
|
38
|
+
/** Open a contact's company in your companies screen. */
|
|
39
|
+
onOpenCompany?: (companyId: number) => void;
|
|
40
|
+
className?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function AddContact({ dealId, existing, onAdded }: { dealId: string; existing: ReadonlySet<number>; onAdded: () => void }) {
|
|
44
|
+
const [text, setText] = useState("");
|
|
45
|
+
const [role, setRole] = useState("");
|
|
46
|
+
const term = useDebouncedValue(text.trim());
|
|
47
|
+
const found = useG8Query(async (signal) => (await g8Get<ContactRecord[]>("/contacts", { name: term, limit: 8, page: 1 }, { signal })).data ?? [], [term], { enabled: term.length > 1 });
|
|
48
|
+
const add = useG8Mutation(
|
|
49
|
+
(c: ContactRecord) => g8Post(`/deals/${encodeURIComponent(dealId)}/contacts`, { person_id: String(c.id), ...(role ? { role } : {}) }),
|
|
50
|
+
{ invalidates: [["deals", "detail", dealId], ["deals", "list"]], onSuccess: () => { setText(""); setRole(""); onAdded(); } },
|
|
51
|
+
);
|
|
52
|
+
const choices = (found.data ?? []).filter((c) => !existing.has(c.id));
|
|
53
|
+
return (
|
|
54
|
+
<div className="space-y-2">
|
|
55
|
+
<div className="flex flex-wrap items-end gap-2">
|
|
56
|
+
<div className="relative min-w-48 flex-1">
|
|
57
|
+
<Search className="pointer-events-none absolute left-2 top-2.5 size-4 text-muted-foreground" aria-hidden />
|
|
58
|
+
<Input aria-label="Add a contact to this deal" className="pl-8" placeholder="Search contacts to add" value={text} onChange={(e) => setText(e.target.value)} />
|
|
59
|
+
</div>
|
|
60
|
+
<select aria-label="Role for the new contact" className="h-9 rounded-md border bg-background px-2 text-sm" value={role} onChange={(e) => setRole(e.target.value)}>
|
|
61
|
+
<option value="">No role</option>
|
|
62
|
+
{CONTACT_ROLES.map((r) => <option key={r} value={r}>{roleLabel(r)}</option>)}
|
|
63
|
+
</select>
|
|
64
|
+
</div>
|
|
65
|
+
{add.error ? <ApiError error={add.error} title="Could not add the contact" /> : null}
|
|
66
|
+
{found.error ? <ApiError error={found.error} title="Could not search contacts" /> : null}
|
|
67
|
+
{term.length > 1 && !found.loading && !found.error && choices.length === 0 ? <p className="text-xs text-muted-foreground">No other contacts match.</p> : null}
|
|
68
|
+
<ul className="divide-y rounded-md border empty:hidden" aria-label="Contacts to add">
|
|
69
|
+
{choices.map((c) => (
|
|
70
|
+
<li key={c.id}>
|
|
71
|
+
<button type="button" className="flex w-full items-center gap-2 p-2 text-left text-sm hover:bg-muted disabled:opacity-60" disabled={add.loading} aria-label={`Add ${contactName(c)}`} onClick={() => void add.mutate(c)}>
|
|
72
|
+
<EntityAvatar name={contactName(c)} size="sm" />
|
|
73
|
+
<span className="font-medium">{contactName(c)}</span>
|
|
74
|
+
<span className="text-xs text-muted-foreground">{[c.job_title, c.company_name].filter(Boolean).join(" at ")}</span>
|
|
75
|
+
</button>
|
|
76
|
+
</li>
|
|
77
|
+
))}
|
|
78
|
+
</ul>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function DealContactsEditor({ dealId, onChanged, onOpenContact, onOpenCompany, className }: DealContactsEditorProps) {
|
|
84
|
+
const base = `/deals/${encodeURIComponent(dealId)}/contacts`;
|
|
85
|
+
const [busyId, setBusyId] = useState<number | null>(null);
|
|
86
|
+
const [removing, setRemoving] = useState<DealContact | null>(null);
|
|
87
|
+
// Same query key as DealContactsTab: one request, and the ids already on the deal are hidden from the search results.
|
|
88
|
+
const deal = useG8Query(async (signal) => dataOf<Deal>(await g8Get(`/deals/${encodeURIComponent(dealId)}`, undefined, { signal })), [dealId], { key: ["deals", "detail", dealId] });
|
|
89
|
+
const existing = new Set((deal.data?.contacts ?? []).map((c) => c.id));
|
|
90
|
+
const done = () => onChanged?.();
|
|
91
|
+
const refresh = [["deals", "detail", dealId], ["deals", "list"]] as const;
|
|
92
|
+
|
|
93
|
+
const role = useG8Mutation(async (v: { contact: DealContact; role: string }) => {
|
|
94
|
+
setBusyId(v.contact.id);
|
|
95
|
+
try { await g8Put(`${base}/${v.contact.id}`, { role: v.role || null }); } finally { setBusyId(null); }
|
|
96
|
+
}, { invalidates: refresh, onSuccess: done });
|
|
97
|
+
const primary = useG8Mutation(async (c: DealContact) => {
|
|
98
|
+
setBusyId(c.id);
|
|
99
|
+
try { await g8Put(`${base}/${encodeURIComponent(String(c.id))}/primary`); } finally { setBusyId(null); }
|
|
100
|
+
}, { invalidates: refresh, onSuccess: done });
|
|
101
|
+
const remove = useG8Mutation(async (c: DealContact) => {
|
|
102
|
+
setBusyId(c.id);
|
|
103
|
+
try { await g8Delete(`${base}/${encodeURIComponent(String(c.id))}`); } finally { setBusyId(null); }
|
|
104
|
+
}, { invalidates: refresh, onSuccess: () => { setRemoving(null); done(); }, onError: () => setRemoving(null) });
|
|
105
|
+
const failure = role.error ?? primary.error ?? remove.error;
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div className={`space-y-4 ${className ?? ""}`}>
|
|
109
|
+
<AddContact dealId={dealId} existing={existing} onAdded={done} />
|
|
110
|
+
{failure ? <ApiError error={failure} title="That contact change failed" /> : null}
|
|
111
|
+
<DealContactsTab
|
|
112
|
+
dealId={dealId}
|
|
113
|
+
onOpenContact={onOpenContact}
|
|
114
|
+
onOpenCompany={onOpenCompany}
|
|
115
|
+
renderContactActions={(c, deal) => {
|
|
116
|
+
const isPrimary = deal.primary_contact?.id === c.id;
|
|
117
|
+
const name = c.name || c.email || `contact ${c.id}`;
|
|
118
|
+
const options = c.role && !CONTACT_ROLES.includes(c.role as (typeof CONTACT_ROLES)[number]) ? [c.role, ...CONTACT_ROLES] : CONTACT_ROLES;
|
|
119
|
+
return (
|
|
120
|
+
<span className="flex shrink-0 items-center gap-1">
|
|
121
|
+
<select aria-label={`Role of ${name}`} className="h-8 rounded-md border bg-background px-1 text-xs" disabled={busyId === c.id} value={c.role ?? ""} onChange={(e) => void role.mutate({ contact: c, role: e.target.value })}>
|
|
122
|
+
<option value="">No role</option>
|
|
123
|
+
{options.map((r) => <option key={r} value={r}>{roleLabel(r)}</option>)}
|
|
124
|
+
</select>
|
|
125
|
+
<Button size="sm" variant="ghost" disabled={isPrimary || busyId === c.id} aria-label={`Make ${name} primary`} onClick={() => void primary.mutate(c)}>{isPrimary ? "Primary" : "Make primary"}</Button>
|
|
126
|
+
<Button size="sm" variant="ghost" disabled={busyId === c.id} aria-label={`Remove ${name}`} onClick={() => setRemoving(c)}>Remove</Button>
|
|
127
|
+
</span>
|
|
128
|
+
);
|
|
129
|
+
}}
|
|
130
|
+
/>
|
|
131
|
+
<ConfirmSpendDialog open={removing != null} tier="destructive" title={`Remove ${removing?.name || removing?.email || "this contact"} from the deal?`} description="The contact stays in your CRM; only the link to this deal is removed." confirmLabel="Remove contact" affected={1} confirming={remove.loading} onConfirm={() => removing && void remove.mutate(removing)} onCancel={() => setRemoving(null)} />
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* 5. DELETE /deals/{id} (deals.deleteDeal) DESTRUCTIVE -> ConfirmSpendDialog first
|
|
13
13
|
* Compose notes, tasks and the activity timeline from the crm block under `children`.
|
|
14
14
|
*/
|
|
15
|
-
import { useEffect, useState, type ReactNode } from "react";
|
|
15
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
16
16
|
import { Button, Input, Label, Skeleton, Textarea } from "@graph8/sdk/ui";
|
|
17
17
|
import { ApiError } from "@/g8/components/api-error";
|
|
18
18
|
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
@@ -21,6 +21,7 @@ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
|
21
21
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
22
22
|
import { usePipelines } from "../hooks/use-pipelines";
|
|
23
23
|
import { dataOf, formatMoney, type Deal } from "../lib/deals-types";
|
|
24
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
24
25
|
|
|
25
26
|
export interface DealDetailProps {
|
|
26
27
|
/** Edit this deal; omit to create one. */
|
|
@@ -53,6 +54,7 @@ function fromDeal(d: Deal): Form {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
export function DealDetail({ dealId, ownerId, contactIds = [], pipelineId, allowDuplicate, onSaved, onDeleted, children, className }: DealDetailProps) {
|
|
57
|
+
const brand = useBrand();
|
|
56
58
|
const { pipelines, defaultPipeline } = usePipelines();
|
|
57
59
|
const [form, setForm] = useState<Form>(EMPTY);
|
|
58
60
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
@@ -60,14 +62,23 @@ export function DealDetail({ dealId, ownerId, contactIds = [], pipelineId, allow
|
|
|
60
62
|
const [createdId, setCreatedId] = useState<string | null>(null);
|
|
61
63
|
const [createdRevision, setCreatedRevision] = useState<number | null>(null);
|
|
62
64
|
const effectiveId = dealId ?? createdId ?? undefined;
|
|
63
|
-
const deal = useG8Query(async (signal) => dataOf<Deal>(await g8Get(`/deals/${encodeURIComponent(dealId as string)}`, undefined, { signal })), [dealId], {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
const deal = useG8Query(async (signal) => dataOf<Deal>(await g8Get(`/deals/${encodeURIComponent(dealId as string)}`, undefined, { signal })), [dealId], {
|
|
66
|
+
enabled: Boolean(dealId),
|
|
67
|
+
key: ["deals", "detail", dealId ?? null],
|
|
68
|
+
// The form is seeded from this record: never swap it under someone typing on a window refocus.
|
|
69
|
+
refetchOnFocus: false,
|
|
70
|
+
});
|
|
71
|
+
const history = useG8Query(async (signal) => dataOf<{ items?: HistoryItem[] }>(await g8Get(`/deals/${encodeURIComponent(dealId as string)}/history`, { limit: 20 }, { signal })).items ?? [], [dealId], { enabled: Boolean(dealId), key: ["deals", "history", dealId ?? null] });
|
|
72
|
+
// Unsaved edits win over a refreshed record (focus, invalidation from a board move, refetch): only an
|
|
73
|
+
// untouched form follows the server. Saving or switching deals makes the form untouched again.
|
|
74
|
+
const dirty = useRef(false);
|
|
75
|
+
// A different deal (or create mode again) must not show the previous one's fields. Declared BEFORE the
|
|
76
|
+
// seeding effect so that, when the record is already cached, the reset runs first and the seed wins.
|
|
77
|
+
useEffect(() => { dirty.current = false; setForm(EMPTY); setCreatedId(null); setCreatedRevision(null); setConfirmDelete(false); }, [dealId]);
|
|
78
|
+
useEffect(() => { if (deal.data && !dirty.current) setForm(fromDeal(deal.data)); }, [deal.data]);
|
|
68
79
|
|
|
69
80
|
const pipeline = pipelines.find((p) => p.id === (deal.data?.pipeline_id ?? pipelineId)) ?? defaultPipeline;
|
|
70
|
-
const set = <K extends keyof Form>(k: K, v: Form[K]) => setForm((f) => ({ ...f, [k]: v }));
|
|
81
|
+
const set = <K extends keyof Form>(k: K, v: Form[K]) => { dirty.current = true; setForm((f) => ({ ...f, [k]: v })); };
|
|
71
82
|
|
|
72
83
|
const save = useG8Mutation(async () => {
|
|
73
84
|
const body = {
|
|
@@ -81,11 +92,12 @@ export function DealDetail({ dealId, ownerId, contactIds = [], pipelineId, allow
|
|
|
81
92
|
? await g8Patch(`/deals/${encodeURIComponent(effectiveId)}`, { ...body, expected_revision: (dealId ? deal.data?.revision : createdRevision) ?? undefined })
|
|
82
93
|
: 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 });
|
|
83
94
|
return dataOf<Deal>(env);
|
|
84
|
-
}, { onSuccess: (saved) => { if (!dealId && saved.id) { setCreatedId(saved.id); setCreatedRevision(saved.revision ?? null); } if (dealId) { void deal.refetch(); void history.refetch(); } onSaved?.(saved); } });
|
|
85
|
-
const remove = useG8Mutation(async () => { await g8Delete(`/deals/${encodeURIComponent(dealId as string)}`); }, { onSuccess: () => { setConfirmDelete(false); onDeleted?.(dealId as string); }, onError: () => setConfirmDelete(false) });
|
|
95
|
+
}, { invalidates: [["deals", "list"], ["deals", "history"]], onSuccess: (saved) => { dirty.current = false; if (!dealId && saved.id) { setCreatedId(saved.id); setCreatedRevision(saved.revision ?? null); } if (dealId) { void deal.refetch(); void history.refetch(); } onSaved?.(saved); } });
|
|
96
|
+
const remove = useG8Mutation(async () => { await g8Delete(`/deals/${encodeURIComponent(dealId as string)}`); }, { invalidates: [["deals", "list"]], onSuccess: () => { setConfirmDelete(false); onDeleted?.(dealId as string); }, onError: () => setConfirmDelete(false) });
|
|
86
97
|
|
|
87
98
|
if (deal.error) return <ApiError error={deal.error} title="Could not load the deal" onRetry={() => void deal.refetch()} />;
|
|
88
|
-
|
|
99
|
+
// The hook keeps the previous deal's data while the next loads: wait for THIS deal, so a blank form is never shown (or saved) over it.
|
|
100
|
+
if (dealId && (!deal.data || String(deal.data.id) !== String(dealId))) return <Skeleton className="h-56 w-full" aria-label="Loading deal" />;
|
|
89
101
|
const d = deal.data;
|
|
90
102
|
const canCreate = Boolean(ownerId);
|
|
91
103
|
|
|
@@ -105,7 +117,7 @@ export function DealDetail({ dealId, ownerId, contactIds = [], pipelineId, allow
|
|
|
105
117
|
</div>
|
|
106
118
|
</div>
|
|
107
119
|
<div className="space-y-1"><Label htmlFor="deal-desc">Description</Label><Textarea id="deal-desc" rows={3} value={form.description} onChange={(e) => set("description", e.target.value)} /></div>
|
|
108
|
-
{!dealId && !canCreate ? <p className="text-sm text-muted-foreground">Pass ownerId (a
|
|
120
|
+
{!dealId && !canCreate ? <p className="text-sm text-muted-foreground">Pass ownerId (a {brand.linkLabel} user id) to create deals: the API requires an owner.</p> : null}
|
|
109
121
|
{save.error ? <ApiError error={save.error} title="Could not save the deal" /> : null}
|
|
110
122
|
{remove.error ? <ApiError error={remove.error} title="Could not delete the deal" /> : null}
|
|
111
123
|
<div className="flex justify-between">
|
|
@@ -116,14 +128,14 @@ export function DealDetail({ dealId, ownerId, contactIds = [], pipelineId, allow
|
|
|
116
128
|
|
|
117
129
|
{d ? (
|
|
118
130
|
<section aria-label="Contacts" className="space-y-1 text-sm">
|
|
119
|
-
<h3 className="font-
|
|
131
|
+
<h3 className="font-medium">Contacts ({d.contact_count ?? d.contacts?.length ?? 0})</h3>
|
|
120
132
|
{(d.contacts ?? []).map((c) => <p key={c.id}>{c.name ?? c.email}{c.role ? ` · ${c.role}` : ""}{d.primary_contact?.id === c.id ? " (primary)" : ""}</p>)}
|
|
121
133
|
<p className="text-muted-foreground">Owner: {d.owner_name ?? d.owner_email ?? "none"} · {formatMoney(d.amount, d.currency)}</p>
|
|
122
134
|
</section>
|
|
123
135
|
) : null}
|
|
124
136
|
{dealId ? (
|
|
125
137
|
<section aria-label="Stage history" className="space-y-1 text-sm">
|
|
126
|
-
<h3 className="font-
|
|
138
|
+
<h3 className="font-medium">Stage history</h3>
|
|
127
139
|
{history.error ? <ApiError error={history.error} title="Could not load history" onRetry={() => void history.refetch()} /> : null}
|
|
128
140
|
{(history.data ?? []).length === 0 && !history.loading ? <p className="text-muted-foreground">No stage changes yet.</p> : null}
|
|
129
141
|
{(history.data ?? []).map((h, i) => <p key={i}>{h.from_stage_name ?? "New"} to {h.to_stage_name ?? "unknown"} <span className="text-muted-foreground">{h.changed_at ?? h.created_at ?? ""}</span></p>)}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DealFilterBar: the deal list filters beside the search box: owner, stage, outcome, stale, activity, close-date
|
|
5
|
+
* range and "no contact". It only edits a DealFilterState; `filterQuery(state)` turns that into the `usePipelineDeals`
|
|
6
|
+
* filters (GET /deals owner_id, stage_id, outcome, stale_before, has_activity, close_after, close_before, has_contact).
|
|
7
|
+
*
|
|
8
|
+
* SDK operations: none directly (the owners come from the deals block's useDefaultOwner: GET /team-members).
|
|
9
|
+
* Dates are date-only (YYYY-MM-DD); the "to" date is inclusive, the API's close_before is exclusive, so
|
|
10
|
+
* `filterQuery` adds one day in UTC.
|
|
11
|
+
*/
|
|
12
|
+
import { Button } from "@graph8/sdk/ui";
|
|
13
|
+
import { NO_FILTERS, filtersActive, type DealFilterState } from "../lib/deal-extras";
|
|
14
|
+
import type { Stage } from "../lib/deals-types";
|
|
15
|
+
import type { OwnerOption } from "../hooks/use-default-owner";
|
|
16
|
+
|
|
17
|
+
export interface DealFilterBarProps {
|
|
18
|
+
value: DealFilterState;
|
|
19
|
+
onChange: (next: DealFilterState) => void;
|
|
20
|
+
stages: Stage[];
|
|
21
|
+
owners: OwnerOption[];
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SELECT = "h-9 rounded-md border bg-background px-2 text-sm";
|
|
26
|
+
const DATE = "h-9 rounded-md border bg-background px-2 text-sm";
|
|
27
|
+
|
|
28
|
+
export function DealFilterBar({ value, onChange, stages, owners, className }: DealFilterBarProps) {
|
|
29
|
+
const set = <K extends keyof DealFilterState>(key: K, v: DealFilterState[K]) => onChange({ ...value, [key]: v });
|
|
30
|
+
return (
|
|
31
|
+
<div className={`flex flex-wrap items-center gap-2 ${className ?? ""}`} role="group" aria-label="Deal filters">
|
|
32
|
+
<select aria-label="Filter by owner" className={SELECT} value={value.ownerId} onChange={(e) => set("ownerId", e.target.value)}>
|
|
33
|
+
<option value="">Any owner</option>
|
|
34
|
+
{owners.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
|
35
|
+
</select>
|
|
36
|
+
<select aria-label="Filter by stage" className={SELECT} value={value.stageId} onChange={(e) => set("stageId", e.target.value)}>
|
|
37
|
+
<option value="">Any stage</option>
|
|
38
|
+
{stages.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
|
39
|
+
</select>
|
|
40
|
+
<select aria-label="Filter by outcome" className={SELECT} value={value.outcome} onChange={(e) => set("outcome", e.target.value as DealFilterState["outcome"])}>
|
|
41
|
+
<option value="">Open, won and lost</option>
|
|
42
|
+
<option value="open">Open</option>
|
|
43
|
+
<option value="won">Won</option>
|
|
44
|
+
<option value="lost">Lost</option>
|
|
45
|
+
</select>
|
|
46
|
+
<select aria-label="Filter by staleness" className={SELECT} value={value.staleDays} onChange={(e) => set("staleDays", e.target.value as DealFilterState["staleDays"])}>
|
|
47
|
+
<option value="">Any activity age</option>
|
|
48
|
+
{(["7", "14", "30", "60", "90"] as const).map((d) => <option key={d} value={d}>Stale {d}+ days</option>)}
|
|
49
|
+
</select>
|
|
50
|
+
<select aria-label="Filter by activity" className={SELECT} value={value.activity} onChange={(e) => set("activity", e.target.value as DealFilterState["activity"])}>
|
|
51
|
+
<option value="">With or without activity</option>
|
|
52
|
+
<option value="yes">Has activity</option>
|
|
53
|
+
<option value="no">No activity</option>
|
|
54
|
+
</select>
|
|
55
|
+
<label className="flex items-center gap-1 text-xs text-muted-foreground">Closes from
|
|
56
|
+
<input aria-label="Close date from" type="date" className={DATE} value={value.closeFrom} max={value.closeTo || undefined} onChange={(e) => set("closeFrom", e.target.value)} />
|
|
57
|
+
</label>
|
|
58
|
+
<label className="flex items-center gap-1 text-xs text-muted-foreground">to
|
|
59
|
+
<input aria-label="Close date to" type="date" className={DATE} value={value.closeTo} min={value.closeFrom || undefined} onChange={(e) => set("closeTo", e.target.value)} />
|
|
60
|
+
</label>
|
|
61
|
+
<label className="flex items-center gap-1 text-sm"><input type="checkbox" checked={value.orphaned} onChange={(e) => set("orphaned", e.target.checked)} />No contact</label>
|
|
62
|
+
{filtersActive(value) ? <Button size="sm" variant="ghost" onClick={() => onChange(NO_FILTERS)}>Clear filters</Button> : null}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DealLineItems: the products on a deal: add, edit and remove lines, with per-line totals and the deal total.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. GET /deals/{id}/line-items (deals.listDealLineItems) read - lines, derived amount, quote mismatch flag
|
|
8
|
+
* 2. PUT /deals/{id}/line-items { line_items } (deals.replaceDealLineItems) write - sends the FULL desired set (an empty list clears them)
|
|
9
|
+
* Money: unit prices are typed in dollars and sent as whole CENTS; the server recomputes each line total from
|
|
10
|
+
* unit price, quantity and discount. The editor appears only after the lines loaded, and Save stays disabled until
|
|
11
|
+
* something changed, so a record that has not loaded is never overwritten. Fields the screen does not show (catalog
|
|
12
|
+
* ids, description, billing frequency) are carried over untouched.
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
15
|
+
import { Plus, Trash2 } from "lucide-react";
|
|
16
|
+
import { Button, Input, Skeleton } from "@graph8/sdk/ui";
|
|
17
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
18
|
+
import { g8Get, g8Put } from "@/g8/lib/g8-client";
|
|
19
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
20
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
21
|
+
import { dataOf } from "../lib/deals-types";
|
|
22
|
+
import { centsToDollarsText, dollarsToCents, formatCents, lineTotalCents, type DealLineItem, type LineItemsPayload } from "../lib/deal-extras";
|
|
23
|
+
|
|
24
|
+
export interface DealLineItemsProps {
|
|
25
|
+
dealId: string;
|
|
26
|
+
/** Called after the lines were saved, so a screen can refresh the deal amount. */
|
|
27
|
+
onSaved?: () => void;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Row {
|
|
32
|
+
key: string;
|
|
33
|
+
base: DealLineItem | null;
|
|
34
|
+
name: string;
|
|
35
|
+
quantity: string;
|
|
36
|
+
price: string;
|
|
37
|
+
discount: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const FREQUENCIES: Array<[string, string]> = [["one_time", "One time"], ["month", "Monthly"], ["quarter", "Quarterly"], ["year", "Yearly"]];
|
|
41
|
+
let keyCounter = 0;
|
|
42
|
+
const nextKey = () => `row-${keyCounter++}`;
|
|
43
|
+
|
|
44
|
+
const rowFrom = (li: DealLineItem): Row => ({ key: li.id ?? nextKey(), base: li, name: li.product_name, quantity: String(li.quantity), price: centsToDollarsText(li.unit_amount), discount: String(li.discount_pct ?? 0) });
|
|
45
|
+
const blankRow = (): Row => ({ key: nextKey(), base: null, name: "", quantity: "1", price: "", discount: "0" });
|
|
46
|
+
|
|
47
|
+
/** The line a row stands for, or null while a field is invalid. */
|
|
48
|
+
function parse(r: Row, sortOrder: number): DealLineItem | null {
|
|
49
|
+
const cents = dollarsToCents(r.price);
|
|
50
|
+
const quantity = Number(r.quantity);
|
|
51
|
+
const discount = Number(r.discount === "" ? 0 : r.discount);
|
|
52
|
+
if (!r.name.trim() || cents == null || cents > 1_000_000_000 || !Number.isInteger(quantity) || quantity < 1 || quantity > 10_000 || !Number.isFinite(discount) || discount < 0 || discount > 100) return null;
|
|
53
|
+
const { id: _id, ...kept } = (r.base ?? {}) as DealLineItem & { line_total?: number };
|
|
54
|
+
const { line_total: _total, ...carried } = kept as DealLineItem & { line_total?: number };
|
|
55
|
+
return { ...carried, product_name: r.name.trim(), quantity, unit_amount: cents, discount_pct: discount, sort_order: sortOrder };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function DealLineItems({ dealId, onSaved, className }: DealLineItemsProps) {
|
|
59
|
+
const q = useG8Query(async (signal) => dataOf<LineItemsPayload>(await g8Get(`/deals/${encodeURIComponent(dealId)}/line-items`, undefined, { signal })), [dealId], { key: ["deals", "line-items", dealId], refetchOnFocus: false });
|
|
60
|
+
const [rows, setRows] = useState<Row[]>([]);
|
|
61
|
+
const baseline = useRef("[]");
|
|
62
|
+
const dirty = useRef(false);
|
|
63
|
+
// A different deal must not show the previous deal's lines.
|
|
64
|
+
useEffect(() => { dirty.current = false; setRows([]); baseline.current = "[]"; }, [dealId]);
|
|
65
|
+
// Unsaved edits win over a refreshed record: only an untouched editor follows the server.
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!q.data || dirty.current) return;
|
|
68
|
+
const next = (q.data.line_items ?? []).map(rowFrom);
|
|
69
|
+
setRows(next);
|
|
70
|
+
baseline.current = JSON.stringify(next.map((r) => [r.name, r.quantity, r.price, r.discount]));
|
|
71
|
+
}, [q.data]);
|
|
72
|
+
|
|
73
|
+
const edit = (key: string, change: Partial<Row>) => { dirty.current = true; setRows((all) => all.map((r) => (r.key === key ? { ...r, ...change } : r))); };
|
|
74
|
+
const parsed = rows.map((r, i) => parse(r, i));
|
|
75
|
+
const valid = parsed.every((p) => p !== null);
|
|
76
|
+
const changed = JSON.stringify(rows.map((r) => [r.name, r.quantity, r.price, r.discount])) !== baseline.current;
|
|
77
|
+
const currency = q.data?.currency;
|
|
78
|
+
const total = useMemo(() => parsed.reduce((sum, p) => sum + (p ? lineTotalCents(p) : 0), 0), [parsed]);
|
|
79
|
+
|
|
80
|
+
const save = useG8Mutation(
|
|
81
|
+
() => g8Put(`/deals/${encodeURIComponent(dealId)}/line-items`, { line_items: parsed as DealLineItem[] }),
|
|
82
|
+
{ invalidates: [["deals", "line-items", dealId], ["deals", "detail", dealId], ["deals", "list"]], onSuccess: () => { dirty.current = false; void q.refetch(); onSaved?.(); } },
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (q.error) return <ApiError error={q.error} title="Could not load the line items" onRetry={() => void q.refetch()} />;
|
|
86
|
+
if (!q.data) return <Skeleton className="h-32 w-full" aria-label="Loading line items" />;
|
|
87
|
+
return (
|
|
88
|
+
<form className={`space-y-3 ${className ?? ""}`} onSubmit={(e) => { e.preventDefault(); if (changed && valid) void save.mutate(undefined); }}>
|
|
89
|
+
{q.data.amount_mismatch ? (
|
|
90
|
+
<p role="status" className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
|
91
|
+
The deal amount ({q.data.amount ?? 0}) and its linked quote ({q.data.linked_quote_total ?? 0}) disagree.
|
|
92
|
+
</p>
|
|
93
|
+
) : null}
|
|
94
|
+
{rows.length === 0 ? <p className="text-sm text-muted-foreground">No line items yet. Add the products this deal is for.</p> : null}
|
|
95
|
+
<ul className="space-y-2" aria-label="Line items">
|
|
96
|
+
{rows.map((r, i) => {
|
|
97
|
+
const line = parsed[i];
|
|
98
|
+
return (
|
|
99
|
+
<li key={r.key} className="grid gap-2 rounded-md border p-2 sm:grid-cols-[1fr_5rem_7rem_5rem_7rem_auto] sm:items-center">
|
|
100
|
+
<Input aria-label={`Product ${i + 1}`} placeholder="Product" value={r.name} onChange={(e) => edit(r.key, { name: e.target.value })} />
|
|
101
|
+
<Input aria-label={`Quantity ${i + 1}`} type="number" min={1} max={10000} step={1} value={r.quantity} onChange={(e) => edit(r.key, { quantity: e.target.value })} />
|
|
102
|
+
<Input aria-label={`Unit price ${i + 1}`} type="number" min={0} step="0.01" placeholder="0.00" value={r.price} onChange={(e) => edit(r.key, { price: e.target.value })} />
|
|
103
|
+
<Input aria-label={`Discount percent ${i + 1}`} type="number" min={0} max={100} step="any" value={r.discount} onChange={(e) => edit(r.key, { discount: e.target.value })} />
|
|
104
|
+
<span className="text-right text-sm tabular-nums" aria-label={`Line total ${i + 1}`}>{line ? formatCents(lineTotalCents(line), currency) : "-"}</span>
|
|
105
|
+
<Button type="button" size="sm" variant="ghost" aria-label={`Remove line ${i + 1}`} onClick={() => { dirty.current = true; setRows((all) => all.filter((x) => x.key !== r.key)); }}><Trash2 className="size-4" aria-hidden /></Button>
|
|
106
|
+
{r.base?.billing_frequency && r.base.billing_frequency !== "one_time" ? <span className="text-xs text-muted-foreground sm:col-span-6">{FREQUENCIES.find(([v]) => v === r.base?.billing_frequency)?.[1] ?? r.base.billing_frequency} billing</span> : null}
|
|
107
|
+
</li>
|
|
108
|
+
);
|
|
109
|
+
})}
|
|
110
|
+
</ul>
|
|
111
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
112
|
+
<Button type="button" size="sm" variant="outline" onClick={() => { dirty.current = true; setRows((all) => [...all, blankRow()]); }}><Plus className="size-4" aria-hidden />Add line</Button>
|
|
113
|
+
<p className="text-sm font-semibold tabular-nums" data-testid="line-items-total">Total {formatCents(total, currency)}</p>
|
|
114
|
+
</div>
|
|
115
|
+
{!valid ? <p className="text-xs text-muted-foreground">Each line needs a product, a whole quantity from 1 to 10,000, a price and a discount from 0 to 100.</p> : null}
|
|
116
|
+
{save.error ? <ApiError error={save.error} title="Could not save the line items" /> : null}
|
|
117
|
+
<div className="flex justify-end"><Button type="submit" loading={save.loading} disabled={!changed || !valid}>Save line items</Button></div>
|
|
118
|
+
</form>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DealNotesTab: list, add, edit and delete the notes of one deal.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. GET /deals/{id}/notes (notes.listDealNotes) read
|
|
8
|
+
* 2. POST /deals/{id}/notes { content } (notes.createDealNote) write
|
|
9
|
+
* 3. PUT /deals/{id}/notes/{note_id} (deals.updateDealNote) write - only when the text changed
|
|
10
|
+
* 4. DELETE /deals/{id}/notes/{note_id} (deals.deleteDealNote) DESTRUCTIVE -> ConfirmSpendDialog first
|
|
11
|
+
* A `read_only` note (projected from synced CRM activity) can be neither edited nor deleted.
|
|
12
|
+
*/
|
|
13
|
+
import { useState } from "react";
|
|
14
|
+
import { StickyNote } from "lucide-react";
|
|
15
|
+
import { Button, Skeleton, Textarea } from "@graph8/sdk/ui";
|
|
16
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
17
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
18
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
19
|
+
import { g8Delete, g8Get, g8Post, g8Put } from "@/g8/lib/g8-client";
|
|
20
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
21
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
22
|
+
import { rowsOf, type DealNote } from "../lib/deal-extras";
|
|
23
|
+
|
|
24
|
+
export interface DealNotesTabProps {
|
|
25
|
+
dealId: string;
|
|
26
|
+
/** Called after any write, so a sibling activity timeline can reload. */
|
|
27
|
+
onChanged?: () => void;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const when = (iso: string | null | undefined): string => (iso ? new Date(iso).toLocaleString() : "");
|
|
32
|
+
|
|
33
|
+
export function DealNotesTab({ dealId, onChanged, className }: DealNotesTabProps) {
|
|
34
|
+
const base = `/deals/${encodeURIComponent(dealId)}/notes`;
|
|
35
|
+
const [draft, setDraft] = useState("");
|
|
36
|
+
const [editing, setEditing] = useState<{ id: string; original: string; content: string } | null>(null);
|
|
37
|
+
const [deleting, setDeleting] = useState<DealNote | null>(null);
|
|
38
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
39
|
+
const notes = useG8Query(async (signal) => rowsOf<DealNote>(await g8Get(base, undefined, { signal })), [dealId], { key: ["deals", "notes", dealId] });
|
|
40
|
+
const changed = () => { void notes.refetch(); onChanged?.(); };
|
|
41
|
+
|
|
42
|
+
const add = useG8Mutation((content: string) => g8Post(base, { content }), { invalidates: [["deals", "notes", dealId]], onSuccess: () => { setDraft(""); changed(); } });
|
|
43
|
+
const edit = useG8Mutation(async (v: { id: string; content: string }) => {
|
|
44
|
+
setBusyId(v.id);
|
|
45
|
+
try { await g8Put(`${base}/${encodeURIComponent(v.id)}`, { content: v.content }); } finally { setBusyId(null); }
|
|
46
|
+
}, { invalidates: [["deals", "notes", dealId]], onSuccess: () => { setEditing(null); changed(); } });
|
|
47
|
+
const remove = useG8Mutation(async (id: string) => {
|
|
48
|
+
setBusyId(id);
|
|
49
|
+
try { await g8Delete(`${base}/${encodeURIComponent(id)}`); } finally { setBusyId(null); }
|
|
50
|
+
}, { invalidates: [["deals", "notes", dealId]], onSuccess: () => { setDeleting(null); changed(); }, onError: () => setDeleting(null) });
|
|
51
|
+
|
|
52
|
+
const items = notes.data ?? [];
|
|
53
|
+
const unchanged = editing ? editing.content.trim() === editing.original.trim() : true;
|
|
54
|
+
return (
|
|
55
|
+
<div className={`flex flex-col gap-3 ${className ?? ""}`}>
|
|
56
|
+
<form className="flex flex-col gap-2" onSubmit={(e) => { e.preventDefault(); if (draft.trim()) void add.mutate(draft.trim()); }}>
|
|
57
|
+
<Textarea aria-label="New note" rows={3} placeholder="Write a note about this deal" value={draft} onChange={(e) => setDraft(e.target.value)} />
|
|
58
|
+
<div className="flex justify-end"><Button type="submit" size="sm" loading={add.loading} disabled={!draft.trim()}>Add note</Button></div>
|
|
59
|
+
</form>
|
|
60
|
+
{add.error ? <ApiError error={add.error} title="Could not add the note" /> : null}
|
|
61
|
+
{edit.error ? <ApiError error={edit.error} title="Could not save the note" /> : null}
|
|
62
|
+
{remove.error ? <ApiError error={remove.error} title="Could not delete the note" /> : null}
|
|
63
|
+
{notes.error ? <ApiError error={notes.error} title="Could not load notes" onRetry={() => void notes.refetch()} /> : null}
|
|
64
|
+
{notes.loading && !notes.data ? <Skeleton className="h-16 w-full" aria-label="Loading notes" /> : null}
|
|
65
|
+
{!notes.loading && !notes.error && items.length === 0 ? <EmptyState icon={<StickyNote className="size-8" />} title="No notes yet" description="Capture what was said, decided or promised." /> : null}
|
|
66
|
+
<ul className="flex flex-col gap-2" aria-label="Deal notes">
|
|
67
|
+
{items.map((n) => (
|
|
68
|
+
<li key={n.id} className="rounded-md border p-3 text-sm" data-testid="deal-note">
|
|
69
|
+
{editing?.id === n.id ? (
|
|
70
|
+
<form className="flex flex-col gap-2" onSubmit={(e) => { e.preventDefault(); if (!unchanged && editing.content.trim()) void edit.mutate({ id: n.id, content: editing.content.trim() }); }}>
|
|
71
|
+
<Textarea aria-label="Edit note" rows={3} value={editing.content} onChange={(e) => setEditing({ ...editing, content: e.target.value })} />
|
|
72
|
+
<div className="flex justify-end gap-2">
|
|
73
|
+
<Button type="button" size="sm" variant="outline" onClick={() => setEditing(null)}>Cancel</Button>
|
|
74
|
+
<Button type="submit" size="sm" loading={busyId === n.id && edit.loading} disabled={unchanged || !editing.content.trim()}>Save note</Button>
|
|
75
|
+
</div>
|
|
76
|
+
</form>
|
|
77
|
+
) : (
|
|
78
|
+
<>
|
|
79
|
+
<p className="whitespace-pre-wrap">{n.content}</p>
|
|
80
|
+
<div className="mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
|
81
|
+
<span>{[n.created_by_name, when(n.updated_at ?? n.created_at)].filter(Boolean).join(" · ")}{n.read_only ? " · synced, read only" : ""}</span>
|
|
82
|
+
{n.read_only ? null : (
|
|
83
|
+
<span className="flex gap-1">
|
|
84
|
+
<Button size="sm" variant="ghost" aria-label={`Edit note: ${n.content.slice(0, 30)}`} onClick={() => setEditing({ id: n.id, original: n.content, content: n.content })}>Edit</Button>
|
|
85
|
+
<Button size="sm" variant="ghost" aria-label={`Delete note: ${n.content.slice(0, 30)}`} loading={busyId === n.id && remove.loading} onClick={() => setDeleting(n)}>Delete</Button>
|
|
86
|
+
</span>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
</>
|
|
90
|
+
)}
|
|
91
|
+
</li>
|
|
92
|
+
))}
|
|
93
|
+
</ul>
|
|
94
|
+
<ConfirmSpendDialog open={deleting != null} tier="destructive" title="Delete this note?" description={deleting?.content.slice(0, 160)} confirmLabel="Delete note" affected={1} confirming={remove.loading} onConfirm={() => deleting && void remove.mutate(deleting.id)} onCancel={() => setDeleting(null)} />
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
* DealContactsTab: GET /deals/{id} (deals.getDeal) read - the deal's contacts and roles
|
|
9
9
|
* GET /contacts/{id} ... (crm ContactDetail, in a side sheet)
|
|
10
10
|
* DealQuotesTab: GET /quotes?deal_id=&page=&limit (quotes.listQuotes) read - needs the quotes:read scope
|
|
11
|
+
* (`actions` renders above the list: pass the quotes block's CreateQuoteFromDeal there)
|
|
11
12
|
* Nothing here is billable, external or destructive.
|
|
12
13
|
*/
|
|
13
|
-
import { useState } from "react";
|
|
14
|
+
import { useState, type ReactNode } from "react";
|
|
14
15
|
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 { Button, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
|
|
16
17
|
import { ApiError } from "@/g8/components/api-error";
|
|
17
18
|
import { EmptyState } from "@/g8/components/empty-state";
|
|
18
19
|
import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
|
|
@@ -20,11 +21,21 @@ import { StatusPill, toneForStatus, humanizeStatus } from "@/g8/components/scree
|
|
|
20
21
|
import { g8Get } from "@/g8/lib/g8-client";
|
|
21
22
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
22
23
|
import { ContactDetail } from "@/g8/crm/components/contact-detail";
|
|
23
|
-
import { dataOf, type Deal } from "../lib/deals-types";
|
|
24
|
+
import { dataOf, type Deal, type DealContact } from "../lib/deals-types";
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
/** `renderContactActions` adds controls beside each contact (DealContactsEditor uses it for role, primary and remove). */
|
|
27
|
+
export interface DealContactsTabProps {
|
|
28
|
+
dealId: string;
|
|
29
|
+
renderContactActions?: (contact: DealContact, deal: Deal) => ReactNode;
|
|
30
|
+
/** Open the person in your contacts screen (adds an "Open in contacts" button to the contact sheet). */
|
|
31
|
+
onOpenContact?: (contactId: number) => void;
|
|
32
|
+
/** Forwarded to the contact sheet's "Open company" button. */
|
|
33
|
+
onOpenCompany?: (companyId: number) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function DealContactsTab({ dealId, renderContactActions, onOpenContact, onOpenCompany }: DealContactsTabProps) {
|
|
26
37
|
const [open, setOpen] = useState<number | null>(null);
|
|
27
|
-
const q = useG8Query(async (signal) => dataOf<Deal>(await g8Get(`/deals/${encodeURIComponent(dealId)}`, undefined, { signal })), [dealId]);
|
|
38
|
+
const q = useG8Query(async (signal) => dataOf<Deal>(await g8Get(`/deals/${encodeURIComponent(dealId)}`, undefined, { signal })), [dealId], { key: ["deals", "detail", dealId] });
|
|
28
39
|
if (q.error) return <ApiError error={q.error} title="Could not load the deal's contacts" onRetry={() => void q.refetch()} />;
|
|
29
40
|
if (!q.data) return <Skeleton className="h-24 w-full" aria-label="Loading contacts" />;
|
|
30
41
|
const contacts = q.data.contacts ?? [];
|
|
@@ -33,7 +44,7 @@ export function DealContactsTab({ dealId }: { dealId: string }) {
|
|
|
33
44
|
<>
|
|
34
45
|
<ul className="divide-y rounded-md border" aria-label="Deal contacts">
|
|
35
46
|
{contacts.map((c) => (
|
|
36
|
-
<li key={c.id}>
|
|
47
|
+
<li key={c.id} className={renderContactActions ? "flex items-center gap-2 pr-2" : undefined}>
|
|
37
48
|
<button type="button" className="flex w-full items-center gap-3 p-3 text-left hover:bg-muted" onClick={() => setOpen(c.id)}>
|
|
38
49
|
<EntityAvatar name={c.name || c.email || "Contact"} size="md" />
|
|
39
50
|
<span className="min-w-0 flex-1">
|
|
@@ -43,13 +54,19 @@ export function DealContactsTab({ dealId }: { dealId: string }) {
|
|
|
43
54
|
{c.role ? <StatusPill tone="neutral">{humanizeStatus(c.role)}</StatusPill> : null}
|
|
44
55
|
{q.data?.primary_contact?.id === c.id ? <StatusPill tone="info">Primary</StatusPill> : null}
|
|
45
56
|
</button>
|
|
57
|
+
{renderContactActions && q.data ? renderContactActions(c, q.data) : null}
|
|
46
58
|
</li>
|
|
47
59
|
))}
|
|
48
60
|
</ul>
|
|
49
61
|
<Sheet open={open != null} onOpenChange={(o) => { if (!o) setOpen(null); }}>
|
|
50
62
|
<SheetContent className="w-full overflow-y-auto sm:max-w-2xl">
|
|
51
63
|
<SheetHeader><SheetTitle>Contact</SheetTitle><SheetDescription>The full CRM record.</SheetDescription></SheetHeader>
|
|
52
|
-
{open != null ?
|
|
64
|
+
{open != null ? (
|
|
65
|
+
<>
|
|
66
|
+
{onOpenContact ? <div className="px-4"><Button size="sm" variant="outline" onClick={() => onOpenContact(open)}>Open in contacts</Button></div> : null}
|
|
67
|
+
<ContactDetail contactId={open} onOpenCompany={onOpenCompany} />
|
|
68
|
+
</>
|
|
69
|
+
) : null}
|
|
53
70
|
</SheetContent>
|
|
54
71
|
</Sheet>
|
|
55
72
|
</>
|
|
@@ -58,16 +75,19 @@ export function DealContactsTab({ dealId }: { dealId: string }) {
|
|
|
58
75
|
|
|
59
76
|
interface QuoteRow { id: string; title?: string | null; quote_number?: string | null; status?: string | null; total?: number | null; currency?: string | null }
|
|
60
77
|
|
|
61
|
-
|
|
78
|
+
/** `actions` renders above the list, for example the quotes block's "Create quote from this deal" button. */
|
|
79
|
+
export function DealQuotesTab({ dealId, onOpenQuote, actions, refreshKey }: { dealId: string; onOpenQuote?: (quoteId: string) => void; actions?: ReactNode; refreshKey?: number }) {
|
|
62
80
|
const q = useG8Query(async (signal) => {
|
|
63
81
|
const env = await g8Get("/quotes", { deal_id: dealId, page: 1, limit: 50 }, { signal });
|
|
64
82
|
const inner = dataOf<{ items?: QuoteRow[] } | QuoteRow[]>(env);
|
|
65
83
|
return Array.isArray(inner) ? inner : (inner.items ?? []);
|
|
66
|
-
}, [dealId]);
|
|
84
|
+
}, [dealId, refreshKey]);
|
|
67
85
|
if (q.error) return <ApiError error={q.error} title="Could not load quotes" onRetry={() => void q.refetch()} />;
|
|
68
86
|
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."
|
|
87
|
+
if (q.data.length === 0) return <div className="space-y-3">{actions}<EmptyState icon={<FileText className="size-8" />} title="No quotes for this deal" description="Create one from the Quotes screen." /></div>;
|
|
70
88
|
return (
|
|
89
|
+
<div className="space-y-3">
|
|
90
|
+
{actions}
|
|
71
91
|
<Table>
|
|
72
92
|
<TableHeader><TableRow><TableHead>Quote</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Total</TableHead></TableRow></TableHeader>
|
|
73
93
|
<TableBody>
|
|
@@ -83,5 +103,6 @@ export function DealQuotesTab({ dealId, onOpenQuote }: { dealId: string; onOpenQ
|
|
|
83
103
|
))}
|
|
84
104
|
</TableBody>
|
|
85
105
|
</Table>
|
|
106
|
+
</div>
|
|
86
107
|
);
|
|
87
108
|
}
|