3 Commits

159 changed files with 2841 additions and 2083 deletions

View File

@@ -1,7 +1,7 @@
{
"mcpServers": {
"dual-graph": {
"url": "http://127.0.0.1:8080/mcp"
}
}
}
"mcpServers": {
"dual-graph": {
"url": "http://127.0.0.1:8080/mcp"
}
}
}

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
# Backend API base URL (no trailing slash)
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1

View File

@@ -1,7 +0,0 @@
.next
node_modules
dist
build
coverage
public
.env*

View File

@@ -1,4 +1,2 @@
#!/usr/bin/env sh
# . "$(dirname "$0")/_/husky.sh"
# npx lint-staged
# npm run lint

View File

@@ -1,9 +1,12 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"useTabs": false,
"printWidth": 80,
"tabWidth": 2,
"semi": true,
"endOfLine": "auto"
"useTabs": false,
"endOfLine": "auto",
"arrowParens": "always",
"bracketSpacing": true,
"jsxSingleQuote": false
}

Binary file not shown.

View File

@@ -1,21 +1,8 @@
import nextConfig from 'eslint-config-next';
import prettierPlugin from 'eslint-plugin-prettier';
const prettierRules = {
'prettier/prettier': [
'error',
{
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
endOfLine: 'auto',
},
],
};
const additionalRules = {
...prettierRules,
// Some React rules are intentionally relaxed for this project.
'prettier/prettier': 'error',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/immutability': 'off',
@@ -24,7 +11,17 @@ const additionalRules = {
'import/no-anonymous-default-export': 'off',
};
export default [
const eslintConfig = [
{
ignores: [
'.next/**',
'node_modules/**',
'dist/**',
'build/**',
'coverage/**',
'public/**',
],
},
...nextConfig,
{
files: ['**/*.{js,jsx,ts,tsx}'],
@@ -34,3 +31,5 @@ export default [
rules: additionalRules,
},
];
export default eslintConfig;

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
reactStrictMode: false,
};
export default nextConfig;

477
package-lock.json generated
View File

@@ -51,11 +51,10 @@
"autoprefixer": "^10.4.20",
"baseline-browser-mapping": "^2.10.37",
"eslint": "^9.39.4",
"eslint-config-next": "^16.1.7",
"eslint-config-next": "^16.0.10",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"postcss": "^8.5",
"prettier": "^3.8.1",
"tailwindcss": "^4.1.9",
@@ -1207,9 +1206,9 @@
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.7.tgz",
"integrity": "sha512-v/bRGOJlfRCO+NDKt0bZlIIWjhMKU8xbgEQBo+rV9C8S6czZvs96LZ/v24/GvpEnovZlL4QDpku/RzWHVbmPpA==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.10.tgz",
"integrity": "sha512-b2NlWN70bbPLmfyoLvvidPKWENBYYIe017ZGUpElvQjDytCWgxPJx7L9juxHt0xHvNVA08ZHJdOyhGzon/KJuw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4032,35 +4031,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-escapes": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
"integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"environment": "^1.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -4559,39 +4529,6 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/cli-cursor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"restore-cursor": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz",
"integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"slice-ansi": "^8.0.0",
"string-width": "^8.2.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -4627,13 +4564,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -4646,16 +4576,6 @@
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -5030,19 +4950,6 @@
"node": ">=10.13.0"
}
},
"node_modules/environment": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
"integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/es-abstract": {
"version": "1.24.1",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
@@ -5301,13 +5208,13 @@
}
},
"node_modules/eslint-config-next": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.7.tgz",
"integrity": "sha512-FTq1i/QDltzq+zf9aB/cKWAiZ77baG0V7h8dRQh3thVx7I4dwr6ZXQrWKAaTB7x5VwVXlzoUTyMLIVQPLj2gJg==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.10.tgz",
"integrity": "sha512-BxouZUm0I45K4yjOOIzj24nTi0H2cGo0y7xUmk+Po/PYtJXFBYVDS1BguE7t28efXjKdcN0tmiLivxQy//SsZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/eslint-plugin-next": "16.1.7",
"@next/eslint-plugin-next": "16.0.10",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@@ -6063,19 +5970,6 @@
"node": ">=6.9.0"
}
},
"node_modules/get-east-asian-width": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
"integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -6571,22 +6465,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -7253,68 +7131,6 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lint-staged": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz",
"integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"commander": "^14.0.3",
"listr2": "^9.0.5",
"picomatch": "^4.0.3",
"string-argv": "^0.3.2",
"tinyexec": "^1.0.4",
"yaml": "^2.8.2"
},
"bin": {
"lint-staged": "bin/lint-staged.js"
},
"engines": {
"node": ">=20.17"
},
"funding": {
"url": "https://opencollective.com/lint-staged"
}
},
"node_modules/lint-staged/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/listr2": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
"integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"cli-truncate": "^5.0.0",
"colorette": "^2.0.20",
"eventemitter3": "^5.0.1",
"log-update": "^6.1.0",
"rfdc": "^1.4.1",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/listr2/node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -7344,56 +7160,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/log-update": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
"integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-escapes": "^7.0.0",
"cli-cursor": "^5.0.0",
"slice-ansi": "^7.1.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-update/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/log-update/node_modules/slice-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -7489,19 +7255,6 @@
"node": ">= 0.6"
}
},
"node_modules/mimic-function": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -7878,22 +7631,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/onetime": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-function": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -8009,9 +7746,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8517,23 +8254,6 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"onetime": "^7.0.0",
"signal-exit": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -8545,13 +8265,6 @@
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"dev": true,
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -8843,49 +8556,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/slice-ansi": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz",
"integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.3",
"is-fullwidth-code-point": "^5.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/slice-ansi/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/sonner": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
@@ -8926,33 +8596,6 @@
"node": ">= 0.4"
}
},
"node_modules/string-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
"integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.6.19"
}
},
"node_modules/string-width": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz",
"integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.5.0",
"strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -9066,22 +8709,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -9207,16 +8834,6 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
"integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -9753,62 +9370,6 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -9816,22 +9377,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -6,19 +6,11 @@
"prepare": "husky install",
"build": "next build",
"dev": "next dev",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"start": "next start"
},
"lint-staged": {
"*.+(js|jsx|ts|tsx)": [
"npm run lint:fix"
],
"*.+(json|md|css|scss|html)": [
"npm run format"
]
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@hookform/resolvers": "^5.4.0",
@@ -63,11 +55,10 @@
"autoprefixer": "^10.4.20",
"baseline-browser-mapping": "^2.10.37",
"eslint": "^9.39.4",
"eslint-config-next": "^16.1.7",
"eslint-config-next": "^16.0.10",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"postcss": "^8.5",
"prettier": "^3.8.1",
"tailwindcss": "^4.1.9",

View File

@@ -6,45 +6,21 @@ import { Label } from '@/components/ui/label';
import { GuestGuard } from '@/guards';
import { useLoginForm } from '@/hooks/useLoginForm';
import { Loader2 } from 'lucide-react';
import Image from 'next/image';
export default function LoginPage() {
const { register, handleSubmit, errors, isLoading } = useLoginForm();
return (
<GuestGuard>
<main className="grid min-h-screen bg-background text-foreground lg:grid-cols-[1.05fr_0.95fr]">
<section className="relative hidden overflow-hidden border-r border-border/60 lg:block">
<Image
src="/background.png"
alt=""
fill
priority
className="object-cover"
sizes="55vw"
/>
<div className="absolute inset-0 bg-background/35" />
<div className="absolute inset-x-0 bottom-0 p-10">
<div className="max-w-lg space-y-3">
<p className="text-sm font-medium uppercase tracking-[0.18em] text-primary">
<div className="flex min-h-screen w-full">
<div className="hidden md:flex w-[40%]" />
<div className="flex flex-1 md:w-[60%] items-center justify-center p-6 border-l border-border h-screen">
<section className="w-full max-w-sm space-y-7">
<header className="space-y-2 text-center md:text-left">
<p className="text-sm font-medium text-muted-foreground">
VisionRoad
</p>
<h1 className="text-4xl font-semibold tracking-normal text-foreground">
Road intelligence for faster inspection decisions
</h1>
<p className="text-sm leading-6 text-muted-foreground">
Review projects, uploads, detections, and chainage insights from one secure
workspace.
</p>
</div>
</div>
</section>
<section className="flex min-h-screen items-center justify-center px-6 py-10">
<div className="w-full max-w-sm space-y-7">
<header className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">VisionRoad</p>
<h2 className="text-2xl font-semibold tracking-normal">Sign in</h2>
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
<p className="text-sm text-muted-foreground">
Use your email and password to continue.
</p>
@@ -65,7 +41,9 @@ export default function LoginPage() {
})}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
<p className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
@@ -83,7 +61,9 @@ export default function LoginPage() {
})}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
<p className="text-sm text-destructive">
{errors.password.message}
</p>
)}
</div>
@@ -98,9 +78,9 @@ export default function LoginPage() {
)}
</Button>
</form>
</div>
</section>
</main>
</section>
</div>
</div>
</GuestGuard>
);
}

View File

@@ -11,8 +11,8 @@ export default function AccessPage() {
</div>
<h1 className="text-2xl font-semibold">Access denied</h1>
<p className="mt-2 text-sm text-muted-foreground">
You do not have permission to access any available module. Contact your administrator if
you need access.
You do not have permission to access any available module. Contact
your administrator if you need access.
</p>
</div>
</main>

View File

@@ -2,12 +2,8 @@
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Edit, RotateCcw } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { usePermissions } from '@/hooks/usePermissions';
import type { Client } from '@/types';
function formatDate(value?: string | null) {
@@ -19,27 +15,15 @@ function formatDate(value?: string | null) {
}).format(new Date(value));
}
interface UseClientColumnsParams {
onEdit: (client: Client) => void;
onToggleStatus: (client: Client) => void;
pendingClientId?: number;
}
export function useClientColumns({
onEdit,
onToggleStatus,
pendingClientId,
}: UseClientColumnsParams): ColumnDef<Client>[] {
const { hasPermission } = usePermissions();
const canEdit = hasPermission(PERMISSIONS.CLIENT.UPDATE);
const canDelete = hasPermission(PERMISSIONS.CLIENT.DELETE);
export function useClientColumns(): ColumnDef<Client>[] {
return useMemo(() => {
const columns: ColumnDef<Client>[] = [
return [
{
accessorKey: 'name',
header: 'Client',
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
cell: ({ row }) => (
<span className="font-medium">{row.original.name}</span>
),
},
{
accessorKey: 'email',
@@ -51,7 +35,9 @@ export function useClientColumns({
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.contact_name}</p>
<p className="text-xs text-muted-foreground">{row.original.contact_phone_number}</p>
<p className="text-xs text-muted-foreground">
{row.original.contact_phone_number}
</p>
</div>
),
},
@@ -75,38 +61,5 @@ export function useClientColumns({
),
},
];
if (!canEdit && !canDelete) return columns;
columns.push({
id: 'actions',
header: () => <div className="text-right">Actions</div>,
enableSorting: false,
cell: ({ row }) => {
const client = row.original;
return (
<div className="flex justify-end gap-2">
{canEdit ? (
<Button variant="outline" size="sm" onClick={() => onEdit(client)}>
<Edit className="size-4" />
</Button>
) : null}
{canDelete ? (
<Button
variant="outline"
size="sm"
disabled={pendingClientId === client.id}
onClick={() => onToggleStatus(client)}
>
<RotateCcw className="mr-2 size-4" />
{client.is_active ? 'Deactivate' : 'Activate'}
</Button>
) : null}
</div>
);
},
});
return columns;
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingClientId]);
}, []);
}

View File

@@ -38,8 +38,12 @@ export function ClientSheet({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{clientId ? 'Edit Client' : 'Create Client'}</DialogTitle>
<DialogDescription>Manage company and primary contact details.</DialogDescription>
<DialogTitle>
{clientId ? 'Edit Client' : 'Create Client'}
</DialogTitle>
<DialogDescription>
Manage company and primary contact details.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="grid gap-4 md:grid-cols-2">
@@ -88,7 +92,11 @@ export function ClientSheet({
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="gst">GST</Label>
<Input id="gst" placeholder="27ABCDE1234F1Z5" {...register('gst')} />
<Input
id="gst"
placeholder="27ABCDE1234F1Z5"
{...register('gst')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pan">PAN</Label>
@@ -141,7 +149,9 @@ export function ClientSheet({
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{clientId ? 'Update Client' : 'Create Client'}
</Button>
</DialogFooter>

View File

@@ -2,8 +2,10 @@
import type { ReactNode } from 'react';
import type { ColumnDef, SortingState } from '@tanstack/react-table';
import { Edit, RotateCcw } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import { PERMISSIONS } from '@/constants/permissions';
import type { Client } from '@/types';
interface ClientTableProps {
@@ -18,6 +20,9 @@ interface ClientTableProps {
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onSortingChange: (sorting: SortingState) => void;
onEdit: (client: Client) => void;
onToggleStatus: (client: Client) => void;
pendingClientId?: number;
}
export function ClientTable({
@@ -32,6 +37,9 @@ export function ClientTable({
onPageChange,
onLimitChange,
onSortingChange,
onEdit,
onToggleStatus,
pendingClientId,
}: ClientTableProps) {
return (
<DataTable
@@ -41,6 +49,21 @@ export function ClientTable({
isLoading={isLoading}
toolbar={toolbar}
emptyTitle="No clients found."
actions={[
{
label: 'Edit',
icon: <Edit className="size-4" />,
permission: PERMISSIONS.CLIENT.UPDATE,
onClick: onEdit,
},
{
label: (client) => (client.is_active ? 'Deactivate' : 'Activate'),
icon: <RotateCcw className="size-4" />,
permission: PERMISSIONS.CLIENT.DELETE,
disabled: (client) => pendingClientId === client.id,
onClick: onToggleStatus,
},
]}
pagination={{
skip,
limit,

View File

@@ -12,7 +12,8 @@ export function useClientFilters() {
const [limit, setLimitValue] = useState(10);
const [sorting, setSortingValue] = useState<SortingState>([]);
const [searchTerm, setSearchTermValue] = useState('');
const [statusFilter, setStatusFilterValue] = useState<ClientStatusFilter>('all');
const [statusFilter, setStatusFilterValue] =
useState<ClientStatusFilter>('all');
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
const setSearchTerm = useCallback((value: string) => {

View File

@@ -38,7 +38,9 @@ export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: clientKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update client' : 'Failed to create client');
toast.error(
values.id ? 'Failed to update client' : 'Failed to create client',
);
},
});
}
@@ -46,7 +48,8 @@ export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
export function useClientStatusMutation() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (client: Client) => clientService.updateClientStatus(client.id, !client.is_active),
mutationFn: (client: Client) =>
clientService.updateClientStatus(client.id, !client.is_active),
onSuccess: () => {
toast.success('Client status updated');
queryClient.invalidateQueries({ queryKey: clientKeys.all });

View File

@@ -37,7 +37,8 @@ function buildClientListParams({
export function useClientsQuery(params: UseClientsQueryParams) {
const { skip, limit, searchTerm, statusFilter, sorting } = params;
const listParams = useMemo(
() => buildClientListParams({ skip, limit, searchTerm, statusFilter, sorting }),
() =>
buildClientListParams({ skip, limit, searchTerm, statusFilter, sorting }),
[limit, searchTerm, skip, sorting, statusFilter],
);

View File

@@ -4,7 +4,6 @@ import { useCallback, useMemo, useState } from 'react';
import { Building2, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
@@ -45,7 +44,8 @@ export default function ClientsPage() {
const clientForm = useClientForm({
onSaved: () => setIsSheetOpen(false),
});
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } = clientForm;
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } =
clientForm;
const { register, handleSubmit, clientId, isSaving } = clientForm;
const statusMutation = useClientStatusMutation();
const { mutate: updateClientStatus, pendingClientId } = statusMutation;
@@ -70,11 +70,7 @@ export default function ClientsPage() {
[updateClientStatus],
);
const columns = useClientColumns({
onEdit: openEdit,
onToggleStatus: toggleStatus,
pendingClientId,
});
const columns = useClientColumns();
const total = clientsQuery.data?.total ?? 0;
const clients = clientsQuery.data?.items ?? [];
@@ -119,9 +115,10 @@ export default function ClientsPage() {
onPageChange={setSkip}
onLimitChange={setLimit}
onSortingChange={setSorting}
onEdit={openEdit}
onToggleStatus={toggleStatus}
pendingClientId={pendingClientId}
/>
<PoweredBy />
</main>
<ClientSheet

View File

@@ -30,10 +30,13 @@ import { DashboardMap } from '@/components/dashboard/dashboard-map';
import { PageHeader } from '@/components/page-header';
import { projectService, projectSummaryService } from '@/services/api';
import { Project } from '@/types';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton';
import { PoweredBy } from '@/components/powered-by';
import { toast } from 'sonner';
import { Reveal, StaggerContainer, StaggerItem } from '@/components/ui/reveal';
@@ -161,7 +164,8 @@ function calculateStats(summary: ProjectSummary | null): DetectionStats {
chnDrainIssue > 0 ||
chnDefectiveCulvert > 0
) {
const shortName = chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
const shortName =
chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
chainageData.push({
name: shortName,
defected_sign_board: chnDefectedSignboard,
@@ -208,8 +212,12 @@ function calculateStats(summary: ProjectSummary | null): DetectionStats {
export default function DashboardPage() {
const [isLoading, setIsLoading] = useState(true);
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [projectSummary, setProjectSummary] = useState<ProjectSummary | null>(null);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null,
);
const [projectSummary, setProjectSummary] = useState<ProjectSummary | null>(
null,
);
const [stats, setStats] = useState<DetectionStats>({
totalDefectedSignboard: 0,
totalPothole: 0,
@@ -231,7 +239,9 @@ export default function DashboardPage() {
setProjects(projectsData.items);
if (projectsData.items.length > 0) {
setSelectedProjectId(projectsData.items[projectsData.items.length - 1].id);
setSelectedProjectId(
projectsData.items[projectsData.items.length - 1].id,
);
}
} catch (err) {
console.error('Failed to load projects:', err);
@@ -259,12 +269,18 @@ export default function DashboardPage() {
: [];
// Extract chainages from selected package
const [selectedPackageId, setSelectedPackageId] = useState<string | null>(null);
const [selectedChainageId, setSelectedChainageId] = useState<string | null>(null); // Changed 'selectedLocationId' to 'selectedChainageId'
const [selectedPackageId, setSelectedPackageId] = useState<string | null>(
null,
);
const [selectedChainageId, setSelectedChainageId] = useState<string | null>(
null,
); // Changed 'selectedLocationId' to 'selectedChainageId'
const chainages =
projectSummary && selectedPackageId && selectedPackageId !== 'all'
? Object.keys(projectSummary.packages[selectedPackageId]?.chainages || {}).map((chnName) => ({
? Object.keys(
projectSummary.packages[selectedPackageId]?.chainages || {},
).map((chnName) => ({
id: chnName,
name: chnName,
}))
@@ -282,9 +298,10 @@ export default function DashboardPage() {
try {
setIsLoading(true);
const summary = await projectSummaryService.getProjectSummary<ProjectSummary>(
selectedProjectId,
);
const summary =
await projectSummaryService.getProjectSummary<ProjectSummary>(
selectedProjectId,
);
setProjectSummary(summary);
} catch (err) {
console.error('Failed to load project summary:', err);
@@ -323,8 +340,15 @@ export default function DashboardPage() {
// Auto-select last chainage when package is selected
useEffect(() => {
if (projectSummary && selectedPackageId && selectedPackageId !== 'all' && selectedChainageId === null) {
const chainageIds = Object.keys(projectSummary.packages[selectedPackageId]?.chainages || {});
if (
projectSummary &&
selectedPackageId &&
selectedPackageId !== 'all' &&
selectedChainageId === null
) {
const chainageIds = Object.keys(
projectSummary.packages[selectedPackageId]?.chainages || {},
);
if (chainageIds.length > 0) {
setSelectedChainageId(chainageIds[chainageIds.length - 1]);
}
@@ -416,7 +440,8 @@ export default function DashboardPage() {
chnDrainIssue > 0 ||
chnDefectiveCulvert > 0
) {
const shortName = chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
const shortName =
chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
chainageData.push({
name: shortName,
defected_sign_board: chnDefectedSignboard,
@@ -479,7 +504,10 @@ export default function DashboardPage() {
<span>Filters</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0 overflow-hidden" align="end">
<PopoverContent
className="w-[320px] p-0 overflow-hidden"
align="end"
>
<div className="p-4 border-b bg-muted/30">
<h3 className="font-bold text-sm flex items-center gap-2 text-foreground">
<Milestone className="h-4 w-4 text-primary" />
@@ -578,7 +606,9 @@ export default function DashboardPage() {
<Card className="flex-1 h-full">
<CardHeader className="items-center pb-0">
<CardTitle>Detection Distribution</CardTitle>
<CardDescription>Breakdown of all detected road conditions</CardDescription>
<CardDescription>
Breakdown of all detected road conditions
</CardDescription>
</CardHeader>
<CardContent className="flex-1 pb-0">
<DetectionDonutChart
@@ -606,13 +636,18 @@ export default function DashboardPage() {
<Reveal delay={0.3} direction="right" className="flex flex-col">
<Card className="flex-1 h-full">
<CardHeader className="items-center pb-0">
<CardTitle className="text-base font-bold">Severity by Segment</CardTitle>
<CardTitle className="text-base font-bold">
Severity by Segment
</CardTitle>
<CardDescription className="text-xs">
Detections grouped by road segment
</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
<ChainageBarChart data={stats.chainageData || []} isLoading={isLoading} />
<ChainageBarChart
data={stats.chainageData || []}
isLoading={isLoading}
/>
</CardContent>
<CardFooter className="flex-col gap-2 text-sm">
<div className="leading-none text-muted-foreground">
@@ -636,8 +671,6 @@ export default function DashboardPage() {
</Reveal>
</>
)}
<PoweredBy />
</div>
</main>
</>

View File

@@ -13,13 +13,12 @@ const ModulesLayout = ({
}>) => {
return (
<AuthGuard>
<SidebarProvider className="bg-transparent overflow-hidden">
<AppSidebar className="bg-transparent" />
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden bg-transparent">
<SidebarProvider>
<AppSidebar />
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden">
<div className="flex-1 overflow-auto">
{/* Centering container wrapper */}
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full">
<div className="flex gap-3 items-center sticky top-0 bg-background/30 backdrop-blur-xl z-30 py-3 px-6 border-b border-border/10">
<div className="flex gap-3 items-center sticky top-0 bg-background z-50 py-3 px-6 border-b border-border">
<SidebarTrigger />
<ModeToggle />
<Separator orientation="vertical" className="mx-2 h-4" />

View File

@@ -4,7 +4,11 @@ import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Package, Project } from '@/types';
function EmptyValue() {
@@ -35,16 +39,14 @@ function StateBadges({ value }: { value: string | null }) {
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-[10px] font-bold text-muted-foreground transition-colors hover:bg-muted"
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-muted-foreground transition-colors hover:bg-muted"
>
+{remainingStates.length}
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
Other States
</p>
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
{remainingStates.map((state) => (
<Badge key={state} variant="secondary">
{state}
@@ -64,20 +66,22 @@ export function usePackageColumns(projects: Project[]) {
{
accessorKey: 'name',
header: 'Package Name',
cell: ({ row }) => <div className="font-semibold">{row.original.name}</div>,
cell: ({ row }) => <div>{row.original.name}</div>,
},
{
accessorKey: 'project_id',
header: 'Project',
cell: ({ row }) =>
projects.find((project) => project.id === row.original.project_id)?.name ||
row.original.project_id,
projects.find((project) => project.id === row.original.project_id)
?.name || row.original.project_id,
},
{
id: 'project_state',
header: 'Project State',
cell: ({ row }) => {
const project = projects.find((item) => item.id === row.original.project_id);
const project = projects.find(
(item) => item.id === row.original.project_id,
);
return <StateBadges value={project?.state ?? null} />;
},
},

View File

@@ -1,7 +1,7 @@
'use client';
import type { ComponentProps } from 'react';
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Globe, Loader2, Package as PackageIcon } from 'lucide-react';
import { FormField } from '@/components/form';
@@ -36,7 +36,7 @@ interface PackageDialogProps {
onProjectChange: (projectId: string) => void;
register: UseFormRegister<PackageFormValues>;
errors: FieldErrors<PackageFormValues>;
touchedFields: UseFormReturn<PackageFormValues>['formState']['touchedFields'];
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean;
isSaving: boolean;
@@ -52,31 +52,36 @@ export function PackageDialog({
onProjectChange,
register,
errors,
touchedFields,
isSubmitted,
onSubmit,
canSubmit,
isSaving,
}: PackageDialogProps) {
const projectErrorMessage = touchedFields.project_id ? errors.project_id?.message : undefined;
const nameErrorMessage = touchedFields.name ? errors.name?.message : undefined;
const startErrorMessage = touchedFields.chainage_start_km
const projectErrorMessage = isSubmitted
? errors.project_id?.message
: undefined;
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const startErrorMessage = isSubmitted
? errors.chainage_start_km?.message
: undefined;
const endErrorMessage = touchedFields.chainage_end_km
const endErrorMessage = isSubmitted
? errors.chainage_end_km?.message
: undefined;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl" onOpenAutoFocus={(event) => event.preventDefault()}>
<DialogContent
className="max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3 text-xl">
<DialogTitle className="flex items-center gap-3">
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
<PackageIcon className="size-5" />
</div>
{packageId ? 'Edit Package Details' : 'Create New Package'}
</DialogTitle>
<DialogDescription className="text-sm">
<DialogDescription>
{packageId
? 'Update the technical specifications for your road infrastructure package.'
: 'Select a project and provide the essential data to establish a new package.'}
@@ -89,7 +94,7 @@ export function PackageDialog({
<Select value={projectId} onValueChange={onProjectChange}>
<SelectTrigger aria-invalid={!!projectErrorMessage}>
{isProjectsLoading ? (
<span className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</span>
@@ -105,12 +110,22 @@ export function PackageDialog({
))}
</SelectContent>
</Select>
<input type="hidden" {...register('project_id')} value={projectId} readOnly />
<input
type="hidden"
{...register('project_id')}
value={projectId}
readOnly
/>
</FormField>
) : null}
<div className="grid gap-5 md:grid-cols-2">
<FormField id="package-name" label="Package Name" required error={nameErrorMessage}>
<FormField
id="package-name"
label="Package Name"
required
error={nameErrorMessage}
>
<Input
id="package-name"
placeholder="e.g. Package 01"
@@ -128,10 +143,18 @@ export function PackageDialog({
</span>
}
>
<Input id="package-region" placeholder="e.g. North Zone" {...register('region')} />
<Input
id="package-region"
placeholder="e.g. North Zone"
{...register('region')}
/>
</FormField>
<FormField id="package-start" label="Segment Start (km)" error={startErrorMessage}>
<FormField
id="package-start"
label="Segment Start (km)"
error={startErrorMessage}
>
<Input
id="package-start"
type="number"
@@ -142,7 +165,11 @@ export function PackageDialog({
/>
</FormField>
<FormField id="package-end" label="Segment End (km)" error={endErrorMessage}>
<FormField
id="package-end"
label="Segment End (km)"
error={endErrorMessage}
>
<Input
id="package-end"
type="number"
@@ -155,7 +182,12 @@ export function PackageDialog({
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button type="submit" disabled={isSaving || !canSubmit}>

View File

@@ -1,6 +1,7 @@
'use client';
import type { ColumnDef } from '@tanstack/react-table';
import { Edit3, Trash2 } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import type { Package } from '@/types';
@@ -35,8 +36,19 @@ export function PackageTable({
title="Packages"
data={packages}
columns={columns}
onEdit={onEdit}
onDelete={onDelete}
actions={[
{
label: 'Edit',
icon: <Edit3 className="size-4" />,
onClick: onEdit,
},
{
label: 'Delete',
icon: <Trash2 className="size-4" />,
className: 'text-destructive',
onClick: onDelete,
},
]}
isLoading={isLoading}
emptyTitle="No packages found."
pagination={{

View File

@@ -49,7 +49,9 @@ function toNumberOrZero(value: string) {
return value.trim() ? Number(value) : 0;
}
function toPackagePayload(values: PackageFormValues): PackageCreate | PackageUpdate {
function toPackagePayload(
values: PackageFormValues,
): PackageCreate | PackageUpdate {
return {
project_id: values.project_id,
name: values.name.trim(),
@@ -67,10 +69,10 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
reset,
setValue,
control,
formState: { errors, isSubmitting, touchedFields },
formState: { errors, isSubmitting, isSubmitted },
} = useForm<PackageFormValues>({
defaultValues,
mode: 'onTouched',
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(packageFormSchema),
});
@@ -78,7 +80,8 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
const packageId = useWatch({ control, name: 'id' });
const projectId = useWatch({ control, name: 'project_id' }) || '';
const packageName = useWatch({ control, name: 'name' }) || '';
const canSubmit = projectId.trim().length > 0 && packageName.trim().length > 0;
const canSubmit =
projectId.trim().length > 0 && packageName.trim().length > 0;
const saveMutation = useMutation({
mutationFn: (values: PackageFormValues) => {
@@ -94,7 +97,9 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: packageKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update package' : 'Failed to create package');
toast.error(
values.id ? 'Failed to update package' : 'Failed to create package',
);
},
});
@@ -130,7 +135,11 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
);
const setProjectId = useCallback(
(value: string) => setValue('project_id', value, { shouldDirty: true, shouldValidate: true }),
(value: string) =>
setValue('project_id', value, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue],
);
@@ -144,7 +153,7 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
projectId,
setProjectId,
errors,
touchedFields,
isSubmitted,
canSubmit,
isSaving: isSubmitting || saveMutation.isPending,
};

View File

@@ -15,7 +15,10 @@ interface UsePackagesQueryParams {
}
export function usePackagesQuery({ skip, limit }: UsePackagesQueryParams) {
const listParams = useMemo<PaginationParams>(() => ({ skip, limit }), [limit, skip]);
const listParams = useMemo<PaginationParams>(
() => ({ skip, limit }),
[limit, skip],
);
return useQuery({
queryKey: packageKeys.list(listParams),

View File

@@ -4,7 +4,6 @@ import { useCallback, useState } from 'react';
import { Package as PackageIcon, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { Button } from '@/components/ui/button';
import type { Package } from '@/types';
@@ -38,7 +37,7 @@ export default function PackagePage() {
projectId,
setProjectId,
errors,
touchedFields,
isSubmitted,
canSubmit,
isSaving,
} = packageForm;
@@ -108,8 +107,6 @@ export default function PackagePage() {
onEdit={openEdit}
onDelete={deletePackage}
/>
<PoweredBy />
</main>
<PackageDialog
@@ -122,7 +119,7 @@ export default function PackagePage() {
onProjectChange={setProjectId}
register={register}
errors={errors}
touchedFields={touchedFields}
isSubmitted={isSubmitted}
onSubmit={onSubmit}
canSubmit={canSubmit}
isSaving={isSaving}

View File

@@ -2,12 +2,8 @@
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Edit, RotateCcw } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { usePermissions } from '@/hooks/usePermissions';
import type { Plan } from '@/types';
function formatDate(value?: string | null) {
@@ -25,37 +21,24 @@ function formatPrice(value: string, cycle: string) {
return `${price} / ${cycle}`;
}
interface UsePlanColumnsParams {
onEdit: (plan: Plan) => void;
onToggleStatus: (plan: Plan) => void;
pendingPlanId?: number;
}
export function usePlanColumns({
onEdit,
onToggleStatus,
pendingPlanId,
}: UsePlanColumnsParams): ColumnDef<Plan>[] {
const { hasPermission } = usePermissions();
const canEdit = hasPermission(PERMISSIONS.PLAN.UPDATE);
const canDelete = hasPermission(PERMISSIONS.PLAN.DELETE);
export function usePlanColumns(): ColumnDef<Plan>[] {
return useMemo(() => {
const columns: ColumnDef<Plan>[] = [
return [
{
accessorKey: 'name',
header: 'Plan',
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.name}</p>
<p className="text-xs text-muted-foreground">{row.original.slug}</p>
<p>{row.original.name}</p>
<p className="text-muted-foreground">{row.original.slug}</p>
</div>
),
},
{
accessorKey: 'price',
header: 'Price',
cell: ({ row }) => formatPrice(row.original.price, row.original.billing_cycle),
cell: ({ row }) =>
formatPrice(row.original.price, row.original.billing_cycle),
},
{
accessorKey: 'trial_days',
@@ -67,7 +50,7 @@ export function usePlanColumns({
header: 'Limits',
enableSorting: false,
cell: ({ row }) => (
<div className="text-xs text-muted-foreground">
<div className="text-muted-foreground">
<p>{row.original.max_projects} projects</p>
<p>{row.original.max_users} users</p>
</div>
@@ -86,43 +69,12 @@ export function usePlanColumns({
<Badge variant={row.original.is_active ? 'default' : 'secondary'}>
{row.original.is_active ? 'Active' : 'Inactive'}
</Badge>
{row.original.is_custom ? <Badge variant="outline">Custom</Badge> : null}
{row.original.is_custom ? (
<Badge variant="outline">Custom</Badge>
) : null}
</div>
),
},
];
if (!canEdit && !canDelete) return columns;
columns.push({
id: 'actions',
header: () => <div className="text-right">Actions</div>,
enableSorting: false,
cell: ({ row }) => {
const plan = row.original;
return (
<div className="flex justify-end gap-2">
{canEdit ? (
<Button variant="outline" size="sm" onClick={() => onEdit(plan)}>
<Edit className="size-4" />
</Button>
) : null}
{canDelete ? (
<Button
variant="outline"
size="sm"
disabled={pendingPlanId === plan.id}
onClick={() => onToggleStatus(plan)}
>
<RotateCcw className="mr-2 size-4" />
{plan.is_active ? 'Deactivate' : 'Activate'}
</Button>
) : null}
</div>
);
},
});
return columns;
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingPlanId]);
}, []);
}

View File

@@ -31,7 +31,10 @@ export function PlanFilters({
placeholder="Search plans"
className="w-full sm:w-72"
/>
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as PlanStatusFilter)}>
<Select
value={statusFilter}
onValueChange={(value) => onStatusChange(value as PlanStatusFilter)}
>
<SelectTrigger className="w-full sm:w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>

View File

@@ -93,7 +93,7 @@ export function PlanSheet({
placeholder="Basic plan for small teams"
{...register('description', { required: true })}
required
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
/>
</div>
@@ -180,7 +180,7 @@ export function PlanSheet({
</div>
<div className="flex flex-wrap gap-5">
<label className="flex items-center gap-2 text-sm">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="size-4 rounded border-border accent-primary"
@@ -188,7 +188,7 @@ export function PlanSheet({
/>
Active
</label>
<label className="flex items-center gap-2 text-sm">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="size-4 rounded border-border accent-primary"
@@ -201,10 +201,12 @@ export function PlanSheet({
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<Label>Permissions</Label>
<span className="text-xs text-muted-foreground">{permissionIds.length} selected</span>
<span className="text-muted-foreground">
{permissionIds.length} selected
</span>
</div>
{isPermissionsLoading ? (
<div className="rounded-md border p-6 text-sm text-muted-foreground">
<div className="rounded-md border p-6 text-muted-foreground">
Loading permissions...
</div>
) : (
@@ -226,7 +228,9 @@ export function PlanSheet({
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{planId ? 'Update Plan' : 'Create Plan'}
</Button>
</SheetFooter>

View File

@@ -2,8 +2,10 @@
import type { ReactNode } from 'react';
import type { ColumnDef, SortingState } from '@tanstack/react-table';
import { Edit, RotateCcw } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import { PERMISSIONS } from '@/constants/permissions';
import type { Plan } from '@/types';
interface PlanTableProps {
@@ -18,6 +20,9 @@ interface PlanTableProps {
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onSortingChange: (sorting: SortingState) => void;
onEdit: (plan: Plan) => void;
onToggleStatus: (plan: Plan) => void;
pendingPlanId?: number;
}
export function PlanTable({
@@ -32,6 +37,9 @@ export function PlanTable({
onPageChange,
onLimitChange,
onSortingChange,
onEdit,
onToggleStatus,
pendingPlanId,
}: PlanTableProps) {
return (
<DataTable
@@ -41,6 +49,21 @@ export function PlanTable({
isLoading={isLoading}
toolbar={toolbar}
emptyTitle="No plans found."
actions={[
{
label: 'Edit',
icon: <Edit className="size-4" />,
permission: PERMISSIONS.PLAN.UPDATE,
onClick: onEdit,
},
{
label: (plan) => (plan.is_active ? 'Deactivate' : 'Activate'),
icon: <RotateCcw className="size-4" />,
permission: PERMISSIONS.PLAN.DELETE,
disabled: (plan) => pendingPlanId === plan.id,
onClick: onToggleStatus,
},
]}
pagination={{
skip,
limit,

View File

@@ -117,7 +117,10 @@ export function usePlanForm({
max_organizations: plan.max_organizations ?? 0,
max_users: plan.max_users ?? 0,
max_roles: plan.max_roles ?? 0,
permission_ids: collectPermissionIdsByKeys(permissionTree, plan.permissions || []),
permission_ids: collectPermissionIdsByKeys(
permissionTree,
plan.permissions || [],
),
is_active: plan.is_active,
is_custom: plan.is_custom,
});
@@ -126,7 +129,11 @@ export function usePlanForm({
);
const setPermissionIds = useCallback(
(ids: number[]) => setValue('permission_ids', ids, { shouldDirty: true, shouldValidate: true }),
(ids: number[]) =>
setValue('permission_ids', ids, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue],
);

View File

@@ -19,7 +19,9 @@ export function useSavePlanMutation({ onSaved }: { onSaved: () => void }) {
onSaved();
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update plan' : 'Failed to create plan');
toast.error(
values.id ? 'Failed to update plan' : 'Failed to create plan',
);
},
});
}
@@ -28,7 +30,8 @@ export function usePlanStatusMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (plan: Plan) => planService.updatePlanStatus(plan.id, !plan.is_active),
mutationFn: (plan: Plan) =>
planService.updatePlanStatus(plan.id, !plan.is_active),
onSuccess: () => {
toast.success('Plan status updated');
queryClient.invalidateQueries({ queryKey: planKeys.lists() });

View File

@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BadgeIndianRupee, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
@@ -17,7 +16,10 @@ import { PlanTable } from './components/PlanTable';
import { usePlanFilters } from './hooks/usePlanFilters';
import { usePlanForm } from './hooks/usePlanForm';
import { usePlanStatusMutation } from './hooks/usePlanMutations';
import { useOrganizationPermissionTreeQuery, usePlansQuery } from './hooks/usePlanQueries';
import {
useOrganizationPermissionTreeQuery,
usePlansQuery,
} from './hooks/usePlanQueries';
export default function PlansPage() {
const [isSheetOpen, setIsSheetOpen] = useState(false);
@@ -124,11 +126,7 @@ export default function PlansPage() {
[statusMutation],
);
const columns = usePlanColumns({
onEdit: openEdit,
onToggleStatus: toggleStatus,
pendingPlanId: statusMutation.variables?.id,
});
const columns = usePlanColumns();
const plans = plansQuery.data?.items ?? [];
const total = plansQuery.data?.total ?? plansQuery.data?.totalItems ?? 0;
@@ -173,9 +171,10 @@ export default function PlansPage() {
onPageChange={setSkip}
onLimitChange={setLimit}
onSortingChange={setSorting}
onEdit={openEdit}
onToggleStatus={toggleStatus}
pendingPlanId={statusMutation.variables?.id}
/>
<PoweredBy />
</main>
<PlanSheet

View File

@@ -4,7 +4,11 @@ import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Project } from '@/types';
function EmptyValue() {
@@ -35,16 +39,14 @@ function StateBadges({ value }: { value: string | null }) {
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-[10px] font-bold text-muted-foreground transition-colors hover:bg-muted"
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-muted-foreground transition-colors hover:bg-muted"
>
+{remainingStates.length}
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
Other States
</p>
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
{remainingStates.map((state) => (
<Badge key={state} variant="secondary">
{state}
@@ -64,7 +66,7 @@ export function useProjectColumns() {
{
accessorKey: 'name',
header: 'Project Name',
cell: ({ row }) => <div className="font-semibold">{row.original.name}</div>,
cell: ({ row }) => <div>{row.original.name}</div>,
},
{
accessorKey: 'state',

View File

@@ -1,7 +1,7 @@
'use client';
import type { ComponentProps } from 'react';
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Layers, Loader2, MapPin, Route } from 'lucide-react';
import { FormField } from '@/components/form';
@@ -24,7 +24,7 @@ interface ProjectDialogProps {
projectId?: string;
register: UseFormRegister<ProjectFormValues>;
errors: FieldErrors<ProjectFormValues>;
touchedFields: UseFormReturn<ProjectFormValues>['formState']['touchedFields'];
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean;
isSaving: boolean;
@@ -36,28 +36,35 @@ export function ProjectDialog({
projectId,
register,
errors,
touchedFields,
isSubmitted,
onSubmit,
canSubmit,
isSaving,
}: ProjectDialogProps) {
const nameErrorMessage = touchedFields.name ? errors.name?.message : undefined;
const startLatErrorMessage = touchedFields.start_lat ? errors.start_lat?.message : undefined;
const startLngErrorMessage = touchedFields.start_lng ? errors.start_lng?.message : undefined;
const endLatErrorMessage = touchedFields.end_lat ? errors.end_lat?.message : undefined;
const endLngErrorMessage = touchedFields.end_lng ? errors.end_lng?.message : undefined;
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const startLatErrorMessage = isSubmitted
? errors.start_lat?.message
: undefined;
const startLngErrorMessage = isSubmitted
? errors.start_lng?.message
: undefined;
const endLatErrorMessage = isSubmitted ? errors.end_lat?.message : undefined;
const endLngErrorMessage = isSubmitted ? errors.end_lng?.message : undefined;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl" onOpenAutoFocus={(event) => event.preventDefault()}>
<DialogContent
className="max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3 text-xl">
<DialogTitle className="flex items-center gap-3">
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
<Layers className="size-5" />
</div>
{projectId ? 'Edit Project Details' : 'Create New Project'}
</DialogTitle>
<DialogDescription className="text-sm">
<DialogDescription>
{projectId
? 'Update the technical specifications for your road infrastructure project.'
: 'Provide the essential road data to establish a new analysis project.'}
@@ -65,7 +72,12 @@ export function ProjectDialog({
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-6">
<FormField id="project-name" label="Project Name" required error={nameErrorMessage}>
<FormField
id="project-name"
label="Project Name"
required
error={nameErrorMessage}
>
<Input
id="project-name"
placeholder="Enter a descriptive project name"
@@ -76,7 +88,11 @@ export function ProjectDialog({
<div className="grid gap-5 md:grid-cols-2">
<FormField id="project-state" label="State">
<Input id="project-state" placeholder="e.g. Maharashtra" {...register('state')} />
<Input
id="project-state"
placeholder="e.g. Maharashtra"
{...register('state')}
/>
</FormField>
<FormField
id="project-corridor"
@@ -97,29 +113,37 @@ export function ProjectDialog({
<div className="grid gap-8 pt-2 md:grid-cols-2">
<div className="space-y-4">
<p className="flex items-center gap-2 text-[11px] font-black tracking-widest text-muted-foreground">
<p className="flex items-center gap-2 text-muted-foreground">
<MapPin className="size-3.5 opacity-60" />
START POINT
</p>
<div className="grid grid-cols-2 gap-4">
<FormField id="project-start-lat" label="Lat" error={startLatErrorMessage}>
<FormField
id="project-start-lat"
label="Lat"
error={startLatErrorMessage}
>
<Input
id="project-start-lat"
type="number"
step="any"
placeholder="0.0000"
className="font-mono text-xs"
className="font-mono"
aria-invalid={!!startLatErrorMessage}
{...register('start_lat')}
/>
</FormField>
<FormField id="project-start-lng" label="Lng" error={startLngErrorMessage}>
<FormField
id="project-start-lng"
label="Lng"
error={startLngErrorMessage}
>
<Input
id="project-start-lng"
type="number"
step="any"
placeholder="0.0000"
className="font-mono text-xs"
className="font-mono"
aria-invalid={!!startLngErrorMessage}
{...register('start_lng')}
/>
@@ -128,29 +152,37 @@ export function ProjectDialog({
</div>
<div className="space-y-4">
<p className="flex items-center gap-2 text-[11px] font-black tracking-widest text-muted-foreground">
<p className="flex items-center gap-2 text-muted-foreground">
<MapPin className="size-3.5 opacity-60" />
END POINT
</p>
<div className="grid grid-cols-2 gap-4">
<FormField id="project-end-lat" label="Lat" error={endLatErrorMessage}>
<FormField
id="project-end-lat"
label="Lat"
error={endLatErrorMessage}
>
<Input
id="project-end-lat"
type="number"
step="any"
placeholder="0.0000"
className="font-mono text-xs"
className="font-mono"
aria-invalid={!!endLatErrorMessage}
{...register('end_lat')}
/>
</FormField>
<FormField id="project-end-lng" label="Lng" error={endLngErrorMessage}>
<FormField
id="project-end-lng"
label="Lng"
error={endLngErrorMessage}
>
<Input
id="project-end-lng"
type="number"
step="any"
placeholder="0.0000"
className="font-mono text-xs"
className="font-mono"
aria-invalid={!!endLngErrorMessage}
{...register('end_lng')}
/>

View File

@@ -1,6 +1,7 @@
'use client';
import type { ColumnDef } from '@tanstack/react-table';
import { Edit3, Trash2 } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import type { Project } from '@/types';
@@ -35,8 +36,19 @@ export function ProjectTable({
title="Projects"
data={projects}
columns={columns}
onEdit={onEdit}
onDelete={onDelete}
actions={[
{
label: 'Edit',
icon: <Edit3 className="size-4" />,
onClick: onEdit,
},
{
label: 'Delete',
icon: <Trash2 className="size-4" />,
className: 'text-destructive',
onClick: onDelete,
},
]}
isLoading={isLoading}
emptyTitle="No projects found."
pagination={{

View File

@@ -53,7 +53,9 @@ function toNullableNumber(value: string) {
return value.trim() ? Number(value) : null;
}
function toProjectPayload(values: ProjectFormValues): ProjectCreate | ProjectUpdate {
function toProjectPayload(
values: ProjectFormValues,
): ProjectCreate | ProjectUpdate {
return {
name: values.name.trim(),
state: trimOptional(values.state),
@@ -72,10 +74,10 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
handleSubmit,
reset,
control,
formState: { errors, isSubmitting, touchedFields },
formState: { errors, isSubmitting, isSubmitted },
} = useForm<ProjectFormValues>({
defaultValues,
mode: 'onTouched',
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(projectFormSchema),
});
@@ -98,7 +100,9 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: projectKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update project' : 'Failed to create project');
toast.error(
values.id ? 'Failed to update project' : 'Failed to create project',
);
},
});
@@ -139,7 +143,7 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
resetForm: () => reset(defaultValues),
projectId,
errors,
touchedFields,
isSubmitted,
canSubmit,
isSaving: isSubmitting || saveMutation.isPending,
};

View File

@@ -15,7 +15,10 @@ interface UseProjectsQueryParams {
}
export function useProjectsQuery({ skip, limit }: UseProjectsQueryParams) {
const listParams = useMemo<PaginationParams>(() => ({ skip, limit }), [limit, skip]);
const listParams = useMemo<PaginationParams>(
() => ({ skip, limit }),
[limit, skip],
);
return useQuery({
queryKey: projectKeys.list(listParams),
@@ -36,7 +39,8 @@ export function useDeleteProjectMutation() {
},
onError: () => {
toast.error('Deletion failed', {
description: 'The project could not be removed. Please try again or check your permissions.',
description:
'The project could not be removed. Please try again or check your permissions.',
});
},
});

View File

@@ -4,7 +4,6 @@ import { useCallback, useState } from 'react';
import { Layers, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { Button } from '@/components/ui/button';
import type { Project } from '@/types';
@@ -13,7 +12,10 @@ import { ProjectTable } from './components/ProjectTable';
import { useProjectColumns } from './components/ProjectColumns';
import { useProjectFilters } from './hooks/useProjectFilters';
import { useProjectForm } from './hooks/useProjectForm';
import { useDeleteProjectMutation, useProjectsQuery } from './hooks/useProjectQueries';
import {
useDeleteProjectMutation,
useProjectsQuery,
} from './hooks/useProjectQueries';
export default function ProjectPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
@@ -31,7 +33,7 @@ export default function ProjectPage() {
resetForm,
projectId,
errors,
touchedFields,
isSubmitted,
canSubmit,
isSaving,
} = projectForm;
@@ -61,7 +63,9 @@ export default function ProjectPage() {
const deleteProject = useCallback(
(project: Project) => {
if (!confirm(`Are you sure you want to delete project "${project.name}"?`)) {
if (
!confirm(`Are you sure you want to delete project "${project.name}"?`)
) {
return;
}
deleteProjectMutation.mutate(project);
@@ -100,8 +104,6 @@ export default function ProjectPage() {
onEdit={openEdit}
onDelete={deleteProject}
/>
<PoweredBy />
</main>
<ProjectDialog
@@ -110,7 +112,7 @@ export default function ProjectPage() {
projectId={projectId}
register={register}
errors={errors}
touchedFields={touchedFields}
isSubmitted={isSubmitted}
onSubmit={onSubmit}
canSubmit={canSubmit}
isSaving={isSaving}

View File

@@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button';
import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react';
import VideoPlayerSection from '@/components/video-player-section';
import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { sessionService, videoService } from '@/services/api';
import { SessionContext, DetectionData, DetectionType } from '@/types';
import { getVideoFile, clearVideoFile } from '@/lib/video-storage';
@@ -20,8 +19,11 @@ export default function VideoResultsPage() {
const router = useRouter();
const { videoId } = useParams() as { videoId: string };
const [session, setSession] = useState<SessionContext | null>(null);
const [detectionData, setDetectionData] = useState<DetectionData | null>(null);
const [detectionType, setDetectionType] = useState<DetectionType>('pothole-detection');
const [detectionData, setDetectionData] = useState<DetectionData | null>(
null,
);
const [detectionType, setDetectionType] =
useState<DetectionType>('pothole-detection');
const [videoFile, setVideoFile] = useState<File | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -90,7 +92,9 @@ export default function VideoResultsPage() {
<div className="min-h-screen flex items-center justify-center">
<Card className="flex flex-col items-center gap-4 p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading detection results...</p>
<p className="text-sm text-muted-foreground">
Loading detection results...
</p>
</Card>
</div>
);
@@ -102,7 +106,10 @@ export default function VideoResultsPage() {
<Card className="flex flex-col items-center gap-6 p-8 text-center max-w-md">
<p className="text-destructive font-medium">{error}</p>
<div className="flex gap-4">
<Button onClick={() => router.push(ROUTES.UPLOAD)} variant="outline">
<Button
onClick={() => router.push(ROUTES.UPLOAD)}
variant="outline"
>
Back to Upload
</Button>
<Button onClick={handleNewAnalysis}>New Analysis</Button>
@@ -117,7 +124,11 @@ export default function VideoResultsPage() {
<main className="min-h-screen">
<div className="container mx-auto px-6 py-10 max-w-[1600px]">
<div className="mb-8">
<PageHeader title={getTitle()} description={`Video ID: ${videoId}`} icon={TrendingUp} />
<PageHeader
title={getTitle()}
description={`Video ID: ${videoId}`}
icon={TrendingUp}
/>
</div>
{session && (
@@ -182,7 +193,6 @@ export default function VideoResultsPage() {
projectId={session?.projectId || undefined}
/>
)}
<PoweredBy />
</div>
</main>
</div>

View File

@@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button';
import { Loader2, TrendingUp } from 'lucide-react';
import VideoPlayerSection from '@/components/video-player-section';
import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { sessionService } from '@/services/api';
import { SessionContext, DetectionData, DetectionType } from '@/types';
import { clearVideoFile } from '@/lib/video-storage';
@@ -56,8 +55,10 @@ export default function ResultsPage() {
};
const getTitle = () => {
if (detectionType === 'pothole-detection') return 'Pothole Detection Results';
if (detectionType === 'sign-board-detection') return 'Signboard Detection Results';
if (detectionType === 'pothole-detection')
return 'Pothole Detection Results';
if (detectionType === 'sign-board-detection')
return 'Signboard Detection Results';
return 'Pothole & Signboard Detection Results';
};
@@ -147,7 +148,6 @@ export default function ResultsPage() {
projectId={session?.projectId || undefined}
/>
)}
<PoweredBy />
</div>
</main>
</div>

View File

@@ -2,35 +2,17 @@
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Edit, RotateCcw } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { usePermissions } from '@/hooks/usePermissions';
import type { Role } from '@/types';
interface UseRoleColumnsParams {
onEdit: (role: Role) => void;
onToggleStatus: (role: Role) => void;
isStatusPending: boolean;
}
export function useRoleColumns({
onEdit,
onToggleStatus,
isStatusPending,
}: UseRoleColumnsParams): ColumnDef<Role>[] {
const { hasPermission } = usePermissions();
const canEdit = hasPermission(PERMISSIONS.ROLE.UPDATE);
const canDelete = hasPermission(PERMISSIONS.ROLE.DELETE);
export function useRoleColumns(): ColumnDef<Role>[] {
return useMemo(() => {
const columns: ColumnDef<Role>[] = [
return [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
cell: ({ row }) => <span>{row.original.name}</span>,
},
{
accessorKey: 'display_name',
@@ -54,44 +36,13 @@ export function useRoleColumns({
accessorKey: 'effective_status',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.original.effective_status ? 'default' : 'secondary'}>
<Badge
variant={row.original.effective_status ? 'default' : 'secondary'}
>
{row.original.effective_status ? 'Active' : 'Inactive'}
</Badge>
),
},
];
if (!canEdit && !canDelete) return columns;
columns.push({
id: 'actions',
header: () => <div className="text-right">Actions</div>,
enableSorting: false,
cell: ({ row }) => {
const role = row.original;
return (
<div className="flex justify-end gap-2">
{canEdit ? (
<Button variant="outline" size="sm" onClick={() => onEdit(role)}>
<Edit className="size-4" />
</Button>
) : null}
{canDelete ? (
<Button
variant="outline"
size="sm"
disabled={isStatusPending}
onClick={() => onToggleStatus(role)}
>
<RotateCcw className="mr-2 size-4" />
{role.effective_status ? 'Deactivate' : 'Activate'}
</Button>
) : null}
</div>
);
},
});
return columns;
}, [canDelete, canEdit, isStatusPending, onEdit, onToggleStatus]);
}, []);
}

View File

@@ -31,7 +31,10 @@ export function RoleFilters({
onChange={onSearchChange}
placeholder="Search roles"
/>
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as StatusFilter)}>
<Select
value={statusFilter}
onValueChange={(value) => onStatusChange(value as StatusFilter)}
>
<SelectTrigger className="md:w-44">
<SelectValue />
</SelectTrigger>

View File

@@ -1,7 +1,7 @@
'use client';
import type { ComponentProps } from 'react';
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
@@ -28,7 +28,7 @@ interface RoleSheetProps {
roleId?: number;
register: UseFormRegister<RoleFormValues>;
errors: FieldErrors<RoleFormValues>;
touchedFields: UseFormReturn<RoleFormValues>['formState']['touchedFields'];
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
permissionTree: PermissionTreeItem[];
permissionIds: number[];
@@ -44,7 +44,7 @@ export function RoleSheet({
roleId,
register,
errors,
touchedFields,
isSubmitted,
onSubmit,
permissionTree,
permissionIds,
@@ -53,8 +53,8 @@ export function RoleSheet({
canSubmit,
isSaving,
}: RoleSheetProps) {
const nameErrorMessage = touchedFields.name ? errors.name?.message : undefined;
const displayNameErrorMessage = touchedFields.display_name
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const displayNameErrorMessage = isSubmitted
? errors.display_name?.message
: undefined;
@@ -114,14 +114,16 @@ export function RoleSheet({
<FormField
label="Permissions"
required
error={errors.permission_ids?.message}
error={isSubmitted ? errors.permission_ids?.message : undefined}
className="space-y-3"
labelEnd={
<span className="text-xs text-muted-foreground">{permissionIds.length} selected</span>
<span className="text-muted-foreground">
{permissionIds.length} selected
</span>
}
>
{isPermissionsLoading ? (
<div className="rounded-md border p-6 text-sm text-muted-foreground">
<div className="rounded-md border p-6 text-muted-foreground">
Loading permissions...
</div>
) : (
@@ -136,7 +138,9 @@ export function RoleSheet({
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{roleId ? 'Update Role' : 'Create Role'}
</Button>
<SheetClose asChild>

View File

@@ -2,8 +2,10 @@
import type { ColumnDef, SortingState } from '@tanstack/react-table';
import type { ReactNode } from 'react';
import { Edit, RotateCcw } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import { PERMISSIONS } from '@/constants/permissions';
import type { Role } from '@/types';
interface RoleTableProps {
@@ -18,6 +20,9 @@ interface RoleTableProps {
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onSortingChange: (sorting: SortingState) => void;
onEdit: (role: Role) => void;
onToggleStatus: (role: Role) => void;
isStatusPending: boolean;
}
export function RoleTable({
@@ -32,6 +37,9 @@ export function RoleTable({
onPageChange,
onLimitChange,
onSortingChange,
onEdit,
onToggleStatus,
isStatusPending,
}: RoleTableProps) {
return (
<DataTable
@@ -41,6 +49,21 @@ export function RoleTable({
isLoading={isLoading}
toolbar={toolbar}
emptyTitle="No roles found."
actions={[
{
label: 'Edit',
icon: <Edit className="size-4" />,
permission: PERMISSIONS.ROLE.UPDATE,
onClick: onEdit,
},
{
label: (role) => (role.effective_status ? 'Deactivate' : 'Activate'),
icon: <RotateCcw className="size-4" />,
permission: PERMISSIONS.ROLE.DELETE,
disabled: () => isStatusPending,
onClick: onToggleStatus,
},
]}
pagination={{
skip,
limit,

View File

@@ -48,10 +48,10 @@ export function useRoleForm({
handleSubmit,
reset,
setValue,
formState: { errors, isSubmitting, touchedFields },
formState: { errors, isSubmitting, isSubmitted },
} = useForm<RoleFormValues>({
defaultValues,
mode: 'onTouched',
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(roleFormSchema),
});
@@ -61,7 +61,9 @@ export function useRoleForm({
const roleName = useWatch({ control, name: 'name' }) || '';
const displayName = useWatch({ control, name: 'display_name' }) || '';
const canSubmit =
roleName.trim().length > 0 && displayName.trim().length > 0 && permissionIds.length > 0;
roleName.trim().length > 0 &&
displayName.trim().length > 0 &&
permissionIds.length > 0;
const saveMutation = useMutation({
mutationFn: (values: RoleFormValues) =>
@@ -81,7 +83,9 @@ export function useRoleForm({
queryClient.invalidateQueries({ queryKey: roleKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update role' : 'Failed to create role');
toast.error(
values.id ? 'Failed to update role' : 'Failed to create role',
);
},
});
@@ -116,14 +120,21 @@ export function useRoleForm({
name: role.name || '',
display_name: role.display_name || '',
description: role.description || '',
permission_ids: collectPermissionIdsByKeys(permissionTree, role.permissions || []),
permission_ids: collectPermissionIdsByKeys(
permissionTree,
role.permissions || [],
),
});
},
[permissionTree, reset],
);
const setPermissionIds = useCallback(
(ids: number[]) => setValue('permission_ids', ids, { shouldDirty: true, shouldValidate: true }),
(ids: number[]) =>
setValue('permission_ids', ids, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue],
);
@@ -136,7 +147,7 @@ export function useRoleForm({
permissionIds,
roleId,
errors,
touchedFields,
isSubmitted,
canSubmit,
isSaving: isSubmitting || saveMutation.isPending,
};

View File

@@ -32,7 +32,8 @@ function buildRoleListParams({
skip,
limit,
search_term: searchTerm || undefined,
effective_status: statusFilter === 'all' ? undefined : statusFilter === 'active',
effective_status:
statusFilter === 'all' ? undefined : statusFilter === 'active',
...getServerSortParams(sorting),
};
}
@@ -40,7 +41,8 @@ function buildRoleListParams({
export function useRolesQuery(params: UseRolesQueryParams) {
const { skip, limit, searchTerm, statusFilter, sorting } = params;
const listParams = useMemo(
() => buildRoleListParams({ skip, limit, searchTerm, statusFilter, sorting }),
() =>
buildRoleListParams({ skip, limit, searchTerm, statusFilter, sorting }),
[limit, searchTerm, skip, sorting, statusFilter],
);
@@ -71,7 +73,8 @@ export function useRoleStatusMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (role: Role) => roleService.updateRoleStatus(role.id, !role.effective_status),
mutationFn: (role: Role) =>
roleService.updateRoleStatus(role.id, !role.effective_status),
onSuccess: () => {
toast.success('Role status updated');
queryClient.invalidateQueries({ queryKey: roleKeys.all });

View File

@@ -4,7 +4,6 @@ import { useCallback, useState } from 'react';
import { Plus, ShieldCheck } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
@@ -24,7 +23,9 @@ import {
} from './hooks/useRoleQueries';
export default function RolesPage() {
const organizationId = useAppStore((state) => state.user?.organization_id ?? null);
const organizationId = useAppStore(
(state) => state.user?.organization_id ?? null,
);
const [isSheetOpen, setIsSheetOpen] = useState(false);
const {
skip,
@@ -62,12 +63,13 @@ export default function RolesPage() {
permissionIds,
setPermissionIds,
errors,
touchedFields,
isSubmitted,
canSubmit,
isSaving,
} = roleForm;
const statusMutation = useRoleStatusMutation();
const { mutate: updateRoleStatus, isPending: isStatusPending } = statusMutation;
const { mutate: updateRoleStatus, isPending: isStatusPending } =
statusMutation;
const openCreate = useCallback(() => {
prepareCreateRole();
@@ -89,11 +91,7 @@ export default function RolesPage() {
[updateRoleStatus],
);
const columns = useRoleColumns({
onEdit: openEdit,
onToggleStatus: toggleStatus,
isStatusPending,
});
const columns = useRoleColumns();
const total = rolesQuery.data?.total ?? 0;
const roles = rolesQuery.data?.items ?? [];
@@ -135,9 +133,10 @@ export default function RolesPage() {
onPageChange={setSkip}
onLimitChange={setLimit}
onSortingChange={setSorting}
onEdit={openEdit}
onToggleStatus={toggleStatus}
isStatusPending={isStatusPending}
/>
<PoweredBy />
</main>
<RoleSheet
@@ -146,7 +145,7 @@ export default function RolesPage() {
roleId={roleId}
register={register}
errors={errors}
touchedFields={touchedFields}
isSubmitted={isSubmitted}
onSubmit={onSubmit}
permissionTree={permissionsQuery.permissionTree}
permissionIds={permissionIds}

View File

@@ -2,10 +2,19 @@
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { ArrowDownCircle, ArrowUpCircle, MapPin, Milestone } from 'lucide-react';
import {
ArrowDownCircle,
ArrowUpCircle,
MapPin,
Milestone,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Chainage, Package, Project } from '@/types';
function EmptyValue() {
@@ -36,16 +45,14 @@ function StateBadges({ value }: { value: string | null }) {
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-[10px] font-bold text-muted-foreground transition-colors hover:bg-muted"
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-muted-foreground transition-colors hover:bg-muted"
>
+{remainingStates.length}
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
Other States
</p>
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
{remainingStates.map((state) => (
<Badge key={state} variant="secondary">
{state}
@@ -65,7 +72,7 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
{
accessorKey: 'segment_name',
header: 'Segment Name',
cell: ({ row }) => <div className="font-semibold">{row.original.segment_name}</div>,
cell: ({ row }) => <div>{row.original.segment_name}</div>,
},
{
accessorKey: 'package_id',
@@ -78,7 +85,9 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
id: 'project',
header: 'Project',
cell: ({ row }) => {
const pkg = packages.find((item) => item.id === row.original.package_id);
const pkg = packages.find(
(item) => item.id === row.original.package_id,
);
const project = projects.find((item) => item.id === pkg?.project_id);
return project?.name || <EmptyValue />;
},
@@ -87,7 +96,9 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
id: 'project_state',
header: 'Project State',
cell: ({ row }) => {
const pkg = packages.find((item) => item.id === row.original.package_id);
const pkg = packages.find(
(item) => item.id === row.original.package_id,
);
const project = projects.find((item) => item.id === pkg?.project_id);
return <StateBadges value={project?.state ?? null} />;
},
@@ -101,7 +112,7 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
<div className="flex flex-row gap-2">
<Badge
variant="outline"
className="flex items-center gap-1.5 border-amber-500/50 font-bold text-amber-500"
className="flex items-center gap-1.5 border-amber-500/50 text-amber-500"
>
<Milestone className="size-3" />
{segment.chainage_start_km} - {segment.chainage_end_km}
@@ -124,10 +135,11 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
cell: ({ row }) => (
<Badge
variant="outline"
className="flex items-center gap-1.5 border-blue-500/50 font-bold text-blue-500"
className="flex items-center gap-1.5 border-blue-500/50 text-blue-500"
>
<MapPin className="size-3" />
{row.original.start_lat.toFixed(4)}, {row.original.start_lng.toFixed(4)}
{row.original.start_lat.toFixed(4)},{' '}
{row.original.start_lng.toFixed(4)}
</Badge>
),
},
@@ -137,7 +149,7 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
cell: ({ row }) => (
<Badge
variant="outline"
className="flex items-center gap-1.5 border-blue-500/50 font-bold text-blue-500"
className="flex items-center gap-1.5 border-blue-500/50 text-blue-500"
>
<MapPin className="size-3" />
{row.original.end_lat.toFixed(4)}, {row.original.end_lng.toFixed(4)}

View File

@@ -1,7 +1,7 @@
'use client';
import type { ComponentProps } from 'react';
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Loader2, MapPin, Milestone } from 'lucide-react';
import { FormField } from '@/components/form';
@@ -42,7 +42,7 @@ interface SegmentDialogProps {
onDirectionChange: (direction: 'UP' | 'DOWN') => void;
register: UseFormRegister<SegmentFormValues>;
errors: FieldErrors<SegmentFormValues>;
touchedFields: UseFormReturn<SegmentFormValues>['formState']['touchedFields'];
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean;
isSaving: boolean;
@@ -64,13 +64,13 @@ export function SegmentDialog({
onDirectionChange,
register,
errors,
touchedFields,
isSubmitted,
onSubmit,
canSubmit,
isSaving,
}: SegmentDialogProps) {
const getError = (field: keyof SegmentFormValues) =>
touchedFields[field] ? errors[field]?.message : undefined;
isSubmitted ? errors[field]?.message : undefined;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -79,13 +79,13 @@ export function SegmentDialog({
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3 text-xl">
<DialogTitle className="flex items-center gap-3">
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
<Milestone className="size-5" />
</div>
{segmentId ? 'Edit Segment' : 'Create Segment'}
</DialogTitle>
<DialogDescription className="text-sm">
<DialogDescription>
{segmentId
? 'Update the technical specifications for your road segment.'
: 'Select a project and package, then provide the segment data.'}
@@ -95,11 +95,15 @@ export function SegmentDialog({
<form onSubmit={onSubmit} className="space-y-6">
{!segmentId ? (
<div className="grid gap-5 md:grid-cols-2">
<FormField label="Project" required error={getError('project_id')}>
<FormField
label="Project"
required
error={getError('project_id')}
>
<Select value={projectId} onValueChange={onProjectChange}>
<SelectTrigger aria-invalid={!!getError('project_id')}>
{isProjectsLoading ? (
<span className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</span>
@@ -115,10 +119,19 @@ export function SegmentDialog({
))}
</SelectContent>
</Select>
<input type="hidden" {...register('project_id')} value={projectId} readOnly />
<input
type="hidden"
{...register('project_id')}
value={projectId}
readOnly
/>
</FormField>
<FormField label="Package" required error={getError('package_id')}>
<FormField
label="Package"
required
error={getError('package_id')}
>
<Select
value={packageId}
onValueChange={onPackageChange}
@@ -126,12 +139,18 @@ export function SegmentDialog({
>
<SelectTrigger aria-invalid={!!getError('package_id')}>
{isPackagesLoading ? (
<span className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</span>
) : (
<SelectValue placeholder={projectId ? 'Choose a package' : 'Select project first'} />
<SelectValue
placeholder={
projectId
? 'Choose a package'
: 'Select project first'
}
/>
)}
</SelectTrigger>
<SelectContent>
@@ -142,7 +161,12 @@ export function SegmentDialog({
))}
</SelectContent>
</Select>
<input type="hidden" {...register('package_id')} value={packageId} readOnly />
<input
type="hidden"
{...register('package_id')}
value={packageId}
readOnly
/>
</FormField>
</div>
) : null}
@@ -211,12 +235,17 @@ export function SegmentDialog({
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-4">
<p className="flex items-center gap-2 text-[11px] font-black tracking-widest text-muted-foreground">
<p className="flex items-center gap-2 text-muted-foreground">
<MapPin className="size-3.5 opacity-60" />
START COORDINATES
</p>
<div className="grid grid-cols-2 gap-4">
<FormField id="start-lat" label="Latitude" required error={getError('start_lat')}>
<FormField
id="start-lat"
label="Latitude"
required
error={getError('start_lat')}
>
<Input
id="start-lat"
type="number"
@@ -228,7 +257,12 @@ export function SegmentDialog({
{...register('start_lat')}
/>
</FormField>
<FormField id="start-lng" label="Longitude" required error={getError('start_lng')}>
<FormField
id="start-lng"
label="Longitude"
required
error={getError('start_lng')}
>
<Input
id="start-lng"
type="number"
@@ -244,12 +278,17 @@ export function SegmentDialog({
</div>
<div className="space-y-4">
<p className="flex items-center gap-2 text-[11px] font-black tracking-widest text-muted-foreground">
<p className="flex items-center gap-2 text-muted-foreground">
<MapPin className="size-3.5 opacity-60" />
END COORDINATES
</p>
<div className="grid grid-cols-2 gap-4">
<FormField id="end-lat" label="Latitude" required error={getError('end_lat')}>
<FormField
id="end-lat"
label="Latitude"
required
error={getError('end_lat')}
>
<Input
id="end-lat"
type="number"
@@ -261,7 +300,12 @@ export function SegmentDialog({
{...register('end_lat')}
/>
</FormField>
<FormField id="end-lng" label="Longitude" required error={getError('end_lng')}>
<FormField
id="end-lng"
label="Longitude"
required
error={getError('end_lng')}
>
<Input
id="end-lng"
type="number"
@@ -278,7 +322,12 @@ export function SegmentDialog({
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button type="submit" disabled={isSaving || !canSubmit}>

View File

@@ -1,6 +1,7 @@
'use client';
import type { ColumnDef } from '@tanstack/react-table';
import { Edit3, Trash2 } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import type { Chainage } from '@/types';
@@ -35,8 +36,19 @@ export function SegmentTable({
title="Segments"
data={segments}
columns={columns}
onEdit={onEdit}
onDelete={onDelete}
actions={[
{
label: 'Edit',
icon: <Edit3 className="size-4" />,
onClick: onEdit,
},
{
label: 'Delete',
icon: <Trash2 className="size-4" />,
className: 'text-destructive',
onClick: onDelete,
},
]}
isLoading={isLoading}
emptyTitle="No segments found."
pagination={{

View File

@@ -17,7 +17,9 @@ const requiredNumber = (message: string) =>
.string()
.trim()
.min(1, message)
.refine((value) => Number.isFinite(Number(value)), { message: 'Enter a valid number' });
.refine((value) => Number.isFinite(Number(value)), {
message: 'Enter a valid number',
});
const latitude = requiredNumber('Latitude is required').refine(
(value) => {
@@ -64,7 +66,9 @@ const defaultValues: SegmentFormValues = {
direction: 'UP',
};
function toSegmentPayload(values: SegmentFormValues): ChainageCreate | ChainageUpdate {
function toSegmentPayload(
values: SegmentFormValues,
): ChainageCreate | ChainageUpdate {
return {
package_id: values.package_id,
segment_name: values.segment_name.trim(),
@@ -86,10 +90,10 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
reset,
setValue,
control,
formState: { errors, isSubmitting, touchedFields },
formState: { errors, isSubmitting, isSubmitted },
} = useForm<SegmentFormValues>({
defaultValues,
mode: 'onTouched',
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(segmentFormSchema),
});
@@ -99,7 +103,8 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
const packageId = useWatch({ control, name: 'package_id' }) || '';
const direction = useWatch({ control, name: 'direction' }) || 'UP';
const segmentName = useWatch({ control, name: 'segment_name' }) || '';
const chainageStartKm = useWatch({ control, name: 'chainage_start_km' }) || '';
const chainageStartKm =
useWatch({ control, name: 'chainage_start_km' }) || '';
const chainageEndKm = useWatch({ control, name: 'chainage_end_km' }) || '';
const startLat = useWatch({ control, name: 'start_lat' }) || '';
const startLng = useWatch({ control, name: 'start_lng' }) || '';
@@ -130,14 +135,18 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: segmentKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update segment' : 'Failed to create segment');
toast.error(
values.id ? 'Failed to update segment' : 'Failed to create segment',
);
},
});
const onSubmit = handleSubmit(
(values) => saveMutation.mutate(values),
(formErrors) => {
const firstMessage = Object.values(formErrors).find((error) => error?.message)?.message;
const firstMessage = Object.values(formErrors).find(
(error) => error?.message,
)?.message;
if (firstMessage) {
toast.error(String(firstMessage));
}
@@ -169,14 +178,21 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
const setProjectId = useCallback(
(value: string) => {
setValue('project_id', value, { shouldDirty: true, shouldValidate: true });
setValue('project_id', value, {
shouldDirty: true,
shouldValidate: true,
});
setValue('package_id', '', { shouldDirty: true, shouldValidate: true });
},
[setValue],
);
const setPackageId = useCallback(
(value: string) => setValue('package_id', value, { shouldDirty: true, shouldValidate: true }),
(value: string) =>
setValue('package_id', value, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue],
);
@@ -200,7 +216,7 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
direction,
setDirection,
errors,
touchedFields,
isSubmitted,
canSubmit,
isSaving: isSubmitting || saveMutation.isPending,
};

View File

@@ -4,7 +4,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { toast } from 'sonner';
import { chainageService, packageService, projectService } from '@/services/api';
import {
chainageService,
packageService,
projectService,
} from '@/services/api';
import type { Chainage, PaginationParams } from '@/types';
import { segmentKeys } from '../queries/segmentKeys';
@@ -15,7 +19,10 @@ interface UseSegmentsQueryParams {
}
export function useSegmentsQuery({ skip, limit }: UseSegmentsQueryParams) {
const listParams = useMemo<PaginationParams>(() => ({ skip, limit }), [limit, skip]);
const listParams = useMemo<PaginationParams>(
() => ({ skip, limit }),
[limit, skip],
);
return useQuery({
queryKey: segmentKeys.list(listParams),
@@ -40,7 +47,8 @@ export function useAllPackageOptionsQuery() {
export function usePackagesByProjectQuery(projectId: string, enabled: boolean) {
return useQuery({
queryKey: ['packages', 'by-project', projectId],
queryFn: () => packageService.getPackagesByProject(projectId, { skip: 0, limit: 1000 }),
queryFn: () =>
packageService.getPackagesByProject(projectId, { skip: 0, limit: 1000 }),
enabled: enabled && Boolean(projectId),
});
}
@@ -49,7 +57,8 @@ export function useDeleteSegmentMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (segment: Chainage) => chainageService.deleteChainage(segment.id),
mutationFn: (segment: Chainage) =>
chainageService.deleteChainage(segment.id),
onSuccess: (_data, segment) => {
toast.success('Segment deleted', {
description: `${segment.segment_name} has been removed from the system.`,

View File

@@ -4,7 +4,6 @@ import { useCallback, useState } from 'react';
import { Milestone, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { Button } from '@/components/ui/button';
import type { Chainage } from '@/types';
@@ -45,11 +44,14 @@ export default function SegmentPage() {
direction,
setDirection,
errors,
touchedFields,
isSubmitted,
canSubmit,
isSaving,
} = segmentForm;
const projectPackagesQuery = usePackagesByProjectQuery(projectId, isDialogOpen);
const projectPackagesQuery = usePackagesByProjectQuery(
projectId,
isDialogOpen,
);
const openCreate = useCallback(() => {
prepareCreateSegment();
@@ -58,7 +60,9 @@ export default function SegmentPage() {
const openEdit = useCallback(
(segment: Chainage) => {
const pkg = allPackagesQuery.data?.items.find((item) => item.id === segment.package_id);
const pkg = allPackagesQuery.data?.items.find(
(item) => item.id === segment.package_id,
);
prepareEditSegment(segment, pkg?.project_id || '');
setIsDialogOpen(true);
},
@@ -77,7 +81,11 @@ export default function SegmentPage() {
const deleteSegment = useCallback(
(segment: Chainage) => {
if (!confirm(`Are you sure you want to delete segment "${segment.segment_name}"?`)) {
if (
!confirm(
`Are you sure you want to delete segment "${segment.segment_name}"?`,
)
) {
return;
}
deleteSegmentMutation.mutate(segment);
@@ -119,8 +127,6 @@ export default function SegmentPage() {
onEdit={openEdit}
onDelete={deleteSegment}
/>
<PoweredBy />
</main>
<SegmentDialog
@@ -139,7 +145,7 @@ export default function SegmentPage() {
onDirectionChange={setDirection}
register={register}
errors={errors}
touchedFields={touchedFields}
isSubmitted={isSubmitted}
onSubmit={onSubmit}
canSubmit={canSubmit}
isSaving={isSaving}

View File

@@ -2,12 +2,8 @@
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Edit, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { usePermissions } from '@/hooks/usePermissions';
import type { Tenant } from '@/types';
function formatDate(value?: string | null) {
@@ -19,30 +15,16 @@ function formatDate(value?: string | null) {
}).format(new Date(value));
}
interface UseTenantColumnsParams {
onEdit: (tenant: Tenant) => void;
onDelete: (tenant: Tenant) => void;
pendingDeleteId?: number;
}
export function useTenantColumns({
onEdit,
onDelete,
pendingDeleteId,
}: UseTenantColumnsParams): ColumnDef<Tenant>[] {
const { hasPermission } = usePermissions();
const canEdit = hasPermission(PERMISSIONS.TENANT.UPDATE);
const canDelete = hasPermission(PERMISSIONS.TENANT.DELETE);
export function useTenantColumns(): ColumnDef<Tenant>[] {
return useMemo(() => {
const columns: ColumnDef<Tenant>[] = [
return [
{
accessorKey: 'name',
header: 'Tenant',
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.name}</p>
<p className="text-xs text-muted-foreground">{row.original.slug}</p>
<p>{row.original.name}</p>
<p className="text-muted-foreground">{row.original.slug}</p>
</div>
),
},
@@ -76,37 +58,5 @@ export function useTenantColumns({
),
},
];
if (!canEdit && !canDelete) return columns;
columns.push({
id: 'actions',
header: () => <div className="text-right">Actions</div>,
enableSorting: false,
cell: ({ row }) => {
const tenant = row.original;
return (
<div className="flex justify-end gap-2">
{canEdit ? (
<Button variant="outline" size="sm" onClick={() => onEdit(tenant)}>
<Edit className="size-4" />
</Button>
) : null}
{canDelete ? (
<Button
variant="outline"
size="sm"
disabled={pendingDeleteId === tenant.id}
onClick={() => onDelete(tenant)}
>
<Trash2 className="size-4" />
</Button>
) : null}
</div>
);
},
});
return columns;
}, [canDelete, canEdit, onDelete, onEdit, pendingDeleteId]);
}, []);
}

View File

@@ -67,15 +67,20 @@ export function TenantSheet({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{tenantId ? 'Edit Tenant' : 'Create Tenant'}</DialogTitle>
<DialogTitle>
{tenantId ? 'Edit Tenant' : 'Create Tenant'}
</DialogTitle>
<DialogDescription>
Bind a client and subscription plan, then invite the tenant administrator.
Bind a client and subscription plan, then invite the tenant
administrator.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="space-y-1">
<p className="text-sm font-medium">Basic Information</p>
<p className="text-xs text-muted-foreground">Tenant identity and subscription binding.</p>
<p>Basic Information</p>
<p className="text-muted-foreground">
Tenant identity and subscription binding.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
@@ -106,9 +111,17 @@ export function TenantSheet({
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Client</Label>
<Select value={clientId} onValueChange={onClientChange} disabled={isLookupsLoading}>
<Select
value={clientId}
onValueChange={onClientChange}
disabled={isLookupsLoading}
>
<SelectTrigger>
<SelectValue placeholder={isLookupsLoading ? 'Loading clients...' : 'Select client'} />
<SelectValue
placeholder={
isLookupsLoading ? 'Loading clients...' : 'Select client'
}
/>
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
@@ -121,9 +134,17 @@ export function TenantSheet({
</div>
<div className="space-y-2">
<Label>Subscription Plan</Label>
<Select value={planId} onValueChange={onPlanChange} disabled={isLookupsLoading}>
<Select
value={planId}
onValueChange={onPlanChange}
disabled={isLookupsLoading}
>
<SelectTrigger>
<SelectValue placeholder={isLookupsLoading ? 'Loading plans...' : 'Select plan'} />
<SelectValue
placeholder={
isLookupsLoading ? 'Loading plans...' : 'Select plan'
}
/>
</SelectTrigger>
<SelectContent>
{plans.map((plan) => (
@@ -139,7 +160,11 @@ export function TenantSheet({
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tenant-domain">Domain</Label>
<Input id="tenant-domain" placeholder="acme.example.com" {...register('domain')} />
<Input
id="tenant-domain"
placeholder="acme.example.com"
{...register('domain')}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="tenant-description">Description</Label>
@@ -147,7 +172,7 @@ export function TenantSheet({
id="tenant-description"
placeholder="North zone operations"
{...register('description')}
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
/>
</div>
</div>
@@ -155,8 +180,8 @@ export function TenantSheet({
{!isEditMode ? (
<>
<div className="space-y-1">
<p className="text-sm font-medium">Tenant Administrator</p>
<p className="text-xs text-muted-foreground">
<p>Tenant Administrator</p>
<p className="text-muted-foreground">
Invitation details for the primary tenant admin account.
</p>
</div>
@@ -204,10 +229,10 @@ export function TenantSheet({
</div>
</>
) : adminEmail ? (
<div className="rounded-md border bg-muted/40 p-4 text-sm text-muted-foreground">
<div className="rounded-md border bg-muted/40 p-4 text-muted-foreground">
Admin invitation details cannot be changed after tenant creation.
<p className="mt-1 text-foreground">
Current admin email: <span className="font-medium">{adminEmail}</span>
Current admin email: <span>{adminEmail}</span>
</p>
</div>
) : null}
@@ -222,7 +247,9 @@ export function TenantSheet({
Cancel
</Button>
<Button type="submit" disabled={isSaving || isLookupsLoading}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{tenantId ? 'Update Tenant' : 'Create Tenant'}
</Button>
</DialogFooter>

View File

@@ -2,8 +2,10 @@
import type { ReactNode } from 'react';
import type { ColumnDef, SortingState } from '@tanstack/react-table';
import { Edit, Trash2 } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import { PERMISSIONS } from '@/constants/permissions';
import type { Tenant } from '@/types';
interface TenantTableProps {
@@ -18,6 +20,9 @@ interface TenantTableProps {
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onSortingChange: (sorting: SortingState) => void;
onEdit: (tenant: Tenant) => void;
onDelete: (tenant: Tenant) => void;
pendingDeleteId?: number;
}
export function TenantTable({
@@ -32,6 +37,9 @@ export function TenantTable({
onPageChange,
onLimitChange,
onSortingChange,
onEdit,
onDelete,
pendingDeleteId,
}: TenantTableProps) {
return (
<DataTable
@@ -41,6 +49,22 @@ export function TenantTable({
isLoading={isLoading}
toolbar={toolbar}
emptyTitle="No tenants found."
actions={[
{
label: 'Edit',
icon: <Edit className="size-4" />,
permission: PERMISSIONS.TENANT.UPDATE,
onClick: onEdit,
},
{
label: 'Delete',
icon: <Trash2 className="size-4" />,
permission: PERMISSIONS.TENANT.DELETE,
className: 'text-destructive',
disabled: (tenant) => pendingDeleteId === tenant.id,
onClick: onDelete,
},
]}
pagination={{
skip,
limit,

View File

@@ -12,7 +12,8 @@ export function useTenantFilters() {
const [limit, setLimitValue] = useState(10);
const [sorting, setSortingValue] = useState<SortingState>([]);
const [searchTerm, setSearchTermValue] = useState('');
const [statusFilter, setStatusFilterValue] = useState<TenantStatusFilter>('all');
const [statusFilter, setStatusFilterValue] =
useState<TenantStatusFilter>('all');
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
const setSearchTerm = useCallback((value: string) => {

View File

@@ -82,7 +82,11 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
}
if (!isEditMode) {
if (!values.admin_first_name.trim() || !values.admin_last_name.trim() || !values.admin_email.trim()) {
if (
!values.admin_first_name.trim() ||
!values.admin_last_name.trim() ||
!values.admin_email.trim()
) {
toast.error('Complete all required admin fields');
return;
}

View File

@@ -58,7 +58,9 @@ export function useSaveTenantMutation({ onSaved }: { onSaved: () => void }) {
onSaved();
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update tenant' : 'Failed to create tenant');
toast.error(
values.id ? 'Failed to update tenant' : 'Failed to create tenant',
);
},
});
}

View File

@@ -4,7 +4,6 @@ import { useCallback, useMemo, useState } from 'react';
import { Globe, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
@@ -117,17 +116,14 @@ export default function TenantsPage() {
[deleteMutation],
);
const columns = useTenantColumns({
onEdit: openEdit,
onDelete: handleDelete,
pendingDeleteId: deleteMutation.isPending ? deleteMutation.variables : undefined,
});
const columns = useTenantColumns();
const tenants = tenantsQuery.data?.items ?? [];
const total = tenantsQuery.data?.total ?? 0;
const clients = clientsLookupQuery.data?.items ?? [];
const plans = plansLookupQuery.data?.items ?? [];
const isLookupsLoading = clientsLookupQuery.isLoading || plansLookupQuery.isLoading;
const isLookupsLoading =
clientsLookupQuery.isLoading || plansLookupQuery.isLoading;
const toolbar = useMemo(
() => (
@@ -170,9 +166,12 @@ export default function TenantsPage() {
onPageChange={setSkip}
onLimitChange={setLimit}
onSortingChange={setSorting}
onEdit={openEdit}
onDelete={handleDelete}
pendingDeleteId={
deleteMutation.isPending ? deleteMutation.variables : undefined
}
/>
<PoweredBy />
</main>
<TenantSheet

View File

@@ -5,14 +5,16 @@ import { useRouter, useParams } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { sessionService, videoService } from '@/services/api';
import { SessionContext } from '@/types';
import { ROUTES } from '@/utils/routes';
import { Button } from '@/components/ui/button';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const WS_URL = API_URL?.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://');
const WS_URL = API_URL?.replace(/^https:\/\//, 'wss://').replace(
/^http:\/\//,
'ws://',
);
export default function VideoProcessingPage() {
const router = useRouter();
@@ -41,12 +43,12 @@ export default function VideoProcessingPage() {
setProgress(data.progress || 0);
let message = data.message || 'Processing...';
const uniqueCount =
data.unique_potholes ??
data.unique_pothole ??
data.unique_signboards ??
data.unique_signboard ??
data.unique_culverts ??
data.unique_culvert ??
data.unique_potholes ??
data.unique_pothole ??
data.unique_signboards ??
data.unique_signboard ??
data.unique_culverts ??
data.unique_culvert ??
data.unique_drain_issue;
if (uniqueCount !== undefined) {
message += ` | Unique: ${uniqueCount} | Total: ${data.total_detections || 0}`;
@@ -102,7 +104,9 @@ export default function VideoProcessingPage() {
}
if (statusData.status === 'error') {
setError(statusData.message || 'An error occurred during processing.');
setError(
statusData.message || 'An error occurred during processing.',
);
setIsLoading(false);
return;
}
@@ -204,7 +208,9 @@ export default function VideoProcessingPage() {
<Card className="flex-1 flex flex-col items-center justify-center py-12 px-8 min-h-[450px]">
<div className="flex flex-col items-center justify-center w-full max-w-2xl space-y-10">
<div className="text-center space-y-4">
<h2 className="text-3xl font-bold tracking-tight">Uploading ...</h2>
<h2 className="text-3xl font-bold tracking-tight">
Uploading ...
</h2>
<p className="text-sm font-mono text-muted-foreground tracking-widest">
ID: {videoId}
</p>
@@ -215,7 +221,9 @@ export default function VideoProcessingPage() {
<span className="font-bold text-muted-foreground text-xs uppercase tracking-widest">
Progress
</span>
<span className="font-bold text-primary text-lg">{progress}%</span>
<span className="font-bold text-primary text-lg">
{progress}%
</span>
</div>
<div className="h-4 rounded-full bg-secondary overflow-hidden border">
<div
@@ -234,15 +242,17 @@ export default function VideoProcessingPage() {
{error && (
<div className="w-full p-6 rounded-md bg-destructive/10 border border-destructive/20 text-center space-y-3">
<p className="text-destructive font-medium">{error}</p>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
<Button
variant="outline"
size="sm"
onClick={() => window.location.reload()}
>
Retry Connection
</Button>
</div>
)}
</div>
</Card>
<PoweredBy />
</div>
</main>
</div>

View File

@@ -2,7 +2,13 @@
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -15,7 +21,6 @@ import {
} from '@/components/ui/select';
import { Loader2, TrendingUp, ChevronRight } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { sessionService, videoService } from '@/services/api';
import { SessionContext } from '@/types';
import { storeVideoFile } from '@/lib/video-storage';
@@ -50,17 +55,20 @@ export default function UploadPage() {
// Load session on mount
useEffect(() => {
// We don't load session on mount for the upload page to ensure
// We don't load session on mount for the upload page to ensure
// project details are filled manually as requested.
setIsLoading(false);
}, []);
const handleSelectionChange = useCallback((selectedSession: SessionContext | null) => {
setSession(selectedSession);
if (selectedSession) {
sessionService.saveSession(selectedSession);
}
}, []);
const handleSelectionChange = useCallback(
(selectedSession: SessionContext | null) => {
setSession(selectedSession);
if (selectedSession) {
sessionService.saveSession(selectedSession);
}
},
[],
);
const handleUpload = async () => {
if (!file) {
@@ -104,7 +112,8 @@ export default function UploadPage() {
} catch (err) {
let errorMessage = 'Upload failed';
if (err instanceof TypeError && err.message === 'Failed to fetch') {
errorMessage = 'Cannot connect to server. Please check if backend is running.';
errorMessage =
'Cannot connect to server. Please check if backend is running.';
} else if (err instanceof Error) {
errorMessage = err.message;
}
@@ -121,7 +130,9 @@ export default function UploadPage() {
);
}
const isSessionComplete = !!(session && sessionService.isSessionValid(session));
const isSessionComplete = !!(
session && sessionService.isSessionValid(session)
);
const isFormValid = isSessionComplete && file && jsonFile;
return (
@@ -141,14 +152,16 @@ export default function UploadPage() {
<Reveal direction="up" delay={0.1}>
<Card className="border">
<CardHeader>
<CardTitle className="text-xl font-bold">1. Project Details</CardTitle>
<CardTitle className="text-xl font-bold">
1. Project Details
</CardTitle>
<CardDescription>
Select the target project, package, and segment.
</CardDescription>
</CardHeader>
<CardContent>
<ProjectSelectionSection
onSelectionChange={handleSelectionChange}
<ProjectSelectionSection
onSelectionChange={handleSelectionChange}
asStep={true}
hideButton={true}
/>
@@ -158,7 +171,13 @@ export default function UploadPage() {
{/* Module 2: Upload Data */}
<Reveal direction="up" delay={0.2}>
<Card className={cn("border transition-all duration-300", !isSessionComplete && "opacity-60 pointer-events-none grayscale-[0.5]")}>
<Card
className={cn(
'border transition-all duration-300',
!isSessionComplete &&
'opacity-60 pointer-events-none grayscale-[0.5]',
)}
>
<CardHeader>
<CardTitle className="text-xl font-bold flex items-center gap-2">
2. Upload Video Data
@@ -171,7 +190,10 @@ export default function UploadPage() {
<div className="grid grid-cols-1 gap-6 text-sm">
{/* Video File Input */}
<div className="space-y-2">
<Label htmlFor="video-file" className="text-sm font-semibold">
<Label
htmlFor="video-file"
className="text-sm font-semibold"
>
Video File <span className="text-destructive">*</span>
</Label>
<div className="relative group">
@@ -192,8 +214,12 @@ export default function UploadPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* JSON File Input */}
<div className="space-y-2">
<Label htmlFor="json-file" className="text-sm font-semibold">
GPS JSON File <span className="text-destructive">*</span>
<Label
htmlFor="json-file"
className="text-sm font-semibold"
>
GPS JSON File{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="json-file"
@@ -211,7 +237,10 @@ export default function UploadPage() {
{/* Select Method */}
<div className="space-y-2">
<Label htmlFor="select-method" className="text-sm font-semibold">
<Label
htmlFor="select-method"
className="text-sm font-semibold"
>
Analysis Method
</Label>
<Select
@@ -219,7 +248,10 @@ export default function UploadPage() {
onValueChange={setSelectMethod}
disabled={uploading || !isSessionComplete}
>
<SelectTrigger id="select-method" className="h-11 bg-muted/20">
<SelectTrigger
id="select-method"
className="h-11 bg-muted/20"
>
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
@@ -265,12 +297,6 @@ export default function UploadPage() {
</Card>
</Reveal>
</div>
<div className="mt-12">
<Reveal delay={0.4}>
<PoweredBy />
</Reveal>
</div>
</main>
</div>
);

View File

@@ -2,12 +2,8 @@
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Edit, RotateCcw } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { usePermissions } from '@/hooks/usePermissions';
import type { AdministrationUser } from '@/types';
function formatDate(value?: string | null) {
@@ -19,30 +15,17 @@ function formatDate(value?: string | null) {
}).format(new Date(value));
}
interface UseUserColumnsParams {
onEdit: (user: AdministrationUser) => void;
onToggleStatus: (user: AdministrationUser) => void;
pendingUserId?: number;
}
export function useUserColumns({
onEdit,
onToggleStatus,
pendingUserId,
}: UseUserColumnsParams): ColumnDef<AdministrationUser>[] {
const { hasPermission } = usePermissions();
const canEdit = hasPermission(PERMISSIONS.USER.UPDATE);
const canDelete = hasPermission(PERMISSIONS.USER.DELETE);
export function useUserColumns(): ColumnDef<AdministrationUser>[] {
return useMemo(() => {
const columns: ColumnDef<AdministrationUser>[] = [
return [
{
accessorKey: 'first_name',
header: 'Name',
cell: ({ row }) => (
<span className="font-medium">
{[row.original.first_name, row.original.last_name].filter(Boolean).join(' ') ||
row.original.username}
<span>
{[row.original.first_name, row.original.last_name]
.filter(Boolean)
.join(' ') || row.original.username}
</span>
),
},
@@ -82,44 +65,17 @@ export function useUserColumns({
accessorKey: 'effective_status',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.original.effective_status === 'active' ? 'default' : 'secondary'}>
<Badge
variant={
row.original.effective_status === 'active'
? 'default'
: 'secondary'
}
>
{row.original.effective_status}
</Badge>
),
},
];
if (!canEdit && !canDelete) return columns;
columns.push({
id: 'actions',
header: () => <div className="text-right">Actions</div>,
enableSorting: false,
cell: ({ row }) => {
const user = row.original;
return (
<div className="flex justify-end gap-2">
{canEdit ? (
<Button variant="outline" size="sm" onClick={() => onEdit(user)}>
<Edit className="size-4" />
</Button>
) : null}
{canDelete ? (
<Button
variant="outline"
size="sm"
disabled={pendingUserId === user.id || user.effective_status === 'pending'}
onClick={() => onToggleStatus(user)}
>
<RotateCcw className="mr-2 size-4" />
{user.effective_status === 'active' ? 'Deactivate' : 'Activate'}
</Button>
) : null}
</div>
);
},
});
return columns;
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingUserId]);
}, []);
}

View File

@@ -30,7 +30,10 @@ export function UserFilters({
onChange={onSearchChange}
placeholder="Search users"
/>
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as StatusFilter)}>
<Select
value={statusFilter}
onValueChange={(value) => onStatusChange(value as StatusFilter)}
>
<SelectTrigger className="md:w-44">
<SelectValue />
</SelectTrigger>

View File

@@ -1,7 +1,7 @@
'use client';
import { useRef, type ComponentProps } from 'react';
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
@@ -24,7 +24,7 @@ interface UserSheetProps {
userId?: number;
register: UseFormRegister<UserFormValues>;
errors: FieldErrors<UserFormValues>;
touchedFields: UseFormReturn<UserFormValues>['formState']['touchedFields'];
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
roleId: string;
onRoleChange: (roleId: string) => void;
@@ -38,7 +38,7 @@ export function UserSheet({
userId,
register,
errors,
touchedFields,
isSubmitted,
onSubmit,
roleId,
onRoleChange,
@@ -46,11 +46,17 @@ export function UserSheet({
isSaving,
}: UserSheetProps) {
const roleComboboxPortalRef = useRef<HTMLDivElement | null>(null);
const firstNameErrorMessage = touchedFields.first_name ? errors.first_name?.message : undefined;
const lastNameErrorMessage = touchedFields.last_name ? errors.last_name?.message : undefined;
const emailErrorMessage = touchedFields.email ? errors.email?.message : undefined;
const phoneErrorMessage = touchedFields.phone_number ? errors.phone_number?.message : undefined;
const roleErrorMessage = touchedFields.role_id ? errors.role_id?.message : undefined;
const firstNameErrorMessage = isSubmitted
? errors.first_name?.message
: undefined;
const lastNameErrorMessage = isSubmitted
? errors.last_name?.message
: undefined;
const emailErrorMessage = isSubmitted ? errors.email?.message : undefined;
const phoneErrorMessage = isSubmitted
? errors.phone_number?.message
: undefined;
const roleErrorMessage = isSubmitted ? errors.role_id?.message : undefined;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -149,7 +155,9 @@ export function UserSheet({
</Button>
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{userId ? 'Update User' : 'Create User'}
</Button>

View File

@@ -7,7 +7,12 @@ interface UserStatsProps {
pendingCount: number;
}
export function UserStats({ total, activeCount, inactiveCount, pendingCount }: UserStatsProps) {
export function UserStats({
total,
activeCount,
inactiveCount,
pendingCount,
}: UserStatsProps) {
const stats = [
{ label: 'Total', value: total },
{ label: 'Active', value: activeCount },
@@ -19,8 +24,8 @@ export function UserStats({ total, activeCount, inactiveCount, pendingCount }: U
<div className="grid gap-3 md:grid-cols-4">
{stats.map((stat) => (
<div key={stat.label} className="rounded-lg border bg-card p-4">
<p className="text-xs text-muted-foreground">{stat.label}</p>
<p className="mt-1 text-2xl font-semibold">{stat.value}</p>
<p className="text-muted-foreground">{stat.label}</p>
<p>{stat.value}</p>
</div>
))}
</div>

View File

@@ -2,8 +2,10 @@
import type { ReactNode } from 'react';
import type { ColumnDef, SortingState } from '@tanstack/react-table';
import { Edit, RotateCcw } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import { PERMISSIONS } from '@/constants/permissions';
import type { AdministrationUser } from '@/types';
interface UserTableProps {
@@ -18,6 +20,9 @@ interface UserTableProps {
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onSortingChange: (sorting: SortingState) => void;
onEdit: (user: AdministrationUser) => void;
onToggleStatus: (user: AdministrationUser) => void;
pendingUserId?: number;
}
export function UserTable({
@@ -32,6 +37,9 @@ export function UserTable({
onPageChange,
onLimitChange,
onSortingChange,
onEdit,
onToggleStatus,
pendingUserId,
}: UserTableProps) {
return (
<DataTable
@@ -41,6 +49,23 @@ export function UserTable({
isLoading={isLoading}
toolbar={toolbar}
emptyTitle="No users found."
actions={[
{
label: 'Edit',
icon: <Edit className="size-4" />,
permission: PERMISSIONS.USER.UPDATE,
onClick: onEdit,
},
{
label: (user) =>
user.effective_status === 'active' ? 'Deactivate' : 'Activate',
icon: <RotateCcw className="size-4" />,
permission: PERMISSIONS.USER.DELETE,
disabled: (user) =>
pendingUserId === user.id || user.effective_status === 'pending',
onClick: onToggleStatus,
},
]}
pagination={{
skip,
limit,

View File

@@ -42,10 +42,10 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) {
handleSubmit: submitForm,
reset,
setValue,
formState: { errors, isSubmitting, touchedFields },
formState: { errors, isSubmitting, isSubmitted },
} = useForm<UserFormValues>({
defaultValues,
mode: 'onTouched',
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(userFormSchema),
});
@@ -62,7 +62,9 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) {
const lastName = useWatch({ control, name: 'last_name' }) || '';
const email = useWatch({ control, name: 'email' }) || '';
const phoneNumber = useWatch({ control, name: 'phone_number' }) || '';
const isPhoneValid = phoneNumber.trim() === '' || /^\+(?:[0-9] ?){6,14}[0-9]$/.test(phoneNumber.trim());
const isPhoneValid =
phoneNumber.trim() === '' ||
/^\+(?:[0-9] ?){6,14}[0-9]$/.test(phoneNumber.trim());
const canSubmit =
firstName.trim().length > 0 &&
lastName.trim().length > 0 &&
@@ -131,7 +133,7 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) {
roleId,
userId,
errors,
touchedFields,
isSubmitted,
canSubmit,
isSaving: isSubmitting || saveMutation.isPending,
};

View File

@@ -31,7 +31,9 @@ export function useSaveUserMutation({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: userKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update user' : 'Failed to create user');
toast.error(
values.id ? 'Failed to update user' : 'Failed to create user',
);
},
});
}
@@ -40,7 +42,10 @@ export function useUserStatusMutation() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (user: AdministrationUser) =>
userService.updateUserStatus(user.id, getNextStatus(user.effective_status)),
userService.updateUserStatus(
user.id,
getNextStatus(user.effective_status),
),
onSuccess: () => {
toast.success('User status updated');
queryClient.invalidateQueries({ queryKey: userKeys.all });

View File

@@ -37,7 +37,8 @@ function buildUserListParams({
export function useUsersQuery(params: UseUsersQueryParams) {
const { skip, limit, searchTerm, statusFilter, sorting } = params;
const listParams = useMemo(
() => buildUserListParams({ skip, limit, searchTerm, statusFilter, sorting }),
() =>
buildUserListParams({ skip, limit, searchTerm, statusFilter, sorting }),
[limit, searchTerm, skip, sorting, statusFilter],
);

View File

@@ -4,7 +4,6 @@ import { useCallback, useMemo, useState } from 'react';
import { Plus, Users } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
@@ -55,7 +54,7 @@ export default function UsersPage() {
roleId,
setRoleId,
errors,
touchedFields,
isSubmitted,
canSubmit,
isSaving,
} = userForm;
@@ -82,11 +81,7 @@ export default function UsersPage() {
[updateUserStatus],
);
const columns = useUserColumns({
onEdit: openEdit,
onToggleStatus: toggleStatus,
pendingUserId,
});
const columns = useUserColumns();
const total = usersQuery.data?.total ?? 0;
const users = usersQuery.data?.items ?? [];
@@ -138,9 +133,10 @@ export default function UsersPage() {
onPageChange={setSkip}
onLimitChange={setLimit}
onSortingChange={setSorting}
onEdit={openEdit}
onToggleStatus={toggleStatus}
pendingUserId={pendingUserId}
/>
<PoweredBy />
</main>
<UserSheet
@@ -149,7 +145,7 @@ export default function UsersPage() {
userId={userId}
register={register}
errors={errors}
touchedFields={touchedFields}
isSubmitted={isSubmitted}
onSubmit={handleSubmit}
roleId={roleId}
onRoleChange={setRoleId}

View File

@@ -1,5 +1,6 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import './typography.css';
@custom-variant dark (&:is(.dark *));
@@ -7,7 +8,7 @@
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0 / 0.4);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
@@ -23,14 +24,14 @@
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.702 0.183 293.541);
--chart-1: oklch(0.65 0.18 25); /* Soft Red/Rose - High Priority */
--chart-2: oklch(0.78 0.12 75); /* Warm Amber - Distinct */
--chart-3: oklch(0.68 0.12 245); /* Azure Blue - Cool Professional */
--chart-4: oklch(0.75 0.1 165); /* Mint Teal - Balanced */
--chart-5: oklch(0.85 0.08 195); /* Soft Cyan - Subdued */
--chart-6: oklch(0.62 0.22 295); /* Deep Violet - Theme Primary */
--chart-8: oklch(0.58 0.2 335); /* Cool Magenta - Distant Accent */
--sidebar: oklch(0.985 0 0 / 0.6);
--chart-1: oklch(0.65 0.18 25); /* Soft Red/Rose - High Priority */
--chart-2: oklch(0.78 0.12 75); /* Warm Amber - Distinct */
--chart-3: oklch(0.68 0.12 245); /* Azure Blue - Cool Professional */
--chart-4: oklch(0.75 0.1 165); /* Mint Teal - Balanced */
--chart-5: oklch(0.85 0.08 195); /* Soft Cyan - Subdued */
--chart-6: oklch(0.62 0.22 295); /* Deep Violet - Theme Primary */
--chart-8: oklch(0.58 0.2 335); /* Cool Magenta - Distant Accent */
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.541 0.281 293.009);
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
@@ -43,7 +44,7 @@
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885 / 0.6);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
@@ -66,7 +67,7 @@
--chart-5: oklch(0.88 0.1 195);
--chart-6: oklch(0.65 0.24 295);
--chart-8: oklch(0.62 0.22 335);
--sidebar: oklch(0.21 0.006 285.885 / 0.6);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.606 0.25 292.717);
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
@@ -122,34 +123,10 @@
@layer base {
* {
@apply border-border outline-ring/50;
@apply border-border outline-none;
}
body {
@apply bg-transparent text-foreground;
min-height: 100vh;
}
/* Modern Scrollbar Styles */
::-webkit-scrollbar {
width: 8px; /* Slightly wider for better accessibility */
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--muted-foreground);
border-radius: 20px;
border: 2px solid transparent;
background-clip: content-box;
opacity: 0.5; /* More visible default */
}
/* Firefox Support */
* {
/* scrollbar-width: ; */
scrollbar-color: var(--muted-foreground) transparent;
@apply bg-background text-foreground;
}
}

View File

@@ -5,7 +5,6 @@ import { ThemeProvider } from '@/providers/ThemeProvider';
import AppInitializer from '@/providers/AppInitializer';
import QueryProvider from '@/providers/QueryProvider';
import { Toaster } from '@/components/ui/sonner';
import { BackgroundGradient } from '@/components/background-gradient';
const geistSans = Geist({
variable: '--font-geist-sans',
@@ -18,8 +17,11 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
title: {
default: 'VisionRoad',
template: '%s | VisionRoad',
},
description: 'Road infrastructure monitoring and pothole detection platform',
};
export default function RootLayout({
@@ -29,7 +31,9 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
@@ -38,10 +42,7 @@ export default function RootLayout({
>
<QueryProvider>
<AppInitializer>
<div className="relative min-h-screen isolate">
<BackgroundGradient />
{children}
</div>
{children}
<Toaster position="top-center" />
</AppInitializer>
</QueryProvider>

56
src/app/typography.css Normal file
View File

@@ -0,0 +1,56 @@
@layer base {
h1,
h2,
h3,
h4,
h5,
h6 {
/* margin: 0.1rem 0 0.1rem; */
font-family: inherit;
font-weight: 600;
line-height: 1.2;
color: var(--foreground);
}
h1:first-child,
h2:first-child,
h3:first-child,
h4:first-child,
h5:first-child,
h6:first-child {
margin-top: 0;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.875rem;
}
h3 {
font-size: 1.75rem;
}
h4 {
font-size: 1.5rem;
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin: 0.2rem 0 0.1rem;
/* line-height: 1.5; */
}
p:last-child {
margin-bottom: 0;
}
}

View File

@@ -44,7 +44,9 @@ export function BreadcrumbBasic() {
{isLast ? (
<BreadcrumbPage>{formatSegment(segment)}</BreadcrumbPage>
) : (
<BreadcrumbLink href={href}>{formatSegment(segment)}</BreadcrumbLink>
<BreadcrumbLink href={href}>
{formatSegment(segment)}
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!isLast && <BreadcrumbSeparator />}

View File

@@ -2,9 +2,17 @@
import * as React from 'react';
import { ChevronRight, Package } from 'lucide-react';
import { NavUser } from '@/components/nav-user';
import { filterMenuItems, menuItems, type MenuItem } from '@/config/menu.config';
import {
filterMenuItems,
menuItems,
type MenuItem,
} from '@/config/menu.config';
import { usePermissions } from '@/hooks/usePermissions';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Sidebar,
SidebarContent,
@@ -30,7 +38,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const authUser = useAppStore((state) => state.user);
const navItems = filterMenuItems(menuItems, hasPermission);
const user = {
name: [authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') || 'Admin',
name:
[authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') ||
'Admin',
email: authUser?.email || '',
avatar: authUser?.profile_photo_url || '/avatars/profile.jpg',
};
@@ -52,7 +62,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarGroup>
<SidebarMenu>
{navItems.map((item) => (
<SidebarNavItem key={item.title} item={item} pathname={pathname} />
<SidebarNavItem
key={item.title}
item={item}
pathname={pathname}
/>
))}
</SidebarMenu>
</SidebarGroup>
@@ -65,14 +79,25 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
);
}
function SidebarNavItem({ item, pathname }: { item: MenuItem; pathname: string }) {
function SidebarNavItem({
item,
pathname,
}: {
item: MenuItem;
pathname: string;
}) {
const isActive = item.path ? pathname === item.path : false;
const isChildActive = item.children?.some((child) => child.path === pathname) ?? false;
const isChildActive =
item.children?.some((child) => child.path === pathname) ?? false;
const hasChildren = Boolean(item.children?.length);
if (hasChildren) {
return (
<Collapsible asChild defaultOpen={isChildActive} className="group/collapsible">
<Collapsible
asChild
defaultOpen={isChildActive}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title} isActive={isChildActive}>
@@ -86,7 +111,10 @@ function SidebarNavItem({ item, pathname }: { item: MenuItem; pathname: string }
{item.children?.map((child) => (
<SidebarMenuSubItem key={child.title}>
{child.path ? (
<SidebarMenuSubButton asChild isActive={pathname === child.path}>
<SidebarMenuSubButton
asChild
isActive={pathname === child.path}
>
<Link href={child.path}>
<child.icon />
<span>{child.title}</span>

View File

@@ -15,7 +15,10 @@ import {
ComboboxValue,
} from '@/components/ui/combobox';
import type { AsyncComboboxOption, AsyncComboboxProps } from './AsyncCombobox.types';
import type {
AsyncComboboxOption,
AsyncComboboxProps,
} from './AsyncCombobox.types';
import { VirtualComboboxList } from './VirtualComboboxList';
type VirtualizerHandle = Virtualizer<HTMLDivElement, Element>;
@@ -108,7 +111,9 @@ export function AsyncCombobox({
filter={null}
disabled={isDisabled}
itemToStringLabel={(item) => item.label}
isItemEqualToValue={(item, currentValue) => item.value === currentValue.value}
isItemEqualToValue={(item, currentValue) =>
item.value === currentValue.value
}
onItemHighlighted={handleItemHighlighted}
>
<ComboboxTrigger
@@ -131,7 +136,11 @@ export function AsyncCombobox({
}
/>
<ComboboxContent container={portalContainer}>
<ComboboxInput showTrigger={false} placeholder={searchPlaceholder} disabled={isDisabled} />
<ComboboxInput
showTrigger={false}
placeholder={searchPlaceholder}
disabled={isDisabled}
/>
{isError ? (
<div className="px-3 py-2 text-sm text-destructive">
{errorMessage || 'Unable to load options.'}

View File

@@ -1,6 +1,12 @@
'use client';
import { useCallback, useEffect, useImperativeHandle, useRef, type RefObject } from 'react';
import {
useCallback,
useEffect,
useImperativeHandle,
useRef,
type RefObject,
} from 'react';
import { Combobox } from '@base-ui/react/combobox';
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
import { Loader2 } from 'lucide-react';
@@ -72,7 +78,13 @@ export function VirtualComboboxList({
) {
onLoadMore();
}
}, [filteredItems.length, hasNextPage, isFetchingNextPage, lastVirtualIndex, onLoadMore]);
}, [
filteredItems.length,
hasNextPage,
isFetchingNextPage,
lastVirtualIndex,
onLoadMore,
]);
if (!filteredItems.length && !hasNextPage) {
return null;

View File

@@ -5,7 +5,10 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { useDebounce } from '@/hooks/useDebounce';
import type { AsyncComboboxOption, PaginatedLookupConfig } from './AsyncCombobox.types';
import type {
AsyncComboboxOption,
PaginatedLookupConfig,
} from './AsyncCombobox.types';
export function usePaginatedLookup<T>({
enabled = true,
@@ -35,7 +38,10 @@ export function usePaginatedLookup<T>({
return undefined;
}
const loadedCount = allPages.reduce((count, page) => count + page.items.length, 0);
const loadedCount = allPages.reduce(
(count, page) => count + page.items.length,
0,
);
return loadedCount < lastPage.total ? loadedCount : undefined;
},
enabled,
@@ -67,7 +73,11 @@ export function usePaginatedLookup<T>({
const item = await resolveSelected(selectedValue);
return item ? mapOption(item) : null;
},
enabled: enabled && Boolean(selectedValue) && !selectedInList && Boolean(resolveSelected),
enabled:
enabled &&
Boolean(selectedValue) &&
!selectedInList &&
Boolean(resolveSelected),
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
});

View File

@@ -1,36 +0,0 @@
import React from 'react';
export function BackgroundGradient() {
return (
<div
className="fixed inset-0 -z-10 h-full w-full overflow-hidden pointer-events-none"
aria-hidden="true"
>
{/* Top Gradient */}
<div className="absolute inset-x-0 top-0 transform-gpu overflow-hidden blur-3xl">
<div
className="relative left-[calc(50%-11rem)] aspect-1155/678 w-144.5 -translate-x-1/2 rotate-30 bg-linear-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%-30rem)] sm:w-288.75"
style={{
clipPath:
'polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)',
}}
/>
</div>
{/* Middle/Bottom Gradient for balance */}
<div className="absolute inset-x-0 bottom-0 transform-gpu overflow-hidden blur-3xl">
<div
className="relative left-[calc(50%+11rem)] aspect-1155/678 w-144.5 -translate-x-1/2 rotate-30 bg-linear-to-tr from-[#ff80b5] to-[#9089fc] opacity-20 sm:left-[calc(50%+30rem)] sm:w-288.75"
style={{
clipPath:
'polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)',
}}
/>
</div>
</div>
);
}
export function BackgroundGradientBottom() {
return null; // Integrated into the main BackgroundGradient component
}

View File

@@ -55,7 +55,10 @@ const chartConfig = {
},
} satisfies ChartConfig;
export function ChainageBarChart({ data, isLoading = false }: ChainageBarChartProps) {
export function ChainageBarChart({
data,
isLoading = false,
}: ChainageBarChartProps) {
if (isLoading) {
return (
<div className="h-[250px] flex items-center justify-center">
@@ -68,7 +71,9 @@ export function ChainageBarChart({ data, isLoading = false }: ChainageBarChartPr
return (
<div className="h-[250px] flex flex-col items-center justify-center text-muted-foreground">
<p className="text-sm">No segment data available</p>
<p className="text-xs mt-1">Process videos to see detections by segment</p>
<p className="text-xs mt-1">
Process videos to see detections by segment
</p>
</div>
);
}
@@ -94,17 +99,43 @@ export function ChainageBarChart({ data, isLoading = false }: ChainageBarChartPr
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => (value.length > 8 ? `${value.slice(0, 8)}...` : value)}
tickFormatter={(value) =>
value.length > 8 ? `${value.slice(0, 8)}...` : value
}
fontSize={12}
/>
<YAxis tickLine={false} axisLine={false} fontSize={12} tickMargin={10} />
<ChartTooltip cursor={false} content={<ChartTooltipContent indicator="dashed" />} />
<YAxis
tickLine={false}
axisLine={false}
fontSize={12}
tickMargin={10}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dashed" />}
/>
<Bar dataKey="pothole" fill="var(--color-pothole)" radius={4} />
<Bar dataKey="defected_sign_board" fill="var(--color-defected_sign_board)" radius={4} />
<Bar
dataKey="defected_sign_board"
fill="var(--color-defected_sign_board)"
radius={4}
/>
<Bar dataKey="road_crack" fill="var(--color-road_crack)" radius={4} />
<Bar dataKey="damaged_road_marking" fill="var(--color-damaged_road_marking)" radius={4} />
<Bar dataKey="drain_issue" fill="var(--color-drain_issue)" radius={4} />
<Bar dataKey="defective_culvert" fill="var(--color-defective_culvert)" radius={4} />
<Bar
dataKey="damaged_road_marking"
fill="var(--color-damaged_road_marking)"
radius={4}
/>
<Bar
dataKey="drain_issue"
fill="var(--color-drain_issue)"
radius={4}
/>
<Bar
dataKey="defective_culvert"
fill="var(--color-defective_culvert)"
radius={4}
/>
</BarChart>
</ChartContainer>
</div>

View File

@@ -1,7 +1,13 @@
'use client';
import { useEffect } from 'react';
import { MapContainer, TileLayer, CircleMarker, Popup, useMap } from 'react-leaflet';
import {
MapContainer,
TileLayer,
CircleMarker,
Popup,
useMap,
} from 'react-leaflet';
import { LatLngBounds, LatLng } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { Detection } from '@/types';
@@ -24,7 +30,9 @@ function FitBounds({ bounds }: { bounds: LatLngBounds }) {
return null;
}
export default function DashboardMapContent({ detections }: DashboardMapContentProps) {
export default function DashboardMapContent({
detections,
}: DashboardMapContentProps) {
if (detections.length === 0) {
return (
<div className="h-full w-full flex items-center justify-center bg-muted/20">
@@ -56,9 +64,9 @@ export default function DashboardMapContent({ detections }: DashboardMapContentP
const getMarkerColor = (typeId: string) => {
const config = DETECTION_TYPES[typeId.toLowerCase()];
if (config) {
return {
fill: config.color,
stroke: '#ffffff' // White stroke for better visibility on the map
return {
fill: config.color,
stroke: '#ffffff', // White stroke for better visibility on the map
};
}
return { fill: '#64748b', stroke: '#475569' }; // Default Slate
@@ -104,28 +112,44 @@ export default function DashboardMapContent({ detections }: DashboardMapContentP
<div className="font-bold text-base border-b border-border pb-2 mb-3 leading-none">
{typeName}
</div>
<div className="space-y-2 mb-4">
<div className="flex justify-between items-baseline gap-4">
<span className="text-muted-foreground text-xs">Class</span>
<span className="font-medium text-right capitalize">{detection.class.replace(/_/g, ' ')}</span>
<span className="font-medium text-right capitalize">
{detection.class.replace(/_/g, ' ')}
</span>
</div>
<div className="flex justify-between items-baseline gap-4">
<span className="text-muted-foreground text-xs">Confidence</span>
<span className="font-medium text-right">{(detection.confidence * 100).toFixed(1)}%</span>
<span className="text-muted-foreground text-xs">
Confidence
</span>
<span className="font-medium text-right">
{(detection.confidence * 100).toFixed(1)}%
</span>
</div>
</div>
<div className="pt-2 border-t border-border/50">
<div className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground mb-2">Location Details</div>
<div className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground mb-2">
Location Details
</div>
<div className="grid grid-cols-2 gap-3 bg-muted/40 p-2.5 rounded-md font-mono text-[11px]">
<div className="space-y-0.5">
<span className="text-[9px] block text-muted-foreground/70 uppercase">Latitude</span>
<span className="font-medium tracking-tighter">{detection.latitude!.toFixed(6)}</span>
<span className="text-[9px] block text-muted-foreground/70 uppercase">
Latitude
</span>
<span className="font-medium tracking-tighter">
{detection.latitude!.toFixed(6)}
</span>
</div>
<div className="space-y-0.5">
<span className="text-[9px] block text-muted-foreground/70 uppercase">Longitude</span>
<span className="font-medium tracking-tighter">{detection.longitude!.toFixed(6)}</span>
<span className="text-[9px] block text-muted-foreground/70 uppercase">
Longitude
</span>
<span className="font-medium tracking-tighter">
{detection.longitude!.toFixed(6)}
</span>
</div>
</div>
</div>

View File

@@ -59,18 +59,23 @@ export function DashboardMap({
for (const [pkgName, pkg] of Object.entries(packagesToProcess)) {
if (!pkg) continue;
const chainagesToProcess =
selectedChainageId && selectedChainageId !== 'all'
? { [selectedChainageId]: (pkg as any).chainages?.[selectedChainageId] }
? {
[selectedChainageId]: (pkg as any).chainages?.[
selectedChainageId
],
}
: (pkg as any).chainages || {};
for (const [chnName, chn] of Object.entries(chainagesToProcess)) {
if (!chn) continue;
const chainageDetections = ((chn as any).detections || []).filter(
(d: any) => (d.type || d.class || '').toLowerCase() !== 'good_sign_board'
);
filteredDetections.push(...chainageDetections);
const chainageDetections = ((chn as any).detections || []).filter(
(d: any) =>
(d.type || d.class || '').toLowerCase() !== 'good_sign_board',
);
filteredDetections.push(...chainageDetections);
}
}
@@ -99,16 +104,18 @@ export function DashboardMap({
const validDetections = detections.filter((d) => {
const hasGps = d.latitude && d.longitude;
if (!hasGps) return false;
if (selectedTypes.length === 0) return true;
const typeId = (d.type || d.class || '').toLowerCase();
return selectedTypes.includes(typeId);
});
return (
<Card
<Card
className={`overflow-hidden transition-all duration-300 p-2 rounded-lg ${
isMaximized ? 'fixed inset-0 z-100 m-0 rounded-none bg-background' : className
isMaximized
? 'fixed inset-0 z-100 m-0 rounded-none bg-background'
: className
}`}
>
<CardContent className="p-0 relative rounded-lg">
@@ -119,9 +126,13 @@ export function DashboardMap({
variant="default"
size="icon"
onClick={() => setIsMaximized(!isMaximized)}
title={isMaximized ? "Exit Fullscreen" : "Maximize Map"}
title={isMaximized ? 'Exit Fullscreen' : 'Maximize Map'}
>
{isMaximized ? <Minimize2 className="h-5 w-5" /> : <Maximize2 className="h-5 w-5" />}
{isMaximized ? (
<Minimize2 className="h-5 w-5" />
) : (
<Maximize2 className="h-5 w-5" />
)}
</Button>
</div>
</div>
@@ -133,9 +144,12 @@ export function DashboardMap({
{Object.values(DETECTION_TYPES).map((type) => {
const typeId = type.id.toLowerCase();
const count = detections.filter(
(d) => (d.type || d.class || '').toLowerCase() === typeId && d.latitude && d.longitude
(d) =>
(d.type || d.class || '').toLowerCase() === typeId &&
d.latitude &&
d.longitude,
).length;
if (
count === 0 &&
(type.id.includes('culvert') || type.id === 'drain_issue')
@@ -143,21 +157,20 @@ export function DashboardMap({
return null;
if (type.id === 'good_sign_board') return null;
const isSelected = selectedTypes.length === 0 || selectedTypes.includes(typeId);
const isSelected =
selectedTypes.length === 0 || selectedTypes.includes(typeId);
return (
<button
key={type.id}
onClick={() => toggleType(typeId)}
className={`flex items-center gap-2 px-2 py-1.5 text-zinc-100 rounded-full transition-all text-xs ${
isSelected
? 'bg-zinc-800/80 '
: 'bg-transparent '
isSelected ? 'bg-zinc-800/80 ' : 'bg-transparent '
}`}
>
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: type.color }}
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: type.color }}
/>
<span>
{type.label} ({count})
@@ -169,7 +182,9 @@ export function DashboardMap({
</div>
</div>
<div className={`${isMaximized ? 'h-screen' : 'h-[600px]'} w-full relative transition-all`}>
<div
className={`${isMaximized ? 'h-screen' : 'h-[600px]'} w-full relative transition-all`}
>
{isLoading ? (
<div className="h-full w-full flex items-center justify-center bg-muted/50">
<Loader2 className="h-8 w-8 text-primary animate-spin" />
@@ -181,7 +196,9 @@ export function DashboardMap({
) : validDetections.length === 0 ? (
<div className="h-full w-full flex items-center justify-center bg-muted/50">
<div className="text-center">
<p className="text-muted-foreground">No detections with GPS coordinates found</p>
<p className="text-muted-foreground">
No detections with GPS coordinates found
</p>
<p className="text-sm text-muted-foreground mt-1 opacity-70">
Process some videos to see detections on the map
</p>
@@ -192,7 +209,6 @@ export function DashboardMap({
)}
{/* Bottom Left Label Overlay */}
</div>
</CardContent>
</Card>

View File

@@ -45,9 +45,21 @@ export function DashboardSkeleton() {
</CardHeader>
<CardContent className="h-[300px] flex items-end justify-between gap-2 px-6 pb-8 pt-4">
{[...Array(8)].map((_, i) => {
const heights = ['60%', '40%', '75%', '50%', '65%', '35%', '80%', '45%'];
const heights = [
'60%',
'40%',
'75%',
'50%',
'65%',
'35%',
'80%',
'45%',
];
return (
<div key={i} className="flex flex-col gap-1 items-center flex-1">
<div
key={i}
className="flex flex-col gap-1 items-center flex-1"
>
<Skeleton
className="w-full rounded-t-sm"
style={{ height: heights[i % heights.length] }}

View File

@@ -96,7 +96,7 @@ export function DetectionDonutChart({
defectedSignboard,
roadCrack,
damagedRoadMarking,
// goodSignboard,
// goodSignboard,
drainIssue,
defectiveCulvert,
],
@@ -124,15 +124,32 @@ export function DetectionDonutChart({
}
return (
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px]">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px]"
>
<PieChart>
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
<Pie data={chartData} dataKey="count" nameKey="type" innerRadius={60} strokeWidth={5}>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Pie
data={chartData}
dataKey="count"
nameKey="type"
innerRadius={60}
strokeWidth={5}
>
<Label
content={({ viewBox }) => {
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}

View File

@@ -38,7 +38,9 @@ export function StatsCard({
<>
<p className="text-3xl font-bold tracking-tight">{value}</p>
{subtitle && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">{subtitle}</p>
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
{subtitle}
</p>
)}
</>
)}

View File

@@ -36,7 +36,7 @@ export function TableFooter<TData>({
</span>
{onPageSizeChange ? (
<div className="flex items-center gap-2">
<span >Rows per page</span>
<span>Rows per page</span>
<Select
value={String(currentPageSize)}
onValueChange={(value) => onPageSizeChange(Number(value))}

View File

@@ -23,8 +23,8 @@ const TopHeader = ({
{(title || onAddNew) && (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
{title ? <h2 className="text-base font-semibold">{title}</h2> : null}
<p className="text-sm text-muted-foreground">Total {itemCount ?? 0}</p>
{title ? <h2>{title}</h2> : null}
<p className="text-muted-foreground">Total {itemCount ?? 0}</p>
</div>
{onAddNew ? (
<Button onClick={onAddNew}>

View File

@@ -0,0 +1,52 @@
'use client';
import type { MouseEvent, ReactNode } from 'react';
import { PermissionGuard } from '@/guards';
import type { PermissionInput } from '@/hooks/usePermissions';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
interface TableActionButtonProps {
label: string;
permission?: PermissionInput;
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
disabled?: boolean;
className?: string;
children: ReactNode;
}
export function TableActionButton({
label,
permission,
onClick,
disabled,
className,
children,
}: TableActionButtonProps) {
return (
<PermissionGuard permissions={permission}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className={cn('size-8', className)}
onClick={onClick}
disabled={disabled}
aria-label={label}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</PermissionGuard>
);
}

View File

@@ -1,13 +1,21 @@
'use client';
import { flexRender } from '@tanstack/react-table';
import { TableHead, TableHeader as ShadTableHeader, TableRow } from '@/components/ui/table';
import {
TableHead,
TableHeader as ShadTableHeader,
TableRow,
} from '@/components/ui/table';
import { Table } from '@tanstack/react-table';
import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide } from 'lucide-react';
import {
ArrowDownWideNarrow,
ArrowUpDown,
ArrowUpNarrowWide,
} from 'lucide-react';
import { cn } from '@/lib/utils';
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
return (
<ShadTableHeader className="sticky top-0 z-10 bg-card/80 backdrop-blur-xl">
<ShadTableHeader className="sticky top-0 z-10 bg-card">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
@@ -23,9 +31,11 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
return (
<TableHead
key={header.id}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onClick={
canSort ? header.column.getToggleSortingHandler() : undefined
}
className={cn(
'h-10 bg-card/80 px-2 text-foreground backdrop-blur-xl transition-colors',
'h-10 bg-card px-2 text-foreground transition-colors',
canSort && 'cursor-pointer select-none hover:bg-muted/60',
sortDirection && 'bg-muted/70',
)}
@@ -33,13 +43,18 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
{header.isPlaceholder ? null : (
<div className="flex min-h-8 w-full items-center justify-between gap-2">
<span className="truncate">
{flexRender(header.column.columnDef.header, header.getContext())}
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</span>
{canSort ? (
<SortIcon
className={cn(
'size-3.5 shrink-0 transition-colors',
sortDirection ? 'text-foreground' : 'text-muted-foreground/60',
sortDirection
? 'text-foreground'
: 'text-muted-foreground/60',
)}
/>
) : null}

View File

@@ -12,12 +12,22 @@ import {
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Edit3, Trash2 } from 'lucide-react';
import { TooltipProvider } from '@/components/ui/tooltip';
import type { PermissionInput } from '@/hooks/usePermissions';
import TopHeader from './Header';
import TableHeader from './TableHeader';
import { TableFooter } from './Footer';
import { TableActionButton } from './TableActionButton';
export interface DataTableAction<TData> {
label: string | ((item: TData) => string);
icon: React.ReactNode;
onClick: (item: TData) => void;
permission?: PermissionInput;
disabled?: (item: TData) => boolean;
className?: string;
}
export interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@@ -26,8 +36,7 @@ export interface DataTableProps<TData, TValue> {
onAddNew?: () => void;
addButtonText?: string;
isLoading?: boolean;
onEdit?: (item: TData) => void;
onDelete?: (item: TData) => void;
actions?: DataTableAction<TData>[];
toolbar?: React.ReactNode;
emptyTitle?: string;
emptyDescription?: string;
@@ -49,8 +58,7 @@ export function DataTable<TData, TValue>({
onAddNew,
addButtonText,
isLoading = false,
onEdit,
onDelete,
actions,
toolbar,
emptyTitle = 'No results found.',
emptyDescription = 'Try adjusting your filters or search terms.',
@@ -59,12 +67,15 @@ export function DataTable<TData, TValue>({
onSortingChange,
}: DataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = React.useState({});
const [internalSorting, setInternalSorting] = React.useState<SortingState>([]);
const [internalSorting, setInternalSorting] = React.useState<SortingState>(
[],
);
const sorting = controlledSorting ?? internalSorting;
const isManualSorting = Boolean(onSortingChange);
const handleSortingChange = React.useCallback(
(updater: SortingState | ((old: SortingState) => SortingState)) => {
const nextSorting = typeof updater === 'function' ? updater(sorting) : updater;
const nextSorting =
typeof updater === 'function' ? updater(sorting) : updater;
if (onSortingChange) {
onSortingChange(nextSorting);
return;
@@ -77,7 +88,7 @@ export function DataTable<TData, TValue>({
const columns = React.useMemo(() => {
const cols: ColumnDef<TData, TValue>[] = [...initialColumns];
if (onEdit || onDelete) {
if (actions?.length) {
cols.push({
id: 'actions',
header: () => <div className="text-right">Actions</div>,
@@ -85,37 +96,35 @@ export function DataTable<TData, TValue>({
const item = row.original;
return (
<div className="flex justify-end gap-2">
{onEdit && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onEdit(item);
}}
>
<Edit3 className="size-4" />
</Button>
)}
{onDelete && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onDelete(item);
}}
>
<Trash2 className="size-4 text-destructive" />
</Button>
)}
{actions.map((action) => {
const label =
typeof action.label === 'function'
? action.label(item)
: action.label;
return (
<TableActionButton
key={label}
label={label}
permission={action.permission}
className={action.className}
disabled={action.disabled?.(item)}
onClick={(event) => {
event.stopPropagation();
action.onClick(item);
}}
>
{action.icon}
</TableActionButton>
);
})}
</div>
);
},
});
}
return cols;
}, [initialColumns, onEdit, onDelete]);
}, [actions, initialColumns]);
const table = useReactTable({
data,
@@ -127,7 +136,9 @@ export function DataTable<TData, TValue>({
getPaginationRowModel: getPaginationRowModel(),
manualPagination: !!pagination,
manualSorting: isManualSorting,
pageCount: pagination?.totalItems ? Math.ceil(pagination.totalItems / pagination.limit) : -1,
pageCount: pagination?.totalItems
? Math.ceil(pagination.totalItems / pagination.limit)
: -1,
state: {
rowSelection,
sorting,
@@ -158,7 +169,8 @@ export function DataTable<TData, TValue>({
});
return (
<section className="space-y-4 rounded-lg border bg-card p-4">
<TooltipProvider delayDuration={0}>
<section className="space-y-4 rounded-lg border bg-card p-4">
<TopHeader
title={onAddNew ? title : undefined}
itemCount={pagination?.totalItems ?? data.length}
@@ -192,14 +204,20 @@ export function DataTable<TData, TValue>({
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-28 text-center">
<TableCell
colSpan={columns.length}
className="h-28 text-center"
>
<div className="flex flex-col items-center justify-center text-muted-foreground gap-1">
<p className="text-sm font-medium">{emptyTitle}</p>
<p className="text-xs">{emptyDescription}</p>
@@ -216,6 +234,7 @@ export function DataTable<TData, TValue>({
pageSize={pagination?.limit}
onPageSizeChange={pagination?.onLimitChange}
/>
</section>
</section>
</TooltipProvider>
);
}

View File

@@ -30,13 +30,16 @@ export function FormField({
<div className={cn('space-y-2', className)}>
<div className="flex items-center justify-between gap-3">
<Label htmlFor={id} className={labelClassName}>
{label} {required ? <span className="text-destructive">*</span> : null}
{label}{' '}
{required ? <span className="text-destructive">*</span> : null}
</Label>
{labelEnd}
</div>
{children}
{error ? (
<p className={cn('-mt-1 text-sm text-destructive', errorClassName)}>{error}</p>
<p className={cn('-mt-1 text-sm text-destructive', errorClassName)}>
{error}
</p>
) : null}
</div>
);

View File

@@ -48,15 +48,19 @@ export function RoleCombobox({
effective_status: true,
sort_by: 'display_name',
sort_order: 'asc',
}),
}),
mapOption: mapRoleOption,
resolveSelected: (roleId) => {
const numericRoleId = parseRoleId(roleId);
return numericRoleId ? roleService.getRoleById(numericRoleId) : Promise.resolve(null);
return numericRoleId
? roleService.getRoleById(numericRoleId)
: Promise.resolve(null);
},
resolveSelectedQueryKey: (roleId) => {
const numericRoleId = parseRoleId(roleId);
return numericRoleId ? roleKeys.detail(numericRoleId) : [...roleKeys.details(), 'invalid', roleId];
return numericRoleId
? roleKeys.detail(numericRoleId)
: [...roleKeys.details(), 'invalid', roleId];
},
});

View File

@@ -1,11 +1,26 @@
'use client';
import { useEffect } from 'react';
import { MapContainer, TileLayer, Polyline, CircleMarker, Popup, useMap } from 'react-leaflet';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
MapContainer,
TileLayer,
Polyline,
CircleMarker,
Popup,
useMap,
} from 'react-leaflet';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { LatLngBounds, LatLng } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { getDetectionModeConfig, DETECTION_TYPES } from '@/constants/detectionModeConfig';
import {
getDetectionModeConfig,
DETECTION_TYPES,
} from '@/constants/detectionModeConfig';
import { DetectionType } from '@/types';
type Detection = {
@@ -47,7 +62,12 @@ function FitBounds({ bounds }: { bounds: LatLngBounds }) {
return null;
}
export default function MapModal({ open, onClose, detections, detectionType }: MapModalProps) {
export default function MapModal({
open,
onClose,
detections,
detectionType,
}: MapModalProps) {
const validDetections = detections.filter((d) => d.latitude && d.longitude);
if (validDetections.length === 0) {
@@ -73,7 +93,9 @@ export default function MapModal({ open, onClose, detections, detectionType }: M
new LatLng(routeCoordinates[0][0], routeCoordinates[0][1]),
new LatLng(routeCoordinates[0][0], routeCoordinates[0][1]),
);
routeCoordinates.forEach((coord) => bounds.extend(new LatLng(coord[0], coord[1])));
routeCoordinates.forEach((coord) =>
bounds.extend(new LatLng(coord[0], coord[1])),
);
const center: [number, number] = [
(bounds.getNorth() + bounds.getSouth()) / 2,
(bounds.getEast() + bounds.getWest()) / 2,
@@ -85,20 +107,32 @@ export default function MapModal({ open, onClose, detections, detectionType }: M
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-6xl h-[85vh] p-0 flex flex-col overflow-hidden border-none shadow-2xl">
<DialogHeader className="px-6 py-4 border-b shrink-0">
<DialogTitle className="text-xl font-bold">{modeConfig.label} Map</DialogTitle>
<DialogTitle className="text-xl font-bold">
{modeConfig.label} Map
</DialogTitle>
<p className="text-xs text-muted-foreground font-medium">
{validDetections.length} points of interest identified with GPS data
</p>
</DialogHeader>
<div className="flex-1 w-full relative bg-muted/20">
<MapContainer center={center} zoom={13} className="h-full w-full" scrollWheelZoom={true}>
<MapContainer
center={center}
zoom={13}
className="h-full w-full"
scrollWheelZoom={true}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Polyline positions={routeCoordinates} color="#3b82f6" weight={4} opacity={0.6} />
<Polyline
positions={routeCoordinates}
color="#3b82f6"
weight={4}
opacity={0.6}
/>
{validDetections.map((detection, idx) => {
const type = (detection.type || '').toLowerCase();
@@ -121,30 +155,51 @@ export default function MapModal({ open, onClose, detections, detectionType }: M
<Popup>
<div className="text-sm min-w-[200px] py-1">
<div className="font-bold text-base border-b border-border pb-2 mb-3 leading-none capitalize">
{(detection.type || '').replace(/_/g, ' ')} <span className="text-muted-foreground font-medium text-sm ml-1">#{detection.id}</span>
{(detection.type || '').replace(/_/g, ' ')}{' '}
<span className="text-muted-foreground font-medium text-sm ml-1">
#{detection.id}
</span>
</div>
<div className="space-y-2 mb-4">
<div className="flex justify-between items-baseline gap-4">
<span className="text-muted-foreground text-xs">Frame</span>
<span className="font-medium text-right font-mono">{detection.frame_number}</span>
<span className="text-muted-foreground text-xs">
Frame
</span>
<span className="font-medium text-right font-mono">
{detection.frame_number}
</span>
</div>
<div className="flex justify-between items-baseline gap-4">
<span className="text-muted-foreground text-xs">Confidence</span>
<span className="font-medium text-right">{(detection.confidence * 100).toFixed(1)}%</span>
<span className="text-muted-foreground text-xs">
Confidence
</span>
<span className="font-medium text-right">
{(detection.confidence * 100).toFixed(1)}%
</span>
</div>
</div>
<div className="pt-2 border-t border-border/50">
<div className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground mb-2">Location Details</div>
<div className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground mb-2">
Location Details
</div>
<div className="grid grid-cols-2 gap-3 bg-muted/40 p-2.5 rounded-md font-mono text-[11px]">
<div className="space-y-0.5">
<span className="text-[9px] block text-muted-foreground/70 uppercase">Latitude</span>
<span className="font-medium tracking-tighter">{detection.latitude.toFixed(6)}</span>
<span className="text-[9px] block text-muted-foreground/70 uppercase">
Latitude
</span>
<span className="font-medium tracking-tighter">
{detection.latitude.toFixed(6)}
</span>
</div>
<div className="space-y-0.5">
<span className="text-[9px] block text-muted-foreground/70 uppercase">Longitude</span>
<span className="font-medium tracking-tighter">{detection.longitude.toFixed(6)}</span>
<span className="text-[9px] block text-muted-foreground/70 uppercase">
Longitude
</span>
<span className="font-medium tracking-tighter">
{detection.longitude.toFixed(6)}
</span>
</div>
</div>
</div>

View File

@@ -25,9 +25,15 @@ export function ModeToggle() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('light')}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -1,6 +1,13 @@
'use client';
import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogOut, Sparkles } from 'lucide-react';
import {
BadgeCheck,
Bell,
ChevronsUpDown,
CreditCard,
LogOut,
Sparkles,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@@ -118,15 +125,15 @@ export function NavUser({
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
handleLogout();
}}
disabled={isLoggingOut}
>
<LogOut />
{isLoggingOut ? 'Logging out' : 'Log out'}
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
handleLogout();
}}
disabled={isLoggingOut}
>
<LogOut />
{isLoggingOut ? 'Logging out' : 'Log out'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -11,21 +11,19 @@ interface PageHeaderProps {
actions?: React.ReactNode;
}
export function PageHeader({ title, description, icon: Icon, children, actions }: PageHeaderProps) {
export function PageHeader({
title,
description,
icon: Icon,
children,
actions,
}: PageHeaderProps) {
return (
<Reveal direction="down" className="w-full">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-6">
{/* <div className="p-3.5 rounded-xl bg-primary text-primary-foreground flex items-center justify-center shadow-lg shadow-primary/5 ring-1 ring-white/10">
{Icon && <Icon className="h-7 w-7" />}
{children}
</div> */}
<div className="flex flex-col">
<h1 className="text-3xl font-extrabold tracking-tight ">{title}</h1>
<p className="text-muted-foreground mt-1 text-sm font-semibold tracking-wide opacity-80">
{description}
</p>
</div>
<div className="flex flex-col">
<h1>{title}</h1>
<p className="text-muted-foreground opacity-80">{description}</p>
</div>
{actions && <div className="flex items-center gap-4">{actions}</div>}
</div>

Some files were not shown because too many files have changed in this diff Show More