Compare commits
37 Commits
9695c80e20
...
adff2e2c41
| Author | SHA1 | Date | |
|---|---|---|---|
| adff2e2c41 | |||
| 3e5dbd5893 | |||
| 11f95c4727 | |||
| e805cdee09 | |||
| 57461a05f2 | |||
| 8a080cb051 | |||
| 063f963fd4 | |||
| 1f8ca87bd0 | |||
| 8665762ba2 | |||
| 81a53b692d | |||
| 82050372c6 | |||
| 2fc01f74e2 | |||
| 67237982c8 | |||
| c1c81687d8 | |||
| 81b68fdb61 | |||
| 5439a5ff45 | |||
| 6e16d4f2c8 | |||
| dfb95c5213 | |||
| 5e4cf61d6e | |||
| b282fdf56d | |||
| 066374bebc | |||
| 27cf5c9d3f | |||
| a731fca5e0 | |||
| 664d80809a | |||
| fffd8e3e12 | |||
| a567a52e26 | |||
| 8c3d19a429 | |||
| a18fff4d6f | |||
| dff9e2222f | |||
| d8fa02dc5d | |||
| 62ebf1ebf6 | |||
| 9e76b8e6d0 | |||
| 6cf363dce1 | |||
| 4add7a6c83 | |||
| af4d7fae97 | |||
| defb956751 | |||
| c6894bfea0 |
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
58
package-lock.json
generated
58
package-lock.json
generated
@@ -26,7 +26,7 @@
|
|||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"leaflet": "^1.9.4",
|
"libphonenumber-js": "^1.13.7",
|
||||||
"lucide-react": "^0.548.0",
|
"lucide-react": "^0.548.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
@@ -35,7 +35,6 @@
|
|||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-hook-form": "^7.79.0",
|
"react-hook-form": "^7.79.0",
|
||||||
"react-leaflet": "^5.0.0",
|
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
@@ -44,7 +43,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.9",
|
"@tailwindcss/postcss": "^4.1.9",
|
||||||
"@types/leaflet": "^1.9.21",
|
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -2881,17 +2879,6 @@
|
|||||||
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
|
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@react-leaflet/core": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
|
||||||
"license": "Hippocratic-2.1",
|
|
||||||
"peerDependencies": {
|
|
||||||
"leaflet": "^1.9.0",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -3379,13 +3366,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/geojson": {
|
|
||||||
"version": "7946.0.16",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
|
||||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -3400,16 +3380,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/leaflet": {
|
|
||||||
"version": "1.9.21",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
|
||||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/geojson": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.15",
|
"version": "22.19.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
@@ -6850,12 +6820,6 @@
|
|||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/leaflet": {
|
|
||||||
"version": "1.9.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
|
||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
|
||||||
"license": "BSD-2-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -6870,6 +6834,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/libphonenumber-js": {
|
||||||
|
"version": "1.13.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.7.tgz",
|
||||||
|
"integrity": "sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.31.1",
|
"version": "1.31.1",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||||
@@ -8017,20 +7987,6 @@
|
|||||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-leaflet": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
|
||||||
"license": "Hippocratic-2.1",
|
|
||||||
"dependencies": {
|
|
||||||
"@react-leaflet/core": "^3.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"leaflet": "^1.9.0",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"leaflet": "^1.9.4",
|
"libphonenumber-js": "^1.13.7",
|
||||||
"lucide-react": "^0.548.0",
|
"lucide-react": "^0.548.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
@@ -39,7 +39,6 @@
|
|||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-hook-form": "^7.79.0",
|
"react-hook-form": "^7.79.0",
|
||||||
"react-leaflet": "^5.0.0",
|
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
@@ -48,7 +47,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.9",
|
"@tailwindcss/postcss": "^4.1.9",
|
||||||
"@types/leaflet": "^1.9.21",
|
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
740
public/flags/flags.css
Normal file
740
public/flags/flags.css
Normal file
@@ -0,0 +1,740 @@
|
|||||||
|
span.flag {
|
||||||
|
width: 44px;
|
||||||
|
height: 30px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
img.flag {
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
.flag {
|
||||||
|
background: url(./flags_responsive.png) no-repeat;
|
||||||
|
background-size: 100%;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.flag-ad {
|
||||||
|
background-position: 0 0.413223%;
|
||||||
|
}
|
||||||
|
.flag-ae {
|
||||||
|
background-position: 0 0.826446%;
|
||||||
|
}
|
||||||
|
.flag-af {
|
||||||
|
background-position: 0 1.239669%;
|
||||||
|
}
|
||||||
|
.flag-ag {
|
||||||
|
background-position: 0 1.652893%;
|
||||||
|
}
|
||||||
|
.flag-ai {
|
||||||
|
background-position: 0 2.066116%;
|
||||||
|
}
|
||||||
|
.flag-al {
|
||||||
|
background-position: 0 2.479339%;
|
||||||
|
}
|
||||||
|
.flag-am {
|
||||||
|
background-position: 0 2.892562%;
|
||||||
|
}
|
||||||
|
.flag-an {
|
||||||
|
background-position: 0 3.305785%;
|
||||||
|
}
|
||||||
|
.flag-ao {
|
||||||
|
background-position: 0 3.719008%;
|
||||||
|
}
|
||||||
|
.flag-aq {
|
||||||
|
background-position: 0 4.132231%;
|
||||||
|
}
|
||||||
|
.flag-ar {
|
||||||
|
background-position: 0 4.545455%;
|
||||||
|
}
|
||||||
|
.flag-as {
|
||||||
|
background-position: 0 4.958678%;
|
||||||
|
}
|
||||||
|
.flag-at {
|
||||||
|
background-position: 0 5.371901%;
|
||||||
|
}
|
||||||
|
.flag-au {
|
||||||
|
background-position: 0 5.785124%;
|
||||||
|
}
|
||||||
|
.flag-aw {
|
||||||
|
background-position: 0 6.198347%;
|
||||||
|
}
|
||||||
|
.flag-az {
|
||||||
|
background-position: 0 6.61157%;
|
||||||
|
}
|
||||||
|
.flag-ba {
|
||||||
|
background-position: 0 7.024793%;
|
||||||
|
}
|
||||||
|
.flag-bb {
|
||||||
|
background-position: 0 7.438017%;
|
||||||
|
}
|
||||||
|
.flag-bd {
|
||||||
|
background-position: 0 7.85124%;
|
||||||
|
}
|
||||||
|
.flag-be {
|
||||||
|
background-position: 0 8.264463%;
|
||||||
|
}
|
||||||
|
.flag-bf {
|
||||||
|
background-position: 0 8.677686%;
|
||||||
|
}
|
||||||
|
.flag-bg {
|
||||||
|
background-position: 0 9.090909%;
|
||||||
|
}
|
||||||
|
.flag-bh {
|
||||||
|
background-position: 0 9.504132%;
|
||||||
|
}
|
||||||
|
.flag-bi {
|
||||||
|
background-position: 0 9.917355%;
|
||||||
|
}
|
||||||
|
.flag-bj {
|
||||||
|
background-position: 0 10.330579%;
|
||||||
|
}
|
||||||
|
.flag-bm {
|
||||||
|
background-position: 0 10.743802%;
|
||||||
|
}
|
||||||
|
.flag-bn {
|
||||||
|
background-position: 0 11.157025%;
|
||||||
|
}
|
||||||
|
.flag-bo {
|
||||||
|
background-position: 0 11.570248%;
|
||||||
|
}
|
||||||
|
.flag-br {
|
||||||
|
background-position: 0 11.983471%;
|
||||||
|
}
|
||||||
|
.flag-bs {
|
||||||
|
background-position: 0 12.396694%;
|
||||||
|
}
|
||||||
|
.flag-bt {
|
||||||
|
background-position: 0 12.809917%;
|
||||||
|
}
|
||||||
|
.flag-bv {
|
||||||
|
background-position: 0 13.22314%;
|
||||||
|
}
|
||||||
|
.flag-bw {
|
||||||
|
background-position: 0 13.636364%;
|
||||||
|
}
|
||||||
|
.flag-by {
|
||||||
|
background-position: 0 14.049587%;
|
||||||
|
}
|
||||||
|
.flag-bz {
|
||||||
|
background-position: 0 14.46281%;
|
||||||
|
}
|
||||||
|
.flag-ca {
|
||||||
|
background-position: 0 14.876033%;
|
||||||
|
}
|
||||||
|
.flag-cc {
|
||||||
|
background-position: 0 15.289256%;
|
||||||
|
}
|
||||||
|
.flag-cd {
|
||||||
|
background-position: 0 15.702479%;
|
||||||
|
}
|
||||||
|
.flag-cf {
|
||||||
|
background-position: 0 16.115702%;
|
||||||
|
}
|
||||||
|
.flag-cg {
|
||||||
|
background-position: 0 16.528926%;
|
||||||
|
}
|
||||||
|
.flag-ch {
|
||||||
|
background-position: 0 16.942149%;
|
||||||
|
}
|
||||||
|
.flag-ci {
|
||||||
|
background-position: 0 17.355372%;
|
||||||
|
}
|
||||||
|
.flag-ck {
|
||||||
|
background-position: 0 17.768595%;
|
||||||
|
}
|
||||||
|
.flag-cl {
|
||||||
|
background-position: 0 18.181818%;
|
||||||
|
}
|
||||||
|
.flag-cm {
|
||||||
|
background-position: 0 18.595041%;
|
||||||
|
}
|
||||||
|
.flag-cn {
|
||||||
|
background-position: 0 19.008264%;
|
||||||
|
}
|
||||||
|
.flag-co {
|
||||||
|
background-position: 0 19.421488%;
|
||||||
|
}
|
||||||
|
.flag-cr {
|
||||||
|
background-position: 0 19.834711%;
|
||||||
|
}
|
||||||
|
.flag-cu {
|
||||||
|
background-position: 0 20.247934%;
|
||||||
|
}
|
||||||
|
.flag-cv {
|
||||||
|
background-position: 0 20.661157%;
|
||||||
|
}
|
||||||
|
.flag-cx {
|
||||||
|
background-position: 0 21.07438%;
|
||||||
|
}
|
||||||
|
.flag-cy {
|
||||||
|
background-position: 0 21.487603%;
|
||||||
|
}
|
||||||
|
.flag-cz {
|
||||||
|
background-position: 0 21.900826%;
|
||||||
|
}
|
||||||
|
.flag-de {
|
||||||
|
background-position: 0 22.31405%;
|
||||||
|
}
|
||||||
|
.flag-dj {
|
||||||
|
background-position: 0 22.727273%;
|
||||||
|
}
|
||||||
|
.flag-dk {
|
||||||
|
background-position: 0 23.140496%;
|
||||||
|
}
|
||||||
|
.flag-dm {
|
||||||
|
background-position: 0 23.553719%;
|
||||||
|
}
|
||||||
|
.flag-do {
|
||||||
|
background-position: 0 23.966942%;
|
||||||
|
}
|
||||||
|
.flag-dz {
|
||||||
|
background-position: 0 24.380165%;
|
||||||
|
}
|
||||||
|
.flag-ec {
|
||||||
|
background-position: 0 24.793388%;
|
||||||
|
}
|
||||||
|
.flag-ee {
|
||||||
|
background-position: 0 25.206612%;
|
||||||
|
}
|
||||||
|
.flag-eg {
|
||||||
|
background-position: 0 25.619835%;
|
||||||
|
}
|
||||||
|
.flag-eh {
|
||||||
|
background-position: 0 26.033058%;
|
||||||
|
}
|
||||||
|
.flag-er {
|
||||||
|
background-position: 0 26.446281%;
|
||||||
|
}
|
||||||
|
.flag-es {
|
||||||
|
background-position: 0 26.859504%;
|
||||||
|
}
|
||||||
|
.flag-et {
|
||||||
|
background-position: 0 27.272727%;
|
||||||
|
}
|
||||||
|
.flag-fi {
|
||||||
|
background-position: 0 27.68595%;
|
||||||
|
}
|
||||||
|
.flag-fj {
|
||||||
|
background-position: 0 28.099174%;
|
||||||
|
}
|
||||||
|
.flag-fk {
|
||||||
|
background-position: 0 28.512397%;
|
||||||
|
}
|
||||||
|
.flag-fm {
|
||||||
|
background-position: 0 28.92562%;
|
||||||
|
}
|
||||||
|
.flag-fo {
|
||||||
|
background-position: 0 29.338843%;
|
||||||
|
}
|
||||||
|
.flag-fr {
|
||||||
|
background-position: 0 29.752066%;
|
||||||
|
}
|
||||||
|
.flag-ga {
|
||||||
|
background-position: 0 30.165289%;
|
||||||
|
}
|
||||||
|
.flag-gd {
|
||||||
|
background-position: 0 30.578512%;
|
||||||
|
}
|
||||||
|
.flag-ge {
|
||||||
|
background-position: 0 30.991736%;
|
||||||
|
}
|
||||||
|
.flag-gf {
|
||||||
|
background-position: 0 31.404959%;
|
||||||
|
}
|
||||||
|
.flag-gh {
|
||||||
|
background-position: 0 31.818182%;
|
||||||
|
}
|
||||||
|
.flag-gi {
|
||||||
|
background-position: 0 32.231405%;
|
||||||
|
}
|
||||||
|
.flag-gl {
|
||||||
|
background-position: 0 32.644628%;
|
||||||
|
}
|
||||||
|
.flag-gm {
|
||||||
|
background-position: 0 33.057851%;
|
||||||
|
}
|
||||||
|
.flag-gn {
|
||||||
|
background-position: 0 33.471074%;
|
||||||
|
}
|
||||||
|
.flag-gp {
|
||||||
|
background-position: 0 33.884298%;
|
||||||
|
}
|
||||||
|
.flag-gq {
|
||||||
|
background-position: 0 34.297521%;
|
||||||
|
}
|
||||||
|
.flag-gr {
|
||||||
|
background-position: 0 34.710744%;
|
||||||
|
}
|
||||||
|
.flag-gs {
|
||||||
|
background-position: 0 35.123967%;
|
||||||
|
}
|
||||||
|
.flag-gt {
|
||||||
|
background-position: 0 35.53719%;
|
||||||
|
}
|
||||||
|
.flag-gu {
|
||||||
|
background-position: 0 35.950413%;
|
||||||
|
}
|
||||||
|
.flag-gw {
|
||||||
|
background-position: 0 36.363636%;
|
||||||
|
}
|
||||||
|
.flag-gy {
|
||||||
|
background-position: 0 36.77686%;
|
||||||
|
}
|
||||||
|
.flag-hk {
|
||||||
|
background-position: 0 37.190083%;
|
||||||
|
}
|
||||||
|
.flag-hm {
|
||||||
|
background-position: 0 37.603306%;
|
||||||
|
}
|
||||||
|
.flag-hn {
|
||||||
|
background-position: 0 38.016529%;
|
||||||
|
}
|
||||||
|
.flag-hr {
|
||||||
|
background-position: 0 38.429752%;
|
||||||
|
}
|
||||||
|
.flag-ht {
|
||||||
|
background-position: 0 38.842975%;
|
||||||
|
}
|
||||||
|
.flag-hu {
|
||||||
|
background-position: 0 39.256198%;
|
||||||
|
}
|
||||||
|
.flag-id {
|
||||||
|
background-position: 0 39.669421%;
|
||||||
|
}
|
||||||
|
.flag-ie {
|
||||||
|
background-position: 0 40.082645%;
|
||||||
|
}
|
||||||
|
.flag-il {
|
||||||
|
background-position: 0 40.495868%;
|
||||||
|
}
|
||||||
|
.flag-in {
|
||||||
|
background-position: 0 40.909091%;
|
||||||
|
}
|
||||||
|
.flag-io {
|
||||||
|
background-position: 0 41.322314%;
|
||||||
|
}
|
||||||
|
.flag-iq {
|
||||||
|
background-position: 0 41.735537%;
|
||||||
|
}
|
||||||
|
.flag-ir {
|
||||||
|
background-position: 0 42.14876%;
|
||||||
|
}
|
||||||
|
.flag-is {
|
||||||
|
background-position: 0 42.561983%;
|
||||||
|
}
|
||||||
|
.flag-it {
|
||||||
|
background-position: 0 42.975207%;
|
||||||
|
}
|
||||||
|
.flag-jm {
|
||||||
|
background-position: 0 43.38843%;
|
||||||
|
}
|
||||||
|
.flag-jo {
|
||||||
|
background-position: 0 43.801653%;
|
||||||
|
}
|
||||||
|
.flag-jp {
|
||||||
|
background-position: 0 44.214876%;
|
||||||
|
}
|
||||||
|
.flag-ke {
|
||||||
|
background-position: 0 44.628099%;
|
||||||
|
}
|
||||||
|
.flag-kg {
|
||||||
|
background-position: 0 45.041322%;
|
||||||
|
}
|
||||||
|
.flag-kh {
|
||||||
|
background-position: 0 45.454545%;
|
||||||
|
}
|
||||||
|
.flag-ki {
|
||||||
|
background-position: 0 45.867769%;
|
||||||
|
}
|
||||||
|
.flag-km {
|
||||||
|
background-position: 0 46.280992%;
|
||||||
|
}
|
||||||
|
.flag-kn {
|
||||||
|
background-position: 0 46.694215%;
|
||||||
|
}
|
||||||
|
.flag-kp {
|
||||||
|
background-position: 0 47.107438%;
|
||||||
|
}
|
||||||
|
.flag-kr {
|
||||||
|
background-position: 0 47.520661%;
|
||||||
|
}
|
||||||
|
.flag-kw {
|
||||||
|
background-position: 0 47.933884%;
|
||||||
|
}
|
||||||
|
.flag-ky {
|
||||||
|
background-position: 0 48.347107%;
|
||||||
|
}
|
||||||
|
.flag-kz {
|
||||||
|
background-position: 0 48.760331%;
|
||||||
|
}
|
||||||
|
.flag-la {
|
||||||
|
background-position: 0 49.173554%;
|
||||||
|
}
|
||||||
|
.flag-lb {
|
||||||
|
background-position: 0 49.586777%;
|
||||||
|
}
|
||||||
|
.flag-lc {
|
||||||
|
background-position: 0 50%;
|
||||||
|
}
|
||||||
|
.flag-li {
|
||||||
|
background-position: 0 50.413223%;
|
||||||
|
}
|
||||||
|
.flag-lk {
|
||||||
|
background-position: 0 50.826446%;
|
||||||
|
}
|
||||||
|
.flag-lr {
|
||||||
|
background-position: 0 51.239669%;
|
||||||
|
}
|
||||||
|
.flag-ls {
|
||||||
|
background-position: 0 51.652893%;
|
||||||
|
}
|
||||||
|
.flag-lt {
|
||||||
|
background-position: 0 52.066116%;
|
||||||
|
}
|
||||||
|
.flag-lu {
|
||||||
|
background-position: 0 52.479339%;
|
||||||
|
}
|
||||||
|
.flag-lv {
|
||||||
|
background-position: 0 52.892562%;
|
||||||
|
}
|
||||||
|
.flag-ly {
|
||||||
|
background-position: 0 53.305785%;
|
||||||
|
}
|
||||||
|
.flag-ma {
|
||||||
|
background-position: 0 53.719008%;
|
||||||
|
}
|
||||||
|
.flag-mc {
|
||||||
|
background-position: 0 54.132231%;
|
||||||
|
}
|
||||||
|
.flag-md {
|
||||||
|
background-position: 0 54.545455%;
|
||||||
|
}
|
||||||
|
.flag-me {
|
||||||
|
background-position: 0 54.958678%;
|
||||||
|
}
|
||||||
|
.flag-mg {
|
||||||
|
background-position: 0 55.371901%;
|
||||||
|
}
|
||||||
|
.flag-mh {
|
||||||
|
background-position: 0 55.785124%;
|
||||||
|
}
|
||||||
|
.flag-mk {
|
||||||
|
background-position: 0 56.198347%;
|
||||||
|
}
|
||||||
|
.flag-ml {
|
||||||
|
background-position: 0 56.61157%;
|
||||||
|
}
|
||||||
|
.flag-mm {
|
||||||
|
background-position: 0 57.024793%;
|
||||||
|
}
|
||||||
|
.flag-mn {
|
||||||
|
background-position: 0 57.438017%;
|
||||||
|
}
|
||||||
|
.flag-mo {
|
||||||
|
background-position: 0 57.85124%;
|
||||||
|
}
|
||||||
|
.flag-mp {
|
||||||
|
background-position: 0 58.264463%;
|
||||||
|
}
|
||||||
|
.flag-mq {
|
||||||
|
background-position: 0 58.677686%;
|
||||||
|
}
|
||||||
|
.flag-mr {
|
||||||
|
background-position: 0 59.090909%;
|
||||||
|
}
|
||||||
|
.flag-ms {
|
||||||
|
background-position: 0 59.504132%;
|
||||||
|
}
|
||||||
|
.flag-mt {
|
||||||
|
background-position: 0 59.917355%;
|
||||||
|
}
|
||||||
|
.flag-mu {
|
||||||
|
background-position: 0 60.330579%;
|
||||||
|
}
|
||||||
|
.flag-mv {
|
||||||
|
background-position: 0 60.743802%;
|
||||||
|
}
|
||||||
|
.flag-mw {
|
||||||
|
background-position: 0 61.157025%;
|
||||||
|
}
|
||||||
|
.flag-mx {
|
||||||
|
background-position: 0 61.570248%;
|
||||||
|
}
|
||||||
|
.flag-my {
|
||||||
|
background-position: 0 61.983471%;
|
||||||
|
}
|
||||||
|
.flag-mz {
|
||||||
|
background-position: 0 62.396694%;
|
||||||
|
}
|
||||||
|
.flag-na {
|
||||||
|
background-position: 0 62.809917%;
|
||||||
|
}
|
||||||
|
.flag-nc {
|
||||||
|
background-position: 0 63.22314%;
|
||||||
|
}
|
||||||
|
.flag-ne {
|
||||||
|
background-position: 0 63.636364%;
|
||||||
|
}
|
||||||
|
.flag-nf {
|
||||||
|
background-position: 0 64.049587%;
|
||||||
|
}
|
||||||
|
.flag-ng {
|
||||||
|
background-position: 0 64.46281%;
|
||||||
|
}
|
||||||
|
.flag-ni {
|
||||||
|
background-position: 0 64.876033%;
|
||||||
|
}
|
||||||
|
.flag-nl {
|
||||||
|
background-position: 0 65.289256%;
|
||||||
|
}
|
||||||
|
.flag-no {
|
||||||
|
background-position: 0 65.702479%;
|
||||||
|
}
|
||||||
|
.flag-np {
|
||||||
|
background-position: 0 66.115702%;
|
||||||
|
}
|
||||||
|
.flag-nr {
|
||||||
|
background-position: 0 66.528926%;
|
||||||
|
}
|
||||||
|
.flag-nu {
|
||||||
|
background-position: 0 66.942149%;
|
||||||
|
}
|
||||||
|
.flag-nz {
|
||||||
|
background-position: 0 67.355372%;
|
||||||
|
}
|
||||||
|
.flag-om {
|
||||||
|
background-position: 0 67.768595%;
|
||||||
|
}
|
||||||
|
.flag-pa {
|
||||||
|
background-position: 0 68.181818%;
|
||||||
|
}
|
||||||
|
.flag-pe {
|
||||||
|
background-position: 0 68.595041%;
|
||||||
|
}
|
||||||
|
.flag-pf {
|
||||||
|
background-position: 0 69.008264%;
|
||||||
|
}
|
||||||
|
.flag-pg {
|
||||||
|
background-position: 0 69.421488%;
|
||||||
|
}
|
||||||
|
.flag-ph {
|
||||||
|
background-position: 0 69.834711%;
|
||||||
|
}
|
||||||
|
.flag-pk {
|
||||||
|
background-position: 0 70.247934%;
|
||||||
|
}
|
||||||
|
.flag-pl {
|
||||||
|
background-position: 0 70.661157%;
|
||||||
|
}
|
||||||
|
.flag-pm {
|
||||||
|
background-position: 0 71.07438%;
|
||||||
|
}
|
||||||
|
.flag-pn {
|
||||||
|
background-position: 0 71.487603%;
|
||||||
|
}
|
||||||
|
.flag-pr {
|
||||||
|
background-position: 0 71.900826%;
|
||||||
|
}
|
||||||
|
.flag-pt {
|
||||||
|
background-position: 0 72.31405%;
|
||||||
|
}
|
||||||
|
.flag-pw {
|
||||||
|
background-position: 0 72.727273%;
|
||||||
|
}
|
||||||
|
.flag-py {
|
||||||
|
background-position: 0 73.140496%;
|
||||||
|
}
|
||||||
|
.flag-qa {
|
||||||
|
background-position: 0 73.553719%;
|
||||||
|
}
|
||||||
|
.flag-re {
|
||||||
|
background-position: 0 73.966942%;
|
||||||
|
}
|
||||||
|
.flag-ro {
|
||||||
|
background-position: 0 74.380165%;
|
||||||
|
}
|
||||||
|
.flag-rs {
|
||||||
|
background-position: 0 74.793388%;
|
||||||
|
}
|
||||||
|
.flag-ru {
|
||||||
|
background-position: 0 75.206612%;
|
||||||
|
}
|
||||||
|
.flag-rw {
|
||||||
|
background-position: 0 75.619835%;
|
||||||
|
}
|
||||||
|
.flag-sa {
|
||||||
|
background-position: 0 76.033058%;
|
||||||
|
}
|
||||||
|
.flag-sb {
|
||||||
|
background-position: 0 76.446281%;
|
||||||
|
}
|
||||||
|
.flag-sc {
|
||||||
|
background-position: 0 76.859504%;
|
||||||
|
}
|
||||||
|
.flag-sd {
|
||||||
|
background-position: 0 77.272727%;
|
||||||
|
}
|
||||||
|
.flag-se {
|
||||||
|
background-position: 0 77.68595%;
|
||||||
|
}
|
||||||
|
.flag-sg {
|
||||||
|
background-position: 0 78.099174%;
|
||||||
|
}
|
||||||
|
.flag-sh {
|
||||||
|
background-position: 0 78.512397%;
|
||||||
|
}
|
||||||
|
.flag-si {
|
||||||
|
background-position: 0 78.92562%;
|
||||||
|
}
|
||||||
|
.flag-sj {
|
||||||
|
background-position: 0 79.338843%;
|
||||||
|
}
|
||||||
|
.flag-sk {
|
||||||
|
background-position: 0 79.752066%;
|
||||||
|
}
|
||||||
|
.flag-sl {
|
||||||
|
background-position: 0 80.165289%;
|
||||||
|
}
|
||||||
|
.flag-sm {
|
||||||
|
background-position: 0 80.578512%;
|
||||||
|
}
|
||||||
|
.flag-sn {
|
||||||
|
background-position: 0 80.991736%;
|
||||||
|
}
|
||||||
|
.flag-so {
|
||||||
|
background-position: 0 81.404959%;
|
||||||
|
}
|
||||||
|
.flag-sr {
|
||||||
|
background-position: 0 81.818182%;
|
||||||
|
}
|
||||||
|
.flag-ss {
|
||||||
|
background-position: 0 82.231405%;
|
||||||
|
}
|
||||||
|
.flag-st {
|
||||||
|
background-position: 0 82.644628%;
|
||||||
|
}
|
||||||
|
.flag-sv {
|
||||||
|
background-position: 0 83.057851%;
|
||||||
|
}
|
||||||
|
.flag-sy {
|
||||||
|
background-position: 0 83.471074%;
|
||||||
|
}
|
||||||
|
.flag-sz {
|
||||||
|
background-position: 0 83.884298%;
|
||||||
|
}
|
||||||
|
.flag-tc {
|
||||||
|
background-position: 0 84.297521%;
|
||||||
|
}
|
||||||
|
.flag-td {
|
||||||
|
background-position: 0 84.710744%;
|
||||||
|
}
|
||||||
|
.flag-tf {
|
||||||
|
background-position: 0 85.123967%;
|
||||||
|
}
|
||||||
|
.flag-tg {
|
||||||
|
background-position: 0 85.53719%;
|
||||||
|
}
|
||||||
|
.flag-th {
|
||||||
|
background-position: 0 85.950413%;
|
||||||
|
}
|
||||||
|
.flag-tj {
|
||||||
|
background-position: 0 86.363636%;
|
||||||
|
}
|
||||||
|
.flag-tk {
|
||||||
|
background-position: 0 86.77686%;
|
||||||
|
}
|
||||||
|
.flag-tl {
|
||||||
|
background-position: 0 87.190083%;
|
||||||
|
}
|
||||||
|
.flag-tm {
|
||||||
|
background-position: 0 87.603306%;
|
||||||
|
}
|
||||||
|
.flag-tn {
|
||||||
|
background-position: 0 88.016529%;
|
||||||
|
}
|
||||||
|
.flag-to {
|
||||||
|
background-position: 0 88.429752%;
|
||||||
|
}
|
||||||
|
.flag-tp {
|
||||||
|
background-position: 0 88.842975%;
|
||||||
|
}
|
||||||
|
.flag-tr {
|
||||||
|
background-position: 0 89.256198%;
|
||||||
|
}
|
||||||
|
.flag-tt {
|
||||||
|
background-position: 0 89.669421%;
|
||||||
|
}
|
||||||
|
.flag-tv {
|
||||||
|
background-position: 0 90.082645%;
|
||||||
|
}
|
||||||
|
.flag-tw {
|
||||||
|
background-position: 0 90.495868%;
|
||||||
|
}
|
||||||
|
.flag-ty {
|
||||||
|
background-position: 0 90.909091%;
|
||||||
|
}
|
||||||
|
.flag-tz {
|
||||||
|
background-position: 0 91.322314%;
|
||||||
|
}
|
||||||
|
.flag-ua {
|
||||||
|
background-position: 0 91.735537%;
|
||||||
|
}
|
||||||
|
.flag-ug {
|
||||||
|
background-position: 0 92.14876%;
|
||||||
|
}
|
||||||
|
.flag-gb,
|
||||||
|
.flag-uk {
|
||||||
|
background-position: 0 92.561983%;
|
||||||
|
}
|
||||||
|
.flag-um {
|
||||||
|
background-position: 0 92.975207%;
|
||||||
|
}
|
||||||
|
.flag-us {
|
||||||
|
background-position: 0 93.38843%;
|
||||||
|
}
|
||||||
|
.flag-uy {
|
||||||
|
background-position: 0 93.801653%;
|
||||||
|
}
|
||||||
|
.flag-uz {
|
||||||
|
background-position: 0 94.214876%;
|
||||||
|
}
|
||||||
|
.flag-va {
|
||||||
|
background-position: 0 94.628099%;
|
||||||
|
}
|
||||||
|
.flag-vc {
|
||||||
|
background-position: 0 95.041322%;
|
||||||
|
}
|
||||||
|
.flag-ve {
|
||||||
|
background-position: 0 95.454545%;
|
||||||
|
}
|
||||||
|
.flag-vg {
|
||||||
|
background-position: 0 95.867769%;
|
||||||
|
}
|
||||||
|
.flag-vi {
|
||||||
|
background-position: 0 96.280992%;
|
||||||
|
}
|
||||||
|
.flag-vn {
|
||||||
|
background-position: 0 96.694215%;
|
||||||
|
}
|
||||||
|
.flag-vu {
|
||||||
|
background-position: 0 97.107438%;
|
||||||
|
}
|
||||||
|
.flag-wf {
|
||||||
|
background-position: 0 97.520661%;
|
||||||
|
}
|
||||||
|
.flag-ws {
|
||||||
|
background-position: 0 97.933884%;
|
||||||
|
}
|
||||||
|
.flag-ye {
|
||||||
|
background-position: 0 98.347107%;
|
||||||
|
}
|
||||||
|
.flag-za {
|
||||||
|
background-position: 0 98.760331%;
|
||||||
|
}
|
||||||
|
.flag-zm {
|
||||||
|
background-position: 0 99.173554%;
|
||||||
|
}
|
||||||
|
.flag-zr {
|
||||||
|
background-position: 0 99.586777%;
|
||||||
|
}
|
||||||
|
.flag-zw {
|
||||||
|
background-position: 0 100%;
|
||||||
|
}
|
||||||
BIN
public/flags/flags_responsive.png
Normal file
BIN
public/flags/flags_responsive.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 278 KiB |
@@ -31,7 +31,7 @@ function ForgotPasswordContent() {
|
|||||||
<div className="flex h-screen flex-1 items-center justify-center border-l border-border p-6 md:w-[60%]">
|
<div className="flex h-screen flex-1 items-center justify-center border-l border-border p-6 md:w-[60%]">
|
||||||
<section className="w-full max-w-sm space-y-7">
|
<section className="w-full max-w-sm space-y-7">
|
||||||
<header className="space-y-2 text-center md:text-left">
|
<header className="space-y-2 text-center md:text-left">
|
||||||
<p className="text-muted-foreground">VisionRoad</p>
|
<p className="text-muted-foreground">RoadMonitor</p>
|
||||||
<h1>{displayInfo.title}</h1>
|
<h1>{displayInfo.title}</h1>
|
||||||
<p className="text-muted-foreground">{displayInfo.description}</p>
|
<p className="text-muted-foreground">{displayInfo.description}</p>
|
||||||
</header>
|
</header>
|
||||||
@@ -60,7 +60,11 @@ function ForgotPasswordContent() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={isLoading || !canSubmit}
|
disabled={isLoading || !canSubmit}
|
||||||
>
|
>
|
||||||
{isLoading ? <Loader2 className="size-4 animate-spin" /> : <Send />}
|
{isLoading ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send />
|
||||||
|
)}
|
||||||
{displayInfo.buttonLabel}
|
{displayInfo.buttonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function LoginPage() {
|
|||||||
<section className="w-full max-w-sm space-y-7">
|
<section className="w-full max-w-sm space-y-7">
|
||||||
<header className="space-y-2 text-center md:text-left">
|
<header className="space-y-2 text-center md:text-left">
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
VisionRoad
|
RoadMonitor
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function ResetPasswordContent() {
|
|||||||
<div className="flex h-screen flex-1 items-center justify-center border-l border-border p-6 md:w-[60%]">
|
<div className="flex h-screen flex-1 items-center justify-center border-l border-border p-6 md:w-[60%]">
|
||||||
<section className="w-full max-w-sm space-y-7">
|
<section className="w-full max-w-sm space-y-7">
|
||||||
<header className="space-y-2 text-center md:text-left">
|
<header className="space-y-2 text-center md:text-left">
|
||||||
<p className="text-muted-foreground">VisionRoad</p>
|
<p className="text-muted-foreground">RoadMonitor</p>
|
||||||
<h1>{displayInfo.title}</h1>
|
<h1>{displayInfo.title}</h1>
|
||||||
<p className="text-muted-foreground">{displayInfo.description}</p>
|
<p className="text-muted-foreground">{displayInfo.description}</p>
|
||||||
</header>
|
</header>
|
||||||
@@ -69,7 +69,9 @@ function ResetPasswordContent() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={isLoading || !canSubmit}
|
disabled={isLoading || !canSubmit}
|
||||||
>
|
>
|
||||||
{isLoading ? <Loader2 className="size-4 animate-spin" /> : null}
|
{isLoading ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
{displayInfo.buttonLabel}
|
{displayInfo.buttonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ export function useClientColumns(): ColumnDef<Client>[] {
|
|||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'first_name',
|
||||||
header: 'Client',
|
header: 'Client',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-medium">{row.original.name}</span>
|
<span className="font-medium">
|
||||||
|
{`${row.original.first_name} ${row.original.last_name}`.trim()}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import { useRef, type ComponentProps } from 'react';
|
||||||
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
import {
|
||||||
|
Controller,
|
||||||
|
type Control,
|
||||||
|
type FieldErrors,
|
||||||
|
type UseFormRegister,
|
||||||
|
} from 'react-hook-form';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { FormField } from '@/components/form';
|
import { FormField, PhoneInput } from '@/components/form';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -22,6 +27,7 @@ interface ClientSheetProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
clientId?: number;
|
clientId?: number;
|
||||||
register: UseFormRegister<ClientFormValues>;
|
register: UseFormRegister<ClientFormValues>;
|
||||||
|
control: Control<ClientFormValues>;
|
||||||
errors: FieldErrors<ClientFormValues>;
|
errors: FieldErrors<ClientFormValues>;
|
||||||
isSubmitted: boolean;
|
isSubmitted: boolean;
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
@@ -34,13 +40,20 @@ export function ClientSheet({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
clientId,
|
clientId,
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
errors,
|
errors,
|
||||||
isSubmitted,
|
isSubmitted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: ClientSheetProps) {
|
}: ClientSheetProps) {
|
||||||
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
|
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const firstNameErrorMessage = isSubmitted
|
||||||
|
? errors.first_name?.message
|
||||||
|
: undefined;
|
||||||
|
const lastNameErrorMessage = isSubmitted
|
||||||
|
? errors.last_name?.message
|
||||||
|
: undefined;
|
||||||
const emailErrorMessage = isSubmitted ? errors.email?.message : undefined;
|
const emailErrorMessage = isSubmitted ? errors.email?.message : undefined;
|
||||||
const landlineErrorMessage = isSubmitted
|
const landlineErrorMessage = isSubmitted
|
||||||
? errors.landline_number?.message
|
? errors.landline_number?.message
|
||||||
@@ -68,23 +81,43 @@ export function ClientSheet({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
<form
|
||||||
|
noValidate
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
<div className="max-h-[58vh] space-y-5 overflow-y-auto px-6 py-4">
|
<div className="max-h-[58vh] space-y-5 overflow-y-auto px-6 py-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
id="client-name"
|
id="client-first-name"
|
||||||
label="Client Name"
|
label="First Name"
|
||||||
required
|
required
|
||||||
error={nameErrorMessage}
|
error={firstNameErrorMessage}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="client-name"
|
id="client-first-name"
|
||||||
placeholder="Enter client name"
|
placeholder="Enter first name"
|
||||||
aria-invalid={!!nameErrorMessage}
|
aria-invalid={!!firstNameErrorMessage}
|
||||||
{...register('name')}
|
{...register('first_name')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="client-last-name"
|
||||||
|
label="Last Name"
|
||||||
|
required
|
||||||
|
error={lastNameErrorMessage}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="client-last-name"
|
||||||
|
placeholder="Enter last name"
|
||||||
|
aria-invalid={!!lastNameErrorMessage}
|
||||||
|
{...register('last_name')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
id="client-email"
|
id="client-email"
|
||||||
label="Company Email"
|
label="Company Email"
|
||||||
@@ -94,14 +127,12 @@ export function ClientSheet({
|
|||||||
<Input
|
<Input
|
||||||
id="client-email"
|
id="client-email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="info@example.com"
|
placeholder="Enter company email"
|
||||||
aria-invalid={!!emailErrorMessage}
|
aria-invalid={!!emailErrorMessage}
|
||||||
{...register('email')}
|
{...register('email')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<FormField
|
<FormField
|
||||||
id="landline-number"
|
id="landline-number"
|
||||||
label="Landline Number"
|
label="Landline Number"
|
||||||
@@ -110,42 +141,50 @@ export function ClientSheet({
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="landline-number"
|
id="landline-number"
|
||||||
placeholder="+91-22-12345678"
|
placeholder="Enter landline number"
|
||||||
aria-invalid={!!landlineErrorMessage}
|
aria-invalid={!!landlineErrorMessage}
|
||||||
{...register('landline_number')}
|
{...register('landline_number')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
|
||||||
id="address"
|
|
||||||
label="Address"
|
|
||||||
required
|
|
||||||
error={addressErrorMessage}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="address"
|
|
||||||
placeholder="12 MG Road, Mumbai, MH 400001"
|
|
||||||
aria-invalid={!!addressErrorMessage}
|
|
||||||
{...register('address')}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="address"
|
||||||
|
label="Address"
|
||||||
|
required
|
||||||
|
error={addressErrorMessage}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="address"
|
||||||
|
placeholder="Enter address"
|
||||||
|
aria-invalid={!!addressErrorMessage}
|
||||||
|
{...register('address')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<FormField id="gst" label="GST">
|
<FormField id="gst" label="GST">
|
||||||
<Input
|
<Input
|
||||||
id="gst"
|
id="gst"
|
||||||
placeholder="27ABCDE1234F1Z5"
|
placeholder="Enter GST number"
|
||||||
{...register('gst')}
|
{...register('gst')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField id="pan" label="PAN">
|
<FormField id="pan" label="PAN">
|
||||||
<Input id="pan" placeholder="ABCDE1234F" {...register('pan')} />
|
<Input
|
||||||
|
id="pan"
|
||||||
|
placeholder="Enter PAN number"
|
||||||
|
{...register('pan')}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField id="tan" label="TAN">
|
<FormField id="tan" label="TAN">
|
||||||
<Input id="tan" placeholder="MUMA12345B" {...register('tan')} />
|
<Input
|
||||||
|
id="tan"
|
||||||
|
placeholder="Enter TAN number"
|
||||||
|
{...register('tan')}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -170,11 +209,19 @@ export function ClientSheet({
|
|||||||
required
|
required
|
||||||
error={contactPhoneErrorMessage}
|
error={contactPhoneErrorMessage}
|
||||||
>
|
>
|
||||||
<Input
|
<Controller
|
||||||
id="contact-phone-number"
|
control={control}
|
||||||
placeholder="+919876543210"
|
name="contact_phone_number"
|
||||||
aria-invalid={!!contactPhoneErrorMessage}
|
render={({ field }) => (
|
||||||
{...register('contact_phone_number')}
|
<PhoneInput
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="Enter contact phone"
|
||||||
|
aria-invalid={!!contactPhoneErrorMessage}
|
||||||
|
portalContainer={dropdownPortalRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@@ -187,7 +234,7 @@ export function ClientSheet({
|
|||||||
<Input
|
<Input
|
||||||
id="contact-email"
|
id="contact-email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="contact@example.com"
|
placeholder="Enter contact email"
|
||||||
aria-invalid={!!contactEmailErrorMessage}
|
aria-invalid={!!contactEmailErrorMessage}
|
||||||
{...register('contact_email')}
|
{...register('contact_email')}
|
||||||
/>
|
/>
|
||||||
@@ -213,6 +260,8 @@ export function ClientSheet({
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div ref={dropdownPortalRef} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,45 +1,43 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { Client } from '@/types';
|
import type { Client } from '@/types';
|
||||||
import { useSaveClientMutation } from './useClientMutations';
|
import { useSaveClientMutation } from './useClientMutations';
|
||||||
|
|
||||||
const phoneSchema = z
|
const requiredText = (message: string) => z.string().trim().min(1, message);
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1, 'Phone number is required')
|
|
||||||
.regex(/^\+(?:[0-9] ?|-){6,18}[0-9]$/, 'Please enter a valid phone number');
|
|
||||||
|
|
||||||
const optionalTaxIdSchema = z.string().trim();
|
const optionalTaxIdSchema = z.string().trim();
|
||||||
|
|
||||||
const clientFormSchema = z.object({
|
const clientFormSchema = z.object({
|
||||||
id: z.number().optional(),
|
id: z.number().optional(),
|
||||||
name: z.string().trim().min(1, 'Client name is required'),
|
first_name: requiredText('First name is required'),
|
||||||
email: z.string().trim().min(1, 'Email is required').email('Invalid email'),
|
last_name: requiredText('Last name is required'),
|
||||||
landline_number: phoneSchema,
|
email: requiredText('Email is required').email('Invalid email'),
|
||||||
address: z.string().trim().min(1, 'Address is required'),
|
landline_number: requiredText('Landline number is required'),
|
||||||
|
address: requiredText('Address is required'),
|
||||||
gst: optionalTaxIdSchema,
|
gst: optionalTaxIdSchema,
|
||||||
pan: optionalTaxIdSchema,
|
pan: optionalTaxIdSchema,
|
||||||
tan: optionalTaxIdSchema,
|
tan: optionalTaxIdSchema,
|
||||||
contact_name: z.string().trim().min(1, 'Contact name is required'),
|
contact_name: requiredText('Contact name is required'),
|
||||||
contact_phone_number: phoneSchema,
|
contact_phone_number: requiredText('Contact phone number is required').refine(
|
||||||
contact_email: z
|
(value) => isValidPhoneNumber(value),
|
||||||
.string()
|
'Enter a valid phone number',
|
||||||
.trim()
|
),
|
||||||
.min(1, 'Contact email is required')
|
contact_email: requiredText('Contact email is required').email(
|
||||||
.email('Invalid contact email'),
|
'Invalid contact email',
|
||||||
|
),
|
||||||
is_active: z.boolean(),
|
is_active: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ClientFormValues = z.infer<typeof clientFormSchema>;
|
export type ClientFormValues = z.infer<typeof clientFormSchema>;
|
||||||
|
|
||||||
const defaultValues: ClientFormValues = {
|
const defaultValues: ClientFormValues = {
|
||||||
name: '',
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
email: '',
|
email: '',
|
||||||
landline_number: '',
|
landline_number: '',
|
||||||
address: '',
|
address: '',
|
||||||
@@ -73,7 +71,8 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const clientId = useWatch({ control, name: 'id' });
|
const clientId = useWatch({ control, name: 'id' });
|
||||||
const name = useWatch({ control, name: 'name' }) || '';
|
const firstName = useWatch({ control, name: 'first_name' }) || '';
|
||||||
|
const lastName = useWatch({ control, name: 'last_name' }) || '';
|
||||||
const email = useWatch({ control, name: 'email' }) || '';
|
const email = useWatch({ control, name: 'email' }) || '';
|
||||||
const landlineNumber = useWatch({ control, name: 'landline_number' }) || '';
|
const landlineNumber = useWatch({ control, name: 'landline_number' }) || '';
|
||||||
const address = useWatch({ control, name: 'address' }) || '';
|
const address = useWatch({ control, name: 'address' }) || '';
|
||||||
@@ -82,7 +81,8 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
useWatch({ control, name: 'contact_phone_number' }) || '';
|
useWatch({ control, name: 'contact_phone_number' }) || '';
|
||||||
const contactEmail = useWatch({ control, name: 'contact_email' }) || '';
|
const contactEmail = useWatch({ control, name: 'contact_email' }) || '';
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
name.trim().length > 0 &&
|
firstName.trim().length > 0 &&
|
||||||
|
lastName.trim().length > 0 &&
|
||||||
email.trim().length > 0 &&
|
email.trim().length > 0 &&
|
||||||
landlineNumber.trim().length > 0 &&
|
landlineNumber.trim().length > 0 &&
|
||||||
address.trim().length > 0 &&
|
address.trim().length > 0 &&
|
||||||
@@ -90,18 +90,7 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
contactPhoneNumber.trim().length > 0 &&
|
contactPhoneNumber.trim().length > 0 &&
|
||||||
contactEmail.trim().length > 0;
|
contactEmail.trim().length > 0;
|
||||||
|
|
||||||
const handleSubmit = submitForm(
|
const handleSubmit = submitForm((values) => saveMutation.mutate(values));
|
||||||
(values) => saveMutation.mutate(values),
|
|
||||||
(formErrors) => {
|
|
||||||
const firstError = Object.values(formErrors).find(
|
|
||||||
(error) => error?.message,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (firstError?.message) {
|
|
||||||
toast.error(firstError.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
@@ -111,7 +100,8 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
(client: Client) => {
|
(client: Client) => {
|
||||||
reset({
|
reset({
|
||||||
id: client.id,
|
id: client.id,
|
||||||
name: client.name || '',
|
first_name: client.first_name || '',
|
||||||
|
last_name: client.last_name || '',
|
||||||
email: client.email || '',
|
email: client.email || '',
|
||||||
landline_number: client.landline_number || '',
|
landline_number: client.landline_number || '',
|
||||||
address: client.address || '',
|
address: client.address || '',
|
||||||
@@ -129,6 +119,7 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
openCreate,
|
openCreate,
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
|
|||||||
mutationFn: (values: ClientFormValues) =>
|
mutationFn: (values: ClientFormValues) =>
|
||||||
clientService.saveClient({
|
clientService.saveClient({
|
||||||
id: values.id,
|
id: values.id,
|
||||||
name: values.name.trim(),
|
first_name: values.first_name.trim(),
|
||||||
|
last_name: values.last_name.trim(),
|
||||||
email: values.email.trim(),
|
email: values.email.trim(),
|
||||||
landline_number: values.landline_number.trim(),
|
landline_number: values.landline_number.trim(),
|
||||||
address: values.address.trim(),
|
address: values.address.trim(),
|
||||||
|
|||||||
16
src/app/(modules)/clients/layout.tsx
Normal file
16
src/app/(modules)/clients/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function ClientsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.CLIENT.READ}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ export default function ClientsPage() {
|
|||||||
clientForm;
|
clientForm;
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
clientId,
|
clientId,
|
||||||
errors,
|
errors,
|
||||||
@@ -134,6 +135,7 @@ export default function ClientsPage() {
|
|||||||
onOpenChange={setIsSheetOpen}
|
onOpenChange={setIsSheetOpen}
|
||||||
clientId={clientId}
|
clientId={clientId}
|
||||||
register={register}
|
register={register}
|
||||||
|
control={control}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
isSubmitted={isSubmitted}
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
|||||||
@@ -1,678 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
Activity,
|
|
||||||
MapPin as MapPinIcon,
|
|
||||||
Milestone,
|
|
||||||
BarChart3,
|
|
||||||
AlertTriangle,
|
|
||||||
Zap,
|
|
||||||
PencilLine,
|
|
||||||
CheckCircle2,
|
|
||||||
Settings2,
|
|
||||||
Droplets,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { StatsCard } from '@/components/dashboard/stats-card';
|
|
||||||
import { FilterSelector } from '@/components/dashboard/filter-selector';
|
|
||||||
import { DetectionDonutChart } from '@/components/dashboard/detection-donut-chart';
|
|
||||||
import { ChainageBarChart } from '@/components/dashboard/chainage-bar-chart';
|
|
||||||
import { DashboardMap } from '@/components/dashboard/dashboard-map';
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
|
||||||
import { projectService, projectSummaryService } from '@/services/api';
|
|
||||||
import { Project } from '@/types';
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from '@/components/ui/popover';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Reveal, StaggerContainer, StaggerItem } from '@/components/ui/reveal';
|
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
|
||||||
|
|
||||||
interface ProjectSummary {
|
|
||||||
project: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
corridor_name: string | null;
|
|
||||||
state: string | null;
|
|
||||||
};
|
|
||||||
packages: {
|
|
||||||
[key: string]: {
|
|
||||||
package_id: string;
|
|
||||||
region: string | null;
|
|
||||||
chainages: {
|
|
||||||
[key: string]: {
|
|
||||||
chainage_id: string;
|
|
||||||
chainage: string | null;
|
|
||||||
detection_count: number;
|
|
||||||
detections: Array<{
|
|
||||||
id: number;
|
|
||||||
type: string;
|
|
||||||
class: string;
|
|
||||||
confidence: number;
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DetectionStats {
|
|
||||||
totalDefectedSignboard: number;
|
|
||||||
totalPothole: number;
|
|
||||||
totalRoadCrack: number;
|
|
||||||
totalDamagedRoadMarking: number;
|
|
||||||
totalGoodSignboard: number;
|
|
||||||
totalDrainIssue: number;
|
|
||||||
totalDefectiveCulvert: number;
|
|
||||||
totalRoadDamage: number;
|
|
||||||
chainageData: Array<{
|
|
||||||
name: string;
|
|
||||||
defected_sign_board: number;
|
|
||||||
pothole: number;
|
|
||||||
road_crack: number;
|
|
||||||
damaged_road_marking: number;
|
|
||||||
// good_sign_board: number;
|
|
||||||
drain_issue: number;
|
|
||||||
defective_culvert: number;
|
|
||||||
total: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateStats(summary: ProjectSummary | null): DetectionStats {
|
|
||||||
if (!summary) {
|
|
||||||
return {
|
|
||||||
totalDefectedSignboard: 0,
|
|
||||||
totalPothole: 0,
|
|
||||||
totalRoadCrack: 0,
|
|
||||||
totalDamagedRoadMarking: 0,
|
|
||||||
totalGoodSignboard: 0,
|
|
||||||
totalDrainIssue: 0,
|
|
||||||
totalDefectiveCulvert: 0,
|
|
||||||
totalRoadDamage: 0,
|
|
||||||
chainageData: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let total_defected_sign_board = 0;
|
|
||||||
let total_pothole = 0;
|
|
||||||
let total_road_crack = 0;
|
|
||||||
let total_damaged_road_marking = 0;
|
|
||||||
let total_good_sign_board = 0;
|
|
||||||
let total_drain_issue = 0;
|
|
||||||
let total_defective_culvert = 0;
|
|
||||||
let total_road_damage = 0;
|
|
||||||
const chainageData: DetectionStats['chainageData'] = [];
|
|
||||||
|
|
||||||
for (const pkg of Object.values(summary.packages || {})) {
|
|
||||||
for (const [chnName, chn] of Object.entries(pkg.chainages || {})) {
|
|
||||||
let chnDefectedSignboard = 0;
|
|
||||||
let chnPothole = 0;
|
|
||||||
let chnRoadCrack = 0;
|
|
||||||
let chnDamagedRoadMarking = 0;
|
|
||||||
let chnGoodSignboard = 0;
|
|
||||||
let chnDrainIssue = 0;
|
|
||||||
let chnDefectiveCulvert = 0;
|
|
||||||
|
|
||||||
for (const detection of chn.detections || []) {
|
|
||||||
const type = (detection.type || '').toLowerCase();
|
|
||||||
if (type === 'defected_sign_board') {
|
|
||||||
chnDefectedSignboard++;
|
|
||||||
total_defected_sign_board++;
|
|
||||||
} else if (type === 'pothole') {
|
|
||||||
chnPothole++;
|
|
||||||
total_pothole++;
|
|
||||||
} else if (type === 'road_crack') {
|
|
||||||
chnRoadCrack++;
|
|
||||||
total_road_crack++;
|
|
||||||
} else if (type === 'damaged_road_marking') {
|
|
||||||
chnDamagedRoadMarking++;
|
|
||||||
total_damaged_road_marking++;
|
|
||||||
} else if (type === 'good_sign_board') {
|
|
||||||
chnGoodSignboard++;
|
|
||||||
// total_good_sign_board++;
|
|
||||||
} else if (type === 'drain_issue') {
|
|
||||||
chnDrainIssue++;
|
|
||||||
total_drain_issue++;
|
|
||||||
} else if (type === 'defective_culvert') {
|
|
||||||
chnDefectiveCulvert++;
|
|
||||||
total_defective_culvert++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
chnDefectedSignboard > 0 ||
|
|
||||||
chnPothole > 0 ||
|
|
||||||
chnRoadCrack > 0 ||
|
|
||||||
chnDamagedRoadMarking > 0 ||
|
|
||||||
chnGoodSignboard > 0 ||
|
|
||||||
chnDrainIssue > 0 ||
|
|
||||||
chnDefectiveCulvert > 0
|
|
||||||
) {
|
|
||||||
const shortName =
|
|
||||||
chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
|
|
||||||
chainageData.push({
|
|
||||||
name: shortName,
|
|
||||||
defected_sign_board: chnDefectedSignboard,
|
|
||||||
pothole: chnPothole,
|
|
||||||
road_crack: chnRoadCrack,
|
|
||||||
damaged_road_marking: chnDamagedRoadMarking,
|
|
||||||
// good_sign_board: chnGoodSignboard,
|
|
||||||
drain_issue: chnDrainIssue,
|
|
||||||
defective_culvert: chnDefectiveCulvert,
|
|
||||||
total:
|
|
||||||
chnDefectedSignboard +
|
|
||||||
chnPothole +
|
|
||||||
chnRoadCrack +
|
|
||||||
chnDamagedRoadMarking +
|
|
||||||
// chnGoodSignboard +
|
|
||||||
chnDrainIssue +
|
|
||||||
chnDefectiveCulvert,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
total_road_damage =
|
|
||||||
total_defected_sign_board +
|
|
||||||
total_pothole +
|
|
||||||
total_road_crack +
|
|
||||||
total_damaged_road_marking +
|
|
||||||
total_drain_issue +
|
|
||||||
total_defective_culvert;
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalDefectedSignboard: total_defected_sign_board,
|
|
||||||
totalPothole: total_pothole,
|
|
||||||
totalRoadCrack: total_road_crack,
|
|
||||||
totalDamagedRoadMarking: total_damaged_road_marking,
|
|
||||||
totalGoodSignboard: total_good_sign_board,
|
|
||||||
totalDrainIssue: total_drain_issue,
|
|
||||||
totalDefectiveCulvert: total_defective_culvert,
|
|
||||||
totalRoadDamage: total_road_damage,
|
|
||||||
chainageData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [projectSummary, setProjectSummary] = useState<ProjectSummary | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [stats, setStats] = useState<DetectionStats>({
|
|
||||||
totalDefectedSignboard: 0,
|
|
||||||
totalPothole: 0,
|
|
||||||
totalRoadCrack: 0,
|
|
||||||
totalDamagedRoadMarking: 0,
|
|
||||||
totalGoodSignboard: 0,
|
|
||||||
totalDrainIssue: 0,
|
|
||||||
totalDefectiveCulvert: 0,
|
|
||||||
totalRoadDamage: 0,
|
|
||||||
chainageData: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load projects on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const loadProjects = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const projectsData = await projectService.getProjects();
|
|
||||||
setProjects(projectsData.items);
|
|
||||||
|
|
||||||
if (projectsData.items.length > 0) {
|
|
||||||
setSelectedProjectId(
|
|
||||||
projectsData.items[projectsData.items.length - 1].id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load projects:', err);
|
|
||||||
toast.error('Failed to load projects', {
|
|
||||||
description: 'Please check if the backend is running.',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadProjects();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectedProject = projects.find((p) => p.id === selectedProjectId);
|
|
||||||
|
|
||||||
// Extract packages from project summary
|
|
||||||
const packages = projectSummary
|
|
||||||
? Object.keys(projectSummary.packages || {}).map((pkgName) => ({
|
|
||||||
id: pkgName,
|
|
||||||
name: pkgName,
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Extract chainages from selected package
|
|
||||||
const [selectedPackageId, setSelectedPackageId] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [selectedChainageId, setSelectedChainageId] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
); // Changed 'selectedLocationId' to 'selectedChainageId'
|
|
||||||
|
|
||||||
const chainages =
|
|
||||||
projectSummary && selectedPackageId && selectedPackageId !== 'all'
|
|
||||||
? Object.keys(
|
|
||||||
projectSummary.packages[selectedPackageId]?.chainages || {},
|
|
||||||
).map((chnName) => ({
|
|
||||||
id: chnName,
|
|
||||||
name: chnName,
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Load project summary when project changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedProjectId) {
|
|
||||||
setProjectSummary(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setProjectSummary(null);
|
|
||||||
|
|
||||||
const loadProjectSummary = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const summary =
|
|
||||||
await projectSummaryService.getProjectSummary<ProjectSummary>(
|
|
||||||
selectedProjectId,
|
|
||||||
);
|
|
||||||
setProjectSummary(summary);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load project summary:', err);
|
|
||||||
toast.error('Failed to load project summary');
|
|
||||||
setProjectSummary(null);
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadProjectSummary();
|
|
||||||
}, [selectedProjectId]);
|
|
||||||
|
|
||||||
// Reset package and chainage when project changes
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedPackageId(null);
|
|
||||||
setSelectedChainageId(null);
|
|
||||||
}, [selectedProjectId]);
|
|
||||||
|
|
||||||
// Auto-select last package when project summary is loaded
|
|
||||||
useEffect(() => {
|
|
||||||
if (projectSummary && selectedPackageId === null) {
|
|
||||||
const packageIds = Object.keys(projectSummary.packages || {});
|
|
||||||
if (packageIds.length > 0) {
|
|
||||||
setSelectedPackageId(packageIds[packageIds.length - 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [projectSummary, selectedPackageId]);
|
|
||||||
|
|
||||||
// Reset chainage when package changes
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedChainageId(null);
|
|
||||||
}, [selectedPackageId]);
|
|
||||||
|
|
||||||
// Auto-select last chainage when package is selected
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
projectSummary &&
|
|
||||||
selectedPackageId &&
|
|
||||||
selectedPackageId !== 'all' &&
|
|
||||||
selectedChainageId === null
|
|
||||||
) {
|
|
||||||
const chainageIds = Object.keys(
|
|
||||||
projectSummary.packages[selectedPackageId]?.chainages || {},
|
|
||||||
);
|
|
||||||
if (chainageIds.length > 0) {
|
|
||||||
setSelectedChainageId(chainageIds[chainageIds.length - 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [projectSummary, selectedPackageId, selectedChainageId]);
|
|
||||||
|
|
||||||
// Filter stats based on selections
|
|
||||||
useEffect(() => {
|
|
||||||
if (!projectSummary) {
|
|
||||||
setStats({
|
|
||||||
totalDefectedSignboard: 0,
|
|
||||||
totalPothole: 0,
|
|
||||||
totalRoadCrack: 0,
|
|
||||||
totalDamagedRoadMarking: 0,
|
|
||||||
totalGoodSignboard: 0,
|
|
||||||
totalDrainIssue: 0,
|
|
||||||
totalDefectiveCulvert: 0,
|
|
||||||
totalRoadDamage: 0,
|
|
||||||
chainageData: [], // Changed 'locationData' to 'chainageData'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalDefectedSignboard = 0;
|
|
||||||
let totalPothole = 0;
|
|
||||||
let totalRoadCrack = 0;
|
|
||||||
let totalDamagedRoadMarking = 0;
|
|
||||||
let totalGoodSignboard = 0;
|
|
||||||
let totalDrainIssue = 0;
|
|
||||||
let totalDefectiveCulvert = 0;
|
|
||||||
const chainageData: DetectionStats['chainageData'] = [];
|
|
||||||
|
|
||||||
const packagesToProcess =
|
|
||||||
selectedPackageId && selectedPackageId !== 'all'
|
|
||||||
? { [selectedPackageId]: projectSummary.packages[selectedPackageId] }
|
|
||||||
: projectSummary.packages || {};
|
|
||||||
|
|
||||||
for (const [pkgName, pkg] of Object.entries(packagesToProcess)) {
|
|
||||||
if (!pkg) continue;
|
|
||||||
|
|
||||||
const chainagesToProcess =
|
|
||||||
selectedChainageId && selectedChainageId !== 'all'
|
|
||||||
? { [selectedChainageId]: pkg.chainages?.[selectedChainageId] }
|
|
||||||
: pkg.chainages || {};
|
|
||||||
|
|
||||||
for (const [chnName, chn] of Object.entries(chainagesToProcess)) {
|
|
||||||
if (!chn) continue;
|
|
||||||
|
|
||||||
let chnDefectedSignboard = 0;
|
|
||||||
let chnPothole = 0;
|
|
||||||
let chnRoadCrack = 0;
|
|
||||||
let chnDamagedRoadMarking = 0;
|
|
||||||
let chnGoodSignboard = 0;
|
|
||||||
let chnDrainIssue = 0;
|
|
||||||
let chnDefectiveCulvert = 0;
|
|
||||||
|
|
||||||
for (const detection of chn.detections || []) {
|
|
||||||
const type = (detection.type || '').toLowerCase();
|
|
||||||
if (type === 'defected_sign_board') {
|
|
||||||
chnDefectedSignboard++;
|
|
||||||
totalDefectedSignboard++;
|
|
||||||
} else if (type === 'pothole') {
|
|
||||||
chnPothole++;
|
|
||||||
totalPothole++;
|
|
||||||
} else if (type === 'road_crack') {
|
|
||||||
chnRoadCrack++;
|
|
||||||
totalRoadCrack++;
|
|
||||||
} else if (type === 'damaged_road_marking') {
|
|
||||||
chnDamagedRoadMarking++;
|
|
||||||
totalDamagedRoadMarking++;
|
|
||||||
} else if (type === 'good_sign_board') {
|
|
||||||
// chnGoodSignboard++;
|
|
||||||
// totalGoodSignboard++;
|
|
||||||
} else if (type === 'drain_issue') {
|
|
||||||
chnDrainIssue++;
|
|
||||||
totalDrainIssue++;
|
|
||||||
} else if (type === 'defective_culvert') {
|
|
||||||
chnDefectiveCulvert++;
|
|
||||||
totalDefectiveCulvert++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
chnDefectedSignboard > 0 ||
|
|
||||||
chnPothole > 0 ||
|
|
||||||
chnRoadCrack > 0 ||
|
|
||||||
chnDamagedRoadMarking > 0 ||
|
|
||||||
chnGoodSignboard > 0 ||
|
|
||||||
chnDrainIssue > 0 ||
|
|
||||||
chnDefectiveCulvert > 0
|
|
||||||
) {
|
|
||||||
const shortName =
|
|
||||||
chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
|
|
||||||
chainageData.push({
|
|
||||||
name: shortName,
|
|
||||||
defected_sign_board: chnDefectedSignboard,
|
|
||||||
pothole: chnPothole,
|
|
||||||
road_crack: chnRoadCrack,
|
|
||||||
damaged_road_marking: chnDamagedRoadMarking,
|
|
||||||
// good_sign_board: chnGoodSignboard,
|
|
||||||
drain_issue: chnDrainIssue,
|
|
||||||
defective_culvert: chnDefectiveCulvert,
|
|
||||||
total:
|
|
||||||
chnDefectedSignboard +
|
|
||||||
chnPothole +
|
|
||||||
chnRoadCrack +
|
|
||||||
chnDamagedRoadMarking +
|
|
||||||
// chnGoodSignboard +
|
|
||||||
chnDrainIssue +
|
|
||||||
chnDefectiveCulvert,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setStats({
|
|
||||||
totalDefectedSignboard,
|
|
||||||
totalPothole,
|
|
||||||
totalRoadCrack,
|
|
||||||
totalDamagedRoadMarking,
|
|
||||||
totalGoodSignboard,
|
|
||||||
totalDrainIssue,
|
|
||||||
totalDefectiveCulvert,
|
|
||||||
totalRoadDamage:
|
|
||||||
totalDefectedSignboard +
|
|
||||||
totalPothole +
|
|
||||||
totalRoadCrack +
|
|
||||||
totalDamagedRoadMarking +
|
|
||||||
totalDrainIssue +
|
|
||||||
totalDefectiveCulvert,
|
|
||||||
chainageData,
|
|
||||||
});
|
|
||||||
}, [projectSummary, selectedPackageId, selectedChainageId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Main Content */}
|
|
||||||
<main>
|
|
||||||
<div>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-8 flex items-center justify-between">
|
|
||||||
<PageHeader
|
|
||||||
title="Dashboard"
|
|
||||||
description="A Comprehensive Overview Of Your Road Infrastructure Analysis"
|
|
||||||
icon={BarChart3}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Reveal direction="down" delay={0.2}>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" className="gap-2 font-semibold">
|
|
||||||
<Settings2 className="h-4 w-4" />
|
|
||||||
<span>Filters</span>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="w-[320px] p-0 overflow-hidden"
|
|
||||||
align="end"
|
|
||||||
>
|
|
||||||
<div className="p-4 border-b bg-muted/30">
|
|
||||||
<h3 className="font-bold text-sm flex items-center gap-2 text-foreground">
|
|
||||||
<Milestone className="h-4 w-4 text-primary" />
|
|
||||||
Filter Analysis
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs mt-1 text-muted-foreground">
|
|
||||||
Refine your view by project, package, or segment
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FilterSelector
|
|
||||||
projects={projects}
|
|
||||||
selectedProjectId={selectedProjectId}
|
|
||||||
selectedPackageId={selectedPackageId}
|
|
||||||
selectedChainageId={selectedChainageId} // Changed 'selectedLocationId' to 'selectedChainageId'
|
|
||||||
onProjectChange={setSelectedProjectId}
|
|
||||||
onPackageChange={setSelectedPackageId}
|
|
||||||
onChainageChange={setSelectedChainageId} // Changed 'onLocationChange' to 'onChainageChange'
|
|
||||||
packages={packages}
|
|
||||||
chainages={chainages} // Changed 'locations' to 'chainages'
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</Reveal>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading && !projectSummary ? (
|
|
||||||
<DashboardSkeleton />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Stats Cards - Top Row */}
|
|
||||||
<StaggerContainer className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
|
||||||
<StaggerItem>
|
|
||||||
<StatsCard
|
|
||||||
title="Total Road Damage"
|
|
||||||
subtitle="Combined damage detections"
|
|
||||||
value={stats.totalRoadDamage}
|
|
||||||
icon={Activity}
|
|
||||||
/>
|
|
||||||
</StaggerItem>
|
|
||||||
<StaggerItem>
|
|
||||||
<StatsCard
|
|
||||||
title="Potholes"
|
|
||||||
subtitle="Surface depressions"
|
|
||||||
value={stats.totalPothole}
|
|
||||||
icon={AlertCircle}
|
|
||||||
/>
|
|
||||||
</StaggerItem>
|
|
||||||
<StaggerItem>
|
|
||||||
<StatsCard
|
|
||||||
title="Defected Signboards"
|
|
||||||
subtitle="Damaged traffic signs"
|
|
||||||
value={stats.totalDefectedSignboard}
|
|
||||||
icon={AlertTriangle}
|
|
||||||
/>
|
|
||||||
</StaggerItem>
|
|
||||||
<StaggerItem>
|
|
||||||
<StatsCard
|
|
||||||
title="Road Cracks"
|
|
||||||
subtitle="Surface fissures"
|
|
||||||
value={stats.totalRoadCrack}
|
|
||||||
icon={Zap}
|
|
||||||
/>
|
|
||||||
</StaggerItem>
|
|
||||||
<StaggerItem>
|
|
||||||
<StatsCard
|
|
||||||
title="Damaged Markings"
|
|
||||||
subtitle="Worn road lines"
|
|
||||||
value={stats.totalDamagedRoadMarking}
|
|
||||||
icon={PencilLine}
|
|
||||||
/>
|
|
||||||
</StaggerItem>
|
|
||||||
<StaggerItem>
|
|
||||||
<StatsCard
|
|
||||||
title="Drain Issues"
|
|
||||||
subtitle="Clogged or broken drainage"
|
|
||||||
value={stats.totalDrainIssue}
|
|
||||||
icon={Droplets}
|
|
||||||
/>
|
|
||||||
</StaggerItem>
|
|
||||||
{/* <StaggerItem>
|
|
||||||
<StatsCard
|
|
||||||
title="Good Signboards"
|
|
||||||
subtitle="Informational markers"
|
|
||||||
value={stats.totalGoodSignboard}
|
|
||||||
icon={CheckCircle2}
|
|
||||||
/>
|
|
||||||
</StaggerItem> */}
|
|
||||||
</StaggerContainer>
|
|
||||||
|
|
||||||
{/* Charts Row - Side by Side */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
|
||||||
<Reveal delay={0.2} direction="left" className="flex flex-col">
|
|
||||||
<Card className="flex-1 h-full">
|
|
||||||
<CardHeader className="items-center pb-0">
|
|
||||||
<CardTitle>Detection Distribution</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Breakdown of all detected road conditions
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex-1 pb-0">
|
|
||||||
<DetectionDonutChart
|
|
||||||
defectedSignboard={stats.totalDefectedSignboard}
|
|
||||||
pothole={stats.totalPothole}
|
|
||||||
roadCrack={stats.totalRoadCrack}
|
|
||||||
damagedRoadMarking={stats.totalDamagedRoadMarking}
|
|
||||||
// goodSignboard={stats.totalGoodSignboard}
|
|
||||||
drainIssue={stats.totalDrainIssue}
|
|
||||||
defectiveCulvert={stats.totalDefectiveCulvert}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex-col gap-2 text-sm">
|
|
||||||
<div className="leading-none text-muted-foreground">
|
|
||||||
Total detections:{' '}
|
|
||||||
{stats.totalRoadDamage +
|
|
||||||
// stats.totalGoodSignboard +
|
|
||||||
stats.totalDrainIssue +
|
|
||||||
stats.totalDefectiveCulvert}
|
|
||||||
</div>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
<Reveal delay={0.3} direction="right" className="flex flex-col">
|
|
||||||
<Card className="flex-1 h-full">
|
|
||||||
<CardHeader className="items-center pb-0">
|
|
||||||
<CardTitle className="text-base font-bold">
|
|
||||||
Severity by Segment
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
Detections grouped by road segment
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="h-[300px]">
|
|
||||||
<ChainageBarChart
|
|
||||||
data={stats.chainageData || []}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex-col gap-2 text-sm">
|
|
||||||
<div className="leading-none text-muted-foreground">
|
|
||||||
Analysis based on latest processed sequence
|
|
||||||
</div>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</Reveal>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Map - Full Width Below Charts */}
|
|
||||||
<Reveal delay={0.4} direction="up">
|
|
||||||
<div>
|
|
||||||
<DashboardMap
|
|
||||||
selectedProjectId={selectedProjectId}
|
|
||||||
selectedPackageId={selectedPackageId}
|
|
||||||
selectedChainageId={selectedChainageId} // Changed 'selectedLocationId' to 'selectedChainageId'
|
|
||||||
projectSummary={projectSummary}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
|
import { useRef } from 'react';
|
||||||
import type { FieldErrors, UseFormRegister } 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';
|
||||||
|
import { ProjectSelect } from '@/components/lookups/ProjectSelect';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -15,14 +17,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import type { Project } from '@/types';
|
|
||||||
|
|
||||||
import type { PackageFormValues } from '../hooks/usePackageForm';
|
import type { PackageFormValues } from '../hooks/usePackageForm';
|
||||||
|
|
||||||
@@ -30,8 +24,6 @@ interface PackageDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
packageId?: string;
|
packageId?: string;
|
||||||
projects: Project[];
|
|
||||||
isProjectsLoading: boolean;
|
|
||||||
projectId: string;
|
projectId: string;
|
||||||
onProjectChange: (projectId: string) => void;
|
onProjectChange: (projectId: string) => void;
|
||||||
register: UseFormRegister<PackageFormValues>;
|
register: UseFormRegister<PackageFormValues>;
|
||||||
@@ -46,8 +38,6 @@ export function PackageDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
packageId,
|
packageId,
|
||||||
projects,
|
|
||||||
isProjectsLoading,
|
|
||||||
projectId,
|
projectId,
|
||||||
onProjectChange,
|
onProjectChange,
|
||||||
register,
|
register,
|
||||||
@@ -57,6 +47,7 @@ export function PackageDialog({
|
|||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: PackageDialogProps) {
|
}: PackageDialogProps) {
|
||||||
|
const projectSelectPortalRef = useRef<HTMLDivElement | null>(null);
|
||||||
const projectErrorMessage = isSubmitted
|
const projectErrorMessage = isSubmitted
|
||||||
? errors.project_id?.message
|
? errors.project_id?.message
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -83,7 +74,11 @@ export function PackageDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
<form
|
||||||
|
noValidate
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
|
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -95,25 +90,13 @@ export function PackageDialog({
|
|||||||
|
|
||||||
{!packageId ? (
|
{!packageId ? (
|
||||||
<FormField label="Project" required error={projectErrorMessage}>
|
<FormField label="Project" required error={projectErrorMessage}>
|
||||||
<Select value={projectId} onValueChange={onProjectChange}>
|
<ProjectSelect
|
||||||
<SelectTrigger aria-invalid={!!projectErrorMessage}>
|
value={projectId}
|
||||||
{isProjectsLoading ? (
|
onValueChange={onProjectChange}
|
||||||
<span className="flex items-center gap-2 text-muted-foreground">
|
enabled={open}
|
||||||
<Loader2 className="size-4 animate-spin" />
|
disabled={isSaving}
|
||||||
Loading...
|
portalContainer={projectSelectPortalRef}
|
||||||
</span>
|
/>
|
||||||
) : (
|
|
||||||
<SelectValue placeholder="Choose a project" />
|
|
||||||
)}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{projects.map((project) => (
|
|
||||||
<SelectItem key={project.id} value={project.id}>
|
|
||||||
{project.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
{...register('project_id')}
|
{...register('project_id')}
|
||||||
@@ -141,7 +124,7 @@ export function PackageDialog({
|
|||||||
<FormField id="package-region" label="Region">
|
<FormField id="package-region" label="Region">
|
||||||
<Input
|
<Input
|
||||||
id="package-region"
|
id="package-region"
|
||||||
placeholder="North Zone"
|
placeholder="Enter region"
|
||||||
{...register('region')}
|
{...register('region')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -152,21 +135,22 @@ export function PackageDialog({
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-semibold">Chainage range</h3>
|
<h3 className="text-sm font-semibold">Chainage range</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Optional start and end kilometre values.
|
start and end kilometre values.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
id="package-start"
|
id="package-start"
|
||||||
label="Start (km)"
|
label="Start Chainage"
|
||||||
error={startErrorMessage}
|
error={startErrorMessage}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="package-start"
|
id="package-start"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="1"
|
||||||
placeholder="0.00"
|
min="0"
|
||||||
|
placeholder="Enter start chainage"
|
||||||
aria-invalid={!!startErrorMessage}
|
aria-invalid={!!startErrorMessage}
|
||||||
{...register('chainage_start_km')}
|
{...register('chainage_start_km')}
|
||||||
/>
|
/>
|
||||||
@@ -174,14 +158,15 @@ export function PackageDialog({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
id="package-end"
|
id="package-end"
|
||||||
label="End (km)"
|
label="End Chainage"
|
||||||
error={endErrorMessage}
|
error={endErrorMessage}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="package-end"
|
id="package-end"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="1"
|
||||||
placeholder="0.00"
|
min="0"
|
||||||
|
placeholder="Enter end chainage"
|
||||||
aria-invalid={!!endErrorMessage}
|
aria-invalid={!!endErrorMessage}
|
||||||
{...register('chainage_end_km')}
|
{...register('chainage_end_km')}
|
||||||
/>
|
/>
|
||||||
@@ -200,11 +185,15 @@ export function PackageDialog({
|
|||||||
Cancel
|
Cancel
|
||||||
</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}
|
||||||
{packageId ? 'Update Package' : 'Create Package'}
|
{packageId ? 'Update Package' : 'Create Package'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div ref={projectSelectPortalRef} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,29 +7,25 @@ import { useForm, useWatch } from 'react-hook-form';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
optionalIntegerNumber,
|
||||||
|
validateChainageRange,
|
||||||
|
} from '@/lib/validation/numberField';
|
||||||
import { packageService } from '@/services/api';
|
import { packageService } from '@/services/api';
|
||||||
import type { Package, PackageCreate, PackageUpdate } from '@/types';
|
import type { Package, PackageCreate, PackageUpdate } from '@/types';
|
||||||
|
|
||||||
import { packageKeys } from '../queries/packageKeys';
|
import { packageKeys } from '../queries/packageKeys';
|
||||||
|
|
||||||
const optionalNumber = z.string().refine(
|
const packageFormSchema = z
|
||||||
(value) => {
|
.object({
|
||||||
if (!value.trim()) {
|
id: z.string().optional(),
|
||||||
return true;
|
project_id: z.string().trim().min(1, 'Project is required'),
|
||||||
}
|
name: z.string().trim().min(1, 'Package name is required'),
|
||||||
return Number.isFinite(Number(value));
|
region: z.string().optional(),
|
||||||
},
|
chainage_start_km: optionalIntegerNumber,
|
||||||
{ message: 'Enter a valid number' },
|
chainage_end_km: optionalIntegerNumber,
|
||||||
);
|
})
|
||||||
|
.superRefine(validateChainageRange);
|
||||||
const packageFormSchema = z.object({
|
|
||||||
id: z.string().optional(),
|
|
||||||
project_id: z.string().trim().min(1, 'Project is required'),
|
|
||||||
name: z.string().trim().min(1, 'Package name is required'),
|
|
||||||
region: z.string().optional(),
|
|
||||||
chainage_start_km: optionalNumber,
|
|
||||||
chainage_end_km: optionalNumber,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type PackageFormValues = z.infer<typeof packageFormSchema>;
|
export type PackageFormValues = z.infer<typeof packageFormSchema>;
|
||||||
|
|
||||||
@@ -103,18 +99,7 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = handleSubmit(
|
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
|
||||||
(values) => saveMutation.mutate(values),
|
|
||||||
(formErrors) => {
|
|
||||||
if (formErrors.project_id?.message) {
|
|
||||||
toast.error(formErrors.project_id.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (formErrors.name?.message) {
|
|
||||||
toast.error(formErrors.name.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { toast } from 'sonner';
|
|||||||
import { packageService, projectService } from '@/services/api';
|
import { packageService, projectService } from '@/services/api';
|
||||||
import type { Package, PaginationParams } from '@/types';
|
import type { Package, PaginationParams } from '@/types';
|
||||||
|
|
||||||
|
import { projectKeys } from '../../project/queries/projectKeys';
|
||||||
import { packageKeys } from '../queries/packageKeys';
|
import { packageKeys } from '../queries/packageKeys';
|
||||||
|
|
||||||
interface UsePackagesQueryParams {
|
interface UsePackagesQueryParams {
|
||||||
@@ -28,7 +29,7 @@ export function usePackagesQuery({ skip, limit }: UsePackagesQueryParams) {
|
|||||||
|
|
||||||
export function useProjectOptionsQuery() {
|
export function useProjectOptionsQuery() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['projects', 'options'],
|
queryKey: projectKeys.options(),
|
||||||
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
|
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/app/(modules)/package/layout.tsx
Normal file
16
src/app/(modules)/package/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function PackageLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.PACKAGE.READ}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -113,8 +113,6 @@ export default function PackagePage() {
|
|||||||
open={isDialogOpen}
|
open={isDialogOpen}
|
||||||
onOpenChange={handleDialogOpenChange}
|
onOpenChange={handleDialogOpenChange}
|
||||||
packageId={packageId}
|
packageId={packageId}
|
||||||
projects={projects}
|
|
||||||
isProjectsLoading={projectsQuery.isLoading}
|
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onProjectChange={setProjectId}
|
onProjectChange={setProjectId}
|
||||||
register={register}
|
register={register}
|
||||||
|
|||||||
@@ -4,4 +4,7 @@ export const packageKeys = {
|
|||||||
all: ['packages'] as const,
|
all: ['packages'] as const,
|
||||||
lists: () => [...packageKeys.all, 'list'] as const,
|
lists: () => [...packageKeys.all, 'list'] as const,
|
||||||
list: (params: PaginationParams) => [...packageKeys.lists(), params] as const,
|
list: (params: PaginationParams) => [...packageKeys.lists(), params] as const,
|
||||||
|
options: () => [...packageKeys.all, 'options'] as const,
|
||||||
|
details: () => [...packageKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...packageKeys.details(), id] as const,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { redirect } from 'next/navigation';
|
'use client';
|
||||||
import { ROUTES } from '@/utils/routes';
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { AppLoader } from '@/components/AppLoader';
|
||||||
|
import { useAppStore } from '@/store/app.store';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
redirect(ROUTES.DASHBOARD);
|
const router = useRouter();
|
||||||
|
const defaultRoute = useAppStore((state) => state.defaultRoute);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
router.replace(defaultRoute ?? '/access');
|
||||||
|
}, [defaultRoute, router]);
|
||||||
|
|
||||||
|
return <AppLoader label="Opening workspace" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function PlanSheet({
|
|||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
<form noValidate onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
import type { PermissionTreeItem, Plan } from '@/types';
|
import type { PermissionTreeItem, Plan } from '@/types';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useSavePlanMutation } from './usePlanMutations';
|
import { useSavePlanMutation } from './usePlanMutations';
|
||||||
@@ -104,31 +103,20 @@ export function usePlanForm({
|
|||||||
price.trim().length > 0 &&
|
price.trim().length > 0 &&
|
||||||
permissionIds.length > 0;
|
permissionIds.length > 0;
|
||||||
|
|
||||||
const onSubmit = handleSubmit(
|
const onSubmit = handleSubmit((values) => {
|
||||||
(values) => {
|
saveMutation.mutate({
|
||||||
saveMutation.mutate({
|
...values,
|
||||||
...values,
|
name: values.name.trim(),
|
||||||
name: values.name.trim(),
|
slug: values.slug.trim(),
|
||||||
slug: values.slug.trim(),
|
description: values.description.trim(),
|
||||||
description: values.description.trim(),
|
price: values.price.trim(),
|
||||||
price: values.price.trim(),
|
trial_days: toNumber(values.trial_days),
|
||||||
trial_days: toNumber(values.trial_days),
|
max_projects: toNumber(values.max_projects),
|
||||||
max_projects: toNumber(values.max_projects),
|
max_organizations: toNumber(values.max_organizations),
|
||||||
max_organizations: toNumber(values.max_organizations),
|
max_users: toNumber(values.max_users),
|
||||||
max_users: toNumber(values.max_users),
|
max_roles: toNumber(values.max_roles),
|
||||||
max_roles: toNumber(values.max_roles),
|
});
|
||||||
});
|
});
|
||||||
},
|
|
||||||
(formErrors) => {
|
|
||||||
const firstMessage = Object.values(formErrors).find(
|
|
||||||
(error) => error?.message,
|
|
||||||
)?.message;
|
|
||||||
|
|
||||||
if (firstMessage) {
|
|
||||||
toast.error(String(firstMessage));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
reset({
|
reset({
|
||||||
|
|||||||
16
src/app/(modules)/plans/layout.tsx
Normal file
16
src/app/(modules)/plans/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function PlansLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.PLAN.READ}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -66,7 +66,11 @@ export function ProjectDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
<form
|
||||||
|
noValidate
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
|
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -94,14 +98,14 @@ export function ProjectDialog({
|
|||||||
<FormField id="project-state" label="State">
|
<FormField id="project-state" label="State">
|
||||||
<Input
|
<Input
|
||||||
id="project-state"
|
id="project-state"
|
||||||
placeholder="Maharashtra"
|
placeholder="Enter state"
|
||||||
{...register('state')}
|
{...register('state')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField id="project-corridor" label="Corridor Name">
|
<FormField id="project-corridor" label="Corridor Name">
|
||||||
<Input
|
<Input
|
||||||
id="project-corridor"
|
id="project-corridor"
|
||||||
placeholder="Mumbai-Goa Highway"
|
placeholder="Enter corridor name"
|
||||||
{...register('corridor_name')}
|
{...register('corridor_name')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -127,7 +131,9 @@ export function ProjectDialog({
|
|||||||
id="project-start-lat"
|
id="project-start-lat"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="0.0000"
|
min="-90"
|
||||||
|
max="90"
|
||||||
|
placeholder="Enter start latitude"
|
||||||
aria-invalid={!!startLatErrorMessage}
|
aria-invalid={!!startLatErrorMessage}
|
||||||
{...register('start_lat')}
|
{...register('start_lat')}
|
||||||
/>
|
/>
|
||||||
@@ -141,7 +147,9 @@ export function ProjectDialog({
|
|||||||
id="project-start-lng"
|
id="project-start-lng"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="0.0000"
|
min="-180"
|
||||||
|
max="180"
|
||||||
|
placeholder="Enter start longitude"
|
||||||
aria-invalid={!!startLngErrorMessage}
|
aria-invalid={!!startLngErrorMessage}
|
||||||
{...register('start_lng')}
|
{...register('start_lng')}
|
||||||
/>
|
/>
|
||||||
@@ -158,7 +166,9 @@ export function ProjectDialog({
|
|||||||
id="project-end-lat"
|
id="project-end-lat"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="0.0000"
|
min="-90"
|
||||||
|
max="90"
|
||||||
|
placeholder="Enter end latitude"
|
||||||
aria-invalid={!!endLatErrorMessage}
|
aria-invalid={!!endLatErrorMessage}
|
||||||
{...register('end_lat')}
|
{...register('end_lat')}
|
||||||
/>
|
/>
|
||||||
@@ -172,7 +182,9 @@ export function ProjectDialog({
|
|||||||
id="project-end-lng"
|
id="project-end-lng"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="0.0000"
|
min="-180"
|
||||||
|
max="180"
|
||||||
|
placeholder="Enter end longitude"
|
||||||
aria-invalid={!!endLngErrorMessage}
|
aria-invalid={!!endLngErrorMessage}
|
||||||
{...register('end_lng')}
|
{...register('end_lng')}
|
||||||
/>
|
/>
|
||||||
@@ -192,7 +204,9 @@ export function ProjectDialog({
|
|||||||
Cancel
|
Cancel
|
||||||
</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}
|
||||||
{projectId ? 'Update Project' : 'Create Project'}
|
{projectId ? 'Update Project' : 'Create Project'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -7,30 +7,24 @@ import { useForm, useWatch } from 'react-hook-form';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
optionalLatitude,
|
||||||
|
optionalLongitude,
|
||||||
|
} from '@/lib/validation/numberField';
|
||||||
import { projectService } from '@/services/api';
|
import { projectService } from '@/services/api';
|
||||||
import type { Project, ProjectCreate, ProjectUpdate } from '@/types';
|
import type { Project, ProjectCreate, ProjectUpdate } from '@/types';
|
||||||
|
|
||||||
import { projectKeys } from '../queries/projectKeys';
|
import { projectKeys } from '../queries/projectKeys';
|
||||||
|
|
||||||
const optionalCoordinate = z.string().refine(
|
|
||||||
(value) => {
|
|
||||||
if (!value.trim()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return Number.isFinite(Number(value));
|
|
||||||
},
|
|
||||||
{ message: 'Enter a valid number' },
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectFormSchema = z.object({
|
const projectFormSchema = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
name: z.string().trim().min(1, 'Project name is required'),
|
name: z.string().trim().min(1, 'Project name is required'),
|
||||||
state: z.string().optional(),
|
state: z.string().optional(),
|
||||||
corridor_name: z.string().optional(),
|
corridor_name: z.string().optional(),
|
||||||
start_lat: optionalCoordinate,
|
start_lat: optionalLatitude,
|
||||||
start_lng: optionalCoordinate,
|
start_lng: optionalLongitude,
|
||||||
end_lat: optionalCoordinate,
|
end_lat: optionalLatitude,
|
||||||
end_lng: optionalCoordinate,
|
end_lng: optionalLongitude,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ProjectFormValues = z.infer<typeof projectFormSchema>;
|
export type ProjectFormValues = z.infer<typeof projectFormSchema>;
|
||||||
@@ -106,14 +100,7 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = handleSubmit(
|
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
|
||||||
(values) => saveMutation.mutate(values),
|
|
||||||
(formErrors) => {
|
|
||||||
if (formErrors.name?.message) {
|
|
||||||
toast.error(formErrors.name.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
|
|||||||
16
src/app/(modules)/project/layout.tsx
Normal file
16
src/app/(modules)/project/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function ProjectLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.PROJECT.READ}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,4 +4,7 @@ export const projectKeys = {
|
|||||||
all: ['projects'] as const,
|
all: ['projects'] as const,
|
||||||
lists: () => [...projectKeys.all, 'list'] as const,
|
lists: () => [...projectKeys.all, 'list'] as const,
|
||||||
list: (params: PaginationParams) => [...projectKeys.lists(), params] as const,
|
list: (params: PaginationParams) => [...projectKeys.lists(), params] as const,
|
||||||
|
options: () => [...projectKeys.all, 'options'] as const,
|
||||||
|
details: () => [...projectKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...projectKeys.details(), id] as const,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
function StatSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center rounded-lg border border-muted bg-muted/30 p-4 text-center">
|
||||||
|
<Skeleton className="mb-2 size-9 rounded-md" />
|
||||||
|
<Skeleton className="h-6 w-12" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetectionLogSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-card p-4">
|
||||||
|
<div className="mb-3 flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-36" />
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-12" />
|
||||||
|
<Skeleton className="h-3 w-10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
<Skeleton className="h-3 w-44" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoResultsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<Skeleton className="size-10 rounded-md" />
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<Skeleton className="h-7 w-60 max-w-full" />
|
||||||
|
<Skeleton className="h-3 w-80 max-w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="overflow-hidden rounded-xl border bg-card">
|
||||||
|
<div className="border-b p-6 pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="size-10 rounded" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-5 w-44" />
|
||||||
|
<Skeleton className="h-3 w-64" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
<Skeleton className="aspect-video w-full rounded-lg border" />
|
||||||
|
|
||||||
|
<section className="overflow-hidden rounded-lg border bg-card/80">
|
||||||
|
<div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
<Skeleton className="h-3 w-72 max-w-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2 p-3 sm:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="h-16 w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton className="h-5 w-36" />
|
||||||
|
</div>
|
||||||
|
<div className="h-107.5 rounded-lg border bg-muted/20 p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<DetectionLogSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-2 gap-3 md:grid-cols-5">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<StatSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,200 +1,57 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { ArrowLeft, TrendingUp } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import VideoPlayerSection from '@/components/videoPlayerSection';
|
||||||
import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react';
|
|
||||||
import VideoPlayerSection from '@/components/video-player-section';
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { sessionService, videoService } from '@/services/api';
|
import { Button } from '@/components/ui/button';
|
||||||
import { SessionContext, DetectionData, DetectionType } from '@/types';
|
import { useVideoResultsQuery } from '../hooks/useVideoResults';
|
||||||
import { getVideoFile, clearVideoFile } from '@/lib/video-storage';
|
import { VideoResultsSkeleton } from './components/VideoResultsSkeleton';
|
||||||
import { ROUTES } from '@/utils/routes';
|
|
||||||
import { Card } from '@/components/ui/card';
|
|
||||||
import { getDetectionModeConfig } from '@/constants/detectionModeConfig';
|
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
|
||||||
|
|
||||||
export default function VideoResultsPage() {
|
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 [detectionData, setDetectionData] = useState<DetectionData | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [detectionType, setDetectionType] =
|
|
||||||
useState<DetectionType>('pothole-detection');
|
|
||||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const {
|
||||||
const storedSession = sessionService.loadSession();
|
data: detectionData,
|
||||||
setSession(storedSession);
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error: queryError,
|
||||||
|
} = useVideoResultsQuery(videoId);
|
||||||
|
|
||||||
const fetchResults = async () => {
|
const error = isError
|
||||||
try {
|
? queryError instanceof Error
|
||||||
// Fetch detection data from backend
|
? queryError.message
|
||||||
const data = await videoService.getVideoResults(videoId);
|
: 'Failed to load results'
|
||||||
setDetectionData(data as any);
|
: null;
|
||||||
|
|
||||||
// Try to infer detection type from results if possible
|
|
||||||
if (data.detection_mode) {
|
|
||||||
setDetectionType(data.detection_mode);
|
|
||||||
} else if (
|
|
||||||
data.summary?.unique_signboards !== undefined &&
|
|
||||||
data.summary?.unique_signboards > 0
|
|
||||||
) {
|
|
||||||
setDetectionType('sign-board-detection');
|
|
||||||
} else if (
|
|
||||||
data.summary?.unique_potholes !== undefined &&
|
|
||||||
data.summary?.unique_potholes > 0
|
|
||||||
) {
|
|
||||||
setDetectionType('pothole-detection');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve video file from IndexedDB
|
|
||||||
const storedVideoFile = await getVideoFile(videoId);
|
|
||||||
if (storedVideoFile) {
|
|
||||||
setVideoFile(storedVideoFile);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load results');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (videoId) {
|
|
||||||
fetchResults();
|
|
||||||
}
|
|
||||||
}, [videoId]);
|
|
||||||
|
|
||||||
const handleNewAnalysis = async () => {
|
|
||||||
if (videoId) {
|
|
||||||
try {
|
|
||||||
await clearVideoFile(videoId);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to clear video file:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sessionService.clearSession();
|
|
||||||
router.push(ROUTES.UPLOAD);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTitle = () => {
|
|
||||||
const config = getDetectionModeConfig(detectionType);
|
|
||||||
return `${config.label} Results`;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <VideoResultsSkeleton />;
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<Card className="flex flex-col items-center gap-4 p-8">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Loading detection results...
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
<Card className="flex flex-col items-center gap-6 p-8 text-center max-w-md">
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
<p className="text-destructive font-medium">{error}</p>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<Button
|
|
||||||
onClick={() => router.push(ROUTES.UPLOAD)}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
Back to Upload
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleNewAnalysis}>New Analysis</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="space-y-8">
|
||||||
<main className="min-h-screen">
|
<PageHeader
|
||||||
<div className="container mx-auto px-6 py-10 max-w-[1600px]">
|
title="Detection Results"
|
||||||
<div className="mb-8">
|
description={`Video ID: ${videoId}`}
|
||||||
<PageHeader
|
icon={TrendingUp}
|
||||||
title={getTitle()}
|
actions={
|
||||||
description={`Video ID: ${videoId}`}
|
<Button variant="outline" size="sm" onClick={() => router.back()}>
|
||||||
icon={TrendingUp}
|
<ArrowLeft />
|
||||||
/>
|
Back
|
||||||
</div>
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{session && (
|
{detectionData && <VideoPlayerSection data={detectionData} />}
|
||||||
<div className="mb-6">
|
|
||||||
<Card className="p-0 border shadow-sm overflow-hidden">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between py-4 px-6 gap-6 bg-card">
|
|
||||||
<div className="flex flex-wrap items-center gap-x-12 gap-y-4">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Project
|
|
||||||
</span>
|
|
||||||
<span className="text-base font-bold leading-tight">
|
|
||||||
{session.projectName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col border-l pl-12 border-border/60">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Package
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
|
||||||
{session.packageName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col border-l pl-12 border-border/60">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Segment
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-muted-foreground leading-tight flex items-center gap-1.5">
|
|
||||||
{session.chainageName}
|
|
||||||
{session.chainageDirection && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-1 py-0.5 rounded bg-muted text-[10px] font-bold uppercase border">
|
|
||||||
{session.chainageDirection === 'UP' ? (
|
|
||||||
<ArrowUp className="h-2.5 w-2.5" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown className="h-2.5 w-2.5" />
|
|
||||||
)}
|
|
||||||
{session.chainageDirection}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleNewAnalysis}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="font-semibold px-6 shrink-0 h-9"
|
|
||||||
>
|
|
||||||
Start New Analysis
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detectionData && (
|
|
||||||
<VideoPlayerSection
|
|
||||||
data={detectionData}
|
|
||||||
videoId={videoId}
|
|
||||||
videoFile={videoFile}
|
|
||||||
detectionType={detectionType}
|
|
||||||
projectId={session?.projectId || undefined}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
61
src/app/(modules)/results/hooks/useVideoResults.ts
Normal file
61
src/app/(modules)/results/hooks/useVideoResults.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { videoService } from '@/services/api';
|
||||||
|
|
||||||
|
import { videoKeys } from '../queries/videoKeys';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the completed detection results for a video.
|
||||||
|
*/
|
||||||
|
export function useVideoResultsQuery(videoId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: videoKeys.result(videoId ?? ''),
|
||||||
|
queryFn: () => videoService.getVideoResults(videoId as string),
|
||||||
|
enabled: Boolean(videoId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the annotated video through the authenticated axios client and
|
||||||
|
* expose a local object URL that the native `<video>` element can play.
|
||||||
|
*
|
||||||
|
* Fetching the blob instead of pointing `<video src>` at the protected endpoint
|
||||||
|
* directly is what fixes the 401. The browser cannot attach the Bearer token to
|
||||||
|
* a media request, but axios can.
|
||||||
|
*/
|
||||||
|
export function useAnnotatedVideoQuery(url: string | undefined) {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: videoKeys.annotatedVideo(url ?? ''),
|
||||||
|
queryFn: () => videoService.getAnnotatedVideo(url as string),
|
||||||
|
enabled: Boolean(url),
|
||||||
|
staleTime: Infinity,
|
||||||
|
gcTime: 1000 * 60 * 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query.data) {
|
||||||
|
setObjectUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUrl = URL.createObjectURL(query.data);
|
||||||
|
setObjectUrl(nextUrl);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
URL.revokeObjectURL(nextUrl);
|
||||||
|
};
|
||||||
|
}, [query.data]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
videoUrl: objectUrl,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isError: query.isError,
|
||||||
|
error: query.error,
|
||||||
|
refetch: query.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Loader2, TrendingUp } from 'lucide-react';
|
|
||||||
import VideoPlayerSection from '@/components/video-player-section';
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
|
||||||
import { sessionService } from '@/services/api';
|
|
||||||
import { SessionContext, DetectionData, DetectionType } from '@/types';
|
|
||||||
import { clearVideoFile } from '@/lib/video-storage';
|
|
||||||
import { ROUTES } from '@/utils/routes';
|
|
||||||
import { Card } from '@/components/ui/card';
|
|
||||||
|
|
||||||
export default function ResultsPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [session, setSession] = useState<SessionContext | null>(null);
|
|
||||||
const [detectionData] = useState<DetectionData | null>(null);
|
|
||||||
const [detectionType] = useState<DetectionType>('pothole-detection');
|
|
||||||
const [videoId] = useState<string | null>(null);
|
|
||||||
const [videoFile] = useState<File | null>(null);
|
|
||||||
const [isLoading] = useState(true);
|
|
||||||
const [error] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Load session and video data on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const storedSession = sessionService.loadSession();
|
|
||||||
const videoData = sessionService.loadVideoData();
|
|
||||||
|
|
||||||
if (!sessionService.isSessionValid(storedSession) || !videoData) {
|
|
||||||
router.replace(ROUTES.UPLOAD);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have a videoId, redirect to the dynamic results page
|
|
||||||
if (videoData.videoId) {
|
|
||||||
router.replace(`${ROUTES.RESULTS}/${videoData.videoId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSession(storedSession);
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const handleNewAnalysis = async () => {
|
|
||||||
// Clear video from IndexedDB
|
|
||||||
if (videoId) {
|
|
||||||
try {
|
|
||||||
await clearVideoFile(videoId);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to clear video file:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sessionService.clearSession();
|
|
||||||
router.push(ROUTES.UPLOAD);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTitle = () => {
|
|
||||||
if (detectionType === 'pothole-detection')
|
|
||||||
return 'Pothole Detection Results';
|
|
||||||
if (detectionType === 'sign-board-detection')
|
|
||||||
return 'Signboard Detection Results';
|
|
||||||
return 'Pothole & Signboard Detection Results';
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<Card className="flex flex-col items-center gap-4 p-8">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
||||||
<p className="text-sm text-muted-foreground">Loading results...</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<Card className="flex flex-col items-center gap-6 p-8 text-center max-w-md">
|
|
||||||
<p className="text-destructive font-medium">{error}</p>
|
|
||||||
<Button onClick={handleNewAnalysis}>Start New Analysis</Button>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
<main className="min-h-screen">
|
|
||||||
<div className="container mx-auto px-6 py-10 max-w-[1600px]">
|
|
||||||
<div className="mb-8">
|
|
||||||
<PageHeader
|
|
||||||
title={getTitle()}
|
|
||||||
description="View your AI-powered road analysis results"
|
|
||||||
icon={TrendingUp}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{session && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<Card className="p-0 border shadow-sm overflow-hidden">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between py-4 px-6 gap-6 bg-card">
|
|
||||||
<div className="flex flex-wrap items-center gap-x-12 gap-y-4">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Project
|
|
||||||
</span>
|
|
||||||
<span className="text-base font-bold leading-tight">
|
|
||||||
{session.projectName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col border-l pl-12 border-border/60">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Package
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
|
||||||
{session.packageName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col border-l pl-12 border-border/60">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Segment
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
|
||||||
{session.chainageName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleNewAnalysis}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="font-semibold px-6 shrink-0 h-9"
|
|
||||||
>
|
|
||||||
Start New Analysis
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detectionData && videoId && (
|
|
||||||
<VideoPlayerSection
|
|
||||||
data={detectionData}
|
|
||||||
videoId={videoId}
|
|
||||||
videoFile={videoFile}
|
|
||||||
detectionType={detectionType}
|
|
||||||
projectId={session?.projectId || undefined}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
8
src/app/(modules)/results/queries/videoKeys.ts
Normal file
8
src/app/(modules)/results/queries/videoKeys.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const videoKeys = {
|
||||||
|
all: ['videos'] as const,
|
||||||
|
results: () => [...videoKeys.all, 'results'] as const,
|
||||||
|
result: (videoId: string) => [...videoKeys.results(), videoId] as const,
|
||||||
|
annotatedVideos: () => [...videoKeys.all, 'annotated'] as const,
|
||||||
|
annotatedVideo: (url: string) =>
|
||||||
|
[...videoKeys.annotatedVideos(), url] as const,
|
||||||
|
};
|
||||||
@@ -73,7 +73,7 @@ export function RoleSheet({
|
|||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
<form noValidate onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -89,22 +89,7 @@ export function useRoleForm({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = handleSubmit(
|
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
|
||||||
(values) => saveMutation.mutate(values),
|
|
||||||
(formErrors) => {
|
|
||||||
if (formErrors.name?.message) {
|
|
||||||
toast.error(formErrors.name.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (formErrors.display_name?.message) {
|
|
||||||
toast.error(formErrors.display_name.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (formErrors.permission_ids?.message) {
|
|
||||||
toast.error(formErrors.permission_ids.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
reset({
|
reset({
|
||||||
|
|||||||
16
src/app/(modules)/roles/layout.tsx
Normal file
16
src/app/(modules)/roles/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function RolesLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.ROLE.READ}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
|
import { useRef } from 'react';
|
||||||
import type { FieldErrors, UseFormRegister } 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';
|
||||||
|
import { PackageSelect } from '@/components/lookups/PackageSelect';
|
||||||
|
import { ProjectSelect } from '@/components/lookups/ProjectSelect';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -22,7 +25,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import type { Package, Project } from '@/types';
|
|
||||||
|
|
||||||
import type { SegmentFormValues } from '../hooks/useSegmentForm';
|
import type { SegmentFormValues } from '../hooks/useSegmentForm';
|
||||||
|
|
||||||
@@ -30,10 +32,6 @@ interface SegmentDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
segmentId?: string;
|
segmentId?: string;
|
||||||
projects: Project[];
|
|
||||||
packages: Package[];
|
|
||||||
isProjectsLoading: boolean;
|
|
||||||
isPackagesLoading: boolean;
|
|
||||||
projectId: string;
|
projectId: string;
|
||||||
onProjectChange: (projectId: string) => void;
|
onProjectChange: (projectId: string) => void;
|
||||||
packageId: string;
|
packageId: string;
|
||||||
@@ -52,10 +50,6 @@ export function SegmentDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
segmentId,
|
segmentId,
|
||||||
projects,
|
|
||||||
packages,
|
|
||||||
isProjectsLoading,
|
|
||||||
isPackagesLoading,
|
|
||||||
projectId,
|
projectId,
|
||||||
onProjectChange,
|
onProjectChange,
|
||||||
packageId,
|
packageId,
|
||||||
@@ -69,6 +63,8 @@ export function SegmentDialog({
|
|||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: SegmentDialogProps) {
|
}: SegmentDialogProps) {
|
||||||
|
const projectSelectPortalRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const packageSelectPortalRef = useRef<HTMLDivElement | null>(null);
|
||||||
const getError = (field: keyof SegmentFormValues) =>
|
const getError = (field: keyof SegmentFormValues) =>
|
||||||
isSubmitted ? errors[field]?.message : undefined;
|
isSubmitted ? errors[field]?.message : undefined;
|
||||||
|
|
||||||
@@ -87,7 +83,11 @@ export function SegmentDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
<form
|
||||||
|
noValidate
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
|
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -104,25 +104,13 @@ export function SegmentDialog({
|
|||||||
required
|
required
|
||||||
error={getError('project_id')}
|
error={getError('project_id')}
|
||||||
>
|
>
|
||||||
<Select value={projectId} onValueChange={onProjectChange}>
|
<ProjectSelect
|
||||||
<SelectTrigger aria-invalid={!!getError('project_id')}>
|
value={projectId}
|
||||||
{isProjectsLoading ? (
|
onValueChange={onProjectChange}
|
||||||
<span className="flex items-center gap-2 text-muted-foreground">
|
enabled={open}
|
||||||
<Loader2 className="size-4 animate-spin" />
|
disabled={isSaving}
|
||||||
Loading...
|
portalContainer={projectSelectPortalRef}
|
||||||
</span>
|
/>
|
||||||
) : (
|
|
||||||
<SelectValue placeholder="Choose a project" />
|
|
||||||
)}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{projects.map((project) => (
|
|
||||||
<SelectItem key={project.id} value={project.id}>
|
|
||||||
{project.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
{...register('project_id')}
|
{...register('project_id')}
|
||||||
@@ -136,35 +124,14 @@ export function SegmentDialog({
|
|||||||
required
|
required
|
||||||
error={getError('package_id')}
|
error={getError('package_id')}
|
||||||
>
|
>
|
||||||
<Select
|
<PackageSelect
|
||||||
value={packageId}
|
value={packageId}
|
||||||
onValueChange={onPackageChange}
|
onValueChange={onPackageChange}
|
||||||
disabled={!projectId || isPackagesLoading}
|
projectId={projectId}
|
||||||
>
|
enabled={open && Boolean(projectId)}
|
||||||
<SelectTrigger aria-invalid={!!getError('package_id')}>
|
disabled={!projectId || isSaving}
|
||||||
{isPackagesLoading ? (
|
portalContainer={packageSelectPortalRef}
|
||||||
<span className="flex items-center gap-2 text-muted-foreground">
|
/>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
Loading...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<SelectValue
|
|
||||||
placeholder={
|
|
||||||
projectId
|
|
||||||
? 'Choose a package'
|
|
||||||
: 'Select project first'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{packages.map((pkg) => (
|
|
||||||
<SelectItem key={pkg.id} value={pkg.id}>
|
|
||||||
{pkg.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
{...register('package_id')}
|
{...register('package_id')}
|
||||||
@@ -215,16 +182,16 @@ export function SegmentDialog({
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
id="segment-start-km"
|
id="segment-start-km"
|
||||||
label="Start (km)"
|
label="Start Chainage"
|
||||||
required
|
required
|
||||||
error={getError('chainage_start_km')}
|
error={getError('chainage_start_km')}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="segment-start-km"
|
id="segment-start-km"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="1"
|
||||||
min="0"
|
min="0"
|
||||||
placeholder="0.0"
|
placeholder="Enter start chainage"
|
||||||
aria-invalid={!!getError('chainage_start_km')}
|
aria-invalid={!!getError('chainage_start_km')}
|
||||||
{...register('chainage_start_km')}
|
{...register('chainage_start_km')}
|
||||||
/>
|
/>
|
||||||
@@ -232,16 +199,16 @@ export function SegmentDialog({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
id="segment-end-km"
|
id="segment-end-km"
|
||||||
label="End (km)"
|
label="End Chainage"
|
||||||
required
|
required
|
||||||
error={getError('chainage_end_km')}
|
error={getError('chainage_end_km')}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="segment-end-km"
|
id="segment-end-km"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="1"
|
||||||
min="0"
|
min="0"
|
||||||
placeholder="1.0"
|
placeholder="Enter end chainage"
|
||||||
aria-invalid={!!getError('chainage_end_km')}
|
aria-invalid={!!getError('chainage_end_km')}
|
||||||
{...register('chainage_end_km')}
|
{...register('chainage_end_km')}
|
||||||
/>
|
/>
|
||||||
@@ -345,11 +312,16 @@ export function SegmentDialog({
|
|||||||
Cancel
|
Cancel
|
||||||
</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}
|
||||||
{segmentId ? 'Update Segment' : 'Create Segment'}
|
{segmentId ? 'Update Segment' : 'Create Segment'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div ref={projectSelectPortalRef} />
|
||||||
|
<div ref={packageSelectPortalRef} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,49 +7,32 @@ import { useForm, useWatch } from 'react-hook-form';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
requiredIntegerNumber,
|
||||||
|
requiredLatitude,
|
||||||
|
requiredLongitude,
|
||||||
|
validateChainageRange,
|
||||||
|
} from '@/lib/validation/numberField';
|
||||||
import { chainageService } from '@/services/api';
|
import { chainageService } from '@/services/api';
|
||||||
import type { Chainage, ChainageCreate, ChainageUpdate } from '@/types';
|
import type { Chainage, ChainageCreate, ChainageUpdate } from '@/types';
|
||||||
|
|
||||||
import { segmentKeys } from '../queries/segmentKeys';
|
import { segmentKeys } from '../queries/segmentKeys';
|
||||||
|
|
||||||
const requiredNumber = (message: string) =>
|
const segmentFormSchema = z
|
||||||
z
|
.object({
|
||||||
.string()
|
id: z.string().optional(),
|
||||||
.trim()
|
project_id: z.string().trim().min(1, 'Project is required'),
|
||||||
.min(1, message)
|
package_id: z.string().trim().min(1, 'Package is required'),
|
||||||
.refine((value) => Number.isFinite(Number(value)), {
|
segment_name: z.string().trim().min(1, 'Segment name is required'),
|
||||||
message: 'Enter a valid number',
|
chainage_start_km: requiredIntegerNumber('Start km is required'),
|
||||||
});
|
chainage_end_km: requiredIntegerNumber('End km is required'),
|
||||||
|
start_lat: requiredLatitude,
|
||||||
const latitude = requiredNumber('Latitude is required').refine(
|
start_lng: requiredLongitude,
|
||||||
(value) => {
|
end_lat: requiredLatitude,
|
||||||
const numberValue = Number(value);
|
end_lng: requiredLongitude,
|
||||||
return numberValue >= -90 && numberValue <= 90;
|
direction: z.enum(['UP', 'DOWN']),
|
||||||
},
|
})
|
||||||
{ message: 'Latitude must be between -90 and 90' },
|
.superRefine(validateChainageRange);
|
||||||
);
|
|
||||||
|
|
||||||
const longitude = requiredNumber('Longitude is required').refine(
|
|
||||||
(value) => {
|
|
||||||
const numberValue = Number(value);
|
|
||||||
return numberValue >= -180 && numberValue <= 180;
|
|
||||||
},
|
|
||||||
{ message: 'Longitude must be between -180 and 180' },
|
|
||||||
);
|
|
||||||
|
|
||||||
const segmentFormSchema = z.object({
|
|
||||||
id: z.string().optional(),
|
|
||||||
project_id: z.string().trim().min(1, 'Project is required'),
|
|
||||||
package_id: z.string().trim().min(1, 'Package is required'),
|
|
||||||
segment_name: z.string().trim().min(1, 'Segment name is required'),
|
|
||||||
chainage_start_km: requiredNumber('Start km is required'),
|
|
||||||
chainage_end_km: requiredNumber('End km is required'),
|
|
||||||
start_lat: latitude,
|
|
||||||
start_lng: longitude,
|
|
||||||
end_lat: latitude,
|
|
||||||
end_lng: longitude,
|
|
||||||
direction: z.enum(['UP', 'DOWN']),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type SegmentFormValues = z.infer<typeof segmentFormSchema>;
|
export type SegmentFormValues = z.infer<typeof segmentFormSchema>;
|
||||||
|
|
||||||
@@ -141,17 +124,7 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = handleSubmit(
|
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
|
||||||
(values) => saveMutation.mutate(values),
|
|
||||||
(formErrors) => {
|
|
||||||
const firstMessage = Object.values(formErrors).find(
|
|
||||||
(error) => error?.message,
|
|
||||||
)?.message;
|
|
||||||
if (firstMessage) {
|
|
||||||
toast.error(String(firstMessage));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
} from '@/services/api';
|
} from '@/services/api';
|
||||||
import type { Chainage, PaginationParams } from '@/types';
|
import type { Chainage, PaginationParams } from '@/types';
|
||||||
|
|
||||||
|
import { packageKeys } from '../../package/queries/packageKeys';
|
||||||
|
import { projectKeys } from '../../project/queries/projectKeys';
|
||||||
import { segmentKeys } from '../queries/segmentKeys';
|
import { segmentKeys } from '../queries/segmentKeys';
|
||||||
|
|
||||||
interface UseSegmentsQueryParams {
|
interface UseSegmentsQueryParams {
|
||||||
@@ -32,27 +34,18 @@ export function useSegmentsQuery({ skip, limit }: UseSegmentsQueryParams) {
|
|||||||
|
|
||||||
export function useProjectOptionsQuery() {
|
export function useProjectOptionsQuery() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['projects', 'options'],
|
queryKey: projectKeys.options(),
|
||||||
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
|
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAllPackageOptionsQuery() {
|
export function useAllPackageOptionsQuery() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['packages', 'options'],
|
queryKey: packageKeys.options(),
|
||||||
queryFn: () => packageService.getPackages({ skip: 0, limit: 1000 }),
|
queryFn: () => packageService.getPackages({ skip: 0, limit: 1000 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePackagesByProjectQuery(projectId: string, enabled: boolean) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['packages', 'by-project', projectId],
|
|
||||||
queryFn: () =>
|
|
||||||
packageService.getPackagesByProject(projectId, { skip: 0, limit: 1000 }),
|
|
||||||
enabled: enabled && Boolean(projectId),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteSegmentMutation() {
|
export function useDeleteSegmentMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
16
src/app/(modules)/segment/layout.tsx
Normal file
16
src/app/(modules)/segment/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function SegmentLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.SEGMENT.READ}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@ import { useSegmentForm } from './hooks/useSegmentForm';
|
|||||||
import {
|
import {
|
||||||
useAllPackageOptionsQuery,
|
useAllPackageOptionsQuery,
|
||||||
useDeleteSegmentMutation,
|
useDeleteSegmentMutation,
|
||||||
usePackagesByProjectQuery,
|
|
||||||
useProjectOptionsQuery,
|
useProjectOptionsQuery,
|
||||||
useSegmentsQuery,
|
useSegmentsQuery,
|
||||||
} from './hooks/useSegmentQueries';
|
} from './hooks/useSegmentQueries';
|
||||||
@@ -48,11 +47,6 @@ export default function SegmentPage() {
|
|||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
} = segmentForm;
|
} = segmentForm;
|
||||||
const projectPackagesQuery = usePackagesByProjectQuery(
|
|
||||||
projectId,
|
|
||||||
isDialogOpen,
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
prepareCreateSegment();
|
prepareCreateSegment();
|
||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
@@ -95,7 +89,6 @@ export default function SegmentPage() {
|
|||||||
|
|
||||||
const projects = projectsQuery.data?.items ?? [];
|
const projects = projectsQuery.data?.items ?? [];
|
||||||
const allPackages = allPackagesQuery.data?.items ?? [];
|
const allPackages = allPackagesQuery.data?.items ?? [];
|
||||||
const projectPackages = projectPackagesQuery.data?.items ?? [];
|
|
||||||
const segments = segmentsQuery.data?.items ?? [];
|
const segments = segmentsQuery.data?.items ?? [];
|
||||||
const total = segmentsQuery.data?.totalItems ?? 0;
|
const total = segmentsQuery.data?.totalItems ?? 0;
|
||||||
const columns = useSegmentColumns(projects, allPackages);
|
const columns = useSegmentColumns(projects, allPackages);
|
||||||
@@ -133,10 +126,6 @@ export default function SegmentPage() {
|
|||||||
open={isDialogOpen}
|
open={isDialogOpen}
|
||||||
onOpenChange={handleDialogOpenChange}
|
onOpenChange={handleDialogOpenChange}
|
||||||
segmentId={segmentId}
|
segmentId={segmentId}
|
||||||
projects={projects}
|
|
||||||
packages={projectPackages}
|
|
||||||
isProjectsLoading={projectsQuery.isLoading}
|
|
||||||
isPackagesLoading={projectPackagesQuery.isLoading}
|
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onProjectChange={setProjectId}
|
onProjectChange={setProjectId}
|
||||||
packageId={packageId}
|
packageId={packageId}
|
||||||
|
|||||||
@@ -4,4 +4,6 @@ export const segmentKeys = {
|
|||||||
all: ['segments'] as const,
|
all: ['segments'] as const,
|
||||||
lists: () => [...segmentKeys.all, 'list'] as const,
|
lists: () => [...segmentKeys.all, 'list'] as const,
|
||||||
list: (params: PaginationParams) => [...segmentKeys.lists(), params] as const,
|
list: (params: PaginationParams) => [...segmentKeys.lists(), params] as const,
|
||||||
|
details: () => [...segmentKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...segmentKeys.details(), id] as const,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import { useMemo, useRef, type ComponentProps } from 'react';
|
||||||
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
import {
|
||||||
|
Controller,
|
||||||
|
type Control,
|
||||||
|
type FieldErrors,
|
||||||
|
type UseFormRegister,
|
||||||
|
} from 'react-hook-form';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { FormField } from '@/components/form';
|
import { FormField, PhoneInput, SelectPopover } from '@/components/form';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -15,16 +20,12 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import type { Client, Plan } from '@/types';
|
import type { Client, Plan } from '@/types';
|
||||||
|
|
||||||
import type { TenantFormValues } from '../hooks/useTenantForm';
|
import type {
|
||||||
|
TenantAdminFieldLocks,
|
||||||
|
TenantFormValues,
|
||||||
|
} from '../hooks/useTenantForm';
|
||||||
|
|
||||||
interface TenantSheetProps {
|
interface TenantSheetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -32,6 +33,7 @@ interface TenantSheetProps {
|
|||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
register: UseFormRegister<TenantFormValues>;
|
register: UseFormRegister<TenantFormValues>;
|
||||||
|
control: Control<TenantFormValues>;
|
||||||
errors: FieldErrors<TenantFormValues>;
|
errors: FieldErrors<TenantFormValues>;
|
||||||
isSubmitted: boolean;
|
isSubmitted: boolean;
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
@@ -46,6 +48,7 @@ interface TenantSheetProps {
|
|||||||
isLookupsLoading: boolean;
|
isLookupsLoading: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
adminEmail?: string;
|
adminEmail?: string;
|
||||||
|
adminFieldLocks: TenantAdminFieldLocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TenantSheet({
|
export function TenantSheet({
|
||||||
@@ -54,6 +57,7 @@ export function TenantSheet({
|
|||||||
tenantId,
|
tenantId,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
errors,
|
errors,
|
||||||
isSubmitted,
|
isSubmitted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -68,10 +72,30 @@ export function TenantSheet({
|
|||||||
isLookupsLoading,
|
isLookupsLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
adminEmail,
|
adminEmail,
|
||||||
|
adminFieldLocks,
|
||||||
}: TenantSheetProps) {
|
}: TenantSheetProps) {
|
||||||
|
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
|
||||||
const fieldError = (field: keyof TenantFormValues) =>
|
const fieldError = (field: keyof TenantFormValues) =>
|
||||||
isSubmitted ? errors[field]?.message : undefined;
|
isSubmitted ? errors[field]?.message : undefined;
|
||||||
|
|
||||||
|
const clientOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
clients.map((client) => ({
|
||||||
|
value: String(client.id),
|
||||||
|
label: [client.first_name, client.last_name].filter(Boolean).join(' '),
|
||||||
|
})),
|
||||||
|
[clients],
|
||||||
|
);
|
||||||
|
|
||||||
|
const planOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
plans.map((plan) => ({
|
||||||
|
value: String(plan.id),
|
||||||
|
label: plan.name,
|
||||||
|
})),
|
||||||
|
[plans],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl">
|
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl">
|
||||||
@@ -85,7 +109,11 @@ export function TenantSheet({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
<form
|
||||||
|
noValidate
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
<div className="max-h-[50vh] space-y-5 overflow-y-auto px-6 py-4">
|
<div className="max-h-[50vh] space-y-5 overflow-y-auto px-6 py-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p>Basic Information</p>
|
<p>Basic Information</p>
|
||||||
@@ -128,29 +156,24 @@ export function TenantSheet({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField label="Client" required error={fieldError('client_id')}>
|
<FormField
|
||||||
<Select
|
id="tenant-client"
|
||||||
|
label="Client"
|
||||||
|
required
|
||||||
|
error={fieldError('client_id')}
|
||||||
|
>
|
||||||
|
<SelectPopover
|
||||||
|
options={clientOptions}
|
||||||
value={clientId}
|
value={clientId}
|
||||||
onValueChange={onClientChange}
|
onValueChange={onClientChange}
|
||||||
disabled={isLookupsLoading}
|
disabled={isLookupsLoading}
|
||||||
>
|
portalContainer={dropdownPortalRef}
|
||||||
<SelectTrigger>
|
placeholder={
|
||||||
<SelectValue
|
isLookupsLoading ? 'Loading clients...' : 'Select client'
|
||||||
placeholder={
|
}
|
||||||
isLookupsLoading
|
searchPlaceholder="Search clients"
|
||||||
? 'Loading clients...'
|
emptyMessage="No clients found."
|
||||||
: 'Select client'
|
/>
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{clients.map((client) => (
|
|
||||||
<SelectItem key={client.id} value={String(client.id)}>
|
|
||||||
{client.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
{...register('client_id')}
|
{...register('client_id')}
|
||||||
@@ -160,30 +183,23 @@ export function TenantSheet({
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
|
id="tenant-plan"
|
||||||
label="Subscription Plan"
|
label="Subscription Plan"
|
||||||
required
|
required
|
||||||
error={fieldError('plan_id')}
|
error={fieldError('plan_id')}
|
||||||
>
|
>
|
||||||
<Select
|
<SelectPopover
|
||||||
|
options={planOptions}
|
||||||
value={planId}
|
value={planId}
|
||||||
onValueChange={onPlanChange}
|
onValueChange={onPlanChange}
|
||||||
disabled={isLookupsLoading}
|
disabled={isLookupsLoading}
|
||||||
>
|
portalContainer={dropdownPortalRef}
|
||||||
<SelectTrigger>
|
placeholder={
|
||||||
<SelectValue
|
isLookupsLoading ? 'Loading plans...' : 'Select plan'
|
||||||
placeholder={
|
}
|
||||||
isLookupsLoading ? 'Loading plans...' : 'Select plan'
|
searchPlaceholder="Search plans"
|
||||||
}
|
emptyMessage="No plans found."
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{plans.map((plan) => (
|
|
||||||
<SelectItem key={plan.id} value={String(plan.id)}>
|
|
||||||
{plan.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
{...register('plan_id')}
|
{...register('plan_id')}
|
||||||
@@ -236,6 +252,7 @@ export function TenantSheet({
|
|||||||
id="admin-first-name"
|
id="admin-first-name"
|
||||||
placeholder="Enter first name"
|
placeholder="Enter first name"
|
||||||
aria-invalid={!!fieldError('admin_first_name')}
|
aria-invalid={!!fieldError('admin_first_name')}
|
||||||
|
readOnly={adminFieldLocks.admin_first_name}
|
||||||
{...register('admin_first_name')}
|
{...register('admin_first_name')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -250,6 +267,7 @@ export function TenantSheet({
|
|||||||
id="admin-last-name"
|
id="admin-last-name"
|
||||||
placeholder="Enter last name"
|
placeholder="Enter last name"
|
||||||
aria-invalid={!!fieldError('admin_last_name')}
|
aria-invalid={!!fieldError('admin_last_name')}
|
||||||
|
readOnly={adminFieldLocks.admin_last_name}
|
||||||
{...register('admin_last_name')}
|
{...register('admin_last_name')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -267,6 +285,7 @@ export function TenantSheet({
|
|||||||
type="email"
|
type="email"
|
||||||
placeholder="admin@example.com"
|
placeholder="admin@example.com"
|
||||||
aria-invalid={!!fieldError('admin_email')}
|
aria-invalid={!!fieldError('admin_email')}
|
||||||
|
readOnly={adminFieldLocks.admin_email}
|
||||||
{...register('admin_email')}
|
{...register('admin_email')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -276,11 +295,20 @@ export function TenantSheet({
|
|||||||
label="Phone Number"
|
label="Phone Number"
|
||||||
error={fieldError('admin_phone_number')}
|
error={fieldError('admin_phone_number')}
|
||||||
>
|
>
|
||||||
<Input
|
<Controller
|
||||||
id="admin-phone"
|
control={control}
|
||||||
placeholder="+91-9000011111"
|
name="admin_phone_number"
|
||||||
aria-invalid={!!fieldError('admin_phone_number')}
|
render={({ field }) => (
|
||||||
{...register('admin_phone_number')}
|
<PhoneInput
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="Enter phone number"
|
||||||
|
aria-invalid={!!fieldError('admin_phone_number')}
|
||||||
|
disabled={adminFieldLocks.admin_phone_number}
|
||||||
|
portalContainer={dropdownPortalRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,6 +344,8 @@ export function TenantSheet({
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div ref={dropdownPortalRef} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useCallback } from 'react';
|
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { Tenant } from '@/types';
|
import type { Tenant } from '@/types';
|
||||||
|
|
||||||
import { useSaveTenantMutation } from './useTenantMutations';
|
import { useSaveTenantMutation } from './useTenantMutations';
|
||||||
|
|
||||||
const optionalPhoneSchema = z
|
const requiredText = (message: string) => z.string().trim().min(1, message);
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.refine((value) => value === '' || /^\+(?:[0-9] ?|-){6,18}[0-9]$/.test(value), {
|
|
||||||
message: 'Please enter a valid phone number',
|
|
||||||
});
|
|
||||||
|
|
||||||
const tenantFormSchema = z
|
const tenantFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: z.number().optional(),
|
id: z.number().optional(),
|
||||||
name: z.string().trim().min(1, 'Tenant name is required'),
|
name: requiredText('Tenant name is required'),
|
||||||
slug: z.string().trim().min(1, 'Slug is required'),
|
slug: requiredText('Slug is required'),
|
||||||
client_id: z.string().trim().min(1, 'Client is required'),
|
client_id: requiredText('Client is required'),
|
||||||
plan_id: z.string().trim().min(1, 'Plan is required'),
|
plan_id: requiredText('Plan is required'),
|
||||||
domain: z.string().trim(),
|
domain: z.string().trim(),
|
||||||
description: z.string().trim(),
|
description: z.string().trim(),
|
||||||
admin_first_name: z.string().trim(),
|
admin_first_name: z.string().trim(),
|
||||||
admin_last_name: z.string().trim(),
|
admin_last_name: z.string().trim(),
|
||||||
admin_email: z.string().trim(),
|
admin_email: z.string().trim(),
|
||||||
admin_phone_number: optionalPhoneSchema,
|
admin_phone_number: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((value) => value === '' || isValidPhoneNumber(value), {
|
||||||
|
message: 'Enter a valid phone number',
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.superRefine((values, ctx) => {
|
.superRefine((values, ctx) => {
|
||||||
if (values.id) return;
|
if (values.id) return;
|
||||||
@@ -38,7 +38,7 @@ const tenantFormSchema = z
|
|||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: 'custom',
|
code: 'custom',
|
||||||
path: ['admin_first_name'],
|
path: ['admin_first_name'],
|
||||||
message: 'Admin first name is required',
|
message: 'First name is required',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ const tenantFormSchema = z
|
|||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: 'custom',
|
code: 'custom',
|
||||||
path: ['admin_last_name'],
|
path: ['admin_last_name'],
|
||||||
message: 'Admin last name is required',
|
message: 'Last name is required',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +98,20 @@ function splitContactName(contactName: string) {
|
|||||||
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
|
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TenantAdminFieldLocks = {
|
||||||
|
admin_first_name: boolean;
|
||||||
|
admin_last_name: boolean;
|
||||||
|
admin_email: boolean;
|
||||||
|
admin_phone_number: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultAdminFieldLocks: TenantAdminFieldLocks = {
|
||||||
|
admin_first_name: false,
|
||||||
|
admin_last_name: false,
|
||||||
|
admin_email: false,
|
||||||
|
admin_phone_number: false,
|
||||||
|
};
|
||||||
|
|
||||||
export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -115,10 +129,15 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
const saveMutation = useSaveTenantMutation({
|
const saveMutation = useSaveTenantMutation({
|
||||||
onSaved: () => {
|
onSaved: () => {
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
|
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||||
onSaved();
|
onSaved();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [adminFieldLocks, setAdminFieldLocks] = useState<TenantAdminFieldLocks>(
|
||||||
|
defaultAdminFieldLocks,
|
||||||
|
);
|
||||||
|
|
||||||
const tenantId = useWatch({ control, name: 'id' });
|
const tenantId = useWatch({ control, name: 'id' });
|
||||||
const clientId = useWatch({ control, name: 'client_id' }) || '';
|
const clientId = useWatch({ control, name: 'client_id' }) || '';
|
||||||
const planId = useWatch({ control, name: 'plan_id' }) || '';
|
const planId = useWatch({ control, name: 'plan_id' }) || '';
|
||||||
@@ -138,21 +157,11 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
adminLastName.trim().length > 0 &&
|
adminLastName.trim().length > 0 &&
|
||||||
adminEmail.trim().length > 0));
|
adminEmail.trim().length > 0));
|
||||||
|
|
||||||
const handleSubmit = submitForm(
|
const handleSubmit = submitForm((values) => saveMutation.mutate(values));
|
||||||
(values) => saveMutation.mutate(values),
|
|
||||||
(formErrors) => {
|
|
||||||
const firstMessage = Object.values(formErrors).find(
|
|
||||||
(error) => error?.message,
|
|
||||||
)?.message;
|
|
||||||
|
|
||||||
if (firstMessage) {
|
|
||||||
toast.error(String(firstMessage));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
|
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||||
}, [reset]);
|
}, [reset]);
|
||||||
|
|
||||||
const openEdit = useCallback(
|
const openEdit = useCallback(
|
||||||
@@ -170,19 +179,37 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
admin_email: tenant.admin_email || '',
|
admin_email: tenant.admin_email || '',
|
||||||
admin_phone_number: '',
|
admin_phone_number: '',
|
||||||
});
|
});
|
||||||
|
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||||
},
|
},
|
||||||
[reset],
|
[reset],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clearAdminFields = useCallback(() => {
|
||||||
|
setValue('admin_first_name', '', { shouldDirty: true });
|
||||||
|
setValue('admin_last_name', '', { shouldDirty: true });
|
||||||
|
setValue('admin_email', '', { shouldDirty: true });
|
||||||
|
setValue('admin_phone_number', '', { shouldDirty: true });
|
||||||
|
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||||
|
}, [setValue]);
|
||||||
|
|
||||||
const applyClientAdminDefaults = useCallback(
|
const applyClientAdminDefaults = useCallback(
|
||||||
(contactName: string, contactEmail: string, contactPhone: string) => {
|
(contactName: string, contactEmail: string, contactPhone: string) => {
|
||||||
if (isEditMode) return;
|
if (isEditMode) return;
|
||||||
|
|
||||||
const { firstName, lastName } = splitContactName(contactName);
|
const { firstName, lastName } = splitContactName(contactName);
|
||||||
|
const trimmedEmail = contactEmail.trim();
|
||||||
|
const trimmedPhone = contactPhone.trim();
|
||||||
|
|
||||||
setValue('admin_first_name', firstName, { shouldDirty: true });
|
setValue('admin_first_name', firstName, { shouldDirty: true });
|
||||||
setValue('admin_last_name', lastName, { shouldDirty: true });
|
setValue('admin_last_name', lastName, { shouldDirty: true });
|
||||||
setValue('admin_email', contactEmail, { shouldDirty: true });
|
setValue('admin_email', trimmedEmail, { shouldDirty: true });
|
||||||
setValue('admin_phone_number', contactPhone, { shouldDirty: true });
|
setValue('admin_phone_number', trimmedPhone, { shouldDirty: true });
|
||||||
|
setAdminFieldLocks({
|
||||||
|
admin_first_name: firstName.trim().length > 0,
|
||||||
|
admin_last_name: lastName.trim().length > 0,
|
||||||
|
admin_email: trimmedEmail.length > 0,
|
||||||
|
admin_phone_number: trimmedPhone.length > 0,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[isEditMode, setValue],
|
[isEditMode, setValue],
|
||||||
);
|
);
|
||||||
@@ -197,9 +224,9 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
control,
|
|
||||||
setValue,
|
setValue,
|
||||||
openCreate,
|
openCreate,
|
||||||
openEdit,
|
openEdit,
|
||||||
@@ -211,7 +238,9 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
errors,
|
errors,
|
||||||
isSubmitted,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
|
adminFieldLocks,
|
||||||
applyClientAdminDefaults,
|
applyClientAdminDefaults,
|
||||||
|
clearAdminFields,
|
||||||
syncSlugFromName,
|
syncSlugFromName,
|
||||||
isSaving: isSubmitting || saveMutation.isPending,
|
isSaving: isSubmitting || saveMutation.isPending,
|
||||||
};
|
};
|
||||||
|
|||||||
16
src/app/(modules)/tenants/layout.tsx
Normal file
16
src/app/(modules)/tenants/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function TenantsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.TENANT.READ}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ export default function TenantsPage() {
|
|||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
openCreate: prepareCreateTenant,
|
openCreate: prepareCreateTenant,
|
||||||
openEdit: prepareEditTenant,
|
openEdit: prepareEditTenant,
|
||||||
@@ -65,7 +66,9 @@ export default function TenantsPage() {
|
|||||||
errors,
|
errors,
|
||||||
isSubmitted,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
|
adminFieldLocks,
|
||||||
applyClientAdminDefaults,
|
applyClientAdminDefaults,
|
||||||
|
clearAdminFields,
|
||||||
syncSlugFromName,
|
syncSlugFromName,
|
||||||
setValue,
|
setValue,
|
||||||
isSaving,
|
isSaving,
|
||||||
@@ -77,7 +80,11 @@ export default function TenantsPage() {
|
|||||||
(value: string) => {
|
(value: string) => {
|
||||||
setValue('client_id', value, { shouldDirty: true, shouldValidate: true });
|
setValue('client_id', value, { shouldDirty: true, shouldValidate: true });
|
||||||
|
|
||||||
if (!value || isEditMode) return;
|
if (isEditMode) return;
|
||||||
|
|
||||||
|
clearAdminFields();
|
||||||
|
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
clientService.getClientById(Number(value)).then((client) => {
|
clientService.getClientById(Number(value)).then((client) => {
|
||||||
applyClientAdminDefaults(
|
applyClientAdminDefaults(
|
||||||
@@ -87,7 +94,7 @@ export default function TenantsPage() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[applyClientAdminDefaults, isEditMode, setValue],
|
[applyClientAdminDefaults, clearAdminFields, isEditMode, setValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePlanChange = useCallback(
|
const handlePlanChange = useCallback(
|
||||||
@@ -183,6 +190,7 @@ export default function TenantsPage() {
|
|||||||
tenantId={tenantId}
|
tenantId={tenantId}
|
||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
register={register}
|
register={register}
|
||||||
|
control={control}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
isSubmitted={isSubmitted}
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
@@ -197,6 +205,7 @@ export default function TenantsPage() {
|
|||||||
isLookupsLoading={isLookupsLoading}
|
isLookupsLoading={isLookupsLoading}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
adminEmail={adminEmail}
|
adminEmail={adminEmail}
|
||||||
|
adminFieldLocks={adminFieldLocks}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Component } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { DEFECT_CLASS_STATUS_CONFIG } from '@/constants/defectClassStatus';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries';
|
||||||
|
|
||||||
|
interface TicketDefectClassTabsProps {
|
||||||
|
ticketId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketDefectClassTabs({
|
||||||
|
ticketId,
|
||||||
|
}: TicketDefectClassTabsProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const defectClassParam = searchParams.get('defect_class');
|
||||||
|
const defectClassesQuery = useTicketDefectClassesQuery(ticketId);
|
||||||
|
const defectClasses = useMemo(
|
||||||
|
() => defectClassesQuery.data?.defect_classes ?? [],
|
||||||
|
[defectClassesQuery.data?.defect_classes],
|
||||||
|
);
|
||||||
|
const [selectedClass, setSelectedClass] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!defectClasses.length) return;
|
||||||
|
|
||||||
|
const hasUrlClass = defectClasses.some(
|
||||||
|
(item) => item.class_name === defectClassParam,
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelectedClass((current) => {
|
||||||
|
if (hasUrlClass) return defectClassParam ?? undefined;
|
||||||
|
|
||||||
|
return current &&
|
||||||
|
defectClasses.some((item) => item.class_name === current)
|
||||||
|
? current
|
||||||
|
: defectClasses[0].class_name;
|
||||||
|
});
|
||||||
|
}, [defectClassParam, defectClasses]);
|
||||||
|
|
||||||
|
const handleClassChange = (className: string) => {
|
||||||
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
|
nextParams.set('defect_class', className);
|
||||||
|
setSelectedClass(className);
|
||||||
|
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (defectClassesQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="h-5 w-24 animate-pulse rounded bg-muted" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-2">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="h-11 w-full animate-pulse rounded-lg bg-muted"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defectClassesQuery.isError || !defectClasses.length || !selectedClass) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
value={selectedClass}
|
||||||
|
onValueChange={handleClassChange}
|
||||||
|
orientation="vertical"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span>Issues</span>
|
||||||
|
<Badge className="min-w-5 justify-center rounded-full px-1.5 py-0.5 text-xs tabular-nums">
|
||||||
|
{defectClasses.length}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TabsList className="flex h-auto w-full flex-col items-stretch gap-2 bg-transparent p-0">
|
||||||
|
{defectClasses.map((item) => {
|
||||||
|
const statusConfig =
|
||||||
|
DEFECT_CLASS_STATUS_CONFIG[item.assignment_status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsTrigger
|
||||||
|
key={item.class_name}
|
||||||
|
value={item.class_name}
|
||||||
|
className={cn(
|
||||||
|
'group h-auto w-full flex-none items-center justify-between! gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground shadow-none after:hidden',
|
||||||
|
'data-[state=active]:bg-muted data-[state=active]:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate text-left font-medium">
|
||||||
|
{item.display_name}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 gap-1.5 rounded-full border-0 px-2.5 py-1 text-xs font-medium',
|
||||||
|
statusConfig.badgeClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Component className="size-3" />
|
||||||
|
{statusConfig.label}
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TabsList>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { SparkleButton } from '@/components/ui/sparkle-button';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionGuard } from '@/guards';
|
||||||
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
|
import { TicketStatusBadge } from '../../components/TicketStatusBadge';
|
||||||
|
|
||||||
|
export function TicketDetailHeader({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const ticketLabel = ticket.ticket_name
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Ticket Detail
|
||||||
|
</h1>
|
||||||
|
<TicketStatusBadge status={ticket.status} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs ">Ticket: {ticketLabel}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{ticket.video_id && ticket.status !== 'processing' ? (
|
||||||
|
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||||
|
<SparkleButton
|
||||||
|
className="h-10 min-w-36 px-5"
|
||||||
|
onClick={() => router.push(`/results/${ticket.video_id}`)}
|
||||||
|
>
|
||||||
|
View Analysis
|
||||||
|
</SparkleButton>
|
||||||
|
</PermissionGuard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push('/ticket')}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
Back to List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
function OverviewSkeletonField() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineSkeletonItem({ isLast = false }: { isLast?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex gap-4 pb-8 last:pb-0">
|
||||||
|
{!isLast ? (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute top-6 left-2.75 h-[calc(100%-10px)] w-px bg-border"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Skeleton className="relative z-10 mt-0.5 size-6 shrink-0 rounded-full" />
|
||||||
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Skeleton className="h-4 w-44" />
|
||||||
|
<Skeleton className="h-3 w-28" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-3 w-56" />
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketDetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<main className="relative z-10 space-y-5">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Skeleton className="h-8 w-40" />
|
||||||
|
<Skeleton className="h-6 w-24 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-3 w-72 max-w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Skeleton className="h-10 w-36" />
|
||||||
|
<Skeleton className="h-8 w-28" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-2 gap-x-4 gap-y-6 md:grid-cols-4">
|
||||||
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
|
<OverviewSkeletonField key={index} />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<section className="space-y-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-5/6" />
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-44" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="aspect-video w-full rounded-lg" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex-row items-center">
|
||||||
|
<Skeleton className="size-5 rounded-full" />
|
||||||
|
<Skeleton className="h-5 w-56" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pl-5.5">
|
||||||
|
<TimelineSkeletonItem />
|
||||||
|
<TimelineSkeletonItem />
|
||||||
|
<TimelineSkeletonItem isLast />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="self-start">
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-36" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { History, MessageSquareText } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { TicketDetail, TicketHistoryItem, TicketStatus } from '@/types';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
|
import { formatTicketStatus } from '../../components/TicketStatusBadge';
|
||||||
|
|
||||||
|
function getActorLabel(item: TicketHistoryItem) {
|
||||||
|
return item.actor?.name || item.actor?.email || 'System';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLifecycleTitle(status: TicketStatus) {
|
||||||
|
const titles: Record<TicketStatus, string> = {
|
||||||
|
processing: 'Incident Ticket Created',
|
||||||
|
unassigned: 'Awaiting Assignment',
|
||||||
|
assigned: 'Dispatched to Maintenance',
|
||||||
|
under_review: 'Maintenance Finalized',
|
||||||
|
closed: 'Ticket Closed',
|
||||||
|
};
|
||||||
|
|
||||||
|
return titles[status] ?? formatTicketStatus(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryEntry({
|
||||||
|
item,
|
||||||
|
isLast,
|
||||||
|
}: {
|
||||||
|
item: TicketHistoryItem;
|
||||||
|
isLast: boolean;
|
||||||
|
}) {
|
||||||
|
const isClosed = item.to_status === 'closed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex gap-4 pb-8 last:pb-0">
|
||||||
|
{!isLast ? (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute top-6 left-[11px] h-[calc(100%-10px)] w-px bg-border"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
'relative z-10 mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border-2 bg-background',
|
||||||
|
isClosed
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-primary/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'size-2 rounded-full bg-primary',
|
||||||
|
isClosed &&
|
||||||
|
'h-1.5 w-2.5 rotate-[-45deg] rounded-none border-b-2 border-l-2 border-current bg-transparent',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
{getLifecycleTitle(item.to_status)}
|
||||||
|
</h3>
|
||||||
|
<time
|
||||||
|
dateTime={item.created_at}
|
||||||
|
className="text-xs font-semibold text-muted-foreground tabular-nums"
|
||||||
|
>
|
||||||
|
{formatDate(item.created_at)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
Updated By{' '}
|
||||||
|
<span className="text-foreground/90">{getActorLabel(item)}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{item.note ? (
|
||||||
|
<div className="flex gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm leading-relaxed text-foreground">
|
||||||
|
<MessageSquareText className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||||
|
<p>{item.note}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
const history = ticket.history ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<History className="size-5 text-primary" />
|
||||||
|
Audit & Lifecycle Timeline
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
{history.length ? (
|
||||||
|
<div className="pl-0.5">
|
||||||
|
{history.map((item, index) => (
|
||||||
|
<HistoryEntry
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isLast={index === history.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
No timeline entries yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import type { TicketActor, TicketDetail } from '@/types';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
|
function formatCreatedDate(value: string | null | undefined) {
|
||||||
|
if (!value) return '-';
|
||||||
|
return new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium' }).format(
|
||||||
|
new Date(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(person: TicketActor | null | undefined) {
|
||||||
|
const source = person?.name || person?.email || '?';
|
||||||
|
return source
|
||||||
|
.split(/[\s@._-]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase())
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPersonLabel(person: TicketActor | null | undefined) {
|
||||||
|
return person?.name || person?.email || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaValue({ children }: { children: React.ReactNode }) {
|
||||||
|
return <p className="text-sm font-semibold text-foreground">{children}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonMetaValue({
|
||||||
|
person,
|
||||||
|
accentClassName = 'bg-primary/15 text-primary',
|
||||||
|
}: {
|
||||||
|
person: TicketActor | null | undefined;
|
||||||
|
accentClassName?: string;
|
||||||
|
}) {
|
||||||
|
if (!person) {
|
||||||
|
return <MetaValue>-</MetaValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold ${accentClassName}`}
|
||||||
|
>
|
||||||
|
{getInitials(person)}
|
||||||
|
</span>
|
||||||
|
<p className="truncate text-sm font-medium">{getPersonLabel(person)}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewField({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<MetaLabel>{label}</MetaLabel>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketOverviewCard({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
const detectionCount = ticket.ai_result?.detection_count;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Overview</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="grid grid-cols-2 gap-x-4 gap-y-6 md:grid-cols-4">
|
||||||
|
<OverviewField label="Detection Count">
|
||||||
|
<MetaValue>
|
||||||
|
{detectionCount != null ? `${detectionCount} Occurrences` : '-'}
|
||||||
|
</MetaValue>
|
||||||
|
</OverviewField>
|
||||||
|
|
||||||
|
<OverviewField label="Chainage">
|
||||||
|
<MetaValue>{ticket.chainage_name}</MetaValue>
|
||||||
|
</OverviewField>
|
||||||
|
|
||||||
|
<OverviewField label="Uploaded By">
|
||||||
|
<PersonMetaValue person={ticket.uploader} />
|
||||||
|
</OverviewField>
|
||||||
|
|
||||||
|
<OverviewField label="Created Date">
|
||||||
|
<MetaValue>
|
||||||
|
{formatCreatedDate(ticket.timestamps.created_at)}
|
||||||
|
</MetaValue>
|
||||||
|
</OverviewField>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{ticket.ai_result?.completed_at ? (
|
||||||
|
<CardFooter className="text-xs text-muted-foreground">
|
||||||
|
AI analysis completed {formatDate(ticket.ai_result.completed_at)}
|
||||||
|
</CardFooter>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ImageIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
|
import { RepairProofImagesGrid } from './repair-report/RepairProofImagesGrid';
|
||||||
|
import { RepairReportPanel } from './repair-report/RepairReportPanel';
|
||||||
|
import { RepairVideoComparison } from './repair-report/RepairVideoComparison';
|
||||||
|
|
||||||
|
export function TicketRepairReportCard({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
const repair = ticket.repair;
|
||||||
|
|
||||||
|
if (!repair) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-5">
|
||||||
|
<RepairReportPanel ticket={ticket} />
|
||||||
|
|
||||||
|
<RepairVideoComparison ticket={ticket} />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="size-5 text-primary" />
|
||||||
|
Repair Images
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<RepairProofImagesGrid
|
||||||
|
imageUrls={repair?.proof_image_urls ?? []}
|
||||||
|
submittedAt={repair?.submitted_at ?? null}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionGuard } from '@/guards';
|
||||||
|
import type { TicketDetail } from '@/types';
|
||||||
|
import type { TicketDetailLiveState } from '../../hooks/useTicketDetailEvents';
|
||||||
|
|
||||||
|
import { AssignTicketAction } from './actions/AssignTicketAction';
|
||||||
|
import { ClosedTicketAction } from './actions/ClosedTicketAction';
|
||||||
|
import { NoTicketAction } from './actions/NoTicketAction';
|
||||||
|
import { ProcessingTicketAction } from './actions/ProcessingTicketAction';
|
||||||
|
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
||||||
|
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
||||||
|
import { TicketDefectClassTabs } from './TicketDefectClassTabs';
|
||||||
|
|
||||||
|
interface TicketStatusActionsProps {
|
||||||
|
ticket: TicketDetail;
|
||||||
|
liveState: TicketDetailLiveState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTicketAction(
|
||||||
|
ticket: TicketDetail,
|
||||||
|
liveState: TicketDetailLiveState,
|
||||||
|
) {
|
||||||
|
if (ticket.status === 'processing') {
|
||||||
|
return (
|
||||||
|
<ProcessingTicketAction
|
||||||
|
progress={liveState.progress}
|
||||||
|
errorMessage={liveState.errorMessage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.assignment_status === 'unassigned') {
|
||||||
|
return (
|
||||||
|
<PermissionGuard
|
||||||
|
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||||
|
fallback={<NoTicketAction status={ticket.status} />}
|
||||||
|
>
|
||||||
|
<AssignTicketAction ticket={ticket} />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.assignment_status === 'assigned') {
|
||||||
|
return (
|
||||||
|
<PermissionGuard
|
||||||
|
permissions={PERMISSIONS.TICKET.WORK}
|
||||||
|
fallback={<NoTicketAction status={ticket.status} />}
|
||||||
|
>
|
||||||
|
<SubmitRepairAction ticket={ticket} />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.assignment_status === 'under_review') {
|
||||||
|
return (
|
||||||
|
<PermissionGuard
|
||||||
|
permissions={PERMISSIONS.TICKET.REVIEW}
|
||||||
|
fallback={<NoTicketAction status={ticket.status} />}
|
||||||
|
>
|
||||||
|
<ReviewRepairAction ticket={ticket} />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.status === 'closed') {
|
||||||
|
return <ClosedTicketAction ticket={ticket} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <NoTicketAction status={ticket.status} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketStatusActions({
|
||||||
|
ticket,
|
||||||
|
liveState,
|
||||||
|
}: TicketStatusActionsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<TicketDefectClassTabs ticketId={ticket.id} />
|
||||||
|
{getTicketAction(ticket, liveState)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Send, UserPlus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
|
||||||
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
|
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
|
||||||
|
import { TicketActionCard } from './TicketActionCard';
|
||||||
|
|
||||||
|
export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState('');
|
||||||
|
const [assignNote, setAssignNote] = useState('');
|
||||||
|
|
||||||
|
const assignMutation = useAssignTicketMutation(ticket.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TicketActionCard icon={UserPlus} title="Assign Ticket">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Contractor</Label>
|
||||||
|
<AssignableWorkerSelect
|
||||||
|
value={selectedUserId}
|
||||||
|
onValueChange={setSelectedUserId}
|
||||||
|
disabled={assignMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Note</Label>
|
||||||
|
<Textarea
|
||||||
|
value={assignNote}
|
||||||
|
onChange={(event) => setAssignNote(event.target.value)}
|
||||||
|
placeholder="Please inspect and repair"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={!selectedUserId || assignMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!selectedUserId) return;
|
||||||
|
assignMutation.mutate({
|
||||||
|
assigned_to_user_id: Number(selectedUserId),
|
||||||
|
defect_class: ticket.defect_class,
|
||||||
|
note: assignNote || undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Send />
|
||||||
|
Assign
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TicketActionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Lock, MessageSquare } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
|
function getInitials(name?: string | null, email?: string | null) {
|
||||||
|
const source = name || email || '?';
|
||||||
|
return source
|
||||||
|
.split(/[\s@._-]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase())
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPersonLabel(person: TicketDetail['reviewer']) {
|
||||||
|
return person?.name || person?.email || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReviewDate(value: string | null | undefined) {
|
||||||
|
if (!value) return '-';
|
||||||
|
return new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium' }).format(
|
||||||
|
new Date(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClosedAt(ticket: TicketDetail) {
|
||||||
|
const closedEntry = ticket.history?.find(
|
||||||
|
(item) => item.to_status === 'closed',
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
closedEntry?.created_at ??
|
||||||
|
ticket.reviewer?.reviewed_at ??
|
||||||
|
ticket.timestamps.updated_at
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueClassName,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
valueClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={cn('text-sm font-semibold text-foreground', valueClassName)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FinalCommentsPanel({
|
||||||
|
comment,
|
||||||
|
}: {
|
||||||
|
comment: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
if (!comment?.trim()) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-28 flex-col items-center justify-center rounded-lg border border-dashed border-border bg-muted/10 px-4 py-6 text-center">
|
||||||
|
<MessageSquare className="mb-2 size-5 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">No comments added yet</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-muted/20 px-4 py-3">
|
||||||
|
<p className="text-sm leading-relaxed text-foreground">
|
||||||
|
{comment.trim()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
const reviewer = ticket.reviewer;
|
||||||
|
const reviewedAt = reviewer?.reviewed_at ?? getClosedAt(ticket);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Resolution Details</CardTitle>
|
||||||
|
<CardDescription>Review record for {ticket.id}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="space-y-4 rounded-xl border bg-muted/20 p-4">
|
||||||
|
<MetaField
|
||||||
|
label="Reviewed By"
|
||||||
|
value={
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<Avatar className="size-10 border border-primary/30">
|
||||||
|
<AvatarFallback className="bg-primary/15 text-sm text-primary">
|
||||||
|
{getInitials(reviewer?.name, reviewer?.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate">{getPersonLabel(reviewer)}</p>
|
||||||
|
{reviewer?.email ? (
|
||||||
|
<p className="mt-0.5 truncate text-xs font-medium text-muted-foreground">
|
||||||
|
{reviewer.email}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetaField
|
||||||
|
label="Reviewed Date"
|
||||||
|
value={formatReviewDate(reviewedAt)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||||
|
Review Comment
|
||||||
|
</p>
|
||||||
|
<FinalCommentsPanel comment={reviewer?.review_comment} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full text-muted-foreground"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<Lock className="size-4" />
|
||||||
|
Ticket Finalized
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CircleOff, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { TicketStatus } from '@/types';
|
||||||
|
|
||||||
|
import { TicketActionCard } from './TicketActionCard';
|
||||||
|
|
||||||
|
const statusConfig: Record<
|
||||||
|
'processing',
|
||||||
|
{ icon: typeof Clock; title: string; message: string }
|
||||||
|
> = {
|
||||||
|
processing: {
|
||||||
|
icon: Clock,
|
||||||
|
title: 'Processing',
|
||||||
|
message: 'This ticket is still being processed. Check back shortly.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NoTicketAction({ status }: { status: TicketStatus }) {
|
||||||
|
const config =
|
||||||
|
status === 'processing'
|
||||||
|
? statusConfig[status]
|
||||||
|
: {
|
||||||
|
icon: CircleOff,
|
||||||
|
title: 'No Actions',
|
||||||
|
message: 'No action available for this ticket.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TicketActionCard icon={Icon} title={config.title}>
|
||||||
|
<p className="text-sm text-muted-foreground">{config.message}</p>
|
||||||
|
</TicketActionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AlertCircle, Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import type { TicketProcessingProgress } from '../../../hooks/useTicketDetailEvents';
|
||||||
|
|
||||||
|
import { TicketActionCard } from './TicketActionCard';
|
||||||
|
|
||||||
|
interface ProcessingTicketActionProps {
|
||||||
|
progress: TicketProcessingProgress | null;
|
||||||
|
errorMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProgressMessage(progress: TicketProcessingProgress | null) {
|
||||||
|
if (!progress) return 'Waiting for analysis updates.';
|
||||||
|
if (progress.progress >= 95) {
|
||||||
|
return progress.message ?? 'Rendering annotated video...';
|
||||||
|
}
|
||||||
|
return progress.message ?? 'Processing video.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProcessingTicketAction({
|
||||||
|
progress,
|
||||||
|
errorMessage,
|
||||||
|
}: ProcessingTicketActionProps) {
|
||||||
|
const progressValue = progress ? progress.progress : 0;
|
||||||
|
const hasProgress = Boolean(progress);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TicketActionCard
|
||||||
|
icon={Loader}
|
||||||
|
iconClassName="animate-spin"
|
||||||
|
title="Analyzing Video"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{getProgressMessage(progress)}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-muted-foreground">
|
||||||
|
{hasProgress ? `${Math.round(progressValue)}%` : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={hasProgress ? progressValue : 0} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="flex gap-2 rounded-md border border-destructive/25 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<span>{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TicketActionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { ImageIcon, Loader2, Send, Video, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { useSubmitRepairUploadMutation } from '../../../hooks/useTicketQueries';
|
||||||
|
|
||||||
|
const MAX_IMAGES = 5;
|
||||||
|
|
||||||
|
function RequiredMark() {
|
||||||
|
return <span className="text-destructive">*</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaDropzoneProps {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
accept: string;
|
||||||
|
file: File | null;
|
||||||
|
onFileChange: (file: File | null) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaDropzone({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
accept,
|
||||||
|
file,
|
||||||
|
onFileChange,
|
||||||
|
disabled = false,
|
||||||
|
}: MediaDropzoneProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center cursor-pointer gap-3 rounded-lg border border-dashed px-4 py-3 text-left transition-colors',
|
||||||
|
'hover:border-primary/50 hover:bg-muted/30',
|
||||||
|
disabled && 'pointer-events-none opacity-60',
|
||||||
|
file && 'border-primary/40 bg-muted/20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
className="hidden"
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
onFileChange(event.target.files?.[0] ?? null);
|
||||||
|
event.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-medium">
|
||||||
|
{file ? file.name : label}
|
||||||
|
</span>
|
||||||
|
{file ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Click to replace
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageSlotProps {
|
||||||
|
file: File | null;
|
||||||
|
previewUrl: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
onAdd: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageSlot({
|
||||||
|
file,
|
||||||
|
previewUrl,
|
||||||
|
disabled = false,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
}: ImageSlotProps) {
|
||||||
|
if (file && previewUrl) {
|
||||||
|
return (
|
||||||
|
<div className="group relative aspect-square overflow-hidden rounded-lg border bg-muted/30">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={file.name}
|
||||||
|
className="size-full object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onRemove}
|
||||||
|
className="absolute top-1 right-1 flex size-5 cursor-pointer items-center justify-center rounded-full bg-background/90 text-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
aria-label={`Remove ${file.name}`}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onAdd}
|
||||||
|
className={cn(
|
||||||
|
'flex aspect-square cursor-pointer items-center justify-center rounded-lg border border-dashed bg-muted/20 text-muted-foreground transition-colors',
|
||||||
|
'hover:border-primary/50 hover:bg-muted/40 hover:text-primary',
|
||||||
|
disabled && 'cursor-not-allowed opacity-60',
|
||||||
|
)}
|
||||||
|
aria-label="Add image"
|
||||||
|
>
|
||||||
|
<ImageIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RepairEvidenceFormProps {
|
||||||
|
ticketId: string;
|
||||||
|
defectClass: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepairEvidenceForm({
|
||||||
|
ticketId,
|
||||||
|
defectClass,
|
||||||
|
}: RepairEvidenceFormProps) {
|
||||||
|
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||||
|
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||||
|
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const submitRepairUploadMutation = useSubmitRepairUploadMutation(ticketId);
|
||||||
|
const isSubmitting = submitRepairUploadMutation.isPending;
|
||||||
|
const canSubmit = Boolean(notes.trim() && videoFile);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urls = imageFiles.map((file) => URL.createObjectURL(file));
|
||||||
|
setImagePreviews(urls);
|
||||||
|
return () => {
|
||||||
|
urls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
};
|
||||||
|
}, [imageFiles]);
|
||||||
|
|
||||||
|
const addImages = (files: FileList | null) => {
|
||||||
|
if (!files?.length) return;
|
||||||
|
|
||||||
|
const remaining = MAX_IMAGES - imageFiles.length;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
setError(`Only ${MAX_IMAGES} images can be uploaded.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextImages = [
|
||||||
|
...imageFiles,
|
||||||
|
...Array.from(files).slice(0, remaining),
|
||||||
|
];
|
||||||
|
setImageFiles(nextImages);
|
||||||
|
setError(
|
||||||
|
files.length > remaining
|
||||||
|
? `Only ${MAX_IMAGES} images can be uploaded.`
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeImage = (index: number) => {
|
||||||
|
setImageFiles((current) =>
|
||||||
|
current.filter((_, itemIndex) => itemIndex !== index),
|
||||||
|
);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!notes.trim() || !videoFile) {
|
||||||
|
setError('Repair notes and video are required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await submitRepairUploadMutation.mutateAsync({
|
||||||
|
defect_class: defectClass,
|
||||||
|
notes: notes.trim(),
|
||||||
|
video: videoFile,
|
||||||
|
images: imageFiles,
|
||||||
|
});
|
||||||
|
setNotes('');
|
||||||
|
setVideoFile(null);
|
||||||
|
setImageFiles([]);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageSlots = Array.from({ length: MAX_IMAGES }, (_, index) => ({
|
||||||
|
file: imageFiles[index] ?? null,
|
||||||
|
previewUrl: imagePreviews[index] ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="repair-notes">
|
||||||
|
Repair Notes <RequiredMark />
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="repair-notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(event) => {
|
||||||
|
setNotes(event.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder="Describe the repair work performed..."
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="min-h-24 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-muted-foreground">Media Upload</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-normal">
|
||||||
|
Repair Video <RequiredMark />
|
||||||
|
</Label>
|
||||||
|
<MediaDropzone
|
||||||
|
icon={<Video className="size-4" />}
|
||||||
|
label="Upload Repair Video (MP4, max 50MB)"
|
||||||
|
accept="video/mp4,video/*"
|
||||||
|
file={videoFile}
|
||||||
|
onFileChange={(file) => {
|
||||||
|
setVideoFile(file);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-normal">
|
||||||
|
Upload Images (Max {MAX_IMAGES})
|
||||||
|
</Label>
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
disabled={isSubmitting || imageFiles.length >= MAX_IMAGES}
|
||||||
|
onChange={(event) => {
|
||||||
|
addImages(event.target.files);
|
||||||
|
event.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{imageSlots.map((slot, index) => (
|
||||||
|
<ImageSlot
|
||||||
|
key={index}
|
||||||
|
file={slot.file}
|
||||||
|
previewUrl={slot.previewUrl}
|
||||||
|
disabled={
|
||||||
|
isSubmitting ||
|
||||||
|
(!slot.file && imageFiles.length >= MAX_IMAGES)
|
||||||
|
}
|
||||||
|
onAdd={() => imageInputRef.current?.click()}
|
||||||
|
onRemove={() => removeImage(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!canSubmit || isSubmitting}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Submitting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="size-4" />
|
||||||
|
Submit for Review
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Check, ClipboardCheck, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
|
import { useReviewRepairMutation } from '../../../hooks/useTicketQueries';
|
||||||
|
import { TicketActionCard } from './TicketActionCard';
|
||||||
|
|
||||||
|
export function ReviewRepairAction({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
const [reviewComment, setReviewComment] = useState('');
|
||||||
|
const reviewRepairMutation = useReviewRepairMutation(ticket.id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setReviewComment(ticket.reviewer?.review_comment ?? '');
|
||||||
|
}, [ticket]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TicketActionCard icon={ClipboardCheck} title="Review Repair">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Comment</Label>
|
||||||
|
<Textarea
|
||||||
|
value={reviewComment}
|
||||||
|
onChange={(event) => setReviewComment(event.target.value)}
|
||||||
|
placeholder="Repair verified"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
disabled={!reviewComment || reviewRepairMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
reviewRepairMutation.mutate({
|
||||||
|
defect_class: ticket.defect_class,
|
||||||
|
action: 'approve',
|
||||||
|
comment: reviewComment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Check />
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={!reviewComment || reviewRepairMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
reviewRepairMutation.mutate({
|
||||||
|
defect_class: ticket.defect_class,
|
||||||
|
action: 'reject',
|
||||||
|
comment: reviewComment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TicketActionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Wrench } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
|
import { RepairEvidenceForm } from './RepairEvidenceForm';
|
||||||
|
import { TicketActionCard } from './TicketActionCard';
|
||||||
|
|
||||||
|
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
return (
|
||||||
|
<TicketActionCard icon={Wrench} title="Submit Repair Evidence">
|
||||||
|
<RepairEvidenceForm
|
||||||
|
ticketId={ticket.id}
|
||||||
|
defectClass={ticket.defect_class}
|
||||||
|
/>
|
||||||
|
</TicketActionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TicketActionCardProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
iconClassName?: string;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketActionCard({
|
||||||
|
icon: Icon,
|
||||||
|
iconClassName,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: TicketActionCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Icon className={cn('size-4 text-primary', iconClassName)} />
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>{children}</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ImageIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { formatRepairDate, ReportSurface } from './repairReportUtils';
|
||||||
|
|
||||||
|
const MAX_MEDIA_SLOTS = 5;
|
||||||
|
|
||||||
|
const imageBadgeStyles = [
|
||||||
|
'bg-primary/90 text-primary-foreground',
|
||||||
|
'bg-secondary text-secondary-foreground',
|
||||||
|
'bg-accent text-accent-foreground',
|
||||||
|
'bg-muted text-muted-foreground',
|
||||||
|
'bg-primary/70 text-primary-foreground',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function RepairProofImagesGrid({
|
||||||
|
imageUrls,
|
||||||
|
submittedAt,
|
||||||
|
layout = 'row',
|
||||||
|
}: {
|
||||||
|
imageUrls: string[];
|
||||||
|
submittedAt: string | null;
|
||||||
|
layout?: 'row' | 'sidebar';
|
||||||
|
}) {
|
||||||
|
const imageSlots = Array.from(
|
||||||
|
{ length: MAX_MEDIA_SLOTS },
|
||||||
|
(_, index) => imageUrls[index] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid gap-3',
|
||||||
|
layout === 'sidebar'
|
||||||
|
? 'grid-cols-2 auto-rows-fr'
|
||||||
|
: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{imageSlots.map((url, index) => (
|
||||||
|
<RepairProofImageSlot
|
||||||
|
key={index}
|
||||||
|
url={url}
|
||||||
|
index={index}
|
||||||
|
submittedAt={submittedAt}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RepairProofImageSlot({
|
||||||
|
url,
|
||||||
|
index,
|
||||||
|
submittedAt,
|
||||||
|
}: {
|
||||||
|
url: string | null;
|
||||||
|
index: number;
|
||||||
|
submittedAt: string | null;
|
||||||
|
}) {
|
||||||
|
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(url);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return (
|
||||||
|
<ReportSurface className="space-y-2 p-2">
|
||||||
|
<div className="flex aspect-4/3 items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/10 text-muted-foreground/70">
|
||||||
|
<ImageIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<p className="truncate px-1 text-[11px] text-muted-foreground/70">
|
||||||
|
No image added
|
||||||
|
</p>
|
||||||
|
</ReportSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<ReportSurface className="space-y-2 p-2">
|
||||||
|
<Skeleton className="aspect-4/3 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
</ReportSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !objectUrl) {
|
||||||
|
return (
|
||||||
|
<ReportSurface className="space-y-2 p-2">
|
||||||
|
<div className="flex aspect-4/3 items-center justify-center rounded-lg border border-dashed bg-muted/10 text-xs text-muted-foreground">
|
||||||
|
Unavailable
|
||||||
|
</div>
|
||||||
|
<p className="truncate px-1 text-[11px] text-muted-foreground">
|
||||||
|
Image {index + 1}
|
||||||
|
</p>
|
||||||
|
</ReportSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReportSurface className="space-y-2 p-2">
|
||||||
|
<a
|
||||||
|
href={objectUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="group relative block overflow-hidden rounded-lg border bg-muted/20"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={objectUrl}
|
||||||
|
alt={`Repair proof ${index + 1}`}
|
||||||
|
className="aspect-4/3 w-full object-cover transition-transform group-hover:scale-[1.02]"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<p className="truncate px-1 text-[11px] text-muted-foreground">
|
||||||
|
{submittedAt ? formatRepairDate(submittedAt) : `Image ${index + 1}`}
|
||||||
|
</p>
|
||||||
|
</ReportSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FileText, MessageSquareQuote } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EmptyPanel,
|
||||||
|
formatRepairDate,
|
||||||
|
getPersonLabel,
|
||||||
|
} from './repairReportUtils';
|
||||||
|
|
||||||
|
function MetaLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<MetaLabel>{label}</MetaLabel>
|
||||||
|
<p className="text-sm font-semibold text-foreground">{value || '-'}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepairReportPanel({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
const repair = ticket.repair;
|
||||||
|
|
||||||
|
if (!repair) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="size-5 text-primary" />
|
||||||
|
Repair Report
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<EmptyPanel
|
||||||
|
icon={MessageSquareQuote}
|
||||||
|
title="No repair report submitted yet"
|
||||||
|
description="Repair details will appear here once the assigned worker submits evidence."
|
||||||
|
className="py-10"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="sm:grid-cols-[1fr_auto]">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="size-5 text-primary" />
|
||||||
|
Repair Report
|
||||||
|
</CardTitle>
|
||||||
|
{repair.submitted_at ? (
|
||||||
|
<span className="text-xs text-muted-foreground italic sm:col-start-2 sm:row-start-1">
|
||||||
|
Completed {formatRepairDate(repair.submitted_at)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<ReportField
|
||||||
|
label="Technician"
|
||||||
|
value={getPersonLabel(ticket.worker)}
|
||||||
|
/>
|
||||||
|
<ReportField
|
||||||
|
label="Submitted At"
|
||||||
|
value={
|
||||||
|
repair.submitted_at ? formatRepairDate(repair.submitted_at) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<MetaLabel>Repair Summary</MetaLabel>
|
||||||
|
{repair.notes?.trim() ? (
|
||||||
|
<div className="rounded-lg border bg-muted/15 px-4 py-3">
|
||||||
|
<p className="text-sm leading-relaxed text-foreground/90">
|
||||||
|
{repair.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyPanel
|
||||||
|
icon={MessageSquareQuote}
|
||||||
|
title="No repair notes added yet"
|
||||||
|
className="py-6"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import type { TicketDetail } from '@/types';
|
||||||
|
|
||||||
|
export function RepairVideoComparison({ ticket }: { ticket: TicketDetail }) {
|
||||||
|
const originalVideoUrl = ticket.video?.url ?? null;
|
||||||
|
const repairVideoUrl = ticket.repair?.proof_video_url ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="size-5 text-primary" />
|
||||||
|
Repair Verification
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<ComparisonVideoItem
|
||||||
|
tooltip={ticket.video?.name ?? 'Original inspection video'}
|
||||||
|
videoLabel="Before"
|
||||||
|
videoUrl={originalVideoUrl}
|
||||||
|
meta="AI detection video"
|
||||||
|
emptyTitle="No original video available"
|
||||||
|
emptyDescription="The source inspection video is not attached to this ticket."
|
||||||
|
/>
|
||||||
|
<ComparisonVideoItem
|
||||||
|
tooltip="Contractor repair evidence video"
|
||||||
|
videoLabel="After"
|
||||||
|
videoUrl={repairVideoUrl}
|
||||||
|
meta="Contractor evidence video"
|
||||||
|
emptyTitle={
|
||||||
|
ticket.repair
|
||||||
|
? 'No repair video uploaded'
|
||||||
|
: 'No evidence submitted yet'
|
||||||
|
}
|
||||||
|
emptyDescription={
|
||||||
|
ticket.repair
|
||||||
|
? 'The assigned worker has not uploaded a repair video yet.'
|
||||||
|
: 'Repair video will appear here once evidence is submitted.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComparisonVideoItem({
|
||||||
|
tooltip,
|
||||||
|
videoLabel,
|
||||||
|
videoUrl,
|
||||||
|
meta,
|
||||||
|
emptyTitle,
|
||||||
|
emptyDescription,
|
||||||
|
}: {
|
||||||
|
tooltip: string;
|
||||||
|
videoLabel: string;
|
||||||
|
videoUrl: string | null;
|
||||||
|
meta: string;
|
||||||
|
emptyTitle: string;
|
||||||
|
emptyDescription: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div title={tooltip}>
|
||||||
|
<ProtectedVideoPlayer
|
||||||
|
url={videoUrl}
|
||||||
|
label={videoLabel}
|
||||||
|
meta={meta}
|
||||||
|
emptyTitle={emptyTitle}
|
||||||
|
emptyDescription={emptyDescription}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { TicketActor } from '@/types';
|
||||||
|
|
||||||
|
export function formatRepairDate(value: string | null | undefined) {
|
||||||
|
if (!value) return '-';
|
||||||
|
return new Intl.DateTimeFormat('en-IN', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPersonLabel(person: TicketActor | null | undefined) {
|
||||||
|
return person?.name || person?.email || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p className="text-[11px] font-semibold tracking-[0.12em] text-foreground uppercase">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportSurface({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-lg border bg-muted/15 p-3', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyPanel({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="mb-3 size-6 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||||
|
{description ? (
|
||||||
|
<p className="mt-1 max-w-xs text-xs text-muted-foreground/80">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/app/(modules)/ticket/[ticketId]/page.tsx
Normal file
70
src/app/(modules)/ticket/[ticketId]/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
import { TicketDetailHeader } from './components/TicketDetailHeader';
|
||||||
|
import { TicketDetailSkeleton } from './components/TicketDetailSkeleton';
|
||||||
|
|
||||||
|
import { TicketHistoryCard } from './components/TicketHistoryCard';
|
||||||
|
import { TicketOverviewCard } from './components/TicketOverviewCard';
|
||||||
|
import { TicketRepairReportCard } from './components/TicketRepairReportCard';
|
||||||
|
import { TicketStatusActions } from './components/TicketStatusActions';
|
||||||
|
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
|
||||||
|
import { useTicketDetailQuery } from '../hooks/useTicketQueries';
|
||||||
|
|
||||||
|
export default function TicketDetailPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { ticketId } = useParams() as { ticketId: string };
|
||||||
|
const defectClass = searchParams.get('defect_class') ?? undefined;
|
||||||
|
|
||||||
|
const ticketQuery = useTicketDetailQuery(ticketId, defectClass);
|
||||||
|
const ticket = ticketQuery.data;
|
||||||
|
|
||||||
|
const liveState = useTicketDetailEvents(
|
||||||
|
ticketId,
|
||||||
|
defectClass,
|
||||||
|
Boolean(ticketId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ticketQuery.isLoading) {
|
||||||
|
return <TicketDetailSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticketQuery.isError || !ticket) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3">
|
||||||
|
<p className="text-sm text-destructive">Failed to load ticket.</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push('/ticket')}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
Back to Tickets
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative z-10 space-y-5">
|
||||||
|
<TicketDetailHeader ticket={ticket} />
|
||||||
|
|
||||||
|
<div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<TicketOverviewCard ticket={ticket} />
|
||||||
|
<TicketRepairReportCard ticket={ticket} />
|
||||||
|
<TicketHistoryCard ticket={ticket} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="self-start">
|
||||||
|
<TicketStatusActions ticket={ticket} liveState={liveState} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/app/(modules)/ticket/components/TicketColumns.tsx
Normal file
60
src/app/(modules)/ticket/components/TicketColumns.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import type { TicketListItem } from '@/types';
|
||||||
|
|
||||||
|
import { TicketStatusBadge } from './TicketStatusBadge';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
|
export function useTicketColumns(): ColumnDef<TicketListItem>[] {
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'ticket_name',
|
||||||
|
header: 'Ticket',
|
||||||
|
size: 180,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium">{row.original.ticket_name}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
size: 150,
|
||||||
|
cell: ({ row }) => <TicketStatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'chainage_name',
|
||||||
|
header: 'Segment',
|
||||||
|
size: 180,
|
||||||
|
cell: ({ row }) => row.original.chainage_name || 'N/A',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'detection_count',
|
||||||
|
header: 'Detections',
|
||||||
|
size: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'assigned_to_name',
|
||||||
|
header: 'Assigned To',
|
||||||
|
size: 220,
|
||||||
|
cell: ({ row }) => row.original.assigned_to_name || 'N/A',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_by_name',
|
||||||
|
header: 'Uploaded By',
|
||||||
|
size: 220,
|
||||||
|
cell: ({ row }) => row.original.created_by_name || 'N/A',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'updated_at',
|
||||||
|
header: 'Updated',
|
||||||
|
size: 180,
|
||||||
|
cell: ({ row }) => formatDate(row.original.updated_at),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/app/(modules)/ticket/components/TicketFilters.tsx
Normal file
60
src/app/(modules)/ticket/components/TicketFilters.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { SegmentSelect } from '@/components/lookups/SegmentSelect';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import type { TicketStatus } from '@/types';
|
||||||
|
|
||||||
|
import { formatTicketStatus } from './TicketStatusBadge';
|
||||||
|
|
||||||
|
const ticketStatuses: TicketStatus[] = [
|
||||||
|
'processing',
|
||||||
|
'unassigned',
|
||||||
|
'assigned',
|
||||||
|
'under_review',
|
||||||
|
'closed',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface TicketFiltersProps {
|
||||||
|
status: TicketStatus | 'all';
|
||||||
|
segmentId: string;
|
||||||
|
onStatusChange: (status: TicketStatus | 'all') => void;
|
||||||
|
onSegmentChange: (segmentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketFilters({
|
||||||
|
status,
|
||||||
|
segmentId,
|
||||||
|
onStatusChange,
|
||||||
|
onSegmentChange,
|
||||||
|
}: TicketFiltersProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
onValueChange={(value) => onStatusChange(value as TicketStatus | 'all')}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full sm:w-48">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
{ticketStatuses.map((item) => (
|
||||||
|
<SelectItem key={item} value={item}>
|
||||||
|
{formatTicketStatus(item)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="w-full sm:w-64">
|
||||||
|
<SegmentSelect value={segmentId} onValueChange={onSegmentChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/app/(modules)/ticket/components/TicketStatusBadge.tsx
Normal file
28
src/app/(modules)/ticket/components/TicketStatusBadge.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { TicketStatus } from '@/types';
|
||||||
|
|
||||||
|
const statusClasses: Record<TicketStatus, string> = {
|
||||||
|
processing: 'bg-sky-100 text-sky-700 border-sky-200',
|
||||||
|
unassigned: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||||
|
assigned: 'bg-indigo-100 text-indigo-700 border-indigo-200',
|
||||||
|
under_review: 'bg-violet-100 text-violet-700 border-violet-200',
|
||||||
|
closed: 'bg-stone-100 text-stone-700 border-stone-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatTicketStatus(status: TicketStatus) {
|
||||||
|
return status
|
||||||
|
.split('_')
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketStatusBadge({ status }: { status: TicketStatus }) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={cn('border', statusClasses[status])}>
|
||||||
|
{formatTicketStatus(status)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/app/(modules)/ticket/components/TicketTable.tsx
Normal file
53
src/app/(modules)/ticket/components/TicketTable.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import type { TicketListItem } from '@/types';
|
||||||
|
|
||||||
|
interface TicketTableProps {
|
||||||
|
columns: ColumnDef<TicketListItem>[];
|
||||||
|
tickets: TicketListItem[];
|
||||||
|
isLoading: boolean;
|
||||||
|
toolbar: ReactNode;
|
||||||
|
skip: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
onPageChange: (skip: number) => void;
|
||||||
|
onLimitChange: (limit: number) => void;
|
||||||
|
onView: (ticket: TicketListItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketTable({
|
||||||
|
columns,
|
||||||
|
tickets,
|
||||||
|
isLoading,
|
||||||
|
toolbar,
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
onView,
|
||||||
|
}: TicketTableProps) {
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
title="Tickets"
|
||||||
|
columns={columns}
|
||||||
|
data={tickets}
|
||||||
|
isLoading={isLoading}
|
||||||
|
toolbar={toolbar}
|
||||||
|
emptyTitle="No tickets found."
|
||||||
|
emptyDescription="Ticket rows will appear after video processing creates them."
|
||||||
|
pagination={{
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
totalItems: total,
|
||||||
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
}}
|
||||||
|
onRowClick={onView}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
src/app/(modules)/ticket/hooks/useTenantTicketTableEvents.ts
Normal file
182
src/app/(modules)/ticket/hooks/useTenantTicketTableEvents.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||||
|
import { useSseWithToken } from '@/hooks/sse/useSseWithToken';
|
||||||
|
import { ticketService } from '@/services/api';
|
||||||
|
import type {
|
||||||
|
TicketListItem,
|
||||||
|
TicketListParams,
|
||||||
|
TicketListResponse,
|
||||||
|
TicketTableStatusEvent,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
import { ticketKeys } from '../queries/ticketKeys';
|
||||||
|
|
||||||
|
function patchTicketLists(
|
||||||
|
current: TicketListResponse | undefined,
|
||||||
|
event: TicketTableStatusEvent,
|
||||||
|
params?: TicketListParams,
|
||||||
|
) {
|
||||||
|
if (!current || !event.id) return current;
|
||||||
|
|
||||||
|
const nextRow = buildTicketListItem(event);
|
||||||
|
const existingIndex = current.items.findIndex(
|
||||||
|
(ticket) => ticket.id === event.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
items: current.items.map((ticket) =>
|
||||||
|
ticket.id === event.id ? mergeTicketListItem(ticket, event) : ticket,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind !== 'created') return current;
|
||||||
|
if (!shouldCountCreatedTicket(event, params)) return current;
|
||||||
|
|
||||||
|
if (!nextRow || !isFirstPage(params)) {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
total: current.total + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageLimit = params?.limit ?? current.items.length + 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
items: [nextRow, ...current.items].slice(0, pageLimit),
|
||||||
|
total: current.total + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldCountCreatedTicket(
|
||||||
|
event: TicketTableStatusEvent,
|
||||||
|
params?: TicketListParams,
|
||||||
|
) {
|
||||||
|
if (params?.status && event.status !== params.status) return false;
|
||||||
|
if (params?.chainage_id && event.chainage_id !== params.chainage_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFirstPage(params?: TicketListParams) {
|
||||||
|
return (params?.skip ?? 0) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTicketListParams(queryKey: readonly unknown[]) {
|
||||||
|
const params = queryKey[2];
|
||||||
|
|
||||||
|
if (!params || typeof params !== 'object' || Array.isArray(params)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return params as TicketListParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTicketListItem(
|
||||||
|
event: TicketTableStatusEvent,
|
||||||
|
): TicketListItem | null {
|
||||||
|
if (
|
||||||
|
!event.id ||
|
||||||
|
!event.ticket_name ||
|
||||||
|
!event.status ||
|
||||||
|
event.detection_count === undefined ||
|
||||||
|
!event.updated_at
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
ticket_name: event.ticket_name,
|
||||||
|
chainage_id: event.chainage_id ?? null,
|
||||||
|
chainage_name: event.chainage_name ?? null,
|
||||||
|
status: event.status,
|
||||||
|
default_defect_class: event.default_defect_class ?? null,
|
||||||
|
assigned_to_name: event.assigned_to_name ?? null,
|
||||||
|
detection_count: event.detection_count,
|
||||||
|
created_by_name: event.created_by_name ?? null,
|
||||||
|
created_at: event.created_at ?? event.updated_at,
|
||||||
|
updated_at: event.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeTicketListItem(
|
||||||
|
current: TicketListItem,
|
||||||
|
event: TicketTableStatusEvent,
|
||||||
|
): TicketListItem {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
ticket_name: event.ticket_name ?? current.ticket_name,
|
||||||
|
chainage_id: event.chainage_id ?? current.chainage_id,
|
||||||
|
chainage_name: event.chainage_name ?? current.chainage_name,
|
||||||
|
status: event.status ?? current.status,
|
||||||
|
default_defect_class:
|
||||||
|
event.default_defect_class ?? current.default_defect_class,
|
||||||
|
assigned_to_name: event.assigned_to_name ?? current.assigned_to_name,
|
||||||
|
detection_count: event.detection_count ?? current.detection_count,
|
||||||
|
created_by_name: event.created_by_name ?? current.created_by_name,
|
||||||
|
created_at: event.created_at ?? current.created_at,
|
||||||
|
updated_at: event.updated_at ?? current.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTenantTicketTableEvents(enabled = true) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const hasInvalidatedAfterConnectionErrorRef = useRef(false);
|
||||||
|
|
||||||
|
const events = useMemo(
|
||||||
|
() => ({
|
||||||
|
ticket_status: (event: TicketTableStatusEvent) => {
|
||||||
|
queryClient
|
||||||
|
.getQueryCache()
|
||||||
|
.findAll({ queryKey: ticketKeys.lists() })
|
||||||
|
.forEach((query) => {
|
||||||
|
queryClient.setQueryData<TicketListResponse>(
|
||||||
|
query.queryKey,
|
||||||
|
(current) =>
|
||||||
|
patchTicketLists(
|
||||||
|
current,
|
||||||
|
event,
|
||||||
|
getTicketListParams(query.queryKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getPath = useCallback(
|
||||||
|
(token: string) => API_ROUTES.TICKET_EVENTS.TENANT(token),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onConnectionError = useCallback(() => {
|
||||||
|
if (hasInvalidatedAfterConnectionErrorRef.current) return;
|
||||||
|
|
||||||
|
hasInvalidatedAfterConnectionErrorRef.current = true;
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
const onConnectionOpen = useCallback(() => {
|
||||||
|
hasInvalidatedAfterConnectionErrorRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useSseWithToken({
|
||||||
|
enabled,
|
||||||
|
getToken: ticketService.createTenantTicketEventsToken,
|
||||||
|
getPath,
|
||||||
|
events,
|
||||||
|
onConnectionError,
|
||||||
|
onConnectionOpen,
|
||||||
|
});
|
||||||
|
}
|
||||||
145
src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts
Normal file
145
src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||||
|
import { useSseWithToken } from '@/hooks/sse/useSseWithToken';
|
||||||
|
import { ticketService } from '@/services/api';
|
||||||
|
import type {
|
||||||
|
TicketDetailStatusEvent,
|
||||||
|
TicketProgressEvent,
|
||||||
|
TicketStreamErrorEvent,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
import { ticketKeys } from '../queries/ticketKeys';
|
||||||
|
|
||||||
|
export interface TicketProcessingProgress {
|
||||||
|
progress: number;
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketDetailLiveState {
|
||||||
|
progress: TicketProcessingProgress | null;
|
||||||
|
errorMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TICKET_DETAIL_RECONNECT_EVENTS = ['error'] as const;
|
||||||
|
|
||||||
|
function isHeartbeatEvent(event: { type?: string } | null | undefined) {
|
||||||
|
return event?.type === 'heartbeat';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTicketDetailEvents(
|
||||||
|
ticketId: string | undefined,
|
||||||
|
defectClass: string | undefined,
|
||||||
|
enabled = true,
|
||||||
|
): TicketDetailLiveState {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const defectClassRef = useRef(defectClass);
|
||||||
|
const [progress, setProgress] = useState<TicketProcessingProgress | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
defectClassRef.current = defectClass;
|
||||||
|
}, [defectClass]);
|
||||||
|
|
||||||
|
const refetchTicket = useCallback(() => {
|
||||||
|
if (!ticketId) return;
|
||||||
|
|
||||||
|
queryClient.refetchQueries({
|
||||||
|
queryKey: ticketKeys.detail(ticketId, defectClassRef.current),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.defectClasses(ticketId),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
}, [queryClient, ticketId]);
|
||||||
|
|
||||||
|
const selectDefectClass = useCallback(
|
||||||
|
(nextDefectClass: string | null | undefined) => {
|
||||||
|
if (!ticketId || !nextDefectClass || defectClassRef.current) return;
|
||||||
|
|
||||||
|
router.replace(
|
||||||
|
`/ticket/${ticketId}?defect_class=${encodeURIComponent(nextDefectClass)}`,
|
||||||
|
{ scroll: false },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[router, ticketId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getToken = useCallback(
|
||||||
|
() => ticketService.createTicketEventsToken(ticketId as string),
|
||||||
|
[ticketId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getPath = useCallback(
|
||||||
|
(token: string) =>
|
||||||
|
API_ROUTES.TICKETS.DETAIL_EVENTS(ticketId as string, token),
|
||||||
|
[ticketId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const events = useMemo(
|
||||||
|
() => ({
|
||||||
|
ticket_status: (event: TicketDetailStatusEvent) => {
|
||||||
|
if (isHeartbeatEvent(event)) return;
|
||||||
|
refetchTicket();
|
||||||
|
},
|
||||||
|
progress: (event: TicketProgressEvent) => {
|
||||||
|
setErrorMessage(null);
|
||||||
|
setProgress({
|
||||||
|
progress: event.progress,
|
||||||
|
message: event.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
complete: (event: TicketProgressEvent) => {
|
||||||
|
setErrorMessage(null);
|
||||||
|
setProgress({
|
||||||
|
progress: 100,
|
||||||
|
message: event.message,
|
||||||
|
});
|
||||||
|
selectDefectClass(event.defect_class);
|
||||||
|
refetchTicket();
|
||||||
|
},
|
||||||
|
error: (event: TicketStreamErrorEvent) => {
|
||||||
|
setErrorMessage(
|
||||||
|
event.message ?? 'Live ticket updates disconnected. Reconnecting...',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
heartbeat: () => undefined,
|
||||||
|
}),
|
||||||
|
[refetchTicket, selectDefectClass],
|
||||||
|
);
|
||||||
|
|
||||||
|
const shouldStop = useCallback(
|
||||||
|
(eventName: keyof typeof events, event: unknown) =>
|
||||||
|
eventName === 'ticket_status' &&
|
||||||
|
(event as TicketDetailStatusEvent).status === 'closed',
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onConnectionError = useCallback(() => {
|
||||||
|
setErrorMessage('Live ticket updates disconnected. Reconnecting...');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setProgress(null);
|
||||||
|
setErrorMessage(null);
|
||||||
|
}, [ticketId]);
|
||||||
|
|
||||||
|
useSseWithToken({
|
||||||
|
enabled: Boolean(enabled && ticketId),
|
||||||
|
getToken,
|
||||||
|
getPath,
|
||||||
|
events,
|
||||||
|
reconnectOnEvents: TICKET_DETAIL_RECONNECT_EVENTS,
|
||||||
|
shouldStop,
|
||||||
|
onConnectionError,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { progress, errorMessage };
|
||||||
|
}
|
||||||
207
src/app/(modules)/ticket/hooks/useTicketQueries.ts
Normal file
207
src/app/(modules)/ticket/hooks/useTicketQueries.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { ticketService } from '@/services/api';
|
||||||
|
import type {
|
||||||
|
AssignTicketPayload,
|
||||||
|
CloseTicketPayload,
|
||||||
|
ReviewRepairPayload,
|
||||||
|
SubmitRepairPayload,
|
||||||
|
SubmitRepairUploadPayload,
|
||||||
|
TicketDetail,
|
||||||
|
TicketListParams,
|
||||||
|
TicketStatus,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
import { ticketKeys } from '../queries/ticketKeys';
|
||||||
|
|
||||||
|
const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
interface UseTicketsQueryParams {
|
||||||
|
skip: number;
|
||||||
|
limit: number;
|
||||||
|
status?: TicketStatus | 'all';
|
||||||
|
chainageId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTicketListParams({
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
status,
|
||||||
|
chainageId,
|
||||||
|
}: UseTicketsQueryParams): TicketListParams {
|
||||||
|
return {
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
status: status === 'all' ? undefined : status,
|
||||||
|
chainage_id: chainageId || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTicketsQuery(params: UseTicketsQueryParams) {
|
||||||
|
const { skip, limit, status, chainageId } = params;
|
||||||
|
const listParams = useMemo(
|
||||||
|
() => buildTicketListParams({ skip, limit, status, chainageId }),
|
||||||
|
[chainageId, limit, skip, status],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ticketKeys.list(listParams),
|
||||||
|
queryFn: () => ticketService.getTickets(listParams),
|
||||||
|
staleTime: TICKET_TABLE_REFRESH_MS,
|
||||||
|
refetchInterval: TICKET_TABLE_REFRESH_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTicketDetailQuery(
|
||||||
|
ticketId: string | undefined,
|
||||||
|
defectClass: string | undefined,
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ticketKeys.detail(ticketId ?? '', defectClass),
|
||||||
|
queryFn: () =>
|
||||||
|
ticketService.getTicketDetail(ticketId as string, defectClass),
|
||||||
|
enabled: Boolean(ticketId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTicketDefectClassesQuery(ticketId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ticketKeys.defectClasses(ticketId ?? ''),
|
||||||
|
queryFn: () => ticketService.getTicketDefectClasses(ticketId as string),
|
||||||
|
enabled: Boolean(ticketId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssignableTicketUsersQuery(enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ticketKeys.assignableUsers(),
|
||||||
|
queryFn: () => ticketService.getAssignableUsers(),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeTicketDetailResponse(
|
||||||
|
current: TicketDetail | undefined,
|
||||||
|
next: TicketDetail,
|
||||||
|
) {
|
||||||
|
if (!current) return next;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
...next,
|
||||||
|
history: next.history ?? current.history,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssignTicketMutation(ticketId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: AssignTicketPayload) =>
|
||||||
|
ticketService.assignTicket(ticketId, payload),
|
||||||
|
onSuccess: (ticket, payload) => {
|
||||||
|
toast.success('Ticket assigned');
|
||||||
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId, payload.defect_class),
|
||||||
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to assign ticket'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStartTicketMutation(ticketId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => ticketService.startTicket(ticketId),
|
||||||
|
onSuccess: (ticket) => {
|
||||||
|
toast.success('Ticket started');
|
||||||
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId),
|
||||||
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to start ticket'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubmitRepairMutation(ticketId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: SubmitRepairPayload) =>
|
||||||
|
ticketService.submitRepair(ticketId, payload),
|
||||||
|
onSuccess: (ticket) => {
|
||||||
|
toast.success('Repair submitted');
|
||||||
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId),
|
||||||
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to submit repair'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubmitRepairUploadMutation(ticketId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: SubmitRepairUploadPayload) =>
|
||||||
|
ticketService.submitRepairUpload(ticketId, payload),
|
||||||
|
onSuccess: (ticket, payload) => {
|
||||||
|
toast.success('Repair evidence uploaded');
|
||||||
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId, payload.defect_class),
|
||||||
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to upload repair evidence'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReviewRepairMutation(ticketId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: ReviewRepairPayload) =>
|
||||||
|
ticketService.reviewRepair(ticketId, payload),
|
||||||
|
onSuccess: (ticket, payload) => {
|
||||||
|
toast.success(
|
||||||
|
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
|
||||||
|
);
|
||||||
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId, payload.defect_class),
|
||||||
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to review repair'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCloseTicketMutation(ticketId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: CloseTicketPayload) =>
|
||||||
|
ticketService.closeTicket(ticketId, payload),
|
||||||
|
onSuccess: (ticket, payload) => {
|
||||||
|
toast.success('Ticket closed');
|
||||||
|
queryClient.setQueryData<TicketDetail>(
|
||||||
|
ticketKeys.detail(ticketId, payload.defect_class),
|
||||||
|
(current) => mergeTicketDetailResponse(current, ticket),
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to close ticket'),
|
||||||
|
});
|
||||||
|
}
|
||||||
16
src/app/(modules)/ticket/layout.tsx
Normal file
16
src/app/(modules)/ticket/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function TicketLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.TICKET.READ}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/app/(modules)/ticket/page.tsx
Normal file
90
src/app/(modules)/ticket/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Ticket } from 'lucide-react';
|
||||||
|
import type { TicketListItem, TicketStatus } from '@/types';
|
||||||
|
|
||||||
|
import { TicketFilters } from './components/TicketFilters';
|
||||||
|
import { TicketTable } from './components/TicketTable';
|
||||||
|
import { useTicketColumns } from './components/TicketColumns';
|
||||||
|
import { useTenantTicketTableEvents } from './hooks/useTenantTicketTableEvents';
|
||||||
|
import { useTicketsQuery } from './hooks/useTicketQueries';
|
||||||
|
|
||||||
|
export default function TicketPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [skip, setSkip] = useState(0);
|
||||||
|
const [limit, setLimit] = useState(10);
|
||||||
|
const [status, setStatus] = useState<TicketStatus | 'all'>('all');
|
||||||
|
const [segmentId, setSegmentId] = useState('');
|
||||||
|
|
||||||
|
useTenantTicketTableEvents();
|
||||||
|
|
||||||
|
const ticketsQuery = useTicketsQuery({
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
status,
|
||||||
|
chainageId: segmentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useTicketColumns();
|
||||||
|
const tickets = ticketsQuery.data?.items ?? [];
|
||||||
|
const total = ticketsQuery.data?.total ?? 0;
|
||||||
|
|
||||||
|
const handleStatusChange = useCallback((nextStatus: TicketStatus | 'all') => {
|
||||||
|
setStatus(nextStatus);
|
||||||
|
setSkip(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSegmentChange = useCallback((nextSegmentId: string) => {
|
||||||
|
setSegmentId(nextSegmentId);
|
||||||
|
setSkip(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleView = useCallback(
|
||||||
|
(ticket: TicketListItem) => {
|
||||||
|
const defectClassQuery = ticket.default_defect_class
|
||||||
|
? `?defect_class=${encodeURIComponent(ticket.default_defect_class)}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
router.push(`/ticket/${ticket.id}${defectClassQuery}`);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolbar = useMemo(
|
||||||
|
() => (
|
||||||
|
<TicketFilters
|
||||||
|
status={status}
|
||||||
|
segmentId={segmentId}
|
||||||
|
onStatusChange={handleStatusChange}
|
||||||
|
onSegmentChange={handleSegmentChange}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[handleSegmentChange, handleStatusChange, segmentId, status],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative z-10 space-y-5">
|
||||||
|
<PageHeader
|
||||||
|
title="Tickets"
|
||||||
|
description="Track detection, assignment, repair, review, and closure."
|
||||||
|
icon={Ticket}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TicketTable
|
||||||
|
columns={columns}
|
||||||
|
tickets={tickets}
|
||||||
|
isLoading={ticketsQuery.isLoading}
|
||||||
|
toolbar={toolbar}
|
||||||
|
skip={skip}
|
||||||
|
limit={limit}
|
||||||
|
total={total}
|
||||||
|
onPageChange={setSkip}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
onView={handleView}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/app/(modules)/ticket/queries/ticketKeys.ts
Normal file
13
src/app/(modules)/ticket/queries/ticketKeys.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { TicketListParams } from '@/types';
|
||||||
|
|
||||||
|
export const ticketKeys = {
|
||||||
|
all: ['tickets'] as const,
|
||||||
|
lists: () => [...ticketKeys.all, 'list'] as const,
|
||||||
|
list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const,
|
||||||
|
details: () => [...ticketKeys.all, 'detail'] as const,
|
||||||
|
detail: (ticketId: string, defectClass?: string) =>
|
||||||
|
[...ticketKeys.details(), ticketId, defectClass] as const,
|
||||||
|
defectClasses: (ticketId: string) =>
|
||||||
|
[...ticketKeys.detail(ticketId), 'defect-classes'] as const,
|
||||||
|
assignableUsers: () => [...ticketKeys.all, 'assignable-users'] as const,
|
||||||
|
};
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react';
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
|
||||||
import { sessionService, videoService } from '@/services/api';
|
|
||||||
import { SessionContext } from '@/types';
|
|
||||||
import { ROUTES } from '@/utils/routes';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
|
||||||
const WS_URL = API_URL?.replace(/^https:\/\//, 'wss://').replace(
|
|
||||||
/^http:\/\//,
|
|
||||||
'ws://',
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function VideoProcessingPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { videoId } = useParams() as { videoId: string };
|
|
||||||
const [session, setSession] = useState<SessionContext | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
// Processing states
|
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
const [statusMessage, setStatusMessage] = useState('Initializing...');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
|
||||||
|
|
||||||
const connectWebSocket = useCallback(
|
|
||||||
(vid: string) => {
|
|
||||||
if (wsRef.current) return wsRef.current;
|
|
||||||
|
|
||||||
const ws = new WebSocket(`${WS_URL}/ws/${vid}`);
|
|
||||||
wsRef.current = ws;
|
|
||||||
|
|
||||||
ws.onmessage = async (event) => {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
|
|
||||||
if (data.type === 'progress' || data.progress !== undefined) {
|
|
||||||
setProgress(data.progress || 0);
|
|
||||||
let message = data.message || 'Processing...';
|
|
||||||
const uniqueCount =
|
|
||||||
data.unique_potholes ??
|
|
||||||
data.unique_pothole ??
|
|
||||||
data.unique_signboards ??
|
|
||||||
data.unique_signboard ??
|
|
||||||
data.unique_culverts ??
|
|
||||||
data.unique_culvert ??
|
|
||||||
data.unique_drain_issue;
|
|
||||||
if (uniqueCount !== undefined) {
|
|
||||||
message += ` | Unique: ${uniqueCount} | Total: ${data.total_detections || 0}`;
|
|
||||||
}
|
|
||||||
setStatusMessage(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === 'complete' || data.status === 'completed') {
|
|
||||||
setStatusMessage('Processing completed! Finalizing...');
|
|
||||||
ws.close();
|
|
||||||
|
|
||||||
// Navigate to results
|
|
||||||
setTimeout(() => router.push(`/results/${vid}`), 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === 'error') {
|
|
||||||
setError('Error: ' + data.message);
|
|
||||||
setStatusMessage('');
|
|
||||||
ws.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
setStatusMessage('Connection lost. Reconnecting...');
|
|
||||||
wsRef.current = null;
|
|
||||||
setTimeout(() => connectWebSocket(vid), 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
wsRef.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return ws;
|
|
||||||
},
|
|
||||||
[router],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const storedSession = sessionService.loadSession();
|
|
||||||
setSession(storedSession);
|
|
||||||
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const checkStatus = async () => {
|
|
||||||
try {
|
|
||||||
const statusData = await videoService.getVideoStatus(videoId);
|
|
||||||
|
|
||||||
if (!isMounted) return;
|
|
||||||
|
|
||||||
if (statusData.status === 'completed') {
|
|
||||||
router.replace(`/results/${videoId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusData.status === 'error') {
|
|
||||||
setError(
|
|
||||||
statusData.message || 'An error occurred during processing.',
|
|
||||||
);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If processing, start WebSocket
|
|
||||||
setProgress(statusData.progress || 0);
|
|
||||||
setStatusMessage(statusData.message || 'Resuming processing...');
|
|
||||||
connectWebSocket(videoId);
|
|
||||||
setIsLoading(false);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Status check failed:', err);
|
|
||||||
if (isMounted) {
|
|
||||||
setError('Failed to connect to server.');
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (videoId) {
|
|
||||||
checkStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
if (wsRef.current) {
|
|
||||||
wsRef.current.close();
|
|
||||||
wsRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [videoId, router, connectWebSocket]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<Card className="p-8">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
<main className="min-h-screen flex flex-col">
|
|
||||||
<div className="flex-1 container mx-auto px-6 py-6 max-w-340 flex flex-col">
|
|
||||||
<div className="mb-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Uploading Analysis"
|
|
||||||
description={`Real-time analysis progress for video ID: ${videoId}`}
|
|
||||||
icon={TrendingUp}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{session && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<Card className="p-0 border shadow-sm overflow-hidden">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center py-4 px-6 gap-6 bg-card">
|
|
||||||
<div className="flex flex-wrap items-center gap-x-12 gap-y-4 flex-1">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Project
|
|
||||||
</span>
|
|
||||||
<span className="text-base font-bold leading-tight">
|
|
||||||
{session.projectName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col border-l pl-12 border-border/60">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Package
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
|
||||||
{session.packageName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col border-l pl-12 border-border/60">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Segment
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-muted-foreground leading-tight flex items-center gap-1.5">
|
|
||||||
{session.chainageName}
|
|
||||||
{session.chainageDirection && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-1 py-0.5 rounded bg-muted text-[10px] font-bold uppercase border">
|
|
||||||
{session.chainageDirection === 'UP' ? (
|
|
||||||
<ArrowUp className="h-2.5 w-2.5" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown className="h-2.5 w-2.5" />
|
|
||||||
)}
|
|
||||||
{session.chainageDirection}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className="flex-1 flex flex-col items-center justify-center py-12 px-8 min-h-[450px]">
|
|
||||||
<div className="flex flex-col items-center justify-center w-full max-w-2xl space-y-10">
|
|
||||||
<div className="text-center space-y-4">
|
|
||||||
<h2 className="text-3xl font-bold tracking-tight">
|
|
||||||
Uploading ...
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm font-mono text-muted-foreground tracking-widest">
|
|
||||||
ID: {videoId}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full space-y-4">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="font-bold text-muted-foreground text-xs uppercase tracking-widest">
|
|
||||||
Progress
|
|
||||||
</span>
|
|
||||||
<span className="font-bold text-primary text-lg">
|
|
||||||
{progress}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-4 rounded-full bg-secondary overflow-hidden border">
|
|
||||||
<div
|
|
||||||
className="h-full bg-primary transition-all duration-1000 ease-in-out"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-center pt-2">
|
|
||||||
<div className="inline-flex items-center gap-3 px-4 py-2 rounded-full border bg-secondary/30">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
|
||||||
<p className="text-sm font-semibold">{statusMessage}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="w-full p-6 rounded-md bg-destructive/10 border border-destructive/20 text-center space-y-3">
|
|
||||||
<p className="text-destructive font-medium">{error}</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
>
|
|
||||||
Retry Connection
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
79
src/app/(modules)/upload/components/CardPagination.tsx
Normal file
79
src/app/(modules)/upload/components/CardPagination.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
|
const pageSizes = [10, 20, 40, 50, 100];
|
||||||
|
|
||||||
|
interface CardPaginationProps {
|
||||||
|
skip: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
count: number;
|
||||||
|
onPageChange: (skip: number) => void;
|
||||||
|
onLimitChange: (limit: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardPagination({
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
count,
|
||||||
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
}: CardPaginationProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 text-sm text-muted-foreground md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span>Showing {count} of {total}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>Rows per page</span>
|
||||||
|
<Select
|
||||||
|
value={String(limit)}
|
||||||
|
onValueChange={(value) => onLimitChange(Number(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-18 bg-background">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{pageSizes.map((size) => (
|
||||||
|
<SelectItem key={size} value={String(size)}>
|
||||||
|
{size}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={skip === 0}
|
||||||
|
onClick={() => onPageChange(Math.max(0, skip - limit))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={skip + limit >= total}
|
||||||
|
onClick={() => onPageChange(skip + limit)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
src/app/(modules)/upload/components/CreateUploadDialog.tsx
Normal file
172
src/app/(modules)/upload/components/CreateUploadDialog.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Loader2, Play } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import type { SessionContext } from '@/types';
|
||||||
|
|
||||||
|
import { useCreateUploadMutation } from '../hooks/useUploadQueries';
|
||||||
|
import { InputDataSection } from './InputDataSection';
|
||||||
|
import { LocationSection } from './LocationSection';
|
||||||
|
import { UploadSettingsSection } from './UploadSettingsSection';
|
||||||
|
|
||||||
|
interface CreateUploadDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateUploadDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: CreateUploadDialogProps) {
|
||||||
|
const [session, setSession] = useState<SessionContext | null>(null);
|
||||||
|
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||||
|
const [gpsFile, setGpsFile] = useState<File | null>(null);
|
||||||
|
const [detectionMode, setDetectionMode] = useState('yolo');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const createUploadMutation = useCreateUploadMutation();
|
||||||
|
const isSubmitting = createUploadMutation.isPending;
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setSession(null);
|
||||||
|
setVideoFile(null);
|
||||||
|
setGpsFile(null);
|
||||||
|
setDetectionMode('yolo');
|
||||||
|
setError(null);
|
||||||
|
createUploadMutation.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSubmit = Boolean(
|
||||||
|
session?.chainageId && videoFile && gpsFile && detectionMode,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenChange = (nextOpen: boolean) => {
|
||||||
|
onOpenChange(nextOpen);
|
||||||
|
if (!nextOpen) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!session?.chainageId || !videoFile || !gpsFile) {
|
||||||
|
setError('Complete location and input data before uploading.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', videoFile);
|
||||||
|
formData.append('detection_mode', detectionMode);
|
||||||
|
formData.append('chainage_id', session.chainageId);
|
||||||
|
formData.append('json_file', gpsFile);
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createUploadMutation.mutateAsync(formData);
|
||||||
|
handleOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TypeError && err.message === 'Failed to fetch') {
|
||||||
|
setError(
|
||||||
|
'Cannot connect to server. Please check if backend is running.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-5xl"
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||||
|
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||||
|
Upload Video
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure location and upload road inspection data.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||||
|
<LocationSection
|
||||||
|
value={session}
|
||||||
|
onChange={setSession}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
portalContainer={dropdownPortalRef}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<InputDataSection
|
||||||
|
videoFile={videoFile}
|
||||||
|
gpsFile={gpsFile}
|
||||||
|
onVideoFileChange={(file) => {
|
||||||
|
setVideoFile(file);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onGpsFileChange={(file) => {
|
||||||
|
setGpsFile(file);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<UploadSettingsSection
|
||||||
|
value={detectionMode}
|
||||||
|
onValueChange={setDetectionMode}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
portalContainer={dropdownPortalRef}
|
||||||
|
/>
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={!canSubmit || isSubmitting}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Uploading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="size-4" />
|
||||||
|
Upload
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
|
||||||
|
<div ref={dropdownPortalRef} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/app/(modules)/upload/components/InputDataSection.tsx
Normal file
50
src/app/(modules)/upload/components/InputDataSection.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { UploadFileDropzone } from './UploadFileDropzone';
|
||||||
|
|
||||||
|
interface InputDataSectionProps {
|
||||||
|
videoFile: File | null;
|
||||||
|
gpsFile: File | null;
|
||||||
|
onVideoFileChange: (file: File | null) => void;
|
||||||
|
onGpsFileChange: (file: File | null) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InputDataSection({
|
||||||
|
videoFile,
|
||||||
|
gpsFile,
|
||||||
|
onVideoFileChange,
|
||||||
|
onGpsFileChange,
|
||||||
|
disabled = false,
|
||||||
|
}: InputDataSectionProps) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">Input Data</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Attach the road video and GPS telemetry for processing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<UploadFileDropzone
|
||||||
|
title="Drop video here or browse"
|
||||||
|
description="Accepted formats: MP4, MOV, AVI, MKV"
|
||||||
|
accept="video/*"
|
||||||
|
file={videoFile}
|
||||||
|
onFileChange={onVideoFileChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<UploadFileDropzone
|
||||||
|
title="Drop GPS JSON here or browse"
|
||||||
|
description="Accepted format: JSON telemetry file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
file={gpsFile}
|
||||||
|
onFileChange={onGpsFileChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
159
src/app/(modules)/upload/components/LocationSection.tsx
Normal file
159
src/app/(modules)/upload/components/LocationSection.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { RefObject } from 'react';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { FormField } from '@/components/form';
|
||||||
|
import { PackageSelect } from '@/components/lookups/PackageSelect';
|
||||||
|
import { ProjectSelect } from '@/components/lookups/ProjectSelect';
|
||||||
|
import { SegmentSelect } from '@/components/lookups/SegmentSelect';
|
||||||
|
import {
|
||||||
|
packageService,
|
||||||
|
projectService,
|
||||||
|
chainageService,
|
||||||
|
} from '@/services/api';
|
||||||
|
import type {
|
||||||
|
Chainage,
|
||||||
|
Package as PackageType,
|
||||||
|
Project,
|
||||||
|
SessionContext,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
interface LocationSectionProps {
|
||||||
|
value: SessionContext | null;
|
||||||
|
onChange: (session: SessionContext | null) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
portalContainer?: RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocationSection({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
portalContainer,
|
||||||
|
}: LocationSectionProps) {
|
||||||
|
const [projectId, setProjectId] = useState(value?.projectId ?? '');
|
||||||
|
const [packageId, setPackageId] = useState(value?.packageId ?? '');
|
||||||
|
const [segmentId, setSegmentId] = useState(value?.chainageId ?? '');
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [selectedPackage, setSelectedPackage] = useState<PackageType | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [segment, setSegment] = useState<Chainage | null>(null);
|
||||||
|
|
||||||
|
const latestProjectIdRef = useRef('');
|
||||||
|
const latestPackageIdRef = useRef('');
|
||||||
|
const latestSegmentIdRef = useRef('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (project && selectedPackage && segment) {
|
||||||
|
onChange({
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.name,
|
||||||
|
packageId: selectedPackage.id,
|
||||||
|
packageName: selectedPackage.name,
|
||||||
|
chainageId: segment.id,
|
||||||
|
chainageName: segment.segment_name,
|
||||||
|
chainageDirection: segment.direction,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(null);
|
||||||
|
}, [onChange, project, selectedPackage, segment]);
|
||||||
|
|
||||||
|
const handleProjectChange = useCallback((nextProjectId: string) => {
|
||||||
|
latestProjectIdRef.current = nextProjectId;
|
||||||
|
latestPackageIdRef.current = '';
|
||||||
|
latestSegmentIdRef.current = '';
|
||||||
|
setProjectId(nextProjectId);
|
||||||
|
setPackageId('');
|
||||||
|
setSegmentId('');
|
||||||
|
setProject(null);
|
||||||
|
setSelectedPackage(null);
|
||||||
|
setSegment(null);
|
||||||
|
|
||||||
|
if (!nextProjectId) return;
|
||||||
|
|
||||||
|
void projectService.getProjectById(nextProjectId).then((nextProject) => {
|
||||||
|
if (latestProjectIdRef.current === nextProjectId) {
|
||||||
|
setProject(nextProject);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePackageChange = useCallback((nextPackageId: string) => {
|
||||||
|
latestPackageIdRef.current = nextPackageId;
|
||||||
|
latestSegmentIdRef.current = '';
|
||||||
|
setPackageId(nextPackageId);
|
||||||
|
setSegmentId('');
|
||||||
|
setSelectedPackage(null);
|
||||||
|
setSegment(null);
|
||||||
|
|
||||||
|
if (!nextPackageId) return;
|
||||||
|
|
||||||
|
void packageService.getPackageById(nextPackageId).then((nextPackage) => {
|
||||||
|
if (latestPackageIdRef.current === nextPackageId) {
|
||||||
|
setSelectedPackage(nextPackage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSegmentChange = useCallback((nextSegmentId: string) => {
|
||||||
|
latestSegmentIdRef.current = nextSegmentId;
|
||||||
|
setSegmentId(nextSegmentId);
|
||||||
|
setSegment(null);
|
||||||
|
|
||||||
|
if (!nextSegmentId) return;
|
||||||
|
|
||||||
|
void chainageService.getChainageById(nextSegmentId).then((nextSegment) => {
|
||||||
|
if (latestSegmentIdRef.current === nextSegmentId) {
|
||||||
|
setSegment(nextSegment);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">Location</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Select the project hierarchy for this upload.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
|
<FormField label="Project" required>
|
||||||
|
<ProjectSelect
|
||||||
|
value={projectId}
|
||||||
|
onValueChange={handleProjectChange}
|
||||||
|
disabled={disabled}
|
||||||
|
portalContainer={portalContainer}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Package" required>
|
||||||
|
<PackageSelect
|
||||||
|
value={packageId}
|
||||||
|
onValueChange={handlePackageChange}
|
||||||
|
projectId={projectId}
|
||||||
|
enabled={Boolean(projectId)}
|
||||||
|
disabled={disabled || !projectId}
|
||||||
|
portalContainer={portalContainer}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Segment" required>
|
||||||
|
<SegmentSelect
|
||||||
|
value={segmentId}
|
||||||
|
onValueChange={handleSegmentChange}
|
||||||
|
packageId={packageId}
|
||||||
|
enabled={Boolean(packageId)}
|
||||||
|
disabled={disabled || !packageId}
|
||||||
|
portalContainer={portalContainer}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/app/(modules)/upload/components/UploadActionsMenu.tsx
Normal file
63
src/app/(modules)/upload/components/UploadActionsMenu.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Eye, MoreVertical, PlayCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import type { UploadVideo } from '@/types';
|
||||||
|
|
||||||
|
interface UploadActionsMenuProps {
|
||||||
|
upload: UploadVideo;
|
||||||
|
onViewVideo: (upload: UploadVideo) => void;
|
||||||
|
onViewDetails: (upload: UploadVideo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadActionsMenu({
|
||||||
|
upload,
|
||||||
|
onViewVideo,
|
||||||
|
onViewDetails,
|
||||||
|
}: UploadActionsMenuProps) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreVertical className="size-4" />
|
||||||
|
<span className="sr-only">Open actions</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{upload.video_url ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onViewVideo(upload);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlayCircle className="size-4" />
|
||||||
|
View Video
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onViewDetails(upload);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
View Details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/app/(modules)/upload/components/UploadCardGrid.tsx
Normal file
107
src/app/(modules)/upload/components/UploadCardGrid.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FileVideo } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import type { UploadVideo } from '@/types';
|
||||||
|
|
||||||
|
import { CardPagination } from './CardPagination';
|
||||||
|
import { UploadActionsMenu } from './UploadActionsMenu';
|
||||||
|
import { UploadThumbnail } from './UploadThumbnail';
|
||||||
|
import { formatUploadedAt } from './uploadUtils';
|
||||||
|
|
||||||
|
interface UploadCardGridProps {
|
||||||
|
uploads: UploadVideo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
skip: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
onPageChange: (skip: number) => void;
|
||||||
|
onLimitChange: (limit: number) => void;
|
||||||
|
onUploadNew: () => void;
|
||||||
|
onOpenUpload: (upload: UploadVideo) => void;
|
||||||
|
onViewVideo: (upload: UploadVideo) => void;
|
||||||
|
onViewDetails: (upload: UploadVideo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadCardGrid({
|
||||||
|
uploads,
|
||||||
|
isLoading,
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
onUploadNew,
|
||||||
|
onOpenUpload,
|
||||||
|
onViewVideo,
|
||||||
|
onViewDetails,
|
||||||
|
}: UploadCardGridProps) {
|
||||||
|
const skeletonItems = Array.from({ length: 6 });
|
||||||
|
|
||||||
|
if (!isLoading && uploads.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
|
||||||
|
<div className="flex size-12 items-center justify-center rounded-md border bg-muted/40">
|
||||||
|
<FileVideo className="size-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base">No uploads found</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Upload your first road inspection video.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={onUploadNew}>Upload New</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
|
||||||
|
{isLoading
|
||||||
|
? skeletonItems.map((_, index) => (
|
||||||
|
<Card key={index} className="h-62 animate-pulse bg-muted/30" />
|
||||||
|
))
|
||||||
|
: uploads.map((upload) => (
|
||||||
|
<Card
|
||||||
|
key={upload.video_id}
|
||||||
|
className="cursor-pointer overflow-hidden transition-colors hover:bg-muted/30"
|
||||||
|
onClick={() => onOpenUpload(upload)}
|
||||||
|
>
|
||||||
|
<CardContent className="space-y-3 p-3">
|
||||||
|
<UploadThumbnail upload={upload} />
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">
|
||||||
|
{upload.filename}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Uploaded {formatUploadedAt(upload.uploaded_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UploadActionsMenu
|
||||||
|
upload={upload}
|
||||||
|
onViewVideo={onViewVideo}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardPagination
|
||||||
|
skip={skip}
|
||||||
|
limit={limit}
|
||||||
|
total={total}
|
||||||
|
count={uploads.length}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onLimitChange={onLimitChange}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/app/(modules)/upload/components/UploadColumns.tsx
Normal file
77
src/app/(modules)/upload/components/UploadColumns.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { UploadActionsMenu } from './UploadActionsMenu';
|
||||||
|
import { UploadThumbnail } from './UploadThumbnail';
|
||||||
|
import { formatUploadedAt, getUploadStatus } from './uploadUtils';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import type { UploadVideo } from '@/types';
|
||||||
|
|
||||||
|
interface UseUploadColumnsProps {
|
||||||
|
onViewVideo: (upload: UploadVideo) => void;
|
||||||
|
onViewDetails: (upload: UploadVideo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadColumns({
|
||||||
|
onViewVideo,
|
||||||
|
onViewDetails,
|
||||||
|
}: UseUploadColumnsProps) {
|
||||||
|
return useMemo<ColumnDef<UploadVideo>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'thumbnail',
|
||||||
|
header: 'Thumbnail',
|
||||||
|
size: 140,
|
||||||
|
minSize: 120,
|
||||||
|
enableSorting: false,
|
||||||
|
meta: { disableTruncate: true },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<UploadThumbnail upload={row.original} className="w-28" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'filename',
|
||||||
|
header: 'Filename',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium">{row.original.filename}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
size: 130,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge className="rounded-full">
|
||||||
|
{getUploadStatus(row.original)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'uploaded_at',
|
||||||
|
header: 'Uploaded At',
|
||||||
|
cell: ({ row }) => formatUploadedAt(row.original.uploaded_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: () => <span className="sr-only">Actions</span>,
|
||||||
|
size: 56,
|
||||||
|
minSize: 56,
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
meta: { disableTruncate: true },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<UploadActionsMenu
|
||||||
|
upload={row.original}
|
||||||
|
onViewVideo={onViewVideo}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onViewDetails, onViewVideo],
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/app/(modules)/upload/components/UploadDetailsDialog.tsx
Normal file
79
src/app/(modules)/upload/components/UploadDetailsDialog.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import type { UploadVideo } from '@/types';
|
||||||
|
|
||||||
|
import { formatUploadedAt, getUploadStatus } from './uploadUtils';
|
||||||
|
|
||||||
|
interface UploadDetailsDialogProps {
|
||||||
|
upload: UploadVideo | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadDetailsDialog({
|
||||||
|
upload,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: UploadDetailsDialogProps) {
|
||||||
|
const title = upload?.filename ?? 'Upload Details';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader className="min-w-0 pr-8">
|
||||||
|
<DialogTitle className="min-w-0 max-w-full overflow-hidden text-left">
|
||||||
|
{upload?.filename ? (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span
|
||||||
|
className="block w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-80 break-all">
|
||||||
|
{title}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : (
|
||||||
|
title
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>Uploaded video details</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{upload ? (
|
||||||
|
<dl className="grid grid-cols-[120px_1fr] gap-x-4 gap-y-3 text-sm">
|
||||||
|
<dt className="text-muted-foreground">Status</dt>
|
||||||
|
<dd>
|
||||||
|
<Badge className="rounded-full">{getUploadStatus(upload)}</Badge>
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted-foreground">Uploaded At</dt>
|
||||||
|
<dd>{formatUploadedAt(upload.uploaded_at)}</dd>
|
||||||
|
<dt className="text-muted-foreground">Uploaded By</dt>
|
||||||
|
<dd>{upload.uploaded_by_email ?? '-'}</dd>
|
||||||
|
<dt className="text-muted-foreground">Video ID</dt>
|
||||||
|
<dd className="break-all font-mono text-xs">{upload.video_id}</dd>
|
||||||
|
</dl>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/app/(modules)/upload/components/UploadFileDropzone.tsx
Normal file
93
src/app/(modules)/upload/components/UploadFileDropzone.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { FileCheck2, UploadCloud } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface UploadFileDropzoneProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
accept: string;
|
||||||
|
file: File | null;
|
||||||
|
onFileChange: (file: File | null) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadFileDropzone({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
accept,
|
||||||
|
file,
|
||||||
|
onFileChange,
|
||||||
|
disabled = false,
|
||||||
|
}: UploadFileDropzoneProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const handleFiles = (files: FileList | null) => {
|
||||||
|
if (disabled) return;
|
||||||
|
onFileChange(files?.[0] ?? null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) inputRef.current?.click();
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (!disabled && (event.key === 'Enter' || event.key === ' ')) {
|
||||||
|
event.preventDefault();
|
||||||
|
inputRef.current?.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!disabled) setIsDragging(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setIsDragging(false)}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
handleFiles(event.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'group flex min-h-32 cursor-pointer flex-col justify-between rounded-md border border-dashed bg-card/60 p-4 transition-colors',
|
||||||
|
'hover:border-primary/60 hover:bg-secondary/40',
|
||||||
|
isDragging && 'border-primary bg-secondary/60',
|
||||||
|
disabled && 'pointer-events-none cursor-not-allowed opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => handleFiles(event.target.files)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex size-9 items-center justify-center rounded-md border bg-background">
|
||||||
|
<UploadCloud className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">{title}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{file ? (
|
||||||
|
<Badge variant="secondary" className="mt-4 max-w-full justify-start">
|
||||||
|
<FileCheck2 className="size-3" />
|
||||||
|
<span className="truncate">{file.name}</span>
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { RefObject } from 'react';
|
||||||
|
|
||||||
|
import { FormField, SelectPopover } from '@/components/form';
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: 'yolo', label: 'Road Defect Detection' },
|
||||||
|
{ value: 'yoloe', label: 'YOLOE Open-Vocabulary Detection' },
|
||||||
|
{ value: 'yoloe_trained_vl', label: 'YOLOE With Vision Language Model' },
|
||||||
|
{ value: 'culvert_detection', label: 'Culvert Detection' },
|
||||||
|
{ value: 'combined', label: 'Road Defect Detection with vl' },
|
||||||
|
{ value: 'yoloe_seg', label: 'Improved Road Defect Detection' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface UploadSettingsSectionProps {
|
||||||
|
value: string;
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
portalContainer?: RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadSettingsSection({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
disabled = false,
|
||||||
|
portalContainer,
|
||||||
|
}: UploadSettingsSectionProps) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">
|
||||||
|
Upload Settings
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Select the detection workflow for this upload.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<FormField id="detection-mode" label="Detection Mode">
|
||||||
|
<SelectPopover
|
||||||
|
options={options}
|
||||||
|
value={value}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
disabled={disabled}
|
||||||
|
portalContainer={portalContainer}
|
||||||
|
placeholder="Select method"
|
||||||
|
searchPlaceholder="Search methods"
|
||||||
|
emptyMessage="No methods found."
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/app/(modules)/upload/components/UploadTable.tsx
Normal file
50
src/app/(modules)/upload/components/UploadTable.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import type { UploadVideo } from '@/types';
|
||||||
|
|
||||||
|
interface UploadTableProps {
|
||||||
|
columns: ColumnDef<UploadVideo>[];
|
||||||
|
uploads: UploadVideo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
skip: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
onPageChange: (skip: number) => void;
|
||||||
|
onLimitChange: (limit: number) => void;
|
||||||
|
onRowClick: (upload: UploadVideo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadTable({
|
||||||
|
columns,
|
||||||
|
uploads,
|
||||||
|
isLoading,
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
onRowClick,
|
||||||
|
}: UploadTableProps) {
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
data={uploads}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyTitle="No uploads found"
|
||||||
|
emptyDescription="Upload your first road inspection video."
|
||||||
|
pagination={{
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
totalItems: total,
|
||||||
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
}}
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
enableColumnControls={false}
|
||||||
|
minColumnSize={120}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/app/(modules)/upload/components/UploadThumbnail.tsx
Normal file
33
src/app/(modules)/upload/components/UploadThumbnail.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { ImageIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { UploadVideo } from '@/types';
|
||||||
|
|
||||||
|
import { resolveMediaUrl } from './uploadUtils';
|
||||||
|
|
||||||
|
interface UploadThumbnailProps {
|
||||||
|
upload: UploadVideo;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadThumbnail({ upload, className }: UploadThumbnailProps) {
|
||||||
|
const thumbnailUrl = resolveMediaUrl(upload.thumbnail);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="aspect-video overflow-hidden rounded-md border bg-muted/40">
|
||||||
|
{thumbnailUrl ? (
|
||||||
|
<img
|
||||||
|
src={thumbnailUrl}
|
||||||
|
alt={upload.filename}
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
||||||
|
<ImageIcon className="size-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/app/(modules)/upload/components/UploadVideoDialog.tsx
Normal file
44
src/app/(modules)/upload/components/UploadVideoDialog.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import type { UploadVideo } from '@/types';
|
||||||
|
|
||||||
|
interface UploadVideoDialogProps {
|
||||||
|
upload: UploadVideo | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadVideoDialog({
|
||||||
|
upload,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: UploadVideoDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="gap-3 p-3 sm:max-w-5xl sm:p-4">
|
||||||
|
<DialogHeader className="min-w-0 pr-8">
|
||||||
|
<DialogTitle className="truncate text-left text-base">
|
||||||
|
Video Preview
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="truncate text-xs">
|
||||||
|
{upload?.filename ?? 'Uploaded video'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ProtectedVideoPlayer
|
||||||
|
url={open ? upload?.video_url : undefined}
|
||||||
|
className="rounded-lg"
|
||||||
|
unavailableTitle="Video preview unavailable"
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/app/(modules)/upload/components/ViewSwitcher.tsx
Normal file
46
src/app/(modules)/upload/components/ViewSwitcher.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Grid2X2, Table2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import type { UploadViewMode } from '../hooks/useUploadFilters';
|
||||||
|
|
||||||
|
interface ViewSwitcherProps {
|
||||||
|
value: UploadViewMode;
|
||||||
|
onValueChange: (value: UploadViewMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ViewSwitcher({ value, onValueChange }: ViewSwitcherProps) {
|
||||||
|
const items: Array<{
|
||||||
|
value: UploadViewMode;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}> = [
|
||||||
|
{ value: 'card', label: 'Card View', icon: <Grid2X2 className="size-4" /> },
|
||||||
|
{ value: 'table', label: 'Table View', icon: <Table2 className="size-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex rounded-md border bg-card p-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onValueChange(item.value)}
|
||||||
|
className={cn(
|
||||||
|
'gap-2',
|
||||||
|
value === item.value && 'bg-secondary text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
29
src/app/(modules)/upload/components/uploadUtils.ts
Normal file
29
src/app/(modules)/upload/components/uploadUtils.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { ENV_CONSTANT } from '@/constants/secrect.constant';
|
||||||
|
import type { UploadVideo } from '@/types';
|
||||||
|
|
||||||
|
export function getUploadStatus(upload: UploadVideo) {
|
||||||
|
return upload.status?.status || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUploadedAt(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '-';
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMediaUrl(url?: string | null) {
|
||||||
|
if (!url) return null;
|
||||||
|
if (/^https?:\/\//i.test(url)) return url;
|
||||||
|
|
||||||
|
const baseUrl = ENV_CONSTANT.BASE_API_URL?.replace(/\/$/, '');
|
||||||
|
const path = url.startsWith('/') ? url : `/${url}`;
|
||||||
|
return baseUrl ? `${baseUrl}${path}` : path;
|
||||||
|
}
|
||||||
|
|
||||||
27
src/app/(modules)/upload/hooks/useUploadFilters.ts
Normal file
27
src/app/(modules)/upload/hooks/useUploadFilters.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export type UploadViewMode = 'card' | 'table';
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT = 20;
|
||||||
|
|
||||||
|
export function useUploadFilters() {
|
||||||
|
const [skip, setSkip] = useState(0);
|
||||||
|
const [limit, setLimitValue] = useState(DEFAULT_LIMIT);
|
||||||
|
const [viewMode, setViewMode] = useState<UploadViewMode>('card');
|
||||||
|
|
||||||
|
const setLimit = (nextLimit: number) => {
|
||||||
|
setLimitValue(nextLimit);
|
||||||
|
setSkip(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
skip,
|
||||||
|
setSkip,
|
||||||
|
limit,
|
||||||
|
setLimit,
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
54
src/app/(modules)/upload/hooks/useUploadQueries.ts
Normal file
54
src/app/(modules)/upload/hooks/useUploadQueries.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { videoService } from '@/services/api';
|
||||||
|
import type { PaginationParams } from '@/types';
|
||||||
|
|
||||||
|
export const uploadKeys = {
|
||||||
|
all: ['uploads'] as const,
|
||||||
|
lists: () => [...uploadKeys.all, 'list'] as const,
|
||||||
|
list: (params: PaginationParams) => [...uploadKeys.lists(), params] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseMyUploadsQueryParams {
|
||||||
|
skip: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUploadListParams({
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
}: UseMyUploadsQueryParams): PaginationParams {
|
||||||
|
return {
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMyUploadsQuery(params: UseMyUploadsQueryParams) {
|
||||||
|
const { skip, limit } = params;
|
||||||
|
const listParams = useMemo(
|
||||||
|
() => buildUploadListParams({ skip, limit }),
|
||||||
|
[limit, skip],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: uploadKeys.list(listParams),
|
||||||
|
queryFn: () => videoService.getMyUploads(listParams),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateUploadMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (formData: FormData) => videoService.uploadVideo(formData),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Upload created');
|
||||||
|
queryClient.invalidateQueries({ queryKey: uploadKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
16
src/app/(modules)/upload/layout.tsx
Normal file
16
src/app/(modules)/upload/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function UploadLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.VIDEO.UPLOAD}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,303 +1,119 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { Plus, UploadCloud } from 'lucide-react';
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { Loader2, TrendingUp, ChevronRight } from 'lucide-react';
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
|
||||||
import { sessionService, videoService } from '@/services/api';
|
|
||||||
import { SessionContext } from '@/types';
|
|
||||||
import { storeVideoFile } from '@/lib/video-storage';
|
|
||||||
import { ProjectSelectionSection } from '@/components/project-selection-section';
|
|
||||||
import { Reveal } from '@/components/ui/reveal';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const DETECTION_METHODS = [
|
import { PageHeader } from '@/components/page-header';
|
||||||
{ value: 'yolo', label: 'Road Defect Detection' },
|
import { Button } from '@/components/ui/button';
|
||||||
// { value: 'yolo_vl', label: 'YOLO with Vision-Language Model' },
|
import type { UploadVideo } from '@/types';
|
||||||
// { value: 'sam3', label: 'OpenAI SAM 3 Segmentation Model' },
|
|
||||||
{ value: 'yoloe', label: 'YOLOE Open-Vocabulary Detection' },
|
import { CreateUploadDialog } from './components/CreateUploadDialog';
|
||||||
{ value: 'yoloe_trained_vl', label: 'YOLOE With Vision Language Model' },
|
import { UploadCardGrid } from './components/UploadCardGrid';
|
||||||
{ value: 'culvert_detection', label: 'Culvert Detection' },
|
import { useUploadColumns } from './components/UploadColumns';
|
||||||
{ value: 'combined', label: 'Road Defect Detection with vl' },
|
import { UploadDetailsDialog } from './components/UploadDetailsDialog';
|
||||||
// { value: 'gemini_video', label: 'Gemini AI Analysis' },
|
import { UploadTable } from './components/UploadTable';
|
||||||
] as const;
|
import { UploadVideoDialog } from './components/UploadVideoDialog';
|
||||||
|
import { ViewSwitcher } from './components/ViewSwitcher';
|
||||||
|
import { useUploadFilters } from './hooks/useUploadFilters';
|
||||||
|
import { useMyUploadsQuery } from './hooks/useUploadQueries';
|
||||||
|
|
||||||
export default function UploadPage() {
|
export default function UploadPage() {
|
||||||
const router = useRouter();
|
const { skip, setSkip, limit, setLimit, viewMode, setViewMode } =
|
||||||
const [session, setSession] = useState<SessionContext | null>(null);
|
useUploadFilters();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const uploadsQuery = useMyUploadsQuery({ skip, limit });
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [detailsUpload, setDetailsUpload] = useState<UploadVideo | null>(null);
|
||||||
|
const [videoUpload, setVideoUpload] = useState<UploadVideo | null>(null);
|
||||||
|
|
||||||
// Form states
|
const uploads = uploadsQuery.data?.items ?? [];
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const total = uploadsQuery.data?.total ?? 0;
|
||||||
const [jsonFile, setJsonFile] = useState<File | null>(null);
|
const isLoading = uploadsQuery.isLoading;
|
||||||
const [selectMethod, setSelectMethod] = useState('yolo');
|
|
||||||
|
|
||||||
// Upload states
|
const handleViewDetails = useCallback((upload: UploadVideo) => {
|
||||||
const [uploading, setUploading] = useState(false);
|
setDetailsUpload(upload);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Load session on mount
|
|
||||||
useEffect(() => {
|
|
||||||
// We don't load session on mount for the upload page to ensure
|
|
||||||
// project details are filled manually as requested.
|
|
||||||
setIsLoading(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelectionChange = useCallback(
|
const handleViewUploadVideo = useCallback((upload: UploadVideo) => {
|
||||||
(selectedSession: SessionContext | null) => {
|
setVideoUpload(upload);
|
||||||
setSession(selectedSession);
|
}, []);
|
||||||
if (selectedSession) {
|
|
||||||
sessionService.saveSession(selectedSession);
|
const handleOpenUpload = useCallback(
|
||||||
|
(upload: UploadVideo) => {
|
||||||
|
if (upload.video_url) {
|
||||||
|
handleViewUploadVideo(upload);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[handleViewUploadVideo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const columns = useUploadColumns({
|
||||||
if (!file) {
|
onViewVideo: handleViewUploadVideo,
|
||||||
setError('Please select a video file');
|
onViewDetails: handleViewDetails,
|
||||||
return;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (!jsonFile) {
|
|
||||||
setError('Please select a GPS JSON file');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
setError('Please complete the project selection first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
formData.append('detection_mode', selectMethod);
|
|
||||||
formData.append('chainage_id', session.chainageId as string);
|
|
||||||
formData.append('json_file', jsonFile);
|
|
||||||
|
|
||||||
setUploading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await videoService.uploadVideo(formData);
|
|
||||||
|
|
||||||
// Store file locally
|
|
||||||
if (file) {
|
|
||||||
try {
|
|
||||||
await storeVideoFile(result.video_id, file);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to store video file:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionService.saveVideoData({ videoId: result.video_id });
|
|
||||||
router.push(`/upload/${result.video_id}`);
|
|
||||||
} catch (err) {
|
|
||||||
let errorMessage = 'Upload failed';
|
|
||||||
if (err instanceof TypeError && err.message === 'Failed to fetch') {
|
|
||||||
errorMessage =
|
|
||||||
'Cannot connect to server. Please check if backend is running.';
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
errorMessage = err.message;
|
|
||||||
}
|
|
||||||
setError(errorMessage);
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<Loader2 className="h-10 w-10 animate-spin text-primary" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSessionComplete = !!(
|
|
||||||
session && sessionService.isSessionValid(session)
|
|
||||||
);
|
|
||||||
const isFormValid = isSessionComplete && file && jsonFile;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen pb-12">
|
<>
|
||||||
<main>
|
<main className="space-y-5">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="mb-10">
|
title="My Uploads"
|
||||||
<PageHeader
|
description="View and manage uploaded road inspection videos."
|
||||||
title="Intelligent Road Asset AI Analysis"
|
icon={UploadCloud}
|
||||||
description="High-precision detection for potholes, signboards, and culverts from video data"
|
actions={
|
||||||
icon={TrendingUp}
|
<Button onClick={() => setIsCreateOpen(true)} size="sm">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Upload New
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<ViewSwitcher value={viewMode} onValueChange={setViewMode} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewMode === 'card' ? (
|
||||||
|
<UploadCardGrid
|
||||||
|
uploads={uploads}
|
||||||
|
isLoading={isLoading}
|
||||||
|
skip={skip}
|
||||||
|
limit={limit}
|
||||||
|
total={total}
|
||||||
|
onPageChange={setSkip}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
onUploadNew={() => setIsCreateOpen(true)}
|
||||||
|
onOpenUpload={handleOpenUpload}
|
||||||
|
onViewVideo={handleViewUploadVideo}
|
||||||
|
onViewDetails={handleViewDetails}
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
|
<UploadTable
|
||||||
<div className="space-y-8 mt-8">
|
columns={columns}
|
||||||
{/* Module 1: Project Selection */}
|
uploads={uploads}
|
||||||
<Reveal direction="up" delay={0.1}>
|
isLoading={isLoading}
|
||||||
<Card className="border">
|
skip={skip}
|
||||||
<CardHeader>
|
limit={limit}
|
||||||
<CardTitle className="text-xl font-bold">
|
total={total}
|
||||||
1. Project Details
|
onPageChange={setSkip}
|
||||||
</CardTitle>
|
onLimitChange={setLimit}
|
||||||
<CardDescription>
|
onRowClick={handleOpenUpload}
|
||||||
Select the target project, package, and segment.
|
/>
|
||||||
</CardDescription>
|
)}
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ProjectSelectionSection
|
|
||||||
onSelectionChange={handleSelectionChange}
|
|
||||||
asStep={true}
|
|
||||||
hideButton={true}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
{/* Module 2: Upload Data */}
|
|
||||||
<Reveal direction="up" delay={0.2}>
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
'border transition-all duration-300',
|
|
||||||
!isSessionComplete &&
|
|
||||||
'opacity-60 pointer-events-none grayscale-[0.5]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
|
||||||
2. Upload Video Data
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Provide the video file and GPS telemetry for processing.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div className="grid grid-cols-1 gap-6 text-sm">
|
|
||||||
{/* Video File Input */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="video-file"
|
|
||||||
className="text-sm font-semibold"
|
|
||||||
>
|
|
||||||
Video File <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<div className="relative group">
|
|
||||||
<Input
|
|
||||||
id="video-file"
|
|
||||||
type="file"
|
|
||||||
accept="video/*"
|
|
||||||
onChange={(e) => {
|
|
||||||
setFile(e.target.files?.[0] || null);
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
disabled={uploading || !isSessionComplete}
|
|
||||||
className="h-12 bg-muted/30 border-dashed border-2 group-hover:border-primary/50 transition-colors cursor-pointer file:mr-4 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:bg-primary file:text-primary-foreground file:font-semibold file:text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{/* JSON File Input */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="json-file"
|
|
||||||
className="text-sm font-semibold"
|
|
||||||
>
|
|
||||||
GPS JSON File{' '}
|
|
||||||
<span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="json-file"
|
|
||||||
type="file"
|
|
||||||
accept=".json,application/json"
|
|
||||||
onChange={(e) => {
|
|
||||||
setJsonFile(e.target.files?.[0] || null);
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
disabled={uploading || !isSessionComplete}
|
|
||||||
required
|
|
||||||
className="h-11 bg-muted/30 file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:bg-primary/10 file:text-primary file:font-medium file:text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Select Method */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="select-method"
|
|
||||||
className="text-sm font-semibold"
|
|
||||||
>
|
|
||||||
Analysis Method
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={selectMethod}
|
|
||||||
onValueChange={setSelectMethod}
|
|
||||||
disabled={uploading || !isSessionComplete}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
id="select-method"
|
|
||||||
className="h-11 bg-muted/20"
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Select method" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{DETECTION_METHODS.map((method) => (
|
|
||||||
<SelectItem key={method.value} value={method.value}>
|
|
||||||
{method.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Display */}
|
|
||||||
{error && (
|
|
||||||
<div className="p-4 rounded-md bg-destructive/5 border border-destructive/20 text-destructive text-sm font-medium animate-in fade-in slide-in-from-top-1">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Primary Action Button */}
|
|
||||||
<div className="pt-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleUpload}
|
|
||||||
disabled={!isFormValid || uploading}
|
|
||||||
className="w-full h-14 font-extrabold uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
{uploading ? (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
|
||||||
<span>Uploading...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>Upload & Process Analysis</span>
|
|
||||||
<ChevronRight className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Reveal>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
|
<CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} />
|
||||||
|
<UploadDetailsDialog
|
||||||
|
upload={detailsUpload}
|
||||||
|
open={Boolean(detailsUpload)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDetailsUpload(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<UploadVideoDialog
|
||||||
|
upload={videoUpload}
|
||||||
|
open={Boolean(videoUpload)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setVideoUpload(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef, type ComponentProps } from 'react';
|
import { useRef, type ComponentProps } from 'react';
|
||||||
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
import {
|
||||||
|
Controller,
|
||||||
|
type Control,
|
||||||
|
type FieldErrors,
|
||||||
|
type UseFormRegister,
|
||||||
|
} from 'react-hook-form';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { FormField } from '@/components/form';
|
import { FormField, PhoneInput } from '@/components/form';
|
||||||
import { RoleSelect } from '@/components/lookups/RoleSelect';
|
import { RoleSelect } from '@/components/lookups/RoleSelect';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +28,7 @@ interface UserSheetProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
register: UseFormRegister<UserFormValues>;
|
register: UseFormRegister<UserFormValues>;
|
||||||
|
control: Control<UserFormValues>;
|
||||||
errors: FieldErrors<UserFormValues>;
|
errors: FieldErrors<UserFormValues>;
|
||||||
isSubmitted: boolean;
|
isSubmitted: boolean;
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
@@ -37,6 +43,7 @@ export function UserSheet({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
userId,
|
userId,
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
errors,
|
errors,
|
||||||
isSubmitted,
|
isSubmitted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -72,6 +79,7 @@ export function UserSheet({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
|
noValidate
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
className="flex min-h-0 flex-1 flex-col"
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
>
|
>
|
||||||
@@ -126,16 +134,29 @@ export function UserSheet({
|
|||||||
label="Phone Number"
|
label="Phone Number"
|
||||||
error={phoneErrorMessage}
|
error={phoneErrorMessage}
|
||||||
>
|
>
|
||||||
<Input
|
<Controller
|
||||||
id="phone-number"
|
control={control}
|
||||||
placeholder="+919876543210"
|
name="phone_number"
|
||||||
aria-invalid={!!phoneErrorMessage}
|
render={({ field }) => (
|
||||||
{...register('phone_number')}
|
<PhoneInput
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="Enter phone number"
|
||||||
|
aria-invalid={!!phoneErrorMessage}
|
||||||
|
portalContainer={roleComboboxPortalRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField label="Role" required error={roleErrorMessage}>
|
<FormField
|
||||||
|
id="role"
|
||||||
|
label="Role"
|
||||||
|
required
|
||||||
|
error={roleErrorMessage}
|
||||||
|
>
|
||||||
<RoleSelect
|
<RoleSelect
|
||||||
value={roleId}
|
value={roleId}
|
||||||
onValueChange={onRoleChange}
|
onValueChange={onRoleChange}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { AdministrationUser } from '@/types';
|
import type { AdministrationUser } from '@/types';
|
||||||
import { useSaveUserMutation } from './useUserMutations';
|
import { useSaveUserMutation } from './useUserMutations';
|
||||||
|
|
||||||
const optionalPhoneSchema = z
|
const requiredText = (message: string) => z.string().trim().min(1, message);
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.refine((value) => value === '' || /^\+(?:[0-9] ?){6,14}[0-9]$/.test(value), {
|
|
||||||
message: 'Please enter a valid phone number',
|
|
||||||
});
|
|
||||||
|
|
||||||
const userFormSchema = z.object({
|
const userFormSchema = z.object({
|
||||||
id: z.number().optional(),
|
id: z.number().optional(),
|
||||||
first_name: z.string().trim().min(1, 'First name is required'),
|
first_name: requiredText('First name is required'),
|
||||||
last_name: z.string().trim().min(1, 'Last name is required'),
|
last_name: requiredText('Last name is required'),
|
||||||
email: z.string().trim().min(1, 'Email is required').email('Invalid email'),
|
email: requiredText('Email is required').email('Invalid email'),
|
||||||
phone_number: optionalPhoneSchema,
|
phone_number: z
|
||||||
role_id: z.string().trim().min(1, 'Role is required'),
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((value) => value === '' || isValidPhoneNumber(value), {
|
||||||
|
message: 'Enter a valid phone number',
|
||||||
|
}),
|
||||||
|
role_id: requiredText('Role is required'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UserFormValues = z.infer<typeof userFormSchema>;
|
export type UserFormValues = z.infer<typeof userFormSchema>;
|
||||||
@@ -61,41 +61,13 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
const firstName = useWatch({ control, name: 'first_name' }) || '';
|
const firstName = useWatch({ control, name: 'first_name' }) || '';
|
||||||
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 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 &&
|
||||||
email.trim().length > 0 &&
|
email.trim().length > 0 &&
|
||||||
roleId.trim().length > 0 &&
|
roleId.trim().length > 0;
|
||||||
isPhoneValid;
|
|
||||||
|
|
||||||
const handleSubmit = submitForm(
|
const handleSubmit = submitForm((values) => saveMutation.mutate(values));
|
||||||
(values) => saveMutation.mutate(values),
|
|
||||||
(formErrors) => {
|
|
||||||
if (formErrors.first_name?.message) {
|
|
||||||
toast.error(formErrors.first_name.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (formErrors.last_name?.message) {
|
|
||||||
toast.error(formErrors.last_name.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (formErrors.email?.message) {
|
|
||||||
toast.error(formErrors.email.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (formErrors.phone_number?.message) {
|
|
||||||
toast.error(formErrors.phone_number.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (formErrors.role_id?.message) {
|
|
||||||
toast.error(formErrors.role_id.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
@@ -125,6 +97,7 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
openCreate,
|
openCreate,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user