@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,359 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CompaniesScreen: the finished accounts workspace. A searchable, filterable, sortable, paginated grid of companies on the
|
|
5
|
+
* left; the open company (people, timeline, notes, tasks, fields) beside it. No props are required.
|
|
6
|
+
*
|
|
7
|
+
* Composes: ScreenLayout, StatStrip, Toolbar, SplitView, DetailPanel, FormSheet (screen kit) with RecordForm,
|
|
8
|
+
* ContactTable and RecordForm (the People tab and "Add person"), ActivityTimeline, NotesPanel, TaskList and DedupeReview (this block).
|
|
9
|
+
*
|
|
10
|
+
* Flows: search and filter (name, domain, industry, country; debounced) - sort the loaded page - paginate - open a company
|
|
11
|
+
* (deep link ?id=, full page with &view=full) - create a company (a domain is required; an existing one is merged) - edit - delete one or many (confirm,
|
|
12
|
+
* "N of M" report, 404 = already gone) - see, open and add its people - add notes and tasks - review and merge duplicates -
|
|
13
|
+
* hand a company to other blocks through the slot props.
|
|
14
|
+
*
|
|
15
|
+
* SDK operations: none of its own. listCompanies, getCompany, getActivityFeed, duplicateCounts are `read`; forms, notes and
|
|
16
|
+
* tasks are `write`; deleteCompany (rows, detail and bulk) is `destructive` and always goes through ConfirmSpendDialog.
|
|
17
|
+
* Company import is not part of the API (the import wizard writes contacts, which create their companies by domain).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useEffect, useMemo, useState } from "react";
|
|
21
|
+
import type { ReactNode } from "react";
|
|
22
|
+
import { Button, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
|
|
23
|
+
import { Building2, GitMerge, Plus, Trash2, UserPlus } from "lucide-react";
|
|
24
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
25
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
26
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
27
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
28
|
+
import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
|
|
29
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
30
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
31
|
+
import { ScreenLayout } from "@/g8/components/screen/screen-layout";
|
|
32
|
+
import { SplitView } from "@/g8/components/screen/split-view";
|
|
33
|
+
import { Toolbar } from "@/g8/components/screen/toolbar";
|
|
34
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
35
|
+
import { rowActivationProps } from "@/g8/hooks/use-row-activation";
|
|
36
|
+
import { RecordLink } from "@/g8/components/screen/record-link";
|
|
37
|
+
import { plural } from "@/g8/lib/plural";
|
|
38
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
39
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
40
|
+
import { useUrlFilters } from "@/g8/hooks/use-url-filters";
|
|
41
|
+
import { MoreFilters, RangeFilter, TextFilter } from "@/g8/components/screen/filter-controls";
|
|
42
|
+
import { g8Delete, g8Get } from "@/g8/lib/g8-client";
|
|
43
|
+
import { usePageQuery } from "../hooks/use-page-query";
|
|
44
|
+
import { companyName, errorStatus, location } from "../lib/crm-types";
|
|
45
|
+
import type { CompanyRecord, ContactRecord, ScreenActionContext } from "../lib/crm-types";
|
|
46
|
+
import { Pager, SortHead, formatDay, sortRows, useBulkDelete, useRowSelection, useSort } from "../lib/screen-parts";
|
|
47
|
+
import { ActivityTimeline } from "./activity-timeline";
|
|
48
|
+
import { ContactTable } from "./contact-table";
|
|
49
|
+
import { DedupeReview } from "./dedupe-review";
|
|
50
|
+
import { NotesPanel } from "./notes-panel";
|
|
51
|
+
import { RecordForm } from "./record-form";
|
|
52
|
+
import { TaskList } from "./task-list";
|
|
53
|
+
|
|
54
|
+
export interface CompaniesScreenProps {
|
|
55
|
+
pageSize?: number;
|
|
56
|
+
/** Buttons at the end of each row. */
|
|
57
|
+
renderRowActions?: (row: CompanyRecord, ctx: ScreenActionContext) => ReactNode;
|
|
58
|
+
/** Buttons in the open company's header. */
|
|
59
|
+
renderDetailActions?: (company: CompanyRecord, ctx: ScreenActionContext) => ReactNode;
|
|
60
|
+
/** Extra tabs on the open company. */
|
|
61
|
+
extraDetailTabs?: Array<{ id: string; label: string; render: (company: CompanyRecord) => ReactNode }>;
|
|
62
|
+
/** Extra buttons in the page header, before "New company". */
|
|
63
|
+
toolbarActions?: ReactNode;
|
|
64
|
+
/** Extra bulk actions next to Delete. */
|
|
65
|
+
renderBulkActions?: (selection: { ids: number[]; rows: CompanyRecord[]; clear: () => void }) => ReactNode;
|
|
66
|
+
/** Called when a person in the People tab is clicked. Without it the rows are not clickable. */
|
|
67
|
+
onOpenContact?: (contact: ContactRecord) => void;
|
|
68
|
+
className?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Every filter the companies API takes: text (name, domain, industry, country, state, city, description) and four from/to ranges. */
|
|
72
|
+
const FILTER_KEYS = ["name", "domain", "industry", "country", "state", "city", "description", "employee_count_min", "employee_count_max", "revenue_min", "revenue_max", "founded_year_min", "founded_year_max", "linkedin_followers_min", "linkedin_followers_max"] as const;
|
|
73
|
+
type FilterKey = (typeof FILTER_KEYS)[number];
|
|
74
|
+
const MORE_KEYS: readonly FilterKey[] = FILTER_KEYS.filter((k) => !["name", "domain", "industry", "country"].includes(k));
|
|
75
|
+
|
|
76
|
+
function sortValue(row: CompanyRecord, key: string): string | null | undefined {
|
|
77
|
+
if (key === "name") return row.name || row.domain;
|
|
78
|
+
if (key === "domain") return row.domain;
|
|
79
|
+
if (key === "industry") return row.industry;
|
|
80
|
+
if (key === "employees") return row.employee_count;
|
|
81
|
+
if (key === "location") return location(row);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const dash = <span className="text-muted-foreground">-</span>;
|
|
86
|
+
|
|
87
|
+
export function CompaniesScreen(props: CompaniesScreenProps) {
|
|
88
|
+
const { pageSize = 25 } = props;
|
|
89
|
+
const [idParam, setIdParamRaw] = useUrlState("id");
|
|
90
|
+
const [view, setView] = useUrlState("view");
|
|
91
|
+
const full = view === "full" && idParam != null && idParam !== "";
|
|
92
|
+
/** Changing or clearing the open record also leaves the full-page view. */
|
|
93
|
+
const setIdParam = (v: string | null): void => { if (v == null) setView(null); setIdParamRaw(v); };
|
|
94
|
+
const parsedId = idParam ? Number(idParam) : null;
|
|
95
|
+
/** A malformed deep link (`?id=abc`) opens nothing instead of fetching /companies/NaN. */
|
|
96
|
+
const openId = parsedId != null && Number.isFinite(parsedId) ? parsedId : null;
|
|
97
|
+
// The filters live in the URL (`co_city=...`), so a filtered view survives reload, Back and sharing.
|
|
98
|
+
const urlFilters = useUrlFilters(FILTER_KEYS, { prefix: "co_" });
|
|
99
|
+
const { draft, filters } = urlFilters;
|
|
100
|
+
const [sheet, setSheet] = useState<"new" | "dupes" | null>(null);
|
|
101
|
+
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
102
|
+
const [note, setNote] = useState<string | null>(null);
|
|
103
|
+
const { sort, toggle: toggleSort } = useSort();
|
|
104
|
+
|
|
105
|
+
const list = usePageQuery<CompanyRecord>(
|
|
106
|
+
(page, signal) => g8Get<CompanyRecord[]>("/companies", { page, limit: pageSize, ...Object.fromEntries(Object.entries(filters).filter(([, v]) => v)) }, { signal }),
|
|
107
|
+
[filters],
|
|
108
|
+
pageSize,
|
|
109
|
+
["companies", "list", "screen"],
|
|
110
|
+
urlFilters.ready,
|
|
111
|
+
);
|
|
112
|
+
const dupes = useG8Query((signal) => g8Get<{ pending?: number; count?: number }>("/duplicates/counts", { entity_type: "company" }, { signal }), [], { key: ["duplicates", "counts", "company"] });
|
|
113
|
+
const selection = useRowSelection<CompanyRecord>(JSON.stringify(filters));
|
|
114
|
+
const rows = useMemo(() => sortRows(list.rows, sort, sortValue), [list.rows, sort]);
|
|
115
|
+
const hasFilters = urlFilters.active;
|
|
116
|
+
const pending = dupes.data?.data.pending ?? dupes.data?.data.count;
|
|
117
|
+
const industries = new Set(list.rows.map((r) => r.industry).filter(Boolean)).size;
|
|
118
|
+
const reload = () => { void list.refetch(); void dupes.refetch(); };
|
|
119
|
+
const set = (key: FilterKey, v: string) => urlFilters.set(key, v);
|
|
120
|
+
const allOnPage = rows.length > 0 && rows.every((r) => selection.selected.has(r.id));
|
|
121
|
+
|
|
122
|
+
const bulk = useBulkDelete("/companies", (gone, total) => {
|
|
123
|
+
selection.drop(gone);
|
|
124
|
+
setConfirmDelete(false);
|
|
125
|
+
setNote(gone.length < total ? `Deleted ${gone.length} of ${total}. ${total - gone.length} could not be deleted; they are still selected.` : null);
|
|
126
|
+
if (openId != null && gone.includes(openId)) setIdParam(null);
|
|
127
|
+
reload();
|
|
128
|
+
});
|
|
129
|
+
useEffect(() => { setNote(null); }, [filters]);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<ScreenLayout
|
|
133
|
+
className={props.className}
|
|
134
|
+
title="Companies"
|
|
135
|
+
description="Every account, who works there and what has happened with them."
|
|
136
|
+
icon={<Building2 className="size-5" aria-hidden />}
|
|
137
|
+
actions={
|
|
138
|
+
<>
|
|
139
|
+
{props.toolbarActions}
|
|
140
|
+
<Button variant="outline" size="sm" onClick={() => setSheet("dupes")}><GitMerge className="size-4" aria-hidden />Duplicates{pending ? ` (${pending})` : ""}</Button>
|
|
141
|
+
<Button size="sm" onClick={() => setSheet("new")}><Plus className="size-4" aria-hidden />New company</Button>
|
|
142
|
+
</>
|
|
143
|
+
}
|
|
144
|
+
stats={[
|
|
145
|
+
{ label: "Companies", value: list.loading && list.total === 0 ? "-" : list.total.toLocaleString(), hint: hasFilters ? "Matching the filters" : "In your workspace" },
|
|
146
|
+
{ label: "Possible duplicates", value: pending ?? "-", tone: pending ? "warning" : "neutral", hint: "Review and merge" },
|
|
147
|
+
{ label: "Industries on this page", value: industries, hint: "Distinct industries in view" },
|
|
148
|
+
]}
|
|
149
|
+
>
|
|
150
|
+
<SplitView
|
|
151
|
+
detailOpen={openId != null}
|
|
152
|
+
expanded={full}
|
|
153
|
+
onExpandedChange={(e) => setView(e ? "full" : null)}
|
|
154
|
+
onCloseDetail={() => setIdParam(null)}
|
|
155
|
+
detailWidth="lg"
|
|
156
|
+
list={
|
|
157
|
+
<div className="flex flex-col gap-3">
|
|
158
|
+
<Toolbar
|
|
159
|
+
search={{ value: draft.name, onChange: (v) => set("name", v), placeholder: "Search companies by name" }}
|
|
160
|
+
filters={
|
|
161
|
+
<>
|
|
162
|
+
<input aria-label="Filter by domain" placeholder="Domain" className="h-9 w-36 rounded-md border bg-background px-3 text-sm" value={draft.domain} onChange={(e) => set("domain", e.target.value)} />
|
|
163
|
+
<input aria-label="Filter by industry" placeholder="Industry" className="h-9 w-36 rounded-md border bg-background px-3 text-sm" value={draft.industry} onChange={(e) => set("industry", e.target.value)} />
|
|
164
|
+
<input aria-label="Filter by country" placeholder="Country" className="h-9 w-32 rounded-md border bg-background px-3 text-sm" value={draft.country} onChange={(e) => set("country", e.target.value)} />
|
|
165
|
+
<MoreFilters activeCount={MORE_KEYS.filter((k) => draft[k]).length}>
|
|
166
|
+
<TextFilter label="state" value={draft.state} onChange={(v) => set("state", v)} className="w-28" />
|
|
167
|
+
<TextFilter label="city" value={draft.city} onChange={(v) => set("city", v)} className="w-32" />
|
|
168
|
+
<TextFilter label="description" value={draft.description} onChange={(v) => set("description", v)} className="w-44" />
|
|
169
|
+
<RangeFilter label="Employees" min={draft.employee_count_min} max={draft.employee_count_max} onMinChange={(v) => set("employee_count_min", v)} onMaxChange={(v) => set("employee_count_max", v)} />
|
|
170
|
+
<RangeFilter label="Revenue" min={draft.revenue_min} max={draft.revenue_max} onMinChange={(v) => set("revenue_min", v)} onMaxChange={(v) => set("revenue_max", v)} />
|
|
171
|
+
<RangeFilter label="Founded" min={draft.founded_year_min} max={draft.founded_year_max} onMinChange={(v) => set("founded_year_min", v)} onMaxChange={(v) => set("founded_year_max", v)} />
|
|
172
|
+
<RangeFilter label="LinkedIn followers" min={draft.linkedin_followers_min} max={draft.linkedin_followers_max} onMinChange={(v) => set("linkedin_followers_min", v)} onMaxChange={(v) => set("linkedin_followers_max", v)} />
|
|
173
|
+
</MoreFilters>
|
|
174
|
+
{hasFilters ? <Button variant="ghost" size="sm" onClick={urlFilters.clear}>Clear filters</Button> : null}
|
|
175
|
+
</>
|
|
176
|
+
}
|
|
177
|
+
/>
|
|
178
|
+
{selection.ids.length > 0 ? (
|
|
179
|
+
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm" role="status">
|
|
180
|
+
<span className="tabular-nums">{selection.ids.length} selected</span>
|
|
181
|
+
{props.renderBulkActions?.({ ids: selection.ids, rows: selection.rows, clear: selection.clear })}
|
|
182
|
+
<Button variant="destructive" size="sm" onClick={() => setConfirmDelete(true)}><Trash2 className="size-4" aria-hidden />Delete {selection.ids.length}</Button>
|
|
183
|
+
<Button variant="ghost" size="sm" onClick={selection.clear}>Clear selection</Button>
|
|
184
|
+
</div>
|
|
185
|
+
) : null}
|
|
186
|
+
{list.error ? <ApiError error={list.error} title="Could not load companies" onRetry={() => void list.refetch()} /> : null}
|
|
187
|
+
{note ? <p role="alert" className="text-sm text-destructive">{note}</p> : null}
|
|
188
|
+
|
|
189
|
+
<div className="overflow-hidden rounded-md border">
|
|
190
|
+
<Table>
|
|
191
|
+
<TableHeader>
|
|
192
|
+
<TableRow>
|
|
193
|
+
<TableHead className="w-8"><input type="checkbox" aria-label="Select page" checked={allOnPage} onChange={() => selection.togglePage(rows)} /></TableHead>
|
|
194
|
+
<SortHead label="Company" sortKey="name" sort={sort} onSort={toggleSort} />
|
|
195
|
+
<SortHead label="Domain" sortKey="domain" sort={sort} onSort={toggleSort} />
|
|
196
|
+
<SortHead label="Industry" sortKey="industry" sort={sort} onSort={toggleSort} />
|
|
197
|
+
<SortHead label="Employees" sortKey="employees" sort={sort} onSort={toggleSort} />
|
|
198
|
+
<SortHead label="Location" sortKey="location" sort={sort} onSort={toggleSort} />
|
|
199
|
+
{props.renderRowActions ? <TableHead className="w-8"><span className="sr-only">Actions</span></TableHead> : null}
|
|
200
|
+
</TableRow>
|
|
201
|
+
</TableHeader>
|
|
202
|
+
<TableBody>
|
|
203
|
+
{list.loading && rows.length === 0
|
|
204
|
+
? Array.from({ length: 6 }, (_, i) => <TableRow key={`sk${i}`}><TableCell colSpan={6 + (props.renderRowActions ? 1 : 0)}><Skeleton className="h-6 w-full" /></TableCell></TableRow>)
|
|
205
|
+
: rows.map((row) => {
|
|
206
|
+
const name = companyName(row);
|
|
207
|
+
return (
|
|
208
|
+
<TableRow key={row.id} data-testid={`company-row-${row.id}`} data-state={row.id === openId ? "selected" : undefined} className="cursor-pointer" {...rowActivationProps(() => setIdParam(String(row.id)))}>
|
|
209
|
+
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
210
|
+
<input type="checkbox" aria-label={`Select ${name}`} checked={selection.selected.has(row.id)} onChange={() => selection.toggle(row)} />
|
|
211
|
+
</TableCell>
|
|
212
|
+
<TableCell><span className="flex items-center gap-2 font-medium"><EntityAvatar name={name} imageUrl={row.logo_url} kind="company" size="sm" /><RecordLink param="id" id={row.id} onOpen={() => setIdParam(String(row.id))}>{name}</RecordLink></span></TableCell>
|
|
213
|
+
<TableCell>{row.domain || dash}</TableCell>
|
|
214
|
+
<TableCell>{row.industry || dash}</TableCell>
|
|
215
|
+
<TableCell>{row.employee_count || dash}</TableCell>
|
|
216
|
+
<TableCell>{location(row) || dash}</TableCell>
|
|
217
|
+
{props.renderRowActions ? <TableCell onClick={(e) => e.stopPropagation()}>{props.renderRowActions(row, { reload })}</TableCell> : null}
|
|
218
|
+
</TableRow>
|
|
219
|
+
);
|
|
220
|
+
})}
|
|
221
|
+
</TableBody>
|
|
222
|
+
</Table>
|
|
223
|
+
{!list.loading && !list.error && rows.length === 0 ? (
|
|
224
|
+
<EmptyState
|
|
225
|
+
title={hasFilters ? "No companies match these filters" : "No companies yet"}
|
|
226
|
+
description={hasFilters ? "Try fewer filters." : "Add a company, or import contacts and their companies are created from their domains."}
|
|
227
|
+
action={hasFilters ? <Button variant="outline" onClick={urlFilters.clear}>Clear filters</Button> : <Button onClick={() => setSheet("new")}>New company</Button>}
|
|
228
|
+
/>
|
|
229
|
+
) : null}
|
|
230
|
+
<Pager page={list.page} pageCount={list.pageCount} total={list.total} noun="companies" hasNext={list.hasNext} loading={list.loading} onPage={list.setPage} />
|
|
231
|
+
</div>
|
|
232
|
+
{sort ? <p className="text-xs text-muted-foreground">Sorted within this page only.</p> : null}
|
|
233
|
+
</div>
|
|
234
|
+
}
|
|
235
|
+
detail={openId != null ? (
|
|
236
|
+
<CompanyPanel key={openId} full={full} setFull={(v) => setView(v ? "full" : null)} companyId={openId} seed={list.rows.find((r) => r.id === openId)} onClose={() => setIdParam(null)} onChanged={reload} onDeleted={() => { setIdParam(null); reload(); }}
|
|
237
|
+
onOpenContact={props.onOpenContact} renderActions={props.renderDetailActions} extraTabs={props.extraDetailTabs} />
|
|
238
|
+
) : null}
|
|
239
|
+
/>
|
|
240
|
+
|
|
241
|
+
<FormSheet open={sheet === "new"} onOpenChange={(o) => setSheet(o ? "new" : null)} title="New company" description="A domain identifies the company; if it exists, it is updated instead.">
|
|
242
|
+
<RecordForm entity="company" onCancel={() => setSheet(null)} onSaved={({ id }) => { setSheet(null); reload(); if (id != null) setIdParam(String(id)); }} />
|
|
243
|
+
</FormSheet>
|
|
244
|
+
<FormSheet open={sheet === "dupes"} onOpenChange={(o) => setSheet(o ? "dupes" : null)} title="Duplicate companies" description="Merge pairs, or mark them as not duplicates.">
|
|
245
|
+
<DedupeReview entity="company" onChanged={reload} />
|
|
246
|
+
</FormSheet>
|
|
247
|
+
|
|
248
|
+
<ConfirmSpendDialog
|
|
249
|
+
open={confirmDelete}
|
|
250
|
+
tier="destructive"
|
|
251
|
+
title={`Delete ${plural(selection.ids.length, "company", "companies")}`}
|
|
252
|
+
description="Their contacts stay but lose the company link."
|
|
253
|
+
affected={selection.ids.length}
|
|
254
|
+
confirming={bulk.loading}
|
|
255
|
+
onConfirm={() => void bulk.mutate(selection.ids)}
|
|
256
|
+
onCancel={() => setConfirmDelete(false)}
|
|
257
|
+
/>
|
|
258
|
+
</ScreenLayout>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
interface CompanyPanelProps {
|
|
263
|
+
full: boolean;
|
|
264
|
+
setFull: (full: boolean) => void;
|
|
265
|
+
companyId: number;
|
|
266
|
+
/** The list row, for fields the detail answer lacks (owner). */
|
|
267
|
+
seed?: CompanyRecord;
|
|
268
|
+
onClose: () => void;
|
|
269
|
+
onChanged: () => void;
|
|
270
|
+
onDeleted: () => void;
|
|
271
|
+
onOpenContact?: (contact: ContactRecord) => void;
|
|
272
|
+
renderActions?: CompaniesScreenProps["renderDetailActions"];
|
|
273
|
+
extraTabs?: CompaniesScreenProps["extraDetailTabs"];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function CompanyPanel({ full, setFull, companyId, seed, onClose, onChanged, onDeleted, onOpenContact, renderActions, extraTabs = [] }: CompanyPanelProps) {
|
|
277
|
+
const [editing, setEditing] = useState(false);
|
|
278
|
+
const [confirm, setConfirm] = useState(false);
|
|
279
|
+
const [feedKey, setFeedKey] = useState(0);
|
|
280
|
+
const [addingPerson, setAddingPerson] = useState(false);
|
|
281
|
+
const [peopleKey, setPeopleKey] = useState(0);
|
|
282
|
+
const query = useG8Query((signal) => g8Get<CompanyRecord>(`/companies/${companyId}`, { include_custom_fields: true }, { signal }), [companyId], {
|
|
283
|
+
key: ["companies", "detail", companyId],
|
|
284
|
+
// The row the company was opened from paints the panel at once; the full record replaces it.
|
|
285
|
+
placeholderData: seed ? { data: seed } : undefined,
|
|
286
|
+
});
|
|
287
|
+
/** Edit, delete and slot actions wait for the full record: a list row must never seed the edit form. */
|
|
288
|
+
const partial = query.isPlaceholderData === true;
|
|
289
|
+
const remove = useG8Mutation(async () => {
|
|
290
|
+
try { await g8Delete(`/companies/${companyId}`); } catch (e) { if (errorStatus(e) !== 404) throw e; }
|
|
291
|
+
}, { invalidates: [["companies", "list"]], onSuccess: () => { setConfirm(false); onDeleted(); } });
|
|
292
|
+
const bump = () => setFeedKey((k) => k + 1);
|
|
293
|
+
|
|
294
|
+
if (query.loading && !query.data) return <div className="flex flex-col gap-3 p-4"><Skeleton className="h-14 w-full" /><Skeleton className="h-40 w-full" /></div>;
|
|
295
|
+
// A failed full record must not leave the list row on screen with every action disabled.
|
|
296
|
+
if (query.error && (!query.data || partial)) return <div className="p-4"><ApiError error={query.error} title="Could not load this company" onRetry={() => void query.refetch()} /></div>;
|
|
297
|
+
const company = query.data!.data;
|
|
298
|
+
const name = companyName(company);
|
|
299
|
+
const val = (v?: string | number | null): ReactNode => (v == null || v === "" ? dash : v);
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<>
|
|
303
|
+
<DetailPanel
|
|
304
|
+
title={name}
|
|
305
|
+
subtitle={[company.domain, company.industry].filter(Boolean).join(" - ") || "No domain or industry"}
|
|
306
|
+
avatar={<EntityAvatar name={name} imageUrl={company.logo_url} kind="company" size="lg" />}
|
|
307
|
+
onClose={onClose}
|
|
308
|
+
onExpand={full ? undefined : () => setFull(true)}
|
|
309
|
+
onCollapse={full ? () => setFull(false) : undefined}
|
|
310
|
+
actions={
|
|
311
|
+
<>
|
|
312
|
+
{partial ? null : renderActions?.(company, { reload: () => { void query.refetch(); bump(); onChanged(); } })}
|
|
313
|
+
<Button variant="outline" size="sm" disabled={partial} title={partial ? "Loading the full record" : undefined} onClick={() => setEditing(true)}>Edit</Button>
|
|
314
|
+
<Button variant="destructive" size="sm" disabled={partial} onClick={() => setConfirm(true)}>Delete</Button>
|
|
315
|
+
</>
|
|
316
|
+
}
|
|
317
|
+
tabs={[
|
|
318
|
+
{ id: "overview", label: "Overview", content: (
|
|
319
|
+
<KeyValueList columns={2} items={[
|
|
320
|
+
{ label: "Website", value: val(company.website) },
|
|
321
|
+
{ label: "Phone", value: val(company.phone) },
|
|
322
|
+
{ label: "Employees", value: val(company.employee_count) },
|
|
323
|
+
{ label: "Revenue", value: val(company.revenue) },
|
|
324
|
+
{ label: "Founded", value: val(company.founded_year) },
|
|
325
|
+
{ label: "Location", value: val(location(company)) },
|
|
326
|
+
{ label: "LinkedIn", value: val(company.linkedin_url) },
|
|
327
|
+
{ label: "Owner", value: val(company.owner_name ?? seed?.owner_name) },
|
|
328
|
+
{ label: "People", value: val(company.contact_count) },
|
|
329
|
+
{ label: "Added", value: val(formatDay(company.created_at)) },
|
|
330
|
+
...Object.entries(company.custom_fields ?? {}).map(([k, v]) => ({ label: k, value: val(v) })),
|
|
331
|
+
...(company.description ? [{ label: "About", value: company.description }] : []),
|
|
332
|
+
]} />
|
|
333
|
+
) },
|
|
334
|
+
{ id: "people", label: "People", content: (
|
|
335
|
+
<div className="flex flex-col gap-3">
|
|
336
|
+
<div><Button variant="outline" size="sm" onClick={() => setAddingPerson(true)}><UserPlus className="size-4" aria-hidden />Add person</Button></div>
|
|
337
|
+
<ContactTable key={peopleKey} companyId={companyId} pageSize={10} onOpen={onOpenContact} />
|
|
338
|
+
</div>
|
|
339
|
+
) },
|
|
340
|
+
{ id: "activity", label: "Activity", content: <ActivityTimeline entityType="company" entityId={companyId} refreshKey={feedKey} /> },
|
|
341
|
+
{ id: "notes", label: "Notes", content: <NotesPanel entity="company" entityId={companyId} onChanged={bump} /> },
|
|
342
|
+
{ id: "tasks", label: "Tasks", content: <TaskList entity="company" entityId={companyId} onChanged={bump} /> },
|
|
343
|
+
...(partial ? [] : extraTabs).map((t) => ({ id: t.id, label: t.label, content: t.render(company) })),
|
|
344
|
+
]}
|
|
345
|
+
/>
|
|
346
|
+
{remove.error ? <div className="p-4"><ApiError error={remove.error} title="Could not delete the company" /></div> : null}
|
|
347
|
+
<FormSheet open={editing && !partial} onOpenChange={setEditing} title={`Edit ${name}`}>
|
|
348
|
+
<RecordForm entity="company" record={company} onCancel={() => setEditing(false)} onSaved={() => { setEditing(false); void query.refetch(); bump(); onChanged(); }} />
|
|
349
|
+
</FormSheet>
|
|
350
|
+
<FormSheet open={addingPerson} onOpenChange={setAddingPerson} title={`Add a person at ${name}`} description="They are linked to this company by its domain.">
|
|
351
|
+
<RecordForm entity="contact" defaults={company.domain ? { company_domain: company.domain } : undefined} onCancel={() => setAddingPerson(false)}
|
|
352
|
+
onSaved={() => { setAddingPerson(false); setPeopleKey((k) => k + 1); bump(); onChanged(); void query.refetch(); }}
|
|
353
|
+
onDuplicate={() => setAddingPerson(false)} />
|
|
354
|
+
</FormSheet>
|
|
355
|
+
<ConfirmSpendDialog open={confirm} tier="destructive" title={`Delete ${name}`} description="Its contacts stay but lose the company link." affected={1}
|
|
356
|
+
confirming={remove.loading} onConfirm={() => void remove.mutate(undefined)} onCancel={() => setConfirm(false)} />
|
|
357
|
+
</>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
@@ -21,7 +21,7 @@ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
|
21
21
|
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
22
22
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
23
23
|
import { g8Delete, g8Get } from "@/g8/lib/g8-client";
|
|
24
|
-
import { initials, location } from "../lib/crm-types";
|
|
24
|
+
import { companyName, initials, location } from "../lib/crm-types";
|
|
25
25
|
import type { CompanyRecord, ContactRecord } from "../lib/crm-types";
|
|
26
26
|
import { ActivityTimeline } from "./activity-timeline";
|
|
27
27
|
import { ContactTable } from "./contact-table";
|
|
@@ -52,21 +52,23 @@ export function CompanyDetail({ companyId, onDeleted, onOpenContact, renderActio
|
|
|
52
52
|
const [editing, setEditing] = useState(false);
|
|
53
53
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
54
54
|
const [feedKey, setFeedKey] = useState(0);
|
|
55
|
-
const query = useG8Query((signal) => g8Get<CompanyRecord>(`/companies/${companyId}`, { include_custom_fields: true }, { signal }), [companyId]);
|
|
56
|
-
const remove = useG8Mutation(() => g8Delete(`/companies/${companyId}`), { onSuccess: () => { setConfirmDelete(false); onDeleted?.(companyId); } });
|
|
55
|
+
const query = useG8Query((signal) => g8Get<CompanyRecord>(`/companies/${companyId}`, { include_custom_fields: true }, { signal }), [companyId], { key: ["companies", "detail", companyId] });
|
|
56
|
+
const remove = useG8Mutation(() => g8Delete(`/companies/${companyId}`), { invalidates: [["companies", "list"]], onSuccess: () => { setConfirmDelete(false); onDeleted?.(companyId); } });
|
|
57
57
|
const bump = () => setFeedKey((k) => k + 1);
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
// The hook keeps the previous record's data while the next one loads: show THIS record only (edit, delete and tabs all act on companyId).
|
|
60
|
+
const wrongRecord = query.data != null && String(query.data.data?.id) !== String(companyId);
|
|
61
|
+
if (query.error && (!query.data || wrongRecord)) return <ApiError error={query.error} title="Could not load this company" onRetry={() => void query.refetch()} />;
|
|
62
|
+
if ((query.loading && !query.data) || wrongRecord) return <div className={`flex flex-col gap-3 ${className ?? ""}`}><Skeleton className="h-16 w-full" /><Skeleton className="h-40 w-full" /></div>;
|
|
61
63
|
const company = query.data!.data;
|
|
62
|
-
const name = company
|
|
64
|
+
const name = companyName(company);
|
|
63
65
|
|
|
64
66
|
return (
|
|
65
67
|
<div className={`flex flex-col gap-4 ${className ?? ""}`}>
|
|
66
68
|
<header className="flex flex-wrap items-center gap-3">
|
|
67
|
-
<div aria-hidden className="flex size-12 items-center justify-center rounded-md bg-muted text-sm font-
|
|
69
|
+
<div aria-hidden className="flex size-12 items-center justify-center rounded-md bg-muted text-sm font-medium">{initials(name)}</div>
|
|
68
70
|
<div className="min-w-0 flex-1">
|
|
69
|
-
<h2 className="truncate text-lg font-
|
|
71
|
+
<h2 className="truncate text-lg font-medium">{name}</h2>
|
|
70
72
|
<p className="truncate text-sm text-muted-foreground">{[company.domain, company.industry].filter(Boolean).join(" - ") || "No domain or industry"}</p>
|
|
71
73
|
</div>
|
|
72
74
|
<div className="flex flex-wrap items-center gap-2">
|
|
@@ -17,11 +17,14 @@ import { ApiError } from "@/g8/components/api-error";
|
|
|
17
17
|
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
18
18
|
import { EmptyState } from "@/g8/components/empty-state";
|
|
19
19
|
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
20
|
+
import { rowActivationProps } from "@/g8/hooks/use-row-activation";
|
|
21
|
+
import { RecordLink } from "@/g8/components/screen/record-link";
|
|
22
|
+
import { plural } from "@/g8/lib/plural";
|
|
20
23
|
import { g8Delete, g8Get } from "@/g8/lib/g8-client";
|
|
21
24
|
import { useCustomFields } from "../hooks/use-custom-fields";
|
|
22
25
|
import { useDebouncedValue } from "../hooks/use-debounced";
|
|
23
26
|
import { usePageQuery } from "../hooks/use-page-query";
|
|
24
|
-
import { errorStatus, fieldLabel, location } from "../lib/crm-types";
|
|
27
|
+
import { companyName, errorStatus, fieldLabel, location } from "../lib/crm-types";
|
|
25
28
|
import type { CompanyRecord } from "../lib/crm-types";
|
|
26
29
|
|
|
27
30
|
export interface CompanyFilters {
|
|
@@ -32,6 +35,8 @@ export interface CompanyFilters {
|
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
export interface CompanyTableProps {
|
|
38
|
+
/** URL param the host screen reads for this record type. With it the name is a real link (Cmd-click opens it in a new tab); without it a plain button. */
|
|
39
|
+
linkParam?: string;
|
|
35
40
|
pageSize?: number;
|
|
36
41
|
initialFilters?: CompanyFilters;
|
|
37
42
|
selectable?: boolean;
|
|
@@ -57,6 +62,7 @@ export function CompanyTable(props: CompanyTableProps) {
|
|
|
57
62
|
(page, signal) => g8Get<CompanyRecord[]>("/companies", { page, limit: pageSize, include_custom_fields: customFields || undefined, ...filters }, { signal }),
|
|
58
63
|
[filters, customFields, props.refreshKey],
|
|
59
64
|
pageSize,
|
|
65
|
+
["companies", "list", "table"],
|
|
60
66
|
);
|
|
61
67
|
|
|
62
68
|
const ids = useMemo(() => [...selected.keys()], [selected]);
|
|
@@ -89,6 +95,7 @@ export function CompanyTable(props: CompanyTableProps) {
|
|
|
89
95
|
const gone = toDelete.filter((_, i) => results[i].status === "fulfilled" || errorStatus((results[i] as PromiseRejectedResult).reason) === 404);
|
|
90
96
|
return { gone, total: toDelete.length };
|
|
91
97
|
}, {
|
|
98
|
+
invalidates: [["companies", "list"]],
|
|
92
99
|
onSuccess: ({ gone, total }) => {
|
|
93
100
|
setConfirmOpen(false);
|
|
94
101
|
const next = new Map(selected);
|
|
@@ -139,9 +146,9 @@ export function CompanyTable(props: CompanyTableProps) {
|
|
|
139
146
|
{list.loading && list.rows.length === 0
|
|
140
147
|
? Array.from({ length: 5 }, (_, i) => <TableRow key={`sk${i}`}><TableCell colSpan={5 + columns.length + (selectable ? 1 : 0)}><Skeleton className="h-5 w-full" /></TableCell></TableRow>)
|
|
141
148
|
: list.rows.map((row) => (
|
|
142
|
-
<TableRow key={row.id} data-testid={`company-row-${row.id}`} className={onOpen ? "cursor-pointer" : undefined}
|
|
149
|
+
<TableRow key={row.id} data-testid={`company-row-${row.id}`} className={onOpen ? "cursor-pointer" : undefined} {...(onOpen ? rowActivationProps(() => onOpen(row)) : {})}>
|
|
143
150
|
{selectable ? <TableCell onClick={(e) => e.stopPropagation()}><input type="checkbox" aria-label={`Select ${row.name ?? row.domain}`} checked={selected.has(row.id)} onChange={() => toggle(row)} /></TableCell> : null}
|
|
144
|
-
<TableCell className="font-medium">{
|
|
151
|
+
<TableCell className="font-medium">{onOpen ? <RecordLink param={props.linkParam} id={row.id} onOpen={() => onOpen(row)}>{companyName(row)}</RecordLink> : companyName(row)}</TableCell>
|
|
145
152
|
<TableCell>{row.domain || "-"}</TableCell>
|
|
146
153
|
<TableCell>{row.industry || "-"}</TableCell>
|
|
147
154
|
<TableCell>{row.employee_count || "-"}</TableCell>
|
|
@@ -165,7 +172,7 @@ export function CompanyTable(props: CompanyTableProps) {
|
|
|
165
172
|
</div>
|
|
166
173
|
</div>
|
|
167
174
|
|
|
168
|
-
<ConfirmSpendDialog open={confirmOpen} tier="destructive" title={`Delete ${ids.length
|
|
175
|
+
<ConfirmSpendDialog open={confirmOpen} tier="destructive" title={`Delete ${plural(ids.length, "company", "companies")}`}
|
|
169
176
|
description="Their contacts stay, but lose the company link." affected={ids.length} confirming={remove.loading}
|
|
170
177
|
onConfirm={() => void remove.mutate(ids)} onCancel={() => setConfirmOpen(false)} />
|
|
171
178
|
</div>
|
|
@@ -25,7 +25,7 @@ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
|
25
25
|
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
26
26
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
27
27
|
import { g8Delete, g8Get } from "@/g8/lib/g8-client";
|
|
28
|
-
import { contactName, initials, isMasked, location } from "../lib/crm-types";
|
|
28
|
+
import { contactName, contactSubtitle, initials, isMasked, location } from "../lib/crm-types";
|
|
29
29
|
import type { ContactRecord } from "../lib/crm-types";
|
|
30
30
|
import { ActivityTimeline } from "./activity-timeline";
|
|
31
31
|
import { NotesPanel } from "./notes-panel";
|
|
@@ -58,25 +58,28 @@ export function ContactDetail({ contactId, onDeleted, onOpenCompany, renderActio
|
|
|
58
58
|
const [editing, setEditing] = useState(false);
|
|
59
59
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
60
60
|
const [feedKey, setFeedKey] = useState(0);
|
|
61
|
-
const query = useG8Query((signal) => g8Get<ContactRecord>(`/contacts/${contactId}`, { include_custom_fields: true }, { signal }), [contactId]);
|
|
61
|
+
const query = useG8Query((signal) => g8Get<ContactRecord>(`/contacts/${contactId}`, { include_custom_fields: true }, { signal }), [contactId], { key: ["contacts", "detail", contactId] });
|
|
62
62
|
const remove = useG8Mutation(() => g8Delete(`/contacts/${contactId}`), {
|
|
63
|
+
invalidates: [["contacts", "list"]],
|
|
63
64
|
onSuccess: () => { setConfirmDelete(false); onDeleted?.(contactId); },
|
|
64
65
|
});
|
|
65
66
|
const bump = () => setFeedKey((k) => k + 1);
|
|
66
67
|
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
// The hook keeps the previous record's data while the next one loads: show THIS record only (edit, delete and tabs all act on contactId).
|
|
69
|
+
const wrongRecord = query.data != null && String(query.data.data?.id) !== String(contactId);
|
|
70
|
+
if (query.error && (!query.data || wrongRecord)) return <ApiError error={query.error} title="Could not load this contact" onRetry={() => void query.refetch()} />;
|
|
71
|
+
if ((query.loading && !query.data) || wrongRecord) return <div className={`flex flex-col gap-3 ${className ?? ""}`}><Skeleton className="h-16 w-full" /><Skeleton className="h-40 w-full" /></div>;
|
|
69
72
|
const contact = query.data!.data;
|
|
70
73
|
const name = contactName(contact);
|
|
71
74
|
|
|
72
75
|
return (
|
|
73
76
|
<div className={`flex flex-col gap-4 ${className ?? ""}`}>
|
|
74
77
|
<header className="flex flex-wrap items-center gap-3">
|
|
75
|
-
<div aria-hidden className="flex size-12 items-center justify-center rounded-full bg-muted text-sm font-
|
|
78
|
+
<div aria-hidden className="flex size-12 items-center justify-center rounded-full bg-muted text-sm font-medium">{initials(name)}</div>
|
|
76
79
|
<div className="min-w-0 flex-1">
|
|
77
|
-
<h2 className="truncate text-lg font-
|
|
80
|
+
<h2 className="truncate text-lg font-medium">{name}</h2>
|
|
78
81
|
<p className="truncate text-sm text-muted-foreground">
|
|
79
|
-
{
|
|
82
|
+
{contactSubtitle(contact)}
|
|
80
83
|
</p>
|
|
81
84
|
</div>
|
|
82
85
|
<div className="flex flex-wrap items-center gap-2">
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ContactLookupLink: "Open contact" for something that only knows a person's email (an inbox thread, a meeting
|
|
5
|
+
* attendee, a visitor). It looks the email up in your CRM and, when exactly one contact has it, shows a button that
|
|
6
|
+
* calls `onOpen(contactId)` (route to your contacts screen: `router.push("/contacts?id=" + id)`). When nobody has that
|
|
7
|
+
* email it renders nothing, so a thread from a stranger gets no dead button.
|
|
8
|
+
*
|
|
9
|
+
* SDK operations, in order:
|
|
10
|
+
* 1. listContacts GET /contacts?email=<email>&limit=2 (read)
|
|
11
|
+
* Nothing here is billable, external or destructive.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Button } from "@graph8/sdk/ui";
|
|
15
|
+
import { UserRound } from "lucide-react";
|
|
16
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
17
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
18
|
+
|
|
19
|
+
export interface ContactLookupLinkProps {
|
|
20
|
+
/** The person's email. Nothing is looked up (and nothing rendered) without one. */
|
|
21
|
+
email?: string | null;
|
|
22
|
+
/** Called with the CRM contact id when the button is clicked. */
|
|
23
|
+
onOpen: (contactId: number) => void;
|
|
24
|
+
label?: string;
|
|
25
|
+
variant?: "default" | "outline" | "secondary" | "ghost";
|
|
26
|
+
size?: "default" | "sm";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ContactLookupLink({ email, onOpen, label = "Open contact", variant = "outline", size = "sm" }: ContactLookupLinkProps) {
|
|
30
|
+
const address = email?.trim().toLowerCase() ?? "";
|
|
31
|
+
const q = useG8Query(
|
|
32
|
+
async (signal) => ((await g8Get<Array<{ id: number; work_email?: string | null }>>("/contacts", { email: address, limit: 2, page: 1 }, { signal })).data ?? []),
|
|
33
|
+
[address],
|
|
34
|
+
{ enabled: address.includes("@"), key: ["contacts", "by-email", address] },
|
|
35
|
+
);
|
|
36
|
+
const match = (q.data ?? []).find((c) => !c.work_email || c.work_email.toLowerCase() === address);
|
|
37
|
+
if (!address || !match) return null;
|
|
38
|
+
return (
|
|
39
|
+
<Button size={size} variant={variant} onClick={() => onOpen(match.id)}>
|
|
40
|
+
<UserRound className="mr-1 size-4" aria-hidden />
|
|
41
|
+
{label}
|
|
42
|
+
</Button>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -20,6 +20,9 @@ import { ApiError } from "@/g8/components/api-error";
|
|
|
20
20
|
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
21
21
|
import { EmptyState } from "@/g8/components/empty-state";
|
|
22
22
|
import { g8Delete, g8Get } from "@/g8/lib/g8-client";
|
|
23
|
+
import { rowActivationProps } from "@/g8/hooks/use-row-activation";
|
|
24
|
+
import { RecordLink } from "@/g8/components/screen/record-link";
|
|
25
|
+
import { plural } from "@/g8/lib/plural";
|
|
23
26
|
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
24
27
|
import { useCustomFields } from "../hooks/use-custom-fields";
|
|
25
28
|
import { useDebouncedValue } from "../hooks/use-debounced";
|
|
@@ -35,6 +38,8 @@ export interface ContactFilters {
|
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export interface ContactTableProps {
|
|
41
|
+
/** URL param the host screen reads for this record type. With it the name is a real link (Cmd-click opens it in a new tab); without it a plain button. */
|
|
42
|
+
linkParam?: string;
|
|
38
43
|
/** Only members of this list. */
|
|
39
44
|
listId?: number;
|
|
40
45
|
/** Only people of this company (mashup company id). */
|
|
@@ -82,6 +87,7 @@ export function ContactTable(props: ContactTableProps) {
|
|
|
82
87
|
),
|
|
83
88
|
[listId, companyId, filters, customFields, props.refreshKey],
|
|
84
89
|
pageSize,
|
|
90
|
+
["contacts", "list", "table"],
|
|
85
91
|
);
|
|
86
92
|
|
|
87
93
|
const ids = useMemo(() => [...selected.keys()], [selected]);
|
|
@@ -117,6 +123,7 @@ export function ContactTable(props: ContactTableProps) {
|
|
|
117
123
|
const gone = toDelete.filter((_, i) => results[i].status === "fulfilled" || errorStatus((results[i] as PromiseRejectedResult).reason) === 404);
|
|
118
124
|
return { gone, total: toDelete.length };
|
|
119
125
|
}, {
|
|
126
|
+
invalidates: [["contacts", "list"]],
|
|
120
127
|
onSuccess: ({ gone, total }) => {
|
|
121
128
|
setConfirmOpen(false);
|
|
122
129
|
const next = new Map(selected);
|
|
@@ -172,13 +179,13 @@ export function ContactTable(props: ContactTableProps) {
|
|
|
172
179
|
<TableRow key={`sk${i}`}><TableCell colSpan={5 + columns.length + (selectable ? 1 : 0)}><Skeleton className="h-5 w-full" /></TableCell></TableRow>
|
|
173
180
|
))
|
|
174
181
|
: list.rows.map((row) => (
|
|
175
|
-
<TableRow key={row.id} data-testid={`contact-row-${row.id}`} className={onOpen ? "cursor-pointer" : undefined}
|
|
182
|
+
<TableRow key={row.id} data-testid={`contact-row-${row.id}`} className={onOpen ? "cursor-pointer" : undefined} {...(onOpen ? rowActivationProps(() => onOpen(row)) : {})}>
|
|
176
183
|
{selectable ? (
|
|
177
184
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
178
185
|
<input type="checkbox" aria-label={`Select ${contactName(row)}`} checked={selected.has(row.id)} onChange={() => toggle(row)} />
|
|
179
186
|
</TableCell>
|
|
180
187
|
) : null}
|
|
181
|
-
<TableCell className="font-medium">{contactName(row)}</TableCell>
|
|
188
|
+
<TableCell className="font-medium">{onOpen ? <RecordLink param={props.linkParam} id={row.id} onOpen={() => onOpen(row)}>{contactName(row)}</RecordLink> : contactName(row)}</TableCell>
|
|
182
189
|
<TableCell><CellValue value={row.job_title} /></TableCell>
|
|
183
190
|
<TableCell><CellValue value={row.company_name} /></TableCell>
|
|
184
191
|
<TableCell><CellValue value={row.work_email} /></TableCell>
|
|
@@ -205,7 +212,7 @@ export function ContactTable(props: ContactTableProps) {
|
|
|
205
212
|
<ConfirmSpendDialog
|
|
206
213
|
open={confirmOpen}
|
|
207
214
|
tier="destructive"
|
|
208
|
-
title={`Delete ${ids.length
|
|
215
|
+
title={`Delete ${plural(ids.length, "contact")}`}
|
|
209
216
|
description="They are removed from every list and sequence."
|
|
210
217
|
affected={ids.length}
|
|
211
218
|
confirming={remove.loading}
|