Compare commits
77 Commits
c6894bfea0
...
migration
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d7f956c3b | |||
| 0026824f84 | |||
| a18eb64dac | |||
| 6c71d8f8a7 | |||
| 0e2172366b | |||
| 56180f8d18 | |||
| 5269b29aac | |||
| 9fe7109faf | |||
| 9720546455 | |||
| 8727963c54 | |||
| 0e8108b875 | |||
| 87dde1c8d1 | |||
| a376558b2f | |||
| 4fe93460d9 | |||
| cbfb6e040b | |||
| 7a82e17bbb | |||
| 23186ffd29 | |||
| fb9a654aea | |||
| d88978a830 | |||
| ba9731de86 | |||
| c0f3a718ef | |||
| 14220445ad | |||
| 79272c7abc | |||
| 26e15360f7 | |||
| b6a0f04f1e | |||
| 9cf4f0e795 | |||
| fdcfc3a8db | |||
| 278b9591d7 | |||
| 02adbb4c10 | |||
| 53fe4e11fe | |||
| 2eb58856af | |||
| c73ba0a01f | |||
| 1a94ff80e6 | |||
| b1174c8825 | |||
| d4a7a7c139 | |||
| 57b33bd061 | |||
| 81df3a9973 | |||
| 09ce57c9e0 | |||
| ea684fc9fe | |||
| ec77696157 | |||
| 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 | |||
| 9695c80e20 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,6 +44,7 @@ Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Test / coverage
|
||||
coverage/
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"style": "radix-vega",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
@@ -18,5 +19,7 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
|
||||
6
next-env.d.ts
vendored
6
next-env.d.ts
vendored
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
3423
package-lock.json
generated
3423
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -27,22 +27,29 @@
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
"@vidstack/react": "^1.15.6",
|
||||
"axios": "^1.13.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"libphonenumber-js": "^1.13.7",
|
||||
"lightgallery": "^2.9.0",
|
||||
"lucide-react": "^0.548.0",
|
||||
"motion": "^12.38.0",
|
||||
"next": "16.0.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.6.0",
|
||||
"react": "19.2.0",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"recharts": "2.15.4",
|
||||
"recharts": "^3.8.0",
|
||||
"shadcn": "^4.13.0",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
|
||||
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%]">
|
||||
<section className="w-full max-w-sm space-y-7">
|
||||
<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>
|
||||
<p className="text-muted-foreground">{displayInfo.description}</p>
|
||||
</header>
|
||||
@@ -60,7 +60,11 @@ function ForgotPasswordContent() {
|
||||
className="w-full"
|
||||
disabled={isLoading || !canSubmit}
|
||||
>
|
||||
{isLoading ? <Loader2 className="size-4 animate-spin" /> : <Send />}
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send />
|
||||
)}
|
||||
{displayInfo.buttonLabel}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PasswordField } from '@/components/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AuthBackground } from '@/components/auth/AuthBackground';
|
||||
import { GuestGuard } from '@/guards';
|
||||
import { useLoginForm } from '@/hooks/useLoginForm';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
@@ -17,12 +18,12 @@ export default function LoginPage() {
|
||||
return (
|
||||
<GuestGuard>
|
||||
<div className="flex min-h-screen w-full">
|
||||
<div className="hidden md:flex w-[40%]" />
|
||||
<div className="flex flex-1 md:w-[60%] items-center justify-center p-6 border-l border-border h-screen">
|
||||
<AuthBackground />
|
||||
<div className="flex flex-1 md:w-1/2 items-center justify-center p-6 border-l border-border h-screen">
|
||||
<section className="w-full max-w-sm space-y-7">
|
||||
<header className="space-y-2 text-center md:text-left">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
VisionRoad
|
||||
RoadMonitor
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -27,13 +27,13 @@ function ResetPasswordContent() {
|
||||
<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">
|
||||
<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>
|
||||
<p className="text-muted-foreground">{displayInfo.description}</p>
|
||||
</header>
|
||||
|
||||
{!hasToken ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-destructive">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-destructive">
|
||||
Password reset token is missing.
|
||||
</div>
|
||||
) : (
|
||||
@@ -69,7 +69,9 @@ function ResetPasswordContent() {
|
||||
className="w-full"
|
||||
disabled={isLoading || !canSubmit}
|
||||
>
|
||||
{isLoading ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : null}
|
||||
{displayInfo.buttonLabel}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
27
src/app/(modules)/_components/module-shell-header.tsx
Normal file
27
src/app/(modules)/_components/module-shell-header.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { BreadcrumbBasic } from '@/components/app-breadcrumb';
|
||||
import { ModeToggle } from '@/components/mode-toogle';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SidebarTrigger, useSidebar } from '@/components/ui/sidebar';
|
||||
|
||||
export function ModuleShellHeader() {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-50 flex h-14 items-center justify-between gap-4 border-b border-border bg-background/95 px-6 backdrop-blur supports-backdrop-filter:bg-background/80">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{isMobile ? (
|
||||
<>
|
||||
<SidebarTrigger className="shrink-0 bg-background/80 text-muted-foreground shadow-xs backdrop-blur-sm" />
|
||||
<Separator orientation="vertical" className="h-4 shrink-0" />
|
||||
</>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<BreadcrumbBasic />
|
||||
</div>
|
||||
</div>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,10 +19,12 @@ export function useClientColumns(): ColumnDef<Client>[] {
|
||||
return useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
accessorKey: 'first_name',
|
||||
header: 'Client',
|
||||
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,12 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { UseFormRegister } from 'react-hook-form';
|
||||
import { useRef, type ComponentProps } from 'react';
|
||||
import {
|
||||
Controller,
|
||||
type Control,
|
||||
type FieldErrors,
|
||||
type UseFormRegister,
|
||||
} from 'react-hook-form';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { FormField, PhoneInput } from '@/components/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -14,7 +21,6 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { ClientFormValues } from '../hooks/useClientForm';
|
||||
|
||||
interface ClientSheetProps {
|
||||
@@ -22,7 +28,11 @@ interface ClientSheetProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
clientId?: number;
|
||||
register: UseFormRegister<ClientFormValues>;
|
||||
control: Control<ClientFormValues>;
|
||||
errors: FieldErrors<ClientFormValues>;
|
||||
isSubmitted: boolean;
|
||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||
canSubmit: boolean;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
@@ -31,12 +41,38 @@ export function ClientSheet({
|
||||
onOpenChange,
|
||||
clientId,
|
||||
register,
|
||||
control,
|
||||
errors,
|
||||
isSubmitted,
|
||||
onSubmit,
|
||||
canSubmit,
|
||||
isSaving,
|
||||
}: ClientSheetProps) {
|
||||
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 landlineErrorMessage = isSubmitted
|
||||
? errors.landline_number?.message
|
||||
: undefined;
|
||||
const addressErrorMessage = isSubmitted ? errors.address?.message : undefined;
|
||||
const contactNameErrorMessage = isSubmitted
|
||||
? errors.contact_name?.message
|
||||
: undefined;
|
||||
const contactPhoneErrorMessage = isSubmitted
|
||||
? errors.contact_phone_number?.message
|
||||
: undefined;
|
||||
const contactEmailErrorMessage = isSubmitted
|
||||
? errors.contact_email?.message
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{clientId ? 'Edit Client' : 'Create Client'}
|
||||
@@ -45,101 +81,169 @@ export function ClientSheet({
|
||||
Manage company and primary contact details.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
||||
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<DialogBody className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client-name">Name</Label>
|
||||
<Input
|
||||
id="client-name"
|
||||
placeholder="Acme Corp"
|
||||
{...register('name', { required: true })}
|
||||
<FormField
|
||||
id="client-first-name"
|
||||
label="First Name"
|
||||
required
|
||||
error={firstNameErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="client-first-name"
|
||||
placeholder="Enter first name"
|
||||
aria-invalid={!!firstNameErrorMessage}
|
||||
{...register('first_name')}
|
||||
/>
|
||||
</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="space-y-2">
|
||||
<Label htmlFor="client-email">Email</Label>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
id="client-email"
|
||||
label="Company Email"
|
||||
required
|
||||
error={emailErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="client-email"
|
||||
type="email"
|
||||
placeholder="info@acme.com"
|
||||
{...register('email', { required: true })}
|
||||
required
|
||||
placeholder="Enter company email"
|
||||
aria-invalid={!!emailErrorMessage}
|
||||
{...register('email')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="landline-number">Landline Number</Label>
|
||||
<FormField
|
||||
id="landline-number"
|
||||
label="Landline Number"
|
||||
required
|
||||
error={landlineErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="landline-number"
|
||||
placeholder="+91-22-12345678"
|
||||
{...register('landline_number', { required: true })}
|
||||
required
|
||||
placeholder="Enter landline number"
|
||||
aria-invalid={!!landlineErrorMessage}
|
||||
{...register('landline_number')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address">Address</Label>
|
||||
|
||||
<FormField
|
||||
id="address"
|
||||
label="Address"
|
||||
required
|
||||
error={addressErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="address"
|
||||
placeholder="12 MG Road, Mumbai, MH 400001"
|
||||
{...register('address', { required: true })}
|
||||
required
|
||||
placeholder="Enter address"
|
||||
aria-invalid={!!addressErrorMessage}
|
||||
{...register('address')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gst">GST</Label>
|
||||
<FormField id="gst" label="GST">
|
||||
<Input
|
||||
id="gst"
|
||||
placeholder="27ABCDE1234F1Z5"
|
||||
placeholder="Enter GST number"
|
||||
{...register('gst')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pan">PAN</Label>
|
||||
<Input id="pan" placeholder="ABCDE1234F" {...register('pan')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tan">TAN</Label>
|
||||
<Input id="tan" placeholder="MUMA12345B" {...register('tan')} />
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField id="pan" label="PAN">
|
||||
<Input
|
||||
id="pan"
|
||||
placeholder="Enter PAN number"
|
||||
{...register('pan')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField id="tan" label="TAN">
|
||||
<Input
|
||||
id="tan"
|
||||
placeholder="Enter TAN number"
|
||||
{...register('tan')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-name">Contact Name</Label>
|
||||
<FormField
|
||||
id="contact-name"
|
||||
label="Contact Name"
|
||||
required
|
||||
error={contactNameErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="contact-name"
|
||||
placeholder="Jane Doe"
|
||||
{...register('contact_name', { required: true })}
|
||||
required
|
||||
placeholder="Enter contact name"
|
||||
aria-invalid={!!contactNameErrorMessage}
|
||||
{...register('contact_name')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-phone-number">Contact Phone</Label>
|
||||
<Input
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="contact-phone-number"
|
||||
placeholder="+91-9876543210"
|
||||
{...register('contact_phone_number', { required: true })}
|
||||
label="Contact Phone"
|
||||
required
|
||||
error={contactPhoneErrorMessage}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="contact_phone_number"
|
||||
render={({ field }) => (
|
||||
<PhoneInput
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="Enter contact phone"
|
||||
aria-invalid={!!contactPhoneErrorMessage}
|
||||
portalContainer={dropdownPortalRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-email">Contact Email</Label>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="contact-email"
|
||||
label="Contact Email"
|
||||
required
|
||||
error={contactEmailErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
placeholder="jane.doe@acme.com"
|
||||
{...register('contact_email', { required: true })}
|
||||
required
|
||||
placeholder="Enter contact email"
|
||||
aria-invalid={!!contactEmailErrorMessage}
|
||||
{...register('contact_email')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="px-0">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -148,7 +252,8 @@ export function ClientSheet({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
@@ -156,6 +261,8 @@ export function ClientSheet({
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
<div ref={dropdownPortalRef} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
import { useCallback } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Client } from '@/types';
|
||||
import { useSaveClientMutation } from './useClientMutations';
|
||||
|
||||
export interface ClientFormValues {
|
||||
id?: number;
|
||||
name: string;
|
||||
email: string;
|
||||
landline_number: string;
|
||||
address: string;
|
||||
gst: string;
|
||||
pan: string;
|
||||
tan: string;
|
||||
contact_name: string;
|
||||
contact_phone_number: string;
|
||||
contact_email: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
const requiredText = (message: string) => z.string().trim().min(1, message);
|
||||
const optionalTaxIdSchema = z.string().trim();
|
||||
|
||||
const clientFormSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
first_name: requiredText('First name is required'),
|
||||
last_name: requiredText('Last name is required'),
|
||||
email: requiredText('Email is required').email('Invalid email'),
|
||||
landline_number: requiredText('Landline number is required'),
|
||||
address: requiredText('Address is required'),
|
||||
gst: optionalTaxIdSchema,
|
||||
pan: optionalTaxIdSchema,
|
||||
tan: optionalTaxIdSchema,
|
||||
contact_name: requiredText('Contact name is required'),
|
||||
contact_phone_number: requiredText('Contact phone number is required').refine(
|
||||
(value) => isValidPhoneNumber(value),
|
||||
'Enter a valid phone number',
|
||||
),
|
||||
contact_email: requiredText('Contact email is required').email(
|
||||
'Invalid contact email',
|
||||
),
|
||||
is_active: z.boolean(),
|
||||
});
|
||||
|
||||
export type ClientFormValues = z.infer<typeof clientFormSchema>;
|
||||
|
||||
const defaultValues: ClientFormValues = {
|
||||
name: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
landline_number: '',
|
||||
address: '',
|
||||
@@ -42,9 +56,12 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
||||
handleSubmit: submitForm,
|
||||
reset,
|
||||
control,
|
||||
formState: { isSubmitting, errors },
|
||||
formState: { isSubmitting, errors, isSubmitted },
|
||||
} = useForm<ClientFormValues>({
|
||||
defaultValues,
|
||||
mode: 'onSubmit',
|
||||
reValidateMode: 'onChange',
|
||||
resolver: zodResolver(clientFormSchema),
|
||||
});
|
||||
const saveMutation = useSaveClientMutation({
|
||||
onSaved: () => {
|
||||
@@ -54,23 +71,26 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
||||
});
|
||||
|
||||
const clientId = useWatch({ control, name: 'id' });
|
||||
const firstName = useWatch({ control, name: 'first_name' }) || '';
|
||||
const lastName = useWatch({ control, name: 'last_name' }) || '';
|
||||
const email = useWatch({ control, name: 'email' }) || '';
|
||||
const landlineNumber = useWatch({ control, name: 'landline_number' }) || '';
|
||||
const address = useWatch({ control, name: 'address' }) || '';
|
||||
const contactName = useWatch({ control, name: 'contact_name' }) || '';
|
||||
const contactPhoneNumber =
|
||||
useWatch({ control, name: 'contact_phone_number' }) || '';
|
||||
const contactEmail = useWatch({ control, name: 'contact_email' }) || '';
|
||||
const canSubmit =
|
||||
firstName.trim().length > 0 &&
|
||||
lastName.trim().length > 0 &&
|
||||
email.trim().length > 0 &&
|
||||
landlineNumber.trim().length > 0 &&
|
||||
address.trim().length > 0 &&
|
||||
contactName.trim().length > 0 &&
|
||||
contactPhoneNumber.trim().length > 0 &&
|
||||
contactEmail.trim().length > 0;
|
||||
|
||||
const handleSubmit = submitForm(
|
||||
(values) => saveMutation.mutate(values),
|
||||
(formErrors) => {
|
||||
if (
|
||||
formErrors.name ||
|
||||
formErrors.email ||
|
||||
formErrors.landline_number ||
|
||||
formErrors.address ||
|
||||
formErrors.contact_name ||
|
||||
formErrors.contact_phone_number ||
|
||||
formErrors.contact_email
|
||||
) {
|
||||
toast.error('Complete all required client fields');
|
||||
}
|
||||
},
|
||||
);
|
||||
const handleSubmit = submitForm((values) => saveMutation.mutate(values));
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
reset(defaultValues);
|
||||
@@ -80,7 +100,8 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
||||
(client: Client) => {
|
||||
reset({
|
||||
id: client.id,
|
||||
name: client.name || '',
|
||||
first_name: client.first_name || '',
|
||||
last_name: client.last_name || '',
|
||||
email: client.email || '',
|
||||
landline_number: client.landline_number || '',
|
||||
address: client.address || '',
|
||||
@@ -98,12 +119,15 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
||||
|
||||
return {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
openCreate,
|
||||
openEdit,
|
||||
clientId,
|
||||
errors,
|
||||
isSubmitted,
|
||||
canSubmit,
|
||||
isSaving: isSubmitting || saveMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
|
||||
mutationFn: (values: ClientFormValues) =>
|
||||
clientService.saveClient({
|
||||
id: values.id,
|
||||
name: values.name.trim(),
|
||||
first_name: values.first_name.trim(),
|
||||
last_name: values.last_name.trim(),
|
||||
email: values.email.trim(),
|
||||
landline_number: values.landline_number.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>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,16 @@ export default function ClientsPage() {
|
||||
});
|
||||
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } =
|
||||
clientForm;
|
||||
const { register, handleSubmit, clientId, isSaving } = clientForm;
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
clientId,
|
||||
errors,
|
||||
isSubmitted,
|
||||
canSubmit,
|
||||
isSaving,
|
||||
} = clientForm;
|
||||
const statusMutation = useClientStatusMutation();
|
||||
const { mutate: updateClientStatus, pendingClientId } = statusMutation;
|
||||
|
||||
@@ -126,7 +135,11 @@ export default function ClientsPage() {
|
||||
onOpenChange={setIsSheetOpen}
|
||||
clientId={clientId}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
isSubmitted={isSubmitted}
|
||||
onSubmit={handleSubmit}
|
||||
canSubmit={canSubmit}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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,8 @@
|
||||
import { BreadcrumbBasic } from '@/components/app-breadcrumb';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { ModeToggle } from '@/components/mode-toogle';
|
||||
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { AuthGuard } from '@/guards';
|
||||
import React from 'react';
|
||||
import { ModuleShellHeader } from './_components/module-shell-header';
|
||||
|
||||
const ModulesLayout = ({
|
||||
children,
|
||||
@@ -15,16 +13,11 @@ const ModulesLayout = ({
|
||||
<AuthGuard>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full">
|
||||
<div className="flex gap-3 items-center sticky top-0 bg-background z-50 py-3 px-6 border-b border-border">
|
||||
<SidebarTrigger />
|
||||
<ModeToggle />
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<BreadcrumbBasic />
|
||||
</div>
|
||||
<div className="p-6 flex-1">{children}</div>
|
||||
<main className="flex flex-1 h-screen w-full flex-col overflow-hidden">
|
||||
<div className="scroll-stable flex-1 overflow-auto">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-380 flex-col">
|
||||
<ModuleShellHeader />
|
||||
<div className="flex-1 p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||
import { Globe, Loader2, Package as PackageIcon } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { FormField } from '@/components/form';
|
||||
import { ProjectSelect } from '@/components/lookups/ProjectSelect';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -15,14 +18,6 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
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';
|
||||
|
||||
@@ -30,8 +25,6 @@ interface PackageDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
packageId?: string;
|
||||
projects: Project[];
|
||||
isProjectsLoading: boolean;
|
||||
projectId: string;
|
||||
onProjectChange: (projectId: string) => void;
|
||||
register: UseFormRegister<PackageFormValues>;
|
||||
@@ -46,8 +39,6 @@ export function PackageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
packageId,
|
||||
projects,
|
||||
isProjectsLoading,
|
||||
projectId,
|
||||
onProjectChange,
|
||||
register,
|
||||
@@ -57,6 +48,7 @@ export function PackageDialog({
|
||||
canSubmit,
|
||||
isSaving,
|
||||
}: PackageDialogProps) {
|
||||
const projectSelectPortalRef = useRef<HTMLDivElement | null>(null);
|
||||
const projectErrorMessage = isSubmitted
|
||||
? errors.project_id?.message
|
||||
: undefined;
|
||||
@@ -71,45 +63,41 @@ export function PackageDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-2xl"
|
||||
className="sm:max-w-2xl"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
|
||||
<PackageIcon className="size-5" />
|
||||
</div>
|
||||
{packageId ? 'Edit Package Details' : 'Create New Package'}
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{packageId ? 'Edit Package' : 'Create Package'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{packageId
|
||||
? 'Update the technical specifications for your road infrastructure package.'
|
||||
: 'Select a project and provide the essential data to establish a new package.'}
|
||||
Manage package details and optional chainage range.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<DialogBody className="space-y-6">
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Basic details</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Project binding, package name, and region.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!packageId ? (
|
||||
<FormField label="Project" required error={projectErrorMessage}>
|
||||
<Select value={projectId} onValueChange={onProjectChange}>
|
||||
<SelectTrigger aria-invalid={!!projectErrorMessage}>
|
||||
{isProjectsLoading ? (
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</span>
|
||||
) : (
|
||||
<SelectValue placeholder="Choose a project" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ProjectSelect
|
||||
value={projectId}
|
||||
onValueChange={onProjectChange}
|
||||
enabled={open}
|
||||
disabled={isSaving}
|
||||
portalContainer={projectSelectPortalRef}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
{...register('project_id')}
|
||||
@@ -119,7 +107,7 @@ export function PackageDialog({
|
||||
</FormField>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
id="package-name"
|
||||
label="Package Name"
|
||||
@@ -128,38 +116,42 @@ export function PackageDialog({
|
||||
>
|
||||
<Input
|
||||
id="package-name"
|
||||
placeholder="e.g. Package 01"
|
||||
placeholder="Enter package name"
|
||||
aria-invalid={!!nameErrorMessage}
|
||||
{...register('name')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="package-region"
|
||||
label={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Globe className="size-3.5 opacity-60" />
|
||||
Region
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<FormField id="package-region" label="Region">
|
||||
<Input
|
||||
id="package-region"
|
||||
placeholder="e.g. North Zone"
|
||||
placeholder="Enter region"
|
||||
{...register('region')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Chainage range</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
start and end kilometre values.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
id="package-start"
|
||||
label="Segment Start (km)"
|
||||
label="Start Chainage"
|
||||
error={startErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="package-start"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
step="1"
|
||||
min="0"
|
||||
placeholder="Enter start chainage"
|
||||
aria-invalid={!!startErrorMessage}
|
||||
{...register('chainage_start_km')}
|
||||
/>
|
||||
@@ -167,19 +159,22 @@ export function PackageDialog({
|
||||
|
||||
<FormField
|
||||
id="package-end"
|
||||
label="Segment End (km)"
|
||||
label="End Chainage"
|
||||
error={endErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="package-end"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
step="1"
|
||||
min="0"
|
||||
placeholder="Enter end chainage"
|
||||
aria-invalid={!!endErrorMessage}
|
||||
{...register('chainage_end_km')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -191,11 +186,15 @@ export function PackageDialog({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||
{isSaving ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
{packageId ? 'Update Package' : 'Create Package'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
<div ref={projectSelectPortalRef} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -7,29 +7,25 @@ import { useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
optionalIntegerNumber,
|
||||
validateChainageRange,
|
||||
} from '@/lib/validation/numberField';
|
||||
import { packageService } from '@/services/api';
|
||||
import type { Package, PackageCreate, PackageUpdate } from '@/types';
|
||||
|
||||
import { packageKeys } from '../queries/packageKeys';
|
||||
|
||||
const optionalNumber = z.string().refine(
|
||||
(value) => {
|
||||
if (!value.trim()) {
|
||||
return true;
|
||||
}
|
||||
return Number.isFinite(Number(value));
|
||||
},
|
||||
{ message: 'Enter a valid number' },
|
||||
);
|
||||
|
||||
const packageFormSchema = z.object({
|
||||
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,
|
||||
});
|
||||
chainage_start_km: optionalIntegerNumber,
|
||||
chainage_end_km: optionalIntegerNumber,
|
||||
})
|
||||
.superRefine(validateChainageRange);
|
||||
|
||||
export type PackageFormValues = z.infer<typeof packageFormSchema>;
|
||||
|
||||
@@ -103,18 +99,7 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
(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 onSubmit = handleSubmit((values) => saveMutation.mutate(values));
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
reset(defaultValues);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toast } from 'sonner';
|
||||
import { packageService, projectService } from '@/services/api';
|
||||
import type { Package, PaginationParams } from '@/types';
|
||||
|
||||
import { projectKeys } from '../../project/queries/projectKeys';
|
||||
import { packageKeys } from '../queries/packageKeys';
|
||||
|
||||
interface UsePackagesQueryParams {
|
||||
@@ -28,7 +29,7 @@ export function usePackagesQuery({ skip, limit }: UsePackagesQueryParams) {
|
||||
|
||||
export function useProjectOptionsQuery() {
|
||||
return useQuery({
|
||||
queryKey: ['projects', 'options'],
|
||||
queryKey: projectKeys.options(),
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,16 @@ import { useCallback, useState } from 'react';
|
||||
import { Package as PackageIcon, Plus } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Package } from '@/types';
|
||||
|
||||
@@ -20,6 +30,7 @@ import {
|
||||
|
||||
export default function PackagePage() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [packageToDelete, setPackageToDelete] = useState<Package | null>(null);
|
||||
const { skip, setSkip, limit, setLimit } = usePackageFilters();
|
||||
const packagesQuery = usePackagesQuery({ skip, limit });
|
||||
const projectsQuery = useProjectOptionsQuery();
|
||||
@@ -67,14 +78,25 @@ export default function PackagePage() {
|
||||
|
||||
const deletePackage = useCallback(
|
||||
(pkg: Package) => {
|
||||
if (!confirm(`Are you sure you want to delete package "${pkg.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
deletePackageMutation.mutate(pkg);
|
||||
setPackageToDelete(pkg);
|
||||
},
|
||||
[deletePackageMutation],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setPackageToDelete(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const confirmDeletePackage = useCallback(() => {
|
||||
if (!packageToDelete) return;
|
||||
|
||||
deletePackageMutation.mutate(packageToDelete, {
|
||||
onSettled: () => setPackageToDelete(null),
|
||||
});
|
||||
}, [deletePackageMutation, packageToDelete]);
|
||||
|
||||
const projects = projectsQuery.data?.items ?? [];
|
||||
const packages = packagesQuery.data?.items ?? [];
|
||||
const total = packagesQuery.data?.totalItems ?? 0;
|
||||
@@ -113,8 +135,6 @@ export default function PackagePage() {
|
||||
open={isDialogOpen}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
packageId={packageId}
|
||||
projects={projects}
|
||||
isProjectsLoading={projectsQuery.isLoading}
|
||||
projectId={projectId}
|
||||
onProjectChange={setProjectId}
|
||||
register={register}
|
||||
@@ -124,6 +144,36 @@ export default function PackagePage() {
|
||||
canSubmit={canSubmit}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(packageToDelete)}
|
||||
onOpenChange={handleDeleteDialogOpenChange}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete package?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{packageToDelete?.name}
|
||||
</span>
|
||||
. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deletePackageMutation.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deletePackageMutation.isPending}
|
||||
onClick={confirmDeletePackage}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,4 +4,7 @@ export const packageKeys = {
|
||||
all: ['packages'] as const,
|
||||
lists: () => [...packageKeys.all, 'list'] 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';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { AppLoader } from '@/components/AppLoader';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
|
||||
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" />;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { Control, UseFormRegister } from 'react-hook-form';
|
||||
import type { Control, FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { FormField } from '@/components/form';
|
||||
import { PermissionTree } from '@/components/permission-tree';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -34,7 +35,10 @@ interface PlanSheetProps {
|
||||
planId?: number;
|
||||
register: UseFormRegister<PlanFormValues>;
|
||||
control: Control<PlanFormValues>;
|
||||
errors: FieldErrors<PlanFormValues>;
|
||||
isSubmitted: boolean;
|
||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||
canSubmit: boolean;
|
||||
permissionTree: PermissionTreeItem[];
|
||||
permissionIds: number[];
|
||||
onPermissionIdsChange: (ids: number[]) => void;
|
||||
@@ -48,70 +52,105 @@ export function PlanSheet({
|
||||
planId,
|
||||
register,
|
||||
control,
|
||||
errors,
|
||||
isSubmitted,
|
||||
onSubmit,
|
||||
canSubmit,
|
||||
permissionTree,
|
||||
permissionIds,
|
||||
onPermissionIdsChange,
|
||||
isPermissionsLoading,
|
||||
isSaving,
|
||||
}: PlanSheetProps) {
|
||||
const fieldError = (field: keyof PlanFormValues) =>
|
||||
isSubmitted ? errors[field]?.message : undefined;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full overflow-y-auto sm:max-w-3xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{planId ? 'Edit Plan' : 'Create Plan'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
<SheetContent className="w-full! gap-0 p-0 sm:max-w-3xl! lg:max-w-4xl!">
|
||||
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
{planId ? 'Edit Plan' : 'Create Plan'}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-sm">
|
||||
Configure subscription limits, billing, and permission access.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
||||
|
||||
<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="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plan-name">Name</Label>
|
||||
<FormField
|
||||
id="plan-name"
|
||||
label="Name"
|
||||
required
|
||||
error={fieldError('name')}
|
||||
>
|
||||
<Input
|
||||
id="plan-name"
|
||||
placeholder="Starter"
|
||||
{...register('name', { required: true })}
|
||||
required
|
||||
placeholder="Enter plan name"
|
||||
aria-invalid={!!fieldError('name')}
|
||||
{...register('name')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plan-slug">Slug</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="plan-slug"
|
||||
label="Slug"
|
||||
required
|
||||
error={fieldError('slug')}
|
||||
>
|
||||
<Input
|
||||
id="plan-slug"
|
||||
placeholder="starter"
|
||||
{...register('slug', { required: true })}
|
||||
required
|
||||
aria-invalid={!!fieldError('slug')}
|
||||
{...register('slug')}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plan-description">Description</Label>
|
||||
<textarea
|
||||
<FormField
|
||||
id="plan-description"
|
||||
label="Description"
|
||||
required
|
||||
error={fieldError('description')}
|
||||
>
|
||||
<Textarea
|
||||
id="plan-description"
|
||||
placeholder="Basic plan for small teams"
|
||||
{...register('description', { required: true })}
|
||||
required
|
||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
|
||||
aria-invalid={!!fieldError('description')}
|
||||
{...register('description')}
|
||||
className="min-h-20"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plan-price">Price</Label>
|
||||
<FormField
|
||||
id="plan-price"
|
||||
label="Price"
|
||||
required
|
||||
error={fieldError('price')}
|
||||
>
|
||||
<Input
|
||||
id="plan-price"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="29.99"
|
||||
{...register('price', { required: true })}
|
||||
required
|
||||
aria-invalid={!!fieldError('price')}
|
||||
{...register('price')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Billing Cycle</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Billing Cycle"
|
||||
required
|
||||
error={fieldError('billing_cycle')}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="billing_cycle"
|
||||
@@ -128,62 +167,86 @@ export function PlanSheet({
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plan-trial-days">Trial Days</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="plan-trial-days"
|
||||
label="Trial Days"
|
||||
error={fieldError('trial_days')}
|
||||
>
|
||||
<Input
|
||||
id="plan-trial-days"
|
||||
type="number"
|
||||
min="0"
|
||||
aria-invalid={!!fieldError('trial_days')}
|
||||
{...register('trial_days', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-projects">Projects</Label>
|
||||
<FormField
|
||||
id="max-projects"
|
||||
label="Projects"
|
||||
error={fieldError('max_projects')}
|
||||
>
|
||||
<Input
|
||||
id="max-projects"
|
||||
type="number"
|
||||
min="0"
|
||||
aria-invalid={!!fieldError('max_projects')}
|
||||
{...register('max_projects', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-organizations">Organizations</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="max-organizations"
|
||||
label="Organizations"
|
||||
error={fieldError('max_organizations')}
|
||||
>
|
||||
<Input
|
||||
id="max-organizations"
|
||||
type="number"
|
||||
min="0"
|
||||
aria-invalid={!!fieldError('max_organizations')}
|
||||
{...register('max_organizations', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-users">Users</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="max-users"
|
||||
label="Users"
|
||||
error={fieldError('max_users')}
|
||||
>
|
||||
<Input
|
||||
id="max-users"
|
||||
type="number"
|
||||
min="0"
|
||||
aria-invalid={!!fieldError('max_users')}
|
||||
{...register('max_users', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-roles">Roles</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="max-roles"
|
||||
label="Roles"
|
||||
error={fieldError('max_roles')}
|
||||
>
|
||||
<Input
|
||||
id="max-roles"
|
||||
type="number"
|
||||
min="0"
|
||||
aria-invalid={!!fieldError('max_roles')}
|
||||
{...register('max_roles', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-5">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-border accent-primary"
|
||||
className="size-4 rounded-lg border-border accent-primary"
|
||||
{...register('is_active')}
|
||||
/>
|
||||
Active
|
||||
@@ -191,22 +254,26 @@ export function PlanSheet({
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-border accent-primary"
|
||||
className="size-4 rounded-lg border-border accent-primary"
|
||||
{...register('is_custom')}
|
||||
/>
|
||||
Custom plan
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label>Permissions</Label>
|
||||
<FormField
|
||||
label="Permissions"
|
||||
required
|
||||
error={fieldError('permission_ids')}
|
||||
className="space-y-3"
|
||||
labelEnd={
|
||||
<span className="text-muted-foreground">
|
||||
{permissionIds.length} selected
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isPermissionsLoading ? (
|
||||
<div className="rounded-md border p-6 text-muted-foreground">
|
||||
<div className="rounded-lg border p-6 text-muted-foreground">
|
||||
Loading permissions...
|
||||
</div>
|
||||
) : (
|
||||
@@ -216,9 +283,10 @@ export function PlanSheet({
|
||||
onChange={onPermissionIdsChange}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-0">
|
||||
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -227,7 +295,7 @@ export function PlanSheet({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
collectDefaultPermissionIds,
|
||||
collectPermissionIdsByKeys,
|
||||
@@ -7,26 +8,39 @@ import {
|
||||
import type { PermissionTreeItem, Plan } from '@/types';
|
||||
import { useCallback } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useSavePlanMutation } from './usePlanMutations';
|
||||
|
||||
export interface PlanFormValues {
|
||||
id?: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
price: string;
|
||||
billing_cycle: string;
|
||||
trial_days: number;
|
||||
max_projects: number;
|
||||
max_organizations: number;
|
||||
max_users: number;
|
||||
max_roles: number;
|
||||
permission_ids: number[];
|
||||
is_active: boolean;
|
||||
is_custom: boolean;
|
||||
}
|
||||
const nonNegativeNumber = z.number().refine(
|
||||
(value) => Number.isFinite(value) && value >= 0,
|
||||
{ message: 'Enter a valid number' },
|
||||
);
|
||||
|
||||
const planFormSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
name: z.string().trim().min(1, 'Plan name is required'),
|
||||
slug: z.string().trim().min(1, 'Slug is required'),
|
||||
description: z.string().trim().min(1, 'Description is required'),
|
||||
price: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Price is required')
|
||||
.refine((value) => Number.isFinite(Number(value)) && Number(value) >= 0, {
|
||||
message: 'Enter a valid price',
|
||||
}),
|
||||
billing_cycle: z.string().trim().min(1, 'Billing cycle is required'),
|
||||
trial_days: nonNegativeNumber,
|
||||
max_projects: nonNegativeNumber,
|
||||
max_organizations: nonNegativeNumber,
|
||||
max_users: nonNegativeNumber,
|
||||
max_roles: nonNegativeNumber,
|
||||
permission_ids: z.array(z.number()).min(1, 'Select at least one permission'),
|
||||
is_active: z.boolean(),
|
||||
is_custom: z.boolean(),
|
||||
});
|
||||
|
||||
export type PlanFormValues = z.infer<typeof planFormSchema>;
|
||||
|
||||
const defaultValues: PlanFormValues = {
|
||||
name: '',
|
||||
@@ -62,9 +76,12 @@ export function usePlanForm({
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
formState: { errors, isSubmitting, isSubmitted },
|
||||
} = useForm<PlanFormValues>({
|
||||
defaultValues,
|
||||
mode: 'onSubmit',
|
||||
reValidateMode: 'onChange',
|
||||
resolver: zodResolver(planFormSchema),
|
||||
});
|
||||
const saveMutation = useSavePlanMutation({
|
||||
onSaved: () => {
|
||||
@@ -75,13 +92,18 @@ export function usePlanForm({
|
||||
|
||||
const planId = useWatch({ control, name: 'id' });
|
||||
const permissionIds = useWatch({ control, name: 'permission_ids' }) || [];
|
||||
const name = useWatch({ control, name: 'name' }) || '';
|
||||
const slug = useWatch({ control, name: 'slug' }) || '';
|
||||
const description = useWatch({ control, name: 'description' }) || '';
|
||||
const price = useWatch({ control, name: 'price' }) || '';
|
||||
const canSubmit =
|
||||
name.trim().length > 0 &&
|
||||
slug.trim().length > 0 &&
|
||||
description.trim().length > 0 &&
|
||||
price.trim().length > 0 &&
|
||||
permissionIds.length > 0;
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
if (values.permission_ids.length === 0) {
|
||||
toast.error('Select at least one permission');
|
||||
return;
|
||||
}
|
||||
|
||||
saveMutation.mutate({
|
||||
...values,
|
||||
name: values.name.trim(),
|
||||
@@ -146,6 +168,9 @@ export function usePlanForm({
|
||||
planId,
|
||||
permissionIds,
|
||||
setPermissionIds,
|
||||
errors,
|
||||
isSubmitted,
|
||||
canSubmit,
|
||||
isSaving: isSubmitting || saveMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,9 @@ export default function PlansPage() {
|
||||
planId,
|
||||
permissionIds,
|
||||
setPermissionIds,
|
||||
errors,
|
||||
isSubmitted,
|
||||
canSubmit,
|
||||
isSaving,
|
||||
} = planForm;
|
||||
const statusMutation = usePlanStatusMutation();
|
||||
@@ -183,7 +186,10 @@ export default function PlansPage() {
|
||||
planId={planId}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
isSubmitted={isSubmitted}
|
||||
onSubmit={onSubmit}
|
||||
canSubmit={canSubmit}
|
||||
permissionTree={permissionsQuery.permissionTree}
|
||||
permissionIds={permissionIds}
|
||||
onPermissionIdsChange={setPermissionIds}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||
import { Layers, Loader2, MapPin, Route } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { FormField } from '@/components/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -54,24 +55,32 @@ export function ProjectDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-2xl"
|
||||
className="sm:max-w-2xl"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
|
||||
<Layers className="size-5" />
|
||||
</div>
|
||||
{projectId ? 'Edit Project Details' : 'Create New Project'}
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{projectId ? 'Edit Project' : 'Create Project'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{projectId
|
||||
? 'Update the technical specifications for your road infrastructure project.'
|
||||
: 'Provide the essential road data to establish a new analysis project.'}
|
||||
Manage road project identity and optional coordinate boundaries.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<DialogBody className="space-y-6">
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Basic details</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Project name and corridor information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
id="project-name"
|
||||
label="Project Name"
|
||||
@@ -80,116 +89,111 @@ export function ProjectDialog({
|
||||
>
|
||||
<Input
|
||||
id="project-name"
|
||||
placeholder="Enter a descriptive project name"
|
||||
placeholder="Enter project name"
|
||||
aria-invalid={!!nameErrorMessage}
|
||||
{...register('name')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField id="project-state" label="State">
|
||||
<Input
|
||||
id="project-state"
|
||||
placeholder="e.g. Maharashtra"
|
||||
placeholder="Enter state"
|
||||
{...register('state')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="project-corridor"
|
||||
label={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Route className="size-3.5 opacity-60" />
|
||||
Corridor Name
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<FormField id="project-corridor" label="Corridor Name">
|
||||
<Input
|
||||
id="project-corridor"
|
||||
placeholder="e.g. Mumbai-Goa Highway"
|
||||
placeholder="Enter corridor name"
|
||||
{...register('corridor_name')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-8 pt-2 md:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<p className="flex items-center gap-2 text-muted-foreground">
|
||||
<MapPin className="size-3.5 opacity-60" />
|
||||
START POINT
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Coordinates</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Optional start and end points for the project boundary.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
id="project-start-lat"
|
||||
label="Lat"
|
||||
label="Start Latitude"
|
||||
error={startLatErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="project-start-lat"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="0.0000"
|
||||
className="font-mono"
|
||||
min="-90"
|
||||
max="90"
|
||||
placeholder="Enter start latitude"
|
||||
aria-invalid={!!startLatErrorMessage}
|
||||
{...register('start_lat')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="project-start-lng"
|
||||
label="Lng"
|
||||
label="Start Longitude"
|
||||
error={startLngErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="project-start-lng"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="0.0000"
|
||||
className="font-mono"
|
||||
min="-180"
|
||||
max="180"
|
||||
placeholder="Enter start longitude"
|
||||
aria-invalid={!!startLngErrorMessage}
|
||||
{...register('start_lng')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="flex items-center gap-2 text-muted-foreground">
|
||||
<MapPin className="size-3.5 opacity-60" />
|
||||
END POINT
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
id="project-end-lat"
|
||||
label="Lat"
|
||||
label="End Latitude"
|
||||
error={endLatErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="project-end-lat"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="0.0000"
|
||||
className="font-mono"
|
||||
min="-90"
|
||||
max="90"
|
||||
placeholder="Enter end latitude"
|
||||
aria-invalid={!!endLatErrorMessage}
|
||||
{...register('end_lat')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="project-end-lng"
|
||||
label="Lng"
|
||||
label="End Longitude"
|
||||
error={endLngErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="project-end-lng"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="0.0000"
|
||||
className="font-mono"
|
||||
min="-180"
|
||||
max="180"
|
||||
placeholder="Enter end longitude"
|
||||
aria-invalid={!!endLngErrorMessage}
|
||||
{...register('end_lng')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -201,7 +205,9 @@ export function ProjectDialog({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||
{isSaving ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
{projectId ? 'Update Project' : 'Create Project'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -7,30 +7,24 @@ import { useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
optionalLatitude,
|
||||
optionalLongitude,
|
||||
} from '@/lib/validation/numberField';
|
||||
import { projectService } from '@/services/api';
|
||||
import type { Project, ProjectCreate, ProjectUpdate } from '@/types';
|
||||
|
||||
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({
|
||||
id: z.string().optional(),
|
||||
name: z.string().trim().min(1, 'Project name is required'),
|
||||
state: z.string().optional(),
|
||||
corridor_name: z.string().optional(),
|
||||
start_lat: optionalCoordinate,
|
||||
start_lng: optionalCoordinate,
|
||||
end_lat: optionalCoordinate,
|
||||
end_lng: optionalCoordinate,
|
||||
start_lat: optionalLatitude,
|
||||
start_lng: optionalLongitude,
|
||||
end_lat: optionalLatitude,
|
||||
end_lng: optionalLongitude,
|
||||
});
|
||||
|
||||
export type ProjectFormValues = z.infer<typeof projectFormSchema>;
|
||||
@@ -106,14 +100,7 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
(values) => saveMutation.mutate(values),
|
||||
(formErrors) => {
|
||||
if (formErrors.name?.message) {
|
||||
toast.error(formErrors.name.message);
|
||||
}
|
||||
},
|
||||
);
|
||||
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
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,6 +4,16 @@ import { useCallback, useState } from 'react';
|
||||
import { Layers, Plus } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Project } from '@/types';
|
||||
|
||||
@@ -19,6 +29,7 @@ import {
|
||||
|
||||
export default function ProjectPage() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
|
||||
const { skip, setSkip, limit, setLimit } = useProjectFilters();
|
||||
const projectsQuery = useProjectsQuery({ skip, limit });
|
||||
const deleteProjectMutation = useDeleteProjectMutation();
|
||||
@@ -63,16 +74,25 @@ export default function ProjectPage() {
|
||||
|
||||
const deleteProject = useCallback(
|
||||
(project: Project) => {
|
||||
if (
|
||||
!confirm(`Are you sure you want to delete project "${project.name}"?`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deleteProjectMutation.mutate(project);
|
||||
setProjectToDelete(project);
|
||||
},
|
||||
[deleteProjectMutation],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setProjectToDelete(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const confirmDeleteProject = useCallback(() => {
|
||||
if (!projectToDelete) return;
|
||||
|
||||
deleteProjectMutation.mutate(projectToDelete, {
|
||||
onSettled: () => setProjectToDelete(null),
|
||||
});
|
||||
}, [deleteProjectMutation, projectToDelete]);
|
||||
|
||||
const columns = useProjectColumns();
|
||||
const projects = projectsQuery.data?.items ?? [];
|
||||
const total = projectsQuery.data?.totalItems ?? 0;
|
||||
@@ -117,6 +137,36 @@ export default function ProjectPage() {
|
||||
canSubmit={canSubmit}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(projectToDelete)}
|
||||
onOpenChange={handleDeleteDialogOpenChange}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete project?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{projectToDelete?.name}
|
||||
</span>
|
||||
. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteProjectMutation.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleteProjectMutation.isPending}
|
||||
onClick={confirmDeleteProject}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,4 +4,7 @@ export const projectKeys = {
|
||||
all: ['projects'] as const,
|
||||
lists: () => [...projectKeys.all, 'list'] 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,108 @@
|
||||
'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-lg" />
|
||||
<Skeleton className="h-6 w-12" />
|
||||
<Skeleton className="mt-2 h-3 w-20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetectionLogSkeleton() {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-20 shrink-0 rounded-lg" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-28" />
|
||||
<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>
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-3 w-44" />
|
||||
</div>
|
||||
</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-lg" />
|
||||
<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-lg border bg-card">
|
||||
<div className="border-b p-6 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-lg" />
|
||||
<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-lg" />
|
||||
))}
|
||||
</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,34 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import VideoPlayerSection from '@/components/video-player-section';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, TrendingUp } from 'lucide-react';
|
||||
import VideoPlayerSection from '@/components/videoPlayerSection';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { sessionService } from '@/services/api';
|
||||
import { SessionContext, CompletedVideoResult, DetectionType } from '@/types';
|
||||
import { clearVideoFile } from '@/lib/video-storage';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { getDetectionModeConfig } from '@/constants/detectionModeConfig';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useVideoResultsQuery } from '../hooks/useVideoResults';
|
||||
|
||||
const inferDetectionType = (data: CompletedVideoResult): DetectionType => {
|
||||
if (data.detection_mode) return data.detection_mode as DetectionType;
|
||||
|
||||
const signboards = data.summary?.unique_signboards || 0;
|
||||
const potholes = data.summary?.unique_potholes || 0;
|
||||
|
||||
if (signboards > 0 && potholes > 0) return 'pot-sign-detection';
|
||||
if (signboards > 0) return 'sign-board-detection';
|
||||
return 'pothole-detection';
|
||||
};
|
||||
import { VideoResultsSkeleton } from './components/VideoResultsSkeleton';
|
||||
|
||||
export default function VideoResultsPage() {
|
||||
const router = useRouter();
|
||||
const { videoId } = useParams() as { videoId: string };
|
||||
const [session, setSession] = useState<SessionContext | null>(null);
|
||||
|
||||
const {
|
||||
data: detectionData,
|
||||
@@ -37,145 +19,39 @@ export default function VideoResultsPage() {
|
||||
error: queryError,
|
||||
} = useVideoResultsQuery(videoId);
|
||||
|
||||
const detectionType = useMemo<DetectionType>(
|
||||
() => (detectionData ? inferDetectionType(detectionData) : 'pothole-detection'),
|
||||
[detectionData],
|
||||
);
|
||||
|
||||
const error = isError
|
||||
? queryError instanceof Error
|
||||
? queryError.message
|
||||
: 'Failed to load results'
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
setSession(sessionService.loadSession());
|
||||
}, []);
|
||||
|
||||
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) {
|
||||
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 detection results...
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
return <VideoResultsSkeleton />;
|
||||
}
|
||||
|
||||
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>
|
||||
<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 className="flex min-h-[60vh] items-center justify-center">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</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">
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title={getTitle()}
|
||||
title="Detection Results"
|
||||
description={`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 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
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detectionData && (
|
||||
<VideoPlayerSection
|
||||
data={detectionData}
|
||||
videoId={videoId}
|
||||
detectionType={detectionType}
|
||||
projectId={session?.projectId || undefined}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{detectionData && <VideoPlayerSection data={detectionData} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { videoService } from '@/services/api';
|
||||
import type { VideoAnnotationFramesParams } from '@/types';
|
||||
|
||||
import { videoKeys } from '../queries/videoKeys';
|
||||
|
||||
@@ -18,6 +19,20 @@ export function useVideoResultsQuery(videoId: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useVideoAnnotationFramesQuery(
|
||||
videoId: string | undefined,
|
||||
params: VideoAnnotationFramesParams,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: videoKeys.annotationFrames(videoId ?? '', params),
|
||||
queryFn: () =>
|
||||
videoService.getVideoAnnotationFrames(videoId as string, params),
|
||||
enabled: Boolean(videoId),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
gcTime: 1000 * 60 * 30,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the annotated video through the authenticated axios client and
|
||||
* expose a local object URL that the native `<video>` element can play.
|
||||
@@ -26,10 +41,10 @@ export function useVideoResultsQuery(videoId: string | undefined) {
|
||||
* 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) {
|
||||
export function useProtectedVideoQuery(url: string | undefined) {
|
||||
const query = useQuery({
|
||||
queryKey: videoKeys.annotatedVideo(url ?? ''),
|
||||
queryFn: () => videoService.getAnnotatedVideo(url as string),
|
||||
queryFn: () => videoService.getProtectedVideo(url as string),
|
||||
enabled: Boolean(url),
|
||||
staleTime: Infinity,
|
||||
gcTime: 1000 * 60 * 30,
|
||||
@@ -59,3 +74,5 @@ export function useAnnotatedVideoQuery(url: string | undefined) {
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export const useAnnotatedVideoQuery = useProtectedVideoQuery;
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { sessionService } from '@/services/api';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
export default function ResultsPage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const storedSession = sessionService.loadSession();
|
||||
const videoData = sessionService.loadVideoData();
|
||||
|
||||
if (!sessionService.isSessionValid(storedSession) || !videoData?.videoId) {
|
||||
router.replace(ROUTES.UPLOAD);
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(`${ROUTES.RESULTS}/${videoData.videoId}`);
|
||||
}, [router]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,10 @@ export const videoKeys = {
|
||||
all: ['videos'] as const,
|
||||
results: () => [...videoKeys.all, 'results'] as const,
|
||||
result: (videoId: string) => [...videoKeys.results(), videoId] as const,
|
||||
annotationFrames: (
|
||||
videoId: string,
|
||||
params: { start_time_ms: number; end_time_ms: number },
|
||||
) => [...videoKeys.result(videoId), 'annotation-frames', params] as const,
|
||||
annotatedVideos: () => [...videoKeys.all, 'annotated'] as const,
|
||||
annotatedVideo: (url: string) =>
|
||||
[...videoKeys.annotatedVideos(), url] as const,
|
||||
|
||||
@@ -62,16 +62,22 @@ export function RoleSheet({
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex h-full flex-col gap-0 p-0 sm:max-w-3xl lg:max-w-2xl"
|
||||
className="flex h-full w-full! flex-col gap-0 p-0 sm:max-w-3xl!"
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b px-6 py-4">
|
||||
<SheetTitle>{roleId ? 'Edit Role' : 'Create Role'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
{roleId ? 'Edit Role' : 'Create Role'}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-sm">
|
||||
Assign the role details and permission access for this organization.
|
||||
</SheetDescription>
|
||||
</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="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
@@ -123,7 +129,7 @@ export function RoleSheet({
|
||||
}
|
||||
>
|
||||
{isPermissionsLoading ? (
|
||||
<div className="rounded-md border p-6 text-muted-foreground">
|
||||
<div className="rounded-lg border p-6 text-muted-foreground">
|
||||
Loading permissions...
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -89,22 +89,7 @@ export function useRoleForm({
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
(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 onSubmit = handleSubmit((values) => saveMutation.mutate(values));
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
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,13 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||
import { Loader2, MapPin, Milestone } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { FormField } from '@/components/form';
|
||||
import { PackageSelect } from '@/components/lookups/PackageSelect';
|
||||
import { ProjectSelect } from '@/components/lookups/ProjectSelect';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -22,7 +26,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { Package, Project } from '@/types';
|
||||
|
||||
import type { SegmentFormValues } from '../hooks/useSegmentForm';
|
||||
|
||||
@@ -30,10 +33,6 @@ interface SegmentDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
segmentId?: string;
|
||||
projects: Project[];
|
||||
packages: Package[];
|
||||
isProjectsLoading: boolean;
|
||||
isPackagesLoading: boolean;
|
||||
projectId: string;
|
||||
onProjectChange: (projectId: string) => void;
|
||||
packageId: string;
|
||||
@@ -52,10 +51,6 @@ export function SegmentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
segmentId,
|
||||
projects,
|
||||
packages,
|
||||
isProjectsLoading,
|
||||
isPackagesLoading,
|
||||
projectId,
|
||||
onProjectChange,
|
||||
packageId,
|
||||
@@ -69,56 +64,54 @@ export function SegmentDialog({
|
||||
canSubmit,
|
||||
isSaving,
|
||||
}: SegmentDialogProps) {
|
||||
const projectSelectPortalRef = useRef<HTMLDivElement | null>(null);
|
||||
const packageSelectPortalRef = useRef<HTMLDivElement | null>(null);
|
||||
const getError = (field: keyof SegmentFormValues) =>
|
||||
isSubmitted ? errors[field]?.message : undefined;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-2xl"
|
||||
className="sm:max-w-2xl"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
|
||||
<Milestone className="size-5" />
|
||||
</div>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{segmentId ? 'Edit Segment' : 'Create Segment'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{segmentId
|
||||
? 'Update the technical specifications for your road segment.'
|
||||
: 'Select a project and package, then provide the segment data.'}
|
||||
Manage segment binding, chainage values, direction, and coordinates.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<DialogBody className="space-y-6">
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Basic details</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Project, package, segment name, and direction.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!segmentId ? (
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
label="Project"
|
||||
required
|
||||
error={getError('project_id')}
|
||||
>
|
||||
<Select value={projectId} onValueChange={onProjectChange}>
|
||||
<SelectTrigger aria-invalid={!!getError('project_id')}>
|
||||
{isProjectsLoading ? (
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</span>
|
||||
) : (
|
||||
<SelectValue placeholder="Choose a project" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ProjectSelect
|
||||
value={projectId}
|
||||
onValueChange={onProjectChange}
|
||||
enabled={open}
|
||||
disabled={isSaving}
|
||||
portalContainer={projectSelectPortalRef}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
{...register('project_id')}
|
||||
@@ -132,35 +125,14 @@ export function SegmentDialog({
|
||||
required
|
||||
error={getError('package_id')}
|
||||
>
|
||||
<Select
|
||||
<PackageSelect
|
||||
value={packageId}
|
||||
onValueChange={onPackageChange}
|
||||
disabled={!projectId || isPackagesLoading}
|
||||
>
|
||||
<SelectTrigger aria-invalid={!!getError('package_id')}>
|
||||
{isPackagesLoading ? (
|
||||
<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'
|
||||
}
|
||||
projectId={projectId}
|
||||
enabled={open && Boolean(projectId)}
|
||||
disabled={!projectId || isSaving}
|
||||
portalContainer={packageSelectPortalRef}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{packages.map((pkg) => (
|
||||
<SelectItem key={pkg.id} value={pkg.id}>
|
||||
{pkg.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input
|
||||
type="hidden"
|
||||
{...register('package_id')}
|
||||
@@ -171,7 +143,7 @@ export function SegmentDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
id="segment-name"
|
||||
label="Segment Name"
|
||||
@@ -180,7 +152,7 @@ export function SegmentDialog({
|
||||
>
|
||||
<Input
|
||||
id="segment-name"
|
||||
placeholder="e.g. Mumbai to Pune"
|
||||
placeholder="Enter segment name"
|
||||
aria-invalid={!!getError('segment_name')}
|
||||
{...register('segment_name')}
|
||||
/>
|
||||
@@ -197,19 +169,30 @@ export function SegmentDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Chainage</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Required start and end kilometre values.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
id="segment-start-km"
|
||||
label="Start (km)"
|
||||
label="Start Chainage"
|
||||
required
|
||||
error={getError('chainage_start_km')}
|
||||
>
|
||||
<Input
|
||||
id="segment-start-km"
|
||||
type="number"
|
||||
step="any"
|
||||
step="1"
|
||||
min="0"
|
||||
placeholder="0.0"
|
||||
placeholder="Enter start chainage"
|
||||
aria-invalid={!!getError('chainage_start_km')}
|
||||
{...register('chainage_start_km')}
|
||||
/>
|
||||
@@ -217,32 +200,36 @@ export function SegmentDialog({
|
||||
|
||||
<FormField
|
||||
id="segment-end-km"
|
||||
label="End (km)"
|
||||
label="End Chainage"
|
||||
required
|
||||
error={getError('chainage_end_km')}
|
||||
>
|
||||
<Input
|
||||
id="segment-end-km"
|
||||
type="number"
|
||||
step="any"
|
||||
step="1"
|
||||
min="0"
|
||||
placeholder="1.0"
|
||||
placeholder="Enter end chainage"
|
||||
aria-invalid={!!getError('chainage_end_km')}
|
||||
{...register('chainage_end_km')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<p className="flex items-center gap-2 text-muted-foreground">
|
||||
<MapPin className="size-3.5 opacity-60" />
|
||||
START COORDINATES
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Coordinates</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Required start and end point latitude and longitude.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
id="start-lat"
|
||||
label="Latitude"
|
||||
label="Start Latitude"
|
||||
required
|
||||
error={getError('start_lat')}
|
||||
>
|
||||
@@ -259,7 +246,7 @@ export function SegmentDialog({
|
||||
</FormField>
|
||||
<FormField
|
||||
id="start-lng"
|
||||
label="Longitude"
|
||||
label="Start Longitude"
|
||||
required
|
||||
error={getError('start_lng')}
|
||||
>
|
||||
@@ -275,17 +262,11 @@ export function SegmentDialog({
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="flex items-center gap-2 text-muted-foreground">
|
||||
<MapPin className="size-3.5 opacity-60" />
|
||||
END COORDINATES
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
id="end-lat"
|
||||
label="Latitude"
|
||||
label="End Latitude"
|
||||
required
|
||||
error={getError('end_lat')}
|
||||
>
|
||||
@@ -302,7 +283,7 @@ export function SegmentDialog({
|
||||
</FormField>
|
||||
<FormField
|
||||
id="end-lng"
|
||||
label="Longitude"
|
||||
label="End Longitude"
|
||||
required
|
||||
error={getError('end_lng')}
|
||||
>
|
||||
@@ -319,7 +300,8 @@ export function SegmentDialog({
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -331,11 +313,16 @@ export function SegmentDialog({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||
{isSaving ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
{segmentId ? 'Update Segment' : 'Create Segment'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
<div ref={projectSelectPortalRef} />
|
||||
<div ref={packageSelectPortalRef} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -7,49 +7,32 @@ import { useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
requiredIntegerNumber,
|
||||
requiredLatitude,
|
||||
requiredLongitude,
|
||||
validateChainageRange,
|
||||
} from '@/lib/validation/numberField';
|
||||
import { chainageService } from '@/services/api';
|
||||
import type { Chainage, ChainageCreate, ChainageUpdate } from '@/types';
|
||||
|
||||
import { segmentKeys } from '../queries/segmentKeys';
|
||||
|
||||
const requiredNumber = (message: string) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, message)
|
||||
.refine((value) => Number.isFinite(Number(value)), {
|
||||
message: 'Enter a valid number',
|
||||
});
|
||||
|
||||
const latitude = requiredNumber('Latitude is required').refine(
|
||||
(value) => {
|
||||
const numberValue = Number(value);
|
||||
return numberValue >= -90 && numberValue <= 90;
|
||||
},
|
||||
{ message: 'Latitude must be between -90 and 90' },
|
||||
);
|
||||
|
||||
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({
|
||||
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,
|
||||
chainage_start_km: requiredIntegerNumber('Start km is required'),
|
||||
chainage_end_km: requiredIntegerNumber('End km is required'),
|
||||
start_lat: requiredLatitude,
|
||||
start_lng: requiredLongitude,
|
||||
end_lat: requiredLatitude,
|
||||
end_lng: requiredLongitude,
|
||||
direction: z.enum(['UP', 'DOWN']),
|
||||
});
|
||||
})
|
||||
.superRefine(validateChainageRange);
|
||||
|
||||
export type SegmentFormValues = z.infer<typeof segmentFormSchema>;
|
||||
|
||||
@@ -141,17 +124,7 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
(values) => saveMutation.mutate(values),
|
||||
(formErrors) => {
|
||||
const firstMessage = Object.values(formErrors).find(
|
||||
(error) => error?.message,
|
||||
)?.message;
|
||||
if (firstMessage) {
|
||||
toast.error(String(firstMessage));
|
||||
}
|
||||
},
|
||||
);
|
||||
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
reset(defaultValues);
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from '@/services/api';
|
||||
import type { Chainage, PaginationParams } from '@/types';
|
||||
|
||||
import { packageKeys } from '../../package/queries/packageKeys';
|
||||
import { projectKeys } from '../../project/queries/projectKeys';
|
||||
import { segmentKeys } from '../queries/segmentKeys';
|
||||
|
||||
interface UseSegmentsQueryParams {
|
||||
@@ -32,27 +34,18 @@ export function useSegmentsQuery({ skip, limit }: UseSegmentsQueryParams) {
|
||||
|
||||
export function useProjectOptionsQuery() {
|
||||
return useQuery({
|
||||
queryKey: ['projects', 'options'],
|
||||
queryKey: projectKeys.options(),
|
||||
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAllPackageOptionsQuery() {
|
||||
return useQuery({
|
||||
queryKey: ['packages', 'options'],
|
||||
queryKey: packageKeys.options(),
|
||||
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() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,16 @@ import { useCallback, useState } from 'react';
|
||||
import { Milestone, Plus } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Chainage } from '@/types';
|
||||
|
||||
@@ -15,13 +25,13 @@ import { useSegmentForm } from './hooks/useSegmentForm';
|
||||
import {
|
||||
useAllPackageOptionsQuery,
|
||||
useDeleteSegmentMutation,
|
||||
usePackagesByProjectQuery,
|
||||
useProjectOptionsQuery,
|
||||
useSegmentsQuery,
|
||||
} from './hooks/useSegmentQueries';
|
||||
|
||||
export default function SegmentPage() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [segmentToDelete, setSegmentToDelete] = useState<Chainage | null>(null);
|
||||
const { skip, setSkip, limit, setLimit } = useSegmentFilters();
|
||||
const segmentsQuery = useSegmentsQuery({ skip, limit });
|
||||
const projectsQuery = useProjectOptionsQuery();
|
||||
@@ -48,11 +58,6 @@ export default function SegmentPage() {
|
||||
canSubmit,
|
||||
isSaving,
|
||||
} = segmentForm;
|
||||
const projectPackagesQuery = usePackagesByProjectQuery(
|
||||
projectId,
|
||||
isDialogOpen,
|
||||
);
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
prepareCreateSegment();
|
||||
setIsDialogOpen(true);
|
||||
@@ -81,21 +86,27 @@ export default function SegmentPage() {
|
||||
|
||||
const deleteSegment = useCallback(
|
||||
(segment: Chainage) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to delete segment "${segment.segment_name}"?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deleteSegmentMutation.mutate(segment);
|
||||
setSegmentToDelete(segment);
|
||||
},
|
||||
[deleteSegmentMutation],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setSegmentToDelete(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const confirmDeleteSegment = useCallback(() => {
|
||||
if (!segmentToDelete) return;
|
||||
|
||||
deleteSegmentMutation.mutate(segmentToDelete, {
|
||||
onSettled: () => setSegmentToDelete(null),
|
||||
});
|
||||
}, [deleteSegmentMutation, segmentToDelete]);
|
||||
|
||||
const projects = projectsQuery.data?.items ?? [];
|
||||
const allPackages = allPackagesQuery.data?.items ?? [];
|
||||
const projectPackages = projectPackagesQuery.data?.items ?? [];
|
||||
const segments = segmentsQuery.data?.items ?? [];
|
||||
const total = segmentsQuery.data?.totalItems ?? 0;
|
||||
const columns = useSegmentColumns(projects, allPackages);
|
||||
@@ -133,10 +144,6 @@ export default function SegmentPage() {
|
||||
open={isDialogOpen}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
segmentId={segmentId}
|
||||
projects={projects}
|
||||
packages={projectPackages}
|
||||
isProjectsLoading={projectsQuery.isLoading}
|
||||
isPackagesLoading={projectPackagesQuery.isLoading}
|
||||
projectId={projectId}
|
||||
onProjectChange={setProjectId}
|
||||
packageId={packageId}
|
||||
@@ -150,6 +157,36 @@ export default function SegmentPage() {
|
||||
canSubmit={canSubmit}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(segmentToDelete)}
|
||||
onOpenChange={handleDeleteDialogOpenChange}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete segment?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{segmentToDelete?.segment_name}
|
||||
</span>
|
||||
. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteSegmentMutation.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleteSegmentMutation.isPending}
|
||||
onClick={confirmDeleteSegment}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,4 +4,6 @@ export const segmentKeys = {
|
||||
all: ['segments'] as const,
|
||||
lists: () => [...segmentKeys.all, 'list'] 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,12 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { UseFormRegister } from 'react-hook-form';
|
||||
import { useMemo, useRef, type ComponentProps } from 'react';
|
||||
import {
|
||||
Controller,
|
||||
type Control,
|
||||
type FieldErrors,
|
||||
type UseFormRegister,
|
||||
} from 'react-hook-form';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { FormField, PhoneInput, SelectPopover } from '@/components/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -14,17 +21,12 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { Client, Plan } from '@/types';
|
||||
|
||||
import type { TenantFormValues } from '../hooks/useTenantForm';
|
||||
import type {
|
||||
TenantAdminFieldLocks,
|
||||
TenantFormValues,
|
||||
} from '../hooks/useTenantForm';
|
||||
|
||||
interface TenantSheetProps {
|
||||
open: boolean;
|
||||
@@ -32,7 +34,11 @@ interface TenantSheetProps {
|
||||
tenantId?: number;
|
||||
isEditMode: boolean;
|
||||
register: UseFormRegister<TenantFormValues>;
|
||||
control: Control<TenantFormValues>;
|
||||
errors: FieldErrors<TenantFormValues>;
|
||||
isSubmitted: boolean;
|
||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||
canSubmit: boolean;
|
||||
clientId: string;
|
||||
planId: string;
|
||||
onClientChange: (clientId: string) => void;
|
||||
@@ -43,6 +49,7 @@ interface TenantSheetProps {
|
||||
isLookupsLoading: boolean;
|
||||
isSaving: boolean;
|
||||
adminEmail?: string;
|
||||
adminFieldLocks: TenantAdminFieldLocks;
|
||||
}
|
||||
|
||||
export function TenantSheet({
|
||||
@@ -51,7 +58,11 @@ export function TenantSheet({
|
||||
tenantId,
|
||||
isEditMode,
|
||||
register,
|
||||
control,
|
||||
errors,
|
||||
isSubmitted,
|
||||
onSubmit,
|
||||
canSubmit,
|
||||
clientId,
|
||||
planId,
|
||||
onClientChange,
|
||||
@@ -62,10 +73,33 @@ export function TenantSheet({
|
||||
isLookupsLoading,
|
||||
isSaving,
|
||||
adminEmail,
|
||||
adminFieldLocks,
|
||||
}: TenantSheetProps) {
|
||||
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
|
||||
const fieldError = (field: keyof TenantFormValues) =>
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{tenantId ? 'Edit Tenant' : 'Create Tenant'}
|
||||
@@ -75,7 +109,13 @@ export function TenantSheet({
|
||||
administrator.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
||||
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<DialogBody className="space-y-5">
|
||||
<div className="space-y-1">
|
||||
<p>Basic Information</p>
|
||||
<p className="text-muted-foreground">
|
||||
@@ -84,97 +124,113 @@ export function TenantSheet({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-name">Tenant Name</Label>
|
||||
<FormField
|
||||
id="tenant-name"
|
||||
label="Tenant Name"
|
||||
required
|
||||
error={fieldError('name')}
|
||||
>
|
||||
<Input
|
||||
id="tenant-name"
|
||||
placeholder="Acme Corp"
|
||||
placeholder="Enter tenant name"
|
||||
aria-invalid={!!fieldError('name')}
|
||||
{...register('name', {
|
||||
required: true,
|
||||
onChange: (event) => onNameChange(event.target.value),
|
||||
})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-slug">Slug</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="tenant-slug"
|
||||
label="Slug"
|
||||
required
|
||||
error={fieldError('slug')}
|
||||
>
|
||||
<Input
|
||||
id="tenant-slug"
|
||||
placeholder="acme-corp"
|
||||
{...register('slug', { required: true })}
|
||||
required
|
||||
aria-invalid={!!fieldError('slug')}
|
||||
{...register('slug')}
|
||||
readOnly={isEditMode}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Client</Label>
|
||||
<Select
|
||||
<FormField
|
||||
id="tenant-client"
|
||||
label="Client"
|
||||
required
|
||||
error={fieldError('client_id')}
|
||||
>
|
||||
<SelectPopover
|
||||
options={clientOptions}
|
||||
value={clientId}
|
||||
onValueChange={onClientChange}
|
||||
disabled={isLookupsLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
portalContainer={dropdownPortalRef}
|
||||
placeholder={
|
||||
isLookupsLoading ? 'Loading clients...' : 'Select client'
|
||||
}
|
||||
searchPlaceholder="Search clients"
|
||||
emptyMessage="No clients found."
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={String(client.id)}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Subscription Plan</Label>
|
||||
<Select
|
||||
<input
|
||||
type="hidden"
|
||||
{...register('client_id')}
|
||||
value={clientId}
|
||||
readOnly
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="tenant-plan"
|
||||
label="Subscription Plan"
|
||||
required
|
||||
error={fieldError('plan_id')}
|
||||
>
|
||||
<SelectPopover
|
||||
options={planOptions}
|
||||
value={planId}
|
||||
onValueChange={onPlanChange}
|
||||
disabled={isLookupsLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
portalContainer={dropdownPortalRef}
|
||||
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>
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
{...register('plan_id')}
|
||||
value={planId}
|
||||
readOnly
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-domain">Domain</Label>
|
||||
<FormField id="tenant-domain" label="Domain">
|
||||
<Input
|
||||
id="tenant-domain"
|
||||
placeholder="acme.example.com"
|
||||
{...register('domain')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="tenant-description">Description</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="tenant-description"
|
||||
label="Description"
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<textarea
|
||||
id="tenant-description"
|
||||
placeholder="North zone operations"
|
||||
{...register('description')}
|
||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
|
||||
className="min-h-20 w-full rounded-lg border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{!isEditMode ? (
|
||||
@@ -187,57 +243,89 @@ export function TenantSheet({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-first-name">First Name</Label>
|
||||
<FormField
|
||||
id="admin-first-name"
|
||||
label="First Name"
|
||||
required
|
||||
error={fieldError('admin_first_name')}
|
||||
>
|
||||
<Input
|
||||
id="admin-first-name"
|
||||
placeholder="Jane"
|
||||
{...register('admin_first_name', { required: !isEditMode })}
|
||||
required
|
||||
placeholder="Enter first name"
|
||||
aria-invalid={!!fieldError('admin_first_name')}
|
||||
readOnly={adminFieldLocks.admin_first_name}
|
||||
{...register('admin_first_name')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-last-name">Last Name</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="admin-last-name"
|
||||
label="Last Name"
|
||||
required
|
||||
error={fieldError('admin_last_name')}
|
||||
>
|
||||
<Input
|
||||
id="admin-last-name"
|
||||
placeholder="Doe"
|
||||
{...register('admin_last_name', { required: !isEditMode })}
|
||||
required
|
||||
placeholder="Enter last name"
|
||||
aria-invalid={!!fieldError('admin_last_name')}
|
||||
readOnly={adminFieldLocks.admin_last_name}
|
||||
{...register('admin_last_name')}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-email">Admin Email</Label>
|
||||
<FormField
|
||||
id="admin-email"
|
||||
label="Admin Email"
|
||||
required
|
||||
error={fieldError('admin_email')}
|
||||
>
|
||||
<Input
|
||||
id="admin-email"
|
||||
type="email"
|
||||
placeholder="jane@acme.com"
|
||||
{...register('admin_email', { required: !isEditMode })}
|
||||
required
|
||||
placeholder="admin@example.com"
|
||||
aria-invalid={!!fieldError('admin_email')}
|
||||
readOnly={adminFieldLocks.admin_email}
|
||||
{...register('admin_email')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-phone">Phone Number</Label>
|
||||
<Input
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="admin-phone"
|
||||
placeholder="+91-9000011111"
|
||||
{...register('admin_phone_number')}
|
||||
label="Phone Number"
|
||||
error={fieldError('admin_phone_number')}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="admin_phone_number"
|
||||
render={({ field }) => (
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</>
|
||||
) : adminEmail ? (
|
||||
<div className="rounded-md border bg-muted/40 p-4 text-muted-foreground">
|
||||
Admin invitation details cannot be changed after tenant creation.
|
||||
<div className="rounded-lg border bg-muted/40 p-4 text-muted-foreground">
|
||||
Admin invitation details cannot be changed after tenant
|
||||
creation.
|
||||
<p className="mt-1 text-foreground">
|
||||
Current admin email: <span>{adminEmail}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="px-0">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -246,7 +334,10 @@ export function TenantSheet({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving || isLookupsLoading}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving || isLookupsLoading || !canSubmit}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
@@ -254,6 +345,8 @@ export function TenantSheet({
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
<div ref={dropdownPortalRef} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,27 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Tenant } from '@/types';
|
||||
|
||||
import { useSaveTenantMutation } from './useTenantMutations';
|
||||
|
||||
export interface TenantFormValues {
|
||||
id?: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
client_id: string;
|
||||
plan_id: string;
|
||||
domain: string;
|
||||
description: string;
|
||||
admin_first_name: string;
|
||||
admin_last_name: string;
|
||||
admin_email: string;
|
||||
admin_phone_number: string;
|
||||
const requiredText = (message: string) => z.string().trim().min(1, message);
|
||||
|
||||
const tenantFormSchema = z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
name: requiredText('Tenant name is required'),
|
||||
slug: requiredText('Slug is required'),
|
||||
client_id: requiredText('Client is required'),
|
||||
plan_id: requiredText('Plan is required'),
|
||||
domain: z.string().trim(),
|
||||
description: z.string().trim(),
|
||||
admin_first_name: z.string().trim(),
|
||||
admin_last_name: z.string().trim(),
|
||||
admin_email: z.string().trim(),
|
||||
admin_phone_number: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => value === '' || isValidPhoneNumber(value), {
|
||||
message: 'Enter a valid phone number',
|
||||
}),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
if (values.id) return;
|
||||
|
||||
if (!values.admin_first_name.trim()) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['admin_first_name'],
|
||||
message: 'First name is required',
|
||||
});
|
||||
}
|
||||
|
||||
if (!values.admin_last_name.trim()) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['admin_last_name'],
|
||||
message: 'Last name is required',
|
||||
});
|
||||
}
|
||||
|
||||
if (!values.admin_email.trim()) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['admin_email'],
|
||||
message: 'Admin email is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!z.string().email().safeParse(values.admin_email).success) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['admin_email'],
|
||||
message: 'Invalid admin email',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type TenantFormValues = z.infer<typeof tenantFormSchema>;
|
||||
|
||||
const defaultValues: TenantFormValues = {
|
||||
name: '',
|
||||
slug: '',
|
||||
@@ -50,6 +98,20 @@ function splitContactName(contactName: string) {
|
||||
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 }) {
|
||||
const {
|
||||
register,
|
||||
@@ -57,48 +119,49 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
formState: { errors, isSubmitting, isSubmitted },
|
||||
} = useForm<TenantFormValues>({
|
||||
defaultValues,
|
||||
mode: 'onSubmit',
|
||||
reValidateMode: 'onChange',
|
||||
resolver: zodResolver(tenantFormSchema),
|
||||
});
|
||||
const saveMutation = useSaveTenantMutation({
|
||||
onSaved: () => {
|
||||
reset(defaultValues);
|
||||
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||
onSaved();
|
||||
},
|
||||
});
|
||||
|
||||
const [adminFieldLocks, setAdminFieldLocks] = useState<TenantAdminFieldLocks>(
|
||||
defaultAdminFieldLocks,
|
||||
);
|
||||
|
||||
const tenantId = useWatch({ control, name: 'id' });
|
||||
const clientId = useWatch({ control, name: 'client_id' }) || '';
|
||||
const planId = useWatch({ control, name: 'plan_id' }) || '';
|
||||
const name = useWatch({ control, name: 'name' }) || '';
|
||||
const slug = useWatch({ control, name: 'slug' }) || '';
|
||||
const adminFirstName = useWatch({ control, name: 'admin_first_name' }) || '';
|
||||
const adminLastName = useWatch({ control, name: 'admin_last_name' }) || '';
|
||||
const adminEmail = useWatch({ control, name: 'admin_email' }) || '';
|
||||
const isEditMode = Boolean(tenantId);
|
||||
const canSubmit =
|
||||
name.trim().length > 0 &&
|
||||
slug.trim().length > 0 &&
|
||||
clientId.trim().length > 0 &&
|
||||
planId.trim().length > 0 &&
|
||||
(isEditMode ||
|
||||
(adminFirstName.trim().length > 0 &&
|
||||
adminLastName.trim().length > 0 &&
|
||||
adminEmail.trim().length > 0));
|
||||
|
||||
const handleSubmit = submitForm(
|
||||
(values) => {
|
||||
if (!values.client_id || !values.plan_id) {
|
||||
toast.error('Select a client and plan');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEditMode) {
|
||||
if (
|
||||
!values.admin_first_name.trim() ||
|
||||
!values.admin_last_name.trim() ||
|
||||
!values.admin_email.trim()
|
||||
) {
|
||||
toast.error('Complete all required admin fields');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
saveMutation.mutate(values);
|
||||
},
|
||||
() => toast.error('Complete all required tenant fields'),
|
||||
);
|
||||
const handleSubmit = submitForm((values) => saveMutation.mutate(values));
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
reset(defaultValues);
|
||||
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||
}, [reset]);
|
||||
|
||||
const openEdit = useCallback(
|
||||
@@ -116,19 +179,37 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
||||
admin_email: tenant.admin_email || '',
|
||||
admin_phone_number: '',
|
||||
});
|
||||
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||
},
|
||||
[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(
|
||||
(contactName: string, contactEmail: string, contactPhone: string) => {
|
||||
if (isEditMode) return;
|
||||
|
||||
const { firstName, lastName } = splitContactName(contactName);
|
||||
const trimmedEmail = contactEmail.trim();
|
||||
const trimmedPhone = contactPhone.trim();
|
||||
|
||||
setValue('admin_first_name', firstName, { shouldDirty: true });
|
||||
setValue('admin_last_name', lastName, { shouldDirty: true });
|
||||
setValue('admin_email', contactEmail, { shouldDirty: true });
|
||||
setValue('admin_phone_number', contactPhone, { shouldDirty: true });
|
||||
setValue('admin_email', trimmedEmail, { 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],
|
||||
);
|
||||
@@ -143,9 +224,9 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
||||
|
||||
return {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
openCreate,
|
||||
openEdit,
|
||||
@@ -154,7 +235,12 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
||||
planId,
|
||||
adminEmail,
|
||||
isEditMode,
|
||||
errors,
|
||||
isSubmitted,
|
||||
canSubmit,
|
||||
adminFieldLocks,
|
||||
applyClientAdminDefaults,
|
||||
clearAdminFields,
|
||||
syncSlugFromName,
|
||||
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 {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
openCreate: prepareCreateTenant,
|
||||
openEdit: prepareEditTenant,
|
||||
@@ -62,7 +63,12 @@ export default function TenantsPage() {
|
||||
planId,
|
||||
adminEmail,
|
||||
isEditMode,
|
||||
errors,
|
||||
isSubmitted,
|
||||
canSubmit,
|
||||
adminFieldLocks,
|
||||
applyClientAdminDefaults,
|
||||
clearAdminFields,
|
||||
syncSlugFromName,
|
||||
setValue,
|
||||
isSaving,
|
||||
@@ -74,7 +80,11 @@ export default function TenantsPage() {
|
||||
(value: string) => {
|
||||
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) => {
|
||||
applyClientAdminDefaults(
|
||||
@@ -84,7 +94,7 @@ export default function TenantsPage() {
|
||||
);
|
||||
});
|
||||
},
|
||||
[applyClientAdminDefaults, isEditMode, setValue],
|
||||
[applyClientAdminDefaults, clearAdminFields, isEditMode, setValue],
|
||||
);
|
||||
|
||||
const handlePlanChange = useCallback(
|
||||
@@ -180,7 +190,11 @@ export default function TenantsPage() {
|
||||
tenantId={tenantId}
|
||||
isEditMode={isEditMode}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
isSubmitted={isSubmitted}
|
||||
onSubmit={handleSubmit}
|
||||
canSubmit={canSubmit}
|
||||
clientId={clientId}
|
||||
planId={planId}
|
||||
onClientChange={handleClientChange}
|
||||
@@ -191,6 +205,7 @@ export default function TenantsPage() {
|
||||
isLookupsLoading={isLookupsLoading}
|
||||
isSaving={isSaving}
|
||||
adminEmail={adminEmail}
|
||||
adminFieldLocks={adminFieldLocks}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Timeline, TimelineItem } from '@/components/ui/timeline';
|
||||
|
||||
function TimelineEntrySkeleton({
|
||||
index,
|
||||
isLast,
|
||||
}: {
|
||||
index: number;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TimelineItem
|
||||
layout="left"
|
||||
isLast={isLast}
|
||||
opposite={
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="ml-auto h-4 w-16" />
|
||||
<Skeleton className="ml-auto h-3 w-12" />
|
||||
</div>
|
||||
}
|
||||
marker={<Skeleton className="size-4 rounded-full" />}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
index === 0
|
||||
? 'rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5'
|
||||
: 'rounded-lg border bg-card p-4'
|
||||
}
|
||||
>
|
||||
{index === 0 ? <Skeleton className="h-5 w-28 rounded-lg" /> : null}
|
||||
<Skeleton className={index === 0 ? 'mt-3 h-4 w-44' : 'h-4 w-44'} />
|
||||
{index === 0 ? (
|
||||
<Skeleton className="mt-2 h-3 w-full max-w-md" />
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="h-3 w-48 max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="mt-3 h-16 w-full rounded-lg" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TimelineItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketClassContentSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4" aria-label="Loading defect class details">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-lg" />
|
||||
<Skeleton className="h-5 w-56 max-w-full" />
|
||||
</div>
|
||||
|
||||
<Timeline>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<TimelineEntrySkeleton
|
||||
key={index}
|
||||
index={index}
|
||||
isLast={index === 2}
|
||||
/>
|
||||
))}
|
||||
</Timeline>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketClassActionsSkeleton() {
|
||||
return (
|
||||
<Card aria-label="Loading defect class actions">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-4 rounded-lg" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useTicketClassDetectionsQuery } from '../../hooks/useTicketQueries';
|
||||
|
||||
interface TicketClassDetectionPreviewProps {
|
||||
videoId?: string | null;
|
||||
defectClassName: string;
|
||||
displayName: string;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
function DetectionMetadataBar({
|
||||
items,
|
||||
}: {
|
||||
items: { label: string; value: string | number }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg bg-muted/40 px-4 py-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:divide-x sm:divide-border/70">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={cn(
|
||||
'space-y-1',
|
||||
index === 0 && 'sm:pr-4',
|
||||
index === 1 && 'sm:px-4',
|
||||
index === 2 && 'sm:pl-4',
|
||||
)}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">{item.label}</p>
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TicketClassDetectionPreviewSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="aspect-video w-full rounded-lg" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-3 w-28" />
|
||||
<Skeleton
|
||||
className="w-full rounded-lg"
|
||||
style={{ aspectRatio: '16 / 9' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted/40 px-4 py-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:divide-x sm:divide-border/70">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'space-y-1',
|
||||
index === 0 && 'sm:pr-4',
|
||||
index === 1 && 'sm:px-4',
|
||||
index === 2 && 'sm:pl-4',
|
||||
)}
|
||||
>
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketClassDetectionPreview({
|
||||
videoId,
|
||||
defectClassName,
|
||||
displayName,
|
||||
totalCount,
|
||||
}: TicketClassDetectionPreviewProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const visual = getDefectVisual(defectClassName);
|
||||
const IssueIcon = visual.icon;
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentIndex(0);
|
||||
}, [defectClassName]);
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
class_name: defectClassName,
|
||||
skip: currentIndex,
|
||||
limit: 1,
|
||||
sort: 'timestamp_asc',
|
||||
}),
|
||||
[currentIndex, defectClassName],
|
||||
);
|
||||
|
||||
const detectionsQuery = useTicketClassDetectionsQuery(
|
||||
videoId ?? undefined,
|
||||
queryParams,
|
||||
);
|
||||
const detectionResult = detectionsQuery.data;
|
||||
const activeDetection = detectionResult?.items[0];
|
||||
const detectionCount = detectionResult?.total ?? totalCount ?? 0;
|
||||
const mediaAspectRatio =
|
||||
detectionResult &&
|
||||
detectionResult.video_width > 0 &&
|
||||
detectionResult.video_height > 0
|
||||
? `${detectionResult.video_width} / ${detectionResult.video_height}`
|
||||
: '16 / 9';
|
||||
|
||||
useEffect(() => {
|
||||
if (detectionCount === 0 && currentIndex !== 0) {
|
||||
setCurrentIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (detectionCount > 0 && currentIndex > detectionCount - 1) {
|
||||
setCurrentIndex(detectionCount - 1);
|
||||
}
|
||||
}, [currentIndex, detectionCount]);
|
||||
|
||||
const isNavigationDisabled =
|
||||
detectionCount === 0 || detectionsQuery.isLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-10 shrink-0 self-start items-center justify-center rounded-lg',
|
||||
visual.cardClassName,
|
||||
)}
|
||||
>
|
||||
<IssueIcon className={cn('size-5', visual.colorClassName)} />
|
||||
</span>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3>{displayName}</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'rounded-lg',
|
||||
visual.cardClassName,
|
||||
visual.colorClassName,
|
||||
)}
|
||||
>
|
||||
{detectionCount} Detections
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Review detections in ascending timestamp order.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 self-start sm:self-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setCurrentIndex((index) =>
|
||||
detectionCount === 0
|
||||
? 0
|
||||
: (index - 1 + detectionCount) % detectionCount,
|
||||
)
|
||||
}
|
||||
disabled={isNavigationDisabled}
|
||||
aria-label="Previous detection"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="min-w-16 text-center text-sm font-medium text-foreground">
|
||||
{detectionCount === 0
|
||||
? '0 of 0'
|
||||
: `${currentIndex + 1} of ${detectionCount}`}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setCurrentIndex((index) =>
|
||||
detectionCount === 0 ? 0 : (index + 1) % detectionCount,
|
||||
)
|
||||
}
|
||||
disabled={isNavigationDisabled}
|
||||
aria-label="Next detection"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detectionsQuery.isLoading && !detectionsQuery.data ? (
|
||||
<TicketClassDetectionPreviewSkeleton />
|
||||
) : detectionsQuery.isError ? (
|
||||
<div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-8 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<AlertTriangle className="size-6 text-destructive" />
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
Failed to load detection preview.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void detectionsQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : !activeDetection || !detectionResult ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
No detections are available for this issue.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<p className="text-muted-foreground">Detection Image</p>
|
||||
<div className="overflow-hidden rounded-lg">
|
||||
<AnnotatedDetectionImage
|
||||
detection={activeDetection}
|
||||
videoWidth={detectionResult.video_width}
|
||||
videoHeight={detectionResult.video_height}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetectionLocationMap
|
||||
className="min-w-0"
|
||||
latitude={activeDetection.location.latitude}
|
||||
longitude={activeDetection.location.longitude}
|
||||
label={`${displayName} - Frame ${activeDetection.frame.number}`}
|
||||
title="Location on Map"
|
||||
variant="plain"
|
||||
aspectRatio={mediaAspectRatio}
|
||||
showCoordinates={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DetectionMetadataBar
|
||||
items={[
|
||||
{
|
||||
label: 'Confidence',
|
||||
value: `${Math.round(activeDetection.detection.confidence * 100)}%`,
|
||||
},
|
||||
{
|
||||
label: 'Frame',
|
||||
value: activeDetection.frame.number,
|
||||
},
|
||||
{
|
||||
label: 'Timestamp',
|
||||
value: `${activeDetection.frame.timestamp_seconds}s`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Component, ListChecks } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } 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 type { TicketOverviewDetail } from '@/types';
|
||||
|
||||
import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries';
|
||||
import { TicketClassDetectionPreview } from './TicketClassDetectionPreview';
|
||||
|
||||
interface TicketDefectClassTabsProps {
|
||||
ticketId: string;
|
||||
ticket?: TicketOverviewDetail;
|
||||
}
|
||||
|
||||
export function TicketDefectClassTabs({
|
||||
ticketId,
|
||||
ticket,
|
||||
}: 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,
|
||||
);
|
||||
|
||||
if (hasUrlClass) {
|
||||
setSelectedClass(defectClassParam ?? undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const firstClass = defectClasses[0].class_name;
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.set('defect_class', firstClass);
|
||||
setSelectedClass(firstClass);
|
||||
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
|
||||
}, [defectClassParam, defectClasses, pathname, router, searchParams]);
|
||||
|
||||
const handleClassChange = (className: string) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
nextParams.set('defect_class', className);
|
||||
setSelectedClass(className);
|
||||
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
|
||||
};
|
||||
const selectedIssue = ticket?.ai_result.detections_by_class.find(
|
||||
(item) => item.class_name === selectedClass,
|
||||
);
|
||||
const selectedClassItem = defectClasses.find(
|
||||
(item) => item.class_name === selectedClass,
|
||||
);
|
||||
const previewDisplayName =
|
||||
selectedIssue?.display_name ??
|
||||
selectedClassItem?.display_name ??
|
||||
selectedClass ??
|
||||
'Detection';
|
||||
const previewVideoId = ticket?.video_id ?? defectClassesQuery.data?.video_id;
|
||||
|
||||
if (defectClassesQuery.isLoading) {
|
||||
return (
|
||||
<Card className="w-full rounded-lg">
|
||||
<CardContent className="space-y-4 p-5">
|
||||
<div className="h-5 w-24 animate-pulse rounded-lg bg-muted" />
|
||||
<div 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"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</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">
|
||||
<CardContent className="grid gap-0 p-0 lg:grid-cols-[minmax(220px,280px)_1fr] lg:divide-x lg:divide-border">
|
||||
<div className="space-y-4 px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListChecks className="size-5 shrink-0 text-primary" />
|
||||
<h2 className="text-base font-semibold">Detected Issues</h2>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="min-w-5 min-h-5 justify-center rounded-full"
|
||||
>
|
||||
{defectClasses.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<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 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 rounded-full',
|
||||
statusConfig.badgeClassName,
|
||||
)}
|
||||
>
|
||||
<Component className="size-3" />
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 px-5">
|
||||
<TicketClassDetectionPreview
|
||||
videoId={previewVideoId}
|
||||
defectClassName={selectedClass}
|
||||
displayName={previewDisplayName}
|
||||
totalCount={selectedIssue?.count}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { TicketOverviewDetail } from '@/types';
|
||||
|
||||
export function TicketDetailHeader({
|
||||
ticket,
|
||||
}: {
|
||||
ticket: TicketOverviewDetail;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const ticketLabel = ticket.ticket_name || ticket.id;
|
||||
|
||||
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">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Ticket Detail</h1>
|
||||
<p className="text-xs ">Ticket: {ticketLabel}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/ticket')}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to List
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { History, MessageSquareText } from 'lucide-react';
|
||||
|
||||
import { PersonInfo } from '@/components/person-avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Timeline, TimelineItem } from '@/components/ui/timeline';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TicketDetail, TicketHistoryItem, TicketHistorySource } from '@/types';
|
||||
import { formatTimelineDateTime } from '@/utils/date';
|
||||
import { getPersonLabel } from '@/utils/person';
|
||||
|
||||
function TimelineDate({ value }: { value: string }) {
|
||||
const { date, time } = formatTimelineDateTime(value);
|
||||
|
||||
return (
|
||||
<time dateTime={value} className="block tabular-nums">
|
||||
<span className="block text-sm font-semibold text-foreground">{date}</span>
|
||||
<span className="block text-xs text-muted-foreground">{time}</span>
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryNote({ item }: { item: TicketHistoryItem }) {
|
||||
const noteText = item.note?.text?.trim();
|
||||
if (!noteText) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-amber-300/80 bg-amber-50/90 px-3 py-2.5 dark:border-amber-500/30 dark:bg-amber-500/10">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-900 dark:text-amber-200">
|
||||
<MessageSquareText className="size-3.5 shrink-0 text-amber-700 dark:text-amber-300" />
|
||||
<span>
|
||||
Note by {getPersonLabel(item.note?.author ?? item.actor)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-amber-950/80 dark:text-amber-100/90">
|
||||
{noteText}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getSourceLabel(source: TicketHistorySource) {
|
||||
return source === 'system' ? 'System Generated' : 'User Action';
|
||||
}
|
||||
|
||||
function SystemHistoryEntry({ item }: { item: TicketHistoryItem }) {
|
||||
const noteText = item.note?.text?.trim();
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5">
|
||||
<Badge
|
||||
|
||||
className="rounded-lg px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase"
|
||||
>
|
||||
{getSourceLabel(item.source)}
|
||||
</Badge>
|
||||
<h3 className="mt-2 text-sm font-semibold text-foreground">{item.title}</h3>
|
||||
{noteText ? (
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{noteText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserHistoryEntry({ item }: { item: TicketHistoryItem }) {
|
||||
const noteText = item.note?.text?.trim();
|
||||
const isAssigned = item.event_type === 'assigned';
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-foreground">{item.title}</h3>
|
||||
<PersonInfo person={item.actor} />
|
||||
{isAssigned && item.target_user ? (
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Assigned to</p>
|
||||
<PersonInfo person={item.target_user} />
|
||||
</div>
|
||||
) : null}
|
||||
{noteText ? <HistoryNote item={item} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) {
|
||||
const history = ticket.history ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="size-5 text-primary" />
|
||||
<h2 className="text-base font-semibold">Audit & Lifecycle Timeline</h2>
|
||||
</div>
|
||||
|
||||
{history.length ? (
|
||||
<Timeline>
|
||||
{history.map((item, index) => (
|
||||
<TimelineItem
|
||||
key={item.id}
|
||||
layout="left"
|
||||
isLast={index === history.length - 1}
|
||||
opposite={<TimelineDate value={item.created_at} />}
|
||||
marker={
|
||||
<span className="flex size-4 items-center justify-center rounded-full border bg-background shadow-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 rounded-full',
|
||||
item.source === 'system'
|
||||
? 'bg-muted-foreground'
|
||||
: 'bg-primary',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{item.source === 'system' ? (
|
||||
<SystemHistoryEntry item={item} />
|
||||
) : (
|
||||
<UserHistoryEntry item={item} />
|
||||
)}
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import type { MediaPlayerInstance } from '@vidstack/react';
|
||||
|
||||
import { useProtectedVideoQuery } from '@/app/(modules)/results/hooks/useVideoResults';
|
||||
import AnnotatedVideoPlayer from '@/components/video/annotatedVideoPlayer';
|
||||
import { useVideoAnnotationPlayback } from '@/components/video/useVideoAnnotationPlayback';
|
||||
import type { TicketOverviewDetail } from '@/types';
|
||||
|
||||
type TicketInlineAnalysisVideoProps = {
|
||||
ticket: TicketOverviewDetail;
|
||||
};
|
||||
|
||||
export function TicketInlineAnalysisVideo({
|
||||
ticket,
|
||||
}: TicketInlineAnalysisVideoProps) {
|
||||
const videoRef = useRef<MediaPlayerInstance | null>(null);
|
||||
const videoId = ticket.video_id ?? ticket.video?.id ?? undefined;
|
||||
const sourceUrl = ticket.video?.url ?? undefined;
|
||||
const fps = ticket.video_metadata?.fps ?? 0;
|
||||
const videoWidth = ticket.video_metadata?.resolution.width ?? 0;
|
||||
const videoHeight = ticket.video_metadata?.resolution.height ?? 0;
|
||||
const {
|
||||
visibleDetections,
|
||||
handleSeeked,
|
||||
handleTimeUpdate,
|
||||
handleVideoFrame,
|
||||
} = useVideoAnnotationPlayback({
|
||||
videoId,
|
||||
logs: [],
|
||||
fps,
|
||||
videoRef,
|
||||
});
|
||||
const {
|
||||
videoUrl,
|
||||
isLoading: isVideoLoading,
|
||||
isError: isVideoError,
|
||||
refetch: refetchVideo,
|
||||
} = useProtectedVideoQuery(sourceUrl);
|
||||
|
||||
if (!videoId || !sourceUrl) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-8 text-sm text-muted-foreground">
|
||||
Analysis video is unavailable for this ticket.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<AnnotatedVideoPlayer
|
||||
videoRef={videoRef}
|
||||
logs={[]}
|
||||
visibleDetections={visibleDetections}
|
||||
videoWidth={videoWidth}
|
||||
videoHeight={videoHeight}
|
||||
videoUrl={videoUrl}
|
||||
isLoading={isVideoLoading}
|
||||
isError={isVideoError}
|
||||
onRetry={() => void refetchVideo()}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onVideoFrame={handleVideoFrame}
|
||||
onSeeked={handleSeeked}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Activity,
|
||||
CalendarDays,
|
||||
Clock3,
|
||||
Gauge,
|
||||
MapPin,
|
||||
Monitor,
|
||||
UserRound,
|
||||
Play
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TicketOverviewDetail } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
import { TicketInlineAnalysisVideo } from './TicketInlineAnalysisVideo';
|
||||
|
||||
type IconComponent = React.ComponentType<{ className?: string }>;
|
||||
|
||||
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 OverviewField({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 space-y-1">
|
||||
<MetaLabel>{label}</MetaLabel>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
colorClassName,
|
||||
cardClassName,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
colorClassName: string;
|
||||
cardClassName: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg bg-muted/40 p-4 hover:scale-105 transition-transform ease-in-out',
|
||||
cardClassName,
|
||||
)}
|
||||
>
|
||||
<p className={cn('text-2xl font-bold tracking-tight', colorClassName)}>
|
||||
{value}
|
||||
</p>
|
||||
<p className="mt-2 truncate text-xs font-semibold text-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">Detected</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoMetric({
|
||||
icon: Icon,
|
||||
label,
|
||||
}: {
|
||||
icon: IconComponent;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 font-medium">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketOverviewCard({
|
||||
ticket,
|
||||
}: {
|
||||
ticket: TicketOverviewDetail;
|
||||
}) {
|
||||
const [isAnalysisOpen, setIsAnalysisOpen] = useState(false);
|
||||
const videoMetadata = ticket.video_metadata;
|
||||
const duration =
|
||||
typeof videoMetadata?.duration_seconds === 'number'
|
||||
? `${Math.floor(videoMetadata.duration_seconds / 60)}:${Math.floor(
|
||||
videoMetadata.duration_seconds % 60,
|
||||
)
|
||||
.toString()
|
||||
.padStart(2, '0')}`
|
||||
: null;
|
||||
const chainageName =
|
||||
ticket.location?.chainage?.name || ticket.chainage_name || null;
|
||||
const packageName = ticket.location?.package?.name || null;
|
||||
const locationLabel =
|
||||
[chainageName, packageName].filter(Boolean).join(', ') || null;
|
||||
const uploadedOn =
|
||||
ticket.timestamps?.created_at || ticket.ai_result.completed_at || null;
|
||||
const uploaderName = ticket.uploader?.name || null;
|
||||
const analysisVideoId = ticket.video_id || ticket.video?.id || null;
|
||||
const videoName = ticket.video?.name || null;
|
||||
const analysisTitle = videoName || ticket.ticket_name || 'Analysis Video';
|
||||
const hasVideoMetrics = Boolean(
|
||||
videoMetadata?.fps || duration || videoMetadata?.resolution?.label,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{uploadedOn || uploaderName || locationLabel ? (
|
||||
<Card>
|
||||
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x">
|
||||
{uploadedOn ? (
|
||||
<div className="flex items-center gap-3 sm:pr-4">
|
||||
<CalendarDays className="size-5 shrink-0 text-muted-foreground" />
|
||||
<OverviewField label="Uploaded On">
|
||||
<MetaValue>{formatDate(uploadedOn)}</MetaValue>
|
||||
</OverviewField>
|
||||
</div>
|
||||
) : null}
|
||||
{uploaderName ? (
|
||||
<div className="flex items-center gap-3 sm:px-4">
|
||||
<UserRound className="size-5 shrink-0 text-muted-foreground" />
|
||||
<OverviewField label="Uploaded By">
|
||||
<MetaValue>{uploaderName}</MetaValue>
|
||||
</OverviewField>
|
||||
</div>
|
||||
) : null}
|
||||
{locationLabel ? (
|
||||
<div className="flex items-center gap-3 sm:pl-4">
|
||||
<MapPin className="size-5 shrink-0 text-muted-foreground" />
|
||||
<OverviewField label="Location">
|
||||
<MetaValue>{locationLabel}</MetaValue>
|
||||
</OverviewField>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="size-5 text-primary" />
|
||||
AI Detection Summary
|
||||
</CardTitle>
|
||||
{analysisVideoId ? (
|
||||
<CardAction>
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setIsAnalysisOpen(true)}
|
||||
>
|
||||
<Play className="mr-2 size-4" />
|
||||
View Video
|
||||
</Button>
|
||||
</PermissionGuard>
|
||||
</CardAction>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<SummaryCard
|
||||
label="Total Detections"
|
||||
value={ticket.ai_result.detection_count}
|
||||
colorClassName="text-violet-600"
|
||||
cardClassName="bg-violet-500/8"
|
||||
/>
|
||||
{ticket.ai_result.detections_by_class.map((item) => {
|
||||
const visual = getDefectVisual(item.class_name);
|
||||
return (
|
||||
<SummaryCard
|
||||
key={item.class_name}
|
||||
label={item.display_name}
|
||||
value={item.count}
|
||||
colorClassName={visual.colorClassName}
|
||||
cardClassName={visual.cardClassName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasVideoMetrics ? (
|
||||
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3">
|
||||
{videoMetadata?.fps ? (
|
||||
<VideoMetric icon={Gauge} label={`${videoMetadata.fps} FPS`} />
|
||||
) : null}
|
||||
{duration ? (
|
||||
<VideoMetric icon={Clock3} label={`${duration} Duration`} />
|
||||
) : null}
|
||||
{videoMetadata?.resolution?.label ? (
|
||||
<VideoMetric
|
||||
icon={Monitor}
|
||||
label={`${videoMetadata.resolution.label} Resolution`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={isAnalysisOpen} onOpenChange={setIsAnalysisOpen}>
|
||||
<DialogContent className="sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{analysisTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Video analysis with annotation overlay
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<TicketInlineAnalysisVideo ticket={ticket} />
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
} from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
function OverviewMetaSkeletonField({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${className ?? ''}`}>
|
||||
<Skeleton className="size-5 shrink-0 rounded-lg" />
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-4 w-32 max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummarySkeletonCard() {
|
||||
return (
|
||||
<div className="rounded-lg bg-muted/40 p-4">
|
||||
<Skeleton className="h-7 w-10" />
|
||||
<Skeleton className="mt-3 h-3 w-24 max-w-full" />
|
||||
<Skeleton className="mt-2 h-3 w-16" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketOverviewHeaderSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-3 w-36 max-w-full" />
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Skeleton className="h-8 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketOverviewCardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-5" aria-label="Loading ticket overview">
|
||||
<Card>
|
||||
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x">
|
||||
<OverviewMetaSkeletonField className="sm:pr-4" />
|
||||
<OverviewMetaSkeletonField className="sm:px-4" />
|
||||
<OverviewMetaSkeletonField className="sm:pl-4" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-lg" />
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</div>
|
||||
<CardAction>
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<SummarySkeletonCard key={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 sm:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<Skeleton className="size-4 rounded-lg" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { AssignedContractorAction } from './actions/AssignedContractorAction';
|
||||
import { AssignTicketAction } from './actions/AssignTicketAction';
|
||||
import { ClosedTicketAction } from './actions/ClosedTicketAction';
|
||||
import { NoTicketAction } from './actions/NoTicketAction';
|
||||
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
||||
import { ReviewRepairAction } from './actions/ReviewRepairAction';
|
||||
import { SubmittedRepairProofAction } from './actions/SubmittedRepairProofAction';
|
||||
import { SubmitRepairAction } from './actions/SubmitRepairAction';
|
||||
|
||||
interface TicketStatusActionsProps {
|
||||
ticket: TicketDetail;
|
||||
}
|
||||
|
||||
function getTicketAction(ticket: TicketDetail) {
|
||||
if (ticket.assignment_status === 'unassigned') {
|
||||
return (
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||
fallback={<NoTicketAction />}
|
||||
>
|
||||
<AssignTicketAction ticket={ticket} />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
if (ticket.assignment_status === 'assigned') {
|
||||
return (
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||
fallback={
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.WORK}
|
||||
fallback={<NoTicketAction />}
|
||||
>
|
||||
<SubmitRepairAction ticket={ticket} />
|
||||
</PermissionGuard>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<AssignedContractorAction ticket={ticket} />
|
||||
<ReviewExtensionRequestAction ticket={ticket} />
|
||||
</>
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
if (ticket.assignment_status === 'under_review') {
|
||||
return (
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.REVIEW}
|
||||
fallback={<NoTicketAction />}
|
||||
>
|
||||
<ReviewRepairAction ticket={ticket} />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
ticket.assignment_status === 'approved' ||
|
||||
ticket.assignment_status === 'rejected'
|
||||
) {
|
||||
return <ClosedTicketAction ticket={ticket} />;
|
||||
}
|
||||
|
||||
return <NoTicketAction />;
|
||||
}
|
||||
|
||||
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
|
||||
const action = getTicketAction(ticket);
|
||||
const submittedProof = ticket.repair?.submitted_at ? (
|
||||
<SubmittedRepairProofAction ticket={ticket} />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{submittedProof}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Users, Mail, Phone, Send, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
|
||||
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 { AssignableTicketUser, TicketDetail } from '@/types';
|
||||
|
||||
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
const [selectedContractor, setSelectedContractor] =
|
||||
useState<AssignableTicketUser | null>(null);
|
||||
const [dueDate, setDueDate] = useState<Date>();
|
||||
const [assignNote, setAssignNote] = useState('');
|
||||
|
||||
const assignMutation = useAssignTicketMutation(ticket.id);
|
||||
const today = useMemo(() => {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}, []);
|
||||
const maxDueMonth = useMemo(
|
||||
() => new Date(today.getFullYear() + 20, 11),
|
||||
[today],
|
||||
);
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={Users} title="Assign to Contractor">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Contractor *</Label>
|
||||
<AssignableWorkerSelect
|
||||
value={selectedUserId}
|
||||
onValueChange={(userId, user) => {
|
||||
setSelectedUserId(userId);
|
||||
setSelectedContractor(user);
|
||||
}}
|
||||
disabled={assignMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
{selectedContractor ? (
|
||||
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
|
||||
<p className="text-sm font-medium">Contractor Details</p>
|
||||
{selectedContractor.phone_number ? (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Phone className="size-4 shrink-0" />
|
||||
{selectedContractor.phone_number}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Mail className="size-4 shrink-0" />
|
||||
<span className="truncate">{selectedContractor.email}</span>
|
||||
</p>
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ShieldCheck className="size-4 shrink-0" />
|
||||
{selectedContractor.role.name}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="assign-due-date">
|
||||
Due Date <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<DatePickerSimple
|
||||
id="assign-due-date"
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
showLabel={false}
|
||||
placeholder="Select due date"
|
||||
disabled={assignMutation.isPending}
|
||||
className="w-full"
|
||||
startMonth={today}
|
||||
endMonth={maxDueMonth}
|
||||
disabledDates={{ before: today }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Assignment Note (Optional)</Label>
|
||||
<Textarea
|
||||
value={assignNote}
|
||||
onChange={(event) => setAssignNote(event.target.value)}
|
||||
placeholder="Add any instructions or notes for the contractor..."
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!selectedUserId || !dueDate || assignMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!selectedUserId || !dueDate) return;
|
||||
assignMutation.mutate({
|
||||
assigned_to_user_id: Number(selectedUserId),
|
||||
defect_class: ticket.defect_class,
|
||||
due_at: dueDate.toISOString(),
|
||||
note: assignNote || undefined,
|
||||
});
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Send />
|
||||
Assign Ticket
|
||||
</Button>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { CalendarClock, UserCheck } from 'lucide-react';
|
||||
|
||||
import { PersonInfo } from '@/components/person-avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { TicketDetail } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
function AssignmentField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="truncate text-sm font-medium text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssignedContractorAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const worker = ticket.worker;
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={UserCheck} title="Assigned Contractor">
|
||||
{worker ? (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<PersonInfo person={worker} size="lg" />
|
||||
<Badge>
|
||||
Assigned
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-5 border-t pt-4">
|
||||
<AssignmentField label="Role" value={worker.role?.name ?? '-'} />
|
||||
<AssignmentField
|
||||
label="Assigned By"
|
||||
value={worker.assigned_by_name ?? '-'}
|
||||
/>
|
||||
<AssignmentField
|
||||
label="Assigned On"
|
||||
value={formatDate(worker.assigned_at)}
|
||||
/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Due Date</p>
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<CalendarClock className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{formatDate(worker.due_at)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Assignment details are not available.
|
||||
</p>
|
||||
)}
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Lock, MessageSquare } from 'lucide-react';
|
||||
|
||||
import { PersonInfo } from '@/components/person-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';
|
||||
import { formatDateOnly } from '@/utils/date';
|
||||
|
||||
function getClosedAt(ticket: TicketDetail) {
|
||||
const approvedEntry = [...(ticket.history ?? [])]
|
||||
.reverse()
|
||||
.find(
|
||||
(item) =>
|
||||
item.event_type === 'repair_approved' ||
|
||||
item.to_class_status === 'approved',
|
||||
);
|
||||
return (
|
||||
approvedEntry?.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.ticket_name}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
<div className="space-y-4 rounded-lg border bg-muted/20 p-4">
|
||||
<MetaField
|
||||
label="Reviewed By"
|
||||
value={<PersonInfo person={reviewer} size="lg" />}
|
||||
/>
|
||||
|
||||
<MetaField
|
||||
label="Reviewed Date"
|
||||
value={formatDateOnly(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,15 @@
|
||||
'use client';
|
||||
|
||||
import { CircleOff } from 'lucide-react';
|
||||
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
export function NoTicketAction() {
|
||||
return (
|
||||
<TicketActionCard icon={CircleOff} title="No Actions">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No action available for this ticket.
|
||||
</p>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
Camera,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Play,
|
||||
Plus,
|
||||
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 = 30;
|
||||
const MAX_NOTES = 500;
|
||||
|
||||
function RequiredMark() {
|
||||
return <span className="text-destructive">*</span>;
|
||||
}
|
||||
|
||||
function formatVideoDuration(seconds: number) {
|
||||
const totalSeconds = Math.floor(seconds);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const remainingSeconds = totalSeconds % 60;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface MediaSectionHeaderProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
addLabel: string;
|
||||
onAdd: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function MediaSectionHeader({
|
||||
icon,
|
||||
title,
|
||||
addLabel,
|
||||
onAdd,
|
||||
disabled = false,
|
||||
}: MediaSectionHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary">{icon}</span>
|
||||
<span className="text-sm font-semibold">{title}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={onAdd}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaHint({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="rounded-lg bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmptyMediaPlaceholderProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function EmptyMediaPlaceholder({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
disabled = false,
|
||||
onClick,
|
||||
}: EmptyMediaPlaceholderProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex w-full min-w-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border/80 bg-muted/15 px-4 py-6 text-center transition-colors',
|
||||
'hover:border-primary/40 hover:bg-primary/5',
|
||||
disabled && 'cursor-not-allowed opacity-60',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
<span className="max-w-xs text-xs text-muted-foreground">
|
||||
{description}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ImageThumbnailProps {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
disabled?: boolean;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function ImageThumbnail({
|
||||
file,
|
||||
previewUrl,
|
||||
disabled = false,
|
||||
onRemove,
|
||||
}: ImageThumbnailProps) {
|
||||
return (
|
||||
<div className="group relative size-20 shrink-0 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-foreground/80 text-background shadow-sm transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VideoThumbnailProps {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
duration: string | null;
|
||||
disabled?: boolean;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function VideoThumbnail({
|
||||
file,
|
||||
previewUrl,
|
||||
duration,
|
||||
disabled = false,
|
||||
onRemove,
|
||||
}: VideoThumbnailProps) {
|
||||
return (
|
||||
<div className="group relative h-20 w-36 shrink-0 overflow-hidden rounded-lg border bg-muted/30">
|
||||
<video
|
||||
src={previewUrl}
|
||||
className="size-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
aria-label={file.name}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20">
|
||||
<span className="flex size-8 items-center justify-center rounded-full bg-background/90 text-foreground shadow-sm">
|
||||
<Play className="size-3.5 fill-current" />
|
||||
</span>
|
||||
</div>
|
||||
{duration ? (
|
||||
<span className="pointer-events-none absolute bottom-1.5 right-1.5 rounded-lg bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||
{duration}
|
||||
</span>
|
||||
) : null}
|
||||
<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-foreground/80 text-background shadow-sm transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RepairEvidenceFormProps {
|
||||
ticketId: string;
|
||||
defectClass: string;
|
||||
}
|
||||
|
||||
export function RepairEvidenceForm({
|
||||
ticketId,
|
||||
defectClass,
|
||||
}: RepairEvidenceFormProps) {
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const videoInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoPreview, setVideoPreview] = useState<string | null>(null);
|
||||
const [videoDuration, setVideoDuration] = useState<string | 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);
|
||||
const canAddMoreImages = imageFiles.length < MAX_IMAGES;
|
||||
|
||||
useEffect(() => {
|
||||
const urls = imageFiles.map((file) => URL.createObjectURL(file));
|
||||
setImagePreviews(urls);
|
||||
return () => {
|
||||
urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [imageFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoFile) {
|
||||
setVideoPreview(null);
|
||||
setVideoDuration(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(videoFile);
|
||||
setVideoPreview(url);
|
||||
setVideoDuration(null);
|
||||
|
||||
let cancelled = false;
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.onloadedmetadata = () => {
|
||||
if (cancelled || !Number.isFinite(video.duration)) return;
|
||||
setVideoDuration(formatVideoDuration(video.duration));
|
||||
};
|
||||
video.src = url;
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [videoFile]);
|
||||
|
||||
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 selectVideo = (files: FileList | null) => {
|
||||
setVideoFile(files?.[0] ?? null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const removeVideo = () => {
|
||||
setVideoFile(null);
|
||||
setError(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 a 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 openImagePicker = () => {
|
||||
if (!isSubmitting && canAddMoreImages) {
|
||||
imageInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const openVideoPicker = () => {
|
||||
if (!isSubmitting) {
|
||||
videoInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex gap-3 rounded-lg border border-primary/15 bg-primary/5 p-4">
|
||||
<Camera className="mt-0.5 size-5 shrink-0 text-primary" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">Instructions</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload clear photos and one video of the repaired area and provide
|
||||
relevant details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
disabled={isSubmitting || !canAddMoreImages}
|
||||
onChange={(event) => {
|
||||
addImages(event.target.files);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={videoInputRef}
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/x-msvideo,video/*"
|
||||
className="hidden"
|
||||
disabled={isSubmitting}
|
||||
onChange={(event) => {
|
||||
selectVideo(event.target.files);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="space-y-3">
|
||||
<MediaSectionHeader
|
||||
icon={<Camera className="size-4" />}
|
||||
title="Photos"
|
||||
addLabel="Add Photos"
|
||||
onAdd={openImagePicker}
|
||||
disabled={isSubmitting || !canAddMoreImages}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{imageFiles.length === 0 ? (
|
||||
<EmptyMediaPlaceholder
|
||||
icon={<ImageIcon className="size-5" />}
|
||||
title="No photos added yet"
|
||||
description="Tap to upload repair photos"
|
||||
disabled={isSubmitting}
|
||||
onClick={openImagePicker}
|
||||
className="min-h-26"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex min-w-min gap-3">
|
||||
{imageFiles.map((file, index) => (
|
||||
<ImageThumbnail
|
||||
key={`${file.name}-${file.lastModified}-${index}`}
|
||||
file={file}
|
||||
previewUrl={imagePreviews[index]}
|
||||
disabled={isSubmitting}
|
||||
onRemove={() => removeImage(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MediaHint>
|
||||
You can upload up to {MAX_IMAGES} images (JPG, PNG)
|
||||
</MediaHint>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Video className="size-4 text-primary" />
|
||||
<span className="text-sm font-semibold">Video</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{!videoFile || !videoPreview ? (
|
||||
<EmptyMediaPlaceholder
|
||||
icon={<Video className="size-5" />}
|
||||
title="No video added yet"
|
||||
description="Tap to upload a repair video"
|
||||
disabled={isSubmitting}
|
||||
onClick={openVideoPicker}
|
||||
className="min-h-[104px]"
|
||||
/>
|
||||
) : (
|
||||
<VideoThumbnail
|
||||
file={videoFile}
|
||||
previewUrl={videoPreview}
|
||||
duration={videoDuration}
|
||||
disabled={isSubmitting}
|
||||
onRemove={removeVideo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MediaHint>You can upload one video (MP4, MOV, AVI)</MediaHint>
|
||||
</section>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repair-notes">
|
||||
Repair Notes <RequiredMark />
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
id="repair-notes"
|
||||
value={notes}
|
||||
onChange={(event) => {
|
||||
setNotes(event.target.value.slice(0, MAX_NOTES));
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="Describe the repair work completed, materials used, and any additional observations."
|
||||
disabled={isSubmitting}
|
||||
className="min-h-28 resize-none pb-8"
|
||||
/>
|
||||
<span className="pointer-events-none absolute bottom-2 right-3 text-xs text-muted-foreground">
|
||||
{notes.length} / {MAX_NOTES}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg 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,157 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Clock3, Loader2, Send } from 'lucide-react';
|
||||
|
||||
import { DatePicker } from '@/components/form/DatePicker';
|
||||
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { useRequestTicketExtensionMutation } from '../../../hooks/useTicketQueries';
|
||||
|
||||
const MAX_REASON_LENGTH = 300;
|
||||
|
||||
interface RequestExtensionFormProps {
|
||||
ticket: TicketDetail;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function RequestExtensionForm({
|
||||
ticket,
|
||||
onCancel,
|
||||
}: RequestExtensionFormProps) {
|
||||
const currentDueAt = ticket.worker?.due_at;
|
||||
const currentDueDate = useMemo(
|
||||
() => (currentDueAt ? new Date(currentDueAt) : undefined),
|
||||
[currentDueAt],
|
||||
);
|
||||
const earliestRequestedDate = useMemo(() => {
|
||||
const date = currentDueDate ? new Date(currentDueDate) : new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
if (currentDueDate) date.setDate(date.getDate() + 1);
|
||||
return date;
|
||||
}, [currentDueDate]);
|
||||
const latestRequestedMonth = useMemo(
|
||||
() => new Date(earliestRequestedDate.getFullYear() + 20, 11),
|
||||
[earliestRequestedDate],
|
||||
);
|
||||
const [requestedDueDate, setRequestedDueDate] = useState<Date>();
|
||||
const [reason, setReason] = useState('');
|
||||
const assignmentId = ticket.worker?.assignment_id;
|
||||
const requestExtensionMutation = useRequestTicketExtensionMutation(
|
||||
ticket.id,
|
||||
assignmentId,
|
||||
ticket.defect_class,
|
||||
);
|
||||
const isPending = requestExtensionMutation.isPending;
|
||||
const isRequestedDateValid = Boolean(
|
||||
requestedDueDate &&
|
||||
(!currentDueDate || requestedDueDate.getTime() > currentDueDate.getTime()),
|
||||
);
|
||||
const canSubmit =
|
||||
Boolean(assignmentId) &&
|
||||
isRequestedDateValid &&
|
||||
Boolean(reason.trim()) &&
|
||||
!isPending;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!requestedDueDate || !canSubmit) return;
|
||||
|
||||
await requestExtensionMutation.mutateAsync({
|
||||
requested_due_at: requestedDueDate.toISOString(),
|
||||
reason: reason.trim(),
|
||||
});
|
||||
setRequestedDueDate(undefined);
|
||||
setReason('');
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex gap-3 rounded-lg border border-primary/15 bg-primary/5 p-4">
|
||||
<Clock3 className="mt-0.5 size-5 shrink-0 text-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Request more time to complete the repair. Your manager will review the
|
||||
request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Current Due Date</Label>
|
||||
<DatePicker
|
||||
value={currentDueDate}
|
||||
showTimeZone={false}
|
||||
placeholder="No due date available"
|
||||
disabled
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Requested Due Date <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<DatePickerSimple
|
||||
id="requested-due-date"
|
||||
value={requestedDueDate}
|
||||
onChange={setRequestedDueDate}
|
||||
showLabel={false}
|
||||
placeholder="Select requested due date"
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
startMonth={earliestRequestedDate}
|
||||
endMonth={latestRequestedMonth}
|
||||
disabledDates={{ before: earliestRequestedDate }}
|
||||
/>
|
||||
{requestedDueDate && !isRequestedDateValid ? (
|
||||
<p className="text-xs text-destructive">
|
||||
Requested date must be after the current due date.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="extension-reason">
|
||||
Reason for Extension <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
id="extension-reason"
|
||||
value={reason}
|
||||
onChange={(event) =>
|
||||
setReason(event.target.value.slice(0, MAX_REASON_LENGTH))
|
||||
}
|
||||
placeholder="Explain why additional time is required to complete the repair."
|
||||
disabled={isPending}
|
||||
className="min-h-28 resize-none pb-8"
|
||||
/>
|
||||
<span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground">
|
||||
{reason.length} / {MAX_REASON_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This request will be sent to your manager for approval.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Send />}
|
||||
{isPending ? 'Submitting...' : 'Submit Request'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
Check,
|
||||
Clock3,
|
||||
Loader2,
|
||||
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,
|
||||
TicketExtensionRequest,
|
||||
TicketExtensionRequestsResponse,
|
||||
} from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import {
|
||||
useReviewTicketExtensionMutation,
|
||||
useTicketExtensionRequestQuery,
|
||||
} from '../../../hooks/useTicketQueries';
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
const MAX_NOTE_LENGTH = 300;
|
||||
|
||||
function getPendingRequest(response: TicketExtensionRequestsResponse) {
|
||||
return response.items
|
||||
.filter((request) => request.status === 'pending')
|
||||
.reduce<TicketExtensionRequest | null>((latest, request) => {
|
||||
if (!latest) return request;
|
||||
return new Date(request.requested_at) > new Date(latest.requested_at)
|
||||
? request
|
||||
: latest;
|
||||
}, null);
|
||||
}
|
||||
|
||||
function getExtensionDays(request: TicketExtensionRequest) {
|
||||
const difference =
|
||||
new Date(request.requested_due_at).getTime() -
|
||||
new Date(request.current_due_at).getTime();
|
||||
return Math.max(0, Math.ceil(difference / (24 * 60 * 60 * 1000)));
|
||||
}
|
||||
|
||||
function Requester({ request }: { request: TicketExtensionRequest }) {
|
||||
const name = request.requested_by?.name ?? 'Assigned worker';
|
||||
const initials = name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join('');
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 rounded-lg bg-muted/40 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
|
||||
{initials || 'W'}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] text-muted-foreground">Requested By</p>
|
||||
<p className="truncate text-sm font-medium">{name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] text-muted-foreground">Requested On</p>
|
||||
<p className="text-sm font-medium">
|
||||
{formatDate(request.requested_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExtensionReviewForm({
|
||||
ticket,
|
||||
request,
|
||||
}: {
|
||||
ticket: TicketDetail;
|
||||
request: TicketExtensionRequest;
|
||||
}) {
|
||||
const [note, setNote] = useState('');
|
||||
const [selectedAction, setSelectedAction] = useState<
|
||||
'approve' | 'reject' | null
|
||||
>(null);
|
||||
const assignmentId = ticket.worker?.assignment_id as number;
|
||||
const reviewMutation = useReviewTicketExtensionMutation(
|
||||
ticket.id,
|
||||
assignmentId,
|
||||
ticket.defect_class,
|
||||
);
|
||||
const extensionDays = getExtensionDays(request);
|
||||
|
||||
const submitDecision = async (action: 'approve' | 'reject') => {
|
||||
setSelectedAction(action);
|
||||
try {
|
||||
await reviewMutation.mutateAsync({ action, note: note.trim() });
|
||||
} finally {
|
||||
setSelectedAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={CalendarClock} title="Extension Request Details">
|
||||
<div className="space-y-4">
|
||||
<Requester request={request} />
|
||||
|
||||
<section className="space-y-2">
|
||||
<p className="text-xs font-semibold">Request Summary</p>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-3 rounded-lg border p-3">
|
||||
<div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Current Due Date
|
||||
</p>
|
||||
<p className="text-xs font-medium">
|
||||
{formatDate(request.current_due_at)}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="mt-6 size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Requested Due Date
|
||||
</p>
|
||||
<p className="text-xs font-medium">
|
||||
{formatDate(request.requested_due_at)}
|
||||
</p>
|
||||
{extensionDays > 0 ? (
|
||||
<span className="mt-1 inline-flex rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300">
|
||||
+{extensionDays} {extensionDays === 1 ? 'Day' : 'Days'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-[11px] text-muted-foreground">Reason</p>
|
||||
<p className="mt-1 text-sm leading-relaxed">{request.reason}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="extension-manager-note">
|
||||
Add Manager Comments{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
id="extension-manager-note"
|
||||
value={note}
|
||||
onChange={(event) =>
|
||||
setNote(event.target.value.slice(0, MAX_NOTE_LENGTH))
|
||||
}
|
||||
placeholder="Add a note explaining your decision..."
|
||||
disabled={reviewMutation.isPending}
|
||||
className="min-h-24 resize-none pb-8"
|
||||
/>
|
||||
<span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground">
|
||||
{note.length} / {MAX_NOTE_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="border-destructive/40 text-destructive hover:bg-destructive/5 hover:text-destructive"
|
||||
disabled={reviewMutation.isPending}
|
||||
onClick={() => submitDecision('reject')}
|
||||
>
|
||||
{selectedAction === 'reject' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<X />
|
||||
)}
|
||||
Reject Request
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={reviewMutation.isPending}
|
||||
onClick={() => submitDecision('approve')}
|
||||
>
|
||||
{selectedAction === 'approve' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Check />
|
||||
)}
|
||||
Approve Extension
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReviewExtensionRequestAction({
|
||||
ticket,
|
||||
}: {
|
||||
ticket: TicketDetail;
|
||||
}) {
|
||||
const assignmentId = ticket.worker?.assignment_id;
|
||||
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
||||
ticket.id,
|
||||
assignmentId,
|
||||
);
|
||||
|
||||
if (!assignmentId || extensionRequestQuery.isError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (extensionRequestQuery.isLoading) {
|
||||
return (
|
||||
<TicketActionCard icon={Clock3} title="Extension Request">
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
<span className="text-sm">Checking for requests...</span>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
|
||||
const pendingRequest = extensionRequestQuery.data
|
||||
? getPendingRequest(extensionRequestQuery.data)
|
||||
: null;
|
||||
|
||||
if (!pendingRequest) return null;
|
||||
|
||||
return <ExtensionReviewForm ticket={ticket} request={pendingRequest} />;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'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
|
||||
variant={'secondary'}
|
||||
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,52 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Wrench } from 'lucide-react';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { RepairEvidenceForm } from './RepairEvidenceForm';
|
||||
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
type RepairActionTab = 'repair-proof' | 'request-extension';
|
||||
|
||||
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const [activeTab, setActiveTab] = useState<RepairActionTab>('repair-proof');
|
||||
|
||||
if (ticket.repair?.submitted_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={Wrench} title="Repair Proof">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as RepairActionTab)}
|
||||
className="gap-5"
|
||||
>
|
||||
<TabsList variant="line" className="grid h-auto w-full grid-cols-2 p-0">
|
||||
<TabsTrigger value="repair-proof" className="pb-3">
|
||||
Upload Repair Proof
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="request-extension" className="pb-3">
|
||||
Request Extension
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="repair-proof">
|
||||
<RepairEvidenceForm
|
||||
ticketId={ticket.id}
|
||||
defectClass={ticket.defect_class}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="request-extension">
|
||||
<TicketExtensionRequestPanel
|
||||
ticket={ticket}
|
||||
onCancelRequest={() => setActiveTab('repair-proof')}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { RepairProofSummary as SubmittedRepairProofAction } from '../repair/RepairProofSummary';
|
||||
@@ -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('text-primary', iconClassName)} />
|
||||
{title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
History,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
TicketDetail,
|
||||
TicketExtensionRequest,
|
||||
TicketExtensionRequestsResponse,
|
||||
} from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
|
||||
import { RequestExtensionForm } from './RequestExtensionForm';
|
||||
|
||||
interface TicketExtensionRequestPanelProps {
|
||||
ticket: TicketDetail;
|
||||
onCancelRequest: () => void;
|
||||
}
|
||||
|
||||
interface DetailRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, children, className }: DetailRowProps) {
|
||||
return (
|
||||
<div className={cn('space-y-1 px-3 py-2.5', className)}>
|
||||
<p className="text-[11px] font-medium text-muted-foreground">{label}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-medium">{value}</p>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
pending: {
|
||||
title: 'Request Pending',
|
||||
description: 'Your extension request is waiting for manager approval.',
|
||||
icon: Clock3,
|
||||
className:
|
||||
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300',
|
||||
},
|
||||
approved: {
|
||||
title: 'Request Approved',
|
||||
description: 'Your extension request has been approved by the manager.',
|
||||
icon: CheckCircle2,
|
||||
className:
|
||||
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300',
|
||||
},
|
||||
rejected: {
|
||||
title: 'Request Rejected',
|
||||
description: 'Your extension request has been rejected by the manager.',
|
||||
icon: AlertCircle,
|
||||
className:
|
||||
'border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300',
|
||||
},
|
||||
cancelled: {
|
||||
title: 'Request Cancelled',
|
||||
description: 'This extension request was cancelled.',
|
||||
icon: XCircle,
|
||||
className: 'border-border bg-muted/50 text-muted-foreground',
|
||||
},
|
||||
} satisfies Record<
|
||||
TicketExtensionRequest['status'],
|
||||
{
|
||||
title: string;
|
||||
description: string;
|
||||
icon: typeof Clock3;
|
||||
className: string;
|
||||
}
|
||||
>;
|
||||
|
||||
function getLatestRequest(response: TicketExtensionRequestsResponse) {
|
||||
return response.items.reduce<TicketExtensionRequest | null>(
|
||||
(latest, item) => {
|
||||
if (!latest) return item;
|
||||
return new Date(item.requested_at) > new Date(latest.requested_at)
|
||||
? item
|
||||
: latest;
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
function getExtensionDays(request: TicketExtensionRequest) {
|
||||
const difference =
|
||||
new Date(request.requested_due_at).getTime() -
|
||||
new Date(request.current_due_at).getTime();
|
||||
return Math.max(0, Math.ceil(difference / (24 * 60 * 60 * 1000)));
|
||||
}
|
||||
|
||||
function ExtensionRequestDetails({
|
||||
request,
|
||||
}: {
|
||||
request: TicketExtensionRequest;
|
||||
}) {
|
||||
const config = statusConfig[request.status];
|
||||
const StatusIcon = config.icon;
|
||||
const extensionDays = getExtensionDays(request);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className={cn('flex gap-3 rounded-lg border p-4', config.className)}>
|
||||
<StatusIcon className="mt-0.5 size-5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">{config.title}</p>
|
||||
<p className="text-xs leading-relaxed">{config.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="space-y-2">
|
||||
<p className="text-xs font-semibold">Request Details</p>
|
||||
<div className="divide-y overflow-hidden rounded-lg border">
|
||||
<DetailRow
|
||||
label="Requested On"
|
||||
value={formatDate(request.requested_at)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Requested Due Date"
|
||||
value={formatDate(request.requested_due_at)}
|
||||
>
|
||||
{extensionDays > 0 ? (
|
||||
<span className="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary">
|
||||
+{extensionDays} {extensionDays === 1 ? 'Day' : 'Days'}
|
||||
</span>
|
||||
) : null}
|
||||
</DetailRow>
|
||||
<DetailRow label="Reason for Extension" value={request.reason} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{request.resolved_at || request.resolved_by ? (
|
||||
<section className="space-y-2">
|
||||
<p className="text-xs font-semibold">Manager Response</p>
|
||||
<div className="divide-y overflow-hidden rounded-lg border">
|
||||
{request.resolved_at ? (
|
||||
<DetailRow
|
||||
label="Responded On"
|
||||
value={formatDate(request.resolved_at)}
|
||||
/>
|
||||
) : null}
|
||||
{request.resolved_by ? (
|
||||
<DetailRow
|
||||
label="Resolved By"
|
||||
value={`${request.resolved_by.name} · ${request.resolved_by.role.name}`}
|
||||
/>
|
||||
) : null}
|
||||
{request.status === 'approved' ? (
|
||||
<DetailRow
|
||||
label="Approved Due Date"
|
||||
value={formatDate(request.requested_due_at)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-2">
|
||||
<p className="text-xs font-semibold">Original Due Date</p>
|
||||
<div className="rounded-lg border">
|
||||
<DetailRow
|
||||
label="Original schedule"
|
||||
value={formatDate(request.current_due_at)}
|
||||
>
|
||||
<CalendarDays className="size-4 text-muted-foreground" />
|
||||
</DetailRow>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Button type="button" variant="outline" className="w-full">
|
||||
<History />
|
||||
View Approval History
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketExtensionRequestPanel({
|
||||
ticket,
|
||||
onCancelRequest,
|
||||
}: TicketExtensionRequestPanelProps) {
|
||||
const assignmentId = ticket.worker?.assignment_id;
|
||||
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
||||
ticket.id,
|
||||
assignmentId,
|
||||
);
|
||||
|
||||
if (!assignmentId) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
No active assignment is available for this ticket.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (extensionRequestQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
<span className="ml-2 text-sm">Loading extension request...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (extensionRequestQuery.isError) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||
<p className="text-sm text-destructive">
|
||||
Extension request details could not be loaded.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => extensionRequestQuery.refetch()}
|
||||
>
|
||||
<RotateCcw />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const latestRequest = extensionRequestQuery.data
|
||||
? getLatestRequest(extensionRequestQuery.data)
|
||||
: null;
|
||||
|
||||
if (!latestRequest) {
|
||||
return <RequestExtensionForm ticket={ticket} onCancel={onCancelRequest} />;
|
||||
}
|
||||
|
||||
return <ExtensionRequestDetails request={latestRequest} />;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import LightGallery from 'lightgallery/react';
|
||||
import lgThumbnail from 'lightgallery/plugins/thumbnail';
|
||||
import lgZoom from 'lightgallery/plugins/zoom';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { protectedMediaService } from '@/services/api';
|
||||
|
||||
interface RepairProofGalleryProps {
|
||||
imageUrls: string[];
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_IMAGES = 8;
|
||||
|
||||
export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
|
||||
const queries = useQueries({
|
||||
queries: imageUrls.map((url) => ({
|
||||
queryKey: ['protected-media', 'v2', url],
|
||||
queryFn: () => protectedMediaService.getBlob(url),
|
||||
staleTime: Infinity,
|
||||
gcTime: 1000 * 60 * 30,
|
||||
})),
|
||||
});
|
||||
|
||||
const blobs = queries.map((query) => query.data);
|
||||
const objectUrls = useMemo(
|
||||
() => blobs.map((blob) => (blob ? URL.createObjectURL(blob) : null)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[...blobs],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
objectUrls.forEach((url) => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
});
|
||||
},
|
||||
[objectUrls],
|
||||
);
|
||||
|
||||
if (queries.some((query) => query.isLoading)) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-1 overflow-hidden rounded-lg">
|
||||
{imageUrls.slice(0, MAX_VISIBLE_IMAGES).map((url, index) => (
|
||||
<Skeleton
|
||||
key={`${url}-${index}`}
|
||||
className="aspect-square rounded-lg"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const images = objectUrls.flatMap((url, index) =>
|
||||
url ? [{ url, originalIndex: index }] : [],
|
||||
);
|
||||
const visibleCount = Math.min(MAX_VISIBLE_IMAGES, images.length);
|
||||
const overflowCount = images.length - visibleCount;
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
|
||||
Repair images are unavailable.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LightGallery
|
||||
elementClassNames="grid grid-cols-4 gap-1 overflow-hidden rounded-lg"
|
||||
mode="lg-fade"
|
||||
speed={400}
|
||||
plugins={[lgThumbnail, lgZoom]}
|
||||
thumbnail
|
||||
download={false}
|
||||
mobileSettings={{ controls: true, showCloseIcon: true, download: false }}
|
||||
>
|
||||
{images.map(({ url, originalIndex }, index) => {
|
||||
const isVisible = index < visibleCount;
|
||||
const isOverflowTile = overflowCount > 0 && index === visibleCount - 1;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={imageUrls[originalIndex]}
|
||||
data-src={url}
|
||||
data-sub-html={`<p>Repair evidence ${originalIndex + 1}</p>`}
|
||||
className={isVisible ? 'group relative aspect-square' : 'hidden'}
|
||||
aria-label={
|
||||
isOverflowTile
|
||||
? `View all ${images.length} repair images`
|
||||
: `View repair image ${originalIndex + 1} of ${images.length}`
|
||||
}
|
||||
>
|
||||
{/* Blob URLs cannot be handled by next/image. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={url}
|
||||
alt={`Repair evidence ${originalIndex + 1}`}
|
||||
className="size-full object-cover transition duration-300 group-hover:scale-[1.03] group-hover:brightness-90"
|
||||
/>
|
||||
{isOverflowTile ? (
|
||||
<span className="absolute inset-0 flex items-center justify-center bg-black/55 text-xl font-semibold text-white transition-colors group-hover:bg-black/65">
|
||||
+{overflowCount}
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</LightGallery>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { CheckCircle2, HardHat, Wrench, ToolCase } from 'lucide-react';
|
||||
|
||||
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { formatDate } from '@/utils/date';
|
||||
import { RepairProofGallery } from './RepairProofGallery';
|
||||
|
||||
function SubmittedBanner({
|
||||
submittedAt,
|
||||
submitterName,
|
||||
}: {
|
||||
submittedAt: string;
|
||||
submitterName: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-3 dark:border-emerald-900/60 dark:bg-emerald-950/30">
|
||||
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
|
||||
Repair Submitted
|
||||
</p>
|
||||
<p className="text-xs text-emerald-700/80 dark:text-emerald-300/80">
|
||||
{formatDate(submittedAt)} by {submitterName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageGallery({
|
||||
imageUrls,
|
||||
hasVideo,
|
||||
}: {
|
||||
imageUrls: string[];
|
||||
hasVideo: boolean;
|
||||
}) {
|
||||
if (imageUrls.length === 0) {
|
||||
return hasVideo ? null : (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-dashed px-3 py-4 text-sm text-muted-foreground">
|
||||
<HardHat className="size-4 shrink-0" />
|
||||
No media attached
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <RepairProofGallery imageUrls={imageUrls} />;
|
||||
}
|
||||
|
||||
function Notes({ notes }: { notes: string | null | undefined }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-foreground">Repair Notes</p>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{notes?.trim() || 'No repair notes provided.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepairProofSummary({ ticket }: { ticket: TicketDetail }) {
|
||||
const repair = ticket.repair;
|
||||
|
||||
if (!repair?.submitted_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const submitterName = ticket.worker?.name?.trim() || 'Assigned worker';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ToolCase className="text-primary" />
|
||||
Repair Proof
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<SubmittedBanner
|
||||
submittedAt={repair.submitted_at}
|
||||
submitterName={submitterName}
|
||||
/>
|
||||
|
||||
<ProtectedVideoPlayer
|
||||
url={repair.proof_video_url}
|
||||
meta="Contractor evidence video"
|
||||
emptyTitle="No repair video uploaded"
|
||||
emptyDescription="The assigned worker has not uploaded a repair video."
|
||||
className="rounded-lg"
|
||||
/>
|
||||
|
||||
<ImageGallery
|
||||
imageUrls={repair.proof_image_urls}
|
||||
hasVideo={Boolean(repair.proof_video_url)}
|
||||
/>
|
||||
|
||||
<Notes notes={repair.notes} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
91
src/app/(modules)/ticket/[ticketId]/page.tsx
Normal file
91
src/app/(modules)/ticket/[ticketId]/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'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 {
|
||||
TicketOverviewCardSkeleton,
|
||||
TicketOverviewHeaderSkeleton,
|
||||
} from './components/TicketOverviewSkeleton';
|
||||
import {
|
||||
TicketClassActionsSkeleton,
|
||||
TicketClassContentSkeleton,
|
||||
} from './components/TicketClassDetailSkeleton';
|
||||
|
||||
import { TicketHistoryCard } from './components/TicketHistoryCard';
|
||||
import { TicketOverviewCard } from './components/TicketOverviewCard';
|
||||
import { TicketStatusActions } from './components/TicketStatusActions';
|
||||
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
|
||||
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
|
||||
import {
|
||||
useTicketClassDetailQuery,
|
||||
useTicketOverviewQuery,
|
||||
} 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 overviewQuery = useTicketOverviewQuery(ticketId);
|
||||
const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
|
||||
const overview = overviewQuery.data;
|
||||
const classDetail = classDetailQuery.data;
|
||||
const isClassDetailLoading = Boolean(
|
||||
defectClass && classDetailQuery.isLoading,
|
||||
);
|
||||
|
||||
useTicketDetailEvents(ticketId, defectClass, Boolean(ticketId));
|
||||
|
||||
if (overviewQuery.isError) {
|
||||
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">
|
||||
{overview ? (
|
||||
<TicketDetailHeader ticket={overview} />
|
||||
) : (
|
||||
<TicketOverviewHeaderSkeleton />
|
||||
)}
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]">
|
||||
<div className="space-y-5">
|
||||
{overview ? (
|
||||
<TicketOverviewCard ticket={overview} />
|
||||
) : (
|
||||
<TicketOverviewCardSkeleton />
|
||||
)}
|
||||
<TicketDefectClassTabs ticketId={ticketId} ticket={overview} />
|
||||
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
|
||||
{!isClassDetailLoading && classDetail ? (
|
||||
<TicketHistoryCard ticket={classDetail} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 self-start">
|
||||
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
|
||||
{!isClassDetailLoading && classDetail ? (
|
||||
<TicketStatusActions ticket={classDetail} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
58
src/app/(modules)/ticket/components/TicketColumns.tsx
Normal file
58
src/app/(modules)/ticket/components/TicketColumns.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import type { TicketListItem } from '@/types';
|
||||
|
||||
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: 'chainage_name',
|
||||
header: 'Segment',
|
||||
size: 180,
|
||||
cell: ({ row }) => row.original.chainage_name || 'N/A',
|
||||
},
|
||||
{
|
||||
accessorKey: 'detection_count',
|
||||
header: 'Detections',
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: 'created_by',
|
||||
header: 'Uploaded By',
|
||||
size: 220,
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{row.original.created_by_name || 'N/A'}
|
||||
</p>
|
||||
{row.original.created_by_email ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.original.created_by_email}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: 'Updated',
|
||||
size: 180,
|
||||
cell: ({ row }) => formatDate(row.original.updated_at),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
}
|
||||
19
src/app/(modules)/ticket/components/TicketFilters.tsx
Normal file
19
src/app/(modules)/ticket/components/TicketFilters.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { SegmentSelect } from '@/components/lookups/SegmentSelect';
|
||||
|
||||
interface TicketFiltersProps {
|
||||
segmentId: string;
|
||||
onSegmentChange: (segmentId: string) => void;
|
||||
}
|
||||
|
||||
export function TicketFilters({
|
||||
segmentId,
|
||||
onSegmentChange,
|
||||
}: TicketFiltersProps) {
|
||||
return (
|
||||
<div className="w-full sm:w-64">
|
||||
<SegmentSelect value={segmentId} onValueChange={onSegmentChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/app/(modules)/ticket/components/TicketStatusBadge.tsx
Normal file
27
src/app/(modules)/ticket/components/TicketStatusBadge.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TicketStatus } from '@/types';
|
||||
|
||||
const statusClasses: Record<TicketStatus, string> = {
|
||||
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="Tickets will appear when road issues are detected."
|
||||
pagination={{
|
||||
skip,
|
||||
limit,
|
||||
totalItems: total,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
}}
|
||||
onRowClick={onView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
178
src/app/(modules)/ticket/hooks/useTenantTicketTableEvents.ts
Normal file
178
src/app/(modules)/ticket/hooks/useTenantTicketTableEvents.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
'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?.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.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,
|
||||
default_defect_class: event.default_defect_class ?? null,
|
||||
detection_count: event.detection_count,
|
||||
created_by_name: event.created_by_name ?? null,
|
||||
created_by_email: event.created_by_email ?? 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,
|
||||
default_defect_class:
|
||||
event.default_defect_class ?? current.default_defect_class,
|
||||
detection_count: event.detection_count ?? current.detection_count,
|
||||
created_by_name: event.created_by_name ?? current.created_by_name,
|
||||
created_by_email: event.created_by_email ?? current.created_by_email,
|
||||
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,
|
||||
});
|
||||
}
|
||||
76
src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts
Normal file
76
src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, 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 { TicketDetailStatusEvent } from '@/types';
|
||||
|
||||
import { ticketKeys } from '../queries/ticketKeys';
|
||||
|
||||
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,
|
||||
): void {
|
||||
const queryClient = useQueryClient();
|
||||
const defectClassRef = useRef(defectClass);
|
||||
|
||||
useEffect(() => {
|
||||
defectClassRef.current = defectClass;
|
||||
}, [defectClass]);
|
||||
|
||||
const refetchTicket = useCallback(() => {
|
||||
if (!ticketId) return;
|
||||
|
||||
queryClient.refetchQueries({ queryKey: ticketKeys.overview(ticketId) });
|
||||
if (defectClassRef.current) {
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClassRef.current),
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.defectClasses(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
}, [queryClient, 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();
|
||||
},
|
||||
error: () => undefined,
|
||||
heartbeat: () => undefined,
|
||||
}),
|
||||
[refetchTicket],
|
||||
);
|
||||
|
||||
useSseWithToken({
|
||||
enabled: Boolean(enabled && ticketId),
|
||||
getToken,
|
||||
getPath,
|
||||
events,
|
||||
reconnectOnEvents: TICKET_DETAIL_RECONNECT_EVENTS,
|
||||
});
|
||||
}
|
||||
304
src/app/(modules)/ticket/hooks/useTicketQueries.ts
Normal file
304
src/app/(modules)/ticket/hooks/useTicketQueries.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { ticketService, videoService } from '@/services/api';
|
||||
import type {
|
||||
AssignTicketPayload,
|
||||
CloseTicketPayload,
|
||||
RequestTicketExtensionPayload,
|
||||
ReviewTicketExtensionPayload,
|
||||
ReviewRepairPayload,
|
||||
SubmitRepairPayload,
|
||||
SubmitRepairUploadPayload,
|
||||
TicketDetail,
|
||||
TicketListParams,
|
||||
VideoDetectionsParams,
|
||||
} from '@/types';
|
||||
|
||||
import { ticketKeys } from '../queries/ticketKeys';
|
||||
|
||||
const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000;
|
||||
|
||||
interface UseTicketsQueryParams {
|
||||
skip: number;
|
||||
limit: number;
|
||||
chainageId?: string;
|
||||
}
|
||||
|
||||
function buildTicketListParams({
|
||||
skip,
|
||||
limit,
|
||||
chainageId,
|
||||
}: UseTicketsQueryParams): TicketListParams {
|
||||
return {
|
||||
skip,
|
||||
limit,
|
||||
chainage_id: chainageId || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTicketsQuery(params: UseTicketsQueryParams) {
|
||||
const { skip, limit, chainageId } = params;
|
||||
const listParams = useMemo(
|
||||
() => buildTicketListParams({ skip, limit, chainageId }),
|
||||
[chainageId, limit, skip],
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.list(listParams),
|
||||
queryFn: () => ticketService.getTickets(listParams),
|
||||
staleTime: TICKET_TABLE_REFRESH_MS,
|
||||
refetchInterval: TICKET_TABLE_REFRESH_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketOverviewQuery(ticketId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.overview(ticketId ?? ''),
|
||||
queryFn: () => ticketService.getTicketOverview(ticketId as string),
|
||||
enabled: Boolean(ticketId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketClassDetailQuery(
|
||||
ticketId: string | undefined,
|
||||
defectClass: string | undefined,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.classDetail(ticketId ?? '', defectClass ?? ''),
|
||||
queryFn: () =>
|
||||
ticketService.getTicketClassDetail(
|
||||
ticketId as string,
|
||||
defectClass as string,
|
||||
),
|
||||
enabled: Boolean(ticketId && defectClass),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketDefectClassesQuery(ticketId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.defectClasses(ticketId ?? ''),
|
||||
queryFn: () => ticketService.getTicketDefectClasses(ticketId as string),
|
||||
enabled: Boolean(ticketId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketClassDetectionsQuery(
|
||||
videoId: string | undefined,
|
||||
params: VideoDetectionsParams,
|
||||
) {
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
skip: params.skip ?? 0,
|
||||
limit: params.limit ?? 1,
|
||||
class_name: params.class_name,
|
||||
min_confidence: params.min_confidence,
|
||||
sort: params.sort ?? 'timestamp_asc',
|
||||
search: params.search,
|
||||
}),
|
||||
[
|
||||
params.class_name,
|
||||
params.limit,
|
||||
params.min_confidence,
|
||||
params.search,
|
||||
params.skip,
|
||||
params.sort,
|
||||
],
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.classDetections(videoId ?? '', queryParams),
|
||||
queryFn: () =>
|
||||
videoService.getVideoDetections(videoId as string, queryParams),
|
||||
enabled: Boolean(videoId && queryParams.class_name),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketExtensionRequestQuery(
|
||||
ticketId: string | undefined,
|
||||
assignmentId: number | undefined,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.extensionRequest(ticketId ?? '', assignmentId),
|
||||
queryFn: () =>
|
||||
ticketService.getTicketExtensionRequest(
|
||||
ticketId as string,
|
||||
assignmentId as number,
|
||||
),
|
||||
enabled: Boolean(ticketId && assignmentId),
|
||||
});
|
||||
}
|
||||
|
||||
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.classDetail(ticketId, payload.defect_class),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
onError: () => toast.error('Failed to assign 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.classDetail(ticketId, ticket.defect_class),
|
||||
(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.classDetail(ticketId, payload.defect_class),
|
||||
(current) => mergeTicketDetailResponse(current, ticket),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
onError: () => toast.error('Failed to upload repair evidence'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRequestTicketExtensionMutation(
|
||||
ticketId: string,
|
||||
assignmentId: number | undefined,
|
||||
defectClass: string,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: RequestTicketExtensionPayload) => {
|
||||
if (assignmentId === undefined) {
|
||||
throw new Error('No active assignment is available for this ticket.');
|
||||
}
|
||||
return ticketService.requestTicketExtension(
|
||||
ticketId,
|
||||
assignmentId,
|
||||
payload,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Extension request submitted');
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
onError: () => toast.error('Failed to request an extension'),
|
||||
});
|
||||
}
|
||||
export function useReviewTicketExtensionMutation(
|
||||
ticketId: string,
|
||||
assignmentId: number,
|
||||
defectClass: string,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: ReviewTicketExtensionPayload) =>
|
||||
ticketService.reviewTicketExtension(ticketId, assignmentId, payload),
|
||||
onSuccess: (_response, payload) => {
|
||||
toast.success(
|
||||
payload.action === 'approve'
|
||||
? 'Extension request approved'
|
||||
: 'Extension request rejected',
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
onError: () => toast.error('Failed to review extension request'),
|
||||
});
|
||||
}
|
||||
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.classDetail(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.classDetail(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>
|
||||
);
|
||||
}
|
||||
81
src/app/(modules)/ticket/page.tsx
Normal file
81
src/app/(modules)/ticket/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'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 } 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 [segmentId, setSegmentId] = useState('');
|
||||
|
||||
useTenantTicketTableEvents();
|
||||
|
||||
const ticketsQuery = useTicketsQuery({
|
||||
skip,
|
||||
limit,
|
||||
chainageId: segmentId,
|
||||
});
|
||||
|
||||
const columns = useTicketColumns();
|
||||
const tickets = ticketsQuery.data?.items ?? [];
|
||||
const total = ticketsQuery.data?.total ?? 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
|
||||
segmentId={segmentId}
|
||||
onSegmentChange={handleSegmentChange}
|
||||
/>
|
||||
),
|
||||
[handleSegmentChange, segmentId],
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
25
src/app/(modules)/ticket/queries/ticketKeys.ts
Normal file
25
src/app/(modules)/ticket/queries/ticketKeys.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { TicketListParams, VideoDetectionsParams } 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,
|
||||
overview: (ticketId: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'overview'] as const,
|
||||
classDetail: (ticketId: string, defectClass: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'class', defectClass] as const,
|
||||
classDetections: (videoId: string, params: VideoDetectionsParams) =>
|
||||
[...ticketKeys.details(), 'video', videoId, 'detections', params] as const,
|
||||
defectClasses: (ticketId: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'defect-classes'] as const,
|
||||
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
|
||||
[
|
||||
...ticketKeys.details(),
|
||||
ticketId,
|
||||
'assignments',
|
||||
assignmentId,
|
||||
'extension-request',
|
||||
] 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>
|
||||
);
|
||||
}
|
||||
171
src/app/(modules)/upload/components/CreateUploadDialog.tsx
Normal file
171
src/app/(modules)/upload/components/CreateUploadDialog.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { Loader2, Play } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
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="sm:max-w-5xl"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload Video</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure location and upload road inspection data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="space-y-5">
|
||||
<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-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
85
src/app/(modules)/upload/components/UploadActionsMenu.tsx
Normal file
85
src/app/(modules)/upload/components/UploadActionsMenu.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
MoreVertical,
|
||||
PlayCircle,
|
||||
RotateCcw,
|
||||
Ticket,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { UploadListItem } from '@/types';
|
||||
|
||||
interface UploadActionsMenuProps {
|
||||
upload: UploadListItem;
|
||||
onViewVideo: (upload: UploadListItem) => void;
|
||||
}
|
||||
|
||||
export function UploadActionsMenu({
|
||||
upload,
|
||||
onViewVideo,
|
||||
}: UploadActionsMenuProps) {
|
||||
const router = useRouter();
|
||||
|
||||
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.raw_video_url ? (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onViewVideo(upload);
|
||||
}}
|
||||
>
|
||||
<PlayCircle className="size-4" />
|
||||
View Video
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem>
|
||||
<Eye /> View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<RotateCcw /> Retry Analysis
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!upload.ticket_id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (upload.ticket_id) {
|
||||
router.push(`/ticket/${upload.ticket_id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ticket /> Open Ticket
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2 /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
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-lg 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-lg 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>
|
||||
);
|
||||
}
|
||||
|
||||
156
src/app/(modules)/upload/components/UploadListColumns.tsx
Normal file
156
src/app/(modules)/upload/components/UploadListColumns.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Video } from 'lucide-react';
|
||||
|
||||
import { ENV_CONSTANT } from '@/constants/secrect.constant';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { UploadListItem } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { UploadActionsMenu } from './UploadActionsMenu';
|
||||
import { UploadResultCell } from './UploadResultCell';
|
||||
import { UploadStatusBadge } from './UploadStatusBadge';
|
||||
|
||||
function resolveThumbnailUrl(value: string | null) {
|
||||
if (!value) return null;
|
||||
if (/^https?:\/\//i.test(value)) return value;
|
||||
|
||||
const baseUrl = ENV_CONSTANT.BASE_API_URL?.replace(/\/$/, '');
|
||||
const path = value.startsWith('/') ? value : `/${value}`;
|
||||
return baseUrl ? `${baseUrl}${path}` : path;
|
||||
}
|
||||
|
||||
export function useUploadListColumns(
|
||||
onViewVideo: (upload: UploadListItem) => void,
|
||||
): ColumnDef<UploadListItem>[] {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'thumbnail',
|
||||
header: 'Thumbnail',
|
||||
size: 104,
|
||||
minSize: 96,
|
||||
enableSorting: false,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => {
|
||||
const thumbnail = resolveThumbnailUrl(row.original.thumbnail);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex aspect-video w-20 items-center justify-center overflow-hidden rounded-lg border bg-muted bg-cover bg-center"
|
||||
style={
|
||||
thumbnail
|
||||
? { backgroundImage: `url("${thumbnail}")` }
|
||||
: undefined
|
||||
}
|
||||
role={thumbnail ? 'img' : undefined}
|
||||
aria-label={thumbnail ? 'Upload thumbnail' : undefined}
|
||||
>
|
||||
{!thumbnail ? (
|
||||
<Video className="size-5 text-muted-foreground" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'uploaded_by',
|
||||
header: 'Uploader',
|
||||
size: 190,
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{row.original.uploaded_by.name ?? '—'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.original.uploaded_by.email ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'project',
|
||||
header: 'Project',
|
||||
size: 165,
|
||||
cell: ({ row }) => row.original.project ?? '—',
|
||||
},
|
||||
{
|
||||
accessorKey: 'package',
|
||||
header: 'Package',
|
||||
size: 110,
|
||||
cell: ({ row }) => row.original.package ?? '—',
|
||||
},
|
||||
{
|
||||
accessorKey: 'segment',
|
||||
header: 'Segment',
|
||||
size: 140,
|
||||
cell: ({ row }) => row.original.segment ?? '—',
|
||||
},
|
||||
{
|
||||
accessorKey: 'chainage',
|
||||
header: 'Chainage',
|
||||
size: 155,
|
||||
cell: ({ row }) => row.original.chainage ?? '—',
|
||||
},
|
||||
{
|
||||
accessorKey: 'uploaded_at',
|
||||
header: 'Uploaded At',
|
||||
size: 175,
|
||||
cell: ({ row }) => formatDate(row.original.uploaded_at),
|
||||
},
|
||||
{
|
||||
accessorKey: 'is_uploaded',
|
||||
header: 'Upload Status',
|
||||
size: 125,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) =>
|
||||
row.original.is_uploaded ? (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
|
||||
Uploaded
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Analysis Status',
|
||||
size: 145,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => (
|
||||
<UploadStatusBadge
|
||||
status={row.original.status}
|
||||
progress={row.original.progress}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'result',
|
||||
header: 'Result',
|
||||
size: 170,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => <UploadResultCell item={row.original} />,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 64,
|
||||
minSize: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center">
|
||||
<UploadActionsMenu
|
||||
upload={row.original}
|
||||
onViewVideo={onViewVideo}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[onViewVideo],
|
||||
);
|
||||
}
|
||||
46
src/app/(modules)/upload/components/UploadResultCell.tsx
Normal file
46
src/app/(modules)/upload/components/UploadResultCell.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { CircleMinus, CircleX, TicketCheck } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { UploadListItem } from '@/types';
|
||||
|
||||
export function UploadResultCell({ item }: { item: UploadListItem }) {
|
||||
if (item.result === 'ticket_created') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Badge className="bg-violet-500/15 text-violet-600 dark:text-violet-400">
|
||||
<TicketCheck /> Ticket Created
|
||||
</Badge>
|
||||
<p className="text-xs font-medium">
|
||||
{item.ticket_name ?? item.ticket_id}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.result === 'analysis_failed') {
|
||||
return (
|
||||
<div className="space-y-1 text-red-600 dark:text-red-400">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium">
|
||||
<CircleX className="size-3.5" /> Analysis Failed
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.error_message ?? 'Processing failed'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.result === 'no_defects_found') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-500">
|
||||
<CircleMinus className="size-3.5" /> No Defects Found
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.total_detections ?? 0} issues detected
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
65
src/app/(modules)/upload/components/UploadStatusBadge.tsx
Normal file
65
src/app/(modules)/upload/components/UploadStatusBadge.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { CheckCircle2, Clock3, LoaderCircle, XCircle } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import type { UploadStatus } from '@/types';
|
||||
|
||||
interface UploadStatusBadgeProps {
|
||||
status: UploadStatus;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
queued: {
|
||||
label: 'Queued',
|
||||
icon: Clock3,
|
||||
className: 'bg-zinc-500/15 text-zinc-500 dark:text-zinc-300',
|
||||
},
|
||||
processing: {
|
||||
label: 'Processing',
|
||||
icon: LoaderCircle,
|
||||
className: 'bg-blue-500/15 text-blue-600 dark:text-blue-400',
|
||||
},
|
||||
completed: {
|
||||
label: 'Completed',
|
||||
icon: CheckCircle2,
|
||||
className: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
|
||||
},
|
||||
failed: {
|
||||
label: 'Failed',
|
||||
icon: XCircle,
|
||||
className: 'bg-red-500/15 text-red-600 dark:text-red-400',
|
||||
},
|
||||
} satisfies Record<
|
||||
UploadStatus,
|
||||
{ label: string; icon: typeof Clock3; className: string }
|
||||
>;
|
||||
|
||||
export function UploadStatusBadge({
|
||||
status,
|
||||
progress,
|
||||
}: UploadStatusBadgeProps) {
|
||||
const config = statusConfig[status];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Badge variant="secondary" className={config.className}>
|
||||
<Icon
|
||||
className={status === 'processing' ? 'animate-spin' : undefined}
|
||||
/>
|
||||
{config.label}
|
||||
</Badge>
|
||||
{status === 'processing' ? (
|
||||
<div className="w-24 space-y-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{progress}% complete
|
||||
</span>
|
||||
<Progress value={progress} className="h-1.5" />
|
||||
</div>
|
||||
) : status === 'completed' ? (
|
||||
<p className="text-xs text-muted-foreground">100%</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/app/(modules)/upload/components/UploadTable.tsx
Normal file
47
src/app/(modules)/upload/components/UploadTable.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import type { UploadListItem } from '@/types';
|
||||
|
||||
interface UploadTableProps {
|
||||
columns: ColumnDef<UploadListItem>[];
|
||||
uploads: UploadListItem[];
|
||||
isLoading: boolean;
|
||||
skip: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
onPageChange: (skip: number) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
}
|
||||
|
||||
export function UploadTable({
|
||||
columns,
|
||||
uploads,
|
||||
isLoading,
|
||||
skip,
|
||||
limit,
|
||||
total,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
}: 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,
|
||||
}}
|
||||
enableColumnControls={false}
|
||||
minColumnSize={120}
|
||||
/>
|
||||
);
|
||||
}
|
||||
41
src/app/(modules)/upload/components/VideoPreviewDialog.tsx
Normal file
41
src/app/(modules)/upload/components/VideoPreviewDialog.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { UploadListItem } from '@/types';
|
||||
|
||||
interface VideoPreviewDialogProps {
|
||||
upload: UploadListItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function VideoPreviewDialog({
|
||||
upload,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: VideoPreviewDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Video Preview</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<ProtectedVideoPlayer
|
||||
url={open ? upload?.raw_video_url : undefined}
|
||||
className="rounded-lg"
|
||||
unavailableTitle="Video preview unavailable"
|
||||
/>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user