@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,379 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TasksScreen: every task in the workspace in one place, the work queue behind the per-record TaskList.
|
|
5
|
+
* No props are required.
|
|
6
|
+
*
|
|
7
|
+
* Composes: ScreenLayout (with Open / Completed / All tabs), StatStrip, Toolbar, SplitView, DetailPanel, FormSheet
|
|
8
|
+
* (screen kit). It is the only crm screen that lists tasks across records.
|
|
9
|
+
*
|
|
10
|
+
* Flows: switch Open / Completed / All - search by title - filter by priority, type and linked record type - page through
|
|
11
|
+
* results - tick a task to complete or reopen it - open a task (deep link ?id=, full page with &view=full) to read, edit or delete it - create a task
|
|
12
|
+
* (standalone, or linked to a record with `defaultRecord`) - complete or delete many at once ("N of M" report, 404 =
|
|
13
|
+
* already gone) - jump to the linked contact or company through `onOpenRecord`.
|
|
14
|
+
*
|
|
15
|
+
* SDK operations: none of its own. listAllTasks and getTask are `read`; createGlobalTask and updateTask are `write`;
|
|
16
|
+
* deleteTask (detail and bulk) is `destructive` and goes through ConfirmSpendDialog. Priority: 1 high, 2 medium, 3 low, 0 none.
|
|
17
|
+
* Due dates are sent as YYYY-MM-DD and shown in UTC so a date never shifts a day.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useEffect, useState } from "react";
|
|
21
|
+
import type { FormEvent, ReactNode } from "react";
|
|
22
|
+
import { Button, Input, Label, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Textarea } from "@graph8/sdk/ui";
|
|
23
|
+
import { CheckCheck, CheckSquare, Plus, Trash2 } from "lucide-react";
|
|
24
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
25
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
26
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
27
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
28
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
29
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
30
|
+
import { ScreenLayout } from "@/g8/components/screen/screen-layout";
|
|
31
|
+
import { SplitView } from "@/g8/components/screen/split-view";
|
|
32
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
33
|
+
import { Toolbar } from "@/g8/components/screen/toolbar";
|
|
34
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
35
|
+
import { rowActivationProps } from "@/g8/hooks/use-row-activation";
|
|
36
|
+
import { RecordLink } from "@/g8/components/screen/record-link";
|
|
37
|
+
import { plural } from "@/g8/lib/plural";
|
|
38
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
39
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
40
|
+
import { g8Delete, g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
41
|
+
import { useDebouncedValue } from "../hooks/use-debounced";
|
|
42
|
+
import { usePageQuery } from "../hooks/use-page-query";
|
|
43
|
+
import { errorStatus } from "../lib/crm-types";
|
|
44
|
+
import { Pager, formatDay, useBulkDelete, useRowSelection } from "../lib/screen-parts";
|
|
45
|
+
import type { TaskItem } from "./task-list";
|
|
46
|
+
|
|
47
|
+
/** The fields TasksScreen reads from a task; the API may send more. */
|
|
48
|
+
export interface WorkspaceTask extends TaskItem {
|
|
49
|
+
task_type?: string | null;
|
|
50
|
+
entity_type?: string | null;
|
|
51
|
+
entity_id?: string | null;
|
|
52
|
+
entity_label?: string | null;
|
|
53
|
+
created_at?: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface TasksScreenProps {
|
|
57
|
+
pageSize?: number;
|
|
58
|
+
/** Which tab opens first. Default "open". */
|
|
59
|
+
initialStatus?: "open" | "completed" | "all";
|
|
60
|
+
/** New tasks are linked to this record (for example when the screen sits on a contact page). */
|
|
61
|
+
defaultRecord?: { entity_type: "contact" | "company" | "deal"; entity_id: string };
|
|
62
|
+
/** Called to open the linked record of a task ("Open contact"). Without it the link is plain text. */
|
|
63
|
+
onOpenRecord?: (record: { entity_type: string; entity_id: string }) => void;
|
|
64
|
+
renderRowActions?: (task: WorkspaceTask) => ReactNode;
|
|
65
|
+
renderDetailActions?: (task: WorkspaceTask) => ReactNode;
|
|
66
|
+
toolbarActions?: ReactNode;
|
|
67
|
+
className?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const PRIORITY: Record<number, string> = { 1: "High", 2: "Medium", 3: "Low", 4: "Low" };
|
|
71
|
+
const TASK_TYPES = ["call", "email", "meeting", "todo"];
|
|
72
|
+
const ENTITY_TYPES = ["contact", "company", "deal"];
|
|
73
|
+
type Tab = "open" | "completed" | "all";
|
|
74
|
+
|
|
75
|
+
function ymd(date = new Date()): string {
|
|
76
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
77
|
+
}
|
|
78
|
+
const isOverdue = (t: WorkspaceTask): boolean => t.status !== "completed" && Boolean(t.due_date) && t.due_date!.slice(0, 10) < ymd();
|
|
79
|
+
/** `pagination.total` of a list answer (the typed envelope only declares the cursor fields). */
|
|
80
|
+
const totalOf = (res?: { pagination?: unknown }): number | undefined => (res?.pagination as { total?: number } | undefined)?.total;
|
|
81
|
+
const label = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
|
|
82
|
+
|
|
83
|
+
function PriorityPill({ priority }: { priority?: number | null }) {
|
|
84
|
+
if (!priority) return <span className="text-muted-foreground">-</span>;
|
|
85
|
+
return <StatusPill tone={priority === 1 ? "negative" : priority === 2 ? "warning" : "neutral"}>{PRIORITY[priority] ?? "Low"}</StatusPill>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function TasksScreen(props: TasksScreenProps) {
|
|
89
|
+
const { pageSize = 25 } = props;
|
|
90
|
+
const [idParam, setIdParamRaw] = useUrlState("id");
|
|
91
|
+
const [view, setView] = useUrlState("view");
|
|
92
|
+
const full = view === "full" && idParam != null && idParam !== "";
|
|
93
|
+
/** Changing or clearing the open record also leaves the full-page view. */
|
|
94
|
+
const setIdParam = (v: string | null): void => { if (v == null) setView(null); setIdParamRaw(v); };
|
|
95
|
+
const openId = idParam || null;
|
|
96
|
+
const [tab, setTab] = useState<Tab>(props.initialStatus ?? "open");
|
|
97
|
+
const [search, setSearch] = useState("");
|
|
98
|
+
const [priority, setPriority] = useState("");
|
|
99
|
+
const [taskType, setTaskType] = useState("");
|
|
100
|
+
const [entityType, setEntityType] = useState(props.defaultRecord?.entity_type ?? "");
|
|
101
|
+
const q = useDebouncedValue(search);
|
|
102
|
+
const [sheet, setSheet] = useState<"new" | "edit" | null>(null);
|
|
103
|
+
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
104
|
+
const [note, setNote] = useState<string | null>(null);
|
|
105
|
+
|
|
106
|
+
const scope = { status: tab === "all" ? undefined : tab, search: q || undefined, priority: priority || undefined, task_type: taskType || undefined, entity_type: entityType || undefined, entity_id: props.defaultRecord?.entity_id };
|
|
107
|
+
const list = usePageQuery<WorkspaceTask>(
|
|
108
|
+
(page, signal) => g8Get<WorkspaceTask[]>("/tasks", { limit: pageSize, offset: (page - 1) * pageSize, ...scope }, { signal }),
|
|
109
|
+
[scope.status, scope.search, scope.priority, scope.task_type, scope.entity_type, scope.entity_id],
|
|
110
|
+
pageSize,
|
|
111
|
+
["tasks", "list", "screen"],
|
|
112
|
+
);
|
|
113
|
+
const recordScope = { entity_type: props.defaultRecord?.entity_type, entity_id: props.defaultRecord?.entity_id };
|
|
114
|
+
const openCount = useG8Query((signal) => g8Get<WorkspaceTask[]>("/tasks", { status: "open", limit: 1, ...recordScope }, { signal }), [recordScope.entity_type, recordScope.entity_id], { key: ["tasks", "count", "open", recordScope] });
|
|
115
|
+
const doneCount = useG8Query((signal) => g8Get<WorkspaceTask[]>("/tasks", { status: "completed", limit: 1, ...recordScope }, { signal }), [recordScope.entity_type, recordScope.entity_id], { key: ["tasks", "count", "completed", recordScope] });
|
|
116
|
+
const selection = useRowSelection<WorkspaceTask>(JSON.stringify(scope));
|
|
117
|
+
const reload = () => { void list.refetch(); void openCount.refetch(); void doneCount.refetch(); };
|
|
118
|
+
const hasFilters = Boolean(search || priority || taskType || entityType);
|
|
119
|
+
const overdue = list.rows.filter(isOverdue).length;
|
|
120
|
+
|
|
121
|
+
const toggle = useG8Mutation((t: WorkspaceTask) => g8Patch(`/tasks/${t.id}`, { status: t.status === "completed" ? "open" : "completed" }), { invalidates: ["tasks"], onSuccess: reload });
|
|
122
|
+
const completeMany = useG8Mutation(async (ids: string[]) => {
|
|
123
|
+
const results = await Promise.allSettled(ids.map((id) => g8Patch(`/tasks/${id}`, { status: "completed" })));
|
|
124
|
+
return { done: ids.filter((_, i) => results[i].status === "fulfilled"), total: ids.length };
|
|
125
|
+
}, { invalidates: ["tasks"], onSuccess: ({ done, total }) => { selection.drop(done); setNote(done.length < total ? `Completed ${done.length} of ${total}. ${total - done.length} could not be updated; they are still selected.` : null); reload(); } });
|
|
126
|
+
const bulk = useBulkDelete<string>("/tasks", (gone, total) => {
|
|
127
|
+
selection.drop(gone);
|
|
128
|
+
setConfirmDelete(false);
|
|
129
|
+
setNote(gone.length < total ? `Deleted ${gone.length} of ${total}. ${total - gone.length} could not be deleted; they are still selected.` : null);
|
|
130
|
+
if (openId && gone.includes(openId)) setIdParam(null);
|
|
131
|
+
reload();
|
|
132
|
+
});
|
|
133
|
+
useEffect(() => { setNote(null); }, [tab, q, priority, taskType, entityType]);
|
|
134
|
+
const allOnPage = list.rows.length > 0 && list.rows.every((r) => selection.selected.has(r.id));
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<ScreenLayout
|
|
138
|
+
className={props.className}
|
|
139
|
+
title="Tasks"
|
|
140
|
+
description="Follow-ups across every contact and company, so nothing slips."
|
|
141
|
+
icon={<CheckSquare className="size-5" aria-hidden />}
|
|
142
|
+
actions={<>{props.toolbarActions}<Button size="sm" onClick={() => setSheet("new")}><Plus className="size-4" aria-hidden />New task</Button></>}
|
|
143
|
+
tabs={[
|
|
144
|
+
{ id: "open", label: "Open", count: totalOf(openCount.data) },
|
|
145
|
+
{ id: "completed", label: "Completed", count: totalOf(doneCount.data) },
|
|
146
|
+
{ id: "all", label: "All" },
|
|
147
|
+
]}
|
|
148
|
+
activeTab={tab}
|
|
149
|
+
onTabChange={(id) => setTab(id as Tab)}
|
|
150
|
+
stats={[
|
|
151
|
+
{ label: "Open", value: totalOf(openCount.data) ?? "-" },
|
|
152
|
+
{ label: "Completed", value: totalOf(doneCount.data) ?? "-" },
|
|
153
|
+
{ label: "Overdue on this page", value: overdue, tone: overdue ? "negative" : "neutral", hint: "Open tasks past their due date" },
|
|
154
|
+
]}
|
|
155
|
+
>
|
|
156
|
+
<SplitView
|
|
157
|
+
detailOpen={openId != null}
|
|
158
|
+
expanded={full}
|
|
159
|
+
onExpandedChange={(e) => setView(e ? "full" : null)}
|
|
160
|
+
onCloseDetail={() => setIdParam(null)}
|
|
161
|
+
detailWidth="md"
|
|
162
|
+
list={
|
|
163
|
+
<div className="flex flex-col gap-3">
|
|
164
|
+
<Toolbar
|
|
165
|
+
search={{ value: search, onChange: setSearch, placeholder: "Search tasks by title" }}
|
|
166
|
+
filters={
|
|
167
|
+
<>
|
|
168
|
+
<select aria-label="Filter by priority" className="h-9 rounded-md border bg-background px-2 text-sm" value={priority} onChange={(e) => setPriority(e.target.value)}>
|
|
169
|
+
<option value="">Any priority</option><option value="1">High</option><option value="2">Medium</option><option value="3">Low</option>
|
|
170
|
+
</select>
|
|
171
|
+
<select aria-label="Filter by type" className="h-9 rounded-md border bg-background px-2 text-sm" value={taskType} onChange={(e) => setTaskType(e.target.value)}>
|
|
172
|
+
<option value="">Any type</option>{TASK_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
|
|
173
|
+
</select>
|
|
174
|
+
{props.defaultRecord ? null : (
|
|
175
|
+
<select aria-label="Filter by linked record" className="h-9 rounded-md border bg-background px-2 text-sm" value={entityType} onChange={(e) => setEntityType(e.target.value)}>
|
|
176
|
+
<option value="">Any record</option>{ENTITY_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
|
|
177
|
+
</select>
|
|
178
|
+
)}
|
|
179
|
+
{hasFilters ? <Button variant="ghost" size="sm" onClick={() => { setSearch(""); setPriority(""); setTaskType(""); setEntityType(props.defaultRecord?.entity_type ?? ""); }}>Clear filters</Button> : null}
|
|
180
|
+
</>
|
|
181
|
+
}
|
|
182
|
+
/>
|
|
183
|
+
{selection.ids.length > 0 ? (
|
|
184
|
+
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm" role="status">
|
|
185
|
+
<span className="tabular-nums">{selection.ids.length} selected</span>
|
|
186
|
+
<Button variant="outline" size="sm" loading={completeMany.loading} onClick={() => void completeMany.mutate(selection.ids)}><CheckCheck className="size-4" aria-hidden />Complete {selection.ids.length}</Button>
|
|
187
|
+
<Button variant="destructive" size="sm" onClick={() => setConfirmDelete(true)}><Trash2 className="size-4" aria-hidden />Delete {selection.ids.length}</Button>
|
|
188
|
+
<Button variant="ghost" size="sm" onClick={selection.clear}>Clear selection</Button>
|
|
189
|
+
</div>
|
|
190
|
+
) : null}
|
|
191
|
+
{list.error ? <ApiError error={list.error} title="Could not load tasks" onRetry={() => void list.refetch()} /> : null}
|
|
192
|
+
{toggle.error ? <ApiError error={toggle.error} title="Could not update the task" /> : null}
|
|
193
|
+
{note ? <p role="alert" className="text-sm text-destructive">{note}</p> : null}
|
|
194
|
+
|
|
195
|
+
<div className="overflow-hidden rounded-md border">
|
|
196
|
+
<Table>
|
|
197
|
+
<TableHeader>
|
|
198
|
+
<TableRow>
|
|
199
|
+
<TableHead className="w-8"><input type="checkbox" aria-label="Select page" checked={allOnPage} onChange={() => selection.togglePage(list.rows)} /></TableHead>
|
|
200
|
+
<TableHead className="w-8"><span className="sr-only">Done</span></TableHead>
|
|
201
|
+
<TableHead>Task</TableHead>
|
|
202
|
+
<TableHead>Linked to</TableHead>
|
|
203
|
+
<TableHead>Due</TableHead>
|
|
204
|
+
<TableHead>Priority</TableHead>
|
|
205
|
+
<TableHead>Assignee</TableHead>
|
|
206
|
+
{props.renderRowActions ? <TableHead className="w-8"><span className="sr-only">Actions</span></TableHead> : null}
|
|
207
|
+
</TableRow>
|
|
208
|
+
</TableHeader>
|
|
209
|
+
<TableBody>
|
|
210
|
+
{list.loading && list.rows.length === 0
|
|
211
|
+
? Array.from({ length: 6 }, (_, i) => <TableRow key={`sk${i}`}><TableCell colSpan={7 + (props.renderRowActions ? 1 : 0)}><Skeleton className="h-6 w-full" /></TableCell></TableRow>)
|
|
212
|
+
: list.rows.map((t) => {
|
|
213
|
+
const done = t.status === "completed";
|
|
214
|
+
return (
|
|
215
|
+
<TableRow key={t.id} data-testid={`task-row-${t.id}`} data-state={t.id === openId ? "selected" : undefined} className="cursor-pointer" {...rowActivationProps(() => setIdParam(t.id))}>
|
|
216
|
+
<TableCell onClick={(e) => e.stopPropagation()}><input type="checkbox" aria-label={`Select ${t.title}`} checked={selection.selected.has(t.id)} onChange={() => selection.toggle(t)} /></TableCell>
|
|
217
|
+
<TableCell onClick={(e) => e.stopPropagation()}><input type="checkbox" aria-label={`${done ? "Reopen" : "Complete"} ${t.title}`} checked={done} onChange={() => void toggle.mutate(t)} /></TableCell>
|
|
218
|
+
<TableCell className={done ? "text-muted-foreground line-through" : "font-medium"}><RecordLink param="id" id={t.id} onOpen={() => setIdParam(t.id)}>{t.title}</RecordLink></TableCell>
|
|
219
|
+
<TableCell>{t.entity_label ? <span>{t.entity_label}{t.entity_type ? <span className="ml-1 text-xs text-muted-foreground">{label(t.entity_type)}</span> : null}</span> : <span className="text-muted-foreground">-</span>}</TableCell>
|
|
220
|
+
<TableCell className="whitespace-nowrap">{t.due_date ? <span className={isOverdue(t) ? "text-destructive" : undefined}>{formatDay(t.due_date)}{isOverdue(t) ? " (overdue)" : ""}</span> : <span className="text-muted-foreground">-</span>}</TableCell>
|
|
221
|
+
<TableCell><PriorityPill priority={t.priority} /></TableCell>
|
|
222
|
+
<TableCell>{t.assignee_name || <span className="text-muted-foreground">-</span>}</TableCell>
|
|
223
|
+
{props.renderRowActions ? <TableCell onClick={(e) => e.stopPropagation()}>{props.renderRowActions(t)}</TableCell> : null}
|
|
224
|
+
</TableRow>
|
|
225
|
+
);
|
|
226
|
+
})}
|
|
227
|
+
</TableBody>
|
|
228
|
+
</Table>
|
|
229
|
+
{!list.loading && !list.error && list.rows.length === 0 ? (
|
|
230
|
+
<EmptyState
|
|
231
|
+
title={hasFilters ? "No tasks match these filters" : tab === "completed" ? "Nothing completed yet" : "No tasks yet"}
|
|
232
|
+
description={hasFilters ? "Try fewer filters." : "Add a follow-up so nothing slips."}
|
|
233
|
+
action={hasFilters ? undefined : <Button onClick={() => setSheet("new")}>New task</Button>}
|
|
234
|
+
/>
|
|
235
|
+
) : null}
|
|
236
|
+
<Pager page={list.page} pageCount={list.pageCount} total={list.total} noun="tasks" hasNext={list.hasNext} loading={list.loading} onPage={list.setPage} />
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
}
|
|
240
|
+
detail={openId ? (
|
|
241
|
+
<TaskPanel key={openId} full={full} setFull={(v) => setView(v ? "full" : null)} taskId={openId} onClose={() => setIdParam(null)} onChanged={reload} onDeleted={() => { setIdParam(null); reload(); }}
|
|
242
|
+
onEdit={() => setSheet("edit")} sheetEditing={sheet === "edit"} onCloseEdit={() => setSheet(null)}
|
|
243
|
+
onOpenRecord={props.onOpenRecord} renderActions={props.renderDetailActions} />
|
|
244
|
+
) : null}
|
|
245
|
+
/>
|
|
246
|
+
|
|
247
|
+
<FormSheet open={sheet === "new"} onOpenChange={(o) => setSheet(o ? "new" : null)} title="New task" description={props.defaultRecord ? `Linked to this ${props.defaultRecord.entity_type}.` : "A standalone follow-up."}>
|
|
248
|
+
<TaskForm record={props.defaultRecord} onCancel={() => setSheet(null)} onSaved={(t) => { setSheet(null); reload(); if (t?.id) setIdParam(t.id); }} />
|
|
249
|
+
</FormSheet>
|
|
250
|
+
|
|
251
|
+
<ConfirmSpendDialog open={confirmDelete} tier="destructive" title={`Delete ${plural(selection.ids.length, "task")}`} description="They are removed for everyone." affected={selection.ids.length}
|
|
252
|
+
confirming={bulk.loading} onConfirm={() => void bulk.mutate(selection.ids)} onCancel={() => setConfirmDelete(false)} />
|
|
253
|
+
</ScreenLayout>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
interface TaskPanelProps {
|
|
258
|
+
full: boolean;
|
|
259
|
+
setFull: (full: boolean) => void;
|
|
260
|
+
taskId: string;
|
|
261
|
+
onClose: () => void;
|
|
262
|
+
onChanged: () => void;
|
|
263
|
+
onDeleted: () => void;
|
|
264
|
+
onEdit: () => void;
|
|
265
|
+
sheetEditing: boolean;
|
|
266
|
+
onCloseEdit: () => void;
|
|
267
|
+
onOpenRecord?: TasksScreenProps["onOpenRecord"];
|
|
268
|
+
renderActions?: TasksScreenProps["renderDetailActions"];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function TaskPanel({ full, setFull, taskId, onClose, onChanged, onDeleted, onEdit, sheetEditing, onCloseEdit, onOpenRecord, renderActions }: TaskPanelProps) {
|
|
272
|
+
const [confirm, setConfirm] = useState(false);
|
|
273
|
+
const query = useG8Query((signal) => g8Get<WorkspaceTask>(`/tasks/${taskId}`, {}, { signal }), [taskId], { key: ["tasks", "detail", taskId] });
|
|
274
|
+
const toggle = useG8Mutation((t: WorkspaceTask) => g8Patch(`/tasks/${t.id}`, { status: t.status === "completed" ? "open" : "completed" }), { invalidates: ["tasks"], onSuccess: () => { void query.refetch(); onChanged(); } });
|
|
275
|
+
const remove = useG8Mutation(async () => {
|
|
276
|
+
try { await g8Delete(`/tasks/${taskId}`); } catch (e) { if (errorStatus(e) !== 404) throw e; }
|
|
277
|
+
}, { invalidates: [["tasks", "list"], ["tasks", "count"], ["tasks", "record"]], onSuccess: () => { setConfirm(false); onDeleted(); } });
|
|
278
|
+
|
|
279
|
+
if (query.loading && !query.data) return <div className="flex flex-col gap-3 p-4"><Skeleton className="h-14 w-full" /><Skeleton className="h-32 w-full" /></div>;
|
|
280
|
+
if (query.error && !query.data) return <div className="p-4"><ApiError error={query.error} title="Could not load this task" onRetry={() => void query.refetch()} /></div>;
|
|
281
|
+
const task = query.data!.data;
|
|
282
|
+
const done = task.status === "completed";
|
|
283
|
+
const dash = <span className="text-muted-foreground">-</span>;
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<>
|
|
287
|
+
<DetailPanel
|
|
288
|
+
title={task.title}
|
|
289
|
+
subtitle={task.entity_label ? `${task.entity_type ? label(task.entity_type) + ": " : ""}${task.entity_label}` : "Standalone task"}
|
|
290
|
+
badges={<StatusPill tone={done ? "positive" : isOverdue(task) ? "negative" : "info"} dot>{done ? "Completed" : isOverdue(task) ? "Overdue" : "Open"}</StatusPill>}
|
|
291
|
+
onClose={onClose}
|
|
292
|
+
onExpand={full ? undefined : () => setFull(true)}
|
|
293
|
+
onCollapse={full ? () => setFull(false) : undefined}
|
|
294
|
+
actions={
|
|
295
|
+
<>
|
|
296
|
+
{renderActions?.(task)}
|
|
297
|
+
{task.entity_type && task.entity_id && onOpenRecord ? <Button variant="outline" size="sm" onClick={() => onOpenRecord({ entity_type: task.entity_type!, entity_id: task.entity_id! })}>Open {task.entity_type}</Button> : null}
|
|
298
|
+
<Button variant="outline" size="sm" loading={toggle.loading} onClick={() => void toggle.mutate(task)}>{done ? "Reopen" : "Mark complete"}</Button>
|
|
299
|
+
<Button variant="outline" size="sm" onClick={onEdit}>Edit</Button>
|
|
300
|
+
<Button variant="destructive" size="sm" onClick={() => setConfirm(true)}>Delete</Button>
|
|
301
|
+
</>
|
|
302
|
+
}
|
|
303
|
+
>
|
|
304
|
+
<div className="flex flex-col gap-4 p-4">
|
|
305
|
+
{toggle.error ? <ApiError error={toggle.error} title="Could not update the task" /> : null}
|
|
306
|
+
{remove.error ? <ApiError error={remove.error} title="Could not delete the task" /> : null}
|
|
307
|
+
<KeyValueList columns={2} items={[
|
|
308
|
+
{ label: "Due", value: task.due_date ? formatDay(task.due_date) : dash },
|
|
309
|
+
{ label: "Priority", value: <PriorityPill priority={task.priority} /> },
|
|
310
|
+
{ label: "Type", value: task.task_type ? label(task.task_type) : dash },
|
|
311
|
+
{ label: "Assignee", value: task.assignee_name || dash },
|
|
312
|
+
{ label: "Created", value: task.created_at ? formatDay(task.created_at) : dash },
|
|
313
|
+
...(task.description ? [{ label: "Description", value: task.description }] : []),
|
|
314
|
+
]} />
|
|
315
|
+
</div>
|
|
316
|
+
</DetailPanel>
|
|
317
|
+
<FormSheet open={sheetEditing} onOpenChange={(o) => { if (!o) onCloseEdit(); }} title="Edit task">
|
|
318
|
+
<TaskForm task={task} onCancel={onCloseEdit} onSaved={() => { onCloseEdit(); void query.refetch(); onChanged(); }} />
|
|
319
|
+
</FormSheet>
|
|
320
|
+
<ConfirmSpendDialog open={confirm} tier="destructive" title="Delete this task" description={task.title} affected={1}
|
|
321
|
+
confirming={remove.loading} onConfirm={() => void remove.mutate(undefined)} onCancel={() => setConfirm(false)} />
|
|
322
|
+
</>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
interface TaskFormProps {
|
|
327
|
+
task?: WorkspaceTask;
|
|
328
|
+
record?: TasksScreenProps["defaultRecord"];
|
|
329
|
+
onSaved: (task?: WorkspaceTask) => void;
|
|
330
|
+
onCancel: () => void;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function TaskForm({ task, record, onSaved, onCancel }: TaskFormProps) {
|
|
334
|
+
const [title, setTitle] = useState(task?.title ?? "");
|
|
335
|
+
const [description, setDescription] = useState(task?.description ?? "");
|
|
336
|
+
const [due, setDue] = useState(task?.due_date ? task.due_date.slice(0, 10) : "");
|
|
337
|
+
const [priority, setPriority] = useState(String(task ? task.priority ?? 0 : 2));
|
|
338
|
+
const [taskType, setTaskType] = useState(task?.task_type ?? "");
|
|
339
|
+
const [error, setError] = useState<string | null>(null);
|
|
340
|
+
const save = useG8Mutation(async () => {
|
|
341
|
+
const body = { title: title.trim(), description: description.trim() || null, due_date: due || null, priority: Number(priority), task_type: taskType || null };
|
|
342
|
+
if (task) {
|
|
343
|
+
// An untouched priority is not sent, so editing a task without one never invents a priority.
|
|
344
|
+
const { priority: _skip, ...rest } = body;
|
|
345
|
+
const patch = Number(priority) === (task.priority ?? 0) ? rest : body;
|
|
346
|
+
return (await g8Patch<WorkspaceTask>(`/tasks/${task.id}`, patch)).data;
|
|
347
|
+
}
|
|
348
|
+
return (await g8Post<WorkspaceTask>("/tasks", { ...body, ...(record ? { entity_type: record.entity_type, entity_id: record.entity_id } : {}) })).data;
|
|
349
|
+
}, { invalidates: ["tasks"], onSuccess: (t) => onSaved(t) });
|
|
350
|
+
const submit = (e: FormEvent) => {
|
|
351
|
+
e.preventDefault();
|
|
352
|
+
if (!title.trim()) return setError("Give the task a title.");
|
|
353
|
+
setError(null);
|
|
354
|
+
void save.mutate(undefined);
|
|
355
|
+
};
|
|
356
|
+
return (
|
|
357
|
+
<form noValidate onSubmit={submit} className="flex flex-col gap-3" aria-label={task ? "Edit task" : "New task"}>
|
|
358
|
+
<div className="flex flex-col gap-1"><Label htmlFor="task-title">Title</Label><Input id="task-title" value={title} onChange={(e) => setTitle(e.target.value)} /></div>
|
|
359
|
+
<div className="flex flex-col gap-1"><Label htmlFor="task-desc">Description</Label><Textarea id="task-desc" rows={3} value={description} onChange={(e) => setDescription(e.target.value)} /></div>
|
|
360
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
|
361
|
+
<div className="flex flex-col gap-1"><Label htmlFor="task-due">Due date</Label><Input id="task-due" type="date" value={due} onChange={(e) => setDue(e.target.value)} /></div>
|
|
362
|
+
<div className="flex flex-col gap-1"><Label htmlFor="task-priority">Priority</Label>
|
|
363
|
+
<select id="task-priority" className="h-9 rounded-md border bg-background px-2 text-sm" value={priority} onChange={(e) => setPriority(e.target.value)}>
|
|
364
|
+
<option value="1">High</option><option value="2">Medium</option><option value="3">Low</option><option value="0">None</option>
|
|
365
|
+
</select></div>
|
|
366
|
+
<div className="flex flex-col gap-1"><Label htmlFor="task-type">Type</Label>
|
|
367
|
+
<select id="task-type" className="h-9 rounded-md border bg-background px-2 text-sm" value={taskType} onChange={(e) => setTaskType(e.target.value)}>
|
|
368
|
+
<option value="">None</option>{TASK_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
|
|
369
|
+
</select></div>
|
|
370
|
+
</div>
|
|
371
|
+
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
|
372
|
+
{save.error ? <ApiError error={save.error} title="Could not save the task" /> : null}
|
|
373
|
+
<div className="flex justify-end gap-2">
|
|
374
|
+
<Button type="button" variant="ghost" onClick={onCancel}>Cancel</Button>
|
|
375
|
+
<Button type="submit" loading={save.loading}>{task ? "Save changes" : "Create task"}</Button>
|
|
376
|
+
</div>
|
|
377
|
+
</form>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
@@ -12,7 +12,12 @@ import type { CrmEntity, FieldDef } from "../lib/crm-types";
|
|
|
12
12
|
*/
|
|
13
13
|
export function useCustomFields(entity: CrmEntity, listId?: number, enabled = true) {
|
|
14
14
|
const path = entity === "contacts" ? "/fields" : "/fields/companies";
|
|
15
|
-
const query = useG8Query((signal) => g8Get<FieldDef[]>(path, { list_id: listId }, { signal }), [path, listId], {
|
|
15
|
+
const query = useG8Query((signal) => g8Get<FieldDef[]>(path, { list_id: listId }, { signal }), [path, listId], {
|
|
16
|
+
enabled,
|
|
17
|
+
// The schema rarely changes: share it between the table, the detail and the form, fresh for 30 s.
|
|
18
|
+
key: ["fields", entity, { listId }],
|
|
19
|
+
staleTime: 30_000,
|
|
20
|
+
});
|
|
16
21
|
const all = (query.data?.data ?? []) as FieldDef[];
|
|
17
22
|
return { fields: all.filter(isCustomField), all, loading: query.loading, error: query.error, refetch: query.refetch };
|
|
18
23
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
5
5
|
import type { G8Envelope } from "@/g8/lib/g8-client";
|
|
6
|
+
import type { G8QueryKey } from "@/g8/lib/g8-cache";
|
|
6
7
|
import type { PageInfo } from "../lib/crm-types";
|
|
7
8
|
|
|
8
9
|
export interface UsePageQueryResult<T> {
|
|
@@ -24,16 +25,21 @@ export interface UsePageQueryResult<T> {
|
|
|
24
25
|
* have one, so the table keeps a page number instead.
|
|
25
26
|
*
|
|
26
27
|
* Changing `deps` (a filter, a search box) goes back to page 1 without a second request.
|
|
28
|
+
*
|
|
29
|
+
* Optional `cacheKey` joins the shared query cache (each page cached as `[...cacheKey, { page, deps }]`),
|
|
30
|
+
* so the list shows instantly when you come back and refreshes when a write invalidates its prefix.
|
|
27
31
|
*/
|
|
28
32
|
export function usePageQuery<T>(
|
|
29
33
|
fetchPage: (page: number, signal: AbortSignal) => Promise<G8Envelope<T[]>>,
|
|
30
34
|
deps: readonly unknown[],
|
|
31
35
|
pageSize = 25,
|
|
36
|
+
cacheKey?: readonly unknown[],
|
|
32
37
|
): UsePageQueryResult<T> {
|
|
33
38
|
const key = JSON.stringify(deps);
|
|
34
39
|
const [state, setState] = useState({ key, page: 1 });
|
|
35
40
|
const page = state.key === key ? state.page : 1;
|
|
36
|
-
const
|
|
41
|
+
const queryKey: G8QueryKey | undefined = cacheKey ? [...cacheKey, { page, pageSize, deps: key }] : undefined;
|
|
42
|
+
const query = useG8Query((signal) => fetchPage(page, signal), [key, page], { key: queryKey });
|
|
37
43
|
|
|
38
44
|
const info = query.data?.pagination as Partial<PageInfo> | undefined;
|
|
39
45
|
const rows = (query.data?.data ?? []) as T[];
|
|
@@ -102,9 +102,14 @@ export function isMasked(value: unknown): boolean {
|
|
|
102
102
|
return typeof value === "string" && value.trim() === "***";
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
export function contactName(c: Pick<ContactRecord, "first_name" | "last_name" | "full_name" | "work_email">): string {
|
|
105
|
+
export function contactName(c: Pick<ContactRecord, "first_name" | "last_name" | "full_name" | "work_email"> & { id?: number | string }): string {
|
|
106
106
|
const joined = [c.first_name, c.last_name].filter(Boolean).join(" ").trim();
|
|
107
|
-
return joined || c.full_name || (c.work_email && !isMasked(c.work_email) ? c.work_email : "") ||
|
|
107
|
+
return joined || c.full_name || (c.work_email && !isMasked(c.work_email) ? c.work_email : "") || (c.id != null ? `#${c.id}` : "-");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A company's display name: name, then domain, then `#id`, then a dash. Never "Unnamed". */
|
|
111
|
+
export function companyName(c: { name?: string | null; domain?: string | null; id?: number | string }): string {
|
|
112
|
+
return c.name || c.domain || (c.id != null ? `#${c.id}` : "-");
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
export function initials(name: string): string {
|
|
@@ -134,3 +139,8 @@ export function errorDetail(error: unknown): Record<string, unknown> | undefined
|
|
|
134
139
|
const detail = (error as { detail?: unknown } | null)?.detail;
|
|
135
140
|
return detail && typeof detail === "object" && !Array.isArray(detail) ? (detail as Record<string, unknown>) : undefined;
|
|
136
141
|
}
|
|
142
|
+
|
|
143
|
+
/** Passed as the second argument to a screen's `renderRowActions` / `renderDetailActions`: reload what the screen shows (filters, page, sort and selection are kept). */
|
|
144
|
+
export interface ScreenActionContext {
|
|
145
|
+
reload: () => void;
|
|
146
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Small pieces shared by ContactsScreen, CompaniesScreen and TasksScreen: client-side sorting of the loaded
|
|
5
|
+
* page, a sortable table header, row selection that resets with the data source, the retry-safe bulk delete
|
|
6
|
+
* (Promise.allSettled, 404 = already gone, always refetch) and a pager. No operations of its own except the
|
|
7
|
+
* DELETE the caller names.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
11
|
+
import { Button, TableHead } from "@graph8/sdk/ui";
|
|
12
|
+
import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react";
|
|
13
|
+
import { g8Delete } from "@/g8/lib/g8-client";
|
|
14
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
15
|
+
import { errorStatus, isMasked } from "./crm-types";
|
|
16
|
+
|
|
17
|
+
export type SortDir = "asc" | "desc";
|
|
18
|
+
export interface SortState { key: string; dir: SortDir }
|
|
19
|
+
|
|
20
|
+
/** Sort rows by a text accessor. Empty and locked values always go last. Sorts only what is loaded (the API has no sort). */
|
|
21
|
+
export function sortRows<T>(rows: T[], sort: SortState | null, get: (row: T, key: string) => string | null | undefined): T[] {
|
|
22
|
+
if (!sort) return rows;
|
|
23
|
+
const sign = sort.dir === "asc" ? 1 : -1;
|
|
24
|
+
const norm = (r: T) => { const v = get(r, sort.key); return v == null || v === "" || isMasked(v) ? null : v.toLowerCase(); };
|
|
25
|
+
return [...rows].sort((a, b) => {
|
|
26
|
+
const x = norm(a), y = norm(b);
|
|
27
|
+
if (x === y) return 0;
|
|
28
|
+
if (x == null) return 1;
|
|
29
|
+
if (y == null) return -1;
|
|
30
|
+
return x < y ? -sign : sign;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useSort(initial: SortState | null = null) {
|
|
35
|
+
const [sort, setSort] = useState<SortState | null>(initial);
|
|
36
|
+
const toggle = useCallback((key: string) => setSort((s) => (s?.key !== key ? { key, dir: "asc" } : s.dir === "asc" ? { key, dir: "desc" } : null)), []);
|
|
37
|
+
return { sort, toggle };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function SortHead({ label, sortKey, sort, onSort, className }: { label: string; sortKey: string; sort: SortState | null; onSort: (key: string) => void; className?: string }) {
|
|
41
|
+
const active = sort?.key === sortKey;
|
|
42
|
+
const Icon = !active ? ChevronsUpDown : sort.dir === "asc" ? ArrowUp : ArrowDown;
|
|
43
|
+
return (
|
|
44
|
+
<TableHead className={className} aria-sort={active ? (sort.dir === "asc" ? "ascending" : "descending") : "none"}>
|
|
45
|
+
<button type="button" className="inline-flex items-center gap-1 font-medium hover:text-foreground" onClick={() => onSort(sortKey)}>
|
|
46
|
+
{label}<Icon className={`size-3.5 ${active ? "text-foreground" : "text-muted-foreground"}`} aria-hidden />
|
|
47
|
+
</button>
|
|
48
|
+
</TableHead>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Selected rows keyed by id. Clears itself when `scope` changes (another filter, list or reload). */
|
|
53
|
+
export function useRowSelection<T extends { id: number | string }>(scope: string) {
|
|
54
|
+
const [selected, setSelected] = useState<Map<T["id"], T>>(new Map());
|
|
55
|
+
const last = useRef(scope);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (last.current === scope) return;
|
|
58
|
+
last.current = scope;
|
|
59
|
+
setSelected(new Map());
|
|
60
|
+
}, [scope]);
|
|
61
|
+
const toggle = (row: T) => setSelected((m) => { const n = new Map(m); if (!n.delete(row.id)) n.set(row.id, row); return n; });
|
|
62
|
+
const togglePage = (rows: T[]) => setSelected((m) => {
|
|
63
|
+
const n = new Map(m);
|
|
64
|
+
const all = rows.length > 0 && rows.every((r) => n.has(r.id));
|
|
65
|
+
for (const r of rows) all ? n.delete(r.id) : n.set(r.id, r);
|
|
66
|
+
return n;
|
|
67
|
+
});
|
|
68
|
+
const drop = (ids: Array<T["id"]>) => setSelected((m) => { const n = new Map(m); ids.forEach((id) => n.delete(id)); return n; });
|
|
69
|
+
const clear = () => setSelected(new Map());
|
|
70
|
+
const ids = useMemo(() => [...selected.keys()], [selected]);
|
|
71
|
+
return { selected, ids, rows: [...selected.values()], toggle, togglePage, drop, clear };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Deletes every id under `basePath` (`/contacts`). A 404 counts as done; never throws; `onSettled` gets who is gone. */
|
|
75
|
+
export function useBulkDelete<Id extends number | string = number>(basePath: string, onSettled: (gone: Id[], total: number) => void) {
|
|
76
|
+
return useG8Mutation(async (ids: Id[]) => {
|
|
77
|
+
const results = await Promise.allSettled(ids.map((id) => g8Delete(`${basePath}/${id}`)));
|
|
78
|
+
const gone = ids.filter((_, i) => results[i].status === "fulfilled" || errorStatus((results[i] as PromiseRejectedResult).reason) === 404);
|
|
79
|
+
return { gone, total: ids.length };
|
|
80
|
+
}, {
|
|
81
|
+
// `/contacts` -> every cached contacts list and count refreshes (a deleted record's detail is left alone).
|
|
82
|
+
invalidates: [[basePath.split("/")[1], "list"], [basePath.split("/")[1], "count"], [basePath.split("/")[1], "record"]],
|
|
83
|
+
onSuccess: ({ gone, total }) => onSettled(gone, total),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function Pager({ page, pageCount, total, noun, hasNext, loading, onPage }: { page: number; pageCount: number; total: number; noun: string; hasNext: boolean; loading: boolean; onPage: (p: number) => void }) {
|
|
88
|
+
return (
|
|
89
|
+
<div className="flex items-center justify-between border-t px-3 py-2 text-sm text-muted-foreground">
|
|
90
|
+
<span className="tabular-nums">{total.toLocaleString()} {noun}</span>
|
|
91
|
+
<div className="flex items-center gap-2">
|
|
92
|
+
<Button variant="outline" size="sm" disabled={page <= 1 || loading} onClick={() => onPage(page - 1)}>Previous</Button>
|
|
93
|
+
<span className="tabular-nums">Page {page} of {pageCount}</span>
|
|
94
|
+
<Button variant="outline" size="sm" disabled={!hasNext || loading} onClick={() => onPage(page + 1)}>Next</Button>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Date-only or timestamp to a short UTC date, so a `2026-10-15` due date never shifts a day. */
|
|
101
|
+
export function formatDay(value?: string | null): string {
|
|
102
|
+
if (!value) return "";
|
|
103
|
+
const d = new Date(value.length === 10 ? `${value}T00:00:00Z` : value);
|
|
104
|
+
return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString(undefined, { timeZone: "UTC", month: "short", day: "numeric", year: "numeric" });
|
|
105
|
+
}
|
package/blocks/deals/README.md
CHANGED
|
@@ -4,6 +4,29 @@ A kanban of deals by stage with drag and drop, a deal detail with history, a sta
|
|
|
4
4
|
Install with `npx @graph8/sdk add deals` (depends on `crm`, whose notes, tasks and activity timeline you can
|
|
5
5
|
compose under `DealDetail`'s `children`). Drag and drop needs `npm i @dnd-kit/core`.
|
|
6
6
|
|
|
7
|
+
## Screens
|
|
8
|
+
|
|
9
|
+
`DealsScreen` (`components/deals-screen.tsx`) is the finished product; mount it on a route with no props:
|
|
10
|
+
`<DealsScreen />`. Everything below is reachable from it, no extra code.
|
|
11
|
+
|
|
12
|
+
- Board and List views, search by name, a pipeline switcher when there is more than one pipeline.
|
|
13
|
+
- Headline numbers: open deals, open value, estimated weighted value, won value.
|
|
14
|
+
- Open a deal (board card or list row, deep link `?id=<deal id>`): Details (edit, stage history, confirmed delete) and
|
|
15
|
+
Activity tabs. Moving a card into a won or lost stage asks for the close date and amount first.
|
|
16
|
+
- **New deal**: name, amount, close date, stage, an **owner** and **at least one contact**. The API requires both
|
|
17
|
+
(`owner_id`, and `contact_ids` with one or more mashup contact ids that all work at one company; the older note that
|
|
18
|
+
the list may be empty was wrong). A company that already has a deal is refused with 409 unless "Allow another deal"
|
|
19
|
+
is ticked.
|
|
20
|
+
- Forecast tab (totals, win rate, stuck deals, estimated weighted pipeline) and Pipeline settings tab (stage names,
|
|
21
|
+
probabilities, won/lost types, order, delete).
|
|
22
|
+
- Slots for cross-block glue, all optional: `renderRowActions(deal)`, `renderDetailActions(deal)`,
|
|
23
|
+
`extraDetailTabs`, `toolbarActions`. Example: add a Quotes tab with `<QuoteList dealId={deal.id} />`.
|
|
24
|
+
|
|
25
|
+
Scopes the screen needs on top of the lists below: `team:read` (owner default and picker) and `contacts:read`
|
|
26
|
+
(contact search). Proxy `allowedPrefixes` must include `deals`, `pipelines`, `team-members`, `contacts` (and `activity`
|
|
27
|
+
for the timeline).
|
|
28
|
+
|
|
29
|
+
|
|
7
30
|
## The flow, in order
|
|
8
31
|
|
|
9
32
|
1. **Pipelines** `GET /deals/pipelines` (`usePipelines`): `[{ id, name, is_default, stages: [{ id, name,
|
|
@@ -17,8 +40,8 @@ compose under `DealDetail`'s `children`). Drag and drop needs `npm i @dnd-kit/co
|
|
|
17
40
|
won, and an owner. The board prompts for the date and amount first; if the API still answers 422 with
|
|
18
41
|
`close_date_required_for_won_stage`, `amount_required_for_won_stage` or `owner_required_for_closed_stage`,
|
|
19
42
|
the board shows what is missing and rolls back.
|
|
20
|
-
5. **Create / edit** (`DealDetail`): `POST /deals` needs `name`, `owner_id` and `contact_ids` (
|
|
21
|
-
|
|
43
|
+
5. **Create / edit** (`DealDetail`): `POST /deals` needs `name`, `owner_id` and `contact_ids` (at least ONE mashup contact id;
|
|
44
|
+
all of them must work at the same company); `stage_id` defaults to the first stage of the default pipeline. Edits send `expected_revision`;
|
|
22
45
|
a stale revision is refused, so reload and retry. `DELETE /deals/{id}` is destructive and confirmed.
|
|
23
46
|
6. **Configure** (`StageEditor`): `GET /pipelines/{id}`, `POST .../stages`, `PUT .../stages/{stage_id}`,
|
|
24
47
|
`PUT .../stages/reorder` (send every stage id in the new order), `DELETE .../stages/{stage_id}` (destructive,
|
|
@@ -40,7 +63,12 @@ create), `deal.id` + `deal.revision` (card -> detail -> update/delete).
|
|
|
40
63
|
|
|
41
64
|
- The board shows the default pipeline. Moves save immediately; no confirm dialog (nobody is contacted).
|
|
42
65
|
- Every card has a "Move to" select, the keyboard and screen-reader path, doing the same PATCH as dragging.
|
|
43
|
-
-
|
|
66
|
+
- **Deal owner.** graph8 requires an owner on create, so `DealsScreen` never asks: it defaults to the API key's own
|
|
67
|
+
user (`GET /team-members/me`, the roster record of the person who minted a personal key, using their identity-provider
|
|
68
|
+
user id and falling back to their email). An organisation key has no person (that route answers 403), so the owner
|
|
69
|
+
picker preselects the first active team member of `GET /team-members`. The person can change the owner in the picker
|
|
70
|
+
before saving. `DealDetail` alone still takes `ownerId` as a prop.
|
|
71
|
+
- Nothing about deals needs to be asked of the human. If the product has its own user model, pass that user's graph8 id as `ownerId` instead.
|
|
44
72
|
|
|
45
73
|
## Gotchas
|
|
46
74
|
|
|
@@ -70,6 +98,9 @@ create), `deal.id` + `deal.revision` (card -> detail -> update/delete).
|
|
|
70
98
|
| `cliPipelines.deleteStage` | DELETE /pipelines/{id}/stages/{stage_id} | destructive | StageEditor |
|
|
71
99
|
| `deals.getDealAnalytics` | GET /deals/analytics | read | ForecastView |
|
|
72
100
|
| `deals.getDealStageVelocity` | GET /deals/stage-velocity | read | ForecastView |
|
|
101
|
+
| `teamMembers.getMyTeamMember` | GET /team-members/me | read | useDefaultOwner (DealsScreen) |
|
|
102
|
+
| `teamMembers.listTeamMembers` | GET /team-members | read | useDefaultOwner (DealsScreen) |
|
|
103
|
+
| `contacts.listContacts` | GET /contacts | read | DealsScreen contact search |
|
|
73
104
|
|
|
74
105
|
## Recipes (server side)
|
|
75
106
|
|