@graph8/sdk 0.370.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 +19 -0
- package/blocks/_foundation/README.md +14 -0
- package/blocks/_foundation/block.json +44 -0
- package/blocks/_foundation/components/confirm-spend-dialog.tsx +15 -8
- 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 +1 -0
- package/blocks/_foundation/components/screen/split-view.tsx +1 -1
- package/blocks/_foundation/hooks/use-all-pages.ts +54 -0
- package/blocks/_foundation/hooks/use-load-more-list.ts +61 -0
- package/blocks/_foundation/hooks/use-url-filters.ts +93 -0
- package/blocks/_foundation/lib/paging.ts +57 -0
- package/blocks/_foundation/lib/use-debounced-value.ts +13 -0
- package/blocks/agents/README.md +81 -4
- package/blocks/agents/block.json +311 -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 +17 -3
- package/blocks/agents/components/agent-conversations.tsx +143 -0
- package/blocks/agents/components/agent-knowledge-panel.tsx +2 -2
- package/blocks/agents/components/agent-operators.tsx +164 -0
- package/blocks/agents/components/agent-run-detail.tsx +20 -3
- package/blocks/agents/components/agent-runs.tsx +3 -3
- 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 +53 -11
- 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/components/analytics-screen.tsx +16 -3
- package/blocks/analytics/components/kpi-tiles.tsx +1 -1
- package/blocks/analytics/components/report-builder.tsx +15 -2
- package/blocks/appointments/README.md +6 -0
- package/blocks/appointments/block.json +27 -0
- package/blocks/appointments/components/booking-page.tsx +2 -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 +29 -13
- package/blocks/appointments/components/record-meetings-tab.tsx +92 -0
- package/blocks/campaigns/components/campaign-assets.tsx +1 -1
- package/blocks/campaigns/components/campaign-launch-step.tsx +16 -11
- package/blocks/campaigns/components/campaign-metrics.tsx +1 -1
- package/blocks/campaigns/components/campaign-wizard.tsx +10 -5
- package/blocks/campaigns/components/icp-detail.tsx +11 -2
- package/blocks/campaigns/components/icp-editor.tsx +8 -3
- package/blocks/campaigns/components/persona-detail.tsx +11 -2
- package/blocks/campaigns/components/persona-editor.tsx +6 -1
- package/blocks/campaigns/lib/campaigns-utils.ts +4 -4
- package/blocks/coach/components/call-scorecard.tsx +7 -7
- 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/components/copilot-memory.tsx +6 -2
- package/blocks/copilot/components/copilot-panel.tsx +1 -1
- package/blocks/copilot/components/copilot-team-chat.tsx +6 -1
- package/blocks/crm/README.md +6 -0
- package/blocks/crm/block.json +53 -4
- package/blocks/crm/components/companies-screen.tsx +23 -9
- package/blocks/crm/components/company-detail.tsx +2 -2
- package/blocks/crm/components/contact-detail.tsx +4 -4
- package/blocks/crm/components/contact-lookup-link.tsx +44 -0
- package/blocks/crm/components/contacts-screen.tsx +31 -13
- package/blocks/crm/components/dedupe-review.tsx +6 -1
- package/blocks/crm/components/import-wizard.tsx +3 -7
- package/blocks/crm/components/list-select.tsx +20 -0
- package/blocks/crm/components/record-engagement-tab.tsx +103 -0
- package/blocks/crm/components/record-form.tsx +1 -1
- package/blocks/crm/components/tasks-screen.tsx +35 -15
- package/blocks/crm/hooks/use-all-lists.ts +18 -0
- package/blocks/crm/hooks/use-page-query.ts +3 -1
- package/blocks/crm/lib/crm-types.ts +14 -0
- package/blocks/crm/lib/read-lists.ts +16 -0
- package/blocks/deals/README.md +24 -6
- package/blocks/deals/block.json +151 -2
- 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 +2 -2
- 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 +30 -9
- package/blocks/deals/components/deal-tasks-tab.tsx +88 -0
- package/blocks/deals/components/deals-screen.tsx +57 -22
- 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 +1 -1
- package/blocks/deals/hooks/use-pipeline-deals.ts +17 -4
- package/blocks/deals/lib/deal-extras.ts +156 -0
- package/blocks/dialer/README.md +14 -5
- package/blocks/dialer/block.json +17 -2
- package/blocks/dialer/components/call-contact-button.tsx +26 -9
- package/blocks/dialer/components/call-log-screen.tsx +4 -1
- package/blocks/dialer/components/call-log.tsx +16 -4
- package/blocks/dialer/components/call-queue.tsx +3 -3
- package/blocks/dialer/components/dialer-screen.tsx +69 -27
- package/blocks/dialer/components/disposition-panel.tsx +1 -1
- package/blocks/dialer/components/record-calls-tab.tsx +101 -0
- package/blocks/dialer/components/session-start-form.tsx +58 -11
- package/blocks/dialer/components/softphone.tsx +1 -1
- package/blocks/enrichment/README.md +16 -6
- package/blocks/enrichment/block.json +19 -7
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +76 -27
- package/blocks/enrichment/components/email-verify-badge.tsx +3 -3
- package/blocks/enrichment/components/enrich-button.tsx +1 -1
- package/blocks/enrichment/components/enrichment-screen.tsx +70 -17
- package/blocks/enrichment/components/waterfall-config.tsx +27 -7
- package/blocks/enrichment/lib/enrichment-types.ts +39 -0
- package/blocks/enrichment/recipes/bulk-enrich-and-poll.ts +15 -4
- package/blocks/forms/README.md +8 -0
- package/blocks/forms/block.json +107 -0
- 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 +1 -1
- package/blocks/forms/components/form-embed.tsx +1 -1
- 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 +51 -17
- 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 +15 -6
- package/blocks/forms/lib/forms-extra.ts +67 -0
- package/blocks/inbox/README.md +5 -0
- package/blocks/inbox/block.json +15 -0
- package/blocks/inbox/components/record-conversations-tab.tsx +154 -0
- package/blocks/inbox/components/thread-badge.tsx +2 -2
- package/blocks/inbox/components/thread-view.tsx +1 -1
- package/blocks/integrations/README.md +54 -1
- package/blocks/integrations/block.json +189 -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 +20 -1
- 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 +4 -4
- package/blocks/integrations/components/integrations-screen.tsx +20 -3
- 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 +6 -6
- package/blocks/integrations/components/sync-triggers.tsx +134 -0
- package/blocks/integrations/components/webhook-detail.tsx +26 -7
- package/blocks/integrations/components/webhook-manager.tsx +30 -17
- 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 +5 -0
- package/blocks/integrations/lib/load-more.tsx +141 -0
- package/blocks/intent/components/account-intent-score.tsx +5 -5
- package/blocks/intent/components/intent-feed.tsx +8 -8
- package/blocks/intent/components/intent-keyword-manager.tsx +8 -8
- package/blocks/intent/lib/intent-utils.ts +4 -4
- 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/lists/README.md +7 -1
- package/blocks/lists/block.json +31 -3
- package/blocks/lists/components/add-company-people-to-list-button.tsx +73 -0
- package/blocks/lists/components/add-to-list-button.tsx +17 -7
- package/blocks/lists/components/list-picker.tsx +13 -3
- package/blocks/lists/components/list-table.tsx +1 -1
- package/blocks/lists/components/lists-screen.tsx +4 -2
- package/blocks/lists/components/record-lists-tab.tsx +99 -0
- package/blocks/lists/components/suppression-manager.tsx +27 -6
- package/blocks/lists/components/suppressions-screen.tsx +21 -12
- package/blocks/mailboxes/block.json +4 -0
- package/blocks/mailboxes/components/connect-mailbox.tsx +9 -8
- package/blocks/mailboxes/components/domain-detail.tsx +10 -4
- package/blocks/mailboxes/components/domain-purchase.tsx +14 -11
- package/blocks/mailboxes/components/mailbox-detail.tsx +3 -2
- package/blocks/mailboxes/components/mailbox-list.tsx +8 -3
- package/blocks/mailboxes/components/mailboxes-screen.tsx +8 -3
- package/blocks/mailboxes/components/warmup-health.tsx +20 -20
- package/blocks/mailboxes/lib/mailboxes-utils.ts +4 -4
- package/blocks/mailboxes/lib/use-provider-domains.ts +23 -0
- package/blocks/newsletter/README.md +12 -0
- package/blocks/newsletter/block.json +117 -0
- package/blocks/newsletter/components/issue-scheduler.tsx +2 -2
- 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 +36 -13
- 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/components/nurture-builder.tsx +10 -2
- package/blocks/nurture/components/nurture-enroll.tsx +6 -7
- package/blocks/nurture/components/nurture-form.tsx +5 -8
- package/blocks/nurture/components/nurture-screen.tsx +16 -5
- package/blocks/nurture/components/nurture-stats.tsx +5 -4
- package/blocks/objects/README.md +1 -1
- package/blocks/objects/block.json +13 -5
- package/blocks/objects/components/attribute-field-input.tsx +14 -1
- package/blocks/objects/components/object-record-detail.tsx +8 -1
- package/blocks/objects/components/object-schema-editor.tsx +1 -1
- package/blocks/objects/components/record-roles-panel.tsx +15 -5
- package/blocks/pages/README.md +14 -0
- package/blocks/pages/block.json +113 -1
- 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-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 +48 -1
- package/blocks/quotes/components/create-quote-from-deal.tsx +48 -0
- 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 +3 -2
- package/blocks/quotes/components/quote-viewer.tsx +7 -2
- package/blocks/quotes/components/record-quotes-tab.tsx +90 -0
- package/blocks/radar/components/competitor-battle-card.tsx +1 -1
- package/blocks/radar/components/radar-overview.tsx +4 -4
- package/blocks/radar/components/radar-screen.tsx +2 -2
- package/blocks/sequencer/README.md +6 -0
- package/blocks/sequencer/block.json +28 -1
- package/blocks/sequencer/components/add-to-sequence-button.tsx +82 -0
- package/blocks/sequencer/components/enroll-dialog.tsx +2 -2
- package/blocks/sequencer/components/record-sequences-tab.tsx +126 -0
- package/blocks/sequencer/components/sequence-builder.tsx +19 -20
- package/blocks/sequencer/components/sequence-detail.tsx +4 -2
- package/blocks/sequencer/components/sequence-enrollments.tsx +4 -2
- package/blocks/sequencer/components/sequence-list.tsx +1 -1
- package/blocks/sequencer/components/sequence-stats.tsx +8 -8
- package/blocks/sequencer/components/sequences-screen.tsx +3 -0
- package/blocks/sequencer/components/step-editor.tsx +11 -11
- package/blocks/sequencer/lib/sequencer-utils.ts +5 -5
- package/blocks/social-ads/README.md +8 -0
- package/blocks/social-ads/block.json +137 -4
- package/blocks/social-ads/components/audience-sync-detail.tsx +9 -3
- package/blocks/social-ads/components/audience-sync.tsx +6 -2
- package/blocks/social-ads/components/campaign-ads.tsx +166 -0
- package/blocks/social-ads/components/campaign-detail.tsx +6 -1
- 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 +6 -1
- package/blocks/social-ads/components/creative-gallery.tsx +20 -2
- 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 +4 -2
- package/blocks/social-ads/components/post-detail.tsx +3 -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-screen.tsx +33 -6
- 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/components/snippet-install.tsx +18 -9
- package/blocks/visitors/components/traffic-chart.tsx +4 -4
- package/blocks/visitors/components/visitor-company-card.tsx +24 -14
- package/blocks/visitors/components/visitor-feed.tsx +5 -5
- package/blocks/webchat/components/chat-widget-config.tsx +1 -1
- package/blocks/webchat/components/conversation-list.tsx +1 -1
- package/blocks/workbench/components/column-recipe-editor.tsx +1 -1
- package/blocks/workbench/components/run-progress.tsx +1 -1
- package/blocks/workbench/components/workbench-grid.tsx +1 -1
- package/blocks/workbench/components/workbench-screen.tsx +15 -5
- package/blocks/workbench/components/workbench-table-list.tsx +14 -2
- package/blocks/workflows/components/workflow-editor.tsx +1 -1
- package/dist/cli.js +536 -392
- package/dist/index.browser.js +1 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +1 -1
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +1 -1
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +1 -1
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +1 -1
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/brand.d.ts +11 -2
- package/dist/types/blocks/create.d.ts +7 -3
- package/dist/types/blocks/doctor.d.ts +1 -0
- package/dist/types/blocks/flows.d.ts +7 -1
- package/dist/types/blocks/orgBrand.d.ts +34 -0
- package/dist/types/cli.d.ts +3 -0
- package/dist/types/version.d.ts +2 -2
- package/docs/api/desk.d.ts +2 -2
- package/docs/api/desk.md +3 -3
- package/package.json +1 -1
- 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 +17 -4
- package/stacks/apollo/pages/calls.tsx.tmpl +23 -0
- package/stacks/apollo/pages/companies.tsx.tmpl +19 -36
- package/stacks/apollo/pages/contacts.tsx.tmpl +29 -95
- 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 +36 -8
- 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 +8 -4
- package/stacks/clay/pages/companies.tsx.tmpl +17 -1
- package/stacks/clay/pages/contacts.tsx.tmpl +12 -0
- package/stacks/clay/pages/lists.tsx.tmpl +12 -0
- package/stacks/clay/pages/tasks.tsx.tmpl +19 -0
- package/stacks/clay.json +16 -3
- 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 +23 -6
- 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 +9 -4
- package/stacks/hubspot/pages/companies.tsx.tmpl +14 -35
- package/stacks/hubspot/pages/contacts.tsx.tmpl +29 -95
- package/stacks/hubspot/pages/deals.tsx.tmpl +10 -1
- package/stacks/hubspot/pages/inbox.tsx.tmpl +12 -1
- 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/sequences.tsx.tmpl +12 -0
- package/stacks/hubspot/pages/tasks.tsx.tmpl +20 -0
- package/stacks/hubspot.json +21 -5
- 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 +8 -4
- 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 +20 -5
- 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 +27 -66
- 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 +25 -7
- package/stacks/pipedrive/pages/companies.tsx.tmpl +11 -35
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +15 -35
- package/stacks/pipedrive/pages/deals.tsx.tmpl +10 -1
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +12 -1
- package/stacks/pipedrive/pages/meetings.tsx.tmpl +13 -0
- package/stacks/pipedrive/pages/tasks.tsx.tmpl +20 -0
- package/stacks/pipedrive.json +4 -2
- package/stacks/salesforce/pages/companies.tsx.tmpl +14 -35
- package/stacks/salesforce/pages/contacts.tsx.tmpl +20 -35
- package/stacks/salesforce/pages/deals.tsx.tmpl +10 -1
- package/stacks/salesforce/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/salesforce/pages/lists.tsx.tmpl +12 -0
- package/stacks/salesforce/pages/tasks.tsx.tmpl +20 -0
- package/stacks/salesforce.json +7 -3
- package/stacks/zoominfo/pages/companies.tsx.tmpl +17 -1
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +12 -0
- package/stacks/zoominfo/pages/lists.tsx.tmpl +12 -0
- package/stacks/zoominfo/pages/tasks.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +16 -3
- package/styles/styles.css +1 -1
- package/templates/next-app/app/globals.css.tmpl +349 -64
- package/templates/next-app/components/app-shell.tsx.tmpl +6 -0
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +4 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ConnectionHealth: is this CRM connection actually working? Its connection record, its live status check ("Recheck" asks the CRM again)
|
|
5
|
+
* and the CRM's account metadata (which edition, which objects it exposes). Reads only; nothing here changes the connection.
|
|
6
|
+
* The checks are read-only but reach the CRM, so a CRM that is down answers with an error and Retry rather than a stale "connected".
|
|
7
|
+
*
|
|
8
|
+
* SDK operations, in order:
|
|
9
|
+
* 1. getCrmConnection GET /integrations/crm/connections/{connection_id} (read)
|
|
10
|
+
* 2. getCrmConnectionStatus GET /integrations/crm/connections/{connection_id}/status (read)
|
|
11
|
+
* 3. getCrmConnectionMetadata GET /integrations/crm/connections/{connection_id}/metadata?crm_type (read)
|
|
12
|
+
* 4. getLastCompletedMigration GET /integrations/connections/{connection_id}/last-migration (read) last historic import
|
|
13
|
+
*/
|
|
14
|
+
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
15
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
16
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
17
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
18
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
19
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
20
|
+
import { FactList } from "../lib/facts";
|
|
21
|
+
import { crmType } from "../lib/integrations-utils";
|
|
22
|
+
|
|
23
|
+
export interface ConnectionHealthProps {
|
|
24
|
+
connectionId: string;
|
|
25
|
+
/** hubspot, salesforce, ... (sent as crm_type for the metadata read). */
|
|
26
|
+
provider: string;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const enc = encodeURIComponent;
|
|
31
|
+
const data = (res: unknown): Record<string, unknown> => ((res as { data?: Record<string, unknown> } | null)?.data ?? {}) as Record<string, unknown>;
|
|
32
|
+
|
|
33
|
+
function Card({ title, query, empty }: { title: string; query: { data: unknown; error: unknown; loading: boolean; refetch: () => Promise<unknown> }; empty: string }) {
|
|
34
|
+
return (
|
|
35
|
+
<SectionCard title={title} actions={<Button size="sm" variant="ghost" onClick={() => void query.refetch()}>Recheck</Button>}>
|
|
36
|
+
{query.error ? <ApiError error={query.error} title={`Could not check: ${title.toLowerCase()}`} onRetry={() => void query.refetch()} /> : null}
|
|
37
|
+
{query.loading && !query.data ? <Skeleton className="h-12 w-full" /> : null}
|
|
38
|
+
{query.data && !query.error ? <FactList data={data(query.data)} empty={empty} /> : null}
|
|
39
|
+
</SectionCard>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ConnectionHealth({ connectionId, provider, className }: ConnectionHealthProps) {
|
|
44
|
+
const connection = useG8Query((signal) => g8Get(`/integrations/crm/connections/${enc(connectionId)}`, undefined, { signal }), [connectionId]);
|
|
45
|
+
const status = useG8Query((signal) => g8Get(`/integrations/crm/connections/${enc(connectionId)}/status`, undefined, { signal }), [connectionId]);
|
|
46
|
+
const metadata = useG8Query((signal) => g8Get(`/integrations/crm/connections/${enc(connectionId)}/metadata`, { crm_type: crmType(provider) }, { signal }), [connectionId, provider]);
|
|
47
|
+
const last = useG8Query((signal) => g8Get(`/integrations/connections/${enc(connectionId)}/last-migration`, undefined, { signal }), [connectionId]);
|
|
48
|
+
const s = data(status.data);
|
|
49
|
+
const ok = s.connected ?? s.is_connected ?? s.healthy;
|
|
50
|
+
return (
|
|
51
|
+
<div className={`flex flex-col gap-4 ${className ?? ""}`}>
|
|
52
|
+
{status.data && !status.error ? <div className="flex items-center gap-2"><StatusPill tone={ok === false ? "negative" : "positive"} dot>{ok === false ? "Not healthy" : "Healthy"}</StatusPill><span className="text-xs text-muted-foreground">Checked just now</span></div> : null}
|
|
53
|
+
<Card title="Live status" query={status} empty="The CRM reported no details." />
|
|
54
|
+
<Card title="Connection" query={connection} empty="No connection details." />
|
|
55
|
+
<Card title="CRM account" query={metadata} empty="The CRM reported no account details." />
|
|
56
|
+
<Card title="Last historic import" query={last} empty="No historic import has completed for this connection." />
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* 2. listConnections GET /integrations/connections (read; the connection to disconnect)
|
|
12
12
|
* 3. deleteConnection DELETE /integrations/connections/{id}?provider_config_key= (destructive, confirmed)
|
|
13
13
|
* 4. SyncStatus see sync-status.tsx (getCrmStatus, getCrmFields, set push/pull enabled, getSyncJob, pushContactsToCrm)
|
|
14
|
+
* A connected CRM also gets: Health (ConnectionHealth), Sync now (SyncTriggers: push/pull contacts, deals and leads as background jobs),
|
|
15
|
+
* CRM records (CrmRecords: browse what is in the CRM), Fields (FieldMappings) and Historic import (MigrationPanel). SugarCRM, whose sign-in
|
|
16
|
+
* the API can run, gets a Connection tab (SugarCrmConnect) instead of the link-out; the other CRMs link out because their OAuth screen only
|
|
17
|
+
* runs in the graph8 app.
|
|
14
18
|
*/
|
|
15
19
|
import type { ReactNode } from "react";
|
|
16
20
|
import { Skeleton } from "@graph8/sdk/ui";
|
|
@@ -25,7 +29,13 @@ import { arrayOf, formatWhen, GRAPH8_APP_INTEGRATIONS_PATH, PROVIDER_LABELS } fr
|
|
|
25
29
|
import type { ConnectionRow, CrmProvider, CrmSyncRow } from "../lib/integrations-utils";
|
|
26
30
|
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
27
31
|
import { AskButton, useConfirmAction } from "@/g8/components/confirmed-action";
|
|
32
|
+
import { ConnectionHealth } from "./connection-health";
|
|
33
|
+
import { CrmRecords } from "./crm-records";
|
|
34
|
+
import { FieldMappings } from "./field-mappings";
|
|
35
|
+
import { MigrationPanel } from "./migration-panel";
|
|
36
|
+
import { SugarCrmConnect } from "./sugarcrm-connect";
|
|
28
37
|
import { SyncStatus } from "./sync-status";
|
|
38
|
+
import { SyncTriggers } from "./sync-triggers";
|
|
29
39
|
import { brandUrl, useBrand } from "@/g8/hooks/use-brand";
|
|
30
40
|
|
|
31
41
|
export interface CrmDetailProps {
|
|
@@ -60,6 +70,7 @@ export function CrmDetail({ provider, connectUrl: connectUrlProp, records, onClo
|
|
|
60
70
|
const connected = Boolean(row && CONNECTED.includes(row.status?.toLowerCase()));
|
|
61
71
|
const all = arrayOf<ConnectionRow>(connections.data).filter((c) => (c.provider ?? "").toLowerCase() === provider || (c.provider_config_key ?? "").toLowerCase().split(/[-_]/)[0] === provider);
|
|
62
72
|
const connection = all.find((c) => row?.connection_id && c.connection_id === row.connection_id) ?? (all.length === 1 ? all[0] : undefined);
|
|
73
|
+
const connectionId = row?.connection_id ?? connection?.connection_id ?? "";
|
|
63
74
|
const changed = () => { void syncs.refetch(); void connections.refetch(); onChanged?.(); };
|
|
64
75
|
|
|
65
76
|
const actions = connected ? (
|
|
@@ -68,7 +79,7 @@ export function CrmDetail({ provider, connectUrl: connectUrlProp, records, onClo
|
|
|
68
79
|
description="Syncs configured against this connection stop working. Records already synced stay."
|
|
69
80
|
run={() => g8Delete(`/integrations/connections/${connection!.connection_id}`, { query: { provider_config_key: connection!.provider_config_key } })} onDone={changed} />
|
|
70
81
|
) : (
|
|
71
|
-
<a className="text-sm underline" href={connectUrl} target="_blank" rel="noreferrer">Connect in {brand.linkLabel}</a>
|
|
82
|
+
provider === "sugarcrm" ? null : <a className="text-sm underline" href={connectUrl} target="_blank" rel="noreferrer">Connect in {brand.linkLabel}</a>
|
|
72
83
|
);
|
|
73
84
|
|
|
74
85
|
const tabs = [
|
|
@@ -87,6 +98,14 @@ export function CrmDetail({ provider, connectUrl: connectUrlProp, records, onClo
|
|
|
87
98
|
),
|
|
88
99
|
},
|
|
89
100
|
{ id: "sync", label: "Sync", content: <SyncStatus provider={provider} records={records} /> },
|
|
101
|
+
...(provider === "sugarcrm" ? [{ id: "connection", label: "Connection", content: <SugarCrmConnect connected={connected} onChanged={changed} /> }] : []),
|
|
102
|
+
...(connected && connectionId ? [
|
|
103
|
+
{ id: "health", label: "Health", content: <ConnectionHealth connectionId={connectionId} provider={provider} /> },
|
|
104
|
+
{ id: "sync-now", label: "Sync now", content: <SyncTriggers connectionId={connectionId} provider={provider} /> },
|
|
105
|
+
{ id: "records", label: "CRM records", content: <CrmRecords connectionId={connectionId} /> },
|
|
106
|
+
{ id: "fields", label: "Fields", content: <FieldMappings connectionId={connectionId} /> },
|
|
107
|
+
{ id: "import", label: "Historic import", content: <MigrationPanel connectionId={connectionId} provider={provider} /> },
|
|
108
|
+
] : []),
|
|
90
109
|
];
|
|
91
110
|
return (
|
|
92
111
|
<>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CrmRecords: look inside a connected CRM without leaving graph8. Contacts, Companies, Deals and Leads as lists (25 at a time, "Load more"
|
|
5
|
+
* pages through all of them), the CRM's own activity log, and a record's fields (plus a contact's activities) when you open one.
|
|
6
|
+
* Read only: changing CRM records from here is deliberately not offered (use the sync to push graph8 data; edit in the CRM itself).
|
|
7
|
+
* Not every CRM has every kind (Pipedrive has no leads in the same sense); a kind the CRM refuses shows its error with Retry.
|
|
8
|
+
*
|
|
9
|
+
* SDK operations, in order (connection_id from the connection):
|
|
10
|
+
* 1. listCrmContacts / listCrmCompanies / listCrmDeals / listCrmLeads GET /integrations/crm/connections/{connection_id}/{kind}?limit&offset[&name] (read)
|
|
11
|
+
* 2. getCrmContact / getCrmCompany / getCrmDeal / getCrmLead GET /integrations/crm/connections/{connection_id}/{kind}/{id} (read)
|
|
12
|
+
* 3. listCrmContactActivities GET /integrations/crm/connections/{connection_id}/contacts/{contact_id}/activities?limit (read)
|
|
13
|
+
* 4. listCrmActivities GET /integrations/crm/activities?connection_id&limit&offset (read)
|
|
14
|
+
*/
|
|
15
|
+
import { useEffect, useState } from "react";
|
|
16
|
+
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
17
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
18
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
19
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
20
|
+
import { Toolbar } from "@/g8/components/screen/toolbar";
|
|
21
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
22
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
23
|
+
import { CRM_ENTITIES, crmRecordSubtitle, crmRecordTitle, flattenRecord } from "../lib/crm-utils";
|
|
24
|
+
import type { CrmEntity, CrmRecord } from "../lib/crm-utils";
|
|
25
|
+
import { FactList } from "../lib/facts";
|
|
26
|
+
import { LoadMore, useLoadMoreList } from "../lib/load-more";
|
|
27
|
+
import { arrayOf, formatWhen } from "../lib/integrations-utils";
|
|
28
|
+
|
|
29
|
+
export interface CrmRecordsProps {
|
|
30
|
+
connectionId: string;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Tab = CrmEntity | "activity";
|
|
35
|
+
const enc = encodeURIComponent;
|
|
36
|
+
|
|
37
|
+
function RecordFacts({ connectionId, entity, id }: { connectionId: string; entity: CrmEntity; id: string }) {
|
|
38
|
+
const rec = useG8Query((signal) => g8Get<CrmRecord>(`/integrations/crm/connections/${enc(connectionId)}/${entity}/${enc(id)}`, undefined, { signal }), [connectionId, entity, id]);
|
|
39
|
+
const acts = useG8Query((signal) => g8Get(`/integrations/crm/connections/${enc(connectionId)}/contacts/${enc(id)}/activities`, { limit: 20 }, { signal }), [connectionId, id], { enabled: entity === "contacts" });
|
|
40
|
+
const data = ((rec.data as { data?: CrmRecord } | undefined)?.data ?? {}) as CrmRecord;
|
|
41
|
+
const rows = arrayOf<Record<string, unknown>>(acts.data);
|
|
42
|
+
return (
|
|
43
|
+
<SectionCard title={crmRecordTitle(data) || "Record"} description={rec.loading ? "Loading..." : crmRecordSubtitle(data) || undefined}>
|
|
44
|
+
{rec.error ? <ApiError error={rec.error} title="Could not load the record" onRetry={() => void rec.refetch()} /> : null}
|
|
45
|
+
{rec.loading && !rec.data ? <Skeleton className="h-16 w-full" /> : null}
|
|
46
|
+
{rec.data ? <FactList data={flattenRecord(data)} empty="This record has no fields we can show." /> : null}
|
|
47
|
+
{entity === "contacts" ? (
|
|
48
|
+
<div className="mt-4">
|
|
49
|
+
<h4 className="mb-1 text-sm font-medium">Recent activity</h4>
|
|
50
|
+
{acts.error ? <ApiError compact error={acts.error} title="Could not load the activities" onRetry={() => void acts.refetch()} /> : null}
|
|
51
|
+
{acts.loading && !acts.data ? <Skeleton className="h-8 w-full" /> : null}
|
|
52
|
+
{acts.data && rows.length === 0 ? <p className="text-sm text-muted-foreground">No activity in the CRM.</p> : null}
|
|
53
|
+
{rows.length > 0 ? <><ul className="divide-y rounded-md border text-sm">{rows.map((a, i) => <li key={String(a.id ?? i)} className="px-3 py-1.5">{String(a.subject ?? a.type ?? a.title ?? "Activity")}<span className="ml-2 text-xs text-muted-foreground">{formatWhen(String(a.timestamp ?? a.created_at ?? a.date ?? "") || null)}</span></li>)}</ul>{rows.length >= 20 ? <p className="mt-1 text-xs text-muted-foreground">Showing the latest 20 activities.</p> : null}</> : null}
|
|
54
|
+
</div>
|
|
55
|
+
) : null}
|
|
56
|
+
</SectionCard>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function CrmRecords({ connectionId, className }: CrmRecordsProps) {
|
|
61
|
+
const [tab, setTab] = useState<Tab>("contacts");
|
|
62
|
+
const [search, setSearch] = useState("");
|
|
63
|
+
const [open, setOpen] = useState<string | null>(null);
|
|
64
|
+
useEffect(() => { setOpen(null); setSearch(""); }, [connectionId, tab]);
|
|
65
|
+
const q = search.trim();
|
|
66
|
+
const list = useLoadMoreList<CrmRecord>(async (offset, limit, signal) => {
|
|
67
|
+
const base = `/integrations/crm/connections/${enc(connectionId)}`;
|
|
68
|
+
const res = tab === "activity" ? await g8Get("/integrations/crm/activities", { connection_id: connectionId, limit, offset }, { signal })
|
|
69
|
+
: await g8Get(`${base}/${tab}`, { limit, offset, name: tab === "contacts" ? q || undefined : undefined }, { signal });
|
|
70
|
+
return arrayOf<CrmRecord>(res);
|
|
71
|
+
}, [connectionId, tab, q], { pageSize: 25 });
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className={`flex flex-col gap-3 ${className ?? ""}`}>
|
|
75
|
+
<div role="tablist" aria-label="CRM record kinds" className="flex flex-wrap gap-1 border-b">
|
|
76
|
+
{([...CRM_ENTITIES, { id: "activity", label: "Activity log" }] as const).map((t) => (
|
|
77
|
+
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id as Tab)} className={`px-3 py-2 text-sm ${tab === t.id ? "border-b-2 border-foreground font-medium" : "text-muted-foreground"}`}>{t.label}</button>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
{tab === "contacts" ? <Toolbar search={{ value: search, onChange: setSearch, placeholder: "Search contacts by name" }} /> : null}
|
|
81
|
+
{list.error ? <ApiError error={list.error} title="Could not load these records" onRetry={list.reload} /> : null}
|
|
82
|
+
{list.loading ? <Skeleton className="h-24 w-full" aria-label="Loading records" /> : null}
|
|
83
|
+
<ul className="divide-y rounded-md border" aria-label="CRM records">
|
|
84
|
+
{list.items.map((r, i) => {
|
|
85
|
+
const id = String(r.id ?? "");
|
|
86
|
+
return tab === "activity" ? (
|
|
87
|
+
<li key={id || i} className="px-3 py-2 text-sm">{String(r.subject ?? r.type ?? r.title ?? "Activity")}<span className="ml-2 text-xs text-muted-foreground">{formatWhen(String(r.timestamp ?? r.created_at ?? r.date ?? "") || null)}</span></li>
|
|
88
|
+
) : (
|
|
89
|
+
<li key={id || i} className={open === id ? "bg-accent" : ""}>
|
|
90
|
+
<button type="button" className="block w-full px-3 py-2 text-left text-sm" onClick={() => setOpen(open === id ? null : id)} disabled={!id}>
|
|
91
|
+
<span className="block font-medium">{crmRecordTitle(r)}</span><span className="block text-xs text-muted-foreground">{crmRecordSubtitle(r)}</span>
|
|
92
|
+
</button>
|
|
93
|
+
</li>
|
|
94
|
+
);
|
|
95
|
+
})}
|
|
96
|
+
</ul>
|
|
97
|
+
{!list.loading && !list.error && list.items.length === 0 ? <EmptyState compact title={q ? "No contacts match" : "Nothing here"} description={tab === "activity" ? "The CRM has no activity for this connection yet." : "This CRM has no records of this kind."} /> : null}
|
|
98
|
+
<LoadMore list={list} noun="records" />
|
|
99
|
+
{open && tab !== "activity" ? <RecordFacts key={`${tab}:${open}`} connectionId={connectionId} entity={tab} id={open} /> : null}
|
|
100
|
+
{open ? <Button size="sm" variant="ghost" className="self-start" onClick={() => setOpen(null)}>Close record</Button> : null}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FieldMappings: which fields exist on each side of the sync. The CRM's fields for a kind of record (contact, company, deal) next to the
|
|
5
|
+
* graph8 fields they can map to, each searchable. Read only: the mapping itself is chosen in graph8 (the API has no call to save one),
|
|
6
|
+
* so this view is for checking that a field you expect on the CRM side exists before you sync.
|
|
7
|
+
*
|
|
8
|
+
* SDK operations, in order:
|
|
9
|
+
* 1. discoverCrmFields GET /integrations/crm/fields?connection_id&entity_type (read) the CRM's fields
|
|
10
|
+
* 2. listMappableGraph8Fields GET /integrations/crm/fields/mappable?object_type (read) graph8 fields a CRM field can map to
|
|
11
|
+
*/
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
import { Input, Skeleton } from "@graph8/sdk/ui";
|
|
14
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
15
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
16
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
17
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
18
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
19
|
+
import { arrayOf } from "../lib/integrations-utils";
|
|
20
|
+
|
|
21
|
+
export interface FieldMappingsProps {
|
|
22
|
+
connectionId: string;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Field { name?: string; key?: string; label?: string; type?: string; field_type?: string }
|
|
27
|
+
// The API accepts only the plural object names (CrmFieldEntityType / Graph8FieldObjectType).
|
|
28
|
+
const KINDS = [{ id: "contacts", label: "Contacts" }, { id: "companies", label: "Companies" }, { id: "deals", label: "Deals" }];
|
|
29
|
+
const nameOf = (f: Field): string => f.label ?? f.name ?? f.key ?? "";
|
|
30
|
+
|
|
31
|
+
function FieldColumn({ title, query, filter }: { title: string; query: { data: unknown; error: unknown; loading: boolean; refetch: () => Promise<unknown> }; filter: string }) {
|
|
32
|
+
const rows = arrayOf<Field>(query.data).filter((f) => nameOf(f).toLowerCase().includes(filter.toLowerCase()));
|
|
33
|
+
return (
|
|
34
|
+
<SectionCard title={title} description={query.data ? `${rows.length} fields` : undefined} flush>
|
|
35
|
+
{query.error ? <div className="p-3"><ApiError error={query.error} title="Could not load the fields" onRetry={() => void query.refetch()} /></div> : null}
|
|
36
|
+
{query.loading && !query.data ? <Skeleton className="m-3 h-16" /> : null}
|
|
37
|
+
<ul className="max-h-80 divide-y overflow-y-auto text-sm">{rows.map((f, i) => <li key={`${nameOf(f)}-${i}`} className="flex justify-between gap-2 px-3 py-1.5"><span>{nameOf(f)}</span><span className="text-xs text-muted-foreground">{f.type ?? f.field_type ?? ""}</span></li>)}</ul>
|
|
38
|
+
{query.data && !query.error && rows.length === 0 ? <p className="px-3 py-2 text-sm text-muted-foreground">No fields match.</p> : null}
|
|
39
|
+
</SectionCard>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function FieldMappings({ connectionId, className }: FieldMappingsProps) {
|
|
44
|
+
const brand = useBrand();
|
|
45
|
+
const [kind, setKind] = useState("contacts");
|
|
46
|
+
const [filter, setFilter] = useState("");
|
|
47
|
+
const crm = useG8Query((signal) => g8Get("/integrations/crm/fields", { connection_id: connectionId, entity_type: kind }, { signal }), [connectionId, kind]);
|
|
48
|
+
const mine = useG8Query((signal) => g8Get("/integrations/crm/fields/mappable", { object_type: kind }, { signal }), [kind]);
|
|
49
|
+
return (
|
|
50
|
+
<div className={`flex flex-col gap-3 ${className ?? ""}`}>
|
|
51
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
52
|
+
<div role="group" aria-label="Record kind" className="flex gap-1">{KINDS.map((k) => <button key={k.id} type="button" aria-pressed={kind === k.id} onClick={() => setKind(k.id)} className={`rounded-md border px-3 py-1 text-sm ${kind === k.id ? "bg-foreground text-background" : ""}`}>{k.label}</button>)}</div>
|
|
53
|
+
<Input aria-label="Filter fields" className="h-8 max-w-xs" placeholder="Filter fields" value={filter} onChange={(e) => setFilter(e.target.value)} />
|
|
54
|
+
</div>
|
|
55
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
56
|
+
<FieldColumn title="In the CRM" query={crm} filter={filter} />
|
|
57
|
+
<FieldColumn title={`In ${brand.name}`} query={mine} filter={filter} />
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -54,7 +54,7 @@ const CHAT_TOOLS = [
|
|
|
54
54
|
|
|
55
55
|
function Pill({ on, label }: { on: boolean; label?: string }) {
|
|
56
56
|
return (
|
|
57
|
-
<span className={`rounded px-2 py-0.5 text-xs font-medium ${on ? "bg-
|
|
57
|
+
<span className={`rounded px-2 py-0.5 text-xs font-medium ${on ? "bg-positive text-positive-foreground" : "bg-muted text-foreground"}`}>
|
|
58
58
|
{label ?? (on ? "Connected" : "Not connected")}
|
|
59
59
|
</span>
|
|
60
60
|
);
|
|
@@ -70,7 +70,7 @@ function ChatTools() {
|
|
|
70
70
|
}, []);
|
|
71
71
|
if (state.loading && !state.data) return <Skeleton className="h-10 w-full" />;
|
|
72
72
|
return (
|
|
73
|
-
<ul className="divide-y rounded border border-
|
|
73
|
+
<ul className="divide-y rounded border border-border" aria-label="Workspace tools">
|
|
74
74
|
{(state.data ?? []).map((t) => (
|
|
75
75
|
<li key={t.key} className="flex items-center justify-between px-3 py-2 text-sm">
|
|
76
76
|
<span>{t.label}</span>
|
|
@@ -110,7 +110,7 @@ export function IntegrationList({ connectUrl: connectUrlProp, onOpenSync, onSele
|
|
|
110
110
|
|
|
111
111
|
return (
|
|
112
112
|
<div className={`space-y-4 ${className ?? ""}`}>
|
|
113
|
-
<ul className="divide-y rounded border border-
|
|
113
|
+
<ul className="divide-y rounded border border-border" aria-label="CRM integrations">
|
|
114
114
|
{CRM_PROVIDERS.map((p) => {
|
|
115
115
|
const row = byProvider(p);
|
|
116
116
|
const connected = Boolean(row && CONNECTED_STATES.includes(row.status?.toLowerCase()));
|
|
@@ -119,7 +119,7 @@ export function IntegrationList({ connectUrl: connectUrlProp, onOpenSync, onSele
|
|
|
119
119
|
<li key={p} className="flex flex-wrap items-center justify-between gap-2 px-3 py-3" data-provider={p}>
|
|
120
120
|
<div>
|
|
121
121
|
{onSelectCrm ? <button type="button" className="font-medium hover:underline" onClick={() => onSelectCrm(p)}>{PROVIDER_LABELS[p]}</button> : <div className="font-medium">{PROVIDER_LABELS[p]}</div>}
|
|
122
|
-
<div className="text-xs text-
|
|
122
|
+
<div className="text-xs text-muted-foreground">
|
|
123
123
|
{connected ? `Last sync: ${formatWhen(row?.last_sync_at)}` : "Connect to sync contacts and companies."}
|
|
124
124
|
</div>
|
|
125
125
|
</div>
|
|
@@ -8,8 +8,15 @@
|
|
|
8
8
|
* Webhooks create webhooks (WebhookManager); each opens its complete detail screen (WebhookDetail: every field, edit, deliveries,
|
|
9
9
|
* pause, rotate secret, delete; `?id=`). Every detail also opens as a full page with `?view=full`.
|
|
10
10
|
*
|
|
11
|
+
* Tools Slack, Google Workspace and Roam with live status, a connect link-out for each that is not connected, a Google connection
|
|
12
|
+
* test and a Drive folder browser (WorkspaceTools).
|
|
13
|
+
* Ad accounts the ad platforms graph8 can sync audiences to, which are connected, their accounts, and a warning for any the platform
|
|
14
|
+
* has paused (AdAccounts).
|
|
15
|
+
* A connected CRM's detail also has Health, Sync now, CRM records, Fields and Historic import tabs (see CrmDetail).
|
|
16
|
+
*
|
|
11
17
|
* SDK operations: two reads for the stat strip (GET /crm-syncs, GET /webhooks). Composes IntegrationList (read +
|
|
12
|
-
* destructive, confirmed), SyncStatus (external, confirmed)
|
|
18
|
+
* destructive, confirmed), SyncStatus and SyncTriggers (external, confirmed), WebhookManager (external + destructive, confirmed),
|
|
19
|
+
* WorkspaceTools and AdAccounts (reads).
|
|
13
20
|
* Prerequisites: a CRM connected in the app (where: "app"); a public https route with G8_WEBHOOK_SECRET for webhooks.
|
|
14
21
|
*/
|
|
15
22
|
import type { ReactNode } from "react";
|
|
@@ -24,7 +31,9 @@ import { SplitView } from "@/g8/components/screen/split-view";
|
|
|
24
31
|
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
25
32
|
import { arrayOf, CRM_PROVIDERS, GRAPH8_APP_INTEGRATIONS_PATH, PROVIDER_LABELS } from "../lib/integrations-utils";
|
|
26
33
|
import type { CrmProvider, CrmSyncRow } from "../lib/integrations-utils";
|
|
34
|
+
import { AdAccounts } from "./ad-accounts";
|
|
27
35
|
import { IntegrationList } from "./integration-list";
|
|
36
|
+
import { WorkspaceTools } from "./workspace-tools";
|
|
28
37
|
import { CrmDetail } from "./crm-detail";
|
|
29
38
|
import { WebhookDetail } from "./webhook-detail";
|
|
30
39
|
import { WebhookManager } from "./webhook-manager";
|
|
@@ -67,13 +76,15 @@ export function IntegrationsScreen(props: IntegrationsScreenProps) {
|
|
|
67
76
|
const brand = useBrand();
|
|
68
77
|
const connectUrl = props.connectUrl ?? brandUrl(brand, GRAPH8_APP_INTEGRATIONS_PATH);
|
|
69
78
|
const [tabValue, setTab] = useUrlState("tab", "connections");
|
|
70
|
-
const tab =
|
|
79
|
+
const tab = ["webhooks", "tools", "ads"].includes(tabValue) ? tabValue : "connections";
|
|
71
80
|
const [id, setId] = useUrlState("id", "");
|
|
72
81
|
const [view, setView] = useUrlState("view", "");
|
|
82
|
+
const [, setMigration] = useUrlState("migration", "");
|
|
73
83
|
const expanded = view === "full" && Boolean(id);
|
|
74
84
|
const [tick, setTick] = useState(0);
|
|
75
85
|
const stats = useStats();
|
|
76
86
|
const close = () => {
|
|
87
|
+
setMigration("");
|
|
77
88
|
setId("");
|
|
78
89
|
setView("");
|
|
79
90
|
};
|
|
@@ -101,6 +112,8 @@ export function IntegrationsScreen(props: IntegrationsScreenProps) {
|
|
|
101
112
|
tabs={[
|
|
102
113
|
{ id: "connections", label: "Connections" },
|
|
103
114
|
{ id: "webhooks", label: "Webhooks" },
|
|
115
|
+
{ id: "tools", label: "Tools" },
|
|
116
|
+
{ id: "ads", label: "Ad accounts" },
|
|
104
117
|
]}
|
|
105
118
|
activeTab={tab}
|
|
106
119
|
onTabChange={(next) => {
|
|
@@ -118,11 +131,15 @@ export function IntegrationsScreen(props: IntegrationsScreenProps) {
|
|
|
118
131
|
</a>
|
|
119
132
|
</SectionCard>
|
|
120
133
|
{split(
|
|
121
|
-
<IntegrationList key={tick} connectUrl={connectUrl} onSelectCrm={(p) => setId(p)} onOpenSync={(p) => setId(p)} />,
|
|
134
|
+
<IntegrationList key={tick} connectUrl={connectUrl} onSelectCrm={(p) => { setMigration(""); setId(p); }} onOpenSync={(p) => { setMigration(""); setId(p); }} />,
|
|
122
135
|
provider ? <CrmDetail key={provider} provider={provider} connectUrl={connectUrl} records={props.pushRecords} {...panel} {...slots({ kind: "crm", provider })} /> : null,
|
|
123
136
|
provider != null,
|
|
124
137
|
)}
|
|
125
138
|
</div>
|
|
139
|
+
) : tab === "tools" ? (
|
|
140
|
+
<WorkspaceTools connectUrl={connectUrl} />
|
|
141
|
+
) : tab === "ads" ? (
|
|
142
|
+
<AdAccounts connectUrl={connectUrl} />
|
|
126
143
|
) : (
|
|
127
144
|
split(
|
|
128
145
|
<WebhookManager key={tick} defaultUrl={props.defaultWebhookUrl} onSelectWebhook={(h) => setId(h.id)} />,
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MigrationPanel: bring a CRM's history into graph8, and keep it current afterwards. Three steps for a connected CRM:
|
|
5
|
+
* 1. Scan look at the CRM and count what is there (nothing is imported yet).
|
|
6
|
+
* 2. Review the counts per kind of record, the warnings, then Import (or Cancel). Importing copies the CRM's records into graph8.
|
|
7
|
+
* 3. Later "Sync changes" brings in only what changed in the CRM since the last import.
|
|
8
|
+
* Progress is polled until the run reaches a resting state (waiting for you, finished, failed or cancelled). The last completed import,
|
|
9
|
+
* if any, is shown first. Scanning and importing change data and read a lot from the CRM, so each goes through ConfirmSpendDialog
|
|
10
|
+
* (external); cancelling a run is destructive and confirmed. Salesforce record owners that are not in the workspace need a decision or the API
|
|
11
|
+
* refuses the confirm, so each unmatched owner is sent as an "unassigned" decision (their records are imported without an owner; nothing is
|
|
12
|
+
* invited or reassigned from here). Choosing something else per owner is done in the app.
|
|
13
|
+
*
|
|
14
|
+
* SDK operations, in order:
|
|
15
|
+
* 1. getLastCompletedMigration GET /integrations/connections/{connection_id}/last-migration (read)
|
|
16
|
+
* 2. startMigrationDiscovery POST /integrations/migrations/discovery { connection_id, crm_type } (external, confirmed)
|
|
17
|
+
* 3. getMigrationStatus GET /integrations/migrations/{migration_id} (read, polled)
|
|
18
|
+
* 4. confirmMigration POST /integrations/migrations/{migration_id}/confirm {} (external, confirmed)
|
|
19
|
+
* 5. cancelMigration POST /integrations/migrations/{migration_id}/cancel (destructive, confirmed)
|
|
20
|
+
* 6. startDeltaSync POST /integrations/migrations/delta-sync { connection_id, recover_missing: false } (external, confirmed)
|
|
21
|
+
* The id of a started run is kept in the page's address (?migration=), so reloading does not lose a run that is still going.
|
|
22
|
+
*/
|
|
23
|
+
import { useEffect, useRef, useState } from "react";
|
|
24
|
+
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
25
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
26
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
27
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
28
|
+
import { StatusPill, toneForStatus } from "@/g8/components/screen/status-pill";
|
|
29
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
30
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
31
|
+
import { useJobPoll } from "@/g8/hooks/use-job-poll";
|
|
32
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
33
|
+
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
34
|
+
import { FactList } from "../lib/facts";
|
|
35
|
+
import { crmType, PROVIDER_LABELS } from "../lib/integrations-utils";
|
|
36
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
37
|
+
import type { CrmProvider } from "../lib/integrations-utils";
|
|
38
|
+
|
|
39
|
+
export interface MigrationPanelProps {
|
|
40
|
+
connectionId: string;
|
|
41
|
+
provider: CrmProvider;
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Migration { migration_id?: string; status: string; phase?: string | null; overall_percent?: number; eta_seconds?: number | null; entities?: Record<string, Record<string, unknown> | number>; warnings?: Array<{ message?: string; code?: string }>; errors?: Array<{ message?: string; code?: string }>; owners?: Array<{ sf_user_id?: string; match?: { status?: string } }> }
|
|
46
|
+
type Ask = "scan" | "import" | "cancel" | "delta";
|
|
47
|
+
const REST = ["awaiting_confirmation", "paused", "completed", "failed", "cancelled"];
|
|
48
|
+
const enc = encodeURIComponent;
|
|
49
|
+
const text = (w: { message?: string; code?: string }): string => w.message ?? w.code ?? "See the app for details.";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Salesforce owners who are not in graph8 and have no match need a decision or the confirm is refused (422). The default keeps the import
|
|
53
|
+
* unattended and reversible: their records are imported unassigned (the "unassigned" action). Nothing is invited or reassigned from here.
|
|
54
|
+
*/
|
|
55
|
+
export function ownerDecisions(owners: Migration["owners"]): Array<{ sf_user_id: string; action: "unassigned" }> {
|
|
56
|
+
return (owners ?? []).filter((o) => o.sf_user_id && o.match?.status === "unmatched").map((o) => ({ sf_user_id: o.sf_user_id as string, action: "unassigned" as const }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function MigrationPanel({ connectionId, provider, className }: MigrationPanelProps) {
|
|
60
|
+
const brand = useBrand();
|
|
61
|
+
const label = PROVIDER_LABELS[provider];
|
|
62
|
+
const [id, setId] = useUrlState("migration", "");
|
|
63
|
+
const [ask, setAsk] = useState<Ask | null>(null);
|
|
64
|
+
// A run belongs to the connection it was started for: switching connection drops it (and its URL id) so it can never be confirmed under another CRM.
|
|
65
|
+
const first = useRef(true);
|
|
66
|
+
useEffect(() => { setAsk(null); if (first.current) { first.current = false; return; } setId(""); }, [connectionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
67
|
+
const last = useG8Query((signal) => g8Get(`/integrations/connections/${enc(connectionId)}/last-migration`, undefined, { signal }), [connectionId]);
|
|
68
|
+
const poll = useJobPoll<Migration>(async (signal) => (await g8Get<Migration>(`/integrations/migrations/${enc(id)}`, undefined, { signal })).data, { isTerminal: (m) => REST.includes(String(m.status).toLowerCase()), enabled: Boolean(id), intervalMs: 3000, maxIntervalMs: 15000 });
|
|
69
|
+
useEffect(() => { if (id) poll.start(); }, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
70
|
+
const m = poll.status;
|
|
71
|
+
const status = String(m?.status ?? "").toLowerCase();
|
|
72
|
+
|
|
73
|
+
const started = (res: { data?: { migration_id?: string; id?: string } }) => { const next = res.data?.migration_id ?? res.data?.id; if (next) setId(next); setAsk(null); };
|
|
74
|
+
const scan = useG8Mutation(() => g8Post<{ migration_id?: string }>("/integrations/migrations/discovery", { connection_id: connectionId, crm_type: crmType(provider) }), { onSuccess: started, onError: () => setAsk(null) });
|
|
75
|
+
const delta = useG8Mutation(() => g8Post<{ migration_id?: string }>("/integrations/migrations/delta-sync", { connection_id: connectionId, recover_missing: false }), { onSuccess: started, onError: () => setAsk(null) });
|
|
76
|
+
const confirm = useG8Mutation(() => g8Post(`/integrations/migrations/${enc(id)}/confirm`, decisions.length ? { owner_decisions: decisions } : {}), { onSuccess: () => { setAsk(null); poll.start(); }, onError: () => setAsk(null) });
|
|
77
|
+
const cancel = useG8Mutation(() => g8Post(`/integrations/migrations/${enc(id)}/cancel`, {}), { onSuccess: () => { setAsk(null); poll.start(); }, onError: () => setAsk(null) });
|
|
78
|
+
const busy = ask === "scan" ? scan.loading : ask === "import" ? confirm.loading : ask === "cancel" ? cancel.loading : delta.loading;
|
|
79
|
+
const lastRun = ((last.data as { data?: { migration?: Record<string, unknown> | null } } | undefined)?.data?.migration) ?? null;
|
|
80
|
+
const entities = Object.entries(m?.entities ?? {});
|
|
81
|
+
const decisions = ownerDecisions(m?.owners);
|
|
82
|
+
|
|
83
|
+
const COPY: Record<Ask, { tier: "external" | "destructive"; title: string; description: string; confirm: string }> = {
|
|
84
|
+
scan: { tier: "external", title: `Scan ${label}`, description: `${brand.name} reads ${label} to count what it holds. Nothing is imported until you confirm the import.`, confirm: "Scan" },
|
|
85
|
+
import: { tier: "external", title: `Import from ${label}`, description: `Copies the ${label} records counted below into ${brand.name} and can take a long time.${decisions.length ? ` ${decisions.length} record owner${decisions.length === 1 ? " is" : "s are"} not in ${brand.name}; their records are imported unassigned.` : ""}`, confirm: "Import" },
|
|
86
|
+
cancel: { tier: "destructive", title: "Cancel this import", description: `The run stops where it is. Records already copied stay in ${brand.name}.`, confirm: "Cancel import" },
|
|
87
|
+
delta: { tier: "external", title: `Sync changes from ${label}`, description: `Brings in only the records that changed in ${label} since the last import.`, confirm: "Sync changes" },
|
|
88
|
+
};
|
|
89
|
+
const c = ask ? COPY[ask] : null;
|
|
90
|
+
const run = () => void (ask === "scan" ? scan : ask === "import" ? confirm : ask === "cancel" ? cancel : delta).mutate(undefined);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className={`flex flex-col gap-4 ${className ?? ""}`}>
|
|
94
|
+
<SectionCard title="Last completed import" actions={<Button size="sm" variant="ghost" onClick={() => void last.refetch()}>Recheck</Button>}>
|
|
95
|
+
{last.error ? <ApiError error={last.error} title="Could not check the last import" onRetry={() => void last.refetch()} /> : null}
|
|
96
|
+
{last.loading && !last.data ? <Skeleton className="h-8 w-full" /> : null}
|
|
97
|
+
{last.data && !last.error ? (lastRun ? <FactList data={lastRun} /> : <p className="text-sm text-muted-foreground">No import has completed for this connection.</p>) : null}
|
|
98
|
+
</SectionCard>
|
|
99
|
+
<div className="flex flex-wrap gap-2">
|
|
100
|
+
<Button onClick={() => setAsk("scan")}>Scan {label}</Button>
|
|
101
|
+
<Button variant="outline" onClick={() => setAsk("delta")}>Sync changes</Button>
|
|
102
|
+
</div>
|
|
103
|
+
{scan.error ? <ApiError error={scan.error} title="Could not start the scan" /> : null}
|
|
104
|
+
{delta.error ? <ApiError error={delta.error} title="Could not start the sync" /> : null}
|
|
105
|
+
{id ? (
|
|
106
|
+
<SectionCard title="Current run" description={id} actions={<Button size="sm" variant="ghost" onClick={() => setId("")}>Hide</Button>}>
|
|
107
|
+
{poll.error && !m ? <ApiError error={poll.error} title="Could not read the run" onRetry={() => poll.start()} /> : null}
|
|
108
|
+
{!m && !poll.error ? <Skeleton className="h-12 w-full" aria-label="Loading run" /> : null}
|
|
109
|
+
{m ? (
|
|
110
|
+
<div className="flex flex-col gap-3">
|
|
111
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
112
|
+
<StatusPill tone={toneForStatus(status)} dot>{status.replace(/_/g, " ")}</StatusPill>
|
|
113
|
+
{m.phase ? <span className="text-xs text-muted-foreground">{String(m.phase).replace(/_/g, " ")}</span> : null}
|
|
114
|
+
{typeof m.overall_percent === "number" ? <span className="text-xs text-muted-foreground">{Math.round(m.overall_percent)}%</span> : null}
|
|
115
|
+
{poll.polling ? <span className="text-xs text-muted-foreground">updating</span> : null}
|
|
116
|
+
</div>
|
|
117
|
+
{entities.length ? <ul className="divide-y rounded-md border text-sm" aria-label="Records found">{entities.map(([k, v]) => <li key={k} className="flex justify-between px-3 py-1.5"><span>{k.replace(/_/g, " ")}</span><span className="text-muted-foreground">{typeof v === "number" ? v.toLocaleString() : Object.entries(v).filter(([, x]) => typeof x === "number").map(([n, x]) => `${n.replace(/_/g, " ")} ${(x as number).toLocaleString()}`).join(", ")}</span></li>)}</ul> : null}
|
|
118
|
+
{(m.warnings ?? []).map((w, i) => <p key={i} role="status" className="rounded-md border border-warning-foreground/25 bg-warning p-2 text-sm text-warning-foreground">{text(w)}</p>)}
|
|
119
|
+
{(m.errors ?? []).map((e, i) => <p key={i} role="alert" className="text-sm text-error-foreground">{text(e)}</p>)}
|
|
120
|
+
<div className="flex gap-2">
|
|
121
|
+
{status === "awaiting_confirmation" ? <Button onClick={() => setAsk("import")}>Import</Button> : null}
|
|
122
|
+
{!["completed", "failed", "cancelled"].includes(status) ? <Button variant="ghost" onClick={() => setAsk("cancel")}>Cancel</Button> : null}
|
|
123
|
+
</div>
|
|
124
|
+
{confirm.error ? <ApiError error={confirm.error} title="Could not start the import" /> : null}
|
|
125
|
+
{cancel.error ? <ApiError error={cancel.error} title="Could not cancel the run" /> : null}
|
|
126
|
+
</div>
|
|
127
|
+
) : null}
|
|
128
|
+
</SectionCard>
|
|
129
|
+
) : null}
|
|
130
|
+
<ConfirmSpendDialog open={c != null} tier={c?.tier ?? "external"} title={c?.title ?? ""} description={c?.description} confirmLabel={c?.confirm} confirming={busy} onConfirm={run} onCancel={() => setAsk(null)} />
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|