Compare commits
85 Commits
4add7a6c83
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 224361f144 | |||
| 77b6755d05 | |||
| 2f25fd49be | |||
| 3891500cf2 | |||
| 062e89fcba | |||
| 725c256791 | |||
| 5d3dac0b21 | |||
| e077467bce | |||
| e94da54aa9 | |||
| 76c87811d8 | |||
| 830883c6b7 | |||
| 68829dc72b | |||
| 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 |
@@ -1,2 +1,2 @@
|
||||
# Backend API base URL (no trailing slash)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||
# Server-only backend origin. Do not expose this as a NEXT_PUBLIC variable.
|
||||
BACKEND_ORIGIN=http://localhost:8000
|
||||
|
||||
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.
|
||||
@@ -1,7 +1,21 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const backendOrigin = process.env.BACKEND_ORIGIN?.replace(/\/+$/, '');
|
||||
|
||||
if (!backendOrigin) {
|
||||
throw new Error('BACKEND_ORIGIN is required');
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: false,
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/:namespace(api|biz)/:path*',
|
||||
destination: `${backendOrigin}/:namespace/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
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"
|
||||
},
|
||||
|
||||
5
public/color_logo.svg
Normal file
5
public/color_logo.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="#372AAC"/>
|
||||
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8026C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0642C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="#372AAC"/>
|
||||
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="#372AAC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
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 |
5
public/logo.svg
Normal file
5
public/logo.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="white"/>
|
||||
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8025C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0641C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="white"/>
|
||||
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 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>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function LoginPage() {
|
||||
<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,13 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { FieldErrors, 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 } from '@/components/form';
|
||||
import { FormField, PhoneInput } from '@/components/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -22,6 +28,7 @@ interface ClientSheetProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
clientId?: number;
|
||||
register: UseFormRegister<ClientFormValues>;
|
||||
control: Control<ClientFormValues>;
|
||||
errors: FieldErrors<ClientFormValues>;
|
||||
isSubmitted: boolean;
|
||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||
@@ -34,13 +41,20 @@ export function ClientSheet({
|
||||
onOpenChange,
|
||||
clientId,
|
||||
register,
|
||||
control,
|
||||
errors,
|
||||
isSubmitted,
|
||||
onSubmit,
|
||||
canSubmit,
|
||||
isSaving,
|
||||
}: ClientSheetProps) {
|
||||
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
|
||||
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
|
||||
const firstNameErrorMessage = isSubmitted
|
||||
? errors.first_name?.message
|
||||
: undefined;
|
||||
const lastNameErrorMessage = isSubmitted
|
||||
? errors.last_name?.message
|
||||
: undefined;
|
||||
const emailErrorMessage = isSubmitted ? errors.email?.message : undefined;
|
||||
const landlineErrorMessage = isSubmitted
|
||||
? errors.landline_number?.message
|
||||
@@ -58,33 +72,53 @@ export function ClientSheet({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{clientId ? 'Edit Client' : 'Create Client'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
<DialogDescription>
|
||||
Manage company and primary contact details.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="max-h-[58vh] space-y-5 overflow-y-auto px-6 py-4">
|
||||
<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">
|
||||
<FormField
|
||||
id="client-name"
|
||||
label="Client Name"
|
||||
id="client-first-name"
|
||||
label="First Name"
|
||||
required
|
||||
error={nameErrorMessage}
|
||||
error={firstNameErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="client-name"
|
||||
placeholder="Enter client name"
|
||||
aria-invalid={!!nameErrorMessage}
|
||||
{...register('name')}
|
||||
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="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
id="client-email"
|
||||
label="Company Email"
|
||||
@@ -94,14 +128,12 @@ export function ClientSheet({
|
||||
<Input
|
||||
id="client-email"
|
||||
type="email"
|
||||
placeholder="info@example.com"
|
||||
placeholder="Enter company email"
|
||||
aria-invalid={!!emailErrorMessage}
|
||||
{...register('email')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
id="landline-number"
|
||||
label="Landline Number"
|
||||
@@ -110,42 +142,50 @@ export function ClientSheet({
|
||||
>
|
||||
<Input
|
||||
id="landline-number"
|
||||
placeholder="+91-22-12345678"
|
||||
placeholder="Enter landline number"
|
||||
aria-invalid={!!landlineErrorMessage}
|
||||
{...register('landline_number')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="address"
|
||||
label="Address"
|
||||
required
|
||||
error={addressErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="address"
|
||||
placeholder="12 MG Road, Mumbai, MH 400001"
|
||||
aria-invalid={!!addressErrorMessage}
|
||||
{...register('address')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
id="address"
|
||||
label="Address"
|
||||
required
|
||||
error={addressErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="address"
|
||||
placeholder="Enter address"
|
||||
aria-invalid={!!addressErrorMessage}
|
||||
{...register('address')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<FormField id="gst" label="GST">
|
||||
<Input
|
||||
id="gst"
|
||||
placeholder="27ABCDE1234F1Z5"
|
||||
placeholder="Enter GST number"
|
||||
{...register('gst')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField id="pan" label="PAN">
|
||||
<Input id="pan" placeholder="ABCDE1234F" {...register('pan')} />
|
||||
<Input
|
||||
id="pan"
|
||||
placeholder="Enter PAN number"
|
||||
{...register('pan')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField id="tan" label="TAN">
|
||||
<Input id="tan" placeholder="MUMA12345B" {...register('tan')} />
|
||||
<Input
|
||||
id="tan"
|
||||
placeholder="Enter TAN number"
|
||||
{...register('tan')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
@@ -170,11 +210,19 @@ export function ClientSheet({
|
||||
required
|
||||
error={contactPhoneErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="contact-phone-number"
|
||||
placeholder="+919876543210"
|
||||
aria-invalid={!!contactPhoneErrorMessage}
|
||||
{...register('contact_phone_number')}
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -187,15 +235,15 @@ export function ClientSheet({
|
||||
<Input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
placeholder="contact@example.com"
|
||||
placeholder="Enter contact email"
|
||||
aria-invalid={!!contactEmailErrorMessage}
|
||||
{...register('contact_email')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -213,6 +261,8 @@ export function ClientSheet({
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
<div ref={dropdownPortalRef} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,45 +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';
|
||||
|
||||
const phoneSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Phone number is required')
|
||||
.regex(/^\+(?:[0-9] ?|-){6,18}[0-9]$/, 'Please enter a valid phone number');
|
||||
|
||||
const requiredText = (message: string) => z.string().trim().min(1, message);
|
||||
const optionalTaxIdSchema = z.string().trim();
|
||||
|
||||
const clientFormSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
name: z.string().trim().min(1, 'Client name is required'),
|
||||
email: z.string().trim().min(1, 'Email is required').email('Invalid email'),
|
||||
landline_number: phoneSchema,
|
||||
address: z.string().trim().min(1, 'Address is required'),
|
||||
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: z.string().trim().min(1, 'Contact name is required'),
|
||||
contact_phone_number: phoneSchema,
|
||||
contact_email: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Contact email is required')
|
||||
.email('Invalid contact email'),
|
||||
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: '',
|
||||
@@ -73,7 +71,8 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
||||
});
|
||||
|
||||
const clientId = useWatch({ control, name: 'id' });
|
||||
const name = useWatch({ control, name: 'name' }) || '';
|
||||
const firstName = useWatch({ control, name: 'first_name' }) || '';
|
||||
const lastName = useWatch({ control, name: 'last_name' }) || '';
|
||||
const email = useWatch({ control, name: 'email' }) || '';
|
||||
const landlineNumber = useWatch({ control, name: 'landline_number' }) || '';
|
||||
const address = useWatch({ control, name: 'address' }) || '';
|
||||
@@ -82,7 +81,8 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
||||
useWatch({ control, name: 'contact_phone_number' }) || '';
|
||||
const contactEmail = useWatch({ control, name: 'contact_email' }) || '';
|
||||
const canSubmit =
|
||||
name.trim().length > 0 &&
|
||||
firstName.trim().length > 0 &&
|
||||
lastName.trim().length > 0 &&
|
||||
email.trim().length > 0 &&
|
||||
landlineNumber.trim().length > 0 &&
|
||||
address.trim().length > 0 &&
|
||||
@@ -90,18 +90,7 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
||||
contactPhoneNumber.trim().length > 0 &&
|
||||
contactEmail.trim().length > 0;
|
||||
|
||||
const handleSubmit = submitForm(
|
||||
(values) => saveMutation.mutate(values),
|
||||
(formErrors) => {
|
||||
const firstError = Object.values(formErrors).find(
|
||||
(error) => error?.message,
|
||||
);
|
||||
|
||||
if (firstError?.message) {
|
||||
toast.error(firstError.message);
|
||||
}
|
||||
},
|
||||
);
|
||||
const handleSubmit = submitForm((values) => saveMutation.mutate(values));
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
reset(defaultValues);
|
||||
@@ -111,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 || '',
|
||||
@@ -129,6 +119,7 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
||||
|
||||
return {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
openCreate,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export default function ClientsPage() {
|
||||
clientForm;
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
clientId,
|
||||
errors,
|
||||
@@ -134,6 +135,7 @@ export default function ClientsPage() {
|
||||
onOpenChange={setIsSheetOpen}
|
||||
clientId={clientId}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
isSubmitted={isSubmitted}
|
||||
onSubmit={handleSubmit}
|
||||
|
||||
@@ -1,678 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
AlertCircle,
|
||||
Activity,
|
||||
MapPin as MapPinIcon,
|
||||
Milestone,
|
||||
BarChart3,
|
||||
AlertTriangle,
|
||||
Zap,
|
||||
PencilLine,
|
||||
CheckCircle2,
|
||||
Settings2,
|
||||
Droplets,
|
||||
} from 'lucide-react';
|
||||
import { StatsCard } from '@/components/dashboard/stats-card';
|
||||
import { FilterSelector } from '@/components/dashboard/filter-selector';
|
||||
import { DetectionDonutChart } from '@/components/dashboard/detection-donut-chart';
|
||||
import { ChainageBarChart } from '@/components/dashboard/chainage-bar-chart';
|
||||
import { DashboardMap } from '@/components/dashboard/dashboard-map';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { projectService, projectSummaryService } from '@/services/api';
|
||||
import { Project } from '@/types';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton';
|
||||
import { toast } from 'sonner';
|
||||
import { Reveal, StaggerContainer, StaggerItem } from '@/components/ui/reveal';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
interface ProjectSummary {
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
corridor_name: string | null;
|
||||
state: string | null;
|
||||
};
|
||||
packages: {
|
||||
[key: string]: {
|
||||
package_id: string;
|
||||
region: string | null;
|
||||
chainages: {
|
||||
[key: string]: {
|
||||
chainage_id: string;
|
||||
chainage: string | null;
|
||||
detection_count: number;
|
||||
detections: Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
class: string;
|
||||
confidence: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface DetectionStats {
|
||||
totalDefectedSignboard: number;
|
||||
totalPothole: number;
|
||||
totalRoadCrack: number;
|
||||
totalDamagedRoadMarking: number;
|
||||
totalGoodSignboard: number;
|
||||
totalDrainIssue: number;
|
||||
totalDefectiveCulvert: number;
|
||||
totalRoadDamage: number;
|
||||
chainageData: Array<{
|
||||
name: string;
|
||||
defected_sign_board: number;
|
||||
pothole: number;
|
||||
road_crack: number;
|
||||
damaged_road_marking: number;
|
||||
// good_sign_board: number;
|
||||
drain_issue: number;
|
||||
defective_culvert: number;
|
||||
total: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
function calculateStats(summary: ProjectSummary | null): DetectionStats {
|
||||
if (!summary) {
|
||||
return {
|
||||
totalDefectedSignboard: 0,
|
||||
totalPothole: 0,
|
||||
totalRoadCrack: 0,
|
||||
totalDamagedRoadMarking: 0,
|
||||
totalGoodSignboard: 0,
|
||||
totalDrainIssue: 0,
|
||||
totalDefectiveCulvert: 0,
|
||||
totalRoadDamage: 0,
|
||||
chainageData: [],
|
||||
};
|
||||
}
|
||||
|
||||
let total_defected_sign_board = 0;
|
||||
let total_pothole = 0;
|
||||
let total_road_crack = 0;
|
||||
let total_damaged_road_marking = 0;
|
||||
let total_good_sign_board = 0;
|
||||
let total_drain_issue = 0;
|
||||
let total_defective_culvert = 0;
|
||||
let total_road_damage = 0;
|
||||
const chainageData: DetectionStats['chainageData'] = [];
|
||||
|
||||
for (const pkg of Object.values(summary.packages || {})) {
|
||||
for (const [chnName, chn] of Object.entries(pkg.chainages || {})) {
|
||||
let chnDefectedSignboard = 0;
|
||||
let chnPothole = 0;
|
||||
let chnRoadCrack = 0;
|
||||
let chnDamagedRoadMarking = 0;
|
||||
let chnGoodSignboard = 0;
|
||||
let chnDrainIssue = 0;
|
||||
let chnDefectiveCulvert = 0;
|
||||
|
||||
for (const detection of chn.detections || []) {
|
||||
const type = (detection.type || '').toLowerCase();
|
||||
if (type === 'defected_sign_board') {
|
||||
chnDefectedSignboard++;
|
||||
total_defected_sign_board++;
|
||||
} else if (type === 'pothole') {
|
||||
chnPothole++;
|
||||
total_pothole++;
|
||||
} else if (type === 'road_crack') {
|
||||
chnRoadCrack++;
|
||||
total_road_crack++;
|
||||
} else if (type === 'damaged_road_marking') {
|
||||
chnDamagedRoadMarking++;
|
||||
total_damaged_road_marking++;
|
||||
} else if (type === 'good_sign_board') {
|
||||
chnGoodSignboard++;
|
||||
// total_good_sign_board++;
|
||||
} else if (type === 'drain_issue') {
|
||||
chnDrainIssue++;
|
||||
total_drain_issue++;
|
||||
} else if (type === 'defective_culvert') {
|
||||
chnDefectiveCulvert++;
|
||||
total_defective_culvert++;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
chnDefectedSignboard > 0 ||
|
||||
chnPothole > 0 ||
|
||||
chnRoadCrack > 0 ||
|
||||
chnDamagedRoadMarking > 0 ||
|
||||
chnGoodSignboard > 0 ||
|
||||
chnDrainIssue > 0 ||
|
||||
chnDefectiveCulvert > 0
|
||||
) {
|
||||
const shortName =
|
||||
chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
|
||||
chainageData.push({
|
||||
name: shortName,
|
||||
defected_sign_board: chnDefectedSignboard,
|
||||
pothole: chnPothole,
|
||||
road_crack: chnRoadCrack,
|
||||
damaged_road_marking: chnDamagedRoadMarking,
|
||||
// good_sign_board: chnGoodSignboard,
|
||||
drain_issue: chnDrainIssue,
|
||||
defective_culvert: chnDefectiveCulvert,
|
||||
total:
|
||||
chnDefectedSignboard +
|
||||
chnPothole +
|
||||
chnRoadCrack +
|
||||
chnDamagedRoadMarking +
|
||||
// chnGoodSignboard +
|
||||
chnDrainIssue +
|
||||
chnDefectiveCulvert,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total_road_damage =
|
||||
total_defected_sign_board +
|
||||
total_pothole +
|
||||
total_road_crack +
|
||||
total_damaged_road_marking +
|
||||
total_drain_issue +
|
||||
total_defective_culvert;
|
||||
|
||||
return {
|
||||
totalDefectedSignboard: total_defected_sign_board,
|
||||
totalPothole: total_pothole,
|
||||
totalRoadCrack: total_road_crack,
|
||||
totalDamagedRoadMarking: total_damaged_road_marking,
|
||||
totalGoodSignboard: total_good_sign_board,
|
||||
totalDrainIssue: total_drain_issue,
|
||||
totalDefectiveCulvert: total_defective_culvert,
|
||||
totalRoadDamage: total_road_damage,
|
||||
chainageData,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [projectSummary, setProjectSummary] = useState<ProjectSummary | null>(
|
||||
null,
|
||||
);
|
||||
const [stats, setStats] = useState<DetectionStats>({
|
||||
totalDefectedSignboard: 0,
|
||||
totalPothole: 0,
|
||||
totalRoadCrack: 0,
|
||||
totalDamagedRoadMarking: 0,
|
||||
totalGoodSignboard: 0,
|
||||
totalDrainIssue: 0,
|
||||
totalDefectiveCulvert: 0,
|
||||
totalRoadDamage: 0,
|
||||
chainageData: [],
|
||||
});
|
||||
|
||||
// Load projects on mount
|
||||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const projectsData = await projectService.getProjects();
|
||||
setProjects(projectsData.items);
|
||||
|
||||
if (projectsData.items.length > 0) {
|
||||
setSelectedProjectId(
|
||||
projectsData.items[projectsData.items.length - 1].id,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load projects:', err);
|
||||
toast.error('Failed to load projects', {
|
||||
description: 'Please check if the backend is running.',
|
||||
});
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
loadProjects();
|
||||
}, []);
|
||||
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId);
|
||||
|
||||
// Extract packages from project summary
|
||||
const packages = projectSummary
|
||||
? Object.keys(projectSummary.packages || {}).map((pkgName) => ({
|
||||
id: pkgName,
|
||||
name: pkgName,
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Extract chainages from selected package
|
||||
const [selectedPackageId, setSelectedPackageId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedChainageId, setSelectedChainageId] = useState<string | null>(
|
||||
null,
|
||||
); // Changed 'selectedLocationId' to 'selectedChainageId'
|
||||
|
||||
const chainages =
|
||||
projectSummary && selectedPackageId && selectedPackageId !== 'all'
|
||||
? Object.keys(
|
||||
projectSummary.packages[selectedPackageId]?.chainages || {},
|
||||
).map((chnName) => ({
|
||||
id: chnName,
|
||||
name: chnName,
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Load project summary when project changes
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId) {
|
||||
setProjectSummary(null);
|
||||
return;
|
||||
}
|
||||
setProjectSummary(null);
|
||||
|
||||
const loadProjectSummary = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const summary =
|
||||
await projectSummaryService.getProjectSummary<ProjectSummary>(
|
||||
selectedProjectId,
|
||||
);
|
||||
setProjectSummary(summary);
|
||||
} catch (err) {
|
||||
console.error('Failed to load project summary:', err);
|
||||
toast.error('Failed to load project summary');
|
||||
setProjectSummary(null);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
loadProjectSummary();
|
||||
}, [selectedProjectId]);
|
||||
|
||||
// Reset package and chainage when project changes
|
||||
useEffect(() => {
|
||||
setSelectedPackageId(null);
|
||||
setSelectedChainageId(null);
|
||||
}, [selectedProjectId]);
|
||||
|
||||
// Auto-select last package when project summary is loaded
|
||||
useEffect(() => {
|
||||
if (projectSummary && selectedPackageId === null) {
|
||||
const packageIds = Object.keys(projectSummary.packages || {});
|
||||
if (packageIds.length > 0) {
|
||||
setSelectedPackageId(packageIds[packageIds.length - 1]);
|
||||
}
|
||||
}
|
||||
}, [projectSummary, selectedPackageId]);
|
||||
|
||||
// Reset chainage when package changes
|
||||
useEffect(() => {
|
||||
setSelectedChainageId(null);
|
||||
}, [selectedPackageId]);
|
||||
|
||||
// Auto-select last chainage when package is selected
|
||||
useEffect(() => {
|
||||
if (
|
||||
projectSummary &&
|
||||
selectedPackageId &&
|
||||
selectedPackageId !== 'all' &&
|
||||
selectedChainageId === null
|
||||
) {
|
||||
const chainageIds = Object.keys(
|
||||
projectSummary.packages[selectedPackageId]?.chainages || {},
|
||||
);
|
||||
if (chainageIds.length > 0) {
|
||||
setSelectedChainageId(chainageIds[chainageIds.length - 1]);
|
||||
}
|
||||
}
|
||||
}, [projectSummary, selectedPackageId, selectedChainageId]);
|
||||
|
||||
// Filter stats based on selections
|
||||
useEffect(() => {
|
||||
if (!projectSummary) {
|
||||
setStats({
|
||||
totalDefectedSignboard: 0,
|
||||
totalPothole: 0,
|
||||
totalRoadCrack: 0,
|
||||
totalDamagedRoadMarking: 0,
|
||||
totalGoodSignboard: 0,
|
||||
totalDrainIssue: 0,
|
||||
totalDefectiveCulvert: 0,
|
||||
totalRoadDamage: 0,
|
||||
chainageData: [], // Changed 'locationData' to 'chainageData'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let totalDefectedSignboard = 0;
|
||||
let totalPothole = 0;
|
||||
let totalRoadCrack = 0;
|
||||
let totalDamagedRoadMarking = 0;
|
||||
let totalGoodSignboard = 0;
|
||||
let totalDrainIssue = 0;
|
||||
let totalDefectiveCulvert = 0;
|
||||
const chainageData: DetectionStats['chainageData'] = [];
|
||||
|
||||
const packagesToProcess =
|
||||
selectedPackageId && selectedPackageId !== 'all'
|
||||
? { [selectedPackageId]: projectSummary.packages[selectedPackageId] }
|
||||
: projectSummary.packages || {};
|
||||
|
||||
for (const [pkgName, pkg] of Object.entries(packagesToProcess)) {
|
||||
if (!pkg) continue;
|
||||
|
||||
const chainagesToProcess =
|
||||
selectedChainageId && selectedChainageId !== 'all'
|
||||
? { [selectedChainageId]: pkg.chainages?.[selectedChainageId] }
|
||||
: pkg.chainages || {};
|
||||
|
||||
for (const [chnName, chn] of Object.entries(chainagesToProcess)) {
|
||||
if (!chn) continue;
|
||||
|
||||
let chnDefectedSignboard = 0;
|
||||
let chnPothole = 0;
|
||||
let chnRoadCrack = 0;
|
||||
let chnDamagedRoadMarking = 0;
|
||||
let chnGoodSignboard = 0;
|
||||
let chnDrainIssue = 0;
|
||||
let chnDefectiveCulvert = 0;
|
||||
|
||||
for (const detection of chn.detections || []) {
|
||||
const type = (detection.type || '').toLowerCase();
|
||||
if (type === 'defected_sign_board') {
|
||||
chnDefectedSignboard++;
|
||||
totalDefectedSignboard++;
|
||||
} else if (type === 'pothole') {
|
||||
chnPothole++;
|
||||
totalPothole++;
|
||||
} else if (type === 'road_crack') {
|
||||
chnRoadCrack++;
|
||||
totalRoadCrack++;
|
||||
} else if (type === 'damaged_road_marking') {
|
||||
chnDamagedRoadMarking++;
|
||||
totalDamagedRoadMarking++;
|
||||
} else if (type === 'good_sign_board') {
|
||||
// chnGoodSignboard++;
|
||||
// totalGoodSignboard++;
|
||||
} else if (type === 'drain_issue') {
|
||||
chnDrainIssue++;
|
||||
totalDrainIssue++;
|
||||
} else if (type === 'defective_culvert') {
|
||||
chnDefectiveCulvert++;
|
||||
totalDefectiveCulvert++;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
chnDefectedSignboard > 0 ||
|
||||
chnPothole > 0 ||
|
||||
chnRoadCrack > 0 ||
|
||||
chnDamagedRoadMarking > 0 ||
|
||||
chnGoodSignboard > 0 ||
|
||||
chnDrainIssue > 0 ||
|
||||
chnDefectiveCulvert > 0
|
||||
) {
|
||||
const shortName =
|
||||
chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
|
||||
chainageData.push({
|
||||
name: shortName,
|
||||
defected_sign_board: chnDefectedSignboard,
|
||||
pothole: chnPothole,
|
||||
road_crack: chnRoadCrack,
|
||||
damaged_road_marking: chnDamagedRoadMarking,
|
||||
// good_sign_board: chnGoodSignboard,
|
||||
drain_issue: chnDrainIssue,
|
||||
defective_culvert: chnDefectiveCulvert,
|
||||
total:
|
||||
chnDefectedSignboard +
|
||||
chnPothole +
|
||||
chnRoadCrack +
|
||||
chnDamagedRoadMarking +
|
||||
// chnGoodSignboard +
|
||||
chnDrainIssue +
|
||||
chnDefectiveCulvert,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setStats({
|
||||
totalDefectedSignboard,
|
||||
totalPothole,
|
||||
totalRoadCrack,
|
||||
totalDamagedRoadMarking,
|
||||
totalGoodSignboard,
|
||||
totalDrainIssue,
|
||||
totalDefectiveCulvert,
|
||||
totalRoadDamage:
|
||||
totalDefectedSignboard +
|
||||
totalPothole +
|
||||
totalRoadCrack +
|
||||
totalDamagedRoadMarking +
|
||||
totalDrainIssue +
|
||||
totalDefectiveCulvert,
|
||||
chainageData,
|
||||
});
|
||||
}, [projectSummary, selectedPackageId, selectedChainageId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main Content */}
|
||||
<main>
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
description="A Comprehensive Overview Of Your Road Infrastructure Analysis"
|
||||
icon={BarChart3}
|
||||
/>
|
||||
|
||||
<Reveal direction="down" delay={0.2}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="gap-2 font-semibold">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
<span>Filters</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[320px] p-0 overflow-hidden"
|
||||
align="end"
|
||||
>
|
||||
<div className="p-4 border-b bg-muted/30">
|
||||
<h3 className="font-bold text-sm flex items-center gap-2 text-foreground">
|
||||
<Milestone className="h-4 w-4 text-primary" />
|
||||
Filter Analysis
|
||||
</h3>
|
||||
<p className="text-xs mt-1 text-muted-foreground">
|
||||
Refine your view by project, package, or segment
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<FilterSelector
|
||||
projects={projects}
|
||||
selectedProjectId={selectedProjectId}
|
||||
selectedPackageId={selectedPackageId}
|
||||
selectedChainageId={selectedChainageId} // Changed 'selectedLocationId' to 'selectedChainageId'
|
||||
onProjectChange={setSelectedProjectId}
|
||||
onPackageChange={setSelectedPackageId}
|
||||
onChainageChange={setSelectedChainageId} // Changed 'onLocationChange' to 'onChainageChange'
|
||||
packages={packages}
|
||||
chainages={chainages} // Changed 'locations' to 'chainages'
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
{isLoading && !projectSummary ? (
|
||||
<DashboardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{/* Stats Cards - Top Row */}
|
||||
<StaggerContainer className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
<StaggerItem>
|
||||
<StatsCard
|
||||
title="Total Road Damage"
|
||||
subtitle="Combined damage detections"
|
||||
value={stats.totalRoadDamage}
|
||||
icon={Activity}
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<StatsCard
|
||||
title="Potholes"
|
||||
subtitle="Surface depressions"
|
||||
value={stats.totalPothole}
|
||||
icon={AlertCircle}
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<StatsCard
|
||||
title="Defected Signboards"
|
||||
subtitle="Damaged traffic signs"
|
||||
value={stats.totalDefectedSignboard}
|
||||
icon={AlertTriangle}
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<StatsCard
|
||||
title="Road Cracks"
|
||||
subtitle="Surface fissures"
|
||||
value={stats.totalRoadCrack}
|
||||
icon={Zap}
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<StatsCard
|
||||
title="Damaged Markings"
|
||||
subtitle="Worn road lines"
|
||||
value={stats.totalDamagedRoadMarking}
|
||||
icon={PencilLine}
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<StatsCard
|
||||
title="Drain Issues"
|
||||
subtitle="Clogged or broken drainage"
|
||||
value={stats.totalDrainIssue}
|
||||
icon={Droplets}
|
||||
/>
|
||||
</StaggerItem>
|
||||
{/* <StaggerItem>
|
||||
<StatsCard
|
||||
title="Good Signboards"
|
||||
subtitle="Informational markers"
|
||||
value={stats.totalGoodSignboard}
|
||||
icon={CheckCircle2}
|
||||
/>
|
||||
</StaggerItem> */}
|
||||
</StaggerContainer>
|
||||
|
||||
{/* Charts Row - Side by Side */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||
<Reveal delay={0.2} direction="left" className="flex flex-col">
|
||||
<Card className="flex-1 h-full">
|
||||
<CardHeader className="items-center pb-0">
|
||||
<CardTitle>Detection Distribution</CardTitle>
|
||||
<CardDescription>
|
||||
Breakdown of all detected road conditions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 pb-0">
|
||||
<DetectionDonutChart
|
||||
defectedSignboard={stats.totalDefectedSignboard}
|
||||
pothole={stats.totalPothole}
|
||||
roadCrack={stats.totalRoadCrack}
|
||||
damagedRoadMarking={stats.totalDamagedRoadMarking}
|
||||
// goodSignboard={stats.totalGoodSignboard}
|
||||
drainIssue={stats.totalDrainIssue}
|
||||
defectiveCulvert={stats.totalDefectiveCulvert}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-2 text-sm">
|
||||
<div className="leading-none text-muted-foreground">
|
||||
Total detections:{' '}
|
||||
{stats.totalRoadDamage +
|
||||
// stats.totalGoodSignboard +
|
||||
stats.totalDrainIssue +
|
||||
stats.totalDefectiveCulvert}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.3} direction="right" className="flex flex-col">
|
||||
<Card className="flex-1 h-full">
|
||||
<CardHeader className="items-center pb-0">
|
||||
<CardTitle className="text-base font-bold">
|
||||
Severity by Segment
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Detections grouped by road segment
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px]">
|
||||
<ChainageBarChart
|
||||
data={stats.chainageData || []}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-2 text-sm">
|
||||
<div className="leading-none text-muted-foreground">
|
||||
Analysis based on latest processed sequence
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
{/* Map - Full Width Below Charts */}
|
||||
<Reveal delay={0.4} direction="up">
|
||||
<div>
|
||||
<DashboardMap
|
||||
selectedProjectId={selectedProjectId}
|
||||
selectedPackageId={selectedPackageId}
|
||||
selectedChainageId={selectedChainageId} // Changed 'selectedLocationId' to 'selectedChainageId'
|
||||
projectSummary={projectSummary}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,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 { 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,20 +63,24 @@ export function PackageDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-2xl"
|
||||
className="sm:max-w-2xl"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{packageId ? 'Edit Package' : 'Create Package'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
<DialogDescription>
|
||||
Manage package details and optional chainage range.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
|
||||
<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>
|
||||
@@ -95,25 +91,13 @@ export function PackageDialog({
|
||||
|
||||
{!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')}
|
||||
@@ -141,7 +125,7 @@ export function PackageDialog({
|
||||
<FormField id="package-region" label="Region">
|
||||
<Input
|
||||
id="package-region"
|
||||
placeholder="North Zone"
|
||||
placeholder="Enter region"
|
||||
{...register('region')}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -152,21 +136,22 @@ export function PackageDialog({
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Chainage range</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Optional start and end kilometre values.
|
||||
start and end kilometre values.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
id="package-start"
|
||||
label="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')}
|
||||
/>
|
||||
@@ -174,23 +159,24 @@ export function PackageDialog({
|
||||
|
||||
<FormField
|
||||
id="package-end"
|
||||
label="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>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -200,11 +186,15 @@ export function PackageDialog({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
{packageId ? 'Update Package' : 'Create Package'}
|
||||
</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({
|
||||
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,
|
||||
});
|
||||
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: 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" />;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function PlanSheet({
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full gap-0 p-0 sm:max-w-3xl">
|
||||
<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'}
|
||||
@@ -77,7 +77,11 @@ export function PlanSheet({
|
||||
</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
|
||||
@@ -242,7 +246,7 @@ 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_active')}
|
||||
/>
|
||||
Active
|
||||
@@ -250,7 +254,7 @@ 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
|
||||
@@ -269,7 +273,7 @@ export function PlanSheet({
|
||||
}
|
||||
>
|
||||
{isPermissionsLoading ? (
|
||||
<div className="rounded-md border p-6 text-muted-foreground">
|
||||
<div className="rounded-lg border p-6 text-muted-foreground">
|
||||
Loading permissions...
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -8,7 +8,6 @@ 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';
|
||||
@@ -104,31 +103,20 @@ export function usePlanForm({
|
||||
price.trim().length > 0 &&
|
||||
permissionIds.length > 0;
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
(values) => {
|
||||
saveMutation.mutate({
|
||||
...values,
|
||||
name: values.name.trim(),
|
||||
slug: values.slug.trim(),
|
||||
description: values.description.trim(),
|
||||
price: values.price.trim(),
|
||||
trial_days: toNumber(values.trial_days),
|
||||
max_projects: toNumber(values.max_projects),
|
||||
max_organizations: toNumber(values.max_organizations),
|
||||
max_users: toNumber(values.max_users),
|
||||
max_roles: toNumber(values.max_roles),
|
||||
});
|
||||
},
|
||||
(formErrors) => {
|
||||
const firstMessage = Object.values(formErrors).find(
|
||||
(error) => error?.message,
|
||||
)?.message;
|
||||
|
||||
if (firstMessage) {
|
||||
toast.error(String(firstMessage));
|
||||
}
|
||||
},
|
||||
);
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
saveMutation.mutate({
|
||||
...values,
|
||||
name: values.name.trim(),
|
||||
slug: values.slug.trim(),
|
||||
description: values.description.trim(),
|
||||
price: values.price.trim(),
|
||||
trial_days: toNumber(values.trial_days),
|
||||
max_projects: toNumber(values.max_projects),
|
||||
max_organizations: toNumber(values.max_organizations),
|
||||
max_users: toNumber(values.max_users),
|
||||
max_roles: toNumber(values.max_roles),
|
||||
});
|
||||
});
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
reset({
|
||||
|
||||
16
src/app/(modules)/plans/layout.tsx
Normal file
16
src/app/(modules)/plans/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionRouteGuard } from '@/guards';
|
||||
|
||||
export default function PlansLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PermissionRouteGuard permission={PERMISSIONS.PLAN.READ}>
|
||||
{children}
|
||||
</PermissionRouteGuard>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { FormField } from '@/components/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -54,20 +55,24 @@ export function ProjectDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-2xl"
|
||||
className="sm:max-w-2xl"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{projectId ? 'Edit Project' : 'Create Project'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
<DialogDescription>
|
||||
Manage road project identity and optional coordinate boundaries.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
|
||||
<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>
|
||||
@@ -94,14 +99,14 @@ export function ProjectDialog({
|
||||
<FormField id="project-state" label="State">
|
||||
<Input
|
||||
id="project-state"
|
||||
placeholder="Maharashtra"
|
||||
placeholder="Enter state"
|
||||
{...register('state')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField id="project-corridor" label="Corridor Name">
|
||||
<Input
|
||||
id="project-corridor"
|
||||
placeholder="Mumbai-Goa Highway"
|
||||
placeholder="Enter corridor name"
|
||||
{...register('corridor_name')}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -127,7 +132,9 @@ export function ProjectDialog({
|
||||
id="project-start-lat"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="0.0000"
|
||||
min="-90"
|
||||
max="90"
|
||||
placeholder="Enter start latitude"
|
||||
aria-invalid={!!startLatErrorMessage}
|
||||
{...register('start_lat')}
|
||||
/>
|
||||
@@ -141,7 +148,9 @@ export function ProjectDialog({
|
||||
id="project-start-lng"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="0.0000"
|
||||
min="-180"
|
||||
max="180"
|
||||
placeholder="Enter start longitude"
|
||||
aria-invalid={!!startLngErrorMessage}
|
||||
{...register('start_lng')}
|
||||
/>
|
||||
@@ -158,7 +167,9 @@ export function ProjectDialog({
|
||||
id="project-end-lat"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="0.0000"
|
||||
min="-90"
|
||||
max="90"
|
||||
placeholder="Enter end latitude"
|
||||
aria-invalid={!!endLatErrorMessage}
|
||||
{...register('end_lat')}
|
||||
/>
|
||||
@@ -172,7 +183,9 @@ export function ProjectDialog({
|
||||
id="project-end-lng"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="0.0000"
|
||||
min="-180"
|
||||
max="180"
|
||||
placeholder="Enter end longitude"
|
||||
aria-invalid={!!endLngErrorMessage}
|
||||
{...register('end_lng')}
|
||||
/>
|
||||
@@ -180,9 +193,9 @@ export function ProjectDialog({
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -192,7 +205,9 @@ export function ProjectDialog({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
{projectId ? 'Update Project' : 'Create Project'}
|
||||
</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 } from 'lucide-react';
|
||||
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,92 +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">
|
||||
<PageHeader
|
||||
title={getTitle()}
|
||||
description={`Video ID: ${videoId}`}
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Detection Results"
|
||||
description={`Video ID: ${videoId}`}
|
||||
icon={TrendingUp}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft />
|
||||
Back
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{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,7 +62,7 @@ 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 pr-12">
|
||||
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
@@ -73,7 +73,11 @@ export function RoleSheet({
|
||||
</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
|
||||
@@ -125,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 } 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,26 +64,32 @@ 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="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-2xl"
|
||||
className="sm:max-w-2xl"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{segmentId ? 'Edit Segment' : 'Create Segment'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
<DialogDescription>
|
||||
Manage segment binding, chainage values, direction, and coordinates.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
|
||||
<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>
|
||||
@@ -104,25 +105,13 @@ export function SegmentDialog({
|
||||
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')}
|
||||
@@ -136,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'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{packages.map((pkg) => (
|
||||
<SelectItem key={pkg.id} value={pkg.id}>
|
||||
{pkg.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
projectId={projectId}
|
||||
enabled={open && Boolean(projectId)}
|
||||
disabled={!projectId || isSaving}
|
||||
portalContainer={packageSelectPortalRef}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
{...register('package_id')}
|
||||
@@ -215,16 +183,16 @@ export function SegmentDialog({
|
||||
<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')}
|
||||
/>
|
||||
@@ -232,16 +200,16 @@ 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')}
|
||||
/>
|
||||
@@ -333,9 +301,9 @@ export function SegmentDialog({
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -345,11 +313,16 @@ export function SegmentDialog({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
{segmentId ? 'Update Segment' : 'Create Segment'}
|
||||
</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({
|
||||
id: z.string().optional(),
|
||||
project_id: z.string().trim().min(1, 'Project is required'),
|
||||
package_id: z.string().trim().min(1, 'Package is required'),
|
||||
segment_name: z.string().trim().min(1, 'Segment name is required'),
|
||||
chainage_start_km: requiredNumber('Start km is required'),
|
||||
chainage_end_km: requiredNumber('End km is required'),
|
||||
start_lat: latitude,
|
||||
start_lng: longitude,
|
||||
end_lat: latitude,
|
||||
end_lng: longitude,
|
||||
direction: z.enum(['UP', 'DOWN']),
|
||||
});
|
||||
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: 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,13 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { FieldErrors, 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 } from '@/components/form';
|
||||
import { FormField, PhoneInput, SelectPopover } from '@/components/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -15,16 +21,12 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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,6 +34,7 @@ interface TenantSheetProps {
|
||||
tenantId?: number;
|
||||
isEditMode: boolean;
|
||||
register: UseFormRegister<TenantFormValues>;
|
||||
control: Control<TenantFormValues>;
|
||||
errors: FieldErrors<TenantFormValues>;
|
||||
isSubmitted: boolean;
|
||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||
@@ -46,6 +49,7 @@ interface TenantSheetProps {
|
||||
isLookupsLoading: boolean;
|
||||
isSaving: boolean;
|
||||
adminEmail?: string;
|
||||
adminFieldLocks: TenantAdminFieldLocks;
|
||||
}
|
||||
|
||||
export function TenantSheet({
|
||||
@@ -54,6 +58,7 @@ export function TenantSheet({
|
||||
tenantId,
|
||||
isEditMode,
|
||||
register,
|
||||
control,
|
||||
errors,
|
||||
isSubmitted,
|
||||
onSubmit,
|
||||
@@ -68,25 +73,49 @@ 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="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{tenantId ? 'Edit Tenant' : 'Create Tenant'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
<DialogDescription>
|
||||
Bind a client and subscription plan, then invite the tenant
|
||||
administrator.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="max-h-[50vh] space-y-5 overflow-y-auto px-6 py-4">
|
||||
<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">
|
||||
@@ -128,29 +157,24 @@ export function TenantSheet({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label="Client" required error={fieldError('client_id')}>
|
||||
<Select
|
||||
<FormField
|
||||
id="tenant-client"
|
||||
label="Client"
|
||||
required
|
||||
error={fieldError('client_id')}
|
||||
>
|
||||
<SelectPopover
|
||||
options={clientOptions}
|
||||
value={clientId}
|
||||
onValueChange={onClientChange}
|
||||
disabled={isLookupsLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLookupsLoading
|
||||
? 'Loading clients...'
|
||||
: 'Select client'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={String(client.id)}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
portalContainer={dropdownPortalRef}
|
||||
placeholder={
|
||||
isLookupsLoading ? 'Loading clients...' : 'Select client'
|
||||
}
|
||||
searchPlaceholder="Search clients"
|
||||
emptyMessage="No clients found."
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
{...register('client_id')}
|
||||
@@ -160,30 +184,23 @@ export function TenantSheet({
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="tenant-plan"
|
||||
label="Subscription Plan"
|
||||
required
|
||||
error={fieldError('plan_id')}
|
||||
>
|
||||
<Select
|
||||
<SelectPopover
|
||||
options={planOptions}
|
||||
value={planId}
|
||||
onValueChange={onPlanChange}
|
||||
disabled={isLookupsLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLookupsLoading ? 'Loading plans...' : 'Select plan'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{plans.map((plan) => (
|
||||
<SelectItem key={plan.id} value={String(plan.id)}>
|
||||
{plan.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
portalContainer={dropdownPortalRef}
|
||||
placeholder={
|
||||
isLookupsLoading ? 'Loading plans...' : 'Select plan'
|
||||
}
|
||||
searchPlaceholder="Search plans"
|
||||
emptyMessage="No plans found."
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
{...register('plan_id')}
|
||||
@@ -211,7 +228,7 @@ export function TenantSheet({
|
||||
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"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -236,6 +253,7 @@ export function TenantSheet({
|
||||
id="admin-first-name"
|
||||
placeholder="Enter first name"
|
||||
aria-invalid={!!fieldError('admin_first_name')}
|
||||
readOnly={adminFieldLocks.admin_first_name}
|
||||
{...register('admin_first_name')}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -250,6 +268,7 @@ export function TenantSheet({
|
||||
id="admin-last-name"
|
||||
placeholder="Enter last name"
|
||||
aria-invalid={!!fieldError('admin_last_name')}
|
||||
readOnly={adminFieldLocks.admin_last_name}
|
||||
{...register('admin_last_name')}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -267,6 +286,7 @@ export function TenantSheet({
|
||||
type="email"
|
||||
placeholder="admin@example.com"
|
||||
aria-invalid={!!fieldError('admin_email')}
|
||||
readOnly={adminFieldLocks.admin_email}
|
||||
{...register('admin_email')}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -276,17 +296,26 @@ export function TenantSheet({
|
||||
label="Phone Number"
|
||||
error={fieldError('admin_phone_number')}
|
||||
>
|
||||
<Input
|
||||
id="admin-phone"
|
||||
placeholder="+91-9000011111"
|
||||
aria-invalid={!!fieldError('admin_phone_number')}
|
||||
{...register('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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</>
|
||||
) : adminEmail ? (
|
||||
<div className="rounded-md border bg-muted/40 p-4 text-muted-foreground">
|
||||
<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">
|
||||
@@ -294,9 +323,9 @@ export function TenantSheet({
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -316,6 +345,8 @@ export function TenantSheet({
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
<div ref={dropdownPortalRef} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useCallback } from 'react';
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Tenant } from '@/types';
|
||||
|
||||
import { useSaveTenantMutation } from './useTenantMutations';
|
||||
|
||||
const optionalPhoneSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => value === '' || /^\+(?:[0-9] ?|-){6,18}[0-9]$/.test(value), {
|
||||
message: 'Please enter a valid phone number',
|
||||
});
|
||||
const requiredText = (message: string) => z.string().trim().min(1, message);
|
||||
|
||||
const tenantFormSchema = z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
name: z.string().trim().min(1, 'Tenant name is required'),
|
||||
slug: z.string().trim().min(1, 'Slug is required'),
|
||||
client_id: z.string().trim().min(1, 'Client is required'),
|
||||
plan_id: z.string().trim().min(1, 'Plan is required'),
|
||||
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: optionalPhoneSchema,
|
||||
admin_phone_number: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => value === '' || isValidPhoneNumber(value), {
|
||||
message: 'Enter a valid phone number',
|
||||
}),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
if (values.id) return;
|
||||
@@ -38,7 +38,7 @@ const tenantFormSchema = z
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['admin_first_name'],
|
||||
message: 'Admin first name is required',
|
||||
message: 'First name is required',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ const tenantFormSchema = z
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['admin_last_name'],
|
||||
message: 'Admin last name is required',
|
||||
message: 'Last name is required',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,6 +98,20 @@ function splitContactName(contactName: string) {
|
||||
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -115,10 +129,15 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
||||
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' }) || '';
|
||||
@@ -138,21 +157,11 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
||||
adminLastName.trim().length > 0 &&
|
||||
adminEmail.trim().length > 0));
|
||||
|
||||
const handleSubmit = submitForm(
|
||||
(values) => saveMutation.mutate(values),
|
||||
(formErrors) => {
|
||||
const firstMessage = Object.values(formErrors).find(
|
||||
(error) => error?.message,
|
||||
)?.message;
|
||||
|
||||
if (firstMessage) {
|
||||
toast.error(String(firstMessage));
|
||||
}
|
||||
},
|
||||
);
|
||||
const handleSubmit = submitForm((values) => saveMutation.mutate(values));
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
reset(defaultValues);
|
||||
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||
}, [reset]);
|
||||
|
||||
const openEdit = useCallback(
|
||||
@@ -170,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],
|
||||
);
|
||||
@@ -197,9 +224,9 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
||||
|
||||
return {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
openCreate,
|
||||
openEdit,
|
||||
@@ -211,7 +238,9 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
||||
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,
|
||||
@@ -65,7 +66,9 @@ export default function TenantsPage() {
|
||||
errors,
|
||||
isSubmitted,
|
||||
canSubmit,
|
||||
adminFieldLocks,
|
||||
applyClientAdminDefaults,
|
||||
clearAdminFields,
|
||||
syncSlugFromName,
|
||||
setValue,
|
||||
isSaving,
|
||||
@@ -77,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(
|
||||
@@ -87,7 +94,7 @@ export default function TenantsPage() {
|
||||
);
|
||||
});
|
||||
},
|
||||
[applyClientAdminDefaults, isEditMode, setValue],
|
||||
[applyClientAdminDefaults, clearAdminFields, isEditMode, setValue],
|
||||
);
|
||||
|
||||
const handlePlanChange = useCallback(
|
||||
@@ -183,6 +190,7 @@ export default function TenantsPage() {
|
||||
tenantId={tenantId}
|
||||
isEditMode={isEditMode}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
isSubmitted={isSubmitted}
|
||||
onSubmit={handleSubmit}
|
||||
@@ -197,6 +205,7 @@ export default function TenantsPage() {
|
||||
isLookupsLoading={isLookupsLoading}
|
||||
isSaving={isSaving}
|
||||
adminEmail={adminEmail}
|
||||
adminFieldLocks={adminFieldLocks}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { CalendarCheck2, ImageIcon, MapPin, Smartphone } from 'lucide-react';
|
||||
|
||||
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DetectionResultItem } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { RepairProofGallery } from '../../../components/repair/RepairProofGallery';
|
||||
import { formatVideoTimestamp, type IssueReviewStatus } from './reviewTypes';
|
||||
|
||||
const REVIEW_STATUS_LABEL: Record<IssueReviewStatus, string> = {
|
||||
not_submitted: 'Awaiting Repair',
|
||||
pending: 'Under Review',
|
||||
approved: 'Approved',
|
||||
rejected: 'Rejected',
|
||||
};
|
||||
|
||||
const REVIEW_STATUS_CLASS: Record<IssueReviewStatus, string> = {
|
||||
not_submitted: 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-300',
|
||||
pending: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||
approved: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||
rejected: 'bg-destructive/10 text-destructive',
|
||||
};
|
||||
|
||||
function MetadataItem({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 space-y-1 px-4 first:pl-0 last:pr-0">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<div className="truncate text-sm font-semibold text-foreground">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IssueEvidencePanelProps {
|
||||
detection: DetectionResultItem;
|
||||
issueNumber: number;
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
reviewStatus: IssueReviewStatus;
|
||||
}
|
||||
|
||||
export function IssueEvidencePanel({
|
||||
detection,
|
||||
issueNumber,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
reviewStatus,
|
||||
}: IssueEvidencePanelProps) {
|
||||
const repairProof = detection.repair_proof;
|
||||
const displayName = detection.detection.display_name;
|
||||
const confidence = Math.round(detection.detection.confidence * 100);
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">
|
||||
{displayName} #{issueNumber}
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(REVIEW_STATUS_CLASS[reviewStatus])}
|
||||
>
|
||||
{REVIEW_STATUS_LABEL[reviewStatus]}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 divide-x sm:grid-cols-4">
|
||||
<MetadataItem
|
||||
label="Video Timestamp"
|
||||
value={formatVideoTimestamp(detection.frame.timestamp_seconds)}
|
||||
/>
|
||||
<MetadataItem label="Frame" value={detection.frame.number} />
|
||||
<MetadataItem label="Confidence" value={`${confidence}%`} />
|
||||
<MetadataItem label="Issue ID" value={detection.detection.id} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid items-start gap-4 2xl:grid-cols-2">
|
||||
<section className="min-w-0 space-y-2">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||
<ImageIcon className="size-4 text-primary" />
|
||||
Detection Image (Before)
|
||||
</p>
|
||||
<AnnotatedDetectionImage
|
||||
detection={detection}
|
||||
videoWidth={videoWidth}
|
||||
videoHeight={videoHeight}
|
||||
aspectRatio="16 / 9"
|
||||
enableLightbox
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="min-w-0 space-y-2">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||
<MapPin className="size-4 text-primary" />
|
||||
Detection Location
|
||||
</p>
|
||||
<DetectionLocationMap
|
||||
latitude={detection.location.latitude}
|
||||
longitude={detection.location.longitude}
|
||||
label={`${displayName} #${issueNumber} - Frame ${detection.frame.number}`}
|
||||
title=""
|
||||
variant="plain"
|
||||
aspectRatio="16 / 9"
|
||||
showCoordinates={false}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||
<CalendarCheck2 className="size-4 text-primary" />
|
||||
Repair Proof (After Repair)
|
||||
</p>
|
||||
{repairProof?.submitted ? (
|
||||
<Badge className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||
<Smartphone />
|
||||
Uploaded from App
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
{!repairProof?.submitted ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center">
|
||||
<p className="text-sm font-medium">
|
||||
Repair proof is not submitted yet.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This issue cannot be reviewed until repair evidence is
|
||||
available.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{repairProof.submitted_at ? (
|
||||
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
Uploaded from the field app on{' '}
|
||||
{formatDate(repairProof.submitted_at)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<RepairProofGallery
|
||||
imageUrls={repairProof.proof_image_urls}
|
||||
maxVisibleImages={4}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5 rounded-lg border bg-card p-4 shadow-xs">
|
||||
<p className="text-xs font-semibold text-foreground">
|
||||
Repair Notes
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
|
||||
{repairProof.notes?.trim() ||
|
||||
'No repair notes were provided.'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
Info,
|
||||
Loader2,
|
||||
X,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
DetectionResultItem,
|
||||
ReviewDetectionRepairProofPayload,
|
||||
} from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import type { IssueReviewStatus } from './reviewTypes';
|
||||
|
||||
type ReviewDecision = ReviewDetectionRepairProofPayload['action'];
|
||||
|
||||
interface IssueReviewActionProps {
|
||||
detection: DetectionResultItem;
|
||||
issueLabel: string;
|
||||
status: IssueReviewStatus;
|
||||
isPending: boolean;
|
||||
onSave: (payload: ReviewDetectionRepairProofPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
export function IssueReviewAction({
|
||||
detection,
|
||||
issueLabel,
|
||||
status,
|
||||
isPending,
|
||||
onSave,
|
||||
}: IssueReviewActionProps) {
|
||||
const [comment, setComment] = useState('');
|
||||
const [decision, setDecision] = useState<ReviewDecision>();
|
||||
|
||||
useEffect(() => {
|
||||
setComment('');
|
||||
setDecision(undefined);
|
||||
}, [detection.detection.id]);
|
||||
|
||||
if (status === 'not_submitted') {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ClipboardCheck className="size-5 text-primary" />
|
||||
Review & Action
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border border-dashed bg-muted/30 px-4 py-8 text-center">
|
||||
<Info className="mx-auto size-6 text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-medium">Repair proof required</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{issueLabel} is not ready for review.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'approved' || status === 'rejected') {
|
||||
const isApproved = status === 'approved';
|
||||
const repairProof = detection.repair_proof;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ClipboardCheck className="size-5 text-primary" />
|
||||
Review Result
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border px-4 py-5 text-center',
|
||||
isApproved
|
||||
? 'border-emerald-500/20 bg-emerald-500/5'
|
||||
: 'border-destructive/20 bg-destructive/5',
|
||||
)}
|
||||
>
|
||||
{isApproved ? (
|
||||
<CheckCircle2 className="mx-auto size-8 text-emerald-600" />
|
||||
) : (
|
||||
<XCircle className="mx-auto size-8 text-destructive" />
|
||||
)}
|
||||
<Badge
|
||||
className={cn(
|
||||
'mt-3',
|
||||
isApproved
|
||||
? 'bg-emerald-500/10 text-emerald-600'
|
||||
: 'bg-destructive/10 text-destructive',
|
||||
)}
|
||||
>
|
||||
{isApproved ? 'Approved' : 'Rejected'}
|
||||
</Badge>
|
||||
{repairProof?.reviewed_by_name || repairProof?.reviewed_at ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{repairProof.reviewed_by_name
|
||||
? `By ${repairProof.reviewed_by_name}`
|
||||
: 'Reviewed'}
|
||||
{repairProof.reviewed_at
|
||||
? ` on ${formatDate(repairProof.reviewed_at)}`
|
||||
: ''}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{repairProof?.review_comment ? (
|
||||
<div className="space-y-1.5 rounded-lg bg-muted/30 p-3">
|
||||
<p className="text-xs font-semibold">Review Comment</p>
|
||||
<p className="text-sm whitespace-pre-wrap text-muted-foreground">
|
||||
{repairProof.review_comment}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const submitDecision = async (nextDecision: ReviewDecision) => {
|
||||
setDecision(nextDecision);
|
||||
|
||||
try {
|
||||
await onSave({
|
||||
action: nextDecision,
|
||||
comment: comment.trim() || undefined,
|
||||
});
|
||||
} catch {
|
||||
// The mutation owns user-facing error feedback.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ClipboardCheck className="size-5 text-primary" />
|
||||
Review & Action
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="review-comment">Comment (Optional)</Label>
|
||||
<Textarea
|
||||
id="review-comment"
|
||||
value={comment}
|
||||
maxLength={4000}
|
||||
rows={5}
|
||||
disabled={isPending}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="Enter your review comments..."
|
||||
/>
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
{comment.length} / 4000
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Decision</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() => void submitDecision('approve')}
|
||||
className={cn(
|
||||
decision === 'approve' &&
|
||||
'border-emerald-500 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300',
|
||||
)}
|
||||
>
|
||||
{isPending && decision === 'approve' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Check />
|
||||
)}
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() => void submitDecision('reject')}
|
||||
className={cn(
|
||||
decision === 'reject' &&
|
||||
'border-destructive bg-destructive/10 text-destructive hover:bg-destructive/15',
|
||||
)}
|
||||
>
|
||||
{isPending && decision === 'reject' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<X />
|
||||
)}
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { ProofStatusFilter } from './reviewTypes';
|
||||
|
||||
const PROOF_STATUS_FILTERS: Array<{
|
||||
value: ProofStatusFilter;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
];
|
||||
|
||||
interface IssueReviewHeaderProps {
|
||||
ticketLabel: string;
|
||||
defectLabel: string;
|
||||
currentIndex: number;
|
||||
total: number;
|
||||
proofStatus: ProofStatusFilter;
|
||||
onBack: () => void;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onProofStatusChange: (status: ProofStatusFilter) => void;
|
||||
}
|
||||
|
||||
export function IssueReviewHeader({
|
||||
ticketLabel,
|
||||
defectLabel,
|
||||
currentIndex,
|
||||
total,
|
||||
proofStatus,
|
||||
onBack,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onProofStatusChange,
|
||||
}: IssueReviewHeaderProps) {
|
||||
return (
|
||||
<header className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
aria-label="Back to ticket"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="truncate text-2xl font-semibold tracking-tight">
|
||||
{defectLabel} Review
|
||||
</h1>
|
||||
<Badge variant="secondary">{ticketLabel}</Badge>
|
||||
</div>
|
||||
<div
|
||||
className="flex max-w-full flex-nowrap items-center gap-1.5 overflow-x-auto pt-1 pb-1"
|
||||
aria-label="Filter issues by repair proof status"
|
||||
>
|
||||
{PROOF_STATUS_FILTERS.map((filter) => {
|
||||
const isActive = proofStatus === filter.value;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isActive ? 'default' : 'outline'}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => onProofStatusChange(filter.value)}
|
||||
className="h-7 shrink-0 rounded-full px-3 text-xs"
|
||||
>
|
||||
{filter.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 self-end lg:self-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={total === 0 || currentIndex <= 0}
|
||||
onClick={onPrevious}
|
||||
aria-label="Previous issue"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<div className="min-w-16 text-center text-sm font-semibold">
|
||||
{total === 0 ? '0 of 0' : `${currentIndex + 1} of ${total}`}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={total === 0 || currentIndex >= total - 1}
|
||||
onClick={onNext}
|
||||
aria-label="Next issue"
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Hourglass,
|
||||
ListChecks,
|
||||
Wrench,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DetectionResultItem } from '@/types';
|
||||
|
||||
import {
|
||||
formatVideoTimestamp,
|
||||
getIssueId,
|
||||
getIssueReviewStatus,
|
||||
type IssueReviewStatus,
|
||||
} from './reviewTypes';
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
IssueReviewStatus,
|
||||
{
|
||||
label: string;
|
||||
icon: typeof Hourglass;
|
||||
className: string;
|
||||
}
|
||||
> = {
|
||||
not_submitted: {
|
||||
label: 'Awaiting Repair',
|
||||
icon: Wrench,
|
||||
className: 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-300',
|
||||
},
|
||||
pending: {
|
||||
label: 'Under Review',
|
||||
icon: Hourglass,
|
||||
className: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||
},
|
||||
approved: {
|
||||
label: 'Approved',
|
||||
icon: CheckCircle2,
|
||||
className: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||
},
|
||||
rejected: {
|
||||
label: 'Rejected',
|
||||
icon: XCircle,
|
||||
className: 'bg-destructive/10 text-destructive',
|
||||
},
|
||||
};
|
||||
|
||||
interface IssueReviewQueueProps {
|
||||
detections: DetectionResultItem[];
|
||||
totalCount: number;
|
||||
selectedIssueId?: string;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onSelect: (issueId: string) => void;
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
export function IssueReviewQueue({
|
||||
detections,
|
||||
totalCount,
|
||||
selectedIssueId,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onSelect,
|
||||
onLoadMore,
|
||||
}: IssueReviewQueueProps) {
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const defectLabel = detections[0]?.detection.display_name ?? 'Detected';
|
||||
|
||||
useEffect(() => {
|
||||
const target = loadMoreRef.current;
|
||||
if (!target || !hasNextPage || isFetchingNextPage) return;
|
||||
|
||||
const root = target.closest<HTMLElement>(
|
||||
'[data-slot="scroll-area-viewport"]',
|
||||
);
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) onLoadMore();
|
||||
},
|
||||
{ root, rootMargin: '160px 0px' },
|
||||
);
|
||||
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, onLoadMore]);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden xl:h-full xl:min-h-0">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ListChecks className="size-5 text-primary" />
|
||||
{defectLabel} Issues
|
||||
<Badge variant="secondary" className="ml-auto rounded-full">
|
||||
{totalCount}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 flex-1 p-2">
|
||||
<ScrollArea className="h-112 pr-2 xl:h-full">
|
||||
<div className="space-y-2 p-1">
|
||||
{detections.map((detection, index) => {
|
||||
const issueId = getIssueId(detection);
|
||||
const status = getIssueReviewStatus(detection);
|
||||
const config = STATUS_CONFIG[status];
|
||||
const StatusIcon = config.icon;
|
||||
const isSelected = issueId === selectedIssueId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={detection.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(issueId)}
|
||||
aria-current={isSelected ? 'true' : undefined}
|
||||
className={cn(
|
||||
'w-full rounded-lg border p-3 text-left transition-colors',
|
||||
'hover:border-primary/40 hover:bg-muted/50',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/6 shadow-sm'
|
||||
: 'border-border bg-card',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
{defectLabel} #{index + 1}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{defectLabel} #{index + 1}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock3 className="size-3.5" />
|
||||
{formatVideoTimestamp(
|
||||
detection.frame.timestamp_seconds,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn('shrink-0 px-1.5', config.className)}
|
||||
>
|
||||
<StatusIcon />
|
||||
{config.label}
|
||||
</Badge>
|
||||
<span aria-hidden="true">|</span>
|
||||
<span>Frame {detection.frame.number}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div ref={loadMoreRef} className="space-y-2 py-1">
|
||||
{isFetchingNextPage ? (
|
||||
<>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</>
|
||||
) : hasNextPage ? (
|
||||
<p className="py-3 text-center text-xs text-muted-foreground">
|
||||
Scroll to load more issues
|
||||
</p>
|
||||
) : detections.length > 0 ? (
|
||||
<p className="py-3 text-center text-xs text-muted-foreground">
|
||||
All {totalCount} issues loaded
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export function IssueReviewSkeleton() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-56" />
|
||||
<Skeleton className="h-4 w-36" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
<div className="grid gap-5 xl:grid-cols-[280px_minmax(0,1fr)_330px]">
|
||||
{[280, 600, 330].map((width) => (
|
||||
<Card key={width}>
|
||||
<CardHeader className="border-b">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-20 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { DetectionProofStatus, DetectionResultItem } from '@/types';
|
||||
|
||||
export type ProofStatusFilter =
|
||||
| 'all'
|
||||
| Extract<DetectionProofStatus, 'pending' | 'approved' | 'rejected'>;
|
||||
|
||||
export type IssueReviewStatus =
|
||||
| 'not_submitted'
|
||||
| 'pending'
|
||||
| 'approved'
|
||||
| 'rejected';
|
||||
|
||||
export function getIssueId(detection: DetectionResultItem) {
|
||||
return String(detection.detection.id);
|
||||
}
|
||||
|
||||
export function getIssueReviewStatus(
|
||||
detection: DetectionResultItem,
|
||||
): IssueReviewStatus {
|
||||
const repairProof = detection.repair_proof;
|
||||
if (!repairProof?.submitted) return 'not_submitted';
|
||||
|
||||
return repairProof.review_status;
|
||||
}
|
||||
|
||||
export function formatVideoTimestamp(totalSeconds: number) {
|
||||
const seconds = Math.max(0, Math.floor(totalSeconds));
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds % 60;
|
||||
|
||||
return `${minutes}:${remainder.toString().padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
useParams,
|
||||
usePathname,
|
||||
useRouter,
|
||||
useSearchParams,
|
||||
} from 'next/navigation';
|
||||
import { AlertTriangle, ClipboardCheck } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import type { ReviewDetectionRepairProofPayload } from '@/types';
|
||||
|
||||
import {
|
||||
useReviewDetectionRepairProofMutation,
|
||||
useTicketClassReviewDetectionsQuery,
|
||||
useTicketOverviewQuery,
|
||||
} from '../../../hooks/useTicketQueries';
|
||||
import { IssueEvidencePanel } from './components/IssueEvidencePanel';
|
||||
import { IssueReviewAction } from './components/IssueReviewAction';
|
||||
import { IssueReviewHeader } from './components/IssueReviewHeader';
|
||||
import { IssueReviewQueue } from './components/IssueReviewQueue';
|
||||
import { IssueReviewSkeleton } from './components/IssueReviewSkeleton';
|
||||
import {
|
||||
getIssueId,
|
||||
getIssueReviewStatus,
|
||||
type ProofStatusFilter,
|
||||
} from './components/reviewTypes';
|
||||
|
||||
const PROOF_STATUS_FILTERS: ProofStatusFilter[] = [
|
||||
'all',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
];
|
||||
|
||||
export default function TicketClassReviewPage() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { ticketId, defectClass } = useParams() as {
|
||||
ticketId: string;
|
||||
defectClass: string;
|
||||
};
|
||||
const proofStatusParam = searchParams.get('proof_status');
|
||||
const proofStatus: ProofStatusFilter = PROOF_STATUS_FILTERS.includes(
|
||||
proofStatusParam as ProofStatusFilter,
|
||||
)
|
||||
? (proofStatusParam as ProofStatusFilter)
|
||||
: 'all';
|
||||
|
||||
const overviewQuery = useTicketOverviewQuery(ticketId);
|
||||
const ticket = overviewQuery.data;
|
||||
const videoId = ticket?.video_id ?? ticket?.video?.id ?? undefined;
|
||||
const detectionsQuery = useTicketClassReviewDetectionsQuery(
|
||||
videoId,
|
||||
defectClass,
|
||||
proofStatus === 'all' ? 'submitted' : proofStatus,
|
||||
);
|
||||
const detectionResult = detectionsQuery.data?.pages[0];
|
||||
const detections = useMemo(
|
||||
() => detectionsQuery.data?.pages.flatMap((page) => page.items) ?? [],
|
||||
[detectionsQuery.data?.pages],
|
||||
);
|
||||
const totalCount = detectionResult?.total ?? 0;
|
||||
const selectedIssueParam = searchParams.get('issue_id') ?? undefined;
|
||||
const reviewMutation = useReviewDetectionRepairProofMutation({
|
||||
ticketId,
|
||||
videoId,
|
||||
defectClass,
|
||||
});
|
||||
|
||||
const selectIssue = useCallback(
|
||||
(issueId: string) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.set('issue_id', issueId);
|
||||
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
const handleProofStatusChange = useCallback(
|
||||
(status: ProofStatusFilter) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
if (status === 'all') {
|
||||
nextParams.delete('proof_status');
|
||||
} else {
|
||||
nextParams.set('proof_status', status);
|
||||
}
|
||||
nextParams.delete('issue_id');
|
||||
const query = nextParams.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, {
|
||||
scroll: false,
|
||||
});
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detections.length) return;
|
||||
|
||||
const hasSelectedIssue = detections.some(
|
||||
(detection) => getIssueId(detection) === selectedIssueParam,
|
||||
);
|
||||
if (hasSelectedIssue) return;
|
||||
|
||||
if (
|
||||
selectedIssueParam &&
|
||||
detectionsQuery.hasNextPage &&
|
||||
!detectionsQuery.isFetchingNextPage
|
||||
) {
|
||||
void detectionsQuery.fetchNextPage();
|
||||
return;
|
||||
}
|
||||
|
||||
const firstReviewable = detections.find(
|
||||
(detection) => getIssueReviewStatus(detection) === 'pending',
|
||||
);
|
||||
selectIssue(getIssueId(firstReviewable ?? detections[0]));
|
||||
}, [detections, detectionsQuery, selectIssue, selectedIssueParam]);
|
||||
|
||||
const activeIndex = Math.max(
|
||||
0,
|
||||
detections.findIndex(
|
||||
(detection) => getIssueId(detection) === selectedIssueParam,
|
||||
),
|
||||
);
|
||||
const activeDetection = detections[activeIndex];
|
||||
const activeIssueId = activeDetection
|
||||
? getIssueId(activeDetection)
|
||||
: undefined;
|
||||
const activeReviewStatus = activeDetection
|
||||
? getIssueReviewStatus(activeDetection)
|
||||
: 'not_submitted';
|
||||
|
||||
const handleSaveReview = useCallback(
|
||||
async (payload: ReviewDetectionRepairProofPayload) => {
|
||||
if (!activeDetection) return;
|
||||
|
||||
await reviewMutation.mutateAsync({
|
||||
detectionId: activeDetection.detection.id,
|
||||
payload,
|
||||
});
|
||||
},
|
||||
[activeDetection, reviewMutation],
|
||||
);
|
||||
|
||||
const handleNext = useCallback(async () => {
|
||||
if (activeIndex < detections.length - 1) {
|
||||
selectIssue(getIssueId(detections[activeIndex + 1]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!detectionsQuery.hasNextPage) return;
|
||||
const nextResult = await detectionsQuery.fetchNextPage();
|
||||
const nextDetections =
|
||||
nextResult.data?.pages.flatMap((page) => page.items) ?? [];
|
||||
const nextDetection = nextDetections[activeIndex + 1];
|
||||
if (nextDetection) selectIssue(getIssueId(nextDetection));
|
||||
}, [activeIndex, detections, detectionsQuery, selectIssue]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (detectionsQuery.hasNextPage && !detectionsQuery.isFetchingNextPage) {
|
||||
void detectionsQuery.fetchNextPage();
|
||||
}
|
||||
}, [detectionsQuery]);
|
||||
|
||||
const backToTicket = () => {
|
||||
const params = new URLSearchParams({ defect_class: defectClass });
|
||||
router.push(`${ROUTES.TICKET_DETAIL(ticketId)}?${params.toString()}`);
|
||||
};
|
||||
|
||||
if (overviewQuery.isLoading || (videoId && detectionsQuery.isLoading)) {
|
||||
return <IssueReviewSkeleton />;
|
||||
}
|
||||
|
||||
if (overviewQuery.isError || !ticket || !videoId) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
|
||||
<AlertTriangle className="size-8 text-destructive" />
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
Unable to open the review workspace.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Ticket or video information is unavailable.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={backToTicket}>
|
||||
Back to Ticket
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (detectionsQuery.isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
|
||||
<AlertTriangle className="size-8 text-destructive" />
|
||||
<div>
|
||||
<p className="font-semibold">Failed to load class detections.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
The review screen uses the detection API for its queue and
|
||||
evidence.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={backToTicket}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void detectionsQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeDetection || !detectionResult) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<IssueReviewHeader
|
||||
ticketLabel={ticket.ticket_name || ticket.id}
|
||||
defectLabel={defectClass}
|
||||
currentIndex={0}
|
||||
total={0}
|
||||
proofStatus={proofStatus}
|
||||
onBack={backToTicket}
|
||||
onPrevious={() => undefined}
|
||||
onNext={() => undefined}
|
||||
onProofStatusChange={handleProofStatusChange}
|
||||
/>
|
||||
<Card>
|
||||
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
|
||||
<ClipboardCheck className="size-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-semibold">No detections found.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
There are no {defectClass} issues available for review.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const defectLabel = activeDetection.detection.display_name;
|
||||
return (
|
||||
<main className="relative z-10 space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
|
||||
<IssueReviewHeader
|
||||
ticketLabel={ticket.ticket_name || ticket.id}
|
||||
defectLabel={defectLabel}
|
||||
currentIndex={activeIndex}
|
||||
total={totalCount}
|
||||
proofStatus={proofStatus}
|
||||
onBack={backToTicket}
|
||||
onPrevious={() =>
|
||||
selectIssue(getIssueId(detections[Math.max(0, activeIndex - 1)]))
|
||||
}
|
||||
onNext={() => void handleNext()}
|
||||
onProofStatusChange={handleProofStatusChange}
|
||||
/>
|
||||
|
||||
<div className="grid items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[280px_minmax(0,1fr)_330px] xl:items-stretch xl:overflow-hidden">
|
||||
<IssueReviewQueue
|
||||
detections={detections}
|
||||
totalCount={totalCount}
|
||||
selectedIssueId={activeIssueId}
|
||||
hasNextPage={Boolean(detectionsQuery.hasNextPage)}
|
||||
isFetchingNextPage={detectionsQuery.isFetchingNextPage}
|
||||
onSelect={selectIssue}
|
||||
onLoadMore={handleLoadMore}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-2">
|
||||
<IssueEvidencePanel
|
||||
detection={activeDetection}
|
||||
issueNumber={activeIndex + 1}
|
||||
videoWidth={detectionResult.video_width}
|
||||
videoHeight={detectionResult.video_height}
|
||||
reviewStatus={activeReviewStatus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-2">
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.REVIEW}
|
||||
fallback={
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<ClipboardCheck className="mx-auto size-7 text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-semibold">View-only access</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Review permission is required to take action.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<IssueReviewAction
|
||||
detection={activeDetection}
|
||||
issueLabel={`${defectLabel} #${activeIndex + 1}`}
|
||||
status={activeReviewStatus}
|
||||
isPending={reviewMutation.isPending}
|
||||
onSave={handleSaveReview}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { DetectionDiscardReason, DiscardDetectionPayload } from '@/types';
|
||||
|
||||
const DISCARD_REASON_OPTIONS: Array<{
|
||||
value: DetectionDiscardReason;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: 'not_a_defect', label: 'Not a defect' },
|
||||
{ value: 'wrong_class', label: 'Wrong classification' },
|
||||
{ value: 'duplicate', label: 'Duplicate detection' },
|
||||
{ value: 'poor_image_quality', label: 'Poor image quality' },
|
||||
{ value: 'incorrect_location', label: 'Incorrect location' },
|
||||
{ value: 'already_repaired', label: 'Already repaired' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
interface DetectionDiscardDialogProps {
|
||||
open: boolean;
|
||||
detectionLabel: string;
|
||||
isPending: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: DiscardDetectionPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
export function DetectionDiscardDialog({
|
||||
open,
|
||||
detectionLabel,
|
||||
isPending,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: DetectionDiscardDialogProps) {
|
||||
const [reason, setReason] = useState<DetectionDiscardReason | ''>('');
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const resetForm = () => {
|
||||
setReason('');
|
||||
setComment('');
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (isPending) return;
|
||||
|
||||
if (!nextOpen) resetForm();
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!reason || isPending) return;
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
reason,
|
||||
comment: comment.trim() || undefined,
|
||||
});
|
||||
resetForm();
|
||||
} catch {
|
||||
// The mutation displays the request error and keeps the dialog open.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent showCloseButton={!isPending}>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Discard detection</DialogTitle>
|
||||
<DialogDescription>
|
||||
Remove {detectionLabel} from this ticket and record why the AI
|
||||
result is incorrect.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="discard-reason">Reason</Label>
|
||||
<Select
|
||||
value={reason}
|
||||
onValueChange={(value) =>
|
||||
setReason(value as DetectionDiscardReason)
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
<SelectTrigger id="discard-reason" className="w-full">
|
||||
<SelectValue placeholder="Select a reason" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DISCARD_REASON_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="discard-comment">Comment</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Optional | {comment.length}/500
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="discard-comment"
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="Add details that can help improve future detections"
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={!reason || isPending}
|
||||
>
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Trash2 />}
|
||||
{isPending ? 'Discarding' : 'Discard detection'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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,314 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, ChevronLeft, ChevronRight, Trash2 } 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 {
|
||||
useDiscardDetectionMutation,
|
||||
useTicketClassDetectionsQuery,
|
||||
} from '../../hooks/useTicketQueries';
|
||||
import { DetectionDiscardDialog } from './DetectionDiscardDialog';
|
||||
|
||||
interface TicketClassDetectionPreviewProps {
|
||||
ticketId: string;
|
||||
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({
|
||||
ticketId,
|
||||
videoId,
|
||||
defectClassName,
|
||||
displayName,
|
||||
totalCount,
|
||||
}: TicketClassDetectionPreviewProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
|
||||
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 discardMutation = useDiscardDetectionMutation(
|
||||
ticketId,
|
||||
videoId ?? undefined,
|
||||
defectClassName,
|
||||
);
|
||||
const detectionResult = detectionsQuery.data;
|
||||
const activeDetection = detectionResult?.items[0];
|
||||
const detectionCount = detectionResult?.total ?? totalCount ?? 0;
|
||||
|
||||
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="w-full overflow-hidden rounded-lg">
|
||||
<AnnotatedDetectionImage
|
||||
detection={activeDetection}
|
||||
videoWidth={detectionResult.video_width}
|
||||
videoHeight={detectionResult.video_height}
|
||||
aspectRatio="16 / 9"
|
||||
enableLightbox
|
||||
/>
|
||||
</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="16 / 9"
|
||||
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 className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setIsDiscardDialogOpen(true)}
|
||||
>
|
||||
<Trash2 />
|
||||
Discard detection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DetectionDiscardDialog
|
||||
open={isDiscardDialogOpen}
|
||||
detectionLabel={activeDetection.detection.display_name}
|
||||
isPending={discardMutation.isPending}
|
||||
onOpenChange={setIsDiscardDialogOpen}
|
||||
onSubmit={async (payload) => {
|
||||
await discardMutation.mutateAsync({
|
||||
detectionId: activeDetection.detection.id,
|
||||
payload,
|
||||
});
|
||||
setIsDiscardDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
'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
|
||||
ticketId={ticketId}
|
||||
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,75 @@
|
||||
'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 { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
|
||||
import { RequestExtensionAction } from './actions/RequestExtensionAction';
|
||||
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
||||
|
||||
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 />}
|
||||
>
|
||||
<RequestExtensionAction ticket={ticket} />
|
||||
</PermissionGuard>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<AssignedContractorAction ticket={ticket} />
|
||||
<ReviewExtensionRequestAction ticket={ticket} />
|
||||
</>
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
if (ticket.assignment_status === 'under_review') {
|
||||
return (
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.REVIEW}
|
||||
fallback={<NoTicketAction />}
|
||||
>
|
||||
<OpenRepairReviewAction ticket={ticket} />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
ticket.assignment_status === 'approved' ||
|
||||
ticket.assignment_status === 'rejected'
|
||||
) {
|
||||
return <ClosedTicketAction ticket={ticket} />;
|
||||
}
|
||||
|
||||
return <NoTicketAction />;
|
||||
}
|
||||
|
||||
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
|
||||
return <div className="space-y-3">{getTicketAction(ticket)}</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,37 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowRight, ClipboardCheck } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { TicketDetail } from '@/types';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
export function OpenRepairReviewAction({ ticket }: { ticket: TicketDetail }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<TicketActionCard icon={ClipboardCheck} title="Review Submitted Repairs">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Review every submitted repair separately using its detection image,
|
||||
location, and repair proof.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
ROUTES.TICKET_CLASS_REVIEW(ticket.id, ticket.defect_class),
|
||||
)
|
||||
}
|
||||
>
|
||||
Open Issue Review
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Clock3 } from 'lucide-react';
|
||||
|
||||
import type { TicketDetail } from '@/types';
|
||||
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
|
||||
|
||||
export function RequestExtensionAction({ ticket }: { ticket: TicketDetail }) {
|
||||
return (
|
||||
<TicketActionCard icon={Clock3} title="Request Extension">
|
||||
<TicketExtensionRequestPanel ticket={ticket} />
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
'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;
|
||||
}
|
||||
|
||||
export function RequestExtensionForm({ ticket }: 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('');
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Send />}
|
||||
{isPending ? 'Submitting...' : 'Submit Request'}
|
||||
</Button>
|
||||
</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,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,244 @@
|
||||
'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 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,
|
||||
}: {
|
||||
ticket: TicketDetail;
|
||||
}) {
|
||||
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} />;
|
||||
}
|
||||
|
||||
return <ExtensionRequestDetails request={latestRequest} />;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
'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[];
|
||||
maxVisibleImages?: number;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_IMAGES = 8;
|
||||
|
||||
export function RepairProofGallery({
|
||||
imageUrls,
|
||||
maxVisibleImages = MAX_VISIBLE_IMAGES,
|
||||
}: 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, maxVisibleImages).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(maxVisibleImages, 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 px-2 text-center text-sm font-semibold text-white transition-colors group-hover:bg-black/65 sm:text-base">
|
||||
+{overflowCount} more
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</LightGallery>
|
||||
);
|
||||
}
|
||||
93
src/app/(modules)/ticket/[ticketId]/page.tsx
Normal file
93
src/app/(modules)/ticket/[ticketId]/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'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 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
|
||||
<div className="xl:shrink-0">
|
||||
{overview ? (
|
||||
<TicketDetailHeader ticket={overview} />
|
||||
) : (
|
||||
<TicketOverviewHeaderSkeleton />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)] xl:items-stretch xl:overflow-hidden">
|
||||
<div className="space-y-5 xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
{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 xl:min-h-0 xl:self-stretch xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
{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,
|
||||
});
|
||||
}
|
||||
371
src/app/(modules)/ticket/hooks/useTicketQueries.ts
Normal file
371
src/app/(modules)/ticket/hooks/useTicketQueries.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { detectionService, ticketService, videoService } from '@/services/api';
|
||||
import type {
|
||||
AssignTicketPayload,
|
||||
CloseTicketPayload,
|
||||
DiscardDetectionPayload,
|
||||
RequestTicketExtensionPayload,
|
||||
ReviewDetectionRepairProofPayload,
|
||||
ReviewTicketExtensionPayload,
|
||||
TicketDetail,
|
||||
TicketListParams,
|
||||
DetectionProofStatus,
|
||||
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 useTicketClassReviewDetectionsQuery(
|
||||
videoId: string | undefined,
|
||||
defectClass: string | undefined,
|
||||
proofStatus?: DetectionProofStatus,
|
||||
) {
|
||||
const pageSize = 20;
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ticketKeys.classReviewDetections(
|
||||
videoId ?? '',
|
||||
defectClass ?? '',
|
||||
proofStatus,
|
||||
),
|
||||
queryFn: ({ pageParam }) =>
|
||||
videoService.getVideoDetections(videoId as string, {
|
||||
class_name: defectClass,
|
||||
proof_status: proofStatus,
|
||||
sort: 'timestamp_asc',
|
||||
skip: pageParam,
|
||||
limit: pageSize,
|
||||
}),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const nextSkip = lastPage.skip + lastPage.items.length;
|
||||
return nextSkip < lastPage.total ? nextSkip : undefined;
|
||||
},
|
||||
enabled: Boolean(videoId && defectClass),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDiscardDetectionMutation(
|
||||
ticketId: string,
|
||||
videoId: string | undefined,
|
||||
defectClass: string,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
detectionId,
|
||||
payload,
|
||||
}: {
|
||||
detectionId: number;
|
||||
payload: DiscardDetectionPayload;
|
||||
}) => detectionService.discardDetection(detectionId, payload),
|
||||
onSuccess: async () => {
|
||||
toast.success('Detection discarded');
|
||||
|
||||
await Promise.all([
|
||||
videoId
|
||||
? queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetectionLists(videoId),
|
||||
})
|
||||
: Promise.resolve(),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.overview(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.defectClasses(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
|
||||
]);
|
||||
},
|
||||
onError: () => toast.error('Failed to discard detection'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useReviewDetectionRepairProofMutation({
|
||||
ticketId,
|
||||
videoId,
|
||||
defectClass,
|
||||
}: {
|
||||
ticketId: string;
|
||||
videoId: string | undefined;
|
||||
defectClass: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
detectionId,
|
||||
payload,
|
||||
}: {
|
||||
detectionId: number;
|
||||
payload: ReviewDetectionRepairProofPayload;
|
||||
}) => detectionService.reviewRepairProof(ticketId, detectionId, payload),
|
||||
onSuccess: async (_repairProof, variables) => {
|
||||
toast.success(
|
||||
variables.payload.action === 'approve'
|
||||
? 'Repair proof approved'
|
||||
: 'Repair proof rejected',
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
videoId
|
||||
? queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetectionLists(videoId),
|
||||
})
|
||||
: Promise.resolve(),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.classDetail(ticketId, defectClass),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.defectClasses(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.overview(ticketId),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
|
||||
]);
|
||||
},
|
||||
onError: () => toast.error('Failed to review repair proof'),
|
||||
});
|
||||
}
|
||||
|
||||
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 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 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>
|
||||
);
|
||||
}
|
||||
38
src/app/(modules)/ticket/queries/ticketKeys.ts
Normal file
38
src/app/(modules)/ticket/queries/ticketKeys.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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,
|
||||
classDetectionLists: (videoId: string) =>
|
||||
[...ticketKeys.details(), 'video', videoId, 'detections'] as const,
|
||||
classDetections: (videoId: string, params: VideoDetectionsParams) =>
|
||||
[...ticketKeys.classDetectionLists(videoId), params] as const,
|
||||
classReviewDetections: (
|
||||
videoId: string,
|
||||
defectClass: string,
|
||||
proofStatus?: string,
|
||||
) =>
|
||||
[
|
||||
...ticketKeys.classDetectionLists(videoId),
|
||||
'class-review',
|
||||
defectClass,
|
||||
proofStatus ?? 'all',
|
||||
] 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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user