@agent-native/core 0.123.0 → 0.123.1

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.
Files changed (263) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +16 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/templates-meta.ts +12 -0
  5. package/corpus/templates/crm/.agents/skills/agent-native-docs/SKILL.md +115 -0
  6. package/corpus/templates/crm/.agents/skills/agent-native-toolkit/SKILL.md +177 -0
  7. package/corpus/templates/crm/.agents/skills/crm/SKILL.md +205 -0
  8. package/corpus/templates/crm/.agents/skills/customizing-agent-native/SKILL.md +220 -0
  9. package/corpus/templates/crm/.agents/skills/feature-flags/SKILL.md +169 -0
  10. package/corpus/templates/crm/.agents/skills/sharing/SKILL.md +228 -0
  11. package/corpus/templates/crm/.agents/skills/storing-data/SKILL.md +178 -0
  12. package/corpus/templates/crm/.agents/skills/upgrade-agent-native/SKILL.md +122 -0
  13. package/corpus/templates/crm/.env.example +5 -0
  14. package/corpus/templates/crm/AGENTS.md +82 -0
  15. package/corpus/templates/crm/CHANGELOG.md +25 -0
  16. package/corpus/templates/crm/DEVELOPING.md +66 -0
  17. package/corpus/templates/crm/README.md +122 -0
  18. package/corpus/templates/crm/_gitignore +12 -0
  19. package/corpus/templates/crm/actions/_crm-action-utils.ts +98 -0
  20. package/corpus/templates/crm/actions/_crm-attribute-utils.ts +219 -0
  21. package/corpus/templates/crm/actions/_crm-dashboard.ts +69 -0
  22. package/corpus/templates/crm/actions/_crm-data-program-actions.ts +15 -0
  23. package/corpus/templates/crm/actions/_crm-list-utils.ts +775 -0
  24. package/corpus/templates/crm/actions/_crm-signal-utils.ts +79 -0
  25. package/corpus/templates/crm/actions/add-crm-record-to-list.ts +164 -0
  26. package/corpus/templates/crm/actions/apply-crm-proposals.ts +291 -0
  27. package/corpus/templates/crm/actions/archive-crm-attribute.ts +69 -0
  28. package/corpus/templates/crm/actions/attach-call-evidence.ts +170 -0
  29. package/corpus/templates/crm/actions/configure-crm-connection.ts +126 -0
  30. package/corpus/templates/crm/actions/configure-native-crm.ts +44 -0
  31. package/corpus/templates/crm/actions/create-crm-attribute.ts +350 -0
  32. package/corpus/templates/crm/actions/create-crm-list.ts +194 -0
  33. package/corpus/templates/crm/actions/create-crm-record.ts +148 -0
  34. package/corpus/templates/crm/actions/create-crm-signal-tracker.ts +58 -0
  35. package/corpus/templates/crm/actions/delete-crm-saved-view.ts +68 -0
  36. package/corpus/templates/crm/actions/delete-staged-dataset.ts +14 -0
  37. package/corpus/templates/crm/actions/estimate-crm-enrichment.ts +229 -0
  38. package/corpus/templates/crm/actions/find-crm-duplicates.ts +87 -0
  39. package/corpus/templates/crm/actions/get-crm-automation-recipe.ts +36 -0
  40. package/corpus/templates/crm/actions/get-crm-dashboard-panel.ts +35 -0
  41. package/corpus/templates/crm/actions/get-crm-dashboard.ts +14 -0
  42. package/corpus/templates/crm/actions/get-crm-overview.ts +13 -0
  43. package/corpus/templates/crm/actions/get-crm-pipeline-data.ts +61 -0
  44. package/corpus/templates/crm/actions/get-crm-record-page.ts +287 -0
  45. package/corpus/templates/crm/actions/get-crm-record.ts +75 -0
  46. package/corpus/templates/crm/actions/get-crm-workspace.ts +364 -0
  47. package/corpus/templates/crm/actions/install-crm-pipeline-dashboard.ts +81 -0
  48. package/corpus/templates/crm/actions/list-crm-attributes.ts +71 -0
  49. package/corpus/templates/crm/actions/list-crm-connections.ts +68 -0
  50. package/corpus/templates/crm/actions/list-crm-dashboard-revisions.ts +15 -0
  51. package/corpus/templates/crm/actions/list-crm-dashboards.ts +15 -0
  52. package/corpus/templates/crm/actions/list-crm-enrichment-slots.ts +28 -0
  53. package/corpus/templates/crm/actions/list-crm-list-entries.ts +192 -0
  54. package/corpus/templates/crm/actions/list-crm-lists.ts +80 -0
  55. package/corpus/templates/crm/actions/list-crm-proposals.ts +55 -0
  56. package/corpus/templates/crm/actions/list-crm-record-field-history.ts +201 -0
  57. package/corpus/templates/crm/actions/list-crm-record-values.ts +200 -0
  58. package/corpus/templates/crm/actions/list-crm-records.ts +103 -0
  59. package/corpus/templates/crm/actions/list-crm-saved-views.ts +66 -0
  60. package/corpus/templates/crm/actions/list-crm-signal-hits.ts +43 -0
  61. package/corpus/templates/crm/actions/list-crm-signal-trackers.ts +32 -0
  62. package/corpus/templates/crm/actions/list-crm-tasks.ts +22 -0
  63. package/corpus/templates/crm/actions/list-staged-datasets.ts +14 -0
  64. package/corpus/templates/crm/actions/list-workspace-connections.ts +47 -0
  65. package/corpus/templates/crm/actions/manage-crm-attribute-option.ts +272 -0
  66. package/corpus/templates/crm/actions/manage-crm-signal-tracker.ts +87 -0
  67. package/corpus/templates/crm/actions/manage-crm-task.ts +132 -0
  68. package/corpus/templates/crm/actions/merge-crm-records.ts +691 -0
  69. package/corpus/templates/crm/actions/navigate.ts +54 -0
  70. package/corpus/templates/crm/actions/provider-api-catalog.ts +22 -0
  71. package/corpus/templates/crm/actions/provider-api-docs.ts +54 -0
  72. package/corpus/templates/crm/actions/provider-api-request.ts +92 -0
  73. package/corpus/templates/crm/actions/query-staged-dataset.ts +44 -0
  74. package/corpus/templates/crm/actions/record-crm-call-insight.ts +160 -0
  75. package/corpus/templates/crm/actions/record-crm-smart-signal.ts +150 -0
  76. package/corpus/templates/crm/actions/remove-crm-list-entry.ts +79 -0
  77. package/corpus/templates/crm/actions/restore-crm-dashboard-revision.ts +23 -0
  78. package/corpus/templates/crm/actions/review-crm-signal.ts +31 -0
  79. package/corpus/templates/crm/actions/run-crm-attribute-fill.ts +443 -0
  80. package/corpus/templates/crm/actions/run-crm-enrichment.ts +647 -0
  81. package/corpus/templates/crm/actions/run-crm-saved-view-program.ts +103 -0
  82. package/corpus/templates/crm/actions/run-crm-signal-trackers.ts +265 -0
  83. package/corpus/templates/crm/actions/run.ts +7 -0
  84. package/corpus/templates/crm/actions/save-crm-dashboard.ts +51 -0
  85. package/corpus/templates/crm/actions/save-crm-saved-view.ts +296 -0
  86. package/corpus/templates/crm/actions/sync-crm.ts +146 -0
  87. package/corpus/templates/crm/actions/update-crm-attribute.ts +111 -0
  88. package/corpus/templates/crm/actions/update-crm-list-entry.ts +102 -0
  89. package/corpus/templates/crm/actions/update-crm-list.ts +97 -0
  90. package/corpus/templates/crm/actions/update-crm-record.ts +543 -0
  91. package/corpus/templates/crm/actions/view-screen.ts +324 -0
  92. package/corpus/templates/crm/agent-native.app-skill.json +69 -0
  93. package/corpus/templates/crm/app/components/crm/CreateCrmRecordDialog.tsx +158 -0
  94. package/corpus/templates/crm/app/components/crm/CrmDashboardPanel.tsx +78 -0
  95. package/corpus/templates/crm/app/components/crm/CrmSignalsPanel.tsx +220 -0
  96. package/corpus/templates/crm/app/components/crm/IntelligenceSettings.tsx +414 -0
  97. package/corpus/templates/crm/app/components/crm/RecordActions.tsx +648 -0
  98. package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +488 -0
  99. package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +232 -0
  100. package/corpus/templates/crm/app/components/crm/SavedViewDataProgram.tsx +93 -0
  101. package/corpus/templates/crm/app/components/crm/Surface.tsx +97 -0
  102. package/corpus/templates/crm/app/components/crm/WorkOverview.tsx +180 -0
  103. package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +988 -0
  104. package/corpus/templates/crm/app/components/crm/board/board-model.ts +399 -0
  105. package/corpus/templates/crm/app/components/crm/board/entry-filter.ts +150 -0
  106. package/corpus/templates/crm/app/components/crm/board/view-draft.ts +227 -0
  107. package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +1033 -0
  108. package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +620 -0
  109. package/corpus/templates/crm/app/components/crm/grid/clipboard.ts +84 -0
  110. package/corpus/templates/crm/app/components/crm/grid/model.ts +304 -0
  111. package/corpus/templates/crm/app/components/crm/grid/navigation.ts +220 -0
  112. package/corpus/templates/crm/app/components/crm/grid/query.ts +214 -0
  113. package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +192 -0
  114. package/corpus/templates/crm/app/components/crm/record/FieldHistory.tsx +179 -0
  115. package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +103 -0
  116. package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +235 -0
  117. package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +206 -0
  118. package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +245 -0
  119. package/corpus/templates/crm/app/components/crm/record/record-data.ts +603 -0
  120. package/corpus/templates/crm/app/components/crm/settings/AdvancedSettings.tsx +49 -0
  121. package/corpus/templates/crm/app/components/crm/settings/AttributeOptionsEditor.tsx +441 -0
  122. package/corpus/templates/crm/app/components/crm/settings/ConnectionSettings.tsx +169 -0
  123. package/corpus/templates/crm/app/components/crm/settings/FieldsSettings.tsx +935 -0
  124. package/corpus/templates/crm/app/components/crm/settings/ListsSettings.tsx +339 -0
  125. package/corpus/templates/crm/app/components/crm/settings/settings-admin.ts +377 -0
  126. package/corpus/templates/crm/app/components/crm/shared/AttributeValueParts.tsx +69 -0
  127. package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +591 -0
  128. package/corpus/templates/crm/app/components/layout/CrmCommandMenu.tsx +556 -0
  129. package/corpus/templates/crm/app/components/layout/CrmLayout.tsx +130 -0
  130. package/corpus/templates/crm/app/components/layout/CrmSidebar.tsx +407 -0
  131. package/corpus/templates/crm/app/components/layout/command-palette-search.ts +126 -0
  132. package/corpus/templates/crm/app/components/layout/crm-ui-intents.ts +41 -0
  133. package/corpus/templates/crm/app/components/layout/sidebar-collapse.ts +40 -0
  134. package/corpus/templates/crm/app/components/layout/sidebar-lists.ts +109 -0
  135. package/corpus/templates/crm/app/components/ui/alert-dialog.tsx +1 -0
  136. package/corpus/templates/crm/app/components/ui/badge.tsx +1 -0
  137. package/corpus/templates/crm/app/components/ui/button.tsx +1 -0
  138. package/corpus/templates/crm/app/components/ui/card.tsx +1 -0
  139. package/corpus/templates/crm/app/components/ui/checkbox.tsx +1 -0
  140. package/corpus/templates/crm/app/components/ui/dialog.tsx +1 -0
  141. package/corpus/templates/crm/app/components/ui/dropdown-menu.tsx +1 -0
  142. package/corpus/templates/crm/app/components/ui/hover-card.tsx +1 -0
  143. package/corpus/templates/crm/app/components/ui/input.tsx +1 -0
  144. package/corpus/templates/crm/app/components/ui/label.tsx +1 -0
  145. package/corpus/templates/crm/app/components/ui/popover.tsx +1 -0
  146. package/corpus/templates/crm/app/components/ui/select.tsx +1 -0
  147. package/corpus/templates/crm/app/components/ui/sheet.tsx +1 -0
  148. package/corpus/templates/crm/app/components/ui/skeleton.tsx +1 -0
  149. package/corpus/templates/crm/app/components/ui/switch.tsx +1 -0
  150. package/corpus/templates/crm/app/components/ui/table.tsx +1 -0
  151. package/corpus/templates/crm/app/components/ui/tabs.tsx +1 -0
  152. package/corpus/templates/crm/app/components/ui/textarea.tsx +1 -0
  153. package/corpus/templates/crm/app/components/ui/toolkit-provider.tsx +17 -0
  154. package/corpus/templates/crm/app/components/ui/tooltip.tsx +1 -0
  155. package/corpus/templates/crm/app/design-system.ts +3 -0
  156. package/corpus/templates/crm/app/entry.client.tsx +19 -0
  157. package/corpus/templates/crm/app/entry.server.tsx +10 -0
  158. package/corpus/templates/crm/app/global.css +167 -0
  159. package/corpus/templates/crm/app/hooks/use-keyboard-shortcuts.ts +206 -0
  160. package/corpus/templates/crm/app/hooks/use-navigation-state.ts +48 -0
  161. package/corpus/templates/crm/app/i18n/en-US.ts +684 -0
  162. package/corpus/templates/crm/app/i18n/index.ts +34 -0
  163. package/corpus/templates/crm/app/lib/dashboard.ts +13 -0
  164. package/corpus/templates/crm/app/lib/navigation.ts +17 -0
  165. package/corpus/templates/crm/app/lib/tab-id.ts +1 -0
  166. package/corpus/templates/crm/app/lib/types.ts +172 -0
  167. package/corpus/templates/crm/app/lib/utils.ts +1 -0
  168. package/corpus/templates/crm/app/root.tsx +93 -0
  169. package/corpus/templates/crm/app/routes/_index.tsx +26 -0
  170. package/corpus/templates/crm/app/routes/accounts.tsx +41 -0
  171. package/corpus/templates/crm/app/routes/agent.tsx +5 -0
  172. package/corpus/templates/crm/app/routes/ask.tsx +53 -0
  173. package/corpus/templates/crm/app/routes/dashboard.tsx +118 -0
  174. package/corpus/templates/crm/app/routes/lists.tsx +228 -0
  175. package/corpus/templates/crm/app/routes/opportunities.tsx +41 -0
  176. package/corpus/templates/crm/app/routes/people.tsx +41 -0
  177. package/corpus/templates/crm/app/routes/proposals.tsx +388 -0
  178. package/corpus/templates/crm/app/routes/records.$recordId.tsx +53 -0
  179. package/corpus/templates/crm/app/routes/records.tsx +64 -0
  180. package/corpus/templates/crm/app/routes/settings.tsx +118 -0
  181. package/corpus/templates/crm/app/routes/setup.tsx +308 -0
  182. package/corpus/templates/crm/app/routes/tasks.tsx +252 -0
  183. package/corpus/templates/crm/app/routes/views.tsx +683 -0
  184. package/corpus/templates/crm/app/routes.ts +19 -0
  185. package/corpus/templates/crm/app/vite-env.d.ts +6 -0
  186. 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
  187. package/corpus/templates/crm/changelog/2026-07-26-crm-now-finds-likely-duplicate-records-and-tells-you-exactly.md +6 -0
  188. package/corpus/templates/crm/changelog/2026-07-26-enrichment-now-shows-what-a-run-will-cost-before-it-starts-g.md +6 -0
  189. package/corpus/templates/crm/changelog/2026-07-26-lists-turn-any-set-of-records-into-a-pipeline-with-its-own-s.md +6 -0
  190. package/corpus/templates/crm/changelog/2026-07-26-moving-a-record-or-list-entry-into-a-stage-that-was-retired-.md +6 -0
  191. package/corpus/templates/crm/changelog/2026-07-26-opening-a-record-now-shows-one-page-with-its-fields-the-list.md +6 -0
  192. 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
  193. package/corpus/templates/crm/changelog/2026-07-26-provider-record-edits-now-show-an-exact-before-after-diff-an.md +6 -0
  194. package/corpus/templates/crm/changelog/2026-07-26-record-page-attribute-labels-now-read-in-full-instead-of-bei.md +6 -0
  195. package/corpus/templates/crm/changelog/2026-07-26-records-now-use-typed-fields-currency-dates-ratings-status-s.md +6 -0
  196. package/corpus/templates/crm/changelog/2026-07-26-retyping-a-value-on-the-record-page-now-replaces-it-instead-.md +6 -0
  197. package/corpus/templates/crm/changelog/2026-07-26-settings-now-has-fields-and-lists-tabs-for-adding-fields-edi.md +6 -0
  198. package/corpus/templates/crm/changelog/2026-07-26-the-proposal-review-now-shows-the-field-that-actually-change.md +6 -0
  199. package/corpus/templates/crm/changelog/2026-07-26-the-record-grid-is-now-a-real-spreadsheet-filter-sort-and-pa.md +6 -0
  200. package/corpus/templates/crm/components.json +20 -0
  201. package/corpus/templates/crm/docs/architecture/crm-contract.md +214 -0
  202. package/corpus/templates/crm/docs/features/README.md +55 -0
  203. package/corpus/templates/crm/docs/features/f1-records-and-fields.mdx +52 -0
  204. package/corpus/templates/crm/docs/features/f2-lists-pipelines-and-views.mdx +48 -0
  205. package/corpus/templates/crm/docs/features/f3-work-and-record-quality.mdx +40 -0
  206. package/corpus/templates/crm/docs/features/f4-evidence-and-chat.mdx +47 -0
  207. package/corpus/templates/crm/learnings.defaults.md +5 -0
  208. package/corpus/templates/crm/netlify.toml +11 -0
  209. package/corpus/templates/crm/package.json +61 -0
  210. package/corpus/templates/crm/public/favicon.svg +1 -0
  211. package/corpus/templates/crm/public/icon-180.svg +1 -0
  212. package/corpus/templates/crm/public/manifest.json +11 -0
  213. package/corpus/templates/crm/react-router.config.ts +7 -0
  214. package/corpus/templates/crm/server/crm/adapter.ts +32 -0
  215. package/corpus/templates/crm/server/crm/crm-field-firewall.ts +64 -0
  216. package/corpus/templates/crm/server/crm/crm-mirror.ts +721 -0
  217. package/corpus/templates/crm/server/crm/field-policy-attributes.ts +48 -0
  218. package/corpus/templates/crm/server/crm/hubspot-adapter.ts +1146 -0
  219. package/corpus/templates/crm/server/crm/native-adapter.ts +1810 -0
  220. package/corpus/templates/crm/server/crm/provider-record-link.ts +264 -0
  221. package/corpus/templates/crm/server/crm/read-through.ts +207 -0
  222. package/corpus/templates/crm/server/crm/salesforce-adapter.ts +1255 -0
  223. package/corpus/templates/crm/server/db/crm-store.ts +1112 -0
  224. package/corpus/templates/crm/server/db/index.ts +191 -0
  225. package/corpus/templates/crm/server/db/schema.ts +647 -0
  226. package/corpus/templates/crm/server/lib/crm-query.ts +1558 -0
  227. package/corpus/templates/crm/server/lib/dedupe.ts +714 -0
  228. package/corpus/templates/crm/server/lib/enrichment-cost.ts +381 -0
  229. package/corpus/templates/crm/server/lib/enrichment-slots.ts +519 -0
  230. package/corpus/templates/crm/server/lib/intelligence/default-detectors.ts +65 -0
  231. package/corpus/templates/crm/server/lib/intelligence/evidence.ts +105 -0
  232. package/corpus/templates/crm/server/lib/intelligence/keyword-detector.ts +94 -0
  233. package/corpus/templates/crm/server/lib/intelligence/smart-detector.ts +39 -0
  234. package/corpus/templates/crm/server/lib/intelligence/summary.ts +19 -0
  235. package/corpus/templates/crm/server/lib/lifecycle.ts +634 -0
  236. package/corpus/templates/crm/server/lib/provider-api.ts +64 -0
  237. package/corpus/templates/crm/server/middleware/auth.ts +4 -0
  238. package/corpus/templates/crm/server/plugins/agent-chat.ts +65 -0
  239. package/corpus/templates/crm/server/plugins/auth.ts +14 -0
  240. package/corpus/templates/crm/server/plugins/core-routes.ts +17 -0
  241. package/corpus/templates/crm/server/plugins/db.ts +738 -0
  242. package/corpus/templates/crm/server/routes/[...page].get.ts +5 -0
  243. package/corpus/templates/crm/shared/crm-attributes.ts +469 -0
  244. package/corpus/templates/crm/shared/crm-automation-recipes.ts +132 -0
  245. package/corpus/templates/crm/shared/crm-contract.ts +417 -0
  246. package/corpus/templates/crm/shared/crm-navigation.ts +190 -0
  247. package/corpus/templates/crm/shared/crm-sales-config.ts +63 -0
  248. package/corpus/templates/crm/ssr-entry.ts +11 -0
  249. package/corpus/templates/crm/tsconfig.json +24 -0
  250. package/corpus/templates/crm/vite.config.ts +15 -0
  251. package/corpus/templates/crm/vitest.config.ts +5 -0
  252. package/corpus/templates/design/app/i18n-data.ts +12 -12
  253. package/corpus/templates/design/changelog/2026-07-26-apply-design-updates-from-a-centered-top-toolbar-without-cov.md +6 -0
  254. package/dist/cli/templates-meta.d.ts.map +1 -1
  255. package/dist/cli/templates-meta.js +12 -0
  256. package/dist/cli/templates-meta.js.map +1 -1
  257. package/dist/collab/routes.d.ts +1 -1
  258. package/dist/observability/routes.d.ts +3 -3
  259. package/dist/resources/handlers.d.ts +1 -1
  260. package/dist/server/realtime-token.d.ts +1 -1
  261. package/dist/server/transcribe-voice.d.ts +1 -1
  262. package/package.json +1 -1
  263. package/src/cli/templates-meta.ts +12 -0
@@ -0,0 +1,1033 @@
1
+ import { useT } from "@agent-native/core/client/i18n";
2
+ import {
3
+ IconArrowDown,
4
+ IconArrowUp,
5
+ IconColumns3,
6
+ IconDatabaseOff,
7
+ IconDotsVertical,
8
+ IconEyeOff,
9
+ IconListCheck,
10
+ IconPlus,
11
+ IconRefresh,
12
+ IconTrash,
13
+ IconX,
14
+ } from "@tabler/icons-react";
15
+ import {
16
+ flexRender,
17
+ getCoreRowModel,
18
+ useReactTable,
19
+ type ColumnDef,
20
+ } from "@tanstack/react-table";
21
+ import { useVirtualizer } from "@tanstack/react-virtual";
22
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
23
+ import { Link } from "react-router";
24
+ import { toast } from "sonner";
25
+
26
+ import {
27
+ AlertDialog,
28
+ AlertDialogAction,
29
+ AlertDialogCancel,
30
+ AlertDialogContent,
31
+ AlertDialogDescription,
32
+ AlertDialogFooter,
33
+ AlertDialogHeader,
34
+ AlertDialogTitle,
35
+ } from "@/components/ui/alert-dialog";
36
+ import { Button } from "@/components/ui/button";
37
+ import { Checkbox } from "@/components/ui/checkbox";
38
+ import {
39
+ DropdownMenu,
40
+ DropdownMenuCheckboxItem,
41
+ DropdownMenuContent,
42
+ DropdownMenuItem,
43
+ DropdownMenuLabel,
44
+ DropdownMenuSeparator,
45
+ DropdownMenuTrigger,
46
+ } from "@/components/ui/dropdown-menu";
47
+ import { Input } from "@/components/ui/input";
48
+ import {
49
+ Popover,
50
+ PopoverContent,
51
+ PopoverTrigger,
52
+ } from "@/components/ui/popover";
53
+ import { Skeleton } from "@/components/ui/skeleton";
54
+ import { cn } from "@/lib/utils";
55
+
56
+ import { decodeTsv, encodeTsv } from "./clipboard";
57
+ import { CellDisplay, CellEditor, ProvenanceMarker } from "./GridCell";
58
+ import {
59
+ cellSpecFor,
60
+ copyCell,
61
+ isCellEditable,
62
+ isSuppressedDisplayNameCell,
63
+ parseCell,
64
+ type CrmCellValue,
65
+ type CrmGridAttribute,
66
+ type CrmGridRow,
67
+ } from "./model";
68
+ import {
69
+ applyMove,
70
+ isInRange,
71
+ jumpCell,
72
+ moveCell,
73
+ pasteTargets,
74
+ resolveGridKey,
75
+ selectionRange,
76
+ type CellRef,
77
+ type GridDirection,
78
+ type GridSelection,
79
+ } from "./navigation";
80
+ import {
81
+ moveGridColumn,
82
+ resolveGridColumns,
83
+ setGridColumnHidden,
84
+ setGridColumnWidth,
85
+ type CrmGridColumn,
86
+ type CrmGridSortEntry,
87
+ } from "./query";
88
+
89
+ const ROW_HEIGHT = 34;
90
+ const SELECT_WIDTH = 36;
91
+ const NAME_WIDTH = 240;
92
+ const DEFAULT_WIDTH = 200;
93
+ /** Rows from the bottom of the loaded set at which the next page is requested. */
94
+ const PREFETCH_ROWS = 8;
95
+
96
+ export interface CrmGridCommit {
97
+ row: CrmGridRow;
98
+ attribute: CrmGridAttribute;
99
+ value: CrmCellValue;
100
+ }
101
+
102
+ export interface CrmGridProps {
103
+ attributes: CrmGridAttribute[];
104
+ rows: CrmGridRow[];
105
+ columns: CrmGridColumn[];
106
+ onColumnsChange: (next: CrmGridColumn[]) => void;
107
+ sort: CrmGridSortEntry[];
108
+ onSortChange: (next: CrmGridSortEntry[]) => void;
109
+ isLoading: boolean;
110
+ isFetchingNextPage?: boolean;
111
+ hasNextPage?: boolean;
112
+ onLoadMore?: () => void;
113
+ error?: unknown;
114
+ onRetry?: () => void;
115
+ emptyTitle: string;
116
+ emptyDescription?: string;
117
+ /** Header for the fixed record-name column. */
118
+ nameLabel: string;
119
+ /** Href for one row's record page; the name column links to it when given. */
120
+ rowHref?: (row: CrmGridRow) => string;
121
+ onCommitCell: (commit: CrmGridCommit) => Promise<void>;
122
+ onAddAttribute?: () => void;
123
+ onAddToList?: (rowIds: string[]) => void;
124
+ onDeleteRows?: (rowIds: string[]) => Promise<void>;
125
+ now?: Date;
126
+ }
127
+
128
+ function sameValue(a: CrmCellValue, b: CrmCellValue): boolean {
129
+ if (a === b) return true;
130
+ if (a === null || b === null) return false;
131
+ if (typeof a === "object" || typeof b === "object") {
132
+ return JSON.stringify(a) === JSON.stringify(b);
133
+ }
134
+ return false;
135
+ }
136
+
137
+ export function CrmGrid(props: CrmGridProps) {
138
+ const t = useT();
139
+ const scrollRef = useRef<HTMLDivElement>(null);
140
+ const [selection, setSelection] = useState<GridSelection | null>(null);
141
+ const [editing, setEditing] = useState<{
142
+ ref: CellRef;
143
+ seed?: string;
144
+ } | null>(null);
145
+ const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
146
+ const [confirmDelete, setConfirmDelete] = useState(false);
147
+
148
+ const ordered = useMemo(
149
+ () => resolveGridColumns(props.columns, props.attributes),
150
+ [props.columns, props.attributes],
151
+ );
152
+ const visible = useMemo(
153
+ () =>
154
+ ordered
155
+ .filter((column) => !column.hidden)
156
+ .flatMap((column) => {
157
+ const attribute = props.attributes.find(
158
+ (candidate) => candidate.apiSlug === column.attributeId,
159
+ );
160
+ return attribute ? [{ column, attribute }] : [];
161
+ }),
162
+ [ordered, props.attributes],
163
+ );
164
+ const bounds = { rows: props.rows.length, cols: visible.length };
165
+
166
+ // -------------------------------------------------------------------------
167
+ // Writes
168
+ // -------------------------------------------------------------------------
169
+
170
+ const commit = useCallback(
171
+ async (ref: CellRef, raw: CrmCellValue, isRawText: boolean) => {
172
+ const row = props.rows[ref.row];
173
+ const target = visible[ref.col];
174
+ if (!row || !target) return false;
175
+ const { attribute } = target;
176
+ let value: CrmCellValue;
177
+ if (isRawText) {
178
+ const parsed = parseCell(attribute, String(raw ?? ""));
179
+ if (!parsed.ok) {
180
+ toast.error(
181
+ t(`grid.parse.${parsed.reason}`, {
182
+ attribute: attribute.label,
183
+ value: parsed.detail ?? "",
184
+ }),
185
+ );
186
+ return false;
187
+ }
188
+ value = parsed.value;
189
+ } else {
190
+ value = raw;
191
+ }
192
+ if (sameValue(row.values[attribute.apiSlug] ?? null, value)) return true;
193
+ await props.onCommitCell({ row, attribute, value });
194
+ return true;
195
+ },
196
+ [props.rows, props.onCommitCell, t, visible],
197
+ );
198
+
199
+ // -------------------------------------------------------------------------
200
+ // Clipboard
201
+ // -------------------------------------------------------------------------
202
+
203
+ function selectionTsv(): string {
204
+ if (!selection) return "";
205
+ const range = selectionRange(selection);
206
+ const grid: string[][] = [];
207
+ for (let row = range.top; row <= range.bottom; row++) {
208
+ const cells: string[] = [];
209
+ for (let col = range.left; col <= range.right; col++) {
210
+ const target = visible[col];
211
+ const record = props.rows[row];
212
+ cells.push(
213
+ target && record
214
+ ? copyCell(
215
+ target.attribute,
216
+ record.values[target.attribute.apiSlug] ?? null,
217
+ )
218
+ : "",
219
+ );
220
+ }
221
+ grid.push(cells);
222
+ }
223
+ return encodeTsv(grid);
224
+ }
225
+
226
+ async function pasteTsv(text: string) {
227
+ if (!selection) return;
228
+ const cells = decodeTsv(text);
229
+ if (!cells.length) return;
230
+ const range = pasteTargets({
231
+ selection,
232
+ bounds,
233
+ rows: cells.length,
234
+ cols: Math.max(...cells.map((row) => row.length)),
235
+ });
236
+ let written = 0;
237
+ let skipped = 0;
238
+ for (let row = range.top; row <= range.bottom; row++) {
239
+ for (let col = range.left; col <= range.right; col++) {
240
+ const source =
241
+ cells[(row - range.top) % cells.length]?.[
242
+ (col - range.left) % Math.max(1, cells[0]?.length ?? 1)
243
+ ];
244
+ if (source === undefined) continue;
245
+ const target = visible[col];
246
+ if (!target || !isCellEditable(target.attribute)) {
247
+ skipped += 1;
248
+ continue;
249
+ }
250
+ const ok = await commit({ row, col }, source, true);
251
+ if (ok) written += 1;
252
+ else skipped += 1;
253
+ }
254
+ }
255
+ if (written) toast.success(t("grid.pasteApplied", { count: written }));
256
+ if (skipped) toast.error(t("grid.pasteSkipped", { count: skipped }));
257
+ }
258
+
259
+ // -------------------------------------------------------------------------
260
+ // Keyboard
261
+ // -------------------------------------------------------------------------
262
+
263
+ function onKeyDown(event: React.KeyboardEvent) {
264
+ if (!selection) return;
265
+ const intent = resolveGridKey(event, { editing: Boolean(editing) });
266
+ if (!intent) return;
267
+ // Copy and paste stay native so the browser's own clipboard events fire.
268
+ if (intent.type === "copy" || intent.type === "paste") return;
269
+ event.preventDefault();
270
+ const focus = selection.focus;
271
+ if (intent.type === "move") {
272
+ setSelection(
273
+ applyMove(
274
+ selection,
275
+ moveCell(focus, intent.direction, bounds),
276
+ intent.extend,
277
+ ),
278
+ );
279
+ return;
280
+ }
281
+ if (intent.type === "jump") {
282
+ setSelection(
283
+ applyMove(
284
+ selection,
285
+ jumpCell(focus, intent.edge, bounds),
286
+ intent.extend,
287
+ ),
288
+ );
289
+ return;
290
+ }
291
+ if (intent.type === "selectAll") {
292
+ setSelection({
293
+ anchor: { row: 0, col: 0 },
294
+ focus: { row: bounds.rows - 1, col: bounds.cols - 1 },
295
+ });
296
+ return;
297
+ }
298
+ if (intent.type === "edit" || intent.type === "type") {
299
+ const target = visible[focus.col];
300
+ if (!target || !isCellEditable(target.attribute)) {
301
+ toast.error(
302
+ t("grid.cellReadOnly", { attribute: target?.attribute.label ?? "" }),
303
+ );
304
+ return;
305
+ }
306
+ setEditing({
307
+ ref: focus,
308
+ ...(intent.type === "type" ? { seed: intent.text } : {}),
309
+ });
310
+ return;
311
+ }
312
+ if (intent.type === "cancel") {
313
+ setEditing(null);
314
+ return;
315
+ }
316
+ if (intent.type === "clear") {
317
+ const range = selectionRange(selection);
318
+ void (async () => {
319
+ for (let row = range.top; row <= range.bottom; row++) {
320
+ for (let col = range.left; col <= range.right; col++) {
321
+ const target = visible[col];
322
+ if (!target || !isCellEditable(target.attribute)) continue;
323
+ await commit({ row, col }, null, false);
324
+ }
325
+ }
326
+ })();
327
+ }
328
+ }
329
+
330
+ async function commitFromEditor(
331
+ raw: CrmCellValue,
332
+ isRawText: boolean,
333
+ direction?: GridDirection,
334
+ ) {
335
+ if (!editing) return;
336
+ const ref = editing.ref;
337
+ setEditing(null);
338
+ const ok = await commit(ref, raw, isRawText);
339
+ // A rejected value keeps the caret where the user can fix it; only a
340
+ // committed one advances.
341
+ if (ok && direction && selection) {
342
+ setSelection(
343
+ applyMove(selection, moveCell(ref, direction, bounds), false),
344
+ );
345
+ }
346
+ }
347
+
348
+ // -------------------------------------------------------------------------
349
+ // Columns
350
+ // -------------------------------------------------------------------------
351
+
352
+ function toggleSort(attribute: CrmGridAttribute) {
353
+ const current = props.sort[0];
354
+ const isCurrent = current?.attributeId === attribute.apiSlug;
355
+ if (!isCurrent) {
356
+ props.onSortChange([
357
+ { attributeId: attribute.apiSlug, direction: "asc" },
358
+ ]);
359
+ return;
360
+ }
361
+ props.onSortChange(
362
+ current?.direction === "asc"
363
+ ? [{ attributeId: attribute.apiSlug, direction: "desc" }]
364
+ : [],
365
+ );
366
+ }
367
+
368
+ const columnDefs = useMemo<ColumnDef<CrmGridRow>[]>(
369
+ () =>
370
+ visible.map(({ column, attribute }) => ({
371
+ id: attribute.apiSlug,
372
+ size:
373
+ column.width ?? cellSpecFor(attribute).defaultWidth ?? DEFAULT_WIDTH,
374
+ header: attribute.label,
375
+ accessorFn: (row: CrmGridRow) => row.values[attribute.apiSlug] ?? null,
376
+ })),
377
+ [visible],
378
+ );
379
+
380
+ const table = useReactTable({
381
+ data: props.rows,
382
+ columns: columnDefs,
383
+ getCoreRowModel: getCoreRowModel(),
384
+ columnResizeMode: "onChange",
385
+ getRowId: (row) => row.id,
386
+ onColumnSizingChange: (updater) => {
387
+ const current = Object.fromEntries(
388
+ visible.map(({ column, attribute }) => [
389
+ attribute.apiSlug,
390
+ column.width ?? cellSpecFor(attribute).defaultWidth ?? DEFAULT_WIDTH,
391
+ ]),
392
+ );
393
+ const next = typeof updater === "function" ? updater(current) : updater;
394
+ let columns = props.columns.length ? props.columns : ordered;
395
+ for (const [attributeId, width] of Object.entries(next)) {
396
+ if (current[attributeId] === width) continue;
397
+ columns = setGridColumnWidth(
398
+ columns.some((entry) => entry.attributeId === attributeId)
399
+ ? columns
400
+ : [...columns, { attributeId }],
401
+ attributeId,
402
+ width as number,
403
+ );
404
+ }
405
+ props.onColumnsChange(columns);
406
+ },
407
+ });
408
+
409
+ // -------------------------------------------------------------------------
410
+ // Rows
411
+ // -------------------------------------------------------------------------
412
+
413
+ const virtualizer = useVirtualizer({
414
+ count: props.rows.length,
415
+ getScrollElement: () => scrollRef.current,
416
+ estimateSize: () => ROW_HEIGHT,
417
+ overscan: 14,
418
+ });
419
+ const virtualRows = virtualizer.getVirtualItems();
420
+ const lastVisible = virtualRows[virtualRows.length - 1]?.index ?? 0;
421
+ const { hasNextPage, isFetchingNextPage, onLoadMore } = props;
422
+ const rowCount = props.rows.length;
423
+ useEffect(() => {
424
+ if (!hasNextPage || isFetchingNextPage || rowCount === 0) return;
425
+ if (lastVisible >= rowCount - PREFETCH_ROWS) onLoadMore?.();
426
+ }, [hasNextPage, isFetchingNextPage, lastVisible, onLoadMore, rowCount]);
427
+
428
+ const totalWidth =
429
+ SELECT_WIDTH +
430
+ NAME_WIDTH +
431
+ visible.reduce(
432
+ (sum, { column, attribute }) =>
433
+ sum +
434
+ (column.width ?? cellSpecFor(attribute).defaultWidth ?? DEFAULT_WIDTH),
435
+ 0,
436
+ ) +
437
+ 40;
438
+
439
+ function toggleRow(rowId: string) {
440
+ setSelectedRows((current) => {
441
+ const next = new Set(current);
442
+ if (next.has(rowId)) next.delete(rowId);
443
+ else next.add(rowId);
444
+ return next;
445
+ });
446
+ }
447
+
448
+ if (props.error) {
449
+ return (
450
+ <GridMessage
451
+ icon={<IconRefresh className="size-5" />}
452
+ title={t("grid.errorTitle")}
453
+ description={
454
+ props.error instanceof Error
455
+ ? props.error.message
456
+ : t("grid.errorDescription")
457
+ }
458
+ action={
459
+ props.onRetry ? (
460
+ <Button variant="outline" size="sm" onClick={props.onRetry}>
461
+ {t("grid.retry")}
462
+ </Button>
463
+ ) : null
464
+ }
465
+ />
466
+ );
467
+ }
468
+
469
+ if (props.isLoading && props.rows.length === 0) {
470
+ return (
471
+ <div className="grid gap-1.5 p-5">
472
+ {Array.from({ length: 10 }, (_, index) => (
473
+ <Skeleton key={index} className="h-8 w-full" />
474
+ ))}
475
+ </div>
476
+ );
477
+ }
478
+
479
+ if (props.rows.length === 0) {
480
+ return (
481
+ <GridMessage
482
+ icon={<IconDatabaseOff className="size-5" />}
483
+ title={props.emptyTitle}
484
+ description={props.emptyDescription ?? t("grid.emptyDescription")}
485
+ />
486
+ );
487
+ }
488
+
489
+ return (
490
+ <div className="relative flex min-h-0 flex-1 flex-col">
491
+ <div
492
+ ref={scrollRef}
493
+ tabIndex={0}
494
+ onKeyDown={onKeyDown}
495
+ onCopy={(event) => {
496
+ const text = selectionTsv();
497
+ if (!text) return;
498
+ event.preventDefault();
499
+ event.clipboardData.setData("text/plain", text);
500
+ }}
501
+ onPaste={(event) => {
502
+ if (!selection) return;
503
+ const text = event.clipboardData.getData("text/plain");
504
+ if (!text) return;
505
+ event.preventDefault();
506
+ void pasteTsv(text);
507
+ }}
508
+ className="min-h-0 flex-1 overflow-auto outline-none focus-visible:ring-1 focus-visible:ring-ring"
509
+ >
510
+ <div style={{ width: totalWidth, minWidth: "100%" }}>
511
+ <div className="sticky top-0 z-20 flex h-9 items-stretch border-b border-border bg-background/95 backdrop-blur">
512
+ <div
513
+ className="flex shrink-0 items-center justify-center border-r border-border/60"
514
+ style={{ width: SELECT_WIDTH }}
515
+ >
516
+ <Checkbox
517
+ checked={
518
+ selectedRows.size > 0 &&
519
+ selectedRows.size === props.rows.length
520
+ }
521
+ onCheckedChange={(next) =>
522
+ setSelectedRows(
523
+ next === true
524
+ ? new Set(props.rows.map((row) => row.id))
525
+ : new Set(),
526
+ )
527
+ }
528
+ aria-label={t("grid.selectAllRows")}
529
+ />
530
+ </div>
531
+ <div
532
+ className="flex shrink-0 items-center border-r border-border/60 px-2 text-xs font-medium text-muted-foreground"
533
+ style={{ width: NAME_WIDTH }}
534
+ >
535
+ <span className="truncate">{props.nameLabel}</span>
536
+ </div>
537
+ {table.getHeaderGroups()[0]?.headers.map((header, index) => {
538
+ const attribute = visible[index]?.attribute;
539
+ if (!attribute) return null;
540
+ const sorted = props.sort.find(
541
+ (entry) => entry.attributeId === attribute.apiSlug,
542
+ );
543
+ return (
544
+ <div
545
+ key={header.id}
546
+ draggable
547
+ onDragStart={(event) =>
548
+ event.dataTransfer.setData("text/plain", attribute.apiSlug)
549
+ }
550
+ onDragOver={(event) => event.preventDefault()}
551
+ onDrop={(event) => {
552
+ event.preventDefault();
553
+ const moved = event.dataTransfer.getData("text/plain");
554
+ if (moved && moved !== attribute.apiSlug) {
555
+ props.onColumnsChange(
556
+ moveGridColumn(ordered, moved, index),
557
+ );
558
+ }
559
+ }}
560
+ style={{ width: header.getSize() }}
561
+ className="group/header relative flex shrink-0 items-center gap-1 border-r border-border/60 px-2 text-xs font-medium text-muted-foreground"
562
+ >
563
+ <button
564
+ type="button"
565
+ onClick={() => toggleSort(attribute)}
566
+ className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 text-left hover:text-foreground"
567
+ >
568
+ <span className="truncate">
569
+ {flexRender(
570
+ header.column.columnDef.header,
571
+ header.getContext(),
572
+ )}
573
+ </span>
574
+ {sorted?.direction === "asc" ? (
575
+ <IconArrowUp className="size-3 shrink-0" />
576
+ ) : sorted?.direction === "desc" ? (
577
+ <IconArrowDown className="size-3 shrink-0" />
578
+ ) : null}
579
+ </button>
580
+ <DropdownMenu>
581
+ <DropdownMenuTrigger asChild>
582
+ <button
583
+ type="button"
584
+ aria-label={t("grid.columnOptions", {
585
+ attribute: attribute.label,
586
+ })}
587
+ className="shrink-0 cursor-pointer opacity-0 transition-opacity group-hover/header:opacity-100 focus-visible:opacity-100"
588
+ >
589
+ <IconDotsVertical className="size-3.5" />
590
+ </button>
591
+ </DropdownMenuTrigger>
592
+ <DropdownMenuContent align="end">
593
+ <DropdownMenuItem
594
+ onSelect={() =>
595
+ props.onColumnsChange(
596
+ setGridColumnHidden(
597
+ ordered,
598
+ attribute.apiSlug,
599
+ true,
600
+ ),
601
+ )
602
+ }
603
+ >
604
+ <IconEyeOff className="size-4" /> {t("grid.hideColumn")}
605
+ </DropdownMenuItem>
606
+ </DropdownMenuContent>
607
+ </DropdownMenu>
608
+ <div
609
+ onMouseDown={header.getResizeHandler()}
610
+ onTouchStart={header.getResizeHandler()}
611
+ className={cn(
612
+ "absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none hover:bg-ring",
613
+ header.column.getIsResizing() && "bg-ring",
614
+ )}
615
+ />
616
+ </div>
617
+ );
618
+ })}
619
+ <ColumnPicker
620
+ attributes={props.attributes}
621
+ columns={ordered}
622
+ onColumnsChange={props.onColumnsChange}
623
+ {...(props.onAddAttribute
624
+ ? { onAddAttribute: props.onAddAttribute }
625
+ : {})}
626
+ />
627
+ </div>
628
+
629
+ <div
630
+ style={{ height: virtualizer.getTotalSize(), position: "relative" }}
631
+ >
632
+ {virtualRows.map((virtualRow) => {
633
+ const row = props.rows[virtualRow.index];
634
+ if (!row) return null;
635
+ const range = selection ? selectionRange(selection) : null;
636
+ return (
637
+ <div
638
+ key={row.id}
639
+ className={cn(
640
+ "absolute left-0 flex items-stretch border-b border-border/50 hover:bg-muted/25",
641
+ selectedRows.has(row.id) && "bg-primary/5",
642
+ )}
643
+ style={{
644
+ top: virtualRow.start,
645
+ height: ROW_HEIGHT,
646
+ width: totalWidth,
647
+ }}
648
+ >
649
+ <div
650
+ className="flex shrink-0 items-center justify-center border-r border-border/50"
651
+ style={{ width: SELECT_WIDTH }}
652
+ >
653
+ <Checkbox
654
+ checked={selectedRows.has(row.id)}
655
+ onCheckedChange={() => toggleRow(row.id)}
656
+ aria-label={t("grid.selectRow", {
657
+ name: row.displayName,
658
+ })}
659
+ />
660
+ </div>
661
+ <div
662
+ className="flex shrink-0 items-center overflow-hidden border-r border-border/50 px-2 text-sm font-medium"
663
+ style={{ width: NAME_WIDTH }}
664
+ >
665
+ {props.rowHref ? (
666
+ <Link
667
+ to={props.rowHref(row)}
668
+ className="truncate rounded-sm outline-none hover:underline focus-visible:ring-1 focus-visible:ring-ring"
669
+ >
670
+ {row.displayName}
671
+ </Link>
672
+ ) : (
673
+ <span className="truncate">{row.displayName}</span>
674
+ )}
675
+ </div>
676
+ {visible.map(({ column, attribute }, colIndex) => {
677
+ const ref = { row: virtualRow.index, col: colIndex };
678
+ const active =
679
+ selection?.focus.row === ref.row &&
680
+ selection.focus.col === ref.col;
681
+ const inRange = range ? isInRange(range, ref) : false;
682
+ const isEditing =
683
+ editing?.ref.row === ref.row &&
684
+ editing.ref.col === ref.col;
685
+ return (
686
+ <div
687
+ key={attribute.apiSlug}
688
+ role="gridcell"
689
+ tabIndex={-1}
690
+ onMouseDown={(event) => {
691
+ if (event.shiftKey && selection) {
692
+ event.preventDefault();
693
+ setSelection({
694
+ anchor: selection.anchor,
695
+ focus: ref,
696
+ });
697
+ return;
698
+ }
699
+ setSelection({ anchor: ref, focus: ref });
700
+ setEditing(null);
701
+ }}
702
+ onDoubleClick={() => {
703
+ if (isCellEditable(attribute)) setEditing({ ref });
704
+ }}
705
+ style={{
706
+ width:
707
+ column.width ??
708
+ cellSpecFor(attribute).defaultWidth ??
709
+ DEFAULT_WIDTH,
710
+ }}
711
+ className={cn(
712
+ "relative flex shrink-0 items-center overflow-hidden border-r border-border/50 px-2 text-sm",
713
+ cellSpecFor(attribute).align === "right" &&
714
+ "justify-end",
715
+ cellSpecFor(attribute).align === "center" &&
716
+ "justify-center",
717
+ inRange && !active && "bg-primary/10",
718
+ active && "ring-1 ring-inset ring-primary",
719
+ isEditing && "z-10 bg-background p-0",
720
+ )}
721
+ >
722
+ {isEditing ? (
723
+ <CellEditor
724
+ attribute={attribute}
725
+ value={row.values[attribute.apiSlug] ?? null}
726
+ {...(editing?.seed !== undefined
727
+ ? { seed: editing.seed }
728
+ : {})}
729
+ onCommit={(raw, isRawText, direction) =>
730
+ void commitFromEditor(
731
+ raw as CrmCellValue,
732
+ isRawText,
733
+ direction,
734
+ )
735
+ }
736
+ onCancel={() => setEditing(null)}
737
+ />
738
+ ) : (
739
+ <>
740
+ <CellDisplay
741
+ attribute={attribute}
742
+ // A displayName that only duplicates this row's
743
+ // name is hidden here, not dropped from the row:
744
+ // an absent name must still show its own value.
745
+ value={
746
+ isSuppressedDisplayNameCell(
747
+ attribute.apiSlug,
748
+ row.values,
749
+ )
750
+ ? null
751
+ : (row.values[attribute.apiSlug] ?? null)
752
+ }
753
+ {...(row.valuesSince?.[attribute.apiSlug]
754
+ ? { since: row.valuesSince[attribute.apiSlug] }
755
+ : {})}
756
+ {...(props.now ? { now: props.now } : {})}
757
+ />
758
+ <ProvenanceMarker
759
+ provenance={row.provenance?.[attribute.apiSlug]}
760
+ attributeLabel={attribute.label}
761
+ />
762
+ </>
763
+ )}
764
+ </div>
765
+ );
766
+ })}
767
+ </div>
768
+ );
769
+ })}
770
+ </div>
771
+ {props.isFetchingNextPage ? (
772
+ <div className="flex h-10 items-center px-3 text-xs text-muted-foreground">
773
+ {t("grid.loadingMore")}
774
+ </div>
775
+ ) : null}
776
+ </div>
777
+ </div>
778
+
779
+ {selectedRows.size > 0 ? (
780
+ <BulkBar
781
+ count={selectedRows.size}
782
+ attributes={props.attributes.filter(isCellEditable)}
783
+ onClear={() => setSelectedRows(new Set())}
784
+ {...(props.onAddToList
785
+ ? { onAddToList: () => props.onAddToList?.([...selectedRows]) }
786
+ : {})}
787
+ {...(props.onDeleteRows
788
+ ? { onDelete: () => setConfirmDelete(true) }
789
+ : {})}
790
+ onApply={async (attribute, text) => {
791
+ const parsed = parseCell(attribute, text);
792
+ if (!parsed.ok) {
793
+ toast.error(
794
+ t(`grid.parse.${parsed.reason}`, {
795
+ attribute: attribute.label,
796
+ value: parsed.detail ?? "",
797
+ }),
798
+ );
799
+ return;
800
+ }
801
+ const targets = props.rows.filter((row) =>
802
+ selectedRows.has(row.id),
803
+ );
804
+ for (const row of targets) {
805
+ await props.onCommitCell({ row, attribute, value: parsed.value });
806
+ }
807
+ toast.success(t("grid.bulkApplied", { count: targets.length }));
808
+ }}
809
+ />
810
+ ) : null}
811
+
812
+ <AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
813
+ <AlertDialogContent>
814
+ <AlertDialogHeader>
815
+ <AlertDialogTitle>{t("grid.deleteTitle")}</AlertDialogTitle>
816
+ <AlertDialogDescription>
817
+ {t("grid.deleteDescription", { count: selectedRows.size })}
818
+ </AlertDialogDescription>
819
+ </AlertDialogHeader>
820
+ <AlertDialogFooter>
821
+ <AlertDialogCancel>{t("grid.cancel")}</AlertDialogCancel>
822
+ <AlertDialogAction
823
+ onClick={() => {
824
+ const ids = [...selectedRows];
825
+ setConfirmDelete(false);
826
+ void props
827
+ .onDeleteRows?.(ids)
828
+ .then(() => setSelectedRows(new Set()))
829
+ .catch((error: unknown) =>
830
+ toast.error(
831
+ error instanceof Error
832
+ ? error.message
833
+ : t("grid.deleteFailed"),
834
+ ),
835
+ );
836
+ }}
837
+ >
838
+ {t("grid.deleteConfirm")}
839
+ </AlertDialogAction>
840
+ </AlertDialogFooter>
841
+ </AlertDialogContent>
842
+ </AlertDialog>
843
+ </div>
844
+ );
845
+ }
846
+
847
+ function GridMessage({
848
+ icon,
849
+ title,
850
+ description,
851
+ action,
852
+ }: {
853
+ icon: React.ReactNode;
854
+ title: string;
855
+ description: string;
856
+ action?: React.ReactNode;
857
+ }) {
858
+ return (
859
+ <div className="grid min-h-[320px] place-items-center p-6">
860
+ <div className="max-w-sm text-center">
861
+ <div className="mx-auto grid size-10 place-items-center rounded-full bg-muted text-muted-foreground">
862
+ {icon}
863
+ </div>
864
+ <h2 className="mt-4 text-base font-semibold">{title}</h2>
865
+ <p className="mt-2 text-sm leading-6 text-muted-foreground">
866
+ {description}
867
+ </p>
868
+ {action ? <div className="mt-4">{action}</div> : null}
869
+ </div>
870
+ </div>
871
+ );
872
+ }
873
+
874
+ function ColumnPicker({
875
+ attributes,
876
+ columns,
877
+ onColumnsChange,
878
+ onAddAttribute,
879
+ }: {
880
+ attributes: CrmGridAttribute[];
881
+ columns: CrmGridColumn[];
882
+ onColumnsChange: (next: CrmGridColumn[]) => void;
883
+ onAddAttribute?: () => void;
884
+ }) {
885
+ const t = useT();
886
+ const hidden = new Set(
887
+ columns
888
+ .filter((column) => column.hidden)
889
+ .map((column) => column.attributeId),
890
+ );
891
+ return (
892
+ <DropdownMenu>
893
+ <DropdownMenuTrigger asChild>
894
+ <button
895
+ type="button"
896
+ aria-label={t("grid.configureColumns")}
897
+ className="flex w-10 shrink-0 cursor-pointer items-center justify-center text-muted-foreground hover:text-foreground"
898
+ >
899
+ <IconPlus className="size-4" />
900
+ </button>
901
+ </DropdownMenuTrigger>
902
+ <DropdownMenuContent
903
+ align="end"
904
+ className="max-h-80 w-56 overflow-y-auto"
905
+ >
906
+ <DropdownMenuLabel className="flex items-center gap-2">
907
+ <IconColumns3 className="size-4" /> {t("grid.columns")}
908
+ </DropdownMenuLabel>
909
+ <DropdownMenuSeparator />
910
+ {attributes.map((attribute) => (
911
+ <DropdownMenuCheckboxItem
912
+ key={attribute.apiSlug}
913
+ checked={!hidden.has(attribute.apiSlug)}
914
+ onCheckedChange={(checked) =>
915
+ onColumnsChange(
916
+ setGridColumnHidden(columns, attribute.apiSlug, !checked),
917
+ )
918
+ }
919
+ >
920
+ {attribute.label}
921
+ </DropdownMenuCheckboxItem>
922
+ ))}
923
+ {onAddAttribute ? (
924
+ <>
925
+ <DropdownMenuSeparator />
926
+ <DropdownMenuItem onSelect={onAddAttribute}>
927
+ <IconPlus className="size-4" /> {t("grid.addAttribute")}
928
+ </DropdownMenuItem>
929
+ </>
930
+ ) : null}
931
+ </DropdownMenuContent>
932
+ </DropdownMenu>
933
+ );
934
+ }
935
+
936
+ function BulkBar({
937
+ count,
938
+ attributes,
939
+ onApply,
940
+ onAddToList,
941
+ onDelete,
942
+ onClear,
943
+ }: {
944
+ count: number;
945
+ attributes: CrmGridAttribute[];
946
+ onApply: (attribute: CrmGridAttribute, value: string) => Promise<void>;
947
+ onAddToList?: () => void;
948
+ onDelete?: () => void;
949
+ onClear: () => void;
950
+ }) {
951
+ const t = useT();
952
+ const [attributeSlug, setAttributeSlug] = useState("");
953
+ const [value, setValue] = useState("");
954
+ const attribute = attributes.find(
955
+ (candidate) => candidate.apiSlug === attributeSlug,
956
+ );
957
+ return (
958
+ <div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center">
959
+ <div className="pointer-events-auto flex items-center gap-2 rounded-full border border-border bg-background/95 py-1.5 pl-4 pr-2 shadow-lg backdrop-blur">
960
+ <span className="text-sm font-medium">
961
+ {t("grid.selectedCount", { count })}
962
+ </span>
963
+ <span className="h-4 w-px bg-border" />
964
+ <Popover>
965
+ <PopoverTrigger asChild>
966
+ <Button variant="ghost" size="sm" className="gap-1.5">
967
+ {t("grid.bulkEdit")}
968
+ </Button>
969
+ </PopoverTrigger>
970
+ <PopoverContent align="center" className="w-72 space-y-2">
971
+ <select
972
+ value={attributeSlug}
973
+ onChange={(event) => setAttributeSlug(event.target.value)}
974
+ aria-label={t("grid.bulkAttribute")}
975
+ className="h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
976
+ >
977
+ <option value="">{t("grid.bulkChooseAttribute")}</option>
978
+ {attributes.map((candidate) => (
979
+ <option key={candidate.apiSlug} value={candidate.apiSlug}>
980
+ {candidate.label}
981
+ </option>
982
+ ))}
983
+ </select>
984
+ <Input
985
+ value={value}
986
+ onChange={(event) => setValue(event.target.value)}
987
+ placeholder={t("grid.bulkValue")}
988
+ />
989
+ <Button
990
+ size="sm"
991
+ className="w-full"
992
+ disabled={!attribute}
993
+ onClick={() => {
994
+ if (attribute) void onApply(attribute, value);
995
+ }}
996
+ >
997
+ {t("grid.bulkApply", { count })}
998
+ </Button>
999
+ </PopoverContent>
1000
+ </Popover>
1001
+ {onAddToList ? (
1002
+ <Button
1003
+ variant="ghost"
1004
+ size="sm"
1005
+ className="gap-1.5"
1006
+ onClick={onAddToList}
1007
+ >
1008
+ <IconListCheck className="size-4" /> {t("grid.addToList")}
1009
+ </Button>
1010
+ ) : null}
1011
+ {onDelete ? (
1012
+ <Button
1013
+ variant="ghost"
1014
+ size="sm"
1015
+ className="gap-1.5 text-destructive hover:text-destructive"
1016
+ onClick={onDelete}
1017
+ >
1018
+ <IconTrash className="size-4" /> {t("grid.delete")}
1019
+ </Button>
1020
+ ) : null}
1021
+ <Button
1022
+ variant="ghost"
1023
+ size="icon"
1024
+ className="size-8"
1025
+ aria-label={t("grid.clearSelection")}
1026
+ onClick={onClear}
1027
+ >
1028
+ <IconX className="size-4" />
1029
+ </Button>
1030
+ </div>
1031
+ </div>
1032
+ );
1033
+ }