Browse Source

Dashboard view (#3442)

David Höck 10 months ago
parent
commit
f321d56087
31 changed files with 1513 additions and 145 deletions
  1. 451 2
      package-lock.json
  2. 4 0
      packages/dashboard/package.json
  3. 7 5
      packages/dashboard/src/components/data-table/data-table.tsx
  4. 10 9
      packages/dashboard/src/components/layout/app-layout.tsx
  5. 111 74
      packages/dashboard/src/components/layout/nav-main.tsx
  6. 20 0
      packages/dashboard/src/components/shared/alerts.tsx
  7. 49 0
      packages/dashboard/src/components/shared/animated-number.tsx
  8. 6 1
      packages/dashboard/src/components/shared/paginated-list-data-table.tsx
  9. 97 0
      packages/dashboard/src/framework/dashboard-widget/base-widget.tsx
  10. 96 0
      packages/dashboard/src/framework/dashboard-widget/latest-orders-widget/index.tsx
  11. 35 0
      packages/dashboard/src/framework/dashboard-widget/latest-orders-widget/latest-orders-widget.graphql.ts
  12. 29 0
      packages/dashboard/src/framework/dashboard-widget/metrics-widget/chart.tsx
  13. 82 0
      packages/dashboard/src/framework/dashboard-widget/metrics-widget/index.tsx
  14. 14 0
      packages/dashboard/src/framework/dashboard-widget/metrics-widget/metrics-widget.graphql.ts
  15. 167 0
      packages/dashboard/src/framework/dashboard-widget/orders-summary/index.tsx
  16. 14 0
      packages/dashboard/src/framework/dashboard-widget/orders-summary/order-summary-widget.graphql.ts
  17. 15 0
      packages/dashboard/src/framework/dashboard-widget/registry.tsx
  18. 22 0
      packages/dashboard/src/framework/dashboard-widget/types.ts
  19. 44 1
      packages/dashboard/src/framework/defaults.ts
  20. 6 0
      packages/dashboard/src/framework/extension-api/define-dashboard-extension.ts
  21. 8 0
      packages/dashboard/src/framework/extension-api/extension-api-types.ts
  22. 8 4
      packages/dashboard/src/framework/layout-engine/page-layout.tsx
  23. 25 19
      packages/dashboard/src/framework/nav-menu/nav-menu.ts
  24. 3 3
      packages/dashboard/src/hooks/use-local-format.ts
  25. 13 1
      packages/dashboard/src/index.ts
  26. 0 19
      packages/dashboard/src/routes/_authenticated/dashboard.tsx
  27. 149 3
      packages/dashboard/src/routes/_authenticated/index.tsx
  28. 2 1
      packages/dashboard/src/styles.css
  29. 8 2
      packages/dev-server/scripts/generate-past-orders.ts
  30. 9 0
      packages/dev-server/test-plugins/reviews/dashboard/custom-widget.tsx
  31. 9 1
      packages/dev-server/test-plugins/reviews/dashboard/index.tsx

+ 451 - 2
package-lock.json

@@ -15127,6 +15127,69 @@
         "csv-parse": "*"
       }
     },
+    "node_modules/@types/d3-array": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
+      "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-color": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+      "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-ease": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+      "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-interpolate": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+      "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-color": "*"
+      }
+    },
+    "node_modules/@types/d3-path": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+      "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-scale": {
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+      "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-time": "*"
+      }
+    },
+    "node_modules/@types/d3-shape": {
+      "version": "3.1.7",
+      "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
+      "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-path": "*"
+      }
+    },
+    "node_modules/@types/d3-time": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+      "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-timer": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+      "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+      "license": "MIT"
+    },
     "node_modules/@types/dateformat": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/@types/dateformat/-/dateformat-3.0.1.tgz",
@@ -15663,6 +15726,16 @@
         "@types/react": "^19.0.0"
       }
     },
+    "node_modules/@types/react-grid-layout": {
+      "version": "1.3.5",
+      "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz",
+      "integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/react": "*"
+      }
+    },
     "node_modules/@types/resize-observer-browser": {
       "version": "0.1.11",
       "resolved": "https://registry.npmjs.org/@types/resize-observer-browser/-/resize-observer-browser-0.1.11.tgz",
@@ -21101,7 +21174,6 @@
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
       "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
-      "devOptional": true,
       "license": "MIT"
     },
     "node_modules/csv-parse": {
@@ -21133,6 +21205,127 @@
       "devOptional": true,
       "license": "MIT"
     },
+    "node_modules/d3-array": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+      "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+      "license": "ISC",
+      "dependencies": {
+        "internmap": "1 - 2"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-ease": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+      "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-format": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+      "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-path": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+      "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-scale": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+      "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2.10.0 - 3",
+        "d3-format": "1 - 3",
+        "d3-interpolate": "1.2.0 - 3",
+        "d3-time": "2.1.1 - 3",
+        "d3-time-format": "2 - 4"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-shape": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+      "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-path": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+      "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time-format": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+      "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-time": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-timer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/dargs": {
       "version": "8.1.0",
       "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz",
@@ -21386,6 +21579,12 @@
       "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
       "license": "MIT"
     },
+    "node_modules/decimal.js-light": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+      "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+      "license": "MIT"
+    },
     "node_modules/decompress-response": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -21698,6 +21897,16 @@
         "node": ">=6.0.0"
       }
     },
+    "node_modules/dom-helpers": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+      "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.8.7",
+        "csstype": "^3.0.2"
+      }
+    },
     "node_modules/dom-serialize": {
       "version": "2.2.1",
       "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz",
@@ -23261,6 +23470,12 @@
       "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
       "license": "MIT"
     },
+    "node_modules/fast-equals": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
+      "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
+      "license": "MIT"
+    },
     "node_modules/fast-glob": {
       "version": "3.3.2",
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
@@ -23885,6 +24100,33 @@
         "url": "https://github.com/sponsors/rawify"
       }
     },
+    "node_modules/framer-motion": {
+      "version": "12.6.2",
+      "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.6.2.tgz",
+      "integrity": "sha512-7LgPRlPs5aG8UxeZiMCMZz8firC53+2+9TnWV22tuSi38D3IFRxHRUqOREKckAkt6ztX+Dn6weLcatQilJTMcg==",
+      "license": "MIT",
+      "dependencies": {
+        "motion-dom": "^12.6.1",
+        "motion-utils": "^12.5.0",
+        "tslib": "^2.4.0"
+      },
+      "peerDependencies": {
+        "@emotion/is-prop-valid": "*",
+        "react": "^18.0.0 || ^19.0.0",
+        "react-dom": "^18.0.0 || ^19.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@emotion/is-prop-valid": {
+          "optional": true
+        },
+        "react": {
+          "optional": true
+        },
+        "react-dom": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/fresh": {
       "version": "0.5.2",
       "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -26096,6 +26338,15 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/internmap": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+      "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/intl-messageformat": {
       "version": "10.7.15",
       "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.15.tgz",
@@ -31824,6 +32075,47 @@
       "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==",
       "license": "BSD-3-Clause"
     },
+    "node_modules/motion": {
+      "version": "12.6.2",
+      "resolved": "https://registry.npmjs.org/motion/-/motion-12.6.2.tgz",
+      "integrity": "sha512-8OBjjuC59WuWHKmPzVWT5M0t5kDxtkfMfHF1M7Iey6F/nvd0AI15YlPnpGlcagW/eOfkdWDO90U/K5LF/k55Yw==",
+      "license": "MIT",
+      "dependencies": {
+        "framer-motion": "^12.6.2",
+        "tslib": "^2.4.0"
+      },
+      "peerDependencies": {
+        "@emotion/is-prop-valid": "*",
+        "react": "^18.0.0 || ^19.0.0",
+        "react-dom": "^18.0.0 || ^19.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@emotion/is-prop-valid": {
+          "optional": true
+        },
+        "react": {
+          "optional": true
+        },
+        "react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/motion-dom": {
+      "version": "12.6.1",
+      "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.6.1.tgz",
+      "integrity": "sha512-8XVsriTUEVOepoIDgE/LDGdg7qaKXWdt+wQA/8z0p8YzJDLYL8gbimZ3YkCLlj7bB2i/4UBD/g+VO7y9ZY0zHQ==",
+      "license": "MIT",
+      "dependencies": {
+        "motion-utils": "^12.5.0"
+      }
+    },
+    "node_modules/motion-utils": {
+      "version": "12.5.0",
+      "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.5.0.tgz",
+      "integrity": "sha512-+hFFzvimn0sBMP9iPxBa9OtRX35ZQ3py0UHnb8U29VD+d8lQ8zH3dTygJWqK7av2v6yhg7scj9iZuvTS0f4+SA==",
+      "license": "MIT"
+    },
     "node_modules/mrmime": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz",
@@ -35423,6 +35715,29 @@
         "react": "^19.0.0"
       }
     },
+    "node_modules/react-draggable": {
+      "version": "4.4.6",
+      "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz",
+      "integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==",
+      "license": "MIT",
+      "dependencies": {
+        "clsx": "^1.1.1",
+        "prop-types": "^15.8.1"
+      },
+      "peerDependencies": {
+        "react": ">= 16.3.0",
+        "react-dom": ">= 16.3.0"
+      }
+    },
+    "node_modules/react-draggable/node_modules/clsx": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+      "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/react-dropzone": {
       "version": "14.3.8",
       "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
@@ -35440,6 +35755,24 @@
         "react": ">= 16.8 || 18.0.0"
       }
     },
+    "node_modules/react-grid-layout": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.1.tgz",
+      "integrity": "sha512-4Fr+kKMk0+m1HL/BWfHxi/lRuaOmDNNKQDcu7m12+NEYcen20wIuZFo789u3qWCyvUsNUxCiyf0eKq4WiJSNYw==",
+      "license": "MIT",
+      "dependencies": {
+        "clsx": "^2.0.0",
+        "fast-equals": "^4.0.3",
+        "prop-types": "^15.8.1",
+        "react-draggable": "^4.4.5",
+        "react-resizable": "^3.0.5",
+        "resize-observer-polyfill": "^1.5.1"
+      },
+      "peerDependencies": {
+        "react": ">= 16.3.0",
+        "react-dom": ">= 16.3.0"
+      }
+    },
     "node_modules/react-hook-form": {
       "version": "7.54.2",
       "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz",
@@ -35460,7 +35793,6 @@
       "version": "18.3.1",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
       "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
-      "devOptional": true,
       "license": "MIT"
     },
     "node_modules/react-refresh": {
@@ -35520,6 +35852,43 @@
         }
       }
     },
+    "node_modules/react-resizable": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz",
+      "integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==",
+      "license": "MIT",
+      "dependencies": {
+        "prop-types": "15.x",
+        "react-draggable": "^4.0.3"
+      },
+      "peerDependencies": {
+        "react": ">= 16.3"
+      }
+    },
+    "node_modules/react-smooth": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
+      "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-equals": "^5.0.1",
+        "prop-types": "^15.8.1",
+        "react-transition-group": "^4.4.5"
+      },
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/react-smooth/node_modules/fast-equals": {
+      "version": "5.2.2",
+      "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
+      "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
     "node_modules/react-style-singleton": {
       "version": "2.2.3",
       "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -35542,6 +35911,22 @@
         }
       }
     },
+    "node_modules/react-transition-group": {
+      "version": "4.4.5",
+      "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+      "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@babel/runtime": "^7.5.5",
+        "dom-helpers": "^5.0.1",
+        "loose-envify": "^1.4.0",
+        "prop-types": "^15.6.2"
+      },
+      "peerDependencies": {
+        "react": ">=16.6.0",
+        "react-dom": ">=16.6.0"
+      }
+    },
     "node_modules/read": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/read/-/read-3.0.1.tgz",
@@ -35836,6 +36221,38 @@
         "url": "https://github.com/sponsors/jonschlinkert"
       }
     },
+    "node_modules/recharts": {
+      "version": "2.15.1",
+      "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz",
+      "integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==",
+      "license": "MIT",
+      "dependencies": {
+        "clsx": "^2.0.0",
+        "eventemitter3": "^4.0.1",
+        "lodash": "^4.17.21",
+        "react-is": "^18.3.1",
+        "react-smooth": "^4.0.4",
+        "recharts-scale": "^0.4.4",
+        "tiny-invariant": "^1.3.1",
+        "victory-vendor": "^36.6.8"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/recharts-scale": {
+      "version": "0.4.5",
+      "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+      "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+      "license": "MIT",
+      "dependencies": {
+        "decimal.js-light": "^2.4.1"
+      }
+    },
     "node_modules/redent": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -36094,6 +36511,12 @@
       "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
       "license": "MIT"
     },
+    "node_modules/resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
+      "license": "MIT"
+    },
     "node_modules/resolve": {
       "version": "1.22.8",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
@@ -40977,6 +41400,28 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/victory-vendor": {
+      "version": "36.9.2",
+      "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+      "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+      "license": "MIT AND ISC",
+      "dependencies": {
+        "@types/d3-array": "^3.0.3",
+        "@types/d3-ease": "^3.0.0",
+        "@types/d3-interpolate": "^3.0.1",
+        "@types/d3-scale": "^4.0.2",
+        "@types/d3-shape": "^3.1.0",
+        "@types/d3-time": "^3.0.0",
+        "@types/d3-timer": "^3.0.0",
+        "d3-array": "^3.1.6",
+        "d3-ease": "^3.0.1",
+        "d3-interpolate": "^3.0.1",
+        "d3-scale": "^4.0.2",
+        "d3-shape": "^3.1.0",
+        "d3-time": "^3.0.0",
+        "d3-timer": "^3.0.1"
+      }
+    },
     "node_modules/vite": {
       "version": "6.1.1",
       "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.1.tgz",
@@ -46223,12 +46668,15 @@
         "graphql": "~16.10.0",
         "json-edit-react": "^1.23.1",
         "lucide-react": "^0.475.0",
+        "motion": "^12.6.2",
         "next-themes": "^0.4.6",
         "react": "^19.0.0",
         "react-day-picker": "^8.10.1",
         "react-dom": "^19.0.0",
         "react-dropzone": "^14.3.8",
+        "react-grid-layout": "^1.5.1",
         "react-hook-form": "^7.54.2",
+        "recharts": "^2.15.1",
         "sonner": "^2.0.1",
         "tailwind-merge": "^3.0.1",
         "tailwindcss": "^4.0.6",
@@ -46248,6 +46696,7 @@
         "@tanstack/router-plugin": "^1.105.0",
         "@types/react": "^19.0.10",
         "@types/react-dom": "^19.0.4",
+        "@types/react-grid-layout": "^1.3.5",
         "@vitejs/plugin-react": "^4.3.4",
         "eslint": "^9.19.0",
         "eslint-plugin-react": "^7.37.4",

+ 4 - 0
packages/dashboard/package.json

@@ -62,12 +62,15 @@
     "graphql": "~16.10.0",
     "json-edit-react": "^1.23.1",
     "lucide-react": "^0.475.0",
+    "motion": "^12.6.2",
     "next-themes": "^0.4.6",
     "react": "^19.0.0",
     "react-day-picker": "^8.10.1",
     "react-dom": "^19.0.0",
     "react-dropzone": "^14.3.8",
+    "react-grid-layout": "^1.5.1",
     "react-hook-form": "^7.54.2",
+    "recharts": "^2.15.1",
     "sonner": "^2.0.1",
     "tailwind-merge": "^3.0.1",
     "tailwindcss": "^4.0.6",
@@ -87,6 +90,7 @@
     "@tanstack/router-plugin": "^1.105.0",
     "@types/react": "^19.0.10",
     "@types/react-dom": "^19.0.4",
+    "@types/react-grid-layout": "^1.3.5",
     "@vitejs/plugin-react": "^4.3.4",
     "eslint": "^9.19.0",
     "eslint-plugin-react": "^7.37.4",

+ 7 - 5
packages/dashboard/src/components/data-table/data-table.tsx

@@ -16,7 +16,7 @@ import {
     SortingState,
     Table as TableType,
     useReactTable,
-    VisibilityState
+    VisibilityState,
 } from '@tanstack/react-table';
 import { CircleX, Filter } from 'lucide-react';
 import React, { Suspense, useEffect } from 'react';
@@ -43,6 +43,7 @@ interface DataTableProps<TData, TValue> {
     onSearchTermChange?: (searchTerm: string) => void;
     defaultColumnVisibility?: VisibilityState;
     facetedFilters?: { [key: string]: FacetedFilter | undefined };
+    disableViewOptions?: boolean;
 }
 
 export function DataTable<TData, TValue>({
@@ -59,6 +60,7 @@ export function DataTable<TData, TValue>({
     onSearchTermChange,
     defaultColumnVisibility,
     facetedFilters,
+    disableViewOptions,
 }: DataTableProps<TData, TValue>) {
     const [sorting, setSorting] = React.useState<SortingState>(sortingInitialState || []);
     const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(filtersInitialState || []);
@@ -105,8 +107,8 @@ export function DataTable<TData, TValue>({
 
     return (
         <>
-            <div className="flex justify-between items-start mt-2">
-                <div className="flex flex-col">
+            <div className="flex justify-between items-start">
+                <div className="flex flex-col space-y-2">
                     <div className="flex items-center justify-start gap-2">
                         {onSearchTermChange && (
                             <div className="flex items-center">
@@ -129,7 +131,7 @@ export function DataTable<TData, TValue>({
                             ))}
                         </Suspense>
                     </div>
-                    <div className="flex gap-1 mt-2">
+                    <div className="flex gap-1">
                         {columnFilters
                             .filter(f => !facetedFilters?.[f.id])
                             .map(f => {
@@ -155,7 +157,7 @@ export function DataTable<TData, TValue>({
                             })}
                     </div>
                 </div>
-                <DataTableViewOptions table={table} />
+                {!disableViewOptions && <DataTableViewOptions table={table} />}
             </div>
             <div className="rounded-md border my-2">
                 <Table>

+ 10 - 9
packages/dashboard/src/components/layout/app-layout.tsx

@@ -4,6 +4,7 @@ import { Separator } from '@/components/ui/separator.js';
 import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar.js';
 import { Outlet } from '@tanstack/react-router';
 import * as React from 'react';
+import { Alerts } from '../shared/alerts.js';
 
 export function AppLayout() {
     return (
@@ -11,19 +12,19 @@ export function AppLayout() {
             <AppSidebar />
             <SidebarInset>
                 <div className="container mx-auto">
-                    <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
-                        <div className="flex items-center gap-2 px-4">
-                            <SidebarTrigger className="-ml-1" />
-                            <Separator orientation="vertical" className="mr-2 h-4" />
-                            <GeneratedBreadcrumbs />
+                    <header className="border-b border-border flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
+                        <div className="flex items-center justify-between gap-2 px-4 w-full">
+                            <div className="flex items-center justify-start gap-2">
+                                <SidebarTrigger className="-ml-1" />
+                                <Separator orientation="vertical" className="mr-2 h-4" />
+                                <GeneratedBreadcrumbs />
+                            </div>
+                            <Alerts />
                         </div>
                     </header>
-                    <div className="px-4">
-                        <Outlet />
-                    </div>
+                    <Outlet />
                 </div>
             </SidebarInset>
         </SidebarProvider>
     );
 }
-

+ 111 - 74
packages/dashboard/src/components/layout/nav-main.tsx

@@ -9,18 +9,18 @@ import {
     SidebarMenuSubButton,
     SidebarMenuSubItem,
 } from '@/components/ui/sidebar.js';
-import { NavMenuSection } from '@/framework/nav-menu/nav-menu.js';
+import { NavMenuSection, NavMenuItem } from '@/framework/nav-menu/nav-menu.js';
 import { Link, rootRouteId, useLocation, useMatch } from '@tanstack/react-router';
 import { ChevronRight } from 'lucide-react';
 import * as React from 'react';
 
-export function NavMain({ items }: { items: NavMenuSection[] }) {
+export function NavMain({ items }: { items: Array<NavMenuSection | NavMenuItem> }) {
     const location = useLocation();
     // State to track which bottom section is currently open
     const [openBottomSectionId, setOpenBottomSectionId] = React.useState<string | null>(null);
 
     // Split sections into top and bottom groups based on placement property
-    const topSections = items.filter(item => item.placement !== 'bottom');
+    const topSections = items.filter(item => item.placement === 'top');
     const bottomSections = items.filter(item => item.placement === 'bottom');
 
     // Handle bottom section open/close
@@ -38,9 +38,12 @@ export function NavMain({ items }: { items: NavMenuSection[] }) {
 
         // Check if the current path is in any bottom section
         for (const section of bottomSections) {
-            const matchingItem = section.items?.find(
-                item => currentPath === item.url || currentPath.startsWith(`${item.url}/`),
-            );
+            const matchingItem =
+                'items' in section
+                    ? section.items?.find(
+                          item => currentPath === item.url || currentPath.startsWith(`${item.url}/`),
+                      )
+                    : null;
 
             if (matchingItem) {
                 setOpenBottomSectionId(section.id);
@@ -50,78 +53,112 @@ export function NavMain({ items }: { items: NavMenuSection[] }) {
     }, [location.pathname]);
 
     // Render a top navigation section
-    const renderTopSection = (item: NavMenuSection) => (
-        <Collapsible key={item.title} asChild defaultOpen={item.defaultOpen} className="group/collapsible">
-            <SidebarMenuItem>
-                <CollapsibleTrigger asChild>
-                    <SidebarMenuButton tooltip={item.title}>
-                        {item.icon && <item.icon />}
-                        <span>{item.title}</span>
-                        <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
+    const renderTopSection = (item: NavMenuSection | NavMenuItem) => {
+        if ('url' in item) {
+            return (
+                <SidebarMenuItem key={item.title}>
+                    <SidebarMenuButton tooltip={item.title} asChild>
+                        <Link to={item.url}>
+                            {item.icon && <item.icon />}
+                            <span>{item.title}</span>
+                        </Link>
                     </SidebarMenuButton>
-                </CollapsibleTrigger>
-                <CollapsibleContent>
-                    <SidebarMenuSub>
-                        {item.items?.map(subItem => (
-                            <SidebarMenuSubItem key={subItem.title}>
-                                <SidebarMenuSubButton
-                                    asChild
-                                    isActive={
-                                        location.pathname === subItem.url ||
-                                        location.pathname.startsWith(`${subItem.url}/`)
-                                    }
-                                >
-                                    <Link to={subItem.url}>
-                                        <span>{subItem.title}</span>
-                                    </Link>
-                                </SidebarMenuSubButton>
-                            </SidebarMenuSubItem>
-                        ))}
-                    </SidebarMenuSub>
-                </CollapsibleContent>
-            </SidebarMenuItem>
-        </Collapsible>
-    );
+                </SidebarMenuItem>
+            );
+        }
+
+        return (
+            <Collapsible
+                key={item.title}
+                asChild
+                defaultOpen={item.defaultOpen}
+                className="group/collapsible"
+            >
+                <SidebarMenuItem>
+                    <CollapsibleTrigger asChild>
+                        <SidebarMenuButton tooltip={item.title}>
+                            {item.icon && <item.icon />}
+                            <span>{item.title}</span>
+                            <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
+                        </SidebarMenuButton>
+                    </CollapsibleTrigger>
+                    <CollapsibleContent>
+                        <SidebarMenuSub>
+                            {item.items?.map(subItem => (
+                                <SidebarMenuSubItem key={subItem.title}>
+                                    <SidebarMenuSubButton
+                                        asChild
+                                        isActive={
+                                            location.pathname === subItem.url ||
+                                            location.pathname.startsWith(`${subItem.url}/`)
+                                        }
+                                    >
+                                        <Link to={subItem.url}>
+                                            <span>{subItem.title}</span>
+                                        </Link>
+                                    </SidebarMenuSubButton>
+                                </SidebarMenuSubItem>
+                            ))}
+                        </SidebarMenuSub>
+                    </CollapsibleContent>
+                </SidebarMenuItem>
+            </Collapsible>
+        );
+    };
 
     // Render a bottom navigation section with controlled open state
-    const renderBottomSection = (item: NavMenuSection) => (
-        <Collapsible
-            key={item.title}
-            asChild
-            open={openBottomSectionId === item.id}
-            onOpenChange={isOpen => handleBottomSectionToggle(item.id, isOpen)}
-            className="group/collapsible"
-        >
-            <SidebarMenuItem>
-                <CollapsibleTrigger asChild>
-                    <SidebarMenuButton tooltip={item.title}>
-                        {item.icon && <item.icon />}
-                        <span>{item.title}</span>
-                        <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
+    const renderBottomSection = (item: NavMenuSection | NavMenuItem) => {
+        if ('url' in item) {
+            return (
+                <SidebarMenuItem key={item.title}>
+                    <SidebarMenuButton tooltip={item.title} asChild>
+                        <Link to={item.url}>
+                            {item.icon && <item.icon />}
+                            <span>{item.title}</span>
+                        </Link>
                     </SidebarMenuButton>
-                </CollapsibleTrigger>
-                <CollapsibleContent>
-                    <SidebarMenuSub>
-                        {item.items?.map(subItem => (
-                            <SidebarMenuSubItem key={subItem.title}>
-                                <SidebarMenuSubButton
-                                    asChild
-                                    isActive={
-                                        location.pathname === subItem.url ||
-                                        location.pathname.startsWith(`${subItem.url}/`)
-                                    }
-                                >
-                                    <Link to={subItem.url}>
-                                        <span>{subItem.title}</span>
-                                    </Link>
-                                </SidebarMenuSubButton>
-                            </SidebarMenuSubItem>
-                        ))}
-                    </SidebarMenuSub>
-                </CollapsibleContent>
-            </SidebarMenuItem>
-        </Collapsible>
-    );
+                </SidebarMenuItem>
+            );
+        }
+        return (
+            <Collapsible
+                key={item.title}
+                asChild
+                open={openBottomSectionId === item.id}
+                onOpenChange={isOpen => handleBottomSectionToggle(item.id, isOpen)}
+                className="group/collapsible"
+            >
+                <SidebarMenuItem>
+                    <CollapsibleTrigger asChild>
+                        <SidebarMenuButton tooltip={item.title}>
+                            {item.icon && <item.icon />}
+                            <span>{item.title}</span>
+                            <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
+                        </SidebarMenuButton>
+                    </CollapsibleTrigger>
+                    <CollapsibleContent>
+                        <SidebarMenuSub>
+                            {item.items?.map(subItem => (
+                                <SidebarMenuSubItem key={subItem.title}>
+                                    <SidebarMenuSubButton
+                                        asChild
+                                        isActive={
+                                            location.pathname === subItem.url ||
+                                            location.pathname.startsWith(`${subItem.url}/`)
+                                        }
+                                    >
+                                        <Link to={subItem.url}>
+                                            <span>{subItem.title}</span>
+                                        </Link>
+                                    </SidebarMenuSubButton>
+                                </SidebarMenuSubItem>
+                            ))}
+                        </SidebarMenuSub>
+                    </CollapsibleContent>
+                </SidebarMenuItem>
+            </Collapsible>
+        );
+    };
 
     return (
         <>

+ 20 - 0
packages/dashboard/src/components/shared/alerts.tsx

@@ -0,0 +1,20 @@
+import { BellIcon } from 'lucide-react';
+import { Button } from '../ui/button.js';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog.js';
+
+export function Alerts() {
+    return (
+        <Dialog>
+            <DialogTrigger asChild>
+                <Button size="icon" variant="ghost">
+                    <BellIcon />
+                </Button>
+            </DialogTrigger>
+            <DialogContent>
+                <DialogHeader>
+                    <DialogTitle>Alerts</DialogTitle>
+                </DialogHeader>
+            </DialogContent>
+        </Dialog>
+    );
+}

+ 49 - 0
packages/dashboard/src/components/shared/animated-number.tsx

@@ -0,0 +1,49 @@
+import { useChannel } from '@/index.js';
+import { useLocalFormat } from '@/index.js';
+import { motion, useSpring, useTransform } from 'motion/react';
+import { useEffect } from 'react';
+
+type AnimationConfig = {
+    mass?: number;
+    stiffness?: number;
+    damping?: number;
+};
+
+export function AnimatedNumber({
+    value,
+    animationConfig = { mass: 0.8, stiffness: 75, damping: 15 },
+}: {
+    value: number;
+    animationConfig?: AnimationConfig;
+}) {
+    let spring = useSpring(value, animationConfig);
+    let display = useTransform(spring, current => Math.round(current).toLocaleString());
+
+    useEffect(() => {
+        spring.set(value);
+    }, [spring, value]);
+
+    return <motion.span>{display}</motion.span>;
+}
+
+export function AnimatedCurrency({
+    value,
+    animationConfig = { mass: 0.8, stiffness: 75, damping: 15 },
+}: {
+    value: number;
+    animationConfig?: AnimationConfig;
+}) {
+    const { activeChannel } = useChannel();
+    const { formatCurrency } = useLocalFormat();
+
+    let spring = useSpring(value, animationConfig);
+    let display = useTransform(spring, current =>
+        formatCurrency(current, activeChannel?.defaultCurrencyCode ?? 'USD'),
+    );
+
+    useEffect(() => {
+        spring.set(value);
+    }, [spring, value]);
+
+    return <motion.span>{display}</motion.span>;
+}

+ 6 - 1
packages/dashboard/src/components/shared/paginated-list-data-table.tsx

@@ -206,6 +206,7 @@ export interface PaginatedListDataTableProps<
     onFilterChange: (table: Table<any>, filters: ColumnFiltersState) => void;
     facetedFilters?: FacetedFilterConfig<T>;
     rowActions?: RowAction<PaginatedListItemFields<T>>[];
+    disableViewOptions?: boolean;
 }
 
 export const PaginatedListDataTableKey = 'PaginatedListDataTable';
@@ -234,6 +235,7 @@ export function PaginatedListDataTable<
     onFilterChange,
     facetedFilters,
     rowActions,
+    disableViewOptions,
 }: PaginatedListDataTableProps<T, U, V, AC>) {
     const [searchTerm, setSearchTerm] = React.useState<string>('');
     const [debouncedSearchTerm] = useDebounce(searchTerm, 500);
@@ -418,6 +420,7 @@ export function PaginatedListDataTable<
                 onSearchTermChange={onSearchTermChange ? term => setSearchTerm(term) : undefined}
                 defaultColumnVisibility={columnVisibility}
                 facetedFilters={facetedFilters}
+                disableViewOptions={disableViewOptions}
             />
         </PaginatedListContext.Provider>
     );
@@ -445,7 +448,9 @@ function getRowActions(
                                 {action.label}
                             </DropdownMenuItem>
                         ))}
-                        {deleteMutation && <DeleteMutationRowAction deleteMutation={deleteMutation} row={row} />}
+                        {deleteMutation && (
+                            <DeleteMutationRowAction deleteMutation={deleteMutation} row={row} />
+                        )}
                     </DropdownMenuContent>
                 </DropdownMenu>
             );

+ 97 - 0
packages/dashboard/src/framework/dashboard-widget/base-widget.tsx

@@ -0,0 +1,97 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
+import { cn } from '@/lib/utils.js';
+import { Trans } from '@lingui/react/macro';
+import { PropsWithChildren, useRef, useEffect, useState, createContext, useContext } from 'react';
+
+type WidgetDimensions = {
+    width: number;
+    height: number;
+};
+
+export const WidgetContentContext = createContext<WidgetDimensions>({ width: 0, height: 0 });
+
+export const useWidgetDimensions = () => {
+    const context = useContext(WidgetContentContext);
+    if (!context) {
+        throw new Error('useWidgetDimensions must be used within a DashboardBaseWidget');
+    }
+    return context;
+};
+
+export type DashboardBaseWidgetProps = PropsWithChildren<{
+    id: string;
+    title?: string;
+    description?: string;
+    config?: Record<string, unknown>;
+    actions?: React.ReactNode;
+}>;
+
+export function DashboardBaseWidget({
+    id,
+    config,
+    children,
+    title,
+    description,
+    actions,
+}: DashboardBaseWidgetProps) {
+    const headerRef = useRef<HTMLDivElement>(null);
+    const wrapperRef = useRef<HTMLDivElement>(null);
+    const contentRef = useRef<HTMLDivElement>(null);
+    const [dimensions, setDimensions] = useState<WidgetDimensions>({ width: 0, height: 0 });
+
+    useEffect(() => {
+        const updateDimensions = () => {
+            if (wrapperRef.current && contentRef.current) {
+                const contentStyles = window.getComputedStyle(contentRef.current);
+                const paddingTop = parseFloat(contentStyles.paddingTop);
+                const paddingBottom = parseFloat(contentStyles.paddingBottom);
+                const paddingLeft = parseFloat(contentStyles.paddingLeft);
+                const paddingRight = parseFloat(contentStyles.paddingRight);
+
+                const headerHeight = headerRef.current?.offsetHeight ?? 0;
+
+                setDimensions({
+                    width: wrapperRef.current.offsetWidth - paddingLeft - paddingRight,
+                    height: wrapperRef.current.offsetHeight - paddingTop - paddingBottom - headerHeight,
+                });
+            }
+        };
+
+        updateDimensions();
+        const observer = new ResizeObserver(updateDimensions);
+        if (wrapperRef.current) {
+            observer.observe(wrapperRef.current);
+        }
+
+        return () => observer.disconnect();
+    }, []);
+
+    return (
+        <Card
+            ref={wrapperRef}
+            key={`dashboard-widget-${id}`}
+            className={cn('h-full w-full flex flex-col rounded-md', !title && 'pt-6')}
+        >
+            {title && (
+                <CardHeader
+                    ref={headerRef}
+                    className={cn(
+                        'flex flex-row items-center',
+                        actions ? 'justify-between' : 'justify-start',
+                    )}
+                >
+                    <div>
+                        <CardTitle>
+                            <Trans>{title}</Trans>
+                        </CardTitle>
+                        {description && <CardDescription>{description}</CardDescription>}
+                    </div>
+                    {actions && <div className="flex items-center gap-2">{actions}</div>}
+                </CardHeader>
+            )}
+            <CardContent ref={contentRef} className="grow">
+                <WidgetContentContext.Provider value={dimensions}>{children}</WidgetContentContext.Provider>
+            </CardContent>
+        </Card>
+    );
+}

+ 96 - 0
packages/dashboard/src/framework/dashboard-widget/latest-orders-widget/index.tsx

@@ -0,0 +1,96 @@
+import { useQuery } from '@tanstack/react-query';
+import { latestOrdersQuery } from './latest-orders-widget.graphql.js';
+import { DashboardBaseWidget } from '../base-widget.js';
+import { PaginatedListDataTable, addCustomFields, useLocalFormat } from '@/index.js';
+import { ColumnFiltersState } from '@tanstack/react-table';
+import { useState } from 'react';
+import { SortingState } from '@tanstack/react-table';
+import { Link } from '@tanstack/react-router';
+import { formatRelative } from 'date-fns';
+import { Button } from '@/components/ui/button.js';
+export const WIDGET_ID = 'latest-orders-widget';
+
+export function LatestOrdersWidget() {
+    const [sorting, setSorting] = useState<SortingState>([
+        {
+            id: 'orderPlacedAt',
+            desc: true,
+        },
+    ]);
+    const [page, setPage] = useState(1);
+    const [pageSize, setPageSize] = useState(10);
+    const [filters, setFilters] = useState<ColumnFiltersState>([]);
+    const { formatCurrency } = useLocalFormat();
+
+    return (
+        <DashboardBaseWidget id={WIDGET_ID} title="Latest Orders" description="Your latest orders">
+            <PaginatedListDataTable
+                disableViewOptions
+                page={page}
+                transformVariables={variables => ({
+                    ...variables,
+                    options: {
+                        ...variables.options,
+                        filter: {
+                            active: {
+                                eq: false,
+                            },
+                            state: {
+                                notIn: ['Cancelled', 'Draft'],
+                            },
+                        },
+                    },
+                })}
+                customizeColumns={{
+                    code: {
+                        header: 'Code',
+                        cell: ({ row }) => {
+                            return (
+                                <Button variant="ghost" asChild>
+                                    <Link to={`/orders/${row.original.id}`}>{row.original.code}</Link>
+                                </Button>
+                            );
+                        },
+                    },
+                    orderPlacedAt: {
+                        header: 'Placed At',
+                        cell: ({ row }) => {
+                            return (
+                                <span>
+                                    {formatRelative(row.original.orderPlacedAt ?? new Date(), new Date())}
+                                </span>
+                            );
+                        },
+                    },
+                    total: {
+                        header: 'Total',
+                        cell: ({ row }) => {
+                            return (
+                                <span>{formatCurrency(row.original.total, row.original.currencyCode)}</span>
+                            );
+                        },
+                    },
+                }}
+                itemsPerPage={pageSize}
+                sorting={sorting}
+                columnFilters={filters}
+                listQuery={latestOrdersQuery}
+                onPageChange={(_, page, pageSize) => {
+                    setPage(page);
+                    setPageSize(pageSize);
+                }}
+                onSortChange={(_, sorting) => {
+                    setSorting(sorting);
+                }}
+                onFilterChange={(_, filters) => {
+                    setFilters(filters);
+                }}
+                defaultVisibility={{
+                    code: true,
+                    total: true,
+                    orderPlacedAt: true,
+                }}
+            />
+        </DashboardBaseWidget>
+    );
+}

+ 35 - 0
packages/dashboard/src/framework/dashboard-widget/latest-orders-widget/latest-orders-widget.graphql.ts

@@ -0,0 +1,35 @@
+import { graphql } from '@/graphql/graphql.js';
+
+export const latestOrderItemFragment = graphql(`
+    fragment LatestOrderItem on Order {
+        id
+        createdAt
+        updatedAt
+        type
+        orderPlacedAt
+        code
+        state
+        total
+        totalWithTax
+        currencyCode
+        customer {
+            id
+            firstName
+            lastName
+        }
+    }
+`);
+
+export const latestOrdersQuery = graphql(
+    `
+        query GetLatestOrders($options: OrderListOptions) {
+            orders(options: $options) {
+                items {
+                    ...LatestOrderItem
+                }
+                totalItems
+            }
+        }
+    `,
+    [latestOrderItemFragment],
+);

+ 29 - 0
packages/dashboard/src/framework/dashboard-widget/metrics-widget/chart.tsx

@@ -0,0 +1,29 @@
+import { Area, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
+
+import { AreaChart } from 'recharts';
+import { useWidgetDimensions } from '../base-widget.js';
+
+export function MetricsChart({
+    chartData,
+    formatValue,
+}: {
+    chartData: any[];
+    formatValue: (value: any) => string;
+}) {
+    const { width, height } = useWidgetDimensions();
+
+    return (
+        <AreaChart width={width} height={height} data={chartData}>
+            <CartesianGrid strokeDasharray="4 4" />
+            <XAxis className="text-xs text-muted-foreground" dataKey="name" interval={2} />
+            <YAxis className="text-xs text-muted-foreground" tickFormatter={formatValue} />
+            <Tooltip formatter={formatValue} />
+            <Area
+                type="monotone"
+                dataKey="sales"
+                stroke="var(--color-brand)"
+                fill="var(--color-brand-lighter)"
+            />
+        </AreaChart>
+    );
+}

+ 82 - 0
packages/dashboard/src/framework/dashboard-widget/metrics-widget/index.tsx

@@ -0,0 +1,82 @@
+import { api } from '@/graphql/api.js';
+import { useQuery } from '@tanstack/react-query';
+import { useMemo, useState } from 'react';
+import { Area, AreaChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
+import { DashboardBaseWidget, useWidgetDimensions } from '../base-widget.js';
+import { orderChartDataQuery } from './metrics-widget.graphql.js';
+import { MetricsChart } from './chart.js';
+import { useLocalFormat } from '@/hooks/use-local-format.js';
+import { useChannel } from '@/hooks/use-channel.js';
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs.js';
+
+enum DATA_TYPES {
+    OrderCount = 'OrderCount',
+    OrderTotal = 'OrderTotal',
+    AverageOrderValue = 'AverageOrderValue',
+}
+
+export function MetricsWidget() {
+    const { formatDate, formatCurrency } = useLocalFormat();
+    const { activeChannel } = useChannel();
+    const [dataType, setDataType] = useState<DATA_TYPES>(DATA_TYPES.OrderTotal);
+
+    const { data, error } = useQuery({
+        queryKey: ['dashboard-order-metrics', dataType],
+        queryFn: () => {
+            return api.query(orderChartDataQuery, {
+                types: [dataType],
+                refresh: true,
+            });
+        },
+    });
+
+    const chartData = useMemo(() => {
+        const entry = data?.metricSummary.at(0);
+        if (!entry) {
+            return undefined;
+        }
+
+        const { interval, type, entries } = entry;
+
+        const values = entries.map(({ label, value }) => ({
+            name: formatDate(label, { month: 'short', day: 'numeric' }),
+            sales: value,
+        }));
+
+        return {
+            values,
+            interval,
+            type,
+        };
+    }, [data]);
+
+    return (
+        <DashboardBaseWidget
+            id="metrics-widget"
+            title="Metrics"
+            description="Metrics widget"
+            actions={
+                <Tabs defaultValue={dataType} onValueChange={value => setDataType(value as DATA_TYPES)}>
+                    <TabsList>
+                        <TabsTrigger value={DATA_TYPES.OrderCount}>Order Count</TabsTrigger>
+                        <TabsTrigger value={DATA_TYPES.OrderTotal}>Order Total</TabsTrigger>
+                        <TabsTrigger value={DATA_TYPES.AverageOrderValue}>Average Order Value</TabsTrigger>
+                    </TabsList>
+                </Tabs>
+            }
+        >
+            {chartData && (
+                <MetricsChart
+                    formatValue={value => {
+                        if (dataType === DATA_TYPES.OrderCount) {
+                            return value;
+                        }
+
+                        return formatCurrency(value, activeChannel?.defaultCurrencyCode ?? 'USD', 0);
+                    }}
+                    chartData={chartData.values}
+                />
+            )}
+        </DashboardBaseWidget>
+    );
+}

+ 14 - 0
packages/dashboard/src/framework/dashboard-widget/metrics-widget/metrics-widget.graphql.ts

@@ -0,0 +1,14 @@
+import { graphql } from '@/graphql/graphql.js';
+
+export const orderChartDataQuery = graphql(`
+    query GetOrderChartData($refresh: Boolean, $types: [MetricType!]!) {
+        metricSummary(input: { interval: Daily, types: $types, refresh: $refresh }) {
+            interval
+            type
+            entries {
+                label
+                value
+            }
+        }
+    }
+`);

+ 167 - 0
packages/dashboard/src/framework/dashboard-widget/orders-summary/index.tsx

@@ -0,0 +1,167 @@
+import { useQuery } from '@tanstack/react-query';
+import { DashboardBaseWidget } from '../base-widget.js';
+import { orderSummaryQuery } from './order-summary-widget.graphql.js';
+import { api } from '@/graphql/api.js';
+import { useMemo, useState } from 'react';
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs.js';
+import { useChannel, useLocalFormat } from '@/index.js';
+import { startOfDay, endOfDay, subDays, subMonths, startOfMonth, endOfMonth } from 'date-fns';
+import { AnimatedCurrency, AnimatedNumber } from '@/components/shared/animated-number.js';
+
+export const WIDGET_ID = 'orders-summary-widget';
+
+enum Range {
+    Today = 'today',
+    Yesterday = 'yesterday',
+    ThisWeek = 'thisWeek',
+    ThisMonth = 'thisMonth',
+}
+
+interface PercentageChangeProps {
+    value: number;
+}
+
+function PercentageChange({ value }: PercentageChangeProps) {
+    const isZero = value === 0;
+    const isPositive = value > 0;
+    const colorClass = isZero ? 'text-muted-foreground' : isPositive ? 'text-success' : 'text-destructive';
+    const arrow = isZero ? null : isPositive ? '↑' : '↓';
+
+    return (
+        <p className={`text-sm ${colorClass}`}>
+            {arrow} <AnimatedNumber value={Math.abs(value)} />%
+        </p>
+    );
+}
+
+export function OrdersSummaryWidget() {
+    const [range, setRange] = useState<Range>(Range.Today);
+    const { formatCurrency } = useLocalFormat();
+    const { activeChannel } = useChannel();
+
+    const variables = useMemo(() => {
+        const now = new Date();
+
+        switch (range) {
+            case Range.Today: {
+                const today = now;
+                const yesterday = subDays(now, 1);
+
+                return {
+                    start: startOfDay(today).toISOString(),
+                    end: endOfDay(today).toISOString(),
+                    previousStart: startOfDay(yesterday).toISOString(),
+                    previousEnd: endOfDay(yesterday).toISOString(),
+                };
+            }
+            case Range.Yesterday: {
+                const yesterday = subDays(now, 1);
+                const dayBeforeYesterday = subDays(now, 2);
+
+                return {
+                    start: startOfDay(yesterday).toISOString(),
+                    end: endOfDay(yesterday).toISOString(),
+                    previousStart: startOfDay(dayBeforeYesterday).toISOString(),
+                    previousEnd: endOfDay(dayBeforeYesterday).toISOString(),
+                };
+            }
+            case Range.ThisWeek: {
+                const today = now;
+                const sixDaysAgo = subDays(now, 6);
+                const sevenDaysAgo = subDays(now, 7);
+                const thirteenDaysAgo = subDays(now, 13);
+
+                return {
+                    start: startOfDay(sixDaysAgo).toISOString(),
+                    end: endOfDay(today).toISOString(),
+                    previousStart: startOfDay(thirteenDaysAgo).toISOString(),
+                    previousEnd: endOfDay(sevenDaysAgo).toISOString(),
+                };
+            }
+            case Range.ThisMonth: {
+                const lastMonth = subMonths(now, 1);
+                const twoMonthsAgo = subMonths(now, 2);
+
+                return {
+                    start: startOfMonth(lastMonth).toISOString(),
+                    end: endOfMonth(lastMonth).toISOString(),
+                    previousStart: startOfMonth(twoMonthsAgo).toISOString(),
+                    previousEnd: endOfMonth(twoMonthsAgo).toISOString(),
+                };
+            }
+        }
+    }, [range]);
+
+    const { data } = useQuery({
+        queryKey: ['orders-summary', range],
+        queryFn: () =>
+            api.query(orderSummaryQuery, {
+                start: variables.start,
+                end: variables.end,
+            }),
+    });
+
+    const { data: previousData } = useQuery({
+        queryKey: ['orders-summary', 'previous', range],
+        queryFn: () =>
+            api.query(orderSummaryQuery, {
+                start: variables.previousStart,
+                end: variables.previousEnd,
+            }),
+    });
+
+    const calculatePercentChange = (current: number, previous: number) => {
+        if (previous === 0) return 0;
+        return ((current - previous) / previous) * 100;
+    };
+
+    const currentTotalOrders = data?.orders.totalItems ?? 0;
+    const previousTotalOrders = previousData?.orders.totalItems ?? 0;
+    const currentRevenue = data?.orders.items.reduce((acc, order) => acc + order.totalWithTax, 0) ?? 0;
+    const previousRevenue =
+        previousData?.orders.items.reduce((acc, order) => acc + order.totalWithTax, 0) ?? 0;
+
+    const orderChange = calculatePercentChange(currentTotalOrders, previousTotalOrders);
+    const revenueChange = calculatePercentChange(currentRevenue, previousRevenue);
+
+    return (
+        <DashboardBaseWidget
+            id={WIDGET_ID}
+            title="Orders Summary"
+            description="Your orders summary"
+            actions={
+                <Tabs defaultValue={range} onValueChange={value => setRange(value as Range)}>
+                    <TabsList>
+                        <TabsTrigger value={Range.Today}>Today</TabsTrigger>
+                        <TabsTrigger value={Range.Yesterday}>Yesterday</TabsTrigger>
+                        <TabsTrigger value={Range.ThisWeek}>This Week</TabsTrigger>
+                        <TabsTrigger value={Range.ThisMonth}>This Month</TabsTrigger>
+                    </TabsList>
+                </Tabs>
+            }
+        >
+            <div className="flex flex-col gap-4 items-center justify-center text-center">
+                <div className="flex flex-col gap-2">
+                    <p className="text-lg text-muted-foreground">Total Orders</p>
+                    <p className="text-3xl font-semibold">
+                        <AnimatedNumber
+                            animationConfig={{ mass: 0.5, stiffness: 90, damping: 10 }}
+                            value={currentTotalOrders}
+                        />
+                    </p>
+                    <PercentageChange value={orderChange} />
+                </div>
+                <div className="flex flex-col gap-2">
+                    <p className="text-lg text-muted-foreground">Total Revenue</p>
+                    <p className="text-3xl font-semibold">
+                        <AnimatedCurrency
+                            animationConfig={{ mass: 0.2, stiffness: 90, damping: 10 }}
+                            value={currentRevenue}
+                        />
+                    </p>
+                    <PercentageChange value={revenueChange} />
+                </div>
+            </div>
+        </DashboardBaseWidget>
+    );
+}

+ 14 - 0
packages/dashboard/src/framework/dashboard-widget/orders-summary/order-summary-widget.graphql.ts

@@ -0,0 +1,14 @@
+import { graphql } from '@/graphql/graphql.js';
+
+export const orderSummaryQuery = graphql(`
+    query GetOrderSummary($start: DateTime!, $end: DateTime!) {
+        orders(options: { filter: { orderPlacedAt: { between: { start: $start, end: $end } } } }) {
+            totalItems
+            items {
+                id
+                totalWithTax
+                currencyCode
+            }
+        }
+    }
+`);

+ 15 - 0
packages/dashboard/src/framework/dashboard-widget/registry.tsx

@@ -0,0 +1,15 @@
+import { DashboardWidgetDefinition } from './types.js';
+
+const dashboardWidgetRegistry = new Map<string, DashboardWidgetDefinition>();
+
+export function registerDashboardWidget(widget: DashboardWidgetDefinition) {
+    dashboardWidgetRegistry.set(widget.id, widget);
+}
+
+export function getDashboardWidgetRegistry() {
+    return dashboardWidgetRegistry;
+}
+
+export function getDashboardWidget(id: string) {
+    return dashboardWidgetRegistry.get(id);
+}

+ 22 - 0
packages/dashboard/src/framework/dashboard-widget/types.ts

@@ -0,0 +1,22 @@
+import { DashboardBaseWidgetProps } from './base-widget.js';
+
+export type DashboardWidgetInstance = {
+    id: string;
+    widgetId: string;
+    layout: {
+        x: number;
+        y: number;
+        w: number;
+        h: number;
+    };
+    config?: Record<string, unknown>;
+};
+
+export type DashboardWidgetDefinition = {
+    id: string;
+    name: string;
+    component: React.ComponentType<DashboardBaseWidgetProps>;
+    defaultSize: { w: number; h: number; x?: number; y?: number };
+    minSize?: { w: number; h: number };
+    maxSize?: { w: number; h: number };
+};

+ 44 - 1
packages/dashboard/src/framework/defaults.ts

@@ -1,8 +1,30 @@
 import { navMenu } from '@/framework/nav-menu/nav-menu.js';
-import { BookOpen, Bot, Settings2, ShoppingCart, SquareTerminal, Users, Mail, Terminal } from 'lucide-react';
+import {
+    BookOpen,
+    Bot,
+    Settings2,
+    ShoppingCart,
+    SquareTerminal,
+    Users,
+    Mail,
+    Terminal,
+    LayoutDashboardIcon,
+} from 'lucide-react';
+
+import { LatestOrdersWidget } from './dashboard-widget/latest-orders-widget/index.js';
+import { MetricsWidget } from './dashboard-widget/metrics-widget/index.js';
+import { OrdersSummaryWidget } from './dashboard-widget/orders-summary/index.js';
+import { registerDashboardWidget } from './dashboard-widget/registry.js';
 
 navMenu({
     sections: [
+        {
+            id: 'dashboard',
+            title: 'Dashboard',
+            placement: 'top',
+            icon: LayoutDashboardIcon,
+            url: '/',
+        },
         {
             id: 'catalog',
             title: 'Catalog',
@@ -174,3 +196,24 @@ navMenu({
         },
     ],
 });
+
+registerDashboardWidget({
+    id: 'metrics-widget',
+    name: 'Metrics Widget',
+    component: MetricsWidget,
+    defaultSize: { w: 12, h: 6, x: 0, y: 0 },
+});
+
+registerDashboardWidget({
+    id: 'latest-orders-widget',
+    name: 'Latest Orders Widget',
+    component: LatestOrdersWidget,
+    defaultSize: { w: 6, h: 7, x: 0, y: 0 },
+});
+
+registerDashboardWidget({
+    id: 'orders-summary-widget',
+    name: 'Orders Summary Widget',
+    component: OrdersSummaryWidget,
+    defaultSize: { w: 6, h: 3, x: 6, y: 0 },
+});

+ 6 - 0
packages/dashboard/src/framework/extension-api/define-dashboard-extension.ts

@@ -1,3 +1,4 @@
+import { registerDashboardWidget } from '@/framework/dashboard-widget/registry.js';
 import { DashboardExtension } from '@/framework/extension-api/extension-api-types.js';
 import { addNavMenuItem, NavMenuItem } from '@/framework/nav-menu/nav-menu.js';
 import { registerRoute } from '@/framework/page/page-api.js';
@@ -26,6 +27,11 @@ export function defineDashboardExtension(extension: DashboardExtension) {
             }
         }
     }
+    if (extension.widgets) {
+        for (const widget of extension.widgets) {
+            registerDashboardWidget(widget);
+        }
+    }
     if (extensionSourceChangeCallbacks.size) {
         for (const callback of extensionSourceChangeCallbacks) {
             callback();

+ 8 - 0
packages/dashboard/src/framework/extension-api/extension-api-types.ts

@@ -3,6 +3,8 @@ import { NavMenuItem } from '@/framework/nav-menu/nav-menu.js';
 import { AnyRoute, RouteOptions } from '@tanstack/react-router';
 import React from 'react';
 
+import { DashboardWidgetDefinition } from '../dashboard-widget/types.js';
+
 export interface DashboardRouteDefinition {
     component: (route: AnyRoute) => React.ReactNode;
     id: string;
@@ -11,6 +13,12 @@ export interface DashboardRouteDefinition {
     loader?: RouteOptions['loader'];
 }
 
+/**
+ * @description
+ * The main entry point for a dashboard extension.
+ * This is used to define the routes, widgets, etc. that will be displayed in the dashboard.
+ */
 export interface DashboardExtension {
     routes: DashboardRouteDefinition[];
+    widgets: DashboardWidgetDefinition[];
 }

+ 8 - 4
packages/dashboard/src/framework/layout-engine/page-layout.tsx

@@ -3,7 +3,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
 import { Form } from '@/components/ui/form.js';
 import { useCustomFieldConfig } from '@/hooks/use-custom-field-config.js';
 import { cn } from '@/lib/utils.js';
-import React from 'react';
+import React, { ComponentProps, PropsWithChildren } from 'react';
 import { Control, UseFormReturn } from 'react-hook-form';
 import { useMediaQuery } from '@uidotdev/usehooks';
 
@@ -82,12 +82,16 @@ export function DetailFormGrid({ children }: { children: React.ReactNode }) {
     return <div className="md:grid md:grid-cols-2 gap-4 items-start mb-4">{children}</div>;
 }
 
-export function Page({ children }: { children: React.ReactNode }) {
-    return <div className="m-4">{children}</div>;
+export function Page({ children, ...props }: PropsWithChildren<ComponentProps<'div'>>) {
+    return (
+        <div className={cn('m-4', props.className)} {...props}>
+            {children}
+        </div>
+    );
 }
 
 export function PageTitle({ children }: { children: React.ReactNode }) {
-    return <h1 className="text-2xl font-bold mb-4">{children}</h1>;
+    return <h1 className="text-2xl font-semibold mb-4">{children}</h1>;
 }
 
 export function PageActionBar({ children }: { children: React.ReactNode }) {

+ 25 - 19
packages/dashboard/src/framework/nav-menu/nav-menu.ts

@@ -3,23 +3,24 @@ import type { LucideIcon } from 'lucide-react';
 // Define the placement options for navigation sections
 export type NavMenuSectionPlacement = 'top' | 'bottom';
 
-export interface NavMenuItem {
+interface NavMenuBaseItem {
     id: string;
-    title: React.ReactNode;
+    title: string | React.ReactNode;
+    icon?: LucideIcon;
+    placement?: NavMenuSectionPlacement;
+}
+
+export interface NavMenuItem extends NavMenuBaseItem {
     url: string;
 }
 
-export interface NavMenuSection {
-    title: string;
-    id: string;
-    icon?: LucideIcon;
+export interface NavMenuSection extends NavMenuBaseItem {
     defaultOpen?: boolean;
     items?: NavMenuItem[];
-    placement?: NavMenuSectionPlacement;
 }
 
 export interface NavMenuConfig {
-    sections: NavMenuSection[];
+    sections: Array<NavMenuSection | NavMenuItem>;
 }
 
 let navMenuConfig: NavMenuConfig = { sections: [] };
@@ -32,19 +33,24 @@ export function addNavMenuItem(item: NavMenuItem, sectionId: string) {
     navMenuConfig.sections = [...navMenuConfig.sections];
     const sectionIndex = navMenuConfig.sections.findIndex(s => s.id === sectionId);
     if (sectionIndex !== -1) {
-        const section = {
-            ...navMenuConfig.sections[sectionIndex],
-            items: [...(navMenuConfig.sections[sectionIndex]?.items ?? [])],
-        };
-        const itemIndex = section.items.findIndex(i => i.id === item.id);
-        if (itemIndex === -1) {
-            section.items.push(item);
-            navMenuConfig.sections.splice(sectionIndex, 1, section);
+        const sectionFromConfig = navMenuConfig.sections[sectionIndex];
+
+        if ('items' in sectionFromConfig) {
+            const section = {
+                ...sectionFromConfig,
+                items: [...(sectionFromConfig.items ?? [])],
+            };
+            const itemIndex = section.items.findIndex(i => i.id === item.id);
+            if (itemIndex === -1) {
+                section.items.push(item);
+                navMenuConfig.sections.splice(sectionIndex, 1, section);
+            } else {
+                section.items.splice(itemIndex, 1, item);
+                navMenuConfig.sections.splice(sectionIndex, 1, section);
+            }
         } else {
-            section.items.splice(itemIndex, 1, item);
+            navMenuConfig.sections.splice(sectionIndex, 1, item);
         }
-
-        navMenuConfig.sections.splice(sectionIndex, 1, section);
     }
 }
 

+ 3 - 3
packages/dashboard/src/hooks/use-local-format.ts

@@ -40,12 +40,12 @@ export function useLocalFormat() {
     );
 
     const formatCurrency = useCallback(
-        (value: number, currency: string) => {
+        (value: number, currency: string, precision?: number) => {
             return i18n.number(toMajorUnits(value), {
                 style: 'currency',
                 currency,
-                minimumFractionDigits: moneyStrategyPrecision,
-                maximumFractionDigits: moneyStrategyPrecision,
+                minimumFractionDigits: precision ?? moneyStrategyPrecision,
+                maximumFractionDigits: precision ?? moneyStrategyPrecision,
             });
         },
         [i18n, moneyStrategyPrecision, toMajorUnits],

+ 13 - 1
packages/dashboard/src/index.ts

@@ -1,5 +1,5 @@
 // This file is auto-generated. Do not edit manually.
-// Generated on: 2025-03-26T14:10:22.200Z
+// Generated on: 2025-03-26T15:54:22.739Z
 
 export * from './components/data-display/boolean.js';
 export * from './components/data-display/date-time.js';
@@ -25,6 +25,8 @@ export * from './components/layout/nav-main.js';
 export * from './components/layout/nav-projects.js';
 export * from './components/layout/nav-user.js';
 export * from './components/login/login-form.js';
+export * from './components/shared/alerts.js';
+export * from './components/shared/animated-number.js';
 export * from './components/shared/asset-gallery.js';
 export * from './components/shared/asset-picker-dialog.js';
 export * from './components/shared/asset-preview-dialog.js';
@@ -100,6 +102,16 @@ export * from './components/ui/textarea.js';
 export * from './components/ui/tooltip.js';
 export * from './framework/component-registry/component-registry.js';
 export * from './framework/component-registry/dynamic-component.js';
+export * from './framework/dashboard-widget/base-widget.js';
+export * from './framework/dashboard-widget/latest-orders-widget/index.js';
+export * from './framework/dashboard-widget/latest-orders-widget/latest-orders-widget.graphql.js';
+export * from './framework/dashboard-widget/metrics-widget/chart.js';
+export * from './framework/dashboard-widget/metrics-widget/index.js';
+export * from './framework/dashboard-widget/metrics-widget/metrics-widget.graphql.js';
+export * from './framework/dashboard-widget/orders-summary/index.js';
+export * from './framework/dashboard-widget/orders-summary/order-summary-widget.graphql.js';
+export * from './framework/dashboard-widget/registry.js';
+export * from './framework/dashboard-widget/types.js';
 export * from './framework/defaults.js';
 export * from './framework/document-introspection/add-custom-fields.js';
 export * from './framework/document-introspection/get-document-structure.js';

+ 0 - 19
packages/dashboard/src/routes/_authenticated/dashboard.tsx

@@ -1,19 +0,0 @@
-import * as React from 'react';
-import { createFileRoute } from '@tanstack/react-router';
-
-export const Route = createFileRoute('/_authenticated/dashboard')({
-    component: Dashboard,
-});
-
-function Dashboard() {
-    return (
-        <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
-            <div className="grid auto-rows-min gap-4 md:grid-cols-3">
-                <div className="aspect-video rounded-xl bg-muted/50" />
-                <div className="aspect-video rounded-xl bg-muted/50" />
-                <div className="aspect-video rounded-xl bg-muted/50" />
-            </div>
-            <div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
-        </div>
-    );
-}

+ 149 - 3
packages/dashboard/src/routes/_authenticated/index.tsx

@@ -1,9 +1,155 @@
+import { Button } from '@/components/ui/button.js';
+import { DashboardBaseWidgetProps } from '@/framework/dashboard-widget/base-widget.js';
+import { LatestOrdersWidget } from '@/framework/dashboard-widget/latest-orders-widget/index.js';
+import { MetricsWidget } from '@/framework/dashboard-widget/metrics-widget/index.js';
+import { OrdersSummaryWidget } from '@/framework/dashboard-widget/orders-summary/index.js';
+import { getDashboardWidget, getDashboardWidgetRegistry } from '@/framework/dashboard-widget/registry.js';
+import { DashboardWidgetInstance, WidgetDefinition } from '@/framework/dashboard-widget/types.js';
+import { Page, PageActionBar, PageActionBarRight, PageTitle } from '@/framework/layout-engine/page-layout.js';
 import { createFileRoute } from '@tanstack/react-router';
+import * as React from 'react';
+import { useEffect, useMemo, useState } from 'react';
+import { Responsive as ResponsiveGridLayout, WidthProvider } from 'react-grid-layout';
+import 'react-grid-layout/css/styles.css';
+import 'react-resizable/css/styles.css';
 
 export const Route = createFileRoute('/_authenticated/')({
-    component: RouteComponent,
+    component: DashboardPage,
 });
 
-function RouteComponent() {
-    return <div>Hello "/_authenticated/"!</div>;
+const findNextPosition = (
+    existingWidgets: DashboardWidgetInstance[],
+    newWidgetSize: { w: number; h: number },
+) => {
+    // Create a set of all occupied cells
+    const occupied = new Set();
+    let maxExistingRow = 0;
+
+    existingWidgets.forEach(widget => {
+        const { x, y, w, h } = widget.layout;
+        // Track the maximum row used by existing widgets
+        maxExistingRow = Math.max(maxExistingRow, y + h);
+
+        for (let i = x; i < x + w; i++) {
+            for (let j = y; j < y + h; j++) {
+                occupied.add(`${i},${j}`);
+            }
+        }
+    });
+
+    // Search up to 3 rows past the last occupied row
+    const maxSearchRows = maxExistingRow + 3;
+
+    // Find first position where the widget fits
+    for (let y = 0; y < maxSearchRows; y++) {
+        for (let x = 0; x < 12 - (newWidgetSize.w || 1); x++) {
+            let fits = true;
+            // Check if all cells needed for this widget are free
+            for (let i = x; i < x + (newWidgetSize.w || 1); i++) {
+                for (let j = y; j < y + (newWidgetSize.h || 1); j++) {
+                    if (occupied.has(`${i},${j}`)) {
+                        fits = false;
+                        break;
+                    }
+                }
+                if (!fits) break;
+            }
+            if (fits) {
+                return { x, y };
+            }
+        }
+    }
+    // If no space found, place it in the next row after all existing widgets
+    return { x: 0, y: maxExistingRow };
+};
+
+function DashboardPage() {
+    const [widgets, setWidgets] = useState<DashboardWidgetInstance[]>([]);
+    const [editMode, setEditMode] = useState(false);
+
+    useEffect(() => {
+        const initialWidgets = Array.from(getDashboardWidgetRegistry().entries()).reduce(
+            (acc: DashboardWidgetInstance[], [id, widget]) => {
+                const layout = {
+                    ...widget.defaultSize,
+                    x: widget.defaultSize.x ?? 0,
+                    y: widget.defaultSize.y ?? 0,
+                };
+
+                // If x or y is not set, find the next available position
+                if (widget.defaultSize.x === undefined || widget.defaultSize.y === undefined) {
+                    const pos = findNextPosition(acc, {
+                        w: widget.defaultSize.w || 1,
+                        h: widget.defaultSize.h || 1,
+                    });
+                    layout.x = pos.x;
+                    layout.y = pos.y;
+                }
+
+                return [
+                    ...acc,
+                    {
+                        id,
+                        widgetId: id,
+                        layout,
+                    },
+                ];
+            },
+            [],
+        );
+
+        setWidgets(initialWidgets);
+    }, []);
+
+    const handleLayoutChange = (layout: ReactGridLayout.Layout[]) => {
+        console.log({ layout });
+        setWidgets(prev =>
+            prev.map((widget, i) => ({
+                ...widget,
+                layout: layout[i],
+            })),
+        );
+    };
+
+    const ResponsiveReactGridLayout = useMemo(() => WidthProvider(ResponsiveGridLayout), []);
+
+    return (
+        <Page className="min-h-dvh w-full">
+            <PageTitle>Dashboard</PageTitle>
+            <PageActionBar>
+                <PageActionBarRight>
+                    <Button variant="outline" onClick={() => setEditMode(prev => !prev)}>
+                        Edit Mode
+                        {editMode ? (
+                            <span className="text-xs text-destructive">ON</span>
+                        ) : (
+                            <span className="text-xs text-muted-foreground">OFF</span>
+                        )}
+                    </Button>
+                </PageActionBarRight>
+            </PageActionBar>
+            <ResponsiveReactGridLayout
+                className="h-full w-full"
+                layouts={{ lg: widgets.map(w => ({ ...w.layout, i: w.id })) }}
+                onLayoutChange={handleLayoutChange}
+                cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
+                rowHeight={100}
+                isDraggable={editMode}
+                isResizable={editMode}
+                autoSize={true}
+            >
+                {widgets.map(widget => {
+                    const definition = getDashboardWidget(widget.widgetId);
+                    if (!definition) return null;
+                    const WidgetComponent = definition.component;
+
+                    return (
+                        <div key={widget.id}>
+                            <WidgetComponent id={widget.id} config={widget.config} />
+                        </div>
+                    );
+                })}
+            </ResponsiveReactGridLayout>
+        </Page>
+    );
 }

+ 2 - 1
packages/dashboard/src/styles.css

@@ -2,7 +2,6 @@
 @import 'tailwindcss';
 @import 'tw-animate-css';
 
-
 @custom-variant dark (&:is(.dark *));
 
 :root {
@@ -120,6 +119,8 @@
     --color-sidebar-foreground: var(--sidebar-foreground);
     --color-sidebar: var(--sidebar);
     --color-brand: #17c1ff;
+    --color-brand-lighter: #e6f9ff;
+    --color-brand-darker: #0099ff;
     --font-sans: 'Geist', sans-serif;
     --font-mono: 'Geist Mono', monospace;
 }

+ 8 - 2
packages/dev-server/scripts/generate-past-orders.ts

@@ -45,7 +45,9 @@ async function generatePastOrders() {
     for (let i = DAYS_TO_COVER; i > 0; i--) {
         const numberOfOrders = Math.floor(Math.random() * 10) + 5;
         Logger.info(
-            `Generating ${numberOfOrders} orders for ${dayjs().subtract(i, 'day').format('YYYY-MM-DD')}`,
+            `Generating ${numberOfOrders} orders for ${dayjs()
+                .subtract(30 - i, 'day')
+                .format('YYYY-MM-DD')}`,
         );
         for (let j = 0; j < numberOfOrders; j++) {
             const customer = getRandomItem(customers);
@@ -81,7 +83,11 @@ async function generatePastOrders() {
                 continue;
             }
             const randomHourOfDay = Math.floor(Math.random() * 24);
-            const placedAt = dayjs().subtract(i, 'day').startOf('day').add(randomHourOfDay, 'hour').toDate();
+            const placedAt = dayjs()
+                .subtract(DAYS_TO_COVER - i, 'day')
+                .startOf('day')
+                .add(randomHourOfDay, 'hour')
+                .toDate();
             await connection.getRepository(ctx, 'Order').update(order.id, {
                 orderPlacedAt: placedAt,
             });

+ 9 - 0
packages/dev-server/test-plugins/reviews/dashboard/custom-widget.tsx

@@ -0,0 +1,9 @@
+import { DashboardBaseWidget } from '@vendure/dashboard';
+
+export function CustomWidget() {
+    return (
+        <DashboardBaseWidget id="custom-widget" title="Custom Widget" description="This is a custom widget">
+            <div>Hello from the reviews plugin</div>
+        </DashboardBaseWidget>
+    );
+}

+ 9 - 1
packages/dev-server/test-plugins/reviews/dashboard/index.tsx

@@ -1,8 +1,16 @@
 import { defineDashboardExtension } from '@vendure/dashboard';
-
+import { CustomWidget } from './custom-widget';
 import { reviewDetail } from './review-detail';
 import { reviewList } from './review-list';
 
 export default defineDashboardExtension({
     routes: [reviewList, reviewDetail],
+    widgets: [
+        {
+            id: 'custom-widget',
+            name: 'Custom Widget',
+            component: CustomWidget,
+            defaultSize: { w: 3, h: 3 },
+        },
+    ],
 });