chore: update eslint and prettier configuration

This commit is contained in:
2026-06-17 16:23:28 +05:30
parent b54242cf7e
commit 4c2241ff72
140 changed files with 2270 additions and 1287 deletions

2
.env.example Normal file
View File

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

View File

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

View File

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

View File

@@ -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
} }

View File

@@ -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
View File

@@ -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
View File

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

477
package-lock.json generated
View File

@@ -51,11 +51,10 @@
"autoprefixer": "^10.4.20", "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",

View File

@@ -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",

View File

@@ -17,7 +17,9 @@ export default function LoginPage() {
<div className="flex flex-1 md:w-[60%] items-center justify-center p-6 border-l border-border h-screen"> <div className="flex flex-1 md:w-[60%] items-center justify-center p-6 border-l border-border h-screen">
<section className="w-full max-w-sm space-y-7"> <section className="w-full max-w-sm space-y-7">
<header className="space-y-2 text-center md:text-left"> <header className="space-y-2 text-center md:text-left">
<p className="text-sm font-medium text-muted-foreground">VisionRoad</p> <p className="text-sm font-medium text-muted-foreground">
VisionRoad
</p>
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1> <h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Use your email and password to continue. Use your email and password to continue.
@@ -39,7 +41,9 @@ export default function LoginPage() {
})} })}
/> />
{errors.email && ( {errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p> <p className="text-sm text-destructive">
{errors.email.message}
</p>
)} )}
</div> </div>
@@ -57,7 +61,9 @@ export default function LoginPage() {
})} })}
/> />
{errors.password && ( {errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p> <p className="text-sm text-destructive">
{errors.password.message}
</p>
)} )}
</div> </div>

View File

@@ -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>

View File

@@ -21,7 +21,9 @@ export function useClientColumns(): ColumnDef<Client>[] {
{ {
accessorKey: 'name', accessorKey: 'name',
header: 'Client', header: 'Client',
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>, cell: ({ row }) => (
<span className="font-medium">{row.original.name}</span>
),
}, },
{ {
accessorKey: 'email', accessorKey: 'email',
@@ -33,7 +35,9 @@ export function useClientColumns(): ColumnDef<Client>[] {
cell: ({ row }) => ( cell: ({ row }) => (
<div> <div>
<p className="font-medium">{row.original.contact_name}</p> <p className="font-medium">{row.original.contact_name}</p>
<p className="text-xs text-muted-foreground">{row.original.contact_phone_number}</p> <p className="text-xs text-muted-foreground">
{row.original.contact_phone_number}
</p>
</div> </div>
), ),
}, },

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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 });

View File

@@ -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],
); );

View File

@@ -44,7 +44,8 @@ export default function ClientsPage() {
const clientForm = useClientForm({ const clientForm = useClientForm({
onSaved: () => setIsSheetOpen(false), onSaved: () => setIsSheetOpen(false),
}); });
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } = clientForm; const { openCreate: prepareCreateClient, openEdit: prepareEditClient } =
clientForm;
const { register, handleSubmit, clientId, isSaving } = clientForm; const { register, handleSubmit, clientId, isSaving } = clientForm;
const statusMutation = useClientStatusMutation(); const statusMutation = useClientStatusMutation();
const { mutate: updateClientStatus, pendingClientId } = statusMutation; const { mutate: updateClientStatus, pendingClientId } = statusMutation;

View File

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

View File

@@ -4,7 +4,11 @@ import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Package, Project } from '@/types'; import type { Package, Project } from '@/types';
function EmptyValue() { function EmptyValue() {
@@ -42,9 +46,7 @@ function StateBadges({ value }: { value: string | null }) {
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start"> <PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-muted-foreground"> <p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
Other States
</p>
{remainingStates.map((state) => ( {remainingStates.map((state) => (
<Badge key={state} variant="secondary"> <Badge key={state} variant="secondary">
{state} {state}
@@ -70,14 +72,16 @@ export function usePackageColumns(projects: Project[]) {
accessorKey: 'project_id', accessorKey: 'project_id',
header: 'Project', header: 'Project',
cell: ({ row }) => cell: ({ row }) =>
projects.find((project) => project.id === row.original.project_id)?.name || projects.find((project) => project.id === row.original.project_id)
row.original.project_id, ?.name || row.original.project_id,
}, },
{ {
id: 'project_state', id: 'project_state',
header: 'Project State', header: 'Project State',
cell: ({ row }) => { cell: ({ row }) => {
const project = projects.find((item) => item.id === row.original.project_id); const project = projects.find(
(item) => item.id === row.original.project_id,
);
return <StateBadges value={project?.state ?? null} />; return <StateBadges value={project?.state ?? null} />;
}, },
}, },

View File

@@ -57,14 +57,23 @@ export function PackageDialog({
canSubmit, canSubmit,
isSaving, isSaving,
}: PackageDialogProps) { }: PackageDialogProps) {
const projectErrorMessage = isSubmitted ? errors.project_id?.message : undefined; const projectErrorMessage = isSubmitted
? errors.project_id?.message
: undefined;
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined; const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const startErrorMessage = isSubmitted ? errors.chainage_start_km?.message : undefined; const startErrorMessage = isSubmitted
const endErrorMessage = isSubmitted ? errors.chainage_end_km?.message : undefined; ? errors.chainage_start_km?.message
: undefined;
const endErrorMessage = isSubmitted
? errors.chainage_end_km?.message
: undefined;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl" onOpenAutoFocus={(event) => event.preventDefault()}> <DialogContent
className="max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="gap-2"> <DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3"> <DialogTitle className="flex items-center gap-3">
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm"> <div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
@@ -101,12 +110,22 @@ export function PackageDialog({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<input type="hidden" {...register('project_id')} value={projectId} readOnly /> <input
type="hidden"
{...register('project_id')}
value={projectId}
readOnly
/>
</FormField> </FormField>
) : null} ) : null}
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
<FormField id="package-name" label="Package Name" required error={nameErrorMessage}> <FormField
id="package-name"
label="Package Name"
required
error={nameErrorMessage}
>
<Input <Input
id="package-name" id="package-name"
placeholder="e.g. Package 01" placeholder="e.g. Package 01"
@@ -124,10 +143,18 @@ export function PackageDialog({
</span> </span>
} }
> >
<Input id="package-region" placeholder="e.g. North Zone" {...register('region')} /> <Input
id="package-region"
placeholder="e.g. North Zone"
{...register('region')}
/>
</FormField> </FormField>
<FormField id="package-start" label="Segment Start (km)" error={startErrorMessage}> <FormField
id="package-start"
label="Segment Start (km)"
error={startErrorMessage}
>
<Input <Input
id="package-start" id="package-start"
type="number" type="number"
@@ -138,7 +165,11 @@ export function PackageDialog({
/> />
</FormField> </FormField>
<FormField id="package-end" label="Segment End (km)" error={endErrorMessage}> <FormField
id="package-end"
label="Segment End (km)"
error={endErrorMessage}
>
<Input <Input
id="package-end" id="package-end"
type="number" type="number"

View File

@@ -49,7 +49,9 @@ function toNumberOrZero(value: string) {
return value.trim() ? Number(value) : 0; return value.trim() ? Number(value) : 0;
} }
function toPackagePayload(values: PackageFormValues): PackageCreate | PackageUpdate { function toPackagePayload(
values: PackageFormValues,
): PackageCreate | PackageUpdate {
return { return {
project_id: values.project_id, project_id: values.project_id,
name: values.name.trim(), name: values.name.trim(),
@@ -78,7 +80,8 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
const packageId = useWatch({ control, name: 'id' }); const packageId = useWatch({ control, name: 'id' });
const projectId = useWatch({ control, name: 'project_id' }) || ''; const projectId = useWatch({ control, name: 'project_id' }) || '';
const packageName = useWatch({ control, name: 'name' }) || ''; const packageName = useWatch({ control, name: 'name' }) || '';
const canSubmit = projectId.trim().length > 0 && packageName.trim().length > 0; const canSubmit =
projectId.trim().length > 0 && packageName.trim().length > 0;
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: (values: PackageFormValues) => { mutationFn: (values: PackageFormValues) => {
@@ -94,7 +97,9 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: packageKeys.all }); queryClient.invalidateQueries({ queryKey: packageKeys.all });
}, },
onError: (_error, values) => { onError: (_error, values) => {
toast.error(values.id ? 'Failed to update package' : 'Failed to create package'); toast.error(
values.id ? 'Failed to update package' : 'Failed to create package',
);
}, },
}); });
@@ -130,7 +135,11 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
); );
const setProjectId = useCallback( const setProjectId = useCallback(
(value: string) => setValue('project_id', value, { shouldDirty: true, shouldValidate: true }), (value: string) =>
setValue('project_id', value, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue], [setValue],
); );

View File

@@ -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),

View File

@@ -37,7 +37,8 @@ export function usePlanColumns(): ColumnDef<Plan>[] {
{ {
accessorKey: 'price', accessorKey: 'price',
header: 'Price', header: 'Price',
cell: ({ row }) => formatPrice(row.original.price, row.original.billing_cycle), cell: ({ row }) =>
formatPrice(row.original.price, row.original.billing_cycle),
}, },
{ {
accessorKey: 'trial_days', accessorKey: 'trial_days',
@@ -68,7 +69,9 @@ export function usePlanColumns(): ColumnDef<Plan>[] {
<Badge variant={row.original.is_active ? 'default' : 'secondary'}> <Badge variant={row.original.is_active ? 'default' : 'secondary'}>
{row.original.is_active ? 'Active' : 'Inactive'} {row.original.is_active ? 'Active' : 'Inactive'}
</Badge> </Badge>
{row.original.is_custom ? <Badge variant="outline">Custom</Badge> : null} {row.original.is_custom ? (
<Badge variant="outline">Custom</Badge>
) : null}
</div> </div>
), ),
}, },

View File

@@ -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>

View File

@@ -201,7 +201,9 @@ export function PlanSheet({
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<Label>Permissions</Label> <Label>Permissions</Label>
<span className="text-muted-foreground">{permissionIds.length} selected</span> <span className="text-muted-foreground">
{permissionIds.length} selected
</span>
</div> </div>
{isPermissionsLoading ? ( {isPermissionsLoading ? (
<div className="rounded-md border p-6 text-muted-foreground"> <div className="rounded-md border p-6 text-muted-foreground">
@@ -226,7 +228,9 @@ export function PlanSheet({
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSaving}> <Button type="submit" disabled={isSaving}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null} {isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{planId ? 'Update Plan' : 'Create Plan'} {planId ? 'Update Plan' : 'Create Plan'}
</Button> </Button>
</SheetFooter> </SheetFooter>

View File

@@ -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],
); );

View File

@@ -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() });

View File

@@ -16,7 +16,10 @@ import { PlanTable } from './components/PlanTable';
import { usePlanFilters } from './hooks/usePlanFilters'; import { usePlanFilters } from './hooks/usePlanFilters';
import { usePlanForm } from './hooks/usePlanForm'; import { usePlanForm } from './hooks/usePlanForm';
import { usePlanStatusMutation } from './hooks/usePlanMutations'; import { usePlanStatusMutation } from './hooks/usePlanMutations';
import { useOrganizationPermissionTreeQuery, usePlansQuery } from './hooks/usePlanQueries'; import {
useOrganizationPermissionTreeQuery,
usePlansQuery,
} from './hooks/usePlanQueries';
export default function PlansPage() { export default function PlansPage() {
const [isSheetOpen, setIsSheetOpen] = useState(false); const [isSheetOpen, setIsSheetOpen] = useState(false);
@@ -57,8 +60,15 @@ export default function PlansPage() {
onSaved: () => handleSheetOpenChange(false), onSaved: () => handleSheetOpenChange(false),
}); });
const { openCreate: prepareCreatePlan, openEdit: prepareEditPlan } = planForm; const { openCreate: prepareCreatePlan, openEdit: prepareEditPlan } = planForm;
const { register, control, onSubmit, planId, permissionIds, setPermissionIds, isSaving } = const {
planForm; register,
control,
onSubmit,
planId,
permissionIds,
setPermissionIds,
isSaving,
} = planForm;
const statusMutation = usePlanStatusMutation(); const statusMutation = usePlanStatusMutation();
const openCreate = useCallback(() => { const openCreate = useCallback(() => {

View File

@@ -4,7 +4,11 @@ import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Project } from '@/types'; import type { Project } from '@/types';
function EmptyValue() { function EmptyValue() {
@@ -42,9 +46,7 @@ function StateBadges({ value }: { value: string | null }) {
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start"> <PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-muted-foreground"> <p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
Other States
</p>
{remainingStates.map((state) => ( {remainingStates.map((state) => (
<Badge key={state} variant="secondary"> <Badge key={state} variant="secondary">
{state} {state}

View File

@@ -42,14 +42,21 @@ export function ProjectDialog({
isSaving, isSaving,
}: ProjectDialogProps) { }: ProjectDialogProps) {
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined; const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const startLatErrorMessage = isSubmitted ? errors.start_lat?.message : undefined; const startLatErrorMessage = isSubmitted
const startLngErrorMessage = isSubmitted ? errors.start_lng?.message : undefined; ? errors.start_lat?.message
: undefined;
const startLngErrorMessage = isSubmitted
? errors.start_lng?.message
: undefined;
const endLatErrorMessage = isSubmitted ? errors.end_lat?.message : undefined; const endLatErrorMessage = isSubmitted ? errors.end_lat?.message : undefined;
const endLngErrorMessage = isSubmitted ? errors.end_lng?.message : undefined; const endLngErrorMessage = isSubmitted ? errors.end_lng?.message : undefined;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl" onOpenAutoFocus={(event) => event.preventDefault()}> <DialogContent
className="max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="gap-2"> <DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3"> <DialogTitle className="flex items-center gap-3">
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm"> <div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
@@ -65,7 +72,12 @@ export function ProjectDialog({
</DialogHeader> </DialogHeader>
<form onSubmit={onSubmit} className="space-y-6"> <form onSubmit={onSubmit} className="space-y-6">
<FormField id="project-name" label="Project Name" required error={nameErrorMessage}> <FormField
id="project-name"
label="Project Name"
required
error={nameErrorMessage}
>
<Input <Input
id="project-name" id="project-name"
placeholder="Enter a descriptive project name" placeholder="Enter a descriptive project name"
@@ -76,7 +88,11 @@ export function ProjectDialog({
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
<FormField id="project-state" label="State"> <FormField id="project-state" label="State">
<Input id="project-state" placeholder="e.g. Maharashtra" {...register('state')} /> <Input
id="project-state"
placeholder="e.g. Maharashtra"
{...register('state')}
/>
</FormField> </FormField>
<FormField <FormField
id="project-corridor" id="project-corridor"
@@ -102,7 +118,11 @@ export function ProjectDialog({
START POINT START POINT
</p> </p>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField id="project-start-lat" label="Lat" error={startLatErrorMessage}> <FormField
id="project-start-lat"
label="Lat"
error={startLatErrorMessage}
>
<Input <Input
id="project-start-lat" id="project-start-lat"
type="number" type="number"
@@ -113,7 +133,11 @@ export function ProjectDialog({
{...register('start_lat')} {...register('start_lat')}
/> />
</FormField> </FormField>
<FormField id="project-start-lng" label="Lng" error={startLngErrorMessage}> <FormField
id="project-start-lng"
label="Lng"
error={startLngErrorMessage}
>
<Input <Input
id="project-start-lng" id="project-start-lng"
type="number" type="number"
@@ -133,7 +157,11 @@ export function ProjectDialog({
END POINT END POINT
</p> </p>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField id="project-end-lat" label="Lat" error={endLatErrorMessage}> <FormField
id="project-end-lat"
label="Lat"
error={endLatErrorMessage}
>
<Input <Input
id="project-end-lat" id="project-end-lat"
type="number" type="number"
@@ -144,7 +172,11 @@ export function ProjectDialog({
{...register('end_lat')} {...register('end_lat')}
/> />
</FormField> </FormField>
<FormField id="project-end-lng" label="Lng" error={endLngErrorMessage}> <FormField
id="project-end-lng"
label="Lng"
error={endLngErrorMessage}
>
<Input <Input
id="project-end-lng" id="project-end-lng"
type="number" type="number"

View File

@@ -53,7 +53,9 @@ function toNullableNumber(value: string) {
return value.trim() ? Number(value) : null; return value.trim() ? Number(value) : null;
} }
function toProjectPayload(values: ProjectFormValues): ProjectCreate | ProjectUpdate { function toProjectPayload(
values: ProjectFormValues,
): ProjectCreate | ProjectUpdate {
return { return {
name: values.name.trim(), name: values.name.trim(),
state: trimOptional(values.state), state: trimOptional(values.state),
@@ -98,7 +100,9 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: projectKeys.all }); queryClient.invalidateQueries({ queryKey: projectKeys.all });
}, },
onError: (_error, values) => { onError: (_error, values) => {
toast.error(values.id ? 'Failed to update project' : 'Failed to create project'); toast.error(
values.id ? 'Failed to update project' : 'Failed to create project',
);
}, },
}); });

View File

@@ -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.',
}); });
}, },
}); });

View File

@@ -12,7 +12,10 @@ import { ProjectTable } from './components/ProjectTable';
import { useProjectColumns } from './components/ProjectColumns'; import { useProjectColumns } from './components/ProjectColumns';
import { useProjectFilters } from './hooks/useProjectFilters'; import { useProjectFilters } from './hooks/useProjectFilters';
import { useProjectForm } from './hooks/useProjectForm'; import { useProjectForm } from './hooks/useProjectForm';
import { useDeleteProjectMutation, useProjectsQuery } from './hooks/useProjectQueries'; import {
useDeleteProjectMutation,
useProjectsQuery,
} from './hooks/useProjectQueries';
export default function ProjectPage() { export default function ProjectPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
@@ -60,7 +63,9 @@ export default function ProjectPage() {
const deleteProject = useCallback( const deleteProject = useCallback(
(project: Project) => { (project: Project) => {
if (!confirm(`Are you sure you want to delete project "${project.name}"?`)) { if (
!confirm(`Are you sure you want to delete project "${project.name}"?`)
) {
return; return;
} }
deleteProjectMutation.mutate(project); deleteProjectMutation.mutate(project);

View File

@@ -19,8 +19,11 @@ export default function VideoResultsPage() {
const router = useRouter(); const router = useRouter();
const { videoId } = useParams() as { videoId: string }; const { videoId } = useParams() as { videoId: string };
const [session, setSession] = useState<SessionContext | null>(null); const [session, setSession] = useState<SessionContext | null>(null);
const [detectionData, setDetectionData] = useState<DetectionData | null>(null); const [detectionData, setDetectionData] = useState<DetectionData | null>(
const [detectionType, setDetectionType] = useState<DetectionType>('pothole-detection'); null,
);
const [detectionType, setDetectionType] =
useState<DetectionType>('pothole-detection');
const [videoFile, setVideoFile] = useState<File | null>(null); const [videoFile, setVideoFile] = useState<File | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -89,7 +92,9 @@ export default function VideoResultsPage() {
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
<Card className="flex flex-col items-center gap-4 p-8"> <Card className="flex flex-col items-center gap-4 p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading detection results...</p> <p className="text-sm text-muted-foreground">
Loading detection results...
</p>
</Card> </Card>
</div> </div>
); );
@@ -101,7 +106,10 @@ export default function VideoResultsPage() {
<Card className="flex flex-col items-center gap-6 p-8 text-center max-w-md"> <Card className="flex flex-col items-center gap-6 p-8 text-center max-w-md">
<p className="text-destructive font-medium">{error}</p> <p className="text-destructive font-medium">{error}</p>
<div className="flex gap-4"> <div className="flex gap-4">
<Button onClick={() => router.push(ROUTES.UPLOAD)} variant="outline"> <Button
onClick={() => router.push(ROUTES.UPLOAD)}
variant="outline"
>
Back to Upload Back to Upload
</Button> </Button>
<Button onClick={handleNewAnalysis}>New Analysis</Button> <Button onClick={handleNewAnalysis}>New Analysis</Button>
@@ -116,7 +124,11 @@ export default function VideoResultsPage() {
<main className="min-h-screen"> <main className="min-h-screen">
<div className="container mx-auto px-6 py-10 max-w-[1600px]"> <div className="container mx-auto px-6 py-10 max-w-[1600px]">
<div className="mb-8"> <div className="mb-8">
<PageHeader title={getTitle()} description={`Video ID: ${videoId}`} icon={TrendingUp} /> <PageHeader
title={getTitle()}
description={`Video ID: ${videoId}`}
icon={TrendingUp}
/>
</div> </div>
{session && ( {session && (

View File

@@ -55,8 +55,10 @@ export default function ResultsPage() {
}; };
const getTitle = () => { const getTitle = () => {
if (detectionType === 'pothole-detection') return 'Pothole Detection Results'; if (detectionType === 'pothole-detection')
if (detectionType === 'sign-board-detection') return 'Signboard Detection Results'; return 'Pothole Detection Results';
if (detectionType === 'sign-board-detection')
return 'Signboard Detection Results';
return 'Pothole & Signboard Detection Results'; return 'Pothole & Signboard Detection Results';
}; };

View File

@@ -36,7 +36,9 @@ export function useRoleColumns(): ColumnDef<Role>[] {
accessorKey: 'effective_status', accessorKey: 'effective_status',
header: 'Status', header: 'Status',
cell: ({ row }) => ( cell: ({ row }) => (
<Badge variant={row.original.effective_status ? 'default' : 'secondary'}> <Badge
variant={row.original.effective_status ? 'default' : 'secondary'}
>
{row.original.effective_status ? 'Active' : 'Inactive'} {row.original.effective_status ? 'Active' : 'Inactive'}
</Badge> </Badge>
), ),

View File

@@ -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>

View File

@@ -54,7 +54,9 @@ export function RoleSheet({
isSaving, isSaving,
}: RoleSheetProps) { }: RoleSheetProps) {
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined; const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const displayNameErrorMessage = isSubmitted ? errors.display_name?.message : undefined; const displayNameErrorMessage = isSubmitted
? errors.display_name?.message
: undefined;
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
@@ -72,7 +74,12 @@ export function RoleSheet({
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col"> <form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4"> <div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<FormField id="role-name" label="Role Name" required error={nameErrorMessage}> <FormField
id="role-name"
label="Role Name"
required
error={nameErrorMessage}
>
<Input <Input
id="role-name" id="role-name"
placeholder="Enter role name" placeholder="Enter role name"
@@ -110,7 +117,9 @@ export function RoleSheet({
error={isSubmitted ? errors.permission_ids?.message : undefined} error={isSubmitted ? errors.permission_ids?.message : undefined}
className="space-y-3" className="space-y-3"
labelEnd={ labelEnd={
<span className="text-muted-foreground">{permissionIds.length} selected</span> <span className="text-muted-foreground">
{permissionIds.length} selected
</span>
} }
> >
{isPermissionsLoading ? ( {isPermissionsLoading ? (
@@ -129,7 +138,9 @@ export function RoleSheet({
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end"> <SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
<Button type="submit" disabled={isSaving || !canSubmit}> <Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null} {isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{roleId ? 'Update Role' : 'Create Role'} {roleId ? 'Update Role' : 'Create Role'}
</Button> </Button>
<SheetClose asChild> <SheetClose asChild>

View File

@@ -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],
); );

View File

@@ -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 });

View File

@@ -23,7 +23,9 @@ import {
} from './hooks/useRoleQueries'; } from './hooks/useRoleQueries';
export default function RolesPage() { export default function RolesPage() {
const organizationId = useAppStore((state) => state.user?.organization_id ?? null); const organizationId = useAppStore(
(state) => state.user?.organization_id ?? null,
);
const [isSheetOpen, setIsSheetOpen] = useState(false); const [isSheetOpen, setIsSheetOpen] = useState(false);
const { const {
skip, skip,
@@ -66,7 +68,8 @@ export default function RolesPage() {
isSaving, isSaving,
} = roleForm; } = roleForm;
const statusMutation = useRoleStatusMutation(); const statusMutation = useRoleStatusMutation();
const { mutate: updateRoleStatus, isPending: isStatusPending } = statusMutation; const { mutate: updateRoleStatus, isPending: isStatusPending } =
statusMutation;
const openCreate = useCallback(() => { const openCreate = useCallback(() => {
prepareCreateRole(); prepareCreateRole();

View File

@@ -2,10 +2,19 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { ArrowDownCircle, ArrowUpCircle, MapPin, Milestone } from 'lucide-react'; import {
ArrowDownCircle,
ArrowUpCircle,
MapPin,
Milestone,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Chainage, Package, Project } from '@/types'; import type { Chainage, Package, Project } from '@/types';
function EmptyValue() { function EmptyValue() {
@@ -43,9 +52,7 @@ function StateBadges({ value }: { value: string | null }) {
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start"> <PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-muted-foreground"> <p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
Other States
</p>
{remainingStates.map((state) => ( {remainingStates.map((state) => (
<Badge key={state} variant="secondary"> <Badge key={state} variant="secondary">
{state} {state}
@@ -78,7 +85,9 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
id: 'project', id: 'project',
header: 'Project', header: 'Project',
cell: ({ row }) => { cell: ({ row }) => {
const pkg = packages.find((item) => item.id === row.original.package_id); const pkg = packages.find(
(item) => item.id === row.original.package_id,
);
const project = projects.find((item) => item.id === pkg?.project_id); const project = projects.find((item) => item.id === pkg?.project_id);
return project?.name || <EmptyValue />; return project?.name || <EmptyValue />;
}, },
@@ -87,7 +96,9 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
id: 'project_state', id: 'project_state',
header: 'Project State', header: 'Project State',
cell: ({ row }) => { cell: ({ row }) => {
const pkg = packages.find((item) => item.id === row.original.package_id); const pkg = packages.find(
(item) => item.id === row.original.package_id,
);
const project = projects.find((item) => item.id === pkg?.project_id); const project = projects.find((item) => item.id === pkg?.project_id);
return <StateBadges value={project?.state ?? null} />; return <StateBadges value={project?.state ?? null} />;
}, },
@@ -127,7 +138,8 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
className="flex items-center gap-1.5 border-blue-500/50 text-blue-500" className="flex items-center gap-1.5 border-blue-500/50 text-blue-500"
> >
<MapPin className="size-3" /> <MapPin className="size-3" />
{row.original.start_lat.toFixed(4)}, {row.original.start_lng.toFixed(4)} {row.original.start_lat.toFixed(4)},{' '}
{row.original.start_lng.toFixed(4)}
</Badge> </Badge>
), ),
}, },

View File

@@ -95,7 +95,11 @@ export function SegmentDialog({
<form onSubmit={onSubmit} className="space-y-6"> <form onSubmit={onSubmit} className="space-y-6">
{!segmentId ? ( {!segmentId ? (
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
<FormField label="Project" required error={getError('project_id')}> <FormField
label="Project"
required
error={getError('project_id')}
>
<Select value={projectId} onValueChange={onProjectChange}> <Select value={projectId} onValueChange={onProjectChange}>
<SelectTrigger aria-invalid={!!getError('project_id')}> <SelectTrigger aria-invalid={!!getError('project_id')}>
{isProjectsLoading ? ( {isProjectsLoading ? (
@@ -115,10 +119,19 @@ export function SegmentDialog({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<input type="hidden" {...register('project_id')} value={projectId} readOnly /> <input
type="hidden"
{...register('project_id')}
value={projectId}
readOnly
/>
</FormField> </FormField>
<FormField label="Package" required error={getError('package_id')}> <FormField
label="Package"
required
error={getError('package_id')}
>
<Select <Select
value={packageId} value={packageId}
onValueChange={onPackageChange} onValueChange={onPackageChange}
@@ -132,7 +145,11 @@ export function SegmentDialog({
</span> </span>
) : ( ) : (
<SelectValue <SelectValue
placeholder={projectId ? 'Choose a package' : 'Select project first'} placeholder={
projectId
? 'Choose a package'
: 'Select project first'
}
/> />
)} )}
</SelectTrigger> </SelectTrigger>
@@ -144,7 +161,12 @@ export function SegmentDialog({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<input type="hidden" {...register('package_id')} value={packageId} readOnly /> <input
type="hidden"
{...register('package_id')}
value={packageId}
readOnly
/>
</FormField> </FormField>
</div> </div>
) : null} ) : null}
@@ -218,7 +240,12 @@ export function SegmentDialog({
START COORDINATES START COORDINATES
</p> </p>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField id="start-lat" label="Latitude" required error={getError('start_lat')}> <FormField
id="start-lat"
label="Latitude"
required
error={getError('start_lat')}
>
<Input <Input
id="start-lat" id="start-lat"
type="number" type="number"
@@ -230,7 +257,12 @@ export function SegmentDialog({
{...register('start_lat')} {...register('start_lat')}
/> />
</FormField> </FormField>
<FormField id="start-lng" label="Longitude" required error={getError('start_lng')}> <FormField
id="start-lng"
label="Longitude"
required
error={getError('start_lng')}
>
<Input <Input
id="start-lng" id="start-lng"
type="number" type="number"
@@ -251,7 +283,12 @@ export function SegmentDialog({
END COORDINATES END COORDINATES
</p> </p>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField id="end-lat" label="Latitude" required error={getError('end_lat')}> <FormField
id="end-lat"
label="Latitude"
required
error={getError('end_lat')}
>
<Input <Input
id="end-lat" id="end-lat"
type="number" type="number"
@@ -263,7 +300,12 @@ export function SegmentDialog({
{...register('end_lat')} {...register('end_lat')}
/> />
</FormField> </FormField>
<FormField id="end-lng" label="Longitude" required error={getError('end_lng')}> <FormField
id="end-lng"
label="Longitude"
required
error={getError('end_lng')}
>
<Input <Input
id="end-lng" id="end-lng"
type="number" type="number"

View File

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

View File

@@ -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.`,

View File

@@ -48,7 +48,10 @@ export default function SegmentPage() {
canSubmit, canSubmit,
isSaving, isSaving,
} = segmentForm; } = segmentForm;
const projectPackagesQuery = usePackagesByProjectQuery(projectId, isDialogOpen); const projectPackagesQuery = usePackagesByProjectQuery(
projectId,
isDialogOpen,
);
const openCreate = useCallback(() => { const openCreate = useCallback(() => {
prepareCreateSegment(); prepareCreateSegment();
@@ -57,7 +60,9 @@ export default function SegmentPage() {
const openEdit = useCallback( const openEdit = useCallback(
(segment: Chainage) => { (segment: Chainage) => {
const pkg = allPackagesQuery.data?.items.find((item) => item.id === segment.package_id); const pkg = allPackagesQuery.data?.items.find(
(item) => item.id === segment.package_id,
);
prepareEditSegment(segment, pkg?.project_id || ''); prepareEditSegment(segment, pkg?.project_id || '');
setIsDialogOpen(true); setIsDialogOpen(true);
}, },
@@ -76,7 +81,11 @@ export default function SegmentPage() {
const deleteSegment = useCallback( const deleteSegment = useCallback(
(segment: Chainage) => { (segment: Chainage) => {
if (!confirm(`Are you sure you want to delete segment "${segment.segment_name}"?`)) { if (
!confirm(
`Are you sure you want to delete segment "${segment.segment_name}"?`,
)
) {
return; return;
} }
deleteSegmentMutation.mutate(segment); deleteSegmentMutation.mutate(segment);

View File

@@ -67,15 +67,20 @@ export function TenantSheet({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl"> <DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{tenantId ? 'Edit Tenant' : 'Create Tenant'}</DialogTitle> <DialogTitle>
{tenantId ? 'Edit Tenant' : 'Create Tenant'}
</DialogTitle>
<DialogDescription> <DialogDescription>
Bind a client and subscription plan, then invite the tenant administrator. Bind a client and subscription plan, then invite the tenant
administrator.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4"> <form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="space-y-1"> <div className="space-y-1">
<p>Basic Information</p> <p>Basic Information</p>
<p className="text-muted-foreground">Tenant identity and subscription binding.</p> <p className="text-muted-foreground">
Tenant identity and subscription binding.
</p>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
@@ -106,10 +111,16 @@ export function TenantSheet({
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label>Client</Label> <Label>Client</Label>
<Select value={clientId} onValueChange={onClientChange} disabled={isLookupsLoading}> <Select
value={clientId}
onValueChange={onClientChange}
disabled={isLookupsLoading}
>
<SelectTrigger> <SelectTrigger>
<SelectValue <SelectValue
placeholder={isLookupsLoading ? 'Loading clients...' : 'Select client'} placeholder={
isLookupsLoading ? 'Loading clients...' : 'Select client'
}
/> />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -123,10 +134,16 @@ export function TenantSheet({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Subscription Plan</Label> <Label>Subscription Plan</Label>
<Select value={planId} onValueChange={onPlanChange} disabled={isLookupsLoading}> <Select
value={planId}
onValueChange={onPlanChange}
disabled={isLookupsLoading}
>
<SelectTrigger> <SelectTrigger>
<SelectValue <SelectValue
placeholder={isLookupsLoading ? 'Loading plans...' : 'Select plan'} placeholder={
isLookupsLoading ? 'Loading plans...' : 'Select plan'
}
/> />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -143,7 +160,11 @@ export function TenantSheet({
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="tenant-domain">Domain</Label> <Label htmlFor="tenant-domain">Domain</Label>
<Input id="tenant-domain" placeholder="acme.example.com" {...register('domain')} /> <Input
id="tenant-domain"
placeholder="acme.example.com"
{...register('domain')}
/>
</div> </div>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<Label htmlFor="tenant-description">Description</Label> <Label htmlFor="tenant-description">Description</Label>
@@ -226,7 +247,9 @@ export function TenantSheet({
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSaving || isLookupsLoading}> <Button type="submit" disabled={isSaving || isLookupsLoading}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null} {isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{tenantId ? 'Update Tenant' : 'Create Tenant'} {tenantId ? 'Update Tenant' : 'Create Tenant'}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -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) => {

View File

@@ -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;
} }

View File

@@ -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',
);
}, },
}); });
} }

View File

@@ -122,7 +122,8 @@ export default function TenantsPage() {
const total = tenantsQuery.data?.total ?? 0; const total = tenantsQuery.data?.total ?? 0;
const clients = clientsLookupQuery.data?.items ?? []; const clients = clientsLookupQuery.data?.items ?? [];
const plans = plansLookupQuery.data?.items ?? []; const plans = plansLookupQuery.data?.items ?? [];
const isLookupsLoading = clientsLookupQuery.isLoading || plansLookupQuery.isLoading; const isLookupsLoading =
clientsLookupQuery.isLoading || plansLookupQuery.isLoading;
const toolbar = useMemo( const toolbar = useMemo(
() => ( () => (
@@ -167,7 +168,9 @@ export default function TenantsPage() {
onSortingChange={setSorting} onSortingChange={setSorting}
onEdit={openEdit} onEdit={openEdit}
onDelete={handleDelete} onDelete={handleDelete}
pendingDeleteId={deleteMutation.isPending ? deleteMutation.variables : undefined} pendingDeleteId={
deleteMutation.isPending ? deleteMutation.variables : undefined
}
/> />
</main> </main>

View File

@@ -11,7 +11,10 @@ import { ROUTES } from '@/utils/routes';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
const API_URL = process.env.NEXT_PUBLIC_API_URL; const API_URL = process.env.NEXT_PUBLIC_API_URL;
const WS_URL = API_URL?.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://'); const WS_URL = API_URL?.replace(/^https:\/\//, 'wss://').replace(
/^http:\/\//,
'ws://',
);
export default function VideoProcessingPage() { export default function VideoProcessingPage() {
const router = useRouter(); const router = useRouter();
@@ -101,7 +104,9 @@ export default function VideoProcessingPage() {
} }
if (statusData.status === 'error') { if (statusData.status === 'error') {
setError(statusData.message || 'An error occurred during processing.'); setError(
statusData.message || 'An error occurred during processing.',
);
setIsLoading(false); setIsLoading(false);
return; return;
} }
@@ -203,7 +208,9 @@ export default function VideoProcessingPage() {
<Card className="flex-1 flex flex-col items-center justify-center py-12 px-8 min-h-[450px]"> <Card className="flex-1 flex flex-col items-center justify-center py-12 px-8 min-h-[450px]">
<div className="flex flex-col items-center justify-center w-full max-w-2xl space-y-10"> <div className="flex flex-col items-center justify-center w-full max-w-2xl space-y-10">
<div className="text-center space-y-4"> <div className="text-center space-y-4">
<h2 className="text-3xl font-bold tracking-tight">Uploading ...</h2> <h2 className="text-3xl font-bold tracking-tight">
Uploading ...
</h2>
<p className="text-sm font-mono text-muted-foreground tracking-widest"> <p className="text-sm font-mono text-muted-foreground tracking-widest">
ID: {videoId} ID: {videoId}
</p> </p>
@@ -214,7 +221,9 @@ export default function VideoProcessingPage() {
<span className="font-bold text-muted-foreground text-xs uppercase tracking-widest"> <span className="font-bold text-muted-foreground text-xs uppercase tracking-widest">
Progress Progress
</span> </span>
<span className="font-bold text-primary text-lg">{progress}%</span> <span className="font-bold text-primary text-lg">
{progress}%
</span>
</div> </div>
<div className="h-4 rounded-full bg-secondary overflow-hidden border"> <div className="h-4 rounded-full bg-secondary overflow-hidden border">
<div <div
@@ -233,7 +242,11 @@ export default function VideoProcessingPage() {
{error && ( {error && (
<div className="w-full p-6 rounded-md bg-destructive/10 border border-destructive/20 text-center space-y-3"> <div className="w-full p-6 rounded-md bg-destructive/10 border border-destructive/20 text-center space-y-3">
<p className="text-destructive font-medium">{error}</p> <p className="text-destructive font-medium">{error}</p>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}> <Button
variant="outline"
size="sm"
onClick={() => window.location.reload()}
>
Retry Connection Retry Connection
</Button> </Button>
</div> </div>

View File

@@ -2,7 +2,13 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -54,12 +60,15 @@ export default function UploadPage() {
setIsLoading(false); setIsLoading(false);
}, []); }, []);
const handleSelectionChange = useCallback((selectedSession: SessionContext | null) => { const handleSelectionChange = useCallback(
(selectedSession: SessionContext | null) => {
setSession(selectedSession); setSession(selectedSession);
if (selectedSession) { if (selectedSession) {
sessionService.saveSession(selectedSession); sessionService.saveSession(selectedSession);
} }
}, []); },
[],
);
const handleUpload = async () => { const handleUpload = async () => {
if (!file) { if (!file) {
@@ -103,7 +112,8 @@ export default function UploadPage() {
} catch (err) { } catch (err) {
let errorMessage = 'Upload failed'; let errorMessage = 'Upload failed';
if (err instanceof TypeError && err.message === 'Failed to fetch') { if (err instanceof TypeError && err.message === 'Failed to fetch') {
errorMessage = 'Cannot connect to server. Please check if backend is running.'; errorMessage =
'Cannot connect to server. Please check if backend is running.';
} else if (err instanceof Error) { } else if (err instanceof Error) {
errorMessage = err.message; errorMessage = err.message;
} }
@@ -120,7 +130,9 @@ export default function UploadPage() {
); );
} }
const isSessionComplete = !!(session && sessionService.isSessionValid(session)); const isSessionComplete = !!(
session && sessionService.isSessionValid(session)
);
const isFormValid = isSessionComplete && file && jsonFile; const isFormValid = isSessionComplete && file && jsonFile;
return ( return (
@@ -140,7 +152,9 @@ export default function UploadPage() {
<Reveal direction="up" delay={0.1}> <Reveal direction="up" delay={0.1}>
<Card className="border"> <Card className="border">
<CardHeader> <CardHeader>
<CardTitle className="text-xl font-bold">1. Project Details</CardTitle> <CardTitle className="text-xl font-bold">
1. Project Details
</CardTitle>
<CardDescription> <CardDescription>
Select the target project, package, and segment. Select the target project, package, and segment.
</CardDescription> </CardDescription>
@@ -157,7 +171,13 @@ export default function UploadPage() {
{/* Module 2: Upload Data */} {/* Module 2: Upload Data */}
<Reveal direction="up" delay={0.2}> <Reveal direction="up" delay={0.2}>
<Card className={cn("border transition-all duration-300", !isSessionComplete && "opacity-60 pointer-events-none grayscale-[0.5]")}> <Card
className={cn(
'border transition-all duration-300',
!isSessionComplete &&
'opacity-60 pointer-events-none grayscale-[0.5]',
)}
>
<CardHeader> <CardHeader>
<CardTitle className="text-xl font-bold flex items-center gap-2"> <CardTitle className="text-xl font-bold flex items-center gap-2">
2. Upload Video Data 2. Upload Video Data
@@ -170,7 +190,10 @@ export default function UploadPage() {
<div className="grid grid-cols-1 gap-6 text-sm"> <div className="grid grid-cols-1 gap-6 text-sm">
{/* Video File Input */} {/* Video File Input */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="video-file" className="text-sm font-semibold"> <Label
htmlFor="video-file"
className="text-sm font-semibold"
>
Video File <span className="text-destructive">*</span> Video File <span className="text-destructive">*</span>
</Label> </Label>
<div className="relative group"> <div className="relative group">
@@ -191,8 +214,12 @@ export default function UploadPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* JSON File Input */} {/* JSON File Input */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="json-file" className="text-sm font-semibold"> <Label
GPS JSON File <span className="text-destructive">*</span> htmlFor="json-file"
className="text-sm font-semibold"
>
GPS JSON File{' '}
<span className="text-destructive">*</span>
</Label> </Label>
<Input <Input
id="json-file" id="json-file"
@@ -210,7 +237,10 @@ export default function UploadPage() {
{/* Select Method */} {/* Select Method */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="select-method" className="text-sm font-semibold"> <Label
htmlFor="select-method"
className="text-sm font-semibold"
>
Analysis Method Analysis Method
</Label> </Label>
<Select <Select
@@ -218,7 +248,10 @@ export default function UploadPage() {
onValueChange={setSelectMethod} onValueChange={setSelectMethod}
disabled={uploading || !isSessionComplete} disabled={uploading || !isSessionComplete}
> >
<SelectTrigger id="select-method" className="h-11 bg-muted/20"> <SelectTrigger
id="select-method"
className="h-11 bg-muted/20"
>
<SelectValue placeholder="Select method" /> <SelectValue placeholder="Select method" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

View File

@@ -23,8 +23,9 @@ export function useUserColumns(): ColumnDef<AdministrationUser>[] {
header: 'Name', header: 'Name',
cell: ({ row }) => ( cell: ({ row }) => (
<span> <span>
{[row.original.first_name, row.original.last_name].filter(Boolean).join(' ') || {[row.original.first_name, row.original.last_name]
row.original.username} .filter(Boolean)
.join(' ') || row.original.username}
</span> </span>
), ),
}, },
@@ -64,7 +65,13 @@ export function useUserColumns(): ColumnDef<AdministrationUser>[] {
accessorKey: 'effective_status', accessorKey: 'effective_status',
header: 'Status', header: 'Status',
cell: ({ row }) => ( cell: ({ row }) => (
<Badge variant={row.original.effective_status === 'active' ? 'default' : 'secondary'}> <Badge
variant={
row.original.effective_status === 'active'
? 'default'
: 'secondary'
}
>
{row.original.effective_status} {row.original.effective_status}
</Badge> </Badge>
), ),

View File

@@ -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>

View File

@@ -46,10 +46,16 @@ export function UserSheet({
isSaving, isSaving,
}: UserSheetProps) { }: UserSheetProps) {
const roleComboboxPortalRef = useRef<HTMLDivElement | null>(null); const roleComboboxPortalRef = useRef<HTMLDivElement | null>(null);
const firstNameErrorMessage = isSubmitted ? errors.first_name?.message : undefined; const firstNameErrorMessage = isSubmitted
const lastNameErrorMessage = isSubmitted ? errors.last_name?.message : undefined; ? errors.first_name?.message
: undefined;
const lastNameErrorMessage = isSubmitted
? errors.last_name?.message
: undefined;
const emailErrorMessage = isSubmitted ? errors.email?.message : undefined; const emailErrorMessage = isSubmitted ? errors.email?.message : undefined;
const phoneErrorMessage = isSubmitted ? errors.phone_number?.message : undefined; const phoneErrorMessage = isSubmitted
? errors.phone_number?.message
: undefined;
const roleErrorMessage = isSubmitted ? errors.role_id?.message : undefined; const roleErrorMessage = isSubmitted ? errors.role_id?.message : undefined;
return ( return (
@@ -63,7 +69,12 @@ export function UserSheet({
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4"> <form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<FormField id="first-name" label="First Name" required error={firstNameErrorMessage}> <FormField
id="first-name"
label="First Name"
required
error={firstNameErrorMessage}
>
<Input <Input
id="first-name" id="first-name"
placeholder="Enter first name" placeholder="Enter first name"
@@ -72,7 +83,12 @@ export function UserSheet({
/> />
</FormField> </FormField>
<FormField id="last-name" label="Last Name" required error={lastNameErrorMessage}> <FormField
id="last-name"
label="Last Name"
required
error={lastNameErrorMessage}
>
<Input <Input
id="last-name" id="last-name"
placeholder="Enter last name" placeholder="Enter last name"
@@ -82,7 +98,12 @@ export function UserSheet({
</FormField> </FormField>
</div> </div>
<FormField id="email" label="Email" required error={emailErrorMessage}> <FormField
id="email"
label="Email"
required
error={emailErrorMessage}
>
<Input <Input
id="email" id="email"
placeholder="name@example.com" placeholder="name@example.com"
@@ -91,7 +112,11 @@ export function UserSheet({
/> />
</FormField> </FormField>
<FormField id="phone-number" label="Phone Number" error={phoneErrorMessage}> <FormField
id="phone-number"
label="Phone Number"
error={phoneErrorMessage}
>
<Input <Input
id="phone-number" id="phone-number"
placeholder="+919876543210" placeholder="+919876543210"
@@ -111,7 +136,12 @@ export function UserSheet({
<div ref={roleComboboxPortalRef} /> <div ref={roleComboboxPortalRef} />
<input type="hidden" {...register('role_id')} value={roleId} readOnly /> <input
type="hidden"
{...register('role_id')}
value={roleId}
readOnly
/>
</FormField> </FormField>
<DialogFooter className="px-0"> <DialogFooter className="px-0">
@@ -125,7 +155,9 @@ export function UserSheet({
</Button> </Button>
<Button type="submit" disabled={isSaving || !canSubmit}> <Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null} {isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{userId ? 'Update User' : 'Create User'} {userId ? 'Update User' : 'Create User'}
</Button> </Button>

View File

@@ -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 },

View File

@@ -57,10 +57,12 @@ export function UserTable({
onClick: onEdit, onClick: onEdit,
}, },
{ {
label: (user) => (user.effective_status === 'active' ? 'Deactivate' : 'Activate'), label: (user) =>
user.effective_status === 'active' ? 'Deactivate' : 'Activate',
icon: <RotateCcw className="size-4" />, icon: <RotateCcw className="size-4" />,
permission: PERMISSIONS.USER.DELETE, permission: PERMISSIONS.USER.DELETE,
disabled: (user) => pendingUserId === user.id || user.effective_status === 'pending', disabled: (user) =>
pendingUserId === user.id || user.effective_status === 'pending',
onClick: onToggleStatus, onClick: onToggleStatus,
}, },
]} ]}

View File

@@ -63,7 +63,8 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) {
const email = useWatch({ control, name: 'email' }) || ''; const email = useWatch({ control, name: 'email' }) || '';
const phoneNumber = useWatch({ control, name: 'phone_number' }) || ''; const phoneNumber = useWatch({ control, name: 'phone_number' }) || '';
const isPhoneValid = const isPhoneValid =
phoneNumber.trim() === '' || /^\+(?:[0-9] ?){6,14}[0-9]$/.test(phoneNumber.trim()); phoneNumber.trim() === '' ||
/^\+(?:[0-9] ?){6,14}[0-9]$/.test(phoneNumber.trim());
const canSubmit = const canSubmit =
firstName.trim().length > 0 && firstName.trim().length > 0 &&
lastName.trim().length > 0 && lastName.trim().length > 0 &&

View File

@@ -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 });

View File

@@ -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],
); );

View File

@@ -17,8 +17,11 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Create Next App', title: {
description: 'Generated by create next app', default: 'VisionRoad',
template: '%s | VisionRoad',
},
description: 'Road infrastructure monitoring and pothole detection platform',
}; };
export default function RootLayout({ export default function RootLayout({
@@ -28,7 +31,9 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}> <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"
defaultTheme="system" defaultTheme="system"

View File

@@ -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 />}

View File

@@ -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>

View File

@@ -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.'}

View File

@@ -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;

View File

@@ -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,
}); });

View File

@@ -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>

View File

@@ -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>

View File

@@ -62,13 +62,18 @@ 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>

View File

@@ -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] }}

View File

@@ -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}

View File

@@ -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>
)} )}
</> </>
)} )}

View File

@@ -5,7 +5,11 @@ import type { MouseEvent, ReactNode } from 'react';
import { PermissionGuard } from '@/guards'; import { PermissionGuard } from '@/guards';
import type { PermissionInput } from '@/hooks/usePermissions'; import type { PermissionInput } from '@/hooks/usePermissions';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface TableActionButtonProps { interface TableActionButtonProps {

View File

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

View File

@@ -67,12 +67,15 @@ export function DataTable<TData, TValue>({
onSortingChange, onSortingChange,
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = React.useState({}); const [rowSelection, setRowSelection] = React.useState({});
const [internalSorting, setInternalSorting] = React.useState<SortingState>([]); const [internalSorting, setInternalSorting] = React.useState<SortingState>(
[],
);
const sorting = controlledSorting ?? internalSorting; const sorting = controlledSorting ?? internalSorting;
const isManualSorting = Boolean(onSortingChange); const isManualSorting = Boolean(onSortingChange);
const handleSortingChange = React.useCallback( const handleSortingChange = React.useCallback(
(updater: SortingState | ((old: SortingState) => SortingState)) => { (updater: SortingState | ((old: SortingState) => SortingState)) => {
const nextSorting = typeof updater === 'function' ? updater(sorting) : updater; const nextSorting =
typeof updater === 'function' ? updater(sorting) : updater;
if (onSortingChange) { if (onSortingChange) {
onSortingChange(nextSorting); onSortingChange(nextSorting);
return; return;
@@ -95,7 +98,9 @@ export function DataTable<TData, TValue>({
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{actions.map((action) => { {actions.map((action) => {
const label = const label =
typeof action.label === 'function' ? action.label(item) : action.label; typeof action.label === 'function'
? action.label(item)
: action.label;
return ( return (
<TableActionButton <TableActionButton
@@ -131,7 +136,9 @@ export function DataTable<TData, TValue>({
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
manualPagination: !!pagination, manualPagination: !!pagination,
manualSorting: isManualSorting, manualSorting: isManualSorting,
pageCount: pagination?.totalItems ? Math.ceil(pagination.totalItems / pagination.limit) : -1, pageCount: pagination?.totalItems
? Math.ceil(pagination.totalItems / pagination.limit)
: -1,
state: { state: {
rowSelection, rowSelection,
sorting, sorting,
@@ -191,17 +198,26 @@ export function DataTable<TData, TValue>({
)) ))
) : table.getRowModel().rows?.length ? ( ) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}> <TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}> <TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell> </TableCell>
))} ))}
</TableRow> </TableRow>
)) ))
) : ( ) : (
<TableRow> <TableRow>
<TableCell colSpan={columns.length} className="h-28 text-center"> <TableCell
colSpan={columns.length}
className="h-28 text-center"
>
<div className="flex flex-col items-center justify-center text-muted-foreground gap-1"> <div className="flex flex-col items-center justify-center text-muted-foreground gap-1">
<p className="text-sm font-medium">{emptyTitle}</p> <p className="text-sm font-medium">{emptyTitle}</p>
<p className="text-xs">{emptyDescription}</p> <p className="text-xs">{emptyDescription}</p>

View File

@@ -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>
); );

View File

@@ -52,11 +52,15 @@ export function RoleCombobox({
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];
}, },
}); });

View File

@@ -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='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' attribution='&copy; <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>

View File

@@ -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>
); );

View File

@@ -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';

View File

@@ -11,11 +11,16 @@ interface PageHeaderProps {
actions?: React.ReactNode; actions?: React.ReactNode;
} }
export function PageHeader({ title, description, icon: Icon, children, actions }: PageHeaderProps) { export function PageHeader({
title,
description,
icon: Icon,
children,
actions,
}: PageHeaderProps) {
return ( return (
<Reveal direction="down" className="w-full"> <Reveal direction="down" className="w-full">
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<div className="flex flex-col"> <div className="flex flex-col">
<h1>{title}</h1> <h1>{title}</h1>
<p className="text-muted-foreground opacity-80">{description}</p> <p className="text-muted-foreground opacity-80">{description}</p>

View File

@@ -3,7 +3,9 @@
import { CheckboxTree } from '@/components/ui/tree'; import { CheckboxTree } from '@/components/ui/tree';
import type { PermissionTreeItem, PermissionTreeNode } from '@/types'; import type { PermissionTreeItem, PermissionTreeNode } from '@/types';
export function mapPermissionTree(nodes: PermissionTreeNode[]): PermissionTreeItem[] { export function mapPermissionTree(
nodes: PermissionTreeNode[],
): PermissionTreeItem[] {
return nodes.map((node) => ({ return nodes.map((node) => ({
id: node.id, id: node.id,
key: node.slug || node.name, key: node.slug || node.name,
@@ -15,14 +17,19 @@ export function mapPermissionTree(nodes: PermissionTreeNode[]): PermissionTreeIt
})); }));
} }
export function collectDefaultPermissionIds(nodes: PermissionTreeItem[]): number[] { export function collectDefaultPermissionIds(
nodes: PermissionTreeItem[],
): number[] {
return nodes.flatMap((node) => [ return nodes.flatMap((node) => [
...(node.isGrantedByDefault ? [node.id] : []), ...(node.isGrantedByDefault ? [node.id] : []),
...collectDefaultPermissionIds(node.children), ...collectDefaultPermissionIds(node.children),
]); ]);
} }
export function collectPermissionIdsByKeys(nodes: PermissionTreeItem[], keys: string[]): number[] { export function collectPermissionIdsByKeys(
nodes: PermissionTreeItem[],
keys: string[],
): number[] {
const selectedKeys = new Set(keys); const selectedKeys = new Set(keys);
return nodes.flatMap((node) => [ return nodes.flatMap((node) => [
...(selectedKeys.has(node.key) ? [node.id] : []), ...(selectedKeys.has(node.key) ? [node.id] : []),

View File

@@ -1,7 +1,13 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { import {
@@ -12,8 +18,17 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Loader2, Check, ArrowUp, ArrowDown } from 'lucide-react'; import { Loader2, Check, ArrowUp, ArrowDown } from 'lucide-react';
import { projectService, packageService, chainageService } from '@/services/api'; import {
import { Project, Package as PackageType, Chainage, SessionContext } from '@/types'; projectService,
packageService,
chainageService,
} from '@/services/api';
import {
Project,
Package as PackageType,
Chainage,
SessionContext,
} from '@/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type ProjectSelectionSectionProps = { type ProjectSelectionSectionProps = {
@@ -30,17 +45,23 @@ export function ProjectSelectionSection({
onSelectionComplete, onSelectionComplete,
onSelectionChange, onSelectionChange,
asStep = false, asStep = false,
hideButton = false hideButton = false,
}: ProjectSelectionSectionProps) { }: ProjectSelectionSectionProps) {
// Data states // Data states
const [projects, setProjects] = useState<Project[]>(globalProjectsCache || []); const [projects, setProjects] = useState<Project[]>(
globalProjectsCache || [],
);
const [packages, setPackages] = useState<PackageType[]>([]); const [packages, setPackages] = useState<PackageType[]>([]);
const [chainages, setChainages] = useState<Chainage[]>([]); const [chainages, setChainages] = useState<Chainage[]>([]);
// Selection states // Selection states
const [selectedProject, setSelectedProject] = useState<Project | null>(null); const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [selectedPackage, setSelectedPackage] = useState<PackageType | null>(null); const [selectedPackage, setSelectedPackage] = useState<PackageType | null>(
const [selectedChainage, setSelectedChainage] = useState<Chainage | null>(null); null,
);
const [selectedChainage, setSelectedChainage] = useState<Chainage | null>(
null,
);
// Loading states // Loading states
const [loadingProjects, setLoadingProjects] = useState(!globalProjectsCache); const [loadingProjects, setLoadingProjects] = useState(!globalProjectsCache);
@@ -66,10 +87,13 @@ export function ProjectSelectionSection({
// If a fetch is already in flight, reuse that promise // If a fetch is already in flight, reuse that promise
if (!projectsPromise) { if (!projectsPromise) {
projectsPromise = projectService.getProjects().then(data => { projectsPromise = projectService
.getProjects()
.then((data) => {
globalProjectsCache = data.items; globalProjectsCache = data.items;
return data.items; return data.items;
}).catch(err => { })
.catch((err) => {
projectsPromise = null; // Reset on error to allow retry projectsPromise = null; // Reset on error to allow retry
throw err; throw err;
}); });
@@ -93,7 +117,9 @@ export function ProjectSelectionSection({
}; };
loadProjects(); loadProjects();
return () => { isMounted = false; }; return () => {
isMounted = false;
};
}, []); }, []);
// Load packages when project changes // Load packages when project changes
@@ -112,7 +138,9 @@ export function ProjectSelectionSection({
setSelectedPackage(null); setSelectedPackage(null);
setSelectedChainage(null); setSelectedChainage(null);
setChainages([]); setChainages([]);
const data = await packageService.getPackagesByProject(selectedProject.id); const data = await packageService.getPackagesByProject(
selectedProject.id,
);
if (isMounted) { if (isMounted) {
setPackages(data.items); setPackages(data.items);
} }
@@ -128,7 +156,9 @@ export function ProjectSelectionSection({
} }
}; };
loadPackages(); loadPackages();
return () => { isMounted = false; }; return () => {
isMounted = false;
};
}, [selectedProject]); }, [selectedProject]);
// Load chainages when package changes // Load chainages when package changes
@@ -145,7 +175,9 @@ export function ProjectSelectionSection({
setLoadingChainages(true); setLoadingChainages(true);
setError(null); setError(null);
setSelectedChainage(null); setSelectedChainage(null);
const data = await chainageService.getChainagesByPackage(selectedPackage.id); const data = await chainageService.getChainagesByPackage(
selectedPackage.id,
);
if (isMounted) { if (isMounted) {
setChainages(data.items); setChainages(data.items);
} }
@@ -161,7 +193,9 @@ export function ProjectSelectionSection({
} }
}; };
loadChainages(); loadChainages();
return () => { isMounted = false; }; return () => {
isMounted = false;
};
}, [selectedPackage]); }, [selectedPackage]);
const handleProjectChange = (projectId: string) => { const handleProjectChange = (projectId: string) => {
@@ -185,7 +219,8 @@ export function ProjectSelectionSection({
// Handle change reporting // Handle change reporting
useEffect(() => { useEffect(() => {
if (onSelectionChange) { if (onSelectionChange) {
const currentIds = selectedProject && selectedPackage && selectedChainage const currentIds =
selectedProject && selectedPackage && selectedChainage
? `${selectedProject.id}-${selectedPackage.id}-${selectedChainage.id}` ? `${selectedProject.id}-${selectedPackage.id}-${selectedChainage.id}`
: null; : null;
@@ -210,7 +245,12 @@ export function ProjectSelectionSection({
}, [selectedProject, selectedPackage, selectedChainage, onSelectionChange]); }, [selectedProject, selectedPackage, selectedChainage, onSelectionChange]);
const handleProceed = () => { const handleProceed = () => {
if (selectedProject && selectedPackage && selectedChainage && onSelectionComplete) { if (
selectedProject &&
selectedPackage &&
selectedChainage &&
onSelectionComplete
) {
onSelectionComplete({ onSelectionComplete({
projectId: selectedProject.id, projectId: selectedProject.id,
projectName: selectedProject.name, projectName: selectedProject.name,
@@ -228,8 +268,18 @@ export function ProjectSelectionSection({
// Step status helpers // Step status helpers
const getStepStatus = (step: number) => { const getStepStatus = (step: number) => {
if (step === 1) return selectedProject ? 'completed' : 'active'; if (step === 1) return selectedProject ? 'completed' : 'active';
if (step === 2) return selectedPackage ? 'completed' : selectedProject ? 'active' : 'pending'; if (step === 2)
if (step === 3) return selectedChainage ? 'completed' : selectedPackage ? 'active' : 'pending'; return selectedPackage
? 'completed'
: selectedProject
? 'active'
: 'pending';
if (step === 3)
return selectedChainage
? 'completed'
: selectedPackage
? 'active'
: 'pending';
return 'pending'; return 'pending';
}; };
@@ -291,7 +341,9 @@ export function ProjectSelectionSection({
</div> </div>
) : ( ) : (
<SelectValue <SelectValue
placeholder={selectedProject ? 'Select package' : 'Select project first'} placeholder={
selectedProject ? 'Select package' : 'Select project first'
}
/> />
)} )}
</SelectTrigger> </SelectTrigger>
@@ -323,7 +375,9 @@ export function ProjectSelectionSection({
</div> </div>
) : ( ) : (
<SelectValue <SelectValue
placeholder={selectedPackage ? 'Select segment' : 'Select package first'} placeholder={
selectedPackage ? 'Select segment' : 'Select package first'
}
/> />
)} )}
</SelectTrigger> </SelectTrigger>
@@ -331,9 +385,13 @@ export function ProjectSelectionSection({
{chainages.map((chn) => ( {chainages.map((chn) => (
<SelectItem key={chn.id} value={chn.id}> <SelectItem key={chn.id} value={chn.id}>
<div className="flex flex-col gap-0.5 py-0.5"> <div className="flex flex-col gap-0.5 py-0.5">
<span className="font-semibold text-sm">{chn.segment_name}</span> <span className="font-semibold text-sm">
{chn.segment_name}
</span>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground font-medium"> <div className="flex items-center gap-2 text-[10px] text-muted-foreground font-medium">
<span>{chn.chainage_start_km}-{chn.chainage_end_km} km</span> <span>
{chn.chainage_start_km}-{chn.chainage_end_km} km
</span>
<span className="h-2.5 w-px bg-border" /> <span className="h-2.5 w-px bg-border" />
<span className="flex items-center gap-1 uppercase"> <span className="flex items-center gap-1 uppercase">
{chn.direction === 'UP' ? ( {chn.direction === 'UP' ? (
@@ -399,9 +457,12 @@ export function ProjectSelectionSection({
<CardHeader className="pb-6"> <CardHeader className="pb-6">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div>
<CardTitle className="text-2xl font-bold">Select Project Segment</CardTitle> <CardTitle className="text-2xl font-bold">
Select Project Segment
</CardTitle>
<CardDescription className="mt-2 text-base"> <CardDescription className="mt-2 text-base">
Select Project, Package & Segment to begin intelligent road analysis. Select Project, Package & Segment to begin intelligent road
analysis.
</CardDescription> </CardDescription>
</div> </div>
@@ -423,12 +484,18 @@ export function ProjectSelectionSection({
: 'bg-muted text-muted-foreground', : 'bg-muted text-muted-foreground',
)} )}
> >
{status === 'completed' ? <Check className="h-5 w-5" /> : step} {status === 'completed' ? (
<Check className="h-5 w-5" />
) : (
step
)}
</div> </div>
<span <span
className={cn( className={cn(
'text-xs mt-2 font-medium', 'text-xs mt-2 font-medium',
status === 'pending' ? 'text-muted-foreground/60' : 'text-foreground', status === 'pending'
? 'text-muted-foreground/60'
: 'text-foreground',
)} )}
> >
{labels[index]} {labels[index]}
@@ -438,7 +505,9 @@ export function ProjectSelectionSection({
<div <div
className={cn( className={cn(
'w-16 h-0.5 mx-2 mb-6 rounded-full', 'w-16 h-0.5 mx-2 mb-6 rounded-full',
getStepStatus(step + 1) !== 'pending' ? 'bg-primary' : 'bg-border', getStepStatus(step + 1) !== 'pending'
? 'bg-primary'
: 'bg-border',
)} )}
/> />
)} )}
@@ -449,9 +518,7 @@ export function ProjectSelectionSection({
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0">{content}</CardContent>
{content}
</CardContent>
</Card> </Card>
); );
} }

View File

@@ -5,17 +5,26 @@ import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) { function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return ( return (
<AvatarPrimitive.Root <AvatarPrimitive.Root
data-slot="avatar" data-slot="avatar"
className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)} className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props} {...props}
/> />
); );
} }
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) { function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return ( return (
<AvatarPrimitive.Image <AvatarPrimitive.Image
data-slot="avatar-image" data-slot="avatar-image"
@@ -32,7 +41,10 @@ function AvatarFallback({
return ( return (
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
data-slot="avatar-fallback" data-slot="avatar-fallback"
className={cn('bg-muted flex size-full items-center justify-center rounded-full', className)} className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className,
)}
{...props} {...props}
/> />
); );

View File

@@ -10,7 +10,8 @@ const badgeVariants = cva(
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90', default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', secondary:
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive: destructive:
'bg-destructive text-white dark:bg-destructive/60 [a&]:hover:bg-destructive/90', 'bg-destructive text-white dark:bg-destructive/60 [a&]:hover:bg-destructive/90',
outline: outline:
@@ -30,7 +31,8 @@ function Badge({
variant = 'default', variant = 'default',
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) { }: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'span'; const Comp = asChild ? Slot.Root : 'span';
return ( return (

View File

@@ -62,7 +62,11 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
); );
} }
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<'li'>) { function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return ( return (
<li <li
data-slot="breadcrumb-separator" data-slot="breadcrumb-separator"
@@ -76,7 +80,10 @@ function BreadcrumbSeparator({ children, className, ...props }: React.ComponentP
); );
} }
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span'>) { function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return ( return (
<span <span
data-slot="breadcrumb-ellipsis" data-slot="breadcrumb-ellipsis"

View File

@@ -10,11 +10,14 @@ const buttonVariants = cva(
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90', default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60', destructive:
'bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60',
outline: outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground focus-visible:border-ring dark:border-input dark:bg-input/30 dark:hover:bg-input/50', 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground focus-visible:border-ring dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', secondary:
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',
}, },
size: { size: {

View File

@@ -52,14 +52,23 @@ function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)} className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props} {...props}
/> />
); );
} }
function CardContent({ className, ...props }: React.ComponentProps<'div'>) { function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />; return (
<div
data-slot="card-content"
className={cn('px-6', className)}
{...props}
/>
);
} }
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
@@ -72,4 +81,12 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
); );
} }
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -42,7 +42,9 @@ function ChartContainer({
...props ...props
}: React.ComponentProps<'div'> & { }: React.ComponentProps<'div'> & {
config: ChartConfig; config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children']; children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>['children'];
}) { }) {
const uniqueId = React.useId(); const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`; const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
@@ -59,14 +61,18 @@ function ChartContainer({
{...props} {...props}
> >
<ChartStyle id={chartId} config={config} /> <ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer> <RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div> </div>
</ChartContext.Provider> </ChartContext.Provider>
); );
} }
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color); const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) { if (!colorConfig.length) {
return null; return null;
@@ -81,7 +87,9 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
${prefix} [data-chart=${id}] { ${prefix} [data-chart=${id}] {
${colorConfig ${colorConfig
.map(([key, itemConfig]) => { .map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color; const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null; return color ? ` --color-${key}: ${color};` : null;
}) })
.join('\n')} .join('\n')}
@@ -135,7 +143,9 @@ function ChartTooltipContent({
if (labelFormatter) { if (labelFormatter) {
return ( return (
<div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div> <div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)}
</div>
); );
} }
@@ -144,7 +154,15 @@ function ChartTooltipContent({
} }
return <div className={cn('font-medium', labelClassName)}>{value}</div>; return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]); }, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) { if (!active || !payload?.length) {
return null; return null;
@@ -287,29 +305,42 @@ function ChartLegendContent({
} }
// Helper to extract item config from a payload. // Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) { function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== 'object' || payload === null) { if (typeof payload !== 'object' || payload === null) {
return undefined; return undefined;
} }
const payloadPayload = const payloadPayload =
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null 'payload' in payload &&
typeof payload.payload === 'object' &&
payload.payload !== null
? payload.payload ? payload.payload
: undefined; : undefined;
let configLabelKey: string = key; let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') { if (
key in payload &&
typeof payload[key as keyof typeof payload] === 'string'
) {
configLabelKey = payload[key as keyof typeof payload] as string; configLabelKey = payload[key as keyof typeof payload] as string;
} else if ( } else if (
payloadPayload && payloadPayload &&
key in payloadPayload && key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string' typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) { ) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string; configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
} }
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]; return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
} }
export { export {

View File

@@ -2,20 +2,32 @@
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'; import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) { function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />; return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
} }
function CollapsibleTrigger({ function CollapsibleTrigger({
...props ...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) { }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />; return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
} }
function CollapsibleContent({ function CollapsibleContent({
...props ...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) { }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />; return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
} }
export { Collapsible, CollapsibleTrigger, CollapsibleContent }; export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -31,7 +31,9 @@ function ComboboxTrigger({
className="pointer-events-none size-4 text-muted-foreground" className="pointer-events-none size-4 text-muted-foreground"
/> />
); );
const triggerRender = React.isValidElement<{ children?: React.ReactNode }>(render) const triggerRender = React.isValidElement<{ children?: React.ReactNode }>(
render,
)
? React.cloneElement(render, { ? React.cloneElement(render, {
children: ( children: (
<> <>
@@ -81,7 +83,10 @@ function ComboboxInput({
}) { }) {
return ( return (
<InputGroup className={cn('w-auto', className)}> <InputGroup className={cn('w-auto', className)}>
<ComboboxPrimitive.Input render={<InputGroupInput disabled={disabled} />} {...props} /> <ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end"> <InputGroupAddon align="inline-end">
{showTrigger && ( {showTrigger && (
<InputGroupButton <InputGroupButton
@@ -155,7 +160,11 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
); );
} }
function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.Props) { function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return ( return (
<ComboboxPrimitive.Item <ComboboxPrimitive.Item
data-slot="combobox-item" data-slot="combobox-item"
@@ -180,11 +189,18 @@ function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) { function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return ( return (
<ComboboxPrimitive.Group data-slot="combobox-group" className={cn(className)} {...props} /> <ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
); );
} }
function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) { function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return ( return (
<ComboboxPrimitive.GroupLabel <ComboboxPrimitive.GroupLabel
data-slot="combobox-label" data-slot="combobox-label"
@@ -198,7 +214,9 @@ function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Pro
} }
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) { function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />; return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
);
} }
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
@@ -214,7 +232,10 @@ function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
); );
} }
function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) { function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return ( return (
<ComboboxPrimitive.Separator <ComboboxPrimitive.Separator
data-slot="combobox-separator" data-slot="combobox-separator"
@@ -227,7 +248,8 @@ function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.
function ComboboxChips({ function ComboboxChips({
className, className,
...props ...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> & ComboboxPrimitive.Chips.Props) { }: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return ( return (
<ComboboxPrimitive.Chips <ComboboxPrimitive.Chips
data-slot="combobox-chips" data-slot="combobox-chips"
@@ -271,7 +293,11 @@ function ComboboxChip({
); );
} }
function ComboboxChipsInput({ className, children, ...props }: ComboboxPrimitive.Input.Props) { function ComboboxChipsInput({
className,
children,
...props
}: ComboboxPrimitive.Input.Props) {
return ( return (
<ComboboxPrimitive.Input <ComboboxPrimitive.Input
data-slot="combobox-chip-input" data-slot="combobox-chip-input"

View File

@@ -7,19 +7,27 @@ import { Dialog as DialogPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) { function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />; return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) { function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />; return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) { function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />; return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) { function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />; return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
@@ -99,7 +107,10 @@ function DialogFooter({
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props} {...props}
> >
{children} {children}
@@ -112,8 +123,17 @@ function DialogFooter({
); );
} }
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) { function DialogTitle({
return <DialogPrimitive.Title data-slot="dialog-title" className={cn(className)} {...props} />; className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(className)}
{...props}
/>
);
} }
function DialogDescription({ function DialogDescription({

View File

@@ -1,15 +1,15 @@
"use client" 'use client';
import * as React from "react" import * as React from 'react';
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function DropdownMenu({ function DropdownMenu({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
} }
function DropdownMenuPortal({ function DropdownMenuPortal({
@@ -17,7 +17,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
) );
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
@@ -28,7 +28,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger" data-slot="dropdown-menu-trigger"
{...props} {...props}
/> />
) );
} }
function DropdownMenuContent({ function DropdownMenuContent({
@@ -42,13 +42,13 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", 'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
) );
} }
function DropdownMenuGroup({ function DropdownMenuGroup({
@@ -56,17 +56,17 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return ( return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
) );
} }
function DropdownMenuItem({ function DropdownMenuItem({
className, className,
inset, inset,
variant = "default", variant = 'default',
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
variant?: "default" | "destructive" variant?: 'default' | 'destructive';
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
@@ -75,11 +75,11 @@ function DropdownMenuItem({
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
@@ -93,7 +93,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
@@ -105,7 +105,7 @@ function DropdownMenuCheckboxItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
) );
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({
@@ -116,7 +116,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group" data-slot="dropdown-menu-radio-group"
{...props} {...props}
/> />
) );
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
@@ -129,7 +129,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
@@ -140,7 +140,7 @@ function DropdownMenuRadioItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
) );
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
@@ -148,19 +148,19 @@ function DropdownMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", 'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
@@ -170,32 +170,32 @@ function DropdownMenuSeparator({
return ( return (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator" data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)} className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({
className, className,
...props ...props
}: React.ComponentProps<"span">) { }: React.ComponentProps<'span'>) {
return ( return (
<span <span
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn( className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground", 'ml-auto text-xs tracking-widest text-muted-foreground',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSub({ function DropdownMenuSub({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} /> return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
@@ -204,7 +204,7 @@ function DropdownMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@@ -212,14 +212,14 @@ function DropdownMenuSubTrigger({
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", "flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto size-4" /> <ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
) );
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
@@ -230,12 +230,12 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", 'z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -254,4 +254,4 @@ export {
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
} };

View File

@@ -41,8 +41,10 @@ const inputGroupAddonVariants = cva(
{ {
variants: { variants: {
align: { align: {
'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]', 'inline-start':
'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]', 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
'inline-end':
'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
'block-start': 'block-start':
'order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3', 'order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3',
'block-end': 'block-end':
@@ -77,19 +79,23 @@ function InputGroupAddon({
); );
} }
const inputGroupButtonVariants = cva('flex items-center gap-2 text-sm shadow-none', { const inputGroupButtonVariants = cva(
'flex items-center gap-2 text-sm shadow-none',
{
variants: { variants: {
size: { size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5', sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5',
'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0', 'icon-xs':
'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0', 'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
}, },
}, },
defaultVariants: { defaultVariants: {
size: 'xs', size: 'xs',
}, },
}); },
);
function InputGroupButton({ function InputGroupButton({
className, className,
@@ -122,7 +128,10 @@ function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
); );
} }
function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) { function InputGroupInput({
className,
...props
}: React.ComponentProps<'input'>) {
return ( return (
<Input <Input
data-slot="input-group-control" data-slot="input-group-control"
@@ -135,7 +144,10 @@ function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>)
); );
} }
function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) { function InputGroupTextarea({
className,
...props
}: React.ComponentProps<'textarea'>) {
return ( return (
<Textarea <Textarea
data-slot="input-group-control" data-slot="input-group-control"

View File

@@ -5,7 +5,10 @@ import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) { function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return ( return (
<LabelPrimitive.Root <LabelPrimitive.Root
data-slot="label" data-slot="label"

View File

@@ -1,5 +1,9 @@
import * as React from 'react'; import * as React from 'react';
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react'; import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button'; import { Button, buttonVariants } from '@/components/ui/button';
@@ -16,7 +20,10 @@ function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
); );
} }
function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) { function PaginationContent({
className,
...props
}: React.ComponentProps<'ul'>) {
return ( return (
<ul <ul
data-slot="pagination-content" data-slot="pagination-content"
@@ -35,7 +42,12 @@ type PaginationLinkProps = {
} & Pick<React.ComponentProps<typeof Button>, 'size'> & } & Pick<React.ComponentProps<typeof Button>, 'size'> &
React.ComponentProps<'a'>; React.ComponentProps<'a'>;
function PaginationLink({ className, isActive, size = 'icon', ...props }: PaginationLinkProps) { function PaginationLink({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) {
return ( return (
<a <a
aria-current={isActive ? 'page' : undefined} aria-current={isActive ? 'page' : undefined}
@@ -53,7 +65,10 @@ function PaginationLink({ className, isActive, size = 'icon', ...props }: Pagina
); );
} }
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) { function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return ( return (
<PaginationLink <PaginationLink
aria-label="Go to previous page" aria-label="Go to previous page"
@@ -67,7 +82,10 @@ function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof
); );
} }
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) { function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return ( return (
<PaginationLink <PaginationLink
aria-label="Go to next page" aria-label="Go to next page"
@@ -81,7 +99,10 @@ function PaginationNext({ className, ...props }: React.ComponentProps<typeof Pag
); );
} }
function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) { function PaginationEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return ( return (
<span <span
aria-hidden aria-hidden

View File

@@ -5,11 +5,15 @@ import { Popover as PopoverPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) { function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />; return <PopoverPrimitive.Root data-slot="popover" {...props} />;
} }
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />; return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
} }
@@ -35,7 +39,9 @@ function PopoverContent({
); );
} }
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />; return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
} }
@@ -50,10 +56,19 @@ function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
} }
function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) { function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) {
return <div data-slot="popover-title" className={cn('font-medium', className)} {...props} />; return (
<div
data-slot="popover-title"
className={cn('font-medium', className)}
{...props}
/>
);
} }
function PopoverDescription({ className, ...props }: React.ComponentProps<'p'>) { function PopoverDescription({
className,
...props
}: React.ComponentProps<'p'>) {
return ( return (
<p <p
data-slot="popover-description" data-slot="popover-description"

View File

@@ -13,7 +13,10 @@ function Progress({
return ( return (
<ProgressPrimitive.Root <ProgressPrimitive.Root
data-slot="progress" data-slot="progress"
className={cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', className)} className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className,
)}
{...props} {...props}
> >
<ProgressPrimitive.Indicator <ProgressPrimitive.Indicator

View File

@@ -39,8 +39,10 @@ function ScrollBar({
orientation={orientation} orientation={orientation}
className={cn( className={cn(
'flex touch-none p-px transition-colors select-none', 'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent', orientation === 'vertical' &&
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent', 'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent',
className, className,
)} )}
{...props} {...props}

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