@graph8/sdk 0.370.0 → 0.374.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 +22 -3
- package/blocks/_foundation/README.md +61 -0
- package/blocks/_foundation/block.json +57 -0
- package/blocks/_foundation/components/auth-gate.tsx +363 -0
- package/blocks/_foundation/components/confirm-spend-dialog.tsx +15 -8
- package/blocks/_foundation/components/empty-state.tsx +5 -2
- package/blocks/_foundation/components/list-select.tsx +112 -0
- package/blocks/_foundation/components/paging-notices.tsx +55 -0
- package/blocks/_foundation/components/screen/detail-panel.tsx +2 -1
- package/blocks/_foundation/components/screen/filter-controls.tsx +92 -0
- package/blocks/_foundation/components/screen/index.ts +1 -0
- package/blocks/_foundation/components/screen/split-view.tsx +1 -1
- package/blocks/_foundation/hooks/use-all-pages.ts +54 -0
- package/blocks/_foundation/hooks/use-load-more-list.ts +61 -0
- package/blocks/_foundation/hooks/use-url-filters.ts +93 -0
- package/blocks/_foundation/lib/paging.ts +57 -0
- package/blocks/_foundation/lib/use-debounced-value.ts +13 -0
- package/blocks/_foundation/server/auth.ts +815 -0
- package/blocks/agents/README.md +81 -4
- package/blocks/agents/block.json +311 -2
- package/blocks/agents/components/agent-activity.tsx +112 -0
- package/blocks/agents/components/agent-approvals.tsx +135 -0
- package/blocks/agents/components/agent-builder.tsx +17 -3
- package/blocks/agents/components/agent-conversations.tsx +143 -0
- package/blocks/agents/components/agent-knowledge-panel.tsx +2 -2
- package/blocks/agents/components/agent-operators.tsx +164 -0
- package/blocks/agents/components/agent-run-detail.tsx +20 -3
- package/blocks/agents/components/agent-runs.tsx +3 -3
- package/blocks/agents/components/agent-skills-panel.tsx +7 -7
- package/blocks/agents/components/agent-test-history.tsx +121 -0
- package/blocks/agents/components/agents-screen.tsx +53 -11
- package/blocks/agents/components/assistant-sessions.tsx +150 -0
- package/blocks/agents/components/duplicate-agent-button.tsx +42 -0
- package/blocks/agents/components/from-template-button.tsx +65 -0
- package/blocks/agents/components/knowledge-browser.tsx +107 -0
- package/blocks/agents/components/knowledge-detail.tsx +156 -0
- package/blocks/agents/components/knowledge-form.tsx +69 -0
- package/blocks/agents/components/name-dialog.tsx +32 -0
- package/blocks/agents/components/receptionist-button.tsx +46 -0
- package/blocks/agents/components/save-as-template-button.tsx +32 -0
- package/blocks/agents/components/skill-editor.tsx +147 -0
- package/blocks/agents/components/skill-library.tsx +111 -0
- package/blocks/agents/lib/agent-types.ts +7 -1
- package/blocks/agents/lib/facts.tsx +13 -0
- package/blocks/agents/lib/knowledge-utils.ts +49 -0
- package/blocks/agents/lib/load-more.tsx +141 -0
- package/blocks/agents/lib/skill-utils.ts +91 -0
- package/blocks/agents/recipes/create-agent-and-attach-skill.ts +1 -1
- package/blocks/analytics/components/analytics-screen.tsx +16 -3
- package/blocks/analytics/components/kpi-tiles.tsx +1 -1
- package/blocks/analytics/components/report-builder.tsx +15 -2
- package/blocks/appointments/README.md +6 -0
- package/blocks/appointments/block.json +27 -0
- package/blocks/appointments/components/booking-page.tsx +2 -2
- package/blocks/appointments/components/meeting-contact-links.tsx +49 -0
- package/blocks/appointments/components/meeting-detail.tsx +2 -2
- package/blocks/appointments/components/meeting-list.tsx +29 -13
- package/blocks/appointments/components/record-meetings-tab.tsx +92 -0
- package/blocks/campaigns/README.md +1 -1
- package/blocks/campaigns/block.json +34 -1
- package/blocks/campaigns/components/campaign-assets.tsx +1 -1
- package/blocks/campaigns/components/campaign-launch-step.tsx +16 -11
- package/blocks/campaigns/components/campaign-links.tsx +180 -0
- package/blocks/campaigns/components/campaign-metrics.tsx +1 -1
- package/blocks/campaigns/components/campaign-wizard.tsx +10 -5
- package/blocks/campaigns/components/icp-detail.tsx +11 -2
- package/blocks/campaigns/components/icp-editor.tsx +8 -3
- package/blocks/campaigns/components/persona-detail.tsx +11 -2
- package/blocks/campaigns/components/persona-editor.tsx +6 -1
- package/blocks/campaigns/lib/campaigns-utils.ts +4 -4
- package/blocks/coach/components/call-scorecard.tsx +7 -7
- package/blocks/company-intelligence/README.md +132 -0
- package/blocks/company-intelligence/block.json +318 -0
- package/blocks/company-intelligence/components/company-intelligence-screen.tsx +114 -0
- package/blocks/company-intelligence/components/company-intelligence-tab.tsx +88 -0
- package/blocks/company-intelligence/components/company-profile.tsx +68 -0
- package/blocks/company-intelligence/components/intelligence-sources.tsx +63 -0
- package/blocks/company-intelligence/components/report-detail.tsx +189 -0
- package/blocks/company-intelligence/components/research-reports.tsx +136 -0
- package/blocks/company-intelligence/components/research-run.tsx +165 -0
- package/blocks/company-intelligence/components/source-detail.tsx +117 -0
- package/blocks/company-intelligence/components/source-view.tsx +274 -0
- package/blocks/company-intelligence/components/wiki-document-detail.tsx +116 -0
- package/blocks/company-intelligence/components/wiki-documents.tsx +66 -0
- package/blocks/company-intelligence/hooks/use-intelligence.ts +173 -0
- package/blocks/company-intelligence/lib/intelligence-types.ts +230 -0
- package/blocks/company-intelligence/lib/safe-markdown.tsx +150 -0
- package/blocks/company-intelligence/recipes/read-competitors-and-news.ts +33 -0
- package/blocks/company-intelligence/recipes/research-your-website.ts +43 -0
- package/blocks/company-intelligence/recipes/write-the-research-reports.ts +54 -0
- package/blocks/copilot/components/copilot-memory.tsx +6 -2
- package/blocks/copilot/components/copilot-panel.tsx +1 -1
- package/blocks/copilot/components/copilot-team-chat.tsx +6 -1
- package/blocks/crm/README.md +25 -0
- package/blocks/crm/block.json +257 -9
- package/blocks/crm/components/bulk-update.tsx +119 -0
- package/blocks/crm/components/companies-screen.tsx +23 -9
- package/blocks/crm/components/company-detail.tsx +2 -2
- package/blocks/crm/components/contact-detail.tsx +4 -4
- package/blocks/crm/components/contact-documents-tab.tsx +144 -0
- package/blocks/crm/components/contact-insights-tab.tsx +118 -0
- package/blocks/crm/components/contact-lookup-link.tsx +85 -0
- package/blocks/crm/components/contact-row-actions.tsx +43 -0
- package/blocks/crm/components/contact-tag-chips.tsx +113 -0
- package/blocks/crm/components/contacts-screen.tsx +77 -18
- package/blocks/crm/components/dedupe-review.tsx +6 -1
- package/blocks/crm/components/import-wizard.tsx +3 -7
- package/blocks/crm/components/list-select.tsx +20 -0
- package/blocks/crm/components/owner-assign.tsx +132 -0
- package/blocks/crm/components/record-engagement-tab.tsx +103 -0
- package/blocks/crm/components/record-form.tsx +1 -1
- package/blocks/crm/components/record-open-links.tsx +79 -0
- package/blocks/crm/components/tag-assign.tsx +112 -0
- package/blocks/crm/components/tag-manager.tsx +178 -0
- package/blocks/crm/components/tasks-screen.tsx +35 -15
- package/blocks/crm/components/unlock-info-button.tsx +82 -0
- package/blocks/crm/components/verify-email-button.tsx +93 -0
- package/blocks/crm/hooks/use-all-lists.ts +18 -0
- package/blocks/crm/hooks/use-contact-tags.ts +29 -0
- package/blocks/crm/hooks/use-page-query.ts +3 -1
- package/blocks/crm/lib/crm-types.ts +14 -0
- package/blocks/crm/lib/read-lists.ts +16 -0
- package/blocks/crm/lib/record-actions.ts +102 -0
- package/blocks/deals/README.md +25 -6
- package/blocks/deals/block.json +168 -2
- package/blocks/deals/components/close-deal-dialog.tsx +169 -0
- package/blocks/deals/components/create-deal-button.tsx +109 -0
- package/blocks/deals/components/deal-contacts-editor.tsx +134 -0
- package/blocks/deals/components/deal-detail.tsx +7 -5
- package/blocks/deals/components/deal-filters.tsx +65 -0
- package/blocks/deals/components/deal-line-items.tsx +120 -0
- package/blocks/deals/components/deal-notes-tab.tsx +97 -0
- package/blocks/deals/components/deal-related.tsx +30 -9
- package/blocks/deals/components/deal-tasks-tab.tsx +88 -0
- package/blocks/deals/components/deals-screen.tsx +57 -22
- package/blocks/deals/components/forecast-view.tsx +4 -4
- package/blocks/deals/components/pipeline-board.tsx +8 -6
- package/blocks/deals/components/pipeline-editor.tsx +132 -0
- package/blocks/deals/components/record-deals-tab.tsx +62 -0
- package/blocks/deals/components/stage-editor.tsx +1 -1
- package/blocks/deals/hooks/use-pipeline-deals.ts +17 -4
- package/blocks/deals/lib/deal-extras.ts +156 -0
- package/blocks/dialer/README.md +14 -5
- package/blocks/dialer/block.json +17 -2
- package/blocks/dialer/components/call-contact-button.tsx +26 -9
- package/blocks/dialer/components/call-log-screen.tsx +10 -2
- package/blocks/dialer/components/call-log.tsx +16 -4
- package/blocks/dialer/components/call-queue.tsx +3 -3
- package/blocks/dialer/components/dialer-screen.tsx +69 -27
- package/blocks/dialer/components/disposition-panel.tsx +1 -1
- package/blocks/dialer/components/record-calls-tab.tsx +101 -0
- package/blocks/dialer/components/session-start-form.tsx +58 -11
- package/blocks/dialer/components/softphone.tsx +1 -1
- package/blocks/documents/README.md +86 -0
- package/blocks/documents/block.json +378 -0
- package/blocks/documents/components/account-bar.tsx +48 -0
- package/blocks/documents/components/correspondence-form.tsx +95 -0
- package/blocks/documents/components/correspondence-import.tsx +106 -0
- package/blocks/documents/components/correspondence-panel.tsx +191 -0
- package/blocks/documents/components/documents-screen.tsx +76 -0
- package/blocks/documents/components/library-document-detail.tsx +175 -0
- package/blocks/documents/components/library-document-form.tsx +105 -0
- package/blocks/documents/components/library-folder-form.tsx +74 -0
- package/blocks/documents/components/library-panel.tsx +235 -0
- package/blocks/documents/components/library-upload.tsx +160 -0
- package/blocks/documents/components/question-row.tsx +130 -0
- package/blocks/documents/components/questionnaire-detail.tsx +197 -0
- package/blocks/documents/components/questionnaires-panel.tsx +173 -0
- package/blocks/documents/components/record-documents-tab.tsx +109 -0
- package/blocks/documents/lib/documents-types.ts +252 -0
- package/blocks/documents/lib/download.ts +64 -0
- package/blocks/documents/recipes/follow-a-questionnaire.ts +26 -0
- package/blocks/documents/recipes/log-account-correspondence.ts +27 -0
- package/blocks/documents/recipes/organize-the-library.ts +25 -0
- package/blocks/email-signatures/README.md +123 -0
- package/blocks/email-signatures/block.json +341 -0
- package/blocks/email-signatures/components/email-signatures-screen.tsx +166 -0
- package/blocks/email-signatures/components/signature-assign-template.tsx +119 -0
- package/blocks/email-signatures/components/signature-block-editor.tsx +87 -0
- package/blocks/email-signatures/components/signature-campaign-form.tsx +165 -0
- package/blocks/email-signatures/components/signature-campaigns.tsx +75 -0
- package/blocks/email-signatures/components/signature-identity-form.tsx +131 -0
- package/blocks/email-signatures/components/signature-mailboxes.tsx +195 -0
- package/blocks/email-signatures/components/signature-policy.tsx +130 -0
- package/blocks/email-signatures/components/signature-preview.tsx +119 -0
- package/blocks/email-signatures/components/signature-report.tsx +77 -0
- package/blocks/email-signatures/components/signature-template-details.tsx +112 -0
- package/blocks/email-signatures/components/signature-template-form.tsx +109 -0
- package/blocks/email-signatures/hooks/use-signatures-data.ts +93 -0
- package/blocks/email-signatures/lib/signatures-types.ts +392 -0
- package/blocks/email-signatures/recipes/assign-a-signature-to-a-new-hire.ts +32 -0
- package/blocks/email-signatures/recipes/create-a-template-and-make-it-default.ts +35 -0
- package/blocks/email-signatures/recipes/signature-engagement-report.ts +16 -0
- package/blocks/enrichment/README.md +38 -10
- package/blocks/enrichment/block.json +164 -12
- package/blocks/enrichment/components/auto-enrichment-configs.tsx +134 -0
- package/blocks/enrichment/components/auto-enrichment-settings.tsx +82 -0
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +79 -30
- package/blocks/enrichment/components/email-verify-badge.tsx +3 -3
- package/blocks/enrichment/components/enrich-button.tsx +1 -1
- package/blocks/enrichment/components/enrichment-activity.tsx +93 -0
- package/blocks/enrichment/components/enrichment-job-detail.tsx +18 -0
- package/blocks/enrichment/components/enrichment-screen.tsx +103 -19
- package/blocks/enrichment/components/list-pipelines.tsx +97 -0
- package/blocks/enrichment/components/pipeline-detail.tsx +214 -0
- package/blocks/enrichment/components/pipeline-editor.tsx +144 -0
- package/blocks/enrichment/components/pipeline-templates.tsx +90 -0
- package/blocks/enrichment/components/waterfall-config.tsx +27 -7
- package/blocks/enrichment/lib/enrichment-types.ts +51 -0
- package/blocks/enrichment/lib/pipeline-types.ts +234 -0
- package/blocks/enrichment/recipes/bulk-enrich-and-poll.ts +15 -4
- package/blocks/forms/README.md +10 -0
- package/blocks/forms/block.json +107 -0
- package/blocks/forms/components/form-activity.tsx +71 -0
- package/blocks/forms/components/form-backfill.tsx +108 -0
- package/blocks/forms/components/form-builder.tsx +1 -1
- package/blocks/forms/components/form-embed.tsx +1 -1
- package/blocks/forms/components/form-groups.tsx +151 -0
- package/blocks/forms/components/form-mapping-editor.tsx +6 -9
- package/blocks/forms/components/form-route-history.tsx +72 -0
- package/blocks/forms/components/form-saved-views.tsx +81 -0
- package/blocks/forms/components/forms-screen.tsx +57 -20
- package/blocks/forms/components/more-rows.tsx +30 -0
- package/blocks/forms/components/submissions-export.tsx +67 -0
- package/blocks/forms/components/submissions-table.tsx +15 -6
- package/blocks/forms/lib/forms-extra.ts +67 -0
- package/blocks/inbox/README.md +45 -4
- package/blocks/inbox/block.json +145 -8
- package/blocks/inbox/components/blocked-senders.tsx +103 -0
- package/blocks/inbox/components/compose-message.tsx +193 -0
- package/blocks/inbox/components/inbox-screen.tsx +54 -6
- package/blocks/inbox/components/inbox-search.tsx +106 -0
- package/blocks/inbox/components/record-conversations-tab.tsx +154 -0
- package/blocks/inbox/components/tag-manager.tsx +136 -0
- package/blocks/inbox/components/thread-actions.tsx +133 -0
- package/blocks/inbox/components/thread-badge.tsx +2 -2
- package/blocks/inbox/components/thread-view.tsx +3 -1
- package/blocks/inbox/hooks/use-inbox-mailboxes.ts +31 -0
- package/blocks/inbox/lib/inbox-manage.ts +227 -0
- package/blocks/integrations/README.md +54 -1
- package/blocks/integrations/block.json +189 -15
- package/blocks/integrations/components/ad-accounts.tsx +82 -0
- package/blocks/integrations/components/connection-health.tsx +59 -0
- package/blocks/integrations/components/crm-detail.tsx +20 -1
- package/blocks/integrations/components/crm-records.tsx +103 -0
- package/blocks/integrations/components/field-mappings.tsx +61 -0
- package/blocks/integrations/components/integration-list.tsx +4 -4
- package/blocks/integrations/components/integrations-screen.tsx +20 -3
- package/blocks/integrations/components/migration-panel.tsx +133 -0
- package/blocks/integrations/components/sugarcrm-connect.tsx +95 -0
- package/blocks/integrations/components/sync-status.tsx +6 -6
- package/blocks/integrations/components/sync-triggers.tsx +134 -0
- package/blocks/integrations/components/webhook-detail.tsx +26 -7
- package/blocks/integrations/components/webhook-manager.tsx +30 -17
- package/blocks/integrations/components/workspace-tools.tsx +127 -0
- package/blocks/integrations/lib/crm-utils.ts +39 -0
- package/blocks/integrations/lib/facts.tsx +13 -0
- package/blocks/integrations/lib/integrations-utils.ts +5 -0
- package/blocks/integrations/lib/load-more.tsx +141 -0
- package/blocks/intent/components/account-intent-score.tsx +5 -5
- package/blocks/intent/components/intent-feed.tsx +8 -8
- package/blocks/intent/components/intent-keyword-manager.tsx +8 -8
- package/blocks/intent/lib/intent-utils.ts +4 -4
- package/blocks/lifecycle/components/account-related.tsx +8 -1
- package/blocks/lifecycle/components/churn-list.tsx +5 -2
- package/blocks/lifecycle/components/customer-table.tsx +1 -1
- package/blocks/lists/README.md +7 -1
- package/blocks/lists/block.json +31 -3
- package/blocks/lists/components/add-company-people-to-list-button.tsx +73 -0
- package/blocks/lists/components/add-to-list-button.tsx +17 -7
- package/blocks/lists/components/list-picker.tsx +13 -3
- package/blocks/lists/components/list-table.tsx +1 -1
- package/blocks/lists/components/lists-screen.tsx +4 -2
- package/blocks/lists/components/record-lists-tab.tsx +99 -0
- package/blocks/lists/components/suppression-manager.tsx +27 -6
- package/blocks/lists/components/suppressions-screen.tsx +21 -12
- package/blocks/mailboxes/block.json +4 -0
- package/blocks/mailboxes/components/connect-mailbox.tsx +9 -8
- package/blocks/mailboxes/components/domain-detail.tsx +10 -4
- package/blocks/mailboxes/components/domain-purchase.tsx +14 -11
- package/blocks/mailboxes/components/mailbox-detail.tsx +3 -2
- package/blocks/mailboxes/components/mailbox-list.tsx +8 -3
- package/blocks/mailboxes/components/mailboxes-screen.tsx +8 -3
- package/blocks/mailboxes/components/warmup-health.tsx +20 -20
- package/blocks/mailboxes/lib/mailboxes-utils.ts +4 -4
- package/blocks/mailboxes/lib/use-provider-domains.ts +23 -0
- package/blocks/newsletter/README.md +12 -0
- package/blocks/newsletter/block.json +117 -0
- package/blocks/newsletter/components/issue-scheduler.tsx +2 -2
- package/blocks/newsletter/components/issue-versions.tsx +93 -0
- package/blocks/newsletter/components/more-rows.tsx +30 -0
- package/blocks/newsletter/components/newsletter-editor.tsx +1 -1
- package/blocks/newsletter/components/newsletter-list-syncs.tsx +121 -0
- package/blocks/newsletter/components/newsletter-screen.tsx +38 -13
- package/blocks/newsletter/components/newsletter-settings.tsx +211 -0
- package/blocks/newsletter/components/newsletter-stats.tsx +2 -2
- package/blocks/newsletter/components/newsletter-templates.tsx +133 -0
- package/blocks/newsletter/components/subscriber-table.tsx +5 -7
- package/blocks/newsletter/components/subscriber-timeline.tsx +65 -0
- package/blocks/newsletter/lib/newsletter-extra.ts +60 -0
- package/blocks/nurture/components/nurture-builder.tsx +10 -2
- package/blocks/nurture/components/nurture-enroll.tsx +6 -7
- package/blocks/nurture/components/nurture-form.tsx +5 -8
- package/blocks/nurture/components/nurture-screen.tsx +16 -5
- package/blocks/nurture/components/nurture-stats.tsx +5 -4
- package/blocks/objects/README.md +1 -1
- package/blocks/objects/block.json +13 -5
- package/blocks/objects/components/attribute-field-input.tsx +14 -1
- package/blocks/objects/components/object-record-detail.tsx +8 -1
- package/blocks/objects/components/object-schema-editor.tsx +1 -1
- package/blocks/objects/components/record-roles-panel.tsx +15 -5
- package/blocks/pages/README.md +14 -0
- package/blocks/pages/block.json +123 -2
- package/blocks/pages/components/more-rows.tsx +30 -0
- package/blocks/pages/components/page-ai-tools.tsx +196 -0
- package/blocks/pages/components/page-analytics.tsx +176 -0
- package/blocks/pages/components/page-clone.tsx +99 -0
- package/blocks/pages/components/page-domains.tsx +167 -0
- package/blocks/pages/components/page-editor.tsx +1 -1
- package/blocks/pages/components/page-generation-status.tsx +48 -0
- package/blocks/pages/components/page-tracking.tsx +137 -0
- package/blocks/pages/components/pages-screen.tsx +57 -16
- package/blocks/pages/components/seo-report.tsx +6 -1
- package/blocks/pages/hooks/use-landing-page-paths.ts +60 -0
- 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/programmatic-pages/README.md +82 -0
- package/blocks/programmatic-pages/block.json +292 -0
- package/blocks/programmatic-pages/components/programmatic-campaign-form.tsx +136 -0
- package/blocks/programmatic-pages/components/programmatic-generate.tsx +112 -0
- package/blocks/programmatic-pages/components/programmatic-generated-pages.tsx +210 -0
- package/blocks/programmatic-pages/components/programmatic-overview.tsx +84 -0
- package/blocks/programmatic-pages/components/programmatic-pages-screen.tsx +221 -0
- package/blocks/programmatic-pages/components/programmatic-preview.tsx +66 -0
- package/blocks/programmatic-pages/components/programmatic-publish.tsx +102 -0
- package/blocks/programmatic-pages/components/programmatic-settings.tsx +96 -0
- package/blocks/programmatic-pages/components/programmatic-source-fields.tsx +75 -0
- package/blocks/programmatic-pages/components/programmatic-source.tsx +106 -0
- package/blocks/programmatic-pages/components/programmatic-variables.tsx +84 -0
- package/blocks/programmatic-pages/hooks/use-programmatic-workspace.ts +19 -0
- package/blocks/programmatic-pages/lib/programmatic-types.ts +330 -0
- package/blocks/programmatic-pages/recipes/create-a-programmatic-campaign.ts +23 -0
- package/blocks/programmatic-pages/recipes/render-and-follow.ts +21 -0
- package/blocks/programmatic-pages/recipes/review-then-publish.ts +17 -0
- package/blocks/quotes/README.md +38 -1
- package/blocks/quotes/block.json +170 -6
- package/blocks/quotes/components/create-quote-from-deal.tsx +48 -0
- package/blocks/quotes/components/manual-products.tsx +144 -0
- package/blocks/quotes/components/quote-batch-actions.tsx +86 -0
- package/blocks/quotes/components/quote-builder.tsx +1 -1
- package/blocks/quotes/components/quote-documents.tsx +94 -0
- package/blocks/quotes/components/quote-evidence.tsx +95 -0
- package/blocks/quotes/components/quote-lifecycle-actions.tsx +146 -0
- package/blocks/quotes/components/quote-list.tsx +28 -8
- package/blocks/quotes/components/quote-related.tsx +3 -2
- package/blocks/quotes/components/quote-settings.tsx +184 -0
- package/blocks/quotes/components/quote-templates.tsx +189 -0
- package/blocks/quotes/components/quote-viewer.tsx +23 -5
- package/blocks/quotes/components/quotes-screen.tsx +15 -5
- package/blocks/quotes/components/record-quotes-tab.tsx +90 -0
- package/blocks/quotes/lib/quote-admin.ts +264 -0
- package/blocks/radar/components/competitor-battle-card.tsx +1 -1
- package/blocks/radar/components/radar-overview.tsx +4 -4
- package/blocks/radar/components/radar-screen.tsx +2 -2
- package/blocks/sequencer/README.md +61 -0
- package/blocks/sequencer/block.json +200 -6
- package/blocks/sequencer/components/add-to-sequence-button.tsx +82 -0
- package/blocks/sequencer/components/enroll-dialog.tsx +2 -2
- package/blocks/sequencer/components/enrollment-withdraw.tsx +71 -0
- package/blocks/sequencer/components/record-sequences-tab.tsx +126 -0
- package/blocks/sequencer/components/sequence-builder.tsx +32 -23
- package/blocks/sequencer/components/sequence-campaign-link.tsx +78 -0
- package/blocks/sequencer/components/sequence-detail.tsx +10 -2
- package/blocks/sequencer/components/sequence-edit-form.tsx +111 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +5 -2
- package/blocks/sequencer/components/sequence-list.tsx +1 -1
- package/blocks/sequencer/components/sequence-manage-actions.tsx +119 -0
- package/blocks/sequencer/components/sequence-senders-panel.tsx +84 -0
- package/blocks/sequencer/components/sequence-stats.tsx +8 -8
- package/blocks/sequencer/components/sequence-templates.tsx +160 -0
- package/blocks/sequencer/components/sequences-screen.tsx +39 -6
- package/blocks/sequencer/components/step-ai-assist.tsx +196 -0
- package/blocks/sequencer/components/step-editor.tsx +21 -12
- package/blocks/sequencer/components/step-templates.tsx +224 -0
- package/blocks/sequencer/lib/sequencer-manage.ts +235 -0
- package/blocks/sequencer/lib/sequencer-utils.ts +9 -5
- package/blocks/signals/README.md +39 -0
- package/blocks/signals/block.json +287 -0
- package/blocks/signals/components/auto-seed-panel.tsx +111 -0
- package/blocks/signals/components/listener-form.tsx +184 -0
- package/blocks/signals/components/matched-posts.tsx +195 -0
- package/blocks/signals/components/post-engagements.tsx +114 -0
- package/blocks/signals/components/record-social-tab.tsx +49 -0
- package/blocks/signals/components/social-listeners-screen.tsx +250 -0
- package/blocks/signals/components/social-settings.tsx +128 -0
- package/blocks/signals/components/source-lists.tsx +83 -0
- package/blocks/signals/components/top-engagers.tsx +68 -0
- package/blocks/signals/components/x-integration.tsx +129 -0
- package/blocks/signals/lib/signals-types.ts +228 -0
- package/blocks/signals/recipes/preview-and-create-a-listener.ts +16 -0
- package/blocks/signals/recipes/spend-against-caps.ts +14 -0
- package/blocks/signals/recipes/triage-matched-posts.ts +18 -0
- package/blocks/social-ads/README.md +8 -0
- package/blocks/social-ads/block.json +137 -4
- package/blocks/social-ads/components/audience-sync-detail.tsx +9 -3
- package/blocks/social-ads/components/audience-sync.tsx +6 -2
- package/blocks/social-ads/components/campaign-ads.tsx +166 -0
- package/blocks/social-ads/components/campaign-detail.tsx +6 -1
- package/blocks/social-ads/components/campaign-editor.tsx +134 -0
- package/blocks/social-ads/components/creative-concepts.tsx +74 -0
- package/blocks/social-ads/components/creative-detail.tsx +6 -1
- package/blocks/social-ads/components/creative-gallery.tsx +20 -2
- package/blocks/social-ads/components/creative-revisions.tsx +57 -0
- package/blocks/social-ads/components/creative-tools.tsx +176 -0
- package/blocks/social-ads/components/post-composer.tsx +4 -2
- package/blocks/social-ads/components/post-detail.tsx +3 -0
- package/blocks/social-ads/components/social-ideas.tsx +64 -0
- package/blocks/social-ads/components/social-performance.tsx +80 -0
- package/blocks/social-ads/components/social-screen.tsx +33 -6
- package/blocks/social-ads/lib/ads-extra.ts +67 -0
- package/blocks/surveys/README.md +91 -0
- package/blocks/surveys/block.json +299 -0
- package/blocks/surveys/components/account-nps.tsx +69 -0
- package/blocks/surveys/components/contact-survey-history.tsx +52 -0
- package/blocks/surveys/components/org-summary.tsx +82 -0
- package/blocks/surveys/components/sending-domains.tsx +113 -0
- package/blocks/surveys/components/survey-form.tsx +126 -0
- package/blocks/surveys/components/survey-responses.tsx +145 -0
- package/blocks/surveys/components/survey-results.tsx +72 -0
- package/blocks/surveys/components/survey-send-history.tsx +58 -0
- package/blocks/surveys/components/survey-send.tsx +162 -0
- package/blocks/surveys/components/survey-triggers.tsx +166 -0
- package/blocks/surveys/components/surveys-screen.tsx +234 -0
- package/blocks/surveys/lib/surveys-types.ts +228 -0
- package/blocks/surveys/recipes/create-an-nps-survey.ts +22 -0
- package/blocks/surveys/recipes/send-a-survey-and-follow-it.ts +27 -0
- package/blocks/surveys/recipes/survey-score-report.ts +27 -0
- package/blocks/visitors/components/snippet-install.tsx +18 -9
- package/blocks/visitors/components/traffic-chart.tsx +7 -6
- package/blocks/visitors/components/visitor-company-card.tsx +24 -14
- package/blocks/visitors/components/visitor-feed.tsx +5 -5
- package/blocks/web-analytics/README.md +102 -0
- package/blocks/web-analytics/block.json +292 -0
- package/blocks/web-analytics/components/acquisition-tab.tsx +154 -0
- package/blocks/web-analytics/components/analytics-filters.tsx +161 -0
- package/blocks/web-analytics/components/audience-tab.tsx +135 -0
- package/blocks/web-analytics/components/conversions-tab.tsx +128 -0
- package/blocks/web-analytics/components/engagement-tab.tsx +123 -0
- package/blocks/web-analytics/components/overview-tab.tsx +102 -0
- package/blocks/web-analytics/components/pages-tab.tsx +148 -0
- package/blocks/web-analytics/components/ranked-table.tsx +103 -0
- package/blocks/web-analytics/components/realtime-tab.tsx +103 -0
- package/blocks/web-analytics/components/report-card.tsx +71 -0
- package/blocks/web-analytics/components/web-analytics-screen.tsx +90 -0
- package/blocks/web-analytics/hooks/use-report.ts +48 -0
- package/blocks/web-analytics/lib/analytics-scope.ts +155 -0
- package/blocks/web-analytics/lib/page-path.ts +14 -0
- package/blocks/web-analytics/lib/web-analytics-types.ts +187 -0
- package/blocks/web-analytics/recipes/best-landing-pages.ts +26 -0
- package/blocks/web-analytics/recipes/form-conversion-check.ts +19 -0
- package/blocks/web-analytics/recipes/weekly-traffic-report.ts +37 -0
- package/blocks/webchat/components/chat-widget-config.tsx +1 -1
- package/blocks/webchat/components/conversation-list.tsx +1 -1
- package/blocks/workbench/README.md +54 -2
- package/blocks/workbench/block.json +234 -5
- package/blocks/workbench/components/column-recipe-editor.tsx +1 -1
- package/blocks/workbench/components/run-progress.tsx +1 -1
- package/blocks/workbench/components/workbench-auto-enrich.tsx +88 -0
- package/blocks/workbench/components/workbench-automation.tsx +36 -0
- package/blocks/workbench/components/workbench-connections.tsx +136 -0
- package/blocks/workbench/components/workbench-crm-import.tsx +91 -0
- package/blocks/workbench/components/workbench-datafile-import.tsx +154 -0
- package/blocks/workbench/components/workbench-grid.tsx +71 -8
- package/blocks/workbench/components/workbench-pipelines.tsx +173 -0
- package/blocks/workbench/components/workbench-row-actions.tsx +8 -1
- package/blocks/workbench/components/workbench-schedule.tsx +191 -0
- package/blocks/workbench/components/workbench-screen.tsx +65 -22
- package/blocks/workbench/components/workbench-send-to-sequence.tsx +116 -0
- package/blocks/workbench/components/workbench-table-list.tsx +14 -2
- package/blocks/workbench/components/workbench-table-settings.tsx +53 -0
- package/blocks/workbench/components/workbench-triggers.tsx +178 -0
- package/blocks/workbench/hooks/use-connected-crms.ts +29 -0
- package/blocks/workbench/lib/api-status.ts +8 -0
- package/blocks/workbench/lib/automation-types.ts +257 -0
- package/blocks/workflows/components/workflow-editor.tsx +1 -1
- package/dist/cli.js +744 -394
- package/dist/index.browser.js +1 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +1 -1
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +1 -1
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +1 -1
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +1 -1
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/auth.d.ts +45 -0
- package/dist/types/blocks/brand.d.ts +11 -2
- package/dist/types/blocks/create.d.ts +11 -4
- package/dist/types/blocks/doctor.d.ts +1 -0
- package/dist/types/blocks/flows.d.ts +7 -1
- package/dist/types/blocks/orgBrand.d.ts +34 -0
- package/dist/types/cli.d.ts +3 -0
- package/dist/types/version.d.ts +2 -2
- package/docs/api/desk.d.ts +2 -2
- package/docs/api/desk.md +3 -3
- package/docs/api/forms.md +1 -1
- package/package.json +1 -1
- package/stacks/6sense/pages/companies.tsx.tmpl +21 -0
- package/stacks/6sense/pages/contacts.tsx.tmpl +21 -0
- package/stacks/6sense/pages/tasks.tsx.tmpl +19 -0
- package/stacks/6sense.json +37 -4
- package/stacks/apollo/pages/calls.tsx.tmpl +23 -0
- package/stacks/apollo/pages/campaigns.tsx.tmpl +27 -0
- package/stacks/apollo/pages/companies.tsx.tmpl +19 -36
- package/stacks/apollo/pages/contacts.tsx.tmpl +35 -95
- package/stacks/apollo/pages/deals.tsx.tmpl +17 -0
- package/stacks/apollo/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/apollo/pages/lists.tsx.tmpl +24 -0
- package/stacks/apollo/pages/meetings.tsx.tmpl +13 -0
- package/stacks/apollo/pages/sequences.tsx.tmpl +18 -0
- package/stacks/apollo/pages/tasks.tsx.tmpl +20 -0
- package/stacks/apollo.json +68 -9
- package/stacks/calendly/pages/companies.tsx.tmpl +19 -0
- package/stacks/calendly/pages/contacts.tsx.tmpl +21 -0
- package/stacks/calendly/pages/meetings.tsx.tmpl +13 -0
- package/stacks/calendly/pages/tasks.tsx.tmpl +19 -0
- package/stacks/calendly.json +8 -4
- package/stacks/clay/pages/companies.tsx.tmpl +17 -1
- package/stacks/clay/pages/contacts.tsx.tmpl +14 -0
- package/stacks/clay/pages/lists.tsx.tmpl +24 -0
- package/stacks/clay/pages/tasks.tsx.tmpl +19 -0
- package/stacks/clay.json +16 -3
- package/stacks/gong/pages/calls.tsx.tmpl +23 -0
- package/stacks/gong/pages/companies.tsx.tmpl +22 -0
- package/stacks/gong/pages/contacts.tsx.tmpl +34 -0
- package/stacks/gong/pages/lists.tsx.tmpl +12 -0
- package/stacks/gong/pages/meetings.tsx.tmpl +13 -0
- package/stacks/gong/pages/tasks.tsx.tmpl +19 -0
- package/stacks/gong.json +23 -6
- package/stacks/hootsuite/pages/companies.tsx.tmpl +22 -0
- package/stacks/hootsuite/pages/contacts.tsx.tmpl +30 -0
- package/stacks/hootsuite/pages/lists.tsx.tmpl +12 -0
- package/stacks/hootsuite/pages/tasks.tsx.tmpl +19 -0
- package/stacks/hootsuite.json +19 -4
- package/stacks/hubspot/pages/campaigns.tsx.tmpl +30 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +16 -35
- package/stacks/hubspot/pages/contacts.tsx.tmpl +35 -95
- package/stacks/hubspot/pages/deals.tsx.tmpl +14 -1
- package/stacks/hubspot/pages/forms.tsx.tmpl +36 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +12 -1
- package/stacks/hubspot/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/hubspot/pages/lists.tsx.tmpl +12 -0
- package/stacks/hubspot/pages/meetings.tsx.tmpl +13 -0
- package/stacks/hubspot/pages/sequences.tsx.tmpl +18 -0
- package/stacks/hubspot/pages/tasks.tsx.tmpl +20 -0
- package/stacks/hubspot/pages/web-analytics.tsx.tmpl +14 -0
- package/stacks/hubspot.json +66 -7
- 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 +18 -4
- package/stacks/mailchimp/pages/companies.tsx.tmpl +22 -0
- package/stacks/mailchimp/pages/contacts.tsx.tmpl +30 -0
- package/stacks/mailchimp/pages/forms.tsx.tmpl +36 -0
- package/stacks/mailchimp/pages/lists.tsx.tmpl +12 -0
- package/stacks/mailchimp/pages/tasks.tsx.tmpl +19 -0
- package/stacks/mailchimp.json +22 -6
- package/stacks/outreach/pages/calls.tsx.tmpl +23 -0
- package/stacks/outreach/pages/campaigns.tsx.tmpl +27 -0
- package/stacks/outreach/pages/companies.tsx.tmpl +22 -0
- package/stacks/outreach/pages/contacts.tsx.tmpl +29 -66
- package/stacks/outreach/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/outreach/pages/lists.tsx.tmpl +12 -0
- package/stacks/outreach/pages/meetings.tsx.tmpl +13 -0
- package/stacks/outreach/pages/sequences.tsx.tmpl +18 -0
- package/stacks/outreach/pages/tasks.tsx.tmpl +19 -0
- package/stacks/outreach.json +38 -9
- package/stacks/pipedrive/pages/companies.tsx.tmpl +11 -35
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +17 -35
- package/stacks/pipedrive/pages/deals.tsx.tmpl +10 -1
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +12 -1
- package/stacks/pipedrive/pages/meetings.tsx.tmpl +13 -0
- package/stacks/pipedrive/pages/tasks.tsx.tmpl +20 -0
- package/stacks/pipedrive.json +14 -2
- package/stacks/salesforce/pages/companies.tsx.tmpl +16 -35
- package/stacks/salesforce/pages/contacts.tsx.tmpl +26 -35
- package/stacks/salesforce/pages/deals.tsx.tmpl +14 -1
- package/stacks/salesforce/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/salesforce/pages/lists.tsx.tmpl +12 -0
- package/stacks/salesforce/pages/tasks.tsx.tmpl +20 -0
- package/stacks/salesforce.json +17 -3
- package/stacks/zoominfo/pages/companies.tsx.tmpl +17 -1
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +16 -0
- package/stacks/zoominfo/pages/lists.tsx.tmpl +24 -0
- package/stacks/zoominfo/pages/tasks.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +36 -3
- package/styles/styles.css +1 -1
- package/templates/auth/auth-route.ts.tmpl +16 -0
- package/templates/auth/g8.auth.anonymous.ts.tmpl +15 -0
- package/templates/auth/g8.auth.basic.ts.tmpl +17 -0
- package/templates/auth/g8.auth.jwt.ts.tmpl +23 -0
- package/templates/auth/g8.auth.password.ts.tmpl +18 -0
- package/templates/auth/g8.auth.session.ts.tmpl +23 -0
- package/templates/auth/middleware.ts.tmpl +14 -0
- package/templates/next-app/README.md.tmpl +5 -0
- package/templates/next-app/app/globals.css.tmpl +349 -64
- package/templates/next-app/components/app-shell.tsx.tmpl +6 -0
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +4 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DealLineItems: the products on a deal: add, edit and remove lines, with per-line totals and the deal total.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. GET /deals/{id}/line-items (deals.listDealLineItems) read - lines, derived amount, quote mismatch flag
|
|
8
|
+
* 2. PUT /deals/{id}/line-items { line_items } (deals.replaceDealLineItems) write - sends the FULL desired set (an empty list clears them)
|
|
9
|
+
* Money: unit prices are typed in dollars and sent as whole CENTS; the server recomputes each line total from
|
|
10
|
+
* unit price, quantity and discount. The editor appears only after the lines loaded, and Save stays disabled until
|
|
11
|
+
* something changed, so a record that has not loaded is never overwritten. Fields the screen does not show (catalog
|
|
12
|
+
* ids, description, billing frequency) are carried over untouched.
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
15
|
+
import { Plus, Trash2 } from "lucide-react";
|
|
16
|
+
import { Button, Input, Skeleton } from "@graph8/sdk/ui";
|
|
17
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
18
|
+
import { g8Get, g8Put } from "@/g8/lib/g8-client";
|
|
19
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
20
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
21
|
+
import { dataOf } from "../lib/deals-types";
|
|
22
|
+
import { centsToDollarsText, dollarsToCents, formatCents, lineTotalCents, type DealLineItem, type LineItemsPayload } from "../lib/deal-extras";
|
|
23
|
+
|
|
24
|
+
export interface DealLineItemsProps {
|
|
25
|
+
dealId: string;
|
|
26
|
+
/** Called after the lines were saved, so a screen can refresh the deal amount. */
|
|
27
|
+
onSaved?: () => void;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Row {
|
|
32
|
+
key: string;
|
|
33
|
+
base: DealLineItem | null;
|
|
34
|
+
name: string;
|
|
35
|
+
quantity: string;
|
|
36
|
+
price: string;
|
|
37
|
+
discount: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const FREQUENCIES: Array<[string, string]> = [["one_time", "One time"], ["month", "Monthly"], ["quarter", "Quarterly"], ["year", "Yearly"]];
|
|
41
|
+
let keyCounter = 0;
|
|
42
|
+
const nextKey = () => `row-${keyCounter++}`;
|
|
43
|
+
|
|
44
|
+
const rowFrom = (li: DealLineItem): Row => ({ key: li.id ?? nextKey(), base: li, name: li.product_name, quantity: String(li.quantity), price: centsToDollarsText(li.unit_amount), discount: String(li.discount_pct ?? 0) });
|
|
45
|
+
const blankRow = (): Row => ({ key: nextKey(), base: null, name: "", quantity: "1", price: "", discount: "0" });
|
|
46
|
+
|
|
47
|
+
/** The line a row stands for, or null while a field is invalid. */
|
|
48
|
+
function parse(r: Row, sortOrder: number): DealLineItem | null {
|
|
49
|
+
const cents = dollarsToCents(r.price);
|
|
50
|
+
const quantity = Number(r.quantity);
|
|
51
|
+
const discount = Number(r.discount === "" ? 0 : r.discount);
|
|
52
|
+
if (!r.name.trim() || cents == null || cents > 1_000_000_000 || !Number.isInteger(quantity) || quantity < 1 || quantity > 10_000 || !Number.isFinite(discount) || discount < 0 || discount > 100) return null;
|
|
53
|
+
const { id: _id, ...kept } = (r.base ?? {}) as DealLineItem & { line_total?: number };
|
|
54
|
+
const { line_total: _total, ...carried } = kept as DealLineItem & { line_total?: number };
|
|
55
|
+
return { ...carried, product_name: r.name.trim(), quantity, unit_amount: cents, discount_pct: discount, sort_order: sortOrder };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function DealLineItems({ dealId, onSaved, className }: DealLineItemsProps) {
|
|
59
|
+
const q = useG8Query(async (signal) => dataOf<LineItemsPayload>(await g8Get(`/deals/${encodeURIComponent(dealId)}/line-items`, undefined, { signal })), [dealId], { key: ["deals", "line-items", dealId], refetchOnFocus: false });
|
|
60
|
+
const [rows, setRows] = useState<Row[]>([]);
|
|
61
|
+
const baseline = useRef("[]");
|
|
62
|
+
const dirty = useRef(false);
|
|
63
|
+
// A different deal must not show the previous deal's lines.
|
|
64
|
+
useEffect(() => { dirty.current = false; setRows([]); baseline.current = "[]"; }, [dealId]);
|
|
65
|
+
// Unsaved edits win over a refreshed record: only an untouched editor follows the server.
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!q.data || dirty.current) return;
|
|
68
|
+
const next = (q.data.line_items ?? []).map(rowFrom);
|
|
69
|
+
setRows(next);
|
|
70
|
+
baseline.current = JSON.stringify(next.map((r) => [r.name, r.quantity, r.price, r.discount]));
|
|
71
|
+
}, [q.data]);
|
|
72
|
+
|
|
73
|
+
const edit = (key: string, change: Partial<Row>) => { dirty.current = true; setRows((all) => all.map((r) => (r.key === key ? { ...r, ...change } : r))); };
|
|
74
|
+
const parsed = rows.map((r, i) => parse(r, i));
|
|
75
|
+
const valid = parsed.every((p) => p !== null);
|
|
76
|
+
const changed = JSON.stringify(rows.map((r) => [r.name, r.quantity, r.price, r.discount])) !== baseline.current;
|
|
77
|
+
const currency = q.data?.currency;
|
|
78
|
+
const total = useMemo(() => parsed.reduce((sum, p) => sum + (p ? lineTotalCents(p) : 0), 0), [parsed]);
|
|
79
|
+
|
|
80
|
+
const save = useG8Mutation(
|
|
81
|
+
() => g8Put(`/deals/${encodeURIComponent(dealId)}/line-items`, { line_items: parsed as DealLineItem[] }),
|
|
82
|
+
{ invalidates: [["deals", "line-items", dealId], ["deals", "detail", dealId], ["deals", "list"]], onSuccess: () => { dirty.current = false; void q.refetch(); onSaved?.(); } },
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (q.error) return <ApiError error={q.error} title="Could not load the line items" onRetry={() => void q.refetch()} />;
|
|
86
|
+
if (!q.data) return <Skeleton className="h-32 w-full" aria-label="Loading line items" />;
|
|
87
|
+
return (
|
|
88
|
+
<form className={`space-y-3 ${className ?? ""}`} onSubmit={(e) => { e.preventDefault(); if (changed && valid) void save.mutate(undefined); }}>
|
|
89
|
+
{q.data.amount_mismatch ? (
|
|
90
|
+
<p role="status" className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
|
91
|
+
The deal amount ({q.data.amount ?? 0}) and its linked quote ({q.data.linked_quote_total ?? 0}) disagree.
|
|
92
|
+
</p>
|
|
93
|
+
) : null}
|
|
94
|
+
{rows.length === 0 ? <p className="text-sm text-muted-foreground">No line items yet. Add the products this deal is for.</p> : null}
|
|
95
|
+
<ul className="space-y-2" aria-label="Line items">
|
|
96
|
+
{rows.map((r, i) => {
|
|
97
|
+
const line = parsed[i];
|
|
98
|
+
return (
|
|
99
|
+
<li key={r.key} className="grid gap-2 rounded-md border p-2 sm:grid-cols-[1fr_5rem_7rem_5rem_7rem_auto] sm:items-center">
|
|
100
|
+
<Input aria-label={`Product ${i + 1}`} placeholder="Product" value={r.name} onChange={(e) => edit(r.key, { name: e.target.value })} />
|
|
101
|
+
<Input aria-label={`Quantity ${i + 1}`} type="number" min={1} max={10000} step={1} value={r.quantity} onChange={(e) => edit(r.key, { quantity: e.target.value })} />
|
|
102
|
+
<Input aria-label={`Unit price ${i + 1}`} type="number" min={0} step="0.01" placeholder="0.00" value={r.price} onChange={(e) => edit(r.key, { price: e.target.value })} />
|
|
103
|
+
<Input aria-label={`Discount percent ${i + 1}`} type="number" min={0} max={100} step="any" value={r.discount} onChange={(e) => edit(r.key, { discount: e.target.value })} />
|
|
104
|
+
<span className="text-right text-sm tabular-nums" aria-label={`Line total ${i + 1}`}>{line ? formatCents(lineTotalCents(line), currency) : "-"}</span>
|
|
105
|
+
<Button type="button" size="sm" variant="ghost" aria-label={`Remove line ${i + 1}`} onClick={() => { dirty.current = true; setRows((all) => all.filter((x) => x.key !== r.key)); }}><Trash2 className="size-4" aria-hidden /></Button>
|
|
106
|
+
{r.base?.billing_frequency && r.base.billing_frequency !== "one_time" ? <span className="text-xs text-muted-foreground sm:col-span-6">{FREQUENCIES.find(([v]) => v === r.base?.billing_frequency)?.[1] ?? r.base.billing_frequency} billing</span> : null}
|
|
107
|
+
</li>
|
|
108
|
+
);
|
|
109
|
+
})}
|
|
110
|
+
</ul>
|
|
111
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
112
|
+
<Button type="button" size="sm" variant="outline" onClick={() => { dirty.current = true; setRows((all) => [...all, blankRow()]); }}><Plus className="size-4" aria-hidden />Add line</Button>
|
|
113
|
+
<p className="text-sm font-semibold tabular-nums" data-testid="line-items-total">Total {formatCents(total, currency)}</p>
|
|
114
|
+
</div>
|
|
115
|
+
{!valid ? <p className="text-xs text-muted-foreground">Each line needs a product, a whole quantity from 1 to 10,000, a price and a discount from 0 to 100.</p> : null}
|
|
116
|
+
{save.error ? <ApiError error={save.error} title="Could not save the line items" /> : null}
|
|
117
|
+
<div className="flex justify-end"><Button type="submit" loading={save.loading} disabled={!changed || !valid}>Save line items</Button></div>
|
|
118
|
+
</form>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DealNotesTab: list, add, edit and delete the notes of one deal.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. GET /deals/{id}/notes (notes.listDealNotes) read
|
|
8
|
+
* 2. POST /deals/{id}/notes { content } (notes.createDealNote) write
|
|
9
|
+
* 3. PUT /deals/{id}/notes/{note_id} (deals.updateDealNote) write - only when the text changed
|
|
10
|
+
* 4. DELETE /deals/{id}/notes/{note_id} (deals.deleteDealNote) DESTRUCTIVE -> ConfirmSpendDialog first
|
|
11
|
+
* A `read_only` note (projected from synced CRM activity) can be neither edited nor deleted.
|
|
12
|
+
*/
|
|
13
|
+
import { useState } from "react";
|
|
14
|
+
import { StickyNote } from "lucide-react";
|
|
15
|
+
import { Button, Skeleton, Textarea } from "@graph8/sdk/ui";
|
|
16
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
17
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
18
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
19
|
+
import { g8Delete, g8Get, g8Post, g8Put } from "@/g8/lib/g8-client";
|
|
20
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
21
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
22
|
+
import { rowsOf, type DealNote } from "../lib/deal-extras";
|
|
23
|
+
|
|
24
|
+
export interface DealNotesTabProps {
|
|
25
|
+
dealId: string;
|
|
26
|
+
/** Called after any write, so a sibling activity timeline can reload. */
|
|
27
|
+
onChanged?: () => void;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const when = (iso: string | null | undefined): string => (iso ? new Date(iso).toLocaleString() : "");
|
|
32
|
+
|
|
33
|
+
export function DealNotesTab({ dealId, onChanged, className }: DealNotesTabProps) {
|
|
34
|
+
const base = `/deals/${encodeURIComponent(dealId)}/notes`;
|
|
35
|
+
const [draft, setDraft] = useState("");
|
|
36
|
+
const [editing, setEditing] = useState<{ id: string; original: string; content: string } | null>(null);
|
|
37
|
+
const [deleting, setDeleting] = useState<DealNote | null>(null);
|
|
38
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
39
|
+
const notes = useG8Query(async (signal) => rowsOf<DealNote>(await g8Get(base, undefined, { signal })), [dealId], { key: ["deals", "notes", dealId] });
|
|
40
|
+
const changed = () => { void notes.refetch(); onChanged?.(); };
|
|
41
|
+
|
|
42
|
+
const add = useG8Mutation((content: string) => g8Post(base, { content }), { invalidates: [["deals", "notes", dealId]], onSuccess: () => { setDraft(""); changed(); } });
|
|
43
|
+
const edit = useG8Mutation(async (v: { id: string; content: string }) => {
|
|
44
|
+
setBusyId(v.id);
|
|
45
|
+
try { await g8Put(`${base}/${encodeURIComponent(v.id)}`, { content: v.content }); } finally { setBusyId(null); }
|
|
46
|
+
}, { invalidates: [["deals", "notes", dealId]], onSuccess: () => { setEditing(null); changed(); } });
|
|
47
|
+
const remove = useG8Mutation(async (id: string) => {
|
|
48
|
+
setBusyId(id);
|
|
49
|
+
try { await g8Delete(`${base}/${encodeURIComponent(id)}`); } finally { setBusyId(null); }
|
|
50
|
+
}, { invalidates: [["deals", "notes", dealId]], onSuccess: () => { setDeleting(null); changed(); }, onError: () => setDeleting(null) });
|
|
51
|
+
|
|
52
|
+
const items = notes.data ?? [];
|
|
53
|
+
const unchanged = editing ? editing.content.trim() === editing.original.trim() : true;
|
|
54
|
+
return (
|
|
55
|
+
<div className={`flex flex-col gap-3 ${className ?? ""}`}>
|
|
56
|
+
<form className="flex flex-col gap-2" onSubmit={(e) => { e.preventDefault(); if (draft.trim()) void add.mutate(draft.trim()); }}>
|
|
57
|
+
<Textarea aria-label="New note" rows={3} placeholder="Write a note about this deal" value={draft} onChange={(e) => setDraft(e.target.value)} />
|
|
58
|
+
<div className="flex justify-end"><Button type="submit" size="sm" loading={add.loading} disabled={!draft.trim()}>Add note</Button></div>
|
|
59
|
+
</form>
|
|
60
|
+
{add.error ? <ApiError error={add.error} title="Could not add the note" /> : null}
|
|
61
|
+
{edit.error ? <ApiError error={edit.error} title="Could not save the note" /> : null}
|
|
62
|
+
{remove.error ? <ApiError error={remove.error} title="Could not delete the note" /> : null}
|
|
63
|
+
{notes.error ? <ApiError error={notes.error} title="Could not load notes" onRetry={() => void notes.refetch()} /> : null}
|
|
64
|
+
{notes.loading && !notes.data ? <Skeleton className="h-16 w-full" aria-label="Loading notes" /> : null}
|
|
65
|
+
{!notes.loading && !notes.error && items.length === 0 ? <EmptyState icon={<StickyNote className="size-8" />} title="No notes yet" description="Capture what was said, decided or promised." /> : null}
|
|
66
|
+
<ul className="flex flex-col gap-2" aria-label="Deal notes">
|
|
67
|
+
{items.map((n) => (
|
|
68
|
+
<li key={n.id} className="rounded-md border p-3 text-sm" data-testid="deal-note">
|
|
69
|
+
{editing?.id === n.id ? (
|
|
70
|
+
<form className="flex flex-col gap-2" onSubmit={(e) => { e.preventDefault(); if (!unchanged && editing.content.trim()) void edit.mutate({ id: n.id, content: editing.content.trim() }); }}>
|
|
71
|
+
<Textarea aria-label="Edit note" rows={3} value={editing.content} onChange={(e) => setEditing({ ...editing, content: e.target.value })} />
|
|
72
|
+
<div className="flex justify-end gap-2">
|
|
73
|
+
<Button type="button" size="sm" variant="outline" onClick={() => setEditing(null)}>Cancel</Button>
|
|
74
|
+
<Button type="submit" size="sm" loading={busyId === n.id && edit.loading} disabled={unchanged || !editing.content.trim()}>Save note</Button>
|
|
75
|
+
</div>
|
|
76
|
+
</form>
|
|
77
|
+
) : (
|
|
78
|
+
<>
|
|
79
|
+
<p className="whitespace-pre-wrap">{n.content}</p>
|
|
80
|
+
<div className="mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
|
81
|
+
<span>{[n.created_by_name, when(n.updated_at ?? n.created_at)].filter(Boolean).join(" · ")}{n.read_only ? " · synced, read only" : ""}</span>
|
|
82
|
+
{n.read_only ? null : (
|
|
83
|
+
<span className="flex gap-1">
|
|
84
|
+
<Button size="sm" variant="ghost" aria-label={`Edit note: ${n.content.slice(0, 30)}`} onClick={() => setEditing({ id: n.id, original: n.content, content: n.content })}>Edit</Button>
|
|
85
|
+
<Button size="sm" variant="ghost" aria-label={`Delete note: ${n.content.slice(0, 30)}`} loading={busyId === n.id && remove.loading} onClick={() => setDeleting(n)}>Delete</Button>
|
|
86
|
+
</span>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
</>
|
|
90
|
+
)}
|
|
91
|
+
</li>
|
|
92
|
+
))}
|
|
93
|
+
</ul>
|
|
94
|
+
<ConfirmSpendDialog open={deleting != null} tier="destructive" title="Delete this note?" description={deleting?.content.slice(0, 160)} confirmLabel="Delete note" affected={1} confirming={remove.loading} onConfirm={() => deleting && void remove.mutate(deleting.id)} onCancel={() => setDeleting(null)} />
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
* DealContactsTab: GET /deals/{id} (deals.getDeal) read - the deal's contacts and roles
|
|
9
9
|
* GET /contacts/{id} ... (crm ContactDetail, in a side sheet)
|
|
10
10
|
* DealQuotesTab: GET /quotes?deal_id=&page=&limit (quotes.listQuotes) read - needs the quotes:read scope
|
|
11
|
+
* (`actions` renders above the list: pass the quotes block's CreateQuoteFromDeal there)
|
|
11
12
|
* Nothing here is billable, external or destructive.
|
|
12
13
|
*/
|
|
13
|
-
import { useState } from "react";
|
|
14
|
+
import { useState, type ReactNode } from "react";
|
|
14
15
|
import { FileText, Users } from "lucide-react";
|
|
15
|
-
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
|
|
16
|
+
import { Button, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
|
|
16
17
|
import { ApiError } from "@/g8/components/api-error";
|
|
17
18
|
import { EmptyState } from "@/g8/components/empty-state";
|
|
18
19
|
import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
|
|
@@ -20,9 +21,19 @@ import { StatusPill, toneForStatus, humanizeStatus } from "@/g8/components/scree
|
|
|
20
21
|
import { g8Get } from "@/g8/lib/g8-client";
|
|
21
22
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
22
23
|
import { ContactDetail } from "@/g8/crm/components/contact-detail";
|
|
23
|
-
import { dataOf, type Deal } from "../lib/deals-types";
|
|
24
|
+
import { dataOf, type Deal, type DealContact } from "../lib/deals-types";
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
/** `renderContactActions` adds controls beside each contact (DealContactsEditor uses it for role, primary and remove). */
|
|
27
|
+
export interface DealContactsTabProps {
|
|
28
|
+
dealId: string;
|
|
29
|
+
renderContactActions?: (contact: DealContact, deal: Deal) => ReactNode;
|
|
30
|
+
/** Open the person in your contacts screen (adds an "Open in contacts" button to the contact sheet). */
|
|
31
|
+
onOpenContact?: (contactId: number) => void;
|
|
32
|
+
/** Forwarded to the contact sheet's "Open company" button. */
|
|
33
|
+
onOpenCompany?: (companyId: number) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function DealContactsTab({ dealId, renderContactActions, onOpenContact, onOpenCompany }: DealContactsTabProps) {
|
|
26
37
|
const [open, setOpen] = useState<number | null>(null);
|
|
27
38
|
const q = useG8Query(async (signal) => dataOf<Deal>(await g8Get(`/deals/${encodeURIComponent(dealId)}`, undefined, { signal })), [dealId], { key: ["deals", "detail", dealId] });
|
|
28
39
|
if (q.error) return <ApiError error={q.error} title="Could not load the deal's contacts" onRetry={() => void q.refetch()} />;
|
|
@@ -33,7 +44,7 @@ export function DealContactsTab({ dealId }: { dealId: string }) {
|
|
|
33
44
|
<>
|
|
34
45
|
<ul className="divide-y rounded-md border" aria-label="Deal contacts">
|
|
35
46
|
{contacts.map((c) => (
|
|
36
|
-
<li key={c.id}>
|
|
47
|
+
<li key={c.id} className={renderContactActions ? "flex items-center gap-2 pr-2" : undefined}>
|
|
37
48
|
<button type="button" className="flex w-full items-center gap-3 p-3 text-left hover:bg-muted" onClick={() => setOpen(c.id)}>
|
|
38
49
|
<EntityAvatar name={c.name || c.email || "Contact"} size="md" />
|
|
39
50
|
<span className="min-w-0 flex-1">
|
|
@@ -43,13 +54,19 @@ export function DealContactsTab({ dealId }: { dealId: string }) {
|
|
|
43
54
|
{c.role ? <StatusPill tone="neutral">{humanizeStatus(c.role)}</StatusPill> : null}
|
|
44
55
|
{q.data?.primary_contact?.id === c.id ? <StatusPill tone="info">Primary</StatusPill> : null}
|
|
45
56
|
</button>
|
|
57
|
+
{renderContactActions && q.data ? renderContactActions(c, q.data) : null}
|
|
46
58
|
</li>
|
|
47
59
|
))}
|
|
48
60
|
</ul>
|
|
49
61
|
<Sheet open={open != null} onOpenChange={(o) => { if (!o) setOpen(null); }}>
|
|
50
62
|
<SheetContent className="w-full overflow-y-auto sm:max-w-2xl">
|
|
51
63
|
<SheetHeader><SheetTitle>Contact</SheetTitle><SheetDescription>The full CRM record.</SheetDescription></SheetHeader>
|
|
52
|
-
{open != null ?
|
|
64
|
+
{open != null ? (
|
|
65
|
+
<>
|
|
66
|
+
{onOpenContact ? <div className="px-4"><Button size="sm" variant="outline" onClick={() => onOpenContact(open)}>Open in contacts</Button></div> : null}
|
|
67
|
+
<ContactDetail contactId={open} onOpenCompany={onOpenCompany} />
|
|
68
|
+
</>
|
|
69
|
+
) : null}
|
|
53
70
|
</SheetContent>
|
|
54
71
|
</Sheet>
|
|
55
72
|
</>
|
|
@@ -58,16 +75,19 @@ export function DealContactsTab({ dealId }: { dealId: string }) {
|
|
|
58
75
|
|
|
59
76
|
interface QuoteRow { id: string; title?: string | null; quote_number?: string | null; status?: string | null; total?: number | null; currency?: string | null }
|
|
60
77
|
|
|
61
|
-
|
|
78
|
+
/** `actions` renders above the list, for example the quotes block's "Create quote from this deal" button. */
|
|
79
|
+
export function DealQuotesTab({ dealId, onOpenQuote, actions, refreshKey }: { dealId: string; onOpenQuote?: (quoteId: string) => void; actions?: ReactNode; refreshKey?: number }) {
|
|
62
80
|
const q = useG8Query(async (signal) => {
|
|
63
81
|
const env = await g8Get("/quotes", { deal_id: dealId, page: 1, limit: 50 }, { signal });
|
|
64
82
|
const inner = dataOf<{ items?: QuoteRow[] } | QuoteRow[]>(env);
|
|
65
83
|
return Array.isArray(inner) ? inner : (inner.items ?? []);
|
|
66
|
-
}, [dealId]);
|
|
84
|
+
}, [dealId, refreshKey]);
|
|
67
85
|
if (q.error) return <ApiError error={q.error} title="Could not load quotes" onRetry={() => void q.refetch()} />;
|
|
68
86
|
if (!q.data) return <Skeleton className="h-24 w-full" aria-label="Loading quotes" />;
|
|
69
|
-
if (q.data.length === 0) return <EmptyState icon={<FileText className="size-8" />} title="No quotes for this deal" description="Create one from the Quotes screen."
|
|
87
|
+
if (q.data.length === 0) return <div className="space-y-3">{actions}<EmptyState icon={<FileText className="size-8" />} title="No quotes for this deal" description="Create one from the Quotes screen." /></div>;
|
|
70
88
|
return (
|
|
89
|
+
<div className="space-y-3">
|
|
90
|
+
{actions}
|
|
71
91
|
<Table>
|
|
72
92
|
<TableHeader><TableRow><TableHead>Quote</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Total</TableHead></TableRow></TableHeader>
|
|
73
93
|
<TableBody>
|
|
@@ -83,5 +103,6 @@ export function DealQuotesTab({ dealId, onOpenQuote }: { dealId: string; onOpenQ
|
|
|
83
103
|
))}
|
|
84
104
|
</TableBody>
|
|
85
105
|
</Table>
|
|
106
|
+
</div>
|
|
86
107
|
);
|
|
87
108
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DealTasksTab: the follow-up tasks linked to one deal: list, add, complete and reopen.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. GET /tasks?entity_type=deal&entity_id={id}&status= (tasks.listAllTasks) read
|
|
8
|
+
* 2. POST /tasks { title, due_date, priority, entity_type: "deal", entity_id } (tasks.createGlobalTask) write
|
|
9
|
+
* 3. PATCH /tasks/{task_id} { status } (tasks.updateTask) write - open or completed
|
|
10
|
+
* Priority: 1 high, 2 medium, 3 low, 0 none. Due dates are date-only (YYYY-MM-DD) and shown in UTC.
|
|
11
|
+
*/
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
import { ListChecks } from "lucide-react";
|
|
14
|
+
import { Button, Input, Skeleton } from "@graph8/sdk/ui";
|
|
15
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
16
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
17
|
+
import { g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
18
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
19
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
20
|
+
import { rowsOf, type DealTask } from "../lib/deal-extras";
|
|
21
|
+
|
|
22
|
+
export interface DealTasksTabProps {
|
|
23
|
+
dealId: string;
|
|
24
|
+
onChanged?: () => void;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const PRIORITY = ["", "High", "Medium", "Low", "Low"];
|
|
29
|
+
|
|
30
|
+
export function DealTasksTab({ dealId, onChanged, className }: DealTasksTabProps) {
|
|
31
|
+
const [title, setTitle] = useState("");
|
|
32
|
+
const [due, setDue] = useState("");
|
|
33
|
+
const [priority, setPriority] = useState(2);
|
|
34
|
+
const [showDone, setShowDone] = useState(false);
|
|
35
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
36
|
+
const tasks = useG8Query(
|
|
37
|
+
async (signal) => rowsOf<DealTask>(await g8Get("/tasks", { entity_type: "deal", entity_id: dealId, status: showDone ? undefined : "open", limit: 100 }, { signal })),
|
|
38
|
+
[dealId, showDone],
|
|
39
|
+
{ key: ["tasks", "deal", dealId, { showDone }] },
|
|
40
|
+
);
|
|
41
|
+
const changed = () => { void tasks.refetch(); onChanged?.(); };
|
|
42
|
+
const add = useG8Mutation(
|
|
43
|
+
() => g8Post("/tasks", { title: title.trim(), priority, entity_type: "deal", entity_id: dealId, ...(due ? { due_date: due } : {}) }),
|
|
44
|
+
{ invalidates: [["tasks"]], onSuccess: () => { setTitle(""); setDue(""); changed(); } },
|
|
45
|
+
);
|
|
46
|
+
const toggle = useG8Mutation(async (t: DealTask) => {
|
|
47
|
+
setBusyId(t.id);
|
|
48
|
+
try { await g8Patch(`/tasks/${encodeURIComponent(t.id)}`, { status: t.status === "completed" ? "open" : "completed" }); } finally { setBusyId(null); }
|
|
49
|
+
}, { invalidates: [["tasks"]], onSuccess: changed });
|
|
50
|
+
|
|
51
|
+
const items = tasks.data ?? [];
|
|
52
|
+
return (
|
|
53
|
+
<div className={`flex flex-col gap-3 ${className ?? ""}`}>
|
|
54
|
+
<form className="flex flex-wrap items-end gap-2" onSubmit={(e) => { e.preventDefault(); if (title.trim()) void add.mutate(undefined); }}>
|
|
55
|
+
<Input aria-label="Task title" placeholder="Add a task for this deal" className="min-w-48 flex-1" value={title} onChange={(e) => setTitle(e.target.value)} />
|
|
56
|
+
<Input aria-label="Due date" type="date" className="w-40" value={due} onChange={(e) => setDue(e.target.value)} />
|
|
57
|
+
<select aria-label="Priority" className="h-9 rounded-md border bg-background px-2 text-sm" value={priority} onChange={(e) => setPriority(Number(e.target.value))}>
|
|
58
|
+
<option value={1}>High</option><option value={2}>Medium</option><option value={3}>Low</option><option value={0}>None</option>
|
|
59
|
+
</select>
|
|
60
|
+
<Button type="submit" size="sm" loading={add.loading} disabled={!title.trim()}>Add task</Button>
|
|
61
|
+
</form>
|
|
62
|
+
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
63
|
+
<input type="checkbox" checked={showDone} onChange={(e) => setShowDone(e.target.checked)} /> Show completed
|
|
64
|
+
</label>
|
|
65
|
+
{add.error ? <ApiError error={add.error} title="Could not add the task" /> : null}
|
|
66
|
+
{toggle.error ? <ApiError error={toggle.error} title="Could not update the task" /> : null}
|
|
67
|
+
{tasks.error ? <ApiError error={tasks.error} title="Could not load tasks" onRetry={() => void tasks.refetch()} /> : null}
|
|
68
|
+
{tasks.loading && !tasks.data ? <Skeleton className="h-12 w-full" aria-label="Loading tasks" /> : null}
|
|
69
|
+
{!tasks.loading && !tasks.error && items.length === 0 ? <EmptyState icon={<ListChecks className="size-8" />} title="No tasks" description="Add a follow-up so this deal does not stall." /> : null}
|
|
70
|
+
<ul className="flex flex-col gap-2" aria-label="Deal tasks">
|
|
71
|
+
{items.map((t) => {
|
|
72
|
+
const completed = t.status === "completed";
|
|
73
|
+
return (
|
|
74
|
+
<li key={t.id} className="flex items-center gap-3 rounded-md border p-3" data-testid="deal-task">
|
|
75
|
+
<input type="checkbox" aria-label={`${completed ? "Reopen" : "Complete"} ${t.title}`} checked={completed} disabled={busyId === t.id} onChange={() => void toggle.mutate(t)} />
|
|
76
|
+
<div className="min-w-0 flex-1">
|
|
77
|
+
<p className={`truncate text-sm ${completed ? "text-muted-foreground line-through" : "font-medium"}`}>{t.title}</p>
|
|
78
|
+
<p className="text-xs text-muted-foreground">
|
|
79
|
+
{[t.due_date ? `Due ${new Date(t.due_date.length === 10 ? `${t.due_date}T00:00:00Z` : t.due_date).toLocaleDateString(undefined, { timeZone: "UTC" })}` : null, PRIORITY[t.priority ?? 0] || null, t.assignee_name].filter(Boolean).join(" · ")}
|
|
80
|
+
</p>
|
|
81
|
+
</div>
|
|
82
|
+
</li>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
85
|
+
</ul>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -5,21 +5,28 @@
|
|
|
5
5
|
* pipeline stage settings in one screen. No props are required.
|
|
6
6
|
*
|
|
7
7
|
* It composes: PipelineBoard (kanban, drag or "Move to"), a deals table, DealDetail (edit, stage history, delete),
|
|
8
|
-
* ForecastView, StageEditor,
|
|
8
|
+
* ForecastView, StageEditor, PipelineEditor, DealContactsEditor, DealLineItems, DealNotesTab, DealTasksTab, DealQuotesTab,
|
|
9
|
+
* DealActions (close with a reason, move pipeline), DealFilterBar and the crm block's ActivityTimeline and ContactDetail.
|
|
9
10
|
*
|
|
10
11
|
* Flows:
|
|
11
12
|
* - Board / List toggle, search by deal name, switch pipeline (when there is more than one).
|
|
12
13
|
* - Headline numbers: open deals, open value, estimated weighted value, won value (from the loaded deals).
|
|
14
|
+
* - Filters (board and list): owner, stage, outcome, stale (no activity for 7 to 90 days), activity, close-date range
|
|
15
|
+
* and "no contact".
|
|
13
16
|
* - Open a deal (board card or list row; deep link ?id=, full page with &view=full) -> Details (every field,
|
|
14
|
-
* edit, stage history, confirmed delete), Contacts (
|
|
17
|
+
* edit, stage history, confirmed delete), Contacts (add, set role, make primary, remove), Line items (add, edit,
|
|
18
|
+
* remove, totals), Notes (add, edit, delete), Tasks (add, complete), Quotes and Activity tabs. The header has
|
|
19
|
+
* "Close deal" (won or lost with a reason) and "Move to pipeline". Moving a card into a won/lost stage asks for the close date and amount.
|
|
15
20
|
* - New deal: name, amount, close date, stage, plus the two things the API requires. The OWNER defaults to the API
|
|
16
21
|
* key's own user (GET /team-members/me); with an organisation key it defaults to the first team member and an
|
|
17
22
|
* owner picker lets the person change it. At least ONE CONTACT is required by the API (`contact_ids`), found with a
|
|
18
23
|
* search box. A company that already has a deal is refused (409) unless "Allow another deal" is ticked.
|
|
19
|
-
* - Forecast tab (ForecastView) and Pipeline settings tab (
|
|
24
|
+
* - Forecast tab (ForecastView) and Pipeline settings tab (create, rename, default and delete pipelines, the close
|
|
25
|
+
* reasons, a stage summary with deal counts and values, plus StageEditor: names, probabilities, order, delete).
|
|
20
26
|
*
|
|
21
27
|
* SDK operations (all through the composed parts): listPipelines, listDeals, getDeal, listDealHistory, createDeal,
|
|
22
|
-
* updateDeal, deleteDeal (confirmed),
|
|
28
|
+
* updateDeal, deleteDeal (confirmed), the notes, tasks, contact, line-item, close-reason and pipeline operations of the parts
|
|
29
|
+
* above, getDealAnalytics, getDealStageVelocity, the pipeline-stage writes,
|
|
23
30
|
* listTeamMembers + getMyTeamMember (the owner default) and the crm block's listContacts + activity feed.
|
|
24
31
|
* Nothing here is billable or external; delete is destructive and goes through ConfirmSpendDialog inside DealDetail.
|
|
25
32
|
*
|
|
@@ -50,10 +57,18 @@ import { usePipelineDeals } from "../hooks/use-pipeline-deals";
|
|
|
50
57
|
import { usePipelines } from "../hooks/use-pipelines";
|
|
51
58
|
import { dataOf, formatDate, formatMoney, stageKind, sumMoney, weightedPipeline, type Deal, type Stage } from "../lib/deals-types";
|
|
52
59
|
import { DealDetail } from "./deal-detail";
|
|
53
|
-
import {
|
|
60
|
+
import { DealActions } from "./close-deal-dialog";
|
|
61
|
+
import { DealContactsEditor } from "./deal-contacts-editor";
|
|
62
|
+
import { DealFilterBar } from "./deal-filters";
|
|
63
|
+
import { DealLineItems } from "./deal-line-items";
|
|
64
|
+
import { DealNotesTab } from "./deal-notes-tab";
|
|
65
|
+
import { DealQuotesTab } from "./deal-related";
|
|
66
|
+
import { DealTasksTab } from "./deal-tasks-tab";
|
|
54
67
|
import { ForecastView } from "./forecast-view";
|
|
55
68
|
import { PipelineBoard } from "./pipeline-board";
|
|
69
|
+
import { PipelineEditor } from "./pipeline-editor";
|
|
56
70
|
import { StageEditor } from "./stage-editor";
|
|
71
|
+
import { NO_FILTERS, filterQuery, filtersActive, type DealFilterState } from "../lib/deal-extras";
|
|
57
72
|
import { useBrand } from "@/g8/hooks/use-brand";
|
|
58
73
|
|
|
59
74
|
export interface DealsScreenProps {
|
|
@@ -69,6 +84,12 @@ export interface DealsScreenProps {
|
|
|
69
84
|
toolbarActions?: ReactNode;
|
|
70
85
|
/** Open a quote of the deal in your quotes screen; without it the Quotes tab is read-only. */
|
|
71
86
|
onOpenQuote?: (quoteId: string) => void;
|
|
87
|
+
/** Buttons above the Quotes tab list, for example the quotes block's `<CreateQuoteFromDeal dealId={deal.id} />`. */
|
|
88
|
+
renderQuoteActions?: (deal: Deal) => ReactNode;
|
|
89
|
+
/** Open a deal's contact in your contacts screen; without it the Contacts tab sheet has no such button. */
|
|
90
|
+
onOpenContact?: (contactId: number) => void;
|
|
91
|
+
/** Open the company of a deal's contact in your companies screen. */
|
|
92
|
+
onOpenCompany?: (companyId: number) => void;
|
|
72
93
|
}
|
|
73
94
|
|
|
74
95
|
type View = "board" | "list" | "forecast" | "stages";
|
|
@@ -201,7 +222,7 @@ function DealRows({ deals, stages, selectedId, onOpen, renderRowActions }: { dea
|
|
|
201
222
|
);
|
|
202
223
|
}
|
|
203
224
|
|
|
204
|
-
export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, onOpenQuote }: DealsScreenProps) {
|
|
225
|
+
export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, onOpenQuote, renderQuoteActions, onOpenContact, onOpenCompany }: DealsScreenProps) {
|
|
205
226
|
const brand = useBrand();
|
|
206
227
|
const [viewParam, setViewParam] = useUrlState("tab", "board");
|
|
207
228
|
const [selectedId, setSelectedId] = useUrlState("id");
|
|
@@ -212,10 +233,16 @@ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, r
|
|
|
212
233
|
const [creating, setCreating] = useState(false);
|
|
213
234
|
const [version, setVersion] = useState(0);
|
|
214
235
|
const view = asView(viewParam);
|
|
236
|
+
const [filterState, setFilterState] = useState<DealFilterState>(NO_FILTERS);
|
|
237
|
+
const filterKey = JSON.stringify(filterState);
|
|
238
|
+
// The stale cutoff is midnight UTC, so the object is stable all day; recompute only when a filter changes.
|
|
239
|
+
const filters = useMemo(() => filterQuery(filterState), [filterKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
240
|
+
const owners = useDefaultOwner();
|
|
215
241
|
const pipelines = usePipelines();
|
|
216
242
|
const pipeline = pipelines.pipelines.find((p) => p.id === pipelineParam) ?? pipelines.defaultPipeline;
|
|
217
|
-
const data = usePipelineDeals(pipeline?.id, { search });
|
|
243
|
+
const data = usePipelineDeals(pipeline?.id, { search, ...filters });
|
|
218
244
|
const stages = pipeline?.stages ?? [];
|
|
245
|
+
const filtered = filtersActive(filterState);
|
|
219
246
|
const refresh = () => { setVersion((v) => v + 1); void data.reload(); };
|
|
220
247
|
|
|
221
248
|
const stats = useMemo(() => {
|
|
@@ -224,12 +251,12 @@ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, r
|
|
|
224
251
|
const cur = data.deals[0]?.currency;
|
|
225
252
|
const weighted = (group: ReadonlyArray<Deal>) => Math.round(weightedPipeline(group, stages).value);
|
|
226
253
|
return [
|
|
227
|
-
{ label: "Open deals", value: open.length.toLocaleString() },
|
|
228
|
-
{ label: "Open value", value: sumMoney(open, undefined, cur) },
|
|
229
|
-
{ label: "Weighted (estimate)", value: sumMoney(data.deals, weighted, cur), hint: "Open deals times stage probability" },
|
|
230
|
-
{ label: "Won", value: sumMoney(won, undefined, cur), tone: "positive" as const },
|
|
254
|
+
{ label: "Open deals", value: open.length.toLocaleString(), ...(filtered ? { hint: "Filters applied" } : {}) },
|
|
255
|
+
{ label: "Open value", value: sumMoney(open, undefined, cur), ...(filtered ? { hint: "Filters applied" } : {}) },
|
|
256
|
+
{ label: "Weighted (estimate)", value: sumMoney(data.deals, weighted, cur), hint: filtered ? "Filters applied" : "Open deals times stage probability" },
|
|
257
|
+
{ label: "Won", value: sumMoney(won, undefined, cur), tone: "positive" as const, ...(filtered ? { hint: "Filters applied" } : {}) },
|
|
231
258
|
];
|
|
232
|
-
}, [data.deals, stages]);
|
|
259
|
+
}, [data.deals, stages, filtered]);
|
|
233
260
|
|
|
234
261
|
const listed = data.deals.find((d) => d.id === selectedId);
|
|
235
262
|
// A deep link can point at a deal in another pipeline or hidden by the search: resolve it directly.
|
|
@@ -244,14 +271,17 @@ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, r
|
|
|
244
271
|
title={selected?.name || "Deal"}
|
|
245
272
|
subtitle={selected ? [selected.stage_name, formatMoney(selected.amount, selected.currency)].join(" · ") : undefined}
|
|
246
273
|
badges={selected ? <StatusPill tone={stageTone(stages.find((s) => s.id === selected.stage_id))} dot>{selected.stage_name ?? "Open"}</StatusPill> : undefined}
|
|
247
|
-
actions={selected
|
|
274
|
+
actions={selected ? <>{pipelines.pipelines.length > 0 ? <DealActions deal={selected} pipelines={pipelines.pipelines} onChanged={refresh} /> : null}{renderDetailActions?.(selected)}</> : undefined}
|
|
248
275
|
onClose={close}
|
|
249
276
|
onExpand={mode === "full" ? undefined : () => setMode("full")}
|
|
250
277
|
onCollapse={mode === "full" ? () => setMode(null) : undefined}
|
|
251
278
|
tabs={[
|
|
252
279
|
{ id: "details", label: "Details", content: <DealDetail key={selectedId} dealId={selectedId} onSaved={refresh} onDeleted={() => { close(); refresh(); }} /> },
|
|
253
|
-
{ id: "contacts", label: "Contacts", content: <
|
|
254
|
-
{ id: "
|
|
280
|
+
{ id: "contacts", label: "Contacts", content: <DealContactsEditor key={selectedId} dealId={selectedId} onChanged={refresh} onOpenContact={onOpenContact} onOpenCompany={onOpenCompany} /> },
|
|
281
|
+
{ id: "line-items", label: "Line items", content: <DealLineItems key={selectedId} dealId={selectedId} onSaved={refresh} /> },
|
|
282
|
+
{ id: "notes", label: "Notes", content: <DealNotesTab key={selectedId} dealId={selectedId} /> },
|
|
283
|
+
{ id: "tasks", label: "Tasks", content: <DealTasksTab key={selectedId} dealId={selectedId} /> },
|
|
284
|
+
{ id: "quotes", label: "Quotes", content: <DealQuotesTab key={selectedId} dealId={selectedId} onOpenQuote={onOpenQuote} actions={selected && renderQuoteActions ? renderQuoteActions(selected) : undefined} refreshKey={version} /> },
|
|
255
285
|
{ id: "activity", label: "Activity", content: <ActivityTimeline entityType="deal" entityId={selectedId} /> },
|
|
256
286
|
...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: selected ? t.render(selected) : null })),
|
|
257
287
|
]}
|
|
@@ -264,8 +294,8 @@ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, r
|
|
|
264
294
|
|
|
265
295
|
let body: ReactNode;
|
|
266
296
|
if (view === "forecast") body = <ForecastView pipelineId={pipeline?.id} />;
|
|
267
|
-
else if (view === "stages") body = pipeline ? <div className="space-y-6"><StageSummary stages={stages} deals={data.deals} /><StageEditor pipelineId={pipeline.id} onChanged={refresh} /></div> : <EmptyState title="No pipeline yet" description={`Create a pipeline in ${brand.linkLabel} first.`} />;
|
|
268
|
-
else body = <SplitView detailOpen={Boolean(selectedId)} onCloseDetail={close} expanded={mode === "full" && Boolean(selectedId)} onExpandedChange={(full) => setMode(full ? "full" : null)} detailWidth="lg" detail={detail} list={view === "board" ? <PipelineBoard key={`${pipeline?.id}-${version}`} pipelineId={pipeline?.id} search={search} onSelectDeal={(d) => open(d.id)} onChanged={() => void data.reload()} /> : list} />;
|
|
297
|
+
else if (view === "stages") body = pipeline ? <div className="space-y-6"><PipelineEditor onChanged={(e) => { if (e.kind === "deleted" && e.pipelineId === pipeline.id) { setPipelineParam(""); close(); } refresh(); }} /><StageSummary stages={stages} deals={data.deals} /><StageEditor pipelineId={pipeline.id} onChanged={refresh} /></div> : <EmptyState title="No pipeline yet" description={`Create a pipeline in ${brand.linkLabel} first.`} />;
|
|
298
|
+
else body = <SplitView detailOpen={Boolean(selectedId)} onCloseDetail={close} expanded={mode === "full" && Boolean(selectedId)} onExpandedChange={(full) => setMode(full ? "full" : null)} detailWidth="lg" detail={detail} list={view === "board" ? <PipelineBoard key={`${pipeline?.id}-${version}`} pipelineId={pipeline?.id} search={search} filters={filters} onSelectDeal={(d) => open(d.id)} onChanged={() => void data.reload()} /> : list} />;
|
|
269
299
|
|
|
270
300
|
return (
|
|
271
301
|
<ScreenLayout
|
|
@@ -280,11 +310,16 @@ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, r
|
|
|
280
310
|
toolbar={showToolbar ? (
|
|
281
311
|
<Toolbar
|
|
282
312
|
search={{ value: searchText, onChange: setSearchText, placeholder: "Search deals" }}
|
|
283
|
-
filters={
|
|
284
|
-
|
|
285
|
-
{pipelines.pipelines.
|
|
286
|
-
|
|
287
|
-
|
|
313
|
+
filters={(
|
|
314
|
+
<>
|
|
315
|
+
{pipelines.pipelines.length > 1 ? (
|
|
316
|
+
<select aria-label="Pipeline" className="h-9 rounded-md border bg-background px-2 text-sm" value={pipeline?.id ?? ""} onChange={(e) => { setPipelineParam(e.target.value); setFilterState((f) => ({ ...f, stageId: "" })); close(); }}>
|
|
317
|
+
{pipelines.pipelines.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
318
|
+
</select>
|
|
319
|
+
) : null}
|
|
320
|
+
<DealFilterBar value={filterState} onChange={(next) => { setFilterState(next); }} stages={stages} owners={owners.owners} />
|
|
321
|
+
</>
|
|
322
|
+
)}
|
|
288
323
|
actions={toolbarActions}
|
|
289
324
|
/>
|
|
290
325
|
) : undefined}
|
|
@@ -42,7 +42,7 @@ interface Velocity {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function Tile({ label, value, hint }: { label: string; value: string; hint?: string }) {
|
|
45
|
-
return <div className="rounded-lg border p-3"><div className="text-xs uppercase text-muted-foreground">{label}</div><div className="text-lg font-
|
|
45
|
+
return <div className="rounded-lg border p-3"><div className="text-xs uppercase text-muted-foreground">{label}</div><div className="text-lg font-medium">{value}</div>{hint ? <div className="text-xs text-muted-foreground">{hint}</div> : null}</div>;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
const MAX_OPEN_DEALS = 2000;
|
|
@@ -84,7 +84,7 @@ export function ForecastView({ pipelineId, stuckAfterDays = 14, className }: For
|
|
|
84
84
|
</div>
|
|
85
85
|
|
|
86
86
|
<section aria-label="Deals by stage" className="space-y-1">
|
|
87
|
-
<h3 className="text-sm font-
|
|
87
|
+
<h3 className="text-sm font-medium">Deals by stage</h3>
|
|
88
88
|
{(analytics.data?.by_stage ?? []).map((s) => (
|
|
89
89
|
<div key={`${s.stage_id}-${s.stage_name}`} className="flex items-center gap-2 text-sm">
|
|
90
90
|
<span className="w-40 truncate">{s.stage_name}</span>
|
|
@@ -95,14 +95,14 @@ export function ForecastView({ pipelineId, stuckAfterDays = 14, className }: For
|
|
|
95
95
|
</section>
|
|
96
96
|
|
|
97
97
|
<section aria-label="Stage velocity" className="space-y-1 text-sm">
|
|
98
|
-
<h3 className="font-
|
|
98
|
+
<h3 className="font-medium">Where deals stall</h3>
|
|
99
99
|
{(velocity.data?.by_stage ?? []).map((s) => <p key={s.stage_id}>{s.stage_name}: {s.avg_days_in_stage != null ? `${s.avg_days_in_stage.toFixed(1)} days average` : "no data"}{s.conversion_rate != null ? `, ${Math.round(s.conversion_rate * 100)}% advance` : ""}, {s.still_in_stage_count} waiting</p>)}
|
|
100
100
|
<p className="text-muted-foreground">{velocity.data?.stuck_deals?.total_count ?? 0} deals have not moved in {velocity.data?.stuck_deals?.threshold_days ?? stuckAfterDays}+ days.</p>
|
|
101
101
|
</section>
|
|
102
102
|
|
|
103
103
|
{(analytics.data?.trend ?? []).length > 0 ? (
|
|
104
104
|
<section aria-label="Won and lost by month" className="space-y-1 text-sm">
|
|
105
|
-
<h3 className="font-
|
|
105
|
+
<h3 className="font-medium">Won and lost by month</h3>
|
|
106
106
|
{(analytics.data?.trend ?? []).slice(-6).map((p) => <p key={p.period}>{p.period}: {p.won_count} won{p.won_value != null && !mixed ? ` (${formatMoney(p.won_value, code)})` : ""}, {p.lost_count} lost</p>)}
|
|
107
107
|
</section>
|
|
108
108
|
) : null}
|