@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,88 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DealTasksTab: the follow-up tasks linked to one deal: list, add, complete and reopen.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. GET /tasks?entity_type=deal&entity_id={id}&status= (tasks.listAllTasks) read
|
|
8
|
+
* 2. POST /tasks { title, due_date, priority, entity_type: "deal", entity_id } (tasks.createGlobalTask) write
|
|
9
|
+
* 3. PATCH /tasks/{task_id} { status } (tasks.updateTask) write - open or completed
|
|
10
|
+
* Priority: 1 high, 2 medium, 3 low, 0 none. Due dates are date-only (YYYY-MM-DD) and shown in UTC.
|
|
11
|
+
*/
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
import { ListChecks } from "lucide-react";
|
|
14
|
+
import { Button, Input, Skeleton } from "@graph8/sdk/ui";
|
|
15
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
16
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
17
|
+
import { g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
18
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
19
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
20
|
+
import { rowsOf, type DealTask } from "../lib/deal-extras";
|
|
21
|
+
|
|
22
|
+
export interface DealTasksTabProps {
|
|
23
|
+
dealId: string;
|
|
24
|
+
onChanged?: () => void;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const PRIORITY = ["", "High", "Medium", "Low", "Low"];
|
|
29
|
+
|
|
30
|
+
export function DealTasksTab({ dealId, onChanged, className }: DealTasksTabProps) {
|
|
31
|
+
const [title, setTitle] = useState("");
|
|
32
|
+
const [due, setDue] = useState("");
|
|
33
|
+
const [priority, setPriority] = useState(2);
|
|
34
|
+
const [showDone, setShowDone] = useState(false);
|
|
35
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
36
|
+
const tasks = useG8Query(
|
|
37
|
+
async (signal) => rowsOf<DealTask>(await g8Get("/tasks", { entity_type: "deal", entity_id: dealId, status: showDone ? undefined : "open", limit: 100 }, { signal })),
|
|
38
|
+
[dealId, showDone],
|
|
39
|
+
{ key: ["tasks", "deal", dealId, { showDone }] },
|
|
40
|
+
);
|
|
41
|
+
const changed = () => { void tasks.refetch(); onChanged?.(); };
|
|
42
|
+
const add = useG8Mutation(
|
|
43
|
+
() => g8Post("/tasks", { title: title.trim(), priority, entity_type: "deal", entity_id: dealId, ...(due ? { due_date: due } : {}) }),
|
|
44
|
+
{ invalidates: [["tasks"]], onSuccess: () => { setTitle(""); setDue(""); changed(); } },
|
|
45
|
+
);
|
|
46
|
+
const toggle = useG8Mutation(async (t: DealTask) => {
|
|
47
|
+
setBusyId(t.id);
|
|
48
|
+
try { await g8Patch(`/tasks/${encodeURIComponent(t.id)}`, { status: t.status === "completed" ? "open" : "completed" }); } finally { setBusyId(null); }
|
|
49
|
+
}, { invalidates: [["tasks"]], onSuccess: changed });
|
|
50
|
+
|
|
51
|
+
const items = tasks.data ?? [];
|
|
52
|
+
return (
|
|
53
|
+
<div className={`flex flex-col gap-3 ${className ?? ""}`}>
|
|
54
|
+
<form className="flex flex-wrap items-end gap-2" onSubmit={(e) => { e.preventDefault(); if (title.trim()) void add.mutate(undefined); }}>
|
|
55
|
+
<Input aria-label="Task title" placeholder="Add a task for this deal" className="min-w-48 flex-1" value={title} onChange={(e) => setTitle(e.target.value)} />
|
|
56
|
+
<Input aria-label="Due date" type="date" className="w-40" value={due} onChange={(e) => setDue(e.target.value)} />
|
|
57
|
+
<select aria-label="Priority" className="h-9 rounded-md border bg-background px-2 text-sm" value={priority} onChange={(e) => setPriority(Number(e.target.value))}>
|
|
58
|
+
<option value={1}>High</option><option value={2}>Medium</option><option value={3}>Low</option><option value={0}>None</option>
|
|
59
|
+
</select>
|
|
60
|
+
<Button type="submit" size="sm" loading={add.loading} disabled={!title.trim()}>Add task</Button>
|
|
61
|
+
</form>
|
|
62
|
+
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
63
|
+
<input type="checkbox" checked={showDone} onChange={(e) => setShowDone(e.target.checked)} /> Show completed
|
|
64
|
+
</label>
|
|
65
|
+
{add.error ? <ApiError error={add.error} title="Could not add the task" /> : null}
|
|
66
|
+
{toggle.error ? <ApiError error={toggle.error} title="Could not update the task" /> : null}
|
|
67
|
+
{tasks.error ? <ApiError error={tasks.error} title="Could not load tasks" onRetry={() => void tasks.refetch()} /> : null}
|
|
68
|
+
{tasks.loading && !tasks.data ? <Skeleton className="h-12 w-full" aria-label="Loading tasks" /> : null}
|
|
69
|
+
{!tasks.loading && !tasks.error && items.length === 0 ? <EmptyState icon={<ListChecks className="size-8" />} title="No tasks" description="Add a follow-up so this deal does not stall." /> : null}
|
|
70
|
+
<ul className="flex flex-col gap-2" aria-label="Deal tasks">
|
|
71
|
+
{items.map((t) => {
|
|
72
|
+
const completed = t.status === "completed";
|
|
73
|
+
return (
|
|
74
|
+
<li key={t.id} className="flex items-center gap-3 rounded-md border p-3" data-testid="deal-task">
|
|
75
|
+
<input type="checkbox" aria-label={`${completed ? "Reopen" : "Complete"} ${t.title}`} checked={completed} disabled={busyId === t.id} onChange={() => void toggle.mutate(t)} />
|
|
76
|
+
<div className="min-w-0 flex-1">
|
|
77
|
+
<p className={`truncate text-sm ${completed ? "text-muted-foreground line-through" : "font-medium"}`}>{t.title}</p>
|
|
78
|
+
<p className="text-xs text-muted-foreground">
|
|
79
|
+
{[t.due_date ? `Due ${new Date(t.due_date.length === 10 ? `${t.due_date}T00:00:00Z` : t.due_date).toLocaleDateString(undefined, { timeZone: "UTC" })}` : null, PRIORITY[t.priority ?? 0] || null, t.assignee_name].filter(Boolean).join(" · ")}
|
|
80
|
+
</p>
|
|
81
|
+
</div>
|
|
82
|
+
</li>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
85
|
+
</ul>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -5,21 +5,28 @@
|
|
|
5
5
|
* pipeline stage settings in one screen. No props are required.
|
|
6
6
|
*
|
|
7
7
|
* It composes: PipelineBoard (kanban, drag or "Move to"), a deals table, DealDetail (edit, stage history, delete),
|
|
8
|
-
* ForecastView, StageEditor,
|
|
8
|
+
* ForecastView, StageEditor, PipelineEditor, DealContactsEditor, DealLineItems, DealNotesTab, DealTasksTab, DealQuotesTab,
|
|
9
|
+
* DealActions (close with a reason, move pipeline), DealFilterBar and the crm block's ActivityTimeline and ContactDetail.
|
|
9
10
|
*
|
|
10
11
|
* Flows:
|
|
11
12
|
* - Board / List toggle, search by deal name, switch pipeline (when there is more than one).
|
|
12
13
|
* - Headline numbers: open deals, open value, estimated weighted value, won value (from the loaded deals).
|
|
14
|
+
* - Filters (board and list): owner, stage, outcome, stale (no activity for 7 to 90 days), activity, close-date range
|
|
15
|
+
* and "no contact".
|
|
13
16
|
* - 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 (
|
|
17
|
+
* edit, stage history, confirmed delete), Contacts (add, set role, make primary, remove), Line items (add, edit,
|
|
18
|
+
* remove, totals), Notes (add, edit, delete), Tasks (add, complete), Quotes and Activity tabs. The header has
|
|
19
|
+
* "Close deal" (won or lost with a reason) and "Move to pipeline". Moving a card into a won/lost stage asks for the close date and amount.
|
|
15
20
|
* - New deal: name, amount, close date, stage, plus the two things the API requires. The OWNER defaults to the API
|
|
16
21
|
* key's own user (GET /team-members/me); with an organisation key it defaults to the first team member and an
|
|
17
22
|
* owner picker lets the person change it. At least ONE CONTACT is required by the API (`contact_ids`), found with a
|
|
18
23
|
* 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 (
|
|
24
|
+
* - Forecast tab (ForecastView) and Pipeline settings tab (create, rename, default and delete pipelines, the close
|
|
25
|
+
* reasons, a stage summary with deal counts and values, plus StageEditor: names, probabilities, order, delete).
|
|
20
26
|
*
|
|
21
27
|
* SDK operations (all through the composed parts): listPipelines, listDeals, getDeal, listDealHistory, createDeal,
|
|
22
|
-
* updateDeal, deleteDeal (confirmed),
|
|
28
|
+
* updateDeal, deleteDeal (confirmed), the notes, tasks, contact, line-item, close-reason and pipeline operations of the parts
|
|
29
|
+
* above, getDealAnalytics, getDealStageVelocity, the pipeline-stage writes,
|
|
23
30
|
* listTeamMembers + getMyTeamMember (the owner default) and the crm block's listContacts + activity feed.
|
|
24
31
|
* Nothing here is billable or external; delete is destructive and goes through ConfirmSpendDialog inside DealDetail.
|
|
25
32
|
*
|
|
@@ -43,15 +50,26 @@ import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
|
43
50
|
import { ActivityTimeline } from "@/g8/crm/components/activity-timeline";
|
|
44
51
|
import { useDebouncedValue } from "@/g8/crm/hooks/use-debounced";
|
|
45
52
|
import { contactName, type ContactRecord } from "@/g8/crm/lib/crm-types";
|
|
53
|
+
import { rowActivationProps } from "@/g8/hooks/use-row-activation";
|
|
54
|
+
import { RecordLink } from "@/g8/components/screen/record-link";
|
|
46
55
|
import { useDefaultOwner } from "../hooks/use-default-owner";
|
|
47
56
|
import { usePipelineDeals } from "../hooks/use-pipeline-deals";
|
|
48
57
|
import { usePipelines } from "../hooks/use-pipelines";
|
|
49
58
|
import { dataOf, formatDate, formatMoney, stageKind, sumMoney, weightedPipeline, type Deal, type Stage } from "../lib/deals-types";
|
|
50
59
|
import { DealDetail } from "./deal-detail";
|
|
51
|
-
import {
|
|
60
|
+
import { DealActions } from "./close-deal-dialog";
|
|
61
|
+
import { DealContactsEditor } from "./deal-contacts-editor";
|
|
62
|
+
import { DealFilterBar } from "./deal-filters";
|
|
63
|
+
import { DealLineItems } from "./deal-line-items";
|
|
64
|
+
import { DealNotesTab } from "./deal-notes-tab";
|
|
65
|
+
import { DealQuotesTab } from "./deal-related";
|
|
66
|
+
import { DealTasksTab } from "./deal-tasks-tab";
|
|
52
67
|
import { ForecastView } from "./forecast-view";
|
|
53
68
|
import { PipelineBoard } from "./pipeline-board";
|
|
69
|
+
import { PipelineEditor } from "./pipeline-editor";
|
|
54
70
|
import { StageEditor } from "./stage-editor";
|
|
71
|
+
import { NO_FILTERS, filterQuery, filtersActive, type DealFilterState } from "../lib/deal-extras";
|
|
72
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
55
73
|
|
|
56
74
|
export interface DealsScreenProps {
|
|
57
75
|
/** Start on this pipeline; defaults to the org's default one. */
|
|
@@ -66,6 +84,12 @@ export interface DealsScreenProps {
|
|
|
66
84
|
toolbarActions?: ReactNode;
|
|
67
85
|
/** Open a quote of the deal in your quotes screen; without it the Quotes tab is read-only. */
|
|
68
86
|
onOpenQuote?: (quoteId: string) => void;
|
|
87
|
+
/** Buttons above the Quotes tab list, for example the quotes block's `<CreateQuoteFromDeal dealId={deal.id} />`. */
|
|
88
|
+
renderQuoteActions?: (deal: Deal) => ReactNode;
|
|
89
|
+
/** Open a deal's contact in your contacts screen; without it the Contacts tab sheet has no such button. */
|
|
90
|
+
onOpenContact?: (contactId: number) => void;
|
|
91
|
+
/** Open the company of a deal's contact in your companies screen. */
|
|
92
|
+
onOpenCompany?: (companyId: number) => void;
|
|
69
93
|
}
|
|
70
94
|
|
|
71
95
|
type View = "board" | "list" | "forecast" | "stages";
|
|
@@ -84,6 +108,7 @@ function stageTone(stage: Stage | undefined): "neutral" | "positive" | "negative
|
|
|
84
108
|
|
|
85
109
|
/** Contact search + chips for the new-deal sheet. The API requires at least one contact. */
|
|
86
110
|
function ContactPicker({ value, onChange }: { value: ContactRecord[]; onChange: (next: ContactRecord[]) => void }) {
|
|
111
|
+
const brand = useBrand();
|
|
87
112
|
const [text, setText] = useState("");
|
|
88
113
|
const term = useDebouncedValue(text.trim());
|
|
89
114
|
const found = useG8Query(async (signal) => {
|
|
@@ -119,7 +144,7 @@ function ContactPicker({ value, onChange }: { value: ContactRecord[]; onChange:
|
|
|
119
144
|
</li>
|
|
120
145
|
))}
|
|
121
146
|
</ul>
|
|
122
|
-
<p className="text-xs text-muted-foreground">
|
|
147
|
+
<p className="text-xs text-muted-foreground">{brand.linkLabel} needs at least one contact on a deal. Every contact must work at the same company.</p>
|
|
123
148
|
</div>
|
|
124
149
|
);
|
|
125
150
|
}
|
|
@@ -182,8 +207,8 @@ function DealRows({ deals, stages, selectedId, onOpen, renderRowActions }: { dea
|
|
|
182
207
|
{deals.map((d) => {
|
|
183
208
|
const stage = d.stage_id ? byId.get(d.stage_id) : undefined;
|
|
184
209
|
return (
|
|
185
|
-
<TableRow key={d.id} data-state={selectedId === d.id ? "selected" : undefined} className="cursor-pointer"
|
|
186
|
-
<TableCell><
|
|
210
|
+
<TableRow key={d.id} data-state={selectedId === d.id ? "selected" : undefined} className="cursor-pointer" {...rowActivationProps(() => onOpen(d))}>
|
|
211
|
+
<TableCell><RecordLink param="id" id={d.id} onOpen={() => onOpen(d)}>{d.name || "Untitled deal"}</RecordLink></TableCell>
|
|
187
212
|
<TableCell><StatusPill tone={stageTone(stage)} dot>{stage?.name ?? d.stage_name ?? "Unknown"}</StatusPill></TableCell>
|
|
188
213
|
<TableCell className="text-right tabular-nums">{formatMoney(d.amount, d.currency)}</TableCell>
|
|
189
214
|
<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>
|
|
@@ -197,7 +222,8 @@ function DealRows({ deals, stages, selectedId, onOpen, renderRowActions }: { dea
|
|
|
197
222
|
);
|
|
198
223
|
}
|
|
199
224
|
|
|
200
|
-
export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, onOpenQuote }: DealsScreenProps) {
|
|
225
|
+
export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, onOpenQuote, renderQuoteActions, onOpenContact, onOpenCompany }: DealsScreenProps) {
|
|
226
|
+
const brand = useBrand();
|
|
201
227
|
const [viewParam, setViewParam] = useUrlState("tab", "board");
|
|
202
228
|
const [selectedId, setSelectedId] = useUrlState("id");
|
|
203
229
|
const [mode, setMode] = useUrlState("view");
|
|
@@ -207,10 +233,16 @@ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, r
|
|
|
207
233
|
const [creating, setCreating] = useState(false);
|
|
208
234
|
const [version, setVersion] = useState(0);
|
|
209
235
|
const view = asView(viewParam);
|
|
236
|
+
const [filterState, setFilterState] = useState<DealFilterState>(NO_FILTERS);
|
|
237
|
+
const filterKey = JSON.stringify(filterState);
|
|
238
|
+
// The stale cutoff is midnight UTC, so the object is stable all day; recompute only when a filter changes.
|
|
239
|
+
const filters = useMemo(() => filterQuery(filterState), [filterKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
240
|
+
const owners = useDefaultOwner();
|
|
210
241
|
const pipelines = usePipelines();
|
|
211
242
|
const pipeline = pipelines.pipelines.find((p) => p.id === pipelineParam) ?? pipelines.defaultPipeline;
|
|
212
|
-
const data = usePipelineDeals(pipeline?.id, { search });
|
|
243
|
+
const data = usePipelineDeals(pipeline?.id, { search, ...filters });
|
|
213
244
|
const stages = pipeline?.stages ?? [];
|
|
245
|
+
const filtered = filtersActive(filterState);
|
|
214
246
|
const refresh = () => { setVersion((v) => v + 1); void data.reload(); };
|
|
215
247
|
|
|
216
248
|
const stats = useMemo(() => {
|
|
@@ -219,16 +251,16 @@ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, r
|
|
|
219
251
|
const cur = data.deals[0]?.currency;
|
|
220
252
|
const weighted = (group: ReadonlyArray<Deal>) => Math.round(weightedPipeline(group, stages).value);
|
|
221
253
|
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 },
|
|
254
|
+
{ label: "Open deals", value: open.length.toLocaleString(), ...(filtered ? { hint: "Filters applied" } : {}) },
|
|
255
|
+
{ label: "Open value", value: sumMoney(open, undefined, cur), ...(filtered ? { hint: "Filters applied" } : {}) },
|
|
256
|
+
{ label: "Weighted (estimate)", value: sumMoney(data.deals, weighted, cur), hint: filtered ? "Filters applied" : "Open deals times stage probability" },
|
|
257
|
+
{ label: "Won", value: sumMoney(won, undefined, cur), tone: "positive" as const, ...(filtered ? { hint: "Filters applied" } : {}) },
|
|
226
258
|
];
|
|
227
|
-
}, [data.deals, stages]);
|
|
259
|
+
}, [data.deals, stages, filtered]);
|
|
228
260
|
|
|
229
261
|
const listed = data.deals.find((d) => d.id === selectedId);
|
|
230
262
|
// 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 });
|
|
263
|
+
const fetched = useG8Query(async (signal) => dataOf<Deal>(await g8Get(`/deals/${encodeURIComponent(selectedId ?? "")}`, undefined, { signal })), [selectedId, version], { enabled: Boolean(selectedId) && !listed && !data.loading, key: ["deals", "detail", selectedId ?? null] });
|
|
232
264
|
const selected = listed ?? (fetched.data?.id === selectedId ? fetched.data : undefined);
|
|
233
265
|
const open = (id: string) => setSelectedId(id);
|
|
234
266
|
const close = () => { setSelectedId(null); setMode(null); };
|
|
@@ -239,14 +271,17 @@ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, r
|
|
|
239
271
|
title={selected?.name || "Deal"}
|
|
240
272
|
subtitle={selected ? [selected.stage_name, formatMoney(selected.amount, selected.currency)].join(" · ") : undefined}
|
|
241
273
|
badges={selected ? <StatusPill tone={stageTone(stages.find((s) => s.id === selected.stage_id))} dot>{selected.stage_name ?? "Open"}</StatusPill> : undefined}
|
|
242
|
-
actions={selected
|
|
274
|
+
actions={selected ? <>{pipelines.pipelines.length > 0 ? <DealActions deal={selected} pipelines={pipelines.pipelines} onChanged={refresh} /> : null}{renderDetailActions?.(selected)}</> : undefined}
|
|
243
275
|
onClose={close}
|
|
244
276
|
onExpand={mode === "full" ? undefined : () => setMode("full")}
|
|
245
277
|
onCollapse={mode === "full" ? () => setMode(null) : undefined}
|
|
246
278
|
tabs={[
|
|
247
279
|
{ id: "details", label: "Details", content: <DealDetail key={selectedId} dealId={selectedId} onSaved={refresh} onDeleted={() => { close(); refresh(); }} /> },
|
|
248
|
-
{ id: "contacts", label: "Contacts", content: <
|
|
249
|
-
{ id: "
|
|
280
|
+
{ id: "contacts", label: "Contacts", content: <DealContactsEditor key={selectedId} dealId={selectedId} onChanged={refresh} onOpenContact={onOpenContact} onOpenCompany={onOpenCompany} /> },
|
|
281
|
+
{ id: "line-items", label: "Line items", content: <DealLineItems key={selectedId} dealId={selectedId} onSaved={refresh} /> },
|
|
282
|
+
{ id: "notes", label: "Notes", content: <DealNotesTab key={selectedId} dealId={selectedId} /> },
|
|
283
|
+
{ id: "tasks", label: "Tasks", content: <DealTasksTab key={selectedId} dealId={selectedId} /> },
|
|
284
|
+
{ id: "quotes", label: "Quotes", content: <DealQuotesTab key={selectedId} dealId={selectedId} onOpenQuote={onOpenQuote} actions={selected && renderQuoteActions ? renderQuoteActions(selected) : undefined} refreshKey={version} /> },
|
|
250
285
|
{ id: "activity", label: "Activity", content: <ActivityTimeline entityType="deal" entityId={selectedId} /> },
|
|
251
286
|
...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: selected ? t.render(selected) : null })),
|
|
252
287
|
]}
|
|
@@ -259,8 +294,8 @@ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, r
|
|
|
259
294
|
|
|
260
295
|
let body: ReactNode;
|
|
261
296
|
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=
|
|
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} />;
|
|
297
|
+
else if (view === "stages") body = pipeline ? <div className="space-y-6"><PipelineEditor onChanged={(e) => { if (e.kind === "deleted" && e.pipelineId === pipeline.id) { setPipelineParam(""); close(); } refresh(); }} /><StageSummary stages={stages} deals={data.deals} /><StageEditor pipelineId={pipeline.id} onChanged={refresh} /></div> : <EmptyState title="No pipeline yet" description={`Create a pipeline in ${brand.linkLabel} first.`} />;
|
|
298
|
+
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} filters={filters} onSelectDeal={(d) => open(d.id)} onChanged={() => void data.reload()} /> : list} />;
|
|
264
299
|
|
|
265
300
|
return (
|
|
266
301
|
<ScreenLayout
|
|
@@ -275,11 +310,16 @@ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, r
|
|
|
275
310
|
toolbar={showToolbar ? (
|
|
276
311
|
<Toolbar
|
|
277
312
|
search={{ value: searchText, onChange: setSearchText, placeholder: "Search deals" }}
|
|
278
|
-
filters={
|
|
279
|
-
|
|
280
|
-
{pipelines.pipelines.
|
|
281
|
-
|
|
282
|
-
|
|
313
|
+
filters={(
|
|
314
|
+
<>
|
|
315
|
+
{pipelines.pipelines.length > 1 ? (
|
|
316
|
+
<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); setFilterState((f) => ({ ...f, stageId: "" })); close(); }}>
|
|
317
|
+
{pipelines.pipelines.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
318
|
+
</select>
|
|
319
|
+
) : null}
|
|
320
|
+
<DealFilterBar value={filterState} onChange={(next) => { setFilterState(next); }} stages={stages} owners={owners.owners} />
|
|
321
|
+
</>
|
|
322
|
+
)}
|
|
283
323
|
actions={toolbarActions}
|
|
284
324
|
/>
|
|
285
325
|
) : undefined}
|
|
@@ -42,7 +42,7 @@ interface Velocity {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function Tile({ label, value, hint }: { label: string; value: string; hint?: string }) {
|
|
45
|
-
return <div className="rounded-lg border p-3"><div className="text-xs uppercase text-muted-foreground">{label}</div><div className="text-lg font-
|
|
45
|
+
return <div className="rounded-lg border p-3"><div className="text-xs uppercase text-muted-foreground">{label}</div><div className="text-lg font-medium">{value}</div>{hint ? <div className="text-xs text-muted-foreground">{hint}</div> : null}</div>;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
const MAX_OPEN_DEALS = 2000;
|
|
@@ -84,7 +84,7 @@ export function ForecastView({ pipelineId, stuckAfterDays = 14, className }: For
|
|
|
84
84
|
</div>
|
|
85
85
|
|
|
86
86
|
<section aria-label="Deals by stage" className="space-y-1">
|
|
87
|
-
<h3 className="text-sm font-
|
|
87
|
+
<h3 className="text-sm font-medium">Deals by stage</h3>
|
|
88
88
|
{(analytics.data?.by_stage ?? []).map((s) => (
|
|
89
89
|
<div key={`${s.stage_id}-${s.stage_name}`} className="flex items-center gap-2 text-sm">
|
|
90
90
|
<span className="w-40 truncate">{s.stage_name}</span>
|
|
@@ -95,14 +95,14 @@ export function ForecastView({ pipelineId, stuckAfterDays = 14, className }: For
|
|
|
95
95
|
</section>
|
|
96
96
|
|
|
97
97
|
<section aria-label="Stage velocity" className="space-y-1 text-sm">
|
|
98
|
-
<h3 className="font-
|
|
98
|
+
<h3 className="font-medium">Where deals stall</h3>
|
|
99
99
|
{(velocity.data?.by_stage ?? []).map((s) => <p key={s.stage_id}>{s.stage_name}: {s.avg_days_in_stage != null ? `${s.avg_days_in_stage.toFixed(1)} days average` : "no data"}{s.conversion_rate != null ? `, ${Math.round(s.conversion_rate * 100)}% advance` : ""}, {s.still_in_stage_count} waiting</p>)}
|
|
100
100
|
<p className="text-muted-foreground">{velocity.data?.stuck_deals?.total_count ?? 0} deals have not moved in {velocity.data?.stuck_deals?.threshold_days ?? stuckAfterDays}+ days.</p>
|
|
101
101
|
</section>
|
|
102
102
|
|
|
103
103
|
{(analytics.data?.trend ?? []).length > 0 ? (
|
|
104
104
|
<section aria-label="Won and lost by month" className="space-y-1 text-sm">
|
|
105
|
-
<h3 className="font-
|
|
105
|
+
<h3 className="font-medium">Won and lost by month</h3>
|
|
106
106
|
{(analytics.data?.trend ?? []).slice(-6).map((p) => <p key={p.period}>{p.period}: {p.won_count} won{p.won_value != null && !mixed ? ` (${formatMoney(p.won_value, code)})` : ""}, {p.lost_count} lost</p>)}
|
|
107
107
|
</section>
|
|
108
108
|
) : null}
|
|
@@ -19,7 +19,7 @@ import { DndContext, KeyboardSensor, PointerSensor, useDraggable, useDroppable,
|
|
|
19
19
|
import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
|
|
20
20
|
import { ApiError } from "@/g8/components/api-error";
|
|
21
21
|
import { EmptyState } from "@/g8/components/empty-state";
|
|
22
|
-
import { usePipelineDeals } from "../hooks/use-pipeline-deals";
|
|
22
|
+
import { usePipelineDeals, type PipelineDealFilters } from "../hooks/use-pipeline-deals";
|
|
23
23
|
import { usePipelines } from "../hooks/use-pipelines";
|
|
24
24
|
import { closedStageMarker, formatDate, formatMoney, stageKind, sumAmounts, type Deal, type Stage } from "../lib/deals-types";
|
|
25
25
|
|
|
@@ -28,6 +28,8 @@ export interface PipelineBoardProps {
|
|
|
28
28
|
pipelineId?: string;
|
|
29
29
|
search?: string;
|
|
30
30
|
ownerId?: string;
|
|
31
|
+
/** More list filters (stage, outcome, stale, close-date range ...); the same object the screen passes to usePipelineDeals. */
|
|
32
|
+
filters?: Omit<PipelineDealFilters, "search" | "ownerId">;
|
|
31
33
|
onSelectDeal?: (deal: Deal) => void;
|
|
32
34
|
/** Called after a stage move was saved, so a screen can refresh its totals. */
|
|
33
35
|
onChanged?: () => void;
|
|
@@ -61,7 +63,7 @@ function Column({ stage, stages, deals, moving, onMove, onSelect }: { stage: Sta
|
|
|
61
63
|
return (
|
|
62
64
|
<section ref={setNodeRef} aria-label={stage.name} className={`flex w-72 shrink-0 flex-col gap-2 rounded-lg border bg-muted/40 p-2 ${isOver ? "ring-2 ring-primary" : ""}`}>
|
|
63
65
|
<header className="flex items-center justify-between px-1">
|
|
64
|
-
<h3 className="text-sm font-
|
|
66
|
+
<h3 className="text-sm font-medium">{stage.name}{kind !== "open" ? ` (${kind})` : ""}</h3>
|
|
65
67
|
<span className="text-xs text-muted-foreground">{deals.length} · {formatMoney(sumAmounts(deals), deals[0]?.currency)}</span>
|
|
66
68
|
</header>
|
|
67
69
|
<div className="flex min-h-16 flex-col gap-2">
|
|
@@ -85,7 +87,7 @@ function CloseDialog({ stage, deal, onConfirm, onCancel }: { stage: Stage; deal:
|
|
|
85
87
|
return (
|
|
86
88
|
<div role="dialog" aria-modal="true" aria-label={`Close deal as ${stageKind(stage)}`} className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
|
87
89
|
<form className="w-full max-w-sm space-y-3 rounded-lg bg-background p-5 shadow-xl" onSubmit={(e) => { e.preventDefault(); if (valid) onConfirm({ close_date: date, amount: amount === "" ? undefined : Number(amount) }); }}>
|
|
88
|
-
<h2 className="text-base font-
|
|
90
|
+
<h2 className="text-base font-medium">Move "{deal.name}" to {stage.name}</h2>
|
|
89
91
|
<div className="space-y-1"><Label htmlFor="cd-date">Close date</Label><Input id="cd-date" type="date" value={date} onChange={(e) => setDate(e.target.value)} /></div>
|
|
90
92
|
{won ? <div className="space-y-1"><Label htmlFor="cd-amount">Amount</Label><Input id="cd-amount" type="number" min={0} value={amount} onChange={(e) => setAmount(e.target.value)} /></div> : null}
|
|
91
93
|
<div className="flex justify-end gap-2"><Button type="button" variant="outline" onClick={onCancel}>Cancel</Button><Button type="submit" disabled={!valid}>Move deal</Button></div>
|
|
@@ -94,10 +96,10 @@ function CloseDialog({ stage, deal, onConfirm, onCancel }: { stage: Stage; deal:
|
|
|
94
96
|
);
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
export function PipelineBoard({ pipelineId, search, ownerId, onSelectDeal, onChanged, className }: PipelineBoardProps) {
|
|
99
|
+
export function PipelineBoard({ pipelineId, search, ownerId, filters, onSelectDeal, onChanged, className }: PipelineBoardProps) {
|
|
98
100
|
const pipelines = usePipelines();
|
|
99
101
|
const pipeline = pipelines.pipelines.find((p) => p.id === pipelineId) ?? pipelines.defaultPipeline;
|
|
100
|
-
const { deals, loading, error, reload, moveDeal, movingIds } = usePipelineDeals(pipeline?.id, { search, ownerId });
|
|
102
|
+
const { deals, loading, error, reload, moveDeal, movingIds } = usePipelineDeals(pipeline?.id, { search, ownerId, ...filters });
|
|
101
103
|
const [closing, setClosing] = useState<{ deal: Deal; stage: Stage } | null>(null);
|
|
102
104
|
const [moveError, setMoveError] = useState<unknown>(null);
|
|
103
105
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(KeyboardSensor));
|
|
@@ -127,7 +129,7 @@ export function PipelineBoard({ pipelineId, search, ownerId, onSelectDeal, onCha
|
|
|
127
129
|
if (pipelines.loading || !pipeline) return pipelines.loading ? <Skeleton className="h-64 w-full" aria-label="Loading pipeline" /> : <EmptyState title="No pipeline yet" description="Create a pipeline with stages, then your deals appear here." />;
|
|
128
130
|
return (
|
|
129
131
|
<div className={`space-y-3 ${className ?? ""}`}>
|
|
130
|
-
<h2 className="text-base font-
|
|
132
|
+
<h2 className="text-base font-medium">{pipeline.name}</h2>
|
|
131
133
|
{error ? <ApiError error={error} title="Could not load deals" onRetry={() => void reload()} /> : null}
|
|
132
134
|
{moveError ? <ApiError error={moveError} title="That move was not saved" /> : null}
|
|
133
135
|
{loading && deals.length === 0 ? <Skeleton className="h-48 w-full" aria-label="Loading deals" /> : (
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PipelineEditor: create, rename, set the default and delete whole pipelines, and keep the close reasons.
|
|
5
|
+
* Stages of one pipeline are edited by StageEditor, which the deals screen shows right below.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in order:
|
|
8
|
+
* 1. GET /deals/pipelines (deals.listPipelines) read - via usePipelines
|
|
9
|
+
* 2. POST /pipelines { name, blank: true } (cliPipelines.createPipeline) write - a blank pipeline (add stages next)
|
|
10
|
+
* PUT /pipelines/{id} { name } (cliPipelines.updatePipeline) write - only the changed field is sent
|
|
11
|
+
* PUT /pipelines/{id} { set_default } (cliPipelines.updatePipeline) write
|
|
12
|
+
* 3. DELETE /pipelines/{id} (cliPipelines.deletePipeline) DESTRUCTIVE -> ConfirmSpendDialog first
|
|
13
|
+
* 4. GET /deals/close-reasons (deals.listDealCloseReasons) read
|
|
14
|
+
* POST /deals/close-reasons { kind, label } (deals.createDealCloseReason) write
|
|
15
|
+
* DELETE /deals/close-reasons/{id} (deals.archiveDealCloseReason) DESTRUCTIVE -> ConfirmSpendDialog (hidden unless the API says you may)
|
|
16
|
+
* Pipelines live under the campaigns:* scopes; close reasons under deals:*. The API refuses to delete a pipeline that
|
|
17
|
+
* still has deals; its message is shown. Not covered by this API: suggesting a pipeline with AI is a billable call this
|
|
18
|
+
* editor leaves out.
|
|
19
|
+
*/
|
|
20
|
+
import { useState } from "react";
|
|
21
|
+
import { Plus } from "lucide-react";
|
|
22
|
+
import { Button, Input, Skeleton } from "@graph8/sdk/ui";
|
|
23
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
24
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
25
|
+
import { g8Delete, g8Get, g8Post, g8Put } from "@/g8/lib/g8-client";
|
|
26
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
27
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
28
|
+
import { usePipelines } from "../hooks/use-pipelines";
|
|
29
|
+
import type { Pipeline } from "../lib/deals-types";
|
|
30
|
+
import { reasonsOf, type CloseReason } from "../lib/deal-extras";
|
|
31
|
+
|
|
32
|
+
export interface PipelineEditorProps {
|
|
33
|
+
/** Called after a pipeline was created, renamed, made default or deleted. */
|
|
34
|
+
onChanged?: (event: { kind: "created" | "renamed" | "default" | "deleted"; pipelineId?: string }) => void;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function PipelineRow({ pipeline, busy, onRename, onDefault, onDelete }: { pipeline: Pipeline; busy: boolean; onRename: (name: string) => void; onDefault: () => void; onDelete: () => void }) {
|
|
39
|
+
const [name, setName] = useState(pipeline.name);
|
|
40
|
+
const changed = name.trim() !== pipeline.name && name.trim() !== "";
|
|
41
|
+
return (
|
|
42
|
+
<li className="flex flex-wrap items-center gap-2 rounded-md border p-2">
|
|
43
|
+
<Input aria-label={`Name of pipeline ${pipeline.name}`} className="w-56" value={name} onChange={(e) => setName(e.target.value)} />
|
|
44
|
+
<Button size="sm" variant="outline" loading={busy} disabled={!changed || busy} aria-label={`Rename ${pipeline.name}`} onClick={() => onRename(name.trim())}>Rename</Button>
|
|
45
|
+
{pipeline.is_default ? <span className="text-xs text-muted-foreground">Default</span> : <Button size="sm" variant="ghost" disabled={busy} aria-label={`Make ${pipeline.name} the default`} onClick={onDefault}>Make default</Button>}
|
|
46
|
+
<Button size="sm" variant="ghost" disabled={busy} aria-label={`Delete pipeline ${pipeline.name}`} onClick={onDelete}>Delete</Button>
|
|
47
|
+
</li>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function CloseReasons() {
|
|
52
|
+
const [kind, setKind] = useState<"won" | "lost">("lost");
|
|
53
|
+
const [label, setLabel] = useState("");
|
|
54
|
+
const [archiving, setArchiving] = useState<CloseReason | null>(null);
|
|
55
|
+
const list = useG8Query(async (signal) => reasonsOf(await g8Get("/deals/close-reasons", { kind }, { signal })), [kind], { key: ["deals", "close-reasons", kind] });
|
|
56
|
+
const add = useG8Mutation(() => g8Post("/deals/close-reasons", { kind, label: label.trim() }), { invalidates: [["deals", "close-reasons"]], onSuccess: () => setLabel("") });
|
|
57
|
+
const archive = useG8Mutation((r: CloseReason) => g8Delete(`/deals/close-reasons/${encodeURIComponent(r.id)}`), { invalidates: [["deals", "close-reasons"]], onSuccess: () => setArchiving(null), onError: () => setArchiving(null) });
|
|
58
|
+
const reasons = (list.data ?? []).filter((r) => !r.kind || r.kind === kind);
|
|
59
|
+
return (
|
|
60
|
+
<section aria-label="Close reasons" className="space-y-2">
|
|
61
|
+
<h3 className="text-base font-semibold">Close reasons</h3>
|
|
62
|
+
<p className="text-sm text-muted-foreground">The reasons offered when a deal is closed. Archiving one keeps it on past deals.</p>
|
|
63
|
+
<div className="flex gap-2">
|
|
64
|
+
<select aria-label="Reason type" className="h-9 rounded-md border bg-background px-2 text-sm" value={kind} onChange={(e) => setKind(e.target.value as "won" | "lost")}>
|
|
65
|
+
<option value="won">Won</option>
|
|
66
|
+
<option value="lost">Lost</option>
|
|
67
|
+
</select>
|
|
68
|
+
<Input aria-label="New close reason" placeholder={`Add a ${kind} reason`} value={label} onChange={(e) => setLabel(e.target.value)} />
|
|
69
|
+
<Button loading={add.loading} disabled={!label.trim()} onClick={() => void add.mutate(undefined)}>Add reason</Button>
|
|
70
|
+
</div>
|
|
71
|
+
{add.error ? <ApiError error={add.error} title="Could not add the reason" /> : null}
|
|
72
|
+
{archive.error ? <ApiError error={archive.error} title="Could not archive the reason" /> : null}
|
|
73
|
+
{list.error ? <ApiError error={list.error} title="Could not load close reasons" onRetry={() => void list.refetch()} /> : null}
|
|
74
|
+
{list.loading && !list.data ? <Skeleton className="h-10 w-full" aria-label="Loading close reasons" /> : null}
|
|
75
|
+
{!list.loading && !list.error && reasons.length === 0 ? <p className="text-sm text-muted-foreground">No {kind} reasons yet.</p> : null}
|
|
76
|
+
<ul className="divide-y rounded-md border empty:hidden" aria-label={`${kind} reasons`}>
|
|
77
|
+
{reasons.map((r) => (
|
|
78
|
+
<li key={r.id} className="flex items-center justify-between gap-2 p-2 text-sm">
|
|
79
|
+
<span>{r.label}</span>
|
|
80
|
+
{r.canArchive ? <Button size="sm" variant="ghost" aria-label={`Archive reason ${r.label}`} onClick={() => setArchiving(r)}>Archive</Button> : null}
|
|
81
|
+
</li>
|
|
82
|
+
))}
|
|
83
|
+
</ul>
|
|
84
|
+
<ConfirmSpendDialog open={archiving != null} tier="destructive" title={`Archive the reason "${archiving?.label}"?`} description="It is no longer offered when closing deals." confirmLabel="Archive reason" affected={1} confirming={archive.loading} onConfirm={() => archiving && void archive.mutate(archiving)} onCancel={() => setArchiving(null)} />
|
|
85
|
+
</section>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function PipelineEditor({ onChanged, className }: PipelineEditorProps) {
|
|
90
|
+
const { pipelines, loading, error, refetch } = usePipelines();
|
|
91
|
+
const [newName, setNewName] = useState("");
|
|
92
|
+
const [removing, setRemoving] = useState<Pipeline | null>(null);
|
|
93
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
94
|
+
const inv = [["deals", "pipelines"]] as const;
|
|
95
|
+
|
|
96
|
+
const create = useG8Mutation(async () => g8Post("/pipelines", { name: newName.trim(), blank: true }), { invalidates: inv, onSuccess: () => { setNewName(""); onChanged?.({ kind: "created" }); } });
|
|
97
|
+
const rename = useG8Mutation(async (v: { id: string; name: string }) => {
|
|
98
|
+
setBusyId(v.id);
|
|
99
|
+
try { await g8Put(`/pipelines/${encodeURIComponent(v.id)}`, { name: v.name }); } finally { setBusyId(null); }
|
|
100
|
+
}, { invalidates: inv, onSuccess: (_r, v) => onChanged?.({ kind: "renamed", pipelineId: v.id }) });
|
|
101
|
+
const makeDefault = useG8Mutation(async (id: string) => {
|
|
102
|
+
setBusyId(id);
|
|
103
|
+
try { await g8Put(`/pipelines/${encodeURIComponent(id)}`, { set_default: true }); } finally { setBusyId(null); }
|
|
104
|
+
}, { invalidates: inv, onSuccess: (_r, id) => onChanged?.({ kind: "default", pipelineId: id }) });
|
|
105
|
+
const remove = useG8Mutation(async (p: Pipeline) => {
|
|
106
|
+
setBusyId(p.id);
|
|
107
|
+
try { await g8Delete(`/pipelines/${encodeURIComponent(p.id)}`); } finally { setBusyId(null); }
|
|
108
|
+
}, { invalidates: inv, onSuccess: (_r, p) => { setRemoving(null); onChanged?.({ kind: "deleted", pipelineId: p.id }); }, onError: () => setRemoving(null) });
|
|
109
|
+
const failure = create.error ?? rename.error ?? makeDefault.error ?? remove.error;
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className={`space-y-6 ${className ?? ""}`}>
|
|
113
|
+
<section aria-label="Pipelines" className="space-y-2">
|
|
114
|
+
<h3 className="text-base font-semibold">Pipelines</h3>
|
|
115
|
+
{error ? <ApiError error={error} title="Could not load pipelines" onRetry={() => void refetch()} /> : null}
|
|
116
|
+
{loading && pipelines.length === 0 ? <Skeleton className="h-16 w-full" aria-label="Loading pipelines" /> : null}
|
|
117
|
+
{failure ? <ApiError error={failure} title="That pipeline change failed" /> : null}
|
|
118
|
+
<ul className="space-y-2">
|
|
119
|
+
{pipelines.map((p) => (
|
|
120
|
+
<PipelineRow key={`${p.id}:${p.name}`} pipeline={p} busy={busyId === p.id} onRename={(name) => void rename.mutate({ id: p.id, name })} onDefault={() => void makeDefault.mutate(p.id)} onDelete={() => setRemoving(p)} />
|
|
121
|
+
))}
|
|
122
|
+
</ul>
|
|
123
|
+
<form className="flex gap-2" onSubmit={(e) => { e.preventDefault(); if (newName.trim()) void create.mutate(undefined); }}>
|
|
124
|
+
<Input aria-label="New pipeline name" placeholder="New pipeline name" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
|
125
|
+
<Button type="submit" loading={create.loading} disabled={!newName.trim()}><Plus className="size-4" aria-hidden />Create pipeline</Button>
|
|
126
|
+
</form>
|
|
127
|
+
</section>
|
|
128
|
+
<CloseReasons />
|
|
129
|
+
<ConfirmSpendDialog open={removing != null} tier="destructive" title={`Delete the "${removing?.name}" pipeline?`} description="Its stages are removed too. The API refuses while deals are still in it: move them to another pipeline first." confirmLabel="Delete pipeline" affected={1} confirming={remove.loading} onConfirm={() => removing && void remove.mutate(removing)} onCancel={() => setRemoving(null)} />
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RecordDealsTab: the deals linked to one contact or company, each opening in the deals screen. Use it as an
|
|
5
|
+
* `extraDetailTabs` entry of ContactsScreen / CompaniesScreen:
|
|
6
|
+
* { id: "deals", label: "Deals", render: (c) => <RecordDealsTab kind="contacts" id={c.id} onOpenDeal={(id) => router.push(`/deals?id=${id}`)} /> }
|
|
7
|
+
*
|
|
8
|
+
* SDK operations, in order:
|
|
9
|
+
* 1. getContactDeals GET /contacts/{id}/deals (read) when kind="contacts"
|
|
10
|
+
* getCompanyDeals GET /companies/{id}/deals (read) when kind="companies"
|
|
11
|
+
* Nothing here is billable, external or destructive.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Skeleton } from "@graph8/sdk/ui";
|
|
15
|
+
import { Handshake } from "lucide-react";
|
|
16
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
17
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
18
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
19
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
20
|
+
import { dataOf, formatMoney } from "../lib/deals-types";
|
|
21
|
+
|
|
22
|
+
/** One row of GET /contacts/{id}/deals and GET /companies/{id}/deals (the API's ContactDealResponse). */
|
|
23
|
+
interface LinkedDeal { deal_id?: string | null; name?: string | null; stage?: string | null; value?: number | null; currency?: string | null; role?: string | null }
|
|
24
|
+
|
|
25
|
+
export interface RecordDealsTabProps {
|
|
26
|
+
kind: "contacts" | "companies";
|
|
27
|
+
id: number;
|
|
28
|
+
/** Route to the deal in your deals screen. Without it the rows link to `${dealsPath}?id=<deal id>`. */
|
|
29
|
+
onOpenDeal?: (dealId: string) => void;
|
|
30
|
+
/** Path of your deals screen for the plain links. Default "/deals". */
|
|
31
|
+
dealsPath?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function RecordDealsTab({ kind, id, onOpenDeal, dealsPath = "/deals" }: RecordDealsTabProps) {
|
|
35
|
+
const q = useG8Query(async (signal) => {
|
|
36
|
+
const inner = dataOf<{ items?: LinkedDeal[] } | LinkedDeal[]>(await g8Get(`/${kind}/${id}/deals`, undefined, { signal }));
|
|
37
|
+
return (Array.isArray(inner) ? inner : (inner.items ?? [])).filter((d) => d.deal_id);
|
|
38
|
+
}, [kind, id]);
|
|
39
|
+
if (q.error) return <ApiError error={q.error} title="Could not load deals" onRetry={() => void q.refetch()} />;
|
|
40
|
+
if (!q.data) return <Skeleton className="h-24 w-full" aria-label="Loading deals" />;
|
|
41
|
+
if (q.data.length === 0) return <EmptyState compact icon={<Handshake className="size-8" />} title="No deals yet" description="Deals linked to this record show up here." />;
|
|
42
|
+
return (
|
|
43
|
+
<ul className="divide-y rounded-md border" aria-label="Deals">
|
|
44
|
+
{q.data.map((d) => (
|
|
45
|
+
<li key={d.deal_id}>
|
|
46
|
+
<a
|
|
47
|
+
href={`${dealsPath}?id=${encodeURIComponent(d.deal_id as string)}`}
|
|
48
|
+
className="flex items-center justify-between gap-3 p-3 text-sm hover:bg-muted"
|
|
49
|
+
onClick={(e) => {
|
|
50
|
+
if (!onOpenDeal || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
onOpenDeal(d.deal_id as string);
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<span className="min-w-0 truncate font-medium">{d.name || "Untitled deal"}</span>
|
|
56
|
+
<span className="shrink-0 text-xs text-muted-foreground">{[d.stage, d.value != null ? formatMoney(d.value, d.currency) : null].filter(Boolean).join(" · ")}</span>
|
|
57
|
+
</a>
|
|
58
|
+
</li>
|
|
59
|
+
))}
|
|
60
|
+
</ul>
|
|
61
|
+
);
|
|
62
|
+
}
|