3 Commits

159 changed files with 2841 additions and 2083 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
} }

Binary file not shown.

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

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

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

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

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

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

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

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

View File

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

View File

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

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

View File

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

View File

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

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

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

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

View File

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

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

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

View File

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

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

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

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

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

View File

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

View File

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

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

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

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

View File

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

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

View File

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

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

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

View File

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

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

View File

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

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

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

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

View File

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

View File

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

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

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

View File

@@ -1,5 +1,6 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css'; @import 'tw-animate-css';
@import './typography.css';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -7,7 +8,7 @@
--radius: 0.65rem; --radius: 0.65rem;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823); --foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0 / 0.4); --card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823); --card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823); --popover-foreground: oklch(0.141 0.005 285.823);
@@ -30,7 +31,7 @@
--chart-5: oklch(0.85 0.08 195); /* Soft Cyan - Subdued */ --chart-5: oklch(0.85 0.08 195); /* Soft Cyan - Subdued */
--chart-6: oklch(0.62 0.22 295); /* Deep Violet - Theme Primary */ --chart-6: oklch(0.62 0.22 295); /* Deep Violet - Theme Primary */
--chart-8: oklch(0.58 0.2 335); /* Cool Magenta - Distant Accent */ --chart-8: oklch(0.58 0.2 335); /* Cool Magenta - Distant Accent */
--sidebar: oklch(0.985 0 0 / 0.6); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823); --sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.541 0.281 293.009); --sidebar-primary: oklch(0.541 0.281 293.009);
--sidebar-primary-foreground: oklch(0.969 0.016 293.756); --sidebar-primary-foreground: oklch(0.969 0.016 293.756);
@@ -43,7 +44,7 @@
.dark { .dark {
--background: oklch(0.141 0.005 285.823); --background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885 / 0.6); --card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885); --popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
@@ -66,7 +67,7 @@
--chart-5: oklch(0.88 0.1 195); --chart-5: oklch(0.88 0.1 195);
--chart-6: oklch(0.65 0.24 295); --chart-6: oklch(0.65 0.24 295);
--chart-8: oklch(0.62 0.22 335); --chart-8: oklch(0.62 0.22 335);
--sidebar: oklch(0.21 0.006 285.885 / 0.6); --sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.606 0.25 292.717); --sidebar-primary: oklch(0.606 0.25 292.717);
--sidebar-primary-foreground: oklch(0.969 0.016 293.756); --sidebar-primary-foreground: oklch(0.969 0.016 293.756);
@@ -122,34 +123,10 @@
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-none;
} }
body { body {
@apply bg-transparent text-foreground; @apply bg-background text-foreground;
min-height: 100vh;
}
/* Modern Scrollbar Styles */
::-webkit-scrollbar {
width: 8px; /* Slightly wider for better accessibility */
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--muted-foreground);
border-radius: 20px;
border: 2px solid transparent;
background-clip: content-box;
opacity: 0.5; /* More visible default */
}
/* Firefox Support */
* {
/* scrollbar-width: ; */
scrollbar-color: var(--muted-foreground) transparent;
} }
} }

View File

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

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

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

View File

@@ -44,7 +44,9 @@ export function BreadcrumbBasic() {
{isLast ? ( {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

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

View File

@@ -55,7 +55,10 @@ const chartConfig = {
}, },
} satisfies ChartConfig; } 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

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

View File

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

View File

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

View File

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

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

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,10 +0,0 @@
import Image from 'next/image';
export function PoweredBy() {
return (
<div className="flex items-center justify-center gap-2 mt-3 transition-opacity duration-300">
<span className="font-medium text-muted-foreground">Powered by</span>
<Image src="/sg.svg" alt="Sentient Geeks" width={130} height={20} />
</div>
);
}

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