@agent-native/core 0.123.0 → 0.123.2
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +24 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/templates-meta.ts +12 -0
- package/corpus/core/src/deploy/build.ts +8 -0
- package/corpus/core/src/server/builder-browser.ts +76 -0
- package/corpus/core/src/server/core-routes-plugin.ts +15 -5
- package/corpus/templates/assets/app/global.css +0 -4
- package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +195 -167
- package/corpus/templates/assets/changelog/2026-07-23-drafts-preview-panel-now-fills-the-full-width-with-a-large-p.md +6 -0
- package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +1 -0
- package/corpus/templates/content/actions/_builder-cms-write-client.ts +55 -9
- package/corpus/templates/content/actions/_database-source-utils.ts +756 -182
- package/corpus/templates/content/actions/change-content-database-source-role.ts +21 -20
- package/corpus/templates/content/actions/execute-builder-source-execution.ts +49 -8
- package/corpus/templates/content/actions/materialize-builder-required-fields.ts +42 -9
- package/corpus/templates/content/actions/prepare-builder-source-review.ts +88 -90
- package/corpus/templates/content/actions/preview-builder-source-review.ts +4 -3
- package/corpus/templates/content/actions/refresh-content-database-source.ts +36 -9
- package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +28 -24
- package/corpus/templates/content/app/components/editor/BubbleToolbar.tsx +62 -12
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +91 -16
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +15 -1
- package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +234 -44
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +194 -16
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +95 -42
- package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +4 -2
- package/corpus/templates/content/app/components/editor/extensions/CodeBlockNode.tsx +10 -166
- package/corpus/templates/content/app/components/editor/extensions/VideoBlock.tsx +32 -8
- package/corpus/templates/content/app/components/editor/extensions/VideoNode.ts +14 -0
- package/corpus/templates/content/app/components/editor/registrySlashItems.ts +5 -1
- package/corpus/templates/content/app/hooks/use-content-database.ts +59 -2
- package/corpus/templates/content/app/hooks/use-documents.ts +18 -26
- package/corpus/templates/content/app/root.tsx +1 -6
- package/corpus/templates/content/changelog/2026-07-24-builder-can-now-publish-approved-updates-to-entries-that-are.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-builder-collection-loading-no-longer-restarts-from-the-begin.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-builder-connections-now-complete-securely-from-netlify-deplo.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-builder-draft-creation-now-scopes-execution-to-the-selected-.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-builder-review-now-distinguishes-preparing-a-safe-dry-run-from.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-builder-source-imports-now-recover-from-a-transient-paginati.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-builder-source-refresh-now-preserves-the-provider-bound-fiel.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-builder-update-reviews-now-open-reliably-on-large-collection.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-builder-write-policy-controls-now-reflect-successful-changes.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-date-properties-now-save-reliably-when-selected-with-the-nat.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-editor-slash-search-now-keeps-multi-word-block-names-open.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-exact-editor-slash-commands-now-execute-on-enter-even-if-the.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-new-collaborative-pages-now-save-their-first-edits.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-new-local-builder-rows-no-longer-remain-locked-behind-a-none.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-opening-or-closing-a-collaborative-document-no-longer-lets-u.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-selected-text-now-opens-the-link-editor-with-command-k-or-co.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-slash-menu-block-choices-no-longer-stall-pointer-interaction.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-uploaded-and-embedded-media-now-stay-intact-while-collaborative-document-drafts-save.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-video-link-insertion-now-keeps-its-source-panel-open-while-t.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-builder-publishing-now-shows-safe-validation-details-instead.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-builder-reviews-stay-responsive-on-large-collections-and-pre.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-collaborative-documents-no-longer-flash-duplicated-recent-bl.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-corrected-builder-updates-are-now-reviewed-separately-from-f.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-documents-now-remain-read-only-until-their-live-collaborativ.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-slash-command-menus-now-stay-reachable-when-editing-near-the.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-the-slash-menu-now-shows-one-clear-callout-choice-instead-of.md +6 -0
- package/corpus/templates/content/shared/api.ts +1 -0
- package/corpus/templates/content/shared/builder-mdx.ts +73 -16
- package/corpus/templates/crm/.agents/skills/agent-native-docs/SKILL.md +115 -0
- package/corpus/templates/crm/.agents/skills/agent-native-toolkit/SKILL.md +177 -0
- package/corpus/templates/crm/.agents/skills/crm/SKILL.md +205 -0
- package/corpus/templates/crm/.agents/skills/customizing-agent-native/SKILL.md +220 -0
- package/corpus/templates/crm/.agents/skills/feature-flags/SKILL.md +169 -0
- package/corpus/templates/crm/.agents/skills/sharing/SKILL.md +228 -0
- package/corpus/templates/crm/.agents/skills/storing-data/SKILL.md +178 -0
- package/corpus/templates/crm/.agents/skills/upgrade-agent-native/SKILL.md +122 -0
- package/corpus/templates/crm/.env.example +5 -0
- package/corpus/templates/crm/AGENTS.md +82 -0
- package/corpus/templates/crm/CHANGELOG.md +25 -0
- package/corpus/templates/crm/DEVELOPING.md +66 -0
- package/corpus/templates/crm/README.md +122 -0
- package/corpus/templates/crm/_gitignore +12 -0
- package/corpus/templates/crm/actions/_crm-action-utils.ts +98 -0
- package/corpus/templates/crm/actions/_crm-attribute-utils.ts +219 -0
- package/corpus/templates/crm/actions/_crm-dashboard.ts +69 -0
- package/corpus/templates/crm/actions/_crm-data-program-actions.ts +15 -0
- package/corpus/templates/crm/actions/_crm-list-utils.ts +775 -0
- package/corpus/templates/crm/actions/_crm-signal-utils.ts +79 -0
- package/corpus/templates/crm/actions/add-crm-record-to-list.ts +164 -0
- package/corpus/templates/crm/actions/apply-crm-proposals.ts +291 -0
- package/corpus/templates/crm/actions/archive-crm-attribute.ts +69 -0
- package/corpus/templates/crm/actions/attach-call-evidence.ts +170 -0
- package/corpus/templates/crm/actions/configure-crm-connection.ts +126 -0
- package/corpus/templates/crm/actions/configure-native-crm.ts +44 -0
- package/corpus/templates/crm/actions/create-crm-attribute.ts +350 -0
- package/corpus/templates/crm/actions/create-crm-list.ts +194 -0
- package/corpus/templates/crm/actions/create-crm-record.ts +148 -0
- package/corpus/templates/crm/actions/create-crm-signal-tracker.ts +58 -0
- package/corpus/templates/crm/actions/delete-crm-saved-view.ts +68 -0
- package/corpus/templates/crm/actions/delete-staged-dataset.ts +14 -0
- package/corpus/templates/crm/actions/estimate-crm-enrichment.ts +229 -0
- package/corpus/templates/crm/actions/find-crm-duplicates.ts +87 -0
- package/corpus/templates/crm/actions/get-crm-automation-recipe.ts +36 -0
- package/corpus/templates/crm/actions/get-crm-dashboard-panel.ts +35 -0
- package/corpus/templates/crm/actions/get-crm-dashboard.ts +14 -0
- package/corpus/templates/crm/actions/get-crm-overview.ts +13 -0
- package/corpus/templates/crm/actions/get-crm-pipeline-data.ts +61 -0
- package/corpus/templates/crm/actions/get-crm-record-page.ts +287 -0
- package/corpus/templates/crm/actions/get-crm-record.ts +75 -0
- package/corpus/templates/crm/actions/get-crm-workspace.ts +364 -0
- package/corpus/templates/crm/actions/install-crm-pipeline-dashboard.ts +81 -0
- package/corpus/templates/crm/actions/list-crm-attributes.ts +71 -0
- package/corpus/templates/crm/actions/list-crm-connections.ts +68 -0
- package/corpus/templates/crm/actions/list-crm-dashboard-revisions.ts +15 -0
- package/corpus/templates/crm/actions/list-crm-dashboards.ts +15 -0
- package/corpus/templates/crm/actions/list-crm-enrichment-slots.ts +28 -0
- package/corpus/templates/crm/actions/list-crm-list-entries.ts +192 -0
- package/corpus/templates/crm/actions/list-crm-lists.ts +80 -0
- package/corpus/templates/crm/actions/list-crm-proposals.ts +55 -0
- package/corpus/templates/crm/actions/list-crm-record-field-history.ts +201 -0
- package/corpus/templates/crm/actions/list-crm-record-values.ts +200 -0
- package/corpus/templates/crm/actions/list-crm-records.ts +103 -0
- package/corpus/templates/crm/actions/list-crm-saved-views.ts +66 -0
- package/corpus/templates/crm/actions/list-crm-signal-hits.ts +43 -0
- package/corpus/templates/crm/actions/list-crm-signal-trackers.ts +32 -0
- package/corpus/templates/crm/actions/list-crm-tasks.ts +22 -0
- package/corpus/templates/crm/actions/list-staged-datasets.ts +14 -0
- package/corpus/templates/crm/actions/list-workspace-connections.ts +47 -0
- package/corpus/templates/crm/actions/manage-crm-attribute-option.ts +272 -0
- package/corpus/templates/crm/actions/manage-crm-signal-tracker.ts +87 -0
- package/corpus/templates/crm/actions/manage-crm-task.ts +132 -0
- package/corpus/templates/crm/actions/merge-crm-records.ts +691 -0
- package/corpus/templates/crm/actions/navigate.ts +54 -0
- package/corpus/templates/crm/actions/provider-api-catalog.ts +22 -0
- package/corpus/templates/crm/actions/provider-api-docs.ts +54 -0
- package/corpus/templates/crm/actions/provider-api-request.ts +92 -0
- package/corpus/templates/crm/actions/query-staged-dataset.ts +44 -0
- package/corpus/templates/crm/actions/record-crm-call-insight.ts +160 -0
- package/corpus/templates/crm/actions/record-crm-smart-signal.ts +150 -0
- package/corpus/templates/crm/actions/remove-crm-list-entry.ts +79 -0
- package/corpus/templates/crm/actions/restore-crm-dashboard-revision.ts +23 -0
- package/corpus/templates/crm/actions/review-crm-signal.ts +31 -0
- package/corpus/templates/crm/actions/run-crm-attribute-fill.ts +443 -0
- package/corpus/templates/crm/actions/run-crm-enrichment.ts +647 -0
- package/corpus/templates/crm/actions/run-crm-saved-view-program.ts +103 -0
- package/corpus/templates/crm/actions/run-crm-signal-trackers.ts +265 -0
- package/corpus/templates/crm/actions/run.ts +7 -0
- package/corpus/templates/crm/actions/save-crm-dashboard.ts +51 -0
- package/corpus/templates/crm/actions/save-crm-saved-view.ts +296 -0
- package/corpus/templates/crm/actions/sync-crm.ts +146 -0
- package/corpus/templates/crm/actions/update-crm-attribute.ts +111 -0
- package/corpus/templates/crm/actions/update-crm-list-entry.ts +102 -0
- package/corpus/templates/crm/actions/update-crm-list.ts +97 -0
- package/corpus/templates/crm/actions/update-crm-record.ts +543 -0
- package/corpus/templates/crm/actions/view-screen.ts +324 -0
- package/corpus/templates/crm/agent-native.app-skill.json +69 -0
- package/corpus/templates/crm/app/components/crm/CreateCrmRecordDialog.tsx +158 -0
- package/corpus/templates/crm/app/components/crm/CrmDashboardPanel.tsx +78 -0
- package/corpus/templates/crm/app/components/crm/CrmSignalsPanel.tsx +220 -0
- package/corpus/templates/crm/app/components/crm/IntelligenceSettings.tsx +414 -0
- package/corpus/templates/crm/app/components/crm/RecordActions.tsx +648 -0
- package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +488 -0
- package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +232 -0
- package/corpus/templates/crm/app/components/crm/SavedViewDataProgram.tsx +93 -0
- package/corpus/templates/crm/app/components/crm/Surface.tsx +97 -0
- package/corpus/templates/crm/app/components/crm/WorkOverview.tsx +180 -0
- package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +988 -0
- package/corpus/templates/crm/app/components/crm/board/board-model.ts +399 -0
- package/corpus/templates/crm/app/components/crm/board/entry-filter.ts +150 -0
- package/corpus/templates/crm/app/components/crm/board/view-draft.ts +227 -0
- package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +1033 -0
- package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +620 -0
- package/corpus/templates/crm/app/components/crm/grid/clipboard.ts +84 -0
- package/corpus/templates/crm/app/components/crm/grid/model.ts +304 -0
- package/corpus/templates/crm/app/components/crm/grid/navigation.ts +220 -0
- package/corpus/templates/crm/app/components/crm/grid/query.ts +214 -0
- package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +192 -0
- package/corpus/templates/crm/app/components/crm/record/FieldHistory.tsx +179 -0
- package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +103 -0
- package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +235 -0
- package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +206 -0
- package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +245 -0
- package/corpus/templates/crm/app/components/crm/record/record-data.ts +603 -0
- package/corpus/templates/crm/app/components/crm/settings/AdvancedSettings.tsx +49 -0
- package/corpus/templates/crm/app/components/crm/settings/AttributeOptionsEditor.tsx +441 -0
- package/corpus/templates/crm/app/components/crm/settings/ConnectionSettings.tsx +169 -0
- package/corpus/templates/crm/app/components/crm/settings/FieldsSettings.tsx +935 -0
- package/corpus/templates/crm/app/components/crm/settings/ListsSettings.tsx +339 -0
- package/corpus/templates/crm/app/components/crm/settings/settings-admin.ts +377 -0
- package/corpus/templates/crm/app/components/crm/shared/AttributeValueParts.tsx +69 -0
- package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +591 -0
- package/corpus/templates/crm/app/components/layout/CrmCommandMenu.tsx +556 -0
- package/corpus/templates/crm/app/components/layout/CrmLayout.tsx +130 -0
- package/corpus/templates/crm/app/components/layout/CrmSidebar.tsx +407 -0
- package/corpus/templates/crm/app/components/layout/command-palette-search.ts +126 -0
- package/corpus/templates/crm/app/components/layout/crm-ui-intents.ts +41 -0
- package/corpus/templates/crm/app/components/layout/sidebar-collapse.ts +40 -0
- package/corpus/templates/crm/app/components/layout/sidebar-lists.ts +109 -0
- package/corpus/templates/crm/app/components/ui/alert-dialog.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/badge.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/button.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/card.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/checkbox.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/dialog.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/dropdown-menu.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/hover-card.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/input.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/label.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/popover.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/select.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/sheet.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/skeleton.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/switch.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/table.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/tabs.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/textarea.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/toolkit-provider.tsx +17 -0
- package/corpus/templates/crm/app/components/ui/tooltip.tsx +1 -0
- package/corpus/templates/crm/app/design-system.ts +3 -0
- package/corpus/templates/crm/app/entry.client.tsx +19 -0
- package/corpus/templates/crm/app/entry.server.tsx +10 -0
- package/corpus/templates/crm/app/global.css +167 -0
- package/corpus/templates/crm/app/hooks/use-keyboard-shortcuts.ts +206 -0
- package/corpus/templates/crm/app/hooks/use-navigation-state.ts +48 -0
- package/corpus/templates/crm/app/i18n/en-US.ts +684 -0
- package/corpus/templates/crm/app/i18n/index.ts +34 -0
- package/corpus/templates/crm/app/lib/dashboard.ts +13 -0
- package/corpus/templates/crm/app/lib/navigation.ts +17 -0
- package/corpus/templates/crm/app/lib/tab-id.ts +1 -0
- package/corpus/templates/crm/app/lib/types.ts +172 -0
- package/corpus/templates/crm/app/lib/utils.ts +1 -0
- package/corpus/templates/crm/app/root.tsx +93 -0
- package/corpus/templates/crm/app/routes/_index.tsx +26 -0
- package/corpus/templates/crm/app/routes/accounts.tsx +41 -0
- package/corpus/templates/crm/app/routes/agent.tsx +5 -0
- package/corpus/templates/crm/app/routes/ask.tsx +53 -0
- package/corpus/templates/crm/app/routes/dashboard.tsx +118 -0
- package/corpus/templates/crm/app/routes/lists.tsx +228 -0
- package/corpus/templates/crm/app/routes/opportunities.tsx +41 -0
- package/corpus/templates/crm/app/routes/people.tsx +41 -0
- package/corpus/templates/crm/app/routes/proposals.tsx +388 -0
- package/corpus/templates/crm/app/routes/records.$recordId.tsx +53 -0
- package/corpus/templates/crm/app/routes/records.tsx +64 -0
- package/corpus/templates/crm/app/routes/settings.tsx +118 -0
- package/corpus/templates/crm/app/routes/setup.tsx +308 -0
- package/corpus/templates/crm/app/routes/tasks.tsx +252 -0
- package/corpus/templates/crm/app/routes/views.tsx +683 -0
- package/corpus/templates/crm/app/routes.ts +19 -0
- package/corpus/templates/crm/app/vite-env.d.ts +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-any-list-or-view-can-be-shown-as-a-board-grouped-by-stage-so.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-crm-now-finds-likely-duplicate-records-and-tells-you-exactly.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-enrichment-now-shows-what-a-run-will-cost-before-it-starts-g.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-lists-turn-any-set-of-records-into-a-pipeline-with-its-own-s.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-moving-a-record-or-list-entry-into-a-stage-that-was-retired-.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-opening-a-record-now-shows-one-page-with-its-fields-the-list.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-press-cmd-k-to-jump-to-any-record-list-view-or-setting-and-u.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-provider-record-edits-now-show-an-exact-before-after-diff-an.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-record-page-attribute-labels-now-read-in-full-instead-of-bei.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-records-now-use-typed-fields-currency-dates-ratings-status-s.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-retyping-a-value-on-the-record-page-now-replaces-it-instead-.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-settings-now-has-fields-and-lists-tabs-for-adding-fields-edi.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-the-proposal-review-now-shows-the-field-that-actually-change.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-the-record-grid-is-now-a-real-spreadsheet-filter-sort-and-pa.md +6 -0
- package/corpus/templates/crm/components.json +20 -0
- package/corpus/templates/crm/docs/architecture/crm-contract.md +214 -0
- package/corpus/templates/crm/docs/features/README.md +55 -0
- package/corpus/templates/crm/docs/features/f1-records-and-fields.mdx +52 -0
- package/corpus/templates/crm/docs/features/f2-lists-pipelines-and-views.mdx +48 -0
- package/corpus/templates/crm/docs/features/f3-work-and-record-quality.mdx +40 -0
- package/corpus/templates/crm/docs/features/f4-evidence-and-chat.mdx +47 -0
- package/corpus/templates/crm/learnings.defaults.md +5 -0
- package/corpus/templates/crm/netlify.toml +11 -0
- package/corpus/templates/crm/package.json +61 -0
- package/corpus/templates/crm/public/favicon.svg +1 -0
- package/corpus/templates/crm/public/icon-180.svg +1 -0
- package/corpus/templates/crm/public/manifest.json +11 -0
- package/corpus/templates/crm/react-router.config.ts +7 -0
- package/corpus/templates/crm/server/crm/adapter.ts +32 -0
- package/corpus/templates/crm/server/crm/crm-field-firewall.ts +64 -0
- package/corpus/templates/crm/server/crm/crm-mirror.ts +721 -0
- package/corpus/templates/crm/server/crm/field-policy-attributes.ts +48 -0
- package/corpus/templates/crm/server/crm/hubspot-adapter.ts +1146 -0
- package/corpus/templates/crm/server/crm/native-adapter.ts +1810 -0
- package/corpus/templates/crm/server/crm/provider-record-link.ts +264 -0
- package/corpus/templates/crm/server/crm/read-through.ts +207 -0
- package/corpus/templates/crm/server/crm/salesforce-adapter.ts +1255 -0
- package/corpus/templates/crm/server/db/crm-store.ts +1112 -0
- package/corpus/templates/crm/server/db/index.ts +191 -0
- package/corpus/templates/crm/server/db/schema.ts +647 -0
- package/corpus/templates/crm/server/lib/crm-query.ts +1558 -0
- package/corpus/templates/crm/server/lib/dedupe.ts +714 -0
- package/corpus/templates/crm/server/lib/enrichment-cost.ts +381 -0
- package/corpus/templates/crm/server/lib/enrichment-slots.ts +519 -0
- package/corpus/templates/crm/server/lib/intelligence/default-detectors.ts +65 -0
- package/corpus/templates/crm/server/lib/intelligence/evidence.ts +105 -0
- package/corpus/templates/crm/server/lib/intelligence/keyword-detector.ts +94 -0
- package/corpus/templates/crm/server/lib/intelligence/smart-detector.ts +39 -0
- package/corpus/templates/crm/server/lib/intelligence/summary.ts +19 -0
- package/corpus/templates/crm/server/lib/lifecycle.ts +634 -0
- package/corpus/templates/crm/server/lib/provider-api.ts +64 -0
- package/corpus/templates/crm/server/middleware/auth.ts +4 -0
- package/corpus/templates/crm/server/plugins/agent-chat.ts +65 -0
- package/corpus/templates/crm/server/plugins/auth.ts +14 -0
- package/corpus/templates/crm/server/plugins/core-routes.ts +17 -0
- package/corpus/templates/crm/server/plugins/db.ts +738 -0
- package/corpus/templates/crm/server/routes/[...page].get.ts +5 -0
- package/corpus/templates/crm/shared/crm-attributes.ts +469 -0
- package/corpus/templates/crm/shared/crm-automation-recipes.ts +132 -0
- package/corpus/templates/crm/shared/crm-contract.ts +417 -0
- package/corpus/templates/crm/shared/crm-navigation.ts +190 -0
- package/corpus/templates/crm/shared/crm-sales-config.ts +63 -0
- package/corpus/templates/crm/ssr-entry.ts +11 -0
- package/corpus/templates/crm/tsconfig.json +24 -0
- package/corpus/templates/crm/vite.config.ts +15 -0
- package/corpus/templates/crm/vitest.config.ts +5 -0
- package/corpus/templates/design/app/i18n-data.ts +12 -12
- package/corpus/templates/design/changelog/2026-07-26-apply-design-updates-from-a-centered-top-toolbar-without-cov.md +6 -0
- package/corpus/toolkit/CHANGELOG.md +6 -0
- package/corpus/toolkit/package.json +1 -1
- package/corpus/toolkit/src/editor/useCollabReconcile.ts +14 -1
- package/dist/cli/templates-meta.d.ts.map +1 -1
- package/dist/cli/templates-meta.js +12 -0
- package/dist/cli/templates-meta.js.map +1 -1
- package/dist/collab/routes.d.ts +2 -2
- package/dist/deploy/build.js +6 -0
- package/dist/deploy/build.js.map +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/server/builder-browser.d.ts +13 -0
- package/dist/server/builder-browser.d.ts.map +1 -1
- package/dist/server/builder-browser.js +59 -0
- package/dist/server/builder-browser.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +10 -5
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +2 -2
- package/src/cli/templates-meta.ts +12 -0
- package/src/deploy/build.ts +8 -0
- package/src/server/builder-browser.ts +76 -0
- package/src/server/core-routes-plugin.ts +15 -5
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CRM board: a list's entries, or an object's records, in one column per
|
|
3
|
+
* option of a `status` attribute.
|
|
4
|
+
*
|
|
5
|
+
* Stage history is not a separate table. On a list board the current status
|
|
6
|
+
* row's `active_from` arrives as `valuesSince[slug]`, and the option's
|
|
7
|
+
* `target_days` comes with the attribute — so the SLA the cards show is
|
|
8
|
+
* exactly what an action reading `crm_record_fields` would compute.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { callAction, useActionQuery } from "@agent-native/core/client/hooks";
|
|
12
|
+
import { useT } from "@agent-native/core/client/i18n";
|
|
13
|
+
import {
|
|
14
|
+
IconAlertTriangle,
|
|
15
|
+
IconClock,
|
|
16
|
+
IconClockExclamation,
|
|
17
|
+
IconGripVertical,
|
|
18
|
+
} from "@tabler/icons-react";
|
|
19
|
+
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
20
|
+
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
21
|
+
import { Link } from "react-router";
|
|
22
|
+
import { toast } from "sonner";
|
|
23
|
+
|
|
24
|
+
import { LoadingRows } from "@/components/crm/Surface";
|
|
25
|
+
import { Badge } from "@/components/ui/badge";
|
|
26
|
+
import { Button } from "@/components/ui/button";
|
|
27
|
+
import {
|
|
28
|
+
Table,
|
|
29
|
+
TableBody,
|
|
30
|
+
TableCell,
|
|
31
|
+
TableHead,
|
|
32
|
+
TableHeader,
|
|
33
|
+
TableRow,
|
|
34
|
+
} from "@/components/ui/table";
|
|
35
|
+
|
|
36
|
+
import { ProvenanceMarker } from "../grid/GridCell";
|
|
37
|
+
import type { CrmCellProvenance } from "../grid/model";
|
|
38
|
+
import {
|
|
39
|
+
currencyCodeOf,
|
|
40
|
+
formatAttributeValue,
|
|
41
|
+
valueTokens,
|
|
42
|
+
type CrmAttributeValue,
|
|
43
|
+
type CrmValueShape,
|
|
44
|
+
} from "../shared/attribute-value";
|
|
45
|
+
import {
|
|
46
|
+
AttributeOptionChip,
|
|
47
|
+
AttributeRating,
|
|
48
|
+
} from "../shared/AttributeValueParts";
|
|
49
|
+
import {
|
|
50
|
+
BOARD_UNGROUPED,
|
|
51
|
+
boardCardSla,
|
|
52
|
+
boardColumns,
|
|
53
|
+
boardColumnTotals,
|
|
54
|
+
boardOverruns,
|
|
55
|
+
cardAmountFor,
|
|
56
|
+
moveBoardCard,
|
|
57
|
+
moveValueForColumn,
|
|
58
|
+
objectBoardMoveArgs,
|
|
59
|
+
pickCardAttributes,
|
|
60
|
+
pickCurrencyAttribute,
|
|
61
|
+
type BoardActorType,
|
|
62
|
+
type BoardCard,
|
|
63
|
+
type BoardCardAttribute,
|
|
64
|
+
type BoardColumn,
|
|
65
|
+
type BoardOption,
|
|
66
|
+
} from "./board-model";
|
|
67
|
+
import { toEntryFilters, type EntryFilter } from "./entry-filter";
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Action shapes
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
interface BoardAttributeOption {
|
|
74
|
+
id: string;
|
|
75
|
+
value: string;
|
|
76
|
+
title: string;
|
|
77
|
+
color?: string;
|
|
78
|
+
position: number;
|
|
79
|
+
archived: boolean;
|
|
80
|
+
targetDays?: number | null;
|
|
81
|
+
celebrate?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface BoardAttribute {
|
|
85
|
+
id: string;
|
|
86
|
+
apiSlug: string;
|
|
87
|
+
label: string;
|
|
88
|
+
attributeType: string;
|
|
89
|
+
multi: boolean;
|
|
90
|
+
position: number;
|
|
91
|
+
options?: BoardAttributeOption[];
|
|
92
|
+
config?: Record<string, unknown>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface AttributesResponse {
|
|
96
|
+
attributes: BoardAttribute[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface EntriesResponse {
|
|
100
|
+
entries: Array<{
|
|
101
|
+
id: string;
|
|
102
|
+
recordId: string;
|
|
103
|
+
createdByActorType: string | null;
|
|
104
|
+
record: {
|
|
105
|
+
id: string;
|
|
106
|
+
displayName: string;
|
|
107
|
+
primaryEmail: string | null;
|
|
108
|
+
domain: string | null;
|
|
109
|
+
ownerName: string | null;
|
|
110
|
+
};
|
|
111
|
+
values: Record<string, unknown>;
|
|
112
|
+
valuesSince: Record<string, string>;
|
|
113
|
+
}>;
|
|
114
|
+
complete: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface RecordsResponse {
|
|
118
|
+
records: Array<{
|
|
119
|
+
id: string;
|
|
120
|
+
displayName: string;
|
|
121
|
+
subtitle?: string;
|
|
122
|
+
owner?: string;
|
|
123
|
+
remoteRevision: string | null;
|
|
124
|
+
}>;
|
|
125
|
+
complete: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const PAGE_LIMIT = 100;
|
|
129
|
+
|
|
130
|
+
export interface CrmBoardTarget {
|
|
131
|
+
kind: "list" | "object";
|
|
132
|
+
/** The list id for a list target, the object type for an object target. */
|
|
133
|
+
id: string;
|
|
134
|
+
/** Canonical record kind, for an object target. */
|
|
135
|
+
recordKind?: string | undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface CrmBoardProps {
|
|
139
|
+
target: CrmBoardTarget;
|
|
140
|
+
groupByAttributeId?: string | undefined;
|
|
141
|
+
/** The view's stored filter tree, applied server-side. */
|
|
142
|
+
filter?: unknown;
|
|
143
|
+
mode: "table" | "board";
|
|
144
|
+
/** Attribute ids the view shows, used to pick the card and money columns. */
|
|
145
|
+
columnAttributeIds?: readonly string[];
|
|
146
|
+
/**
|
|
147
|
+
* Reports which status attribute the board resolved to group by, and which
|
|
148
|
+
* ones it could use. A saved board view must persist an explicit
|
|
149
|
+
* `groupByAttributeId`, so the toolbar needs the id the board fell back to.
|
|
150
|
+
*/
|
|
151
|
+
onGrouping?: (state: {
|
|
152
|
+
statusAttributes: Array<{ id: string; label: string }>;
|
|
153
|
+
groupAttributeId: string | null;
|
|
154
|
+
}) => void;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface BoardData {
|
|
158
|
+
isLoading: boolean;
|
|
159
|
+
error: unknown;
|
|
160
|
+
statusAttributes: BoardAttribute[];
|
|
161
|
+
groupAttribute: BoardAttribute | null;
|
|
162
|
+
options: BoardOption[];
|
|
163
|
+
cards: BoardCard[];
|
|
164
|
+
complete: boolean;
|
|
165
|
+
refetch: () => void;
|
|
166
|
+
commit: (move: { card: BoardCard; toValue: string }) => Promise<unknown>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Shared derivation
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
function toBoardOptions(attribute: BoardAttribute | null): BoardOption[] {
|
|
174
|
+
return (attribute?.options ?? []).map((option) => ({
|
|
175
|
+
id: option.id,
|
|
176
|
+
value: option.value,
|
|
177
|
+
title: option.title,
|
|
178
|
+
color: option.color,
|
|
179
|
+
position: option.position,
|
|
180
|
+
archived: option.archived,
|
|
181
|
+
targetDays: option.targetDays ?? null,
|
|
182
|
+
celebrate: option.celebrate ?? false,
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function pickGroupAttribute(
|
|
187
|
+
attributes: readonly BoardAttribute[],
|
|
188
|
+
groupByAttributeId: string | undefined,
|
|
189
|
+
): BoardAttribute | null {
|
|
190
|
+
if (groupByAttributeId) {
|
|
191
|
+
return (
|
|
192
|
+
attributes.find(
|
|
193
|
+
(attribute) =>
|
|
194
|
+
attribute.id === groupByAttributeId ||
|
|
195
|
+
attribute.apiSlug === groupByAttributeId,
|
|
196
|
+
) ?? null
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return (
|
|
200
|
+
attributes.find((attribute) => attribute.attributeType === "status") ?? null
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const ACTOR_TYPES: readonly BoardActorType[] = [
|
|
205
|
+
"user",
|
|
206
|
+
"agent",
|
|
207
|
+
"automation",
|
|
208
|
+
"provider",
|
|
209
|
+
"system",
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
function toActorType(value: unknown): BoardActorType | null {
|
|
213
|
+
return typeof value === "string" &&
|
|
214
|
+
(ACTOR_TYPES as readonly string[]).includes(value)
|
|
215
|
+
? (value as BoardActorType)
|
|
216
|
+
: null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function statusAttributesOf(
|
|
220
|
+
attributes: readonly BoardAttribute[],
|
|
221
|
+
): BoardAttribute[] {
|
|
222
|
+
return attributes.filter((attribute) => attribute.attributeType === "status");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// List target
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
function useListBoard(props: CrmBoardProps, enabled: boolean): BoardData {
|
|
230
|
+
const attributesQuery = useActionQuery<AttributesResponse>(
|
|
231
|
+
"list-crm-attributes" as never,
|
|
232
|
+
{ target: "list", targetId: props.target.id, limit: 200 } as never,
|
|
233
|
+
{ enabled },
|
|
234
|
+
);
|
|
235
|
+
const attributes = attributesQuery.data?.attributes ?? [];
|
|
236
|
+
const groupAttribute = pickGroupAttribute(
|
|
237
|
+
attributes,
|
|
238
|
+
props.groupByAttributeId,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// The filter is translated against the list's own attributes, so the entries
|
|
242
|
+
// call waits for them. One extra round trip on first paint; every later
|
|
243
|
+
// filter change hits the cached attribute query.
|
|
244
|
+
let filters: EntryFilter[] = [];
|
|
245
|
+
let filterError: unknown;
|
|
246
|
+
try {
|
|
247
|
+
filters = attributesQuery.isSuccess
|
|
248
|
+
? toEntryFilters(props.filter, attributes)
|
|
249
|
+
: [];
|
|
250
|
+
} catch (error) {
|
|
251
|
+
filterError = error;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const entriesQuery = useQuery<EntriesResponse>({
|
|
255
|
+
queryKey: ["crm-board-entries", props.target.id, JSON.stringify(filters)],
|
|
256
|
+
queryFn: ({ signal }) =>
|
|
257
|
+
callAction<EntriesResponse>(
|
|
258
|
+
"list-crm-list-entries" as never,
|
|
259
|
+
{
|
|
260
|
+
listId: props.target.id,
|
|
261
|
+
...(filters.length ? { filters } : {}),
|
|
262
|
+
limit: PAGE_LIMIT,
|
|
263
|
+
} as never,
|
|
264
|
+
{ signal },
|
|
265
|
+
),
|
|
266
|
+
enabled: enabled && attributesQuery.isSuccess && !filterError,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const currencyAttribute = pickCurrencyAttribute(
|
|
270
|
+
attributes,
|
|
271
|
+
props.columnAttributeIds ?? [],
|
|
272
|
+
);
|
|
273
|
+
const excluded = new Set(
|
|
274
|
+
[groupAttribute?.id, currencyAttribute?.id].filter(
|
|
275
|
+
(id): id is string => typeof id === "string",
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
const shown = pickCardAttributes(
|
|
279
|
+
attributes,
|
|
280
|
+
excluded,
|
|
281
|
+
props.columnAttributeIds ?? [],
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const groupSlug = groupAttribute?.apiSlug ?? "";
|
|
285
|
+
const entries = entriesQuery.data?.entries;
|
|
286
|
+
const shownKey = shown.map((attribute) => attribute.apiSlug).join(" ");
|
|
287
|
+
const cards = useMemo<BoardCard[]>(() => {
|
|
288
|
+
if (!groupSlug) return [];
|
|
289
|
+
return (entries ?? []).map((entry) => {
|
|
290
|
+
const raw = entry.values[groupSlug];
|
|
291
|
+
return {
|
|
292
|
+
id: entry.id,
|
|
293
|
+
recordId: entry.recordId,
|
|
294
|
+
title: entry.record.displayName,
|
|
295
|
+
subtitle: entry.record.domain ?? entry.record.primaryEmail ?? null,
|
|
296
|
+
owner: entry.record.ownerName ?? null,
|
|
297
|
+
// An entry move writes the entry, not the record.
|
|
298
|
+
remoteRevision: null,
|
|
299
|
+
groupValue: typeof raw === "string" && raw ? raw : BOARD_UNGROUPED,
|
|
300
|
+
groupSince: entry.valuesSince[groupSlug] ?? null,
|
|
301
|
+
// Only a typed currency attribute counts here, never the legacy
|
|
302
|
+
// mirrored `crmRecords.amount` column: that field predates the
|
|
303
|
+
// attribute system and is not what a view's own columns name.
|
|
304
|
+
amount: cardAmountFor(currencyAttribute, entry.values),
|
|
305
|
+
currencyCode: currencyAttribute
|
|
306
|
+
? currencyCodeOf(currencyAttribute.config)
|
|
307
|
+
: null,
|
|
308
|
+
attributes: shown.map((attribute) => ({
|
|
309
|
+
slug: attribute.apiSlug,
|
|
310
|
+
label: attribute.label,
|
|
311
|
+
attributeType: attribute.attributeType,
|
|
312
|
+
multi: attribute.multi,
|
|
313
|
+
options: toBoardOptions(attribute),
|
|
314
|
+
config: attribute.config,
|
|
315
|
+
value: entry.values[attribute.apiSlug] ?? null,
|
|
316
|
+
})),
|
|
317
|
+
actorType: toActorType(entry.createdByActorType),
|
|
318
|
+
};
|
|
319
|
+
});
|
|
320
|
+
}, [entries, groupSlug, currencyAttribute?.id, shownKey]);
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
isLoading: attributesQuery.isLoading || entriesQuery.isLoading,
|
|
324
|
+
error: filterError ?? attributesQuery.error ?? entriesQuery.error,
|
|
325
|
+
statusAttributes: statusAttributesOf(attributes),
|
|
326
|
+
groupAttribute,
|
|
327
|
+
options: toBoardOptions(groupAttribute),
|
|
328
|
+
cards,
|
|
329
|
+
complete: entriesQuery.data?.complete ?? true,
|
|
330
|
+
refetch: () => void entriesQuery.refetch(),
|
|
331
|
+
commit: ({ card, toValue }) =>
|
|
332
|
+
callAction(
|
|
333
|
+
"update-crm-list-entry" as never,
|
|
334
|
+
{
|
|
335
|
+
entryId: card.id,
|
|
336
|
+
values: { [groupSlug]: moveValueForColumn(toValue) },
|
|
337
|
+
} as never,
|
|
338
|
+
),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// Object target
|
|
344
|
+
//
|
|
345
|
+
// A record summary carries no attribute values, so a column is one filtered
|
|
346
|
+
// `list-crm-records` call rather than one page grouped in the browser. Each
|
|
347
|
+
// column is therefore a real server-side result, not a slice of one page.
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
function useObjectBoard(props: CrmBoardProps, enabled: boolean): BoardData {
|
|
351
|
+
const attributesQuery = useActionQuery<AttributesResponse>(
|
|
352
|
+
"list-crm-attributes" as never,
|
|
353
|
+
{ target: "object", targetId: props.target.id, limit: 200 } as never,
|
|
354
|
+
{ enabled },
|
|
355
|
+
);
|
|
356
|
+
const attributes = attributesQuery.data?.attributes ?? [];
|
|
357
|
+
const groupAttribute = pickGroupAttribute(
|
|
358
|
+
attributes,
|
|
359
|
+
props.groupByAttributeId,
|
|
360
|
+
);
|
|
361
|
+
const optionKey = JSON.stringify(
|
|
362
|
+
toBoardOptions(groupAttribute)
|
|
363
|
+
.filter((option) => !option.archived)
|
|
364
|
+
.map((option) => option.value),
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
const buckets = useMemo<Array<{ key: string; value: string | null }>>(() => {
|
|
368
|
+
const values = JSON.parse(optionKey) as string[];
|
|
369
|
+
return [
|
|
370
|
+
...values.map((value) => ({ key: value, value })),
|
|
371
|
+
{ key: BOARD_UNGROUPED, value: null },
|
|
372
|
+
];
|
|
373
|
+
}, [optionKey]);
|
|
374
|
+
|
|
375
|
+
const groupAttributeId = groupAttribute?.id ?? "";
|
|
376
|
+
const filterKey = JSON.stringify(props.filter ?? null);
|
|
377
|
+
const results = useQueries({
|
|
378
|
+
queries: buckets.map((bucket) => ({
|
|
379
|
+
queryKey: [
|
|
380
|
+
"crm-board-records",
|
|
381
|
+
props.target.id,
|
|
382
|
+
groupAttributeId,
|
|
383
|
+
bucket.key,
|
|
384
|
+
filterKey,
|
|
385
|
+
],
|
|
386
|
+
enabled: enabled && Boolean(groupAttributeId),
|
|
387
|
+
queryFn: () =>
|
|
388
|
+
callAction<RecordsResponse>(
|
|
389
|
+
"list-crm-records" as never,
|
|
390
|
+
{
|
|
391
|
+
...(props.target.recordKind
|
|
392
|
+
? { kind: props.target.recordKind }
|
|
393
|
+
: {}),
|
|
394
|
+
filter: {
|
|
395
|
+
op: "and",
|
|
396
|
+
conditions: [
|
|
397
|
+
...conditionsOf(props.filter),
|
|
398
|
+
{
|
|
399
|
+
attributeId: groupAttributeId,
|
|
400
|
+
condition: bucket.value === null ? "is-empty" : "is",
|
|
401
|
+
...(bucket.value === null ? {} : { value: bucket.value }),
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
},
|
|
405
|
+
limit: PAGE_LIMIT,
|
|
406
|
+
} as never,
|
|
407
|
+
),
|
|
408
|
+
})),
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const dataKey = results.map((result) => result.dataUpdatedAt).join(" ");
|
|
412
|
+
const cards = useMemo<BoardCard[]>(
|
|
413
|
+
() =>
|
|
414
|
+
results.flatMap((result, index) => {
|
|
415
|
+
const bucket = buckets[index];
|
|
416
|
+
if (!bucket) return [];
|
|
417
|
+
return (result.data?.records ?? []).map((record) => ({
|
|
418
|
+
id: record.id,
|
|
419
|
+
recordId: record.id,
|
|
420
|
+
title: record.displayName,
|
|
421
|
+
subtitle: record.subtitle ?? null,
|
|
422
|
+
owner: record.owner ?? null,
|
|
423
|
+
remoteRevision: record.remoteRevision,
|
|
424
|
+
groupValue: bucket.value ?? BOARD_UNGROUPED,
|
|
425
|
+
// A record summary reports no `active_from` for the status value, so
|
|
426
|
+
// the SLA stays `unknown` rather than being derived from `updatedAt`.
|
|
427
|
+
groupSince: null,
|
|
428
|
+
amount: null,
|
|
429
|
+
currencyCode: null,
|
|
430
|
+
attributes: [],
|
|
431
|
+
actorType: null,
|
|
432
|
+
}));
|
|
433
|
+
}),
|
|
434
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
435
|
+
[dataKey, buckets],
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
const groupSlug = groupAttribute?.apiSlug ?? "";
|
|
439
|
+
return {
|
|
440
|
+
isLoading:
|
|
441
|
+
attributesQuery.isLoading || results.some((result) => result.isLoading),
|
|
442
|
+
error:
|
|
443
|
+
attributesQuery.error ?? results.find((result) => result.error)?.error,
|
|
444
|
+
statusAttributes: statusAttributesOf(attributes),
|
|
445
|
+
groupAttribute,
|
|
446
|
+
options: toBoardOptions(groupAttribute),
|
|
447
|
+
cards,
|
|
448
|
+
complete: results.every((result) => result.data?.complete ?? true),
|
|
449
|
+
refetch: () => {
|
|
450
|
+
for (const result of results) void result.refetch();
|
|
451
|
+
},
|
|
452
|
+
commit: ({ card, toValue }) =>
|
|
453
|
+
callAction(
|
|
454
|
+
"update-crm-record" as never,
|
|
455
|
+
objectBoardMoveArgs(card, groupSlug, toValue) as never,
|
|
456
|
+
),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function conditionsOf(filter: unknown): unknown[] {
|
|
461
|
+
if (!filter || typeof filter !== "object") return [];
|
|
462
|
+
const conditions = (filter as { conditions?: unknown }).conditions;
|
|
463
|
+
return Array.isArray(conditions) ? conditions : [];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
// Board
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
export function CrmBoard(props: CrmBoardProps) {
|
|
471
|
+
const t = useT();
|
|
472
|
+
const isList = props.target.kind === "list";
|
|
473
|
+
const listBoard = useListBoard(props, isList);
|
|
474
|
+
const objectBoard = useObjectBoard(props, !isList);
|
|
475
|
+
const data = isList ? listBoard : objectBoard;
|
|
476
|
+
|
|
477
|
+
const [optimistic, setOptimistic] = useState<BoardCard[] | null>(null);
|
|
478
|
+
useEffect(() => setOptimistic(null), [data.cards]);
|
|
479
|
+
|
|
480
|
+
const onGrouping = props.onGrouping;
|
|
481
|
+
const statusAttributes = data.statusAttributes;
|
|
482
|
+
const statusKey = statusAttributes.map((attribute) => attribute.id).join(",");
|
|
483
|
+
const groupAttributeId = data.groupAttribute?.id ?? null;
|
|
484
|
+
useEffect(() => {
|
|
485
|
+
onGrouping?.({
|
|
486
|
+
statusAttributes: statusAttributes.map((attribute) => ({
|
|
487
|
+
id: attribute.id,
|
|
488
|
+
label: attribute.label,
|
|
489
|
+
})),
|
|
490
|
+
groupAttributeId,
|
|
491
|
+
});
|
|
492
|
+
// `statusKey` stands in for the attribute array's identity.
|
|
493
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
494
|
+
}, [onGrouping, statusKey, groupAttributeId]);
|
|
495
|
+
|
|
496
|
+
const cards = optimistic ?? data.cards;
|
|
497
|
+
const columns = useMemo(
|
|
498
|
+
() => boardColumns(cards, data.options),
|
|
499
|
+
[cards, data.options],
|
|
500
|
+
);
|
|
501
|
+
// One clock per rendered board so every card's age is measured consistently.
|
|
502
|
+
const now = useMemo(() => new Date(), [cards]);
|
|
503
|
+
const overruns = useMemo(() => boardOverruns(columns, now), [columns, now]);
|
|
504
|
+
|
|
505
|
+
async function move(cardId: string, toValue: string) {
|
|
506
|
+
const result = await moveBoardCard({
|
|
507
|
+
cards,
|
|
508
|
+
cardId,
|
|
509
|
+
toValue,
|
|
510
|
+
now: new Date().toISOString(),
|
|
511
|
+
apply: setOptimistic,
|
|
512
|
+
commit: data.commit,
|
|
513
|
+
});
|
|
514
|
+
if (result.error) {
|
|
515
|
+
toast.error(
|
|
516
|
+
result.error instanceof Error
|
|
517
|
+
? result.error.message
|
|
518
|
+
: t("board.moveFailed"),
|
|
519
|
+
);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (result.moved) data.refetch();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (data.error) {
|
|
526
|
+
return (
|
|
527
|
+
<BoardNotice
|
|
528
|
+
message={
|
|
529
|
+
data.error instanceof Error
|
|
530
|
+
? data.error.message
|
|
531
|
+
: t("board.loadFailed")
|
|
532
|
+
}
|
|
533
|
+
onRetry={data.refetch}
|
|
534
|
+
/>
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (data.isLoading) return <LoadingRows rows={4} />;
|
|
538
|
+
if (!data.groupAttribute) {
|
|
539
|
+
return <BoardNotice message={t("board.noStatusAttribute")} />;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return (
|
|
543
|
+
<div className="flex min-h-0 flex-col">
|
|
544
|
+
<div className="flex flex-wrap items-center gap-2 px-5 pt-4 sm:px-7">
|
|
545
|
+
<span className="text-xs text-muted-foreground">
|
|
546
|
+
{t("board.groupedBy", { attribute: data.groupAttribute.label })}
|
|
547
|
+
</span>
|
|
548
|
+
{overruns.length ? (
|
|
549
|
+
<Badge
|
|
550
|
+
variant="outline"
|
|
551
|
+
className="gap-1 border-amber-500/40 font-normal text-amber-700 dark:text-amber-400"
|
|
552
|
+
>
|
|
553
|
+
<IconAlertTriangle className="size-3.5" />
|
|
554
|
+
{t("board.overrunSummary", { count: overruns.length })}
|
|
555
|
+
</Badge>
|
|
556
|
+
) : null}
|
|
557
|
+
{data.complete ? null : (
|
|
558
|
+
<span className="text-xs text-muted-foreground">
|
|
559
|
+
{t("board.partialPage", { limit: PAGE_LIMIT })}
|
|
560
|
+
</span>
|
|
561
|
+
)}
|
|
562
|
+
</div>
|
|
563
|
+
{props.mode === "table" ? (
|
|
564
|
+
<BoardTable columns={columns} now={now} />
|
|
565
|
+
) : (
|
|
566
|
+
<div className="flex gap-3 overflow-x-auto p-5 sm:p-7">
|
|
567
|
+
{columns.map((column, index) => (
|
|
568
|
+
<BoardColumnView
|
|
569
|
+
key={column.key}
|
|
570
|
+
column={column}
|
|
571
|
+
now={now}
|
|
572
|
+
onDropCard={(cardId) => void move(cardId, column.key)}
|
|
573
|
+
onMoveCard={(cardId, offset) => {
|
|
574
|
+
const next = columns[index + offset];
|
|
575
|
+
if (next) void move(cardId, next.key);
|
|
576
|
+
}}
|
|
577
|
+
/>
|
|
578
|
+
))}
|
|
579
|
+
</div>
|
|
580
|
+
)}
|
|
581
|
+
</div>
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function BoardNotice({
|
|
586
|
+
message,
|
|
587
|
+
onRetry,
|
|
588
|
+
}: {
|
|
589
|
+
message: string;
|
|
590
|
+
onRetry?: () => void;
|
|
591
|
+
}) {
|
|
592
|
+
const t = useT();
|
|
593
|
+
return (
|
|
594
|
+
<div className="m-5 flex flex-wrap items-center gap-3 rounded-lg border border-border/70 bg-card p-4 sm:m-7">
|
|
595
|
+
<IconAlertTriangle className="size-4 text-muted-foreground" />
|
|
596
|
+
<p className="text-sm text-muted-foreground">{message}</p>
|
|
597
|
+
{onRetry ? (
|
|
598
|
+
<Button size="sm" variant="outline" onClick={onRetry}>
|
|
599
|
+
{t("board.retry")}
|
|
600
|
+
</Button>
|
|
601
|
+
) : null}
|
|
602
|
+
</div>
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function BoardColumnView({
|
|
607
|
+
column,
|
|
608
|
+
now,
|
|
609
|
+
onDropCard,
|
|
610
|
+
onMoveCard,
|
|
611
|
+
}: {
|
|
612
|
+
column: BoardColumn;
|
|
613
|
+
now: Date;
|
|
614
|
+
onDropCard: (cardId: string) => void;
|
|
615
|
+
onMoveCard: (cardId: string, offset: -1 | 1) => void;
|
|
616
|
+
}) {
|
|
617
|
+
const t = useT();
|
|
618
|
+
const [isOver, setIsOver] = useState(false);
|
|
619
|
+
const totals = boardColumnTotals(column.cards);
|
|
620
|
+
|
|
621
|
+
return (
|
|
622
|
+
<section
|
|
623
|
+
className={`flex w-72 shrink-0 flex-col rounded-lg border bg-muted/30 ${
|
|
624
|
+
isOver ? "border-primary/60 bg-primary/5" : "border-border/70"
|
|
625
|
+
}`}
|
|
626
|
+
onDragOver={(event) => {
|
|
627
|
+
event.preventDefault();
|
|
628
|
+
setIsOver(true);
|
|
629
|
+
}}
|
|
630
|
+
onDragLeave={() => setIsOver(false)}
|
|
631
|
+
onDrop={(event) => {
|
|
632
|
+
event.preventDefault();
|
|
633
|
+
setIsOver(false);
|
|
634
|
+
const cardId = event.dataTransfer.getData("text/plain");
|
|
635
|
+
if (cardId) onDropCard(cardId);
|
|
636
|
+
}}
|
|
637
|
+
>
|
|
638
|
+
<header className="flex flex-col gap-1 border-b border-border/60 px-3 py-2.5">
|
|
639
|
+
<div className="flex items-center justify-between gap-2">
|
|
640
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
641
|
+
{column.option?.color ? (
|
|
642
|
+
<span
|
|
643
|
+
aria-hidden
|
|
644
|
+
className="size-2 shrink-0 rounded-full"
|
|
645
|
+
style={{ backgroundColor: column.option.color }}
|
|
646
|
+
/>
|
|
647
|
+
) : null}
|
|
648
|
+
<p className="truncate text-sm font-medium">
|
|
649
|
+
{columnTitle(column, t)}
|
|
650
|
+
</p>
|
|
651
|
+
</div>
|
|
652
|
+
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
|
653
|
+
{totals.count}
|
|
654
|
+
</span>
|
|
655
|
+
</div>
|
|
656
|
+
<ColumnTotals totals={totals} />
|
|
657
|
+
</header>
|
|
658
|
+
<div className="flex min-h-24 flex-col gap-2 p-2">
|
|
659
|
+
{column.cards.map((card) => (
|
|
660
|
+
<BoardCardView
|
|
661
|
+
key={card.id}
|
|
662
|
+
card={card}
|
|
663
|
+
option={column.option}
|
|
664
|
+
columnLabel={columnTitle(column, t)}
|
|
665
|
+
now={now}
|
|
666
|
+
onMove={(offset) => onMoveCard(card.id, offset)}
|
|
667
|
+
/>
|
|
668
|
+
))}
|
|
669
|
+
{column.cards.length ? null : (
|
|
670
|
+
<p className="px-1 py-3 text-xs text-muted-foreground">
|
|
671
|
+
{t("board.columnEmpty")}
|
|
672
|
+
</p>
|
|
673
|
+
)}
|
|
674
|
+
</div>
|
|
675
|
+
</section>
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function ColumnTotals({
|
|
680
|
+
totals,
|
|
681
|
+
}: {
|
|
682
|
+
totals: ReturnType<typeof boardColumnTotals>;
|
|
683
|
+
}) {
|
|
684
|
+
const t = useT();
|
|
685
|
+
if (totals.mixedCurrency) {
|
|
686
|
+
return (
|
|
687
|
+
<p className="text-xs text-muted-foreground">
|
|
688
|
+
{t("board.mixedCurrency")}
|
|
689
|
+
</p>
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
if (totals.sum === null) return null;
|
|
693
|
+
return (
|
|
694
|
+
<p className="flex flex-wrap items-baseline gap-x-1.5 text-xs text-muted-foreground">
|
|
695
|
+
<span className="font-medium tabular-nums text-foreground">
|
|
696
|
+
{formatMoney(totals.sum, totals.currencyCode)}
|
|
697
|
+
</span>
|
|
698
|
+
{totals.withoutAmount ? (
|
|
699
|
+
<span>{t("board.withoutAmount", { count: totals.withoutAmount })}</span>
|
|
700
|
+
) : null}
|
|
701
|
+
</p>
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function BoardCardView({
|
|
706
|
+
card,
|
|
707
|
+
option,
|
|
708
|
+
columnLabel,
|
|
709
|
+
now,
|
|
710
|
+
onMove,
|
|
711
|
+
}: {
|
|
712
|
+
card: BoardCard;
|
|
713
|
+
option: BoardOption | null;
|
|
714
|
+
columnLabel: string;
|
|
715
|
+
now: Date;
|
|
716
|
+
onMove: (offset: -1 | 1) => void;
|
|
717
|
+
}) {
|
|
718
|
+
const t = useT();
|
|
719
|
+
const sla = boardCardSla(card, option, now);
|
|
720
|
+
const shown = card.attributes.flatMap((attribute) => {
|
|
721
|
+
const node = cardAttributeNode(attribute, t);
|
|
722
|
+
return node === null
|
|
723
|
+
? []
|
|
724
|
+
: [{ slug: attribute.slug, label: attribute.label, node }];
|
|
725
|
+
});
|
|
726
|
+
// The record/entry has no per-field provenance at this data layer, only who
|
|
727
|
+
// created it — so this is coarser than the grid's per-cell marker, but the
|
|
728
|
+
// same "quiet unless non-human" idiom applies.
|
|
729
|
+
const provenance: CrmCellProvenance | undefined = card.actorType
|
|
730
|
+
? { actorType: card.actorType, readable: true }
|
|
731
|
+
: undefined;
|
|
732
|
+
|
|
733
|
+
return (
|
|
734
|
+
<article
|
|
735
|
+
draggable
|
|
736
|
+
tabIndex={0}
|
|
737
|
+
aria-label={t("board.cardAria", {
|
|
738
|
+
name: card.title,
|
|
739
|
+
column: columnLabel,
|
|
740
|
+
})}
|
|
741
|
+
onDragStart={(event) => {
|
|
742
|
+
event.dataTransfer.setData("text/plain", card.id);
|
|
743
|
+
event.dataTransfer.effectAllowed = "move";
|
|
744
|
+
}}
|
|
745
|
+
onKeyDown={(event) => {
|
|
746
|
+
// Keyboard parity with the drag: a card can be moved without a mouse.
|
|
747
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
748
|
+
event.preventDefault();
|
|
749
|
+
onMove(event.key === "ArrowLeft" ? -1 : 1);
|
|
750
|
+
}
|
|
751
|
+
}}
|
|
752
|
+
className="group relative cursor-grab rounded-md border border-border/70 bg-card p-2.5 shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing"
|
|
753
|
+
>
|
|
754
|
+
{/* A zero-size anchor: `ProvenanceMarker`'s own wedge is absolutely
|
|
755
|
+
positioned at its containing block's bottom-right corner, so this is
|
|
756
|
+
what moves it to the card's top-right instead of the SLA badge's
|
|
757
|
+
corner in the footer. */}
|
|
758
|
+
<span className="absolute right-1.5 top-1.5 size-0">
|
|
759
|
+
<ProvenanceMarker provenance={provenance} attributeLabel={card.title} />
|
|
760
|
+
</span>
|
|
761
|
+
<div className="flex items-start gap-1.5">
|
|
762
|
+
<IconGripVertical className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/50" />
|
|
763
|
+
<div className="min-w-0 flex-1">
|
|
764
|
+
<Link
|
|
765
|
+
to={`/records/${encodeURIComponent(card.recordId)}`}
|
|
766
|
+
className="block truncate text-sm font-medium hover:underline"
|
|
767
|
+
>
|
|
768
|
+
{card.title}
|
|
769
|
+
</Link>
|
|
770
|
+
{card.subtitle ? (
|
|
771
|
+
<p className="truncate text-xs text-muted-foreground">
|
|
772
|
+
{card.subtitle}
|
|
773
|
+
</p>
|
|
774
|
+
) : null}
|
|
775
|
+
{card.amount !== null ? (
|
|
776
|
+
<p className="mt-0.5 text-xs font-medium tabular-nums text-foreground">
|
|
777
|
+
{formatMoney(card.amount, card.currencyCode)}
|
|
778
|
+
</p>
|
|
779
|
+
) : null}
|
|
780
|
+
</div>
|
|
781
|
+
</div>
|
|
782
|
+
{shown.length ? (
|
|
783
|
+
<dl className="mt-2 grid gap-1">
|
|
784
|
+
{shown.map((attribute) => (
|
|
785
|
+
<div key={attribute.slug} className="flex items-baseline gap-2">
|
|
786
|
+
<dt className="w-20 shrink-0 truncate text-[11px] uppercase tracking-wide text-muted-foreground">
|
|
787
|
+
{attribute.label}
|
|
788
|
+
</dt>
|
|
789
|
+
<dd className="min-w-0 flex-1 truncate text-xs">
|
|
790
|
+
{attribute.node}
|
|
791
|
+
</dd>
|
|
792
|
+
</div>
|
|
793
|
+
))}
|
|
794
|
+
</dl>
|
|
795
|
+
) : null}
|
|
796
|
+
<footer className="mt-2 flex items-center justify-between gap-2">
|
|
797
|
+
<div className="flex min-w-0 items-center gap-1.5">
|
|
798
|
+
{card.owner ? (
|
|
799
|
+
<>
|
|
800
|
+
<span
|
|
801
|
+
aria-hidden
|
|
802
|
+
className="grid size-5 shrink-0 place-items-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground"
|
|
803
|
+
>
|
|
804
|
+
{initials(card.owner)}
|
|
805
|
+
</span>
|
|
806
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
807
|
+
{card.owner}
|
|
808
|
+
</span>
|
|
809
|
+
</>
|
|
810
|
+
) : null}
|
|
811
|
+
</div>
|
|
812
|
+
<SlaBadge sla={sla} />
|
|
813
|
+
</footer>
|
|
814
|
+
</article>
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* One card row's value, through the same per-type registry the grid and
|
|
820
|
+
* record panel use — never a second ad hoc formatter. `null` means the value
|
|
821
|
+
* is genuinely empty, so the row is omitted instead of showing a blank dash.
|
|
822
|
+
*/
|
|
823
|
+
function cardAttributeNode(
|
|
824
|
+
attribute: BoardCardAttribute,
|
|
825
|
+
t: Translate,
|
|
826
|
+
): ReactNode | null {
|
|
827
|
+
// `BoardCardAttribute.attributeType` stays a plain string so board-model.ts
|
|
828
|
+
// does not have to import the app's attribute-type union; the registry
|
|
829
|
+
// itself is the source of truth for which strings are valid.
|
|
830
|
+
const shape = attribute as unknown as CrmValueShape;
|
|
831
|
+
const value = attribute.value as CrmAttributeValue;
|
|
832
|
+
if (attribute.attributeType === "rating") {
|
|
833
|
+
return typeof value === "number" ? <AttributeRating value={value} /> : null;
|
|
834
|
+
}
|
|
835
|
+
if (
|
|
836
|
+
attribute.attributeType === "status" ||
|
|
837
|
+
attribute.attributeType === "select"
|
|
838
|
+
) {
|
|
839
|
+
const tokens = valueTokens(shape, value);
|
|
840
|
+
if (!tokens.length) return null;
|
|
841
|
+
return (
|
|
842
|
+
<span className="flex flex-wrap items-center gap-1">
|
|
843
|
+
{tokens.map((token, index) => (
|
|
844
|
+
<AttributeOptionChip key={`${token.label}:${index}`} token={token} />
|
|
845
|
+
))}
|
|
846
|
+
</span>
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
if (attribute.attributeType === "checkbox") {
|
|
850
|
+
if (value !== true && value !== false) return null;
|
|
851
|
+
return value ? t("board.value.yes") : t("board.value.no");
|
|
852
|
+
}
|
|
853
|
+
const text = formatAttributeValue(shape, value);
|
|
854
|
+
return text || null;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function SlaBadge({ sla }: { sla: ReturnType<typeof boardCardSla> }) {
|
|
858
|
+
const t = useT();
|
|
859
|
+
if (sla.status === "not-tracked") return null;
|
|
860
|
+
if (sla.status === "unknown") {
|
|
861
|
+
return (
|
|
862
|
+
<span
|
|
863
|
+
className="text-muted-foreground/60"
|
|
864
|
+
title={t(`board.sla.${sla.reason}`)}
|
|
865
|
+
>
|
|
866
|
+
<IconClock className="size-3.5" />
|
|
867
|
+
</span>
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
if (sla.status === "within") {
|
|
871
|
+
return (
|
|
872
|
+
<span className="flex items-center gap-1 text-xs tabular-nums text-muted-foreground">
|
|
873
|
+
<IconClock className="size-3.5" />
|
|
874
|
+
{t("board.daysInStage", { days: sla.days })}
|
|
875
|
+
</span>
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
return (
|
|
879
|
+
<span
|
|
880
|
+
className="flex items-center gap-1 text-xs font-medium tabular-nums text-amber-700 dark:text-amber-400"
|
|
881
|
+
title={t("board.overrunDetail", {
|
|
882
|
+
days: sla.days,
|
|
883
|
+
overBy: sla.overBy,
|
|
884
|
+
targetDays: sla.targetDays,
|
|
885
|
+
})}
|
|
886
|
+
>
|
|
887
|
+
<IconClockExclamation className="size-3.5" />
|
|
888
|
+
{t("board.overBy", { overBy: sla.overBy })}
|
|
889
|
+
</span>
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function BoardTable({ columns, now }: { columns: BoardColumn[]; now: Date }) {
|
|
894
|
+
const t = useT();
|
|
895
|
+
const rows = columns.flatMap((column) =>
|
|
896
|
+
column.cards.map((card) => ({ card, column })),
|
|
897
|
+
);
|
|
898
|
+
return (
|
|
899
|
+
<div className="overflow-x-auto p-5 sm:p-7">
|
|
900
|
+
<Table>
|
|
901
|
+
<TableHeader>
|
|
902
|
+
<TableRow>
|
|
903
|
+
<TableHead>{t("board.table.record")}</TableHead>
|
|
904
|
+
<TableHead>{t("board.table.stage")}</TableHead>
|
|
905
|
+
<TableHead>{t("board.table.owner")}</TableHead>
|
|
906
|
+
<TableHead className="text-right">
|
|
907
|
+
{t("board.table.amount")}
|
|
908
|
+
</TableHead>
|
|
909
|
+
<TableHead className="text-right">
|
|
910
|
+
{t("board.table.timeInStage")}
|
|
911
|
+
</TableHead>
|
|
912
|
+
</TableRow>
|
|
913
|
+
</TableHeader>
|
|
914
|
+
<TableBody>
|
|
915
|
+
{rows.map(({ card, column }) => (
|
|
916
|
+
<TableRow key={card.id}>
|
|
917
|
+
<TableCell>
|
|
918
|
+
<Link
|
|
919
|
+
to={`/records/${encodeURIComponent(card.recordId)}`}
|
|
920
|
+
className="font-medium hover:underline"
|
|
921
|
+
>
|
|
922
|
+
{card.title}
|
|
923
|
+
</Link>
|
|
924
|
+
{card.subtitle ? (
|
|
925
|
+
<span className="ml-2 text-xs text-muted-foreground">
|
|
926
|
+
{card.subtitle}
|
|
927
|
+
</span>
|
|
928
|
+
) : null}
|
|
929
|
+
</TableCell>
|
|
930
|
+
<TableCell>{columnTitle(column, t)}</TableCell>
|
|
931
|
+
<TableCell>{card.owner ?? EMPTY_CELL}</TableCell>
|
|
932
|
+
<TableCell className="text-right tabular-nums">
|
|
933
|
+
{card.amount === null
|
|
934
|
+
? EMPTY_CELL
|
|
935
|
+
: formatMoney(card.amount, card.currencyCode)}
|
|
936
|
+
</TableCell>
|
|
937
|
+
<TableCell className="text-right">
|
|
938
|
+
<span className="inline-flex justify-end">
|
|
939
|
+
<SlaBadge sla={boardCardSla(card, column.option, now)} />
|
|
940
|
+
</span>
|
|
941
|
+
</TableCell>
|
|
942
|
+
</TableRow>
|
|
943
|
+
))}
|
|
944
|
+
</TableBody>
|
|
945
|
+
</Table>
|
|
946
|
+
</div>
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// ---------------------------------------------------------------------------
|
|
951
|
+
// Formatting
|
|
952
|
+
// ---------------------------------------------------------------------------
|
|
953
|
+
|
|
954
|
+
const EMPTY_CELL = "—";
|
|
955
|
+
|
|
956
|
+
type Translate = ReturnType<typeof useT>;
|
|
957
|
+
|
|
958
|
+
function columnTitle(column: BoardColumn, t: Translate): string {
|
|
959
|
+
if (column.kind === "unset") return t("board.column.unset");
|
|
960
|
+
if (column.kind === "unknown") {
|
|
961
|
+
return t("board.column.unknown", { value: column.key });
|
|
962
|
+
}
|
|
963
|
+
const title = column.option?.title ?? column.key;
|
|
964
|
+
return column.kind === "archived"
|
|
965
|
+
? t("board.column.archived", { title })
|
|
966
|
+
: title;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** Delegates to the one currency formatter in the shared registry. */
|
|
970
|
+
function formatMoney(amount: number, currencyCode: string | null): string {
|
|
971
|
+
return formatAttributeValue(
|
|
972
|
+
{
|
|
973
|
+
attributeType: "currency",
|
|
974
|
+
multi: false,
|
|
975
|
+
...(currencyCode ? { config: { currency: { code: currencyCode } } } : {}),
|
|
976
|
+
},
|
|
977
|
+
amount,
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function initials(name: string): string {
|
|
982
|
+
return name
|
|
983
|
+
.split(/\s+/)
|
|
984
|
+
.filter(Boolean)
|
|
985
|
+
.slice(0, 2)
|
|
986
|
+
.map((part) => part[0]?.toUpperCase() ?? "")
|
|
987
|
+
.join("");
|
|
988
|
+
}
|