@multiplatform.one/frappe-ui 7.28.1 → 7.29.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 (325) hide show
  1. package/dist/cjs/FrappeCalendar.cjs +3 -2
  2. package/dist/cjs/FrappeCalendar.native.js +3 -2
  3. package/dist/cjs/FrappeCalendar.native.js.map +1 -1
  4. package/dist/cjs/FrappeForm.cjs +148 -464
  5. package/dist/cjs/FrappeForm.native.js +152 -633
  6. package/dist/cjs/FrappeForm.native.js.map +1 -1
  7. package/dist/cjs/FrappeListView.cjs +5 -27
  8. package/dist/cjs/FrappeListView.native.js +11 -32
  9. package/dist/cjs/FrappeListView.native.js.map +1 -1
  10. package/dist/cjs/FrappeReportBuilder.cjs +523 -309
  11. package/dist/cjs/FrappeReportBuilder.native.js +658 -353
  12. package/dist/cjs/FrappeReportBuilder.native.js.map +1 -1
  13. package/dist/cjs/FrappeReportView.cjs +35 -234
  14. package/dist/cjs/FrappeReportView.native.js +39 -283
  15. package/dist/cjs/FrappeReportView.native.js.map +1 -1
  16. package/dist/cjs/dependsOn.cjs +2 -2
  17. package/dist/cjs/dependsOn.native.js +1 -2
  18. package/dist/cjs/dependsOn.native.js.map +1 -1
  19. package/dist/cjs/fields/shared/EditorPreviewPanes.cjs +2 -2
  20. package/dist/cjs/fields/shared/EditorPreviewPanes.native.js +2 -2
  21. package/dist/cjs/fields/shared/EditorPreviewPanes.native.js.map +1 -1
  22. package/dist/cjs/formEngine/docPermissions.cjs +20 -0
  23. package/dist/cjs/formEngine/docPermissions.native.js +69 -0
  24. package/dist/cjs/formEngine/docPermissions.native.js.map +1 -1
  25. package/dist/cjs/formEngine/index.cjs +2 -0
  26. package/dist/cjs/formEngine/index.native.js +2 -0
  27. package/dist/cjs/formEngine/index.native.js.map +1 -1
  28. package/dist/cjs/index.cjs +67 -7
  29. package/dist/cjs/index.native.js +67 -7
  30. package/dist/cjs/index.native.js.map +1 -1
  31. package/dist/cjs/liveErrors.cjs +8 -2
  32. package/dist/cjs/liveErrors.native.js +8 -2
  33. package/dist/cjs/liveErrors.native.js.map +1 -1
  34. package/dist/cjs/measures.cjs +31 -0
  35. package/dist/cjs/measures.native.js +34 -0
  36. package/dist/cjs/measures.native.js.map +1 -0
  37. package/dist/cjs/recordShape.cjs +103 -0
  38. package/dist/cjs/recordShape.native.js +115 -0
  39. package/dist/cjs/recordShape.native.js.map +1 -0
  40. package/dist/cjs/report/ReportGrid.cjs +453 -0
  41. package/dist/cjs/report/ReportGrid.native.js +510 -0
  42. package/dist/cjs/report/ReportGrid.native.js.map +1 -0
  43. package/dist/cjs/report/ReportViewToolbar.cjs +253 -0
  44. package/dist/cjs/report/ReportViewToolbar.native.js +303 -0
  45. package/dist/cjs/report/ReportViewToolbar.native.js.map +1 -0
  46. package/dist/cjs/report/queryReport.cjs +444 -0
  47. package/dist/cjs/report/queryReport.native.js +588 -0
  48. package/dist/cjs/report/queryReport.native.js.map +1 -0
  49. package/dist/cjs/report/queryReportColumns.cjs +131 -0
  50. package/dist/cjs/report/queryReportColumns.native.js +159 -0
  51. package/dist/cjs/report/queryReportColumns.native.js.map +1 -0
  52. package/dist/cjs/report/reportViewModel.cjs +386 -0
  53. package/dist/cjs/report/reportViewModel.native.js +657 -0
  54. package/dist/cjs/report/reportViewModel.native.js.map +1 -0
  55. package/dist/cjs/report/savedReports.cjs +137 -0
  56. package/dist/cjs/report/savedReports.native.js +169 -0
  57. package/dist/cjs/report/savedReports.native.js.map +1 -0
  58. package/dist/cjs/report/useFrappeReport.cjs +184 -0
  59. package/dist/cjs/report/useFrappeReport.native.js +214 -0
  60. package/dist/cjs/report/useFrappeReport.native.js.map +1 -0
  61. package/dist/cjs/report/useQueryReport.cjs +201 -0
  62. package/dist/cjs/report/useQueryReport.native.js +233 -0
  63. package/dist/cjs/report/useQueryReport.native.js.map +1 -0
  64. package/dist/cjs/useFrappeForm.cjs +558 -0
  65. package/dist/cjs/useFrappeForm.native.js +714 -0
  66. package/dist/cjs/useFrappeForm.native.js.map +1 -0
  67. package/dist/esm/FrappeCalendar.mjs +4 -3
  68. package/dist/esm/FrappeCalendar.mjs.map +1 -1
  69. package/dist/esm/FrappeCalendar.native.js +4 -3
  70. package/dist/esm/FrappeCalendar.native.js.map +1 -1
  71. package/dist/esm/FrappeForm.mjs +151 -467
  72. package/dist/esm/FrappeForm.mjs.map +1 -1
  73. package/dist/esm/FrappeForm.native.js +155 -636
  74. package/dist/esm/FrappeForm.native.js.map +1 -1
  75. package/dist/esm/FrappeListView.mjs +6 -28
  76. package/dist/esm/FrappeListView.mjs.map +1 -1
  77. package/dist/esm/FrappeListView.native.js +12 -33
  78. package/dist/esm/FrappeListView.native.js.map +1 -1
  79. package/dist/esm/FrappeReportBuilder.mjs +526 -312
  80. package/dist/esm/FrappeReportBuilder.mjs.map +1 -1
  81. package/dist/esm/FrappeReportBuilder.native.js +661 -356
  82. package/dist/esm/FrappeReportBuilder.native.js.map +1 -1
  83. package/dist/esm/FrappeReportView.mjs +30 -229
  84. package/dist/esm/FrappeReportView.mjs.map +1 -1
  85. package/dist/esm/FrappeReportView.native.js +34 -278
  86. package/dist/esm/FrappeReportView.native.js.map +1 -1
  87. package/dist/esm/dependsOn.mjs +2 -2
  88. package/dist/esm/dependsOn.mjs.map +1 -1
  89. package/dist/esm/dependsOn.native.js +1 -2
  90. package/dist/esm/dependsOn.native.js.map +1 -1
  91. package/dist/esm/fields/shared/EditorPreviewPanes.mjs +2 -2
  92. package/dist/esm/fields/shared/EditorPreviewPanes.mjs.map +1 -1
  93. package/dist/esm/fields/shared/EditorPreviewPanes.native.js +2 -2
  94. package/dist/esm/fields/shared/EditorPreviewPanes.native.js.map +1 -1
  95. package/dist/esm/formEngine/docPermissions.mjs +19 -1
  96. package/dist/esm/formEngine/docPermissions.mjs.map +1 -1
  97. package/dist/esm/formEngine/docPermissions.native.js +68 -1
  98. package/dist/esm/formEngine/docPermissions.native.js.map +1 -1
  99. package/dist/esm/formEngine/index.mjs +2 -2
  100. package/dist/esm/formEngine/index.mjs.map +1 -1
  101. package/dist/esm/formEngine/index.native.js +2 -2
  102. package/dist/esm/formEngine/index.native.js.map +1 -1
  103. package/dist/esm/index.mjs +16 -4
  104. package/dist/esm/index.mjs.map +1 -1
  105. package/dist/esm/index.native.js +16 -4
  106. package/dist/esm/index.native.js.map +1 -1
  107. package/dist/esm/liveErrors.mjs +8 -3
  108. package/dist/esm/liveErrors.mjs.map +1 -1
  109. package/dist/esm/liveErrors.native.js +8 -3
  110. package/dist/esm/liveErrors.native.js.map +1 -1
  111. package/dist/esm/measures.mjs +5 -0
  112. package/dist/esm/measures.mjs.map +1 -0
  113. package/dist/esm/measures.native.js +5 -0
  114. package/dist/esm/measures.native.js.map +1 -0
  115. package/dist/esm/recordShape.mjs +74 -0
  116. package/dist/esm/recordShape.mjs.map +1 -0
  117. package/dist/esm/recordShape.native.js +83 -0
  118. package/dist/esm/recordShape.native.js.map +1 -0
  119. package/dist/esm/report/ReportGrid.mjs +428 -0
  120. package/dist/esm/report/ReportGrid.mjs.map +1 -0
  121. package/dist/esm/report/ReportGrid.native.js +471 -0
  122. package/dist/esm/report/ReportGrid.native.js.map +1 -0
  123. package/dist/esm/report/ReportViewToolbar.mjs +228 -0
  124. package/dist/esm/report/ReportViewToolbar.mjs.map +1 -0
  125. package/dist/esm/report/ReportViewToolbar.native.js +264 -0
  126. package/dist/esm/report/ReportViewToolbar.native.js.map +1 -0
  127. package/dist/esm/report/queryReport.mjs +404 -0
  128. package/dist/esm/report/queryReport.mjs.map +1 -0
  129. package/dist/esm/report/queryReport.native.js +545 -0
  130. package/dist/esm/report/queryReport.native.js.map +1 -0
  131. package/dist/esm/report/queryReportColumns.mjs +105 -0
  132. package/dist/esm/report/queryReportColumns.mjs.map +1 -0
  133. package/dist/esm/report/queryReportColumns.native.js +119 -0
  134. package/dist/esm/report/queryReportColumns.native.js.map +1 -0
  135. package/dist/esm/report/reportViewModel.mjs +343 -0
  136. package/dist/esm/report/reportViewModel.mjs.map +1 -0
  137. package/dist/esm/report/reportViewModel.native.js +611 -0
  138. package/dist/esm/report/reportViewModel.native.js.map +1 -0
  139. package/dist/esm/report/savedReports.mjs +112 -0
  140. package/dist/esm/report/savedReports.mjs.map +1 -0
  141. package/dist/esm/report/savedReports.native.js +141 -0
  142. package/dist/esm/report/savedReports.native.js.map +1 -0
  143. package/dist/esm/report/useFrappeReport.mjs +158 -0
  144. package/dist/esm/report/useFrappeReport.mjs.map +1 -0
  145. package/dist/esm/report/useFrappeReport.native.js +185 -0
  146. package/dist/esm/report/useFrappeReport.native.js.map +1 -0
  147. package/dist/esm/report/useQueryReport.mjs +176 -0
  148. package/dist/esm/report/useQueryReport.mjs.map +1 -0
  149. package/dist/esm/report/useQueryReport.native.js +205 -0
  150. package/dist/esm/report/useQueryReport.native.js.map +1 -0
  151. package/dist/esm/useFrappeForm.mjs +533 -0
  152. package/dist/esm/useFrappeForm.mjs.map +1 -0
  153. package/dist/esm/useFrappeForm.native.js +686 -0
  154. package/dist/esm/useFrappeForm.native.js.map +1 -0
  155. package/dist/jsx/FrappeCalendar.mjs +4 -3
  156. package/dist/jsx/FrappeCalendar.mjs.map +1 -1
  157. package/dist/jsx/FrappeCalendar.native.js +3 -2
  158. package/dist/jsx/FrappeCalendar.native.js.map +1 -1
  159. package/dist/jsx/FrappeForm.mjs +151 -467
  160. package/dist/jsx/FrappeForm.mjs.map +1 -1
  161. package/dist/jsx/FrappeForm.native.js +152 -633
  162. package/dist/jsx/FrappeForm.native.js.map +1 -1
  163. package/dist/jsx/FrappeListView.mjs +6 -28
  164. package/dist/jsx/FrappeListView.mjs.map +1 -1
  165. package/dist/jsx/FrappeListView.native.js +11 -32
  166. package/dist/jsx/FrappeListView.native.js.map +1 -1
  167. package/dist/jsx/FrappeReportBuilder.mjs +526 -312
  168. package/dist/jsx/FrappeReportBuilder.mjs.map +1 -1
  169. package/dist/jsx/FrappeReportBuilder.native.js +658 -353
  170. package/dist/jsx/FrappeReportBuilder.native.js.map +1 -1
  171. package/dist/jsx/FrappeReportView.mjs +30 -229
  172. package/dist/jsx/FrappeReportView.mjs.map +1 -1
  173. package/dist/jsx/FrappeReportView.native.js +39 -283
  174. package/dist/jsx/FrappeReportView.native.js.map +1 -1
  175. package/dist/jsx/dependsOn.mjs +2 -2
  176. package/dist/jsx/dependsOn.mjs.map +1 -1
  177. package/dist/jsx/dependsOn.native.js +1 -2
  178. package/dist/jsx/dependsOn.native.js.map +1 -1
  179. package/dist/jsx/fields/shared/EditorPreviewPanes.mjs +2 -2
  180. package/dist/jsx/fields/shared/EditorPreviewPanes.mjs.map +1 -1
  181. package/dist/jsx/fields/shared/EditorPreviewPanes.native.js +2 -2
  182. package/dist/jsx/fields/shared/EditorPreviewPanes.native.js.map +1 -1
  183. package/dist/jsx/formEngine/docPermissions.mjs +19 -1
  184. package/dist/jsx/formEngine/docPermissions.mjs.map +1 -1
  185. package/dist/jsx/formEngine/docPermissions.native.js +69 -0
  186. package/dist/jsx/formEngine/docPermissions.native.js.map +1 -1
  187. package/dist/jsx/formEngine/index.mjs +2 -2
  188. package/dist/jsx/formEngine/index.mjs.map +1 -1
  189. package/dist/jsx/formEngine/index.native.js +2 -0
  190. package/dist/jsx/formEngine/index.native.js.map +1 -1
  191. package/dist/jsx/index.js +16 -4
  192. package/dist/jsx/index.js.map +1 -1
  193. package/dist/jsx/index.mjs +16 -4
  194. package/dist/jsx/index.mjs.map +1 -1
  195. package/dist/jsx/index.native.js +67 -7
  196. package/dist/jsx/index.native.js.map +1 -1
  197. package/dist/jsx/liveErrors.mjs +8 -3
  198. package/dist/jsx/liveErrors.mjs.map +1 -1
  199. package/dist/jsx/liveErrors.native.js +8 -2
  200. package/dist/jsx/liveErrors.native.js.map +1 -1
  201. package/dist/jsx/measures.mjs +5 -0
  202. package/dist/jsx/measures.mjs.map +1 -0
  203. package/dist/jsx/measures.native.js +34 -0
  204. package/dist/jsx/measures.native.js.map +1 -0
  205. package/dist/jsx/recordShape.mjs +74 -0
  206. package/dist/jsx/recordShape.mjs.map +1 -0
  207. package/dist/jsx/recordShape.native.js +115 -0
  208. package/dist/jsx/recordShape.native.js.map +1 -0
  209. package/dist/jsx/report/ReportGrid.mjs +428 -0
  210. package/dist/jsx/report/ReportGrid.mjs.map +1 -0
  211. package/dist/jsx/report/ReportGrid.native.js +510 -0
  212. package/dist/jsx/report/ReportGrid.native.js.map +1 -0
  213. package/dist/jsx/report/ReportViewToolbar.mjs +228 -0
  214. package/dist/jsx/report/ReportViewToolbar.mjs.map +1 -0
  215. package/dist/jsx/report/ReportViewToolbar.native.js +303 -0
  216. package/dist/jsx/report/ReportViewToolbar.native.js.map +1 -0
  217. package/dist/jsx/report/queryReport.mjs +404 -0
  218. package/dist/jsx/report/queryReport.mjs.map +1 -0
  219. package/dist/jsx/report/queryReport.native.js +588 -0
  220. package/dist/jsx/report/queryReport.native.js.map +1 -0
  221. package/dist/jsx/report/queryReportColumns.mjs +105 -0
  222. package/dist/jsx/report/queryReportColumns.mjs.map +1 -0
  223. package/dist/jsx/report/queryReportColumns.native.js +159 -0
  224. package/dist/jsx/report/queryReportColumns.native.js.map +1 -0
  225. package/dist/jsx/report/reportViewModel.mjs +343 -0
  226. package/dist/jsx/report/reportViewModel.mjs.map +1 -0
  227. package/dist/jsx/report/reportViewModel.native.js +657 -0
  228. package/dist/jsx/report/reportViewModel.native.js.map +1 -0
  229. package/dist/jsx/report/savedReports.mjs +112 -0
  230. package/dist/jsx/report/savedReports.mjs.map +1 -0
  231. package/dist/jsx/report/savedReports.native.js +169 -0
  232. package/dist/jsx/report/savedReports.native.js.map +1 -0
  233. package/dist/jsx/report/useFrappeReport.mjs +158 -0
  234. package/dist/jsx/report/useFrappeReport.mjs.map +1 -0
  235. package/dist/jsx/report/useFrappeReport.native.js +214 -0
  236. package/dist/jsx/report/useFrappeReport.native.js.map +1 -0
  237. package/dist/jsx/report/useQueryReport.mjs +176 -0
  238. package/dist/jsx/report/useQueryReport.mjs.map +1 -0
  239. package/dist/jsx/report/useQueryReport.native.js +233 -0
  240. package/dist/jsx/report/useQueryReport.native.js.map +1 -0
  241. package/dist/jsx/useFrappeForm.mjs +533 -0
  242. package/dist/jsx/useFrappeForm.mjs.map +1 -0
  243. package/dist/jsx/useFrappeForm.native.js +714 -0
  244. package/dist/jsx/useFrappeForm.native.js.map +1 -0
  245. package/package.json +16 -16
  246. package/src/FrappeCalendar.tsx +8 -2
  247. package/src/FrappeForm.stories.tsx +52 -0
  248. package/src/FrappeForm.tsx +176 -565
  249. package/src/FrappeListView.cards.stories.tsx +3 -6
  250. package/src/FrappeListView.tsx +15 -55
  251. package/src/FrappeReportBuilder.stories.tsx +34 -7
  252. package/src/FrappeReportBuilder.tsx +790 -380
  253. package/src/FrappeReportView.spec.tsx +10 -6
  254. package/src/FrappeReportView.stories.tsx +20 -1
  255. package/src/FrappeReportView.tsx +89 -415
  256. package/src/dependsOn.ts +4 -1
  257. package/src/fields/shared/EditorPreviewPanes.tsx +2 -2
  258. package/src/formEngine/docPermissions.spec.ts +36 -1
  259. package/src/formEngine/docPermissions.ts +34 -0
  260. package/src/formEngine/index.ts +8 -1
  261. package/src/frappeRemainder.designLaw.spec.ts +9 -1
  262. package/src/frappeRoutes.spec.tsx +9 -0
  263. package/src/frappeViews.designLaw.spec.ts +15 -2
  264. package/src/guestFallback.spec.tsx +236 -0
  265. package/src/index.ts +117 -16
  266. package/src/liveErrors.spec.ts +10 -0
  267. package/src/liveErrors.ts +16 -3
  268. package/src/measures.ts +11 -0
  269. package/src/recordShape.spec.ts +100 -0
  270. package/src/recordShape.ts +133 -0
  271. package/src/report/ReportGrid.spec.tsx +175 -0
  272. package/src/report/ReportGrid.tsx +530 -0
  273. package/src/report/ReportViewToolbar.spec.tsx +90 -0
  274. package/src/report/ReportViewToolbar.tsx +314 -0
  275. package/src/report/queryReport.spec.ts +483 -0
  276. package/src/report/queryReport.ts +722 -0
  277. package/src/report/queryReportColumns.tsx +123 -0
  278. package/src/report/reportViewModel.spec.ts +393 -0
  279. package/src/report/reportViewModel.ts +617 -0
  280. package/src/report/savedReports.ts +172 -0
  281. package/src/report/seam.spec.ts +216 -0
  282. package/src/report/useFrappeReport.ts +241 -0
  283. package/src/report/useQueryReport.spec.tsx +131 -0
  284. package/src/report/useQueryReport.ts +309 -0
  285. package/src/useFrappeForm.spec.tsx +209 -0
  286. package/src/useFrappeForm.ts +778 -0
  287. package/types/FrappeCalendar.d.ts.map +1 -1
  288. package/types/FrappeForm.d.ts +21 -1
  289. package/types/FrappeForm.d.ts.map +1 -1
  290. package/types/FrappeListView.d.ts.map +1 -1
  291. package/types/FrappeReportBuilder.d.ts +66 -16
  292. package/types/FrappeReportBuilder.d.ts.map +1 -1
  293. package/types/FrappeReportView.d.ts +9 -127
  294. package/types/FrappeReportView.d.ts.map +1 -1
  295. package/types/dependsOn.d.ts.map +1 -1
  296. package/types/formEngine/docPermissions.d.ts +14 -0
  297. package/types/formEngine/docPermissions.d.ts.map +1 -1
  298. package/types/formEngine/index.d.ts +1 -1
  299. package/types/formEngine/index.d.ts.map +1 -1
  300. package/types/index.d.ts +15 -3
  301. package/types/index.d.ts.map +1 -1
  302. package/types/liveErrors.d.ts +9 -1
  303. package/types/liveErrors.d.ts.map +1 -1
  304. package/types/measures.d.ts +3 -0
  305. package/types/measures.d.ts.map +1 -0
  306. package/types/recordShape.d.ts +45 -0
  307. package/types/recordShape.d.ts.map +1 -0
  308. package/types/report/ReportGrid.d.ts +20 -0
  309. package/types/report/ReportGrid.d.ts.map +1 -0
  310. package/types/report/ReportViewToolbar.d.ts +31 -0
  311. package/types/report/ReportViewToolbar.d.ts.map +1 -0
  312. package/types/report/queryReport.d.ts +181 -0
  313. package/types/report/queryReport.d.ts.map +1 -0
  314. package/types/report/queryReportColumns.d.ts +12 -0
  315. package/types/report/queryReportColumns.d.ts.map +1 -0
  316. package/types/report/reportViewModel.d.ts +146 -0
  317. package/types/report/reportViewModel.d.ts.map +1 -0
  318. package/types/report/savedReports.d.ts +27 -0
  319. package/types/report/savedReports.d.ts.map +1 -0
  320. package/types/report/useFrappeReport.d.ts +60 -0
  321. package/types/report/useFrappeReport.d.ts.map +1 -0
  322. package/types/report/useQueryReport.d.ts +37 -0
  323. package/types/report/useQueryReport.d.ts.map +1 -0
  324. package/types/useFrappeForm.d.ts +142 -0
  325. package/types/useFrappeForm.d.ts.map +1 -0
@@ -1,64 +1,246 @@
1
- import { useDocTypeMeta, useFrappeDoctypeEvents } from "@multiplatform.one/frappe";
2
- import { downloadBlob } from "@multiplatform.one/platform";
1
+ /**
2
+ * FrappeReportBuilder — a doctype's report view, the desk's
3
+ * /app/<doctype>/view/report (MPO-15, GL-28). The desk's view=report renders
4
+ * this component, so an app that reaches for it gets the same report the desk
5
+ * shows:
6
+ * - desk default columns (name, status, title/image fields, in_list_view
7
+ * and in_standard_filter fields, currency sources, modified), a column
8
+ * picker whose choice persists per doctype on this device while no saved
9
+ * report is selected
10
+ * - a per-column filter row on the standard frappe filter pipeline
11
+ * - one toolbar row: columns, group-by, saved reports, filter chips, export
12
+ * - saved reports on the `Report` doctype, the records desk's "Select
13
+ * Report" lists (in memory on a fixture corpus)
14
+ * - live rows through the collection channel: the full filtered result
15
+ * stays client-side (totals and export need it), ordered
16
+ * deterministically and paged client-side, with a totals row
17
+ * - a reader refused DocType meta (Guest) still gets the rows, laid out
18
+ * from `initialColumns` or one record's own fields, with nothing to save
19
+ * (LC-96); rows refused too is a permission state, never a retry
20
+ * The model is `./report/reportViewModel`; navigation, scope and the selected
21
+ * saved report come in as props, so the desk and a plain app mount the same
22
+ * component.
23
+ */
3
24
  import {
4
- ReportBuilder,
5
- PopoverContent,
6
- Separator,
7
- type ReportColumn,
25
+ BarChart,
26
+ EmptyState,
27
+ ScrollView,
28
+ Spinner,
29
+ YStack,
30
+ isWeb,
31
+ notify,
8
32
  type GroupByConfig,
33
+ type ReportColumn,
9
34
  } from "@multiplatform.one/components";
10
- import { Button, Input, Spinner, formCommonColors } from "@multiplatform.one/forms";
35
+ import {
36
+ Order,
37
+ frappeFieldTypes,
38
+ useDocTypeMeta,
39
+ type DocTypeField,
40
+ type FrappeFieldType,
41
+ } from "@multiplatform.one/frappe";
42
+ import { Button } from "@multiplatform.one/forms";
43
+ import { downloadBlob } from "@multiplatform.one/platform";
11
44
  import { storage } from "@multiplatform.one/store";
12
- import React, { useMemo, useState, useCallback, useEffect, useRef } from "react";
13
- import { useTranslation } from "react-i18next";
14
- import { Popover, Text, XStack, YStack, type YStackProps } from "tamagui";
15
- import { FloppyDiskIcon, FolderOpenIcon, TrashIcon } from "@phosphor-icons/react";
45
+ import { DataTablePagination } from "@multiplatform.one/table";
16
46
  import { useResolvedKnobs } from "@multiplatform.one/theme";
47
+ import React, { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
48
+ import { useTranslation } from "react-i18next";
49
+ import type { YStackProps } from "tamagui";
50
+ import { FrappeNotConfigured } from "./FrappeNotConfigured";
17
51
  import { useFrappeConfig } from "./FrappeProvider";
52
+ import { isPermissionError, refusalReason } from "./liveErrors";
53
+ import { recordKeyLabel, useRecordShape } from "./recordShape";
54
+ import { ReportGrid } from "./report/ReportGrid";
55
+ import { ReportViewToolbar } from "./report/ReportViewToolbar";
56
+ import {
57
+ DEFAULT_REPORT_PAGE_SIZE,
58
+ REPORT_PAGE_SIZES,
59
+ buildGridRows,
60
+ buildReportCsv,
61
+ columnFilterTuples,
62
+ computeTotals,
63
+ defaultReportFieldnames,
64
+ orderReportRows,
65
+ reportColumnsFromMeta,
66
+ resolveSelectedColumns,
67
+ type ReportFilters,
68
+ type ReportMetaSource,
69
+ type ReportRow,
70
+ type ReportSortState,
71
+ } from "./report/reportViewModel";
72
+ import { useSavedReports } from "./report/savedReports";
18
73
  import { useFrappeRows } from "./useFrappeRows";
19
74
 
20
- // ============================================================================
21
- // Saved Report Types (Frappe-specific persisted storage)
22
- // ============================================================================
75
+ type FilterTuple = [string, string, unknown];
23
76
 
24
- interface SavedReport {
25
- name: string;
26
- columns: string[];
27
- groupBy: GroupByConfig | null;
28
- filters: Record<string, unknown> | Array<[string, string, unknown]> | undefined;
77
+ export interface FrappeReportBuilderProps extends Omit<YStackProps, "children"> {
78
+ /** Proper-cased doctype name (e.g. "ToDo", "Sales Order"). */
79
+ doctype: string;
80
+ /**
81
+ * The report's own filters (a deep link, a host's preset). They show as
82
+ * chips the user can clear. The object form means `field = value`.
83
+ */
84
+ filters?: Record<string, unknown> | FilterTuple[];
85
+ /**
86
+ * Filters ANDed into every query: invisible to the chips and not
87
+ * clearable (a desk or tenant scope).
88
+ */
89
+ scopeFilters?: FilterTuple[];
90
+ /** After the user clears `filters`, so a host can drop them from its address too. */
91
+ onClearFilters?: () => void;
92
+ /**
93
+ * Starting columns by fieldname, or as `ReportColumn` rows (the enabled
94
+ * ones). Defaults to desk's default columns.
95
+ */
96
+ initialColumns?: ReadonlyArray<string | ReportColumn>;
97
+ /** Starting group-by field; a `GroupByConfig` also sets the chart's aggregation. */
98
+ initialGroupBy?: string | GroupByConfig;
99
+ /** A bar chart of the groups above the grid while the rows are grouped. */
100
+ showChart?: boolean;
101
+ /** Open a row's document. */
102
+ onOpenRow?: (name: string) => void;
103
+ /** A row press with the row and its index in the full ordered result. */
104
+ onRowPress?: (row: Record<string, unknown>, index: number) => void;
105
+ /** Open a Link cell's target document. */
106
+ onOpenLink?: (doctype: string, name: string) => void;
107
+ /**
108
+ * The selected saved report (a `Report Builder` Report doc name), or null
109
+ * for an unsaved report. Given, the selection is controlled.
110
+ */
111
+ report?: string | null;
112
+ onReportChange?: (report: string | null) => void;
113
+ /** Replaces the CSV download; receives the full filtered, ordered rows. */
114
+ onExport?: (rows: Record<string, unknown>[]) => void;
115
+ /** Shown while the doctype's meta loads. */
116
+ loading?: ReactNode;
117
+ /** Shown when the doctype's meta cannot be read. */
118
+ error?: (error: Error) => ReactNode;
119
+ baseURL?: string;
120
+ auth?: { useToken?: boolean; token?: string; type?: string };
121
+ syncOptions?: Record<string, unknown>;
29
122
  }
30
123
 
31
- function getSavedReportsKey(doctype: string) {
32
- return `frappe-report-${doctype}`;
124
+ const EMPTY_FILTERS: ReportFilters = [];
125
+ const COLUMN_FILTER_DEBOUNCE_MS = 350;
126
+
127
+ function columnsStorageKey(doctype: string): string {
128
+ return `backoffice.report.columns.${doctype}`;
33
129
  }
34
130
 
35
- async function loadSavedReports(doctype: string): Promise<SavedReport[]> {
131
+ async function readStoredColumns(doctype: string): Promise<string[] | undefined> {
36
132
  try {
37
- const raw = await Promise.resolve(storage.getItem(getSavedReportsKey(doctype)));
38
- if (raw) return JSON.parse(raw) as SavedReport[];
133
+ const raw = await Promise.resolve(storage.getItem(columnsStorageKey(doctype)));
134
+ if (!raw) return undefined;
135
+ const parsed: unknown = JSON.parse(raw);
136
+ if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === "string")) {
137
+ return parsed as string[];
138
+ }
39
139
  } catch {
40
- // storage not available
140
+ // storage unavailable or corrupted — defaults win
41
141
  }
42
- return [];
142
+ return undefined;
43
143
  }
44
144
 
45
- async function persistSavedReports(doctype: string, reports: SavedReport[]): Promise<void> {
145
+ function persistStoredColumns(doctype: string, fieldnames: readonly string[]): void {
46
146
  try {
47
- await Promise.resolve(storage.setItem(getSavedReportsKey(doctype), JSON.stringify(reports)));
147
+ void Promise.resolve(storage.setItem(columnsStorageKey(doctype), JSON.stringify(fieldnames)));
48
148
  } catch {
49
- // storage not available
149
+ // storage unavailable — selection stays session-only
50
150
  }
51
151
  }
52
152
 
53
- // ============================================================================
54
- // Export capability
55
- // ============================================================================
153
+ /** `{field: value}` → equality tuples; tuple arrays pass through. */
154
+ function toReportFilters(filters: FrappeReportBuilderProps["filters"]): ReportFilters | undefined {
155
+ if (!filters) return undefined;
156
+ if (Array.isArray(filters)) return filters.length > 0 ? filters : undefined;
157
+ const tuples = Object.entries(filters).map(([field, value]): FilterTuple => [field, "=", value]);
158
+ return tuples.length > 0 ? tuples : undefined;
159
+ }
160
+
161
+ function initialFieldnames(columns: FrappeReportBuilderProps["initialColumns"]): string[] | null {
162
+ if (!columns) return null;
163
+ const fieldnames = columns.flatMap((column) =>
164
+ typeof column === "string" ? [column] : column.enabled ? [column.fieldname] : [],
165
+ );
166
+ return fieldnames.length > 0 ? fieldnames : null;
167
+ }
168
+
169
+ function asFieldType(fieldtype: string | undefined): FrappeFieldType {
170
+ return frappeFieldTypes.find((known) => known === fieldtype) ?? "Data";
171
+ }
172
+
173
+ /**
174
+ * What a reader refused DocType meta lays the report out from (LC-96): the
175
+ * host's `initialColumns`, else one record's own fields. Read-only and all in
176
+ * the default columns.
177
+ */
178
+ function refusedMetaFields(
179
+ initialColumns: FrappeReportBuilderProps["initialColumns"],
180
+ recordFields: readonly DocTypeField[] | undefined,
181
+ ): DocTypeField[] {
182
+ const declared = (initialColumns ?? []).map(
183
+ (column): DocTypeField =>
184
+ typeof column === "string"
185
+ ? { fieldname: column, label: recordKeyLabel(column), fieldtype: "Data" }
186
+ : {
187
+ fieldname: column.fieldname,
188
+ label: column.label,
189
+ fieldtype: asFieldType(column.fieldtype),
190
+ },
191
+ );
192
+ return (declared.length > 0 ? declared : (recordFields ?? []))
193
+ .filter((field) => field.fieldname !== "name")
194
+ .map((field) => ({ ...field, read_only: 1, in_list_view: 1 }));
195
+ }
196
+
197
+ function asError(error: unknown): Error {
198
+ return error instanceof Error ? error : new Error(String(error));
199
+ }
200
+
201
+ function groupField(groupBy: FrappeReportBuilderProps["initialGroupBy"]): string | null {
202
+ if (!groupBy) return null;
203
+ return typeof groupBy === "string" ? groupBy : groupBy.field;
204
+ }
205
+
206
+ /** Group value → aggregate over its rows (count unless the config names one). */
207
+ function aggregateGroups(
208
+ rows: readonly ReportRow[],
209
+ field: string,
210
+ config: GroupByConfig | undefined,
211
+ ): Array<{ label: string; value: number }> {
212
+ const groups = new Map<string, ReportRow[]>();
213
+ for (const row of rows) {
214
+ const key = row[field] === null || row[field] === undefined ? "" : String(row[field]);
215
+ const bucket = groups.get(key);
216
+ if (bucket) bucket.push(row);
217
+ else groups.set(key, [row]);
218
+ }
219
+ const numbers = (group: ReportRow[]) =>
220
+ group.map((row) => Number(config?.aggregationField ? row[config.aggregationField] : 0) || 0);
221
+ return [...groups.entries()].map(([label, group]) => {
222
+ switch (config?.aggregationField ? config.aggregation : "count") {
223
+ case "sum":
224
+ return { label, value: numbers(group).reduce((sum, value) => sum + value, 0) };
225
+ case "avg":
226
+ return {
227
+ label,
228
+ value: numbers(group).reduce((sum, value) => sum + value, 0) / group.length,
229
+ };
230
+ case "min":
231
+ return { label, value: Math.min(...numbers(group)) };
232
+ case "max":
233
+ return { label, value: Math.max(...numbers(group)) };
234
+ default:
235
+ return { label, value: group.length };
236
+ }
237
+ });
238
+ }
56
239
 
57
240
  /**
58
- * Download capability for the built-in CSV export path. `downloadBlob` rides
59
- * a DOM anchor + object URL, and its native/GTK twins warn + no-op — so the
60
- * Export button is hidden where no file could actually be delivered (B12).
61
- * Presence checks, not `typeof window`: Hermes defines `window` without DOM.
241
+ * The export button needs somewhere to put a file: a DOM anchor and an
242
+ * object URL. Native and GTK have neither, so the built-in CSV path is left
243
+ * out there (B12); a host `onExport` owns delivery and shows everywhere.
62
244
  */
63
245
  function canDownloadCsv(): boolean {
64
246
  return (
@@ -70,418 +252,646 @@ function canDownloadCsv(): boolean {
70
252
  );
71
253
  }
72
254
 
73
- // ============================================================================
74
- // Props
75
- // ============================================================================
76
-
77
- export interface FrappeReportBuilderProps extends Omit<YStackProps, "children"> {
78
- doctype: string;
79
- initialColumns?: ReportColumn[];
80
- initialGroupBy?: GroupByConfig;
81
- filters?: Record<string, unknown> | Array<[string, string, unknown]>;
82
- showChart?: boolean;
83
- onExport?: (data: Record<string, unknown>[]) => void;
84
- /** Row press handler — threaded to the generic ReportBuilder rows */
85
- onRowPress?: (row: Record<string, unknown>, index: number) => void;
86
- baseURL?: string;
87
- auth?: { useToken?: boolean; token?: string; type?: string };
88
- syncOptions?: Record<string, unknown>;
255
+ export function FrappeReportBuilder(props: FrappeReportBuilderProps) {
256
+ const config = useFrappeConfig();
257
+ if (!(props.baseURL ?? config?.baseURL) && !config?.fixtures) {
258
+ return <FrappeNotConfigured doctype={props.doctype} />;
259
+ }
260
+ return <FrappeReportBuilderBody {...props} />;
89
261
  }
90
262
 
91
- // ============================================================================
92
- // Component
93
- // ============================================================================
94
-
95
- /**
96
- * Thin Frappe wrapper around the generic ReportBuilder component.
97
- *
98
- * Fetches DocType metadata and data via Frappe hooks, then passes
99
- * column definitions and data to the generic `ReportBuilder` from
100
- * `@multiplatform.one/components`. Adds Frappe-specific features:
101
- * save/load reports to shared storage, CSV export.
102
- */
103
- export function FrappeReportBuilder({
263
+ function FrappeReportBuilderBody({
104
264
  doctype,
265
+ filters,
266
+ scopeFilters,
267
+ onClearFilters,
105
268
  initialColumns,
106
269
  initialGroupBy,
107
- filters,
108
270
  showChart = false,
109
- onExport,
271
+ onOpenRow,
110
272
  onRowPress,
273
+ onOpenLink,
274
+ report: reportProp,
275
+ onReportChange,
276
+ onExport,
277
+ loading: loadingFallback,
278
+ error: errorFallback,
111
279
  baseURL,
112
280
  auth,
113
281
  syncOptions,
114
282
  ...stackProps
115
283
  }: FrappeReportBuilderProps) {
116
- const config = useFrappeConfig();
117
- const { knobProps } = useResolvedKnobs();
118
284
  const { t } = useTranslation();
119
-
285
+ const { knobProps } = useResolvedKnobs();
286
+ const config = useFrappeConfig();
120
287
  const finalBaseURL = baseURL ?? config?.baseURL;
121
288
  const finalAuth = auth ?? config?.auth;
122
289
  const finalSyncOptions = syncOptions ?? config?.syncOptions;
123
-
124
- if (!finalBaseURL && !config?.fixtures) {
125
- throw new Error(
126
- "FrappeReportBuilder requires baseURL prop or FrappeProvider with baseURL or fixtures",
290
+ const fixtureMode = Boolean(config?.fixtures);
291
+
292
+ const connection = useMemo(
293
+ () => ({
294
+ baseURL: finalBaseURL,
295
+ socketPort: config?.socketPort,
296
+ siteName: config?.siteName,
297
+ socketBaseURL: config?.socketBaseURL,
298
+ auth: finalAuth,
299
+ syncOptions: finalSyncOptions,
300
+ realtime: config?.realtime,
301
+ fixtures: config?.fixtures,
302
+ }),
303
+ [
304
+ finalBaseURL,
305
+ config?.socketPort,
306
+ config?.siteName,
307
+ config?.socketBaseURL,
308
+ finalAuth,
309
+ finalSyncOptions,
310
+ config?.realtime,
311
+ config?.fixtures,
312
+ ],
313
+ );
314
+ const { meta, isLoading: metaLoading, error: metaError } = useDocTypeMeta(connection, doctype);
315
+ const metaRefused = !meta && metaError !== undefined && isPermissionError(metaError);
316
+ const initialColumnsJson = JSON.stringify(initialColumns ?? []);
317
+ const declaresColumns = initialColumnsJson !== "[]";
318
+ const recordShape = useRecordShape(connection, doctype, metaRefused && !declaresColumns);
319
+ const layoutMeta = useMemo((): ReportMetaSource | undefined => {
320
+ if (meta) return meta;
321
+ if (!metaRefused) return undefined;
322
+ const fields = refusedMetaFields(
323
+ JSON.parse(initialColumnsJson) as FrappeReportBuilderProps["initialColumns"],
324
+ recordShape.fields,
127
325
  );
128
- }
326
+ return fields.length > 0 ? { fields } : undefined;
327
+ }, [meta, metaRefused, initialColumnsJson, recordShape.fields]);
328
+ const readsWithoutMeta = !meta && layoutMeta !== undefined;
329
+ const savedReports = useSavedReports(
330
+ doctype,
331
+ useMemo(
332
+ () => ({
333
+ baseURL: metaRefused ? "" : (connection.baseURL ?? ""),
334
+ auth: connection.auth,
335
+ fixtures: connection.fixtures,
336
+ }),
337
+ [metaRefused, connection.baseURL, connection.auth, connection.fixtures],
338
+ ),
339
+ );
129
340
 
130
- const connectionConfig = {
131
- baseURL: finalBaseURL,
132
- socketPort: config?.socketPort,
133
- siteName: config?.siteName,
134
- socketBaseURL: config?.socketBaseURL,
135
- auth: finalAuth,
136
- syncOptions: finalSyncOptions,
137
- realtime: config?.realtime,
138
- fixtures: config?.fixtures,
139
- };
140
-
141
- // Metadata
142
- const {
143
- meta,
144
- isLoading: isMetaLoading,
145
- error: metaError,
146
- } = useDocTypeMeta(connectionConfig, doctype);
147
-
148
- // Column definitions from meta
149
- const allColumns = useMemo((): ReportColumn[] => {
150
- if (initialColumns) return initialColumns;
151
- if (!meta) return [];
152
- return meta.fields
153
- .filter(
154
- (f) =>
155
- f.fieldtype !== "Section Break" &&
156
- f.fieldtype !== "Column Break" &&
157
- f.fieldtype !== "Tab Break",
158
- )
159
- .map((f) => ({
160
- fieldname: f.fieldname,
161
- label: f.label || f.fieldname,
162
- fieldtype: f.fieldtype,
163
- enabled: !!f.in_list_view,
164
- }));
165
- }, [initialColumns, meta]);
166
-
167
- // Data fetching
168
- const fetchFields = useMemo(() => {
169
- const fieldNames = allColumns.map((c) => c.fieldname);
170
- if (!fieldNames.includes("name")) {
171
- fieldNames.unshift("name");
172
- }
173
- return fieldNames as readonly string[];
174
- }, [allColumns]);
341
+ // ── Saved-report selection: controlled by `report`, else internal ──
342
+ const [internalReport, setInternalReport] = useState<string | null>(null);
343
+ const currentReport = reportProp !== undefined ? reportProp : internalReport;
344
+
345
+ // ── Columns: defaults → device-stored → initial → explicit override ──
346
+ const allColumns = useMemo(() => {
347
+ const columns = reportColumnsFromMeta(layoutMeta);
348
+ if (!readsWithoutMeta || !layoutMeta) return columns;
349
+ const own = new Set(layoutMeta.fields.map((field) => field.fieldname));
350
+ return columns.filter((column) => column.fieldname === "name" || own.has(column.fieldname));
351
+ }, [layoutMeta, readsWithoutMeta]);
352
+ const defaultFieldnames = useMemo(
353
+ () =>
354
+ readsWithoutMeta && layoutMeta
355
+ ? ["name", ...layoutMeta.fields.map((field) => field.fieldname)]
356
+ : defaultReportFieldnames(layoutMeta),
357
+ [layoutMeta, readsWithoutMeta],
358
+ );
359
+ const initialColumnsKey = JSON.stringify(initialFieldnames(initialColumns));
360
+ const startFieldnames = useMemo(
361
+ () => JSON.parse(initialColumnsKey) as string[] | null,
362
+ [initialColumnsKey],
363
+ );
364
+ const [storedColumns, setStoredColumns] = useState<string[] | undefined>(undefined);
365
+ const [selectedOverride, setSelectedOverride] = useState<string[] | null>(null);
175
366
 
176
- const {
177
- rows,
178
- isQueryLoading: isLoading,
179
- isError,
180
- } = useFrappeRows<{ name: string; doctype: string }>(connectionConfig, doctype, {
181
- fields: fetchFields as readonly ("name" | "doctype")[],
182
- filters: filters as Partial<{ name: string; doctype: string }>,
183
- limit: null,
184
- });
185
- const data = rows as unknown as Record<string, unknown>[];
186
-
187
- // Live staleness: a realtime change on the doctype marks the current
188
- // report result stale until the collection reconciles (data identity
189
- // changes) or a settle window passes (the change may fall outside the
190
- // report's filters/window). Builder UI state (chosen columns, group-by)
191
- // lives in component state and is untouched by refreshes.
192
- const [isLiveStale, setIsLiveStale] = useState(false);
193
- const staleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
194
- const clearStaleTimer = useCallback(() => {
195
- if (staleTimerRef.current) {
196
- clearTimeout(staleTimerRef.current);
197
- staleTimerRef.current = undefined;
198
- }
199
- }, []);
200
- useFrappeDoctypeEvents(connectionConfig as any, doctype, (event) => {
201
- if (
202
- event.type !== "doc_update" &&
203
- event.type !== "list_update" &&
204
- event.type !== "doc_deleted" &&
205
- event.type !== "doc_changed"
206
- ) {
207
- return;
208
- }
209
- setIsLiveStale(true);
210
- clearStaleTimer();
211
- staleTimerRef.current = setTimeout(() => setIsLiveStale(false), 2500);
212
- });
213
- const isFirstData = useRef(true);
214
367
  useEffect(() => {
215
- if (isFirstData.current) {
216
- isFirstData.current = false;
217
- return;
218
- }
219
- setIsLiveStale(false);
220
- clearStaleTimer();
221
- }, [data, clearStaleTimer]);
222
- useEffect(() => clearStaleTimer, [clearStaleTimer]);
368
+ if (fixtureMode) return;
369
+ let cancelled = false;
370
+ void readStoredColumns(doctype).then((stored) => {
371
+ if (!cancelled) setStoredColumns(stored);
372
+ });
373
+ return () => {
374
+ cancelled = true;
375
+ };
376
+ }, [doctype, fixtureMode]);
223
377
 
224
- // Group by state
225
- const [groupBy, setGroupBy] = useState<GroupByConfig | null>(initialGroupBy ?? null);
378
+ const selectedFieldnames =
379
+ selectedOverride ?? startFieldnames ?? storedColumns ?? defaultFieldnames;
380
+ const selectedColumns = useMemo(
381
+ () => resolveSelectedColumns(allColumns, selectedFieldnames),
382
+ [allColumns, selectedFieldnames],
383
+ );
226
384
 
227
- // Enabled columns state
228
- const [enabledColumns, setEnabledColumns] = useState<string[]>([]);
385
+ // ── Filters: the report's own + the per-column filter row ─────
386
+ const filtersKey = JSON.stringify(filters ?? null);
387
+ const propFilters = useMemo(
388
+ () => toReportFilters(JSON.parse(filtersKey) as FrappeReportBuilderProps["filters"]),
389
+ [filtersKey],
390
+ );
391
+ const [filtersCleared, setFiltersCleared] = useState(false);
392
+ const [reportFilters, setReportFilters] = useState<ReportFilters | null>(null);
393
+ const baseFilters = filtersCleared
394
+ ? EMPTY_FILTERS
395
+ : (reportFilters ?? propFilters ?? EMPTY_FILTERS);
396
+
397
+ const [filterDrafts, setFilterDrafts] = useState<Record<string, string>>({});
398
+ const [committedFilters, setCommittedFilters] = useState<Record<string, string>>({});
399
+ useEffect(() => {
400
+ const handle = setTimeout(() => {
401
+ setCommittedFilters((previous) =>
402
+ JSON.stringify(previous) === JSON.stringify(filterDrafts) ? previous : filterDrafts,
403
+ );
404
+ }, COLUMN_FILTER_DEBOUNCE_MS);
405
+ return () => clearTimeout(handle);
406
+ }, [filterDrafts]);
407
+
408
+ const columnTuples = useMemo(
409
+ () => columnFilterTuples(selectedColumns, committedFilters),
410
+ [selectedColumns, committedFilters],
411
+ );
412
+ const scopeKey = JSON.stringify(scopeFilters ?? []);
413
+ const scope = useMemo(() => JSON.parse(scopeKey) as ReportFilters, [scopeKey]);
414
+ const effectiveFilters = useMemo(
415
+ () => [...scope, ...baseFilters, ...columnTuples],
416
+ [scope, baseFilters, columnTuples],
417
+ );
229
418
 
230
- // Initialize enabled columns
419
+ // ── Sort / group / paging ───────────────────────────────────
420
+ const metaSort = useMemo<ReportSortState>(
421
+ () => ({
422
+ field: (meta?.sort_field || "modified").split(" ")[0],
423
+ order: (meta?.sort_order || "DESC").toLowerCase() === "asc" ? "asc" : "desc",
424
+ }),
425
+ [meta?.sort_field, meta?.sort_order],
426
+ );
427
+ const [sortOverride, setSortOverride] = useState<ReportSortState | null>(null);
428
+ const sort = sortOverride ?? metaSort;
429
+ const startGroupBy = groupField(initialGroupBy);
430
+ const chartConfig = typeof initialGroupBy === "object" ? initialGroupBy : undefined;
431
+ const [groupBy, setGroupBy] = useState<string | null>(startGroupBy);
432
+ const [pageSize, setPageSize] = useState<number>(DEFAULT_REPORT_PAGE_SIZE);
433
+ const [page, setPage] = useState(0);
434
+
435
+ // A doctype swap resets every view state (a route reuses the component).
231
436
  useEffect(() => {
232
- if (allColumns.length > 0 && enabledColumns.length === 0) {
233
- const initial = allColumns.filter((c) => c.enabled).map((c) => c.fieldname);
234
- if (initial.length === 0) {
235
- setEnabledColumns(allColumns.slice(0, 5).map((c) => c.fieldname));
236
- } else {
237
- setEnabledColumns(initial);
238
- }
239
- }
240
- }, [allColumns, enabledColumns.length]);
437
+ setSelectedOverride(null);
438
+ setStoredColumns(undefined);
439
+ setSortOverride(null);
440
+ setGroupBy(startGroupBy);
441
+ setPage(0);
442
+ setPageSize(DEFAULT_REPORT_PAGE_SIZE);
443
+ setFilterDrafts({});
444
+ setCommittedFilters({});
445
+ setFiltersCleared(false);
446
+ setReportFilters(null);
447
+ // startGroupBy is the mount-time seed; only a doctype swap re-seeds.
448
+ // eslint-disable-next-line react-hooks/exhaustive-deps
449
+ }, [doctype]);
241
450
 
242
- // Saved reports (Frappe-specific persistence layer)
243
- const [savedReports, setSavedReports] = useState<SavedReport[]>([]);
244
- const [saveDialogOpen, setSaveDialogOpen] = useState(false);
245
- const [saveReportName, setSaveReportName] = useState("");
451
+ useEffect(() => setFiltersCleared(false), [filtersKey]);
246
452
 
453
+ const effectiveFiltersKey = useMemo(() => JSON.stringify(effectiveFilters), [effectiveFilters]);
247
454
  useEffect(() => {
455
+ setPage(0);
456
+ }, [effectiveFiltersKey, pageSize, groupBy]);
457
+
458
+ // ── Apply a saved report's config when the selection changes ─
459
+ useEffect(() => {
460
+ if (!currentReport) {
461
+ setReportFilters(null);
462
+ return;
463
+ }
248
464
  let cancelled = false;
249
- void (async () => {
250
- const reports = await loadSavedReports(doctype);
251
- if (!cancelled) {
252
- setSavedReports(reports);
465
+ void savedReports.load(currentReport).then((saved) => {
466
+ if (cancelled || !saved) return;
467
+ setSelectedOverride(saved.fieldnames.length > 0 ? saved.fieldnames : null);
468
+ setReportFilters(saved.filters);
469
+ setFilterDrafts({});
470
+ setCommittedFilters({});
471
+ setFiltersCleared(false);
472
+ if (saved.sort) setSortOverride(saved.sort);
473
+ if (saved.pageSize && REPORT_PAGE_SIZES.some((size) => size === saved.pageSize)) {
474
+ setPageSize(saved.pageSize);
253
475
  }
254
- })();
476
+ setGroupBy(saved.groupBy ?? null);
477
+ });
255
478
  return () => {
256
479
  cancelled = true;
257
480
  };
258
- }, [doctype]);
481
+ // savedReports.load changes identity with its report list; reloading the
482
+ // config on list refreshes would clobber in-progress edits — key on the
483
+ // selected report (and doctype via load's closure) only.
484
+ // eslint-disable-next-line react-hooks/exhaustive-deps
485
+ }, [currentReport, doctype]);
486
+
487
+ // ── Column selection changes ────────────────────────────────
488
+ const handleToggleColumn = useCallback(
489
+ (fieldname: string) => {
490
+ setSelectedOverride((previous) => {
491
+ const current = previous ?? startFieldnames ?? storedColumns ?? defaultFieldnames;
492
+ const next = current.includes(fieldname)
493
+ ? current.length === 1
494
+ ? current
495
+ : current.filter((entry) => entry !== fieldname)
496
+ : [...current, fieldname];
497
+ if (!fixtureMode && !currentReport) persistStoredColumns(doctype, next);
498
+ return next;
499
+ });
500
+ },
501
+ [startFieldnames, storedColumns, defaultFieldnames, fixtureMode, currentReport, doctype],
502
+ );
259
503
 
260
- const handleSaveReport = useCallback(() => {
261
- if (!saveReportName.trim()) return;
262
- const report: SavedReport = {
263
- name: saveReportName.trim(),
264
- columns: enabledColumns,
265
- groupBy,
266
- filters,
267
- };
268
- const updated = [...savedReports.filter((r) => r.name !== report.name), report];
269
- setSavedReports(updated);
270
- void persistSavedReports(doctype, updated);
271
- setSaveDialogOpen(false);
272
- setSaveReportName("");
273
- }, [saveReportName, enabledColumns, groupBy, filters, savedReports, doctype]);
274
-
275
- const handleLoadReport = useCallback((report: SavedReport) => {
276
- setEnabledColumns(report.columns);
277
- setGroupBy(report.groupBy);
278
- }, []);
504
+ const handleResetColumns = useCallback(() => {
505
+ setSelectedOverride(defaultFieldnames);
506
+ setStoredColumns(undefined);
507
+ if (!fixtureMode) persistStoredColumns(doctype, defaultFieldnames);
508
+ }, [defaultFieldnames, fixtureMode, doctype]);
279
509
 
280
- const handleDeleteReport = useCallback(
510
+ // ── Data: live collection over the full filtered result ─────
511
+ const fetchFields = useMemo(() => {
512
+ const fields = new Set<string>(["name", sort.field]);
513
+ for (const column of selectedColumns) fields.add(column.fieldname);
514
+ if (groupBy) fields.add(groupBy);
515
+ if (chartConfig?.aggregationField) fields.add(chartConfig.aggregationField);
516
+ return [...fields];
517
+ }, [selectedColumns, sort.field, groupBy, chartConfig?.aggregationField]);
518
+
519
+ const orderBy = useMemo(
520
+ () => [{ field: sort.field, order: sort.order === "asc" ? Order.Asc : Order.Desc }],
521
+ [sort.field, sort.order],
522
+ );
523
+ const {
524
+ rows: rowsData,
525
+ syncError,
526
+ isLoading: rowsLoading,
527
+ } = useFrappeRows<ReportRow & { doctype: string }>(connection, doctype, {
528
+ fields: fetchFields,
529
+ filters: effectiveFilters,
530
+ orderBy,
531
+ limit: null,
532
+ enabled: layoutMeta !== undefined,
533
+ mapError: asError,
534
+ });
535
+
536
+ const orderedRows = useMemo(
537
+ () => orderReportRows(rowsData, sort, groupBy),
538
+ [rowsData, sort, groupBy],
539
+ );
540
+
541
+ const total = orderedRows.length;
542
+ const pageCount = Math.max(1, Math.ceil(total / pageSize));
543
+ const effectivePage = Math.min(page, pageCount - 1);
544
+ const gridRows = useMemo(
545
+ () => buildGridRows(orderedRows, effectivePage * pageSize, pageSize, groupBy),
546
+ [orderedRows, effectivePage, pageSize, groupBy],
547
+ );
548
+ const totals = useMemo(
549
+ () => computeTotals(selectedColumns, orderedRows),
550
+ [selectedColumns, orderedRows],
551
+ );
552
+ const groupByColumn = groupBy
553
+ ? allColumns.find((column) => column.fieldname === groupBy)
554
+ : undefined;
555
+ const chartData = useMemo(
556
+ () => (showChart && groupBy ? aggregateGroups(orderedRows, groupBy, chartConfig) : []),
557
+ [showChart, groupBy, orderedRows, chartConfig],
558
+ );
559
+
560
+ // ── Navigation ──────────────────────────────────────────────
561
+ const rowsPressable = Boolean(onOpenRow || onRowPress);
562
+ const handleOpenRow = useCallback(
281
563
  (name: string) => {
282
- const updated = savedReports.filter((r) => r.name !== name);
283
- setSavedReports(updated);
284
- void persistSavedReports(doctype, updated);
564
+ onOpenRow?.(name);
565
+ if (onRowPress) {
566
+ const index = orderedRows.findIndex((row) => row.name === name);
567
+ if (index >= 0) onRowPress(orderedRows[index], index);
568
+ }
285
569
  },
286
- [savedReports, doctype],
570
+ [onOpenRow, onRowPress, orderedRows],
287
571
  );
288
572
 
289
- // CSV export handler
290
- const handleExport = useCallback(
291
- (exportData: Record<string, unknown>[]) => {
292
- if (onExport) {
293
- onExport(exportData);
294
- return;
573
+ const handleSelectReport = useCallback(
574
+ (name: string | null) => {
575
+ if (reportProp === undefined) setInternalReport(name);
576
+ onReportChange?.(name);
577
+ if (!name) {
578
+ setReportFilters(null);
579
+ setSelectedOverride(null);
580
+ setSortOverride(null);
581
+ setGroupBy(null);
295
582
  }
583
+ },
584
+ [reportProp, onReportChange],
585
+ );
586
+
587
+ const handleClearBaseFilters = useCallback(() => {
588
+ setFiltersCleared(true);
589
+ if (propFilters && !reportFilters) onClearFilters?.();
590
+ }, [propFilters, reportFilters, onClearFilters]);
296
591
 
297
- // Generate CSV
298
- const cols = allColumns.filter((c) => enabledColumns.includes(c.fieldname));
299
- const headers = cols.map((c) => c.label);
300
- const csvRows = [headers.join(",")];
301
-
302
- for (const row of exportData) {
303
- const values = cols.map((col) => {
304
- const val = row[col.fieldname];
305
- if (val === null || val === undefined) return "";
306
- const str = String(val);
307
- if (str.includes(",") || str.includes('"') || str.includes("\n")) {
308
- return `"${str.replace(/"/g, '""')}"`;
309
- }
310
- return str;
592
+ // ── Saved-report actions ────────────────────────────────────
593
+ const handleSaveReport = useCallback(
594
+ (name: string) => {
595
+ void savedReports
596
+ .save(name, {
597
+ fieldnames: [...selectedFieldnames],
598
+ filters: effectiveFilters,
599
+ sort,
600
+ pageSize,
601
+ groupBy,
602
+ addTotals: totals.length > 0,
603
+ })
604
+ .then((savedName) => {
605
+ notify({
606
+ severity: "success",
607
+ scope: "page",
608
+ body: t("report.saveDone", {
609
+ defaultValue: "Saved report “{{name}}”",
610
+ name: savedName,
611
+ }),
612
+ });
613
+ if (savedName !== currentReport) handleSelectReport(savedName);
614
+ })
615
+ .catch((error: unknown) => {
616
+ notify({
617
+ severity: "error",
618
+ scope: "page",
619
+ body:
620
+ error instanceof Error
621
+ ? error.message
622
+ : t("report.saveFailed", "Could not save the report"),
623
+ });
311
624
  });
312
- csvRows.push(values.join(","));
313
- }
625
+ },
626
+ [
627
+ savedReports,
628
+ selectedFieldnames,
629
+ effectiveFilters,
630
+ sort,
631
+ pageSize,
632
+ groupBy,
633
+ totals.length,
634
+ currentReport,
635
+ handleSelectReport,
636
+ t,
637
+ ],
638
+ );
314
639
 
315
- const csvContent = csvRows.join("\n");
316
- const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
317
- downloadBlob(blob, `${doctype}_report.csv`);
640
+ const handleDeleteReport = useCallback(
641
+ (name: string) => {
642
+ void savedReports
643
+ .remove(name)
644
+ .then(() => {
645
+ notify({
646
+ severity: "success",
647
+ scope: "page",
648
+ body: t("report.deleteDone", {
649
+ defaultValue: "Deleted report “{{name}}”",
650
+ name,
651
+ }),
652
+ });
653
+ if (name === currentReport) handleSelectReport(null);
654
+ })
655
+ .catch((error: unknown) => {
656
+ notify({
657
+ severity: "error",
658
+ scope: "page",
659
+ body:
660
+ error instanceof Error
661
+ ? error.message
662
+ : t("report.deleteFailed", "Could not delete the report"),
663
+ });
664
+ });
318
665
  },
319
- [allColumns, enabledColumns, onExport, doctype],
666
+ [savedReports, currentReport, handleSelectReport, t],
320
667
  );
321
668
 
322
- // B12 capability gate: a consumer-provided onExport owns delivery and shows
323
- // on every platform; the built-in CSV path only shows where the web download
324
- // pipeline exists. `undefined` hides ReportBuilder's Export button entirely,
325
- // so native/GTK never render a dead control. Web behavior is unchanged.
669
+ // ── Export: full filtered + ordered result ──────────────────
326
670
  const exportEnabled = Boolean(onExport) || canDownloadCsv();
671
+ const handleExport = useCallback(() => {
672
+ if (onExport) {
673
+ onExport(orderedRows);
674
+ return;
675
+ }
676
+ downloadBlob(
677
+ new Blob([buildReportCsv(selectedColumns, orderedRows)], {
678
+ type: "text/csv;charset=utf-8;",
679
+ }),
680
+ `${doctype}_report.csv`,
681
+ );
682
+ notify({
683
+ severity: "success",
684
+ scope: "page",
685
+ body: t("report.exportDone", {
686
+ count: orderedRows.length,
687
+ defaultValue_one: "Exported {{count}} row to CSV",
688
+ defaultValue_other: "Exported {{count}} rows to CSV",
689
+ }),
690
+ });
691
+ }, [onExport, selectedColumns, orderedRows, doctype, t]);
692
+
693
+ const handleColumnFilterChange = useCallback((fieldname: string, value: string) => {
694
+ setFilterDrafts((previous) => {
695
+ if (value === "") {
696
+ if (!(fieldname in previous)) return previous;
697
+ const { [fieldname]: _dropped, ...rest } = previous;
698
+ return rest;
699
+ }
700
+ return { ...previous, [fieldname]: value };
701
+ });
702
+ }, []);
327
703
 
328
- if (isMetaLoading) {
704
+ const handleClearColumnFilters = useCallback(() => {
705
+ setFilterDrafts({});
706
+ setCommittedFilters({});
707
+ }, []);
708
+
709
+ // ── Render ──────────────────────────────────────────────────
710
+ if (metaLoading && !meta) {
329
711
  return (
330
- <YStack
331
- {...knobProps.panelPadding}
332
- alignItems="center"
333
- justifyContent="center"
334
- gap="$2"
335
- {...stackProps}
336
- >
337
- <Spinner size="large" />
338
- <Text color="$color10">{t("Loading {{doctype}} schema...", { doctype })}</Text>
339
- </YStack>
712
+ <>
713
+ {loadingFallback ?? (
714
+ <YStack flex={1} alignItems="center" justifyContent="center" {...stackProps}>
715
+ <Spinner size="large" />
716
+ </YStack>
717
+ )}
718
+ </>
340
719
  );
341
720
  }
342
-
343
- if (metaError && !meta) {
721
+ if (metaError && metaRefused && !layoutMeta) {
722
+ if (recordShape.status === "pending") {
723
+ return (
724
+ <>
725
+ {loadingFallback ?? (
726
+ <YStack flex={1} alignItems="center" justifyContent="center" {...stackProps}>
727
+ <Spinner size="large" />
728
+ </YStack>
729
+ )}
730
+ </>
731
+ );
732
+ }
344
733
  return (
345
- <YStack {...knobProps.panelPadding} alignItems="center" {...knobProps.gap} {...stackProps}>
346
- <Text color="$red11">{t("Failed to load {{doctype}} schema", { doctype })}</Text>
347
- <Text color="$color10" fontSize="$2">
348
- {metaError.message}
349
- </Text>
350
- </YStack>
734
+ <>
735
+ {errorFallback ? (
736
+ errorFallback(metaError)
737
+ ) : (
738
+ <EmptyState
739
+ testID="frappe-report-not-permitted"
740
+ title={t("report.notPermitted", {
741
+ defaultValue: "You can't open the {{doctype}} report",
742
+ doctype,
743
+ })}
744
+ description={refusalReason(metaError)}
745
+ />
746
+ )}
747
+ </>
351
748
  );
352
749
  }
353
-
354
- if (isLoading) {
750
+ if (metaError && !meta && !readsWithoutMeta) {
355
751
  return (
356
- <YStack {...knobProps.panelPadding} alignItems="center" gap="$1" {...stackProps}>
357
- <Spinner size="small" />
358
- <Text color="$color10" fontSize="$2">
359
- {t("Loading data...")}
360
- </Text>
361
- </YStack>
752
+ <>
753
+ {errorFallback ? (
754
+ errorFallback(metaError)
755
+ ) : (
756
+ <EmptyState
757
+ intent="error"
758
+ title={t("report.metaFailed", {
759
+ defaultValue: "Couldn't load the {{doctype}} report",
760
+ doctype,
761
+ })}
762
+ description={metaError.message}
763
+ />
764
+ )}
765
+ </>
362
766
  );
363
767
  }
364
768
 
365
- if (isError) {
366
- return (
367
- <YStack {...knobProps.panelPadding} alignItems="center" {...knobProps.gap} {...stackProps}>
368
- <Text color="$red11">{t("Failed to load {{doctype}} data", { doctype })}</Text>
369
- <Text color="$color10" fontSize="$2">
370
- {t("The live query could not load report rows.")}
371
- </Text>
769
+ let body: React.ReactNode;
770
+ if (syncError && isPermissionError(syncError)) {
771
+ body = (
772
+ <EmptyState
773
+ testID="frappe-report-not-permitted"
774
+ title={t("report.notPermitted", {
775
+ defaultValue: "You can't open the {{doctype}} report",
776
+ doctype,
777
+ })}
778
+ description={refusalReason(syncError)}
779
+ />
780
+ );
781
+ } else if (syncError) {
782
+ body = (
783
+ <EmptyState
784
+ intent="error"
785
+ title={t("report.loadFailed", "Couldn't load report rows")}
786
+ description={syncError.message}
787
+ />
788
+ );
789
+ } else if (rowsLoading && total === 0) {
790
+ body = (
791
+ <YStack flex={1} alignItems="center" justifyContent="center">
792
+ <Spinner size="large" />
372
793
  </YStack>
373
794
  );
374
- }
375
-
376
- return (
377
- <YStack flex={1} {...knobProps.borderRadius} {...stackProps}>
378
- {/* Frappe-specific toolbar: Save/Load reports */}
379
- <XStack
380
- {...knobProps.panelPadding}
381
- {...knobProps.surface}
382
- {...knobProps.gap}
383
- alignItems="center"
384
- borderBottomWidth={1}
385
- borderBottomColor="$borderColor"
386
- >
387
- {/* Subtle stale affordance while a post-event refresh reconciles */}
388
- {isLiveStale ? (
389
- <XStack gap="$2" alignItems="center">
390
- <Spinner size="small" aria-label={t("Updating")} />
391
- <Text {...knobProps.label} color={formCommonColors.muted}>
392
- {t("Updating...")}
393
- </Text>
394
- </XStack>
395
- ) : null}
396
- <XStack flex={1} />
397
- {/* Save */}
398
- <Popover placement="bottom-end" open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
399
- <Popover.Trigger asChild>
400
- <Button size="$2" icon={FloppyDiskIcon}>
401
- {t("Save")}
795
+ } else if (total === 0) {
796
+ const hasFilters = effectiveFilters.length > 0;
797
+ body = (
798
+ <EmptyState
799
+ title={t("report.emptyTitle", "No results")}
800
+ description={
801
+ hasFilters
802
+ ? t("report.emptyFiltered", "No rows match the current filters")
803
+ : t("report.emptyDescription", {
804
+ defaultValue: "There are no {{doctype}} records yet",
805
+ doctype,
806
+ })
807
+ }
808
+ action={
809
+ hasFilters ? (
810
+ <Button
811
+ size="$3"
812
+ onPress={() => {
813
+ handleClearColumnFilters();
814
+ handleClearBaseFilters();
815
+ }}
816
+ >
817
+ {t("report.clearAllFilters", "Clear all filters")}
402
818
  </Button>
403
- </Popover.Trigger>
404
- <PopoverContent>
405
- <YStack {...knobProps.gap} minWidth={220}>
406
- <Text {...knobProps.label} fontWeight="400">
407
- {t("Save Report")}
408
- </Text>
409
- <Input
410
- label={t("Report name")}
411
- labelHidden
412
- placeholder={t("Report name")}
413
- value={saveReportName}
414
- onChangeText={setSaveReportName}
415
- inputProps={{ onSubmitEditing: handleSaveReport } as any}
416
- />
417
- <Button size="$2" onPress={handleSaveReport} disabled={!saveReportName.trim()}>
418
- {t("Save")}
419
- </Button>
420
- </YStack>
421
- </PopoverContent>
422
- </Popover>
423
-
424
- {/* Load */}
425
- {savedReports.length > 0 && (
426
- <Popover placement="bottom-end">
427
- <Popover.Trigger asChild>
428
- <Button size="$2" icon={FolderOpenIcon}>
429
- {t("Load")}
430
- </Button>
431
- </Popover.Trigger>
432
- <PopoverContent>
433
- <YStack {...knobProps.gap} minWidth={180}>
434
- <Text {...knobProps.label} fontWeight="400" color={formCommonColors.muted}>
435
- {t("Saved Reports")}
436
- </Text>
437
- <Separator />
438
- {savedReports.map((report) => (
439
- <XStack
440
- key={report.name}
441
- alignItems="center"
442
- justifyContent="space-between"
443
- padding="$2"
444
- {...knobProps.containerRadius}
445
- hoverStyle={{ backgroundColor: "$color2" }}
446
- cursor="pointer"
447
- onPress={() => handleLoadReport(report)}
448
- >
449
- <Text {...knobProps.body} flex={1}>
450
- {report.name}
451
- </Text>
452
- <Button
453
- size="$2"
454
- circular
455
- chromeless
456
- icon={TrashIcon}
457
- onPress={(e) => {
458
- (e as unknown as { stopPropagation: () => void }).stopPropagation();
459
- handleDeleteReport(report.name);
460
- }}
461
- aria-label={t("Delete {{name}}", { name: report.name })}
462
- />
463
- </XStack>
464
- ))}
465
- </YStack>
466
- </PopoverContent>
467
- </Popover>
819
+ ) : undefined
820
+ }
821
+ />
822
+ );
823
+ } else {
824
+ body = (
825
+ <YStack flex={1} minWidth={0} {...knobProps.gap}>
826
+ {chartData.length > 0 && (
827
+ <YStack {...knobProps.surface} {...knobProps.containerRadius} {...knobProps.panelPadding}>
828
+ <BarChart data={chartData} />
829
+ </YStack>
468
830
  )}
469
- </XStack>
831
+ <ReportGrid
832
+ columns={selectedColumns}
833
+ rows={gridRows}
834
+ sort={sort}
835
+ onSortChange={setSortOverride}
836
+ columnFilters={filterDrafts}
837
+ onColumnFilterChange={handleColumnFilterChange}
838
+ onOpenRow={rowsPressable ? handleOpenRow : undefined}
839
+ onOpenLink={onOpenLink}
840
+ totals={totals}
841
+ groupByColumn={groupByColumn}
842
+ />
843
+ </YStack>
844
+ );
845
+ }
470
846
 
471
- {/* Delegate to generic ReportBuilder */}
472
- <ReportBuilder
847
+ const content = (
848
+ <YStack flex={1} minWidth={0} {...stackProps}>
849
+ <ReportViewToolbar
473
850
  columns={allColumns}
474
- data={data}
475
- groupBy={groupBy ?? undefined}
851
+ selected={selectedFieldnames}
852
+ onToggleColumn={handleToggleColumn}
853
+ onResetColumns={handleResetColumns}
854
+ groupBy={groupBy}
476
855
  onGroupByChange={setGroupBy}
477
- onColumnsChange={setEnabledColumns}
856
+ baseFilters={baseFilters}
857
+ onClearBaseFilters={baseFilters.length > 0 ? handleClearBaseFilters : undefined}
858
+ columnFilterCount={columnTuples.length}
859
+ onClearColumnFilters={handleClearColumnFilters}
860
+ savedReports={savedReports.reports}
861
+ currentReport={currentReport}
862
+ onSelectReport={handleSelectReport}
863
+ onSaveReport={readsWithoutMeta ? undefined : handleSaveReport}
864
+ onDeleteReport={readsWithoutMeta ? undefined : handleDeleteReport}
478
865
  onExport={exportEnabled ? handleExport : undefined}
479
- onRowPress={onRowPress}
480
- showChart={showChart}
481
- flex={1}
866
+ exportDisabled={total === 0}
482
867
  />
868
+ <YStack flex={1} minWidth={0} paddingHorizontal="$3">
869
+ {body}
870
+ </YStack>
871
+ {total > 0 && !syncError && (
872
+ <YStack paddingHorizontal="$3" paddingVertical="$2">
873
+ <DataTablePagination
874
+ pagination={{
875
+ pageIndex: effectivePage,
876
+ pageSize,
877
+ totalCount: total,
878
+ totalPages: pageCount,
879
+ }}
880
+ onPaginationChange={(next) => {
881
+ setPage(next.pageIndex);
882
+ setPageSize(next.pageSize);
883
+ }}
884
+ pageSizeOptions={[...REPORT_PAGE_SIZES]}
885
+ compact
886
+ />
887
+ </YStack>
888
+ )}
483
889
  </YStack>
484
890
  );
891
+
892
+ // Native has no page-level scroll (the desk list precedent) — wrap so rows
893
+ // below the fold stay reachable. Web keeps the flex layout.
894
+ return isWeb ? content : <ScrollView flex={1}>{content}</ScrollView>;
485
895
  }
486
896
 
487
897
  export type { ReportColumn, GroupByConfig } from "@multiplatform.one/components";