Compare commits
3 Commits
182d4ac293
...
4c2241ff72
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c2241ff72 | |||
| b54242cf7e | |||
| 15f54372f4 |
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
build_log.txt
BIN
build_log.txt
Binary file not shown.
@@ -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",
|
||||||
|
|||||||
@@ -6,45 +6,21 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { GuestGuard } from '@/guards';
|
import { GuestGuard } from '@/guards';
|
||||||
import { useLoginForm } from '@/hooks/useLoginForm';
|
import { useLoginForm } from '@/hooks/useLoginForm';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { register, handleSubmit, errors, isLoading } = useLoginForm();
|
const { register, handleSubmit, errors, isLoading } = useLoginForm();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GuestGuard>
|
<GuestGuard>
|
||||||
<main className="grid min-h-screen bg-background text-foreground lg:grid-cols-[1.05fr_0.95fr]">
|
<div className="flex min-h-screen w-full">
|
||||||
<section className="relative hidden overflow-hidden border-r border-border/60 lg:block">
|
<div className="hidden md:flex w-[40%]" />
|
||||||
<Image
|
<div className="flex flex-1 md:w-[60%] items-center justify-center p-6 border-l border-border h-screen">
|
||||||
src="/background.png"
|
<section className="w-full max-w-sm space-y-7">
|
||||||
alt=""
|
<header className="space-y-2 text-center md:text-left">
|
||||||
fill
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
priority
|
|
||||||
className="object-cover"
|
|
||||||
sizes="55vw"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-background/35" />
|
|
||||||
<div className="absolute inset-x-0 bottom-0 p-10">
|
|
||||||
<div className="max-w-lg space-y-3">
|
|
||||||
<p className="text-sm font-medium uppercase tracking-[0.18em] text-primary">
|
|
||||||
VisionRoad
|
VisionRoad
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-4xl font-semibold tracking-normal text-foreground">
|
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
|
||||||
Road intelligence for faster inspection decisions
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm leading-6 text-muted-foreground">
|
|
||||||
Review projects, uploads, detections, and chainage insights from one secure
|
|
||||||
workspace.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="flex min-h-screen items-center justify-center px-6 py-10">
|
|
||||||
<div className="w-full max-w-sm space-y-7">
|
|
||||||
<header className="space-y-2">
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">VisionRoad</p>
|
|
||||||
<h2 className="text-2xl font-semibold tracking-normal">Sign in</h2>
|
|
||||||
<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.
|
||||||
</p>
|
</p>
|
||||||
@@ -65,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>
|
||||||
|
|
||||||
@@ -83,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>
|
||||||
|
|
||||||
@@ -98,9 +78,9 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
</GuestGuard>
|
</GuestGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -2,12 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Edit, RotateCcw } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
|
||||||
import { usePermissions } from '@/hooks/usePermissions';
|
|
||||||
import type { Client } from '@/types';
|
import type { Client } from '@/types';
|
||||||
|
|
||||||
function formatDate(value?: string | null) {
|
function formatDate(value?: string | null) {
|
||||||
@@ -19,27 +15,15 @@ function formatDate(value?: string | null) {
|
|||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseClientColumnsParams {
|
export function useClientColumns(): ColumnDef<Client>[] {
|
||||||
onEdit: (client: Client) => void;
|
|
||||||
onToggleStatus: (client: Client) => void;
|
|
||||||
pendingClientId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useClientColumns({
|
|
||||||
onEdit,
|
|
||||||
onToggleStatus,
|
|
||||||
pendingClientId,
|
|
||||||
}: UseClientColumnsParams): ColumnDef<Client>[] {
|
|
||||||
const { hasPermission } = usePermissions();
|
|
||||||
const canEdit = hasPermission(PERMISSIONS.CLIENT.UPDATE);
|
|
||||||
const canDelete = hasPermission(PERMISSIONS.CLIENT.DELETE);
|
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const columns: ColumnDef<Client>[] = [
|
return [
|
||||||
{
|
{
|
||||||
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',
|
||||||
@@ -51,7 +35,9 @@ export function useClientColumns({
|
|||||||
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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -75,38 +61,5 @@ export function useClientColumns({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}, []);
|
||||||
if (!canEdit && !canDelete) return columns;
|
|
||||||
|
|
||||||
columns.push({
|
|
||||||
id: 'actions',
|
|
||||||
header: () => <div className="text-right">Actions</div>,
|
|
||||||
enableSorting: false,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const client = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{canEdit ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onEdit(client)}>
|
|
||||||
<Edit className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{canDelete ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pendingClientId === client.id}
|
|
||||||
onClick={() => onToggleStatus(client)}
|
|
||||||
>
|
|
||||||
<RotateCcw className="mr-2 size-4" />
|
|
||||||
{client.is_active ? 'Deactivate' : 'Activate'}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingClientId]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
import { Edit, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import type { Client } from '@/types';
|
import type { Client } from '@/types';
|
||||||
|
|
||||||
interface ClientTableProps {
|
interface ClientTableProps {
|
||||||
@@ -18,6 +20,9 @@ interface ClientTableProps {
|
|||||||
onPageChange: (skip: number) => void;
|
onPageChange: (skip: number) => void;
|
||||||
onLimitChange: (limit: number) => void;
|
onLimitChange: (limit: number) => void;
|
||||||
onSortingChange: (sorting: SortingState) => void;
|
onSortingChange: (sorting: SortingState) => void;
|
||||||
|
onEdit: (client: Client) => void;
|
||||||
|
onToggleStatus: (client: Client) => void;
|
||||||
|
pendingClientId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClientTable({
|
export function ClientTable({
|
||||||
@@ -32,6 +37,9 @@ export function ClientTable({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
onSortingChange,
|
onSortingChange,
|
||||||
|
onEdit,
|
||||||
|
onToggleStatus,
|
||||||
|
pendingClientId,
|
||||||
}: ClientTableProps) {
|
}: ClientTableProps) {
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -41,6 +49,21 @@ export function ClientTable({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
toolbar={toolbar}
|
toolbar={toolbar}
|
||||||
emptyTitle="No clients found."
|
emptyTitle="No clients found."
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit className="size-4" />,
|
||||||
|
permission: PERMISSIONS.CLIENT.UPDATE,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (client) => (client.is_active ? 'Deactivate' : 'Activate'),
|
||||||
|
icon: <RotateCcw className="size-4" />,
|
||||||
|
permission: PERMISSIONS.CLIENT.DELETE,
|
||||||
|
disabled: (client) => pendingClientId === client.id,
|
||||||
|
onClick: onToggleStatus,
|
||||||
|
},
|
||||||
|
]}
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useCallback, useMemo, useState } from 'react';
|
|||||||
import { Building2, Plus } from 'lucide-react';
|
import { Building2, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import { PermissionGuard } from '@/guards';
|
import { PermissionGuard } from '@/guards';
|
||||||
@@ -45,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;
|
||||||
@@ -70,11 +70,7 @@ export default function ClientsPage() {
|
|||||||
[updateClientStatus],
|
[updateClientStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useClientColumns({
|
const columns = useClientColumns();
|
||||||
onEdit: openEdit,
|
|
||||||
onToggleStatus: toggleStatus,
|
|
||||||
pendingClientId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = clientsQuery.data?.total ?? 0;
|
const total = clientsQuery.data?.total ?? 0;
|
||||||
const clients = clientsQuery.data?.items ?? [];
|
const clients = clientsQuery.data?.items ?? [];
|
||||||
@@ -119,9 +115,10 @@ export default function ClientsPage() {
|
|||||||
onPageChange={setSkip}
|
onPageChange={setSkip}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={setLimit}
|
||||||
onSortingChange={setSorting}
|
onSortingChange={setSorting}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onToggleStatus={toggleStatus}
|
||||||
|
pendingClientId={pendingClientId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<ClientSheet
|
<ClientSheet
|
||||||
|
|||||||
@@ -30,10 +30,13 @@ 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 { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Reveal, StaggerContainer, StaggerItem } from '@/components/ui/reveal';
|
import { Reveal, StaggerContainer, StaggerItem } from '@/components/ui/reveal';
|
||||||
|
|
||||||
@@ -161,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,
|
||||||
@@ -208,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,
|
||||||
@@ -231,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);
|
||||||
@@ -259,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,
|
||||||
}))
|
}))
|
||||||
@@ -282,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);
|
||||||
@@ -323,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]);
|
||||||
}
|
}
|
||||||
@@ -416,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,
|
||||||
@@ -479,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" />
|
||||||
@@ -578,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
|
||||||
@@ -606,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">
|
||||||
@@ -636,8 +671,6 @@ export default function DashboardPage() {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -13,13 +13,12 @@ const ModulesLayout = ({
|
|||||||
}>) => {
|
}>) => {
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<SidebarProvider className="bg-transparent overflow-hidden">
|
<SidebarProvider>
|
||||||
<AppSidebar className="bg-transparent" />
|
<AppSidebar />
|
||||||
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden bg-transparent">
|
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden">
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{/* Centering container wrapper */}
|
|
||||||
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full">
|
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full">
|
||||||
<div className="flex gap-3 items-center sticky top-0 bg-background/30 backdrop-blur-xl z-30 py-3 px-6 border-b border-border/10">
|
<div className="flex gap-3 items-center sticky top-0 bg-background z-50 py-3 px-6 border-b border-border">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -35,16 +39,14 @@ function StateBadges({ value }: { value: string | null }) {
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-[10px] font-bold text-muted-foreground transition-colors hover:bg-muted"
|
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-muted-foreground transition-colors hover:bg-muted"
|
||||||
>
|
>
|
||||||
+{remainingStates.length}
|
+{remainingStates.length}
|
||||||
</button>
|
</button>
|
||||||
</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-[10px] font-bold uppercase tracking-wider 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}
|
||||||
@@ -64,20 +66,22 @@ export function usePackageColumns(projects: Project[]) {
|
|||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Package Name',
|
header: 'Package Name',
|
||||||
cell: ({ row }) => <div className="font-semibold">{row.original.name}</div>,
|
cell: ({ row }) => <div>{row.original.name}</div>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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} />;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form';
|
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||||
import { Globe, Loader2, Package as PackageIcon } from 'lucide-react';
|
import { Globe, Loader2, Package as PackageIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { FormField } from '@/components/form';
|
import { FormField } from '@/components/form';
|
||||||
@@ -36,7 +36,7 @@ interface PackageDialogProps {
|
|||||||
onProjectChange: (projectId: string) => void;
|
onProjectChange: (projectId: string) => void;
|
||||||
register: UseFormRegister<PackageFormValues>;
|
register: UseFormRegister<PackageFormValues>;
|
||||||
errors: FieldErrors<PackageFormValues>;
|
errors: FieldErrors<PackageFormValues>;
|
||||||
touchedFields: UseFormReturn<PackageFormValues>['formState']['touchedFields'];
|
isSubmitted: boolean;
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
canSubmit: boolean;
|
canSubmit: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
@@ -52,31 +52,36 @@ export function PackageDialog({
|
|||||||
onProjectChange,
|
onProjectChange,
|
||||||
register,
|
register,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: PackageDialogProps) {
|
}: PackageDialogProps) {
|
||||||
const projectErrorMessage = touchedFields.project_id ? errors.project_id?.message : undefined;
|
const projectErrorMessage = isSubmitted
|
||||||
const nameErrorMessage = touchedFields.name ? errors.name?.message : undefined;
|
? errors.project_id?.message
|
||||||
const startErrorMessage = touchedFields.chainage_start_km
|
: undefined;
|
||||||
|
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
|
||||||
|
const startErrorMessage = isSubmitted
|
||||||
? errors.chainage_start_km?.message
|
? errors.chainage_start_km?.message
|
||||||
: undefined;
|
: undefined;
|
||||||
const endErrorMessage = touchedFields.chainage_end_km
|
const endErrorMessage = isSubmitted
|
||||||
? errors.chainage_end_km?.message
|
? errors.chainage_end_km?.message
|
||||||
: undefined;
|
: 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 text-xl">
|
<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">
|
||||||
<PackageIcon className="size-5" />
|
<PackageIcon className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
{packageId ? 'Edit Package Details' : 'Create New Package'}
|
{packageId ? 'Edit Package Details' : 'Create New Package'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm">
|
<DialogDescription>
|
||||||
{packageId
|
{packageId
|
||||||
? 'Update the technical specifications for your road infrastructure package.'
|
? 'Update the technical specifications for your road infrastructure package.'
|
||||||
: 'Select a project and provide the essential data to establish a new package.'}
|
: 'Select a project and provide the essential data to establish a new package.'}
|
||||||
@@ -89,7 +94,7 @@ export function PackageDialog({
|
|||||||
<Select value={projectId} onValueChange={onProjectChange}>
|
<Select value={projectId} onValueChange={onProjectChange}>
|
||||||
<SelectTrigger aria-invalid={!!projectErrorMessage}>
|
<SelectTrigger aria-invalid={!!projectErrorMessage}>
|
||||||
{isProjectsLoading ? (
|
{isProjectsLoading ? (
|
||||||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
<span className="flex items-center gap-2 text-muted-foreground">
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Loading...
|
Loading...
|
||||||
</span>
|
</span>
|
||||||
@@ -105,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"
|
||||||
@@ -128,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"
|
||||||
@@ -142,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"
|
||||||
@@ -155,7 +182,12 @@ export function PackageDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Edit3, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import type { Package } from '@/types';
|
import type { Package } from '@/types';
|
||||||
@@ -35,8 +36,19 @@ export function PackageTable({
|
|||||||
title="Packages"
|
title="Packages"
|
||||||
data={packages}
|
data={packages}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
onEdit={onEdit}
|
actions={[
|
||||||
onDelete={onDelete}
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit3 className="size-4" />,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
icon: <Trash2 className="size-4" />,
|
||||||
|
className: 'text-destructive',
|
||||||
|
onClick: onDelete,
|
||||||
|
},
|
||||||
|
]}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyTitle="No packages found."
|
emptyTitle="No packages found."
|
||||||
pagination={{
|
pagination={{
|
||||||
|
|||||||
@@ -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(),
|
||||||
@@ -67,10 +69,10 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
control,
|
control,
|
||||||
formState: { errors, isSubmitting, touchedFields },
|
formState: { errors, isSubmitting, isSubmitted },
|
||||||
} = useForm<PackageFormValues>({
|
} = useForm<PackageFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
mode: 'onTouched',
|
mode: 'onSubmit',
|
||||||
reValidateMode: 'onChange',
|
reValidateMode: 'onChange',
|
||||||
resolver: zodResolver(packageFormSchema),
|
resolver: zodResolver(packageFormSchema),
|
||||||
});
|
});
|
||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -144,7 +153,7 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
projectId,
|
projectId,
|
||||||
setProjectId,
|
setProjectId,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving: isSubmitting || saveMutation.isPending,
|
isSaving: isSubmitting || saveMutation.isPending,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useCallback, useState } from 'react';
|
|||||||
import { Package as PackageIcon, Plus } from 'lucide-react';
|
import { Package as PackageIcon, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { Package } from '@/types';
|
import type { Package } from '@/types';
|
||||||
|
|
||||||
@@ -38,7 +37,7 @@ export default function PackagePage() {
|
|||||||
projectId,
|
projectId,
|
||||||
setProjectId,
|
setProjectId,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
} = packageForm;
|
} = packageForm;
|
||||||
@@ -108,8 +107,6 @@ export default function PackagePage() {
|
|||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDelete={deletePackage}
|
onDelete={deletePackage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<PackageDialog
|
<PackageDialog
|
||||||
@@ -122,7 +119,7 @@ export default function PackagePage() {
|
|||||||
onProjectChange={setProjectId}
|
onProjectChange={setProjectId}
|
||||||
register={register}
|
register={register}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
touchedFields={touchedFields}
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
canSubmit={canSubmit}
|
canSubmit={canSubmit}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
|
|||||||
@@ -2,12 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Edit, RotateCcw } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
|
||||||
import { usePermissions } from '@/hooks/usePermissions';
|
|
||||||
import type { Plan } from '@/types';
|
import type { Plan } from '@/types';
|
||||||
|
|
||||||
function formatDate(value?: string | null) {
|
function formatDate(value?: string | null) {
|
||||||
@@ -25,37 +21,24 @@ function formatPrice(value: string, cycle: string) {
|
|||||||
return `${price} / ${cycle}`;
|
return `${price} / ${cycle}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UsePlanColumnsParams {
|
export function usePlanColumns(): ColumnDef<Plan>[] {
|
||||||
onEdit: (plan: Plan) => void;
|
|
||||||
onToggleStatus: (plan: Plan) => void;
|
|
||||||
pendingPlanId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePlanColumns({
|
|
||||||
onEdit,
|
|
||||||
onToggleStatus,
|
|
||||||
pendingPlanId,
|
|
||||||
}: UsePlanColumnsParams): ColumnDef<Plan>[] {
|
|
||||||
const { hasPermission } = usePermissions();
|
|
||||||
const canEdit = hasPermission(PERMISSIONS.PLAN.UPDATE);
|
|
||||||
const canDelete = hasPermission(PERMISSIONS.PLAN.DELETE);
|
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const columns: ColumnDef<Plan>[] = [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Plan',
|
header: 'Plan',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{row.original.name}</p>
|
<p>{row.original.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{row.original.slug}</p>
|
<p className="text-muted-foreground">{row.original.slug}</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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',
|
||||||
@@ -67,7 +50,7 @@ export function usePlanColumns({
|
|||||||
header: 'Limits',
|
header: 'Limits',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
<p>{row.original.max_projects} projects</p>
|
<p>{row.original.max_projects} projects</p>
|
||||||
<p>{row.original.max_users} users</p>
|
<p>{row.original.max_users} users</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,43 +69,12 @@ export function usePlanColumns({
|
|||||||
<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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}, []);
|
||||||
if (!canEdit && !canDelete) return columns;
|
|
||||||
|
|
||||||
columns.push({
|
|
||||||
id: 'actions',
|
|
||||||
header: () => <div className="text-right">Actions</div>,
|
|
||||||
enableSorting: false,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const plan = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{canEdit ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onEdit(plan)}>
|
|
||||||
<Edit className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{canDelete ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pendingPlanId === plan.id}
|
|
||||||
onClick={() => onToggleStatus(plan)}
|
|
||||||
>
|
|
||||||
<RotateCcw className="mr-2 size-4" />
|
|
||||||
{plan.is_active ? 'Deactivate' : 'Activate'}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingPlanId]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function PlanSheet({
|
|||||||
placeholder="Basic plan for small teams"
|
placeholder="Basic plan for small teams"
|
||||||
{...register('description', { required: true })}
|
{...register('description', { required: true })}
|
||||||
required
|
required
|
||||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ export function PlanSheet({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-5">
|
<div className="flex flex-wrap gap-5">
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="size-4 rounded border-border accent-primary"
|
className="size-4 rounded border-border accent-primary"
|
||||||
@@ -188,7 +188,7 @@ export function PlanSheet({
|
|||||||
/>
|
/>
|
||||||
Active
|
Active
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="size-4 rounded border-border accent-primary"
|
className="size-4 rounded border-border accent-primary"
|
||||||
@@ -201,10 +201,12 @@ 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-xs 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-sm text-muted-foreground">
|
<div className="rounded-md border p-6 text-muted-foreground">
|
||||||
Loading permissions...
|
Loading permissions...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
import { Edit, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import type { Plan } from '@/types';
|
import type { Plan } from '@/types';
|
||||||
|
|
||||||
interface PlanTableProps {
|
interface PlanTableProps {
|
||||||
@@ -18,6 +20,9 @@ interface PlanTableProps {
|
|||||||
onPageChange: (skip: number) => void;
|
onPageChange: (skip: number) => void;
|
||||||
onLimitChange: (limit: number) => void;
|
onLimitChange: (limit: number) => void;
|
||||||
onSortingChange: (sorting: SortingState) => void;
|
onSortingChange: (sorting: SortingState) => void;
|
||||||
|
onEdit: (plan: Plan) => void;
|
||||||
|
onToggleStatus: (plan: Plan) => void;
|
||||||
|
pendingPlanId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlanTable({
|
export function PlanTable({
|
||||||
@@ -32,6 +37,9 @@ export function PlanTable({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
onSortingChange,
|
onSortingChange,
|
||||||
|
onEdit,
|
||||||
|
onToggleStatus,
|
||||||
|
pendingPlanId,
|
||||||
}: PlanTableProps) {
|
}: PlanTableProps) {
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -41,6 +49,21 @@ export function PlanTable({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
toolbar={toolbar}
|
toolbar={toolbar}
|
||||||
emptyTitle="No plans found."
|
emptyTitle="No plans found."
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit className="size-4" />,
|
||||||
|
permission: PERMISSIONS.PLAN.UPDATE,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (plan) => (plan.is_active ? 'Deactivate' : 'Activate'),
|
||||||
|
icon: <RotateCcw className="size-4" />,
|
||||||
|
permission: PERMISSIONS.PLAN.DELETE,
|
||||||
|
disabled: (plan) => pendingPlanId === plan.id,
|
||||||
|
onClick: onToggleStatus,
|
||||||
|
},
|
||||||
|
]}
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { BadgeIndianRupee, Plus } from 'lucide-react';
|
import { BadgeIndianRupee, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import { PermissionGuard } from '@/guards';
|
import { PermissionGuard } from '@/guards';
|
||||||
@@ -17,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);
|
||||||
@@ -124,11 +126,7 @@ export default function PlansPage() {
|
|||||||
[statusMutation],
|
[statusMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = usePlanColumns({
|
const columns = usePlanColumns();
|
||||||
onEdit: openEdit,
|
|
||||||
onToggleStatus: toggleStatus,
|
|
||||||
pendingPlanId: statusMutation.variables?.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const plans = plansQuery.data?.items ?? [];
|
const plans = plansQuery.data?.items ?? [];
|
||||||
const total = plansQuery.data?.total ?? plansQuery.data?.totalItems ?? 0;
|
const total = plansQuery.data?.total ?? plansQuery.data?.totalItems ?? 0;
|
||||||
@@ -173,9 +171,10 @@ export default function PlansPage() {
|
|||||||
onPageChange={setSkip}
|
onPageChange={setSkip}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={setLimit}
|
||||||
onSortingChange={setSorting}
|
onSortingChange={setSorting}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onToggleStatus={toggleStatus}
|
||||||
|
pendingPlanId={statusMutation.variables?.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<PlanSheet
|
<PlanSheet
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -35,16 +39,14 @@ function StateBadges({ value }: { value: string | null }) {
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-[10px] font-bold text-muted-foreground transition-colors hover:bg-muted"
|
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-muted-foreground transition-colors hover:bg-muted"
|
||||||
>
|
>
|
||||||
+{remainingStates.length}
|
+{remainingStates.length}
|
||||||
</button>
|
</button>
|
||||||
</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-[10px] font-bold uppercase tracking-wider 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}
|
||||||
@@ -64,7 +66,7 @@ export function useProjectColumns() {
|
|||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Project Name',
|
header: 'Project Name',
|
||||||
cell: ({ row }) => <div className="font-semibold">{row.original.name}</div>,
|
cell: ({ row }) => <div>{row.original.name}</div>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'state',
|
accessorKey: 'state',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form';
|
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||||
import { Layers, Loader2, MapPin, Route } from 'lucide-react';
|
import { Layers, Loader2, MapPin, Route } from 'lucide-react';
|
||||||
|
|
||||||
import { FormField } from '@/components/form';
|
import { FormField } from '@/components/form';
|
||||||
@@ -24,7 +24,7 @@ interface ProjectDialogProps {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
register: UseFormRegister<ProjectFormValues>;
|
register: UseFormRegister<ProjectFormValues>;
|
||||||
errors: FieldErrors<ProjectFormValues>;
|
errors: FieldErrors<ProjectFormValues>;
|
||||||
touchedFields: UseFormReturn<ProjectFormValues>['formState']['touchedFields'];
|
isSubmitted: boolean;
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
canSubmit: boolean;
|
canSubmit: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
@@ -36,28 +36,35 @@ export function ProjectDialog({
|
|||||||
projectId,
|
projectId,
|
||||||
register,
|
register,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: ProjectDialogProps) {
|
}: ProjectDialogProps) {
|
||||||
const nameErrorMessage = touchedFields.name ? errors.name?.message : undefined;
|
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
|
||||||
const startLatErrorMessage = touchedFields.start_lat ? errors.start_lat?.message : undefined;
|
const startLatErrorMessage = isSubmitted
|
||||||
const startLngErrorMessage = touchedFields.start_lng ? errors.start_lng?.message : undefined;
|
? errors.start_lat?.message
|
||||||
const endLatErrorMessage = touchedFields.end_lat ? errors.end_lat?.message : undefined;
|
: undefined;
|
||||||
const endLngErrorMessage = touchedFields.end_lng ? errors.end_lng?.message : undefined;
|
const startLngErrorMessage = isSubmitted
|
||||||
|
? errors.start_lng?.message
|
||||||
|
: undefined;
|
||||||
|
const endLatErrorMessage = isSubmitted ? errors.end_lat?.message : undefined;
|
||||||
|
const endLngErrorMessage = isSubmitted ? errors.end_lng?.message : undefined;
|
||||||
|
|
||||||
return (
|
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 text-xl">
|
<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">
|
||||||
<Layers className="size-5" />
|
<Layers className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
{projectId ? 'Edit Project Details' : 'Create New Project'}
|
{projectId ? 'Edit Project Details' : 'Create New Project'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm">
|
<DialogDescription>
|
||||||
{projectId
|
{projectId
|
||||||
? 'Update the technical specifications for your road infrastructure project.'
|
? 'Update the technical specifications for your road infrastructure project.'
|
||||||
: 'Provide the essential road data to establish a new analysis project.'}
|
: 'Provide the essential road data to establish a new analysis project.'}
|
||||||
@@ -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"
|
||||||
@@ -97,29 +113,37 @@ export function ProjectDialog({
|
|||||||
|
|
||||||
<div className="grid gap-8 pt-2 md:grid-cols-2">
|
<div className="grid gap-8 pt-2 md:grid-cols-2">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="flex items-center gap-2 text-[11px] font-black tracking-widest text-muted-foreground">
|
<p className="flex items-center gap-2 text-muted-foreground">
|
||||||
<MapPin className="size-3.5 opacity-60" />
|
<MapPin className="size-3.5 opacity-60" />
|
||||||
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"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="0.0000"
|
placeholder="0.0000"
|
||||||
className="font-mono text-xs"
|
className="font-mono"
|
||||||
aria-invalid={!!startLatErrorMessage}
|
aria-invalid={!!startLatErrorMessage}
|
||||||
{...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"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="0.0000"
|
placeholder="0.0000"
|
||||||
className="font-mono text-xs"
|
className="font-mono"
|
||||||
aria-invalid={!!startLngErrorMessage}
|
aria-invalid={!!startLngErrorMessage}
|
||||||
{...register('start_lng')}
|
{...register('start_lng')}
|
||||||
/>
|
/>
|
||||||
@@ -128,29 +152,37 @@ export function ProjectDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="flex items-center gap-2 text-[11px] font-black tracking-widest text-muted-foreground">
|
<p className="flex items-center gap-2 text-muted-foreground">
|
||||||
<MapPin className="size-3.5 opacity-60" />
|
<MapPin className="size-3.5 opacity-60" />
|
||||||
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"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="0.0000"
|
placeholder="0.0000"
|
||||||
className="font-mono text-xs"
|
className="font-mono"
|
||||||
aria-invalid={!!endLatErrorMessage}
|
aria-invalid={!!endLatErrorMessage}
|
||||||
{...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"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="0.0000"
|
placeholder="0.0000"
|
||||||
className="font-mono text-xs"
|
className="font-mono"
|
||||||
aria-invalid={!!endLngErrorMessage}
|
aria-invalid={!!endLngErrorMessage}
|
||||||
{...register('end_lng')}
|
{...register('end_lng')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Edit3, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import type { Project } from '@/types';
|
import type { Project } from '@/types';
|
||||||
@@ -35,8 +36,19 @@ export function ProjectTable({
|
|||||||
title="Projects"
|
title="Projects"
|
||||||
data={projects}
|
data={projects}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
onEdit={onEdit}
|
actions={[
|
||||||
onDelete={onDelete}
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit3 className="size-4" />,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
icon: <Trash2 className="size-4" />,
|
||||||
|
className: 'text-destructive',
|
||||||
|
onClick: onDelete,
|
||||||
|
},
|
||||||
|
]}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyTitle="No projects found."
|
emptyTitle="No projects found."
|
||||||
pagination={{
|
pagination={{
|
||||||
|
|||||||
@@ -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),
|
||||||
@@ -72,10 +74,10 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
control,
|
control,
|
||||||
formState: { errors, isSubmitting, touchedFields },
|
formState: { errors, isSubmitting, isSubmitted },
|
||||||
} = useForm<ProjectFormValues>({
|
} = useForm<ProjectFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
mode: 'onTouched',
|
mode: 'onSubmit',
|
||||||
reValidateMode: 'onChange',
|
reValidateMode: 'onChange',
|
||||||
resolver: zodResolver(projectFormSchema),
|
resolver: zodResolver(projectFormSchema),
|
||||||
});
|
});
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,7 +143,7 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
resetForm: () => reset(defaultValues),
|
resetForm: () => reset(defaultValues),
|
||||||
projectId,
|
projectId,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving: isSubmitting || saveMutation.isPending,
|
isSaving: isSubmitting || saveMutation.isPending,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useCallback, useState } from 'react';
|
|||||||
import { Layers, Plus } from 'lucide-react';
|
import { Layers, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { Project } from '@/types';
|
import type { Project } from '@/types';
|
||||||
|
|
||||||
@@ -13,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);
|
||||||
@@ -31,7 +33,7 @@ export default function ProjectPage() {
|
|||||||
resetForm,
|
resetForm,
|
||||||
projectId,
|
projectId,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
} = projectForm;
|
} = projectForm;
|
||||||
@@ -61,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);
|
||||||
@@ -100,8 +104,6 @@ export default function ProjectPage() {
|
|||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDelete={deleteProject}
|
onDelete={deleteProject}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<ProjectDialog
|
<ProjectDialog
|
||||||
@@ -110,7 +112,7 @@ export default function ProjectPage() {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
register={register}
|
register={register}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
touchedFields={touchedFields}
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
canSubmit={canSubmit}
|
canSubmit={canSubmit}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react';
|
import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
import VideoPlayerSection from '@/components/video-player-section';
|
import VideoPlayerSection from '@/components/video-player-section';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { sessionService, videoService } from '@/services/api';
|
import { sessionService, videoService } from '@/services/api';
|
||||||
import { SessionContext, DetectionData, DetectionType } from '@/types';
|
import { SessionContext, DetectionData, DetectionType } from '@/types';
|
||||||
import { getVideoFile, clearVideoFile } from '@/lib/video-storage';
|
import { getVideoFile, clearVideoFile } from '@/lib/video-storage';
|
||||||
@@ -20,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);
|
||||||
@@ -90,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>
|
||||||
);
|
);
|
||||||
@@ -102,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>
|
||||||
@@ -117,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 && (
|
||||||
@@ -182,7 +193,6 @@ export default function VideoResultsPage() {
|
|||||||
projectId={session?.projectId || undefined}
|
projectId={session?.projectId || undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<PoweredBy />
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Loader2, TrendingUp } from 'lucide-react';
|
import { Loader2, TrendingUp } from 'lucide-react';
|
||||||
import VideoPlayerSection from '@/components/video-player-section';
|
import VideoPlayerSection from '@/components/video-player-section';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { sessionService } from '@/services/api';
|
import { sessionService } from '@/services/api';
|
||||||
import { SessionContext, DetectionData, DetectionType } from '@/types';
|
import { SessionContext, DetectionData, DetectionType } from '@/types';
|
||||||
import { clearVideoFile } from '@/lib/video-storage';
|
import { clearVideoFile } from '@/lib/video-storage';
|
||||||
@@ -56,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';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,7 +148,6 @@ export default function ResultsPage() {
|
|||||||
projectId={session?.projectId || undefined}
|
projectId={session?.projectId || undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<PoweredBy />
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,35 +2,17 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Edit, RotateCcw } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
|
||||||
import { usePermissions } from '@/hooks/usePermissions';
|
|
||||||
import type { Role } from '@/types';
|
import type { Role } from '@/types';
|
||||||
|
|
||||||
interface UseRoleColumnsParams {
|
export function useRoleColumns(): ColumnDef<Role>[] {
|
||||||
onEdit: (role: Role) => void;
|
|
||||||
onToggleStatus: (role: Role) => void;
|
|
||||||
isStatusPending: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRoleColumns({
|
|
||||||
onEdit,
|
|
||||||
onToggleStatus,
|
|
||||||
isStatusPending,
|
|
||||||
}: UseRoleColumnsParams): ColumnDef<Role>[] {
|
|
||||||
const { hasPermission } = usePermissions();
|
|
||||||
const canEdit = hasPermission(PERMISSIONS.ROLE.UPDATE);
|
|
||||||
const canDelete = hasPermission(PERMISSIONS.ROLE.DELETE);
|
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const columns: ColumnDef<Role>[] = [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Name',
|
header: 'Name',
|
||||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
cell: ({ row }) => <span>{row.original.name}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'display_name',
|
accessorKey: 'display_name',
|
||||||
@@ -54,44 +36,13 @@ export function useRoleColumns({
|
|||||||
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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}, []);
|
||||||
if (!canEdit && !canDelete) return columns;
|
|
||||||
|
|
||||||
columns.push({
|
|
||||||
id: 'actions',
|
|
||||||
header: () => <div className="text-right">Actions</div>,
|
|
||||||
enableSorting: false,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const role = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{canEdit ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onEdit(role)}>
|
|
||||||
<Edit className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{canDelete ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={isStatusPending}
|
|
||||||
onClick={() => onToggleStatus(role)}
|
|
||||||
>
|
|
||||||
<RotateCcw className="mr-2 size-4" />
|
|
||||||
{role.effective_status ? 'Deactivate' : 'Activate'}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
}, [canDelete, canEdit, isStatusPending, onEdit, onToggleStatus]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form';
|
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { FormField } from '@/components/form';
|
import { FormField } from '@/components/form';
|
||||||
@@ -28,7 +28,7 @@ interface RoleSheetProps {
|
|||||||
roleId?: number;
|
roleId?: number;
|
||||||
register: UseFormRegister<RoleFormValues>;
|
register: UseFormRegister<RoleFormValues>;
|
||||||
errors: FieldErrors<RoleFormValues>;
|
errors: FieldErrors<RoleFormValues>;
|
||||||
touchedFields: UseFormReturn<RoleFormValues>['formState']['touchedFields'];
|
isSubmitted: boolean;
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
permissionTree: PermissionTreeItem[];
|
permissionTree: PermissionTreeItem[];
|
||||||
permissionIds: number[];
|
permissionIds: number[];
|
||||||
@@ -44,7 +44,7 @@ export function RoleSheet({
|
|||||||
roleId,
|
roleId,
|
||||||
register,
|
register,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
permissionTree,
|
permissionTree,
|
||||||
permissionIds,
|
permissionIds,
|
||||||
@@ -53,8 +53,8 @@ export function RoleSheet({
|
|||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: RoleSheetProps) {
|
}: RoleSheetProps) {
|
||||||
const nameErrorMessage = touchedFields.name ? errors.name?.message : undefined;
|
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
|
||||||
const displayNameErrorMessage = touchedFields.display_name
|
const displayNameErrorMessage = isSubmitted
|
||||||
? errors.display_name?.message
|
? errors.display_name?.message
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -114,14 +114,16 @@ export function RoleSheet({
|
|||||||
<FormField
|
<FormField
|
||||||
label="Permissions"
|
label="Permissions"
|
||||||
required
|
required
|
||||||
error={errors.permission_ids?.message}
|
error={isSubmitted ? errors.permission_ids?.message : undefined}
|
||||||
className="space-y-3"
|
className="space-y-3"
|
||||||
labelEnd={
|
labelEnd={
|
||||||
<span className="text-xs text-muted-foreground">{permissionIds.length} selected</span>
|
<span className="text-muted-foreground">
|
||||||
|
{permissionIds.length} selected
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isPermissionsLoading ? (
|
{isPermissionsLoading ? (
|
||||||
<div className="rounded-md border p-6 text-sm text-muted-foreground">
|
<div className="rounded-md border p-6 text-muted-foreground">
|
||||||
Loading permissions...
|
Loading permissions...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -136,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>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { Edit, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import type { Role } from '@/types';
|
import type { Role } from '@/types';
|
||||||
|
|
||||||
interface RoleTableProps {
|
interface RoleTableProps {
|
||||||
@@ -18,6 +20,9 @@ interface RoleTableProps {
|
|||||||
onPageChange: (skip: number) => void;
|
onPageChange: (skip: number) => void;
|
||||||
onLimitChange: (limit: number) => void;
|
onLimitChange: (limit: number) => void;
|
||||||
onSortingChange: (sorting: SortingState) => void;
|
onSortingChange: (sorting: SortingState) => void;
|
||||||
|
onEdit: (role: Role) => void;
|
||||||
|
onToggleStatus: (role: Role) => void;
|
||||||
|
isStatusPending: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoleTable({
|
export function RoleTable({
|
||||||
@@ -32,6 +37,9 @@ export function RoleTable({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
onSortingChange,
|
onSortingChange,
|
||||||
|
onEdit,
|
||||||
|
onToggleStatus,
|
||||||
|
isStatusPending,
|
||||||
}: RoleTableProps) {
|
}: RoleTableProps) {
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -41,6 +49,21 @@ export function RoleTable({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
toolbar={toolbar}
|
toolbar={toolbar}
|
||||||
emptyTitle="No roles found."
|
emptyTitle="No roles found."
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit className="size-4" />,
|
||||||
|
permission: PERMISSIONS.ROLE.UPDATE,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (role) => (role.effective_status ? 'Deactivate' : 'Activate'),
|
||||||
|
icon: <RotateCcw className="size-4" />,
|
||||||
|
permission: PERMISSIONS.ROLE.DELETE,
|
||||||
|
disabled: () => isStatusPending,
|
||||||
|
onClick: onToggleStatus,
|
||||||
|
},
|
||||||
|
]}
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -48,10 +48,10 @@ export function useRoleForm({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
formState: { errors, isSubmitting, touchedFields },
|
formState: { errors, isSubmitting, isSubmitted },
|
||||||
} = useForm<RoleFormValues>({
|
} = useForm<RoleFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
mode: 'onTouched',
|
mode: 'onSubmit',
|
||||||
reValidateMode: 'onChange',
|
reValidateMode: 'onChange',
|
||||||
resolver: zodResolver(roleFormSchema),
|
resolver: zodResolver(roleFormSchema),
|
||||||
});
|
});
|
||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -136,7 +147,7 @@ export function useRoleForm({
|
|||||||
permissionIds,
|
permissionIds,
|
||||||
roleId,
|
roleId,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving: isSubmitting || saveMutation.isPending,
|
isSaving: isSubmitting || saveMutation.isPending,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useCallback, useState } from 'react';
|
|||||||
import { Plus, ShieldCheck } from 'lucide-react';
|
import { Plus, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import { PermissionGuard } from '@/guards';
|
import { PermissionGuard } from '@/guards';
|
||||||
@@ -24,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,
|
||||||
@@ -62,12 +63,13 @@ export default function RolesPage() {
|
|||||||
permissionIds,
|
permissionIds,
|
||||||
setPermissionIds,
|
setPermissionIds,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
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();
|
||||||
@@ -89,11 +91,7 @@ export default function RolesPage() {
|
|||||||
[updateRoleStatus],
|
[updateRoleStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useRoleColumns({
|
const columns = useRoleColumns();
|
||||||
onEdit: openEdit,
|
|
||||||
onToggleStatus: toggleStatus,
|
|
||||||
isStatusPending,
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = rolesQuery.data?.total ?? 0;
|
const total = rolesQuery.data?.total ?? 0;
|
||||||
const roles = rolesQuery.data?.items ?? [];
|
const roles = rolesQuery.data?.items ?? [];
|
||||||
@@ -135,9 +133,10 @@ export default function RolesPage() {
|
|||||||
onPageChange={setSkip}
|
onPageChange={setSkip}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={setLimit}
|
||||||
onSortingChange={setSorting}
|
onSortingChange={setSorting}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onToggleStatus={toggleStatus}
|
||||||
|
isStatusPending={isStatusPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<RoleSheet
|
<RoleSheet
|
||||||
@@ -146,7 +145,7 @@ export default function RolesPage() {
|
|||||||
roleId={roleId}
|
roleId={roleId}
|
||||||
register={register}
|
register={register}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
touchedFields={touchedFields}
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
permissionTree={permissionsQuery.permissionTree}
|
permissionTree={permissionsQuery.permissionTree}
|
||||||
permissionIds={permissionIds}
|
permissionIds={permissionIds}
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -36,16 +45,14 @@ function StateBadges({ value }: { value: string | null }) {
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-[10px] font-bold text-muted-foreground transition-colors hover:bg-muted"
|
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-muted-foreground transition-colors hover:bg-muted"
|
||||||
>
|
>
|
||||||
+{remainingStates.length}
|
+{remainingStates.length}
|
||||||
</button>
|
</button>
|
||||||
</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-[10px] font-bold uppercase tracking-wider 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}
|
||||||
@@ -65,7 +72,7 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
|
|||||||
{
|
{
|
||||||
accessorKey: 'segment_name',
|
accessorKey: 'segment_name',
|
||||||
header: 'Segment Name',
|
header: 'Segment Name',
|
||||||
cell: ({ row }) => <div className="font-semibold">{row.original.segment_name}</div>,
|
cell: ({ row }) => <div>{row.original.segment_name}</div>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'package_id',
|
accessorKey: 'package_id',
|
||||||
@@ -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} />;
|
||||||
},
|
},
|
||||||
@@ -101,7 +112,7 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
|
|||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex items-center gap-1.5 border-amber-500/50 font-bold text-amber-500"
|
className="flex items-center gap-1.5 border-amber-500/50 text-amber-500"
|
||||||
>
|
>
|
||||||
<Milestone className="size-3" />
|
<Milestone className="size-3" />
|
||||||
{segment.chainage_start_km} - {segment.chainage_end_km}
|
{segment.chainage_start_km} - {segment.chainage_end_km}
|
||||||
@@ -124,10 +135,11 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex items-center gap-1.5 border-blue-500/50 font-bold text-blue-500"
|
className="flex items-center gap-1.5 border-blue-500/50 text-blue-500"
|
||||||
>
|
>
|
||||||
<MapPin className="size-3" />
|
<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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -137,7 +149,7 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex items-center gap-1.5 border-blue-500/50 font-bold text-blue-500"
|
className="flex items-center gap-1.5 border-blue-500/50 text-blue-500"
|
||||||
>
|
>
|
||||||
<MapPin className="size-3" />
|
<MapPin className="size-3" />
|
||||||
{row.original.end_lat.toFixed(4)}, {row.original.end_lng.toFixed(4)}
|
{row.original.end_lat.toFixed(4)}, {row.original.end_lng.toFixed(4)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form';
|
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||||
import { Loader2, MapPin, Milestone } from 'lucide-react';
|
import { Loader2, MapPin, Milestone } from 'lucide-react';
|
||||||
|
|
||||||
import { FormField } from '@/components/form';
|
import { FormField } from '@/components/form';
|
||||||
@@ -42,7 +42,7 @@ interface SegmentDialogProps {
|
|||||||
onDirectionChange: (direction: 'UP' | 'DOWN') => void;
|
onDirectionChange: (direction: 'UP' | 'DOWN') => void;
|
||||||
register: UseFormRegister<SegmentFormValues>;
|
register: UseFormRegister<SegmentFormValues>;
|
||||||
errors: FieldErrors<SegmentFormValues>;
|
errors: FieldErrors<SegmentFormValues>;
|
||||||
touchedFields: UseFormReturn<SegmentFormValues>['formState']['touchedFields'];
|
isSubmitted: boolean;
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
canSubmit: boolean;
|
canSubmit: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
@@ -64,13 +64,13 @@ export function SegmentDialog({
|
|||||||
onDirectionChange,
|
onDirectionChange,
|
||||||
register,
|
register,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: SegmentDialogProps) {
|
}: SegmentDialogProps) {
|
||||||
const getError = (field: keyof SegmentFormValues) =>
|
const getError = (field: keyof SegmentFormValues) =>
|
||||||
touchedFields[field] ? errors[field]?.message : undefined;
|
isSubmitted ? errors[field]?.message : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -79,13 +79,13 @@ export function SegmentDialog({
|
|||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
<DialogHeader className="gap-2">
|
<DialogHeader className="gap-2">
|
||||||
<DialogTitle className="flex items-center gap-3 text-xl">
|
<DialogTitle className="flex items-center gap-3">
|
||||||
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
|
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
|
||||||
<Milestone className="size-5" />
|
<Milestone className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
{segmentId ? 'Edit Segment' : 'Create Segment'}
|
{segmentId ? 'Edit Segment' : 'Create Segment'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm">
|
<DialogDescription>
|
||||||
{segmentId
|
{segmentId
|
||||||
? 'Update the technical specifications for your road segment.'
|
? 'Update the technical specifications for your road segment.'
|
||||||
: 'Select a project and package, then provide the segment data.'}
|
: 'Select a project and package, then provide the segment data.'}
|
||||||
@@ -95,11 +95,15 @@ 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 ? (
|
||||||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
<span className="flex items-center gap-2 text-muted-foreground">
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Loading...
|
Loading...
|
||||||
</span>
|
</span>
|
||||||
@@ -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}
|
||||||
@@ -126,12 +139,18 @@ export function SegmentDialog({
|
|||||||
>
|
>
|
||||||
<SelectTrigger aria-invalid={!!getError('package_id')}>
|
<SelectTrigger aria-invalid={!!getError('package_id')}>
|
||||||
{isPackagesLoading ? (
|
{isPackagesLoading ? (
|
||||||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
<span className="flex items-center gap-2 text-muted-foreground">
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Loading...
|
Loading...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<SelectValue placeholder={projectId ? 'Choose a package' : 'Select project first'} />
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
projectId
|
||||||
|
? 'Choose a package'
|
||||||
|
: 'Select project first'
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -142,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}
|
||||||
@@ -211,12 +235,17 @@ export function SegmentDialog({
|
|||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="flex items-center gap-2 text-[11px] font-black tracking-widest text-muted-foreground">
|
<p className="flex items-center gap-2 text-muted-foreground">
|
||||||
<MapPin className="size-3.5 opacity-60" />
|
<MapPin className="size-3.5 opacity-60" />
|
||||||
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"
|
||||||
@@ -228,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"
|
||||||
@@ -244,12 +278,17 @@ export function SegmentDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="flex items-center gap-2 text-[11px] font-black tracking-widest text-muted-foreground">
|
<p className="flex items-center gap-2 text-muted-foreground">
|
||||||
<MapPin className="size-3.5 opacity-60" />
|
<MapPin className="size-3.5 opacity-60" />
|
||||||
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"
|
||||||
@@ -261,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"
|
||||||
@@ -278,7 +322,12 @@ export function SegmentDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Edit3, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import type { Chainage } from '@/types';
|
import type { Chainage } from '@/types';
|
||||||
@@ -35,8 +36,19 @@ export function SegmentTable({
|
|||||||
title="Segments"
|
title="Segments"
|
||||||
data={segments}
|
data={segments}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
onEdit={onEdit}
|
actions={[
|
||||||
onDelete={onDelete}
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit3 className="size-4" />,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
icon: <Trash2 className="size-4" />,
|
||||||
|
className: 'text-destructive',
|
||||||
|
onClick: onDelete,
|
||||||
|
},
|
||||||
|
]}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyTitle="No segments found."
|
emptyTitle="No segments found."
|
||||||
pagination={{
|
pagination={{
|
||||||
|
|||||||
@@ -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(),
|
||||||
@@ -86,10 +90,10 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
control,
|
control,
|
||||||
formState: { errors, isSubmitting, touchedFields },
|
formState: { errors, isSubmitting, isSubmitted },
|
||||||
} = useForm<SegmentFormValues>({
|
} = useForm<SegmentFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
mode: 'onTouched',
|
mode: 'onSubmit',
|
||||||
reValidateMode: 'onChange',
|
reValidateMode: 'onChange',
|
||||||
resolver: zodResolver(segmentFormSchema),
|
resolver: zodResolver(segmentFormSchema),
|
||||||
});
|
});
|
||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -200,7 +216,7 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
direction,
|
direction,
|
||||||
setDirection,
|
setDirection,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving: isSubmitting || saveMutation.isPending,
|
isSaving: isSubmitting || saveMutation.isPending,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.`,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useCallback, useState } from 'react';
|
|||||||
import { Milestone, Plus } from 'lucide-react';
|
import { Milestone, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { Chainage } from '@/types';
|
import type { Chainage } from '@/types';
|
||||||
|
|
||||||
@@ -45,11 +44,14 @@ export default function SegmentPage() {
|
|||||||
direction,
|
direction,
|
||||||
setDirection,
|
setDirection,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
} = segmentForm;
|
} = segmentForm;
|
||||||
const projectPackagesQuery = usePackagesByProjectQuery(projectId, isDialogOpen);
|
const projectPackagesQuery = usePackagesByProjectQuery(
|
||||||
|
projectId,
|
||||||
|
isDialogOpen,
|
||||||
|
);
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
prepareCreateSegment();
|
prepareCreateSegment();
|
||||||
@@ -58,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);
|
||||||
},
|
},
|
||||||
@@ -77,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);
|
||||||
@@ -119,8 +127,6 @@ export default function SegmentPage() {
|
|||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDelete={deleteSegment}
|
onDelete={deleteSegment}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SegmentDialog
|
<SegmentDialog
|
||||||
@@ -139,7 +145,7 @@ export default function SegmentPage() {
|
|||||||
onDirectionChange={setDirection}
|
onDirectionChange={setDirection}
|
||||||
register={register}
|
register={register}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
touchedFields={touchedFields}
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
canSubmit={canSubmit}
|
canSubmit={canSubmit}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
|
|||||||
@@ -2,12 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Edit, Trash2 } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
|
||||||
import { usePermissions } from '@/hooks/usePermissions';
|
|
||||||
import type { Tenant } from '@/types';
|
import type { Tenant } from '@/types';
|
||||||
|
|
||||||
function formatDate(value?: string | null) {
|
function formatDate(value?: string | null) {
|
||||||
@@ -19,30 +15,16 @@ function formatDate(value?: string | null) {
|
|||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseTenantColumnsParams {
|
export function useTenantColumns(): ColumnDef<Tenant>[] {
|
||||||
onEdit: (tenant: Tenant) => void;
|
|
||||||
onDelete: (tenant: Tenant) => void;
|
|
||||||
pendingDeleteId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTenantColumns({
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
pendingDeleteId,
|
|
||||||
}: UseTenantColumnsParams): ColumnDef<Tenant>[] {
|
|
||||||
const { hasPermission } = usePermissions();
|
|
||||||
const canEdit = hasPermission(PERMISSIONS.TENANT.UPDATE);
|
|
||||||
const canDelete = hasPermission(PERMISSIONS.TENANT.DELETE);
|
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const columns: ColumnDef<Tenant>[] = [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Tenant',
|
header: 'Tenant',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{row.original.name}</p>
|
<p>{row.original.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{row.original.slug}</p>
|
<p className="text-muted-foreground">{row.original.slug}</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -76,37 +58,5 @@ export function useTenantColumns({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}, []);
|
||||||
if (!canEdit && !canDelete) return columns;
|
|
||||||
|
|
||||||
columns.push({
|
|
||||||
id: 'actions',
|
|
||||||
header: () => <div className="text-right">Actions</div>,
|
|
||||||
enableSorting: false,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const tenant = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{canEdit ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onEdit(tenant)}>
|
|
||||||
<Edit className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{canDelete ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pendingDeleteId === tenant.id}
|
|
||||||
onClick={() => onDelete(tenant)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
}, [canDelete, canEdit, onDelete, onEdit, pendingDeleteId]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 className="text-sm font-medium">Basic Information</p>
|
<p>Basic Information</p>
|
||||||
<p className="text-xs 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,9 +111,17 @@ 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 placeholder={isLookupsLoading ? 'Loading clients...' : 'Select client'} />
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
isLookupsLoading ? 'Loading clients...' : 'Select client'
|
||||||
|
}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{clients.map((client) => (
|
{clients.map((client) => (
|
||||||
@@ -121,9 +134,17 @@ 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 placeholder={isLookupsLoading ? 'Loading plans...' : 'Select plan'} />
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
isLookupsLoading ? 'Loading plans...' : 'Select plan'
|
||||||
|
}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{plans.map((plan) => (
|
{plans.map((plan) => (
|
||||||
@@ -139,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>
|
||||||
@@ -147,7 +172,7 @@ export function TenantSheet({
|
|||||||
id="tenant-description"
|
id="tenant-description"
|
||||||
placeholder="North zone operations"
|
placeholder="North zone operations"
|
||||||
{...register('description')}
|
{...register('description')}
|
||||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,8 +180,8 @@ export function TenantSheet({
|
|||||||
{!isEditMode ? (
|
{!isEditMode ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium">Tenant Administrator</p>
|
<p>Tenant Administrator</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Invitation details for the primary tenant admin account.
|
Invitation details for the primary tenant admin account.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,10 +229,10 @@ export function TenantSheet({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : adminEmail ? (
|
) : adminEmail ? (
|
||||||
<div className="rounded-md border bg-muted/40 p-4 text-sm text-muted-foreground">
|
<div className="rounded-md border bg-muted/40 p-4 text-muted-foreground">
|
||||||
Admin invitation details cannot be changed after tenant creation.
|
Admin invitation details cannot be changed after tenant creation.
|
||||||
<p className="mt-1 text-foreground">
|
<p className="mt-1 text-foreground">
|
||||||
Current admin email: <span className="font-medium">{adminEmail}</span>
|
Current admin email: <span>{adminEmail}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -222,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>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
import { Edit, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import type { Tenant } from '@/types';
|
import type { Tenant } from '@/types';
|
||||||
|
|
||||||
interface TenantTableProps {
|
interface TenantTableProps {
|
||||||
@@ -18,6 +20,9 @@ interface TenantTableProps {
|
|||||||
onPageChange: (skip: number) => void;
|
onPageChange: (skip: number) => void;
|
||||||
onLimitChange: (limit: number) => void;
|
onLimitChange: (limit: number) => void;
|
||||||
onSortingChange: (sorting: SortingState) => void;
|
onSortingChange: (sorting: SortingState) => void;
|
||||||
|
onEdit: (tenant: Tenant) => void;
|
||||||
|
onDelete: (tenant: Tenant) => void;
|
||||||
|
pendingDeleteId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TenantTable({
|
export function TenantTable({
|
||||||
@@ -32,6 +37,9 @@ export function TenantTable({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
onSortingChange,
|
onSortingChange,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
pendingDeleteId,
|
||||||
}: TenantTableProps) {
|
}: TenantTableProps) {
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -41,6 +49,22 @@ export function TenantTable({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
toolbar={toolbar}
|
toolbar={toolbar}
|
||||||
emptyTitle="No tenants found."
|
emptyTitle="No tenants found."
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit className="size-4" />,
|
||||||
|
permission: PERMISSIONS.TENANT.UPDATE,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
icon: <Trash2 className="size-4" />,
|
||||||
|
permission: PERMISSIONS.TENANT.DELETE,
|
||||||
|
className: 'text-destructive',
|
||||||
|
disabled: (tenant) => pendingDeleteId === tenant.id,
|
||||||
|
onClick: onDelete,
|
||||||
|
},
|
||||||
|
]}
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useCallback, useMemo, useState } from 'react';
|
|||||||
import { Globe, Plus } from 'lucide-react';
|
import { Globe, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import { PermissionGuard } from '@/guards';
|
import { PermissionGuard } from '@/guards';
|
||||||
@@ -117,17 +116,14 @@ export default function TenantsPage() {
|
|||||||
[deleteMutation],
|
[deleteMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useTenantColumns({
|
const columns = useTenantColumns();
|
||||||
onEdit: openEdit,
|
|
||||||
onDelete: handleDelete,
|
|
||||||
pendingDeleteId: deleteMutation.isPending ? deleteMutation.variables : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tenants = tenantsQuery.data?.items ?? [];
|
const tenants = tenantsQuery.data?.items ?? [];
|
||||||
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(
|
||||||
() => (
|
() => (
|
||||||
@@ -170,9 +166,12 @@ export default function TenantsPage() {
|
|||||||
onPageChange={setSkip}
|
onPageChange={setSkip}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={setLimit}
|
||||||
onSortingChange={setSorting}
|
onSortingChange={setSorting}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
pendingDeleteId={
|
||||||
|
deleteMutation.isPending ? deleteMutation.variables : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<TenantSheet
|
<TenantSheet
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ import { useRouter, useParams } from 'next/navigation';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react';
|
import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { sessionService, videoService } from '@/services/api';
|
import { sessionService, videoService } from '@/services/api';
|
||||||
import { SessionContext } from '@/types';
|
import { SessionContext } from '@/types';
|
||||||
import { ROUTES } from '@/utils/routes';
|
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();
|
||||||
@@ -102,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;
|
||||||
}
|
}
|
||||||
@@ -204,7 +208,9 @@ export default function VideoProcessingPage() {
|
|||||||
<Card className="flex-1 flex flex-col items-center justify-center py-12 px-8 min-h-[450px]">
|
<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>
|
||||||
@@ -215,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
|
||||||
@@ -234,15 +242,17 @@ 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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</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';
|
||||||
@@ -15,7 +21,6 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Loader2, TrendingUp, ChevronRight } from 'lucide-react';
|
import { Loader2, TrendingUp, ChevronRight } from 'lucide-react';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { sessionService, videoService } from '@/services/api';
|
import { sessionService, videoService } from '@/services/api';
|
||||||
import { SessionContext } from '@/types';
|
import { SessionContext } from '@/types';
|
||||||
import { storeVideoFile } from '@/lib/video-storage';
|
import { storeVideoFile } from '@/lib/video-storage';
|
||||||
@@ -55,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) {
|
||||||
@@ -104,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;
|
||||||
}
|
}
|
||||||
@@ -121,7 +130,9 @@ export default function UploadPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSessionComplete = !!(session && sessionService.isSessionValid(session));
|
const isSessionComplete = !!(
|
||||||
|
session && sessionService.isSessionValid(session)
|
||||||
|
);
|
||||||
const isFormValid = isSessionComplete && file && jsonFile;
|
const isFormValid = isSessionComplete && file && jsonFile;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -141,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>
|
||||||
@@ -158,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
|
||||||
@@ -171,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">
|
||||||
@@ -192,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"
|
||||||
@@ -211,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
|
||||||
@@ -219,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>
|
||||||
@@ -265,12 +297,6 @@ export default function UploadPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12">
|
|
||||||
<Reveal delay={0.4}>
|
|
||||||
<PoweredBy />
|
|
||||||
</Reveal>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,12 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Edit, RotateCcw } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
|
||||||
import { usePermissions } from '@/hooks/usePermissions';
|
|
||||||
import type { AdministrationUser } from '@/types';
|
import type { AdministrationUser } from '@/types';
|
||||||
|
|
||||||
function formatDate(value?: string | null) {
|
function formatDate(value?: string | null) {
|
||||||
@@ -19,30 +15,17 @@ function formatDate(value?: string | null) {
|
|||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseUserColumnsParams {
|
export function useUserColumns(): ColumnDef<AdministrationUser>[] {
|
||||||
onEdit: (user: AdministrationUser) => void;
|
|
||||||
onToggleStatus: (user: AdministrationUser) => void;
|
|
||||||
pendingUserId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUserColumns({
|
|
||||||
onEdit,
|
|
||||||
onToggleStatus,
|
|
||||||
pendingUserId,
|
|
||||||
}: UseUserColumnsParams): ColumnDef<AdministrationUser>[] {
|
|
||||||
const { hasPermission } = usePermissions();
|
|
||||||
const canEdit = hasPermission(PERMISSIONS.USER.UPDATE);
|
|
||||||
const canDelete = hasPermission(PERMISSIONS.USER.DELETE);
|
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const columns: ColumnDef<AdministrationUser>[] = [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'first_name',
|
accessorKey: 'first_name',
|
||||||
header: 'Name',
|
header: 'Name',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-medium">
|
<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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -82,44 +65,17 @@ export function useUserColumns({
|
|||||||
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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}, []);
|
||||||
if (!canEdit && !canDelete) return columns;
|
|
||||||
|
|
||||||
columns.push({
|
|
||||||
id: 'actions',
|
|
||||||
header: () => <div className="text-right">Actions</div>,
|
|
||||||
enableSorting: false,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const user = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{canEdit ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onEdit(user)}>
|
|
||||||
<Edit className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{canDelete ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pendingUserId === user.id || user.effective_status === 'pending'}
|
|
||||||
onClick={() => onToggleStatus(user)}
|
|
||||||
>
|
|
||||||
<RotateCcw className="mr-2 size-4" />
|
|
||||||
{user.effective_status === 'active' ? 'Deactivate' : 'Activate'}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingUserId]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef, type ComponentProps } from 'react';
|
import { useRef, type ComponentProps } from 'react';
|
||||||
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form';
|
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { FormField } from '@/components/form';
|
import { FormField } from '@/components/form';
|
||||||
@@ -24,7 +24,7 @@ interface UserSheetProps {
|
|||||||
userId?: number;
|
userId?: number;
|
||||||
register: UseFormRegister<UserFormValues>;
|
register: UseFormRegister<UserFormValues>;
|
||||||
errors: FieldErrors<UserFormValues>;
|
errors: FieldErrors<UserFormValues>;
|
||||||
touchedFields: UseFormReturn<UserFormValues>['formState']['touchedFields'];
|
isSubmitted: boolean;
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
roleId: string;
|
roleId: string;
|
||||||
onRoleChange: (roleId: string) => void;
|
onRoleChange: (roleId: string) => void;
|
||||||
@@ -38,7 +38,7 @@ export function UserSheet({
|
|||||||
userId,
|
userId,
|
||||||
register,
|
register,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
roleId,
|
roleId,
|
||||||
onRoleChange,
|
onRoleChange,
|
||||||
@@ -46,11 +46,17 @@ export function UserSheet({
|
|||||||
isSaving,
|
isSaving,
|
||||||
}: UserSheetProps) {
|
}: UserSheetProps) {
|
||||||
const roleComboboxPortalRef = useRef<HTMLDivElement | null>(null);
|
const roleComboboxPortalRef = useRef<HTMLDivElement | null>(null);
|
||||||
const firstNameErrorMessage = touchedFields.first_name ? errors.first_name?.message : undefined;
|
const firstNameErrorMessage = isSubmitted
|
||||||
const lastNameErrorMessage = touchedFields.last_name ? errors.last_name?.message : undefined;
|
? errors.first_name?.message
|
||||||
const emailErrorMessage = touchedFields.email ? errors.email?.message : undefined;
|
: undefined;
|
||||||
const phoneErrorMessage = touchedFields.phone_number ? errors.phone_number?.message : undefined;
|
const lastNameErrorMessage = isSubmitted
|
||||||
const roleErrorMessage = touchedFields.role_id ? errors.role_id?.message : undefined;
|
? errors.last_name?.message
|
||||||
|
: undefined;
|
||||||
|
const emailErrorMessage = isSubmitted ? errors.email?.message : undefined;
|
||||||
|
const phoneErrorMessage = isSubmitted
|
||||||
|
? errors.phone_number?.message
|
||||||
|
: undefined;
|
||||||
|
const roleErrorMessage = isSubmitted ? errors.role_id?.message : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -149,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 },
|
||||||
@@ -19,8 +24,8 @@ export function UserStats({ total, activeCount, inactiveCount, pendingCount }: U
|
|||||||
<div className="grid gap-3 md:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
{stats.map((stat) => (
|
{stats.map((stat) => (
|
||||||
<div key={stat.label} className="rounded-lg border bg-card p-4">
|
<div key={stat.label} className="rounded-lg border bg-card p-4">
|
||||||
<p className="text-xs text-muted-foreground">{stat.label}</p>
|
<p className="text-muted-foreground">{stat.label}</p>
|
||||||
<p className="mt-1 text-2xl font-semibold">{stat.value}</p>
|
<p>{stat.value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
import { Edit, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import type { AdministrationUser } from '@/types';
|
import type { AdministrationUser } from '@/types';
|
||||||
|
|
||||||
interface UserTableProps {
|
interface UserTableProps {
|
||||||
@@ -18,6 +20,9 @@ interface UserTableProps {
|
|||||||
onPageChange: (skip: number) => void;
|
onPageChange: (skip: number) => void;
|
||||||
onLimitChange: (limit: number) => void;
|
onLimitChange: (limit: number) => void;
|
||||||
onSortingChange: (sorting: SortingState) => void;
|
onSortingChange: (sorting: SortingState) => void;
|
||||||
|
onEdit: (user: AdministrationUser) => void;
|
||||||
|
onToggleStatus: (user: AdministrationUser) => void;
|
||||||
|
pendingUserId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserTable({
|
export function UserTable({
|
||||||
@@ -32,6 +37,9 @@ export function UserTable({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
onSortingChange,
|
onSortingChange,
|
||||||
|
onEdit,
|
||||||
|
onToggleStatus,
|
||||||
|
pendingUserId,
|
||||||
}: UserTableProps) {
|
}: UserTableProps) {
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -41,6 +49,23 @@ export function UserTable({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
toolbar={toolbar}
|
toolbar={toolbar}
|
||||||
emptyTitle="No users found."
|
emptyTitle="No users found."
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit className="size-4" />,
|
||||||
|
permission: PERMISSIONS.USER.UPDATE,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (user) =>
|
||||||
|
user.effective_status === 'active' ? 'Deactivate' : 'Activate',
|
||||||
|
icon: <RotateCcw className="size-4" />,
|
||||||
|
permission: PERMISSIONS.USER.DELETE,
|
||||||
|
disabled: (user) =>
|
||||||
|
pendingUserId === user.id || user.effective_status === 'pending',
|
||||||
|
onClick: onToggleStatus,
|
||||||
|
},
|
||||||
|
]}
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
handleSubmit: submitForm,
|
handleSubmit: submitForm,
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
formState: { errors, isSubmitting, touchedFields },
|
formState: { errors, isSubmitting, isSubmitted },
|
||||||
} = useForm<UserFormValues>({
|
} = useForm<UserFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
mode: 'onTouched',
|
mode: 'onSubmit',
|
||||||
reValidateMode: 'onChange',
|
reValidateMode: 'onChange',
|
||||||
resolver: zodResolver(userFormSchema),
|
resolver: zodResolver(userFormSchema),
|
||||||
});
|
});
|
||||||
@@ -62,7 +62,9 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
const lastName = useWatch({ control, name: 'last_name' }) || '';
|
const lastName = useWatch({ control, name: 'last_name' }) || '';
|
||||||
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 = phoneNumber.trim() === '' || /^\+(?:[0-9] ?){6,14}[0-9]$/.test(phoneNumber.trim());
|
const isPhoneValid =
|
||||||
|
phoneNumber.trim() === '' ||
|
||||||
|
/^\+(?:[0-9] ?){6,14}[0-9]$/.test(phoneNumber.trim());
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
firstName.trim().length > 0 &&
|
firstName.trim().length > 0 &&
|
||||||
lastName.trim().length > 0 &&
|
lastName.trim().length > 0 &&
|
||||||
@@ -131,7 +133,7 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
roleId,
|
roleId,
|
||||||
userId,
|
userId,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving: isSubmitting || saveMutation.isPending,
|
isSaving: isSubmitting || saveMutation.isPending,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useCallback, useMemo, useState } from 'react';
|
|||||||
import { Plus, Users } from 'lucide-react';
|
import { Plus, Users } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import { PermissionGuard } from '@/guards';
|
import { PermissionGuard } from '@/guards';
|
||||||
@@ -55,7 +54,7 @@ export default function UsersPage() {
|
|||||||
roleId,
|
roleId,
|
||||||
setRoleId,
|
setRoleId,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
} = userForm;
|
} = userForm;
|
||||||
@@ -82,11 +81,7 @@ export default function UsersPage() {
|
|||||||
[updateUserStatus],
|
[updateUserStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useUserColumns({
|
const columns = useUserColumns();
|
||||||
onEdit: openEdit,
|
|
||||||
onToggleStatus: toggleStatus,
|
|
||||||
pendingUserId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = usersQuery.data?.total ?? 0;
|
const total = usersQuery.data?.total ?? 0;
|
||||||
const users = usersQuery.data?.items ?? [];
|
const users = usersQuery.data?.items ?? [];
|
||||||
@@ -138,9 +133,10 @@ export default function UsersPage() {
|
|||||||
onPageChange={setSkip}
|
onPageChange={setSkip}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={setLimit}
|
||||||
onSortingChange={setSorting}
|
onSortingChange={setSorting}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onToggleStatus={toggleStatus}
|
||||||
|
pendingUserId={pendingUserId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<UserSheet
|
<UserSheet
|
||||||
@@ -149,7 +145,7 @@ export default function UsersPage() {
|
|||||||
userId={userId}
|
userId={userId}
|
||||||
register={register}
|
register={register}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
touchedFields={touchedFields}
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
roleId={roleId}
|
roleId={roleId}
|
||||||
onRoleChange={setRoleId}
|
onRoleChange={setRoleId}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
|
@import './typography.css';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@@ -7,7 +8,7 @@
|
|||||||
--radius: 0.65rem;
|
--radius: 0.65rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
--card: oklch(1 0 0 / 0.4);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.141 0.005 285.823);
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
@@ -23,14 +24,14 @@
|
|||||||
--border: oklch(0.92 0.004 286.32);
|
--border: oklch(0.92 0.004 286.32);
|
||||||
--input: oklch(0.92 0.004 286.32);
|
--input: oklch(0.92 0.004 286.32);
|
||||||
--ring: oklch(0.702 0.183 293.541);
|
--ring: oklch(0.702 0.183 293.541);
|
||||||
--chart-1: oklch(0.65 0.18 25); /* Soft Red/Rose - High Priority */
|
--chart-1: oklch(0.65 0.18 25); /* Soft Red/Rose - High Priority */
|
||||||
--chart-2: oklch(0.78 0.12 75); /* Warm Amber - Distinct */
|
--chart-2: oklch(0.78 0.12 75); /* Warm Amber - Distinct */
|
||||||
--chart-3: oklch(0.68 0.12 245); /* Azure Blue - Cool Professional */
|
--chart-3: oklch(0.68 0.12 245); /* Azure Blue - Cool Professional */
|
||||||
--chart-4: oklch(0.75 0.1 165); /* Mint Teal - Balanced */
|
--chart-4: oklch(0.75 0.1 165); /* Mint Teal - Balanced */
|
||||||
--chart-5: oklch(0.85 0.08 195); /* Soft Cyan - Subdued */
|
--chart-5: oklch(0.85 0.08 195); /* Soft Cyan - Subdued */
|
||||||
--chart-6: oklch(0.62 0.22 295); /* Deep Violet - Theme Primary */
|
--chart-6: oklch(0.62 0.22 295); /* Deep Violet - Theme Primary */
|
||||||
--chart-8: oklch(0.58 0.2 335); /* Cool Magenta - Distant Accent */
|
--chart-8: oklch(0.58 0.2 335); /* Cool Magenta - Distant Accent */
|
||||||
--sidebar: oklch(0.985 0 0 / 0.6);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
--sidebar-primary: oklch(0.541 0.281 293.009);
|
--sidebar-primary: oklch(0.541 0.281 293.009);
|
||||||
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
|
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
|
||||||
@@ -43,7 +44,7 @@
|
|||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885 / 0.6);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.21 0.006 285.885);
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
@@ -66,7 +67,7 @@
|
|||||||
--chart-5: oklch(0.88 0.1 195);
|
--chart-5: oklch(0.88 0.1 195);
|
||||||
--chart-6: oklch(0.65 0.24 295);
|
--chart-6: oklch(0.65 0.24 295);
|
||||||
--chart-8: oklch(0.62 0.22 335);
|
--chart-8: oklch(0.62 0.22 335);
|
||||||
--sidebar: oklch(0.21 0.006 285.885 / 0.6);
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.606 0.25 292.717);
|
--sidebar-primary: oklch(0.606 0.25 292.717);
|
||||||
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
|
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
|
||||||
@@ -122,34 +123,10 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-transparent text-foreground;
|
@apply bg-background text-foreground;
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modern Scrollbar Styles */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px; /* Slightly wider for better accessibility */
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background-color: var(--muted-foreground);
|
|
||||||
border-radius: 20px;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
background-clip: content-box;
|
|
||||||
opacity: 0.5; /* More visible default */
|
|
||||||
}
|
|
||||||
/* Firefox Support */
|
|
||||||
* {
|
|
||||||
/* scrollbar-width: ; */
|
|
||||||
scrollbar-color: var(--muted-foreground) transparent;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { ThemeProvider } from '@/providers/ThemeProvider';
|
|||||||
import AppInitializer from '@/providers/AppInitializer';
|
import AppInitializer from '@/providers/AppInitializer';
|
||||||
import QueryProvider from '@/providers/QueryProvider';
|
import QueryProvider from '@/providers/QueryProvider';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { BackgroundGradient } from '@/components/background-gradient';
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: '--font-geist-sans',
|
variable: '--font-geist-sans',
|
||||||
@@ -18,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({
|
||||||
@@ -29,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"
|
||||||
@@ -38,10 +42,7 @@ export default function RootLayout({
|
|||||||
>
|
>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<AppInitializer>
|
<AppInitializer>
|
||||||
<div className="relative min-h-screen isolate">
|
{children}
|
||||||
<BackgroundGradient />
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
</AppInitializer>
|
</AppInitializer>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
|
|||||||
56
src/app/typography.css
Normal file
56
src/app/typography.css
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
@layer base {
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
/* margin: 0.1rem 0 0.1rem; */
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1:first-child,
|
||||||
|
h2:first-child,
|
||||||
|
h3:first-child,
|
||||||
|
h4:first-child,
|
||||||
|
h5:first-child,
|
||||||
|
h6:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.2rem 0 0.1rem;
|
||||||
|
/* line-height: 1.5; */
|
||||||
|
}
|
||||||
|
|
||||||
|
p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export function BackgroundGradient() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 -z-10 h-full w-full overflow-hidden pointer-events-none"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{/* Top Gradient */}
|
|
||||||
<div className="absolute inset-x-0 top-0 transform-gpu overflow-hidden blur-3xl">
|
|
||||||
<div
|
|
||||||
className="relative left-[calc(50%-11rem)] aspect-1155/678 w-144.5 -translate-x-1/2 rotate-30 bg-linear-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%-30rem)] sm:w-288.75"
|
|
||||||
style={{
|
|
||||||
clipPath:
|
|
||||||
'polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Middle/Bottom Gradient for balance */}
|
|
||||||
<div className="absolute inset-x-0 bottom-0 transform-gpu overflow-hidden blur-3xl">
|
|
||||||
<div
|
|
||||||
className="relative left-[calc(50%+11rem)] aspect-1155/678 w-144.5 -translate-x-1/2 rotate-30 bg-linear-to-tr from-[#ff80b5] to-[#9089fc] opacity-20 sm:left-[calc(50%+30rem)] sm:w-288.75"
|
|
||||||
style={{
|
|
||||||
clipPath:
|
|
||||||
'polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BackgroundGradientBottom() {
|
|
||||||
return null; // Integrated into the main BackgroundGradient component
|
|
||||||
}
|
|
||||||
@@ -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))}
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ const TopHeader = ({
|
|||||||
{(title || onAddNew) && (
|
{(title || onAddNew) && (
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
{title ? <h2 className="text-base font-semibold">{title}</h2> : null}
|
{title ? <h2>{title}</h2> : null}
|
||||||
<p className="text-sm text-muted-foreground">Total {itemCount ?? 0}</p>
|
<p className="text-muted-foreground">Total {itemCount ?? 0}</p>
|
||||||
</div>
|
</div>
|
||||||
{onAddNew ? (
|
{onAddNew ? (
|
||||||
<Button onClick={onAddNew}>
|
<Button onClick={onAddNew}>
|
||||||
|
|||||||
52
src/components/data-table/TableActionButton.tsx
Normal file
52
src/components/data-table/TableActionButton.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { MouseEvent, ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { PermissionGuard } from '@/guards';
|
||||||
|
import type { PermissionInput } from '@/hooks/usePermissions';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TableActionButtonProps {
|
||||||
|
label: string;
|
||||||
|
permission?: PermissionInput;
|
||||||
|
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableActionButton({
|
||||||
|
label,
|
||||||
|
permission,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: TableActionButtonProps) {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permissions={permission}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className={cn('size-8', className)}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
'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> }) => {
|
||||||
return (
|
return (
|
||||||
<ShadTableHeader className="sticky top-0 z-10 bg-card/80 backdrop-blur-xl">
|
<ShadTableHeader className="sticky top-0 z-10 bg-card">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
@@ -23,9 +31,11 @@ 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/80 px-2 text-foreground backdrop-blur-xl transition-colors',
|
'h-10 bg-card px-2 text-foreground transition-colors',
|
||||||
canSort && 'cursor-pointer select-none hover:bg-muted/60',
|
canSort && 'cursor-pointer select-none hover:bg-muted/60',
|
||||||
sortDirection && 'bg-muted/70',
|
sortDirection && 'bg-muted/70',
|
||||||
)}
|
)}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -12,12 +12,22 @@ import {
|
|||||||
|
|
||||||
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
import { Edit3, Trash2 } from 'lucide-react';
|
import type { PermissionInput } from '@/hooks/usePermissions';
|
||||||
|
|
||||||
import TopHeader from './Header';
|
import TopHeader from './Header';
|
||||||
import TableHeader from './TableHeader';
|
import TableHeader from './TableHeader';
|
||||||
import { TableFooter } from './Footer';
|
import { TableFooter } from './Footer';
|
||||||
|
import { TableActionButton } from './TableActionButton';
|
||||||
|
|
||||||
|
export interface DataTableAction<TData> {
|
||||||
|
label: string | ((item: TData) => string);
|
||||||
|
icon: React.ReactNode;
|
||||||
|
onClick: (item: TData) => void;
|
||||||
|
permission?: PermissionInput;
|
||||||
|
disabled?: (item: TData) => boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DataTableProps<TData, TValue> {
|
export interface DataTableProps<TData, TValue> {
|
||||||
columns: ColumnDef<TData, TValue>[];
|
columns: ColumnDef<TData, TValue>[];
|
||||||
@@ -26,8 +36,7 @@ export interface DataTableProps<TData, TValue> {
|
|||||||
onAddNew?: () => void;
|
onAddNew?: () => void;
|
||||||
addButtonText?: string;
|
addButtonText?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onEdit?: (item: TData) => void;
|
actions?: DataTableAction<TData>[];
|
||||||
onDelete?: (item: TData) => void;
|
|
||||||
toolbar?: React.ReactNode;
|
toolbar?: React.ReactNode;
|
||||||
emptyTitle?: string;
|
emptyTitle?: string;
|
||||||
emptyDescription?: string;
|
emptyDescription?: string;
|
||||||
@@ -49,8 +58,7 @@ export function DataTable<TData, TValue>({
|
|||||||
onAddNew,
|
onAddNew,
|
||||||
addButtonText,
|
addButtonText,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
onEdit,
|
actions,
|
||||||
onDelete,
|
|
||||||
toolbar,
|
toolbar,
|
||||||
emptyTitle = 'No results found.',
|
emptyTitle = 'No results found.',
|
||||||
emptyDescription = 'Try adjusting your filters or search terms.',
|
emptyDescription = 'Try adjusting your filters or search terms.',
|
||||||
@@ -59,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;
|
||||||
@@ -77,7 +88,7 @@ export function DataTable<TData, TValue>({
|
|||||||
const columns = React.useMemo(() => {
|
const columns = React.useMemo(() => {
|
||||||
const cols: ColumnDef<TData, TValue>[] = [...initialColumns];
|
const cols: ColumnDef<TData, TValue>[] = [...initialColumns];
|
||||||
|
|
||||||
if (onEdit || onDelete) {
|
if (actions?.length) {
|
||||||
cols.push({
|
cols.push({
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: () => <div className="text-right">Actions</div>,
|
header: () => <div className="text-right">Actions</div>,
|
||||||
@@ -85,37 +96,35 @@ export function DataTable<TData, TValue>({
|
|||||||
const item = row.original;
|
const item = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
{onEdit && (
|
{actions.map((action) => {
|
||||||
<Button
|
const label =
|
||||||
variant="outline"
|
typeof action.label === 'function'
|
||||||
size="sm"
|
? action.label(item)
|
||||||
onClick={(e) => {
|
: action.label;
|
||||||
e.stopPropagation();
|
|
||||||
onEdit(item);
|
return (
|
||||||
}}
|
<TableActionButton
|
||||||
>
|
key={label}
|
||||||
<Edit3 className="size-4" />
|
label={label}
|
||||||
</Button>
|
permission={action.permission}
|
||||||
)}
|
className={action.className}
|
||||||
{onDelete && (
|
disabled={action.disabled?.(item)}
|
||||||
<Button
|
onClick={(event) => {
|
||||||
variant="outline"
|
event.stopPropagation();
|
||||||
size="sm"
|
action.onClick(item);
|
||||||
onClick={(e) => {
|
}}
|
||||||
e.stopPropagation();
|
>
|
||||||
onDelete(item);
|
{action.icon}
|
||||||
}}
|
</TableActionButton>
|
||||||
>
|
);
|
||||||
<Trash2 className="size-4 text-destructive" />
|
})}
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return cols;
|
return cols;
|
||||||
}, [initialColumns, onEdit, onDelete]);
|
}, [actions, initialColumns]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -127,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,
|
||||||
@@ -158,7 +169,8 @@ export function DataTable<TData, TValue>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4 rounded-lg border bg-card p-4">
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<section className="space-y-4 rounded-lg border bg-card p-4">
|
||||||
<TopHeader
|
<TopHeader
|
||||||
title={onAddNew ? title : undefined}
|
title={onAddNew ? title : undefined}
|
||||||
itemCount={pagination?.totalItems ?? data.length}
|
itemCount={pagination?.totalItems ?? data.length}
|
||||||
@@ -192,14 +204,20 @@ export function DataTable<TData, TValue>({
|
|||||||
>
|
>
|
||||||
{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>
|
||||||
@@ -216,6 +234,7 @@ export function DataTable<TData, TValue>({
|
|||||||
pageSize={pagination?.limit}
|
pageSize={pagination?.limit}
|
||||||
onPageSizeChange={pagination?.onLimitChange}
|
onPageSizeChange={pagination?.onLimitChange}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,21 +11,19 @@ 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 items-center gap-6">
|
<div className="flex flex-col">
|
||||||
{/* <div className="p-3.5 rounded-xl bg-primary text-primary-foreground flex items-center justify-center shadow-lg shadow-primary/5 ring-1 ring-white/10">
|
<h1>{title}</h1>
|
||||||
{Icon && <Icon className="h-7 w-7" />}
|
<p className="text-muted-foreground opacity-80">{description}</p>
|
||||||
{children}
|
|
||||||
</div> */}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<h1 className="text-3xl font-extrabold tracking-tight ">{title}</h1>
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm font-semibold tracking-wide opacity-80">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{actions && <div className="flex items-center gap-4">{actions}</div>}
|
{actions && <div className="flex items-center gap-4">{actions}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user