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