@graph8/sdk 0.361.0 → 0.370.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 +14 -10
- package/blocks/_foundation/README.md +172 -1
- package/blocks/_foundation/block.json +234 -13
- package/blocks/_foundation/components/api-error.tsx +71 -25
- package/blocks/_foundation/components/brand-provider.tsx +23 -0
- 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 +39 -7
- package/blocks/_foundation/components/screen/bar-list.tsx +71 -0
- package/blocks/_foundation/components/screen/detail-panel.tsx +114 -0
- package/blocks/_foundation/components/screen/entity-avatar.tsx +56 -0
- package/blocks/_foundation/components/screen/form-sheet.tsx +73 -0
- package/blocks/_foundation/components/screen/index.ts +22 -0
- package/blocks/_foundation/components/screen/key-value-list.tsx +65 -0
- package/blocks/_foundation/components/screen/record-link.tsx +76 -0
- package/blocks/_foundation/components/screen/screen-layout.tsx +124 -0
- package/blocks/_foundation/components/screen/section-card.tsx +47 -0
- package/blocks/_foundation/components/screen/sparkline.tsx +77 -0
- package/blocks/_foundation/components/screen/split-view.tsx +125 -0
- package/blocks/_foundation/components/screen/stat-strip.tsx +121 -0
- package/blocks/_foundation/components/screen/status-pill.tsx +104 -0
- package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
- package/blocks/_foundation/components/screen/time-series-chart.tsx +32 -0
- package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
- package/blocks/_foundation/components/screen/toolbar.tsx +144 -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-paginated.ts +120 -0
- package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
- package/blocks/_foundation/hooks/use-url-state.ts +74 -0
- package/blocks/_foundation/lib/g8-cache.ts +454 -0
- package/blocks/_foundation/lib/g8-client.ts +1 -1
- package/blocks/_foundation/lib/plural.ts +23 -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 +14 -0
- package/blocks/agents/block.json +33 -1
- package/blocks/agents/components/agent-builder.tsx +13 -8
- package/blocks/agents/components/agent-knowledge-panel.tsx +2 -1
- package/blocks/agents/components/agent-run-detail.tsx +102 -0
- package/blocks/agents/components/agent-runs.tsx +16 -7
- package/blocks/agents/components/agents-screen.tsx +184 -0
- package/blocks/analytics/README.md +13 -0
- package/blocks/analytics/block.json +45 -1
- package/blocks/analytics/components/analytics-screen.tsx +209 -0
- package/blocks/analytics/components/leaderboard.tsx +14 -7
- package/blocks/analytics/components/report-builder.tsx +23 -9
- package/blocks/analytics/components/report-detail.tsx +84 -0
- package/blocks/analytics/components/trend-chart.tsx +62 -0
- package/blocks/appointments/README.md +21 -1
- package/blocks/appointments/block.json +43 -2
- package/blocks/appointments/components/availability-editor.tsx +17 -5
- package/blocks/appointments/components/event-type-editor.tsx +2 -1
- package/blocks/appointments/components/event-type-related.tsx +77 -0
- package/blocks/appointments/components/meeting-list.tsx +14 -6
- package/blocks/appointments/components/meetings-screen.tsx +157 -0
- package/blocks/appointments/components/scheduling-screen.tsx +122 -0
- package/blocks/appointments/hooks/use-event-types.ts +17 -0
- package/blocks/block.schema.json +18 -0
- package/blocks/block.types.ts +26 -0
- package/blocks/campaigns/README.md +41 -11
- package/blocks/campaigns/block.json +105 -42
- package/blocks/campaigns/components/campaign-assets.tsx +16 -5
- package/blocks/campaigns/components/campaign-launch-step.tsx +102 -19
- package/blocks/campaigns/components/campaign-list.tsx +7 -3
- package/blocks/campaigns/components/campaign-wizard.tsx +43 -10
- package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
- package/blocks/campaigns/components/icp-detail.tsx +154 -0
- package/blocks/campaigns/components/icp-editor.tsx +15 -15
- package/blocks/campaigns/components/persona-detail.tsx +172 -0
- package/blocks/campaigns/components/persona-editor.tsx +14 -11
- package/blocks/campaigns/lib/campaigns-utils.ts +134 -1
- 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/coach-screen.tsx +222 -0
- package/blocks/copilot/README.md +13 -0
- package/blocks/copilot/block.json +14 -1
- 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 +7 -5
- package/blocks/crm/README.md +28 -0
- package/blocks/crm/block.json +58 -2
- package/blocks/crm/components/companies-screen.tsx +345 -0
- package/blocks/crm/components/company-detail.tsx +8 -6
- package/blocks/crm/components/company-table.tsx +11 -4
- package/blocks/crm/components/contact-detail.tsx +6 -3
- package/blocks/crm/components/contact-table.tsx +10 -3
- package/blocks/crm/components/contacts-screen.tsx +359 -0
- package/blocks/crm/components/dedupe-review.tsx +7 -5
- package/blocks/crm/components/import-wizard.tsx +4 -2
- package/blocks/crm/components/notes-panel.tsx +4 -4
- package/blocks/crm/components/record-form.tsx +5 -3
- package/blocks/crm/components/task-list.tsx +4 -4
- package/blocks/crm/components/tasks-screen.tsx +379 -0
- package/blocks/crm/hooks/use-custom-fields.ts +6 -1
- package/blocks/crm/hooks/use-page-query.ts +7 -1
- package/blocks/crm/lib/crm-types.ts +12 -2
- package/blocks/crm/lib/screen-parts.tsx +105 -0
- package/blocks/deals/README.md +34 -3
- package/blocks/deals/block.json +45 -4
- package/blocks/deals/components/deal-detail.tsx +27 -13
- package/blocks/deals/components/deal-related.tsx +87 -0
- package/blocks/deals/components/deals-screen.tsx +298 -0
- package/blocks/deals/components/pipeline-board.tsx +4 -2
- package/blocks/deals/components/stage-editor.tsx +4 -4
- package/blocks/deals/hooks/use-default-owner.ts +60 -0
- package/blocks/deals/hooks/use-pipeline-deals.ts +5 -1
- package/blocks/deals/hooks/use-pipelines.ts +1 -1
- package/blocks/deals/lib/deals-types.ts +14 -0
- package/blocks/dialer/README.md +34 -2
- package/blocks/dialer/block.json +67 -1
- package/blocks/dialer/components/call-contact-button.tsx +252 -0
- package/blocks/dialer/components/call-log-screen.tsx +205 -0
- package/blocks/dialer/components/call-log.tsx +7 -3
- package/blocks/dialer/components/call-queue.tsx +1 -1
- package/blocks/dialer/components/dialer-screen.tsx +319 -0
- package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
- package/blocks/dialer/components/live-calls-table.tsx +7 -3
- package/blocks/dialer/components/session-start-form.tsx +4 -2
- package/blocks/dialer/components/softphone.tsx +3 -1
- package/blocks/enrichment/README.md +14 -2
- package/blocks/enrichment/block.json +32 -4
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +10 -5
- package/blocks/enrichment/components/email-verify-badge.tsx +28 -8
- 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 +288 -0
- package/blocks/enrichment/components/waterfall-config.tsx +39 -10
- package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
- package/blocks/forms/README.md +12 -0
- package/blocks/forms/block.json +39 -17
- package/blocks/forms/components/form-builder.tsx +3 -1
- package/blocks/forms/components/form-embed.tsx +3 -1
- package/blocks/forms/components/forms-screen.tsx +353 -0
- package/blocks/forms/components/submissions-table.tsx +16 -6
- package/blocks/inbox/README.md +27 -3
- package/blocks/inbox/block.json +14 -2
- package/blocks/inbox/components/inbox-list.tsx +7 -3
- package/blocks/inbox/components/inbox-screen.tsx +294 -27
- package/blocks/inbox/components/reply-composer.tsx +65 -9
- package/blocks/inbox/components/tag-and-assign.tsx +4 -2
- 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 +9 -0
- package/blocks/integrations/block.json +46 -1
- package/blocks/integrations/components/crm-detail.tsx +100 -0
- package/blocks/integrations/components/integration-list.tsx +9 -4
- package/blocks/integrations/components/integrations-screen.tsx +135 -0
- package/blocks/integrations/components/sync-status.tsx +5 -3
- package/blocks/integrations/components/webhook-detail.tsx +160 -0
- package/blocks/integrations/components/webhook-manager.tsx +13 -8
- package/blocks/integrations/lib/integrations-utils.ts +6 -1
- package/blocks/intent/README.md +14 -0
- package/blocks/intent/block.json +27 -1
- package/blocks/intent/components/intent-feed.tsx +5 -2
- package/blocks/intent/components/intent-keyword-manager.tsx +8 -4
- package/blocks/intent/components/intent-screen.tsx +265 -0
- package/blocks/intent/components/keyword-detail.tsx +124 -0
- package/blocks/lifecycle/README.md +16 -0
- package/blocks/lifecycle/block.json +34 -7
- package/blocks/lifecycle/components/account-related.tsx +66 -0
- package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
- package/blocks/lifecycle/components/lifecycle-screen.tsx +149 -0
- package/blocks/lifecycle/lib/lifecycle.ts +1 -1
- package/blocks/lists/README.md +13 -0
- package/blocks/lists/block.json +39 -1
- package/blocks/lists/components/add-to-list-button.tsx +3 -2
- package/blocks/lists/components/list-picker.tsx +5 -1
- package/blocks/lists/components/list-table.tsx +11 -5
- package/blocks/lists/components/lists-screen.tsx +344 -0
- package/blocks/lists/components/suppression-manager.tsx +6 -2
- package/blocks/lists/components/suppressions-screen.tsx +255 -0
- package/blocks/mailboxes/README.md +17 -0
- package/blocks/mailboxes/block.json +65 -17
- package/blocks/mailboxes/components/connect-mailbox.tsx +23 -13
- package/blocks/mailboxes/components/domain-detail.tsx +142 -0
- package/blocks/mailboxes/components/domain-purchase.tsx +6 -3
- package/blocks/mailboxes/components/mailbox-detail.tsx +156 -0
- package/blocks/mailboxes/components/mailboxes-screen.tsx +223 -0
- package/blocks/mailboxes/components/warmup-health.tsx +4 -2
- package/blocks/mailboxes/lib/mailboxes-utils.ts +22 -1
- package/blocks/newsletter/README.md +13 -0
- package/blocks/newsletter/block.json +47 -23
- package/blocks/newsletter/components/issue-scheduler.tsx +2 -1
- package/blocks/newsletter/components/newsletter-screen.tsx +404 -0
- package/blocks/newsletter/components/subscriber-table.tsx +12 -6
- package/blocks/nurture/README.md +12 -0
- package/blocks/nurture/block.json +35 -13
- package/blocks/nurture/components/nurture-builder.tsx +3 -1
- package/blocks/nurture/components/nurture-enroll.tsx +6 -3
- package/blocks/nurture/components/nurture-form.tsx +23 -3
- package/blocks/nurture/components/nurture-screen.tsx +283 -0
- package/blocks/nurture/lib/nurture-types.ts +1 -1
- package/blocks/objects/README.md +17 -0
- package/blocks/objects/block.json +29 -16
- package/blocks/objects/components/object-not-found.tsx +3 -1
- package/blocks/objects/components/object-record-detail.tsx +2 -1
- package/blocks/objects/components/object-schema-editor.tsx +3 -1
- package/blocks/objects/components/object-table.tsx +13 -3
- package/blocks/objects/components/objects-screen.tsx +147 -0
- package/blocks/objects/lib/object-types.ts +1 -1
- package/blocks/pages/README.md +12 -0
- package/blocks/pages/block.json +35 -22
- package/blocks/pages/components/brand-kit-editor.tsx +5 -2
- package/blocks/pages/components/content-library.tsx +46 -11
- package/blocks/pages/components/page-list.tsx +6 -3
- package/blocks/pages/components/pages-screen.tsx +154 -0
- package/blocks/quotes/README.md +17 -0
- package/blocks/quotes/block.json +38 -9
- package/blocks/quotes/components/product-picker.tsx +3 -1
- package/blocks/quotes/components/quote-list.tsx +7 -3
- package/blocks/quotes/components/quote-related.tsx +123 -0
- package/blocks/quotes/components/quotes-screen.tsx +169 -0
- package/blocks/radar/README.md +10 -0
- package/blocks/radar/block.json +41 -1
- package/blocks/radar/components/competitor-detail.tsx +117 -0
- package/blocks/radar/components/competitor-tracker.tsx +3 -1
- 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 +49 -9
- package/blocks/sequencer/block.json +99 -19
- package/blocks/sequencer/components/enroll-dialog.tsx +9 -3
- package/blocks/sequencer/components/sequence-builder.tsx +211 -46
- package/blocks/sequencer/components/sequence-detail.tsx +259 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +114 -0
- package/blocks/sequencer/components/sequence-list.tsx +33 -8
- package/blocks/sequencer/components/sequence-stats.tsx +3 -2
- package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
- package/blocks/sequencer/components/sequences-screen.tsx +159 -0
- package/blocks/sequencer/components/step-editor.tsx +3 -1
- package/blocks/sequencer/lib/sequencer-utils.ts +128 -5
- 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 +11 -0
- package/blocks/social-ads/block.json +101 -2
- 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 +188 -0
- package/blocks/social-ads/components/audience-sync.tsx +11 -7
- package/blocks/social-ads/components/campaign-detail.tsx +133 -0
- package/blocks/social-ads/components/creative-detail.tsx +150 -0
- package/blocks/social-ads/components/creative-gallery.tsx +15 -5
- package/blocks/social-ads/components/post-composer.tsx +3 -1
- package/blocks/social-ads/components/post-detail.tsx +160 -0
- package/blocks/social-ads/components/social-post-list.tsx +11 -3
- package/blocks/social-ads/components/social-screen.tsx +160 -0
- package/blocks/visitors/README.md +10 -0
- package/blocks/visitors/block.json +25 -1
- package/blocks/visitors/components/snippet-install.tsx +3 -1
- package/blocks/visitors/components/visitor-company-card.tsx +2 -2
- package/blocks/visitors/components/visitor-detail.tsx +105 -0
- package/blocks/visitors/components/visitor-feed.tsx +11 -2
- 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 +7 -4
- package/blocks/webchat/components/conversation-list.tsx +7 -3
- 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 +4 -2
- package/blocks/workbench/components/workbench-grid.tsx +9 -3
- package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
- package/blocks/workbench/components/workbench-screen.tsx +192 -0
- package/blocks/workbench/components/workbench-sources.tsx +194 -0
- 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 +3 -1
- package/blocks/workflows/components/workflow-list.tsx +13 -6
- package/blocks/workflows/components/workflows-screen.tsx +193 -0
- package/dist/cli.js +2785 -1637
- 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 +113 -0
- package/dist/types/blocks/compose.d.ts +53 -0
- package/dist/types/blocks/create.d.ts +25 -8
- package/dist/types/blocks/doctor.d.ts +6 -1
- package/dist/types/blocks/flows.d.ts +20 -0
- package/dist/types/blocks/navfile.d.ts +35 -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 +32 -0
- package/dist/types/blocks/screens.d.ts +53 -0
- package/dist/types/cli.d.ts +2 -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.json +41 -16
- package/stacks/apollo/pages/companies.tsx.tmpl +50 -0
- package/stacks/apollo/pages/contacts.tsx.tmpl +117 -0
- package/stacks/apollo.json +118 -36
- package/stacks/calendly.json +60 -0
- package/stacks/clay/pages/companies.tsx.tmpl +15 -0
- package/stacks/clay/pages/contacts.tsx.tmpl +19 -0
- package/stacks/clay.json +59 -26
- package/stacks/gong.json +81 -35
- package/stacks/hootsuite.json +99 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +47 -0
- package/stacks/hubspot/pages/contacts.tsx.tmpl +111 -0
- package/stacks/hubspot/pages/deals.tsx.tmpl +11 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +92 -0
- package/stacks/hubspot/pages/quotes.tsx.tmpl +11 -0
- package/stacks/hubspot.json +167 -66
- package/stacks/intercom.json +48 -20
- package/stacks/mailchimp.json +52 -33
- package/stacks/outreach/pages/contacts.tsx.tmpl +85 -0
- package/stacks/outreach.json +88 -25
- package/stacks/pipedrive/pages/companies.tsx.tmpl +47 -0
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +47 -0
- package/stacks/pipedrive/pages/deals.tsx.tmpl +11 -0
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +92 -0
- package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
- package/stacks/pipedrive.json +115 -0
- package/stacks/salesforce/pages/companies.tsx.tmpl +47 -0
- package/stacks/salesforce/pages/contacts.tsx.tmpl +47 -0
- package/stacks/salesforce/pages/deals.tsx.tmpl +11 -0
- package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
- package/stacks/salesforce.json +81 -31
- package/stacks/stack.schema.json +6 -1
- package/stacks/zoominfo/pages/companies.tsx.tmpl +15 -0
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +102 -0
- package/styles/styles.css +1 -1
- package/templates/next-app/README.md.tmpl +55 -8
- package/templates/next-app/app/globals.css.tmpl +105 -0
- package/templates/next-app/app/layout.tsx.tmpl +18 -4
- package/templates/next-app/components/app-shell.tsx.tmpl +248 -23
- package/templates/next-app/components/shell/connection-status.tsx.tmpl +109 -0
- package/templates/next-app/components/shell/nav-model.ts.tmpl +65 -0
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +131 -0
- package/templates/next-app/components/shell/theme-toggle.tsx.tmpl +40 -0
- package/templates/next-app/dot-gitignore.tmpl +2 -0
- package/templates/next-app/lib/nav.ts.tmpl +1 -1
- package/templates/next-app/package.json.tmpl +1 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AgentsScreen: the finished agents workspace. Two tabs.
|
|
5
|
+
* Agents: stat strip (agents, active, inactive), search and status filter, the agent list, and for the open agent
|
|
6
|
+
* (deep link ?id=) a detail panel with Configure (persona, voice, knowledge, skills, active switch, delete),
|
|
7
|
+
* Test (free dry runs, saved cases, the activation gate, run a skill live) and Runs.
|
|
8
|
+
* Runs: every agent run in the workspace as a list; opening one (deep link ?run=) shows the full run (AgentRunDetail:
|
|
9
|
+
* status, step, next action, credits, times, its approvals to approve or reject, cancel). Start a run from here.
|
|
10
|
+
* "New agent" opens a form sheet (name, role, what it is for, personality); the agent starts inactive and opens
|
|
11
|
+
* in the detail panel so voice, knowledge and skills can be added next.
|
|
12
|
+
*
|
|
13
|
+
* Composes: AgentBuilder (hideList), AgentTestConsole, AgentRuns. Billable, external and destructive actions
|
|
14
|
+
* (document upload, run a skill, start/cancel a run, approve, delete an agent) stay inside those parts and are
|
|
15
|
+
* confirmed there with ConfirmSpendDialog.
|
|
16
|
+
*
|
|
17
|
+
* SDK operations of its own:
|
|
18
|
+
* 1. GET /voice/agents?search&page_size=100 (voice.listAgents) read
|
|
19
|
+
* 2. POST /voice/agents { role, persona, identity, entity_type, agent_status: "inactive" } (voice.createAgent) write
|
|
20
|
+
* 3. PUT /voice/agents/{agent_id} { agent_status } (voice.updateAgent) write activate / deactivate from the panel
|
|
21
|
+
* Slot props: renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions.
|
|
22
|
+
*/
|
|
23
|
+
import { useRef, useState, type ReactNode } from "react";
|
|
24
|
+
import { Bot, Plus } from "lucide-react";
|
|
25
|
+
import { Button, Input, Label, Skeleton, Textarea } from "@graph8/sdk/ui";
|
|
26
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
27
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
28
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
29
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
30
|
+
import { g8Get, g8Post, g8Put } from "@/g8/lib/g8-client";
|
|
31
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
32
|
+
import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
|
|
33
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
34
|
+
import { ScreenLayout } from "@/g8/components/screen/screen-layout";
|
|
35
|
+
import { SplitView } from "@/g8/components/screen/split-view";
|
|
36
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
37
|
+
import { Toolbar } from "@/g8/components/screen/toolbar";
|
|
38
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
39
|
+
import { DEFAULT_PERSONA, bodyOf, listOf } from "../lib/agent-types";
|
|
40
|
+
import type { AgentSummary } from "../lib/agent-types";
|
|
41
|
+
import { AgentBuilder } from "./agent-builder";
|
|
42
|
+
import { AgentRunDetail } from "./agent-run-detail";
|
|
43
|
+
import { AgentRuns } from "./agent-runs";
|
|
44
|
+
import { AgentTestConsole } from "./agent-test-console";
|
|
45
|
+
|
|
46
|
+
export interface AgentsScreenProps {
|
|
47
|
+
/** Row-level extra actions (for example "Use in a campaign"). */
|
|
48
|
+
renderRowActions?: (agent: AgentSummary) => ReactNode;
|
|
49
|
+
renderDetailActions?: (agent: AgentSummary) => ReactNode;
|
|
50
|
+
extraDetailTabs?: Array<{ id: string; label: string; render: (agent: AgentSummary) => ReactNode }>;
|
|
51
|
+
toolbarActions?: ReactNode;
|
|
52
|
+
className?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SCREEN_TABS = [{ id: "agents", label: "Agents" }, { id: "runs", label: "Runs" }];
|
|
56
|
+
const FILTERS = [{ id: "", label: "All" }, { id: "active", label: "Active" }, { id: "inactive", label: "Inactive" }];
|
|
57
|
+
|
|
58
|
+
interface NewAgent { name: string; role: string; description: string; persona: string }
|
|
59
|
+
const blank = (): NewAgent => ({ name: "", role: "SDR", description: "", persona: "" });
|
|
60
|
+
const tone = (status: string) => (status === "active" ? "positive" : status === "archived" ? "neutral" : "warning");
|
|
61
|
+
|
|
62
|
+
/** Create sheet. Keeps the id of an agent it already created so a retry after a later failure never makes a twin. */
|
|
63
|
+
function NewAgentSheet({ open, onOpenChange, onCreated }: { open: boolean; onOpenChange: (o: boolean) => void; onCreated: (id: string) => void }) {
|
|
64
|
+
const [form, setForm] = useState<NewAgent>(blank());
|
|
65
|
+
const created = useRef<string | null>(null);
|
|
66
|
+
const create = useG8Mutation(async () => {
|
|
67
|
+
if (created.current) return created.current;
|
|
68
|
+
const res = await g8Post("/voice/agents", {
|
|
69
|
+
entity_type: "agent", role: form.role.trim(), agent_status: "inactive", use_company_knowledge: true, identity: {},
|
|
70
|
+
persona: { ...DEFAULT_PERSONA, agent_name: form.name.trim(), description: form.description.trim(), persona: form.persona.trim() },
|
|
71
|
+
});
|
|
72
|
+
const id = bodyOf<{ agent?: { agent_id?: string } }>(res).agent?.agent_id ?? "";
|
|
73
|
+
created.current = id || null;
|
|
74
|
+
return id;
|
|
75
|
+
}, { onSuccess: (id) => { created.current = null; setForm(blank()); onOpenChange(false); if (id) onCreated(id); } });
|
|
76
|
+
const set = (patch: Partial<NewAgent>) => setForm((f) => ({ ...f, ...patch }));
|
|
77
|
+
const valid = Boolean(form.name.trim() && form.role.trim() && form.description.trim() && form.persona.trim());
|
|
78
|
+
return (
|
|
79
|
+
<FormSheet open={open} onOpenChange={onOpenChange} title="New agent" description="It starts inactive. Add a voice, knowledge and skills, test it, then turn it on."
|
|
80
|
+
footer={<><Button variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button><Button loading={create.loading} disabled={!valid} onClick={() => void create.mutate(undefined)}>Create agent</Button></>}>
|
|
81
|
+
<div className="flex flex-col gap-3">
|
|
82
|
+
<div className="flex flex-col gap-1"><Label htmlFor="na-name">Name</Label><Input id="na-name" value={form.name} onChange={(e) => set({ name: e.target.value })} /></div>
|
|
83
|
+
<div className="flex flex-col gap-1"><Label htmlFor="na-role">Role</Label><Input id="na-role" value={form.role} onChange={(e) => set({ role: e.target.value })} /></div>
|
|
84
|
+
<div className="flex flex-col gap-1"><Label htmlFor="na-desc">What it is for</Label><Input id="na-desc" value={form.description} onChange={(e) => set({ description: e.target.value })} /></div>
|
|
85
|
+
<div className="flex flex-col gap-1"><Label htmlFor="na-persona">Personality and behaviour</Label><Textarea id="na-persona" rows={5} value={form.persona} onChange={(e) => set({ persona: e.target.value })} /></div>
|
|
86
|
+
{create.error ? <ApiError error={create.error} title="Could not create the agent" /> : null}
|
|
87
|
+
</div>
|
|
88
|
+
</FormSheet>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function AgentRow({ agent, selected, onOpen, extra }: { agent: AgentSummary; selected: boolean; onOpen: () => void; extra?: ReactNode }) {
|
|
93
|
+
return (
|
|
94
|
+
<li className={`flex items-center gap-3 border-b px-3 py-2 ${selected ? "bg-accent" : ""}`}>
|
|
95
|
+
<button type="button" data-testid={`agent-${agent.agent_id}`} onClick={onOpen} className="flex min-w-0 flex-1 items-center gap-3 text-left">
|
|
96
|
+
<EntityAvatar name={agent.agent_name} kind="person" size="md" />
|
|
97
|
+
<span className="min-w-0 flex-1">
|
|
98
|
+
<span className="block truncate text-sm font-medium">{agent.agent_name}</span>
|
|
99
|
+
<span className="block truncate text-xs text-muted-foreground">{agent.role}{agent.description ? ` - ${agent.description}` : ""}</span>
|
|
100
|
+
</span>
|
|
101
|
+
<StatusPill tone={tone(agent.agent_status)} dot>{agent.agent_status}</StatusPill>
|
|
102
|
+
</button>
|
|
103
|
+
{extra}
|
|
104
|
+
</li>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function AgentsScreen({ renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, className }: AgentsScreenProps) {
|
|
109
|
+
const [tab, setTab] = useUrlState("tab", "agents");
|
|
110
|
+
const [selected, setSelected] = useUrlState("id", "");
|
|
111
|
+
const [search, setSearch] = useState("");
|
|
112
|
+
const [filter, setFilter] = useState("");
|
|
113
|
+
const [creating, setCreating] = useState(false);
|
|
114
|
+
const [run, setRun] = useUrlState("run", "");
|
|
115
|
+
const [view, setView] = useUrlState("view", "");
|
|
116
|
+
const closeAgent = () => { setSelected(""); setView(""); };
|
|
117
|
+
const closeRun = () => { setRun(""); setView(""); };
|
|
118
|
+
const expand = { expanded: view === "full", onExpandedChange: (e: boolean) => setView(e ? "full" : "") };
|
|
119
|
+
const [runsKey, setRunsKey] = useState(0);
|
|
120
|
+
const q = search.trim();
|
|
121
|
+
|
|
122
|
+
const agents = useG8Query((signal) => g8Get("/voice/agents", { search: q || undefined, page_size: 100 }, { signal }), [q]);
|
|
123
|
+
const all = listOf<AgentSummary>(agents.data, "agents");
|
|
124
|
+
const rows = filter ? all.filter((a) => a.agent_status === filter) : all;
|
|
125
|
+
// Resolve the open agent from the list first, else by id, so a search, a filter or a cold deep link never hides it.
|
|
126
|
+
const inList = all.find((a) => a.agent_id === selected);
|
|
127
|
+
const byId = useG8Query((signal) => g8Get(`/voice/agents/${encodeURIComponent(selected)}`, undefined, { signal }), [selected], { enabled: Boolean(selected) && !inList && !agents.loading });
|
|
128
|
+
const fetched = bodyOf<{ agent?: { agent_id: string; agent_status: string; role: string; persona?: { agent_name?: string; description?: string } } }>(byId.data).agent;
|
|
129
|
+
const open: AgentSummary | undefined = inList ?? (fetched ? { agent_id: fetched.agent_id, agent_name: fetched.persona?.agent_name ?? "Agent", role: fetched.role, agent_status: fetched.agent_status, description: fetched.persona?.description } : undefined);
|
|
130
|
+
const active = all.filter((a) => a.agent_status === "active").length;
|
|
131
|
+
|
|
132
|
+
const toggle = useG8Mutation((a: AgentSummary) => g8Put(`/voice/agents/${a.agent_id}`, { agent_status: a.agent_status === "active" ? "inactive" : "active" }), { onSuccess: () => { void agents.refetch(); void byId.refetch(); } });
|
|
133
|
+
const current = tab === "runs" ? "runs" : "agents";
|
|
134
|
+
|
|
135
|
+
const detail = open ? (
|
|
136
|
+
<DetailPanel title={open.agent_name} subtitle={open.role} avatar={<EntityAvatar name={open.agent_name} kind="person" size="lg" />}
|
|
137
|
+
badges={<StatusPill tone={tone(open.agent_status)} dot>{open.agent_status}</StatusPill>} onClose={closeAgent} onExpand={() => setView("full")}
|
|
138
|
+
actions={<>
|
|
139
|
+
<Button size="sm" variant="outline" loading={toggle.loading} onClick={() => void toggle.mutate(open)}>{open.agent_status === "active" ? "Deactivate" : "Activate"}</Button>
|
|
140
|
+
{renderDetailActions?.(open)}
|
|
141
|
+
</>}
|
|
142
|
+
tabs={[
|
|
143
|
+
{ id: "configure", label: "Configure", content: <AgentBuilder key={`${open.agent_id}:${open.agent_status}`} hideList agentId={open.agent_id} onSelect={(id) => setSelected(id ?? "")} onChanged={() => void agents.refetch()} /> },
|
|
144
|
+
{ id: "test", label: "Test", content: <AgentTestConsole agentId={open.agent_id} /> },
|
|
145
|
+
{ id: "runs", label: "Runs", content: <AgentRuns agentId={open.agent_id} /> },
|
|
146
|
+
...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(open) })),
|
|
147
|
+
]}>
|
|
148
|
+
{toggle.error ? <ApiError error={toggle.error} title="Could not change the status" /> : null}
|
|
149
|
+
</DetailPanel>
|
|
150
|
+
) : selected && !agents.loading && !byId.loading && (byId.error || byId.data) ? <EmptyState title="Agent not found" description="It may have been deleted." /> : selected ? <Skeleton className="m-4 h-40" aria-label="Loading agent" /> : null;
|
|
151
|
+
|
|
152
|
+
const list = (
|
|
153
|
+
<div className="flex flex-col gap-3">
|
|
154
|
+
<Toolbar search={{ value: search, onChange: setSearch, placeholder: "Search agents" }} actions={toolbarActions}
|
|
155
|
+
filters={<div role="group" aria-label="Status" className="flex gap-1">{FILTERS.map((f) => <Button key={f.id || "all"} size="sm" variant={filter === f.id ? "default" : "outline"} onClick={() => setFilter(f.id)}>{f.label}</Button>)}</div>} />
|
|
156
|
+
{agents.error ? <ApiError error={agents.error} title="Could not load agents" onRetry={() => void agents.refetch()} /> : null}
|
|
157
|
+
{agents.loading && all.length === 0 ? <div className="space-y-2">{[0, 1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}</div> : null}
|
|
158
|
+
<ul className="rounded-md border">
|
|
159
|
+
{rows.map((a) => <AgentRow key={a.agent_id} agent={a} selected={a.agent_id === selected} onOpen={() => setSelected(a.agent_id)} extra={renderRowActions?.(a)} />)}
|
|
160
|
+
</ul>
|
|
161
|
+
{!agents.loading && !agents.error && rows.length === 0 ? (
|
|
162
|
+
<EmptyState title={all.length ? "No agents match" : "No agents yet"} description="An agent has a persona, a voice, knowledge and skills."
|
|
163
|
+
action={all.length ? undefined : <Button onClick={() => setCreating(true)}>New agent</Button>} />
|
|
164
|
+
) : null}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<ScreenLayout className={className} title="Agents" description="Build AI agents, prove they work before they talk to anyone, and follow the work they do."
|
|
170
|
+
icon={<Bot className="size-5" />} tabs={SCREEN_TABS} activeTab={current} onTabChange={setTab}
|
|
171
|
+
actions={<Button onClick={() => setCreating(true)}><Plus className="mr-1 size-4" />New agent</Button>}
|
|
172
|
+
stats={current === "agents" ? [
|
|
173
|
+
{ label: "Agents", value: String(all.length) },
|
|
174
|
+
{ label: "Active", value: String(active), tone: "positive" },
|
|
175
|
+
{ label: "Inactive", value: String(all.filter((a) => a.agent_status === "inactive").length), tone: "warning" },
|
|
176
|
+
] : undefined}>
|
|
177
|
+
{current === "runs" ? (
|
|
178
|
+
<SplitView list={<AgentRuns hideDetail selectedId={run || null} onSelectRun={(id) => setRun(id ?? "")} refreshKey={runsKey} />} detailOpen={Boolean(run)} onCloseDetail={closeRun} {...expand} detailWidth="lg"
|
|
179
|
+
detail={run ? <DetailPanel title="Run" subtitle={run} onClose={closeRun} onExpand={() => setView("full")} tabs={[{ id: "run", label: "Run", content: <AgentRunDetail key={run} runId={run} onChanged={() => setRunsKey((k) => k + 1)} /> }]} /> : null} />
|
|
180
|
+
) : <SplitView list={list} detail={detail} detailOpen={Boolean(selected)} onCloseDetail={closeAgent} {...expand} detailWidth="xl" />}
|
|
181
|
+
<NewAgentSheet open={creating} onOpenChange={setCreating} onCreated={(id) => { setSelected(id); setTab("agents"); void agents.refetch(); }} />
|
|
182
|
+
</ScreenLayout>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -3,6 +3,19 @@
|
|
|
3
3
|
Read-only outbound analytics (top-line numbers, rep ranking, funnels) plus a builder for saved custom-object
|
|
4
4
|
reports. Depends only on the foundation. Install with `npx @graph8/sdk add analytics`.
|
|
5
5
|
|
|
6
|
+
## Screens
|
|
7
|
+
|
|
8
|
+
`AnalyticsScreen` (`components/analytics-screen.tsx`) is the finished dashboard; mount it as the page and you are done.
|
|
9
|
+
|
|
10
|
+
- **Overview**: KPI strip (emails, reply rate, calls, meetings) with a daily sparkline and half-over-half change per
|
|
11
|
+
number, a daily trend chart you switch between email, calls and meetings, the meeting or revenue funnel, and the rep
|
|
12
|
+
leaderboard. One 7 / 30 / 90 day range drives every card; tab and range live in the URL (`?tab=`, `?days=`).
|
|
13
|
+
- **Reports**: every saved report in a searchable list. Opening one (`?id=`, full page with `&view=full`) shows `ReportDetail`
|
|
14
|
+
(definition, latest result, Run again, Delete with confirmation) and an Edit tab (`ReportBuilder reportId`); **New report** opens the builder in a sheet.
|
|
15
|
+
- Needs `analytics:read`; Reports also need `objects:read|write|delete` and the per-organisation reports switch.
|
|
16
|
+
- Slot/props: `defaultDays`, `nameFor`, `objectSlug`, `toolbarActions`. New parts: `TrendChart` (on the kit TimeSeriesChart, three trend
|
|
17
|
+
operations); `Leaderboard` gained `hideRange`; `ReportBuilder` gained `reportId` and `hideList`; new `ReportDetail`.
|
|
18
|
+
|
|
6
19
|
## The flow, in order
|
|
7
20
|
|
|
8
21
|
1. **Tiles** `GET /analytics/outbound/summary?days=` (`KpiTiles`): `emails_sent`, `delivery_rate`,
|
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
"archive_object_report_api_reports__report_id__delete",
|
|
18
18
|
"create_object_report_api_reports_post",
|
|
19
19
|
"execute_object_report_api_reports__report_id__execute_post",
|
|
20
|
+
"get_call_trend_analytics_outbound_call_metrics_trend_get",
|
|
21
|
+
"get_email_trend_analytics_outbound_email_metrics_trend_get",
|
|
20
22
|
"get_meeting_funnel_analytics_outbound_meeting_funnel_get",
|
|
23
|
+
"get_meeting_trend_analytics_outbound_meeting_metrics_trend_get",
|
|
24
|
+
"get_object_report_api_reports__report_id__get",
|
|
21
25
|
"get_outbound_summary_analytics_outbound_summary_get",
|
|
22
26
|
"get_sdr_leaderboard_analytics_outbound_sdr_activity_leaderboard_get",
|
|
23
27
|
"get_team_conversion_funnel_analytics_team_conversion_funnel_get",
|
|
@@ -26,6 +30,45 @@
|
|
|
26
30
|
"update_object_report_api_reports__report_id__patch"
|
|
27
31
|
],
|
|
28
32
|
"components": [
|
|
33
|
+
{
|
|
34
|
+
"name": "TrendChart",
|
|
35
|
+
"file": "components/trend-chart.tsx",
|
|
36
|
+
"purpose": "Daily email, call or meeting line chart on the kit TimeSeriesChart.",
|
|
37
|
+
"operations": [
|
|
38
|
+
"get_email_trend_analytics_outbound_email_metrics_trend_get",
|
|
39
|
+
"get_call_trend_analytics_outbound_call_metrics_trend_get",
|
|
40
|
+
"get_meeting_trend_analytics_outbound_meeting_metrics_trend_get"
|
|
41
|
+
],
|
|
42
|
+
"tiers": [
|
|
43
|
+
"read"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "ReportDetail",
|
|
48
|
+
"file": "components/report-detail.tsx",
|
|
49
|
+
"purpose": "One saved report in full: definition, latest result, run again, delete (confirmed).",
|
|
50
|
+
"operations": [
|
|
51
|
+
"get_object_report_api_reports__report_id__get",
|
|
52
|
+
"execute_object_report_api_reports__report_id__execute_post",
|
|
53
|
+
"archive_object_report_api_reports__report_id__delete"
|
|
54
|
+
],
|
|
55
|
+
"tiers": [
|
|
56
|
+
"read",
|
|
57
|
+
"write",
|
|
58
|
+
"destructive"
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"name": "AnalyticsScreen",
|
|
63
|
+
"file": "components/analytics-screen.tsx",
|
|
64
|
+
"purpose": "Finished dashboard: KPI strip with sparklines, daily trend, funnel, rep leaderboard and a Reports tab, one range for all.",
|
|
65
|
+
"operations": [
|
|
66
|
+
"list_object_reports_api_reports_get"
|
|
67
|
+
],
|
|
68
|
+
"tiers": [
|
|
69
|
+
"read"
|
|
70
|
+
]
|
|
71
|
+
},
|
|
29
72
|
{
|
|
30
73
|
"name": "KpiTiles",
|
|
31
74
|
"file": "components/kpi-tiles.tsx",
|
|
@@ -173,6 +216,7 @@
|
|
|
173
216
|
}
|
|
174
217
|
],
|
|
175
218
|
"peerDependencies": {
|
|
176
|
-
"react": ">=18"
|
|
219
|
+
"react": ">=18",
|
|
220
|
+
"lucide-react": ">=0.400.0"
|
|
177
221
|
}
|
|
178
222
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AnalyticsScreen: the finished outbound dashboard. Two tabs.
|
|
5
|
+
* Overview: KPI strip with a daily sparkline per number, a daily trend chart (email, calls or meetings), the
|
|
6
|
+
* meeting or revenue funnel, and the rep leaderboard. One 7/30/90 day range drives all of it.
|
|
7
|
+
* Reports: every saved custom-object report as a searchable list; opening one (deep link ?id=) shows a detail panel
|
|
8
|
+
* with Result (ReportDetail: the definition, the latest run, Run again, Delete) and Edit (ReportBuilder),
|
|
9
|
+
* and "New report" opens the builder in a sheet.
|
|
10
|
+
*
|
|
11
|
+
* Composes: StatStrip, TrendChart, FunnelChart, Leaderboard, ReportDetail, ReportBuilder. No required props.
|
|
12
|
+
*
|
|
13
|
+
* SDK operations (all read except the report ones, which ReportBuilder owns and confirms):
|
|
14
|
+
* 0. GET /reports?limit=50 (Reports tab list); the rest of the report lifecycle is ReportDetail and ReportBuilder.
|
|
15
|
+
* 1. GET /analytics/outbound/summary?days= (analytics.getOutboundSummary) KPI numbers
|
|
16
|
+
* 2. GET /analytics/outbound/{email,call,meeting}-metrics/trend?days= sparklines + trend chart
|
|
17
|
+
* 3. GET /analytics/outbound/meeting-funnel?days= or /analytics/team/conversion-funnel?period_days= funnel
|
|
18
|
+
* 4. GET /analytics/outbound/sdr-activity/leaderboard?days=&limit= leaderboard
|
|
19
|
+
* 5. /reports... (Reports tab, see ReportBuilder: delete is destructive and confirmed)
|
|
20
|
+
*
|
|
21
|
+
* User flows: switch range; switch trend channel; switch funnel source; sort the leaderboard; open Reports and run
|
|
22
|
+
* the whole report lifecycle (build, preview, save, re-run, edit, delete); retry any card after an error; read a
|
|
23
|
+
* stale-cache notice instead of silent old numbers.
|
|
24
|
+
* The tab and range are kept in the URL (?tab=, ?days=) so a link opens the same view.
|
|
25
|
+
*/
|
|
26
|
+
import { useState, type ReactNode } from "react";
|
|
27
|
+
import { BarChart3, FileText, Plus } from "lucide-react";
|
|
28
|
+
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
29
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
30
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
31
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
32
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
33
|
+
import { SplitView } from "@/g8/components/screen/split-view";
|
|
34
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
35
|
+
import { Toolbar } from "@/g8/components/screen/toolbar";
|
|
36
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
37
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
38
|
+
import { ScreenLayout } from "@/g8/components/screen/screen-layout";
|
|
39
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
40
|
+
import { StatStrip, type StatItem } from "@/g8/components/screen/stat-strip";
|
|
41
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
42
|
+
import { useReport } from "../hooks/use-report";
|
|
43
|
+
import { DAY_RANGES, formatNumber, formatPercent, reportBody, type OutboundSummary, type ReportResultData, type SavedReport } from "../lib/analytics-types";
|
|
44
|
+
import { FunnelChart } from "./funnel-chart";
|
|
45
|
+
import { Leaderboard } from "./leaderboard";
|
|
46
|
+
import { ReportBuilder } from "./report-builder";
|
|
47
|
+
import { ReportDetail } from "./report-detail";
|
|
48
|
+
import { TrendChart, TREND_SERIES, seriesOf, type TrendChannel, type TrendRow } from "./trend-chart";
|
|
49
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
50
|
+
|
|
51
|
+
export interface AnalyticsScreenProps {
|
|
52
|
+
/** Window in days when the URL has none. Default 30. */
|
|
53
|
+
defaultDays?: number;
|
|
54
|
+
/** Map a rep email to a display name in the leaderboard. */
|
|
55
|
+
nameFor?: (email: string) => string;
|
|
56
|
+
/** Pre-select the object (slug) in the report builder. */
|
|
57
|
+
objectSlug?: string;
|
|
58
|
+
/** Extra buttons next to the range picker. */
|
|
59
|
+
toolbarActions?: ReactNode;
|
|
60
|
+
className?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const TABS = [{ id: "overview", label: "Overview" }, { id: "reports", label: "Reports" }];
|
|
64
|
+
const CHANNELS: Array<{ id: TrendChannel; label: string }> = [{ id: "email", label: "Email" }, { id: "calls", label: "Calls" }, { id: "meetings", label: "Meetings" }];
|
|
65
|
+
|
|
66
|
+
/** Percent change of the second half of a daily series against the first half; undefined when it cannot be told. */
|
|
67
|
+
export function halfDelta(values: number[]): number | undefined {
|
|
68
|
+
if (values.length < 4) return undefined;
|
|
69
|
+
const mid = Math.floor(values.length / 2);
|
|
70
|
+
const sum = (v: number[]) => v.reduce((a, b) => a + b, 0);
|
|
71
|
+
const before = sum(values.slice(0, mid));
|
|
72
|
+
const after = sum(values.slice(mid));
|
|
73
|
+
return before > 0 ? Math.round(((after - before) / before) * 1000) / 10 : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function Segmented<T extends string>({ label, value, options, onChange }: { label: string; value: T; options: Array<{ id: T; label: string }>; onChange: (v: T) => void }) {
|
|
77
|
+
return (
|
|
78
|
+
<div role="group" aria-label={label} className="flex gap-1">
|
|
79
|
+
{options.map((o) => <Button key={o.id} size="sm" variant={value === o.id ? "default" : "outline"} onClick={() => onChange(o.id)}>{o.label}</Button>)}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function useKpis(days: number) {
|
|
85
|
+
const summary = useReport<OutboundSummary>("/analytics/outbound/summary", { days });
|
|
86
|
+
const email = useReport<TrendRow[]>(TREND_SERIES.email.path, { days });
|
|
87
|
+
const calls = useReport<TrendRow[]>(TREND_SERIES.calls.path, { days });
|
|
88
|
+
const meetings = useReport<TrendRow[]>(TREND_SERIES.meetings.path, { days });
|
|
89
|
+
const s = summary.data;
|
|
90
|
+
const sent = seriesOf(email.data, "sent");
|
|
91
|
+
const replied = seriesOf(email.data, "replied");
|
|
92
|
+
const rate = sent.map((v, i) => (v > 0 ? (replied[i] / v) * 100 : 0));
|
|
93
|
+
const dials = seriesOf(calls.data, "dials");
|
|
94
|
+
const booked = seriesOf(meetings.data, "booked");
|
|
95
|
+
const items: StatItem[] = [
|
|
96
|
+
{ label: "Emails sent", value: formatNumber(s?.emails_sent), footer: <span className="text-xs text-muted-foreground">{`${formatPercent(s?.delivery_rate)} delivered`}</span>, sparkline: sent, delta: halfDelta(sent) },
|
|
97
|
+
{ label: "Reply rate", value: formatPercent(s?.reply_rate), footer: <span className="text-xs text-muted-foreground">{`${formatNumber(s?.emails_replied)} replies`}</span>, sparkline: rate, delta: halfDelta(rate) },
|
|
98
|
+
{ label: "Calls placed", value: formatNumber(s?.calls_dispatched), footer: <span className="text-xs text-muted-foreground">{`${formatPercent(s?.connect_rate)} connected`}</span>, sparkline: dials, delta: halfDelta(dials) },
|
|
99
|
+
{ label: "Meetings booked", value: formatNumber(s?.meetings_booked), footer: <span className="text-xs text-muted-foreground">{`${formatNumber(s?.meetings_active)} still on the calendar`}</span>, sparkline: booked, delta: halfDelta(booked) },
|
|
100
|
+
];
|
|
101
|
+
const refetch = () => { void summary.refetch(); void email.refetch(); void calls.refetch(); void meetings.refetch(); };
|
|
102
|
+
return { items, loading: summary.loading && !summary.data, error: summary.error, stale: summary.stale, refetch };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function Overview({ days, nameFor }: { days: number; nameFor?: (email: string) => string }) {
|
|
106
|
+
const kpis = useKpis(days);
|
|
107
|
+
const [channel, setChannel] = useState<TrendChannel>("email");
|
|
108
|
+
const [source, setSource] = useState<"meetings" | "revenue">("meetings");
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className="space-y-4">
|
|
112
|
+
{kpis.error ? (
|
|
113
|
+
<ApiError error={kpis.error} title="Could not load metrics" onRetry={kpis.refetch} />
|
|
114
|
+
) : <StatStrip items={kpis.items} loading={kpis.loading} />}
|
|
115
|
+
{kpis.stale ? <p role="status" className="text-xs text-muted-foreground">These numbers are from a recent cache because a refresh failed.</p> : null}
|
|
116
|
+
<SectionCard title="Daily activity" description="One line for volume, one dashed line for outcomes." actions={<Segmented label="Channel" value={channel} options={CHANNELS} onChange={setChannel} />}>
|
|
117
|
+
<TrendChart channel={channel} days={days} />
|
|
118
|
+
</SectionCard>
|
|
119
|
+
<div className="grid gap-4 lg:grid-cols-2">
|
|
120
|
+
<SectionCard title="Funnel" actions={<Segmented label="Funnel source" value={source} options={[{ id: "meetings", label: "Meetings" }, { id: "revenue", label: "Revenue" }]} onChange={setSource} />}>
|
|
121
|
+
<FunnelChart source={source} days={days} />
|
|
122
|
+
</SectionCard>
|
|
123
|
+
<SectionCard title="Rep leaderboard" description="Ranked by reply rate. Click a column to re-sort.">
|
|
124
|
+
<Leaderboard days={days} hideRange nameFor={nameFor} />
|
|
125
|
+
</SectionCard>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ReportsPane({ objectSlug }: { objectSlug?: string }) {
|
|
132
|
+
const brand = useBrand();
|
|
133
|
+
const [selected, setSelected] = useUrlState("id", "");
|
|
134
|
+
const [view, setView] = useUrlState("view", "");
|
|
135
|
+
const [search, setSearch] = useState("");
|
|
136
|
+
const [creating, setCreating] = useState(false);
|
|
137
|
+
// Results live here so switching between Result and Edit never runs the report again (each run is a write).
|
|
138
|
+
const [results, setResults] = useState<Record<string, ReportResultData | null>>({});
|
|
139
|
+
const list = useG8Query(async (signal) => reportBody<{ reports?: SavedReport[] }>(await g8Get("/reports", { object_slug: objectSlug || undefined, limit: 50 }, { signal })).reports ?? [], [objectSlug]);
|
|
140
|
+
const switchedOff = (list.error as { status?: number } | undefined)?.status === 404;
|
|
141
|
+
const q = search.trim().toLowerCase();
|
|
142
|
+
const rows = (list.data ?? []).filter((r) => !q || `${r.title} ${r.source.plural_noun}`.toLowerCase().includes(q));
|
|
143
|
+
const open = (list.data ?? []).find((r) => r.id === selected);
|
|
144
|
+
|
|
145
|
+
const detail = open ? (
|
|
146
|
+
<DetailPanel title={open.title} subtitle={open.source.plural_noun} badges={<StatusPill tone={open.visibility === "private" ? "neutral" : "info"}>{open.visibility}</StatusPill>} onClose={() => { setSelected(""); setView(""); }} onExpand={() => setView("full")}
|
|
147
|
+
tabs={[
|
|
148
|
+
{ id: "result", label: "Result", content: <ReportDetail key={open.id} reportId={open.id} result={results[open.id]} onResult={(r) => setResults((m) => ({ ...m, [open.id]: r }))} onDeleted={() => { setSelected(""); void list.refetch(); }} /> },
|
|
149
|
+
{ id: "edit", label: "Edit", content: <ReportBuilder key={open.id} hideList reportId={open.id} autoRun={false} onSaved={() => { setResults((m) => Object.fromEntries(Object.entries(m).filter(([k]) => k !== open.id))); void list.refetch(); }} /> }, // a saved edit changes the query: drop the stale result
|
|
150
|
+
]} />
|
|
151
|
+
) : selected && !list.loading ? <EmptyState title="Report not found" description="It may have been deleted." /> : null;
|
|
152
|
+
|
|
153
|
+
const listView = (
|
|
154
|
+
<div className="flex flex-col gap-3">
|
|
155
|
+
<Toolbar search={{ value: search, onChange: setSearch, placeholder: "Search reports" }} status={list.data ? `${rows.length} of ${list.data.length}` : undefined}
|
|
156
|
+
actions={<Button size="sm" onClick={() => setCreating(true)}><Plus className="mr-1 size-4" />New report</Button>} />
|
|
157
|
+
{switchedOff ? <EmptyState title="Reports are not enabled" description={`Custom-object reports are switched on per organisation in ${brand.linkLabel}.`} /> : list.error ? <ApiError error={list.error} title="Could not load saved reports" onRetry={() => void list.refetch()} /> : null}
|
|
158
|
+
{list.loading && !list.data ? <Skeleton className="h-32 w-full" aria-label="Loading reports" /> : null}
|
|
159
|
+
<ul className="rounded-md border">
|
|
160
|
+
{rows.map((r) => (
|
|
161
|
+
<li key={r.id} className={r.id === selected ? "bg-accent" : ""}>
|
|
162
|
+
<button type="button" data-testid={`report-${r.id}`} onClick={() => setSelected(r.id)} className="flex w-full items-center gap-3 border-b px-3 py-2 text-left">
|
|
163
|
+
<span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{r.title}</span><span className="block truncate text-xs text-muted-foreground">{r.source.plural_noun} - {r.chart_type}</span></span>
|
|
164
|
+
<StatusPill tone={r.visibility === "private" ? "neutral" : "info"}>{r.visibility}</StatusPill>
|
|
165
|
+
</button>
|
|
166
|
+
</li>
|
|
167
|
+
))}
|
|
168
|
+
</ul>
|
|
169
|
+
{!list.loading && !list.error && rows.length === 0 ? <EmptyState title={list.data?.length ? "No reports match" : "No saved reports yet"} description="Pick an object, a measure and a grouping, preview it, then save it."
|
|
170
|
+
action={list.data?.length ? undefined : <Button onClick={() => setCreating(true)}>New report</Button>} /> : null}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
return (
|
|
174
|
+
<>
|
|
175
|
+
<SplitView list={listView} detail={detail} detailOpen={Boolean(selected)} onCloseDetail={() => { setSelected(""); setView(""); }} expanded={view === "full"} onExpandedChange={(e) => setView(e ? "full" : "")} detailWidth="xl" />
|
|
176
|
+
<FormSheet open={creating} onOpenChange={setCreating} title="New report" width="xl" description="Preview as often as you like; nothing is saved until you save.">
|
|
177
|
+
<ReportBuilder hideList objectSlug={objectSlug} onSaved={(r) => { setCreating(false); setSelected(r.id); void list.refetch(); }} />
|
|
178
|
+
</FormSheet>
|
|
179
|
+
</>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function AnalyticsScreen({ defaultDays = 30, nameFor, objectSlug, toolbarActions, className }: AnalyticsScreenProps) {
|
|
184
|
+
const [tab, setTab] = useUrlState("tab", "overview");
|
|
185
|
+
const [daysText, setDaysText] = useUrlState("days", String(defaultDays));
|
|
186
|
+
const parsed = Number(daysText);
|
|
187
|
+
const days = (DAY_RANGES as readonly number[]).includes(parsed) ? parsed : defaultDays;
|
|
188
|
+
const active = TABS.some((t) => t.id === tab) ? tab : "overview";
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<ScreenLayout
|
|
192
|
+
className={className}
|
|
193
|
+
title="Analytics"
|
|
194
|
+
description="How outbound is performing: volume, replies, calls, meetings and your reps."
|
|
195
|
+
icon={active === "reports" ? <FileText className="size-5" /> : <BarChart3 className="size-5" />}
|
|
196
|
+
tabs={TABS}
|
|
197
|
+
activeTab={active}
|
|
198
|
+
onTabChange={setTab}
|
|
199
|
+
actions={
|
|
200
|
+
<div className="flex items-center gap-2">
|
|
201
|
+
{active === "overview" ? <Segmented label="Time range" value={String(days)} options={DAY_RANGES.map((d) => ({ id: String(d), label: `Last ${d} days` }))} onChange={setDaysText} /> : null}
|
|
202
|
+
{toolbarActions}
|
|
203
|
+
</div>
|
|
204
|
+
}
|
|
205
|
+
>
|
|
206
|
+
{active === "reports" ? <ReportsPane objectSlug={objectSlug} /> : <Overview days={days} nameFor={nameFor} />}
|
|
207
|
+
</ScreenLayout>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
@@ -15,6 +15,8 @@ import { ApiError } from "@/g8/components/api-error";
|
|
|
15
15
|
import { EmptyState } from "@/g8/components/empty-state";
|
|
16
16
|
import { useReport } from "../hooks/use-report";
|
|
17
17
|
import { DAY_RANGES, formatNumber, formatPercent, type LeaderRow } from "../lib/analytics-types";
|
|
18
|
+
import { rowActivationProps } from "@/g8/hooks/use-row-activation";
|
|
19
|
+
import { RecordLink } from "@/g8/components/screen/record-link";
|
|
18
20
|
|
|
19
21
|
type SortKey = "reply_rate" | "emails_sent" | "replies" | "meetings_booked";
|
|
20
22
|
|
|
@@ -24,6 +26,8 @@ export interface LeaderboardProps {
|
|
|
24
26
|
/** Map an email to a display name. */
|
|
25
27
|
nameFor?: (email: string) => string;
|
|
26
28
|
onSelectRep?: (email: string) => void;
|
|
29
|
+
/** Hide the built-in 7/30/90 buttons when the host (for example AnalyticsScreen) owns the range; `days` then drives it. */
|
|
30
|
+
hideRange?: boolean;
|
|
27
31
|
className?: string;
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -34,17 +38,20 @@ const COLUMNS: ReadonlyArray<{ key: SortKey; label: string; percent?: boolean }>
|
|
|
34
38
|
{ key: "meetings_booked", label: "Meetings" },
|
|
35
39
|
];
|
|
36
40
|
|
|
37
|
-
export function Leaderboard({ days: initialDays = 30, limit = 20, nameFor, onSelectRep, className }: LeaderboardProps) {
|
|
38
|
-
const [
|
|
41
|
+
export function Leaderboard({ days: initialDays = 30, limit = 20, nameFor, onSelectRep, hideRange, className }: LeaderboardProps) {
|
|
42
|
+
const [ownDays, setDays] = useState(initialDays);
|
|
43
|
+
const days = hideRange ? initialDays : ownDays;
|
|
39
44
|
const [sortKey, setSortKey] = useState<SortKey>("reply_rate");
|
|
40
45
|
const { data, stale, loading, error, refetch } = useReport<LeaderRow[]>("/analytics/outbound/sdr-activity/leaderboard", { days, limit });
|
|
41
46
|
const rows = useMemo(() => [...(Array.isArray(data) ? data : [])].sort((a, b) => Number(b[sortKey] ?? 0) - Number(a[sortKey] ?? 0)), [data, sortKey]);
|
|
42
47
|
|
|
43
48
|
return (
|
|
44
49
|
<section className={`space-y-3 ${className ?? ""}`} aria-label="Rep leaderboard">
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
{hideRange ? null : (
|
|
51
|
+
<div role="group" aria-label="Time range" className="flex gap-1">
|
|
52
|
+
{DAY_RANGES.map((d) => <Button key={d} size="sm" variant={days === d ? "default" : "outline"} onClick={() => setDays(d)}>Last {d} days</Button>)}
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
48
55
|
{error ? (
|
|
49
56
|
<ApiError error={error} title="Could not load the leaderboard" onRetry={() => void refetch()} />
|
|
50
57
|
) : loading && !data ? (
|
|
@@ -68,9 +75,9 @@ export function Leaderboard({ days: initialDays = 30, limit = 20, nameFor, onSel
|
|
|
68
75
|
{rows.map((r, i) => {
|
|
69
76
|
const email = r.sdr_email ?? "unknown";
|
|
70
77
|
return (
|
|
71
|
-
<TableRow key={email} className={onSelectRep ? "cursor-pointer" : undefined}
|
|
78
|
+
<TableRow key={email} className={onSelectRep ? "cursor-pointer" : undefined} {...(onSelectRep ? rowActivationProps(() => onSelectRep(email)) : {})}>
|
|
72
79
|
<TableCell>{i + 1}</TableCell>
|
|
73
|
-
<TableCell className="font-medium">{nameFor ? nameFor(email) : email}</TableCell>
|
|
80
|
+
<TableCell className="font-medium">{onSelectRep ? <RecordLink id={email} onOpen={() => onSelectRep(email)}>{nameFor ? nameFor(email) : email}</RecordLink> : nameFor ? nameFor(email) : email}</TableCell>
|
|
74
81
|
{COLUMNS.map((c) => (
|
|
75
82
|
<TableCell key={c.key} className="text-right">{c.percent ? formatPercent(r[c.key] as number | undefined) : formatNumber(r[c.key] as number | undefined)}</TableCell>
|
|
76
83
|
))}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Preview and run share the org's report-run slots (429 `too_many_report_runs` when all are busy: retry shortly).
|
|
17
17
|
* "write" tier on preview/execute is the contract's classification (they create run records); nothing is sent.
|
|
18
18
|
*/
|
|
19
|
-
import { useState } from "react";
|
|
19
|
+
import { useEffect, useRef, useState } from "react";
|
|
20
20
|
import { Button, Input, Label, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
|
|
21
21
|
import { ApiError } from "@/g8/components/api-error";
|
|
22
22
|
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
@@ -26,11 +26,18 @@ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
|
26
26
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
27
27
|
import { useDebounced } from "../lib/use-debounced";
|
|
28
28
|
import { reportBody, type ReportResultData, type ResultRow, type SavedReport } from "../lib/analytics-types";
|
|
29
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
29
30
|
|
|
30
31
|
export interface ReportBuilderProps {
|
|
31
32
|
/** Pre-select the object (its slug), for example from the objects block. */
|
|
32
33
|
objectSlug?: string;
|
|
33
34
|
onSaved?: (report: SavedReport) => void;
|
|
35
|
+
/** Open this saved report for editing as soon as it is loaded (it is run once, like clicking it in the list). */
|
|
36
|
+
reportId?: string;
|
|
37
|
+
/** Hide the "Saved reports" column when the host (for example AnalyticsScreen) shows its own list. */
|
|
38
|
+
hideList?: boolean;
|
|
39
|
+
/** With `reportId`: run the report once when it is loaded (default true). False only loads the definition. */
|
|
40
|
+
autoRun?: boolean;
|
|
34
41
|
className?: string;
|
|
35
42
|
}
|
|
36
43
|
|
|
@@ -80,7 +87,7 @@ function groupLabel(g: ResultRow["group"][number]): string {
|
|
|
80
87
|
return String(g);
|
|
81
88
|
}
|
|
82
89
|
|
|
83
|
-
function ResultTable({ result }: { result: ReportResultData }) {
|
|
90
|
+
export function ResultTable({ result }: { result: ReportResultData }) {
|
|
84
91
|
const rows = result.rows ?? [];
|
|
85
92
|
if (result.status === "restricted") return <p role="alert" className="text-sm">You do not have access to every field in this report.</p>;
|
|
86
93
|
if (rows.length === 0) return <EmptyState title="No records match" description="Change the object, field or grouping and preview again." />;
|
|
@@ -107,7 +114,8 @@ function ResultTable({ result }: { result: ReportResultData }) {
|
|
|
107
114
|
);
|
|
108
115
|
}
|
|
109
116
|
|
|
110
|
-
export function ReportBuilder({ objectSlug: initialSlug = "", onSaved, className }: ReportBuilderProps) {
|
|
117
|
+
export function ReportBuilder({ objectSlug: initialSlug = "", onSaved, reportId, hideList, autoRun = true, className }: ReportBuilderProps) {
|
|
118
|
+
const brand = useBrand();
|
|
111
119
|
const [slug, setSlug] = useState(initialSlug);
|
|
112
120
|
const [draft, setDraft] = useState<Draft>({ title: "", chart: "table", measure: "count", measureField: "", groupBy: "", grain: "month" });
|
|
113
121
|
const [loaded, setLoaded] = useState<SavedReport | null>(null);
|
|
@@ -143,20 +151,26 @@ export function ReportBuilder({ objectSlug: initialSlug = "", onSaved, className
|
|
|
143
151
|
remove.reset();
|
|
144
152
|
};
|
|
145
153
|
|
|
146
|
-
const open = (r: SavedReport) => {
|
|
154
|
+
const open = (r: SavedReport, execute = true) => {
|
|
147
155
|
setLoaded(r);
|
|
148
156
|
setSlug(r.source.slug);
|
|
149
157
|
setDraft((d) => draftFromReport(r, d));
|
|
150
|
-
void run.mutate(r);
|
|
158
|
+
if (execute) void run.mutate(r);
|
|
151
159
|
};
|
|
160
|
+
// Preload `reportId` once its row arrives (and again if the id changes).
|
|
161
|
+
const preloaded = useRef<string | null>(null);
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
const row = reportId ? (saved.data ?? []).find((r) => r.id === reportId) : undefined;
|
|
164
|
+
if (row && preloaded.current !== reportId) { preloaded.current = reportId ?? null; open(row, autoRun); }
|
|
165
|
+
}); // eslint-disable-line react-hooks/exhaustive-deps
|
|
152
166
|
const failure = preview.error ?? run.error ?? save.error ?? remove.error;
|
|
153
167
|
const switchedOff = (saved.error as { status?: number } | undefined)?.status === 404;
|
|
154
168
|
|
|
155
169
|
return (
|
|
156
|
-
<div className={`grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] ${className ?? ""}`}>
|
|
157
|
-
<section aria-label="Saved reports" className="space-y-2">
|
|
170
|
+
<div className={`grid gap-4 ${hideList ? "" : "lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"} ${className ?? ""}`}>
|
|
171
|
+
{hideList ? null : <section aria-label="Saved reports" className="space-y-2">
|
|
158
172
|
<h3 className="text-sm font-semibold">Saved reports</h3>
|
|
159
|
-
{switchedOff ? <EmptyState title="Reports are not enabled" description=
|
|
173
|
+
{switchedOff ? <EmptyState title="Reports are not enabled" description={`Custom-object reports are switched on per organisation in ${brand.linkLabel}.`} /> : saved.error ? <ApiError error={saved.error} title="Could not load saved reports" onRetry={() => void saved.refetch()} /> : null}
|
|
160
174
|
{(saved.data ?? []).map((r) => (
|
|
161
175
|
<div key={r.id} className="flex items-center justify-between rounded border p-2 text-sm">
|
|
162
176
|
<button type="button" className="text-left hover:underline" onClick={() => open(r)}>{r.title} <span className="text-muted-foreground">({r.source.plural_noun})</span></button>
|
|
@@ -164,7 +178,7 @@ export function ReportBuilder({ objectSlug: initialSlug = "", onSaved, className
|
|
|
164
178
|
</div>
|
|
165
179
|
))}
|
|
166
180
|
{!saved.loading && !saved.error && (saved.data ?? []).length === 0 ? <p className="text-sm text-muted-foreground">Nothing saved yet.</p> : null}
|
|
167
|
-
</section>
|
|
181
|
+
</section>}
|
|
168
182
|
|
|
169
183
|
<section aria-label="Report definition" className="space-y-3">
|
|
170
184
|
<div className="grid gap-3 sm:grid-cols-2">
|