@fui-org/fui-cli 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/README.md +18 -3
  2. package/dist/fui.js +160 -43
  3. package/package.json +4 -2
  4. package/skills/fui/SKILL.md +131 -66
  5. package/skills/fui-skill/README.md +112 -0
  6. package/skills/fui-skill/SKILL.md +269 -0
  7. package/skills/fui-skill/assets/projectdefaultstyle.css +518 -0
  8. package/skills/fui-skill/design-md/airbnb/DESIGN.md +545 -0
  9. package/skills/fui-skill/design-md/airbnb/README.md +5 -0
  10. package/skills/fui-skill/design-md/airtable/DESIGN.md +554 -0
  11. package/skills/fui-skill/design-md/airtable/README.md +5 -0
  12. package/skills/fui-skill/design-md/apple/DESIGN.md +562 -0
  13. package/skills/fui-skill/design-md/apple/README.md +5 -0
  14. package/skills/fui-skill/design-md/asu/DESIGN.md +179 -0
  15. package/skills/fui-skill/design-md/asu/README.md +107 -0
  16. package/skills/fui-skill/design-md/binance/DESIGN.md +634 -0
  17. package/skills/fui-skill/design-md/binance/README.md +5 -0
  18. package/skills/fui-skill/design-md/bmw/DESIGN.md +544 -0
  19. package/skills/fui-skill/design-md/bmw/README.md +5 -0
  20. package/skills/fui-skill/design-md/bmw-m/DESIGN.md +503 -0
  21. package/skills/fui-skill/design-md/bmw-m/README.md +5 -0
  22. package/skills/fui-skill/design-md/bugatti/DESIGN.md +454 -0
  23. package/skills/fui-skill/design-md/bugatti/README.md +5 -0
  24. package/skills/fui-skill/design-md/cal/DESIGN.md +542 -0
  25. package/skills/fui-skill/design-md/cal/README.md +5 -0
  26. package/skills/fui-skill/design-md/claude/DESIGN.md +589 -0
  27. package/skills/fui-skill/design-md/claude/README.md +5 -0
  28. package/skills/fui-skill/design-md/clay/DESIGN.md +541 -0
  29. package/skills/fui-skill/design-md/clay/README.md +5 -0
  30. package/skills/fui-skill/design-md/clickhouse/DESIGN.md +544 -0
  31. package/skills/fui-skill/design-md/clickhouse/README.md +5 -0
  32. package/skills/fui-skill/design-md/cohere/DESIGN.md +451 -0
  33. package/skills/fui-skill/design-md/cohere/README.md +5 -0
  34. package/skills/fui-skill/design-md/coinbase/DESIGN.md +570 -0
  35. package/skills/fui-skill/design-md/coinbase/README.md +5 -0
  36. package/skills/fui-skill/design-md/composio/DESIGN.md +506 -0
  37. package/skills/fui-skill/design-md/composio/README.md +5 -0
  38. package/skills/fui-skill/design-md/cursor/DESIGN.md +537 -0
  39. package/skills/fui-skill/design-md/cursor/README.md +5 -0
  40. package/skills/fui-skill/design-md/elevenlabs/DESIGN.md +504 -0
  41. package/skills/fui-skill/design-md/elevenlabs/README.md +5 -0
  42. package/skills/fui-skill/design-md/expo/DESIGN.md +526 -0
  43. package/skills/fui-skill/design-md/expo/README.md +5 -0
  44. package/skills/fui-skill/design-md/ferrari/DESIGN.md +531 -0
  45. package/skills/fui-skill/design-md/ferrari/README.md +5 -0
  46. package/skills/fui-skill/design-md/figma/DESIGN.md +578 -0
  47. package/skills/fui-skill/design-md/figma/README.md +5 -0
  48. package/skills/fui-skill/design-md/framer/DESIGN.md +544 -0
  49. package/skills/fui-skill/design-md/framer/README.md +5 -0
  50. package/skills/fui-skill/design-md/fui/DESIGN.md +532 -0
  51. package/skills/fui-skill/design-md/hashicorp/DESIGN.md +575 -0
  52. package/skills/fui-skill/design-md/hashicorp/README.md +5 -0
  53. package/skills/fui-skill/design-md/ibm/DESIGN.md +550 -0
  54. package/skills/fui-skill/design-md/ibm/README.md +5 -0
  55. package/skills/fui-skill/design-md/intercom/DESIGN.md +546 -0
  56. package/skills/fui-skill/design-md/intercom/README.md +5 -0
  57. package/skills/fui-skill/design-md/kraken/DESIGN.md +125 -0
  58. package/skills/fui-skill/design-md/kraken/README.md +5 -0
  59. package/skills/fui-skill/design-md/lamborghini/DESIGN.md +288 -0
  60. package/skills/fui-skill/design-md/lamborghini/README.md +5 -0
  61. package/skills/fui-skill/design-md/linear.app/DESIGN.md +548 -0
  62. package/skills/fui-skill/design-md/linear.app/README.md +5 -0
  63. package/skills/fui-skill/design-md/lovable/DESIGN.md +298 -0
  64. package/skills/fui-skill/design-md/lovable/README.md +5 -0
  65. package/skills/fui-skill/design-md/mastercard/DESIGN.md +365 -0
  66. package/skills/fui-skill/design-md/mastercard/README.md +5 -0
  67. package/skills/fui-skill/design-md/meta/DESIGN.md +683 -0
  68. package/skills/fui-skill/design-md/meta/README.md +5 -0
  69. package/skills/fui-skill/design-md/minimax/DESIGN.md +746 -0
  70. package/skills/fui-skill/design-md/minimax/README.md +5 -0
  71. package/skills/fui-skill/design-md/mintlify/DESIGN.md +852 -0
  72. package/skills/fui-skill/design-md/mintlify/README.md +5 -0
  73. package/skills/fui-skill/design-md/miro/DESIGN.md +825 -0
  74. package/skills/fui-skill/design-md/miro/README.md +5 -0
  75. package/skills/fui-skill/design-md/mistral.ai/DESIGN.md +773 -0
  76. package/skills/fui-skill/design-md/mistral.ai/README.md +5 -0
  77. package/skills/fui-skill/design-md/mongodb/DESIGN.md +767 -0
  78. package/skills/fui-skill/design-md/mongodb/README.md +5 -0
  79. package/skills/fui-skill/design-md/nike/DESIGN.md +575 -0
  80. package/skills/fui-skill/design-md/nike/README.md +5 -0
  81. package/skills/fui-skill/design-md/notion/DESIGN.md +821 -0
  82. package/skills/fui-skill/design-md/notion/README.md +5 -0
  83. package/skills/fui-skill/design-md/nvidia/DESIGN.md +640 -0
  84. package/skills/fui-skill/design-md/nvidia/README.md +5 -0
  85. package/skills/fui-skill/design-md/ollama/DESIGN.md +539 -0
  86. package/skills/fui-skill/design-md/ollama/README.md +5 -0
  87. package/skills/fui-skill/design-md/opencode.ai/DESIGN.md +521 -0
  88. package/skills/fui-skill/design-md/opencode.ai/README.md +5 -0
  89. package/skills/fui-skill/design-md/pinterest/DESIGN.md +597 -0
  90. package/skills/fui-skill/design-md/pinterest/README.md +5 -0
  91. package/skills/fui-skill/design-md/playstation/DESIGN.md +661 -0
  92. package/skills/fui-skill/design-md/playstation/README.md +5 -0
  93. package/skills/fui-skill/design-md/posthog/DESIGN.md +690 -0
  94. package/skills/fui-skill/design-md/posthog/README.md +5 -0
  95. package/skills/fui-skill/design-md/raycast/DESIGN.md +669 -0
  96. package/skills/fui-skill/design-md/raycast/README.md +5 -0
  97. package/skills/fui-skill/design-md/renault/DESIGN.md +589 -0
  98. package/skills/fui-skill/design-md/renault/README.md +5 -0
  99. package/skills/fui-skill/design-md/replicate/DESIGN.md +616 -0
  100. package/skills/fui-skill/design-md/replicate/README.md +5 -0
  101. package/skills/fui-skill/design-md/resend/DESIGN.md +585 -0
  102. package/skills/fui-skill/design-md/resend/README.md +5 -0
  103. package/skills/fui-skill/design-md/revolut/DESIGN.md +636 -0
  104. package/skills/fui-skill/design-md/revolut/README.md +5 -0
  105. package/skills/fui-skill/design-md/runwayml/DESIGN.md +244 -0
  106. package/skills/fui-skill/design-md/runwayml/README.md +5 -0
  107. package/skills/fui-skill/design-md/sanity/DESIGN.md +357 -0
  108. package/skills/fui-skill/design-md/sanity/README.md +5 -0
  109. package/skills/fui-skill/design-md/sentry/DESIGN.md +551 -0
  110. package/skills/fui-skill/design-md/sentry/README.md +5 -0
  111. package/skills/fui-skill/design-md/shopify/DESIGN.md +516 -0
  112. package/skills/fui-skill/design-md/shopify/README.md +5 -0
  113. package/skills/fui-skill/design-md/slack/DESIGN.md +482 -0
  114. package/skills/fui-skill/design-md/spacex/DESIGN.md +363 -0
  115. package/skills/fui-skill/design-md/spacex/README.md +5 -0
  116. package/skills/fui-skill/design-md/spotify/DESIGN.md +246 -0
  117. package/skills/fui-skill/design-md/spotify/README.md +5 -0
  118. package/skills/fui-skill/design-md/starbucks/DESIGN.md +580 -0
  119. package/skills/fui-skill/design-md/starbucks/README.md +5 -0
  120. package/skills/fui-skill/design-md/stripe/DESIGN.md +487 -0
  121. package/skills/fui-skill/design-md/stripe/README.md +5 -0
  122. package/skills/fui-skill/design-md/supabase/DESIGN.md +462 -0
  123. package/skills/fui-skill/design-md/supabase/README.md +5 -0
  124. package/skills/fui-skill/design-md/superhuman/DESIGN.md +448 -0
  125. package/skills/fui-skill/design-md/superhuman/README.md +5 -0
  126. package/skills/fui-skill/design-md/tesla/DESIGN.md +286 -0
  127. package/skills/fui-skill/design-md/tesla/README.md +5 -0
  128. package/skills/fui-skill/design-md/theverge/DESIGN.md +339 -0
  129. package/skills/fui-skill/design-md/theverge/README.md +5 -0
  130. package/skills/fui-skill/design-md/together.ai/DESIGN.md +633 -0
  131. package/skills/fui-skill/design-md/together.ai/README.md +5 -0
  132. package/skills/fui-skill/design-md/uber/DESIGN.md +636 -0
  133. package/skills/fui-skill/design-md/uber/README.md +5 -0
  134. package/skills/fui-skill/design-md/vercel/DESIGN.md +736 -0
  135. package/skills/fui-skill/design-md/vercel/README.md +5 -0
  136. package/skills/fui-skill/design-md/vodafone/DESIGN.md +538 -0
  137. package/skills/fui-skill/design-md/vodafone/README.md +5 -0
  138. package/skills/fui-skill/design-md/voltagent/DESIGN.md +521 -0
  139. package/skills/fui-skill/design-md/voltagent/README.md +5 -0
  140. package/skills/fui-skill/design-md/warp/DESIGN.md +526 -0
  141. package/skills/fui-skill/design-md/warp/README.md +5 -0
  142. package/skills/fui-skill/design-md/webflow/DESIGN.md +588 -0
  143. package/skills/fui-skill/design-md/webflow/README.md +5 -0
  144. package/skills/fui-skill/design-md/wired/DESIGN.md +497 -0
  145. package/skills/fui-skill/design-md/wired/README.md +5 -0
  146. package/skills/fui-skill/design-md/wise/DESIGN.md +544 -0
  147. package/skills/fui-skill/design-md/wise/README.md +5 -0
  148. package/skills/fui-skill/design-md/x.ai/DESIGN.md +465 -0
  149. package/skills/fui-skill/design-md/x.ai/README.md +5 -0
  150. package/skills/fui-skill/design-md/zapier/DESIGN.md +537 -0
  151. package/skills/fui-skill/design-md/zapier/README.md +5 -0
  152. package/skills/fui-skill/examples/component.vue +162 -0
  153. package/skills/fui-skill/examples/f-table-patterns.json +331 -0
  154. package/skills/fui-skill/examples/module-patterns.json +973 -0
  155. package/skills/fui-skill/examples/project-patterns.json +222 -0
  156. package/skills/fui-skill/metadata.json +75 -0
  157. package/skills/fui-skill/references/INDEX.md +144 -0
  158. package/skills/fui-skill/references/advanced-techniques.md +160 -0
  159. package/skills/fui-skill/references/coding-standards.md +112 -0
  160. package/skills/fui-skill/references/component-design.md +455 -0
  161. package/skills/fui-skill/references/component-quickref.md +77 -0
  162. package/skills/fui-skill/references/component-table.md +276 -0
  163. package/skills/fui-skill/references/components-dialog.md +192 -0
  164. package/skills/fui-skill/references/components-display.md +147 -0
  165. package/skills/fui-skill/references/components-echart.md +391 -0
  166. package/skills/fui-skill/references/components-input.md +359 -0
  167. package/skills/fui-skill/references/controls-patterns.md +847 -0
  168. package/skills/fui-skill/references/controls-styling-vocabulary.md +140 -0
  169. package/skills/fui-skill/references/db-table-design.md +77 -0
  170. package/skills/fui-skill/references/db-workflow.md +504 -0
  171. package/skills/fui-skill/references/default-function.md +415 -0
  172. package/skills/fui-skill/references/design-modes.md +85 -0
  173. package/skills/fui-skill/references/echart-templates.md +481 -0
  174. package/skills/fui-skill/references/fastproject.md +97 -0
  175. package/skills/fui-skill/references/fsheet.md +218 -0
  176. package/skills/fui-skill/references/fullstack-workflow.md +351 -0
  177. package/skills/fui-skill/references/module-data-patterns.md +126 -0
  178. package/skills/fui-skill/references/module-json-anatomy.md +137 -0
  179. package/skills/fui-skill/references/module-structure.md +260 -0
  180. package/skills/fui-skill/references/new-session.md +108 -0
  181. package/skills/fui-skill/references/pdfmake.md +60 -0
  182. package/skills/fui-skill/references/permission-system.md +169 -0
  183. package/skills/fui-skill/references/platform-architecture.md +294 -0
  184. package/skills/fui-skill/references/project-config.md +335 -0
  185. package/skills/fui-skill/references/project-provisioning.md +383 -0
  186. package/skills/fui-skill/references/script-map.md +296 -0
  187. package/skills/fui-skill/references/sql-clr-functions.md +224 -0
  188. package/skills/fui-skill/references/system-design.md +116 -0
  189. package/skills/fui-skill/references/tapi-file-api.md +191 -0
  190. package/skills/fui-skill/references/tapi-permission-patterns.md +158 -0
  191. package/skills/fui-skill/references/tapi-reference.md +549 -0
  192. package/skills/fui-skill/references/tools-registry.md +460 -0
  193. package/skills/fui-skill/references/ui-crosswindow-patterns.md +317 -0
  194. package/skills/fui-skill/references/ui-dialog-patterns.md +229 -0
  195. package/skills/fui-skill/references/ui-layout-patterns.md +176 -0
  196. package/skills/fui-skill/references/ui-patterns.md +315 -0
  197. package/skills/fui-skill/references/ui-screenshot-review.md +94 -0
  198. package/skills/fui-skill/references/ui-table-cell-patterns.md +316 -0
  199. package/skills/fui-skill/references/ui-templates.md +29 -0
  200. package/skills/fui-skill/references/verification.md +246 -0
  201. package/skills/fui-skill/references/watcher-patterns.md +196 -0
  202. package/skills/fui-skill/references/websocket-realtime.md +254 -0
  203. package/skills/fui-skill/scripts/component-3.0.js +2549 -0
  204. package/skills/fui-skill/scripts/component.js +3142 -0
  205. package/skills/fui-skill/scripts/componentTable-3.0.js +909 -0
  206. package/skills/fui-skill/scripts/componentTable.js +769 -0
  207. package/skills/fui-skill/scripts/defaultfunction-3.0.js +781 -0
  208. package/skills/fui-skill/scripts/defaultfunction.js +966 -0
  209. package/skills/fui-skill/scripts/fastproject-3.0.js +870 -0
  210. package/skills/fui-skill/scripts/fastproject.js +828 -0
  211. package/skills/fui-skill/scripts/fechart.js +890 -0
  212. package/skills/fui-skill/scripts/fsheet.js +1330 -0
@@ -0,0 +1,1330 @@
1
+ // Bộ cellRenderer dựng sẵn của FSheet (tương đương THtml/TLink/TButton/TMenu của
2
+ // componentTable-3.0 cũ) — đăng ký sẵn trong initGrid() nên bất kỳ colDef nào cũng
3
+ // dùng được ngay qua "cellRenderer": "fsXxxCellRenderer", không cần khai gì thêm
4
+ // trong script.js của module. Cấu hình qua cellRendererParams (object hoặc hàm,
5
+ // AG Grid tự merge vào params) — dùng lại openWindow/tableActionEvent, 2 hàm global
6
+ // gốc của FUI (giống hệt componentTable-3.0), không tự chế lại action engine.
7
+ window.fsHtmlCellRenderer = function (p) {
8
+ var div = document.createElement('div')
9
+ div.innerHTML = p.value != null ? String(p.value) : ''
10
+ return div
11
+ }
12
+
13
+ window.fsLinkCellRenderer = function (p) {
14
+ var url = p.urlField ? p.data[p.urlField] : p.value
15
+ var text = p.textField ? p.data[p.textField] : p.value
16
+ var a = document.createElement('a')
17
+ a.style.color = '#1976d2'
18
+ a.innerHTML = (p.icon ? '<i class="mdi ' + p.icon + '" style="margin-right:4px"></i>' : '') + (text != null ? text : '')
19
+ if (p.target === 'dialog') {
20
+ a.href = 'javascript:void(0)'
21
+ a.addEventListener('click', function (e) {
22
+ e.preventDefault()
23
+ openWindow({ id: p.wid, title: p.title, url: url, onclose: p.onclose })
24
+ })
25
+ } else {
26
+ a.href = url || 'javascript:void(0)'
27
+ a.target = p.target || '_blank'
28
+ }
29
+ return a
30
+ }
31
+
32
+ window.fsButtonCellRenderer = function (p) {
33
+ var color = p.color || '#1976d2'
34
+ var btn = document.createElement('button')
35
+ btn.innerHTML = (p.icon ? '<i class="mdi ' + p.icon + '"></i> ' : '') + (p.label || '')
36
+ btn.style.cssText = 'font-size:11px;padding:1px 8px;cursor:pointer;border-radius:3px;border:1px solid ' + color + ';color:' + color + ';background:white;line-height:1.6'
37
+ btn.addEventListener('click', function () {
38
+ if (p.url) openWindow({ id: p.wid, title: p.title, url: p.url, onclose: p.onclose })
39
+ else tableActionEvent({ action: p.action, item: p.data })
40
+ })
41
+ return btn
42
+ }
43
+
44
+ window.fsMenuCellRenderer = function (p) {
45
+ var items = typeof p.items === 'function' ? p.items(p) : (p.items || [])
46
+ var btn = document.createElement('button')
47
+ btn.innerHTML = '<i class="mdi ' + (p.icon || 'mdi-dots-vertical') + '"></i>'
48
+ btn.style.cssText = 'border:none;background:transparent;cursor:pointer;font-size:18px;line-height:1;padding:2px 6px'
49
+ btn.addEventListener('click', function (e) {
50
+ e.stopPropagation()
51
+ openFsMenu(btn, items, p.data)
52
+ })
53
+ return btn
54
+ }
55
+
56
+ function openFsMenu(anchor, items, rowData) {
57
+ closeFsMenu()
58
+ var rect = anchor.getBoundingClientRect()
59
+ var panel = document.createElement('div')
60
+ panel.style.cssText = 'position:fixed;z-index:9999;background:#fff;border:1px solid #ddd;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.15);min-width:160px;top:' + rect.bottom + 'px;left:' + rect.left + 'px'
61
+ items.forEach(function (item) {
62
+ var row = document.createElement('div')
63
+ row.style.cssText = 'padding:8px 12px;cursor:pointer;font-size:13px;display:flex;align-items:center;gap:8px;color:' + (item.color || '#333')
64
+ row.innerHTML = (item.icon ? '<i class="mdi ' + item.icon + '"></i>' : '') + '<span>' + item.text + '</span>'
65
+ row.addEventListener('mouseenter', function () { row.style.background = '#f5f5f5' })
66
+ row.addEventListener('mouseleave', function () { row.style.background = '' })
67
+ row.addEventListener('click', function () {
68
+ closeFsMenu()
69
+ tableActionEvent({ action: item.action, item: rowData })
70
+ })
71
+ panel.appendChild(row)
72
+ })
73
+ document.body.appendChild(panel)
74
+ window.__fsOpenMenuPanel = panel
75
+ setTimeout(function () { document.addEventListener('click', closeFsMenu, { once: true }) }, 0)
76
+ }
77
+
78
+ function closeFsMenu() {
79
+ if (window.__fsOpenMenuPanel) { window.__fsOpenMenuPanel.remove(); window.__fsOpenMenuPanel = null }
80
+ }
81
+
82
+ // Menu chuột phải kiểu Excel (icon + nhãn + phím tắt căn phải). Dùng chung panel/close
83
+ // với openFsMenu (window.__fsOpenMenuPanel) vì chỉ một menu mở tại một thời điểm.
84
+ function openFsContextMenu(x, y, items) {
85
+ closeFsMenu()
86
+ var panel = document.createElement('div')
87
+ panel.style.cssText = 'position:fixed;z-index:9999;background:#fff;border:1px solid #ddd;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.15);min-width:220px;padding:4px 0;top:' + y + 'px;left:' + x + 'px'
88
+ items.forEach(function (item) {
89
+ var row = document.createElement('div')
90
+ row.style.cssText = 'padding:8px 12px;cursor:pointer;font-size:13px;display:flex;align-items:center;gap:10px;color:#333'
91
+ row.innerHTML = '<i class="mdi ' + item.icon + '" style="width:16px;color:#666"></i>' +
92
+ '<span style="flex:1">' + item.text + '</span>' +
93
+ (item.shortcut ? '<span style="color:#999;font-size:12px">' + item.shortcut + '</span>' : '')
94
+ row.addEventListener('mouseenter', function () { row.style.background = '#f5f5f5' })
95
+ row.addEventListener('mouseleave', function () { row.style.background = '' })
96
+ row.addEventListener('click', function () { closeFsMenu(); item.action() })
97
+ panel.appendChild(row)
98
+ })
99
+ document.body.appendChild(panel)
100
+ window.__fsOpenMenuPanel = panel
101
+ setTimeout(function () { document.addEventListener('click', closeFsMenu, { once: true }) }, 0)
102
+ // Giữ trong viewport khi bấm chuột phải gần mép phải/dưới màn hình.
103
+ var rect = panel.getBoundingClientRect()
104
+ if (rect.right > window.innerWidth) panel.style.left = Math.max(0, window.innerWidth - rect.width - 4) + 'px'
105
+ if (rect.bottom > window.innerHeight) panel.style.top = Math.max(0, window.innerHeight - rect.height - 4) + 'px'
106
+ }
107
+
108
+ // Copy qua menu (không phải Ctrl+C) không có DOM ClipboardEvent thật để lấy e.clipboardData
109
+ // → phải tự ghi vào clipboard hệ thống. navigator.clipboard cần secure context; nơi nào
110
+ // không có (http nội bộ, trình duyệt cũ) thì rơi về execCommand('copy') qua textarea ẩn.
111
+ function fsFallbackCopy(text) {
112
+ var ta = document.createElement('textarea')
113
+ ta.value = text
114
+ ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px'
115
+ document.body.appendChild(ta)
116
+ ta.select()
117
+ try { document.execCommand('copy') } catch (err) {}
118
+ document.body.removeChild(ta)
119
+ }
120
+
121
+ // Class của chính AG Grid dùng để tô vùng chọn.
122
+ var FS_RANGE_CLASSES = [
123
+ 'ag-cell-range-selected', 'ag-cell-range-single-cell',
124
+ 'ag-cell-range-top', 'ag-cell-range-right', 'ag-cell-range-bottom', 'ag-cell-range-left',
125
+ 'ag-selection-fill-top', 'ag-selection-fill-right', 'ag-selection-fill-bottom', 'ag-selection-fill-left'
126
+ ]
127
+
128
+ // CSS vùng chọn — vì sao f-sheet phải tự bơm:
129
+ // Bundle UMD của AG Grid có sẵn stylesheet legacy (community-modules/styles/ag-grid.css,
130
+ // gắn <style data-ag-scope="legacy"> vào head lúc load) và trong đó CÓ đủ CSS vùng chọn.
131
+ // NHƯNG ngay khi truyền theme:{...} (Theming API v33+), AG Grid gọi uninstallLegacyCSS()
132
+ // xoá sạch mấy <style data-ag-scope="legacy"> đó rồi bơm lại core.css + CSS của các module
133
+ // ĐANG đăng ký. Phần .ag-cell-range-*/.ag-selection-fill-*/.ag-fill-handle nằm trong CSS
134
+ // của CellSelectionModule (Enterprise) nên bản Community không bao giờ được bơm — gắn class
135
+ // vẫn thành công mà nhìn không thấy gì, đúng triệu chứng "không select được nhiều ô".
136
+ // Core.css chỉ giữ lại đúng .ag-cell-range-single-cell (viền ô đang focus) và
137
+ // .ag-cell{border:1px solid transparent} — nhờ vậy ở đây chỉ cần khai màu/kiểu viền.
138
+ // Viết theo biến CSS của theme nên màu vẫn đổi được bằng
139
+ // withParams({ rangeSelectionBackgroundColor, rangeSelectionBorderColor, rangeSelectionBorderStyle }).
140
+ // Mỗi var() đều kèm giá trị dự phòng: var() không giải được thì thuộc tính thành "invalid at
141
+ // computed-value time" → trong suốt, tức hỏng mà không báo gì. Có fallback thì xấu nhất vẫn nhìn thấy.
142
+ var FS_RANGE_BG = 'var(--ag-range-selection-background-color, rgba(33,150,243,.18))'
143
+ var FS_RANGE_BC = 'var(--ag-range-selection-border-color, #2196f3)'
144
+ var FS_RANGE_BS = 'var(--ag-range-selection-border-style, solid)'
145
+ function fsInstallRangeCss () {
146
+ var st = document.getElementById('fsheet-range-css')
147
+ if (!st) {
148
+ st = document.createElement('style')
149
+ st.id = 'fsheet-range-css'
150
+ st.textContent = [
151
+ // Nền vùng chọn. Chừa ô đang focus (giống Excel: ô neo vẫn nền trắng).
152
+ '.ag-cell-range-selected:not(.ag-cell-focus){background-color:' + FS_RANGE_BG + '}',
153
+ // Viền bao quanh khối. Ô đơn lẻ đã có viền sẵn từ core.css nên loại ra.
154
+ '.ag-cell.ag-cell-range-selected:not(.ag-cell-range-single-cell).ag-cell-range-top{border-top-color:' + FS_RANGE_BC + ';border-top-style:' + FS_RANGE_BS + '}',
155
+ '.ag-cell.ag-cell-range-selected:not(.ag-cell-range-single-cell).ag-cell-range-right{border-right-color:' + FS_RANGE_BC + ';border-right-style:' + FS_RANGE_BS + '}',
156
+ '.ag-cell.ag-cell-range-selected:not(.ag-cell-range-single-cell).ag-cell-range-bottom{border-bottom-color:' + FS_RANGE_BC + ';border-bottom-style:' + FS_RANGE_BS + '}',
157
+ '.ag-cell.ag-cell-range-selected:not(.ag-cell-range-single-cell).ag-cell-range-left{border-left-color:' + FS_RANGE_BC + ';border-left-style:' + FS_RANGE_BS + '}',
158
+ // Viền đứt xem trước vùng sắp fill. Trái/phải phải !important vì .ag-cell đã bị
159
+ // columnBorder của theme set border-right/left.
160
+ '.ag-cell.ag-selection-fill-top{border-top:1px dashed ' + FS_RANGE_BC + '}',
161
+ '.ag-cell.ag-selection-fill-bottom{border-bottom:1px dashed ' + FS_RANGE_BC + '}',
162
+ '.ag-cell.ag-selection-fill-right{border-right:1px dashed ' + FS_RANGE_BC + '!important}',
163
+ '.ag-cell.ag-selection-fill-left{border-left:1px dashed ' + FS_RANGE_BC + '!important}',
164
+ // Nút vuông góc dưới-phải để kéo fill. .ag-cell là position:absolute (core.css) nên neo đúng.
165
+ // Viền trắng bao quanh để nổi lên trên nền vùng chọn, z-index để ô kế bên không phủ mất.
166
+ '.ag-fill-handle{position:absolute;bottom:-2px;right:-2px;width:8px;height:8px;z-index:1;cursor:crosshair;box-sizing:border-box;border:1px solid #fff;background-color:' + FS_RANGE_BC + '}',
167
+ // Nháy báo đã copy: phủ một lớp xanh đậm (::after) NẰM TRÊN nền vùng chọn rồi mờ dần
168
+ // opacity — không animate thẳng background-color của .ag-cell, vì màu đó đang bị luật
169
+ // nền vùng chọn (.ag-cell-range-selected) tranh cùng thuộc tính, animation kết thúc là
170
+ // "bật" ngược lại màu nền bên dưới, giật cục thay vì mờ dần mượt. ::after kế thừa
171
+ // position:absolute từ .ag-cell (chính nó là containing block của pseudo-element) nên
172
+ // inset:0 phủ khít, forwards giữ opacity 0 tới khi JS gỡ class, tránh chớp lại giữa lúc
173
+ // animation xong và setTimeout gỡ class (flashCopy, cùng 800ms).
174
+ '@keyframes fsCopyFlash{from{opacity:.6}to{opacity:0}}',
175
+ '.ag-cell.ag-cell-copy-flash::after{content:"";position:absolute;inset:0;pointer-events:none;background-color:' + FS_RANGE_BC + ';animation:fsCopyFlash .8s ease-out forwards}'
176
+ ].join('\n')
177
+ }
178
+ // appendChild trên node đã nằm trong head = DỜI xuống cuối, không nhân bản. Cần vậy vì
179
+ // AG Grid bơm core.css lúc createGrid() (sau khi file này nạp), mà core.css có luật
180
+ // .ag-cell.ag-cell-last-left-pinned:not(.ag-cell-range-right,…) cùng specificity (0,3,0)
181
+ // với luật viền ở trên — ai đứng sau thì thắng, nên f-sheet phải đứng sau.
182
+ document.head.appendChild(st)
183
+ }
184
+ fsInstallRangeCss()
185
+
186
+ // modulo luôn ra số dương (JS trả số âm với toán hạng âm) — dùng để lặp vòng khối nguồn khi fill.
187
+ function fsMod(a, n) { return ((a % n) + n) % n }
188
+
189
+ // Tách text clipboard (TSV của Excel/Sheets) thành mảng 2 chiều.
190
+ function fsParseClipboard(text) {
191
+ var lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
192
+ while (lines.length && lines[lines.length - 1] === '') lines.pop()
193
+ return lines.map(function (l) { return l.split('\t') })
194
+ }
195
+
196
+ // ===== Kiểu ngày =====
197
+ // KHÔNG dùng moment/dayjs: file này dùng chung V2 + V3, mà V2 chỉ bundle moment còn V3 chỉ
198
+ // bundle dayjs — không thư viện nào chắc chắn có mặt ở cả hai runtime. Cả hai cũng không từ
199
+ // chối nhất quán ngày kiểu 31/02, trong khi f-sheet cần phân biệt rõ hợp lệ / không hợp lệ.
200
+
201
+ // Chuẩn hoá về 'yyyy-mm-dd' — dạng lưu DUY NHẤT của cột ngày. Không nhận diện được → null.
202
+ // Đây là hợp đồng giá trị chung của cả nền tảng: <input type="date"> của trình duyệt,
203
+ // f-date (component.js, emit 'yyyy-mm-dd') và OPENJSON ... date '$.X' của tAPI đều dùng nó.
204
+ function fsToISODate(val) {
205
+ if (val === '' || val === null || val === undefined) return null
206
+ var y, m, d
207
+ // toString thay vì `instanceof Date`: instanceof so identity của constructor nên hỏng khi
208
+ // Date đến từ realm khác (iframe, vm) — mà agDateColumnFilter thì đưa vào một Date.
209
+ if (Object.prototype.toString.call(val) === '[object Date]') {
210
+ if (isNaN(val.getTime())) return null
211
+ y = val.getFullYear(); m = val.getMonth() + 1; d = val.getDate()
212
+ } else {
213
+ var s = String(val).trim()
214
+ // yyyy-mm-dd — không neo đuôi, để bắt luôn ISO datetime '2026-08-15T00:00:00' của tAPI
215
+ var iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/)
216
+ var loc = iso ? null : s.match(/^(\d{1,2})[\/-](\d{1,2})[\/-](\d{4})$/) // dd/mm/yyyy · dd-mm-yyyy
217
+ if (iso) { y = +iso[1]; m = +iso[2]; d = +iso[3] }
218
+ else if (loc) { d = +loc[1]; m = +loc[2]; y = +loc[3] }
219
+ else return null
220
+ }
221
+ // Round-trip bắt buộc: new Date(2026, 12, 15) tự cuộn sang 2027-01-15, tức ngày SAI biến
222
+ // thành một ngày HỢP LỆ KHÁC. Bỏ bước so lại thì 15/13/2026 và 31/02/2026 lọt qua im lặng
223
+ // — đẻ ra hỏng-âm-thầm mới, tệ hơn cái đang đi sửa.
224
+ var dt = new Date(y, m - 1, d)
225
+ if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null
226
+ return y + '-' + (m < 10 ? '0' : '') + m + '-' + (d < 10 ? '0' : '') + d
227
+ }
228
+
229
+ // Hiển thị 'dd/mm/yyyy'. Không parse được → null để caller tự quyết (buildColDefs in chuỗi
230
+ // thô ra, cho dữ liệu rác HIỆN LÊN chứ không biến mất).
231
+ function fsFormatDate(val) {
232
+ var iso = fsToISODate(val)
233
+ if (!iso) return null
234
+ return iso.slice(8, 10) + '/' + iso.slice(5, 7) + '/' + iso.slice(0, 4)
235
+ }
236
+
237
+ // agDateColumnFilter đưa vào một Date lúc nửa đêm; giá trị ô là chuỗi thô nên phải tự so.
238
+ function fsDateFilterComparator(filterLocalDateAtMidnight, cellValue) {
239
+ var iso = fsToISODate(cellValue)
240
+ if (!iso) return -1
241
+ var f = fsToISODate(filterLocalDateAtMidnight)
242
+ if (!f) return 0
243
+ return iso < f ? -1 : (iso > f ? 1 : 0)
244
+ }
245
+
246
+ // Bảng columnType f-sheet đăng ký — NGUỒN DUY NHẤT. gridOptions.columnTypes, parseByType,
247
+ // dán từ Excel, onCellValueChanged và cảnh báo type lạ đều đọc từ đây; thêm type thứ ba =
248
+ // thêm MỘT mục ở đây + một nhánh trong parseByType, không đi sửa 5 chỗ rẽ nhánh rải rác.
249
+ var FS_COLUMN_TYPES = {
250
+ number: { filter: 'agNumberColumnFilter' },
251
+ date: { filter: 'agDateColumnFilter', filterParams: { comparator: fsDateFilterComparator } }
252
+ }
253
+
254
+ // Trả về tên type nếu f-sheet có đăng ký, ngược lại null. Dùng thay cho việc so
255
+ // `=== 'number'` rải rác — chỗ nào cũng hỏi cùng một bảng.
256
+ function fsKnownType(type) {
257
+ if (typeof type !== 'string') return null
258
+ return Object.prototype.hasOwnProperty.call(FS_COLUMN_TYPES, type) ? type : null
259
+ }
260
+
261
+ // `type` lạ thì AG Grid chỉ log warning #36 qua console.warn — mà renderUI chỉ chuyển tiếp
262
+ // console.error và [Vue warn], nên cảnh báo đó không bao giờ tới được người đang sửa module
263
+ // (0/275 bản ghi trong _logs/tool-errors.jsonl nhắc AG Grid). f-sheet tự nói bằng
264
+ // console.error để lỗi nổi lên trong module_simulate({renderUI:true}) và trong log.
265
+ // Phải quét đệ quy vào children: colDef thật của column group nằm trong đó.
266
+ function fsWarnUnknownColumnTypes(defs) {
267
+ var valid = Object.keys(FS_COLUMN_TYPES)
268
+ ;(defs || []).forEach(function (def) {
269
+ if (!def) return
270
+ if (def.children) { fsWarnUnknownColumnTypes(def.children); return }
271
+ if (def.type === null || def.type === undefined || def.type === '') return
272
+ // AG Grid cho phép colDef.type là mảng tên
273
+ ;[].concat(def.type).forEach(function (t) {
274
+ if (valid.indexOf(t) >= 0) return
275
+ console.error(
276
+ '[f-sheet] colDef.type "' + t + '" chưa đăng ký — cột "' +
277
+ (def.headerName || def.field || '?') +
278
+ '" sẽ KHÔNG có valueFormatter/valueParser/filter nào cả (chạy y hệt cột text thô). ' +
279
+ 'f-sheet chỉ nhận: ' + valid.join(', ') + '. Xem references/fsheet.md mục "columnType".'
280
+ )
281
+ })
282
+ })
283
+ }
284
+
285
+ var fsheetTheme = window.agGrid.themeQuartz
286
+ .withPart(window.agGrid.iconSetQuartzLight)
287
+ .withParams({
288
+ borderRadius: 3,
289
+ browserColorScheme: 'light',
290
+ columnBorder: true,
291
+ fontFamily: 'inherit',
292
+ iconSize: 16,
293
+ wrapperBorder: true,
294
+ wrapperBorderRadius: 4
295
+ })
296
+
297
+ Vue.component('FSheet', {
298
+ template: `<div class="f-sheet-root"> <div class="d-flex align-center mb-2"> <span v-if="label" class="text-subtitle-1 font-weight-bold text-primary">{{ label }}</span> <v-spacer></v-spacer> <span v-if="changedCount > 0" class="text-caption text-orange mr-3">{{ changedCount }} dòng thay đổi</span> <v-btn icon variant="text" density="comfortable" color="primary" class="mr-2" title="Xuất Excel" @click="exportExcel"> <v-icon>mdi-microsoft-excel</v-icon> </v-btn> <v-btn v-if="allowAdd" icon variant="text" density="comfortable" color="primary" class="mr-2" title="Thêm dòng" @click="addRow"> <v-icon>mdi-plus</v-icon> </v-btn> <v-btn v-if="changedCount > 0" icon variant="text" density="comfortable" color="success" :loading="saving" :disabled="!updateApi" class="mr-2" title="Lưu thay đổi" @click="saveAll"> <v-icon>mdi-content-save</v-icon> </v-btn> <v-btn v-if="changedCount > 0" icon variant="text" density="comfortable" class="mr-2" title="Huỷ thay đổi" @click="discardAll"> <v-icon>mdi-undo-variant</v-icon> </v-btn> <v-btn v-if="allowDelete && selectedRows.length > 0" icon variant="text" density="comfortable" color="error" title="Xoá dòng đã chọn" @click="deleteSelected"> <v-icon>mdi-delete</v-icon> </v-btn> </div> <div ref="gridEl" :style="gridStyle"></div> </div>`, props: {
299
+ label: {}, columns: {}, items: {}, rowKey: {},
300
+ height: {},
301
+ allowAdd: {}, allowDelete: {}, readonly: {},
302
+ updateApi: {}, saveExtra: {}, defaultItem: {}, deleteApi: {}
303
+ },
304
+ data: function () {
305
+ return {
306
+ gridApi: null,
307
+ changedRows: {},
308
+ selectedRows: [],
309
+ saving: false,
310
+ localCounter: 0,
311
+ fillHeight: 0
312
+ }
313
+ },
314
+ computed: {
315
+ changedCount: function () {
316
+ return Object.keys(this.changedRows).length
317
+ },
318
+ // null = không khai height → lưới tự giãn theo số dòng (domLayout 'autoHeight')
319
+ heightNum: function () {
320
+ if (this.height === undefined || this.height === null || this.height === '') return null
321
+ var n = Number(this.height)
322
+ return isNaN(n) ? null : n
323
+ },
324
+ gridStyle: function () {
325
+ if (this.heightNum === null) return { width: '100%' }
326
+ var h = (this.heightNum > 0) ? this.heightNum : this.fillHeight
327
+ if (!h) return { width: '100%' }
328
+ return { height: h + 'px', width: '100%' }
329
+ }
330
+ },
331
+ watch: {
332
+ items: function (val) {
333
+ if (this.gridApi) {
334
+ this.buildOriginalData(val)
335
+ if (!this.columns || !this.columns.length) {
336
+ this.gridApi.setGridOption('columnDefs', this.buildColDefs(null))
337
+ }
338
+ this.gridApi.setGridOption('rowData', val || [])
339
+ this.changedRows = {}
340
+ this.selectedRows = []
341
+ this.localCounter = 0
342
+ // dữ liệu mới → vùng chọn cũ trỏ vào dòng không còn tồn tại, bỏ đi
343
+ this.clearRange()
344
+ }
345
+ },
346
+ columns: function (val) {
347
+ if (this.gridApi) {
348
+ this.gridApi.setGridOption('columnDefs', this.buildColDefs(val))
349
+ }
350
+ }
351
+ },
352
+ mounted: function () {
353
+ this.initGrid()
354
+ if (this.heightNum !== null && this.heightNum <= 0) {
355
+ this.updateFillHeight()
356
+ this.resizeHandler = this.updateFillHeight.bind(this)
357
+ window.addEventListener('resize', this.resizeHandler)
358
+ }
359
+ },
360
+ beforeDestroy: function () { this.destroyGrid() },
361
+ beforeUnmount: function () { this.destroyGrid() },
362
+ methods: {
363
+ destroyGrid: function () {
364
+ if (this.resizeHandler) {
365
+ window.removeEventListener('resize', this.resizeHandler)
366
+ this.resizeHandler = null
367
+ }
368
+ if (this.mouseUpHandler) {
369
+ document.removeEventListener('mouseup', this.mouseUpHandler)
370
+ this.mouseUpHandler = null
371
+ }
372
+ if (this.mouseDownHandler) {
373
+ document.removeEventListener('pointerdown', this.mouseDownHandler, true)
374
+ document.removeEventListener('mousedown', this.mouseDownHandler, true)
375
+ this.mouseDownHandler = null
376
+ }
377
+ if (this.keyStateHandler) {
378
+ document.removeEventListener('keydown', this.keyStateHandler, true)
379
+ document.removeEventListener('keyup', this.keyStateHandler, true)
380
+ this.keyStateHandler = null
381
+ }
382
+ if (this.rangeObserver) {
383
+ this.rangeObserver.disconnect()
384
+ this.rangeObserver = null
385
+ }
386
+ if (this.$refs.gridEl) {
387
+ if (this.copyHandler) this.$refs.gridEl.removeEventListener('copy', this.copyHandler)
388
+ if (this.pasteHandler) this.$refs.gridEl.removeEventListener('paste', this.pasteHandler)
389
+ if (this.keyDownHandler) this.$refs.gridEl.removeEventListener('keydown', this.keyDownHandler)
390
+ if (this.contextMenuHandler) this.$refs.gridEl.removeEventListener('contextmenu', this.contextMenuHandler)
391
+ }
392
+ this.copyHandler = null
393
+ this.pasteHandler = null
394
+ this.keyDownHandler = null
395
+ this.contextMenuHandler = null
396
+ if (this.copyFlashTimer) { clearTimeout(this.copyFlashTimer); this.copyFlashTimer = null }
397
+ if (this.gridApi) {
398
+ this.gridApi.destroy()
399
+ this.gridApi = null
400
+ }
401
+ },
402
+ updateFillHeight: function () {
403
+ var self = this
404
+ self.$nextTick(function () {
405
+ requestAnimationFrame(function () {
406
+ var el = self.$refs.gridEl
407
+ if (!el) return
408
+ var top = el.getBoundingClientRect().top
409
+ var bottomMargin = Math.abs(self.heightNum)
410
+ self.fillHeight = Math.max(200, window.innerHeight - top - bottomMargin)
411
+ })
412
+ })
413
+ },
414
+ buildOriginalData: function (rows) {
415
+ var keyField = this.rowKey || 'id'
416
+ this.originalData = {}
417
+ this.originalRows = (rows || []).map(function (row) {
418
+ var copy = Object.assign({}, row)
419
+ var key = row[keyField]
420
+ if (key !== undefined && key !== null) {
421
+ this.originalData[String(key)] = copy
422
+ }
423
+ return copy
424
+ }, this)
425
+ },
426
+ autoColDefs: function () {
427
+ var sample = this.items && this.items[0]
428
+ if (!sample) return []
429
+ return Object.keys(sample).map(function (key) {
430
+ return { field: key, headerName: key }
431
+ })
432
+ },
433
+ changeCellStyle: function (params) {
434
+ var self = this
435
+ var keyField = self.rowKey || 'id'
436
+ var field = params.colDef.field
437
+ if (!field) return null
438
+ var data = params.data
439
+ if (!data) return null
440
+ // Dòng mới nhận diện qua _fsNewKey (khoá tạm nội bộ) — KHÔNG còn dựa vào giá trị ô
441
+ // khoá thật là số âm nữa, vì ô khoá thật giờ luôn để trống cho người dùng tự gõ.
442
+ if (data._fsNewKey !== undefined) {
443
+ return { backgroundColor: '#e8f5e9' }
444
+ }
445
+ var key = data[keyField]
446
+ if (key === undefined || key === null) return null
447
+ var orig = self.originalData && self.originalData[String(key)]
448
+ if (orig && orig.hasOwnProperty(field) && params.value !== orig[field]) {
449
+ return { backgroundColor: '#fff8e1' }
450
+ }
451
+ return null
452
+ },
453
+ // Ép giá trị về đúng kiểu của cột. Mọi type ở đây phải có mặt trong FS_COLUMN_TYPES.
454
+ parseByType: function (type, val) {
455
+ if (type === 'number') {
456
+ if (val === '' || val === null || val === undefined) return null
457
+ if (typeof val === 'number') return val
458
+ var num = Number(val)
459
+ return isNaN(num) ? null : num
460
+ }
461
+ if (type === 'date') {
462
+ // Không nhận diện được → null, đối xứng với nhánh number ở trên (giữ chuỗi rác
463
+ // lại thì SP nhận vào một giá trị không phải ngày mà không ai biết).
464
+ return fsToISODate(val)
465
+ }
466
+ return val
467
+ },
468
+
469
+ // ===== Chọn vùng nhiều ô + fill handle + clipboard =====
470
+ // AG Grid gate Cell Selection vào bản Enterprise (cả JS lẫn CSS của module đó), nên
471
+ // f-sheet tự viết phần ĐIỀU KHIỂN rồi gắn đúng bộ class của AG Grid
472
+ // (.ag-cell-range-*, .ag-selection-fill-*, .ag-fill-handle); CSS cho mấy class này
473
+ // được bơm ở đầu file (xem khối #fsheet-range-css).
474
+ // Tô vùng chọn bằng cách sửa class trực tiếp trên DOM (paintRange), KHÔNG dùng
475
+ // refreshCells: refreshCells dựng lại cellRenderer của cả lưới, rê chuột một cái là
476
+ // giật và làm rớt trạng thái ô đang sửa.
477
+ // Giá trị ghi qua node.setDataValue nên onCellValueChanged vẫn chạy → phần
478
+ // changedRows/highlight/lưu bên dưới không cần biết gì về đoạn này.
479
+ colIndex: function (column) {
480
+ if (!this.gridApi || !column) return -1
481
+ return this.gridApi.getAllDisplayedColumns().indexOf(column)
482
+ },
483
+ rangeBounds: function () {
484
+ var a = this.rangeStart, b = this.rangeEnd
485
+ if (!a || !b) return null
486
+ return {
487
+ top: Math.min(a.row, b.row), bottom: Math.max(a.row, b.row),
488
+ left: Math.min(a.col, b.col), right: Math.max(a.col, b.col)
489
+ }
490
+ },
491
+ // Vùng SẼ được fill khi thả chuột (chỉ để vẽ viền đứt xem trước, chưa ghi gì).
492
+ // Kéo chéo thì bám trục có độ lệch lớn hơn, giống Excel.
493
+ fillTarget: function () {
494
+ var r = this.rangeBounds()
495
+ var e = this.fillEnd
496
+ if (!r || !e) return null
497
+ var dRow = e.row > r.bottom ? e.row - r.bottom : (e.row < r.top ? e.row - r.top : 0)
498
+ var dCol = e.col > r.right ? e.col - r.right : (e.col < r.left ? e.col - r.left : 0)
499
+ if (!dRow && !dCol) return null
500
+ if (Math.abs(dRow) >= Math.abs(dCol)) {
501
+ return dRow > 0
502
+ ? { top: r.bottom + 1, bottom: e.row, left: r.left, right: r.right }
503
+ : { top: e.row, bottom: r.top - 1, left: r.left, right: r.right }
504
+ }
505
+ return dCol > 0
506
+ ? { top: r.top, bottom: r.bottom, left: r.right + 1, right: e.col }
507
+ : { top: r.top, bottom: r.bottom, left: e.col, right: r.left - 1 }
508
+ },
509
+ schedulePaint: function () {
510
+ var self = this
511
+ if (self.paintPending) return
512
+ self.paintPending = true
513
+ requestAnimationFrame(function () { self.paintPending = false; self.paintRange() })
514
+ },
515
+ paintRange: function () {
516
+ var self = this
517
+ var el = self.$refs.gridEl
518
+ if (!el || !self.gridApi) return
519
+ var r = self.rangeBounds()
520
+ var f = self.filling ? self.fillTarget() : null
521
+ var single = !!r && r.top === r.bottom && r.left === r.right
522
+ var columns = self.gridApi.getAllDisplayedColumns()
523
+ var idx = {}
524
+ columns.forEach(function (c, i) { idx[c.getColId()] = i })
525
+ var corner = null
526
+ var cells = el.querySelectorAll('.ag-cell')
527
+ var matched = 0
528
+
529
+ Array.prototype.forEach.call(cells, function (cell) {
530
+ cell.classList.remove.apply(cell.classList, FS_RANGE_CLASSES)
531
+ // closest chứ không phải parentElement: từ v36 AG Grid bọc ô trong cell-section
532
+ // (.ag-row > .ag-grid-pinned-left-cells|.ag-grid-scrolling-cells >
533
+ // .ag-grid-container-wrapper > .ag-cell), row-index vẫn nằm trên .ag-row nên
534
+ // parentElement trỏ vào wrapper và tra ra null → NaN → rụng sạch mọi ô.
535
+ var rowEl = cell.closest('.ag-row')
536
+ var row = rowEl ? parseInt(rowEl.getAttribute('row-index'), 10) : NaN
537
+ var col = idx[cell.getAttribute('col-id')]
538
+ if (isNaN(row) || col === undefined) return
539
+ matched++
540
+ if (r && row >= r.top && row <= r.bottom && col >= r.left && col <= r.right) {
541
+ cell.classList.add('ag-cell-range-selected')
542
+ if (single) {
543
+ cell.classList.add('ag-cell-range-single-cell')
544
+ } else {
545
+ if (row === r.top) cell.classList.add('ag-cell-range-top')
546
+ if (row === r.bottom) cell.classList.add('ag-cell-range-bottom')
547
+ if (col === r.left) cell.classList.add('ag-cell-range-left')
548
+ if (col === r.right) cell.classList.add('ag-cell-range-right')
549
+ }
550
+ if (row === r.bottom && col === r.right) corner = cell
551
+ }
552
+ if (f && row >= f.top && row <= f.bottom && col >= f.left && col <= f.right) {
553
+ if (row === f.top) cell.classList.add('ag-selection-fill-top')
554
+ if (row === f.bottom) cell.classList.add('ag-selection-fill-bottom')
555
+ if (col === f.left) cell.classList.add('ag-selection-fill-left')
556
+ if (col === f.right) cell.classList.add('ag-selection-fill-right')
557
+ }
558
+ })
559
+
560
+ // .ag-fill-handle là element con thật (AG Grid style sẵn position/size/cursor),
561
+ // không phải pseudo-element, nên phải gắn/gỡ DOM. Ô .ag-cell đã là position:absolute
562
+ // nên nó tự neo đúng góc dưới-phải.
563
+ Array.prototype.forEach.call(el.querySelectorAll('.ag-fill-handle'), function (h) { h.remove() })
564
+ // Cột không cho sửa (colDef.editable:false) thì cũng không cho kéo fill xuống/ngang
565
+ // — isCellEditable() là API sẵn có của AG Grid, đã dùng đúng chỗ này ở doFill/pasteText.
566
+ var cornerCol = r ? columns[r.right] : null
567
+ var cornerNode = r ? self.gridApi.getDisplayedRowAtIndex(r.bottom) : null
568
+ if (corner && !self.readonly && cornerCol && cornerNode && cornerCol.isCellEditable(cornerNode)) {
569
+ var handle = document.createElement('div')
570
+ handle.className = 'ag-fill-handle'
571
+ corner.appendChild(handle)
572
+ }
573
+
574
+ // Có vùng chọn mà không khớp ô nào = cấu trúc DOM của AG Grid đã đổi (row-index/col-id
575
+ // nằm chỗ khác). Kêu đúng một lần kèm số liệu cần thiết, khỏi phải dò lại từ đầu.
576
+ if (r && matched === 0 && cells.length && !self.rangeWarned) {
577
+ self.rangeWarned = true
578
+ var c0 = cells[0]
579
+ console.warn('[f-sheet] paintRange khớp 0 ô — DOM của AG Grid có thể đã đổi.', {
580
+ cells: cells.length,
581
+ parent: c0.parentElement && c0.parentElement.className,
582
+ rowIndex: c0.closest('.ag-row') && c0.closest('.ag-row').getAttribute('row-index'),
583
+ colId: c0.getAttribute('col-id'),
584
+ colIds: Object.keys(idx)
585
+ })
586
+ }
587
+ },
588
+ // Chặn bôi đen chữ khi đang rê chọn vùng.
589
+ lockSelect: function (on) {
590
+ var el = this.$refs.gridEl
591
+ if (el) el.style.userSelect = on ? 'none' : ''
592
+ },
593
+ clearRange: function () {
594
+ this.rangeStart = null
595
+ this.rangeEnd = null
596
+ this.fillEnd = null
597
+ this.paintRange()
598
+ },
599
+ doFill: function () {
600
+ var self = this
601
+ var r = self.rangeBounds()
602
+ var t = self.fillTarget()
603
+ if (!r || !t || self.readonly) return
604
+ var cols = self.gridApi.getAllDisplayedColumns()
605
+ var spanR = r.bottom - r.top + 1
606
+ var spanC = r.right - r.left + 1
607
+ for (var row = t.top; row <= t.bottom; row++) {
608
+ var node = self.gridApi.getDisplayedRowAtIndex(row)
609
+ if (!node) continue
610
+ // lặp lại tuần tự khối nguồn (kiểu copy), không tự suy diễn dãy số tăng dần
611
+ var srcNode = self.gridApi.getDisplayedRowAtIndex(r.top + fsMod(row - r.top, spanR))
612
+ if (!srcNode) continue
613
+ for (var col = t.left; col <= t.right; col++) {
614
+ var column = cols[col]
615
+ var srcCol = cols[r.left + fsMod(col - r.left, spanC)]
616
+ if (!column || !srcCol) continue
617
+ var cd = column.getColDef()
618
+ var sf = srcCol.getColDef().field
619
+ if (!cd.field || !sf || !column.isCellEditable(node)) continue
620
+ node.setDataValue(cd.field, srcNode.data ? srcNode.data[sf] : null)
621
+ }
622
+ }
623
+ // vùng chọn mới bao trùm cả khối nguồn lẫn phần vừa fill
624
+ self.rangeStart = { row: Math.min(r.top, t.top), col: Math.min(r.left, t.left) }
625
+ self.rangeEnd = { row: Math.max(r.bottom, t.bottom), col: Math.max(r.right, t.right) }
626
+ },
627
+ // Vùng đang chọn, hoặc nếu không có thì ô đang focus (dùng chung cho copy/cut/paste
628
+ // dù gọi từ phím tắt hay từ menu chuột phải).
629
+ currentRangeOrFocus: function () {
630
+ var self = this
631
+ var r = self.rangeBounds()
632
+ if (r) return r
633
+ var fc = self.gridApi.getFocusedCell()
634
+ if (!fc) return null
635
+ var ci = self.colIndex(fc.column)
636
+ return { top: fc.rowIndex, bottom: fc.rowIndex, left: ci, right: ci }
637
+ },
638
+ // Dựng text TSV cho một vùng — dùng chung cho Ctrl+C và menu Copy/Copy with Headers.
639
+ rangeText: function (r, withHeaders) {
640
+ var self = this
641
+ var cols = self.gridApi.getAllDisplayedColumns()
642
+ var out = []
643
+ if (withHeaders) {
644
+ var head = []
645
+ for (var hc = r.left; hc <= r.right; hc++) {
646
+ var hcol = cols[hc]
647
+ head.push(hcol ? (hcol.getColDef().headerName || hcol.getColDef().field || '') : '')
648
+ }
649
+ out.push(head.join('\t'))
650
+ }
651
+ for (var i = r.top; i <= r.bottom; i++) {
652
+ var node = self.gridApi.getDisplayedRowAtIndex(i)
653
+ if (!node) continue
654
+ var line = []
655
+ for (var c = r.left; c <= r.right; c++) {
656
+ var column = cols[c]
657
+ if (!column) continue
658
+ var f = column.getColDef().field
659
+ var v = (f && node.data) ? node.data[f] : ''
660
+ line.push(v === null || v === undefined ? '' : String(v))
661
+ }
662
+ out.push(line.join('\t'))
663
+ }
664
+ return out.join('\n')
665
+ },
666
+ // Ghi clipboard hệ thống khi KHÔNG có ClipboardEvent thật (bấm menu, không phải Ctrl+C).
667
+ writeClipboard: function (text) {
668
+ if (navigator.clipboard && navigator.clipboard.writeText) {
669
+ navigator.clipboard.writeText(text).catch(function () { fsFallbackCopy(text) })
670
+ } else {
671
+ fsFallbackCopy(text)
672
+ }
673
+ },
674
+ // Nháy nền vùng vừa copy để báo hiệu — gỡ rồi gắn lại class để animation chạy lại
675
+ // được cả khi copy liên tiếp cùng một vùng (đổi class suông không tự restart animation).
676
+ flashCopy: function (r) {
677
+ var self = this
678
+ var el = self.$refs.gridEl
679
+ if (!el || !self.gridApi) return
680
+ var cols = self.gridApi.getAllDisplayedColumns()
681
+ var idx = {}
682
+ cols.forEach(function (c, i) { idx[c.getColId()] = i })
683
+ var hit = []
684
+ Array.prototype.forEach.call(el.querySelectorAll('.ag-cell'), function (cell) {
685
+ var rowEl = cell.closest('.ag-row')
686
+ var row = rowEl ? parseInt(rowEl.getAttribute('row-index'), 10) : NaN
687
+ var col = idx[cell.getAttribute('col-id')]
688
+ if (isNaN(row) || col === undefined) return
689
+ if (row >= r.top && row <= r.bottom && col >= r.left && col <= r.right) hit.push(cell)
690
+ })
691
+ if (!hit.length) return
692
+ clearTimeout(self.copyFlashTimer)
693
+ hit.forEach(function (cell) { cell.classList.remove('ag-cell-copy-flash'); void cell.offsetWidth })
694
+ hit.forEach(function (cell) { cell.classList.add('ag-cell-copy-flash') })
695
+ self.copyFlashTimer = setTimeout(function () {
696
+ hit.forEach(function (cell) { cell.classList.remove('ag-cell-copy-flash') })
697
+ }, 800)
698
+ },
699
+ doCopy: function (e) {
700
+ var self = this
701
+ var r = self.currentRangeOrFocus()
702
+ if (!r) return
703
+ var text = self.rangeText(r, false)
704
+ if (!text) return
705
+ e.clipboardData.setData('text/plain', text)
706
+ e.preventDefault()
707
+ self.flashCopy(r)
708
+ },
709
+ // Copy / Copy with Headers từ menu chuột phải — không có ClipboardEvent nên tự ghi clipboard.
710
+ menuCopy: function (withHeaders) {
711
+ var self = this
712
+ var r = self.currentRangeOrFocus()
713
+ if (!r) return
714
+ var text = self.rangeText(r, withHeaders)
715
+ if (!text) return
716
+ self.writeClipboard(text)
717
+ self.flashCopy(r)
718
+ },
719
+ // Cut từ menu chuột phải: copy rồi xoá giá trị các ô trong vùng (chỉ ô sửa được).
720
+ menuCut: function () {
721
+ var self = this
722
+ if (self.readonly) return
723
+ var r = self.currentRangeOrFocus()
724
+ if (!r) return
725
+ var text = self.rangeText(r, false)
726
+ if (!text) return
727
+ self.writeClipboard(text)
728
+ self.flashCopy(r)
729
+ var cols = self.gridApi.getAllDisplayedColumns()
730
+ for (var i = r.top; i <= r.bottom; i++) {
731
+ var node = self.gridApi.getDisplayedRowAtIndex(i)
732
+ if (!node) continue
733
+ for (var c = r.left; c <= r.right; c++) {
734
+ var column = cols[c]
735
+ if (!column) continue
736
+ var cd = column.getColDef()
737
+ if (!cd.field || !column.isCellEditable(node)) continue
738
+ node.setDataValue(cd.field, null)
739
+ }
740
+ }
741
+ },
742
+ // Lõi dán dùng chung cho Ctrl+V (ClipboardEvent) và menu Paste (Clipboard API).
743
+ pasteText: function (text) {
744
+ var self = this
745
+ if (self.readonly || !text) return
746
+ var r = self.currentRangeOrFocus()
747
+ if (!r) return
748
+ var startRow = r.top, startCol = r.left
749
+ var grid = fsParseClipboard(text)
750
+ var cols = self.gridApi.getAllDisplayedColumns()
751
+ grid.forEach(function (rowVals, i) {
752
+ var node = self.gridApi.getDisplayedRowAtIndex(startRow + i)
753
+ if (!node) return
754
+ rowVals.forEach(function (val, j) {
755
+ var column = cols[startCol + j]
756
+ if (!column) return
757
+ var cd = column.getColDef()
758
+ if (!cd.field || !column.isCellEditable(node)) return
759
+ // Dán đi thẳng vào data, KHÔNG qua valueParser của colDef — nên phải tự ép kiểu
760
+ // ở đây, nếu không thì cột ngày/số dán từ Excel vẫn lưu chuỗi thô.
761
+ var pType = fsKnownType(cd.type)
762
+ node.setDataValue(cd.field, pType ? self.parseByType(pType, val) : val)
763
+ })
764
+ })
765
+ self.rangeStart = { row: startRow, col: startCol }
766
+ self.rangeEnd = {
767
+ row: startRow + grid.length - 1,
768
+ col: startCol + (grid[0] ? grid[0].length : 1) - 1
769
+ }
770
+ self.schedulePaint()
771
+ },
772
+ doPaste: function (e) {
773
+ var self = this
774
+ if (self.readonly) return
775
+ var cb = e.clipboardData || window.clipboardData
776
+ var text = cb ? cb.getData('text') : ''
777
+ if (!text) return
778
+ e.preventDefault()
779
+ self.pasteText(text)
780
+ },
781
+ // Paste từ menu chuột phải: không có ClipboardEvent nên phải chủ động đọc clipboard
782
+ // (Clipboard API, cần secure context + có thể xin quyền lần đầu).
783
+ menuPaste: function () {
784
+ var self = this
785
+ if (self.readonly) return
786
+ if (navigator.clipboard && navigator.clipboard.readText) {
787
+ navigator.clipboard.readText().then(function (text) { self.pasteText(text) }).catch(function () {
788
+ Vue.$toast.error('Không đọc được clipboard — thử Ctrl+V hoặc cấp quyền clipboard cho trang.', { position: 'top' })
789
+ })
790
+ } else {
791
+ Vue.$toast.error('Trình duyệt không hỗ trợ đọc clipboard qua menu — dùng Ctrl+V.', { position: 'top' })
792
+ }
793
+ },
794
+ buildColDefs: function (cols) {
795
+ var self = this
796
+ var source = (cols && cols.length) ? cols : self.autoColDefs()
797
+ fsWarnUnknownColumnTypes(source)
798
+ var defs = []
799
+
800
+ if (self.allowDelete && !self.readonly) {
801
+ defs.push({
802
+ headerName: '',
803
+ checkboxSelection: true,
804
+ headerCheckboxSelection: true,
805
+ width: 44,
806
+ minWidth: 44,
807
+ maxWidth: 44,
808
+ sortable: false,
809
+ filter: false,
810
+ resizable: false,
811
+ editable: false,
812
+ pinned: 'left'
813
+ })
814
+ }
815
+
816
+ source.forEach(function (col) {
817
+ var def = Object.assign({}, col)
818
+ if (self.readonly) def.editable = false
819
+ // colDef có thể khai cellEditorParams bằng tên string trỏ tới hàm global (vd
820
+ // script.js của module) khi cần giá trị động (vd danh sách chọn lấy từ vueData) —
821
+ // AG Grid hỗ trợ cellEditorParams là function (gọi lại lúc bắt đầu sửa) nên chỉ cần
822
+ // resolve tên thành function thật, không cần cơ chế đăng ký riêng như cellRenderer.
823
+ ;['cellEditorParams', 'cellRendererParams'].forEach(function (key) {
824
+ if (typeof def[key] === 'string' && typeof window[def[key]] === 'function') {
825
+ def[key] = window[def[key]]
826
+ }
827
+ })
828
+ var userCellStyle = def.cellStyle
829
+ var isNumber = def.type === 'number'
830
+ var isDate = def.type === 'date'
831
+ // Nền vùng chọn do paintRange gắn class lo, không đụng vào cellStyle. Nhờ vậy
832
+ // nền vàng/xanh của ô đã sửa (style inline) vẫn thắng nền vùng chọn (CSS class)
833
+ // → quét chọn ngang qua không làm mất dấu ô đang có thay đổi chưa lưu.
834
+ def.cellStyle = function (params) {
835
+ var change = self.changeCellStyle(params)
836
+ var extra = (typeof userCellStyle === 'function') ? userCellStyle(params) : userCellStyle
837
+ if (!extra) {
838
+ if (isNumber) extra = { textAlign: 'right' }
839
+ else if (isDate) extra = { textAlign: 'center' }
840
+ }
841
+ if (change && extra) return Object.assign({}, extra, change)
842
+ return change || extra || null
843
+ }
844
+ if (isNumber && !def.valueFormatter) {
845
+ def.valueFormatter = function (params) {
846
+ if (params.value === null || params.value === undefined || params.value === '') return ''
847
+ var n = Number(params.value)
848
+ if (isNaN(n)) return String(params.value)
849
+ return n.toLocaleString('en-US', { maximumFractionDigits: 10 })
850
+ }
851
+ }
852
+ if (isNumber && !def.valueParser) {
853
+ def.valueParser = function (params) { return self.parseByType('number', params.newValue) }
854
+ }
855
+ if (isDate && !def.valueFormatter) {
856
+ def.valueFormatter = function (params) {
857
+ if (params.value === null || params.value === undefined || params.value === '') return ''
858
+ var s = fsFormatDate(params.value)
859
+ // Không parse được → in chuỗi thô (đúng khuôn của cột số ở trên): dữ liệu rác
860
+ // phải HIỆN RA, nếu trả '' thì ô trông như trống và không ai đi tìm.
861
+ return s === null ? String(params.value) : s
862
+ }
863
+ }
864
+ if (isDate && !def.valueParser) {
865
+ def.valueParser = function (params) { return self.parseByType('date', params.newValue) }
866
+ }
867
+ defs.push(def)
868
+ })
869
+
870
+ return defs
871
+ },
872
+ initGrid: function () {
873
+ var self = this
874
+ var keyField = self.rowKey || 'id'
875
+ self.buildOriginalData(self.items)
876
+
877
+ // Trạng thái vùng chọn — cố ý KHÔNG khai trong data(): đổi liên tục khi rê chuột,
878
+ // để reactive chỉ tốn công theo dõi mà template không hề dùng tới. Việc vẽ lại
879
+ // do paintRange() chủ động gọi.
880
+ self.rangeStart = null
881
+ self.rangeEnd = null
882
+ self.selecting = false
883
+ self.filling = false
884
+ self.fillEnd = null
885
+ self.paintPending = false
886
+ self.handleGrab = false
887
+ self.shiftGrab = false
888
+ self.rightClick = false
889
+ self.rangeWarned = false
890
+ self.copyFlashTimer = null
891
+
892
+ var colDefs = self.buildColDefs(self.columns)
893
+ // colDef có thể khai cellRenderer/cellEditor bằng tên string trỏ tới class/hàm
894
+ // global (vd script.js của module) — AG Grid cần đăng ký tường minh trong
895
+ // components thì mới resolve được, không tự dò theo window[name].
896
+ // Tên editor có sẵn của AG Grid (vd agDateStringCellEditor) không cần đăng ký,
897
+ // chỉ những hàm global tự viết (window[name] là function) mới được thêm vào đây.
898
+ // Column group (headerName + children) phải quét đệ quy vì colDef thật nằm
899
+ // trong children, không phải ở object group.
900
+ var components = {
901
+ fsHtmlCellRenderer: fsHtmlCellRenderer,
902
+ fsLinkCellRenderer: fsLinkCellRenderer,
903
+ fsButtonCellRenderer: fsButtonCellRenderer,
904
+ fsMenuCellRenderer: fsMenuCellRenderer
905
+ }
906
+ function collectComponents(defs) {
907
+ (defs || []).forEach(function (def) {
908
+ if (def.children) { collectComponents(def.children); return }
909
+ ['cellRenderer', 'cellEditor'].forEach(function (key) {
910
+ var name = def[key]
911
+ if (typeof name === 'string' && !components[name] && typeof window[name] === 'function') {
912
+ components[name] = window[name]
913
+ }
914
+ })
915
+ })
916
+ }
917
+ collectComponents(colDefs)
918
+
919
+ self.gridApi = window.agGrid.createGrid(self.$refs.gridEl, {
920
+ theme: fsheetTheme,
921
+ components: components,
922
+ columnDefs: colDefs,
923
+ rowData: self.items || [],
924
+ domLayout: (self.heightNum === null) ? 'autoHeight' : 'normal',
925
+ columnTypes: FS_COLUMN_TYPES,
926
+ rowSelection: (self.allowDelete && !self.readonly) ? 'multiple' : undefined,
927
+ suppressRowClickSelection: true,
928
+ defaultColDef: {
929
+ // AG Grid mặc định editable=false; f-sheet là lưới NHẬP LIỆU nên bật sẵn,
930
+ // cột nào chỉ đọc thì khai editable:false trong colDef.
931
+ editable: !self.readonly,
932
+ sortable: true,
933
+ filter: true,
934
+ resizable: true,
935
+ flex: 1,
936
+ minWidth: 80
937
+ },
938
+ rowHeight: 36,
939
+ headerHeight: 36,
940
+ animateRows: true,
941
+ stopEditingWhenCellsLoseFocus: true,
942
+ onSelectionChanged: function () {
943
+ self.selectedRows = self.gridApi.getSelectedRows()
944
+ },
945
+ // Bấm trúng chính element .ag-fill-handle thì là kéo fill, còn lại là quét chọn
946
+ // vùng mới. Nhận diện bằng e.target (cờ handleGrab đặt ở pha capture bên dưới)
947
+ // chứ không đo toạ độ góc ô — handle là element thật nên hỏi thẳng nó.
948
+ // Bỏ qua cột không có field (cột checkbox chọn dòng).
949
+ onCellMouseDown: function (params) {
950
+ var e = params.event
951
+ if (!e || e.button !== 0) return
952
+ if (!params.column.getColDef().field) return
953
+ var row = params.node.rowIndex
954
+ var col = self.colIndex(params.column)
955
+ if (self.handleGrab) {
956
+ self.filling = true
957
+ self.fillEnd = { row: row, col: col }
958
+ self.lockSelect(true)
959
+ return
960
+ }
961
+ if (e.shiftKey && self.rangeStart) {
962
+ self.rangeEnd = { row: row, col: col }
963
+ } else {
964
+ self.rangeStart = { row: row, col: col }
965
+ self.rangeEnd = { row: row, col: col }
966
+ }
967
+ self.selecting = true
968
+ self.lockSelect(true)
969
+ self.paintRange()
970
+ },
971
+ onCellMouseOver: function (params) {
972
+ if (!params.node || params.node.rowIndex == null) return
973
+ var pos = { row: params.node.rowIndex, col: self.colIndex(params.column) }
974
+ if (self.filling) { self.fillEnd = pos; self.schedulePaint(); return }
975
+ if (!self.selecting) return
976
+ if (!params.column.getColDef().field) return
977
+ self.rangeEnd = pos
978
+ self.schedulePaint()
979
+ },
980
+ // Di chuyển ô bằng bàn phím → thu vùng chọn về đúng ô đang focus (giống Excel),
981
+ // TRỪ khi đang giữ Shift thì kéo dài vùng chọn tới ô mới (Shift+mũi tên kiểu Excel)
982
+ // thay vì thu về 1 ô — self.shiftGrab phản ánh trạng thái Shift tại thời điểm
983
+ // mousedown/keydown gần nhất (xem mouseDownHandler/keyStateHandler bên dưới).
984
+ // Trong lúc đang rê chuột thì bỏ qua, nếu không sẽ đạp lên vùng vừa quét.
985
+ onCellFocused: function (params) {
986
+ if (self.selecting || self.filling || self.handleGrab || self.rightClick) return
987
+ if (params.rowIndex == null || !params.column || typeof params.column === 'string') return
988
+ var col = self.colIndex(params.column)
989
+ if (self.shiftGrab && self.rangeStart) {
990
+ self.rangeEnd = { row: params.rowIndex, col: col }
991
+ } else {
992
+ self.rangeStart = { row: params.rowIndex, col: col }
993
+ self.rangeEnd = { row: params.rowIndex, col: col }
994
+ }
995
+ // paintRange() thẳng chứ không schedulePaint(): AG Grid tự gỡ .ag-cell-focus khỏi
996
+ // ô cũ NGAY trong cùng thao tác focus, còn schedulePaint() hoãn việc gỡ
997
+ // .ag-cell-range-selected sang khung hình animation kế tiếp — trong khoảng hở đó ô
998
+ // cũ khớp ".ag-cell-range-selected:not(.ag-cell-focus)" nên nền chớp sáng lên rồi
999
+ // mới tắt. Gọi đồng bộ giống onCellMouseDown thì không còn khoảng hở đó nữa.
1000
+ self.paintRange()
1001
+ },
1002
+ // Cuộn/ảo hoá cột/sắp xếp/lọc đều dựng lại DOM ô → class tô vùng chọn bị mất,
1003
+ // phải vẽ lại. Sắp xếp và lọc không phát hai event kia nên cần onModelUpdated.
1004
+ onBodyScroll: function () { self.schedulePaint() },
1005
+ onVirtualColumnsChanged: function () { self.schedulePaint() },
1006
+ onModelUpdated: function () { self.schedulePaint() },
1007
+ onCellValueChanged: function (params) {
1008
+ var field = params.colDef.field
1009
+ var cType = fsKnownType(params.colDef.type)
1010
+ if (field && cType) {
1011
+ var coerced = self.parseByType(cType, params.newValue)
1012
+ if (coerced !== params.newValue) {
1013
+ params.data[field] = coerced
1014
+ params.newValue = coerced
1015
+ }
1016
+ }
1017
+ // Dòng mới tra changedRows theo _fsNewKey (khoá tạm nội bộ, không đổi trong suốt
1018
+ // vòng đời dòng) thay vì giá trị ô khoá thật — ô khoá thật có thể bị người dùng
1019
+ // gõ đi gõ lại nhiều lần, nếu tra theo nó thì mỗi lần sửa lại rơi vào một slot
1020
+ // khác nhau trong changedRows, slot cũ bị bỏ quên (nhìn như "chỉ sửa được 1 lần").
1021
+ var key = (params.data._fsNewKey !== undefined) ? params.data._fsNewKey : params.data[keyField]
1022
+ if (key !== undefined && key !== null) {
1023
+ var next = {}
1024
+ for (var k in self.changedRows) {
1025
+ if (Object.prototype.hasOwnProperty.call(self.changedRows, k)) next[k] = self.changedRows[k]
1026
+ }
1027
+ next[String(key)] = params.data
1028
+ self.changedRows = next
1029
+ }
1030
+ // refreshCells dựng lại DOM ô → mất class vùng chọn, vẽ lại ngay sau đó
1031
+ self.gridApi.refreshCells({ force: true })
1032
+ self.schedulePaint()
1033
+ self.$emit('change', params.data, params.colDef.field, params.newValue)
1034
+ }
1035
+ })
1036
+
1037
+ // createGrid() vừa bơm core.css của AG Grid vào cuối <head> → dời style của f-sheet
1038
+ // xuống sau nó, nếu không mấy luật viền cùng specificity sẽ bị đè.
1039
+ fsInstallRangeCss()
1040
+
1041
+ // Ghi nhận ở pha CAPTURE nên chắc chắn chạy trước mọi handler của AG Grid: thứ tự
1042
+ // giữa onCellMouseDown và onCellFocused không được đảm bảo, mà nếu onCellFocused
1043
+ // chạy trước thì nó thu vùng chọn về 1 ô → kéo fill mất sạch khối nguồn.
1044
+ self.mouseDownHandler = function (e) {
1045
+ var t = e.target
1046
+ self.handleGrab = !!(t && t.classList && t.classList.contains('ag-fill-handle'))
1047
+ self.shiftGrab = !!e.shiftKey
1048
+ self.rightClick = e.button === 2
1049
+ }
1050
+ // AG Grid tự focus lại ô ngay khi bấm chuột (kể cả chuột phải, vì bản Community
1051
+ // không có rangeSvc thật nên nhánh "giữ nguyên vùng chọn khi chuột phải trong vùng"
1052
+ // của chính AG Grid không có tác dụng) — nhưng việc đó chạy trên sự kiện
1053
+ // "pointerdown" (Pointer Events, feature-detect trong RowContainerEventsFeature),
1054
+ // KHÔNG PHẢI "mousedown". pointerdown luôn bắn TRƯỚC mousedown trong cùng một cú
1055
+ // bấm, nên nếu chỉ nghe mousedown thì self.rightClick được set quá trễ — AG Grid đã
1056
+ // kịp đổi focus (và bắn mutation cho rangeObserver) trước khi cờ kịp bật. Nghe thêm
1057
+ // pointerdown ở pha capture trên document để chắc chắn chạy trước AG Grid dù nó
1058
+ // dùng loại event nào (giữ luôn mousedown làm phương án dự phòng cho trình duyệt/
1059
+ // input không hỗ trợ Pointer Events).
1060
+ document.addEventListener('pointerdown', self.mouseDownHandler, true)
1061
+ document.addEventListener('mousedown', self.mouseDownHandler, true)
1062
+ // Cùng cờ shiftGrab ở trên nhưng cập nhật theo bàn phím: AG Grid tự di chuyển focus
1063
+ // khi bấm mũi tên (kể cả giữ Shift, Cell Selection Enterprise mới có kiểu "kéo dài
1064
+ // vùng chọn" sẵn — bản Community không có nên f-sheet tự làm ở onCellFocused), nên
1065
+ // cần biết Shift có đang giữ hay không NGAY LÚC bắt phím, trước khi onCellFocused chạy.
1066
+ self.keyStateHandler = function (e) { self.shiftGrab = !!e.shiftKey }
1067
+ document.addEventListener('keydown', self.keyStateHandler, true)
1068
+ document.addEventListener('keyup', self.keyStateHandler, true)
1069
+ // AG Grid gọi các callback gridOptions.onXxx (onCellFocused, onCellMouseDown...)
1070
+ // qua một hàng đợi setTimeout(0) riêng (mọi "grid event" public đều async, trừ đúng
1071
+ // 3 cái không liên quan tới đây), TRỄ hơn hẳn việc chính AG Grid gỡ/gắn class
1072
+ // .ag-cell-focus lên DOM — việc đó chạy ĐỒNG BỘ ngay trong lúc xử lý mousedown/phím
1073
+ // mũi tên (RowRenderer.updateCellFocus). Khoảng hở này là lý do onCellFocused gọi
1074
+ // paintRange() đồng bộ (bên trên) vẫn chớp: lúc callback của AG Grid chạy tới thì
1075
+ // trình duyệt đã kịp vẽ một khung hình với ô cũ mất .ag-cell-focus mà vẫn còn
1076
+ // .ag-cell-range-selected. MutationObserver không lệ thuộc hàng đợi đó — callback của
1077
+ // nó luôn chạy ở microtask ngay sau khi DOM đổi, tức luôn trước khung hình kế tiếp —
1078
+ // nên bắt trực tiếp lúc .ag-cell-focus đổi rồi tự suy ra ô đang focus qua DOM
1079
+ // (giống contextMenuHandler bên dưới) mà không cần chờ AG Grid gọi lại callback.
1080
+ self.rangeObserver = new MutationObserver(function (records) {
1081
+ if (self.selecting || self.filling || self.handleGrab || self.rightClick) return
1082
+ var focusChanged = records.some(function (m) {
1083
+ if (m.attributeName !== 'class' || !m.target.classList) return false
1084
+ var had = (m.oldValue || '').indexOf('ag-cell-focus') !== -1
1085
+ return had !== m.target.classList.contains('ag-cell-focus')
1086
+ })
1087
+ if (!focusChanged) return
1088
+ var el = self.$refs.gridEl
1089
+ var focusedCell = el && el.querySelector('.ag-cell-focus')
1090
+ if (!focusedCell) return
1091
+ var rowEl = focusedCell.closest('.ag-row')
1092
+ var row = rowEl ? parseInt(rowEl.getAttribute('row-index'), 10) : NaN
1093
+ var columns = self.gridApi.getAllDisplayedColumns()
1094
+ var idx = {}
1095
+ columns.forEach(function (c, i) { idx[c.getColId()] = i })
1096
+ var col = idx[focusedCell.getAttribute('col-id')]
1097
+ if (isNaN(row) || col === undefined) return
1098
+ if (self.shiftGrab && self.rangeStart) {
1099
+ self.rangeEnd = { row: row, col: col }
1100
+ } else {
1101
+ self.rangeStart = { row: row, col: col }
1102
+ self.rangeEnd = { row: row, col: col }
1103
+ }
1104
+ self.paintRange()
1105
+ })
1106
+ self.rangeObserver.observe(self.$refs.gridEl, { attributes: true, attributeFilter: ['class'], attributeOldValue: true, subtree: true })
1107
+ // mouseup nghe ở document (không phải ở lưới) để thả chuột ngoài lưới vẫn kết thúc
1108
+ // được thao tác, tránh kẹt trạng thái đang quét.
1109
+ self.mouseUpHandler = function () {
1110
+ if (self.filling) {
1111
+ self.filling = false
1112
+ self.doFill()
1113
+ self.fillEnd = null
1114
+ }
1115
+ self.selecting = false
1116
+ self.handleGrab = false
1117
+ self.shiftGrab = false
1118
+ self.lockSelect(false)
1119
+ self.paintRange()
1120
+ }
1121
+ document.addEventListener('mouseup', self.mouseUpHandler)
1122
+ // copy/paste gắn ở gridEl: sự kiện nổi lên từ ô đang focus của AG Grid, nên chỉ
1123
+ // chạy khi người dùng thực sự đang ở trong lưới này.
1124
+ self.copyHandler = function (e) { self.doCopy(e) }
1125
+ self.pasteHandler = function (e) { self.doPaste(e) }
1126
+ self.keyDownHandler = function (e) { if (e.key === 'Escape') self.clearRange() }
1127
+ self.$refs.gridEl.addEventListener('copy', self.copyHandler)
1128
+ self.$refs.gridEl.addEventListener('paste', self.pasteHandler)
1129
+ self.$refs.gridEl.addEventListener('keydown', self.keyDownHandler)
1130
+ // Chuột phải trong ô: nếu ô đó nằm ngoài vùng đang chọn thì thu vùng chọn về đúng
1131
+ // ô đó trước (giống Excel), rồi mở menu Cut/Copy/Copy with Headers/Paste tại vị trí
1132
+ // con trỏ. Bỏ qua cột không có field (vd cột checkbox) để không đạp lên vùng đang chọn.
1133
+ self.contextMenuHandler = function (e) {
1134
+ var cellEl = e.target && e.target.closest && e.target.closest('.ag-cell')
1135
+ if (!cellEl) return
1136
+ e.preventDefault()
1137
+ var rowEl = cellEl.closest('.ag-row')
1138
+ var row = rowEl ? parseInt(rowEl.getAttribute('row-index'), 10) : NaN
1139
+ var cols = self.gridApi.getAllDisplayedColumns()
1140
+ var idx = {}
1141
+ cols.forEach(function (c, i) { idx[c.getColId()] = i })
1142
+ var col = idx[cellEl.getAttribute('col-id')]
1143
+ if (isNaN(row) || col === undefined) return
1144
+ var r = self.rangeBounds()
1145
+ var inside = !!r && row >= r.top && row <= r.bottom && col >= r.left && col <= r.right
1146
+ var hasField = cols[col] && cols[col].getColDef().field
1147
+ if (!inside && hasField) {
1148
+ self.rangeStart = { row: row, col: col }
1149
+ self.rangeEnd = { row: row, col: col }
1150
+ self.paintRange()
1151
+ }
1152
+ var items = []
1153
+ if (!self.readonly) {
1154
+ items.push({ icon: 'mdi-content-cut', text: 'Cut', shortcut: 'Ctrl+X', action: function () { self.menuCut() } })
1155
+ }
1156
+ items.push({ icon: 'mdi-content-copy', text: 'Copy', shortcut: 'Ctrl+C', action: function () { self.menuCopy(false) } })
1157
+ items.push({ icon: 'mdi-content-duplicate', text: 'Copy with Headers', action: function () { self.menuCopy(true) } })
1158
+ if (!self.readonly) {
1159
+ items.push({ icon: 'mdi-content-paste', text: 'Paste', shortcut: 'Ctrl+V', action: function () { self.menuPaste() } })
1160
+ }
1161
+ openFsContextMenu(e.clientX, e.clientY, items)
1162
+ }
1163
+ self.$refs.gridEl.addEventListener('contextmenu', self.contextMenuHandler)
1164
+ },
1165
+ getChangedRows: function () {
1166
+ var rows = []
1167
+ for (var k in this.changedRows) {
1168
+ if (Object.prototype.hasOwnProperty.call(this.changedRows, k)) rows.push(this.changedRows[k])
1169
+ }
1170
+ return rows
1171
+ },
1172
+ addRow: function () {
1173
+ if (!this.gridApi) return
1174
+ this.localCounter--
1175
+ var newRow = {}
1176
+ mapData(this.defaultItem || {}, newRow, vueData)
1177
+ // Khoá tạm nội bộ (_fsNewKey), TÁCH RIÊNG khỏi ô khoá thật (rowKey). Trước đây gán
1178
+ // thẳng localCounter vào field khoá thật nên: (1) ô khoá hiện số âm -1, -2... ngay
1179
+ // khi thêm dòng, và (2) mọi bảng theo dõi (changedRows/originalData) tra theo GIÁ
1180
+ // TRỊ Ô KHOÁ — hễ người dùng gõ lại ô khoá là "lạc" khỏi slot cũ trong changedRows,
1181
+ // sửa từ lần 2 trở đi coi như không ghi nhận. _fsNewKey không có colDef nào tham
1182
+ // chiếu nên không hiện thành cột, không gửi kèm lên UI — chỉ f-sheet tự dùng.
1183
+ newRow._fsNewKey = 'new_' + this.localCounter
1184
+ this.gridApi.applyTransaction({ add: [newRow], addIndex: 0 })
1185
+ var next = {}
1186
+ for (var k in this.changedRows) {
1187
+ if (Object.prototype.hasOwnProperty.call(this.changedRows, k)) next[k] = this.changedRows[k]
1188
+ }
1189
+ next[newRow._fsNewKey] = newRow
1190
+ this.changedRows = next
1191
+ this.gridApi.refreshCells({ force: true })
1192
+ },
1193
+ deleteRow: function (row) {
1194
+ var self = this
1195
+ var keyField = self.rowKey || 'id'
1196
+ var isNew = row._fsNewKey !== undefined
1197
+ var trackKey = isNew ? row._fsNewKey : row[keyField]
1198
+
1199
+ function removeFromGrid() {
1200
+ self.gridApi.applyTransaction({ remove: [row] })
1201
+ var next = {}
1202
+ for (var k in self.changedRows) {
1203
+ if (Object.prototype.hasOwnProperty.call(self.changedRows, k) && k !== String(trackKey)) next[k] = self.changedRows[k]
1204
+ }
1205
+ self.changedRows = next
1206
+ if (self.originalData) delete self.originalData[String(trackKey)]
1207
+ }
1208
+
1209
+ if (isNew) { removeFromGrid(); return }
1210
+ if (!self.deleteApi) { removeFromGrid(); return }
1211
+ var extra = {}
1212
+ mapData(self.saveExtra || {}, extra, vueData)
1213
+ ajaxCALL(self.deleteApi, Object.assign({}, extra, row), function (res) {
1214
+ var r = res && res.data && res.data[0] ? res.data[0] : null
1215
+ if (r && r.Message) {
1216
+ if (r.Success) { Vue.$toast.success(r.Message, { position: 'top' }); removeFromGrid() }
1217
+ else Vue.$toast.error(r.Message, { position: 'top' })
1218
+ } else {
1219
+ removeFromGrid()
1220
+ }
1221
+ }, function () {
1222
+ Vue.$toast.error('Lỗi khi xoá dữ liệu', { position: 'top' })
1223
+ })
1224
+ },
1225
+ deleteSelected: function () {
1226
+ var self = this
1227
+ var rows = self.selectedRows.slice()
1228
+ if (!rows.length) return
1229
+ var hasApi = self.deleteApi
1230
+ var newRows = rows.filter(function (r) { return r._fsNewKey !== undefined })
1231
+ var existRows = rows.filter(function (r) { return r._fsNewKey === undefined })
1232
+
1233
+ newRows.forEach(function (r) { self.deleteRow(r) })
1234
+
1235
+ if (!existRows.length) return
1236
+ if (hasApi) {
1237
+ confirm({
1238
+ title: 'Xoá dữ liệu',
1239
+ message: 'Xoá ' + existRows.length + ' dòng đã chọn?',
1240
+ action: function () {
1241
+ existRows.forEach(function (r) { self.deleteRow(r) })
1242
+ self.selectedRows = []
1243
+ }
1244
+ })
1245
+ } else {
1246
+ existRows.forEach(function (r) { self.deleteRow(r) })
1247
+ self.selectedRows = []
1248
+ }
1249
+ },
1250
+ saveAll: function () {
1251
+ var self = this
1252
+ var rows = self.getChangedRows()
1253
+ self.$emit('save', rows)
1254
+ if (!self.updateApi || rows.length === 0) return
1255
+ self.saving = true
1256
+ var pending = rows.length
1257
+ var hasError = false
1258
+
1259
+ rows.forEach(function (row) {
1260
+ var extra = {}
1261
+ mapData(self.saveExtra || {}, extra, vueData)
1262
+ var payload = Object.assign({}, extra, row)
1263
+ // _fsNewKey chỉ là khoá tạm nội bộ để f-sheet tự theo dõi dòng mới, không phải dữ
1264
+ // liệu thật — không gửi lên API. Ô khoá thật (payload[keyField]) giờ luôn đúng bằng
1265
+ // giá trị người dùng đã gõ (có thể để trống nếu API tự sinh khoá), không còn số âm
1266
+ // giả nào cần dọn trước khi gửi nữa.
1267
+ delete payload._fsNewKey
1268
+ ajaxCALL(self.updateApi, payload, function (res) {
1269
+ pending--
1270
+ var r = res && res.data && res.data[0] ? res.data[0] : null
1271
+ if (r && r.Message) {
1272
+ if (r.Success) Vue.$toast.success(r.Message, { position: 'top' })
1273
+ else { Vue.$toast.error(r.Message, { position: 'top' }); hasError = true }
1274
+ }
1275
+ if (pending === 0) {
1276
+ self.saving = false
1277
+ if (!hasError) {
1278
+ var current = []
1279
+ self.gridApi.forEachNode(function (node) {
1280
+ current.push(Object.assign({}, node.data))
1281
+ })
1282
+ self.buildOriginalData(current)
1283
+ self.gridApi.setGridOption('rowData', current)
1284
+ self.changedRows = {}
1285
+ self.localCounter = 0
1286
+ self.$emit('saved')
1287
+ }
1288
+ }
1289
+ }, function () {
1290
+ pending--
1291
+ hasError = true
1292
+ Vue.$toast.error('Lỗi khi lưu dữ liệu', { position: 'top' })
1293
+ if (pending === 0) self.saving = false
1294
+ })
1295
+ })
1296
+ },
1297
+ discardAll: function () {
1298
+ this.changedRows = {}
1299
+ this.localCounter = 0
1300
+ this.selectedRows = []
1301
+ if (this.gridApi) {
1302
+ var restored = (this.originalRows || []).map(function (r) { return Object.assign({}, r) })
1303
+ this.gridApi.setGridOption('rowData', restored)
1304
+ this.gridApi.refreshCells({ force: true })
1305
+ this.clearRange()
1306
+ }
1307
+ },
1308
+ exportExcel: function () {
1309
+ var self = this
1310
+ var cols = self.gridApi.getAllDisplayedColumns().filter(function (col) {
1311
+ var f = col.getColDef().field
1312
+ return f && f !== '_del'
1313
+ })
1314
+ var rows = []
1315
+ self.gridApi.forEachNode(function (node) {
1316
+ var row = {}
1317
+ cols.forEach(function (col) {
1318
+ var def = col.getColDef()
1319
+ row[def.headerName || def.field] = node.data[def.field]
1320
+ })
1321
+ rows.push(row)
1322
+ })
1323
+ jsonToExcel({
1324
+ data: rows,
1325
+ filename: self.label || 'export',
1326
+ worksheet: self.label || 'Sheet1'
1327
+ })
1328
+ }
1329
+ }
1330
+ });