@graph8/sdk 0.362.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 +64 -1
- package/blocks/_foundation/block.json +79 -6
- package/blocks/_foundation/components/api-error.tsx +9 -7
- 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/screen/index.ts +1 -0
- package/blocks/_foundation/components/screen/record-link.tsx +76 -0
- package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
- package/blocks/_foundation/components/screen/time-series-chart.tsx +15 -97
- package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
- package/blocks/_foundation/hooks/use-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/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/block.json +6 -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 +4 -2
- package/blocks/appointments/components/meeting-list.tsx +9 -5
- package/blocks/appointments/components/meetings-screen.tsx +7 -4
- package/blocks/appointments/components/scheduling-screen.tsx +2 -1
- package/blocks/block.schema.json +18 -0
- package/blocks/block.types.ts +17 -0
- package/blocks/campaigns/README.md +41 -11
- package/blocks/campaigns/block.json +105 -42
- package/blocks/campaigns/components/campaign-assets.tsx +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/block.json +9 -3
- package/blocks/deals/components/deal-detail.tsx +23 -11
- package/blocks/deals/components/deal-related.tsx +1 -1
- package/blocks/deals/components/deals-screen.tsx +10 -5
- package/blocks/deals/components/stage-editor.tsx +4 -4
- package/blocks/deals/hooks/use-pipeline-deals.ts +5 -1
- package/blocks/deals/hooks/use-pipelines.ts +1 -1
- 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/block.json +32 -18
- 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 +5 -2
- package/blocks/forms/components/submissions-table.tsx +3 -1
- 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/block.json +13 -8
- package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
- package/blocks/lifecycle/components/lifecycle-screen.tsx +3 -1
- package/blocks/lifecycle/lib/lifecycle.ts +1 -1
- package/blocks/lists/README.md +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/block.json +40 -24
- package/blocks/newsletter/components/issue-scheduler.tsx +2 -1
- package/blocks/newsletter/components/newsletter-screen.tsx +3 -1
- package/blocks/nurture/block.json +28 -14
- 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 +10 -6
- 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/block.json +28 -23
- package/blocks/pages/components/brand-kit-editor.tsx +5 -2
- package/blocks/pages/components/content-library.tsx +9 -4
- package/blocks/pages/components/page-list.tsx +2 -1
- package/blocks/quotes/block.json +17 -12
- package/blocks/quotes/components/product-picker.tsx +3 -1
- package/blocks/quotes/components/quote-related.tsx +5 -2
- 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 +2709 -1721
- 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 +22 -19
- package/dist/types/blocks/doctor.d.ts +5 -1
- 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 +17 -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/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 +40 -9
- package/templates/next-app/app/layout.tsx.tmpl +7 -2
- package/templates/next-app/components/app-shell.tsx.tmpl +99 -89
- package/templates/next-app/dot-gitignore.tmpl +2 -0
- 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/block.json
CHANGED
|
@@ -101,15 +101,21 @@
|
|
|
101
101
|
"name": "DealsScreen",
|
|
102
102
|
"file": "components/deals-screen.tsx",
|
|
103
103
|
"purpose": "Finished deals product: board and list, new deal with the owner defaulted to the API key user, deal detail with activity, forecast and pipeline settings.",
|
|
104
|
-
"operations": [
|
|
105
|
-
|
|
104
|
+
"operations": [
|
|
105
|
+
"get_deal_deals__deal_id__get",
|
|
106
|
+
"list_contacts_contacts_get"
|
|
107
|
+
],
|
|
108
|
+
"tiers": [
|
|
109
|
+
"read"
|
|
110
|
+
]
|
|
106
111
|
},
|
|
107
112
|
{
|
|
108
113
|
"name": "DealContactsTab",
|
|
109
114
|
"file": "components/deal-related.tsx",
|
|
110
115
|
"purpose": "A deal's contacts with roles; open one for the full CRM record. Also exports DealQuotesTab, a read-only list of the deal's quotes.",
|
|
111
116
|
"operations": [
|
|
112
|
-
"get_deal_deals__deal_id__get"
|
|
117
|
+
"get_deal_deals__deal_id__get",
|
|
118
|
+
"list_quotes_quotes_get"
|
|
113
119
|
],
|
|
114
120
|
"tiers": [
|
|
115
121
|
"read"
|