chore: update eslint and prettier configuration
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# Backend API base URL (no trailing slash)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||
@@ -1,7 +0,0 @@
|
||||
.next
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
coverage
|
||||
public
|
||||
.env*
|
||||
@@ -1,4 +1,2 @@
|
||||
#!/usr/bin/env sh
|
||||
# . "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# npx lint-staged
|
||||
# npm run lint
|
||||
|
||||
11
.prettierrc
11
.prettierrc
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
2
next-env.d.ts
vendored
@@ -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
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: false,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
477
package-lock.json
generated
477
package-lock.json
generated
@@ -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",
|
||||
|
||||
15
package.json
15
package.json
@@ -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",
|
||||
|
||||
@@ -17,7 +17,9 @@ export default function LoginPage() {
|
||||
<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>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
VisionRoad
|
||||
</p>
|
||||
<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.
|
||||
@@ -39,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>
|
||||
|
||||
@@ -57,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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -21,7 +21,9 @@ export function useClientColumns(): ColumnDef<Client>[] {
|
||||
{
|
||||
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',
|
||||
@@ -33,7 +35,9 @@ export function useClientColumns(): ColumnDef<Client>[] {
|
||||
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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -44,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;
|
||||
|
||||
@@ -30,7 +30,11 @@ 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 { toast } from 'sonner';
|
||||
@@ -160,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,
|
||||
@@ -207,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,
|
||||
@@ -230,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);
|
||||
@@ -258,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,
|
||||
}))
|
||||
@@ -281,7 +298,8 @@ export default function DashboardPage() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const summary = await projectSummaryService.getProjectSummary<ProjectSummary>(
|
||||
const summary =
|
||||
await projectSummaryService.getProjectSummary<ProjectSummary>(
|
||||
selectedProjectId,
|
||||
);
|
||||
setProjectSummary(summary);
|
||||
@@ -322,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]);
|
||||
}
|
||||
@@ -415,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,
|
||||
@@ -478,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" />
|
||||
@@ -577,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
|
||||
@@ -605,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">
|
||||
|
||||
@@ -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() {
|
||||
@@ -42,9 +46,7 @@ function StateBadges({ value }: { value: string | null }) {
|
||||
</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-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}
|
||||
@@ -70,14 +72,16 @@ export function usePackageColumns(projects: Project[]) {
|
||||
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} />;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -57,14 +57,23 @@ export function PackageDialog({
|
||||
canSubmit,
|
||||
isSaving,
|
||||
}: PackageDialogProps) {
|
||||
const projectErrorMessage = isSubmitted ? errors.project_id?.message : undefined;
|
||||
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 = isSubmitted ? errors.chainage_end_km?.message : undefined;
|
||||
const startErrorMessage = isSubmitted
|
||||
? errors.chainage_start_km?.message
|
||||
: undefined;
|
||||
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">
|
||||
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
|
||||
@@ -101,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"
|
||||
@@ -124,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"
|
||||
@@ -138,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"
|
||||
|
||||
@@ -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(),
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -37,7 +37,8 @@ export function usePlanColumns(): ColumnDef<Plan>[] {
|
||||
{
|
||||
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',
|
||||
@@ -68,7 +69,9 @@ export function usePlanColumns(): ColumnDef<Plan>[] {
|
||||
<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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -201,7 +201,9 @@ export function PlanSheet({
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label>Permissions</Label>
|
||||
<span className="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-muted-foreground">
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -16,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);
|
||||
@@ -57,8 +60,15 @@ export default function PlansPage() {
|
||||
onSaved: () => handleSheetOpenChange(false),
|
||||
});
|
||||
const { openCreate: prepareCreatePlan, openEdit: prepareEditPlan } = planForm;
|
||||
const { register, control, onSubmit, planId, permissionIds, setPermissionIds, isSaving } =
|
||||
planForm;
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
onSubmit,
|
||||
planId,
|
||||
permissionIds,
|
||||
setPermissionIds,
|
||||
isSaving,
|
||||
} = planForm;
|
||||
const statusMutation = usePlanStatusMutation();
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
|
||||
@@ -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() {
|
||||
@@ -42,9 +46,7 @@ function StateBadges({ value }: { value: string | null }) {
|
||||
</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-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}
|
||||
|
||||
@@ -42,14 +42,21 @@ export function ProjectDialog({
|
||||
isSaving,
|
||||
}: ProjectDialogProps) {
|
||||
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
|
||||
const startLatErrorMessage = isSubmitted ? errors.start_lat?.message : undefined;
|
||||
const startLngErrorMessage = isSubmitted ? errors.start_lng?.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">
|
||||
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
|
||||
@@ -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"
|
||||
@@ -102,7 +118,11 @@ export function ProjectDialog({
|
||||
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"
|
||||
@@ -113,7 +133,11 @@ export function ProjectDialog({
|
||||
{...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"
|
||||
@@ -133,7 +157,11 @@ export function ProjectDialog({
|
||||
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"
|
||||
@@ -144,7 +172,11 @@ export function ProjectDialog({
|
||||
{...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"
|
||||
|
||||
@@ -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),
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,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);
|
||||
@@ -60,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);
|
||||
|
||||
@@ -19,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);
|
||||
@@ -89,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>
|
||||
);
|
||||
@@ -101,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>
|
||||
@@ -116,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 && (
|
||||
|
||||
@@ -55,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';
|
||||
};
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ export function useRoleColumns(): ColumnDef<Role>[] {
|
||||
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>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -54,7 +54,9 @@ export function RoleSheet({
|
||||
isSaving,
|
||||
}: RoleSheetProps) {
|
||||
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
|
||||
const displayNameErrorMessage = isSubmitted ? errors.display_name?.message : undefined;
|
||||
const displayNameErrorMessage = isSubmitted
|
||||
? errors.display_name?.message
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
@@ -72,7 +74,12 @@ export function RoleSheet({
|
||||
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField id="role-name" label="Role Name" required error={nameErrorMessage}>
|
||||
<FormField
|
||||
id="role-name"
|
||||
label="Role Name"
|
||||
required
|
||||
error={nameErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="role-name"
|
||||
placeholder="Enter role name"
|
||||
@@ -110,7 +117,9 @@ export function RoleSheet({
|
||||
error={isSubmitted ? errors.permission_ids?.message : undefined}
|
||||
className="space-y-3"
|
||||
labelEnd={
|
||||
<span className="text-muted-foreground">{permissionIds.length} selected</span>
|
||||
<span className="text-muted-foreground">
|
||||
{permissionIds.length} selected
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{isPermissionsLoading ? (
|
||||
@@ -129,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>
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -23,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,
|
||||
@@ -66,7 +68,8 @@ export default function RolesPage() {
|
||||
isSaving,
|
||||
} = roleForm;
|
||||
const statusMutation = useRoleStatusMutation();
|
||||
const { mutate: updateRoleStatus, isPending: isStatusPending } = statusMutation;
|
||||
const { mutate: updateRoleStatus, isPending: isStatusPending } =
|
||||
statusMutation;
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
prepareCreateRole();
|
||||
|
||||
@@ -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() {
|
||||
@@ -43,9 +52,7 @@ function StateBadges({ value }: { value: string | null }) {
|
||||
</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-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}
|
||||
@@ -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} />;
|
||||
},
|
||||
@@ -127,7 +138,8 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
|
||||
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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -95,7 +95,11 @@ 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 ? (
|
||||
@@ -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}
|
||||
@@ -132,7 +145,11 @@ export function SegmentDialog({
|
||||
</span>
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={projectId ? 'Choose a package' : 'Select project first'}
|
||||
placeholder={
|
||||
projectId
|
||||
? 'Choose a package'
|
||||
: 'Select project first'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
@@ -144,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}
|
||||
@@ -218,7 +240,12 @@ export function SegmentDialog({
|
||||
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"
|
||||
@@ -230,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"
|
||||
@@ -251,7 +283,12 @@ export function SegmentDialog({
|
||||
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"
|
||||
@@ -263,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"
|
||||
|
||||
@@ -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(),
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -48,7 +48,10 @@ export default function SegmentPage() {
|
||||
canSubmit,
|
||||
isSaving,
|
||||
} = segmentForm;
|
||||
const projectPackagesQuery = usePackagesByProjectQuery(projectId, isDialogOpen);
|
||||
const projectPackagesQuery = usePackagesByProjectQuery(
|
||||
projectId,
|
||||
isDialogOpen,
|
||||
);
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
prepareCreateSegment();
|
||||
@@ -57,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);
|
||||
},
|
||||
@@ -76,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);
|
||||
|
||||
@@ -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>Basic Information</p>
|
||||
<p className="text-muted-foreground">Tenant identity and subscription binding.</p>
|
||||
<p className="text-muted-foreground">
|
||||
Tenant identity and subscription binding.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
@@ -106,10 +111,16 @@ 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'}
|
||||
placeholder={
|
||||
isLookupsLoading ? 'Loading clients...' : 'Select client'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -123,10 +134,16 @@ 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'}
|
||||
placeholder={
|
||||
isLookupsLoading ? 'Loading plans...' : 'Select plan'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -143,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>
|
||||
@@ -226,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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,7 +122,8 @@ export default function TenantsPage() {
|
||||
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(
|
||||
() => (
|
||||
@@ -167,7 +168,9 @@ export default function TenantsPage() {
|
||||
onSortingChange={setSorting}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
pendingDeleteId={deleteMutation.isPending ? deleteMutation.variables : undefined}
|
||||
pendingDeleteId={
|
||||
deleteMutation.isPending ? deleteMutation.variables : undefined
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ 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();
|
||||
@@ -101,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;
|
||||
}
|
||||
@@ -203,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>
|
||||
@@ -214,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
|
||||
@@ -233,7 +242,11 @@ 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>
|
||||
|
||||
@@ -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';
|
||||
@@ -54,12 +60,15 @@ export default function UploadPage() {
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleSelectionChange = useCallback((selectedSession: SessionContext | null) => {
|
||||
const handleSelectionChange = useCallback(
|
||||
(selectedSession: SessionContext | null) => {
|
||||
setSession(selectedSession);
|
||||
if (selectedSession) {
|
||||
sessionService.saveSession(selectedSession);
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) {
|
||||
@@ -103,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;
|
||||
}
|
||||
@@ -120,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 (
|
||||
@@ -140,7 +152,9 @@ 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>
|
||||
@@ -157,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
|
||||
@@ -170,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">
|
||||
@@ -191,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"
|
||||
@@ -210,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
|
||||
@@ -218,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>
|
||||
|
||||
@@ -23,8 +23,9 @@ export function useUserColumns(): ColumnDef<AdministrationUser>[] {
|
||||
header: 'Name',
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{[row.original.first_name, row.original.last_name].filter(Boolean).join(' ') ||
|
||||
row.original.username}
|
||||
{[row.original.first_name, row.original.last_name]
|
||||
.filter(Boolean)
|
||||
.join(' ') || row.original.username}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -64,7 +65,13 @@ export function useUserColumns(): ColumnDef<AdministrationUser>[] {
|
||||
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>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -46,10 +46,16 @@ export function UserSheet({
|
||||
isSaving,
|
||||
}: UserSheetProps) {
|
||||
const roleComboboxPortalRef = useRef<HTMLDivElement | null>(null);
|
||||
const firstNameErrorMessage = isSubmitted ? errors.first_name?.message : undefined;
|
||||
const lastNameErrorMessage = isSubmitted ? errors.last_name?.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 phoneErrorMessage = isSubmitted
|
||||
? errors.phone_number?.message
|
||||
: undefined;
|
||||
const roleErrorMessage = isSubmitted ? errors.role_id?.message : undefined;
|
||||
|
||||
return (
|
||||
@@ -63,7 +69,12 @@ export function UserSheet({
|
||||
|
||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField id="first-name" label="First Name" required error={firstNameErrorMessage}>
|
||||
<FormField
|
||||
id="first-name"
|
||||
label="First Name"
|
||||
required
|
||||
error={firstNameErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="first-name"
|
||||
placeholder="Enter first name"
|
||||
@@ -72,7 +83,12 @@ export function UserSheet({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField id="last-name" label="Last Name" required error={lastNameErrorMessage}>
|
||||
<FormField
|
||||
id="last-name"
|
||||
label="Last Name"
|
||||
required
|
||||
error={lastNameErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="last-name"
|
||||
placeholder="Enter last name"
|
||||
@@ -82,7 +98,12 @@ export function UserSheet({
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField id="email" label="Email" required error={emailErrorMessage}>
|
||||
<FormField
|
||||
id="email"
|
||||
label="Email"
|
||||
required
|
||||
error={emailErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
placeholder="name@example.com"
|
||||
@@ -91,7 +112,11 @@ export function UserSheet({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField id="phone-number" label="Phone Number" error={phoneErrorMessage}>
|
||||
<FormField
|
||||
id="phone-number"
|
||||
label="Phone Number"
|
||||
error={phoneErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="phone-number"
|
||||
placeholder="+919876543210"
|
||||
@@ -111,7 +136,12 @@ export function UserSheet({
|
||||
|
||||
<div ref={roleComboboxPortalRef} />
|
||||
|
||||
<input type="hidden" {...register('role_id')} value={roleId} readOnly />
|
||||
<input
|
||||
type="hidden"
|
||||
{...register('role_id')}
|
||||
value={roleId}
|
||||
readOnly
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<DialogFooter className="px-0">
|
||||
@@ -125,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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -57,10 +57,12 @@ export function UserTable({
|
||||
onClick: onEdit,
|
||||
},
|
||||
{
|
||||
label: (user) => (user.effective_status === 'active' ? 'Deactivate' : 'Activate'),
|
||||
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',
|
||||
disabled: (user) =>
|
||||
pendingUserId === user.id || user.effective_status === 'pending',
|
||||
onClick: onToggleStatus,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -63,7 +63,8 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) {
|
||||
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());
|
||||
phoneNumber.trim() === '' ||
|
||||
/^\+(?:[0-9] ?){6,14}[0-9]$/.test(phoneNumber.trim());
|
||||
const canSubmit =
|
||||
firstName.trim().length > 0 &&
|
||||
lastName.trim().length > 0 &&
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -17,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({
|
||||
@@ -28,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"
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.'}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
@@ -58,7 +66,7 @@ export default function DashboardMapContent({ detections }: DashboardMapContentP
|
||||
if (config) {
|
||||
return {
|
||||
fill: config.color,
|
||||
stroke: '#ffffff' // White stroke for better visibility on the map
|
||||
stroke: '#ffffff', // White stroke for better visibility on the map
|
||||
};
|
||||
}
|
||||
return { fill: '#64748b', stroke: '#475569' }; // Default Slate
|
||||
@@ -108,24 +116,40 @@ export default function DashboardMapContent({ detections }: DashboardMapContentP
|
||||
<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>
|
||||
|
||||
@@ -62,13 +62,18 @@ export function DashboardMap({
|
||||
|
||||
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'
|
||||
(d: any) =>
|
||||
(d.type || d.class || '').toLowerCase() !== 'good_sign_board',
|
||||
);
|
||||
filteredDetections.push(...chainageDetections);
|
||||
}
|
||||
@@ -108,7 +113,9 @@ export function DashboardMap({
|
||||
return (
|
||||
<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,7 +144,10 @@ 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 (
|
||||
@@ -143,16 +157,15 @@ 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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] }}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -5,7 +5,11 @@ 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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TableActionButtonProps {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
'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> }) => {
|
||||
@@ -23,7 +31,9 @@ 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 px-2 text-foreground transition-colors',
|
||||
canSort && 'cursor-pointer select-none hover:bg-muted/60',
|
||||
@@ -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}
|
||||
|
||||
@@ -67,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;
|
||||
@@ -95,7 +98,9 @@ export function DataTable<TData, TValue>({
|
||||
<div className="flex justify-end gap-2">
|
||||
{actions.map((action) => {
|
||||
const label =
|
||||
typeof action.label === 'function' ? action.label(item) : action.label;
|
||||
typeof action.label === 'function'
|
||||
? action.label(item)
|
||||
: action.label;
|
||||
|
||||
return (
|
||||
<TableActionButton
|
||||
@@ -131,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,
|
||||
@@ -191,17 +198,26 @@ export function DataTable<TData, TValue>({
|
||||
))
|
||||
) : table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -52,11 +52,15 @@ export function RoleCombobox({
|
||||
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];
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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='© <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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -11,11 +11,16 @@ 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 flex-col">
|
||||
<h1>{title}</h1>
|
||||
<p className="text-muted-foreground opacity-80">{description}</p>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { CheckboxTree } from '@/components/ui/tree';
|
||||
import type { PermissionTreeItem, PermissionTreeNode } from '@/types';
|
||||
|
||||
export function mapPermissionTree(nodes: PermissionTreeNode[]): PermissionTreeItem[] {
|
||||
export function mapPermissionTree(
|
||||
nodes: PermissionTreeNode[],
|
||||
): PermissionTreeItem[] {
|
||||
return nodes.map((node) => ({
|
||||
id: node.id,
|
||||
key: node.slug || node.name,
|
||||
@@ -15,14 +17,19 @@ export function mapPermissionTree(nodes: PermissionTreeNode[]): PermissionTreeIt
|
||||
}));
|
||||
}
|
||||
|
||||
export function collectDefaultPermissionIds(nodes: PermissionTreeItem[]): number[] {
|
||||
export function collectDefaultPermissionIds(
|
||||
nodes: PermissionTreeItem[],
|
||||
): number[] {
|
||||
return nodes.flatMap((node) => [
|
||||
...(node.isGrantedByDefault ? [node.id] : []),
|
||||
...collectDefaultPermissionIds(node.children),
|
||||
]);
|
||||
}
|
||||
|
||||
export function collectPermissionIdsByKeys(nodes: PermissionTreeItem[], keys: string[]): number[] {
|
||||
export function collectPermissionIdsByKeys(
|
||||
nodes: PermissionTreeItem[],
|
||||
keys: string[],
|
||||
): number[] {
|
||||
const selectedKeys = new Set(keys);
|
||||
return nodes.flatMap((node) => [
|
||||
...(selectedKeys.has(node.key) ? [node.id] : []),
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
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 { Label } from '@/components/ui/label';
|
||||
import {
|
||||
@@ -12,8 +18,17 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Loader2, Check, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { projectService, packageService, chainageService } from '@/services/api';
|
||||
import { Project, Package as PackageType, Chainage, SessionContext } from '@/types';
|
||||
import {
|
||||
projectService,
|
||||
packageService,
|
||||
chainageService,
|
||||
} from '@/services/api';
|
||||
import {
|
||||
Project,
|
||||
Package as PackageType,
|
||||
Chainage,
|
||||
SessionContext,
|
||||
} from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ProjectSelectionSectionProps = {
|
||||
@@ -30,17 +45,23 @@ export function ProjectSelectionSection({
|
||||
onSelectionComplete,
|
||||
onSelectionChange,
|
||||
asStep = false,
|
||||
hideButton = false
|
||||
hideButton = false,
|
||||
}: ProjectSelectionSectionProps) {
|
||||
// Data states
|
||||
const [projects, setProjects] = useState<Project[]>(globalProjectsCache || []);
|
||||
const [projects, setProjects] = useState<Project[]>(
|
||||
globalProjectsCache || [],
|
||||
);
|
||||
const [packages, setPackages] = useState<PackageType[]>([]);
|
||||
const [chainages, setChainages] = useState<Chainage[]>([]);
|
||||
|
||||
// Selection states
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [selectedPackage, setSelectedPackage] = useState<PackageType | null>(null);
|
||||
const [selectedChainage, setSelectedChainage] = useState<Chainage | null>(null);
|
||||
const [selectedPackage, setSelectedPackage] = useState<PackageType | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedChainage, setSelectedChainage] = useState<Chainage | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Loading states
|
||||
const [loadingProjects, setLoadingProjects] = useState(!globalProjectsCache);
|
||||
@@ -66,10 +87,13 @@ export function ProjectSelectionSection({
|
||||
|
||||
// If a fetch is already in flight, reuse that promise
|
||||
if (!projectsPromise) {
|
||||
projectsPromise = projectService.getProjects().then(data => {
|
||||
projectsPromise = projectService
|
||||
.getProjects()
|
||||
.then((data) => {
|
||||
globalProjectsCache = data.items;
|
||||
return data.items;
|
||||
}).catch(err => {
|
||||
})
|
||||
.catch((err) => {
|
||||
projectsPromise = null; // Reset on error to allow retry
|
||||
throw err;
|
||||
});
|
||||
@@ -93,7 +117,9 @@ export function ProjectSelectionSection({
|
||||
};
|
||||
|
||||
loadProjects();
|
||||
return () => { isMounted = false; };
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load packages when project changes
|
||||
@@ -112,7 +138,9 @@ export function ProjectSelectionSection({
|
||||
setSelectedPackage(null);
|
||||
setSelectedChainage(null);
|
||||
setChainages([]);
|
||||
const data = await packageService.getPackagesByProject(selectedProject.id);
|
||||
const data = await packageService.getPackagesByProject(
|
||||
selectedProject.id,
|
||||
);
|
||||
if (isMounted) {
|
||||
setPackages(data.items);
|
||||
}
|
||||
@@ -128,7 +156,9 @@ export function ProjectSelectionSection({
|
||||
}
|
||||
};
|
||||
loadPackages();
|
||||
return () => { isMounted = false; };
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [selectedProject]);
|
||||
|
||||
// Load chainages when package changes
|
||||
@@ -145,7 +175,9 @@ export function ProjectSelectionSection({
|
||||
setLoadingChainages(true);
|
||||
setError(null);
|
||||
setSelectedChainage(null);
|
||||
const data = await chainageService.getChainagesByPackage(selectedPackage.id);
|
||||
const data = await chainageService.getChainagesByPackage(
|
||||
selectedPackage.id,
|
||||
);
|
||||
if (isMounted) {
|
||||
setChainages(data.items);
|
||||
}
|
||||
@@ -161,7 +193,9 @@ export function ProjectSelectionSection({
|
||||
}
|
||||
};
|
||||
loadChainages();
|
||||
return () => { isMounted = false; };
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [selectedPackage]);
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
@@ -185,7 +219,8 @@ export function ProjectSelectionSection({
|
||||
// Handle change reporting
|
||||
useEffect(() => {
|
||||
if (onSelectionChange) {
|
||||
const currentIds = selectedProject && selectedPackage && selectedChainage
|
||||
const currentIds =
|
||||
selectedProject && selectedPackage && selectedChainage
|
||||
? `${selectedProject.id}-${selectedPackage.id}-${selectedChainage.id}`
|
||||
: null;
|
||||
|
||||
@@ -210,7 +245,12 @@ export function ProjectSelectionSection({
|
||||
}, [selectedProject, selectedPackage, selectedChainage, onSelectionChange]);
|
||||
|
||||
const handleProceed = () => {
|
||||
if (selectedProject && selectedPackage && selectedChainage && onSelectionComplete) {
|
||||
if (
|
||||
selectedProject &&
|
||||
selectedPackage &&
|
||||
selectedChainage &&
|
||||
onSelectionComplete
|
||||
) {
|
||||
onSelectionComplete({
|
||||
projectId: selectedProject.id,
|
||||
projectName: selectedProject.name,
|
||||
@@ -228,8 +268,18 @@ export function ProjectSelectionSection({
|
||||
// Step status helpers
|
||||
const getStepStatus = (step: number) => {
|
||||
if (step === 1) return selectedProject ? 'completed' : 'active';
|
||||
if (step === 2) return selectedPackage ? 'completed' : selectedProject ? 'active' : 'pending';
|
||||
if (step === 3) return selectedChainage ? 'completed' : selectedPackage ? 'active' : 'pending';
|
||||
if (step === 2)
|
||||
return selectedPackage
|
||||
? 'completed'
|
||||
: selectedProject
|
||||
? 'active'
|
||||
: 'pending';
|
||||
if (step === 3)
|
||||
return selectedChainage
|
||||
? 'completed'
|
||||
: selectedPackage
|
||||
? 'active'
|
||||
: 'pending';
|
||||
return 'pending';
|
||||
};
|
||||
|
||||
@@ -291,7 +341,9 @@ export function ProjectSelectionSection({
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={selectedProject ? 'Select package' : 'Select project first'}
|
||||
placeholder={
|
||||
selectedProject ? 'Select package' : 'Select project first'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
@@ -323,7 +375,9 @@ export function ProjectSelectionSection({
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={selectedPackage ? 'Select segment' : 'Select package first'}
|
||||
placeholder={
|
||||
selectedPackage ? 'Select segment' : 'Select package first'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
@@ -331,9 +385,13 @@ export function ProjectSelectionSection({
|
||||
{chainages.map((chn) => (
|
||||
<SelectItem key={chn.id} value={chn.id}>
|
||||
<div className="flex flex-col gap-0.5 py-0.5">
|
||||
<span className="font-semibold text-sm">{chn.segment_name}</span>
|
||||
<span className="font-semibold text-sm">
|
||||
{chn.segment_name}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground font-medium">
|
||||
<span>{chn.chainage_start_km}-{chn.chainage_end_km} km</span>
|
||||
<span>
|
||||
{chn.chainage_start_km}-{chn.chainage_end_km} km
|
||||
</span>
|
||||
<span className="h-2.5 w-px bg-border" />
|
||||
<span className="flex items-center gap-1 uppercase">
|
||||
{chn.direction === 'UP' ? (
|
||||
@@ -399,9 +457,12 @@ export function ProjectSelectionSection({
|
||||
<CardHeader className="pb-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<CardTitle className="text-2xl font-bold">Select Project Segment</CardTitle>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
Select Project Segment
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-2 text-base">
|
||||
Select Project, Package & Segment to begin intelligent road analysis.
|
||||
Select Project, Package & Segment to begin intelligent road
|
||||
analysis.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
@@ -423,12 +484,18 @@ export function ProjectSelectionSection({
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{status === 'completed' ? <Check className="h-5 w-5" /> : step}
|
||||
{status === 'completed' ? (
|
||||
<Check className="h-5 w-5" />
|
||||
) : (
|
||||
step
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs mt-2 font-medium',
|
||||
status === 'pending' ? 'text-muted-foreground/60' : 'text-foreground',
|
||||
status === 'pending'
|
||||
? 'text-muted-foreground/60'
|
||||
: 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{labels[index]}
|
||||
@@ -438,7 +505,9 @@ export function ProjectSelectionSection({
|
||||
<div
|
||||
className={cn(
|
||||
'w-16 h-0.5 mx-2 mb-6 rounded-full',
|
||||
getStepStatus(step + 1) !== 'pending' ? 'bg-primary' : 'bg-border',
|
||||
getStepStatus(step + 1) !== 'pending'
|
||||
? 'bg-primary'
|
||||
: 'bg-border',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@@ -449,9 +518,7 @@ export function ProjectSelectionSection({
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
{content}
|
||||
</CardContent>
|
||||
<CardContent className="pt-0">{content}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,17 +5,26 @@ import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
|
||||
className={cn(
|
||||
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
@@ -32,7 +41,10 @@ function AvatarFallback({
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn('bg-muted flex size-full items-center justify-center rounded-full', className)}
|
||||
className={cn(
|
||||
'bg-muted flex size-full items-center justify-center rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,8 @@ const badgeVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white dark:bg-destructive/60 [a&]:hover:bg-destructive/90',
|
||||
outline:
|
||||
@@ -30,7 +31,8 @@ function Badge({
|
||||
variant = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
}: React.ComponentProps<'span'> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : 'span';
|
||||
|
||||
return (
|
||||
|
||||
@@ -62,7 +62,11 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<'li'>) {
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
@@ -76,7 +80,10 @@ function BreadcrumbSeparator({ children, className, ...props }: React.ComponentP
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
|
||||
@@ -10,11 +10,14 @@ const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground focus-visible:border-ring dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
|
||||
@@ -52,14 +52,23 @@ function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
|
||||
className={cn(
|
||||
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn('px-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
@@ -72,4 +81,12 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
|
||||
@@ -42,7 +42,9 @@ function ChartContainer({
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>['children'];
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
|
||||
@@ -59,14 +61,18 @@ function ChartContainer({
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
@@ -81,7 +87,9 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join('\n')}
|
||||
@@ -135,7 +143,9 @@ function ChartTooltipContent({
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>
|
||||
<div className={cn('font-medium', labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,7 +154,15 @@ function ChartTooltipContent({
|
||||
}
|
||||
|
||||
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
|
||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
@@ -287,29 +305,42 @@ function ChartLegendContent({
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
|
||||
'payload' in payload &&
|
||||
typeof payload.payload === 'object' &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === 'string'
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
||||
) {
|
||||
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -2,20 +2,32 @@
|
||||
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||
|
||||
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />;
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />;
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
|
||||
@@ -31,7 +31,9 @@ function ComboboxTrigger({
|
||||
className="pointer-events-none size-4 text-muted-foreground"
|
||||
/>
|
||||
);
|
||||
const triggerRender = React.isValidElement<{ children?: React.ReactNode }>(render)
|
||||
const triggerRender = React.isValidElement<{ children?: React.ReactNode }>(
|
||||
render,
|
||||
)
|
||||
? React.cloneElement(render, {
|
||||
children: (
|
||||
<>
|
||||
@@ -81,7 +83,10 @@ function ComboboxInput({
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn('w-auto', className)}>
|
||||
<ComboboxPrimitive.Input render={<InputGroupInput disabled={disabled} />} {...props} />
|
||||
<ComboboxPrimitive.Input
|
||||
render={<InputGroupInput disabled={disabled} />}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<InputGroupButton
|
||||
@@ -155,7 +160,11 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.Props) {
|
||||
function ComboboxItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
@@ -180,11 +189,18 @@ function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Group data-slot="combobox-group" className={cn(className)} {...props} />
|
||||
<ComboboxPrimitive.Group
|
||||
data-slot="combobox-group"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
|
||||
function ComboboxLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
@@ -198,7 +214,9 @@ function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Pro
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />;
|
||||
return (
|
||||
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
@@ -214,7 +232,10 @@ function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) {
|
||||
function ComboboxSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
@@ -227,7 +248,8 @@ function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> & ComboboxPrimitive.Chips.Props) {
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||
ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
@@ -271,7 +293,11 @@ function ComboboxChip({
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({ className, children, ...props }: ComboboxPrimitive.Input.Props) {
|
||||
function ComboboxChipsInput({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
|
||||
@@ -7,19 +7,27 @@ import { Dialog as DialogPrimitive } from 'radix-ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
@@ -99,7 +107,10 @@ function DialogFooter({
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
className={cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -112,8 +123,17 @@ function DialogFooter({
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return <DialogPrimitive.Title data-slot="dialog-title" className={cn(className)} {...props} />;
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
import * as React from 'react';
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
@@ -17,7 +17,7 @@ function DropdownMenuPortal({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
@@ -28,7 +28,7 @@ function DropdownMenuTrigger({
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
@@ -42,13 +42,13 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
@@ -56,17 +56,17 @@ function DropdownMenuGroup({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
@@ -75,11 +75,11 @@ function DropdownMenuItem({
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
@@ -93,7 +93,7 @@ function DropdownMenuCheckboxItem({
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
@@ -105,7 +105,7 @@ function DropdownMenuCheckboxItem({
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
@@ -116,7 +116,7 @@ function DropdownMenuRadioGroup({
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
@@ -129,7 +129,7 @@ function DropdownMenuRadioItem({
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -140,7 +140,7 @@ function DropdownMenuRadioItem({
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
@@ -148,19 +148,19 @@ function DropdownMenuLabel({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
@@ -170,32 +170,32 @@ function DropdownMenuSeparator({
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
@@ -204,7 +204,7 @@ function DropdownMenuSubTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
@@ -212,14 +212,14 @@ function DropdownMenuSubTrigger({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
@@ -230,12 +230,12 @@ function DropdownMenuSubContent({
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
'z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -254,4 +254,4 @@ export {
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -41,8 +41,10 @@ const inputGroupAddonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
|
||||
'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
|
||||
'inline-start':
|
||||
'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
|
||||
'inline-end':
|
||||
'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
|
||||
'block-start':
|
||||
'order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3',
|
||||
'block-end':
|
||||
@@ -77,19 +79,23 @@ function InputGroupAddon({
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva('flex items-center gap-2 text-sm shadow-none', {
|
||||
const inputGroupButtonVariants = cva(
|
||||
'flex items-center gap-2 text-sm shadow-none',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5',
|
||||
'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
|
||||
'icon-xs':
|
||||
'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
|
||||
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'xs',
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
@@ -122,7 +128,10 @@ function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
@@ -135,7 +144,10 @@ function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>)
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
|
||||
@@ -5,7 +5,10 @@ import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
@@ -16,7 +20,10 @@ function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'ul'>) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
@@ -35,7 +42,12 @@ type PaginationLinkProps = {
|
||||
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
|
||||
React.ComponentProps<'a'>;
|
||||
|
||||
function PaginationLink({ className, isActive, size = 'icon', ...props }: PaginationLinkProps) {
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = 'icon',
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
@@ -53,7 +65,10 @@ function PaginationLink({ className, isActive, size = 'icon', ...props }: Pagina
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
@@ -67,7 +82,10 @@ function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
@@ -81,7 +99,10 @@ function PaginationNext({ className, ...props }: React.ComponentProps<typeof Pag
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
|
||||
@@ -5,11 +5,15 @@ import { Popover as PopoverPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
@@ -35,7 +39,9 @@ function PopoverContent({
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
@@ -50,10 +56,19 @@ function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) {
|
||||
return <div data-slot="popover-title" className={cn('font-medium', className)} {...props} />;
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
className={cn('font-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
|
||||
@@ -13,7 +13,10 @@ function Progress({
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', className)}
|
||||
className={cn(
|
||||
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user