chore: update eslint and prettier configuration
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"dual-graph": {
|
"dual-graph": {
|
||||||
"url": "http://127.0.0.1:8080/mcp"
|
"url": "http://127.0.0.1:8080/mcp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
#!/usr/bin/env sh
|
||||||
# . "$(dirname "$0")/_/husky.sh"
|
# npm run lint
|
||||||
|
|
||||||
# npx lint-staged
|
|
||||||
|
|||||||
11
.prettierrc
11
.prettierrc
@@ -1,9 +1,12 @@
|
|||||||
{
|
{
|
||||||
|
"semi": true,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
"printWidth": 100,
|
"printWidth": 80,
|
||||||
"useTabs": false,
|
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"semi": true,
|
"useTabs": false,
|
||||||
"endOfLine": "auto"
|
"endOfLine": "auto",
|
||||||
|
"arrowParens": "always",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"jsxSingleQuote": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,8 @@
|
|||||||
import nextConfig from 'eslint-config-next';
|
import nextConfig from 'eslint-config-next';
|
||||||
import prettierPlugin from 'eslint-plugin-prettier';
|
import prettierPlugin from 'eslint-plugin-prettier';
|
||||||
|
|
||||||
const prettierRules = {
|
|
||||||
'prettier/prettier': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
singleQuote: true,
|
|
||||||
trailingComma: 'all',
|
|
||||||
printWidth: 100,
|
|
||||||
endOfLine: 'auto',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const additionalRules = {
|
const additionalRules = {
|
||||||
...prettierRules,
|
'prettier/prettier': 'error',
|
||||||
// Some React rules are intentionally relaxed for this project.
|
|
||||||
'react-hooks/set-state-in-effect': 'off',
|
'react-hooks/set-state-in-effect': 'off',
|
||||||
'react-hooks/exhaustive-deps': 'warn',
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
'react-hooks/immutability': 'off',
|
'react-hooks/immutability': 'off',
|
||||||
@@ -24,7 +11,17 @@ const additionalRules = {
|
|||||||
'import/no-anonymous-default-export': 'off',
|
'import/no-anonymous-default-export': 'off',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default [
|
const eslintConfig = [
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'.next/**',
|
||||||
|
'node_modules/**',
|
||||||
|
'dist/**',
|
||||||
|
'build/**',
|
||||||
|
'coverage/**',
|
||||||
|
'public/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
...nextConfig,
|
...nextConfig,
|
||||||
{
|
{
|
||||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||||
@@ -34,3 +31,5 @@ export default [
|
|||||||
rules: additionalRules,
|
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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// 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",
|
"autoprefixer": "^10.4.20",
|
||||||
"baseline-browser-mapping": "^2.10.37",
|
"baseline-browser-mapping": "^2.10.37",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.1.7",
|
"eslint-config-next": "^16.0.10",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.5",
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.4.0",
|
|
||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"tailwindcss": "^4.1.9",
|
"tailwindcss": "^4.1.9",
|
||||||
@@ -1207,9 +1206,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
"version": "16.1.7",
|
"version": "16.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.10.tgz",
|
||||||
"integrity": "sha512-v/bRGOJlfRCO+NDKt0bZlIIWjhMKU8xbgEQBo+rV9C8S6czZvs96LZ/v24/GvpEnovZlL4QDpku/RzWHVbmPpA==",
|
"integrity": "sha512-b2NlWN70bbPLmfyoLvvidPKWENBYYIe017ZGUpElvQjDytCWgxPJx7L9juxHt0xHvNVA08ZHJdOyhGzon/KJuw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4032,35 +4031,6 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"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": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
@@ -4559,39 +4529,6 @@
|
|||||||
"url": "https://polar.sh/cva"
|
"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": {
|
"node_modules/client-only": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
@@ -4627,13 +4564,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -4646,16 +4576,6 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -5030,19 +4950,6 @@
|
|||||||
"node": ">=10.13.0"
|
"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": {
|
"node_modules/es-abstract": {
|
||||||
"version": "1.24.1",
|
"version": "1.24.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
|
||||||
@@ -5301,13 +5208,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-config-next": {
|
"node_modules/eslint-config-next": {
|
||||||
"version": "16.1.7",
|
"version": "16.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.10.tgz",
|
||||||
"integrity": "sha512-FTq1i/QDltzq+zf9aB/cKWAiZ77baG0V7h8dRQh3thVx7I4dwr6ZXQrWKAaTB7x5VwVXlzoUTyMLIVQPLj2gJg==",
|
"integrity": "sha512-BxouZUm0I45K4yjOOIzj24nTi0H2cGo0y7xUmk+Po/PYtJXFBYVDS1BguE7t28efXjKdcN0tmiLivxQy//SsZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"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-node": "^0.3.6",
|
||||||
"eslint-import-resolver-typescript": "^3.5.2",
|
"eslint-import-resolver-typescript": "^3.5.2",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
@@ -6063,19 +5970,6 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -6571,22 +6465,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/is-generator-function": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||||
@@ -7253,68 +7131,6 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"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": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -7344,56 +7160,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -7489,19 +7255,6 @@
|
|||||||
"node": ">= 0.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": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@@ -7878,22 +7631,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -8009,9 +7746,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -8517,23 +8254,6 @@
|
|||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"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": {
|
"node_modules/reusify": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||||
@@ -8545,13 +8265,6 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
@@ -8843,49 +8556,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/sonner": {
|
||||||
"version": "1.7.4",
|
"version": "1.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||||
@@ -8926,33 +8596,6 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/string.prototype.includes": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
"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"
|
"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": {
|
"node_modules/strip-bom": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
"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==",
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -9753,62 +9370,6 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
@@ -9816,22 +9377,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"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",
|
"prepare": "husky install",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
|
||||||
"*.+(js|jsx|ts|tsx)": [
|
|
||||||
"npm run lint:fix"
|
|
||||||
],
|
|
||||||
"*.+(json|md|css|scss|html)": [
|
|
||||||
"npm run format"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.5.0",
|
"@base-ui/react": "^1.5.0",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
@@ -63,11 +55,10 @@
|
|||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"baseline-browser-mapping": "^2.10.37",
|
"baseline-browser-mapping": "^2.10.37",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.1.7",
|
"eslint-config-next": "^16.0.10",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.5",
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.4.0",
|
|
||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"tailwindcss": "^4.1.9",
|
"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">
|
<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">
|
<section className="w-full max-w-sm space-y-7">
|
||||||
<header className="space-y-2 text-center md:text-left">
|
<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>
|
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Use your email and password to continue.
|
Use your email and password to continue.
|
||||||
@@ -39,7 +41,9 @@ export default function LoginPage() {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.email && (
|
||||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
<p className="text-sm text-destructive">
|
||||||
|
{errors.email.message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,7 +61,9 @@ export default function LoginPage() {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
{errors.password && (
|
{errors.password && (
|
||||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
<p className="text-sm text-destructive">
|
||||||
|
{errors.password.message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export default function AccessPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold">Access denied</h1>
|
<h1 className="text-2xl font-semibold">Access denied</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
You do not have permission to access any available module. Contact your administrator if
|
You do not have permission to access any available module. Contact
|
||||||
you need access.
|
your administrator if you need access.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ export function useClientColumns(): ColumnDef<Client>[] {
|
|||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Client',
|
header: 'Client',
|
||||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium">{row.original.name}</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'email',
|
accessorKey: 'email',
|
||||||
@@ -33,7 +35,9 @@ export function useClientColumns(): ColumnDef<Client>[] {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{row.original.contact_name}</p>
|
<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>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,8 +38,12 @@ export function ClientSheet({
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
|
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{clientId ? 'Edit Client' : 'Create Client'}</DialogTitle>
|
<DialogTitle>
|
||||||
<DialogDescription>Manage company and primary contact details.</DialogDescription>
|
{clientId ? 'Edit Client' : 'Create Client'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Manage company and primary contact details.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<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="grid gap-4 md:grid-cols-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="gst">GST</Label>
|
<Label htmlFor="gst">GST</Label>
|
||||||
<Input id="gst" placeholder="27ABCDE1234F1Z5" {...register('gst')} />
|
<Input
|
||||||
|
id="gst"
|
||||||
|
placeholder="27ABCDE1234F1Z5"
|
||||||
|
{...register('gst')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="pan">PAN</Label>
|
<Label htmlFor="pan">PAN</Label>
|
||||||
@@ -141,7 +149,9 @@ export function ClientSheet({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSaving}>
|
<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'}
|
{clientId ? 'Update Client' : 'Create Client'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export function useClientFilters() {
|
|||||||
const [limit, setLimitValue] = useState(10);
|
const [limit, setLimitValue] = useState(10);
|
||||||
const [sorting, setSortingValue] = useState<SortingState>([]);
|
const [sorting, setSortingValue] = useState<SortingState>([]);
|
||||||
const [searchTerm, setSearchTermValue] = useState('');
|
const [searchTerm, setSearchTermValue] = useState('');
|
||||||
const [statusFilter, setStatusFilterValue] = useState<ClientStatusFilter>('all');
|
const [statusFilter, setStatusFilterValue] =
|
||||||
|
useState<ClientStatusFilter>('all');
|
||||||
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
|
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
|
||||||
|
|
||||||
const setSearchTerm = useCallback((value: string) => {
|
const setSearchTerm = useCallback((value: string) => {
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
|
|||||||
queryClient.invalidateQueries({ queryKey: clientKeys.all });
|
queryClient.invalidateQueries({ queryKey: clientKeys.all });
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
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() {
|
export function useClientStatusMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (client: Client) => clientService.updateClientStatus(client.id, !client.is_active),
|
mutationFn: (client: Client) =>
|
||||||
|
clientService.updateClientStatus(client.id, !client.is_active),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Client status updated');
|
toast.success('Client status updated');
|
||||||
queryClient.invalidateQueries({ queryKey: clientKeys.all });
|
queryClient.invalidateQueries({ queryKey: clientKeys.all });
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ function buildClientListParams({
|
|||||||
export function useClientsQuery(params: UseClientsQueryParams) {
|
export function useClientsQuery(params: UseClientsQueryParams) {
|
||||||
const { skip, limit, searchTerm, statusFilter, sorting } = params;
|
const { skip, limit, searchTerm, statusFilter, sorting } = params;
|
||||||
const listParams = useMemo(
|
const listParams = useMemo(
|
||||||
() => buildClientListParams({ skip, limit, searchTerm, statusFilter, sorting }),
|
() =>
|
||||||
|
buildClientListParams({ skip, limit, searchTerm, statusFilter, sorting }),
|
||||||
[limit, searchTerm, skip, sorting, statusFilter],
|
[limit, searchTerm, skip, sorting, statusFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ export default function ClientsPage() {
|
|||||||
const clientForm = useClientForm({
|
const clientForm = useClientForm({
|
||||||
onSaved: () => setIsSheetOpen(false),
|
onSaved: () => setIsSheetOpen(false),
|
||||||
});
|
});
|
||||||
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } = clientForm;
|
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } =
|
||||||
|
clientForm;
|
||||||
const { register, handleSubmit, clientId, isSaving } = clientForm;
|
const { register, handleSubmit, clientId, isSaving } = clientForm;
|
||||||
const statusMutation = useClientStatusMutation();
|
const statusMutation = useClientStatusMutation();
|
||||||
const { mutate: updateClientStatus, pendingClientId } = statusMutation;
|
const { mutate: updateClientStatus, pendingClientId } = statusMutation;
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ import { DashboardMap } from '@/components/dashboard/dashboard-map';
|
|||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { projectService, projectSummaryService } from '@/services/api';
|
import { projectService, projectSummaryService } from '@/services/api';
|
||||||
import { Project } from '@/types';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton';
|
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -160,7 +164,8 @@ function calculateStats(summary: ProjectSummary | null): DetectionStats {
|
|||||||
chnDrainIssue > 0 ||
|
chnDrainIssue > 0 ||
|
||||||
chnDefectiveCulvert > 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({
|
chainageData.push({
|
||||||
name: shortName,
|
name: shortName,
|
||||||
defected_sign_board: chnDefectedSignboard,
|
defected_sign_board: chnDefectedSignboard,
|
||||||
@@ -207,8 +212,12 @@ function calculateStats(summary: ProjectSummary | null): DetectionStats {
|
|||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||||
const [projectSummary, setProjectSummary] = useState<ProjectSummary | null>(null);
|
null,
|
||||||
|
);
|
||||||
|
const [projectSummary, setProjectSummary] = useState<ProjectSummary | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [stats, setStats] = useState<DetectionStats>({
|
const [stats, setStats] = useState<DetectionStats>({
|
||||||
totalDefectedSignboard: 0,
|
totalDefectedSignboard: 0,
|
||||||
totalPothole: 0,
|
totalPothole: 0,
|
||||||
@@ -230,7 +239,9 @@ export default function DashboardPage() {
|
|||||||
setProjects(projectsData.items);
|
setProjects(projectsData.items);
|
||||||
|
|
||||||
if (projectsData.items.length > 0) {
|
if (projectsData.items.length > 0) {
|
||||||
setSelectedProjectId(projectsData.items[projectsData.items.length - 1].id);
|
setSelectedProjectId(
|
||||||
|
projectsData.items[projectsData.items.length - 1].id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load projects:', err);
|
console.error('Failed to load projects:', err);
|
||||||
@@ -258,12 +269,18 @@ export default function DashboardPage() {
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Extract chainages from selected package
|
// Extract chainages from selected package
|
||||||
const [selectedPackageId, setSelectedPackageId] = useState<string | null>(null);
|
const [selectedPackageId, setSelectedPackageId] = useState<string | null>(
|
||||||
const [selectedChainageId, setSelectedChainageId] = useState<string | null>(null); // Changed 'selectedLocationId' to 'selectedChainageId'
|
null,
|
||||||
|
);
|
||||||
|
const [selectedChainageId, setSelectedChainageId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
); // Changed 'selectedLocationId' to 'selectedChainageId'
|
||||||
|
|
||||||
const chainages =
|
const chainages =
|
||||||
projectSummary && selectedPackageId && selectedPackageId !== 'all'
|
projectSummary && selectedPackageId && selectedPackageId !== 'all'
|
||||||
? Object.keys(projectSummary.packages[selectedPackageId]?.chainages || {}).map((chnName) => ({
|
? Object.keys(
|
||||||
|
projectSummary.packages[selectedPackageId]?.chainages || {},
|
||||||
|
).map((chnName) => ({
|
||||||
id: chnName,
|
id: chnName,
|
||||||
name: chnName,
|
name: chnName,
|
||||||
}))
|
}))
|
||||||
@@ -281,9 +298,10 @@ export default function DashboardPage() {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const summary = await projectSummaryService.getProjectSummary<ProjectSummary>(
|
const summary =
|
||||||
selectedProjectId,
|
await projectSummaryService.getProjectSummary<ProjectSummary>(
|
||||||
);
|
selectedProjectId,
|
||||||
|
);
|
||||||
setProjectSummary(summary);
|
setProjectSummary(summary);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load project summary:', err);
|
console.error('Failed to load project summary:', err);
|
||||||
@@ -322,8 +340,15 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
// Auto-select last chainage when package is selected
|
// Auto-select last chainage when package is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectSummary && selectedPackageId && selectedPackageId !== 'all' && selectedChainageId === null) {
|
if (
|
||||||
const chainageIds = Object.keys(projectSummary.packages[selectedPackageId]?.chainages || {});
|
projectSummary &&
|
||||||
|
selectedPackageId &&
|
||||||
|
selectedPackageId !== 'all' &&
|
||||||
|
selectedChainageId === null
|
||||||
|
) {
|
||||||
|
const chainageIds = Object.keys(
|
||||||
|
projectSummary.packages[selectedPackageId]?.chainages || {},
|
||||||
|
);
|
||||||
if (chainageIds.length > 0) {
|
if (chainageIds.length > 0) {
|
||||||
setSelectedChainageId(chainageIds[chainageIds.length - 1]);
|
setSelectedChainageId(chainageIds[chainageIds.length - 1]);
|
||||||
}
|
}
|
||||||
@@ -415,7 +440,8 @@ export default function DashboardPage() {
|
|||||||
chnDrainIssue > 0 ||
|
chnDrainIssue > 0 ||
|
||||||
chnDefectiveCulvert > 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({
|
chainageData.push({
|
||||||
name: shortName,
|
name: shortName,
|
||||||
defected_sign_board: chnDefectedSignboard,
|
defected_sign_board: chnDefectedSignboard,
|
||||||
@@ -478,7 +504,10 @@ export default function DashboardPage() {
|
|||||||
<span>Filters</span>
|
<span>Filters</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</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">
|
<div className="p-4 border-b bg-muted/30">
|
||||||
<h3 className="font-bold text-sm flex items-center gap-2 text-foreground">
|
<h3 className="font-bold text-sm flex items-center gap-2 text-foreground">
|
||||||
<Milestone className="h-4 w-4 text-primary" />
|
<Milestone className="h-4 w-4 text-primary" />
|
||||||
@@ -577,7 +606,9 @@ export default function DashboardPage() {
|
|||||||
<Card className="flex-1 h-full">
|
<Card className="flex-1 h-full">
|
||||||
<CardHeader className="items-center pb-0">
|
<CardHeader className="items-center pb-0">
|
||||||
<CardTitle>Detection Distribution</CardTitle>
|
<CardTitle>Detection Distribution</CardTitle>
|
||||||
<CardDescription>Breakdown of all detected road conditions</CardDescription>
|
<CardDescription>
|
||||||
|
Breakdown of all detected road conditions
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 pb-0">
|
<CardContent className="flex-1 pb-0">
|
||||||
<DetectionDonutChart
|
<DetectionDonutChart
|
||||||
@@ -605,13 +636,18 @@ export default function DashboardPage() {
|
|||||||
<Reveal delay={0.3} direction="right" className="flex flex-col">
|
<Reveal delay={0.3} direction="right" className="flex flex-col">
|
||||||
<Card className="flex-1 h-full">
|
<Card className="flex-1 h-full">
|
||||||
<CardHeader className="items-center pb-0">
|
<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">
|
<CardDescription className="text-xs">
|
||||||
Detections grouped by road segment
|
Detections grouped by road segment
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="h-[300px]">
|
<CardContent className="h-[300px]">
|
||||||
<ChainageBarChart data={stats.chainageData || []} isLoading={isLoading} />
|
<ChainageBarChart
|
||||||
|
data={stats.chainageData || []}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex-col gap-2 text-sm">
|
<CardFooter className="flex-col gap-2 text-sm">
|
||||||
<div className="leading-none text-muted-foreground">
|
<div className="leading-none text-muted-foreground">
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { useMemo } from 'react';
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
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';
|
import type { Package, Project } from '@/types';
|
||||||
|
|
||||||
function EmptyValue() {
|
function EmptyValue() {
|
||||||
@@ -42,9 +46,7 @@ function StateBadges({ value }: { value: string | null }) {
|
|||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-2" align="start">
|
<PopoverContent className="w-auto p-2" align="start">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="mb-0.5 px-1 text-muted-foreground">
|
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
|
||||||
Other States
|
|
||||||
</p>
|
|
||||||
{remainingStates.map((state) => (
|
{remainingStates.map((state) => (
|
||||||
<Badge key={state} variant="secondary">
|
<Badge key={state} variant="secondary">
|
||||||
{state}
|
{state}
|
||||||
@@ -70,14 +72,16 @@ export function usePackageColumns(projects: Project[]) {
|
|||||||
accessorKey: 'project_id',
|
accessorKey: 'project_id',
|
||||||
header: 'Project',
|
header: 'Project',
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
projects.find((project) => project.id === row.original.project_id)?.name ||
|
projects.find((project) => project.id === row.original.project_id)
|
||||||
row.original.project_id,
|
?.name || row.original.project_id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'project_state',
|
id: 'project_state',
|
||||||
header: 'Project State',
|
header: 'Project State',
|
||||||
cell: ({ row }) => {
|
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} />;
|
return <StateBadges value={project?.state ?? null} />;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,14 +57,23 @@ export function PackageDialog({
|
|||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: PackageDialogProps) {
|
}: 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 nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
|
||||||
const startErrorMessage = isSubmitted ? errors.chainage_start_km?.message : undefined;
|
const startErrorMessage = isSubmitted
|
||||||
const endErrorMessage = isSubmitted ? errors.chainage_end_km?.message : undefined;
|
? errors.chainage_start_km?.message
|
||||||
|
: undefined;
|
||||||
|
const endErrorMessage = isSubmitted
|
||||||
|
? errors.chainage_end_km?.message
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<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">
|
<DialogHeader className="gap-2">
|
||||||
<DialogTitle className="flex items-center gap-3">
|
<DialogTitle className="flex items-center gap-3">
|
||||||
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
|
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
|
||||||
@@ -101,12 +110,22 @@ export function PackageDialog({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<input type="hidden" {...register('project_id')} value={projectId} readOnly />
|
<input
|
||||||
|
type="hidden"
|
||||||
|
{...register('project_id')}
|
||||||
|
value={projectId}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<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
|
<Input
|
||||||
id="package-name"
|
id="package-name"
|
||||||
placeholder="e.g. Package 01"
|
placeholder="e.g. Package 01"
|
||||||
@@ -124,10 +143,18 @@ export function PackageDialog({
|
|||||||
</span>
|
</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>
|
||||||
|
|
||||||
<FormField id="package-start" label="Segment Start (km)" error={startErrorMessage}>
|
<FormField
|
||||||
|
id="package-start"
|
||||||
|
label="Segment Start (km)"
|
||||||
|
error={startErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="package-start"
|
id="package-start"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -138,7 +165,11 @@ export function PackageDialog({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField id="package-end" label="Segment End (km)" error={endErrorMessage}>
|
<FormField
|
||||||
|
id="package-end"
|
||||||
|
label="Segment End (km)"
|
||||||
|
error={endErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="package-end"
|
id="package-end"
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ function toNumberOrZero(value: string) {
|
|||||||
return value.trim() ? Number(value) : 0;
|
return value.trim() ? Number(value) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPackagePayload(values: PackageFormValues): PackageCreate | PackageUpdate {
|
function toPackagePayload(
|
||||||
|
values: PackageFormValues,
|
||||||
|
): PackageCreate | PackageUpdate {
|
||||||
return {
|
return {
|
||||||
project_id: values.project_id,
|
project_id: values.project_id,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
@@ -78,7 +80,8 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
const packageId = useWatch({ control, name: 'id' });
|
const packageId = useWatch({ control, name: 'id' });
|
||||||
const projectId = useWatch({ control, name: 'project_id' }) || '';
|
const projectId = useWatch({ control, name: 'project_id' }) || '';
|
||||||
const packageName = useWatch({ control, name: 'name' }) || '';
|
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({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (values: PackageFormValues) => {
|
mutationFn: (values: PackageFormValues) => {
|
||||||
@@ -94,7 +97,9 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
queryClient.invalidateQueries({ queryKey: packageKeys.all });
|
queryClient.invalidateQueries({ queryKey: packageKeys.all });
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
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(
|
const setProjectId = useCallback(
|
||||||
(value: string) => setValue('project_id', value, { shouldDirty: true, shouldValidate: true }),
|
(value: string) =>
|
||||||
|
setValue('project_id', value, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
}),
|
||||||
[setValue],
|
[setValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ interface UsePackagesQueryParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePackagesQuery({ skip, limit }: 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({
|
return useQuery({
|
||||||
queryKey: packageKeys.list(listParams),
|
queryKey: packageKeys.list(listParams),
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ export function usePlanColumns(): ColumnDef<Plan>[] {
|
|||||||
{
|
{
|
||||||
accessorKey: 'price',
|
accessorKey: 'price',
|
||||||
header: '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',
|
accessorKey: 'trial_days',
|
||||||
@@ -68,7 +69,9 @@ export function usePlanColumns(): ColumnDef<Plan>[] {
|
|||||||
<Badge variant={row.original.is_active ? 'default' : 'secondary'}>
|
<Badge variant={row.original.is_active ? 'default' : 'secondary'}>
|
||||||
{row.original.is_active ? 'Active' : 'Inactive'}
|
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||||
</Badge>
|
</Badge>
|
||||||
{row.original.is_custom ? <Badge variant="outline">Custom</Badge> : null}
|
{row.original.is_custom ? (
|
||||||
|
<Badge variant="outline">Custom</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ export function PlanFilters({
|
|||||||
placeholder="Search plans"
|
placeholder="Search plans"
|
||||||
className="w-full sm:w-72"
|
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">
|
<SelectTrigger className="w-full sm:w-40">
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder="Status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -201,7 +201,9 @@ export function PlanSheet({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<Label>Permissions</Label>
|
<Label>Permissions</Label>
|
||||||
<span className="text-muted-foreground">{permissionIds.length} selected</span>
|
<span className="text-muted-foreground">
|
||||||
|
{permissionIds.length} selected
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isPermissionsLoading ? (
|
{isPermissionsLoading ? (
|
||||||
<div className="rounded-md border p-6 text-muted-foreground">
|
<div className="rounded-md border p-6 text-muted-foreground">
|
||||||
@@ -226,7 +228,9 @@ export function PlanSheet({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSaving}>
|
<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'}
|
{planId ? 'Update Plan' : 'Create Plan'}
|
||||||
</Button>
|
</Button>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
|
|||||||
@@ -117,7 +117,10 @@ export function usePlanForm({
|
|||||||
max_organizations: plan.max_organizations ?? 0,
|
max_organizations: plan.max_organizations ?? 0,
|
||||||
max_users: plan.max_users ?? 0,
|
max_users: plan.max_users ?? 0,
|
||||||
max_roles: plan.max_roles ?? 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_active: plan.is_active,
|
||||||
is_custom: plan.is_custom,
|
is_custom: plan.is_custom,
|
||||||
});
|
});
|
||||||
@@ -126,7 +129,11 @@ export function usePlanForm({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const setPermissionIds = useCallback(
|
const setPermissionIds = useCallback(
|
||||||
(ids: number[]) => setValue('permission_ids', ids, { shouldDirty: true, shouldValidate: true }),
|
(ids: number[]) =>
|
||||||
|
setValue('permission_ids', ids, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
}),
|
||||||
[setValue],
|
[setValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ export function useSavePlanMutation({ onSaved }: { onSaved: () => void }) {
|
|||||||
onSaved();
|
onSaved();
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
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();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (plan: Plan) => planService.updatePlanStatus(plan.id, !plan.is_active),
|
mutationFn: (plan: Plan) =>
|
||||||
|
planService.updatePlanStatus(plan.id, !plan.is_active),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Plan status updated');
|
toast.success('Plan status updated');
|
||||||
queryClient.invalidateQueries({ queryKey: planKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: planKeys.lists() });
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import { PlanTable } from './components/PlanTable';
|
|||||||
import { usePlanFilters } from './hooks/usePlanFilters';
|
import { usePlanFilters } from './hooks/usePlanFilters';
|
||||||
import { usePlanForm } from './hooks/usePlanForm';
|
import { usePlanForm } from './hooks/usePlanForm';
|
||||||
import { usePlanStatusMutation } from './hooks/usePlanMutations';
|
import { usePlanStatusMutation } from './hooks/usePlanMutations';
|
||||||
import { useOrganizationPermissionTreeQuery, usePlansQuery } from './hooks/usePlanQueries';
|
import {
|
||||||
|
useOrganizationPermissionTreeQuery,
|
||||||
|
usePlansQuery,
|
||||||
|
} from './hooks/usePlanQueries';
|
||||||
|
|
||||||
export default function PlansPage() {
|
export default function PlansPage() {
|
||||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
@@ -57,8 +60,15 @@ export default function PlansPage() {
|
|||||||
onSaved: () => handleSheetOpenChange(false),
|
onSaved: () => handleSheetOpenChange(false),
|
||||||
});
|
});
|
||||||
const { openCreate: prepareCreatePlan, openEdit: prepareEditPlan } = planForm;
|
const { openCreate: prepareCreatePlan, openEdit: prepareEditPlan } = planForm;
|
||||||
const { register, control, onSubmit, planId, permissionIds, setPermissionIds, isSaving } =
|
const {
|
||||||
planForm;
|
register,
|
||||||
|
control,
|
||||||
|
onSubmit,
|
||||||
|
planId,
|
||||||
|
permissionIds,
|
||||||
|
setPermissionIds,
|
||||||
|
isSaving,
|
||||||
|
} = planForm;
|
||||||
const statusMutation = usePlanStatusMutation();
|
const statusMutation = usePlanStatusMutation();
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { useMemo } from 'react';
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
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';
|
import type { Project } from '@/types';
|
||||||
|
|
||||||
function EmptyValue() {
|
function EmptyValue() {
|
||||||
@@ -42,9 +46,7 @@ function StateBadges({ value }: { value: string | null }) {
|
|||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-2" align="start">
|
<PopoverContent className="w-auto p-2" align="start">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="mb-0.5 px-1 text-muted-foreground">
|
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
|
||||||
Other States
|
|
||||||
</p>
|
|
||||||
{remainingStates.map((state) => (
|
{remainingStates.map((state) => (
|
||||||
<Badge key={state} variant="secondary">
|
<Badge key={state} variant="secondary">
|
||||||
{state}
|
{state}
|
||||||
|
|||||||
@@ -42,14 +42,21 @@ export function ProjectDialog({
|
|||||||
isSaving,
|
isSaving,
|
||||||
}: ProjectDialogProps) {
|
}: ProjectDialogProps) {
|
||||||
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
|
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
|
||||||
const startLatErrorMessage = isSubmitted ? errors.start_lat?.message : undefined;
|
const startLatErrorMessage = isSubmitted
|
||||||
const startLngErrorMessage = isSubmitted ? errors.start_lng?.message : undefined;
|
? errors.start_lat?.message
|
||||||
|
: undefined;
|
||||||
|
const startLngErrorMessage = isSubmitted
|
||||||
|
? errors.start_lng?.message
|
||||||
|
: undefined;
|
||||||
const endLatErrorMessage = isSubmitted ? errors.end_lat?.message : undefined;
|
const endLatErrorMessage = isSubmitted ? errors.end_lat?.message : undefined;
|
||||||
const endLngErrorMessage = isSubmitted ? errors.end_lng?.message : undefined;
|
const endLngErrorMessage = isSubmitted ? errors.end_lng?.message : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<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">
|
<DialogHeader className="gap-2">
|
||||||
<DialogTitle className="flex items-center gap-3">
|
<DialogTitle className="flex items-center gap-3">
|
||||||
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
|
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
|
||||||
@@ -65,7 +72,12 @@ export function ProjectDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="space-y-6">
|
<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
|
<Input
|
||||||
id="project-name"
|
id="project-name"
|
||||||
placeholder="Enter a descriptive project name"
|
placeholder="Enter a descriptive project name"
|
||||||
@@ -76,7 +88,11 @@ export function ProjectDialog({
|
|||||||
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
<FormField id="project-state" label="State">
|
<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>
|
||||||
<FormField
|
<FormField
|
||||||
id="project-corridor"
|
id="project-corridor"
|
||||||
@@ -102,7 +118,11 @@ export function ProjectDialog({
|
|||||||
START POINT
|
START POINT
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<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
|
<Input
|
||||||
id="project-start-lat"
|
id="project-start-lat"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -113,7 +133,11 @@ export function ProjectDialog({
|
|||||||
{...register('start_lat')}
|
{...register('start_lat')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField id="project-start-lng" label="Lng" error={startLngErrorMessage}>
|
<FormField
|
||||||
|
id="project-start-lng"
|
||||||
|
label="Lng"
|
||||||
|
error={startLngErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="project-start-lng"
|
id="project-start-lng"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -133,7 +157,11 @@ export function ProjectDialog({
|
|||||||
END POINT
|
END POINT
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<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
|
<Input
|
||||||
id="project-end-lat"
|
id="project-end-lat"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -144,7 +172,11 @@ export function ProjectDialog({
|
|||||||
{...register('end_lat')}
|
{...register('end_lat')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField id="project-end-lng" label="Lng" error={endLngErrorMessage}>
|
<FormField
|
||||||
|
id="project-end-lng"
|
||||||
|
label="Lng"
|
||||||
|
error={endLngErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="project-end-lng"
|
id="project-end-lng"
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ function toNullableNumber(value: string) {
|
|||||||
return value.trim() ? Number(value) : null;
|
return value.trim() ? Number(value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toProjectPayload(values: ProjectFormValues): ProjectCreate | ProjectUpdate {
|
function toProjectPayload(
|
||||||
|
values: ProjectFormValues,
|
||||||
|
): ProjectCreate | ProjectUpdate {
|
||||||
return {
|
return {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
state: trimOptional(values.state),
|
state: trimOptional(values.state),
|
||||||
@@ -98,7 +100,9 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
queryClient.invalidateQueries({ queryKey: projectKeys.all });
|
queryClient.invalidateQueries({ queryKey: projectKeys.all });
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
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) {
|
export function useProjectsQuery({ skip, limit }: UseProjectsQueryParams) {
|
||||||
const listParams = useMemo<PaginationParams>(() => ({ skip, limit }), [limit, skip]);
|
const listParams = useMemo<PaginationParams>(
|
||||||
|
() => ({ skip, limit }),
|
||||||
|
[limit, skip],
|
||||||
|
);
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: projectKeys.list(listParams),
|
queryKey: projectKeys.list(listParams),
|
||||||
@@ -36,7 +39,8 @@ export function useDeleteProjectMutation() {
|
|||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error('Deletion failed', {
|
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 { useProjectColumns } from './components/ProjectColumns';
|
||||||
import { useProjectFilters } from './hooks/useProjectFilters';
|
import { useProjectFilters } from './hooks/useProjectFilters';
|
||||||
import { useProjectForm } from './hooks/useProjectForm';
|
import { useProjectForm } from './hooks/useProjectForm';
|
||||||
import { useDeleteProjectMutation, useProjectsQuery } from './hooks/useProjectQueries';
|
import {
|
||||||
|
useDeleteProjectMutation,
|
||||||
|
useProjectsQuery,
|
||||||
|
} from './hooks/useProjectQueries';
|
||||||
|
|
||||||
export default function ProjectPage() {
|
export default function ProjectPage() {
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
@@ -60,7 +63,9 @@ export default function ProjectPage() {
|
|||||||
|
|
||||||
const deleteProject = useCallback(
|
const deleteProject = useCallback(
|
||||||
(project: Project) => {
|
(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;
|
return;
|
||||||
}
|
}
|
||||||
deleteProjectMutation.mutate(project);
|
deleteProjectMutation.mutate(project);
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ export default function VideoResultsPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { videoId } = useParams() as { videoId: string };
|
const { videoId } = useParams() as { videoId: string };
|
||||||
const [session, setSession] = useState<SessionContext | null>(null);
|
const [session, setSession] = useState<SessionContext | null>(null);
|
||||||
const [detectionData, setDetectionData] = useState<DetectionData | null>(null);
|
const [detectionData, setDetectionData] = useState<DetectionData | null>(
|
||||||
const [detectionType, setDetectionType] = useState<DetectionType>('pothole-detection');
|
null,
|
||||||
|
);
|
||||||
|
const [detectionType, setDetectionType] =
|
||||||
|
useState<DetectionType>('pothole-detection');
|
||||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<Card className="flex flex-col items-center gap-4 p-8">
|
<Card className="flex flex-col items-center gap-4 p-8">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
<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>
|
</Card>
|
||||||
</div>
|
</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">
|
<Card className="flex flex-col items-center gap-6 p-8 text-center max-w-md">
|
||||||
<p className="text-destructive font-medium">{error}</p>
|
<p className="text-destructive font-medium">{error}</p>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Button onClick={() => router.push(ROUTES.UPLOAD)} variant="outline">
|
<Button
|
||||||
|
onClick={() => router.push(ROUTES.UPLOAD)}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
Back to Upload
|
Back to Upload
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleNewAnalysis}>New Analysis</Button>
|
<Button onClick={handleNewAnalysis}>New Analysis</Button>
|
||||||
@@ -116,7 +124,11 @@ export default function VideoResultsPage() {
|
|||||||
<main className="min-h-screen">
|
<main className="min-h-screen">
|
||||||
<div className="container mx-auto px-6 py-10 max-w-[1600px]">
|
<div className="container mx-auto px-6 py-10 max-w-[1600px]">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<PageHeader title={getTitle()} description={`Video ID: ${videoId}`} icon={TrendingUp} />
|
<PageHeader
|
||||||
|
title={getTitle()}
|
||||||
|
description={`Video ID: ${videoId}`}
|
||||||
|
icon={TrendingUp}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{session && (
|
{session && (
|
||||||
|
|||||||
@@ -55,8 +55,10 @@ export default function ResultsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getTitle = () => {
|
const getTitle = () => {
|
||||||
if (detectionType === 'pothole-detection') return 'Pothole Detection Results';
|
if (detectionType === 'pothole-detection')
|
||||||
if (detectionType === 'sign-board-detection') return 'Signboard Detection Results';
|
return 'Pothole Detection Results';
|
||||||
|
if (detectionType === 'sign-board-detection')
|
||||||
|
return 'Signboard Detection Results';
|
||||||
return 'Pothole & Signboard Detection Results';
|
return 'Pothole & Signboard Detection Results';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export function useRoleColumns(): ColumnDef<Role>[] {
|
|||||||
accessorKey: 'effective_status',
|
accessorKey: 'effective_status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge variant={row.original.effective_status ? 'default' : 'secondary'}>
|
<Badge
|
||||||
|
variant={row.original.effective_status ? 'default' : 'secondary'}
|
||||||
|
>
|
||||||
{row.original.effective_status ? 'Active' : 'Inactive'}
|
{row.original.effective_status ? 'Active' : 'Inactive'}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ export function RoleFilters({
|
|||||||
onChange={onSearchChange}
|
onChange={onSearchChange}
|
||||||
placeholder="Search roles"
|
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">
|
<SelectTrigger className="md:w-44">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ export function RoleSheet({
|
|||||||
isSaving,
|
isSaving,
|
||||||
}: RoleSheetProps) {
|
}: RoleSheetProps) {
|
||||||
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
|
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
|
||||||
const displayNameErrorMessage = isSubmitted ? errors.display_name?.message : undefined;
|
const displayNameErrorMessage = isSubmitted
|
||||||
|
? errors.display_name?.message
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -72,7 +74,12 @@ export function RoleSheet({
|
|||||||
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
<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="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<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
|
<Input
|
||||||
id="role-name"
|
id="role-name"
|
||||||
placeholder="Enter role name"
|
placeholder="Enter role name"
|
||||||
@@ -110,7 +117,9 @@ export function RoleSheet({
|
|||||||
error={isSubmitted ? errors.permission_ids?.message : undefined}
|
error={isSubmitted ? errors.permission_ids?.message : undefined}
|
||||||
className="space-y-3"
|
className="space-y-3"
|
||||||
labelEnd={
|
labelEnd={
|
||||||
<span className="text-muted-foreground">{permissionIds.length} selected</span>
|
<span className="text-muted-foreground">
|
||||||
|
{permissionIds.length} selected
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isPermissionsLoading ? (
|
{isPermissionsLoading ? (
|
||||||
@@ -129,7 +138,9 @@ export function RoleSheet({
|
|||||||
|
|
||||||
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
|
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
|
||||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
<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'}
|
{roleId ? 'Update Role' : 'Create Role'}
|
||||||
</Button>
|
</Button>
|
||||||
<SheetClose asChild>
|
<SheetClose asChild>
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ export function useRoleForm({
|
|||||||
const roleName = useWatch({ control, name: 'name' }) || '';
|
const roleName = useWatch({ control, name: 'name' }) || '';
|
||||||
const displayName = useWatch({ control, name: 'display_name' }) || '';
|
const displayName = useWatch({ control, name: 'display_name' }) || '';
|
||||||
const canSubmit =
|
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({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (values: RoleFormValues) =>
|
mutationFn: (values: RoleFormValues) =>
|
||||||
@@ -81,7 +83,9 @@ export function useRoleForm({
|
|||||||
queryClient.invalidateQueries({ queryKey: roleKeys.all });
|
queryClient.invalidateQueries({ queryKey: roleKeys.all });
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
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 || '',
|
name: role.name || '',
|
||||||
display_name: role.display_name || '',
|
display_name: role.display_name || '',
|
||||||
description: role.description || '',
|
description: role.description || '',
|
||||||
permission_ids: collectPermissionIdsByKeys(permissionTree, role.permissions || []),
|
permission_ids: collectPermissionIdsByKeys(
|
||||||
|
permissionTree,
|
||||||
|
role.permissions || [],
|
||||||
|
),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[permissionTree, reset],
|
[permissionTree, reset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setPermissionIds = useCallback(
|
const setPermissionIds = useCallback(
|
||||||
(ids: number[]) => setValue('permission_ids', ids, { shouldDirty: true, shouldValidate: true }),
|
(ids: number[]) =>
|
||||||
|
setValue('permission_ids', ids, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
}),
|
||||||
[setValue],
|
[setValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ function buildRoleListParams({
|
|||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
search_term: searchTerm || undefined,
|
search_term: searchTerm || undefined,
|
||||||
effective_status: statusFilter === 'all' ? undefined : statusFilter === 'active',
|
effective_status:
|
||||||
|
statusFilter === 'all' ? undefined : statusFilter === 'active',
|
||||||
...getServerSortParams(sorting),
|
...getServerSortParams(sorting),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -40,7 +41,8 @@ function buildRoleListParams({
|
|||||||
export function useRolesQuery(params: UseRolesQueryParams) {
|
export function useRolesQuery(params: UseRolesQueryParams) {
|
||||||
const { skip, limit, searchTerm, statusFilter, sorting } = params;
|
const { skip, limit, searchTerm, statusFilter, sorting } = params;
|
||||||
const listParams = useMemo(
|
const listParams = useMemo(
|
||||||
() => buildRoleListParams({ skip, limit, searchTerm, statusFilter, sorting }),
|
() =>
|
||||||
|
buildRoleListParams({ skip, limit, searchTerm, statusFilter, sorting }),
|
||||||
[limit, searchTerm, skip, sorting, statusFilter],
|
[limit, searchTerm, skip, sorting, statusFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -71,7 +73,8 @@ export function useRoleStatusMutation() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (role: Role) => roleService.updateRoleStatus(role.id, !role.effective_status),
|
mutationFn: (role: Role) =>
|
||||||
|
roleService.updateRoleStatus(role.id, !role.effective_status),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Role status updated');
|
toast.success('Role status updated');
|
||||||
queryClient.invalidateQueries({ queryKey: roleKeys.all });
|
queryClient.invalidateQueries({ queryKey: roleKeys.all });
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ import {
|
|||||||
} from './hooks/useRoleQueries';
|
} from './hooks/useRoleQueries';
|
||||||
|
|
||||||
export default function RolesPage() {
|
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 [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
const {
|
const {
|
||||||
skip,
|
skip,
|
||||||
@@ -66,7 +68,8 @@ export default function RolesPage() {
|
|||||||
isSaving,
|
isSaving,
|
||||||
} = roleForm;
|
} = roleForm;
|
||||||
const statusMutation = useRoleStatusMutation();
|
const statusMutation = useRoleStatusMutation();
|
||||||
const { mutate: updateRoleStatus, isPending: isStatusPending } = statusMutation;
|
const { mutate: updateRoleStatus, isPending: isStatusPending } =
|
||||||
|
statusMutation;
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
prepareCreateRole();
|
prepareCreateRole();
|
||||||
|
|||||||
@@ -2,10 +2,19 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
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 { 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';
|
import type { Chainage, Package, Project } from '@/types';
|
||||||
|
|
||||||
function EmptyValue() {
|
function EmptyValue() {
|
||||||
@@ -43,9 +52,7 @@ function StateBadges({ value }: { value: string | null }) {
|
|||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-2" align="start">
|
<PopoverContent className="w-auto p-2" align="start">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="mb-0.5 px-1 text-muted-foreground">
|
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
|
||||||
Other States
|
|
||||||
</p>
|
|
||||||
{remainingStates.map((state) => (
|
{remainingStates.map((state) => (
|
||||||
<Badge key={state} variant="secondary">
|
<Badge key={state} variant="secondary">
|
||||||
{state}
|
{state}
|
||||||
@@ -78,7 +85,9 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
|
|||||||
id: 'project',
|
id: 'project',
|
||||||
header: 'Project',
|
header: 'Project',
|
||||||
cell: ({ row }) => {
|
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);
|
const project = projects.find((item) => item.id === pkg?.project_id);
|
||||||
return project?.name || <EmptyValue />;
|
return project?.name || <EmptyValue />;
|
||||||
},
|
},
|
||||||
@@ -87,7 +96,9 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
|
|||||||
id: 'project_state',
|
id: 'project_state',
|
||||||
header: 'Project State',
|
header: 'Project State',
|
||||||
cell: ({ row }) => {
|
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);
|
const project = projects.find((item) => item.id === pkg?.project_id);
|
||||||
return <StateBadges value={project?.state ?? null} />;
|
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"
|
className="flex items-center gap-1.5 border-blue-500/50 text-blue-500"
|
||||||
>
|
>
|
||||||
<MapPin className="size-3" />
|
<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>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,7 +95,11 @@ export function SegmentDialog({
|
|||||||
<form onSubmit={onSubmit} className="space-y-6">
|
<form onSubmit={onSubmit} className="space-y-6">
|
||||||
{!segmentId ? (
|
{!segmentId ? (
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<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}>
|
<Select value={projectId} onValueChange={onProjectChange}>
|
||||||
<SelectTrigger aria-invalid={!!getError('project_id')}>
|
<SelectTrigger aria-invalid={!!getError('project_id')}>
|
||||||
{isProjectsLoading ? (
|
{isProjectsLoading ? (
|
||||||
@@ -115,10 +119,19 @@ export function SegmentDialog({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<input type="hidden" {...register('project_id')} value={projectId} readOnly />
|
<input
|
||||||
|
type="hidden"
|
||||||
|
{...register('project_id')}
|
||||||
|
value={projectId}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="Package" required error={getError('package_id')}>
|
<FormField
|
||||||
|
label="Package"
|
||||||
|
required
|
||||||
|
error={getError('package_id')}
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
value={packageId}
|
value={packageId}
|
||||||
onValueChange={onPackageChange}
|
onValueChange={onPackageChange}
|
||||||
@@ -132,7 +145,11 @@ export function SegmentDialog({
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={projectId ? 'Choose a package' : 'Select project first'}
|
placeholder={
|
||||||
|
projectId
|
||||||
|
? 'Choose a package'
|
||||||
|
: 'Select project first'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -144,7 +161,12 @@ export function SegmentDialog({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<input type="hidden" {...register('package_id')} value={packageId} readOnly />
|
<input
|
||||||
|
type="hidden"
|
||||||
|
{...register('package_id')}
|
||||||
|
value={packageId}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -218,7 +240,12 @@ export function SegmentDialog({
|
|||||||
START COORDINATES
|
START COORDINATES
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<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
|
<Input
|
||||||
id="start-lat"
|
id="start-lat"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -230,7 +257,12 @@ export function SegmentDialog({
|
|||||||
{...register('start_lat')}
|
{...register('start_lat')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField id="start-lng" label="Longitude" required error={getError('start_lng')}>
|
<FormField
|
||||||
|
id="start-lng"
|
||||||
|
label="Longitude"
|
||||||
|
required
|
||||||
|
error={getError('start_lng')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="start-lng"
|
id="start-lng"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -251,7 +283,12 @@ export function SegmentDialog({
|
|||||||
END COORDINATES
|
END COORDINATES
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<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
|
<Input
|
||||||
id="end-lat"
|
id="end-lat"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -263,7 +300,12 @@ export function SegmentDialog({
|
|||||||
{...register('end_lat')}
|
{...register('end_lat')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField id="end-lng" label="Longitude" required error={getError('end_lng')}>
|
<FormField
|
||||||
|
id="end-lng"
|
||||||
|
label="Longitude"
|
||||||
|
required
|
||||||
|
error={getError('end_lng')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="end-lng"
|
id="end-lng"
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ const requiredNumber = (message: string) =>
|
|||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(1, message)
|
.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(
|
const latitude = requiredNumber('Latitude is required').refine(
|
||||||
(value) => {
|
(value) => {
|
||||||
@@ -64,7 +66,9 @@ const defaultValues: SegmentFormValues = {
|
|||||||
direction: 'UP',
|
direction: 'UP',
|
||||||
};
|
};
|
||||||
|
|
||||||
function toSegmentPayload(values: SegmentFormValues): ChainageCreate | ChainageUpdate {
|
function toSegmentPayload(
|
||||||
|
values: SegmentFormValues,
|
||||||
|
): ChainageCreate | ChainageUpdate {
|
||||||
return {
|
return {
|
||||||
package_id: values.package_id,
|
package_id: values.package_id,
|
||||||
segment_name: values.segment_name.trim(),
|
segment_name: values.segment_name.trim(),
|
||||||
@@ -99,7 +103,8 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
const packageId = useWatch({ control, name: 'package_id' }) || '';
|
const packageId = useWatch({ control, name: 'package_id' }) || '';
|
||||||
const direction = useWatch({ control, name: 'direction' }) || 'UP';
|
const direction = useWatch({ control, name: 'direction' }) || 'UP';
|
||||||
const segmentName = useWatch({ control, name: 'segment_name' }) || '';
|
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 chainageEndKm = useWatch({ control, name: 'chainage_end_km' }) || '';
|
||||||
const startLat = useWatch({ control, name: 'start_lat' }) || '';
|
const startLat = useWatch({ control, name: 'start_lat' }) || '';
|
||||||
const startLng = useWatch({ control, name: 'start_lng' }) || '';
|
const startLng = useWatch({ control, name: 'start_lng' }) || '';
|
||||||
@@ -130,14 +135,18 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
queryClient.invalidateQueries({ queryKey: segmentKeys.all });
|
queryClient.invalidateQueries({ queryKey: segmentKeys.all });
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
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(
|
const onSubmit = handleSubmit(
|
||||||
(values) => saveMutation.mutate(values),
|
(values) => saveMutation.mutate(values),
|
||||||
(formErrors) => {
|
(formErrors) => {
|
||||||
const firstMessage = Object.values(formErrors).find((error) => error?.message)?.message;
|
const firstMessage = Object.values(formErrors).find(
|
||||||
|
(error) => error?.message,
|
||||||
|
)?.message;
|
||||||
if (firstMessage) {
|
if (firstMessage) {
|
||||||
toast.error(String(firstMessage));
|
toast.error(String(firstMessage));
|
||||||
}
|
}
|
||||||
@@ -169,14 +178,21 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
|
|
||||||
const setProjectId = useCallback(
|
const setProjectId = useCallback(
|
||||||
(value: string) => {
|
(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('package_id', '', { shouldDirty: true, shouldValidate: true });
|
||||||
},
|
},
|
||||||
[setValue],
|
[setValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setPackageId = useCallback(
|
const setPackageId = useCallback(
|
||||||
(value: string) => setValue('package_id', value, { shouldDirty: true, shouldValidate: true }),
|
(value: string) =>
|
||||||
|
setValue('package_id', value, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
}),
|
||||||
[setValue],
|
[setValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { toast } from 'sonner';
|
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 type { Chainage, PaginationParams } from '@/types';
|
||||||
|
|
||||||
import { segmentKeys } from '../queries/segmentKeys';
|
import { segmentKeys } from '../queries/segmentKeys';
|
||||||
@@ -15,7 +19,10 @@ interface UseSegmentsQueryParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSegmentsQuery({ skip, limit }: 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({
|
return useQuery({
|
||||||
queryKey: segmentKeys.list(listParams),
|
queryKey: segmentKeys.list(listParams),
|
||||||
@@ -40,7 +47,8 @@ export function useAllPackageOptionsQuery() {
|
|||||||
export function usePackagesByProjectQuery(projectId: string, enabled: boolean) {
|
export function usePackagesByProjectQuery(projectId: string, enabled: boolean) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['packages', 'by-project', projectId],
|
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),
|
enabled: enabled && Boolean(projectId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -49,7 +57,8 @@ export function useDeleteSegmentMutation() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (segment: Chainage) => chainageService.deleteChainage(segment.id),
|
mutationFn: (segment: Chainage) =>
|
||||||
|
chainageService.deleteChainage(segment.id),
|
||||||
onSuccess: (_data, segment) => {
|
onSuccess: (_data, segment) => {
|
||||||
toast.success('Segment deleted', {
|
toast.success('Segment deleted', {
|
||||||
description: `${segment.segment_name} has been removed from the system.`,
|
description: `${segment.segment_name} has been removed from the system.`,
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ export default function SegmentPage() {
|
|||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
} = segmentForm;
|
} = segmentForm;
|
||||||
const projectPackagesQuery = usePackagesByProjectQuery(projectId, isDialogOpen);
|
const projectPackagesQuery = usePackagesByProjectQuery(
|
||||||
|
projectId,
|
||||||
|
isDialogOpen,
|
||||||
|
);
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
prepareCreateSegment();
|
prepareCreateSegment();
|
||||||
@@ -57,7 +60,9 @@ export default function SegmentPage() {
|
|||||||
|
|
||||||
const openEdit = useCallback(
|
const openEdit = useCallback(
|
||||||
(segment: Chainage) => {
|
(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 || '');
|
prepareEditSegment(segment, pkg?.project_id || '');
|
||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
},
|
},
|
||||||
@@ -76,7 +81,11 @@ export default function SegmentPage() {
|
|||||||
|
|
||||||
const deleteSegment = useCallback(
|
const deleteSegment = useCallback(
|
||||||
(segment: Chainage) => {
|
(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;
|
return;
|
||||||
}
|
}
|
||||||
deleteSegmentMutation.mutate(segment);
|
deleteSegmentMutation.mutate(segment);
|
||||||
|
|||||||
@@ -67,15 +67,20 @@ export function TenantSheet({
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
|
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{tenantId ? 'Edit Tenant' : 'Create Tenant'}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{tenantId ? 'Edit Tenant' : 'Create Tenant'}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Bind a client and subscription plan, then invite the tenant administrator.
|
Bind a client and subscription plan, then invite the tenant
|
||||||
|
administrator.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p>Basic Information</p>
|
<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>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<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="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Client</Label>
|
<Label>Client</Label>
|
||||||
<Select value={clientId} onValueChange={onClientChange} disabled={isLookupsLoading}>
|
<Select
|
||||||
|
value={clientId}
|
||||||
|
onValueChange={onClientChange}
|
||||||
|
disabled={isLookupsLoading}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={isLookupsLoading ? 'Loading clients...' : 'Select client'}
|
placeholder={
|
||||||
|
isLookupsLoading ? 'Loading clients...' : 'Select client'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -123,10 +134,16 @@ export function TenantSheet({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Subscription Plan</Label>
|
<Label>Subscription Plan</Label>
|
||||||
<Select value={planId} onValueChange={onPlanChange} disabled={isLookupsLoading}>
|
<Select
|
||||||
|
value={planId}
|
||||||
|
onValueChange={onPlanChange}
|
||||||
|
disabled={isLookupsLoading}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={isLookupsLoading ? 'Loading plans...' : 'Select plan'}
|
placeholder={
|
||||||
|
isLookupsLoading ? 'Loading plans...' : 'Select plan'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -143,7 +160,11 @@ export function TenantSheet({
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="tenant-domain">Domain</Label>
|
<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>
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<Label htmlFor="tenant-description">Description</Label>
|
<Label htmlFor="tenant-description">Description</Label>
|
||||||
@@ -226,7 +247,9 @@ export function TenantSheet({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSaving || isLookupsLoading}>
|
<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'}
|
{tenantId ? 'Update Tenant' : 'Create Tenant'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export function useTenantFilters() {
|
|||||||
const [limit, setLimitValue] = useState(10);
|
const [limit, setLimitValue] = useState(10);
|
||||||
const [sorting, setSortingValue] = useState<SortingState>([]);
|
const [sorting, setSortingValue] = useState<SortingState>([]);
|
||||||
const [searchTerm, setSearchTermValue] = useState('');
|
const [searchTerm, setSearchTermValue] = useState('');
|
||||||
const [statusFilter, setStatusFilterValue] = useState<TenantStatusFilter>('all');
|
const [statusFilter, setStatusFilterValue] =
|
||||||
|
useState<TenantStatusFilter>('all');
|
||||||
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
|
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
|
||||||
|
|
||||||
const setSearchTerm = useCallback((value: string) => {
|
const setSearchTerm = useCallback((value: string) => {
|
||||||
|
|||||||
@@ -82,7 +82,11 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isEditMode) {
|
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');
|
toast.error('Complete all required admin fields');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ export function useSaveTenantMutation({ onSaved }: { onSaved: () => void }) {
|
|||||||
onSaved();
|
onSaved();
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
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 total = tenantsQuery.data?.total ?? 0;
|
||||||
const clients = clientsLookupQuery.data?.items ?? [];
|
const clients = clientsLookupQuery.data?.items ?? [];
|
||||||
const plans = plansLookupQuery.data?.items ?? [];
|
const plans = plansLookupQuery.data?.items ?? [];
|
||||||
const isLookupsLoading = clientsLookupQuery.isLoading || plansLookupQuery.isLoading;
|
const isLookupsLoading =
|
||||||
|
clientsLookupQuery.isLoading || plansLookupQuery.isLoading;
|
||||||
|
|
||||||
const toolbar = useMemo(
|
const toolbar = useMemo(
|
||||||
() => (
|
() => (
|
||||||
@@ -167,7 +168,9 @@ export default function TenantsPage() {
|
|||||||
onSortingChange={setSorting}
|
onSortingChange={setSorting}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
pendingDeleteId={deleteMutation.isPending ? deleteMutation.variables : undefined}
|
pendingDeleteId={
|
||||||
|
deleteMutation.isPending ? deleteMutation.variables : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import { ROUTES } from '@/utils/routes';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
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() {
|
export default function VideoProcessingPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -101,7 +104,9 @@ export default function VideoProcessingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (statusData.status === 'error') {
|
if (statusData.status === 'error') {
|
||||||
setError(statusData.message || 'An error occurred during processing.');
|
setError(
|
||||||
|
statusData.message || 'An error occurred during processing.',
|
||||||
|
);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
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]">
|
<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="flex flex-col items-center justify-center w-full max-w-2xl space-y-10">
|
||||||
<div className="text-center space-y-4">
|
<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">
|
<p className="text-sm font-mono text-muted-foreground tracking-widest">
|
||||||
ID: {videoId}
|
ID: {videoId}
|
||||||
</p>
|
</p>
|
||||||
@@ -214,7 +221,9 @@ export default function VideoProcessingPage() {
|
|||||||
<span className="font-bold text-muted-foreground text-xs uppercase tracking-widest">
|
<span className="font-bold text-muted-foreground text-xs uppercase tracking-widest">
|
||||||
Progress
|
Progress
|
||||||
</span>
|
</span>
|
||||||
<span className="font-bold text-primary text-lg">{progress}%</span>
|
<span className="font-bold text-primary text-lg">
|
||||||
|
{progress}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-4 rounded-full bg-secondary overflow-hidden border">
|
<div className="h-4 rounded-full bg-secondary overflow-hidden border">
|
||||||
<div
|
<div
|
||||||
@@ -233,7 +242,11 @@ export default function VideoProcessingPage() {
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="w-full p-6 rounded-md bg-destructive/10 border border-destructive/20 text-center space-y-3">
|
<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>
|
<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
|
Retry Connection
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -54,12 +60,15 @@ export default function UploadPage() {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelectionChange = useCallback((selectedSession: SessionContext | null) => {
|
const handleSelectionChange = useCallback(
|
||||||
setSession(selectedSession);
|
(selectedSession: SessionContext | null) => {
|
||||||
if (selectedSession) {
|
setSession(selectedSession);
|
||||||
sessionService.saveSession(selectedSession);
|
if (selectedSession) {
|
||||||
}
|
sessionService.saveSession(selectedSession);
|
||||||
}, []);
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const handleUpload = async () => {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -103,7 +112,8 @@ export default function UploadPage() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
let errorMessage = 'Upload failed';
|
let errorMessage = 'Upload failed';
|
||||||
if (err instanceof TypeError && err.message === 'Failed to fetch') {
|
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) {
|
} else if (err instanceof Error) {
|
||||||
errorMessage = err.message;
|
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;
|
const isFormValid = isSessionComplete && file && jsonFile;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -140,7 +152,9 @@ export default function UploadPage() {
|
|||||||
<Reveal direction="up" delay={0.1}>
|
<Reveal direction="up" delay={0.1}>
|
||||||
<Card className="border">
|
<Card className="border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl font-bold">1. Project Details</CardTitle>
|
<CardTitle className="text-xl font-bold">
|
||||||
|
1. Project Details
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Select the target project, package, and segment.
|
Select the target project, package, and segment.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
@@ -157,7 +171,13 @@ export default function UploadPage() {
|
|||||||
|
|
||||||
{/* Module 2: Upload Data */}
|
{/* Module 2: Upload Data */}
|
||||||
<Reveal direction="up" delay={0.2}>
|
<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>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||||
2. Upload Video Data
|
2. Upload Video Data
|
||||||
@@ -170,7 +190,10 @@ export default function UploadPage() {
|
|||||||
<div className="grid grid-cols-1 gap-6 text-sm">
|
<div className="grid grid-cols-1 gap-6 text-sm">
|
||||||
{/* Video File Input */}
|
{/* Video File Input */}
|
||||||
<div className="space-y-2">
|
<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>
|
Video File <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
@@ -191,8 +214,12 @@ export default function UploadPage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* JSON File Input */}
|
{/* JSON File Input */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="json-file" className="text-sm font-semibold">
|
<Label
|
||||||
GPS JSON File <span className="text-destructive">*</span>
|
htmlFor="json-file"
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
>
|
||||||
|
GPS JSON File{' '}
|
||||||
|
<span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="json-file"
|
id="json-file"
|
||||||
@@ -210,7 +237,10 @@ export default function UploadPage() {
|
|||||||
|
|
||||||
{/* Select Method */}
|
{/* Select Method */}
|
||||||
<div className="space-y-2">
|
<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
|
Analysis Method
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -218,7 +248,10 @@ export default function UploadPage() {
|
|||||||
onValueChange={setSelectMethod}
|
onValueChange={setSelectMethod}
|
||||||
disabled={uploading || !isSessionComplete}
|
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" />
|
<SelectValue placeholder="Select method" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ export function useUserColumns(): ColumnDef<AdministrationUser>[] {
|
|||||||
header: 'Name',
|
header: 'Name',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span>
|
<span>
|
||||||
{[row.original.first_name, row.original.last_name].filter(Boolean).join(' ') ||
|
{[row.original.first_name, row.original.last_name]
|
||||||
row.original.username}
|
.filter(Boolean)
|
||||||
|
.join(' ') || row.original.username}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -64,7 +65,13 @@ export function useUserColumns(): ColumnDef<AdministrationUser>[] {
|
|||||||
accessorKey: 'effective_status',
|
accessorKey: 'effective_status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge variant={row.original.effective_status === 'active' ? 'default' : 'secondary'}>
|
<Badge
|
||||||
|
variant={
|
||||||
|
row.original.effective_status === 'active'
|
||||||
|
? 'default'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
{row.original.effective_status}
|
{row.original.effective_status}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ export function UserFilters({
|
|||||||
onChange={onSearchChange}
|
onChange={onSearchChange}
|
||||||
placeholder="Search users"
|
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">
|
<SelectTrigger className="md:w-44">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -46,10 +46,16 @@ export function UserSheet({
|
|||||||
isSaving,
|
isSaving,
|
||||||
}: UserSheetProps) {
|
}: UserSheetProps) {
|
||||||
const roleComboboxPortalRef = useRef<HTMLDivElement | null>(null);
|
const roleComboboxPortalRef = useRef<HTMLDivElement | null>(null);
|
||||||
const firstNameErrorMessage = isSubmitted ? errors.first_name?.message : undefined;
|
const firstNameErrorMessage = isSubmitted
|
||||||
const lastNameErrorMessage = isSubmitted ? errors.last_name?.message : undefined;
|
? errors.first_name?.message
|
||||||
|
: undefined;
|
||||||
|
const lastNameErrorMessage = isSubmitted
|
||||||
|
? errors.last_name?.message
|
||||||
|
: undefined;
|
||||||
const emailErrorMessage = isSubmitted ? errors.email?.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;
|
const roleErrorMessage = isSubmitted ? errors.role_id?.message : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,7 +69,12 @@ export function UserSheet({
|
|||||||
|
|
||||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<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
|
<Input
|
||||||
id="first-name"
|
id="first-name"
|
||||||
placeholder="Enter first name"
|
placeholder="Enter first name"
|
||||||
@@ -72,7 +83,12 @@ export function UserSheet({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField id="last-name" label="Last Name" required error={lastNameErrorMessage}>
|
<FormField
|
||||||
|
id="last-name"
|
||||||
|
label="Last Name"
|
||||||
|
required
|
||||||
|
error={lastNameErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="last-name"
|
id="last-name"
|
||||||
placeholder="Enter last name"
|
placeholder="Enter last name"
|
||||||
@@ -82,7 +98,12 @@ export function UserSheet({
|
|||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField id="email" label="Email" required error={emailErrorMessage}>
|
<FormField
|
||||||
|
id="email"
|
||||||
|
label="Email"
|
||||||
|
required
|
||||||
|
error={emailErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
placeholder="name@example.com"
|
placeholder="name@example.com"
|
||||||
@@ -91,7 +112,11 @@ export function UserSheet({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField id="phone-number" label="Phone Number" error={phoneErrorMessage}>
|
<FormField
|
||||||
|
id="phone-number"
|
||||||
|
label="Phone Number"
|
||||||
|
error={phoneErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="phone-number"
|
id="phone-number"
|
||||||
placeholder="+919876543210"
|
placeholder="+919876543210"
|
||||||
@@ -111,7 +136,12 @@ export function UserSheet({
|
|||||||
|
|
||||||
<div ref={roleComboboxPortalRef} />
|
<div ref={roleComboboxPortalRef} />
|
||||||
|
|
||||||
<input type="hidden" {...register('role_id')} value={roleId} readOnly />
|
<input
|
||||||
|
type="hidden"
|
||||||
|
{...register('role_id')}
|
||||||
|
value={roleId}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<DialogFooter className="px-0">
|
<DialogFooter className="px-0">
|
||||||
@@ -125,7 +155,9 @@ export function UserSheet({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
<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'}
|
{userId ? 'Update User' : 'Create User'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ interface UserStatsProps {
|
|||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserStats({ total, activeCount, inactiveCount, pendingCount }: UserStatsProps) {
|
export function UserStats({
|
||||||
|
total,
|
||||||
|
activeCount,
|
||||||
|
inactiveCount,
|
||||||
|
pendingCount,
|
||||||
|
}: UserStatsProps) {
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: 'Total', value: total },
|
{ label: 'Total', value: total },
|
||||||
{ label: 'Active', value: activeCount },
|
{ label: 'Active', value: activeCount },
|
||||||
|
|||||||
@@ -57,10 +57,12 @@ export function UserTable({
|
|||||||
onClick: onEdit,
|
onClick: onEdit,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: (user) => (user.effective_status === 'active' ? 'Deactivate' : 'Activate'),
|
label: (user) =>
|
||||||
|
user.effective_status === 'active' ? 'Deactivate' : 'Activate',
|
||||||
icon: <RotateCcw className="size-4" />,
|
icon: <RotateCcw className="size-4" />,
|
||||||
permission: PERMISSIONS.USER.DELETE,
|
permission: PERMISSIONS.USER.DELETE,
|
||||||
disabled: (user) => pendingUserId === user.id || user.effective_status === 'pending',
|
disabled: (user) =>
|
||||||
|
pendingUserId === user.id || user.effective_status === 'pending',
|
||||||
onClick: onToggleStatus,
|
onClick: onToggleStatus,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
const email = useWatch({ control, name: 'email' }) || '';
|
const email = useWatch({ control, name: 'email' }) || '';
|
||||||
const phoneNumber = useWatch({ control, name: 'phone_number' }) || '';
|
const phoneNumber = useWatch({ control, name: 'phone_number' }) || '';
|
||||||
const isPhoneValid =
|
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 =
|
const canSubmit =
|
||||||
firstName.trim().length > 0 &&
|
firstName.trim().length > 0 &&
|
||||||
lastName.trim().length > 0 &&
|
lastName.trim().length > 0 &&
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ export function useSaveUserMutation({ onSaved }: { onSaved: () => void }) {
|
|||||||
queryClient.invalidateQueries({ queryKey: userKeys.all });
|
queryClient.invalidateQueries({ queryKey: userKeys.all });
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
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 queryClient = useQueryClient();
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (user: AdministrationUser) =>
|
mutationFn: (user: AdministrationUser) =>
|
||||||
userService.updateUserStatus(user.id, getNextStatus(user.effective_status)),
|
userService.updateUserStatus(
|
||||||
|
user.id,
|
||||||
|
getNextStatus(user.effective_status),
|
||||||
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('User status updated');
|
toast.success('User status updated');
|
||||||
queryClient.invalidateQueries({ queryKey: userKeys.all });
|
queryClient.invalidateQueries({ queryKey: userKeys.all });
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ function buildUserListParams({
|
|||||||
export function useUsersQuery(params: UseUsersQueryParams) {
|
export function useUsersQuery(params: UseUsersQueryParams) {
|
||||||
const { skip, limit, searchTerm, statusFilter, sorting } = params;
|
const { skip, limit, searchTerm, statusFilter, sorting } = params;
|
||||||
const listParams = useMemo(
|
const listParams = useMemo(
|
||||||
() => buildUserListParams({ skip, limit, searchTerm, statusFilter, sorting }),
|
() =>
|
||||||
|
buildUserListParams({ skip, limit, searchTerm, statusFilter, sorting }),
|
||||||
[limit, searchTerm, skip, sorting, statusFilter],
|
[limit, searchTerm, skip, sorting, statusFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Create Next App',
|
title: {
|
||||||
description: 'Generated by create next app',
|
default: 'VisionRoad',
|
||||||
|
template: '%s | VisionRoad',
|
||||||
|
},
|
||||||
|
description: 'Road infrastructure monitoring and pothole detection platform',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -28,7 +31,9 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="system"
|
defaultTheme="system"
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ export function BreadcrumbBasic() {
|
|||||||
{isLast ? (
|
{isLast ? (
|
||||||
<BreadcrumbPage>{formatSegment(segment)}</BreadcrumbPage>
|
<BreadcrumbPage>{formatSegment(segment)}</BreadcrumbPage>
|
||||||
) : (
|
) : (
|
||||||
<BreadcrumbLink href={href}>{formatSegment(segment)}</BreadcrumbLink>
|
<BreadcrumbLink href={href}>
|
||||||
|
{formatSegment(segment)}
|
||||||
|
</BreadcrumbLink>
|
||||||
)}
|
)}
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
{!isLast && <BreadcrumbSeparator />}
|
{!isLast && <BreadcrumbSeparator />}
|
||||||
|
|||||||
@@ -2,9 +2,17 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ChevronRight, Package } from 'lucide-react';
|
import { ChevronRight, Package } from 'lucide-react';
|
||||||
import { NavUser } from '@/components/nav-user';
|
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 { usePermissions } from '@/hooks/usePermissions';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -30,7 +38,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
const authUser = useAppStore((state) => state.user);
|
const authUser = useAppStore((state) => state.user);
|
||||||
const navItems = filterMenuItems(menuItems, hasPermission);
|
const navItems = filterMenuItems(menuItems, hasPermission);
|
||||||
const user = {
|
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 || '',
|
email: authUser?.email || '',
|
||||||
avatar: authUser?.profile_photo_url || '/avatars/profile.jpg',
|
avatar: authUser?.profile_photo_url || '/avatars/profile.jpg',
|
||||||
};
|
};
|
||||||
@@ -52,7 +62,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<SidebarNavItem key={item.title} item={item} pathname={pathname} />
|
<SidebarNavItem
|
||||||
|
key={item.title}
|
||||||
|
item={item}
|
||||||
|
pathname={pathname}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</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 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);
|
const hasChildren = Boolean(item.children?.length);
|
||||||
|
|
||||||
if (hasChildren) {
|
if (hasChildren) {
|
||||||
return (
|
return (
|
||||||
<Collapsible asChild defaultOpen={isChildActive} className="group/collapsible">
|
<Collapsible
|
||||||
|
asChild
|
||||||
|
defaultOpen={isChildActive}
|
||||||
|
className="group/collapsible"
|
||||||
|
>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<SidebarMenuButton tooltip={item.title} isActive={isChildActive}>
|
<SidebarMenuButton tooltip={item.title} isActive={isChildActive}>
|
||||||
@@ -86,7 +111,10 @@ function SidebarNavItem({ item, pathname }: { item: MenuItem; pathname: string }
|
|||||||
{item.children?.map((child) => (
|
{item.children?.map((child) => (
|
||||||
<SidebarMenuSubItem key={child.title}>
|
<SidebarMenuSubItem key={child.title}>
|
||||||
{child.path ? (
|
{child.path ? (
|
||||||
<SidebarMenuSubButton asChild isActive={pathname === child.path}>
|
<SidebarMenuSubButton
|
||||||
|
asChild
|
||||||
|
isActive={pathname === child.path}
|
||||||
|
>
|
||||||
<Link href={child.path}>
|
<Link href={child.path}>
|
||||||
<child.icon />
|
<child.icon />
|
||||||
<span>{child.title}</span>
|
<span>{child.title}</span>
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import {
|
|||||||
ComboboxValue,
|
ComboboxValue,
|
||||||
} from '@/components/ui/combobox';
|
} from '@/components/ui/combobox';
|
||||||
|
|
||||||
import type { AsyncComboboxOption, AsyncComboboxProps } from './AsyncCombobox.types';
|
import type {
|
||||||
|
AsyncComboboxOption,
|
||||||
|
AsyncComboboxProps,
|
||||||
|
} from './AsyncCombobox.types';
|
||||||
import { VirtualComboboxList } from './VirtualComboboxList';
|
import { VirtualComboboxList } from './VirtualComboboxList';
|
||||||
|
|
||||||
type VirtualizerHandle = Virtualizer<HTMLDivElement, Element>;
|
type VirtualizerHandle = Virtualizer<HTMLDivElement, Element>;
|
||||||
@@ -108,7 +111,9 @@ export function AsyncCombobox({
|
|||||||
filter={null}
|
filter={null}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
itemToStringLabel={(item) => item.label}
|
itemToStringLabel={(item) => item.label}
|
||||||
isItemEqualToValue={(item, currentValue) => item.value === currentValue.value}
|
isItemEqualToValue={(item, currentValue) =>
|
||||||
|
item.value === currentValue.value
|
||||||
|
}
|
||||||
onItemHighlighted={handleItemHighlighted}
|
onItemHighlighted={handleItemHighlighted}
|
||||||
>
|
>
|
||||||
<ComboboxTrigger
|
<ComboboxTrigger
|
||||||
@@ -131,7 +136,11 @@ export function AsyncCombobox({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ComboboxContent container={portalContainer}>
|
<ComboboxContent container={portalContainer}>
|
||||||
<ComboboxInput showTrigger={false} placeholder={searchPlaceholder} disabled={isDisabled} />
|
<ComboboxInput
|
||||||
|
showTrigger={false}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<div className="px-3 py-2 text-sm text-destructive">
|
<div className="px-3 py-2 text-sm text-destructive">
|
||||||
{errorMessage || 'Unable to load options.'}
|
{errorMessage || 'Unable to load options.'}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
'use client';
|
'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 { Combobox } from '@base-ui/react/combobox';
|
||||||
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
@@ -72,7 +78,13 @@ export function VirtualComboboxList({
|
|||||||
) {
|
) {
|
||||||
onLoadMore();
|
onLoadMore();
|
||||||
}
|
}
|
||||||
}, [filteredItems.length, hasNextPage, isFetchingNextPage, lastVirtualIndex, onLoadMore]);
|
}, [
|
||||||
|
filteredItems.length,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
lastVirtualIndex,
|
||||||
|
onLoadMore,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!filteredItems.length && !hasNextPage) {
|
if (!filteredItems.length && !hasNextPage) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
|||||||
|
|
||||||
import { useDebounce } from '@/hooks/useDebounce';
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
|
|
||||||
import type { AsyncComboboxOption, PaginatedLookupConfig } from './AsyncCombobox.types';
|
import type {
|
||||||
|
AsyncComboboxOption,
|
||||||
|
PaginatedLookupConfig,
|
||||||
|
} from './AsyncCombobox.types';
|
||||||
|
|
||||||
export function usePaginatedLookup<T>({
|
export function usePaginatedLookup<T>({
|
||||||
enabled = true,
|
enabled = true,
|
||||||
@@ -35,7 +38,10 @@ export function usePaginatedLookup<T>({
|
|||||||
return undefined;
|
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;
|
return loadedCount < lastPage.total ? loadedCount : undefined;
|
||||||
},
|
},
|
||||||
enabled,
|
enabled,
|
||||||
@@ -67,7 +73,11 @@ export function usePaginatedLookup<T>({
|
|||||||
const item = await resolveSelected(selectedValue);
|
const item = await resolveSelected(selectedValue);
|
||||||
return item ? mapOption(item) : null;
|
return item ? mapOption(item) : null;
|
||||||
},
|
},
|
||||||
enabled: enabled && Boolean(selectedValue) && !selectedInList && Boolean(resolveSelected),
|
enabled:
|
||||||
|
enabled &&
|
||||||
|
Boolean(selectedValue) &&
|
||||||
|
!selectedInList &&
|
||||||
|
Boolean(resolveSelected),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
gcTime: 30 * 60 * 1000,
|
gcTime: 30 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ const chartConfig = {
|
|||||||
},
|
},
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
export function ChainageBarChart({ data, isLoading = false }: ChainageBarChartProps) {
|
export function ChainageBarChart({
|
||||||
|
data,
|
||||||
|
isLoading = false,
|
||||||
|
}: ChainageBarChartProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="h-[250px] flex items-center justify-center">
|
<div className="h-[250px] flex items-center justify-center">
|
||||||
@@ -68,7 +71,9 @@ export function ChainageBarChart({ data, isLoading = false }: ChainageBarChartPr
|
|||||||
return (
|
return (
|
||||||
<div className="h-[250px] flex flex-col items-center justify-center text-muted-foreground">
|
<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-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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -94,17 +99,43 @@ export function ChainageBarChart({ data, isLoading = false }: ChainageBarChartPr
|
|||||||
tickLine={false}
|
tickLine={false}
|
||||||
tickMargin={10}
|
tickMargin={10}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(value) => (value.length > 8 ? `${value.slice(0, 8)}...` : value)}
|
tickFormatter={(value) =>
|
||||||
|
value.length > 8 ? `${value.slice(0, 8)}...` : value
|
||||||
|
}
|
||||||
fontSize={12}
|
fontSize={12}
|
||||||
/>
|
/>
|
||||||
<YAxis tickLine={false} axisLine={false} fontSize={12} tickMargin={10} />
|
<YAxis
|
||||||
<ChartTooltip cursor={false} content={<ChartTooltipContent indicator="dashed" />} />
|
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="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="road_crack" fill="var(--color-road_crack)" radius={4} />
|
||||||
<Bar dataKey="damaged_road_marking" fill="var(--color-damaged_road_marking)" radius={4} />
|
<Bar
|
||||||
<Bar dataKey="drain_issue" fill="var(--color-drain_issue)" radius={4} />
|
dataKey="damaged_road_marking"
|
||||||
<Bar dataKey="defective_culvert" fill="var(--color-defective_culvert)" radius={4} />
|
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>
|
</BarChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
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 { LatLngBounds, LatLng } from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { Detection } from '@/types';
|
import { Detection } from '@/types';
|
||||||
@@ -24,7 +30,9 @@ function FitBounds({ bounds }: { bounds: LatLngBounds }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardMapContent({ detections }: DashboardMapContentProps) {
|
export default function DashboardMapContent({
|
||||||
|
detections,
|
||||||
|
}: DashboardMapContentProps) {
|
||||||
if (detections.length === 0) {
|
if (detections.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full flex items-center justify-center bg-muted/20">
|
<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) {
|
if (config) {
|
||||||
return {
|
return {
|
||||||
fill: config.color,
|
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
|
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="space-y-2 mb-4">
|
||||||
<div className="flex justify-between items-baseline gap-4">
|
<div className="flex justify-between items-baseline gap-4">
|
||||||
<span className="text-muted-foreground text-xs">Class</span>
|
<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>
|
||||||
<div className="flex justify-between items-baseline gap-4">
|
<div className="flex justify-between items-baseline gap-4">
|
||||||
<span className="text-muted-foreground text-xs">Confidence</span>
|
<span className="text-muted-foreground text-xs">
|
||||||
<span className="font-medium text-right">{(detection.confidence * 100).toFixed(1)}%</span>
|
Confidence
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-right">
|
||||||
|
{(detection.confidence * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 border-t border-border/50">
|
<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="grid grid-cols-2 gap-3 bg-muted/40 p-2.5 rounded-md font-mono text-[11px]">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<span className="text-[9px] block text-muted-foreground/70 uppercase">Latitude</span>
|
<span className="text-[9px] block text-muted-foreground/70 uppercase">
|
||||||
<span className="font-medium tracking-tighter">{detection.latitude!.toFixed(6)}</span>
|
Latitude
|
||||||
|
</span>
|
||||||
|
<span className="font-medium tracking-tighter">
|
||||||
|
{detection.latitude!.toFixed(6)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<span className="text-[9px] block text-muted-foreground/70 uppercase">Longitude</span>
|
<span className="text-[9px] block text-muted-foreground/70 uppercase">
|
||||||
<span className="font-medium tracking-tighter">{detection.longitude!.toFixed(6)}</span>
|
Longitude
|
||||||
|
</span>
|
||||||
|
<span className="font-medium tracking-tighter">
|
||||||
|
{detection.longitude!.toFixed(6)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,15 +62,20 @@ export function DashboardMap({
|
|||||||
|
|
||||||
const chainagesToProcess =
|
const chainagesToProcess =
|
||||||
selectedChainageId && selectedChainageId !== 'all'
|
selectedChainageId && selectedChainageId !== 'all'
|
||||||
? { [selectedChainageId]: (pkg as any).chainages?.[selectedChainageId] }
|
? {
|
||||||
|
[selectedChainageId]: (pkg as any).chainages?.[
|
||||||
|
selectedChainageId
|
||||||
|
],
|
||||||
|
}
|
||||||
: (pkg as any).chainages || {};
|
: (pkg as any).chainages || {};
|
||||||
|
|
||||||
for (const [chnName, chn] of Object.entries(chainagesToProcess)) {
|
for (const [chnName, chn] of Object.entries(chainagesToProcess)) {
|
||||||
if (!chn) continue;
|
if (!chn) continue;
|
||||||
const chainageDetections = ((chn as any).detections || []).filter(
|
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);
|
);
|
||||||
|
filteredDetections.push(...chainageDetections);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +113,9 @@ export function DashboardMap({
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={`overflow-hidden transition-all duration-300 p-2 rounded-lg ${
|
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">
|
<CardContent className="p-0 relative rounded-lg">
|
||||||
@@ -119,9 +126,13 @@ export function DashboardMap({
|
|||||||
variant="default"
|
variant="default"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setIsMaximized(!isMaximized)}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,7 +144,10 @@ export function DashboardMap({
|
|||||||
{Object.values(DETECTION_TYPES).map((type) => {
|
{Object.values(DETECTION_TYPES).map((type) => {
|
||||||
const typeId = type.id.toLowerCase();
|
const typeId = type.id.toLowerCase();
|
||||||
const count = detections.filter(
|
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;
|
).length;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -143,16 +157,15 @@ export function DashboardMap({
|
|||||||
return null;
|
return null;
|
||||||
if (type.id === 'good_sign_board') 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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={type.id}
|
key={type.id}
|
||||||
onClick={() => toggleType(typeId)}
|
onClick={() => toggleType(typeId)}
|
||||||
className={`flex items-center gap-2 px-2 py-1.5 text-zinc-100 rounded-full transition-all text-xs ${
|
className={`flex items-center gap-2 px-2 py-1.5 text-zinc-100 rounded-full transition-all text-xs ${
|
||||||
isSelected
|
isSelected ? 'bg-zinc-800/80 ' : 'bg-transparent '
|
||||||
? 'bg-zinc-800/80 '
|
|
||||||
: 'bg-transparent '
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -169,7 +182,9 @@ export function DashboardMap({
|
|||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{isLoading ? (
|
||||||
<div className="h-full w-full flex items-center justify-center bg-muted/50">
|
<div className="h-full w-full flex items-center justify-center bg-muted/50">
|
||||||
<Loader2 className="h-8 w-8 text-primary animate-spin" />
|
<Loader2 className="h-8 w-8 text-primary animate-spin" />
|
||||||
@@ -181,7 +196,9 @@ export function DashboardMap({
|
|||||||
) : validDetections.length === 0 ? (
|
) : validDetections.length === 0 ? (
|
||||||
<div className="h-full w-full flex items-center justify-center bg-muted/50">
|
<div className="h-full w-full flex items-center justify-center bg-muted/50">
|
||||||
<div className="text-center">
|
<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">
|
<p className="text-sm text-muted-foreground mt-1 opacity-70">
|
||||||
Process some videos to see detections on the map
|
Process some videos to see detections on the map
|
||||||
</p>
|
</p>
|
||||||
@@ -192,7 +209,6 @@ export function DashboardMap({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom Left Label Overlay */}
|
{/* Bottom Left Label Overlay */}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -45,9 +45,21 @@ export function DashboardSkeleton() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="h-[300px] flex items-end justify-between gap-2 px-6 pb-8 pt-4">
|
<CardContent className="h-[300px] flex items-end justify-between gap-2 px-6 pb-8 pt-4">
|
||||||
{[...Array(8)].map((_, i) => {
|
{[...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 (
|
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
|
<Skeleton
|
||||||
className="w-full rounded-t-sm"
|
className="w-full rounded-t-sm"
|
||||||
style={{ height: heights[i % heights.length] }}
|
style={{ height: heights[i % heights.length] }}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function DetectionDonutChart({
|
|||||||
defectedSignboard,
|
defectedSignboard,
|
||||||
roadCrack,
|
roadCrack,
|
||||||
damagedRoadMarking,
|
damagedRoadMarking,
|
||||||
// goodSignboard,
|
// goodSignboard,
|
||||||
drainIssue,
|
drainIssue,
|
||||||
defectiveCulvert,
|
defectiveCulvert,
|
||||||
],
|
],
|
||||||
@@ -124,15 +124,32 @@ export function DetectionDonutChart({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px]">
|
<ChartContainer
|
||||||
|
config={chartConfig}
|
||||||
|
className="mx-auto aspect-square max-h-[250px]"
|
||||||
|
>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
|
<ChartTooltip
|
||||||
<Pie data={chartData} dataKey="count" nameKey="type" innerRadius={60} strokeWidth={5}>
|
cursor={false}
|
||||||
|
content={<ChartTooltipContent hideLabel />}
|
||||||
|
/>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
dataKey="count"
|
||||||
|
nameKey="type"
|
||||||
|
innerRadius={60}
|
||||||
|
strokeWidth={5}
|
||||||
|
>
|
||||||
<Label
|
<Label
|
||||||
content={({ viewBox }) => {
|
content={({ viewBox }) => {
|
||||||
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||||
return (
|
return (
|
||||||
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
|
<text
|
||||||
|
x={viewBox.cx}
|
||||||
|
y={viewBox.cy}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
>
|
||||||
<tspan
|
<tspan
|
||||||
x={viewBox.cx}
|
x={viewBox.cx}
|
||||||
y={viewBox.cy}
|
y={viewBox.cy}
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ export function StatsCard({
|
|||||||
<>
|
<>
|
||||||
<p className="text-3xl font-bold tracking-tight">{value}</p>
|
<p className="text-3xl font-bold tracking-tight">{value}</p>
|
||||||
{subtitle && (
|
{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>
|
</span>
|
||||||
{onPageSizeChange ? (
|
{onPageSizeChange ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span >Rows per page</span>
|
<span>Rows per page</span>
|
||||||
<Select
|
<Select
|
||||||
value={String(currentPageSize)}
|
value={String(currentPageSize)}
|
||||||
onValueChange={(value) => onPageSizeChange(Number(value))}
|
onValueChange={(value) => onPageSizeChange(Number(value))}
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import type { MouseEvent, ReactNode } from 'react';
|
|||||||
import { PermissionGuard } from '@/guards';
|
import { PermissionGuard } from '@/guards';
|
||||||
import type { PermissionInput } from '@/hooks/usePermissions';
|
import type { PermissionInput } from '@/hooks/usePermissions';
|
||||||
import { Button } from '@/components/ui/button';
|
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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface TableActionButtonProps {
|
interface TableActionButtonProps {
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { flexRender } from '@tanstack/react-table';
|
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 { Table } from '@tanstack/react-table';
|
||||||
import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide } from 'lucide-react';
|
import {
|
||||||
|
ArrowDownWideNarrow,
|
||||||
|
ArrowUpDown,
|
||||||
|
ArrowUpNarrowWide,
|
||||||
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
||||||
@@ -23,7 +31,9 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
|||||||
return (
|
return (
|
||||||
<TableHead
|
<TableHead
|
||||||
key={header.id}
|
key={header.id}
|
||||||
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
onClick={
|
||||||
|
canSort ? header.column.getToggleSortingHandler() : undefined
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-10 bg-card px-2 text-foreground transition-colors',
|
'h-10 bg-card px-2 text-foreground transition-colors',
|
||||||
canSort && 'cursor-pointer select-none hover:bg-muted/60',
|
canSort && 'cursor-pointer select-none hover:bg-muted/60',
|
||||||
@@ -33,13 +43,18 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
|||||||
{header.isPlaceholder ? null : (
|
{header.isPlaceholder ? null : (
|
||||||
<div className="flex min-h-8 w-full items-center justify-between gap-2">
|
<div className="flex min-h-8 w-full items-center justify-between gap-2">
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
{flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{canSort ? (
|
{canSort ? (
|
||||||
<SortIcon
|
<SortIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-3.5 shrink-0 transition-colors',
|
'size-3.5 shrink-0 transition-colors',
|
||||||
sortDirection ? 'text-foreground' : 'text-muted-foreground/60',
|
sortDirection
|
||||||
|
? 'text-foreground'
|
||||||
|
: 'text-muted-foreground/60',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -67,12 +67,15 @@ export function DataTable<TData, TValue>({
|
|||||||
onSortingChange,
|
onSortingChange,
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [rowSelection, setRowSelection] = React.useState({});
|
const [rowSelection, setRowSelection] = React.useState({});
|
||||||
const [internalSorting, setInternalSorting] = React.useState<SortingState>([]);
|
const [internalSorting, setInternalSorting] = React.useState<SortingState>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
const sorting = controlledSorting ?? internalSorting;
|
const sorting = controlledSorting ?? internalSorting;
|
||||||
const isManualSorting = Boolean(onSortingChange);
|
const isManualSorting = Boolean(onSortingChange);
|
||||||
const handleSortingChange = React.useCallback(
|
const handleSortingChange = React.useCallback(
|
||||||
(updater: SortingState | ((old: SortingState) => SortingState)) => {
|
(updater: SortingState | ((old: SortingState) => SortingState)) => {
|
||||||
const nextSorting = typeof updater === 'function' ? updater(sorting) : updater;
|
const nextSorting =
|
||||||
|
typeof updater === 'function' ? updater(sorting) : updater;
|
||||||
if (onSortingChange) {
|
if (onSortingChange) {
|
||||||
onSortingChange(nextSorting);
|
onSortingChange(nextSorting);
|
||||||
return;
|
return;
|
||||||
@@ -95,7 +98,9 @@ export function DataTable<TData, TValue>({
|
|||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
{actions.map((action) => {
|
{actions.map((action) => {
|
||||||
const label =
|
const label =
|
||||||
typeof action.label === 'function' ? action.label(item) : action.label;
|
typeof action.label === 'function'
|
||||||
|
? action.label(item)
|
||||||
|
: action.label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableActionButton
|
<TableActionButton
|
||||||
@@ -131,7 +136,9 @@ export function DataTable<TData, TValue>({
|
|||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
manualPagination: !!pagination,
|
manualPagination: !!pagination,
|
||||||
manualSorting: isManualSorting,
|
manualSorting: isManualSorting,
|
||||||
pageCount: pagination?.totalItems ? Math.ceil(pagination.totalItems / pagination.limit) : -1,
|
pageCount: pagination?.totalItems
|
||||||
|
? Math.ceil(pagination.totalItems / pagination.limit)
|
||||||
|
: -1,
|
||||||
state: {
|
state: {
|
||||||
rowSelection,
|
rowSelection,
|
||||||
sorting,
|
sorting,
|
||||||
@@ -191,17 +198,26 @@ export function DataTable<TData, TValue>({
|
|||||||
))
|
))
|
||||||
) : table.getRowModel().rows?.length ? (
|
) : table.getRowModel().rows?.length ? (
|
||||||
table.getRowModel().rows.map((row) => (
|
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) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<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-sm font-medium">{emptyTitle}</p>
|
||||||
<p className="text-xs">{emptyDescription}</p>
|
<p className="text-xs">{emptyDescription}</p>
|
||||||
|
|||||||
@@ -30,13 +30,16 @@ export function FormField({
|
|||||||
<div className={cn('space-y-2', className)}>
|
<div className={cn('space-y-2', className)}>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<Label htmlFor={id} className={labelClassName}>
|
<Label htmlFor={id} className={labelClassName}>
|
||||||
{label} {required ? <span className="text-destructive">*</span> : null}
|
{label}{' '}
|
||||||
|
{required ? <span className="text-destructive">*</span> : null}
|
||||||
</Label>
|
</Label>
|
||||||
{labelEnd}
|
{labelEnd}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
{error ? (
|
{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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,15 +48,19 @@ export function RoleCombobox({
|
|||||||
effective_status: true,
|
effective_status: true,
|
||||||
sort_by: 'display_name',
|
sort_by: 'display_name',
|
||||||
sort_order: 'asc',
|
sort_order: 'asc',
|
||||||
}),
|
}),
|
||||||
mapOption: mapRoleOption,
|
mapOption: mapRoleOption,
|
||||||
resolveSelected: (roleId) => {
|
resolveSelected: (roleId) => {
|
||||||
const numericRoleId = parseRoleId(roleId);
|
const numericRoleId = parseRoleId(roleId);
|
||||||
return numericRoleId ? roleService.getRoleById(numericRoleId) : Promise.resolve(null);
|
return numericRoleId
|
||||||
|
? roleService.getRoleById(numericRoleId)
|
||||||
|
: Promise.resolve(null);
|
||||||
},
|
},
|
||||||
resolveSelectedQueryKey: (roleId) => {
|
resolveSelectedQueryKey: (roleId) => {
|
||||||
const numericRoleId = parseRoleId(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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { MapContainer, TileLayer, Polyline, CircleMarker, Popup, useMap } from 'react-leaflet';
|
import {
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
MapContainer,
|
||||||
|
TileLayer,
|
||||||
|
Polyline,
|
||||||
|
CircleMarker,
|
||||||
|
Popup,
|
||||||
|
useMap,
|
||||||
|
} from 'react-leaflet';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { LatLngBounds, LatLng } from 'leaflet';
|
import { LatLngBounds, LatLng } from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { getDetectionModeConfig, DETECTION_TYPES } from '@/constants/detectionModeConfig';
|
import {
|
||||||
|
getDetectionModeConfig,
|
||||||
|
DETECTION_TYPES,
|
||||||
|
} from '@/constants/detectionModeConfig';
|
||||||
import { DetectionType } from '@/types';
|
import { DetectionType } from '@/types';
|
||||||
|
|
||||||
type Detection = {
|
type Detection = {
|
||||||
@@ -47,7 +62,12 @@ function FitBounds({ bounds }: { bounds: LatLngBounds }) {
|
|||||||
return null;
|
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);
|
const validDetections = detections.filter((d) => d.latitude && d.longitude);
|
||||||
|
|
||||||
if (validDetections.length === 0) {
|
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]),
|
||||||
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] = [
|
const center: [number, number] = [
|
||||||
(bounds.getNorth() + bounds.getSouth()) / 2,
|
(bounds.getNorth() + bounds.getSouth()) / 2,
|
||||||
(bounds.getEast() + bounds.getWest()) / 2,
|
(bounds.getEast() + bounds.getWest()) / 2,
|
||||||
@@ -85,20 +107,32 @@ export default function MapModal({ open, onClose, detections, detectionType }: M
|
|||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-6xl h-[85vh] p-0 flex flex-col overflow-hidden border-none shadow-2xl">
|
<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">
|
<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">
|
<p className="text-xs text-muted-foreground font-medium">
|
||||||
{validDetections.length} points of interest identified with GPS data
|
{validDetections.length} points of interest identified with GPS data
|
||||||
</p>
|
</p>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 w-full relative bg-muted/20">
|
<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
|
<TileLayer
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
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) => {
|
{validDetections.map((detection, idx) => {
|
||||||
const type = (detection.type || '').toLowerCase();
|
const type = (detection.type || '').toLowerCase();
|
||||||
@@ -121,30 +155,51 @@ export default function MapModal({ open, onClose, detections, detectionType }: M
|
|||||||
<Popup>
|
<Popup>
|
||||||
<div className="text-sm min-w-[200px] py-1">
|
<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">
|
<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>
|
||||||
|
|
||||||
<div className="space-y-2 mb-4">
|
<div className="space-y-2 mb-4">
|
||||||
<div className="flex justify-between items-baseline gap-4">
|
<div className="flex justify-between items-baseline gap-4">
|
||||||
<span className="text-muted-foreground text-xs">Frame</span>
|
<span className="text-muted-foreground text-xs">
|
||||||
<span className="font-medium text-right font-mono">{detection.frame_number}</span>
|
Frame
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-right font-mono">
|
||||||
|
{detection.frame_number}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-baseline gap-4">
|
<div className="flex justify-between items-baseline gap-4">
|
||||||
<span className="text-muted-foreground text-xs">Confidence</span>
|
<span className="text-muted-foreground text-xs">
|
||||||
<span className="font-medium text-right">{(detection.confidence * 100).toFixed(1)}%</span>
|
Confidence
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-right">
|
||||||
|
{(detection.confidence * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 border-t border-border/50">
|
<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="grid grid-cols-2 gap-3 bg-muted/40 p-2.5 rounded-md font-mono text-[11px]">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<span className="text-[9px] block text-muted-foreground/70 uppercase">Latitude</span>
|
<span className="text-[9px] block text-muted-foreground/70 uppercase">
|
||||||
<span className="font-medium tracking-tighter">{detection.latitude.toFixed(6)}</span>
|
Latitude
|
||||||
|
</span>
|
||||||
|
<span className="font-medium tracking-tighter">
|
||||||
|
{detection.latitude.toFixed(6)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<span className="text-[9px] block text-muted-foreground/70 uppercase">Longitude</span>
|
<span className="text-[9px] block text-muted-foreground/70 uppercase">
|
||||||
<span className="font-medium tracking-tighter">{detection.longitude.toFixed(6)}</span>
|
Longitude
|
||||||
|
</span>
|
||||||
|
<span className="font-medium tracking-tighter">
|
||||||
|
{detection.longitude.toFixed(6)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,9 +25,15 @@ export function ModeToggle() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||||
<DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem>
|
Light
|
||||||
<DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||||
|
Dark
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||||
|
System
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
'use client';
|
'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 { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
@@ -118,15 +125,15 @@ export function NavUser({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={(event) => {
|
onSelect={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleLogout();
|
handleLogout();
|
||||||
}}
|
}}
|
||||||
disabled={isLoggingOut}
|
disabled={isLoggingOut}
|
||||||
>
|
>
|
||||||
<LogOut />
|
<LogOut />
|
||||||
{isLoggingOut ? 'Logging out' : 'Log out'}
|
{isLoggingOut ? 'Logging out' : 'Log out'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -11,15 +11,20 @@ interface PageHeaderProps {
|
|||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageHeader({ title, description, icon: Icon, children, actions }: PageHeaderProps) {
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
children,
|
||||||
|
actions,
|
||||||
|
}: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<Reveal direction="down" className="w-full">
|
<Reveal direction="down" className="w-full">
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-col">
|
<h1>{title}</h1>
|
||||||
<h1>{title}</h1>
|
<p className="text-muted-foreground opacity-80">{description}</p>
|
||||||
<p className="text-muted-foreground opacity-80">{description}</p>
|
</div>
|
||||||
</div>
|
|
||||||
{actions && <div className="flex items-center gap-4">{actions}</div>}
|
{actions && <div className="flex items-center gap-4">{actions}</div>}
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import { CheckboxTree } from '@/components/ui/tree';
|
import { CheckboxTree } from '@/components/ui/tree';
|
||||||
import type { PermissionTreeItem, PermissionTreeNode } from '@/types';
|
import type { PermissionTreeItem, PermissionTreeNode } from '@/types';
|
||||||
|
|
||||||
export function mapPermissionTree(nodes: PermissionTreeNode[]): PermissionTreeItem[] {
|
export function mapPermissionTree(
|
||||||
|
nodes: PermissionTreeNode[],
|
||||||
|
): PermissionTreeItem[] {
|
||||||
return nodes.map((node) => ({
|
return nodes.map((node) => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
key: node.slug || node.name,
|
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) => [
|
return nodes.flatMap((node) => [
|
||||||
...(node.isGrantedByDefault ? [node.id] : []),
|
...(node.isGrantedByDefault ? [node.id] : []),
|
||||||
...collectDefaultPermissionIds(node.children),
|
...collectDefaultPermissionIds(node.children),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collectPermissionIdsByKeys(nodes: PermissionTreeItem[], keys: string[]): number[] {
|
export function collectPermissionIdsByKeys(
|
||||||
|
nodes: PermissionTreeItem[],
|
||||||
|
keys: string[],
|
||||||
|
): number[] {
|
||||||
const selectedKeys = new Set(keys);
|
const selectedKeys = new Set(keys);
|
||||||
return nodes.flatMap((node) => [
|
return nodes.flatMap((node) => [
|
||||||
...(selectedKeys.has(node.key) ? [node.id] : []),
|
...(selectedKeys.has(node.key) ? [node.id] : []),
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
@@ -12,8 +18,17 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Loader2, Check, ArrowUp, ArrowDown } from 'lucide-react';
|
import { Loader2, Check, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
import { projectService, packageService, chainageService } from '@/services/api';
|
import {
|
||||||
import { Project, Package as PackageType, Chainage, SessionContext } from '@/types';
|
projectService,
|
||||||
|
packageService,
|
||||||
|
chainageService,
|
||||||
|
} from '@/services/api';
|
||||||
|
import {
|
||||||
|
Project,
|
||||||
|
Package as PackageType,
|
||||||
|
Chainage,
|
||||||
|
SessionContext,
|
||||||
|
} from '@/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type ProjectSelectionSectionProps = {
|
type ProjectSelectionSectionProps = {
|
||||||
@@ -30,17 +45,23 @@ export function ProjectSelectionSection({
|
|||||||
onSelectionComplete,
|
onSelectionComplete,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
asStep = false,
|
asStep = false,
|
||||||
hideButton = false
|
hideButton = false,
|
||||||
}: ProjectSelectionSectionProps) {
|
}: ProjectSelectionSectionProps) {
|
||||||
// Data states
|
// Data states
|
||||||
const [projects, setProjects] = useState<Project[]>(globalProjectsCache || []);
|
const [projects, setProjects] = useState<Project[]>(
|
||||||
|
globalProjectsCache || [],
|
||||||
|
);
|
||||||
const [packages, setPackages] = useState<PackageType[]>([]);
|
const [packages, setPackages] = useState<PackageType[]>([]);
|
||||||
const [chainages, setChainages] = useState<Chainage[]>([]);
|
const [chainages, setChainages] = useState<Chainage[]>([]);
|
||||||
|
|
||||||
// Selection states
|
// Selection states
|
||||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||||
const [selectedPackage, setSelectedPackage] = useState<PackageType | null>(null);
|
const [selectedPackage, setSelectedPackage] = useState<PackageType | null>(
|
||||||
const [selectedChainage, setSelectedChainage] = useState<Chainage | null>(null);
|
null,
|
||||||
|
);
|
||||||
|
const [selectedChainage, setSelectedChainage] = useState<Chainage | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
// Loading states
|
// Loading states
|
||||||
const [loadingProjects, setLoadingProjects] = useState(!globalProjectsCache);
|
const [loadingProjects, setLoadingProjects] = useState(!globalProjectsCache);
|
||||||
@@ -66,13 +87,16 @@ export function ProjectSelectionSection({
|
|||||||
|
|
||||||
// If a fetch is already in flight, reuse that promise
|
// If a fetch is already in flight, reuse that promise
|
||||||
if (!projectsPromise) {
|
if (!projectsPromise) {
|
||||||
projectsPromise = projectService.getProjects().then(data => {
|
projectsPromise = projectService
|
||||||
globalProjectsCache = data.items;
|
.getProjects()
|
||||||
return data.items;
|
.then((data) => {
|
||||||
}).catch(err => {
|
globalProjectsCache = data.items;
|
||||||
projectsPromise = null; // Reset on error to allow retry
|
return data.items;
|
||||||
throw err;
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
|
projectsPromise = null; // Reset on error to allow retry
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -93,7 +117,9 @@ export function ProjectSelectionSection({
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadProjects();
|
loadProjects();
|
||||||
return () => { isMounted = false; };
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load packages when project changes
|
// Load packages when project changes
|
||||||
@@ -112,7 +138,9 @@ export function ProjectSelectionSection({
|
|||||||
setSelectedPackage(null);
|
setSelectedPackage(null);
|
||||||
setSelectedChainage(null);
|
setSelectedChainage(null);
|
||||||
setChainages([]);
|
setChainages([]);
|
||||||
const data = await packageService.getPackagesByProject(selectedProject.id);
|
const data = await packageService.getPackagesByProject(
|
||||||
|
selectedProject.id,
|
||||||
|
);
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setPackages(data.items);
|
setPackages(data.items);
|
||||||
}
|
}
|
||||||
@@ -128,7 +156,9 @@ export function ProjectSelectionSection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadPackages();
|
loadPackages();
|
||||||
return () => { isMounted = false; };
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
}, [selectedProject]);
|
}, [selectedProject]);
|
||||||
|
|
||||||
// Load chainages when package changes
|
// Load chainages when package changes
|
||||||
@@ -145,7 +175,9 @@ export function ProjectSelectionSection({
|
|||||||
setLoadingChainages(true);
|
setLoadingChainages(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSelectedChainage(null);
|
setSelectedChainage(null);
|
||||||
const data = await chainageService.getChainagesByPackage(selectedPackage.id);
|
const data = await chainageService.getChainagesByPackage(
|
||||||
|
selectedPackage.id,
|
||||||
|
);
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setChainages(data.items);
|
setChainages(data.items);
|
||||||
}
|
}
|
||||||
@@ -161,7 +193,9 @@ export function ProjectSelectionSection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadChainages();
|
loadChainages();
|
||||||
return () => { isMounted = false; };
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
}, [selectedPackage]);
|
}, [selectedPackage]);
|
||||||
|
|
||||||
const handleProjectChange = (projectId: string) => {
|
const handleProjectChange = (projectId: string) => {
|
||||||
@@ -185,9 +219,10 @@ export function ProjectSelectionSection({
|
|||||||
// Handle change reporting
|
// Handle change reporting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onSelectionChange) {
|
if (onSelectionChange) {
|
||||||
const currentIds = selectedProject && selectedPackage && selectedChainage
|
const currentIds =
|
||||||
? `${selectedProject.id}-${selectedPackage.id}-${selectedChainage.id}`
|
selectedProject && selectedPackage && selectedChainage
|
||||||
: null;
|
? `${selectedProject.id}-${selectedPackage.id}-${selectedChainage.id}`
|
||||||
|
: null;
|
||||||
|
|
||||||
if (currentIds !== lastReportedIdRef.current) {
|
if (currentIds !== lastReportedIdRef.current) {
|
||||||
lastReportedIdRef.current = currentIds;
|
lastReportedIdRef.current = currentIds;
|
||||||
@@ -210,7 +245,12 @@ export function ProjectSelectionSection({
|
|||||||
}, [selectedProject, selectedPackage, selectedChainage, onSelectionChange]);
|
}, [selectedProject, selectedPackage, selectedChainage, onSelectionChange]);
|
||||||
|
|
||||||
const handleProceed = () => {
|
const handleProceed = () => {
|
||||||
if (selectedProject && selectedPackage && selectedChainage && onSelectionComplete) {
|
if (
|
||||||
|
selectedProject &&
|
||||||
|
selectedPackage &&
|
||||||
|
selectedChainage &&
|
||||||
|
onSelectionComplete
|
||||||
|
) {
|
||||||
onSelectionComplete({
|
onSelectionComplete({
|
||||||
projectId: selectedProject.id,
|
projectId: selectedProject.id,
|
||||||
projectName: selectedProject.name,
|
projectName: selectedProject.name,
|
||||||
@@ -228,8 +268,18 @@ export function ProjectSelectionSection({
|
|||||||
// Step status helpers
|
// Step status helpers
|
||||||
const getStepStatus = (step: number) => {
|
const getStepStatus = (step: number) => {
|
||||||
if (step === 1) return selectedProject ? 'completed' : 'active';
|
if (step === 1) return selectedProject ? 'completed' : 'active';
|
||||||
if (step === 2) return selectedPackage ? 'completed' : selectedProject ? 'active' : 'pending';
|
if (step === 2)
|
||||||
if (step === 3) return selectedChainage ? 'completed' : selectedPackage ? 'active' : 'pending';
|
return selectedPackage
|
||||||
|
? 'completed'
|
||||||
|
: selectedProject
|
||||||
|
? 'active'
|
||||||
|
: 'pending';
|
||||||
|
if (step === 3)
|
||||||
|
return selectedChainage
|
||||||
|
? 'completed'
|
||||||
|
: selectedPackage
|
||||||
|
? 'active'
|
||||||
|
: 'pending';
|
||||||
return 'pending';
|
return 'pending';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -291,7 +341,9 @@ export function ProjectSelectionSection({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={selectedProject ? 'Select package' : 'Select project first'}
|
placeholder={
|
||||||
|
selectedProject ? 'Select package' : 'Select project first'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -323,7 +375,9 @@ export function ProjectSelectionSection({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={selectedPackage ? 'Select segment' : 'Select package first'}
|
placeholder={
|
||||||
|
selectedPackage ? 'Select segment' : 'Select package first'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -331,9 +385,13 @@ export function ProjectSelectionSection({
|
|||||||
{chainages.map((chn) => (
|
{chainages.map((chn) => (
|
||||||
<SelectItem key={chn.id} value={chn.id}>
|
<SelectItem key={chn.id} value={chn.id}>
|
||||||
<div className="flex flex-col gap-0.5 py-0.5">
|
<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">
|
<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="h-2.5 w-px bg-border" />
|
||||||
<span className="flex items-center gap-1 uppercase">
|
<span className="flex items-center gap-1 uppercase">
|
||||||
{chn.direction === 'UP' ? (
|
{chn.direction === 'UP' ? (
|
||||||
@@ -399,9 +457,12 @@ export function ProjectSelectionSection({
|
|||||||
<CardHeader className="pb-6">
|
<CardHeader className="pb-6">
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div>
|
<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">
|
<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>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -423,12 +484,18 @@ export function ProjectSelectionSection({
|
|||||||
: 'bg-muted text-muted-foreground',
|
: 'bg-muted text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === 'completed' ? <Check className="h-5 w-5" /> : step}
|
{status === 'completed' ? (
|
||||||
|
<Check className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
step
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs mt-2 font-medium',
|
'text-xs mt-2 font-medium',
|
||||||
status === 'pending' ? 'text-muted-foreground/60' : 'text-foreground',
|
status === 'pending'
|
||||||
|
? 'text-muted-foreground/60'
|
||||||
|
: 'text-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{labels[index]}
|
{labels[index]}
|
||||||
@@ -438,7 +505,9 @@ export function ProjectSelectionSection({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-16 h-0.5 mx-2 mb-6 rounded-full',
|
'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>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">{content}</CardContent>
|
||||||
{content}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,26 @@ import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
|||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
function Avatar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
return (
|
return (
|
||||||
<AvatarPrimitive.Root
|
<AvatarPrimitive.Root
|
||||||
data-slot="avatar"
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
return (
|
return (
|
||||||
<AvatarPrimitive.Image
|
<AvatarPrimitive.Image
|
||||||
data-slot="avatar-image"
|
data-slot="avatar-image"
|
||||||
@@ -32,7 +41,10 @@ function AvatarFallback({
|
|||||||
return (
|
return (
|
||||||
<AvatarPrimitive.Fallback
|
<AvatarPrimitive.Fallback
|
||||||
data-slot="avatar-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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ const badgeVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
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:
|
destructive:
|
||||||
'bg-destructive text-white dark:bg-destructive/60 [a&]:hover:bg-destructive/90',
|
'bg-destructive text-white dark:bg-destructive/60 [a&]:hover:bg-destructive/90',
|
||||||
outline:
|
outline:
|
||||||
@@ -30,7 +31,8 @@ function Badge({
|
|||||||
variant = 'default',
|
variant = 'default',
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
}: React.ComponentProps<'span'> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot.Root : 'span';
|
const Comp = asChild ? Slot.Root : 'span';
|
||||||
|
|
||||||
return (
|
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 (
|
return (
|
||||||
<li
|
<li
|
||||||
data-slot="breadcrumb-separator"
|
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 (
|
return (
|
||||||
<span
|
<span
|
||||||
data-slot="breadcrumb-ellipsis"
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ const buttonVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
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:
|
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',
|
'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',
|
secondary:
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
'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',
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -52,14 +52,23 @@ function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-action"
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
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'>) {
|
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
|
...props
|
||||||
}: React.ComponentProps<'div'> & {
|
}: React.ComponentProps<'div'> & {
|
||||||
config: ChartConfig;
|
config: ChartConfig;
|
||||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>['children'];
|
||||||
}) {
|
}) {
|
||||||
const uniqueId = React.useId();
|
const uniqueId = React.useId();
|
||||||
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
|
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
|
||||||
@@ -59,14 +61,18 @@ function ChartContainer({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChartStyle id={chartId} config={config} />
|
<ChartStyle id={chartId} config={config} />
|
||||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</ChartContext.Provider>
|
</ChartContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
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) {
|
if (!colorConfig.length) {
|
||||||
return null;
|
return null;
|
||||||
@@ -81,7 +87,9 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
|||||||
${prefix} [data-chart=${id}] {
|
${prefix} [data-chart=${id}] {
|
||||||
${colorConfig
|
${colorConfig
|
||||||
.map(([key, itemConfig]) => {
|
.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;
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
})
|
})
|
||||||
.join('\n')}
|
.join('\n')}
|
||||||
@@ -135,7 +143,9 @@ function ChartTooltipContent({
|
|||||||
|
|
||||||
if (labelFormatter) {
|
if (labelFormatter) {
|
||||||
return (
|
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>;
|
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) {
|
if (!active || !payload?.length) {
|
||||||
return null;
|
return null;
|
||||||
@@ -287,29 +305,42 @@ function ChartLegendContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper to extract item config from a payload.
|
// 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) {
|
if (typeof payload !== 'object' || payload === null) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadPayload =
|
const payloadPayload =
|
||||||
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
|
'payload' in payload &&
|
||||||
|
typeof payload.payload === 'object' &&
|
||||||
|
payload.payload !== null
|
||||||
? payload.payload
|
? payload.payload
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
let configLabelKey: string = key;
|
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;
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
} else if (
|
} else if (
|
||||||
payloadPayload &&
|
payloadPayload &&
|
||||||
key in payloadPayload &&
|
key in payloadPayload &&
|
||||||
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
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 {
|
export {
|
||||||
|
|||||||
@@ -2,20 +2,32 @@
|
|||||||
|
|
||||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
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} />;
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsibleTrigger({
|
function CollapsibleTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />;
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsibleContent({
|
function CollapsibleContent({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
}: 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 };
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ function ComboboxTrigger({
|
|||||||
className="pointer-events-none size-4 text-muted-foreground"
|
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, {
|
? React.cloneElement(render, {
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
@@ -81,7 +83,10 @@ function ComboboxInput({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<InputGroup className={cn('w-auto', className)}>
|
<InputGroup className={cn('w-auto', className)}>
|
||||||
<ComboboxPrimitive.Input render={<InputGroupInput disabled={disabled} />} {...props} />
|
<ComboboxPrimitive.Input
|
||||||
|
render={<InputGroupInput disabled={disabled} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
<InputGroupAddon align="inline-end">
|
<InputGroupAddon align="inline-end">
|
||||||
{showTrigger && (
|
{showTrigger && (
|
||||||
<InputGroupButton
|
<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 (
|
return (
|
||||||
<ComboboxPrimitive.Item
|
<ComboboxPrimitive.Item
|
||||||
data-slot="combobox-item"
|
data-slot="combobox-item"
|
||||||
@@ -180,11 +189,18 @@ function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.
|
|||||||
|
|
||||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<ComboboxPrimitive.GroupLabel
|
<ComboboxPrimitive.GroupLabel
|
||||||
data-slot="combobox-label"
|
data-slot="combobox-label"
|
||||||
@@ -198,7 +214,9 @@ function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
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) {
|
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 (
|
return (
|
||||||
<ComboboxPrimitive.Separator
|
<ComboboxPrimitive.Separator
|
||||||
data-slot="combobox-separator"
|
data-slot="combobox-separator"
|
||||||
@@ -227,7 +248,8 @@ function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.
|
|||||||
function ComboboxChips({
|
function ComboboxChips({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> & ComboboxPrimitive.Chips.Props) {
|
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||||
|
ComboboxPrimitive.Chips.Props) {
|
||||||
return (
|
return (
|
||||||
<ComboboxPrimitive.Chips
|
<ComboboxPrimitive.Chips
|
||||||
data-slot="combobox-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 (
|
return (
|
||||||
<ComboboxPrimitive.Input
|
<ComboboxPrimitive.Input
|
||||||
data-slot="combobox-chip-input"
|
data-slot="combobox-chip-input"
|
||||||
|
|||||||
@@ -7,19 +7,27 @@ import { Dialog as DialogPrimitive } from 'radix-ui';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
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} />;
|
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} />;
|
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} />;
|
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} />;
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +107,10 @@ function DialogFooter({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
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}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -112,8 +123,17 @@ function DialogFooter({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
function DialogTitle({
|
||||||
return <DialogPrimitive.Title data-slot="dialog-title" className={cn(className)} {...props} />;
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogDescription({
|
function DialogDescription({
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"use client"
|
'use client';
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from 'react';
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function DropdownMenu({
|
function DropdownMenu({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuPortal({
|
function DropdownMenuPortal({
|
||||||
@@ -17,7 +17,7 @@ function DropdownMenuPortal({
|
|||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
function DropdownMenuTrigger({
|
||||||
@@ -28,7 +28,7 @@ function DropdownMenuTrigger({
|
|||||||
data-slot="dropdown-menu-trigger"
|
data-slot="dropdown-menu-trigger"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuContent({
|
function DropdownMenuContent({
|
||||||
@@ -42,13 +42,13 @@ function DropdownMenuContent({
|
|||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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",
|
'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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
function DropdownMenuGroup({
|
||||||
@@ -56,17 +56,17 @@ function DropdownMenuGroup({
|
|||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuItem({
|
function DropdownMenuItem({
|
||||||
className,
|
className,
|
||||||
inset,
|
inset,
|
||||||
variant = "default",
|
variant = 'default',
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
variant?: "default" | "destructive"
|
variant?: 'default' | 'destructive';
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
@@ -75,11 +75,11 @@ function DropdownMenuItem({
|
|||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
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!",
|
"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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
function DropdownMenuCheckboxItem({
|
||||||
@@ -93,7 +93,7 @@ function DropdownMenuCheckboxItem({
|
|||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
className={cn(
|
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",
|
"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}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -105,7 +105,7 @@ function DropdownMenuCheckboxItem({
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({
|
function DropdownMenuRadioGroup({
|
||||||
@@ -116,7 +116,7 @@ function DropdownMenuRadioGroup({
|
|||||||
data-slot="dropdown-menu-radio-group"
|
data-slot="dropdown-menu-radio-group"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioItem({
|
function DropdownMenuRadioItem({
|
||||||
@@ -129,7 +129,7 @@ function DropdownMenuRadioItem({
|
|||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
className={cn(
|
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",
|
"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}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -140,7 +140,7 @@ function DropdownMenuRadioItem({
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuLabel({
|
function DropdownMenuLabel({
|
||||||
@@ -148,19 +148,19 @@ function DropdownMenuLabel({
|
|||||||
inset,
|
inset,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
data-slot="dropdown-menu-label"
|
data-slot="dropdown-menu-label"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSeparator({
|
function DropdownMenuSeparator({
|
||||||
@@ -170,32 +170,32 @@ function DropdownMenuSeparator({
|
|||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
data-slot="dropdown-menu-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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuShortcut({
|
function DropdownMenuShortcut({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"span">) {
|
}: React.ComponentProps<'span'>) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-slot="dropdown-menu-shortcut"
|
data-slot="dropdown-menu-shortcut"
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSub({
|
function DropdownMenuSub({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
}: 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({
|
function DropdownMenuSubTrigger({
|
||||||
@@ -204,7 +204,7 @@ function DropdownMenuSubTrigger({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
@@ -212,14 +212,14 @@ function DropdownMenuSubTrigger({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
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",
|
"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}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
function DropdownMenuSubContent({
|
||||||
@@ -230,12 +230,12 @@ function DropdownMenuSubContent({
|
|||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
className={cn(
|
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",
|
'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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -254,4 +254,4 @@ export {
|
|||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ const inputGroupAddonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
align: {
|
align: {
|
||||||
'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
|
'inline-start':
|
||||||
'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
|
'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':
|
'block-start':
|
||||||
'order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3',
|
'order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3',
|
||||||
'block-end':
|
'block-end':
|
||||||
@@ -77,19 +79,23 @@ function InputGroupAddon({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputGroupButtonVariants = cva('flex items-center gap-2 text-sm shadow-none', {
|
const inputGroupButtonVariants = cva(
|
||||||
variants: {
|
'flex items-center gap-2 text-sm shadow-none',
|
||||||
size: {
|
{
|
||||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
|
variants: {
|
||||||
sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5',
|
size: {
|
||||||
'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
|
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
|
||||||
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
|
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-sm': 'size-8 p-0 has-[>svg]:p-0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'xs',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
);
|
||||||
size: 'xs',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function InputGroupButton({
|
function InputGroupButton({
|
||||||
className,
|
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 (
|
return (
|
||||||
<Input
|
<Input
|
||||||
data-slot="input-group-control"
|
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 (
|
return (
|
||||||
<Textarea
|
<Textarea
|
||||||
data-slot="input-group-control"
|
data-slot="input-group-control"
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import * as LabelPrimitive from '@radix-ui/react-label';
|
|||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
return (
|
return (
|
||||||
<LabelPrimitive.Root
|
<LabelPrimitive.Root
|
||||||
data-slot="label"
|
data-slot="label"
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import * as React from 'react';
|
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 { cn } from '@/lib/utils';
|
||||||
import { Button, buttonVariants } from '@/components/ui/button';
|
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 (
|
return (
|
||||||
<ul
|
<ul
|
||||||
data-slot="pagination-content"
|
data-slot="pagination-content"
|
||||||
@@ -35,7 +42,12 @@ type PaginationLinkProps = {
|
|||||||
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
|
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
|
||||||
React.ComponentProps<'a'>;
|
React.ComponentProps<'a'>;
|
||||||
|
|
||||||
function PaginationLink({ className, isActive, size = 'icon', ...props }: PaginationLinkProps) {
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = 'icon',
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
aria-current={isActive ? 'page' : undefined}
|
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 (
|
return (
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
aria-label="Go to previous page"
|
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 (
|
return (
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
aria-label="Go to next page"
|
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 (
|
return (
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import { Popover as PopoverPrimitive } from 'radix-ui';
|
|||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
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} />;
|
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} />;
|
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} />;
|
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'>) {
|
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 (
|
return (
|
||||||
<p
|
<p
|
||||||
data-slot="popover-description"
|
data-slot="popover-description"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user