Merge pull request 'upd: tampilan' (#1) from amalia/01-apr-26 into main
Reviewed-on: #1
This commit is contained in:
76
bun.lock
76
bun.lock
@@ -7,6 +7,7 @@
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "^1.4.1",
|
||||
"@elysiajs/html": "^1.4.0",
|
||||
"@mantine/charts": "^9.0.0",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@prisma/client": "6",
|
||||
@@ -19,6 +20,7 @@
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"react-icons": "^5.6.0",
|
||||
"recharts": "^3.8.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.10",
|
||||
@@ -181,6 +183,8 @@
|
||||
|
||||
"@kitajs/ts-html-plugin": ["@kitajs/ts-html-plugin@4.1.4", "", { "dependencies": { "chalk": "^5.6.2", "tslib": "^2.8.1", "yargs": "^18.0.0" }, "peerDependencies": { "@kitajs/html": "^4.2.10", "typescript": "^5.9.3" }, "bin": { "xss-scan": "dist/cli.js", "ts-html-plugin": "dist/cli.js" } }, "sha512-xK5mNrhnIy73kJFKx5yVGChJyWFRGmIaE0sjlVxVYllk5dyaEYVCrIh1N8AfnseEHka8gAqzPGW95HlkhDvnJA=="],
|
||||
|
||||
"@mantine/charts": ["@mantine/charts@9.0.0", "", { "peerDependencies": { "@mantine/core": "9.0.0", "@mantine/hooks": "9.0.0", "react": "^19.2.0", "react-dom": "^19.2.0", "recharts": ">=3.2.1" } }, "sha512-TnbjiT2tXZDAQWZrv/+Xu3JKYjPiTfO5jSIbcwnxZSVtLI+PIxA7zrSps+it/Nx3ch8GHpDizJ7UArC0UfmNkQ=="],
|
||||
|
||||
"@mantine/core": ["@mantine/core@8.3.18", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", "react-number-format": "^5.4.4", "react-remove-scroll": "^2.7.1", "react-textarea-autosize": "8.5.9", "type-fest": "^4.41.0" }, "peerDependencies": { "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA=="],
|
||||
|
||||
"@mantine/hooks": ["@mantine/hooks@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw=="],
|
||||
@@ -205,6 +209,8 @@
|
||||
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@2.13.0", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA=="],
|
||||
|
||||
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="],
|
||||
@@ -241,6 +247,8 @@
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
|
||||
"@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.95.2", "", {}, "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ=="],
|
||||
@@ -275,12 +283,32 @@
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
|
||||
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||
|
||||
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||
|
||||
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
|
||||
|
||||
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||
|
||||
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
||||
|
||||
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
|
||||
|
||||
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
|
||||
|
||||
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
||||
|
||||
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
|
||||
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
|
||||
@@ -365,10 +393,34 @@
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||
|
||||
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||
|
||||
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||
|
||||
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
|
||||
|
||||
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||
|
||||
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
||||
|
||||
"d3-scale": ["d3-scale@4.0.2", "", { "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" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
|
||||
|
||||
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
|
||||
|
||||
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
|
||||
|
||||
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||
|
||||
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
|
||||
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
@@ -399,6 +451,8 @@
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
@@ -411,6 +465,8 @@
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
||||
|
||||
"events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="],
|
||||
|
||||
"exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
|
||||
@@ -459,6 +515,10 @@
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||
|
||||
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||
@@ -585,8 +645,12 @@
|
||||
|
||||
"react-icons": ["react-icons@5.6.0", "", { "peerDependencies": { "react": "*" } }, "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA=="],
|
||||
|
||||
"react-is": ["react-is@19.2.4", "", {}, "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="],
|
||||
|
||||
"react-number-format": ["react-number-format@5.4.5", "", { "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw=="],
|
||||
|
||||
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
@@ -599,8 +663,16 @@
|
||||
|
||||
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
|
||||
|
||||
"recharts": ["recharts@3.8.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
|
||||
|
||||
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
|
||||
|
||||
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="],
|
||||
@@ -685,6 +757,8 @@
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@37.3.6", "", { "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" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||
|
||||
"vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="],
|
||||
|
||||
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="],
|
||||
@@ -717,6 +791,8 @@
|
||||
|
||||
"@kitajs/ts-html-plugin/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
|
||||
|
||||
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "^1.4.1",
|
||||
"@elysiajs/html": "^1.4.0",
|
||||
"@mantine/charts": "^9.0.0",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@prisma/client": "6",
|
||||
@@ -34,7 +35,8 @@
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"react-icons": "^5.6.0"
|
||||
"react-icons": "^5.6.0",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.10",
|
||||
|
||||
21
src/app.ts
21
src/app.ts
@@ -143,6 +143,27 @@ export function createApp() {
|
||||
set.status = 302; set.headers['location'] = user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile'
|
||||
})
|
||||
|
||||
// ─── Monitoring API ────────────────────────────────
|
||||
.get('/api/dashboard/stats', () => ({
|
||||
totalApps: 3,
|
||||
newErrors: 185,
|
||||
activeUsers: '24.5k',
|
||||
trends: { totalApps: 1, newErrors: 12, activeUsers: 5.2 }
|
||||
}))
|
||||
|
||||
.get('/api/apps', () => [
|
||||
{ id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' },
|
||||
{ id: 'e-commerce', name: 'E-Commerce', status: 'warning', users: 8900, errors: 45, version: '1.8.0' },
|
||||
{ id: 'fitness-app', name: 'Fitness App', status: 'error', users: 3200, errors: 128, version: '0.9.5' },
|
||||
])
|
||||
|
||||
.get('/api/apps/:appId', ({ params: { appId } }) => {
|
||||
const apps = {
|
||||
'desa-plus': { id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' },
|
||||
}
|
||||
return apps[appId as keyof typeof apps] || { id: appId, name: appId, status: 'active', users: 0, errors: 0, version: '1.0.0' }
|
||||
})
|
||||
|
||||
// ─── Example API ───────────────────────────────────
|
||||
.get('/api/hello', () => ({
|
||||
message: 'Hello, world!',
|
||||
|
||||
@@ -5,8 +5,38 @@ import { createRouter, RouterProvider } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
const theme = createTheme({
|
||||
primaryColor: 'blue',
|
||||
primaryColor: 'brand-blue',
|
||||
colors: {
|
||||
'brand-blue': [
|
||||
'#ebf2ff',
|
||||
'#d6e4ff',
|
||||
'#adc8ff',
|
||||
'#85acff',
|
||||
'#5c90ff',
|
||||
'#2563eb', // Primary Blue
|
||||
'#1e4fb8',
|
||||
'#173b85',
|
||||
'#102752',
|
||||
'#09131f',
|
||||
],
|
||||
'brand-purple': [
|
||||
'#f3ebff',
|
||||
'#e7d6ff',
|
||||
'#cfadff',
|
||||
'#b785ff',
|
||||
'#9f5cff',
|
||||
'#7c3aed', // Primary Purple
|
||||
'#632eb8',
|
||||
'#4a2285',
|
||||
'#311652',
|
||||
'#180b1f',
|
||||
],
|
||||
},
|
||||
fontFamily: 'Inter, system-ui, Avenir, Helvetica, Arial, sans-serif',
|
||||
headings: {
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: '600',
|
||||
},
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
|
||||
102
src/frontend/components/AppCard.tsx
Normal file
102
src/frontend/components/AppCard.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Card, Group, Text, ThemeIcon, Badge, Avatar, Stack, Button, Progress, Box } from '@mantine/core'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { TbDeviceMobile, TbActivity, TbAlertTriangle, TbChevronRight } from 'react-icons/tb'
|
||||
|
||||
interface AppCardProps {
|
||||
id: string
|
||||
name: string
|
||||
status: 'active' | 'warning' | 'error'
|
||||
users: number
|
||||
errors: number
|
||||
version: string
|
||||
}
|
||||
|
||||
export function AppCard({ id, name, status, users, errors, version }: AppCardProps) {
|
||||
const statusColor = status === 'active' ? 'teal' : status === 'warning' ? 'orange' : 'red'
|
||||
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
padding="xl"
|
||||
radius="2xl"
|
||||
className="premium-card glass"
|
||||
styles={(theme) => ({
|
||||
root: {
|
||||
backgroundColor: 'rgba(30, 41, 59, 0.4)',
|
||||
borderColor: 'rgba(255,255,255,0.08)',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: '0 12px 24px -8px rgba(0, 0, 0, 0.4)',
|
||||
borderColor: 'rgba(37, 99, 235, 0.3)',
|
||||
},
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Group gap="md">
|
||||
<Avatar
|
||||
variant="gradient"
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||
radius="lg"
|
||||
size="lg"
|
||||
>
|
||||
<TbDeviceMobile size={26} />
|
||||
</Avatar>
|
||||
<Stack gap={0}>
|
||||
<Text fw={700} size="lg" style={{ letterSpacing: '-0.3px' }}>{name}</Text>
|
||||
<Text size="xs" c="dimmed" fw={600}>BUILD v{version}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Badge color={statusColor} variant="dot" size="sm">
|
||||
{status.toUpperCase()}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Stack gap="md" mt="sm">
|
||||
<Box>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Group gap="xs">
|
||||
<TbActivity size={16} color="#2563EB" />
|
||||
<Text size="xs" fw={700} c="dimmed">USER ADOPTION</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={700}>{users.toLocaleString()}</Text>
|
||||
</Group>
|
||||
<Progress value={85} size="sm" color="brand-blue" radius="xl" />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Group gap="xs">
|
||||
<TbAlertTriangle size={16} color={errors > 0 ? '#ef4444' : '#64748b'} />
|
||||
<Text size="xs" fw={700} c="dimmed">HEALTH INCIDENTS</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={700} color={errors > 0 ? 'red' : 'dimmed'}>{errors}</Text>
|
||||
</Group>
|
||||
<Progress value={errors > 0 ? 30 : 0} size="sm" color="red" radius="xl" />
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/apps/${id}`}
|
||||
variant="light"
|
||||
color="brand-blue"
|
||||
fullWidth
|
||||
mt="xl"
|
||||
radius="md"
|
||||
rightSection={<TbChevronRight size={16} />}
|
||||
styles={{
|
||||
root: {
|
||||
height: '44px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(37, 99, 235, 0.15)',
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
Center Intelligence
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
127
src/frontend/components/DashboardCharts.tsx
Normal file
127
src/frontend/components/DashboardCharts.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Group,
|
||||
ThemeIcon,
|
||||
Box,
|
||||
Badge,
|
||||
useMantineTheme
|
||||
} from '@mantine/core'
|
||||
import { LineChart, BarChart } from '@mantine/charts'
|
||||
import { TbTimeline, TbChartBar, TbArrowUpRight } from 'react-icons/tb'
|
||||
|
||||
const activityData = [
|
||||
{ date: 'Mar 26', logs: 1200 },
|
||||
{ date: 'Mar 27', logs: 1900 },
|
||||
{ date: 'Mar 28', logs: 1540 },
|
||||
{ date: 'Mar 29', logs: 2400 },
|
||||
{ date: 'Mar 30', logs: 2100 },
|
||||
{ date: 'Mar 31', logs: 3200 },
|
||||
{ date: 'Apr 01', logs: 3800 },
|
||||
]
|
||||
|
||||
const villageComparisonData = [
|
||||
{ village: 'Sukatani', activity: 4500 },
|
||||
{ village: 'Sukamaju', activity: 3800 },
|
||||
{ village: 'Bojong Gede', activity: 3200 },
|
||||
{ village: 'Beji', activity: 2800 },
|
||||
{ village: 'Tapos', activity: 2400 },
|
||||
]
|
||||
|
||||
export function VillageActivityLineChart() {
|
||||
const theme = useMantineTheme()
|
||||
|
||||
return (
|
||||
<Paper withBorder p="xl" radius="2xl" className="glass h-full">
|
||||
<Stack gap="md" h="100%">
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" size="lg" radius="md" color="blue">
|
||||
<TbTimeline size={20} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={700} size="sm">DAILY ACTIVITY - ALL VILLAGES</Text>
|
||||
<Text size="xs" c="dimmed">Trend over the last 7 days</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}>
|
||||
Growing
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Box h={300} mt="lg">
|
||||
<LineChart
|
||||
h={300}
|
||||
data={activityData}
|
||||
dataKey="date"
|
||||
series={[{ name: 'logs', color: '#2563EB' }]}
|
||||
curveType="monotone"
|
||||
tickLine="none"
|
||||
gridAxis="x"
|
||||
withTooltip
|
||||
tooltipAnimationDuration={200}
|
||||
styles={{
|
||||
root: {
|
||||
'.recharts-line-curve': {
|
||||
strokeWidth: 3,
|
||||
filter: 'drop-shadow(0 4px 8px rgba(37, 99, 235, 0.3))'
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
export function VillageComparisonBarChart() {
|
||||
const theme = useMantineTheme()
|
||||
|
||||
return (
|
||||
<Paper withBorder p="xl" radius="2xl" className="glass h-full">
|
||||
<Stack gap="md" h="100%">
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" size="lg" radius="md" color="brand-purple">
|
||||
<TbChartBar size={20} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={700} size="sm">USAGE COMPARISON BETWEEN VILLAGES</Text>
|
||||
<Text size="xs" c="dimmed">Top 5 most active village deployments</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Box h={300} mt="lg">
|
||||
<BarChart
|
||||
h={300}
|
||||
data={villageComparisonData}
|
||||
dataKey="village"
|
||||
series={[{ name: 'activity', color: 'indigo.6' }]}
|
||||
withTooltip
|
||||
tickLine="none"
|
||||
gridAxis="y"
|
||||
barProps={{
|
||||
radius: [8, 8, 4, 4],
|
||||
}}
|
||||
styles={{
|
||||
bar: {
|
||||
fill: 'url(#barGradient)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Custom SVG Gradient definitions for Premium SaaS look */}
|
||||
<defs>
|
||||
<linearGradient id="barGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#2563EB" stopOpacity={1} />
|
||||
<stop offset="100%" stopColor="#7C3AED" stopOpacity={0.8} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
228
src/frontend/components/DashboardLayout.tsx
Normal file
228
src/frontend/components/DashboardLayout.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { APP_CONFIGS } from '@/frontend/config/appMenus'
|
||||
import {
|
||||
AppShell,
|
||||
Avatar,
|
||||
Box,
|
||||
Burger,
|
||||
Button,
|
||||
Group,
|
||||
Menu,
|
||||
NavLink,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon
|
||||
} from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router'
|
||||
import {
|
||||
TbApps,
|
||||
TbArrowLeft,
|
||||
TbChevronRight,
|
||||
TbDashboard,
|
||||
TbDeviceMobile,
|
||||
TbLogout,
|
||||
TbSettings,
|
||||
TbUserCircle
|
||||
} from 'react-icons/tb'
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const [opened, { toggle }] = useDisclosure()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { appId } = useParams({ strict: false }) as { appId?: string }
|
||||
|
||||
const matches = useMatches()
|
||||
const currentPath = matches[matches.length - 1]?.pathname
|
||||
|
||||
const globalNav = [
|
||||
{ label: 'Dashboard', icon: TbDashboard, to: '/dashboard' },
|
||||
{ label: 'Applications', icon: TbApps, to: '/apps' },
|
||||
{ label: 'Settings', icon: TbSettings, to: '/settings' },
|
||||
]
|
||||
|
||||
const activeApp = appId ? APP_CONFIGS[appId] : null
|
||||
const navLinks = activeApp ? activeApp.menus : globalNav
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 70 }}
|
||||
navbar={{
|
||||
width: 260,
|
||||
breakpoint: 'sm',
|
||||
collapsed: { mobile: !opened },
|
||||
}}
|
||||
padding="xl"
|
||||
styles={(theme) => ({
|
||||
main: {
|
||||
backgroundColor: theme.colors.dark[7], // Dark mode background
|
||||
},
|
||||
})}
|
||||
>
|
||||
<AppShell.Header px="xl">
|
||||
<Group h="100%" justify="space-between">
|
||||
<Group>
|
||||
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
||||
<Group gap="xs">
|
||||
<ThemeIcon
|
||||
size={34}
|
||||
radius="md"
|
||||
variant="gradient"
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||
>
|
||||
<TbDeviceMobile size={18} />
|
||||
</ThemeIcon>
|
||||
<Text
|
||||
size="lg"
|
||||
fw={700}
|
||||
className="gradient-text"
|
||||
style={{ letterSpacing: '-0.5px' }}
|
||||
>
|
||||
Monitoring System
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group gap="md">
|
||||
<Menu shadow="md" width={200} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<Avatar
|
||||
src={undefined}
|
||||
alt="User"
|
||||
color="brand-blue"
|
||||
radius="xl"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Application</Menu.Label>
|
||||
<Menu.Item leftSection={<TbUserCircle size={16} />}>Profile</Menu.Item>
|
||||
<Menu.Item leftSection={<TbSettings size={16} />}>Settings</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label>Danger Zone</Menu.Label>
|
||||
<Menu.Item color="red" leftSection={<TbLogout size={16} />}>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar p="md">
|
||||
<Stack gap="xs" mt="md">
|
||||
{activeApp && (
|
||||
<NavLink
|
||||
label="Back to Dashboard"
|
||||
leftSection={<TbArrowLeft size={20} />}
|
||||
component={Link}
|
||||
to="/dashboard"
|
||||
styles={(theme) => ({
|
||||
root: {
|
||||
borderRadius: theme.radius.md,
|
||||
opacity: 0.7,
|
||||
'&:hover': { opacity: 1 },
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{
|
||||
activeApp &&
|
||||
<Select
|
||||
label="Selected Application"
|
||||
value={appId}
|
||||
data={[
|
||||
{ value: 'desa-plus', label: 'Desa+' },
|
||||
{ value: 'e-commerce', label: 'E-Commerce' },
|
||||
{ value: 'fitness-app', label: 'Fitness App' },
|
||||
]}
|
||||
onChange={(val) => val && navigate({ to: '/apps/$appId', params: { appId: val } })}
|
||||
radius="md"
|
||||
size="sm"
|
||||
w={220}
|
||||
mb={"md"}
|
||||
variant="filled"
|
||||
styles={(theme) => ({
|
||||
input: { border: '1px solid rgba(255,255,255,0.1)' }
|
||||
})}
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
|
||||
{/* {activeApp && (
|
||||
<Text size="xs" fw={700} c="dimmed" px="md" mb="xs" style={{ textTransform: 'uppercase' }}>
|
||||
{activeApp.name} Context
|
||||
</Text>
|
||||
)} */}
|
||||
|
||||
{navLinks.map((link: any) => {
|
||||
const isActive = currentPath === link.to
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={link.label}
|
||||
component={Link}
|
||||
to={link.to}
|
||||
activeOptions={{ exact: true }}
|
||||
label={link.label}
|
||||
leftSection={<link.icon size={20} />}
|
||||
rightSection={<TbChevronRight size={14} />}
|
||||
active={isActive}
|
||||
variant="filled"
|
||||
color="brand-blue"
|
||||
className="sidebar-nav-item"
|
||||
styles={(theme) => ({
|
||||
root: {
|
||||
borderRadius: theme.radius.md,
|
||||
transition: 'all 0.2s ease',
|
||||
'&[data-active]': {
|
||||
background: 'var(--gradient-blue-purple)',
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box style={{ marginTop: 'auto' }}>
|
||||
<Stack gap="xs">
|
||||
<Box
|
||||
p="md"
|
||||
className="glass"
|
||||
style={{ borderRadius: '12px', border: '1px solid rgba(255,255,255,0.05)' }}
|
||||
>
|
||||
<Text size="xs" c="dimmed" fw={600} mb="xs">SYSTEM STATUS</Text>
|
||||
<Group gap="xs">
|
||||
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: '#10b981' }} />
|
||||
<Text size="sm" fw={500}>All Systems Operational</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
fullWidth
|
||||
leftSection={<TbLogout size={16} />}
|
||||
mt="md"
|
||||
>
|
||||
Log out
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
{children}
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
195
src/frontend/components/ErrorDataTable.tsx
Normal file
195
src/frontend/components/ErrorDataTable.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
Table,
|
||||
Badge,
|
||||
Text,
|
||||
Paper,
|
||||
Group,
|
||||
Drawer,
|
||||
Stack,
|
||||
Divider,
|
||||
Code,
|
||||
Button,
|
||||
Box,
|
||||
ScrollArea,
|
||||
Title
|
||||
} from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { useState } from 'react'
|
||||
import { TbMessageReport, TbHistory, TbExternalLink, TbBug } from 'react-icons/tb'
|
||||
|
||||
const mockErrors = [
|
||||
{
|
||||
id: 1,
|
||||
message: 'NullPointerException at village_sync.dart:45',
|
||||
village: 'Sukatani',
|
||||
version: 'v1.2.0',
|
||||
timestamp: '2026-04-01 14:30:15',
|
||||
severity: 'critical',
|
||||
stackTrace: 'at com.desa.sync.VillageManager.sync(VillageManager.java:45)\nat com.desa.sync.SyncService.onHandleIntent(SyncService.java:120)'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
message: 'Failed to load citizen record session',
|
||||
village: 'Sukamaju',
|
||||
version: 'v1.1.8',
|
||||
timestamp: '2026-04-01 14:15:22',
|
||||
severity: 'high',
|
||||
stackTrace: 'Error: Connection timeout reaching upstream citizen-db\n at HttpClient.get (network.dart:88)'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
message: 'SocketException: Connection timed out',
|
||||
village: 'Cikini',
|
||||
version: 'v1.2.0',
|
||||
timestamp: '2026-04-01 13:55:10',
|
||||
severity: 'medium',
|
||||
stackTrace: 'SocketException: OS Error: Connection timed out, errno = 110, address = 10.0.2.2, port = 54332'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
message: 'UI Thread blocking > 500ms',
|
||||
village: 'Beji',
|
||||
version: 'v1.1.2',
|
||||
timestamp: '2026-04-01 13:40:00',
|
||||
severity: 'low',
|
||||
stackTrace: 'ANR (Application Not Responding) detected in main thread.'
|
||||
},
|
||||
]
|
||||
|
||||
export function ErrorDataTable() {
|
||||
const [opened, { open, close }] = useDisclosure(false)
|
||||
const [selectedError, setSelectedError] = useState<any>(null)
|
||||
|
||||
const handleRowClick = (error: any) => {
|
||||
setSelectedError(error)
|
||||
open()
|
||||
}
|
||||
|
||||
const getSeverityColor = (sev: string) => {
|
||||
switch(sev) {
|
||||
case 'critical': return 'red'
|
||||
case 'high': return 'orange'
|
||||
case 'medium': return 'yellow'
|
||||
default: return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper withBorder radius="2xl" className="glass overflow-hidden">
|
||||
<Box p="xl" style={{ borderBottom: '1px solid rgba(255, 255, 255, 0.08)' }}>
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="red" size="lg" radius="md">
|
||||
<TbBug size={20} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>LATEST ERROR REPORTS</Text>
|
||||
</Group>
|
||||
<Button variant="subtle" size="compact-xs" color="blue" rightSection={<TbExternalLink size={14} />}>
|
||||
View All Reports
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<ScrollArea>
|
||||
<Table verticalSpacing="md" highlightOnHover className="data-table">
|
||||
<Table.Thead bg="rgba(0,0,0,0.1)">
|
||||
<Table.Tr>
|
||||
<Table.Th px="xl">Error Message</Table.Th>
|
||||
<Table.Th>Village</Table.Th>
|
||||
<Table.Th>App Version</Table.Th>
|
||||
<Table.Th>Timestamp</Table.Th>
|
||||
<Table.Th pr="xl">Severity</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{mockErrors.map((error) => (
|
||||
<Table.Tr
|
||||
key={error.id}
|
||||
onClick={() => handleRowClick(error)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Table.Td px="xl">
|
||||
<Text size="sm" fw={600} lineClamp={1}>{error.message}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="dot" color="brand-blue" radius="sm">{error.village}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" fw={700} c="dimmed">{error.version}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={6}>
|
||||
<TbHistory size={12} color="gray" />
|
||||
<Text size="xs" c="dimmed">{error.timestamp}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td pr="xl">
|
||||
<Badge color={getSeverityColor(error.severity)} variant="light" size="sm">
|
||||
{error.severity.toUpperCase()}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
position="right"
|
||||
size="md"
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<TbMessageReport color="#ef4444" size={24} />
|
||||
<Title order={4}>Error Investigation</Title>
|
||||
</Group>
|
||||
}
|
||||
styles={{
|
||||
header: { padding: '24px', borderBottom: '1px solid rgba(255,255,255,0.1)' },
|
||||
content: { background: 'rgba(15, 23, 42, 0.95)', backdropFilter: 'blur(12px)' }
|
||||
}}
|
||||
>
|
||||
{selectedError && (
|
||||
<Stack p="lg" gap="xl">
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb={4}>MESSAGE</Text>
|
||||
<Text fw={700} size="lg" color="red">{selectedError.message}</Text>
|
||||
</Box>
|
||||
|
||||
<SimpleGrid cols={2} spacing="lg">
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb={4}>VILLAGE</Text>
|
||||
<Text fw={600}>{selectedError.village}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb={4}>APP VERSION</Text>
|
||||
<Badge variant="outline">{selectedError.version}</Badge>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider opacity={0.1} />
|
||||
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb="sm">STACK TRACE</Text>
|
||||
<Paper p="md" radius="md" bg="dark.8" style={{ border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<Code block color="red" bg="transparent" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||
{selectedError.stackTrace}
|
||||
</Code>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="light" color="gray" onClick={close}>Dismiss</Button>
|
||||
<Button variant="gradient" gradient={{ from: 'red', to: 'orange' }}>Assign Technician</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
import { SimpleGrid, ThemeIcon } from '@mantine/core'
|
||||
66
src/frontend/components/StatsCard.tsx
Normal file
66
src/frontend/components/StatsCard.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Card, Group, Text, ThemeIcon, Stack } from '@mantine/core'
|
||||
import { IconType } from 'react-icons'
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
description?: string
|
||||
icon: IconType
|
||||
color?: string
|
||||
trend?: {
|
||||
value: string
|
||||
positive: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function StatsCard({ title, value, description, icon: Icon, color, trend }: StatsCardProps) {
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
padding="lg"
|
||||
radius="xl"
|
||||
className="premium-card"
|
||||
styles={(theme) => ({
|
||||
root: {
|
||||
backgroundColor: theme.colors.dark[6],
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<ThemeIcon
|
||||
size={42}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color={color || 'brand-blue'}
|
||||
styles={{
|
||||
root: {
|
||||
background: 'rgba(124, 58, 237, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Icon size={22} />
|
||||
</ThemeIcon>
|
||||
{trend && (
|
||||
<Text size="xs" fw={700} c={trend.positive ? 'teal' : 'red'}>
|
||||
{trend.positive ? '+' : ''}{trend.value}%
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" c="dimmed" fw={600} style={{ letterSpacing: '0.02em', textTransform: 'uppercase' }}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="2xl" fw={800} mt={4}>
|
||||
{value}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
96
src/frontend/components/SummaryCard.tsx
Normal file
96
src/frontend/components/SummaryCard.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Card, Group, Text, ThemeIcon, Stack, Progress, Badge } from '@mantine/core'
|
||||
import { IconType } from 'react-icons'
|
||||
import { TbTrendingUp, TbTrendingDown } from 'react-icons/tb'
|
||||
|
||||
interface SummaryCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
icon: IconType
|
||||
color?: string
|
||||
trend?: {
|
||||
value: string
|
||||
positive: boolean
|
||||
}
|
||||
progress?: {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
export function SummaryCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
color = 'brand-blue',
|
||||
trend,
|
||||
progress,
|
||||
isError
|
||||
}: SummaryCardProps) {
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
padding="xl"
|
||||
radius="2xl"
|
||||
className="glass"
|
||||
styles={(theme) => ({
|
||||
root: {
|
||||
backgroundColor: isError && Number(value) > 0 ? 'rgba(239, 68, 68, 0.05)' : 'rgba(30, 41, 59, 0.4)',
|
||||
borderColor: isError && Number(value) > 10 ? 'rgba(239, 68, 68, 0.3)' : 'rgba(255, 255, 255, 0.08)',
|
||||
transition: 'transform 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
}
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Group justify="space-between" mb="md">
|
||||
<ThemeIcon
|
||||
size={48}
|
||||
radius="lg"
|
||||
variant="light"
|
||||
color={isError ? 'red' : color}
|
||||
>
|
||||
<Icon size={26} />
|
||||
</ThemeIcon>
|
||||
|
||||
{trend && (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={trend.positive ? 'teal' : 'red'}
|
||||
leftSection={trend.positive ? <TbTrendingUp size={14} /> : <TbTrendingDown size={14} />}
|
||||
>
|
||||
{trend.value}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={700} c="dimmed" style={{ letterSpacing: '0.05em' }}>
|
||||
{title.toUpperCase()}
|
||||
</Text>
|
||||
<Text size="2.4rem" fw={900} style={{ fontFamily: 'Outfit, sans-serif', letterSpacing: '-1px' }}>
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{progress && (
|
||||
<Box mt="md">
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" fw={600}>{progress.label}</Text>
|
||||
<Text size="xs" fw={700}>{progress.value}%</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={progress.value}
|
||||
color={color}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
styles={{ section: { transition: 'width 1s ease' } }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
import { Box } from '@mantine/core'
|
||||
49
src/frontend/config/appMenus.ts
Normal file
49
src/frontend/config/appMenus.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { IconType } from 'react-icons'
|
||||
import { TbChartBar, TbHistory, TbAlertTriangle, TbSettings, TbShoppingCart, TbPackage, TbCreditCard } from 'react-icons/tb'
|
||||
|
||||
export interface MenuItem {
|
||||
value: string
|
||||
label: string
|
||||
icon: IconType
|
||||
to: string
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
id: string
|
||||
name: string
|
||||
menus: MenuItem[]
|
||||
}
|
||||
|
||||
export const APP_CONFIGS: Record<string, AppConfig> = {
|
||||
'desa-plus': {
|
||||
id: 'desa-plus',
|
||||
name: 'Desa+',
|
||||
menus: [
|
||||
{ value: 'overview', label: 'Overview', icon: TbChartBar, to: '/apps/desa-plus' },
|
||||
{ value: 'logs', label: 'Log Activity', icon: TbHistory, to: '/apps/desa-plus/logs' },
|
||||
{ value: 'errors', label: 'Error Reports', icon: TbAlertTriangle, to: '/apps/desa-plus/errors' },
|
||||
{ value: 'manage', label: 'Manage', icon: TbSettings, to: '/apps/desa-plus/manage' },
|
||||
],
|
||||
},
|
||||
'e-commerce': {
|
||||
id: 'e-commerce',
|
||||
name: 'E-Commerce (Example)',
|
||||
menus: [
|
||||
{ value: 'overview', label: 'Overview', icon: TbChartBar, to: '/apps/e-commerce' },
|
||||
{ value: 'logs', label: 'Log Activity', icon: TbHistory, to: '/apps/e-commerce/logs' },
|
||||
{ value: 'errors', label: 'Error Reports', icon: TbAlertTriangle, to: '/apps/e-commerce/errors' },
|
||||
{ value: 'orders', label: 'Orders', icon: TbShoppingCart, to: '/apps/e-commerce/orders' },
|
||||
{ value: 'products', label: 'Products', icon: TbPackage, to: '/apps/e-commerce/products' },
|
||||
{ value: 'payments', label: 'Payments', icon: TbCreditCard, to: '/apps/e-commerce/payments' },
|
||||
],
|
||||
},
|
||||
'fitness-app': {
|
||||
id: 'fitness-app',
|
||||
name: 'Fitness App (Example)',
|
||||
menus: [
|
||||
{ value: 'overview', label: 'Overview', icon: TbChartBar, to: '/apps/fitness-app' },
|
||||
{ value: 'logs', label: 'Log Activity', icon: TbHistory, to: '/apps/fitness-app/logs' },
|
||||
{ value: 'errors', label: 'Error Reports', icon: TbAlertTriangle, to: '/apps/fitness-app/errors' },
|
||||
],
|
||||
},
|
||||
}
|
||||
175
src/frontend/routes/apps.$appId.errors.tsx
Normal file
175
src/frontend/routes/apps.$appId.errors.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Paper,
|
||||
Accordion,
|
||||
ThemeIcon,
|
||||
TextInput,
|
||||
Select,
|
||||
Code,
|
||||
Box,
|
||||
Button,
|
||||
SimpleGrid,
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||
import {
|
||||
TbAlertTriangle,
|
||||
TbBug,
|
||||
TbDeviceDesktop,
|
||||
TbDeviceMobile,
|
||||
TbSearch,
|
||||
TbFilter,
|
||||
TbCircleCheck,
|
||||
TbUserCheck
|
||||
} from 'react-icons/tb'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId/errors')({
|
||||
component: AppErrorsPage,
|
||||
})
|
||||
|
||||
const mockErrors = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'NullPointerException: village_id is null',
|
||||
message: 'Occurred during background sync with central server.',
|
||||
version: '2.4.1',
|
||||
device: 'PC Admin (Windows 10)',
|
||||
time: '2 mins ago',
|
||||
severity: 'critical',
|
||||
users: 24,
|
||||
frequency: 145,
|
||||
stackTrace: 'at com.desa.sync.VillageManager.sync(VillageManager.java:45)\nat com.desa.sync.SyncService.onHandleIntent(SyncService.java:120)\nat android.app.IntentService$ServiceHandler.handleMessage(IntentService.java:78)'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'SocketTimeoutException: Connection reset by peer',
|
||||
message: 'Failed to upload document: surat_kematian_01.pdf',
|
||||
version: '2.4.0',
|
||||
device: 'Android Tablet (Samsung Tab A8)',
|
||||
time: '15 mins ago',
|
||||
severity: 'high',
|
||||
users: 5,
|
||||
frequency: 12,
|
||||
stackTrace: 'java.net.SocketTimeoutException: timeout\nat okio.Okio$4.newTimeoutException(Okio.java:232)\nat okio.AsyncTimeout.exit(AsyncTimeout.java:285)'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'SQLiteException: no such column: village_id',
|
||||
message: 'Failed to query local village profile database.',
|
||||
version: '2.4.1',
|
||||
device: 'PC Admin (Windows 7)',
|
||||
time: '1 hour ago',
|
||||
severity: 'medium',
|
||||
users: 2,
|
||||
frequency: 4,
|
||||
stackTrace: 'java.io.IOException: No space left on device\nat java.io.FileOutputStream.writeBytes(Native Method)'
|
||||
},
|
||||
]
|
||||
|
||||
function AppErrorsPage() {
|
||||
const { appId } = useParams({ from: '/apps/$appId/errors' })
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>Error Reporting Center</Title>
|
||||
<Text size="sm" c="dimmed">Advanced analysis of health issues and crashes for <b>{appId}</b>.</Text>
|
||||
</Stack>
|
||||
<Button variant="light" color="red" leftSection={<TbBug size={16} />}>
|
||||
Export Logs
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||
<Group mb="md" grow>
|
||||
<TextInput
|
||||
placeholder="Search error message, village, or stack trace..."
|
||||
leftSection={<TbSearch size={16} />}
|
||||
radius="md"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Severity"
|
||||
data={['Critical', 'High', 'Medium', 'Low']}
|
||||
leftSection={<TbFilter size={16} />}
|
||||
radius="md"
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Accordion variant="separated" radius="xl">
|
||||
{mockErrors.map((error) => (
|
||||
<Accordion.Item
|
||||
key={error.id}
|
||||
value={error.id.toString()}
|
||||
style={{ border: '1px solid rgba(255,255,255,0.05)', background: 'rgba(255,255,255,0.02)', marginBottom: '12px' }}
|
||||
>
|
||||
<Accordion.Control>
|
||||
<Group wrap="nowrap">
|
||||
<ThemeIcon
|
||||
color={error.severity === 'critical' ? 'red' : error.severity === 'high' ? 'orange' : 'yellow'}
|
||||
variant="light"
|
||||
size="lg"
|
||||
radius="md"
|
||||
>
|
||||
<TbAlertTriangle size={20} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600} lineClamp={1}>{error.title}</Text>
|
||||
<Badge color={error.severity === 'critical' ? 'red' : 'orange'} variant="dot" size="xs">
|
||||
{error.severity.toUpperCase()}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Text size="xs" c="dimmed">{error.time} • v{error.version}</Text>
|
||||
<Group gap={4} visibleFrom="sm">
|
||||
<TbUserCheck size={12} color="gray" />
|
||||
<Text size="xs" c="dimmed">{error.users} Users Affected</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="md" py="xs">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb={4}>MESSAGE</Text>
|
||||
<Text size="sm" fw={500}>{error.message}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb={4}>DEVICE METADATA</Text>
|
||||
<Group gap="xs">
|
||||
{error.device.includes('PC') ? <TbDeviceDesktop size={14} color="gray" /> : <TbDeviceMobile size={14} color="gray" />}
|
||||
<Text size="xs" fw={500}>{error.device}</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb={4}>STACK TRACE</Text>
|
||||
<Paper p="sm" radius="md" bg="dark.8" style={{ border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<Code block color="red" bg="transparent" style={{ fontFamily: 'monospace', whiteSpace: 'pre-wrap', fontSize: '11px' }}>
|
||||
{error.stackTrace}
|
||||
</Code>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Group justify="flex-end" pt="sm">
|
||||
<Button variant="light" size="compact-xs" color="blue">Assign Developer</Button>
|
||||
<Button variant="light" size="compact-xs" color="teal" leftSection={<TbCircleCheck size={14} />}>Mark as Fixed</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
</Accordion>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
125
src/frontend/routes/apps.$appId.index.tsx
Normal file
125
src/frontend/routes/apps.$appId.index.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Paper,
|
||||
Box,
|
||||
ThemeIcon,
|
||||
Select,
|
||||
ActionIcon,
|
||||
Container,
|
||||
Divider,
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute, Link, useParams } from '@tanstack/react-router'
|
||||
import {
|
||||
TbUsers,
|
||||
TbActivity,
|
||||
TbRefresh,
|
||||
TbAlertTriangle,
|
||||
TbCalendar,
|
||||
TbFilter,
|
||||
TbChevronRight,
|
||||
TbArrowUpRight,
|
||||
TbBuildingCommunity,
|
||||
TbVersions
|
||||
} from 'react-icons/tb'
|
||||
import { SummaryCard } from '@/frontend/components/SummaryCard'
|
||||
import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts'
|
||||
import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId/')({
|
||||
component: AppOverviewPage,
|
||||
})
|
||||
|
||||
function AppOverviewPage() {
|
||||
const { appId } = useParams({ from: '/apps/$appId/' })
|
||||
const isDesaPlus = appId === 'desa-plus'
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
{/* 🔝 HEADER SECTION */}
|
||||
<Paper withBorder p="lg" radius="2xl" className="glass">
|
||||
<Group justify="space-between">
|
||||
<Stack gap={0}>
|
||||
<Title order={2} className="gradient-text" style={{ fontSize: '1.8rem' }}>Overview</Title>
|
||||
<Group gap="xs" mt={4}>
|
||||
<Badge variant="light" size="lg" radius="sm" color="brand-blue" leftSection={<TbBuildingCommunity size={14} />}>
|
||||
APP: {isDesaPlus ? 'DESA+' : appId.toUpperCase()}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" fw={600}>LAST UPDATED: JUST NOW</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Group gap="md">
|
||||
<Select
|
||||
placeholder="Date Range"
|
||||
data={['Today', '7 Days', '30 Days']}
|
||||
defaultValue="Today"
|
||||
leftSection={<TbCalendar size={16} />}
|
||||
radius="md"
|
||||
w={140}
|
||||
/>
|
||||
<ActionIcon variant="light" color="brand-blue" size="lg" radius="md">
|
||||
<TbRefresh size={20} />
|
||||
</ActionIcon>
|
||||
<Button
|
||||
variant="gradient"
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED' }}
|
||||
radius="md"
|
||||
leftSection={<TbFilter size={18} />}
|
||||
>
|
||||
Add Filter
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* 📊 1. SUMMARY CARDS */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
||||
<SummaryCard
|
||||
title="Active Version"
|
||||
value="v1.2.0"
|
||||
icon={TbVersions}
|
||||
color="brand-blue"
|
||||
progress={{ value: 92, label: 'User Adoption' }}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Total Activity Today"
|
||||
value="3,842"
|
||||
icon={TbActivity}
|
||||
color="teal"
|
||||
trend={{ value: '14.2%', positive: true }}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Total Villages Active"
|
||||
value="138"
|
||||
icon={TbBuildingCommunity}
|
||||
color="indigo"
|
||||
progress={{ value: 98, label: 'Integration Health' }}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Errors Today"
|
||||
value="12"
|
||||
icon={TbAlertTriangle}
|
||||
color="red"
|
||||
isError={true}
|
||||
trend={{ value: '4.8%', positive: false }}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 📈 📊 2 & 3. CHARTS GRID */}
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
|
||||
<VillageActivityLineChart />
|
||||
<VillageComparisonBarChart />
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 🐞 4. LATEST ERROR REPORTS */}
|
||||
<ErrorDataTable />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
123
src/frontend/routes/apps.$appId.logs.tsx
Normal file
123
src/frontend/routes/apps.$appId.logs.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Paper,
|
||||
Table,
|
||||
TextInput,
|
||||
Select,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Avatar,
|
||||
Code,
|
||||
Button
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||
import { TbSearch, TbFilter, TbDownload, TbCalendar } from 'react-icons/tb'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId/logs')({
|
||||
component: AppLogsPage,
|
||||
})
|
||||
|
||||
const mockLogs = [
|
||||
{ id: 1, type: 'DOCUMENT', village: 'Sukatani', activity: 'GENERATE_SURAT_DOMISILI', operator: 'Budi Santoso', time: '2 mins ago', status: 'SUCCESS' },
|
||||
{ id: 2, type: 'FINANCE', village: 'Sukamaju', activity: 'UPLOAD_LAPORAN_REALISASI_Q1', operator: 'Siti Aminah', time: '15 mins ago', status: 'SUCCESS' },
|
||||
{ id: 3, type: 'SYNC', village: 'Cikini', activity: 'SYNC_DATA_PENDUDUK_SIAK', operator: 'System', time: '1 hour ago', status: 'WARNING' },
|
||||
{ id: 4, type: 'SECURITY', village: 'Bojong Gede', activity: 'LOGIN_ADMIN_DESA', operator: 'Rahmat Hidayat', time: '2 hours ago', status: 'SUCCESS' },
|
||||
{ id: 5, type: 'DOCUMENT', village: 'Tapos', activity: 'VERIFIKASI_SURAT_KEMATIAN', operator: 'Agus Setiawan', time: '4 hours ago', status: 'SUCCESS' },
|
||||
]
|
||||
|
||||
function AppLogsPage() {
|
||||
const { appId } = useParams({ from: '/apps/$appId/logs' })
|
||||
const isDesaPlus = appId === 'desa-plus'
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>{isDesaPlus ? 'Desa+ Service Logs' : 'Application Activity Logs'}</Title>
|
||||
<Text size="sm" c="dimmed">Detailed audit trail of all actions performed within the application instances.</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<Button variant="light" leftSection={<TbDownload size={16} />} radius="md">Export XLS</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||
<Group mb="md" grow>
|
||||
<TextInput
|
||||
placeholder="Search activity, village, or operator..."
|
||||
leftSection={<TbSearch size={16} />}
|
||||
radius="md"
|
||||
/>
|
||||
<Select
|
||||
placeholder="All Service Types"
|
||||
data={['DOCUMENT', 'FINANCE', 'SYNC', 'SECURITY']}
|
||||
leftSection={<TbFilter size={16} />}
|
||||
radius="md"
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Table verticalSpacing="sm" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Village / Instance</Table.Th>
|
||||
<Table.Th>Activity Name</Table.Th>
|
||||
<Table.Th>Operator</Table.Th>
|
||||
<Table.Th>Timestamp</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{mockLogs.map((log) => (
|
||||
<Table.Tr key={log.id}>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={
|
||||
log.type === 'DOCUMENT' ? 'blue' :
|
||||
log.type === 'FINANCE' ? 'teal' :
|
||||
log.type === 'SYNC' ? 'orange' : 'gray'
|
||||
}
|
||||
size="xs"
|
||||
>
|
||||
{log.type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>{log.village}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Code color="brand-blue" bg="transparent" fw={800} style={{ fontSize: '11px' }}>{log.activity}</Code>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Avatar size="xs" radius="xl" color="brand-blue">{log.operator[0]}</Avatar>
|
||||
<Text size="xs" fw={500}>{log.operator}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{log.time}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="dot"
|
||||
color={log.status === 'SUCCESS' ? 'teal' : 'orange'}
|
||||
>
|
||||
{log.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
278
src/frontend/routes/apps.$appId.manage.tsx
Normal file
278
src/frontend/routes/apps.$appId.manage.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Paper,
|
||||
Table,
|
||||
Button,
|
||||
ActionIcon,
|
||||
TextInput,
|
||||
Select,
|
||||
Tooltip,
|
||||
SimpleGrid,
|
||||
Modal,
|
||||
Avatar,
|
||||
Box,
|
||||
NumberInput,
|
||||
} from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||
import {
|
||||
TbPlus,
|
||||
TbSearch,
|
||||
TbPencil,
|
||||
TbTrash,
|
||||
TbUserPlus,
|
||||
TbCircleCheck,
|
||||
TbRefresh,
|
||||
TbUser,
|
||||
TbBuildingCommunity,
|
||||
} from 'react-icons/tb'
|
||||
import { StatsCard } from '@/frontend/components/StatsCard'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId/manage')({
|
||||
component: AppManagePage,
|
||||
})
|
||||
|
||||
const mockDevelopers = [
|
||||
{ value: 'john-doe', label: 'John Doe', avatar: null },
|
||||
{ value: 'amel', label: 'Amel', avatar: null },
|
||||
{ value: 'jane-smith', label: 'Jane Smith', avatar: null },
|
||||
{ value: 'rahmat', label: 'Rahmat Hidayat', avatar: null },
|
||||
]
|
||||
|
||||
function AppManagePage() {
|
||||
const { appId } = useParams({ from: '/apps/$appId' })
|
||||
const [initModalOpened, { open: openInit, close: closeInit }] = useDisclosure(false)
|
||||
const [assignModalOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false)
|
||||
const [selectedVillage, setSelectedVillage] = useState<any>(null)
|
||||
|
||||
const isDesaPlus = appId === 'desa-plus'
|
||||
|
||||
const mockVillages = [
|
||||
{ id: 1, name: 'Sukatani', kecamatan: 'Tapos', population: 4500, status: 'fully integrated', developer: 'John Doe', lastUpdate: '2 mins ago' },
|
||||
{ id: 2, name: 'Sukamaju', kecamatan: 'Cilodong', population: 3800, status: 'sync active', developer: 'Amel', lastUpdate: '15 mins ago' },
|
||||
{ id: 3, name: 'Cikini', kecamatan: 'Menteng', population: 2100, status: 'sync pending', developer: 'Jane Smith', lastUpdate: '-' },
|
||||
{ id: 4, name: 'Bojong Gede', kecamatan: 'Bojong Gede', population: 6700, status: 'fully integrated', developer: 'Rahmat', lastUpdate: '1 hour ago' },
|
||||
]
|
||||
|
||||
if (!isDesaPlus) {
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
|
||||
<TbBuildingCommunity size={48} color="gray" opacity={0.5} />
|
||||
<Title order={3} mt="md">General Management</Title>
|
||||
<Text c="dimmed">This feature is currently customized for Desa+. Other apps coming soon.</Text>
|
||||
</Paper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
{/* Metrics Row */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 4 }} spacing="lg">
|
||||
<StatsCard
|
||||
title="Total Integrations"
|
||||
value={140}
|
||||
icon={TbBuildingCommunity}
|
||||
color="brand-blue"
|
||||
trend={{ value: '12%', positive: true }}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Daily Sync Rate"
|
||||
value="94.2%"
|
||||
icon={TbRefresh}
|
||||
color="teal"
|
||||
trend={{ value: '2.5%', positive: true }}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Avg. Sync Delay"
|
||||
value="45s"
|
||||
icon={TbRefresh}
|
||||
color="orange"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Pending Documents"
|
||||
value={124}
|
||||
icon={TbUser}
|
||||
color="red"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>Village Deployment Center</Title>
|
||||
<Text size="sm" c="dimmed">Monitor and configure **Desa+** village instances across all districts.</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="gradient"
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||
leftSection={<TbPlus size={18} />}
|
||||
radius="md"
|
||||
onClick={openInit}
|
||||
>
|
||||
Initialize New Village
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||
<Group mb="md">
|
||||
<TextInput
|
||||
placeholder="Search village or district..."
|
||||
leftSection={<TbSearch size={16} />}
|
||||
style={{ flex: 1 }}
|
||||
radius="md"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Table className="data-table" verticalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Village Profile</Table.Th>
|
||||
<Table.Th>District</Table.Th>
|
||||
<Table.Th>Integration Status</Table.Th>
|
||||
<Table.Th>Lead Developer</Table.Th>
|
||||
<Table.Th>Last Sync</Table.Th>
|
||||
<Table.Th>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{mockVillages.map((village) => (
|
||||
<Table.Tr key={village.id}>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text fw={700} size="sm">{village.name}</Text>
|
||||
<Text size="xs" c="dimmed">{village.population.toLocaleString()} Residents</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>{village.kecamatan}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={
|
||||
village.status === 'fully integrated' ? 'teal' :
|
||||
village.status === 'sync active' ? 'brand-blue' : 'orange'
|
||||
}
|
||||
variant={village.status === 'sync pending' ? 'outline' : 'light'}
|
||||
leftSection={village.status !== 'sync pending' && <TbCircleCheck size={12} />}
|
||||
radius="sm"
|
||||
style={{ textTransform: 'uppercase', fontVariant: 'small-caps' }}
|
||||
>
|
||||
{village.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Avatar size="xs" radius="xl" color="brand-blue" src={null} />
|
||||
<Text size="sm">{village.developer}</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => { setSelectedVillage(village); openAssign(); }}
|
||||
>
|
||||
<TbUserPlus size={12} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" fw={500} c={village.lastUpdate === '-' ? 'dimmed' : 'teal'}>
|
||||
{village.lastUpdate}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
{village.status === 'sync pending' && (
|
||||
<Button variant="light" size="compact-xs" color="blue" onClick={openInit}>
|
||||
START SYNC
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip label="Village Settings">
|
||||
<ActionIcon variant="light" size="sm" color="gray">
|
||||
<TbPencil size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Unlink Village">
|
||||
<ActionIcon variant="light" size="sm" color="red">
|
||||
<TbTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
{/* MODALS */}
|
||||
<Modal
|
||||
opened={initModalOpened}
|
||||
onClose={closeInit}
|
||||
title={<Title order={4}>Desa+ Instance Initialization</Title>}
|
||||
radius="xl"
|
||||
centered
|
||||
padding="xl"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={2}>
|
||||
<TextInput label="Village Name" placeholder="e.g. Sukatani" radius="md" required />
|
||||
<TextInput label="Kecamatan" placeholder="e.g. Tapos" radius="md" required />
|
||||
</SimpleGrid>
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Population Data Source"
|
||||
placeholder="Select source..."
|
||||
data={['SIAK Terpusat', 'BPS Proyeksi', 'Manual Upload']}
|
||||
radius="md"
|
||||
/>
|
||||
<NumberInput label="Target Residents" placeholder="1000" radius="md" />
|
||||
</Group>
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb="xs">INITIAL SYNC MODULES</Text>
|
||||
<Group gap="xs">
|
||||
<Badge variant="outline" color="blue">PENDUDUK</Badge>
|
||||
<Badge variant="outline" color="teal">KEUANGAN</Badge>
|
||||
<Badge variant="outline" color="brand-purple">PELAYANAN</Badge>
|
||||
<Badge variant="outline" color="orange">APBDes</Badge>
|
||||
</Group>
|
||||
</Box>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="subtle" color="gray" onClick={closeInit}>Cancel</Button>
|
||||
<Button variant="gradient" gradient={{ from: '#2563EB', to: '#7C3AED' }} radius="md">Deploy Instance</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={assignModalOpened}
|
||||
onClose={closeAssign}
|
||||
title={<Title order={4}>Assign Lead Developer</Title>}
|
||||
radius="xl"
|
||||
centered
|
||||
padding="xl"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">Assign a dedicated reviewer for <b>{selectedVillage?.name}</b> instance stability.</Text>
|
||||
<Select
|
||||
label="Technical Lead"
|
||||
placeholder="Search developer..."
|
||||
data={mockDevelopers}
|
||||
leftSection={<TbUser size={16} />}
|
||||
radius="md"
|
||||
searchable
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="subtle" color="gray" onClick={closeAssign}>Cancel</Button>
|
||||
<Button variant="gradient" gradient={{ from: '#2563EB', to: '#7C3AED' }} radius="md">Set Lead</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
120
src/frontend/routes/apps.$appId.orders.tsx
Normal file
120
src/frontend/routes/apps.$appId.orders.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Paper,
|
||||
Table,
|
||||
TextInput,
|
||||
Button,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
SimpleGrid
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { TbSearch, TbFilter, TbEye, TbReceipt, TbTruckDelivery, TbCreditCard, TbTrendingUp } from 'react-icons/tb'
|
||||
import { StatsCard } from '@/frontend/components/StatsCard'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId/orders')({
|
||||
component: OrdersPage,
|
||||
})
|
||||
|
||||
const mockOrders = [
|
||||
{ id: 'ORD-9921', customer: 'John Doe', amount: 'Rp 1.250.000', status: 'PAID', time: '2 mins ago', method: 'BCA Virtual Account' },
|
||||
{ id: 'ORD-9922', customer: 'Jane Smith', amount: 'Rp 450.000', status: 'PENDING', time: '15 mins ago', method: 'OVO' },
|
||||
{ id: 'ORD-9923', customer: 'Rahmat', amount: 'Rp 2.100.000', status: 'SHIPPING', time: '1 hour ago', method: 'Mandiri Transfer' },
|
||||
{ id: 'ORD-9924', customer: 'Amel', amount: 'Rp 750.000', status: 'REFUNDED', time: '2 hours ago', method: 'GoPay' },
|
||||
{ id: 'ORD-9925', customer: 'Siti', amount: 'Rp 1.100.000', status: 'PAID', time: '4 hours ago', method: 'Credit Card' },
|
||||
]
|
||||
|
||||
function OrdersPage() {
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
|
||||
<StatsCard title="Daily Revenue" value="Rp 12.8M" icon={TbTrendingUp} color="teal" trend={{ value: '8.4%', positive: true }} />
|
||||
<StatsCard title="Active Orders" value="142" icon={TbReceipt} color="brand-blue" />
|
||||
<StatsCard title="On Delivery" value="48" icon={TbTruckDelivery} color="orange" />
|
||||
</SimpleGrid>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>Orders Tracking</Title>
|
||||
<Text size="sm" c="dimmed">Detailed transaction and shipment tracking center.</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||
<Group mb="md" grow>
|
||||
<TextInput
|
||||
placeholder="Search Order ID or Customer..."
|
||||
leftSection={<TbSearch size={16} />}
|
||||
radius="md"
|
||||
/>
|
||||
<Badge component="div" variant="light" size="xl" radius="md" style={{ display: 'flex', alignItems: 'center', height: '36px' }}>
|
||||
<TbFilter size={16} style={{ marginRight: 8 }} /> Filter by Status
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Table className="data-table" verticalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Order ID</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Amount</Table.Th>
|
||||
<Table.Th>Payment Method</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{mockOrders.map((order) => (
|
||||
<Table.Tr key={order.id}>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<TbReceipt size={14} color="gray" />
|
||||
<Text fw={600} size="sm">{order.id}</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">{order.time}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{order.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={700} size="sm" color="brand-blue">{order.amount}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
<TbCreditCard size={12} color="gray" />
|
||||
<Text size="xs" c="dimmed">{order.method}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
radius="sm"
|
||||
color={
|
||||
order.status === 'PAID' ? 'teal' :
|
||||
order.status === 'PENDING' ? 'orange' :
|
||||
order.status === 'SHIPPING' ? 'brand-blue' : 'red'
|
||||
}
|
||||
variant={order.status === 'PENDING' ? 'outline' : 'light'}
|
||||
>
|
||||
{order.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label="View Details">
|
||||
<ActionIcon variant="light" size="sm" color="blue">
|
||||
<TbEye size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
124
src/frontend/routes/apps.$appId.payments.tsx
Normal file
124
src/frontend/routes/apps.$appId.payments.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Text,
|
||||
Paper,
|
||||
Group,
|
||||
Badge,
|
||||
Table,
|
||||
ThemeIcon,
|
||||
SimpleGrid,
|
||||
Box,
|
||||
Tooltip
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { TbCreditCard, TbCheck, TbAlertCircle, TbWallet, TbHistory, TbArrowUpRight, TbCircleCheck, TbRefresh } from 'react-icons/tb'
|
||||
import { StatsCard } from '@/frontend/components/StatsCard'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId/payments')({
|
||||
component: PaymentsPage,
|
||||
})
|
||||
|
||||
const mockPayments = [
|
||||
{ id: 'TRX-8412', method: 'Credit Card (Visa)', amount: 'Rp 2.450.000', status: 'SUCCESS', date: '2026-04-01 14:30', gateway: 'Stripe' },
|
||||
{ id: 'TRX-8413', method: 'BCA Virtual Account', amount: 'Rp 1.150.000', status: 'PENDING', date: '2026-04-01 14:28', gateway: 'Midtrans' },
|
||||
{ id: 'TRX-8414', method: 'OVO / E-Wallet', amount: 'Rp 450.000', status: 'SUCCESS', date: '2026-04-01 14:25', gateway: 'Midtrans' },
|
||||
{ id: 'TRX-8415', method: 'ShopeePay', amount: 'Rp 890.000', status: 'FAILED', date: '2026-04-01 14:20', gateway: 'Midtrans' },
|
||||
{ id: 'TRX-8416', method: 'Mandiri Transfer', amount: 'Rp 3.200.000', status: 'SUCCESS', date: '2026-04-01 14:15', gateway: 'Bank Sync' },
|
||||
]
|
||||
|
||||
const gateways = [
|
||||
{ name: 'Stripe', status: 'Operational', color: 'teal' },
|
||||
{ name: 'Midtrans', status: 'Operational', color: 'teal' },
|
||||
{ name: 'Bank Sync', status: 'Slow Response', color: 'orange' },
|
||||
]
|
||||
|
||||
function PaymentsPage() {
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
|
||||
<StatsCard title="Total Volume" value="Rp 842.5M" icon={TbWallet} color="brand-blue" trend={{ value: '12.4%', positive: true }} />
|
||||
<StatsCard title="Success Rate" value="98.2%" icon={TbCircleCheck} color="teal" />
|
||||
<StatsCard title="Refunds (30d)" value="Rp 4.2M" icon={TbHistory} color="red" />
|
||||
</SimpleGrid>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>Payment Gateway Health</Title>
|
||||
<Text size="sm" c="dimmed">Real-time status monitoring for integrated payment providers.</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="lg">
|
||||
{gateways.map((gw) => (
|
||||
<Paper key={gw.name} withBorder radius="xl" p="md" className="glass">
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" color={gw.color} radius="md" size="lg">
|
||||
<TbRefresh size={24} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} size="sm">{gw.name}</Text>
|
||||
</Group>
|
||||
<Badge color={gw.color} variant="dot">{gw.status}</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={4} mt="md">Recent Transactions</Title>
|
||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||
<Table className="data-table" verticalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Transaction ID</Table.Th>
|
||||
<Table.Th>Method</Table.Th>
|
||||
<Table.Th>Gateway</Table.Th>
|
||||
<Table.Th>Amount</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Timestamp</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{mockPayments.map((trx) => (
|
||||
<Table.Tr key={trx.id}>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<TbArrowUpRight size={14} color="gray" />
|
||||
<Text fw={600} size="sm">{trx.id}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<TbCreditCard size={14} color="gray" />
|
||||
<Text size="sm">{trx.method}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="default" radius="sm">{trx.gateway}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={700} size="sm" color="brand-blue">{trx.amount}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={trx.status === 'SUCCESS' ? 'teal' : trx.status === 'PENDING' ? 'orange' : 'red'}
|
||||
variant={trx.status === 'SUCCESS' ? 'light' : 'outline'}
|
||||
leftSection={trx.status === 'SUCCESS' ? <TbCheck size={12} /> : <TbAlertCircle size={12} />}
|
||||
radius="sm"
|
||||
>
|
||||
{trx.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{trx.date}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
111
src/frontend/routes/apps.$appId.products.tsx
Normal file
111
src/frontend/routes/apps.$appId.products.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
Group,
|
||||
Badge,
|
||||
Button,
|
||||
ThemeIcon,
|
||||
Box,
|
||||
Progress,
|
||||
ActionIcon,
|
||||
Tooltip
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { TbPlus, TbPackage, TbAlertTriangle, TbTrendingUp, TbPencil, TbTrash, TbArchive } from 'react-icons/tb'
|
||||
import { StatsCard } from '@/frontend/components/StatsCard'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId/products')({
|
||||
component: ProductsPage,
|
||||
})
|
||||
|
||||
const mockProducts = [
|
||||
{ id: 1, name: 'Premium Wireless Headphones', price: 'Rp 2.450.000', stock: 12, sales: 145, status: 'IN_STOCK' },
|
||||
{ id: 2, name: 'Mechanical Keyboard RGB', price: 'Rp 1.150.000', stock: 4, sales: 89, status: 'LOW_STOCK' },
|
||||
{ id: 3, name: 'Ultra-wide Monitor 34"', price: 'Rp 8.900.000', stock: 2, sales: 34, status: 'LOW_STOCK' },
|
||||
{ id: 4, name: 'Ergonomic Gaming Chair', price: 'Rp 3.200.000', stock: 0, sales: 56, status: 'OUT_OF_STOCK' },
|
||||
{ id: 5, name: 'USB-C Hub Multiport', price: 'Rp 450.000', stock: 45, sales: 231, status: 'IN_STOCK' },
|
||||
{ id: 6, name: 'Webcam 4K Ultra HD', price: 'Rp 1.750.000', stock: 18, sales: 67, status: 'IN_STOCK' },
|
||||
]
|
||||
|
||||
function ProductsPage() {
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
|
||||
<StatsCard title="Total Inventory" value="452 Units" icon={TbPackage} color="brand-blue" />
|
||||
<StatsCard title="Low Stock Alerts" value="8 Items" icon={TbAlertTriangle} color="orange" />
|
||||
<StatsCard title="Top Performing" value="3 items" icon={TbTrendingUp} color="teal" />
|
||||
</SimpleGrid>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>Product Catalog</Title>
|
||||
<Text size="sm" c="dimmed">Inventory management and performance monitoring center.</Text>
|
||||
</Stack>
|
||||
<Button variant="gradient" gradient={{ from: '#2563EB', to: '#7C3AED' }} leftSection={<TbPlus size={18} />} radius="md">
|
||||
Add New Product
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="xl">
|
||||
{mockProducts.map((product) => (
|
||||
<Card key={product.id} withBorder radius="2xl" p="md" className="glass h-full">
|
||||
<Card.Section>
|
||||
<Box h={160} style={{ background: 'rgba(255,255,255,0.03)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ThemeIcon variant="light" size={60} radius="xl" color="brand-blue">
|
||||
<TbArchive size={34} />
|
||||
</ThemeIcon>
|
||||
</Box>
|
||||
</Card.Section>
|
||||
|
||||
<Stack gap="xs" mt="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fw={600} size="sm" truncate>{product.name}</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
color={product.status === 'IN_STOCK' ? 'teal' : product.status === 'LOW_STOCK' ? 'orange' : 'red'}
|
||||
variant="light"
|
||||
>
|
||||
{product.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" mt={4}>
|
||||
<Text fw={700} size="lg" color="brand-blue">{product.price}</Text>
|
||||
<Text size="xs" c="dimmed">{product.sales} Sales</Text>
|
||||
</Group>
|
||||
|
||||
<Box mt="xs">
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed">Stock Level</Text>
|
||||
<Text size="xs" fw={700}>{product.stock}/50</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={(product.stock / 50) * 100}
|
||||
color={product.stock > 10 ? 'teal' : product.stock > 0 ? 'orange' : 'red'}
|
||||
size="xs"
|
||||
radius="xl"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Group justify="flex-end" mt="md" pt="sm" style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
||||
<Tooltip label="Edit Product">
|
||||
<ActionIcon variant="light" size="sm" color="blue">
|
||||
<TbPencil size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove">
|
||||
<ActionIcon variant="light" size="sm" color="red">
|
||||
<TbTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
45
src/frontend/routes/apps.$appId.tsx
Normal file
45
src/frontend/routes/apps.$appId.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute, Outlet, useNavigate, useParams } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/apps/$appId')({
|
||||
component: AppDetailLayout,
|
||||
})
|
||||
|
||||
function AppDetailLayout() {
|
||||
const { appId } = useParams({ from: '/apps/$appId' })
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Format app ID for display (e.g., desa-plus -> Desa+)
|
||||
const appName = appId
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
.replace('Plus', '+')
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container size="xl" py="lg">
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Stack gap={4}>
|
||||
<Title order={1} className="gradient-text" style={{ fontSize: '2.5rem' }}>{appName}</Title>
|
||||
<Text c="dimmed" size="sm" fw={500}>Application ID: <span style={{ fontFamily: 'monospace' }}>{appId}</span></Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Box mt="md">
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
59
src/frontend/routes/apps.index.tsx
Normal file
59
src/frontend/routes/apps.index.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Container, Stack, Title, Text, SimpleGrid, Group, Button, TextInput, Loader } from '@mantine/core'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { TbPlus, TbSearch } from 'react-icons/tb'
|
||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||
import { AppCard } from '@/frontend/components/AppCard'
|
||||
|
||||
export const Route = createFileRoute('/apps/')({
|
||||
component: AppsPage,
|
||||
})
|
||||
|
||||
function AppsPage() {
|
||||
const { data: apps, isLoading } = useQuery({
|
||||
queryKey: ['apps'],
|
||||
queryFn: () => fetch('/api/apps').then((r) => r.json()),
|
||||
})
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container size="xl" py="lg">
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Stack gap={0}>
|
||||
<Title order={2} className="gradient-text">Applications</Title>
|
||||
<Text size="sm" c="dimmed">Manage and monitor all your mobile applications from one place.</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="gradient"
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||
leftSection={<TbPlus size={18} />}
|
||||
radius="md"
|
||||
>
|
||||
Add New Application
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="Search applications..."
|
||||
leftSection={<TbSearch size={16} />}
|
||||
style={{ flex: 1 }}
|
||||
radius="md"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Loader size="xl" type="dots" mx="auto" mt="xl" />
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{apps?.map((app: any) => (
|
||||
<AppCard key={app.id} {...app} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
5
src/frontend/routes/apps.tsx
Normal file
5
src/frontend/routes/apps.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/apps')({
|
||||
component: () => <Outlet />,
|
||||
})
|
||||
@@ -1,20 +1,23 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Paper,
|
||||
Table,
|
||||
Loader,
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { TbChartBar, TbLogout, TbSettings, TbUsers } from 'react-icons/tb'
|
||||
import { createFileRoute, redirect, Link } from '@tanstack/react-router'
|
||||
import { TbActivity, TbApps, TbMessageReport, TbUsers, TbChevronRight } from 'react-icons/tb'
|
||||
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
|
||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||
import { StatsCard } from '@/frontend/components/StatsCard'
|
||||
import { AppCard } from '@/frontend/components/AppCard'
|
||||
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
beforeLoad: async ({ context }) => {
|
||||
@@ -33,62 +36,140 @@ export const Route = createFileRoute('/dashboard')({
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
const stats = [
|
||||
{ title: 'Users', value: '1,234', icon: TbUsers, color: 'blue' },
|
||||
{ title: 'Revenue', value: '$12.4k', icon: TbChartBar, color: 'green' },
|
||||
{ title: 'Settings', value: '3 active', icon: TbSettings, color: 'violet' },
|
||||
const recentErrors = [
|
||||
{ id: 1, app: 'Desa+', message: 'NullPointerException at village_sync.dart:45', version: '2.4.1', time: '2 mins ago', severity: 'critical' },
|
||||
{ id: 2, app: 'E-Commerce', message: 'Failed to load checkout session', version: '1.8.0', time: '15 mins ago', severity: 'high' },
|
||||
{ id: 3, app: 'Fitness App', message: 'SocketException: Connection timed out', version: '0.9.5', time: '1 hour ago', severity: 'medium' },
|
||||
]
|
||||
|
||||
function DashboardPage() {
|
||||
const { data } = useSession()
|
||||
const logout = useLogout()
|
||||
const user = data?.user
|
||||
const { data: sessionData } = useSession()
|
||||
const user = sessionData?.user
|
||||
|
||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||
queryKey: ['dashboard', 'stats'],
|
||||
queryFn: () => fetch('/api/dashboard/stats').then((r) => r.json()),
|
||||
})
|
||||
|
||||
const { data: apps, isLoading: appsLoading } = useQuery({
|
||||
queryKey: ['apps'],
|
||||
queryFn: () => fetch('/api/apps').then((r) => r.json()),
|
||||
})
|
||||
|
||||
return (
|
||||
<Container size="md" py="xl">
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Dashboard</Title>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<TbLogout size={16} />}
|
||||
onClick={() => logout.mutate()}
|
||||
loading={logout.isPending}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Group>
|
||||
<Avatar color="blue" radius="xl" size="lg">
|
||||
{user?.name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Text fw={500}>{user?.name}</Text>
|
||||
<Badge color="red" variant="light" size="sm">SUPER ADMIN</Badge>
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm">{user?.email}</Text>
|
||||
</div>
|
||||
<DashboardLayout>
|
||||
<Container size="xl" py="lg">
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Title order={2} className="gradient-text">Overview Dashboard</Title>
|
||||
<Text size="sm" c="dimmed">Welcome back, {user?.name}. Here is what's happening today.</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="gradient"
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||
leftSection={<TbApps size={18} />}
|
||||
radius="md"
|
||||
component={Link}
|
||||
to="/apps"
|
||||
>
|
||||
Manage All Apps
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.title} withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm" c="dimmed" fw={500}>{stat.title}</Text>
|
||||
<ThemeIcon variant="light" color={stat.color} size="sm">
|
||||
<stat.icon size={14} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<Text fw={700} size="xl">{stat.value}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Container>
|
||||
{statsLoading ? (
|
||||
<Loader size="xl" type="dots" mx="auto" mt="xl" />
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<StatsCard
|
||||
title="Total Applications"
|
||||
value={stats?.totalApps || 0}
|
||||
icon={TbApps}
|
||||
color="brand-blue"
|
||||
trend={{ value: stats?.trends?.totalApps.toString() || '0', positive: true }}
|
||||
/>
|
||||
<StatsCard
|
||||
title="New Errors"
|
||||
value={stats?.newErrors || 0}
|
||||
icon={TbMessageReport}
|
||||
color="brand-purple"
|
||||
trend={{ value: stats?.trends?.newErrors.toString() || '0', positive: false }}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Active Users"
|
||||
value={stats?.activeUsers || 0}
|
||||
icon={TbUsers}
|
||||
color="teal"
|
||||
trend={{ value: stats?.trends?.activeUsers.toString() || '0', positive: true }}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="md">
|
||||
<Title order={3}>Registered Applications</Title>
|
||||
<Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />}>
|
||||
View Report
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{appsLoading ? (
|
||||
<Loader size="xl" type="dots" mx="auto" />
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }}>
|
||||
{apps?.map((app: any) => (
|
||||
<AppCard key={app.id} {...app} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="md">
|
||||
<Title order={3}>Recent Error Reports</Title>
|
||||
<Button variant="subtle" color="brand-blue" rightSection={<TbChevronRight size={16} />}>
|
||||
View All Errors
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||
<Table className="data-table" verticalSpacing="md">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Application</Table.Th>
|
||||
<Table.Th>Error Message</Table.Th>
|
||||
<Table.Th>Version</Table.Th>
|
||||
<Table.Th>Time</Table.Th>
|
||||
<Table.Th>Severity</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{recentErrors.map((error) => (
|
||||
<Table.Tr key={error.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">{error.app}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">{error.message}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color="gray">v{error.version}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{error.time}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={error.severity === 'critical' ? 'red' : error.severity === 'high' ? 'orange' : 'yellow'}
|
||||
variant="dot"
|
||||
>
|
||||
{error.severity.toUpperCase()}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { TbAlertCircle, TbLogin, TbLock, TbMail } from 'react-icons/tb'
|
||||
import { useLogin } from '@/frontend/hooks/useAuth'
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
validateSearch: (search: Record<string, unknown>): { error?: string } => ({
|
||||
error: (search.error as string) || undefined,
|
||||
}),
|
||||
beforeLoad: async ({ context }) => {
|
||||
|
||||
222
src/frontend/routes/settings.tsx
Normal file
222
src/frontend/routes/settings.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Paper,
|
||||
Tabs,
|
||||
Avatar,
|
||||
SimpleGrid,
|
||||
ThemeIcon,
|
||||
List,
|
||||
Box,
|
||||
Divider,
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
TbPlus,
|
||||
TbSearch,
|
||||
TbPencil,
|
||||
TbTrash,
|
||||
TbUserCheck,
|
||||
TbShieldCheck,
|
||||
TbAccessPoint,
|
||||
TbCircleCheck,
|
||||
TbClock,
|
||||
TbApps,
|
||||
} from 'react-icons/tb'
|
||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||
import { StatsCard } from '@/frontend/components/StatsCard'
|
||||
|
||||
export const Route = createFileRoute('/settings')({
|
||||
component: SettingsPage,
|
||||
})
|
||||
|
||||
const mockUsers = [
|
||||
{ id: 1, name: 'Amel', email: 'amel@company.com', role: 'SUPER_ADMIN', apps: 'All', status: 'Online', lastActive: 'Now' },
|
||||
{ id: 2, name: 'John Doe', email: 'john@company.com', role: 'DEVELOPER', apps: 'Desa+, Fitness App', status: 'Offline', lastActive: '2h ago' },
|
||||
{ id: 3, name: 'Jane Smith', email: 'jane@company.com', role: 'QA', apps: 'E-Commerce', status: 'Online', lastActive: '12m ago' },
|
||||
{ id: 4, name: 'Rahmat Hidayat', email: 'rahmat@company.com', role: 'DEVELOPER', apps: 'Desa+', status: 'Online', lastActive: 'Now' },
|
||||
]
|
||||
|
||||
const roles = [
|
||||
{
|
||||
name: 'SUPER_ADMIN',
|
||||
count: 2,
|
||||
color: 'red',
|
||||
permissions: ['Full Access', 'User Mgmt', 'Role Mgmt', 'App Config', 'Logs & Errors']
|
||||
},
|
||||
{
|
||||
name: 'DEVELOPER',
|
||||
count: 12,
|
||||
color: 'brand-blue',
|
||||
permissions: ['View All Apps', 'Manage Assigned App', 'View Logs', 'Resolve Errors', 'Village Setup']
|
||||
},
|
||||
{
|
||||
name: 'QA',
|
||||
count: 5,
|
||||
color: 'orange',
|
||||
permissions: ['View All Apps', 'View Logs', 'Report Errors', 'Test App Features']
|
||||
},
|
||||
]
|
||||
|
||||
function SettingsPage() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container size="xl" py="lg">
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Title order={2} className="gradient-text">Settings</Title>
|
||||
<Text size="sm" c="dimmed">Manage system users, security roles, and application access control.</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
|
||||
<StatsCard title="Total Staff" value={24} icon={TbUserCheck} color="brand-blue" />
|
||||
<StatsCard title="Active Now" value={18} icon={TbAccessPoint} color="teal" />
|
||||
<StatsCard title="Security Roles" value={3} icon={TbShieldCheck} color="purple-primary" />
|
||||
</SimpleGrid>
|
||||
|
||||
<Tabs defaultValue="users" color="brand-blue" variant="pills" radius="md">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="users" leftSection={<TbUserCheck size={16} />}>User Management</Tabs.Tab>
|
||||
<Tabs.Tab value="roles" leftSection={<TbShieldCheck size={16} />}>Role Management</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="users" pt="xl">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<TextInput
|
||||
placeholder="Search users..."
|
||||
leftSection={<TbSearch size={16} />}
|
||||
radius="md"
|
||||
w={350}
|
||||
variant="filled"
|
||||
/>
|
||||
<Button
|
||||
variant="gradient"
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||
leftSection={<TbPlus size={18} />}
|
||||
radius="md"
|
||||
>
|
||||
Add New User
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="2xl" className="glass" p={0} style={{ overflow: 'hidden' }}>
|
||||
<Table className="data-table" verticalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name & Contact</Table.Th>
|
||||
<Table.Th>Role</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>App Access</Table.Th>
|
||||
<Table.Th>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{mockUsers.map((user) => (
|
||||
<Table.Tr key={user.id}>
|
||||
<Table.Td>
|
||||
<Group gap="sm">
|
||||
<Avatar size="sm" radius="xl" color="brand-blue">{user.name.charAt(0)}</Avatar>
|
||||
<Stack gap={0}>
|
||||
<Text fw={600} size="sm">{user.name}</Text>
|
||||
<Text size="xs" c="dimmed">{user.email}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color={user.role === 'SUPER_ADMIN' ? 'red' : user.role === 'DEVELOPER' ? 'brand-blue' : 'orange'}>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={6}>
|
||||
<Box style={{ width: 6, height: 6, borderRadius: '50%', background: user.status === 'Online' ? '#10b981' : '#94a3b8' }} />
|
||||
<Text size="xs" fw={500}>{user.status}</Text>
|
||||
<Text size="xs" c="dimmed" ml="xs"><TbClock size={10} style={{ marginBottom: -2 }} /> {user.lastActive}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
<TbApps size={12} color="gray" />
|
||||
<Text size="xs" fw={500}>{user.apps}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="light" size="sm" color="blue">
|
||||
<TbPencil size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="light" size="sm" color="red">
|
||||
<TbTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="roles" pt="xl">
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="lg">
|
||||
{roles.map((role) => (
|
||||
<Card key={role.name} withBorder radius="2xl" padding="xl" className="glass">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<ThemeIcon size="xl" radius="md" color={role.color} variant="light">
|
||||
<TbShieldCheck size={28} />
|
||||
</ThemeIcon>
|
||||
<Badge variant="default" size="lg" radius="sm">{role.count} Users</Badge>
|
||||
</Group>
|
||||
|
||||
<Stack gap={4}>
|
||||
<Title order={4}>{role.name.replace('_', ' ')}</Title>
|
||||
<Text size="sm" c="dimmed">Core role for secure app management.</Text>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text size="xs" fw={700} c="dimmed" style={{ textTransform: 'uppercase' }}>Key Permissions</Text>
|
||||
<List
|
||||
spacing="xs"
|
||||
size="sm"
|
||||
center
|
||||
icon={
|
||||
<ThemeIcon color="teal" size={16} radius="xl">
|
||||
<TbCircleCheck size={12} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
{role.permissions.map((p) => (
|
||||
<List.Item key={p}>{p}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Button fullWidth variant="light" color={role.color} mt="md" radius="md">
|
||||
Edit Permissions
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
280
src/index.css
280
src/index.css
@@ -1,187 +1,113 @@
|
||||
@import '@mantine/core/styles.css';
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
--font-inter: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
|
||||
/* Monitoring System Colors */
|
||||
--blue-primary: #2563EB;
|
||||
--purple-primary: #7C3AED;
|
||||
--gradient-blue-purple: linear-gradient(135deg, var(--blue-primary) 0%, var(--purple-primary) 100%);
|
||||
|
||||
/* Backgrounds & Cards (Light Mode) */
|
||||
--bg-light: #F8FAFC;
|
||||
--card-light: #FFFFFF;
|
||||
|
||||
/* Backgrounds & Cards (Dark Mode) */
|
||||
--bg-dark: #0F172A;
|
||||
--card-dark: #1E293B;
|
||||
|
||||
/* Transitions */
|
||||
--transition-smooth: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Base Resets */
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
font-family: var(--font-inter);
|
||||
background-color: var(--bg-dark); /* Default to Dark Mode as per App.tsx */
|
||||
color: #F8FAFC;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Custom Scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(124, 58, 237, 0.2);
|
||||
border-radius: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
|
||||
/* Premium Dashboard Utilities */
|
||||
.glass {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 24px; /* XL rounding for cards */
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: var(--gradient-blue-purple);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
background: var(--gradient-blue-purple);
|
||||
}
|
||||
|
||||
.premium-card {
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
.premium-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px -10px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
.sidebar-nav-item {
|
||||
position: relative;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
opacity: 0.05;
|
||||
background: url("./logo.svg");
|
||||
background-size: 256px;
|
||||
transform: rotate(-12deg) scale(1.35);
|
||||
animation: slide 30s linear infinite;
|
||||
pointer-events: none;
|
||||
|
||||
.sidebar-nav-item.active {
|
||||
background: var(--gradient-blue-purple);
|
||||
color: white;
|
||||
}
|
||||
@keyframes slide {
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: 256px 224px;
|
||||
}
|
||||
|
||||
/* Responsive Table */
|
||||
.data-table {
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
.data-table thead th {
|
||||
background: rgba(124, 58, 237, 0.05);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.logo-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.data-table tbody tr {
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 0.3s;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.bun-logo {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
.bun-logo:hover {
|
||||
filter: drop-shadow(0 0 2em #fbf0dfaa);
|
||||
}
|
||||
.react-logo {
|
||||
animation: spin 20s linear infinite;
|
||||
}
|
||||
.react-logo:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
code {
|
||||
background-color: #1a1a1a;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 0.3em;
|
||||
font-family: monospace;
|
||||
}
|
||||
.api-tester {
|
||||
margin: 2rem auto 0;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.endpoint-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #1a1a1a;
|
||||
padding: 0.75rem;
|
||||
border-radius: 12px;
|
||||
font: monospace;
|
||||
border: 2px solid #fbf0df;
|
||||
transition: 0.3s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.endpoint-row:focus-within {
|
||||
border-color: #f3d5a3;
|
||||
}
|
||||
.method {
|
||||
background: #fbf0df;
|
||||
color: #1a1a1a;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 0.9em;
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
width: min-content;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
}
|
||||
.method option {
|
||||
text-align: left;
|
||||
}
|
||||
.url-input {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
background: 0;
|
||||
border: 0;
|
||||
color: #fbf0df;
|
||||
font: 1em monospace;
|
||||
padding: 0.2rem;
|
||||
outline: 0;
|
||||
}
|
||||
.url-input:focus {
|
||||
color: #fff;
|
||||
}
|
||||
.url-input::placeholder {
|
||||
color: rgba(251, 240, 223, 0.4);
|
||||
}
|
||||
.send-button {
|
||||
background: #fbf0df;
|
||||
color: #1a1a1a;
|
||||
border: 0;
|
||||
padding: 0.4rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
transition: 0.1s;
|
||||
cursor: var(--bun-cursor);
|
||||
}
|
||||
.send-button:hover {
|
||||
background: #f3d5a3;
|
||||
transform: translateY(-1px);
|
||||
cursor: pointer;
|
||||
}
|
||||
.response-area {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #fbf0df;
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem;
|
||||
color: #fbf0df;
|
||||
font: monospace;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.response-area:focus {
|
||||
border-color: #f3d5a3;
|
||||
}
|
||||
.response-area::placeholder {
|
||||
color: rgba(251, 240, 223, 0.4);
|
||||
}
|
||||
@media (prefers-reduced-motion) {
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
animation: none !important;
|
||||
}
|
||||
.data-table tbody tr:hover {
|
||||
background: rgba(124, 58, 237, 0.03);
|
||||
}
|
||||
|
||||
30
tests/integration/dashboard.test.ts
Normal file
30
tests/integration/dashboard.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { test, expect, describe } from 'bun:test'
|
||||
import { createTestApp } from '../helpers'
|
||||
|
||||
const app = createTestApp()
|
||||
|
||||
describe('Dashboard Routes (SPA)', () => {
|
||||
test('GET /dashboard returns 200 HTML', async () => {
|
||||
const res = await app.handle(new Request('http://localhost/dashboard'))
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toContain('text/html')
|
||||
})
|
||||
|
||||
test('GET /apps returns 200 HTML', async () => {
|
||||
const res = await app.handle(new Request('http://localhost/apps'))
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toContain('text/html')
|
||||
})
|
||||
|
||||
test('GET /apps/desa-plus returns 200 HTML', async () => {
|
||||
const res = await app.handle(new Request('http://localhost/apps/desa-plus'))
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toContain('text/html')
|
||||
})
|
||||
|
||||
test('GET /settings returns 200 HTML', async () => {
|
||||
const res = await app.handle(new Request('http://localhost/settings'))
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toContain('text/html')
|
||||
})
|
||||
})
|
||||
49
tests/integration/monitoring-api.test.ts
Normal file
49
tests/integration/monitoring-api.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { test, expect, describe } from 'bun:test'
|
||||
import { createTestApp } from '../helpers'
|
||||
|
||||
const app = createTestApp()
|
||||
|
||||
describe('Monitoring API', () => {
|
||||
describe('GET /api/dashboard/stats', () => {
|
||||
test('returns 200 with dashboard stats', async () => {
|
||||
const res = await app.handle(new Request('http://localhost/api/dashboard/stats'))
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body).toHaveProperty('totalApps')
|
||||
expect(body).toHaveProperty('newErrors')
|
||||
expect(body).toHaveProperty('activeUsers')
|
||||
expect(body).toHaveProperty('trends')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/apps', () => {
|
||||
test('returns 200 with list of apps', async () => {
|
||||
const res = await app.handle(new Request('http://localhost/api/apps'))
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(Array.isArray(body)).toBe(true)
|
||||
expect(body.length).toBeGreaterThan(0)
|
||||
expect(body[0]).toHaveProperty('id')
|
||||
expect(body[0]).toHaveProperty('name')
|
||||
expect(body[0]).toHaveProperty('status')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/apps/:appId', () => {
|
||||
test('returns 200 with specific app details', async () => {
|
||||
const res = await app.handle(new Request('http://localhost/api/apps/desa-plus'))
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.id).toBe('desa-plus')
|
||||
expect(body.name).toBe('Desa+')
|
||||
})
|
||||
|
||||
test('returns fallback for unknown app', async () => {
|
||||
const res = await app.handle(new Request('http://localhost/api/apps/unknown-app'))
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.id).toBe('unknown-app')
|
||||
expect(body.name).toBe('unknown-app')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,6 @@
|
||||
"sourceMap": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user