xertica-ui 2.5.0 → 2.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (536) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +56 -17
  3. package/assets/xertica-logo.svg +37 -37
  4. package/assets/xertica-x-logo.svg +20 -20
  5. package/bin/cli.ts +14 -2
  6. package/bin/generate-tokens.ts +262 -262
  7. package/bin/language-config.ts +359 -358
  8. package/components/assistant/code-block/CodeBlock.tsx +268 -268
  9. package/components/assistant/formatted-document/FormattedDocument.tsx +147 -147
  10. package/components/assistant/modern-chat-input/ModernChatInput.tsx +564 -564
  11. package/components/assistant/xertica-assistant/parts/AssistantCollapsedView.tsx +99 -99
  12. package/components/assistant/xertica-assistant/parts/AssistantConversationList.tsx +104 -104
  13. package/components/assistant/xertica-assistant/parts/AssistantDocumentEditor.tsx +81 -81
  14. package/components/assistant/xertica-assistant/parts/AssistantFeedbackDialog.tsx +88 -88
  15. package/components/assistant/xertica-assistant/parts/AssistantHeader.tsx +75 -75
  16. package/components/assistant/xertica-assistant/parts/AssistantMessageBubble.tsx +564 -564
  17. package/components/assistant/xertica-assistant/parts/AssistantTabBar.tsx +67 -67
  18. package/components/assistant/xertica-assistant/parts/AssistantWelcomeScreen.tsx +103 -103
  19. package/components/assistant/xertica-assistant/use-assistant.ts +615 -615
  20. package/components/assistant/xertica-assistant/xertica-assistant.tsx +611 -611
  21. package/components/blocks/card-patterns/ActivityCard.tsx +100 -100
  22. package/components/blocks/card-patterns/ActivityCardSkeleton.tsx +56 -56
  23. package/components/blocks/card-patterns/FeatureCardSkeleton.tsx +58 -58
  24. package/components/blocks/card-patterns/NotificationCard.tsx +140 -140
  25. package/components/blocks/card-patterns/NotificationCardSkeleton.tsx +81 -81
  26. package/components/blocks/card-patterns/ProfileCard.tsx +112 -112
  27. package/components/blocks/card-patterns/ProfileCardSkeleton.tsx +69 -69
  28. package/components/blocks/card-patterns/ProjectCard.tsx +123 -123
  29. package/components/blocks/card-patterns/ProjectCardSkeleton.tsx +67 -67
  30. package/components/blocks/card-patterns/QuickActionCardSkeleton.tsx +44 -44
  31. package/components/blocks/card-patterns/card-patterns.stories.tsx +594 -594
  32. package/components/blocks/card-patterns/index.ts +29 -29
  33. package/components/brand/language-selector/LanguageSelector.tsx +102 -102
  34. package/components/brand/language-selector/language-selector.stories.tsx +111 -111
  35. package/components/brand/language-selector/language-selector.test.tsx +101 -101
  36. package/components/brand/theme-toggle/ThemeToggle.tsx +74 -74
  37. package/components/brand/xertica-provider/xertica-provider.mdx +61 -61
  38. package/components/index.ts +86 -86
  39. package/components/layout/sidebar/sidebar.mdx +1 -1
  40. package/components/layout/sidebar/sidebar.stories.tsx +1033 -787
  41. package/components/layout/sidebar/sidebar.tsx +338 -1
  42. package/components/media/FloatingMediaWrapper.tsx +371 -371
  43. package/components/media/audio-player/AudioPlayer.tsx +768 -768
  44. package/components/media/video-player/VideoPlayer.tsx +310 -310
  45. package/components/pages/home-content/HomeContent.tsx +120 -120
  46. package/components/pages/home-content/home-content.mdx +62 -62
  47. package/components/pages/home-page/HomePage.tsx +78 -78
  48. package/components/pages/home-page/home-page.mdx +53 -53
  49. package/components/pages/template-content/TemplateContent.tsx +1354 -1354
  50. package/components/pages/template-content/template-content.mdx +61 -61
  51. package/components/pages/template-page/TemplatePage.stories.tsx +32 -32
  52. package/components/pages/template-page/template-page.mdx +53 -53
  53. package/components/shared/error-boundary.stories.tsx +114 -114
  54. package/components/shared/error-boundary.tsx +150 -150
  55. package/components/shared/error-fallbacks.tsx +222 -222
  56. package/components/ui/accordion/accordion.mdx +8 -8
  57. package/components/ui/alert/alert.mdx +8 -8
  58. package/components/ui/alert-dialog/alert-dialog.mdx +8 -8
  59. package/components/ui/aspect-ratio/aspect-ratio.mdx +8 -8
  60. package/components/ui/assistant-chart/assistant-chart.mdx +8 -8
  61. package/components/ui/avatar/avatar.mdx +8 -8
  62. package/components/ui/badge/badge.mdx +8 -8
  63. package/components/ui/breadcrumb/breadcrumb.mdx +8 -8
  64. package/components/ui/button/button.mdx +8 -8
  65. package/components/ui/calendar/calendar.mdx +8 -8
  66. package/components/ui/card/card.mdx +8 -8
  67. package/components/ui/carousel/carousel.mdx +8 -8
  68. package/components/ui/chart/chart.mdx +8 -8
  69. package/components/ui/chart/chart.test.tsx +178 -178
  70. package/components/ui/chart/chart.tsx +2245 -2239
  71. package/components/ui/checkbox/checkbox.mdx +8 -8
  72. package/components/ui/collapsible/collapsible.mdx +8 -8
  73. package/components/ui/command/command.mdx +8 -8
  74. package/components/ui/context-menu/context-menu.mdx +8 -8
  75. package/components/ui/dialog/dialog.mdx +8 -8
  76. package/components/ui/drawer/drawer.mdx +8 -8
  77. package/components/ui/dropdown-menu/dropdown-menu.mdx +8 -8
  78. package/components/ui/empty/empty.mdx +8 -8
  79. package/components/ui/file-upload/file-upload.mdx +8 -8
  80. package/components/ui/hover-card/hover-card.mdx +8 -8
  81. package/components/ui/input/input.mdx +8 -8
  82. package/components/ui/input-otp/input-otp.mdx +8 -8
  83. package/components/ui/label/label.mdx +8 -8
  84. package/components/ui/map/map.mdx +8 -8
  85. package/components/ui/menubar/menubar.mdx +8 -8
  86. package/components/ui/navigation-menu/navigation-menu.mdx +8 -8
  87. package/components/ui/notification-badge/notification-badge.mdx +8 -8
  88. package/components/ui/pagination/pagination.mdx +8 -8
  89. package/components/ui/popover/popover.mdx +8 -8
  90. package/components/ui/progress/progress.mdx +8 -8
  91. package/components/ui/radio-group/radio-group.mdx +8 -8
  92. package/components/ui/rating/rating.mdx +8 -8
  93. package/components/ui/resizable/resizable.mdx +8 -8
  94. package/components/ui/route-map/route-map.mdx +8 -8
  95. package/components/ui/scroll-area/scroll-area.mdx +8 -8
  96. package/components/ui/search/search.mdx +8 -8
  97. package/components/ui/select/select.mdx +8 -8
  98. package/components/ui/separator/separator.mdx +8 -8
  99. package/components/ui/sheet/sheet.mdx +8 -8
  100. package/components/ui/simple-map/simple-map.mdx +8 -8
  101. package/components/ui/skeleton/skeleton.mdx +8 -8
  102. package/components/ui/slider/slider.mdx +8 -8
  103. package/components/ui/sonner/sonner.mdx +8 -8
  104. package/components/ui/stats-card/index.ts +2 -2
  105. package/components/ui/stats-card/stats-card-skeleton.tsx +60 -60
  106. package/components/ui/stats-card/stats-card.mdx +8 -8
  107. package/components/ui/stats-card/stats-card.tsx +109 -109
  108. package/components/ui/stepper/stepper.mdx +8 -8
  109. package/components/ui/switch/switch.mdx +8 -8
  110. package/components/ui/table/table.mdx +8 -8
  111. package/components/ui/tabs/tabs.mdx +8 -8
  112. package/components/ui/textarea/textarea.mdx +8 -8
  113. package/components/ui/timeline/timeline.mdx +8 -8
  114. package/components/ui/toggle/toggle.mdx +8 -8
  115. package/components/ui/toggle-group/toggle-group.mdx +8 -8
  116. package/components/ui/tooltip/tooltip.mdx +8 -8
  117. package/components/ui/tree-view/tree-view.mdx +8 -8
  118. package/components.json +511 -511
  119. package/contexts/AuthContext.tsx +121 -121
  120. package/contexts/BrandColorsContext.tsx +282 -282
  121. package/contexts/LanguageContext.test.tsx +121 -121
  122. package/contexts/LanguageContext.tsx +250 -250
  123. package/contexts/theme-data.ts +391 -391
  124. package/dist/{AssistantChart-DoZCyS5r.cjs → AssistantChart-9w31gdAb.cjs} +4 -4
  125. package/dist/{AssistantChart-CldVCVDe.cjs → AssistantChart-BAudAfne.cjs} +5 -5
  126. package/dist/{AssistantChart-Bdd44uBn.cjs → AssistantChart-BAx9VQvb.cjs} +127 -388
  127. package/dist/{AssistantChart-Cu3m7RBo.js → AssistantChart-BP8upjMk.js} +5 -5
  128. package/dist/{AssistantChart-CFhDdGyU.js → AssistantChart-CVko2A1W.js} +130 -391
  129. package/dist/{AssistantChart-C_hwFRRr.js → AssistantChart-CVzmmhx4.js} +4 -4
  130. package/dist/{AudioPlayer-IAU5q5T1.cjs → AudioPlayer-1ypwE2Wh.cjs} +1 -1
  131. package/dist/{AudioPlayer-CGRUtUdN.js → AudioPlayer-DuKXrCfy.js} +1 -1
  132. package/dist/{LanguageContext-CS14yCpi.js → LanguageContext-BwhwC3G2.js} +2 -2
  133. package/dist/{LanguageContext-B_KFTCzT.cjs → LanguageContext-DvUt5jBg.cjs} +2 -2
  134. package/dist/{ThemeContext-C2EwAPDt.js → ThemeContext-BbBNoFTG.js} +2 -2
  135. package/dist/{ThemeContext-Bmod0Cg2.cjs → ThemeContext-BblcjQup.cjs} +13 -8
  136. package/dist/{ThemeContext-BWq9ACPo.js → ThemeContext-Bo-W2WZH.js} +13 -8
  137. package/dist/{ThemeContext-j5aGtPky.cjs → ThemeContext-CP3a0jxy.cjs} +193 -262
  138. package/dist/{ThemeContext-vTjumZeM.cjs → ThemeContext-Cmr8Ex8H.cjs} +2 -2
  139. package/dist/ThemeContext-CpqYShLq.cjs +324 -0
  140. package/dist/{ThemeContext-CQSo4Iwc.js → ThemeContext-D3LzacmG.js} +8 -1
  141. package/dist/ThemeContext-Du2nE1PL.js +325 -0
  142. package/dist/ThemeContext-GeEBTJ3q.cjs +1621 -0
  143. package/dist/ThemeContext-JyLK9B1o.js +1622 -0
  144. package/dist/{ThemeContext-CGk3KK0k.cjs → ThemeContext-U4dEYc6C.cjs} +8 -1
  145. package/dist/{ThemeContext-BXjrgUjW.js → ThemeContext-ept8jhXI.js} +200 -261
  146. package/dist/{VerifyEmailPage-CGIwmWrm.js → VerifyEmailPage-B31mCrMc.js} +1 -1
  147. package/dist/{VerifyEmailPage-C0c2e5n0.js → VerifyEmailPage-BE-L9mB7.js} +7 -7
  148. package/dist/{VerifyEmailPage-DSBMRHtl.js → VerifyEmailPage-BIBOKV7Z.js} +41 -36
  149. package/dist/{VerifyEmailPage-DgIid028.js → VerifyEmailPage-BJjAMUTW.js} +4 -4
  150. package/dist/{VerifyEmailPage--1Vurewl.cjs → VerifyEmailPage-BRSP-Pwt.cjs} +3 -3
  151. package/dist/{VerifyEmailPage-Cwi3kbol.cjs → VerifyEmailPage-Bae2cBXT.cjs} +7 -7
  152. package/dist/{VerifyEmailPage-De6bQjrz.cjs → VerifyEmailPage-BiRm7Nh4.cjs} +41 -36
  153. package/dist/{VerifyEmailPage-ByerOcm4.cjs → VerifyEmailPage-Bv8Ah_TK.cjs} +23 -20
  154. package/dist/VerifyEmailPage-Bvfv8HVQ.js +3214 -0
  155. package/dist/{VerifyEmailPage-BComraR7.cjs → VerifyEmailPage-CR7kb5df.cjs} +22 -12
  156. package/dist/{VerifyEmailPage-CpqqpLpo.cjs → VerifyEmailPage-C_Zk6Gen.cjs} +1 -1
  157. package/dist/{VerifyEmailPage-MTD7AG1Z.js → VerifyEmailPage-C_ihbcth.js} +4 -4
  158. package/dist/{VerifyEmailPage-1WwWczAn.js → VerifyEmailPage-CbgjOF0v.js} +22 -12
  159. package/dist/{VerifyEmailPage-DvMLZgFt.js → VerifyEmailPage-CdYPSJoO.js} +1 -1
  160. package/dist/{VerifyEmailPage-By3Jf__L.cjs → VerifyEmailPage-CkBYfsNy.cjs} +4 -4
  161. package/dist/{VerifyEmailPage-CJLz3jrn.js → VerifyEmailPage-Cyl55sJb.js} +23 -20
  162. package/dist/VerifyEmailPage-D-FRj5TU.cjs +3213 -0
  163. package/dist/{VerifyEmailPage-B4peJjAT.cjs → VerifyEmailPage-DF2ilhum.cjs} +334 -356
  164. package/dist/{VerifyEmailPage-CYXtbKi3.cjs → VerifyEmailPage-DMBh4NM9.cjs} +1 -1
  165. package/dist/{VerifyEmailPage-CgMxRb4z.js → VerifyEmailPage-DTtFfC-J.js} +3 -3
  166. package/dist/{VerifyEmailPage-CFLMls1p.cjs → VerifyEmailPage-Dt7zgA4w.cjs} +4 -4
  167. package/dist/{VerifyEmailPage-C5TNQTBa.js → VerifyEmailPage-EhudUdqF.js} +343 -355
  168. package/dist/{VerifyEmailPage-DGhuIqkb.js → VerifyEmailPage-X14vhdyl.js} +4 -4
  169. package/dist/VerifyEmailPage-hdB8JQGv.cjs +3213 -0
  170. package/dist/{VerifyEmailPage-Bp1XXl3H.cjs → VerifyEmailPage-u_Dn7t1U.cjs} +4 -4
  171. package/dist/VerifyEmailPage-vYHbYK3q.js +3214 -0
  172. package/dist/{XerticaProvider-CBGc4EMA.cjs → XerticaProvider-AChwphCO.cjs} +4 -4
  173. package/dist/{XerticaProvider-BIrqfZ-i.cjs → XerticaProvider-AbWlr7Af.cjs} +8 -11
  174. package/dist/{XerticaProvider-D-yNhF94.cjs → XerticaProvider-B8CaV7xu.cjs} +1 -1
  175. package/dist/{XerticaProvider-CEoWMTxu.js → XerticaProvider-BITjgC5p.js} +2 -2
  176. package/dist/{XerticaProvider-CllrbMEJ.cjs → XerticaProvider-By8q3Roe.cjs} +2 -2
  177. package/dist/{XerticaProvider-C1DKnvLh.js → XerticaProvider-CUYJZc32.js} +4 -4
  178. package/dist/{XerticaProvider-ET0ihewn.cjs → XerticaProvider-CW9hpCdF.cjs} +2 -2
  179. package/dist/{XerticaProvider-Dt5HEzbQ.js → XerticaProvider-CWgby5mY.js} +10 -10
  180. package/dist/XerticaProvider-CWs6EwNa.js +49 -0
  181. package/dist/XerticaProvider-CjQAQPcn.cjs +48 -0
  182. package/dist/XerticaProvider-D5lLumH-.js +49 -0
  183. package/dist/{XerticaProvider-DYq4JWtg.js → XerticaProvider-DQtvJU7m.js} +1 -1
  184. package/dist/XerticaProvider-qQUDop71.cjs +48 -0
  185. package/dist/{XerticaProvider-B7EVH-NF.js → XerticaProvider-siSt9uG2.js} +2 -2
  186. package/dist/{XerticaXLogo-Zw2B276b.cjs → XerticaXLogo-8TTzBjHw.cjs} +1 -1
  187. package/dist/{XerticaXLogo-B7xQ5dhi.js → XerticaXLogo-BWaag64t.js} +1 -1
  188. package/dist/{XerticaXLogo-DZbo4vOE.js → XerticaXLogo-CFuIlYFH.js} +12 -12
  189. package/dist/{XerticaXLogo-bvZSgwGF.cjs → XerticaXLogo-CU-U-GP4.cjs} +7 -13
  190. package/dist/XerticaXLogo-ChryA6xj.js +252 -0
  191. package/dist/{XerticaXLogo-CQUUjXoH.cjs → XerticaXLogo-CziKMQil.cjs} +8 -8
  192. package/dist/XerticaXLogo-DHz5SugF.js +252 -0
  193. package/dist/XerticaXLogo-DTee_y8X.cjs +251 -0
  194. package/dist/{XerticaXLogo-Cmsp-Eey.js → XerticaXLogo-DfUvz-lD.js} +9 -9
  195. package/dist/XerticaXLogo-kslQ8Tk_.cjs +251 -0
  196. package/dist/{alert-dialog-s-vmNkJ_.js → alert-dialog-iDe5VE5o.js} +3 -3
  197. package/dist/{alert-dialog-DSKByiKZ.cjs → alert-dialog-yckpaOpy.cjs} +3 -3
  198. package/dist/cli.js +16 -6
  199. package/dist/components/ui/chart/chart.d.ts +7 -5
  200. package/dist/{google-maps-loader-Y-QkD-Li.cjs → google-maps-loader-BqsYL48U.cjs} +0 -5
  201. package/dist/{google-maps-loader-CTYySAun.js → google-maps-loader-t2IlYBzw.js} +0 -4
  202. package/dist/index-CkTUgOwX.js +8 -0
  203. package/dist/{index-COtD8bRW.cjs → index-D3RLKRAs.cjs} +1 -1
  204. package/dist/index.cjs.js +2 -2
  205. package/dist/index.es.js +2 -2
  206. package/dist/index.umd.js +454 -1027
  207. package/dist/layout.cjs.js +1 -1
  208. package/dist/layout.es.js +1 -1
  209. package/dist/pages.cjs.js +1 -1
  210. package/dist/pages.es.js +1 -1
  211. package/dist/{sidebar-DAaY8bRU.cjs → sidebar-B3EYhli0.cjs} +33 -24
  212. package/dist/{sidebar-nzPoVHBQ.cjs → sidebar-B9NR0lCe.cjs} +46 -41
  213. package/dist/{sidebar-CeTMuzOx.cjs → sidebar-BvF5I2Ue.cjs} +47 -128
  214. package/dist/{sidebar-q7P2Godd.cjs → sidebar-C5B_LHek.cjs} +1 -1
  215. package/dist/{sidebar-CrQDDdcz.js → sidebar-CA6_ek3f.js} +33 -24
  216. package/dist/sidebar-CLmIjgNd.cjs +1136 -0
  217. package/dist/{sidebar-BxGXsDAd.cjs → sidebar-CVUGHOS_.cjs} +8 -16
  218. package/dist/{sidebar-BViy8Eeu.js → sidebar-CmvwjnVb.js} +9 -17
  219. package/dist/{sidebar-B6SlKZYN.js → sidebar-CplprZpM.js} +49 -40
  220. package/dist/sidebar-Duermn32.js +1133 -0
  221. package/dist/{sidebar-BbVIQvlP.js → sidebar-Dz7bd3zP.js} +1 -1
  222. package/dist/{sidebar-0ocFLSks.js → sidebar-KIS0C2JH.js} +50 -127
  223. package/dist/sidebar-OTO_up7Z.js +801 -0
  224. package/dist/sidebar-zowjejT2.cjs +800 -0
  225. package/dist/{use-audio-player-nv8ZSGa1.js → use-audio-player-Bkh23vQ3.js} +3 -7
  226. package/dist/{use-audio-player-NKsWyjWu.cjs → use-audio-player-Dn1NR9xN.cjs} +3 -7
  227. package/dist/{xertica-assistant-dyP7KHM5.cjs → xertica-assistant-B1IaHXnB.cjs} +388 -529
  228. package/dist/{xertica-assistant-ciJaWqm1.js → xertica-assistant-BMqdyRVi.js} +10 -28
  229. package/dist/{xertica-assistant-V_IdW4WF.cjs → xertica-assistant-Bj3vBCq_.cjs} +9 -27
  230. package/dist/{xertica-assistant-yX1CFBBo.js → xertica-assistant-DPsESB6t.js} +390 -531
  231. package/dist/{CodeBlock-7TTgmdGG.cjs → xertica-assistant-Qp3ydksa.cjs} +51 -263
  232. package/dist/{CodeBlock-BeSt1h5P.js → xertica-assistant-gnCJdcZY.js} +7 -219
  233. package/dist/xertica-ui.css +2 -2
  234. package/docs/architecture-improvements.md +456 -456
  235. package/docs/architecture.md +312 -312
  236. package/docs/components/assistant.md +428 -428
  237. package/docs/components/branding.md +252 -252
  238. package/docs/components/card-patterns.md +447 -447
  239. package/docs/components/error-boundary.md +201 -201
  240. package/docs/components/hooks.md +432 -432
  241. package/docs/components/language-selector.md +176 -176
  242. package/docs/components/pages.md +323 -323
  243. package/docs/components/sidebar.md +331 -331
  244. package/docs/components/stats-card.md +138 -138
  245. package/docs/doc-audit.md +244 -244
  246. package/docs/getting-started.md +616 -616
  247. package/docs/guidelines.md +330 -330
  248. package/docs/i18n.md +480 -480
  249. package/docs/installation.md +268 -268
  250. package/docs/llms.md +295 -295
  251. package/docs/state-management.md +289 -289
  252. package/guidelines/Guidelines.md +409 -409
  253. package/llms-compact.txt +1 -1
  254. package/llms-full.txt +10688 -10688
  255. package/llms.txt +1 -1
  256. package/package.json +1 -1
  257. package/styles/xertica/base.css +90 -90
  258. package/styles/xertica/tokens.css +240 -240
  259. package/templates/.prettierignore +4 -4
  260. package/templates/.prettierrc +10 -10
  261. package/templates/CLAUDE.md +180 -180
  262. package/templates/package.json +2 -2
  263. package/templates/src/app/App.tsx +46 -46
  264. package/templates/src/app/components/AuthGuard.tsx +131 -131
  265. package/templates/src/features/assistant/data/mock.ts +75 -75
  266. package/templates/src/features/assistant/hooks/useAssistantConfig.ts +20 -20
  267. package/templates/src/features/assistant/index.ts +5 -5
  268. package/templates/src/features/auth/ui/ForgotPasswordContent.tsx +70 -70
  269. package/templates/src/features/auth/ui/LoginContent.tsx +92 -92
  270. package/templates/src/features/auth/ui/ResetPasswordContent.tsx +183 -183
  271. package/templates/src/features/auth/ui/SocialLoginButtons.tsx +78 -78
  272. package/templates/src/features/auth/ui/VerifyEmailContent.tsx +80 -80
  273. package/templates/src/features/home/data/mock.ts +41 -41
  274. package/templates/src/features/home/hooks/useFeatureCards.ts +20 -20
  275. package/templates/src/features/home/index.ts +11 -11
  276. package/templates/src/features/home/ui/HomeContent.tsx +117 -117
  277. package/templates/src/features/template/ui/CrudTemplate.tsx +112 -112
  278. package/templates/src/features/template/ui/DashboardTemplate.tsx +110 -110
  279. package/templates/src/features/template/ui/FormTemplate.tsx +117 -117
  280. package/templates/src/features/template/ui/LoginTemplate.tsx +59 -59
  281. package/templates/src/features/template/ui/TemplateContent.tsx +1322 -1322
  282. package/templates/src/i18n.ts +124 -124
  283. package/templates/src/locales/en/common.json +21 -21
  284. package/templates/src/locales/en/components/activityCard.json +10 -10
  285. package/templates/src/locales/en/components/assistant.json +119 -119
  286. package/templates/src/locales/en/components/media.json +29 -29
  287. package/templates/src/locales/en/components/notificationCard.json +5 -5
  288. package/templates/src/locales/en/components/profileCard.json +8 -8
  289. package/templates/src/locales/en/components/projectCard.json +10 -10
  290. package/templates/src/locales/en/components/sidebar.json +14 -14
  291. package/templates/src/locales/en/components/stats.json +8 -8
  292. package/templates/src/locales/en/components/team.json +14 -14
  293. package/templates/src/locales/en/errors.json +9 -9
  294. package/templates/src/locales/en/languageSelector.json +7 -7
  295. package/templates/src/locales/en/nav.json +6 -6
  296. package/templates/src/locales/en/pages/crudTemplate.json +25 -25
  297. package/templates/src/locales/en/pages/dashboardTemplate.json +20 -20
  298. package/templates/src/locales/en/pages/forgotPassword.json +10 -10
  299. package/templates/src/locales/en/pages/formTemplate.json +16 -16
  300. package/templates/src/locales/en/pages/home.json +7 -7
  301. package/templates/src/locales/en/pages/login.json +15 -15
  302. package/templates/src/locales/en/pages/loginTemplate.json +9 -9
  303. package/templates/src/locales/en/pages/resetPassword.json +18 -18
  304. package/templates/src/locales/en/pages/templates.json +317 -317
  305. package/templates/src/locales/en/pages/verifyEmail.json +12 -12
  306. package/templates/src/locales/en/themeToggle.json +6 -6
  307. package/templates/src/locales/es/common.json +21 -21
  308. package/templates/src/locales/es/components/activityCard.json +10 -10
  309. package/templates/src/locales/es/components/assistant.json +119 -119
  310. package/templates/src/locales/es/components/media.json +29 -29
  311. package/templates/src/locales/es/components/notificationCard.json +5 -5
  312. package/templates/src/locales/es/components/profileCard.json +8 -8
  313. package/templates/src/locales/es/components/projectCard.json +10 -10
  314. package/templates/src/locales/es/components/sidebar.json +14 -14
  315. package/templates/src/locales/es/components/stats.json +8 -8
  316. package/templates/src/locales/es/components/team.json +14 -14
  317. package/templates/src/locales/es/errors.json +9 -9
  318. package/templates/src/locales/es/languageSelector.json +7 -7
  319. package/templates/src/locales/es/nav.json +6 -6
  320. package/templates/src/locales/es/pages/crudTemplate.json +25 -25
  321. package/templates/src/locales/es/pages/dashboardTemplate.json +20 -20
  322. package/templates/src/locales/es/pages/forgotPassword.json +10 -10
  323. package/templates/src/locales/es/pages/formTemplate.json +16 -16
  324. package/templates/src/locales/es/pages/home.json +7 -7
  325. package/templates/src/locales/es/pages/login.json +15 -15
  326. package/templates/src/locales/es/pages/loginTemplate.json +9 -9
  327. package/templates/src/locales/es/pages/resetPassword.json +18 -18
  328. package/templates/src/locales/es/pages/templates.json +317 -317
  329. package/templates/src/locales/es/pages/verifyEmail.json +12 -12
  330. package/templates/src/locales/es/themeToggle.json +6 -6
  331. package/templates/src/locales/pt-BR/common.json +21 -21
  332. package/templates/src/locales/pt-BR/components/activityCard.json +10 -10
  333. package/templates/src/locales/pt-BR/components/assistant.json +119 -119
  334. package/templates/src/locales/pt-BR/components/media.json +29 -29
  335. package/templates/src/locales/pt-BR/components/notificationCard.json +5 -5
  336. package/templates/src/locales/pt-BR/components/profileCard.json +8 -8
  337. package/templates/src/locales/pt-BR/components/projectCard.json +10 -10
  338. package/templates/src/locales/pt-BR/components/sidebar.json +14 -14
  339. package/templates/src/locales/pt-BR/components/stats.json +8 -8
  340. package/templates/src/locales/pt-BR/components/team.json +14 -14
  341. package/templates/src/locales/pt-BR/errors.json +9 -9
  342. package/templates/src/locales/pt-BR/languageSelector.json +7 -7
  343. package/templates/src/locales/pt-BR/nav.json +6 -6
  344. package/templates/src/locales/pt-BR/pages/crudTemplate.json +25 -25
  345. package/templates/src/locales/pt-BR/pages/dashboardTemplate.json +20 -20
  346. package/templates/src/locales/pt-BR/pages/forgotPassword.json +10 -10
  347. package/templates/src/locales/pt-BR/pages/formTemplate.json +16 -16
  348. package/templates/src/locales/pt-BR/pages/home.json +7 -7
  349. package/templates/src/locales/pt-BR/pages/login.json +15 -15
  350. package/templates/src/locales/pt-BR/pages/loginTemplate.json +9 -9
  351. package/templates/src/locales/pt-BR/pages/resetPassword.json +18 -18
  352. package/templates/src/locales/pt-BR/pages/templates.json +317 -317
  353. package/templates/src/locales/pt-BR/pages/verifyEmail.json +12 -12
  354. package/templates/src/locales/pt-BR/themeToggle.json +6 -6
  355. package/templates/src/pages/AssistantPage.tsx +470 -470
  356. package/templates/src/pages/HomePage.tsx +53 -53
  357. package/templates/src/shared/error-boundary.tsx +150 -150
  358. package/templates/src/shared/error-fallbacks.tsx +222 -222
  359. package/templates/src/styles/xertica/tokens.css +240 -240
  360. package/templates/vite.config.js +20 -20
  361. package/templates/vite.config.ts +55 -55
  362. package/dist/AssistantChart-BKVtGUKF.js +0 -3383
  363. package/dist/AssistantChart-CxGjH7Qk.js +0 -3477
  364. package/dist/AssistantChart-DIpshm3i.js +0 -4784
  365. package/dist/AssistantChart-D_PTeu8P.cjs +0 -3503
  366. package/dist/AssistantChart-WeycT5Pd.cjs +0 -3551
  367. package/dist/AssistantChart-zjsy2GaZ.cjs +0 -4810
  368. package/dist/AudioPlayer-B1lt5cPl.cjs +0 -989
  369. package/dist/AudioPlayer-BZ7bibzU.cjs +0 -982
  370. package/dist/AudioPlayer-BpRPS4-1.cjs +0 -1277
  371. package/dist/AudioPlayer-C12BjQBV.cjs +0 -997
  372. package/dist/AudioPlayer-CFeV8t-5.cjs +0 -936
  373. package/dist/AudioPlayer-Coly3q5R.js +0 -1278
  374. package/dist/AudioPlayer-CySJIyvL.js +0 -937
  375. package/dist/AudioPlayer-DMcG_c7L.js +0 -990
  376. package/dist/AudioPlayer-DcFKRJE_.js +0 -998
  377. package/dist/AudioPlayer-e8LfNoqO.js +0 -983
  378. package/dist/BrandColorsContext-565dDHd5.js +0 -660
  379. package/dist/BrandColorsContext-BcJbtkqn.cjs +0 -659
  380. package/dist/CodeBlock-BgfYL_rD.cjs +0 -2094
  381. package/dist/CodeBlock-BlcqlA9M.cjs +0 -2094
  382. package/dist/CodeBlock-Bnmeu5ez.cjs +0 -2094
  383. package/dist/CodeBlock-BtfPlbAI.js +0 -2078
  384. package/dist/CodeBlock-CIySIuYr.js +0 -2078
  385. package/dist/CodeBlock-CuPtUM-7.cjs +0 -2094
  386. package/dist/CodeBlock-D6ffWXgc.js +0 -2078
  387. package/dist/CodeBlock-D8dcwbit.cjs +0 -2094
  388. package/dist/CodeBlock-DMZrFnlw.cjs +0 -2094
  389. package/dist/CodeBlock-DlBehYN8.js +0 -2078
  390. package/dist/CodeBlock-DnYNI8rQ.js +0 -2078
  391. package/dist/CodeBlock-DvKWbSnE.cjs +0 -2094
  392. package/dist/CodeBlock-DwMCfkFY.js +0 -2078
  393. package/dist/CodeBlock-Dy6CNYyj.js +0 -2078
  394. package/dist/CodeBlock-U1pPOQI7.cjs +0 -2094
  395. package/dist/CodeBlock-f_GpNhEB.js +0 -2078
  396. package/dist/CodeBlock-oB6u8nI1.js +0 -2078
  397. package/dist/CodeBlock-tZC31B73.cjs +0 -2094
  398. package/dist/FeatureCard-CxC-7C-C.cjs +0 -300
  399. package/dist/FeatureCard-DbHWCb4E.js +0 -301
  400. package/dist/ImageWithFallback-CGtidP6B.cjs +0 -4542
  401. package/dist/ImageWithFallback-lsg3pdFg.js +0 -4508
  402. package/dist/LanguageSelector-B5YfbHra.js +0 -231
  403. package/dist/LanguageSelector-D6uacAIM.cjs +0 -230
  404. package/dist/LayoutContext-B45-e9DI.cjs +0 -93
  405. package/dist/LayoutContext-BAql6ZRY.js +0 -97
  406. package/dist/LayoutContext-Bav3UMEA.js +0 -94
  407. package/dist/LayoutContext-BvK-ggDa.cjs +0 -96
  408. package/dist/ThemeContext-BoH4NLfN.js +0 -734
  409. package/dist/ThemeContext-r69W20Xg.cjs +0 -733
  410. package/dist/VerifyEmailPage-COiyNl1y.js +0 -2825
  411. package/dist/VerifyEmailPage-CqKsR2v8.js +0 -2827
  412. package/dist/VerifyEmailPage-DjQKRlUS.cjs +0 -2824
  413. package/dist/VerifyEmailPage-s-1X3LDJ.cjs +0 -2826
  414. package/dist/XerticaOrbe-KL1RBHzw.cjs +0 -1354
  415. package/dist/XerticaOrbe-zwS1p2a8.js +0 -1355
  416. package/dist/XerticaProvider-6btlAlzc.js +0 -17
  417. package/dist/XerticaProvider-BNoNOxQ5.cjs +0 -16
  418. package/dist/XerticaProvider-BlY2limY.cjs +0 -38
  419. package/dist/XerticaProvider-DDuiIcKo.js +0 -39
  420. package/dist/XerticaProvider-cI9hSs27.cjs +0 -38
  421. package/dist/XerticaProvider-hSwhNQex.js +0 -39
  422. package/dist/alert-dialog-BOje--vD.js +0 -847
  423. package/dist/alert-dialog-BtEuQqrg.cjs +0 -870
  424. package/dist/breadcrumb-CqJ7bHY5.js +0 -161
  425. package/dist/breadcrumb-m9Hb2_XN.cjs +0 -177
  426. package/dist/components/assistant/xertica-assistant/hooks/index.d.ts +0 -6
  427. package/dist/components/assistant/xertica-assistant/hooks/use-assistant-conversations.d.ts +0 -21
  428. package/dist/components/assistant/xertica-assistant/hooks/use-assistant-messages.d.ts +0 -49
  429. package/dist/components/assistant/xertica-assistant/hooks/use-assistant-suggestions.d.ts +0 -16
  430. package/dist/components/blocks/audio-player/AudioPlayer.d.ts +0 -35
  431. package/dist/components/blocks/audio-player/index.d.ts +0 -1
  432. package/dist/components/blocks/document-editor/DocumentEditor.d.ts +0 -26
  433. package/dist/components/blocks/document-editor/index.d.ts +0 -1
  434. package/dist/components/blocks/podcast-player/PodcastPlayer.d.ts +0 -41
  435. package/dist/components/blocks/podcast-player/index.d.ts +0 -1
  436. package/dist/components/ui/chart/parts/chart-dashboard.d.ts +0 -113
  437. package/dist/components/ui/chart/parts/chart-metric.d.ts +0 -118
  438. package/dist/components/ui/chart/parts/chart-primitives.d.ts +0 -101
  439. package/dist/components/ui/chart/parts/chart-shared.d.ts +0 -20
  440. package/dist/components/ui/chart/parts/chart-utils.d.ts +0 -12
  441. package/dist/components/ui/chart/parts/index.d.ts +0 -5
  442. package/dist/dropdown-menu-BDB5CmQs.cjs +0 -247
  443. package/dist/dropdown-menu-DQidbKBD.js +0 -231
  444. package/dist/google-maps-loader-BFWp6VPd.js +0 -287
  445. package/dist/google-maps-loader-BKcdgFbu.cjs +0 -312
  446. package/dist/google-maps-loader-CumCNXeG.js +0 -312
  447. package/dist/google-maps-loader-eS3uQ5TA.cjs +0 -287
  448. package/dist/header-Cgy6vYPk.cjs +0 -731
  449. package/dist/header-DRlT4jgI.js +0 -715
  450. package/dist/header-Dux00SI4.cjs +0 -731
  451. package/dist/header-EkGKXPsD.js +0 -715
  452. package/dist/header-WfEywpyc.cjs +0 -731
  453. package/dist/header-tifNQn2U.js +0 -715
  454. package/dist/index-BhapVLVj.js +0 -8
  455. package/dist/index-D6fxYEY8.cjs +0 -7
  456. package/dist/index-DAIp0_HK.js +0 -8
  457. package/dist/index-DW5tYe26.js +0 -8
  458. package/dist/index-GA__GvnG.cjs +0 -7
  459. package/dist/input-2R4loU86.js +0 -127
  460. package/dist/input-DWANSKGb.cjs +0 -145
  461. package/dist/progress-DPtzoVV8.js +0 -175
  462. package/dist/progress-EeaoqqUs.cjs +0 -191
  463. package/dist/rich-text-editor-0mraWT5y.cjs +0 -2376
  464. package/dist/rich-text-editor-B-IkcPD0.js +0 -2874
  465. package/dist/rich-text-editor-B6jMRLzk.cjs +0 -1939
  466. package/dist/rich-text-editor-B8_oYcIR.js +0 -1730
  467. package/dist/rich-text-editor-B9UbSXNb.js +0 -1203
  468. package/dist/rich-text-editor-BYuRBNBU.js +0 -2373
  469. package/dist/rich-text-editor-Bb9pySTs.cjs +0 -2374
  470. package/dist/rich-text-editor-BcL6L3cm.cjs +0 -2374
  471. package/dist/rich-text-editor-BoVZYtTs.cjs +0 -2391
  472. package/dist/rich-text-editor-Bp3zQqMC.js +0 -2954
  473. package/dist/rich-text-editor-CMgSN_w2.js +0 -1189
  474. package/dist/rich-text-editor-CPV1lEPH.cjs +0 -1748
  475. package/dist/rich-text-editor-CeucBdIv.cjs +0 -2971
  476. package/dist/rich-text-editor-CoKqbCtu.cjs +0 -1799
  477. package/dist/rich-text-editor-Cw56T_mB.js +0 -2356
  478. package/dist/rich-text-editor-Cyt8qs2b.js +0 -1921
  479. package/dist/rich-text-editor-D6H84OcX.cjs +0 -1220
  480. package/dist/rich-text-editor-D76gD-QI.js +0 -2328
  481. package/dist/rich-text-editor-DKkokOnA.js +0 -1781
  482. package/dist/rich-text-editor-DNsdpN64.cjs +0 -2359
  483. package/dist/rich-text-editor-DfG8bCyY.js +0 -2358
  484. package/dist/rich-text-editor-Dxjw31Z4.js +0 -2341
  485. package/dist/rich-text-editor-DzP0Epmb.js +0 -2356
  486. package/dist/rich-text-editor-bRkNoeZY.cjs +0 -2891
  487. package/dist/rich-text-editor-lyYE2ZG5.cjs +0 -1207
  488. package/dist/rich-text-editor-skplNlBM.cjs +0 -2345
  489. package/dist/select-Bkbr0f-Z.cjs +0 -162
  490. package/dist/select-CvIVdX2n.js +0 -145
  491. package/dist/sidebar-CK_0ZQHj.cjs +0 -803
  492. package/dist/sidebar-CUuOvYhK.js +0 -787
  493. package/dist/sidebar-DQj1z3jG.cjs +0 -758
  494. package/dist/sidebar-Djn5syhi.cjs +0 -786
  495. package/dist/sidebar-LluMXfam.js +0 -759
  496. package/dist/sidebar-_rT7rBMk.js +0 -787
  497. package/dist/slider-Bc5Hd0y1.js +0 -56
  498. package/dist/slider-N7hFFj6X.cjs +0 -73
  499. package/dist/tooltip-Ded96neP.cjs +0 -137
  500. package/dist/tooltip-HDOoD2-0.js +0 -120
  501. package/dist/use-audio-player-B31J-aqh.cjs +0 -187
  502. package/dist/use-audio-player-BkmEmj8Q.js +0 -185
  503. package/dist/use-audio-player-CLFTWFW1.cjs +0 -184
  504. package/dist/use-audio-player-CLLn00I6.js +0 -188
  505. package/dist/use-file-upload-BcjEo2S5.js +0 -404
  506. package/dist/use-file-upload-CRJR68Tj.cjs +0 -403
  507. package/dist/use-mobile-B0hNy_Y6.cjs +0 -4303
  508. package/dist/use-mobile-BXuYROXM.js +0 -4202
  509. package/dist/use-mobile-Bbd51ASU.cjs +0 -4392
  510. package/dist/use-mobile-Bk6CX-TC.js +0 -4359
  511. package/dist/use-mobile-BvYdisLP.js +0 -4202
  512. package/dist/use-mobile-BzuxjzNX.cjs +0 -4392
  513. package/dist/use-mobile-CG2-SdXV.cjs +0 -4235
  514. package/dist/use-mobile-CKb5pqTs.js +0 -4269
  515. package/dist/use-mobile-CYuAuGDl.js +0 -4202
  516. package/dist/use-mobile-CaENcqm-.js +0 -4508
  517. package/dist/use-mobile-CbrYgJGJ.js +0 -4203
  518. package/dist/use-mobile-Cd4xPrKq.cjs +0 -46
  519. package/dist/use-mobile-DMOvImGQ.cjs +0 -4542
  520. package/dist/use-mobile-DRB3BQgD.cjs +0 -4235
  521. package/dist/use-mobile-DZvv7QMR.js +0 -4359
  522. package/dist/use-mobile-DdI_TXam.cjs +0 -4235
  523. package/dist/use-mobile-DlceKf8a.js +0 -4359
  524. package/dist/use-mobile-DsOnow1o.cjs +0 -4236
  525. package/dist/use-mobile-Kcj6jSnK.cjs +0 -4392
  526. package/dist/use-mobile-bnKcua_i.js +0 -4202
  527. package/dist/use-mobile-j4w2Jrf1.js +0 -30
  528. package/dist/use-mobile-ncXBeE2z.cjs +0 -4235
  529. package/dist/use-rich-text-editor-DjiddBGv.js +0 -282
  530. package/dist/use-rich-text-editor-lpeswbCs.cjs +0 -281
  531. package/dist/xertica-assistant-BdiZag0h.js +0 -2187
  532. package/dist/xertica-assistant-CrgTb6Hs.cjs +0 -2155
  533. package/dist/xertica-assistant-DCsnQyi5.js +0 -2156
  534. package/dist/xertica-assistant-DUBpmEgo.cjs +0 -2186
  535. package/dist/{rich-text-editor-DgF8s7xW.js → rich-text-editor-BmsjY03B.js} +26 -26
  536. package/dist/{rich-text-editor-mWoaSCE4.cjs → rich-text-editor-GS2kpTAK.cjs} +26 -26
@@ -1,2239 +1,2245 @@
1
- 'use client';
2
-
3
- import * as React from 'react';
4
- import * as RechartsPrimitive from 'recharts';
5
-
6
- import { cn } from '../../shared/utils';
7
- import { Alert, AlertDescription, AlertTitle } from '../alert';
8
- import { Button } from '../button';
9
- import {
10
- Card,
11
- CardAction,
12
- CardContent,
13
- CardDescription,
14
- CardFooter,
15
- CardHeader,
16
- CardTitle,
17
- } from '../card';
18
- import { Empty, EmptyAction, EmptyDescription, EmptyIcon, EmptyTitle } from '../empty';
19
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
20
- import { Skeleton } from '../skeleton';
21
- import { BarChart3, RefreshCw, WifiOff } from 'lucide-react';
22
-
23
- // Format: { THEME_NAME: CSS_SELECTOR }
24
- const THEMES = { light: '', dark: '.dark' } as const;
25
-
26
- export type ChartConfig = {
27
- [k in string]: {
28
- label?: React.ReactNode;
29
- icon?: React.ComponentType;
30
- } & (
31
- | { color?: string; theme?: never }
32
- | { color?: never; theme: Record<keyof typeof THEMES, string> }
33
- );
34
- };
35
-
36
- type ChartContextProps = {
37
- config: ChartConfig;
38
- };
39
-
40
- export type DashboardChartDatum = Record<string, string | number | null | undefined>;
41
-
42
- export type DashboardChartSeries = {
43
- key: string;
44
- label?: React.ReactNode;
45
- type?: 'bar' | 'line' | 'area';
46
- stackId?: string;
47
- yAxisId?: string;
48
- };
49
-
50
- export type DashboardChartColors = string[] | Record<string, string>;
51
-
52
- export type ChartPeriod = {
53
- value: string;
54
- label: string;
55
- };
56
-
57
- export type ChartBarSize = 'sm' | 'md' | 'lg' | 'xl' | number;
58
-
59
- /** Curve interpolation type for line and area charts */
60
- export type ChartCurveType =
61
- | 'monotone'
62
- | 'linear'
63
- | 'step'
64
- | 'stepBefore'
65
- | 'stepAfter'
66
- | 'natural'
67
- | 'basis';
68
-
69
- type ChartValueFormatter = (value: number | string) => string;
70
-
71
- export type ChartErrorState = boolean | string | Error | React.ReactNode;
72
-
73
- export type ChartStateProps = {
74
- isLoading?: boolean;
75
- error?: ChartErrorState;
76
- onRetry?: () => void;
77
- retryLabel?: React.ReactNode;
78
- emptyTitle?: React.ReactNode;
79
- emptyDescription?: React.ReactNode;
80
- errorTitle?: React.ReactNode;
81
- errorDescription?: React.ReactNode;
82
- loadingLabel?: React.ReactNode;
83
- stateClassName?: string;
84
- };
85
-
86
- const defaultPeriods: ChartPeriod[] = [
87
- { value: '7d', label: '7 days' },
88
- { value: '30d', label: '30 days' },
89
- { value: '90d', label: '90 days' },
90
- ];
91
-
92
- const defaultChartColors = [
93
- 'var(--chart-1)',
94
- 'var(--chart-2)',
95
- 'var(--chart-3)',
96
- 'var(--chart-4)',
97
- 'var(--chart-5)',
98
- 'var(--chart-6)',
99
- 'var(--chart-7)',
100
- 'var(--chart-8)',
101
- ];
102
-
103
- const chartBarSizes: Record<Exclude<ChartBarSize, number>, number> = {
104
- sm: 8,
105
- md: 14,
106
- lg: 22,
107
- xl: 32,
108
- };
109
-
110
- const ChartContext = React.createContext<ChartContextProps | null>(null);
111
-
112
- export function useChart() {
113
- const context = React.useContext(ChartContext);
114
-
115
- if (!context) {
116
- throw new Error('useChart must be used within a <ChartContainer />');
117
- }
118
-
119
- return context;
120
- }
121
-
122
- /**
123
- * Root container for Recharts-based charts with theme-aware color injection.
124
- *
125
- * @description
126
- * Wraps Recharts' `ResponsiveContainer` and injects CSS custom properties
127
- * (`--color-*`) from a `ChartConfig` object, enabling full dark-mode
128
- * support without hard-coded hex values in chart elements.
129
- *
130
- * @ai-rules
131
- * 1. NEVER pass hex colors directly to Recharts elements (e.g., `fill="#4F46E5"`).
132
- * Always use `fill="var(--color-keyName)"` referencing the injected CSS variables.
133
- * 2. This wrapper is REQUIRED to use `ChartTooltipContent` and `ChartLegendContent`.
134
- * 3. Set height via `className="h-[300px]"` on `ChartContainer`, not on Recharts components.
135
- * 4. Do NOT add another `<ResponsiveContainer>` — it is already included inside.
136
- */
137
- function ChartContainer({
138
- id,
139
- className,
140
- children,
141
- config,
142
- ...props
143
- }: React.ComponentProps<'div'> & {
144
- config: ChartConfig;
145
- children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
146
- }) {
147
- const uniqueId = React.useId();
148
- const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
149
-
150
- return (
151
- <ChartContext.Provider value={{ config }}>
152
- <div
153
- data-slot="chart"
154
- data-chart={chartId}
155
- className={cn(
156
- "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border relative h-[300px] min-h-[200px] w-full min-w-0 overflow-hidden text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
157
- className
158
- )}
159
- {...props}
160
- >
161
- <ChartStyle id={chartId} config={config} />
162
- <RechartsPrimitive.ResponsiveContainer width="100%" height="100%">
163
- {children}
164
- </RechartsPrimitive.ResponsiveContainer>
165
- </div>
166
- </ChartContext.Provider>
167
- );
168
- }
169
-
170
- const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
171
- const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
172
-
173
- if (!colorConfig.length) {
174
- return null;
175
- }
176
-
177
- return (
178
- <style
179
- dangerouslySetInnerHTML={{
180
- __html: Object.entries(THEMES)
181
- .map(
182
- ([theme, prefix]) => `
183
- ${prefix} [data-chart=${id}] {
184
- ${colorConfig
185
- .map(([key, itemConfig]) => {
186
- const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
187
- return color ? ` --color-${key}: ${color};` : null;
188
- })
189
- .join('\n')}
190
- }
191
- `
192
- )
193
- .join('\n'),
194
- }}
195
- />
196
- );
197
- };
198
-
199
- const ChartTooltip = RechartsPrimitive.Tooltip;
200
-
201
- function ChartTooltipContent({
202
- active,
203
- payload,
204
- className,
205
- indicator = 'dot',
206
- hideLabel = false,
207
- hideIndicator = false,
208
- label,
209
- labelFormatter,
210
- labelClassName,
211
- formatter,
212
- color,
213
- nameKey,
214
- labelKey,
215
- }: React.ComponentProps<'div'> & {
216
- active?: boolean;
217
- payload?: RechartsPrimitive.TooltipPayload;
218
- label?: React.ReactNode;
219
- labelFormatter?: (label: React.ReactNode, payload: RechartsPrimitive.TooltipPayload) => React.ReactNode;
220
- labelClassName?: string;
221
- hideLabel?: boolean;
222
- hideIndicator?: boolean;
223
- indicator?: 'line' | 'dot' | 'dashed';
224
- nameKey?: string;
225
- labelKey?: string;
226
- color?: string;
227
- formatter?: RechartsPrimitive.DefaultTooltipContentProps['formatter'];
228
- }) {
229
- const { config } = useChart();
230
-
231
- const tooltipLabel = React.useMemo(() => {
232
- if (hideLabel || !payload?.length) {
233
- return null;
234
- }
235
-
236
- const [item] = payload;
237
- const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
238
- const itemConfig = getPayloadConfigFromPayload(config, item, key);
239
- const value =
240
- !labelKey && typeof label === 'string'
241
- ? config[label as keyof typeof config]?.label || label
242
- : itemConfig?.label;
243
-
244
- if (labelFormatter) {
245
- return (
246
- <div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>
247
- );
248
- }
249
-
250
- if (!value) {
251
- return null;
252
- }
253
-
254
- return <div className={cn('font-medium', labelClassName)}>{value}</div>;
255
- }, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
256
-
257
- if (!active || !payload?.length) {
258
- return null;
259
- }
260
-
261
- const nestLabel = payload.length === 1 && indicator !== 'dot';
262
-
263
- return (
264
- <div
265
- className={cn(
266
- 'border-border/50 bg-background/95 backdrop-blur-sm grid min-w-[8rem] items-start gap-1.5 rounded-xl border px-3 py-2 text-xs shadow-xl',
267
- className
268
- )}
269
- >
270
- {!nestLabel ? tooltipLabel : null}
271
- <div className="grid gap-1.5">
272
- {payload.map((item, index) => {
273
- const key = `${nameKey || item.name || item.dataKey || 'value'}`;
274
- const itemConfig = getPayloadConfigFromPayload(config, item, key);
275
- const indicatorColor = color || item.payload?.fill || item.color;
276
-
277
- return (
278
- <div
279
- key={`${item.dataKey ?? index}`}
280
- className={cn(
281
- '[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
282
- indicator === 'dot' && 'items-center'
283
- )}
284
- >
285
- {formatter && item?.value !== undefined && item.name ? (
286
- formatter(item.value, item.name, item, index, item.payload)
287
- ) : (
288
- <>
289
- {itemConfig?.icon ? (
290
- <itemConfig.icon />
291
- ) : (
292
- !hideIndicator && (
293
- <div
294
- className={cn(
295
- 'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
296
- {
297
- 'h-2.5 w-2.5': indicator === 'dot',
298
- 'w-1': indicator === 'line',
299
- 'w-0 border-[1.5px] border-dashed bg-transparent':
300
- indicator === 'dashed',
301
- 'my-0.5': nestLabel && indicator === 'dashed',
302
- }
303
- )}
304
- style={
305
- {
306
- '--color-bg': indicatorColor,
307
- '--color-border': indicatorColor,
308
- } as React.CSSProperties
309
- }
310
- />
311
- )
312
- )}
313
- <div
314
- className={cn(
315
- 'flex flex-1 justify-between leading-none',
316
- nestLabel ? 'items-end' : 'items-center'
317
- )}
318
- >
319
- <div className="grid gap-1.5">
320
- {nestLabel ? tooltipLabel : null}
321
- <span className="text-muted-foreground">
322
- {itemConfig?.label || item.name}
323
- </span>
324
- </div>
325
- {item.value && (
326
- <span className="text-foreground font-mono font-semibold tabular-nums">
327
- {item.value.toLocaleString()}
328
- </span>
329
- )}
330
- </div>
331
- </>
332
- )}
333
- </div>
334
- );
335
- })}
336
- </div>
337
- </div>
338
- );
339
- }
340
-
341
- const ChartLegend = RechartsPrimitive.Legend;
342
-
343
- function ChartLegendContent({
344
- className,
345
- hideIcon = false,
346
- payload,
347
- verticalAlign = 'bottom',
348
- nameKey,
349
- }: React.ComponentProps<'div'> & {
350
- payload?: RechartsPrimitive.LegendPayload[];
351
- verticalAlign?: 'top' | 'middle' | 'bottom';
352
- hideIcon?: boolean;
353
- nameKey?: string;
354
- }) {
355
- const { config } = useChart();
356
-
357
- if (!payload?.length) {
358
- return null;
359
- }
360
-
361
- return (
362
- <div
363
- className={cn(
364
- 'flex items-center justify-center gap-4',
365
- verticalAlign === 'top' ? 'pb-3' : 'pt-3',
366
- className
367
- )}
368
- >
369
- {payload.map(item => {
370
- const key = `${nameKey ? item.value : item.dataKey || item.value || 'value'}`;
371
- const itemConfig = getPayloadConfigFromPayload(config, item, key);
372
-
373
- return (
374
- <div
375
- key={item.value}
376
- className={cn(
377
- '[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3'
378
- )}
379
- >
380
- {itemConfig?.icon && !hideIcon ? (
381
- <itemConfig.icon />
382
- ) : (
383
- <div
384
- className="h-2 w-2 shrink-0 rounded-full bg-(--color-bg)"
385
- style={
386
- {
387
- '--color-bg': item.color || `var(--color-${key})`,
388
- } as React.CSSProperties
389
- }
390
- />
391
- )}
392
- <span className="text-muted-foreground text-xs">{itemConfig?.label || item.value}</span>
393
- </div>
394
- );
395
- })}
396
- </div>
397
- );
398
- }
399
-
400
- // Helper to extract item config from a payload.
401
- function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
402
- if (typeof payload !== 'object' || payload === null) {
403
- return undefined;
404
- }
405
-
406
- const payloadPayload =
407
- 'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
408
- ? payload.payload
409
- : undefined;
410
-
411
- let configLabelKey: string = key;
412
-
413
- if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
414
- configLabelKey = payload[key as keyof typeof payload] as string;
415
- } else if (
416
- payloadPayload &&
417
- key in payloadPayload &&
418
- typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
419
- ) {
420
- configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
421
- }
422
-
423
- return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
424
- }
425
-
426
- function getChartSeries(
427
- config: ChartConfig,
428
- series?: DashboardChartSeries[]
429
- ): DashboardChartSeries[] {
430
- if (series?.length) {
431
- return series;
432
- }
433
-
434
- return Object.entries(config).map(([key, item]) => ({
435
- key,
436
- label: item.label,
437
- }));
438
- }
439
-
440
- function getSeriesColor(key: string, index: number, colors?: DashboardChartColors) {
441
- if (Array.isArray(colors)) {
442
- return colors[index] || defaultChartColors[index % defaultChartColors.length];
443
- }
444
-
445
- return colors?.[key] || defaultChartColors[index % defaultChartColors.length];
446
- }
447
-
448
- function getChartConfigWithColors(
449
- config: ChartConfig,
450
- keys: string[],
451
- colors?: DashboardChartColors
452
- ): ChartConfig {
453
- return keys.reduce<ChartConfig>((nextConfig, key, index) => {
454
- const item = config[key];
455
-
456
- if (item?.theme && !colors) {
457
- nextConfig[key] = item;
458
- return nextConfig;
459
- }
460
-
461
- nextConfig[key] = {
462
- label: item?.label || key,
463
- icon: item?.icon,
464
- color: colors
465
- ? getSeriesColor(key, index, colors)
466
- : item?.color || getSeriesColor(key, index),
467
- };
468
-
469
- return nextConfig;
470
- }, {});
471
- }
472
-
473
- /** Build a ChartConfig from a list of keys (no pre-existing config needed). */
474
- function buildChartConfig(keys: string[], colors?: DashboardChartColors): ChartConfig {
475
- return keys.reduce<ChartConfig>((cfg, key, index) => {
476
- cfg[key] = {
477
- label: key,
478
- color: getSeriesColor(key, index, colors),
479
- };
480
- return cfg;
481
- }, {});
482
- }
483
-
484
- function formatTick(value: number | string) {
485
- if (typeof value !== 'number') {
486
- return value;
487
- }
488
-
489
- return Intl.NumberFormat('en', {
490
- notation: 'compact',
491
- maximumFractionDigits: 1,
492
- }).format(value);
493
- }
494
-
495
- function defaultFilterData(data: DashboardChartDatum[], period: string) {
496
- const match = period.match(/^(\d+)/);
497
-
498
- if (!match) {
499
- return data;
500
- }
501
-
502
- const limit = Number(match[1]);
503
- return data.slice(Math.max(data.length - limit, 0));
504
- }
505
-
506
- function getErrorDescription(error: ChartErrorState) {
507
- if (typeof error === 'string') {
508
- return error;
509
- }
510
-
511
- if (error instanceof Error) {
512
- return error.message;
513
- }
514
-
515
- return undefined;
516
- }
517
-
518
- function getBarSize(barSize: ChartBarSize = 'md') {
519
- return typeof barSize === 'number' ? barSize : chartBarSizes[barSize];
520
- }
521
-
522
- function hasChartData(data: DashboardChartDatum[], series: DashboardChartSeries[]) {
523
- if (!data.length || !series.length) {
524
- return false;
525
- }
526
-
527
- return data.some(item =>
528
- series.some(serie => {
529
- const value = item[serie.key];
530
- return value !== null && value !== undefined && value !== '';
531
- })
532
- );
533
- }
534
-
535
- function hasPieData(data: DashboardChartDatum[], nameKey: string, valueKey: string) {
536
- return data.some(item => {
537
- const name = item[nameKey];
538
- const value = item[valueKey];
539
- return (
540
- name !== null &&
541
- name !== undefined &&
542
- name !== '' &&
543
- value !== null &&
544
- value !== undefined &&
545
- value !== ''
546
- );
547
- });
548
- }
549
-
550
- function ChartState({
551
- type,
552
- className,
553
- error,
554
- onRetry,
555
- retryLabel = 'Try again',
556
- emptyTitle = 'No data available',
557
- emptyDescription = 'There is no data available for this chart yet.',
558
- errorTitle = 'Connection error',
559
- errorDescription,
560
- loadingLabel = 'Loading chart data',
561
- }: ChartStateProps & {
562
- type: 'empty' | 'error' | 'loading';
563
- className?: string;
564
- }) {
565
- if (type === 'loading') {
566
- return (
567
- <div
568
- className={cn(
569
- 'flex min-h-[240px] flex-col justify-end gap-3 rounded-[var(--radius-card)] border border-border p-6',
570
- className
571
- )}
572
- aria-label={typeof loadingLabel === 'string' ? loadingLabel : undefined}
573
- >
574
- <Skeleton className="h-8 w-2/5" />
575
- <Skeleton className="h-14 w-3/5" />
576
- <Skeleton className="h-24 w-4/5" />
577
- <Skeleton className="h-36 w-full" />
578
- </div>
579
- );
580
- }
581
-
582
- if (type === 'error') {
583
- return (
584
- <div
585
- className={cn(
586
- 'flex min-h-[240px] items-center justify-center rounded-[var(--radius-card)] border border-border p-6',
587
- className
588
- )}
589
- >
590
- <Alert variant="destructive" className="max-w-xl">
591
- <AlertTitle>{errorTitle}</AlertTitle>
592
- <AlertDescription>
593
- {errorDescription ||
594
- getErrorDescription(error) ||
595
- 'Unable to load chart data. Check your connection and try again.'}
596
- </AlertDescription>
597
- {onRetry ? (
598
- <div className="mt-3">
599
- <Button size="sm" variant="outline" onClick={onRetry}>
600
- <RefreshCw className="size-4" />
601
- {retryLabel}
602
- </Button>
603
- </div>
604
- ) : null}
605
- </Alert>
606
- </div>
607
- );
608
- }
609
-
610
- return (
611
- <Empty className={cn('min-h-[240px]', className)}>
612
- <EmptyIcon>
613
- <BarChart3 className="size-10 text-muted-foreground" />
614
- </EmptyIcon>
615
- <EmptyTitle>{emptyTitle}</EmptyTitle>
616
- <EmptyDescription>{emptyDescription}</EmptyDescription>
617
- {onRetry ? (
618
- <EmptyAction>
619
- <Button size="sm" variant="outline" onClick={onRetry}>
620
- <WifiOff className="size-4" />
621
- {retryLabel}
622
- </Button>
623
- </EmptyAction>
624
- ) : null}
625
- </Empty>
626
- );
627
- }
628
-
629
- function getChartState(state: ChartStateProps, hasData: boolean, className?: string) {
630
- if (state.isLoading) {
631
- return <ChartState {...state} type="loading" className={className} />;
632
- }
633
-
634
- if (state.error) {
635
- return <ChartState {...state} type="error" className={className} />;
636
- }
637
-
638
- if (!hasData) {
639
- return <ChartState {...state} type="empty" className={className} />;
640
- }
641
-
642
- return null;
643
- }
644
-
645
- export interface ChartCardProps extends Omit<React.ComponentProps<typeof Card>, 'title'> {
646
- title: React.ReactNode;
647
- description?: React.ReactNode;
648
- action?: React.ReactNode;
649
- footer?: React.ReactNode;
650
- contentClassName?: string;
651
- }
652
-
653
- function ChartCard({
654
- title,
655
- description,
656
- action,
657
- footer,
658
- children,
659
- className,
660
- contentClassName,
661
- ...props
662
- }: ChartCardProps) {
663
- return (
664
- <Card className={cn('w-full min-w-0 overflow-hidden', className)} {...props}>
665
- <CardHeader>
666
- <CardTitle>{title}</CardTitle>
667
- {description ? <CardDescription>{description}</CardDescription> : null}
668
- {action ? <CardAction>{action}</CardAction> : null}
669
- </CardHeader>
670
- <CardContent className={contentClassName}>{children}</CardContent>
671
- {footer ? <CardFooter>{footer}</CardFooter> : null}
672
- </Card>
673
- );
674
- }
675
-
676
- // ─── Gradient defs helper ────────────────────────────────────────────────────
677
-
678
- /**
679
- * Renders SVG `<defs>` with linear gradients for each series key.
680
- * Used internally by area charts when `gradientFill` is enabled.
681
- */
682
- function AreaGradientDefs({
683
- seriesKeys,
684
- opacity = 0.3,
685
- }: {
686
- seriesKeys: string[];
687
- opacity?: number;
688
- }) {
689
- return (
690
- <defs>
691
- {seriesKeys.map(key => (
692
- <linearGradient key={key} id={`gradient-${key}`} x1="0" y1="0" x2="0" y2="1">
693
- <stop offset="5%" stopColor={`var(--color-${key})`} stopOpacity={opacity} />
694
- <stop offset="95%" stopColor={`var(--color-${key})`} stopOpacity={0} />
695
- </linearGradient>
696
- ))}
697
- </defs>
698
- );
699
- }
700
-
701
- // ─── DashboardBarChart ────────────────────────────────────────────────────────
702
-
703
- export interface DashboardBarChartProps
704
- extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
705
- data: DashboardChartDatum[];
706
- indexKey?: string;
707
- series?: DashboardChartSeries[];
708
- colors?: DashboardChartColors;
709
- barSize?: ChartBarSize;
710
- stacked?: boolean;
711
- showGrid?: boolean;
712
- showLegend?: boolean;
713
- valueFormatter?: ChartValueFormatter;
714
- }
715
-
716
- function DashboardBarChart({
717
- data,
718
- config,
719
- indexKey = 'name',
720
- series,
721
- colors,
722
- barSize = 'md',
723
- stacked = false,
724
- showGrid = true,
725
- showLegend = true,
726
- valueFormatter = formatTick,
727
- isLoading,
728
- error,
729
- onRetry,
730
- retryLabel,
731
- emptyTitle,
732
- emptyDescription,
733
- errorTitle,
734
- errorDescription,
735
- loadingLabel,
736
- stateClassName,
737
- className,
738
- ...props
739
- }: DashboardBarChartProps) {
740
- const chartSeries = getChartSeries(config, series);
741
- const chartConfig = getChartConfigWithColors(
742
- config,
743
- chartSeries.map(item => item.key),
744
- colors
745
- );
746
- const chartState = getChartState(
747
- {
748
- isLoading,
749
- error,
750
- onRetry,
751
- retryLabel,
752
- emptyTitle,
753
- emptyDescription,
754
- errorTitle,
755
- errorDescription,
756
- loadingLabel,
757
- },
758
- hasChartData(data, chartSeries),
759
- cn('h-[320px] w-full', stateClassName)
760
- );
761
-
762
- const barElements = React.useMemo(() => {
763
- const topOfStack = new Set<string>();
764
- if (stacked) {
765
- const lastByStack = new Map<string, string>();
766
- chartSeries.forEach(s => lastByStack.set(s.stackId || 'total', s.key));
767
- lastByStack.forEach(key => topOfStack.add(key));
768
- }
769
- return chartSeries.map(item => {
770
- const isTop = !stacked || topOfStack.has(item.key);
771
- return (
772
- <RechartsPrimitive.Bar
773
- key={item.key}
774
- dataKey={item.key}
775
- fill={`var(--color-${item.key})`}
776
- radius={isTop ? [4, 4, 0, 0] : [0, 0, 0, 0]}
777
- barSize={getBarSize(barSize)}
778
- stackId={stacked ? item.stackId || 'total' : item.stackId}
779
- isAnimationActive
780
- animationDuration={600}
781
- animationEasing="ease-out"
782
- />
783
- );
784
- });
785
- }, [stacked, chartSeries]);
786
-
787
- if (chartState) {
788
- return chartState;
789
- }
790
-
791
- return (
792
- <ChartContainer config={chartConfig} className={cn('h-[320px] w-full', className)} {...props}>
793
- <RechartsPrimitive.BarChart data={data} accessibilityLayer barGap={4}>
794
- {showGrid ? (
795
- <RechartsPrimitive.CartesianGrid
796
- vertical={false}
797
- strokeDasharray="3 3"
798
- stroke="var(--border)"
799
- strokeOpacity={0.5}
800
- />
801
- ) : null}
802
- <RechartsPrimitive.XAxis
803
- dataKey={indexKey}
804
- tickLine={false}
805
- axisLine={false}
806
- tickMargin={8}
807
- />
808
- <RechartsPrimitive.YAxis
809
- tickLine={false}
810
- axisLine={false}
811
- tickMargin={8}
812
- tickFormatter={valueFormatter}
813
- />
814
- <ChartTooltip
815
- cursor={{ fill: 'var(--muted)', opacity: 0.4, radius: 4 }}
816
- content={<ChartTooltipContent />}
817
- />
818
- {showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
819
- {barElements}
820
- </RechartsPrimitive.BarChart>
821
- </ChartContainer>
822
- );
823
- }
824
-
825
- // ─── DashboardLineChart ───────────────────────────────────────────────────────
826
-
827
- export interface DashboardLineChartProps
828
- extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
829
- data: DashboardChartDatum[];
830
- indexKey?: string;
831
- series?: DashboardChartSeries[];
832
- colors?: DashboardChartColors;
833
- showDots?: boolean;
834
- showGrid?: boolean;
835
- showLegend?: boolean;
836
- curveType?: ChartCurveType;
837
- strokeWidth?: number;
838
- valueFormatter?: ChartValueFormatter;
839
- }
840
-
841
- function DashboardLineChart({
842
- data,
843
- config,
844
- indexKey = 'name',
845
- series,
846
- colors,
847
- showDots = false,
848
- showGrid = true,
849
- showLegend = true,
850
- curveType = 'monotone',
851
- strokeWidth = 2,
852
- valueFormatter = formatTick,
853
- isLoading,
854
- error,
855
- onRetry,
856
- retryLabel,
857
- emptyTitle,
858
- emptyDescription,
859
- errorTitle,
860
- errorDescription,
861
- loadingLabel,
862
- stateClassName,
863
- className,
864
- ...props
865
- }: DashboardLineChartProps) {
866
- const chartSeries = getChartSeries(config, series);
867
- const chartConfig = getChartConfigWithColors(
868
- config,
869
- chartSeries.map(item => item.key),
870
- colors
871
- );
872
- const chartState = getChartState(
873
- {
874
- isLoading,
875
- error,
876
- onRetry,
877
- retryLabel,
878
- emptyTitle,
879
- emptyDescription,
880
- errorTitle,
881
- errorDescription,
882
- loadingLabel,
883
- },
884
- hasChartData(data, chartSeries),
885
- cn('h-[320px] w-full', stateClassName)
886
- );
887
-
888
- if (chartState) {
889
- return chartState;
890
- }
891
-
892
- return (
893
- <ChartContainer config={chartConfig} className={cn('h-[320px] w-full', className)} {...props}>
894
- <RechartsPrimitive.LineChart data={data} accessibilityLayer>
895
- {showGrid ? (
896
- <RechartsPrimitive.CartesianGrid
897
- vertical={false}
898
- strokeDasharray="3 3"
899
- stroke="var(--border)"
900
- strokeOpacity={0.5}
901
- />
902
- ) : null}
903
- <RechartsPrimitive.XAxis
904
- dataKey={indexKey}
905
- tickLine={false}
906
- axisLine={false}
907
- tickMargin={8}
908
- />
909
- <RechartsPrimitive.YAxis
910
- tickLine={false}
911
- axisLine={false}
912
- tickMargin={8}
913
- tickFormatter={valueFormatter}
914
- />
915
- <ChartTooltip content={<ChartTooltipContent indicator="line" />} />
916
- {showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
917
- {chartSeries.map(item => (
918
- <RechartsPrimitive.Line
919
- key={item.key}
920
- type={curveType}
921
- dataKey={item.key}
922
- stroke={`var(--color-${item.key})`}
923
- strokeWidth={strokeWidth}
924
- dot={showDots ? { r: 3, fill: `var(--color-${item.key})`, strokeWidth: 0 } : false}
925
- activeDot={{ r: 5, strokeWidth: 0 }}
926
- yAxisId={item.yAxisId}
927
- isAnimationActive
928
- animationDuration={600}
929
- animationEasing="ease-out"
930
- />
931
- ))}
932
- </RechartsPrimitive.LineChart>
933
- </ChartContainer>
934
- );
935
- }
936
-
937
- // ─── HorizontalBarChart ───────────────────────────────────────────────────────
938
-
939
- export interface HorizontalBarChartProps
940
- extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
941
- data: DashboardChartDatum[];
942
- indexKey?: string;
943
- series?: DashboardChartSeries[];
944
- colors?: DashboardChartColors;
945
- barSize?: ChartBarSize;
946
- stacked?: boolean;
947
- categoryWidth?: number;
948
- showGrid?: boolean;
949
- showLegend?: boolean;
950
- valueFormatter?: ChartValueFormatter;
951
- }
952
-
953
- function HorizontalBarChart({
954
- data,
955
- config,
956
- indexKey = 'name',
957
- series,
958
- colors,
959
- barSize = 'md',
960
- stacked = false,
961
- categoryWidth = 96,
962
- showGrid = true,
963
- showLegend = true,
964
- valueFormatter = formatTick,
965
- isLoading,
966
- error,
967
- onRetry,
968
- retryLabel,
969
- emptyTitle,
970
- emptyDescription,
971
- errorTitle,
972
- errorDescription,
973
- loadingLabel,
974
- stateClassName,
975
- className,
976
- ...props
977
- }: HorizontalBarChartProps) {
978
- const chartSeries = getChartSeries(config, series);
979
- const chartConfig = getChartConfigWithColors(
980
- config,
981
- chartSeries.map(item => item.key),
982
- colors
983
- );
984
- const chartState = getChartState(
985
- {
986
- isLoading,
987
- error,
988
- onRetry,
989
- retryLabel,
990
- emptyTitle,
991
- emptyDescription,
992
- errorTitle,
993
- errorDescription,
994
- loadingLabel,
995
- },
996
- hasChartData(data, chartSeries),
997
- cn('h-[320px] w-full', stateClassName)
998
- );
999
-
1000
- if (chartState) {
1001
- return chartState;
1002
- }
1003
-
1004
- return (
1005
- <ChartContainer config={chartConfig} className={cn('h-[320px] w-full', className)} {...props}>
1006
- <RechartsPrimitive.BarChart
1007
- data={data}
1008
- layout="vertical"
1009
- accessibilityLayer
1010
- margin={{ left: 8, right: 16 }}
1011
- >
1012
- {showGrid ? (
1013
- <RechartsPrimitive.CartesianGrid
1014
- horizontal={false}
1015
- strokeDasharray="3 3"
1016
- stroke="var(--border)"
1017
- strokeOpacity={0.5}
1018
- />
1019
- ) : null}
1020
- <RechartsPrimitive.XAxis
1021
- type="number"
1022
- tickLine={false}
1023
- axisLine={false}
1024
- tickMargin={8}
1025
- tickFormatter={valueFormatter}
1026
- />
1027
- <RechartsPrimitive.YAxis
1028
- dataKey={indexKey}
1029
- type="category"
1030
- tickLine={false}
1031
- axisLine={false}
1032
- tickMargin={8}
1033
- width={categoryWidth}
1034
- />
1035
- <ChartTooltip content={<ChartTooltipContent />} />
1036
- {showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
1037
- {chartSeries.map(item => (
1038
- <RechartsPrimitive.Bar
1039
- key={item.key}
1040
- dataKey={item.key}
1041
- fill={`var(--color-${item.key})`}
1042
- radius={[0, 4, 4, 0]}
1043
- barSize={getBarSize(barSize)}
1044
- stackId={stacked ? item.stackId || 'total' : item.stackId}
1045
- isAnimationActive
1046
- animationDuration={600}
1047
- animationEasing="ease-out"
1048
- />
1049
- ))}
1050
- </RechartsPrimitive.BarChart>
1051
- </ChartContainer>
1052
- );
1053
- }
1054
-
1055
- // ─── InteractiveTimeSeriesChart ───────────────────────────────────────────────
1056
-
1057
- export interface InteractiveTimeSeriesChartProps
1058
- extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
1059
- data: DashboardChartDatum[];
1060
- indexKey?: string;
1061
- series?: DashboardChartSeries[];
1062
- colors?: DashboardChartColors;
1063
- periods?: ChartPeriod[];
1064
- defaultPeriod?: string;
1065
- defaultSeries?: string;
1066
- filterData?: (data: DashboardChartDatum[], period: string) => DashboardChartDatum[];
1067
- showGrid?: boolean;
1068
- showLegend?: boolean;
1069
- showDots?: boolean;
1070
- gradientFill?: boolean;
1071
- curveType?: ChartCurveType;
1072
- strokeWidth?: number;
1073
- valueFormatter?: ChartValueFormatter;
1074
- }
1075
-
1076
- function InteractiveTimeSeriesChart({
1077
- data,
1078
- config,
1079
- indexKey = 'date',
1080
- series,
1081
- colors,
1082
- periods = defaultPeriods,
1083
- defaultPeriod = periods[1]?.value || periods[0]?.value || '30d',
1084
- defaultSeries,
1085
- filterData = defaultFilterData,
1086
- showGrid = true,
1087
- showLegend = false,
1088
- showDots = false,
1089
- gradientFill = true,
1090
- curveType = 'monotone',
1091
- strokeWidth = 2,
1092
- valueFormatter = formatTick,
1093
- isLoading,
1094
- error,
1095
- onRetry,
1096
- retryLabel,
1097
- emptyTitle,
1098
- emptyDescription,
1099
- errorTitle,
1100
- errorDescription,
1101
- loadingLabel,
1102
- stateClassName,
1103
- className,
1104
- ...props
1105
- }: InteractiveTimeSeriesChartProps) {
1106
- const chartSeries = getChartSeries(config, series);
1107
- const chartConfig = getChartConfigWithColors(
1108
- config,
1109
- chartSeries.map(item => item.key),
1110
- colors
1111
- );
1112
- const [period, setPeriod] = React.useState(defaultPeriod);
1113
- const [activeSeries, setActiveSeries] = React.useState(
1114
- defaultSeries || chartSeries[0]?.key || ''
1115
- );
1116
- const filteredData = React.useMemo(() => filterData(data, period), [data, filterData, period]);
1117
- const visibleSeries =
1118
- chartSeries.length > 1 ? chartSeries.filter(item => item.key === activeSeries) : chartSeries;
1119
- const chartState = getChartState(
1120
- {
1121
- isLoading,
1122
- error,
1123
- onRetry,
1124
- retryLabel,
1125
- emptyTitle,
1126
- emptyDescription,
1127
- errorTitle,
1128
- errorDescription,
1129
- loadingLabel,
1130
- },
1131
- hasChartData(filteredData, visibleSeries),
1132
- cn('h-[320px] w-full', stateClassName)
1133
- );
1134
-
1135
- return (
1136
- <div className="w-full space-y-4">
1137
- <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
1138
- {chartSeries.length > 1 ? (
1139
- <div
1140
- className="inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground"
1141
- role="group"
1142
- aria-label="Selecionar metrica do grafico"
1143
- >
1144
- {chartSeries.map(item => {
1145
- const label = item.label || config[item.key]?.label || item.key;
1146
-
1147
- return (
1148
- <Button
1149
- key={item.key}
1150
- type="button"
1151
- variant="ghost"
1152
- size="sm"
1153
- aria-pressed={activeSeries === item.key}
1154
- onClick={() => setActiveSeries(item.key)}
1155
- className={cn(
1156
- 'h-8 px-3 text-sm font-medium transition-all',
1157
- activeSeries === item.key
1158
- ? 'bg-background text-foreground shadow-sm'
1159
- : 'text-muted-foreground hover:text-foreground'
1160
- )}
1161
- >
1162
- {label}
1163
- </Button>
1164
- );
1165
- })}
1166
- </div>
1167
- ) : (
1168
- <div />
1169
- )}
1170
- <Select value={period} onValueChange={setPeriod}>
1171
- <SelectTrigger
1172
- className="w-full sm:w-[160px]"
1173
- size="sm"
1174
- aria-label="Selecionar periodo do grafico"
1175
- >
1176
- <SelectValue placeholder="Period" />
1177
- </SelectTrigger>
1178
- <SelectContent>
1179
- {periods.map(item => (
1180
- <SelectItem key={item.value} value={item.value}>
1181
- {item.label}
1182
- </SelectItem>
1183
- ))}
1184
- </SelectContent>
1185
- </Select>
1186
- </div>
1187
- {chartState || (
1188
- <ChartContainer
1189
- config={chartConfig}
1190
- className={cn('h-[320px] w-full', className)}
1191
- {...props}
1192
- >
1193
- <RechartsPrimitive.AreaChart data={filteredData} accessibilityLayer>
1194
- {gradientFill && (
1195
- <AreaGradientDefs seriesKeys={visibleSeries.map(s => s.key)} opacity={0.4} />
1196
- )}
1197
- {showGrid ? (
1198
- <RechartsPrimitive.CartesianGrid
1199
- vertical={false}
1200
- strokeDasharray="3 3"
1201
- stroke="var(--border)"
1202
- strokeOpacity={0.5}
1203
- />
1204
- ) : null}
1205
- <RechartsPrimitive.XAxis
1206
- dataKey={indexKey}
1207
- tickLine={false}
1208
- axisLine={false}
1209
- tickMargin={8}
1210
- />
1211
- <RechartsPrimitive.YAxis
1212
- tickLine={false}
1213
- axisLine={false}
1214
- tickMargin={8}
1215
- tickFormatter={valueFormatter}
1216
- />
1217
- <ChartTooltip content={<ChartTooltipContent indicator="line" />} />
1218
- {showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
1219
- {visibleSeries.map(item => (
1220
- <RechartsPrimitive.Area
1221
- key={item.key}
1222
- type={curveType}
1223
- dataKey={item.key}
1224
- stroke={`var(--color-${item.key})`}
1225
- fill={gradientFill ? `url(#gradient-${item.key})` : `var(--color-${item.key})`}
1226
- fillOpacity={gradientFill ? 1 : 0.16}
1227
- strokeWidth={strokeWidth}
1228
- dot={showDots ? { r: 3, fill: `var(--color-${item.key})`, strokeWidth: 0 } : false}
1229
- activeDot={{ r: 5, strokeWidth: 0 }}
1230
- isAnimationActive
1231
- animationDuration={600}
1232
- animationEasing="ease-out"
1233
- />
1234
- ))}
1235
- </RechartsPrimitive.AreaChart>
1236
- </ChartContainer>
1237
- )}
1238
- </div>
1239
- );
1240
- }
1241
-
1242
- // ─── ComboMetricChart ─────────────────────────────────────────────────────────
1243
-
1244
- export interface ComboMetricChartProps
1245
- extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
1246
- data: DashboardChartDatum[];
1247
- indexKey?: string;
1248
- series?: DashboardChartSeries[];
1249
- colors?: DashboardChartColors;
1250
- barSize?: ChartBarSize;
1251
- showGrid?: boolean;
1252
- showLegend?: boolean;
1253
- gradientFill?: boolean;
1254
- curveType?: ChartCurveType;
1255
- valueFormatter?: ChartValueFormatter;
1256
- }
1257
-
1258
- function ComboMetricChart({
1259
- data,
1260
- config,
1261
- indexKey = 'name',
1262
- series,
1263
- colors,
1264
- barSize = 'md',
1265
- showGrid = true,
1266
- showLegend = true,
1267
- gradientFill = false,
1268
- curveType = 'monotone',
1269
- valueFormatter = formatTick,
1270
- isLoading,
1271
- error,
1272
- onRetry,
1273
- retryLabel,
1274
- emptyTitle,
1275
- emptyDescription,
1276
- errorTitle,
1277
- errorDescription,
1278
- loadingLabel,
1279
- stateClassName,
1280
- className,
1281
- ...props
1282
- }: ComboMetricChartProps) {
1283
- const chartSeries = getChartSeries(config, series);
1284
- const chartConfig = getChartConfigWithColors(
1285
- config,
1286
- chartSeries.map(item => item.key),
1287
- colors
1288
- );
1289
- const chartState = getChartState(
1290
- {
1291
- isLoading,
1292
- error,
1293
- onRetry,
1294
- retryLabel,
1295
- emptyTitle,
1296
- emptyDescription,
1297
- errorTitle,
1298
- errorDescription,
1299
- loadingLabel,
1300
- },
1301
- hasChartData(data, chartSeries),
1302
- cn('h-[340px] w-full', stateClassName)
1303
- );
1304
-
1305
- if (chartState) {
1306
- return chartState;
1307
- }
1308
-
1309
- const areaSeries = gradientFill ? chartSeries.filter(s => s.type === 'area').map(s => s.key) : [];
1310
-
1311
- return (
1312
- <ChartContainer config={chartConfig} className={cn('h-[340px] w-full', className)} {...props}>
1313
- <RechartsPrimitive.ComposedChart data={data} accessibilityLayer>
1314
- {gradientFill && areaSeries.length > 0 && (
1315
- <AreaGradientDefs seriesKeys={areaSeries} opacity={0.35} />
1316
- )}
1317
- {showGrid ? (
1318
- <RechartsPrimitive.CartesianGrid
1319
- vertical={false}
1320
- strokeDasharray="3 3"
1321
- stroke="var(--border)"
1322
- strokeOpacity={0.5}
1323
- />
1324
- ) : null}
1325
- <RechartsPrimitive.XAxis
1326
- dataKey={indexKey}
1327
- tickLine={false}
1328
- axisLine={false}
1329
- tickMargin={8}
1330
- />
1331
- <RechartsPrimitive.YAxis
1332
- tickLine={false}
1333
- axisLine={false}
1334
- tickMargin={8}
1335
- tickFormatter={valueFormatter}
1336
- />
1337
- <ChartTooltip content={<ChartTooltipContent />} />
1338
- {showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
1339
- {chartSeries.map(item =>
1340
- item.type === 'line' ? (
1341
- <RechartsPrimitive.Line
1342
- key={item.key}
1343
- type={curveType}
1344
- dataKey={item.key}
1345
- stroke={`var(--color-${item.key})`}
1346
- strokeWidth={2}
1347
- dot={false}
1348
- activeDot={{ r: 5, strokeWidth: 0 }}
1349
- yAxisId={item.yAxisId}
1350
- isAnimationActive
1351
- animationDuration={600}
1352
- animationEasing="ease-out"
1353
- />
1354
- ) : item.type === 'area' ? (
1355
- <RechartsPrimitive.Area
1356
- key={item.key}
1357
- type={curveType}
1358
- dataKey={item.key}
1359
- stroke={`var(--color-${item.key})`}
1360
- fill={gradientFill ? `url(#gradient-${item.key})` : `var(--color-${item.key})`}
1361
- fillOpacity={gradientFill ? 1 : 0.12}
1362
- strokeWidth={2}
1363
- dot={false}
1364
- activeDot={{ r: 5, strokeWidth: 0 }}
1365
- yAxisId={item.yAxisId}
1366
- isAnimationActive
1367
- animationDuration={600}
1368
- animationEasing="ease-out"
1369
- />
1370
- ) : (
1371
- <RechartsPrimitive.Bar
1372
- key={item.key}
1373
- dataKey={item.key}
1374
- fill={`var(--color-${item.key})`}
1375
- radius={[4, 4, 0, 0]}
1376
- barSize={getBarSize(barSize)}
1377
- yAxisId={item.yAxisId}
1378
- isAnimationActive
1379
- animationDuration={600}
1380
- animationEasing="ease-out"
1381
- />
1382
- )
1383
- )}
1384
- </RechartsPrimitive.ComposedChart>
1385
- </ChartContainer>
1386
- );
1387
- }
1388
-
1389
- // ─── DonutBreakdownChart ──────────────────────────────────────────────────────
1390
-
1391
- export interface DonutBreakdownChartProps
1392
- extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
1393
- data: DashboardChartDatum[];
1394
- nameKey?: string;
1395
- valueKey?: string;
1396
- colors?: DashboardChartColors;
1397
- centerLabel?: React.ReactNode;
1398
- centerValue?: React.ReactNode;
1399
- showLegend?: boolean;
1400
- /** Inner radius as percentage string (e.g. "58%") or number */
1401
- innerRadius?: string | number;
1402
- /** Outer radius as percentage string (e.g. "82%") or number */
1403
- outerRadius?: string | number;
1404
- }
1405
-
1406
- function DonutBreakdownChart({
1407
- data,
1408
- config,
1409
- nameKey = 'name',
1410
- valueKey = 'value',
1411
- colors,
1412
- centerLabel,
1413
- centerValue,
1414
- showLegend = true,
1415
- innerRadius = '58%',
1416
- outerRadius = '82%',
1417
- isLoading,
1418
- error,
1419
- onRetry,
1420
- retryLabel,
1421
- emptyTitle,
1422
- emptyDescription,
1423
- errorTitle,
1424
- errorDescription,
1425
- loadingLabel,
1426
- stateClassName,
1427
- className,
1428
- ...props
1429
- }: DonutBreakdownChartProps) {
1430
- const [activeIndex, setActiveIndex] = React.useState(0);
1431
- const chartKeys = data.map((entry, index) => String(entry[nameKey] || `segment-${index}`));
1432
- const chartConfig = getChartConfigWithColors(config, chartKeys, colors);
1433
- const chartState = getChartState(
1434
- {
1435
- isLoading,
1436
- error,
1437
- onRetry,
1438
- retryLabel,
1439
- emptyTitle,
1440
- emptyDescription,
1441
- errorTitle,
1442
- errorDescription,
1443
- loadingLabel,
1444
- },
1445
- hasPieData(data, nameKey, valueKey),
1446
- cn('h-[320px] w-full', stateClassName)
1447
- );
1448
-
1449
- if (chartState) {
1450
- return chartState;
1451
- }
1452
-
1453
- return (
1454
- <ChartContainer config={chartConfig} className={cn('h-[320px] w-full', className)} {...props}>
1455
- <RechartsPrimitive.PieChart accessibilityLayer>
1456
- <ChartTooltip
1457
- cursor={false}
1458
- content={<ChartTooltipContent hideLabel nameKey={nameKey} />}
1459
- />
1460
- {showLegend ? (
1461
- <ChartLegend content={<ChartLegendContent nameKey={nameKey} />} verticalAlign="bottom" />
1462
- ) : null}
1463
- <RechartsPrimitive.Pie
1464
- data={data}
1465
- dataKey={valueKey}
1466
- nameKey={nameKey}
1467
- innerRadius={innerRadius}
1468
- outerRadius={outerRadius}
1469
- paddingAngle={3}
1470
- onMouseEnter={(_, index) => setActiveIndex(index)}
1471
- isAnimationActive
1472
- animationDuration={600}
1473
- animationEasing="ease-out"
1474
- >
1475
- {data.map((entry, index) => {
1476
- const key = String(entry[nameKey] || `segment-${index}`);
1477
-
1478
- return (
1479
- <RechartsPrimitive.Cell
1480
- key={key}
1481
- fill={`var(--color-${key})`}
1482
- opacity={index === activeIndex ? 1 : 0.7}
1483
- stroke="transparent"
1484
- />
1485
- );
1486
- })}
1487
- {centerValue || centerLabel ? (
1488
- <RechartsPrimitive.Label
1489
- position="center"
1490
- content={({ viewBox }) => {
1491
- if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) {
1492
- return null;
1493
- }
1494
-
1495
- return (
1496
- <text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
1497
- {centerValue ? (
1498
- <tspan
1499
- x={viewBox.cx}
1500
- y={viewBox.cy}
1501
- className="fill-foreground text-2xl font-semibold"
1502
- >
1503
- {centerValue}
1504
- </tspan>
1505
- ) : null}
1506
- {centerLabel ? (
1507
- <tspan
1508
- x={viewBox.cx}
1509
- y={Number(viewBox.cy) + 22}
1510
- className="fill-muted-foreground text-xs"
1511
- >
1512
- {centerLabel}
1513
- </tspan>
1514
- ) : null}
1515
- </text>
1516
- );
1517
- }}
1518
- />
1519
- ) : null}
1520
- </RechartsPrimitive.Pie>
1521
- </RechartsPrimitive.PieChart>
1522
- </ChartContainer>
1523
- );
1524
- }
1525
-
1526
- // ─── SparklineChart ───────────────────────────────────────────────────────────
1527
-
1528
- export interface SparklineChartProps {
1529
- /** Data array — each item must have the `dataKey` field */
1530
- data: DashboardChartDatum[];
1531
- /** Key to plot on the Y axis */
1532
- dataKey: string;
1533
- /** Color for the line/area — defaults to `var(--chart-1)` */
1534
- color?: string;
1535
- /** Show filled area under the line */
1536
- filled?: boolean;
1537
- /** Show the last data point as a dot */
1538
- showEndDot?: boolean;
1539
- /** Curve interpolation type */
1540
- curveType?: ChartCurveType;
1541
- /** Stroke width */
1542
- strokeWidth?: number;
1543
- className?: string;
1544
- }
1545
-
1546
- /**
1547
- * Minimal inline sparkline chart — no axes, no grid, no tooltip.
1548
- * Ideal for embedding inside stat cards or table cells.
1549
- *
1550
- * @ai-rules
1551
- * - Use `filled` for area-style sparklines.
1552
- * - Keep `className` height small (e.g. `h-12` or `h-16`).
1553
- * - Do NOT wrap in `ChartCard` it is designed to be inline.
1554
- */
1555
- function SparklineChart({
1556
- data,
1557
- dataKey,
1558
- color = 'var(--chart-1)',
1559
- filled = true,
1560
- showEndDot = true,
1561
- curveType = 'monotone',
1562
- strokeWidth = 2,
1563
- className,
1564
- }: SparklineChartProps) {
1565
- const sparkId = React.useId().replace(/:/g, '');
1566
- const lastPoint = data[data.length - 1];
1567
- const lastValue = lastPoint ? lastPoint[dataKey] : undefined;
1568
-
1569
- return (
1570
- <div className={cn('relative h-12 w-full min-w-0', className)}>
1571
- <RechartsPrimitive.ResponsiveContainer width="100%" height="100%">
1572
- <RechartsPrimitive.AreaChart
1573
- data={data}
1574
- margin={{ top: 4, right: showEndDot ? 8 : 0, bottom: 0, left: 0 }}
1575
- >
1576
- {filled && (
1577
- <defs>
1578
- <linearGradient id={`spark-gradient-${sparkId}`} x1="0" y1="0" x2="0" y2="1">
1579
- <stop offset="5%" stopColor={color} stopOpacity={0.3} />
1580
- <stop offset="95%" stopColor={color} stopOpacity={0} />
1581
- </linearGradient>
1582
- </defs>
1583
- )}
1584
- <RechartsPrimitive.Area
1585
- type={curveType}
1586
- dataKey={dataKey}
1587
- stroke={color}
1588
- strokeWidth={strokeWidth}
1589
- fill={filled ? `url(#spark-gradient-${sparkId})` : 'none'}
1590
- fillOpacity={1}
1591
- dot={false}
1592
- activeDot={false}
1593
- isAnimationActive
1594
- animationDuration={600}
1595
- animationEasing="ease-out"
1596
- />
1597
- </RechartsPrimitive.AreaChart>
1598
- </RechartsPrimitive.ResponsiveContainer>
1599
- {showEndDot && lastValue !== undefined && lastValue !== null && (
1600
- <div
1601
- className="pointer-events-none absolute right-0 top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full ring-2 ring-background"
1602
- style={{ backgroundColor: color }}
1603
- />
1604
- )}
1605
- </div>
1606
- );
1607
- }
1608
-
1609
- // ─── RadarChart ───────────────────────────────────────────────────────────────
1610
-
1611
- export interface RadarMetricChartProps extends ChartStateProps {
1612
- /** Data array — each item is one axis point on the radar */
1613
- data: DashboardChartDatum[];
1614
- /** Key in each datum used as the axis label */
1615
- labelKey: string;
1616
- /**
1617
- * Series to render. Each entry maps to one `<Radar>` element.
1618
- * Use a single entry for a simple radar, multiple for comparison.
1619
- */
1620
- series: DashboardChartSeries[];
1621
- /** Override colors per series key or as an ordered array */
1622
- colors?: DashboardChartColors;
1623
- /** Fill the radar polygon (default: true) */
1624
- filled?: boolean;
1625
- /** Fill opacity when `filled` is true (default: 0.25) */
1626
- fillOpacity?: number;
1627
- /** Show dots on each axis point (default: false) */
1628
- showDots?: boolean;
1629
- /** Show the polar grid lines (default: true) */
1630
- showGrid?: boolean;
1631
- /** Show the legend (default: true when multiple series) */
1632
- showLegend?: boolean;
1633
- /** Format axis tick values */
1634
- valueFormatter?: ChartValueFormatter;
1635
- className?: string;
1636
- }
1637
-
1638
- function RadarMetricChart({
1639
- data,
1640
- labelKey,
1641
- series,
1642
- colors,
1643
- filled = true,
1644
- fillOpacity = 0.25,
1645
- showDots = false,
1646
- showGrid = true,
1647
- showLegend,
1648
- valueFormatter,
1649
- isLoading,
1650
- error,
1651
- onRetry,
1652
- retryLabel,
1653
- emptyTitle,
1654
- emptyDescription,
1655
- errorTitle,
1656
- errorDescription,
1657
- loadingLabel,
1658
- stateClassName,
1659
- className,
1660
- }: RadarMetricChartProps) {
1661
- const chartConfig = React.useMemo(
1662
- () =>
1663
- buildChartConfig(
1664
- series.map(s => s.key),
1665
- colors
1666
- ),
1667
- [series, colors]
1668
- );
1669
-
1670
- const hasData = data.length > 0 && series.length > 0;
1671
- const chartState = getChartState(
1672
- {
1673
- isLoading,
1674
- error,
1675
- emptyTitle,
1676
- emptyDescription,
1677
- errorTitle,
1678
- errorDescription,
1679
- loadingLabel,
1680
- stateClassName,
1681
- onRetry,
1682
- retryLabel,
1683
- },
1684
- hasData
1685
- );
1686
-
1687
- const displayLegend = showLegend ?? series.length > 1;
1688
-
1689
- return (
1690
- <ChartContainer config={chartConfig} className={cn('h-[300px] w-full', className)}>
1691
- {chartState ?? (
1692
- <RechartsPrimitive.RadarChart data={data} accessibilityLayer>
1693
- {showGrid && <RechartsPrimitive.PolarGrid stroke="var(--border)" strokeOpacity={0.6} />}
1694
- <RechartsPrimitive.PolarAngleAxis
1695
- dataKey={labelKey}
1696
- tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }}
1697
- />
1698
- <RechartsPrimitive.PolarRadiusAxis
1699
- tick={false}
1700
- axisLine={false}
1701
- tickFormatter={valueFormatter}
1702
- />
1703
- <ChartTooltip
1704
- content={
1705
- <ChartTooltipContent
1706
- formatter={valueFormatter ? v => valueFormatter(v as number) : undefined}
1707
- />
1708
- }
1709
- />
1710
- {displayLegend && <ChartLegend content={<ChartLegendContent />} />}
1711
- {series.map(s => (
1712
- <RechartsPrimitive.Radar
1713
- key={s.key}
1714
- name={(s.label as string) ?? s.key}
1715
- dataKey={s.key}
1716
- stroke={`var(--color-${s.key})`}
1717
- fill={filled ? `var(--color-${s.key})` : 'none'}
1718
- fillOpacity={filled ? fillOpacity : 0}
1719
- dot={showDots ? { r: 3, fill: `var(--color-${s.key})` } : false}
1720
- isAnimationActive
1721
- animationDuration={600}
1722
- animationEasing="ease-out"
1723
- />
1724
- ))}
1725
- </RechartsPrimitive.RadarChart>
1726
- )}
1727
- </ChartContainer>
1728
- );
1729
- }
1730
-
1731
- // ─── PieMetricChart ───────────────────────────────────────────────────────────
1732
-
1733
- export interface PieMetricChartProps extends ChartStateProps {
1734
- /** Data array — each item is one slice */
1735
- data: DashboardChartDatum[];
1736
- /** Key in each datum used as the slice name/label */
1737
- nameKey: string;
1738
- /** Key in each datum used as the slice value */
1739
- valueKey: string;
1740
- /** Override colors as an ordered array or per-name map */
1741
- colors?: DashboardChartColors;
1742
- /** Outer radius of the pie (default: "80%") */
1743
- outerRadius?: number | string;
1744
- /** Inner radius set > 0 to make a donut (default: 0) */
1745
- innerRadius?: number | string;
1746
- /** Show percentage labels inside/outside each slice (default: false) */
1747
- showLabels?: boolean;
1748
- /** Show the legend (default: true) */
1749
- showLegend?: boolean;
1750
- /**
1751
- * Index of the slice to "explode" (offset outward).
1752
- * Pass -1 or undefined to disable.
1753
- */
1754
- explodeIndex?: number;
1755
- /** Offset distance for the exploded slice in px (default: 12) */
1756
- explodeOffset?: number;
1757
- /** Format tooltip values */
1758
- valueFormatter?: ChartValueFormatter;
1759
- className?: string;
1760
- }
1761
-
1762
- function PieMetricChart({
1763
- data,
1764
- nameKey,
1765
- valueKey,
1766
- colors,
1767
- outerRadius = '80%',
1768
- innerRadius = 0,
1769
- showLabels = false,
1770
- showLegend = true,
1771
- explodeIndex,
1772
- explodeOffset = 12,
1773
- valueFormatter,
1774
- isLoading,
1775
- error,
1776
- onRetry,
1777
- retryLabel,
1778
- emptyTitle,
1779
- emptyDescription,
1780
- errorTitle,
1781
- errorDescription,
1782
- loadingLabel,
1783
- stateClassName,
1784
- className,
1785
- }: PieMetricChartProps) {
1786
- // Build config from unique name values
1787
- const names = React.useMemo(() => data.map(d => String(d[nameKey] ?? '')), [data, nameKey]);
1788
-
1789
- const chartConfig = React.useMemo(() => buildChartConfig(names, colors), [names, colors]);
1790
-
1791
- const hasData = hasPieData(data, nameKey, valueKey);
1792
- const chartState = getChartState(
1793
- {
1794
- isLoading,
1795
- error,
1796
- emptyTitle,
1797
- emptyDescription,
1798
- errorTitle,
1799
- errorDescription,
1800
- loadingLabel,
1801
- stateClassName,
1802
- onRetry,
1803
- retryLabel,
1804
- },
1805
- hasData
1806
- );
1807
-
1808
- return (
1809
- <ChartContainer config={chartConfig} className={cn('h-[300px] w-full', className)}>
1810
- {chartState ?? (
1811
- <RechartsPrimitive.PieChart accessibilityLayer>
1812
- <ChartTooltip
1813
- content={
1814
- <ChartTooltipContent
1815
- nameKey={nameKey}
1816
- formatter={valueFormatter ? v => valueFormatter(v as number) : undefined}
1817
- />
1818
- }
1819
- />
1820
- {showLegend && <ChartLegend content={<ChartLegendContent nameKey={nameKey} />} />}
1821
- <RechartsPrimitive.Pie
1822
- data={data}
1823
- dataKey={valueKey}
1824
- nameKey={nameKey}
1825
- outerRadius={outerRadius}
1826
- innerRadius={innerRadius}
1827
- paddingAngle={2}
1828
- label={
1829
- showLabels
1830
- ? ({ cx, cy, midAngle, innerRadius: ir, outerRadius: or, percent }) => {
1831
- if (midAngle == null || percent == null) return null;
1832
- const RADIAN = Math.PI / 180;
1833
- const radius = Number(ir) + (Number(or) - Number(ir)) * 1.35;
1834
- const x = Number(cx) + radius * Math.cos(-midAngle * RADIAN);
1835
- const y = Number(cy) + radius * Math.sin(-midAngle * RADIAN);
1836
- return percent > 0.04 ? (
1837
- <text
1838
- x={x}
1839
- y={y}
1840
- fill="var(--foreground)"
1841
- textAnchor={x > Number(cx) ? 'start' : 'end'}
1842
- dominantBaseline="central"
1843
- fontSize={12}
1844
- fontWeight={500}
1845
- >
1846
- {`${(percent * 100).toFixed(0)}%`}
1847
- </text>
1848
- ) : null;
1849
- }
1850
- : false
1851
- }
1852
- labelLine={false}
1853
- isAnimationActive
1854
- animationDuration={600}
1855
- animationEasing="ease-out"
1856
- >
1857
- {data.map((entry, index) => {
1858
- const name = String(entry[nameKey] ?? '');
1859
- const isExploded = index === explodeIndex;
1860
- return (
1861
- <RechartsPrimitive.Cell
1862
- key={`cell-${index}`}
1863
- fill={chartConfig[name]?.color ?? `var(--chart-${(index % 8) + 1})`}
1864
- stroke="var(--background)"
1865
- strokeWidth={2}
1866
- style={
1867
- isExploded
1868
- ? {
1869
- filter: `drop-shadow(0 4px 8px color-mix(in srgb, ${chartConfig[name]?.color ?? 'var(--chart-1)'} 40%, transparent))`,
1870
- }
1871
- : undefined
1872
- }
1873
- />
1874
- );
1875
- })}
1876
- </RechartsPrimitive.Pie>
1877
- </RechartsPrimitive.PieChart>
1878
- )}
1879
- </ChartContainer>
1880
- );
1881
- }
1882
-
1883
- // ─── RadialBarMetricChart ─────────────────────────────────────────────────────
1884
-
1885
- export interface RadialBarMetricChartProps extends ChartStateProps {
1886
- /**
1887
- * Data array. Each item should have a `name` field (for labels) and
1888
- * the `dataKey` field (for values, 0–100 for percentage-based display).
1889
- */
1890
- data: DashboardChartDatum[];
1891
- /** Key in each datum used as the bar value (default: "value") */
1892
- dataKey?: string;
1893
- /** Key in each datum used as the bar label (default: "name") */
1894
- nameKey?: string;
1895
- /** Override colors as an ordered array or per-name map */
1896
- colors?: DashboardChartColors;
1897
- /** Inner radius of the radial bar (default: "30%") */
1898
- innerRadius?: number | string;
1899
- /** Outer radius of the radial bar (default: "100%") */
1900
- outerRadius?: number | string;
1901
- /** Start angle in degrees (default: 90 top) */
1902
- startAngle?: number;
1903
- /** End angle in degrees (default: -270 — full circle) */
1904
- endAngle?: number;
1905
- /** Show background track behind each bar (default: true) */
1906
- showBackground?: boolean;
1907
- /** Show the legend (default: true) */
1908
- showLegend?: boolean;
1909
- /** Format tooltip values */
1910
- valueFormatter?: ChartValueFormatter;
1911
- className?: string;
1912
- }
1913
-
1914
- function RadialBarMetricChart({
1915
- data,
1916
- dataKey = 'value',
1917
- nameKey = 'name',
1918
- colors,
1919
- innerRadius = '30%',
1920
- outerRadius = '100%',
1921
- startAngle = 90,
1922
- endAngle = -270,
1923
- showBackground = true,
1924
- showLegend = true,
1925
- valueFormatter,
1926
- isLoading,
1927
- error,
1928
- onRetry,
1929
- retryLabel,
1930
- emptyTitle,
1931
- emptyDescription,
1932
- errorTitle,
1933
- errorDescription,
1934
- loadingLabel,
1935
- stateClassName,
1936
- className,
1937
- }: RadialBarMetricChartProps) {
1938
- const names = React.useMemo(() => data.map(d => String(d[nameKey] ?? '')), [data, nameKey]);
1939
-
1940
- const chartConfig = React.useMemo(() => buildChartConfig(names, colors), [names, colors]);
1941
-
1942
- const hasData = data.length > 0;
1943
- const chartState = getChartState(
1944
- {
1945
- isLoading,
1946
- error,
1947
- emptyTitle,
1948
- emptyDescription,
1949
- errorTitle,
1950
- errorDescription,
1951
- loadingLabel,
1952
- stateClassName,
1953
- onRetry,
1954
- retryLabel,
1955
- },
1956
- hasData
1957
- );
1958
-
1959
- // Inject per-item fill color
1960
- const coloredData = React.useMemo(
1961
- () =>
1962
- data.map((item, index) => {
1963
- const name = String(item[nameKey] ?? '');
1964
- return {
1965
- ...item,
1966
- fill: chartConfig[name]?.color ?? `var(--chart-${(index % 8) + 1})`,
1967
- };
1968
- }),
1969
- [data, nameKey, chartConfig]
1970
- );
1971
-
1972
- return (
1973
- <div className={cn('flex w-full flex-col gap-3', className)}>
1974
- <ChartContainer config={chartConfig} className="h-[260px] w-full">
1975
- {chartState ?? (
1976
- <RechartsPrimitive.RadialBarChart
1977
- data={coloredData}
1978
- innerRadius={innerRadius}
1979
- outerRadius={outerRadius}
1980
- startAngle={startAngle}
1981
- endAngle={endAngle}
1982
- accessibilityLayer
1983
- >
1984
- <ChartTooltip
1985
- cursor={false}
1986
- content={
1987
- <ChartTooltipContent
1988
- nameKey={nameKey}
1989
- formatter={valueFormatter ? v => valueFormatter(v as number) : undefined}
1990
- />
1991
- }
1992
- />
1993
- <RechartsPrimitive.RadialBar
1994
- dataKey={dataKey}
1995
- background={showBackground ? { fill: 'var(--muted)' } : false}
1996
- cornerRadius={6}
1997
- isAnimationActive
1998
- animationDuration={700}
1999
- animationEasing="ease-out"
2000
- />
2001
- </RechartsPrimitive.RadialBarChart>
2002
- )}
2003
- </ChartContainer>
2004
- {showLegend && !chartState && (
2005
- <div className="flex flex-wrap justify-center gap-x-4 gap-y-1.5 px-2">
2006
- {coloredData.map((item, index) => {
2007
- const d = item as DashboardChartDatum & { fill: string };
2008
- const name = String(d[nameKey] ?? '');
2009
- const fillColor = d.fill ?? `var(--chart-${(index % 8) + 1})`;
2010
- const val = d[dataKey];
2011
- return (
2012
- <div key={name} className="flex items-center gap-1.5">
2013
- <span
2014
- className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
2015
- style={{ backgroundColor: fillColor }}
2016
- />
2017
- <span className="text-xs text-muted-foreground">
2018
- {name}
2019
- {val !== undefined && val !== null && (
2020
- <span className="ml-1 font-medium text-foreground">
2021
- {valueFormatter ? valueFormatter(val as number) : String(val)}
2022
- </span>
2023
- )}
2024
- </span>
2025
- </div>
2026
- );
2027
- })}
2028
- </div>
2029
- )}
2030
- </div>
2031
- );
2032
- }
2033
-
2034
- // ─── GaugeChart ───────────────────────────────────────────────────────────────
2035
-
2036
- export interface GaugeChartThreshold {
2037
- /** Upper bound of this zone (0–100) */
2038
- value: number;
2039
- /** Color for this zone */
2040
- color: string;
2041
- /** Optional label for this zone */
2042
- label?: string;
2043
- }
2044
-
2045
- export interface GaugeChartProps {
2046
- /** Current value (must be within [min, max]) */
2047
- value: number;
2048
- /** Minimum value (default: 0) */
2049
- min?: number;
2050
- /** Maximum value (default: 100) */
2051
- max?: number;
2052
- /**
2053
- * Color zones. Each threshold defines the upper bound of a zone.
2054
- * Zones are evaluated in order; the first zone whose `value >= current %`
2055
- * determines the active color.
2056
- * If omitted, uses `--chart-1`.
2057
- */
2058
- thresholds?: GaugeChartThreshold[];
2059
- /** Label shown below the value (e.g. "CPU Usage") */
2060
- label?: React.ReactNode;
2061
- /** Format the center value text (default: shows percentage) */
2062
- valueFormatter?: (value: number, percent: number) => string;
2063
- /** Show the needle indicator (default: true) */
2064
- showNeedle?: boolean;
2065
- className?: string;
2066
- }
2067
-
2068
- function GaugeChart({
2069
- value,
2070
- min = 0,
2071
- max = 100,
2072
- thresholds,
2073
- label,
2074
- valueFormatter,
2075
- showNeedle = true,
2076
- className,
2077
- }: GaugeChartProps) {
2078
- const percent = Math.min(1, Math.max(0, (value - min) / (max - min)));
2079
- const percentInt = Math.round(percent * 100);
2080
-
2081
- // Determine active color from thresholds
2082
- const activeColor = React.useMemo(() => {
2083
- if (!thresholds || thresholds.length === 0) return 'var(--chart-1)';
2084
- for (const t of thresholds) {
2085
- if (percentInt <= t.value) return t.color;
2086
- }
2087
- return thresholds[thresholds.length - 1].color;
2088
- }, [thresholds, percentInt]);
2089
-
2090
- const displayText = valueFormatter ? valueFormatter(value, percentInt) : `${percentInt}%`;
2091
-
2092
- // SVG gauge constants — viewBox is 200×110, arc center at (100, 100)
2093
- const cx = 100;
2094
- const cy = 100;
2095
- const R = 80; // outer radius
2096
- const r = 54; // inner radius (track width = 26)
2097
-
2098
- // Helper: polar cartesian (angle in degrees, = right, CCW)
2099
- const polar = (angleDeg: number, radius: number) => {
2100
- const rad = (angleDeg * Math.PI) / 180;
2101
- return {
2102
- x: cx + radius * Math.cos(rad),
2103
- y: cy - radius * Math.sin(rad),
2104
- };
2105
- };
2106
-
2107
- // Semicircle: from 180° (left) to 0° (right)
2108
- // Track background arc path
2109
- const trackStart = polar(180, R);
2110
- const trackEnd = polar(0, R);
2111
- const trackStartI = polar(180, r);
2112
- const trackEndI = polar(0, r);
2113
- const trackPath = [
2114
- `M ${trackStart.x} ${trackStart.y}`,
2115
- `A ${R} ${R} 0 0 1 ${trackEnd.x} ${trackEnd.y}`,
2116
- `L ${trackEndI.x} ${trackEndI.y}`,
2117
- `A ${r} ${r} 0 0 0 ${trackStartI.x} ${trackStartI.y}`,
2118
- 'Z',
2119
- ].join(' ');
2120
-
2121
- // Value arc: from 180° (left) clockwise to valueAngle
2122
- // The gauge is a semicircle (max 180°), so largeArc is always 0.
2123
- // largeArc=1 would sweep the reflex arc (>180°) which creates the filled-blob bug.
2124
- const valueAngle = 180 - percent * 180;
2125
- const valueEnd = polar(valueAngle, R);
2126
- const valueEndI = polar(valueAngle, r);
2127
- const valuePath =
2128
- percent <= 0
2129
- ? ''
2130
- : percent >= 1
2131
- ? trackPath // full arc = same as track
2132
- : [
2133
- `M ${trackStart.x} ${trackStart.y}`,
2134
- `A ${R} ${R} 0 0 1 ${valueEnd.x} ${valueEnd.y}`,
2135
- `L ${valueEndI.x} ${valueEndI.y}`,
2136
- `A ${r} ${r} 0 0 0 ${trackStartI.x} ${trackStartI.y}`,
2137
- 'Z',
2138
- ].join(' ');
2139
-
2140
- // Needle: rotates from -90° (left, 0%) to +90° (right, 100%)
2141
- // In our coordinate system: 180° at 0%, 0° at 100%
2142
- const needleAngle = 180 - percent * 180;
2143
- const needleTip = polar(needleAngle, r - 6);
2144
- const needleBase1 = polar(needleAngle + 90, 5);
2145
- const needleBase2 = polar(needleAngle - 90, 5);
2146
-
2147
- return (
2148
- <div className={cn('flex flex-col items-center gap-2', className)}>
2149
- <div className="relative w-full max-w-[260px]">
2150
- <svg viewBox="0 0 200 130" className="w-full" aria-label={`Gauge: ${displayText}`}>
2151
- {/* Background track */}
2152
- <path d={trackPath} fill="var(--muted)" />
2153
-
2154
- {/* Value arc */}
2155
- {valuePath && <path d={valuePath} fill={activeColor} />}
2156
-
2157
- {/* Needle */}
2158
- {showNeedle && (
2159
- <g>
2160
- <polygon
2161
- points={`${needleTip.x},${needleTip.y} ${needleBase1.x},${needleBase1.y} ${needleBase2.x},${needleBase2.y}`}
2162
- fill="var(--foreground)"
2163
- opacity={0.85}
2164
- />
2165
- <circle cx={cx} cy={cy} r={5} fill="var(--foreground)" />
2166
- </g>
2167
- )}
2168
-
2169
- {/* Value text — placed below the arc baseline (cy=100) to avoid needle overlap */}
2170
- <text
2171
- x={cx}
2172
- y={cy + 18}
2173
- textAnchor="middle"
2174
- dominantBaseline="middle"
2175
- fontSize={22}
2176
- fontWeight={700}
2177
- fill="currentColor"
2178
- className="fill-foreground"
2179
- >
2180
- {displayText}
2181
- </text>
2182
-
2183
- {/* Label below value */}
2184
- {label && (
2185
- <text
2186
- x={cx}
2187
- y={cy + 36}
2188
- textAnchor="middle"
2189
- dominantBaseline="middle"
2190
- fontSize={10}
2191
- fill="currentColor"
2192
- className="fill-muted-foreground"
2193
- >
2194
- {label}
2195
- </text>
2196
- )}
2197
- </svg>
2198
- </div>
2199
-
2200
- {/* Threshold legend */}
2201
- {thresholds && thresholds.length > 0 && (
2202
- <div className="flex flex-wrap justify-center gap-x-3 gap-y-1">
2203
- {thresholds.map((t, i) => (
2204
- <div key={i} className="flex items-center gap-1.5">
2205
- <span
2206
- className="inline-block h-2 w-2 shrink-0 rounded-full"
2207
- style={{ backgroundColor: t.color }}
2208
- />
2209
- <span className="text-xs text-muted-foreground">{t.label}</span>
2210
- </div>
2211
- ))}
2212
- </div>
2213
- )}
2214
- </div>
2215
- );
2216
- }
2217
-
2218
- // ─── Exports ──────────────────────────────────────────────────────────────────
2219
-
2220
- export {
2221
- ChartContainer,
2222
- ChartTooltip,
2223
- ChartTooltipContent,
2224
- ChartLegend,
2225
- ChartLegendContent,
2226
- ChartStyle,
2227
- ChartCard,
2228
- DashboardBarChart,
2229
- DashboardLineChart,
2230
- HorizontalBarChart,
2231
- InteractiveTimeSeriesChart,
2232
- ComboMetricChart,
2233
- DonutBreakdownChart,
2234
- SparklineChart,
2235
- RadarMetricChart,
2236
- PieMetricChart,
2237
- RadialBarMetricChart,
2238
- GaugeChart,
2239
- };
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import * as RechartsPrimitive from 'recharts';
5
+ import type {
6
+ Payload as TooltipPayloadItem,
7
+ ValueType as TooltipValueType,
8
+ NameType as TooltipNameType,
9
+ } from 'recharts/types/component/DefaultTooltipContent';
10
+ import type { Payload as LegendPayloadItem } from 'recharts/types/component/DefaultLegendContent';
11
+
12
+ import { cn } from '../../shared/utils';
13
+ import { Alert, AlertDescription, AlertTitle } from '../alert';
14
+ import { Button } from '../button';
15
+ import {
16
+ Card,
17
+ CardAction,
18
+ CardContent,
19
+ CardDescription,
20
+ CardFooter,
21
+ CardHeader,
22
+ CardTitle,
23
+ } from '../card';
24
+ import { Empty, EmptyAction, EmptyDescription, EmptyIcon, EmptyTitle } from '../empty';
25
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
26
+ import { Skeleton } from '../skeleton';
27
+ import { BarChart3, RefreshCw, WifiOff } from 'lucide-react';
28
+
29
+ // Format: { THEME_NAME: CSS_SELECTOR }
30
+ const THEMES = { light: '', dark: '.dark' } as const;
31
+
32
+ export type ChartConfig = {
33
+ [k in string]: {
34
+ label?: React.ReactNode;
35
+ icon?: React.ComponentType;
36
+ } & (
37
+ | { color?: string; theme?: never }
38
+ | { color?: never; theme: Record<keyof typeof THEMES, string> }
39
+ );
40
+ };
41
+
42
+ type ChartContextProps = {
43
+ config: ChartConfig;
44
+ };
45
+
46
+ export type DashboardChartDatum = Record<string, string | number | null | undefined>;
47
+
48
+ export type DashboardChartSeries = {
49
+ key: string;
50
+ label?: React.ReactNode;
51
+ type?: 'bar' | 'line' | 'area';
52
+ stackId?: string;
53
+ yAxisId?: string;
54
+ };
55
+
56
+ export type DashboardChartColors = string[] | Record<string, string>;
57
+
58
+ export type ChartPeriod = {
59
+ value: string;
60
+ label: string;
61
+ };
62
+
63
+ export type ChartBarSize = 'sm' | 'md' | 'lg' | 'xl' | number;
64
+
65
+ /** Curve interpolation type for line and area charts */
66
+ export type ChartCurveType =
67
+ | 'monotone'
68
+ | 'linear'
69
+ | 'step'
70
+ | 'stepBefore'
71
+ | 'stepAfter'
72
+ | 'natural'
73
+ | 'basis';
74
+
75
+ type ChartValueFormatter = (value: number | string) => string;
76
+
77
+ export type ChartErrorState = boolean | string | Error | React.ReactNode;
78
+
79
+ export type ChartStateProps = {
80
+ isLoading?: boolean;
81
+ error?: ChartErrorState;
82
+ onRetry?: () => void;
83
+ retryLabel?: React.ReactNode;
84
+ emptyTitle?: React.ReactNode;
85
+ emptyDescription?: React.ReactNode;
86
+ errorTitle?: React.ReactNode;
87
+ errorDescription?: React.ReactNode;
88
+ loadingLabel?: React.ReactNode;
89
+ stateClassName?: string;
90
+ };
91
+
92
+ const defaultPeriods: ChartPeriod[] = [
93
+ { value: '7d', label: '7 days' },
94
+ { value: '30d', label: '30 days' },
95
+ { value: '90d', label: '90 days' },
96
+ ];
97
+
98
+ const defaultChartColors = [
99
+ 'var(--chart-1)',
100
+ 'var(--chart-2)',
101
+ 'var(--chart-3)',
102
+ 'var(--chart-4)',
103
+ 'var(--chart-5)',
104
+ 'var(--chart-6)',
105
+ 'var(--chart-7)',
106
+ 'var(--chart-8)',
107
+ ];
108
+
109
+ const chartBarSizes: Record<Exclude<ChartBarSize, number>, number> = {
110
+ sm: 8,
111
+ md: 14,
112
+ lg: 22,
113
+ xl: 32,
114
+ };
115
+
116
+ const ChartContext = React.createContext<ChartContextProps | null>(null);
117
+
118
+ export function useChart() {
119
+ const context = React.useContext(ChartContext);
120
+
121
+ if (!context) {
122
+ throw new Error('useChart must be used within a <ChartContainer />');
123
+ }
124
+
125
+ return context;
126
+ }
127
+
128
+ /**
129
+ * Root container for Recharts-based charts with theme-aware color injection.
130
+ *
131
+ * @description
132
+ * Wraps Recharts' `ResponsiveContainer` and injects CSS custom properties
133
+ * (`--color-*`) from a `ChartConfig` object, enabling full dark-mode
134
+ * support without hard-coded hex values in chart elements.
135
+ *
136
+ * @ai-rules
137
+ * 1. NEVER pass hex colors directly to Recharts elements (e.g., `fill="#4F46E5"`).
138
+ * Always use `fill="var(--color-keyName)"` referencing the injected CSS variables.
139
+ * 2. This wrapper is REQUIRED to use `ChartTooltipContent` and `ChartLegendContent`.
140
+ * 3. Set height via `className="h-[300px]"` on `ChartContainer`, not on Recharts components.
141
+ * 4. Do NOT add another `<ResponsiveContainer>` — it is already included inside.
142
+ */
143
+ function ChartContainer({
144
+ id,
145
+ className,
146
+ children,
147
+ config,
148
+ ...props
149
+ }: React.ComponentProps<'div'> & {
150
+ config: ChartConfig;
151
+ children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
152
+ }) {
153
+ const uniqueId = React.useId();
154
+ const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
155
+
156
+ return (
157
+ <ChartContext.Provider value={{ config }}>
158
+ <div
159
+ data-slot="chart"
160
+ data-chart={chartId}
161
+ className={cn(
162
+ "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border relative h-[300px] min-h-[200px] w-full min-w-0 overflow-hidden text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
163
+ className
164
+ )}
165
+ {...props}
166
+ >
167
+ <ChartStyle id={chartId} config={config} />
168
+ <RechartsPrimitive.ResponsiveContainer width="100%" height="100%">
169
+ {children}
170
+ </RechartsPrimitive.ResponsiveContainer>
171
+ </div>
172
+ </ChartContext.Provider>
173
+ );
174
+ }
175
+
176
+ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
177
+ const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
178
+
179
+ if (!colorConfig.length) {
180
+ return null;
181
+ }
182
+
183
+ return (
184
+ <style
185
+ dangerouslySetInnerHTML={{
186
+ __html: Object.entries(THEMES)
187
+ .map(
188
+ ([theme, prefix]) => `
189
+ ${prefix} [data-chart=${id}] {
190
+ ${colorConfig
191
+ .map(([key, itemConfig]) => {
192
+ const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
193
+ return color ? ` --color-${key}: ${color};` : null;
194
+ })
195
+ .join('\n')}
196
+ }
197
+ `
198
+ )
199
+ .join('\n'),
200
+ }}
201
+ />
202
+ );
203
+ };
204
+
205
+ const ChartTooltip = RechartsPrimitive.Tooltip;
206
+
207
+ function ChartTooltipContent({
208
+ active,
209
+ payload,
210
+ className,
211
+ indicator = 'dot',
212
+ hideLabel = false,
213
+ hideIndicator = false,
214
+ label,
215
+ labelFormatter,
216
+ labelClassName,
217
+ formatter,
218
+ color,
219
+ nameKey,
220
+ labelKey,
221
+ }: React.ComponentProps<'div'> & {
222
+ active?: boolean;
223
+ payload?: TooltipPayloadItem<TooltipValueType, TooltipNameType>[];
224
+ label?: React.ReactNode;
225
+ labelFormatter?: (label: React.ReactNode, payload: TooltipPayloadItem<TooltipValueType, TooltipNameType>[]) => React.ReactNode;
226
+ labelClassName?: string;
227
+ hideLabel?: boolean;
228
+ hideIndicator?: boolean;
229
+ indicator?: 'line' | 'dot' | 'dashed';
230
+ nameKey?: string;
231
+ labelKey?: string;
232
+ color?: string;
233
+ formatter?: RechartsPrimitive.DefaultTooltipContentProps<TooltipValueType, TooltipNameType>['formatter'];
234
+ }) {
235
+ const { config } = useChart();
236
+
237
+ const tooltipLabel = React.useMemo(() => {
238
+ if (hideLabel || !payload?.length) {
239
+ return null;
240
+ }
241
+
242
+ const [item] = payload;
243
+ const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
244
+ const itemConfig = getPayloadConfigFromPayload(config, item, key);
245
+ const value =
246
+ !labelKey && typeof label === 'string'
247
+ ? config[label as keyof typeof config]?.label || label
248
+ : itemConfig?.label;
249
+
250
+ if (labelFormatter) {
251
+ return (
252
+ <div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>
253
+ );
254
+ }
255
+
256
+ if (!value) {
257
+ return null;
258
+ }
259
+
260
+ return <div className={cn('font-medium', labelClassName)}>{value}</div>;
261
+ }, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
262
+
263
+ if (!active || !payload?.length) {
264
+ return null;
265
+ }
266
+
267
+ const nestLabel = payload.length === 1 && indicator !== 'dot';
268
+
269
+ return (
270
+ <div
271
+ className={cn(
272
+ 'border-border/50 bg-background/95 backdrop-blur-sm grid min-w-[8rem] items-start gap-1.5 rounded-xl border px-3 py-2 text-xs shadow-xl',
273
+ className
274
+ )}
275
+ >
276
+ {!nestLabel ? tooltipLabel : null}
277
+ <div className="grid gap-1.5">
278
+ {payload.map((item: TooltipPayloadItem<TooltipValueType, TooltipNameType>, index: number) => {
279
+ const key = `${nameKey || item.name || item.dataKey || 'value'}`;
280
+ const itemConfig = getPayloadConfigFromPayload(config, item, key);
281
+ const indicatorColor = color || item.payload?.fill || item.color;
282
+
283
+ return (
284
+ <div
285
+ key={`${item.dataKey ?? index}`}
286
+ className={cn(
287
+ '[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
288
+ indicator === 'dot' && 'items-center'
289
+ )}
290
+ >
291
+ {formatter && item?.value !== undefined && item.name ? (
292
+ formatter(item.value, item.name, item, index, item.payload)
293
+ ) : (
294
+ <>
295
+ {itemConfig?.icon ? (
296
+ <itemConfig.icon />
297
+ ) : (
298
+ !hideIndicator && (
299
+ <div
300
+ className={cn(
301
+ 'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
302
+ {
303
+ 'h-2.5 w-2.5': indicator === 'dot',
304
+ 'w-1': indicator === 'line',
305
+ 'w-0 border-[1.5px] border-dashed bg-transparent':
306
+ indicator === 'dashed',
307
+ 'my-0.5': nestLabel && indicator === 'dashed',
308
+ }
309
+ )}
310
+ style={
311
+ {
312
+ '--color-bg': indicatorColor,
313
+ '--color-border': indicatorColor,
314
+ } as React.CSSProperties
315
+ }
316
+ />
317
+ )
318
+ )}
319
+ <div
320
+ className={cn(
321
+ 'flex flex-1 justify-between leading-none',
322
+ nestLabel ? 'items-end' : 'items-center'
323
+ )}
324
+ >
325
+ <div className="grid gap-1.5">
326
+ {nestLabel ? tooltipLabel : null}
327
+ <span className="text-muted-foreground">
328
+ {itemConfig?.label || item.name}
329
+ </span>
330
+ </div>
331
+ {item.value && (
332
+ <span className="text-foreground font-mono font-semibold tabular-nums">
333
+ {item.value.toLocaleString()}
334
+ </span>
335
+ )}
336
+ </div>
337
+ </>
338
+ )}
339
+ </div>
340
+ );
341
+ })}
342
+ </div>
343
+ </div>
344
+ );
345
+ }
346
+
347
+ const ChartLegend = RechartsPrimitive.Legend;
348
+
349
+ function ChartLegendContent({
350
+ className,
351
+ hideIcon = false,
352
+ payload,
353
+ verticalAlign = 'bottom',
354
+ nameKey,
355
+ }: React.ComponentProps<'div'> & {
356
+ payload?: LegendPayloadItem[];
357
+ verticalAlign?: 'top' | 'middle' | 'bottom';
358
+ hideIcon?: boolean;
359
+ nameKey?: string;
360
+ }) {
361
+ const { config } = useChart();
362
+
363
+ if (!payload?.length) {
364
+ return null;
365
+ }
366
+
367
+ return (
368
+ <div
369
+ className={cn(
370
+ 'flex items-center justify-center gap-4',
371
+ verticalAlign === 'top' ? 'pb-3' : 'pt-3',
372
+ className
373
+ )}
374
+ >
375
+ {payload.map(item => {
376
+ const key = `${nameKey ? item.value : item.dataKey || item.value || 'value'}`;
377
+ const itemConfig = getPayloadConfigFromPayload(config, item, key);
378
+
379
+ return (
380
+ <div
381
+ key={item.value}
382
+ className={cn(
383
+ '[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3'
384
+ )}
385
+ >
386
+ {itemConfig?.icon && !hideIcon ? (
387
+ <itemConfig.icon />
388
+ ) : (
389
+ <div
390
+ className="h-2 w-2 shrink-0 rounded-full bg-(--color-bg)"
391
+ style={
392
+ {
393
+ '--color-bg': item.color || `var(--color-${key})`,
394
+ } as React.CSSProperties
395
+ }
396
+ />
397
+ )}
398
+ <span className="text-muted-foreground text-xs">{itemConfig?.label || item.value}</span>
399
+ </div>
400
+ );
401
+ })}
402
+ </div>
403
+ );
404
+ }
405
+
406
+ // Helper to extract item config from a payload.
407
+ function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
408
+ if (typeof payload !== 'object' || payload === null) {
409
+ return undefined;
410
+ }
411
+
412
+ const payloadPayload =
413
+ 'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
414
+ ? payload.payload
415
+ : undefined;
416
+
417
+ let configLabelKey: string = key;
418
+
419
+ if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
420
+ configLabelKey = payload[key as keyof typeof payload] as string;
421
+ } else if (
422
+ payloadPayload &&
423
+ key in payloadPayload &&
424
+ typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
425
+ ) {
426
+ configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
427
+ }
428
+
429
+ return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
430
+ }
431
+
432
+ function getChartSeries(
433
+ config: ChartConfig,
434
+ series?: DashboardChartSeries[]
435
+ ): DashboardChartSeries[] {
436
+ if (series?.length) {
437
+ return series;
438
+ }
439
+
440
+ return Object.entries(config).map(([key, item]) => ({
441
+ key,
442
+ label: item.label,
443
+ }));
444
+ }
445
+
446
+ function getSeriesColor(key: string, index: number, colors?: DashboardChartColors) {
447
+ if (Array.isArray(colors)) {
448
+ return colors[index] || defaultChartColors[index % defaultChartColors.length];
449
+ }
450
+
451
+ return colors?.[key] || defaultChartColors[index % defaultChartColors.length];
452
+ }
453
+
454
+ function getChartConfigWithColors(
455
+ config: ChartConfig,
456
+ keys: string[],
457
+ colors?: DashboardChartColors
458
+ ): ChartConfig {
459
+ return keys.reduce<ChartConfig>((nextConfig, key, index) => {
460
+ const item = config[key];
461
+
462
+ if (item?.theme && !colors) {
463
+ nextConfig[key] = item;
464
+ return nextConfig;
465
+ }
466
+
467
+ nextConfig[key] = {
468
+ label: item?.label || key,
469
+ icon: item?.icon,
470
+ color: colors
471
+ ? getSeriesColor(key, index, colors)
472
+ : item?.color || getSeriesColor(key, index),
473
+ };
474
+
475
+ return nextConfig;
476
+ }, {});
477
+ }
478
+
479
+ /** Build a ChartConfig from a list of keys (no pre-existing config needed). */
480
+ function buildChartConfig(keys: string[], colors?: DashboardChartColors): ChartConfig {
481
+ return keys.reduce<ChartConfig>((cfg, key, index) => {
482
+ cfg[key] = {
483
+ label: key,
484
+ color: getSeriesColor(key, index, colors),
485
+ };
486
+ return cfg;
487
+ }, {});
488
+ }
489
+
490
+ function formatTick(value: number | string) {
491
+ if (typeof value !== 'number') {
492
+ return value;
493
+ }
494
+
495
+ return Intl.NumberFormat('en', {
496
+ notation: 'compact',
497
+ maximumFractionDigits: 1,
498
+ }).format(value);
499
+ }
500
+
501
+ function defaultFilterData(data: DashboardChartDatum[], period: string) {
502
+ const match = period.match(/^(\d+)/);
503
+
504
+ if (!match) {
505
+ return data;
506
+ }
507
+
508
+ const limit = Number(match[1]);
509
+ return data.slice(Math.max(data.length - limit, 0));
510
+ }
511
+
512
+ function getErrorDescription(error: ChartErrorState) {
513
+ if (typeof error === 'string') {
514
+ return error;
515
+ }
516
+
517
+ if (error instanceof Error) {
518
+ return error.message;
519
+ }
520
+
521
+ return undefined;
522
+ }
523
+
524
+ function getBarSize(barSize: ChartBarSize = 'md') {
525
+ return typeof barSize === 'number' ? barSize : chartBarSizes[barSize];
526
+ }
527
+
528
+ function hasChartData(data: DashboardChartDatum[], series: DashboardChartSeries[]) {
529
+ if (!data.length || !series.length) {
530
+ return false;
531
+ }
532
+
533
+ return data.some(item =>
534
+ series.some(serie => {
535
+ const value = item[serie.key];
536
+ return value !== null && value !== undefined && value !== '';
537
+ })
538
+ );
539
+ }
540
+
541
+ function hasPieData(data: DashboardChartDatum[], nameKey: string, valueKey: string) {
542
+ return data.some(item => {
543
+ const name = item[nameKey];
544
+ const value = item[valueKey];
545
+ return (
546
+ name !== null &&
547
+ name !== undefined &&
548
+ name !== '' &&
549
+ value !== null &&
550
+ value !== undefined &&
551
+ value !== ''
552
+ );
553
+ });
554
+ }
555
+
556
+ function ChartState({
557
+ type,
558
+ className,
559
+ error,
560
+ onRetry,
561
+ retryLabel = 'Try again',
562
+ emptyTitle = 'No data available',
563
+ emptyDescription = 'There is no data available for this chart yet.',
564
+ errorTitle = 'Connection error',
565
+ errorDescription,
566
+ loadingLabel = 'Loading chart data',
567
+ }: ChartStateProps & {
568
+ type: 'empty' | 'error' | 'loading';
569
+ className?: string;
570
+ }) {
571
+ if (type === 'loading') {
572
+ return (
573
+ <div
574
+ className={cn(
575
+ 'flex min-h-[240px] flex-col justify-end gap-3 rounded-[var(--radius-card)] border border-border p-6',
576
+ className
577
+ )}
578
+ aria-label={typeof loadingLabel === 'string' ? loadingLabel : undefined}
579
+ >
580
+ <Skeleton className="h-8 w-2/5" />
581
+ <Skeleton className="h-14 w-3/5" />
582
+ <Skeleton className="h-24 w-4/5" />
583
+ <Skeleton className="h-36 w-full" />
584
+ </div>
585
+ );
586
+ }
587
+
588
+ if (type === 'error') {
589
+ return (
590
+ <div
591
+ className={cn(
592
+ 'flex min-h-[240px] items-center justify-center rounded-[var(--radius-card)] border border-border p-6',
593
+ className
594
+ )}
595
+ >
596
+ <Alert variant="destructive" className="max-w-xl">
597
+ <AlertTitle>{errorTitle}</AlertTitle>
598
+ <AlertDescription>
599
+ {errorDescription ||
600
+ getErrorDescription(error) ||
601
+ 'Unable to load chart data. Check your connection and try again.'}
602
+ </AlertDescription>
603
+ {onRetry ? (
604
+ <div className="mt-3">
605
+ <Button size="sm" variant="outline" onClick={onRetry}>
606
+ <RefreshCw className="size-4" />
607
+ {retryLabel}
608
+ </Button>
609
+ </div>
610
+ ) : null}
611
+ </Alert>
612
+ </div>
613
+ );
614
+ }
615
+
616
+ return (
617
+ <Empty className={cn('min-h-[240px]', className)}>
618
+ <EmptyIcon>
619
+ <BarChart3 className="size-10 text-muted-foreground" />
620
+ </EmptyIcon>
621
+ <EmptyTitle>{emptyTitle}</EmptyTitle>
622
+ <EmptyDescription>{emptyDescription}</EmptyDescription>
623
+ {onRetry ? (
624
+ <EmptyAction>
625
+ <Button size="sm" variant="outline" onClick={onRetry}>
626
+ <WifiOff className="size-4" />
627
+ {retryLabel}
628
+ </Button>
629
+ </EmptyAction>
630
+ ) : null}
631
+ </Empty>
632
+ );
633
+ }
634
+
635
+ function getChartState(state: ChartStateProps, hasData: boolean, className?: string) {
636
+ if (state.isLoading) {
637
+ return <ChartState {...state} type="loading" className={className} />;
638
+ }
639
+
640
+ if (state.error) {
641
+ return <ChartState {...state} type="error" className={className} />;
642
+ }
643
+
644
+ if (!hasData) {
645
+ return <ChartState {...state} type="empty" className={className} />;
646
+ }
647
+
648
+ return null;
649
+ }
650
+
651
+ export interface ChartCardProps extends Omit<React.ComponentProps<typeof Card>, 'title'> {
652
+ title: React.ReactNode;
653
+ description?: React.ReactNode;
654
+ action?: React.ReactNode;
655
+ footer?: React.ReactNode;
656
+ contentClassName?: string;
657
+ }
658
+
659
+ function ChartCard({
660
+ title,
661
+ description,
662
+ action,
663
+ footer,
664
+ children,
665
+ className,
666
+ contentClassName,
667
+ ...props
668
+ }: ChartCardProps) {
669
+ return (
670
+ <Card className={cn('w-full min-w-0 overflow-hidden', className)} {...props}>
671
+ <CardHeader>
672
+ <CardTitle>{title}</CardTitle>
673
+ {description ? <CardDescription>{description}</CardDescription> : null}
674
+ {action ? <CardAction>{action}</CardAction> : null}
675
+ </CardHeader>
676
+ <CardContent className={contentClassName}>{children}</CardContent>
677
+ {footer ? <CardFooter>{footer}</CardFooter> : null}
678
+ </Card>
679
+ );
680
+ }
681
+
682
+ // ─── Gradient defs helper ────────────────────────────────────────────────────
683
+
684
+ /**
685
+ * Renders SVG `<defs>` with linear gradients for each series key.
686
+ * Used internally by area charts when `gradientFill` is enabled.
687
+ */
688
+ function AreaGradientDefs({
689
+ seriesKeys,
690
+ opacity = 0.3,
691
+ }: {
692
+ seriesKeys: string[];
693
+ opacity?: number;
694
+ }) {
695
+ return (
696
+ <defs>
697
+ {seriesKeys.map(key => (
698
+ <linearGradient key={key} id={`gradient-${key}`} x1="0" y1="0" x2="0" y2="1">
699
+ <stop offset="5%" stopColor={`var(--color-${key})`} stopOpacity={opacity} />
700
+ <stop offset="95%" stopColor={`var(--color-${key})`} stopOpacity={0} />
701
+ </linearGradient>
702
+ ))}
703
+ </defs>
704
+ );
705
+ }
706
+
707
+ // ─── DashboardBarChart ────────────────────────────────────────────────────────
708
+
709
+ export interface DashboardBarChartProps
710
+ extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
711
+ data: DashboardChartDatum[];
712
+ indexKey?: string;
713
+ series?: DashboardChartSeries[];
714
+ colors?: DashboardChartColors;
715
+ barSize?: ChartBarSize;
716
+ stacked?: boolean;
717
+ showGrid?: boolean;
718
+ showLegend?: boolean;
719
+ valueFormatter?: ChartValueFormatter;
720
+ }
721
+
722
+ function DashboardBarChart({
723
+ data,
724
+ config,
725
+ indexKey = 'name',
726
+ series,
727
+ colors,
728
+ barSize = 'md',
729
+ stacked = false,
730
+ showGrid = true,
731
+ showLegend = true,
732
+ valueFormatter = formatTick,
733
+ isLoading,
734
+ error,
735
+ onRetry,
736
+ retryLabel,
737
+ emptyTitle,
738
+ emptyDescription,
739
+ errorTitle,
740
+ errorDescription,
741
+ loadingLabel,
742
+ stateClassName,
743
+ className,
744
+ ...props
745
+ }: DashboardBarChartProps) {
746
+ const chartSeries = getChartSeries(config, series);
747
+ const chartConfig = getChartConfigWithColors(
748
+ config,
749
+ chartSeries.map(item => item.key),
750
+ colors
751
+ );
752
+ const chartState = getChartState(
753
+ {
754
+ isLoading,
755
+ error,
756
+ onRetry,
757
+ retryLabel,
758
+ emptyTitle,
759
+ emptyDescription,
760
+ errorTitle,
761
+ errorDescription,
762
+ loadingLabel,
763
+ },
764
+ hasChartData(data, chartSeries),
765
+ cn('h-[320px] w-full', stateClassName)
766
+ );
767
+
768
+ const barElements = React.useMemo(() => {
769
+ const topOfStack = new Set<string>();
770
+ if (stacked) {
771
+ const lastByStack = new Map<string, string>();
772
+ chartSeries.forEach(s => lastByStack.set(s.stackId || 'total', s.key));
773
+ lastByStack.forEach(key => topOfStack.add(key));
774
+ }
775
+ return chartSeries.map(item => {
776
+ const isTop = !stacked || topOfStack.has(item.key);
777
+ return (
778
+ <RechartsPrimitive.Bar
779
+ key={item.key}
780
+ dataKey={item.key}
781
+ fill={`var(--color-${item.key})`}
782
+ radius={isTop ? [4, 4, 0, 0] : [0, 0, 0, 0]}
783
+ barSize={getBarSize(barSize)}
784
+ stackId={stacked ? item.stackId || 'total' : item.stackId}
785
+ isAnimationActive
786
+ animationDuration={600}
787
+ animationEasing="ease-out"
788
+ />
789
+ );
790
+ });
791
+ }, [stacked, chartSeries]);
792
+
793
+ if (chartState) {
794
+ return chartState;
795
+ }
796
+
797
+ return (
798
+ <ChartContainer config={chartConfig} className={cn('h-[320px] w-full', className)} {...props}>
799
+ <RechartsPrimitive.BarChart data={data} accessibilityLayer barGap={4}>
800
+ {showGrid ? (
801
+ <RechartsPrimitive.CartesianGrid
802
+ vertical={false}
803
+ strokeDasharray="3 3"
804
+ stroke="var(--border)"
805
+ strokeOpacity={0.5}
806
+ />
807
+ ) : null}
808
+ <RechartsPrimitive.XAxis
809
+ dataKey={indexKey}
810
+ tickLine={false}
811
+ axisLine={false}
812
+ tickMargin={8}
813
+ />
814
+ <RechartsPrimitive.YAxis
815
+ tickLine={false}
816
+ axisLine={false}
817
+ tickMargin={8}
818
+ tickFormatter={valueFormatter}
819
+ />
820
+ <ChartTooltip
821
+ cursor={{ fill: 'var(--muted)', opacity: 0.4, radius: 4 }}
822
+ content={<ChartTooltipContent />}
823
+ />
824
+ {showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
825
+ {barElements}
826
+ </RechartsPrimitive.BarChart>
827
+ </ChartContainer>
828
+ );
829
+ }
830
+
831
+ // ─── DashboardLineChart ───────────────────────────────────────────────────────
832
+
833
+ export interface DashboardLineChartProps
834
+ extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
835
+ data: DashboardChartDatum[];
836
+ indexKey?: string;
837
+ series?: DashboardChartSeries[];
838
+ colors?: DashboardChartColors;
839
+ showDots?: boolean;
840
+ showGrid?: boolean;
841
+ showLegend?: boolean;
842
+ curveType?: ChartCurveType;
843
+ strokeWidth?: number;
844
+ valueFormatter?: ChartValueFormatter;
845
+ }
846
+
847
+ function DashboardLineChart({
848
+ data,
849
+ config,
850
+ indexKey = 'name',
851
+ series,
852
+ colors,
853
+ showDots = false,
854
+ showGrid = true,
855
+ showLegend = true,
856
+ curveType = 'monotone',
857
+ strokeWidth = 2,
858
+ valueFormatter = formatTick,
859
+ isLoading,
860
+ error,
861
+ onRetry,
862
+ retryLabel,
863
+ emptyTitle,
864
+ emptyDescription,
865
+ errorTitle,
866
+ errorDescription,
867
+ loadingLabel,
868
+ stateClassName,
869
+ className,
870
+ ...props
871
+ }: DashboardLineChartProps) {
872
+ const chartSeries = getChartSeries(config, series);
873
+ const chartConfig = getChartConfigWithColors(
874
+ config,
875
+ chartSeries.map(item => item.key),
876
+ colors
877
+ );
878
+ const chartState = getChartState(
879
+ {
880
+ isLoading,
881
+ error,
882
+ onRetry,
883
+ retryLabel,
884
+ emptyTitle,
885
+ emptyDescription,
886
+ errorTitle,
887
+ errorDescription,
888
+ loadingLabel,
889
+ },
890
+ hasChartData(data, chartSeries),
891
+ cn('h-[320px] w-full', stateClassName)
892
+ );
893
+
894
+ if (chartState) {
895
+ return chartState;
896
+ }
897
+
898
+ return (
899
+ <ChartContainer config={chartConfig} className={cn('h-[320px] w-full', className)} {...props}>
900
+ <RechartsPrimitive.LineChart data={data} accessibilityLayer>
901
+ {showGrid ? (
902
+ <RechartsPrimitive.CartesianGrid
903
+ vertical={false}
904
+ strokeDasharray="3 3"
905
+ stroke="var(--border)"
906
+ strokeOpacity={0.5}
907
+ />
908
+ ) : null}
909
+ <RechartsPrimitive.XAxis
910
+ dataKey={indexKey}
911
+ tickLine={false}
912
+ axisLine={false}
913
+ tickMargin={8}
914
+ />
915
+ <RechartsPrimitive.YAxis
916
+ tickLine={false}
917
+ axisLine={false}
918
+ tickMargin={8}
919
+ tickFormatter={valueFormatter}
920
+ />
921
+ <ChartTooltip content={<ChartTooltipContent indicator="line" />} />
922
+ {showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
923
+ {chartSeries.map(item => (
924
+ <RechartsPrimitive.Line
925
+ key={item.key}
926
+ type={curveType}
927
+ dataKey={item.key}
928
+ stroke={`var(--color-${item.key})`}
929
+ strokeWidth={strokeWidth}
930
+ dot={showDots ? { r: 3, fill: `var(--color-${item.key})`, strokeWidth: 0 } : false}
931
+ activeDot={{ r: 5, strokeWidth: 0 }}
932
+ yAxisId={item.yAxisId}
933
+ isAnimationActive
934
+ animationDuration={600}
935
+ animationEasing="ease-out"
936
+ />
937
+ ))}
938
+ </RechartsPrimitive.LineChart>
939
+ </ChartContainer>
940
+ );
941
+ }
942
+
943
+ // ─── HorizontalBarChart ───────────────────────────────────────────────────────
944
+
945
+ export interface HorizontalBarChartProps
946
+ extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
947
+ data: DashboardChartDatum[];
948
+ indexKey?: string;
949
+ series?: DashboardChartSeries[];
950
+ colors?: DashboardChartColors;
951
+ barSize?: ChartBarSize;
952
+ stacked?: boolean;
953
+ categoryWidth?: number;
954
+ showGrid?: boolean;
955
+ showLegend?: boolean;
956
+ valueFormatter?: ChartValueFormatter;
957
+ }
958
+
959
+ function HorizontalBarChart({
960
+ data,
961
+ config,
962
+ indexKey = 'name',
963
+ series,
964
+ colors,
965
+ barSize = 'md',
966
+ stacked = false,
967
+ categoryWidth = 96,
968
+ showGrid = true,
969
+ showLegend = true,
970
+ valueFormatter = formatTick,
971
+ isLoading,
972
+ error,
973
+ onRetry,
974
+ retryLabel,
975
+ emptyTitle,
976
+ emptyDescription,
977
+ errorTitle,
978
+ errorDescription,
979
+ loadingLabel,
980
+ stateClassName,
981
+ className,
982
+ ...props
983
+ }: HorizontalBarChartProps) {
984
+ const chartSeries = getChartSeries(config, series);
985
+ const chartConfig = getChartConfigWithColors(
986
+ config,
987
+ chartSeries.map(item => item.key),
988
+ colors
989
+ );
990
+ const chartState = getChartState(
991
+ {
992
+ isLoading,
993
+ error,
994
+ onRetry,
995
+ retryLabel,
996
+ emptyTitle,
997
+ emptyDescription,
998
+ errorTitle,
999
+ errorDescription,
1000
+ loadingLabel,
1001
+ },
1002
+ hasChartData(data, chartSeries),
1003
+ cn('h-[320px] w-full', stateClassName)
1004
+ );
1005
+
1006
+ if (chartState) {
1007
+ return chartState;
1008
+ }
1009
+
1010
+ return (
1011
+ <ChartContainer config={chartConfig} className={cn('h-[320px] w-full', className)} {...props}>
1012
+ <RechartsPrimitive.BarChart
1013
+ data={data}
1014
+ layout="vertical"
1015
+ accessibilityLayer
1016
+ margin={{ left: 8, right: 16 }}
1017
+ >
1018
+ {showGrid ? (
1019
+ <RechartsPrimitive.CartesianGrid
1020
+ horizontal={false}
1021
+ strokeDasharray="3 3"
1022
+ stroke="var(--border)"
1023
+ strokeOpacity={0.5}
1024
+ />
1025
+ ) : null}
1026
+ <RechartsPrimitive.XAxis
1027
+ type="number"
1028
+ tickLine={false}
1029
+ axisLine={false}
1030
+ tickMargin={8}
1031
+ tickFormatter={valueFormatter}
1032
+ />
1033
+ <RechartsPrimitive.YAxis
1034
+ dataKey={indexKey}
1035
+ type="category"
1036
+ tickLine={false}
1037
+ axisLine={false}
1038
+ tickMargin={8}
1039
+ width={categoryWidth}
1040
+ />
1041
+ <ChartTooltip content={<ChartTooltipContent />} />
1042
+ {showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
1043
+ {chartSeries.map(item => (
1044
+ <RechartsPrimitive.Bar
1045
+ key={item.key}
1046
+ dataKey={item.key}
1047
+ fill={`var(--color-${item.key})`}
1048
+ radius={[0, 4, 4, 0]}
1049
+ barSize={getBarSize(barSize)}
1050
+ stackId={stacked ? item.stackId || 'total' : item.stackId}
1051
+ isAnimationActive
1052
+ animationDuration={600}
1053
+ animationEasing="ease-out"
1054
+ />
1055
+ ))}
1056
+ </RechartsPrimitive.BarChart>
1057
+ </ChartContainer>
1058
+ );
1059
+ }
1060
+
1061
+ // ─── InteractiveTimeSeriesChart ───────────────────────────────────────────────
1062
+
1063
+ export interface InteractiveTimeSeriesChartProps
1064
+ extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
1065
+ data: DashboardChartDatum[];
1066
+ indexKey?: string;
1067
+ series?: DashboardChartSeries[];
1068
+ colors?: DashboardChartColors;
1069
+ periods?: ChartPeriod[];
1070
+ defaultPeriod?: string;
1071
+ defaultSeries?: string;
1072
+ filterData?: (data: DashboardChartDatum[], period: string) => DashboardChartDatum[];
1073
+ showGrid?: boolean;
1074
+ showLegend?: boolean;
1075
+ showDots?: boolean;
1076
+ gradientFill?: boolean;
1077
+ curveType?: ChartCurveType;
1078
+ strokeWidth?: number;
1079
+ valueFormatter?: ChartValueFormatter;
1080
+ }
1081
+
1082
+ function InteractiveTimeSeriesChart({
1083
+ data,
1084
+ config,
1085
+ indexKey = 'date',
1086
+ series,
1087
+ colors,
1088
+ periods = defaultPeriods,
1089
+ defaultPeriod = periods[1]?.value || periods[0]?.value || '30d',
1090
+ defaultSeries,
1091
+ filterData = defaultFilterData,
1092
+ showGrid = true,
1093
+ showLegend = false,
1094
+ showDots = false,
1095
+ gradientFill = true,
1096
+ curveType = 'monotone',
1097
+ strokeWidth = 2,
1098
+ valueFormatter = formatTick,
1099
+ isLoading,
1100
+ error,
1101
+ onRetry,
1102
+ retryLabel,
1103
+ emptyTitle,
1104
+ emptyDescription,
1105
+ errorTitle,
1106
+ errorDescription,
1107
+ loadingLabel,
1108
+ stateClassName,
1109
+ className,
1110
+ ...props
1111
+ }: InteractiveTimeSeriesChartProps) {
1112
+ const chartSeries = getChartSeries(config, series);
1113
+ const chartConfig = getChartConfigWithColors(
1114
+ config,
1115
+ chartSeries.map(item => item.key),
1116
+ colors
1117
+ );
1118
+ const [period, setPeriod] = React.useState(defaultPeriod);
1119
+ const [activeSeries, setActiveSeries] = React.useState(
1120
+ defaultSeries || chartSeries[0]?.key || ''
1121
+ );
1122
+ const filteredData = React.useMemo(() => filterData(data, period), [data, filterData, period]);
1123
+ const visibleSeries =
1124
+ chartSeries.length > 1 ? chartSeries.filter(item => item.key === activeSeries) : chartSeries;
1125
+ const chartState = getChartState(
1126
+ {
1127
+ isLoading,
1128
+ error,
1129
+ onRetry,
1130
+ retryLabel,
1131
+ emptyTitle,
1132
+ emptyDescription,
1133
+ errorTitle,
1134
+ errorDescription,
1135
+ loadingLabel,
1136
+ },
1137
+ hasChartData(filteredData, visibleSeries),
1138
+ cn('h-[320px] w-full', stateClassName)
1139
+ );
1140
+
1141
+ return (
1142
+ <div className="w-full space-y-4">
1143
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
1144
+ {chartSeries.length > 1 ? (
1145
+ <div
1146
+ className="inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground"
1147
+ role="group"
1148
+ aria-label="Selecionar metrica do grafico"
1149
+ >
1150
+ {chartSeries.map(item => {
1151
+ const label = item.label || config[item.key]?.label || item.key;
1152
+
1153
+ return (
1154
+ <Button
1155
+ key={item.key}
1156
+ type="button"
1157
+ variant="ghost"
1158
+ size="sm"
1159
+ aria-pressed={activeSeries === item.key}
1160
+ onClick={() => setActiveSeries(item.key)}
1161
+ className={cn(
1162
+ 'h-8 px-3 text-sm font-medium transition-all',
1163
+ activeSeries === item.key
1164
+ ? 'bg-background text-foreground shadow-sm'
1165
+ : 'text-muted-foreground hover:text-foreground'
1166
+ )}
1167
+ >
1168
+ {label}
1169
+ </Button>
1170
+ );
1171
+ })}
1172
+ </div>
1173
+ ) : (
1174
+ <div />
1175
+ )}
1176
+ <Select value={period} onValueChange={setPeriod}>
1177
+ <SelectTrigger
1178
+ className="w-full sm:w-[160px]"
1179
+ size="sm"
1180
+ aria-label="Selecionar periodo do grafico"
1181
+ >
1182
+ <SelectValue placeholder="Period" />
1183
+ </SelectTrigger>
1184
+ <SelectContent>
1185
+ {periods.map(item => (
1186
+ <SelectItem key={item.value} value={item.value}>
1187
+ {item.label}
1188
+ </SelectItem>
1189
+ ))}
1190
+ </SelectContent>
1191
+ </Select>
1192
+ </div>
1193
+ {chartState || (
1194
+ <ChartContainer
1195
+ config={chartConfig}
1196
+ className={cn('h-[320px] w-full', className)}
1197
+ {...props}
1198
+ >
1199
+ <RechartsPrimitive.AreaChart data={filteredData} accessibilityLayer>
1200
+ {gradientFill && (
1201
+ <AreaGradientDefs seriesKeys={visibleSeries.map(s => s.key)} opacity={0.4} />
1202
+ )}
1203
+ {showGrid ? (
1204
+ <RechartsPrimitive.CartesianGrid
1205
+ vertical={false}
1206
+ strokeDasharray="3 3"
1207
+ stroke="var(--border)"
1208
+ strokeOpacity={0.5}
1209
+ />
1210
+ ) : null}
1211
+ <RechartsPrimitive.XAxis
1212
+ dataKey={indexKey}
1213
+ tickLine={false}
1214
+ axisLine={false}
1215
+ tickMargin={8}
1216
+ />
1217
+ <RechartsPrimitive.YAxis
1218
+ tickLine={false}
1219
+ axisLine={false}
1220
+ tickMargin={8}
1221
+ tickFormatter={valueFormatter}
1222
+ />
1223
+ <ChartTooltip content={<ChartTooltipContent indicator="line" />} />
1224
+ {showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
1225
+ {visibleSeries.map(item => (
1226
+ <RechartsPrimitive.Area
1227
+ key={item.key}
1228
+ type={curveType}
1229
+ dataKey={item.key}
1230
+ stroke={`var(--color-${item.key})`}
1231
+ fill={gradientFill ? `url(#gradient-${item.key})` : `var(--color-${item.key})`}
1232
+ fillOpacity={gradientFill ? 1 : 0.16}
1233
+ strokeWidth={strokeWidth}
1234
+ dot={showDots ? { r: 3, fill: `var(--color-${item.key})`, strokeWidth: 0 } : false}
1235
+ activeDot={{ r: 5, strokeWidth: 0 }}
1236
+ isAnimationActive
1237
+ animationDuration={600}
1238
+ animationEasing="ease-out"
1239
+ />
1240
+ ))}
1241
+ </RechartsPrimitive.AreaChart>
1242
+ </ChartContainer>
1243
+ )}
1244
+ </div>
1245
+ );
1246
+ }
1247
+
1248
+ // ─── ComboMetricChart ─────────────────────────────────────────────────────────
1249
+
1250
+ export interface ComboMetricChartProps
1251
+ extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
1252
+ data: DashboardChartDatum[];
1253
+ indexKey?: string;
1254
+ series?: DashboardChartSeries[];
1255
+ colors?: DashboardChartColors;
1256
+ barSize?: ChartBarSize;
1257
+ showGrid?: boolean;
1258
+ showLegend?: boolean;
1259
+ gradientFill?: boolean;
1260
+ curveType?: ChartCurveType;
1261
+ valueFormatter?: ChartValueFormatter;
1262
+ }
1263
+
1264
+ function ComboMetricChart({
1265
+ data,
1266
+ config,
1267
+ indexKey = 'name',
1268
+ series,
1269
+ colors,
1270
+ barSize = 'md',
1271
+ showGrid = true,
1272
+ showLegend = true,
1273
+ gradientFill = false,
1274
+ curveType = 'monotone',
1275
+ valueFormatter = formatTick,
1276
+ isLoading,
1277
+ error,
1278
+ onRetry,
1279
+ retryLabel,
1280
+ emptyTitle,
1281
+ emptyDescription,
1282
+ errorTitle,
1283
+ errorDescription,
1284
+ loadingLabel,
1285
+ stateClassName,
1286
+ className,
1287
+ ...props
1288
+ }: ComboMetricChartProps) {
1289
+ const chartSeries = getChartSeries(config, series);
1290
+ const chartConfig = getChartConfigWithColors(
1291
+ config,
1292
+ chartSeries.map(item => item.key),
1293
+ colors
1294
+ );
1295
+ const chartState = getChartState(
1296
+ {
1297
+ isLoading,
1298
+ error,
1299
+ onRetry,
1300
+ retryLabel,
1301
+ emptyTitle,
1302
+ emptyDescription,
1303
+ errorTitle,
1304
+ errorDescription,
1305
+ loadingLabel,
1306
+ },
1307
+ hasChartData(data, chartSeries),
1308
+ cn('h-[340px] w-full', stateClassName)
1309
+ );
1310
+
1311
+ if (chartState) {
1312
+ return chartState;
1313
+ }
1314
+
1315
+ const areaSeries = gradientFill ? chartSeries.filter(s => s.type === 'area').map(s => s.key) : [];
1316
+
1317
+ return (
1318
+ <ChartContainer config={chartConfig} className={cn('h-[340px] w-full', className)} {...props}>
1319
+ <RechartsPrimitive.ComposedChart data={data} accessibilityLayer>
1320
+ {gradientFill && areaSeries.length > 0 && (
1321
+ <AreaGradientDefs seriesKeys={areaSeries} opacity={0.35} />
1322
+ )}
1323
+ {showGrid ? (
1324
+ <RechartsPrimitive.CartesianGrid
1325
+ vertical={false}
1326
+ strokeDasharray="3 3"
1327
+ stroke="var(--border)"
1328
+ strokeOpacity={0.5}
1329
+ />
1330
+ ) : null}
1331
+ <RechartsPrimitive.XAxis
1332
+ dataKey={indexKey}
1333
+ tickLine={false}
1334
+ axisLine={false}
1335
+ tickMargin={8}
1336
+ />
1337
+ <RechartsPrimitive.YAxis
1338
+ tickLine={false}
1339
+ axisLine={false}
1340
+ tickMargin={8}
1341
+ tickFormatter={valueFormatter}
1342
+ />
1343
+ <ChartTooltip content={<ChartTooltipContent />} />
1344
+ {showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
1345
+ {chartSeries.map(item =>
1346
+ item.type === 'line' ? (
1347
+ <RechartsPrimitive.Line
1348
+ key={item.key}
1349
+ type={curveType}
1350
+ dataKey={item.key}
1351
+ stroke={`var(--color-${item.key})`}
1352
+ strokeWidth={2}
1353
+ dot={false}
1354
+ activeDot={{ r: 5, strokeWidth: 0 }}
1355
+ yAxisId={item.yAxisId}
1356
+ isAnimationActive
1357
+ animationDuration={600}
1358
+ animationEasing="ease-out"
1359
+ />
1360
+ ) : item.type === 'area' ? (
1361
+ <RechartsPrimitive.Area
1362
+ key={item.key}
1363
+ type={curveType}
1364
+ dataKey={item.key}
1365
+ stroke={`var(--color-${item.key})`}
1366
+ fill={gradientFill ? `url(#gradient-${item.key})` : `var(--color-${item.key})`}
1367
+ fillOpacity={gradientFill ? 1 : 0.12}
1368
+ strokeWidth={2}
1369
+ dot={false}
1370
+ activeDot={{ r: 5, strokeWidth: 0 }}
1371
+ yAxisId={item.yAxisId}
1372
+ isAnimationActive
1373
+ animationDuration={600}
1374
+ animationEasing="ease-out"
1375
+ />
1376
+ ) : (
1377
+ <RechartsPrimitive.Bar
1378
+ key={item.key}
1379
+ dataKey={item.key}
1380
+ fill={`var(--color-${item.key})`}
1381
+ radius={[4, 4, 0, 0]}
1382
+ barSize={getBarSize(barSize)}
1383
+ yAxisId={item.yAxisId}
1384
+ isAnimationActive
1385
+ animationDuration={600}
1386
+ animationEasing="ease-out"
1387
+ />
1388
+ )
1389
+ )}
1390
+ </RechartsPrimitive.ComposedChart>
1391
+ </ChartContainer>
1392
+ );
1393
+ }
1394
+
1395
+ // ─── DonutBreakdownChart ──────────────────────────────────────────────────────
1396
+
1397
+ export interface DonutBreakdownChartProps
1398
+ extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
1399
+ data: DashboardChartDatum[];
1400
+ nameKey?: string;
1401
+ valueKey?: string;
1402
+ colors?: DashboardChartColors;
1403
+ centerLabel?: React.ReactNode;
1404
+ centerValue?: React.ReactNode;
1405
+ showLegend?: boolean;
1406
+ /** Inner radius as percentage string (e.g. "58%") or number */
1407
+ innerRadius?: string | number;
1408
+ /** Outer radius as percentage string (e.g. "82%") or number */
1409
+ outerRadius?: string | number;
1410
+ }
1411
+
1412
+ function DonutBreakdownChart({
1413
+ data,
1414
+ config,
1415
+ nameKey = 'name',
1416
+ valueKey = 'value',
1417
+ colors,
1418
+ centerLabel,
1419
+ centerValue,
1420
+ showLegend = true,
1421
+ innerRadius = '58%',
1422
+ outerRadius = '82%',
1423
+ isLoading,
1424
+ error,
1425
+ onRetry,
1426
+ retryLabel,
1427
+ emptyTitle,
1428
+ emptyDescription,
1429
+ errorTitle,
1430
+ errorDescription,
1431
+ loadingLabel,
1432
+ stateClassName,
1433
+ className,
1434
+ ...props
1435
+ }: DonutBreakdownChartProps) {
1436
+ const [activeIndex, setActiveIndex] = React.useState(0);
1437
+ const chartKeys = data.map((entry, index) => String(entry[nameKey] || `segment-${index}`));
1438
+ const chartConfig = getChartConfigWithColors(config, chartKeys, colors);
1439
+ const chartState = getChartState(
1440
+ {
1441
+ isLoading,
1442
+ error,
1443
+ onRetry,
1444
+ retryLabel,
1445
+ emptyTitle,
1446
+ emptyDescription,
1447
+ errorTitle,
1448
+ errorDescription,
1449
+ loadingLabel,
1450
+ },
1451
+ hasPieData(data, nameKey, valueKey),
1452
+ cn('h-[320px] w-full', stateClassName)
1453
+ );
1454
+
1455
+ if (chartState) {
1456
+ return chartState;
1457
+ }
1458
+
1459
+ return (
1460
+ <ChartContainer config={chartConfig} className={cn('h-[320px] w-full', className)} {...props}>
1461
+ <RechartsPrimitive.PieChart accessibilityLayer>
1462
+ <ChartTooltip
1463
+ cursor={false}
1464
+ content={<ChartTooltipContent hideLabel nameKey={nameKey} />}
1465
+ />
1466
+ {showLegend ? (
1467
+ <ChartLegend content={<ChartLegendContent nameKey={nameKey} />} verticalAlign="bottom" />
1468
+ ) : null}
1469
+ <RechartsPrimitive.Pie
1470
+ data={data}
1471
+ dataKey={valueKey}
1472
+ nameKey={nameKey}
1473
+ innerRadius={innerRadius}
1474
+ outerRadius={outerRadius}
1475
+ paddingAngle={3}
1476
+ onMouseEnter={(_, index) => setActiveIndex(index)}
1477
+ isAnimationActive
1478
+ animationDuration={600}
1479
+ animationEasing="ease-out"
1480
+ >
1481
+ {data.map((entry, index) => {
1482
+ const key = String(entry[nameKey] || `segment-${index}`);
1483
+
1484
+ return (
1485
+ <RechartsPrimitive.Cell
1486
+ key={key}
1487
+ fill={`var(--color-${key})`}
1488
+ opacity={index === activeIndex ? 1 : 0.7}
1489
+ stroke="transparent"
1490
+ />
1491
+ );
1492
+ })}
1493
+ {centerValue || centerLabel ? (
1494
+ <RechartsPrimitive.Label
1495
+ position="center"
1496
+ content={({ viewBox }) => {
1497
+ if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) {
1498
+ return null;
1499
+ }
1500
+
1501
+ return (
1502
+ <text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
1503
+ {centerValue ? (
1504
+ <tspan
1505
+ x={viewBox.cx}
1506
+ y={viewBox.cy}
1507
+ className="fill-foreground text-2xl font-semibold"
1508
+ >
1509
+ {centerValue}
1510
+ </tspan>
1511
+ ) : null}
1512
+ {centerLabel ? (
1513
+ <tspan
1514
+ x={viewBox.cx}
1515
+ y={Number(viewBox.cy) + 22}
1516
+ className="fill-muted-foreground text-xs"
1517
+ >
1518
+ {centerLabel}
1519
+ </tspan>
1520
+ ) : null}
1521
+ </text>
1522
+ );
1523
+ }}
1524
+ />
1525
+ ) : null}
1526
+ </RechartsPrimitive.Pie>
1527
+ </RechartsPrimitive.PieChart>
1528
+ </ChartContainer>
1529
+ );
1530
+ }
1531
+
1532
+ // ─── SparklineChart ───────────────────────────────────────────────────────────
1533
+
1534
+ export interface SparklineChartProps {
1535
+ /** Data array each item must have the `dataKey` field */
1536
+ data: DashboardChartDatum[];
1537
+ /** Key to plot on the Y axis */
1538
+ dataKey: string;
1539
+ /** Color for the line/area — defaults to `var(--chart-1)` */
1540
+ color?: string;
1541
+ /** Show filled area under the line */
1542
+ filled?: boolean;
1543
+ /** Show the last data point as a dot */
1544
+ showEndDot?: boolean;
1545
+ /** Curve interpolation type */
1546
+ curveType?: ChartCurveType;
1547
+ /** Stroke width */
1548
+ strokeWidth?: number;
1549
+ className?: string;
1550
+ }
1551
+
1552
+ /**
1553
+ * Minimal inline sparkline chartno axes, no grid, no tooltip.
1554
+ * Ideal for embedding inside stat cards or table cells.
1555
+ *
1556
+ * @ai-rules
1557
+ * - Use `filled` for area-style sparklines.
1558
+ * - Keep `className` height small (e.g. `h-12` or `h-16`).
1559
+ * - Do NOT wrap in `ChartCard` — it is designed to be inline.
1560
+ */
1561
+ function SparklineChart({
1562
+ data,
1563
+ dataKey,
1564
+ color = 'var(--chart-1)',
1565
+ filled = true,
1566
+ showEndDot = true,
1567
+ curveType = 'monotone',
1568
+ strokeWidth = 2,
1569
+ className,
1570
+ }: SparklineChartProps) {
1571
+ const sparkId = React.useId().replace(/:/g, '');
1572
+ const lastPoint = data[data.length - 1];
1573
+ const lastValue = lastPoint ? lastPoint[dataKey] : undefined;
1574
+
1575
+ return (
1576
+ <div className={cn('relative h-12 w-full min-w-0', className)}>
1577
+ <RechartsPrimitive.ResponsiveContainer width="100%" height="100%">
1578
+ <RechartsPrimitive.AreaChart
1579
+ data={data}
1580
+ margin={{ top: 4, right: showEndDot ? 8 : 0, bottom: 0, left: 0 }}
1581
+ >
1582
+ {filled && (
1583
+ <defs>
1584
+ <linearGradient id={`spark-gradient-${sparkId}`} x1="0" y1="0" x2="0" y2="1">
1585
+ <stop offset="5%" stopColor={color} stopOpacity={0.3} />
1586
+ <stop offset="95%" stopColor={color} stopOpacity={0} />
1587
+ </linearGradient>
1588
+ </defs>
1589
+ )}
1590
+ <RechartsPrimitive.Area
1591
+ type={curveType}
1592
+ dataKey={dataKey}
1593
+ stroke={color}
1594
+ strokeWidth={strokeWidth}
1595
+ fill={filled ? `url(#spark-gradient-${sparkId})` : 'none'}
1596
+ fillOpacity={1}
1597
+ dot={false}
1598
+ activeDot={false}
1599
+ isAnimationActive
1600
+ animationDuration={600}
1601
+ animationEasing="ease-out"
1602
+ />
1603
+ </RechartsPrimitive.AreaChart>
1604
+ </RechartsPrimitive.ResponsiveContainer>
1605
+ {showEndDot && lastValue !== undefined && lastValue !== null && (
1606
+ <div
1607
+ className="pointer-events-none absolute right-0 top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full ring-2 ring-background"
1608
+ style={{ backgroundColor: color }}
1609
+ />
1610
+ )}
1611
+ </div>
1612
+ );
1613
+ }
1614
+
1615
+ // ─── RadarChart ───────────────────────────────────────────────────────────────
1616
+
1617
+ export interface RadarMetricChartProps extends ChartStateProps {
1618
+ /** Data array each item is one axis point on the radar */
1619
+ data: DashboardChartDatum[];
1620
+ /** Key in each datum used as the axis label */
1621
+ labelKey: string;
1622
+ /**
1623
+ * Series to render. Each entry maps to one `<Radar>` element.
1624
+ * Use a single entry for a simple radar, multiple for comparison.
1625
+ */
1626
+ series: DashboardChartSeries[];
1627
+ /** Override colors per series key or as an ordered array */
1628
+ colors?: DashboardChartColors;
1629
+ /** Fill the radar polygon (default: true) */
1630
+ filled?: boolean;
1631
+ /** Fill opacity when `filled` is true (default: 0.25) */
1632
+ fillOpacity?: number;
1633
+ /** Show dots on each axis point (default: false) */
1634
+ showDots?: boolean;
1635
+ /** Show the polar grid lines (default: true) */
1636
+ showGrid?: boolean;
1637
+ /** Show the legend (default: true when multiple series) */
1638
+ showLegend?: boolean;
1639
+ /** Format axis tick values */
1640
+ valueFormatter?: ChartValueFormatter;
1641
+ className?: string;
1642
+ }
1643
+
1644
+ function RadarMetricChart({
1645
+ data,
1646
+ labelKey,
1647
+ series,
1648
+ colors,
1649
+ filled = true,
1650
+ fillOpacity = 0.25,
1651
+ showDots = false,
1652
+ showGrid = true,
1653
+ showLegend,
1654
+ valueFormatter,
1655
+ isLoading,
1656
+ error,
1657
+ onRetry,
1658
+ retryLabel,
1659
+ emptyTitle,
1660
+ emptyDescription,
1661
+ errorTitle,
1662
+ errorDescription,
1663
+ loadingLabel,
1664
+ stateClassName,
1665
+ className,
1666
+ }: RadarMetricChartProps) {
1667
+ const chartConfig = React.useMemo(
1668
+ () =>
1669
+ buildChartConfig(
1670
+ series.map(s => s.key),
1671
+ colors
1672
+ ),
1673
+ [series, colors]
1674
+ );
1675
+
1676
+ const hasData = data.length > 0 && series.length > 0;
1677
+ const chartState = getChartState(
1678
+ {
1679
+ isLoading,
1680
+ error,
1681
+ emptyTitle,
1682
+ emptyDescription,
1683
+ errorTitle,
1684
+ errorDescription,
1685
+ loadingLabel,
1686
+ stateClassName,
1687
+ onRetry,
1688
+ retryLabel,
1689
+ },
1690
+ hasData
1691
+ );
1692
+
1693
+ const displayLegend = showLegend ?? series.length > 1;
1694
+
1695
+ return (
1696
+ <ChartContainer config={chartConfig} className={cn('h-[300px] w-full', className)}>
1697
+ {chartState ?? (
1698
+ <RechartsPrimitive.RadarChart data={data} accessibilityLayer>
1699
+ {showGrid && <RechartsPrimitive.PolarGrid stroke="var(--border)" strokeOpacity={0.6} />}
1700
+ <RechartsPrimitive.PolarAngleAxis
1701
+ dataKey={labelKey}
1702
+ tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }}
1703
+ />
1704
+ <RechartsPrimitive.PolarRadiusAxis
1705
+ tick={false}
1706
+ axisLine={false}
1707
+ tickFormatter={valueFormatter}
1708
+ />
1709
+ <ChartTooltip
1710
+ content={
1711
+ <ChartTooltipContent
1712
+ formatter={valueFormatter ? (v: TooltipValueType) => valueFormatter(v as number) : undefined}
1713
+ />
1714
+ }
1715
+ />
1716
+ {displayLegend && <ChartLegend content={<ChartLegendContent />} />}
1717
+ {series.map(s => (
1718
+ <RechartsPrimitive.Radar
1719
+ key={s.key}
1720
+ name={(s.label as string) ?? s.key}
1721
+ dataKey={s.key}
1722
+ stroke={`var(--color-${s.key})`}
1723
+ fill={filled ? `var(--color-${s.key})` : 'none'}
1724
+ fillOpacity={filled ? fillOpacity : 0}
1725
+ dot={showDots ? { r: 3, fill: `var(--color-${s.key})` } : false}
1726
+ isAnimationActive
1727
+ animationDuration={600}
1728
+ animationEasing="ease-out"
1729
+ />
1730
+ ))}
1731
+ </RechartsPrimitive.RadarChart>
1732
+ )}
1733
+ </ChartContainer>
1734
+ );
1735
+ }
1736
+
1737
+ // ─── PieMetricChart ───────────────────────────────────────────────────────────
1738
+
1739
+ export interface PieMetricChartProps extends ChartStateProps {
1740
+ /** Data array each item is one slice */
1741
+ data: DashboardChartDatum[];
1742
+ /** Key in each datum used as the slice name/label */
1743
+ nameKey: string;
1744
+ /** Key in each datum used as the slice value */
1745
+ valueKey: string;
1746
+ /** Override colors as an ordered array or per-name map */
1747
+ colors?: DashboardChartColors;
1748
+ /** Outer radius of the pie (default: "80%") */
1749
+ outerRadius?: number | string;
1750
+ /** Inner radius — set > 0 to make a donut (default: 0) */
1751
+ innerRadius?: number | string;
1752
+ /** Show percentage labels inside/outside each slice (default: false) */
1753
+ showLabels?: boolean;
1754
+ /** Show the legend (default: true) */
1755
+ showLegend?: boolean;
1756
+ /**
1757
+ * Index of the slice to "explode" (offset outward).
1758
+ * Pass -1 or undefined to disable.
1759
+ */
1760
+ explodeIndex?: number;
1761
+ /** Offset distance for the exploded slice in px (default: 12) */
1762
+ explodeOffset?: number;
1763
+ /** Format tooltip values */
1764
+ valueFormatter?: ChartValueFormatter;
1765
+ className?: string;
1766
+ }
1767
+
1768
+ function PieMetricChart({
1769
+ data,
1770
+ nameKey,
1771
+ valueKey,
1772
+ colors,
1773
+ outerRadius = '80%',
1774
+ innerRadius = 0,
1775
+ showLabels = false,
1776
+ showLegend = true,
1777
+ explodeIndex,
1778
+ explodeOffset = 12,
1779
+ valueFormatter,
1780
+ isLoading,
1781
+ error,
1782
+ onRetry,
1783
+ retryLabel,
1784
+ emptyTitle,
1785
+ emptyDescription,
1786
+ errorTitle,
1787
+ errorDescription,
1788
+ loadingLabel,
1789
+ stateClassName,
1790
+ className,
1791
+ }: PieMetricChartProps) {
1792
+ // Build config from unique name values
1793
+ const names = React.useMemo(() => data.map(d => String(d[nameKey] ?? '')), [data, nameKey]);
1794
+
1795
+ const chartConfig = React.useMemo(() => buildChartConfig(names, colors), [names, colors]);
1796
+
1797
+ const hasData = hasPieData(data, nameKey, valueKey);
1798
+ const chartState = getChartState(
1799
+ {
1800
+ isLoading,
1801
+ error,
1802
+ emptyTitle,
1803
+ emptyDescription,
1804
+ errorTitle,
1805
+ errorDescription,
1806
+ loadingLabel,
1807
+ stateClassName,
1808
+ onRetry,
1809
+ retryLabel,
1810
+ },
1811
+ hasData
1812
+ );
1813
+
1814
+ return (
1815
+ <ChartContainer config={chartConfig} className={cn('h-[300px] w-full', className)}>
1816
+ {chartState ?? (
1817
+ <RechartsPrimitive.PieChart accessibilityLayer>
1818
+ <ChartTooltip
1819
+ content={
1820
+ <ChartTooltipContent
1821
+ nameKey={nameKey}
1822
+ formatter={valueFormatter ? (v: TooltipValueType) => valueFormatter(v as number) : undefined}
1823
+ />
1824
+ }
1825
+ />
1826
+ {showLegend && <ChartLegend content={<ChartLegendContent nameKey={nameKey} />} />}
1827
+ <RechartsPrimitive.Pie
1828
+ data={data}
1829
+ dataKey={valueKey}
1830
+ nameKey={nameKey}
1831
+ outerRadius={outerRadius}
1832
+ innerRadius={innerRadius}
1833
+ paddingAngle={2}
1834
+ label={
1835
+ showLabels
1836
+ ? ({ cx, cy, midAngle, innerRadius: ir, outerRadius: or, percent }) => {
1837
+ if (midAngle == null || percent == null) return null;
1838
+ const RADIAN = Math.PI / 180;
1839
+ const radius = Number(ir) + (Number(or) - Number(ir)) * 1.35;
1840
+ const x = Number(cx) + radius * Math.cos(-midAngle * RADIAN);
1841
+ const y = Number(cy) + radius * Math.sin(-midAngle * RADIAN);
1842
+ return percent > 0.04 ? (
1843
+ <text
1844
+ x={x}
1845
+ y={y}
1846
+ fill="var(--foreground)"
1847
+ textAnchor={x > Number(cx) ? 'start' : 'end'}
1848
+ dominantBaseline="central"
1849
+ fontSize={12}
1850
+ fontWeight={500}
1851
+ >
1852
+ {`${(percent * 100).toFixed(0)}%`}
1853
+ </text>
1854
+ ) : null;
1855
+ }
1856
+ : false
1857
+ }
1858
+ labelLine={false}
1859
+ isAnimationActive
1860
+ animationDuration={600}
1861
+ animationEasing="ease-out"
1862
+ >
1863
+ {data.map((entry, index) => {
1864
+ const name = String(entry[nameKey] ?? '');
1865
+ const isExploded = index === explodeIndex;
1866
+ return (
1867
+ <RechartsPrimitive.Cell
1868
+ key={`cell-${index}`}
1869
+ fill={chartConfig[name]?.color ?? `var(--chart-${(index % 8) + 1})`}
1870
+ stroke="var(--background)"
1871
+ strokeWidth={2}
1872
+ style={
1873
+ isExploded
1874
+ ? {
1875
+ filter: `drop-shadow(0 4px 8px color-mix(in srgb, ${chartConfig[name]?.color ?? 'var(--chart-1)'} 40%, transparent))`,
1876
+ }
1877
+ : undefined
1878
+ }
1879
+ />
1880
+ );
1881
+ })}
1882
+ </RechartsPrimitive.Pie>
1883
+ </RechartsPrimitive.PieChart>
1884
+ )}
1885
+ </ChartContainer>
1886
+ );
1887
+ }
1888
+
1889
+ // ─── RadialBarMetricChart ─────────────────────────────────────────────────────
1890
+
1891
+ export interface RadialBarMetricChartProps extends ChartStateProps {
1892
+ /**
1893
+ * Data array. Each item should have a `name` field (for labels) and
1894
+ * the `dataKey` field (for values, 0–100 for percentage-based display).
1895
+ */
1896
+ data: DashboardChartDatum[];
1897
+ /** Key in each datum used as the bar value (default: "value") */
1898
+ dataKey?: string;
1899
+ /** Key in each datum used as the bar label (default: "name") */
1900
+ nameKey?: string;
1901
+ /** Override colors as an ordered array or per-name map */
1902
+ colors?: DashboardChartColors;
1903
+ /** Inner radius of the radial bar (default: "30%") */
1904
+ innerRadius?: number | string;
1905
+ /** Outer radius of the radial bar (default: "100%") */
1906
+ outerRadius?: number | string;
1907
+ /** Start angle in degrees (default: 90 — top) */
1908
+ startAngle?: number;
1909
+ /** End angle in degrees (default: -270 — full circle) */
1910
+ endAngle?: number;
1911
+ /** Show background track behind each bar (default: true) */
1912
+ showBackground?: boolean;
1913
+ /** Show the legend (default: true) */
1914
+ showLegend?: boolean;
1915
+ /** Format tooltip values */
1916
+ valueFormatter?: ChartValueFormatter;
1917
+ className?: string;
1918
+ }
1919
+
1920
+ function RadialBarMetricChart({
1921
+ data,
1922
+ dataKey = 'value',
1923
+ nameKey = 'name',
1924
+ colors,
1925
+ innerRadius = '30%',
1926
+ outerRadius = '100%',
1927
+ startAngle = 90,
1928
+ endAngle = -270,
1929
+ showBackground = true,
1930
+ showLegend = true,
1931
+ valueFormatter,
1932
+ isLoading,
1933
+ error,
1934
+ onRetry,
1935
+ retryLabel,
1936
+ emptyTitle,
1937
+ emptyDescription,
1938
+ errorTitle,
1939
+ errorDescription,
1940
+ loadingLabel,
1941
+ stateClassName,
1942
+ className,
1943
+ }: RadialBarMetricChartProps) {
1944
+ const names = React.useMemo(() => data.map(d => String(d[nameKey] ?? '')), [data, nameKey]);
1945
+
1946
+ const chartConfig = React.useMemo(() => buildChartConfig(names, colors), [names, colors]);
1947
+
1948
+ const hasData = data.length > 0;
1949
+ const chartState = getChartState(
1950
+ {
1951
+ isLoading,
1952
+ error,
1953
+ emptyTitle,
1954
+ emptyDescription,
1955
+ errorTitle,
1956
+ errorDescription,
1957
+ loadingLabel,
1958
+ stateClassName,
1959
+ onRetry,
1960
+ retryLabel,
1961
+ },
1962
+ hasData
1963
+ );
1964
+
1965
+ // Inject per-item fill color
1966
+ const coloredData = React.useMemo(
1967
+ () =>
1968
+ data.map((item, index) => {
1969
+ const name = String(item[nameKey] ?? '');
1970
+ return {
1971
+ ...item,
1972
+ fill: chartConfig[name]?.color ?? `var(--chart-${(index % 8) + 1})`,
1973
+ };
1974
+ }),
1975
+ [data, nameKey, chartConfig]
1976
+ );
1977
+
1978
+ return (
1979
+ <div className={cn('flex w-full flex-col gap-3', className)}>
1980
+ <ChartContainer config={chartConfig} className="h-[260px] w-full">
1981
+ {chartState ?? (
1982
+ <RechartsPrimitive.RadialBarChart
1983
+ data={coloredData}
1984
+ innerRadius={innerRadius}
1985
+ outerRadius={outerRadius}
1986
+ startAngle={startAngle}
1987
+ endAngle={endAngle}
1988
+ accessibilityLayer
1989
+ >
1990
+ <ChartTooltip
1991
+ cursor={false}
1992
+ content={
1993
+ <ChartTooltipContent
1994
+ nameKey={nameKey}
1995
+ formatter={valueFormatter ? (v: TooltipValueType) => valueFormatter(v as number) : undefined}
1996
+ />
1997
+ }
1998
+ />
1999
+ <RechartsPrimitive.RadialBar
2000
+ dataKey={dataKey}
2001
+ background={showBackground ? { fill: 'var(--muted)' } : false}
2002
+ cornerRadius={6}
2003
+ isAnimationActive
2004
+ animationDuration={700}
2005
+ animationEasing="ease-out"
2006
+ />
2007
+ </RechartsPrimitive.RadialBarChart>
2008
+ )}
2009
+ </ChartContainer>
2010
+ {showLegend && !chartState && (
2011
+ <div className="flex flex-wrap justify-center gap-x-4 gap-y-1.5 px-2">
2012
+ {coloredData.map((item, index) => {
2013
+ const d = item as DashboardChartDatum & { fill: string };
2014
+ const name = String(d[nameKey] ?? '');
2015
+ const fillColor = d.fill ?? `var(--chart-${(index % 8) + 1})`;
2016
+ const val = d[dataKey];
2017
+ return (
2018
+ <div key={name} className="flex items-center gap-1.5">
2019
+ <span
2020
+ className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
2021
+ style={{ backgroundColor: fillColor }}
2022
+ />
2023
+ <span className="text-xs text-muted-foreground">
2024
+ {name}
2025
+ {val !== undefined && val !== null && (
2026
+ <span className="ml-1 font-medium text-foreground">
2027
+ {valueFormatter ? valueFormatter(val as number) : String(val)}
2028
+ </span>
2029
+ )}
2030
+ </span>
2031
+ </div>
2032
+ );
2033
+ })}
2034
+ </div>
2035
+ )}
2036
+ </div>
2037
+ );
2038
+ }
2039
+
2040
+ // ─── GaugeChart ───────────────────────────────────────────────────────────────
2041
+
2042
+ export interface GaugeChartThreshold {
2043
+ /** Upper bound of this zone (0–100) */
2044
+ value: number;
2045
+ /** Color for this zone */
2046
+ color: string;
2047
+ /** Optional label for this zone */
2048
+ label?: string;
2049
+ }
2050
+
2051
+ export interface GaugeChartProps {
2052
+ /** Current value (must be within [min, max]) */
2053
+ value: number;
2054
+ /** Minimum value (default: 0) */
2055
+ min?: number;
2056
+ /** Maximum value (default: 100) */
2057
+ max?: number;
2058
+ /**
2059
+ * Color zones. Each threshold defines the upper bound of a zone.
2060
+ * Zones are evaluated in order; the first zone whose `value >= current %`
2061
+ * determines the active color.
2062
+ * If omitted, uses `--chart-1`.
2063
+ */
2064
+ thresholds?: GaugeChartThreshold[];
2065
+ /** Label shown below the value (e.g. "CPU Usage") */
2066
+ label?: React.ReactNode;
2067
+ /** Format the center value text (default: shows percentage) */
2068
+ valueFormatter?: (value: number, percent: number) => string;
2069
+ /** Show the needle indicator (default: true) */
2070
+ showNeedle?: boolean;
2071
+ className?: string;
2072
+ }
2073
+
2074
+ function GaugeChart({
2075
+ value,
2076
+ min = 0,
2077
+ max = 100,
2078
+ thresholds,
2079
+ label,
2080
+ valueFormatter,
2081
+ showNeedle = true,
2082
+ className,
2083
+ }: GaugeChartProps) {
2084
+ const percent = Math.min(1, Math.max(0, (value - min) / (max - min)));
2085
+ const percentInt = Math.round(percent * 100);
2086
+
2087
+ // Determine active color from thresholds
2088
+ const activeColor = React.useMemo(() => {
2089
+ if (!thresholds || thresholds.length === 0) return 'var(--chart-1)';
2090
+ for (const t of thresholds) {
2091
+ if (percentInt <= t.value) return t.color;
2092
+ }
2093
+ return thresholds[thresholds.length - 1].color;
2094
+ }, [thresholds, percentInt]);
2095
+
2096
+ const displayText = valueFormatter ? valueFormatter(value, percentInt) : `${percentInt}%`;
2097
+
2098
+ // SVG gauge constants viewBox is 200×110, arc center at (100, 100)
2099
+ const cx = 100;
2100
+ const cy = 100;
2101
+ const R = 80; // outer radius
2102
+ const r = 54; // inner radius (track width = 26)
2103
+
2104
+ // Helper: polar → cartesian (angle in degrees, 0° = right, CCW)
2105
+ const polar = (angleDeg: number, radius: number) => {
2106
+ const rad = (angleDeg * Math.PI) / 180;
2107
+ return {
2108
+ x: cx + radius * Math.cos(rad),
2109
+ y: cy - radius * Math.sin(rad),
2110
+ };
2111
+ };
2112
+
2113
+ // Semicircle: from 180° (left) to 0° (right)
2114
+ // Track background arc path
2115
+ const trackStart = polar(180, R);
2116
+ const trackEnd = polar(0, R);
2117
+ const trackStartI = polar(180, r);
2118
+ const trackEndI = polar(0, r);
2119
+ const trackPath = [
2120
+ `M ${trackStart.x} ${trackStart.y}`,
2121
+ `A ${R} ${R} 0 0 1 ${trackEnd.x} ${trackEnd.y}`,
2122
+ `L ${trackEndI.x} ${trackEndI.y}`,
2123
+ `A ${r} ${r} 0 0 0 ${trackStartI.x} ${trackStartI.y}`,
2124
+ 'Z',
2125
+ ].join(' ');
2126
+
2127
+ // Value arc: from 180° (left) clockwise to valueAngle
2128
+ // The gauge is a semicircle (max 180°), so largeArc is always 0.
2129
+ // largeArc=1 would sweep the reflex arc (>180°) which creates the filled-blob bug.
2130
+ const valueAngle = 180 - percent * 180;
2131
+ const valueEnd = polar(valueAngle, R);
2132
+ const valueEndI = polar(valueAngle, r);
2133
+ const valuePath =
2134
+ percent <= 0
2135
+ ? ''
2136
+ : percent >= 1
2137
+ ? trackPath // full arc = same as track
2138
+ : [
2139
+ `M ${trackStart.x} ${trackStart.y}`,
2140
+ `A ${R} ${R} 0 0 1 ${valueEnd.x} ${valueEnd.y}`,
2141
+ `L ${valueEndI.x} ${valueEndI.y}`,
2142
+ `A ${r} ${r} 0 0 0 ${trackStartI.x} ${trackStartI.y}`,
2143
+ 'Z',
2144
+ ].join(' ');
2145
+
2146
+ // Needle: rotates from -90° (left, 0%) to +90° (right, 100%)
2147
+ // In our coordinate system: 180° at 0%, 0° at 100%
2148
+ const needleAngle = 180 - percent * 180;
2149
+ const needleTip = polar(needleAngle, r - 6);
2150
+ const needleBase1 = polar(needleAngle + 90, 5);
2151
+ const needleBase2 = polar(needleAngle - 90, 5);
2152
+
2153
+ return (
2154
+ <div className={cn('flex flex-col items-center gap-2', className)}>
2155
+ <div className="relative w-full max-w-[260px]">
2156
+ <svg viewBox="0 0 200 130" className="w-full" aria-label={`Gauge: ${displayText}`}>
2157
+ {/* Background track */}
2158
+ <path d={trackPath} fill="var(--muted)" />
2159
+
2160
+ {/* Value arc */}
2161
+ {valuePath && <path d={valuePath} fill={activeColor} />}
2162
+
2163
+ {/* Needle */}
2164
+ {showNeedle && (
2165
+ <g>
2166
+ <polygon
2167
+ points={`${needleTip.x},${needleTip.y} ${needleBase1.x},${needleBase1.y} ${needleBase2.x},${needleBase2.y}`}
2168
+ fill="var(--foreground)"
2169
+ opacity={0.85}
2170
+ />
2171
+ <circle cx={cx} cy={cy} r={5} fill="var(--foreground)" />
2172
+ </g>
2173
+ )}
2174
+
2175
+ {/* Value text — placed below the arc baseline (cy=100) to avoid needle overlap */}
2176
+ <text
2177
+ x={cx}
2178
+ y={cy + 18}
2179
+ textAnchor="middle"
2180
+ dominantBaseline="middle"
2181
+ fontSize={22}
2182
+ fontWeight={700}
2183
+ fill="currentColor"
2184
+ className="fill-foreground"
2185
+ >
2186
+ {displayText}
2187
+ </text>
2188
+
2189
+ {/* Label below value */}
2190
+ {label && (
2191
+ <text
2192
+ x={cx}
2193
+ y={cy + 36}
2194
+ textAnchor="middle"
2195
+ dominantBaseline="middle"
2196
+ fontSize={10}
2197
+ fill="currentColor"
2198
+ className="fill-muted-foreground"
2199
+ >
2200
+ {label}
2201
+ </text>
2202
+ )}
2203
+ </svg>
2204
+ </div>
2205
+
2206
+ {/* Threshold legend */}
2207
+ {thresholds && thresholds.length > 0 && (
2208
+ <div className="flex flex-wrap justify-center gap-x-3 gap-y-1">
2209
+ {thresholds.map((t, i) => (
2210
+ <div key={i} className="flex items-center gap-1.5">
2211
+ <span
2212
+ className="inline-block h-2 w-2 shrink-0 rounded-full"
2213
+ style={{ backgroundColor: t.color }}
2214
+ />
2215
+ <span className="text-xs text-muted-foreground">{t.label}</span>
2216
+ </div>
2217
+ ))}
2218
+ </div>
2219
+ )}
2220
+ </div>
2221
+ );
2222
+ }
2223
+
2224
+ // ─── Exports ──────────────────────────────────────────────────────────────────
2225
+
2226
+ export {
2227
+ ChartContainer,
2228
+ ChartTooltip,
2229
+ ChartTooltipContent,
2230
+ ChartLegend,
2231
+ ChartLegendContent,
2232
+ ChartStyle,
2233
+ ChartCard,
2234
+ DashboardBarChart,
2235
+ DashboardLineChart,
2236
+ HorizontalBarChart,
2237
+ InteractiveTimeSeriesChart,
2238
+ ComboMetricChart,
2239
+ DonutBreakdownChart,
2240
+ SparklineChart,
2241
+ RadarMetricChart,
2242
+ PieMetricChart,
2243
+ RadialBarMetricChart,
2244
+ GaugeChart,
2245
+ };