Compare commits
124 Commits
af4d7fae97
...
global-ass
| Author | SHA1 | Date | |
|---|---|---|---|
| c13afa7554 | |||
| 8033450321 | |||
| 98de8e948b | |||
| 3697119656 | |||
| e268b55392 | |||
| da5b1009e3 | |||
| 3d04995f97 | |||
| d3069e1f25 | |||
| 47697b4421 | |||
| c8899c191e | |||
| 8ab9240b77 | |||
| f6436862c0 | |||
| 0ff5792670 | |||
| 0349891f52 | |||
| 7ee621b790 | |||
| 44e9dc4ec8 | |||
| d261404903 | |||
| bad9c50dbf | |||
| 6169bbdc28 | |||
| 0a4d9512cb | |||
| faefd34cd4 | |||
| 514b42ea91 | |||
| 9cd7186357 | |||
| 87fa04e1b5 | |||
| 2caeae7c91 | |||
| 44d3be5d27 | |||
| 58098af0fe | |||
| 1cbad4074c | |||
| 5e0d641575 | |||
| e894676d3b | |||
| 31839d1c70 | |||
| e8f2955acf | |||
| 5e66b70611 | |||
| 7ffe3f0708 | |||
| 8e8803396d | |||
| 0601322361 | |||
| 20f611f35a | |||
| 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 | |||
| 4add7a6c83 | |||
| 9695c80e20 |
@@ -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>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PasswordField } from '@/components/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AuthBackground } from '@/components/auth/AuthBackground';
|
||||
import { GuestGuard } from '@/guards';
|
||||
import { useLoginForm } from '@/hooks/useLoginForm';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
@@ -17,12 +18,12 @@ export default function LoginPage() {
|
||||
return (
|
||||
<GuestGuard>
|
||||
<div className="flex min-h-screen w-full">
|
||||
<div className="hidden md:flex w-[40%]" />
|
||||
<div className="flex flex-1 md:w-[60%] items-center justify-center p-6 border-l border-border h-screen">
|
||||
<AuthBackground />
|
||||
<div className="flex flex-1 md:w-1/2 items-center justify-center p-6 border-l border-border h-screen">
|
||||
<section className="w-full max-w-sm space-y-7">
|
||||
<header className="space-y-2 text-center md:text-left">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
VisionRoad
|
||||
RoadMonitor
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -27,13 +27,13 @@ function ResetPasswordContent() {
|
||||
<div className="flex h-screen flex-1 items-center justify-center border-l border-border p-6 md:w-[60%]">
|
||||
<section className="w-full max-w-sm space-y-7">
|
||||
<header className="space-y-2 text-center md:text-left">
|
||||
<p className="text-muted-foreground">VisionRoad</p>
|
||||
<p className="text-muted-foreground">RoadMonitor</p>
|
||||
<h1>{displayInfo.title}</h1>
|
||||
<p className="text-muted-foreground">{displayInfo.description}</p>
|
||||
</header>
|
||||
|
||||
{!hasToken ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-destructive">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-destructive">
|
||||
Password reset token is missing.
|
||||
</div>
|
||||
) : (
|
||||
@@ -69,7 +69,9 @@ function ResetPasswordContent() {
|
||||
className="w-full"
|
||||
disabled={isLoading || !canSubmit}
|
||||
>
|
||||
{isLoading ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : null}
|
||||
{displayInfo.buttonLabel}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
27
src/app/(modules)/_components/module-shell-header.tsx
Normal file
27
src/app/(modules)/_components/module-shell-header.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { BreadcrumbBasic } from '@/components/app-breadcrumb';
|
||||
import { ModeToggle } from '@/components/mode-toogle';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SidebarTrigger, useSidebar } from '@/components/ui/sidebar';
|
||||
|
||||
export function ModuleShellHeader() {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-50 flex h-14 items-center justify-between gap-4 border-b border-border bg-background/95 px-6 backdrop-blur supports-backdrop-filter:bg-background/80">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{isMobile ? (
|
||||
<>
|
||||
<SidebarTrigger className="shrink-0 bg-background/80 text-muted-foreground shadow-xs backdrop-blur-sm" />
|
||||
<Separator orientation="vertical" className="h-4 shrink-0" />
|
||||
</>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<BreadcrumbBasic />
|
||||
</div>
|
||||
</div>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,10 +19,12 @@ export function useClientColumns(): ColumnDef<Client>[] {
|
||||
return useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
accessorKey: 'first_name',
|
||||
header: 'Client',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
<span className="font-medium">
|
||||
{`${row.original.first_name} ${row.original.last_name}`.trim()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { UseFormRegister } from 'react-hook-form';
|
||||
import { useRef, type ComponentProps } from 'react';
|
||||
import {
|
||||
Controller,
|
||||
type Control,
|
||||
type FieldErrors,
|
||||
type UseFormRegister,
|
||||
} from 'react-hook-form';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { FormField, PhoneInput } from '@/components/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -14,7 +21,6 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { ClientFormValues } from '../hooks/useClientForm';
|
||||
|
||||
interface ClientSheetProps {
|
||||
@@ -22,7 +28,11 @@ interface ClientSheetProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
clientId?: number;
|
||||
register: UseFormRegister<ClientFormValues>;
|
||||
control: Control<ClientFormValues>;
|
||||
errors: FieldErrors<ClientFormValues>;
|
||||
isSubmitted: boolean;
|
||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||
canSubmit: boolean;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
@@ -31,12 +41,38 @@ export function ClientSheet({
|
||||
onOpenChange,
|
||||
clientId,
|
||||
register,
|
||||
control,
|
||||
errors,
|
||||
isSubmitted,
|
||||
onSubmit,
|
||||
canSubmit,
|
||||
isSaving,
|
||||
}: ClientSheetProps) {
|
||||
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
|
||||
const firstNameErrorMessage = isSubmitted
|
||||
? errors.first_name?.message
|
||||
: undefined;
|
||||
const lastNameErrorMessage = isSubmitted
|
||||
? errors.last_name?.message
|
||||
: undefined;
|
||||
const emailErrorMessage = isSubmitted ? errors.email?.message : undefined;
|
||||
const landlineErrorMessage = isSubmitted
|
||||
? errors.landline_number?.message
|
||||
: undefined;
|
||||
const addressErrorMessage = isSubmitted ? errors.address?.message : undefined;
|
||||
const contactNameErrorMessage = isSubmitted
|
||||
? errors.contact_name?.message
|
||||
: undefined;
|
||||
const contactPhoneErrorMessage = isSubmitted
|
||||
? errors.contact_phone_number?.message
|
||||
: undefined;
|
||||
const contactEmailErrorMessage = isSubmitted
|
||||
? errors.contact_email?.message
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{clientId ? 'Edit Client' : 'Create Client'}
|
||||
@@ -45,101 +81,169 @@ export function ClientSheet({
|
||||
Manage company and primary contact details.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
||||
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<DialogBody className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client-name">Name</Label>
|
||||
<Input
|
||||
id="client-name"
|
||||
placeholder="Acme Corp"
|
||||
{...register('name', { required: true })}
|
||||
<FormField
|
||||
id="client-first-name"
|
||||
label="First Name"
|
||||
required
|
||||
error={firstNameErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="client-first-name"
|
||||
placeholder="Enter first name"
|
||||
aria-invalid={!!firstNameErrorMessage}
|
||||
{...register('first_name')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="client-last-name"
|
||||
label="Last Name"
|
||||
required
|
||||
error={lastNameErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="client-last-name"
|
||||
placeholder="Enter last name"
|
||||
aria-invalid={!!lastNameErrorMessage}
|
||||
{...register('last_name')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client-email">Email</Label>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
id="client-email"
|
||||
label="Company Email"
|
||||
required
|
||||
error={emailErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="client-email"
|
||||
type="email"
|
||||
placeholder="info@acme.com"
|
||||
{...register('email', { required: true })}
|
||||
required
|
||||
placeholder="Enter company email"
|
||||
aria-invalid={!!emailErrorMessage}
|
||||
{...register('email')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="landline-number">Landline Number</Label>
|
||||
<FormField
|
||||
id="landline-number"
|
||||
label="Landline Number"
|
||||
required
|
||||
error={landlineErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="landline-number"
|
||||
placeholder="+91-22-12345678"
|
||||
{...register('landline_number', { required: true })}
|
||||
required
|
||||
placeholder="Enter landline number"
|
||||
aria-invalid={!!landlineErrorMessage}
|
||||
{...register('landline_number')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address">Address</Label>
|
||||
|
||||
<FormField
|
||||
id="address"
|
||||
label="Address"
|
||||
required
|
||||
error={addressErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="address"
|
||||
placeholder="12 MG Road, Mumbai, MH 400001"
|
||||
{...register('address', { required: true })}
|
||||
required
|
||||
placeholder="Enter address"
|
||||
aria-invalid={!!addressErrorMessage}
|
||||
{...register('address')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gst">GST</Label>
|
||||
<FormField id="gst" label="GST">
|
||||
<Input
|
||||
id="gst"
|
||||
placeholder="27ABCDE1234F1Z5"
|
||||
placeholder="Enter GST number"
|
||||
{...register('gst')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pan">PAN</Label>
|
||||
<Input id="pan" placeholder="ABCDE1234F" {...register('pan')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tan">TAN</Label>
|
||||
<Input id="tan" placeholder="MUMA12345B" {...register('tan')} />
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField id="pan" label="PAN">
|
||||
<Input
|
||||
id="pan"
|
||||
placeholder="Enter PAN number"
|
||||
{...register('pan')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField id="tan" label="TAN">
|
||||
<Input
|
||||
id="tan"
|
||||
placeholder="Enter TAN number"
|
||||
{...register('tan')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-name">Contact Name</Label>
|
||||
<FormField
|
||||
id="contact-name"
|
||||
label="Contact Name"
|
||||
required
|
||||
error={contactNameErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="contact-name"
|
||||
placeholder="Jane Doe"
|
||||
{...register('contact_name', { required: true })}
|
||||
required
|
||||
placeholder="Enter contact name"
|
||||
aria-invalid={!!contactNameErrorMessage}
|
||||
{...register('contact_name')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-phone-number">Contact Phone</Label>
|
||||
<Input
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="contact-phone-number"
|
||||
placeholder="+91-9876543210"
|
||||
{...register('contact_phone_number', { required: true })}
|
||||
label="Contact Phone"
|
||||
required
|
||||
error={contactPhoneErrorMessage}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="contact_phone_number"
|
||||
render={({ field }) => (
|
||||
<PhoneInput
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="Enter contact phone"
|
||||
aria-invalid={!!contactPhoneErrorMessage}
|
||||
portalContainer={dropdownPortalRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-email">Contact Email</Label>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="contact-email"
|
||||
label="Contact Email"
|
||||
required
|
||||
error={contactEmailErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
placeholder="jane.doe@acme.com"
|
||||
{...register('contact_email', { required: true })}
|
||||
required
|
||||
placeholder="Enter contact email"
|
||||
aria-invalid={!!contactEmailErrorMessage}
|
||||
{...register('contact_email')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="px-0">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -148,7 +252,8 @@ export function ClientSheet({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
@@ -156,6 +261,8 @@ export function ClientSheet({
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
<div ref={dropdownPortalRef} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
import { useCallback } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Client } from '@/types';
|
||||
import { useSaveClientMutation } from './useClientMutations';
|
||||
|
||||
export interface ClientFormValues {
|
||||
id?: number;
|
||||
name: string;
|
||||
email: string;
|
||||
landline_number: string;
|
||||
address: string;
|
||||
gst: string;
|
||||
pan: string;
|
||||
tan: string;
|
||||
contact_name: string;
|
||||
contact_phone_number: string;
|
||||
contact_email: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
const requiredText = (message: string) => z.string().trim().min(1, message);
|
||||
const optionalTaxIdSchema = z.string().trim();
|
||||
|
||||
const clientFormSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
first_name: requiredText('First name is required'),
|
||||
last_name: requiredText('Last name is required'),
|
||||
email: requiredText('Email is required').email('Invalid email'),
|
||||
landline_number: requiredText('Landline number is required'),
|
||||
address: requiredText('Address is required'),
|
||||
gst: optionalTaxIdSchema,
|
||||
pan: optionalTaxIdSchema,
|
||||
tan: optionalTaxIdSchema,
|
||||
contact_name: requiredText('Contact name is required'),
|
||||
contact_phone_number: requiredText('Contact phone number is required').refine(
|
||||
(value) => isValidPhoneNumber(value),
|
||||
'Enter a valid phone number',
|
||||
),
|
||||
contact_email: requiredText('Contact email is required').email(
|
||||
'Invalid contact email',
|
||||
),
|
||||
is_active: z.boolean(),
|
||||
});
|
||||
|
||||
export type ClientFormValues = z.infer<typeof clientFormSchema>;
|
||||
|
||||
const defaultValues: ClientFormValues = {
|
||||
name: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
landline_number: '',
|
||||
address: '',
|
||||
@@ -42,9 +56,12 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
||||
handleSubmit: submitForm,
|
||||
reset,
|
||||
control,
|
||||
formState: { isSubmitting, errors },
|
||||
formState: { isSubmitting, errors, isSubmitted },
|
||||
} = useForm<ClientFormValues>({
|
||||
defaultValues,
|
||||
mode: 'onSubmit',
|
||||
reValidateMode: 'onChange',
|
||||
resolver: zodResolver(clientFormSchema),
|
||||
});
|
||||
const saveMutation = useSaveClientMutation({
|
||||
onSaved: () => {
|
||||
@@ -54,23 +71,26 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
||||
});
|
||||
|
||||
const clientId = useWatch({ control, name: 'id' });
|
||||
const firstName = useWatch({ control, name: 'first_name' }) || '';
|
||||
const lastName = useWatch({ control, name: 'last_name' }) || '';
|
||||
const email = useWatch({ control, name: 'email' }) || '';
|
||||
const landlineNumber = useWatch({ control, name: 'landline_number' }) || '';
|
||||
const address = useWatch({ control, name: 'address' }) || '';
|
||||
const contactName = useWatch({ control, name: 'contact_name' }) || '';
|
||||
const contactPhoneNumber =
|
||||
useWatch({ control, name: 'contact_phone_number' }) || '';
|
||||
const contactEmail = useWatch({ control, name: 'contact_email' }) || '';
|
||||
const canSubmit =
|
||||
firstName.trim().length > 0 &&
|
||||
lastName.trim().length > 0 &&
|
||||
email.trim().length > 0 &&
|
||||
landlineNumber.trim().length > 0 &&
|
||||
address.trim().length > 0 &&
|
||||
contactName.trim().length > 0 &&
|
||||
contactPhoneNumber.trim().length > 0 &&
|
||||
contactEmail.trim().length > 0;
|
||||
|
||||
const handleSubmit = submitForm(
|
||||
(values) => saveMutation.mutate(values),
|
||||
(formErrors) => {
|
||||
if (
|
||||
formErrors.name ||
|
||||
formErrors.email ||
|
||||
formErrors.landline_number ||
|
||||
formErrors.address ||
|
||||
formErrors.contact_name ||
|
||||
formErrors.contact_phone_number ||
|
||||
formErrors.contact_email
|
||||
) {
|
||||
toast.error('Complete all required client fields');
|
||||
}
|
||||
},
|
||||
);
|
||||
const handleSubmit = submitForm((values) => saveMutation.mutate(values));
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
reset(defaultValues);
|
||||
@@ -80,7 +100,8 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
||||
(client: Client) => {
|
||||
reset({
|
||||
id: client.id,
|
||||
name: client.name || '',
|
||||
first_name: client.first_name || '',
|
||||
last_name: client.last_name || '',
|
||||
email: client.email || '',
|
||||
landline_number: client.landline_number || '',
|
||||
address: client.address || '',
|
||||
@@ -98,12 +119,15 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
||||
|
||||
return {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
openCreate,
|
||||
openEdit,
|
||||
clientId,
|
||||
errors,
|
||||
isSubmitted,
|
||||
canSubmit,
|
||||
isSaving: isSubmitting || saveMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
|
||||
mutationFn: (values: ClientFormValues) =>
|
||||
clientService.saveClient({
|
||||
id: values.id,
|
||||
name: values.name.trim(),
|
||||
first_name: values.first_name.trim(),
|
||||
last_name: values.last_name.trim(),
|
||||
email: values.email.trim(),
|
||||
landline_number: values.landline_number.trim(),
|
||||
address: values.address.trim(),
|
||||
|
||||
16
src/app/(modules)/clients/layout.tsx
Normal file
16
src/app/(modules)/clients/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionRouteGuard } from '@/guards';
|
||||
|
||||
export default function ClientsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PermissionRouteGuard permission={PERMISSIONS.CLIENT.READ}>
|
||||
{children}
|
||||
</PermissionRouteGuard>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,16 @@ export default function ClientsPage() {
|
||||
});
|
||||
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } =
|
||||
clientForm;
|
||||
const { register, handleSubmit, clientId, isSaving } = clientForm;
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
clientId,
|
||||
errors,
|
||||
isSubmitted,
|
||||
canSubmit,
|
||||
isSaving,
|
||||
} = clientForm;
|
||||
const statusMutation = useClientStatusMutation();
|
||||
const { mutate: updateClientStatus, pendingClientId } = statusMutation;
|
||||
|
||||
@@ -126,7 +135,11 @@ export default function ClientsPage() {
|
||||
onOpenChange={setIsSheetOpen}
|
||||
clientId={clientId}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
isSubmitted={isSubmitted}
|
||||
onSubmit={handleSubmit}
|
||||
canSubmit={canSubmit}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,678 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
AlertCircle,
|
||||
Activity,
|
||||
MapPin as MapPinIcon,
|
||||
Milestone,
|
||||
BarChart3,
|
||||
AlertTriangle,
|
||||
Zap,
|
||||
PencilLine,
|
||||
CheckCircle2,
|
||||
Settings2,
|
||||
Droplets,
|
||||
} from 'lucide-react';
|
||||
import { StatsCard } from '@/components/dashboard/stats-card';
|
||||
import { FilterSelector } from '@/components/dashboard/filter-selector';
|
||||
import { DetectionDonutChart } from '@/components/dashboard/detection-donut-chart';
|
||||
import { ChainageBarChart } from '@/components/dashboard/chainage-bar-chart';
|
||||
import { DashboardMap } from '@/components/dashboard/dashboard-map';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { projectService, projectSummaryService } from '@/services/api';
|
||||
import { Project } from '@/types';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton';
|
||||
import { toast } from 'sonner';
|
||||
import { Reveal, StaggerContainer, StaggerItem } from '@/components/ui/reveal';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
interface ProjectSummary {
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
corridor_name: string | null;
|
||||
state: string | null;
|
||||
};
|
||||
packages: {
|
||||
[key: string]: {
|
||||
package_id: string;
|
||||
region: string | null;
|
||||
chainages: {
|
||||
[key: string]: {
|
||||
chainage_id: string;
|
||||
chainage: string | null;
|
||||
detection_count: number;
|
||||
detections: Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
class: string;
|
||||
confidence: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface DetectionStats {
|
||||
totalDefectedSignboard: number;
|
||||
totalPothole: number;
|
||||
totalRoadCrack: number;
|
||||
totalDamagedRoadMarking: number;
|
||||
totalGoodSignboard: number;
|
||||
totalDrainIssue: number;
|
||||
totalDefectiveCulvert: number;
|
||||
totalRoadDamage: number;
|
||||
chainageData: Array<{
|
||||
name: string;
|
||||
defected_sign_board: number;
|
||||
pothole: number;
|
||||
road_crack: number;
|
||||
damaged_road_marking: number;
|
||||
// good_sign_board: number;
|
||||
drain_issue: number;
|
||||
defective_culvert: number;
|
||||
total: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
function calculateStats(summary: ProjectSummary | null): DetectionStats {
|
||||
if (!summary) {
|
||||
return {
|
||||
totalDefectedSignboard: 0,
|
||||
totalPothole: 0,
|
||||
totalRoadCrack: 0,
|
||||
totalDamagedRoadMarking: 0,
|
||||
totalGoodSignboard: 0,
|
||||
totalDrainIssue: 0,
|
||||
totalDefectiveCulvert: 0,
|
||||
totalRoadDamage: 0,
|
||||
chainageData: [],
|
||||
};
|
||||
}
|
||||
|
||||
let total_defected_sign_board = 0;
|
||||
let total_pothole = 0;
|
||||
let total_road_crack = 0;
|
||||
let total_damaged_road_marking = 0;
|
||||
let total_good_sign_board = 0;
|
||||
let total_drain_issue = 0;
|
||||
let total_defective_culvert = 0;
|
||||
let total_road_damage = 0;
|
||||
const chainageData: DetectionStats['chainageData'] = [];
|
||||
|
||||
for (const pkg of Object.values(summary.packages || {})) {
|
||||
for (const [chnName, chn] of Object.entries(pkg.chainages || {})) {
|
||||
let chnDefectedSignboard = 0;
|
||||
let chnPothole = 0;
|
||||
let chnRoadCrack = 0;
|
||||
let chnDamagedRoadMarking = 0;
|
||||
let chnGoodSignboard = 0;
|
||||
let chnDrainIssue = 0;
|
||||
let chnDefectiveCulvert = 0;
|
||||
|
||||
for (const detection of chn.detections || []) {
|
||||
const type = (detection.type || '').toLowerCase();
|
||||
if (type === 'defected_sign_board') {
|
||||
chnDefectedSignboard++;
|
||||
total_defected_sign_board++;
|
||||
} else if (type === 'pothole') {
|
||||
chnPothole++;
|
||||
total_pothole++;
|
||||
} else if (type === 'road_crack') {
|
||||
chnRoadCrack++;
|
||||
total_road_crack++;
|
||||
} else if (type === 'damaged_road_marking') {
|
||||
chnDamagedRoadMarking++;
|
||||
total_damaged_road_marking++;
|
||||
} else if (type === 'good_sign_board') {
|
||||
chnGoodSignboard++;
|
||||
// total_good_sign_board++;
|
||||
} else if (type === 'drain_issue') {
|
||||
chnDrainIssue++;
|
||||
total_drain_issue++;
|
||||
} else if (type === 'defective_culvert') {
|
||||
chnDefectiveCulvert++;
|
||||
total_defective_culvert++;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
chnDefectedSignboard > 0 ||
|
||||
chnPothole > 0 ||
|
||||
chnRoadCrack > 0 ||
|
||||
chnDamagedRoadMarking > 0 ||
|
||||
chnGoodSignboard > 0 ||
|
||||
chnDrainIssue > 0 ||
|
||||
chnDefectiveCulvert > 0
|
||||
) {
|
||||
const shortName =
|
||||
chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
|
||||
chainageData.push({
|
||||
name: shortName,
|
||||
defected_sign_board: chnDefectedSignboard,
|
||||
pothole: chnPothole,
|
||||
road_crack: chnRoadCrack,
|
||||
damaged_road_marking: chnDamagedRoadMarking,
|
||||
// good_sign_board: chnGoodSignboard,
|
||||
drain_issue: chnDrainIssue,
|
||||
defective_culvert: chnDefectiveCulvert,
|
||||
total:
|
||||
chnDefectedSignboard +
|
||||
chnPothole +
|
||||
chnRoadCrack +
|
||||
chnDamagedRoadMarking +
|
||||
// chnGoodSignboard +
|
||||
chnDrainIssue +
|
||||
chnDefectiveCulvert,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total_road_damage =
|
||||
total_defected_sign_board +
|
||||
total_pothole +
|
||||
total_road_crack +
|
||||
total_damaged_road_marking +
|
||||
total_drain_issue +
|
||||
total_defective_culvert;
|
||||
|
||||
return {
|
||||
totalDefectedSignboard: total_defected_sign_board,
|
||||
totalPothole: total_pothole,
|
||||
totalRoadCrack: total_road_crack,
|
||||
totalDamagedRoadMarking: total_damaged_road_marking,
|
||||
totalGoodSignboard: total_good_sign_board,
|
||||
totalDrainIssue: total_drain_issue,
|
||||
totalDefectiveCulvert: total_defective_culvert,
|
||||
totalRoadDamage: total_road_damage,
|
||||
chainageData,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [projectSummary, setProjectSummary] = useState<ProjectSummary | null>(
|
||||
null,
|
||||
);
|
||||
const [stats, setStats] = useState<DetectionStats>({
|
||||
totalDefectedSignboard: 0,
|
||||
totalPothole: 0,
|
||||
totalRoadCrack: 0,
|
||||
totalDamagedRoadMarking: 0,
|
||||
totalGoodSignboard: 0,
|
||||
totalDrainIssue: 0,
|
||||
totalDefectiveCulvert: 0,
|
||||
totalRoadDamage: 0,
|
||||
chainageData: [],
|
||||
});
|
||||
|
||||
// Load projects on mount
|
||||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const projectsData = await projectService.getProjects();
|
||||
setProjects(projectsData.items);
|
||||
|
||||
if (projectsData.items.length > 0) {
|
||||
setSelectedProjectId(
|
||||
projectsData.items[projectsData.items.length - 1].id,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load projects:', err);
|
||||
toast.error('Failed to load projects', {
|
||||
description: 'Please check if the backend is running.',
|
||||
});
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
loadProjects();
|
||||
}, []);
|
||||
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId);
|
||||
|
||||
// Extract packages from project summary
|
||||
const packages = projectSummary
|
||||
? Object.keys(projectSummary.packages || {}).map((pkgName) => ({
|
||||
id: pkgName,
|
||||
name: pkgName,
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Extract chainages from selected package
|
||||
const [selectedPackageId, setSelectedPackageId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedChainageId, setSelectedChainageId] = useState<string | null>(
|
||||
null,
|
||||
); // Changed 'selectedLocationId' to 'selectedChainageId'
|
||||
|
||||
const chainages =
|
||||
projectSummary && selectedPackageId && selectedPackageId !== 'all'
|
||||
? Object.keys(
|
||||
projectSummary.packages[selectedPackageId]?.chainages || {},
|
||||
).map((chnName) => ({
|
||||
id: chnName,
|
||||
name: chnName,
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Load project summary when project changes
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId) {
|
||||
setProjectSummary(null);
|
||||
return;
|
||||
}
|
||||
setProjectSummary(null);
|
||||
|
||||
const loadProjectSummary = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const summary =
|
||||
await projectSummaryService.getProjectSummary<ProjectSummary>(
|
||||
selectedProjectId,
|
||||
);
|
||||
setProjectSummary(summary);
|
||||
} catch (err) {
|
||||
console.error('Failed to load project summary:', err);
|
||||
toast.error('Failed to load project summary');
|
||||
setProjectSummary(null);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
loadProjectSummary();
|
||||
}, [selectedProjectId]);
|
||||
|
||||
// Reset package and chainage when project changes
|
||||
useEffect(() => {
|
||||
setSelectedPackageId(null);
|
||||
setSelectedChainageId(null);
|
||||
}, [selectedProjectId]);
|
||||
|
||||
// Auto-select last package when project summary is loaded
|
||||
useEffect(() => {
|
||||
if (projectSummary && selectedPackageId === null) {
|
||||
const packageIds = Object.keys(projectSummary.packages || {});
|
||||
if (packageIds.length > 0) {
|
||||
setSelectedPackageId(packageIds[packageIds.length - 1]);
|
||||
}
|
||||
}
|
||||
}, [projectSummary, selectedPackageId]);
|
||||
|
||||
// Reset chainage when package changes
|
||||
useEffect(() => {
|
||||
setSelectedChainageId(null);
|
||||
}, [selectedPackageId]);
|
||||
|
||||
// Auto-select last chainage when package is selected
|
||||
useEffect(() => {
|
||||
if (
|
||||
projectSummary &&
|
||||
selectedPackageId &&
|
||||
selectedPackageId !== 'all' &&
|
||||
selectedChainageId === null
|
||||
) {
|
||||
const chainageIds = Object.keys(
|
||||
projectSummary.packages[selectedPackageId]?.chainages || {},
|
||||
);
|
||||
if (chainageIds.length > 0) {
|
||||
setSelectedChainageId(chainageIds[chainageIds.length - 1]);
|
||||
}
|
||||
}
|
||||
}, [projectSummary, selectedPackageId, selectedChainageId]);
|
||||
|
||||
// Filter stats based on selections
|
||||
useEffect(() => {
|
||||
if (!projectSummary) {
|
||||
setStats({
|
||||
totalDefectedSignboard: 0,
|
||||
totalPothole: 0,
|
||||
totalRoadCrack: 0,
|
||||
totalDamagedRoadMarking: 0,
|
||||
totalGoodSignboard: 0,
|
||||
totalDrainIssue: 0,
|
||||
totalDefectiveCulvert: 0,
|
||||
totalRoadDamage: 0,
|
||||
chainageData: [], // Changed 'locationData' to 'chainageData'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let totalDefectedSignboard = 0;
|
||||
let totalPothole = 0;
|
||||
let totalRoadCrack = 0;
|
||||
let totalDamagedRoadMarking = 0;
|
||||
let totalGoodSignboard = 0;
|
||||
let totalDrainIssue = 0;
|
||||
let totalDefectiveCulvert = 0;
|
||||
const chainageData: DetectionStats['chainageData'] = [];
|
||||
|
||||
const packagesToProcess =
|
||||
selectedPackageId && selectedPackageId !== 'all'
|
||||
? { [selectedPackageId]: projectSummary.packages[selectedPackageId] }
|
||||
: projectSummary.packages || {};
|
||||
|
||||
for (const [pkgName, pkg] of Object.entries(packagesToProcess)) {
|
||||
if (!pkg) continue;
|
||||
|
||||
const chainagesToProcess =
|
||||
selectedChainageId && selectedChainageId !== 'all'
|
||||
? { [selectedChainageId]: pkg.chainages?.[selectedChainageId] }
|
||||
: pkg.chainages || {};
|
||||
|
||||
for (const [chnName, chn] of Object.entries(chainagesToProcess)) {
|
||||
if (!chn) continue;
|
||||
|
||||
let chnDefectedSignboard = 0;
|
||||
let chnPothole = 0;
|
||||
let chnRoadCrack = 0;
|
||||
let chnDamagedRoadMarking = 0;
|
||||
let chnGoodSignboard = 0;
|
||||
let chnDrainIssue = 0;
|
||||
let chnDefectiveCulvert = 0;
|
||||
|
||||
for (const detection of chn.detections || []) {
|
||||
const type = (detection.type || '').toLowerCase();
|
||||
if (type === 'defected_sign_board') {
|
||||
chnDefectedSignboard++;
|
||||
totalDefectedSignboard++;
|
||||
} else if (type === 'pothole') {
|
||||
chnPothole++;
|
||||
totalPothole++;
|
||||
} else if (type === 'road_crack') {
|
||||
chnRoadCrack++;
|
||||
totalRoadCrack++;
|
||||
} else if (type === 'damaged_road_marking') {
|
||||
chnDamagedRoadMarking++;
|
||||
totalDamagedRoadMarking++;
|
||||
} else if (type === 'good_sign_board') {
|
||||
// chnGoodSignboard++;
|
||||
// totalGoodSignboard++;
|
||||
} else if (type === 'drain_issue') {
|
||||
chnDrainIssue++;
|
||||
totalDrainIssue++;
|
||||
} else if (type === 'defective_culvert') {
|
||||
chnDefectiveCulvert++;
|
||||
totalDefectiveCulvert++;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
chnDefectedSignboard > 0 ||
|
||||
chnPothole > 0 ||
|
||||
chnRoadCrack > 0 ||
|
||||
chnDamagedRoadMarking > 0 ||
|
||||
chnGoodSignboard > 0 ||
|
||||
chnDrainIssue > 0 ||
|
||||
chnDefectiveCulvert > 0
|
||||
) {
|
||||
const shortName =
|
||||
chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
|
||||
chainageData.push({
|
||||
name: shortName,
|
||||
defected_sign_board: chnDefectedSignboard,
|
||||
pothole: chnPothole,
|
||||
road_crack: chnRoadCrack,
|
||||
damaged_road_marking: chnDamagedRoadMarking,
|
||||
// good_sign_board: chnGoodSignboard,
|
||||
drain_issue: chnDrainIssue,
|
||||
defective_culvert: chnDefectiveCulvert,
|
||||
total:
|
||||
chnDefectedSignboard +
|
||||
chnPothole +
|
||||
chnRoadCrack +
|
||||
chnDamagedRoadMarking +
|
||||
// chnGoodSignboard +
|
||||
chnDrainIssue +
|
||||
chnDefectiveCulvert,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setStats({
|
||||
totalDefectedSignboard,
|
||||
totalPothole,
|
||||
totalRoadCrack,
|
||||
totalDamagedRoadMarking,
|
||||
totalGoodSignboard,
|
||||
totalDrainIssue,
|
||||
totalDefectiveCulvert,
|
||||
totalRoadDamage:
|
||||
totalDefectedSignboard +
|
||||
totalPothole +
|
||||
totalRoadCrack +
|
||||
totalDamagedRoadMarking +
|
||||
totalDrainIssue +
|
||||
totalDefectiveCulvert,
|
||||
chainageData,
|
||||
});
|
||||
}, [projectSummary, selectedPackageId, selectedChainageId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main Content */}
|
||||
<main>
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
description="A Comprehensive Overview Of Your Road Infrastructure Analysis"
|
||||
icon={BarChart3}
|
||||
/>
|
||||
|
||||
<Reveal direction="down" delay={0.2}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="gap-2 font-semibold">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
<span>Filters</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[320px] p-0 overflow-hidden"
|
||||
align="end"
|
||||
>
|
||||
<div className="p-4 border-b bg-muted/30">
|
||||
<h3 className="font-bold text-sm flex items-center gap-2 text-foreground">
|
||||
<Milestone className="h-4 w-4 text-primary" />
|
||||
Filter Analysis
|
||||
</h3>
|
||||
<p className="text-xs mt-1 text-muted-foreground">
|
||||
Refine your view by project, package, or segment
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<FilterSelector
|
||||
projects={projects}
|
||||
selectedProjectId={selectedProjectId}
|
||||
selectedPackageId={selectedPackageId}
|
||||
selectedChainageId={selectedChainageId} // Changed 'selectedLocationId' to 'selectedChainageId'
|
||||
onProjectChange={setSelectedProjectId}
|
||||
onPackageChange={setSelectedPackageId}
|
||||
onChainageChange={setSelectedChainageId} // Changed 'onLocationChange' to 'onChainageChange'
|
||||
packages={packages}
|
||||
chainages={chainages} // Changed 'locations' to 'chainages'
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
{isLoading && !projectSummary ? (
|
||||
<DashboardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{/* Stats Cards - Top Row */}
|
||||
<StaggerContainer className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
<StaggerItem>
|
||||
<StatsCard
|
||||
title="Total Road Damage"
|
||||
subtitle="Combined damage detections"
|
||||
value={stats.totalRoadDamage}
|
||||
icon={Activity}
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<StatsCard
|
||||
title="Potholes"
|
||||
subtitle="Surface depressions"
|
||||
value={stats.totalPothole}
|
||||
icon={AlertCircle}
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<StatsCard
|
||||
title="Defected Signboards"
|
||||
subtitle="Damaged traffic signs"
|
||||
value={stats.totalDefectedSignboard}
|
||||
icon={AlertTriangle}
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<StatsCard
|
||||
title="Road Cracks"
|
||||
subtitle="Surface fissures"
|
||||
value={stats.totalRoadCrack}
|
||||
icon={Zap}
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<StatsCard
|
||||
title="Damaged Markings"
|
||||
subtitle="Worn road lines"
|
||||
value={stats.totalDamagedRoadMarking}
|
||||
icon={PencilLine}
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<StatsCard
|
||||
title="Drain Issues"
|
||||
subtitle="Clogged or broken drainage"
|
||||
value={stats.totalDrainIssue}
|
||||
icon={Droplets}
|
||||
/>
|
||||
</StaggerItem>
|
||||
{/* <StaggerItem>
|
||||
<StatsCard
|
||||
title="Good Signboards"
|
||||
subtitle="Informational markers"
|
||||
value={stats.totalGoodSignboard}
|
||||
icon={CheckCircle2}
|
||||
/>
|
||||
</StaggerItem> */}
|
||||
</StaggerContainer>
|
||||
|
||||
{/* Charts Row - Side by Side */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||
<Reveal delay={0.2} direction="left" className="flex flex-col">
|
||||
<Card className="flex-1 h-full">
|
||||
<CardHeader className="items-center pb-0">
|
||||
<CardTitle>Detection Distribution</CardTitle>
|
||||
<CardDescription>
|
||||
Breakdown of all detected road conditions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 pb-0">
|
||||
<DetectionDonutChart
|
||||
defectedSignboard={stats.totalDefectedSignboard}
|
||||
pothole={stats.totalPothole}
|
||||
roadCrack={stats.totalRoadCrack}
|
||||
damagedRoadMarking={stats.totalDamagedRoadMarking}
|
||||
// goodSignboard={stats.totalGoodSignboard}
|
||||
drainIssue={stats.totalDrainIssue}
|
||||
defectiveCulvert={stats.totalDefectiveCulvert}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-2 text-sm">
|
||||
<div className="leading-none text-muted-foreground">
|
||||
Total detections:{' '}
|
||||
{stats.totalRoadDamage +
|
||||
// stats.totalGoodSignboard +
|
||||
stats.totalDrainIssue +
|
||||
stats.totalDefectiveCulvert}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.3} direction="right" className="flex flex-col">
|
||||
<Card className="flex-1 h-full">
|
||||
<CardHeader className="items-center pb-0">
|
||||
<CardTitle className="text-base font-bold">
|
||||
Severity by Segment
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Detections grouped by road segment
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px]">
|
||||
<ChainageBarChart
|
||||
data={stats.chainageData || []}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-2 text-sm">
|
||||
<div className="leading-none text-muted-foreground">
|
||||
Analysis based on latest processed sequence
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
{/* Map - Full Width Below Charts */}
|
||||
<Reveal delay={0.4} direction="up">
|
||||
<div>
|
||||
<DashboardMap
|
||||
selectedProjectId={selectedProjectId}
|
||||
selectedPackageId={selectedPackageId}
|
||||
selectedChainageId={selectedChainageId} // Changed 'selectedLocationId' to 'selectedChainageId'
|
||||
projectSummary={projectSummary}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
TicketExtensionRequest,
|
||||
TicketExtensionRequestStatus,
|
||||
} from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
const statusConfig: Record<
|
||||
TicketExtensionRequestStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
pending: {
|
||||
label: 'Pending',
|
||||
className:
|
||||
'border-amber-200 bg-amber-100 text-amber-800 dark:border-amber-900 dark:bg-amber-950/50 dark:text-amber-300',
|
||||
},
|
||||
approved: {
|
||||
label: 'Approved',
|
||||
className:
|
||||
'border-emerald-200 bg-emerald-100 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/50 dark:text-emerald-300',
|
||||
},
|
||||
rejected: {
|
||||
label: 'Rejected',
|
||||
className:
|
||||
'border-red-200 bg-red-100 text-red-800 dark:border-red-900 dark:bg-red-950/50 dark:text-red-300',
|
||||
},
|
||||
cancelled: {
|
||||
label: 'Cancelled',
|
||||
className: 'border-border bg-muted text-muted-foreground',
|
||||
},
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: TicketExtensionRequestStatus }) {
|
||||
const config = statusConfig[status];
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={cn('border', config.className)}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonCell({
|
||||
name,
|
||||
role,
|
||||
}: {
|
||||
name: string | null | undefined;
|
||||
role: string | null | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{name || '-'}</p>
|
||||
{role ? (
|
||||
<p className="truncate text-xs text-muted-foreground">{role}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useExtensionRequestColumns(): ColumnDef<TicketExtensionRequest>[] {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'ticket_name',
|
||||
header: 'Ticket / Assignment',
|
||||
size: 170,
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.ticket_name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Assignment #{row.original.assignment_id}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'requested_by',
|
||||
header: 'Requested By',
|
||||
size: 170,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => (
|
||||
<PersonCell
|
||||
name={row.original.requested_by?.name}
|
||||
role={row.original.requested_by?.role.name}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
size: 125,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'current_due_at',
|
||||
header: 'Current Due Date',
|
||||
size: 175,
|
||||
cell: ({ row }) => formatDate(row.original.current_due_at),
|
||||
},
|
||||
{
|
||||
accessorKey: 'requested_due_at',
|
||||
header: 'Requested Due Date',
|
||||
size: 175,
|
||||
cell: ({ row }) => formatDate(row.original.requested_due_at),
|
||||
},
|
||||
{
|
||||
accessorKey: 'reason',
|
||||
header: 'Request Reason',
|
||||
size: 260,
|
||||
},
|
||||
{
|
||||
id: 'resolved_by',
|
||||
header: 'Resolved By',
|
||||
size: 170,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => (
|
||||
<PersonCell
|
||||
name={row.original.resolved_by?.name}
|
||||
role={row.original.resolved_by?.role.name}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'decision_note',
|
||||
header: 'Decision Note',
|
||||
size: 220,
|
||||
cell: ({ row }) => row.original.decision_note || '-',
|
||||
},
|
||||
{
|
||||
id: 'previous_requests',
|
||||
header: 'Previous Requests',
|
||||
size: 150,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="secondary">
|
||||
{row.original.history_meta.previous_request_count}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'resolved_at',
|
||||
header: 'Resolved On',
|
||||
size: 175,
|
||||
cell: ({ row }) => formatDate(row.original.resolved_at),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { ArrowRight, Check, Loader2, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
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 { Textarea } from '@/components/ui/textarea';
|
||||
import type { TicketExtensionRequest } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import {
|
||||
MAX_EXTENSION_DECISION_NOTE_LENGTH,
|
||||
type ExtensionRequestDecisionAction,
|
||||
useExtensionRequestDecision,
|
||||
} from '../hooks/useExtensionRequestDecision';
|
||||
|
||||
interface ExtensionRequestDecisionDialogProps {
|
||||
request: TicketExtensionRequest | null;
|
||||
action: ExtensionRequestDecisionAction | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ExtensionRequestDecisionDialog({
|
||||
request,
|
||||
action,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ExtensionRequestDecisionDialogProps) {
|
||||
const handleSuccess = useCallback(() => onOpenChange(false), [onOpenChange]);
|
||||
const {
|
||||
note,
|
||||
setNote,
|
||||
noteError,
|
||||
selectedAction,
|
||||
isPending,
|
||||
isRejectConfirmationOpen,
|
||||
setIsRejectConfirmationOpen,
|
||||
submitDecision,
|
||||
requestRejection,
|
||||
resetDecision,
|
||||
} = useExtensionRequestDecision({
|
||||
ticketId: request?.ticket_id ?? '',
|
||||
assignmentId: request?.assignment_id ?? 0,
|
||||
onSuccess: handleSuccess,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
resetDecision();
|
||||
}, [action, open, request?.id, resetDecision]);
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (isPending) return;
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
if (!request || !action) return null;
|
||||
|
||||
const isApproval = action === 'approve';
|
||||
const noteErrorId = `extension-manager-note-error-${request.id}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl" showCloseButton={!isPending}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isApproval
|
||||
? 'Approve extension request'
|
||||
: 'Reject extension request'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review {request.ticket_name}, Assignment #{request.assignment_id}{' '}
|
||||
before confirming this decision.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="space-y-4">
|
||||
<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-xs text-muted-foreground">
|
||||
Current Due Date
|
||||
</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{formatDate(request.current_due_at)}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="mt-5 size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Requested Due Date
|
||||
</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{formatDate(request.requested_due_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">Requested By</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{request.requested_by?.name ?? 'Assigned worker'}
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-muted-foreground">Reason</p>
|
||||
<p className="mt-1 leading-relaxed">{request.reason}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="extension-manager-note">
|
||||
Add Manager Comments
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isApproval
|
||||
? 'Optional for approval.'
|
||||
: 'Required for rejection.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
id="extension-manager-note"
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
placeholder="Add a note explaining your decision..."
|
||||
disabled={isPending}
|
||||
aria-invalid={Boolean(noteError)}
|
||||
aria-describedby={noteError ? noteErrorId : undefined}
|
||||
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_EXTENSION_DECISION_NOTE_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
{noteError ? (
|
||||
<p id={noteErrorId} className="text-xs text-destructive">
|
||||
{noteError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{isApproval ? (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => void submitDecision('approve')}
|
||||
>
|
||||
{selectedAction === 'approve' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Check />
|
||||
)}
|
||||
Approve Extension
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={requestRejection}
|
||||
>
|
||||
<X />
|
||||
Reject Request
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={isRejectConfirmationOpen}
|
||||
onOpenChange={setIsRejectConfirmationOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reject extension request?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to reject the extension request for{' '}
|
||||
{request.ticket_name}, Assignment #{request.assignment_id}? This
|
||||
decision cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>No</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => void submitDecision('reject')}
|
||||
>
|
||||
{selectedAction === 'reject' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : null}
|
||||
Yes, Reject
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { FilterX } from 'lucide-react';
|
||||
|
||||
import { TableSearchInput } from '@/components/data-table/TableSearchInput';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
import type { ExtensionRequestStatusFilter } from '../hooks/useExtensionRequestFilters';
|
||||
|
||||
interface ExtensionRequestFiltersProps {
|
||||
ticketName: string;
|
||||
statusFilter: ExtensionRequestStatusFilter;
|
||||
onTicketNameChange: (value: string) => void;
|
||||
onStatusChange: (value: ExtensionRequestStatusFilter) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function ExtensionRequestFilters({
|
||||
ticketName,
|
||||
statusFilter,
|
||||
onTicketNameChange,
|
||||
onStatusChange,
|
||||
onClear,
|
||||
}: ExtensionRequestFiltersProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<TableSearchInput
|
||||
value={ticketName}
|
||||
onChange={onTicketNameChange}
|
||||
placeholder="Search by ticket name"
|
||||
className="w-full sm:max-w-sm"
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(value) =>
|
||||
onStatusChange(value as ExtensionRequestStatusFilter)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!ticketName && statusFilter === 'pending'}
|
||||
onClick={onClear}
|
||||
>
|
||||
<FilterX />
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { DataTable, type DataTableAction } from '@/components/data-table';
|
||||
import type { TicketExtensionRequest } from '@/types';
|
||||
|
||||
interface ExtensionRequestTableProps {
|
||||
columns: ColumnDef<TicketExtensionRequest>[];
|
||||
requests: TicketExtensionRequest[];
|
||||
isLoading: boolean;
|
||||
toolbar: ReactNode;
|
||||
skip: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
onPageChange: (skip: number) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
onView: (request: TicketExtensionRequest) => void;
|
||||
actions: DataTableAction<TicketExtensionRequest>[];
|
||||
}
|
||||
|
||||
export function ExtensionRequestTable({
|
||||
columns,
|
||||
requests,
|
||||
isLoading,
|
||||
toolbar,
|
||||
skip,
|
||||
limit,
|
||||
total,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
onView,
|
||||
actions,
|
||||
}: ExtensionRequestTableProps) {
|
||||
return (
|
||||
<DataTable
|
||||
title="Extension Requests"
|
||||
columns={columns}
|
||||
data={requests}
|
||||
isLoading={isLoading}
|
||||
toolbar={toolbar}
|
||||
emptyTitle="No extension requests found."
|
||||
emptyDescription="Try changing the ticket or status filters."
|
||||
onRowClick={onView}
|
||||
actions={actions}
|
||||
actionsSticky
|
||||
minColumnSize={130}
|
||||
pagination={{
|
||||
skip,
|
||||
limit,
|
||||
totalItems: total,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { ticketService } from '@/services/api';
|
||||
import type { ReviewTicketExtensionPayload } from '@/types';
|
||||
|
||||
import { ticketKeys } from '../../ticket/queries/ticketKeys';
|
||||
import { extensionRequestKeys } from '../queries/extensionRequestKeys';
|
||||
|
||||
export const MAX_EXTENSION_DECISION_NOTE_LENGTH = 300;
|
||||
|
||||
export type ExtensionRequestDecisionAction = 'approve' | 'reject';
|
||||
|
||||
interface UseExtensionRequestDecisionOptions {
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function useExtensionRequestDecision({
|
||||
ticketId,
|
||||
assignmentId,
|
||||
onSuccess,
|
||||
}: UseExtensionRequestDecisionOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const [note, setNoteValue] = useState('');
|
||||
const [noteError, setNoteError] = useState<string | null>(null);
|
||||
const [selectedAction, setSelectedAction] =
|
||||
useState<ExtensionRequestDecisionAction | null>(null);
|
||||
const [isRejectConfirmationOpen, setIsRejectConfirmationOpen] =
|
||||
useState(false);
|
||||
|
||||
const reviewMutation = 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: extensionRequestKeys.lists(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.assignments(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.timeline(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: () => toast.error('Failed to review extension request'),
|
||||
});
|
||||
|
||||
const setNote = useCallback((value: string) => {
|
||||
const nextNote = value.slice(0, MAX_EXTENSION_DECISION_NOTE_LENGTH);
|
||||
setNoteValue(nextNote);
|
||||
if (nextNote.trim()) setNoteError(null);
|
||||
}, []);
|
||||
|
||||
const submitDecision = useCallback(
|
||||
async (action: ExtensionRequestDecisionAction) => {
|
||||
const trimmedNote = note.trim();
|
||||
|
||||
if (action === 'reject' && !trimmedNote) {
|
||||
setNoteError('Manager comments are required to reject this request.');
|
||||
setIsRejectConfirmationOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedAction(action);
|
||||
try {
|
||||
await reviewMutation.mutateAsync({
|
||||
action,
|
||||
...(trimmedNote ? { note: trimmedNote } : {}),
|
||||
});
|
||||
} catch {
|
||||
// The mutation displays the error toast and leaves the form open.
|
||||
} finally {
|
||||
setSelectedAction(null);
|
||||
}
|
||||
},
|
||||
[note, reviewMutation],
|
||||
);
|
||||
|
||||
const requestRejection = useCallback(() => {
|
||||
if (!note.trim()) {
|
||||
setNoteError('Manager comments are required to reject this request.');
|
||||
return;
|
||||
}
|
||||
|
||||
setNoteError(null);
|
||||
setIsRejectConfirmationOpen(true);
|
||||
}, [note]);
|
||||
|
||||
const resetDecision = useCallback(() => {
|
||||
setNoteValue('');
|
||||
setNoteError(null);
|
||||
setSelectedAction(null);
|
||||
setIsRejectConfirmationOpen(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
note,
|
||||
setNote,
|
||||
noteError,
|
||||
selectedAction,
|
||||
isPending: reviewMutation.isPending,
|
||||
isRejectConfirmationOpen,
|
||||
setIsRejectConfirmationOpen,
|
||||
submitDecision,
|
||||
requestRejection,
|
||||
resetDecision,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import type { TicketExtensionRequestStatus } from '@/types';
|
||||
|
||||
export type ExtensionRequestStatusFilter = 'all' | TicketExtensionRequestStatus;
|
||||
|
||||
export function useExtensionRequestFilters() {
|
||||
const [skip, setSkip] = useState(0);
|
||||
const [limit, setLimitValue] = useState(10);
|
||||
const [ticketName, setTicketNameValue] = useState('');
|
||||
const [statusFilter, setStatusFilterValue] =
|
||||
useState<ExtensionRequestStatusFilter>('pending');
|
||||
const debouncedTicketName = useDebounce(ticketName.trim(), 400);
|
||||
|
||||
const setLimit = useCallback((value: number) => {
|
||||
setLimitValue(value);
|
||||
setSkip(0);
|
||||
}, []);
|
||||
|
||||
const setTicketName = useCallback((value: string) => {
|
||||
setTicketNameValue(value);
|
||||
setSkip(0);
|
||||
}, []);
|
||||
|
||||
const setStatusFilter = useCallback((value: ExtensionRequestStatusFilter) => {
|
||||
setStatusFilterValue(value);
|
||||
setSkip(0);
|
||||
}, []);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setTicketNameValue('');
|
||||
setStatusFilterValue('pending');
|
||||
setSkip(0);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
skip,
|
||||
setSkip,
|
||||
limit,
|
||||
setLimit,
|
||||
ticketName,
|
||||
debouncedTicketName,
|
||||
setTicketName,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
clearFilters,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { ticketService } from '@/services/api';
|
||||
import type { TicketExtensionRequestsParams } from '@/types';
|
||||
|
||||
import type { ExtensionRequestStatusFilter } from './useExtensionRequestFilters';
|
||||
import { extensionRequestKeys } from '../queries/extensionRequestKeys';
|
||||
|
||||
interface UseExtensionRequestsQueryParams {
|
||||
skip: number;
|
||||
limit: number;
|
||||
ticketName: string;
|
||||
statusFilter: ExtensionRequestStatusFilter;
|
||||
}
|
||||
|
||||
function buildExtensionRequestParams({
|
||||
skip,
|
||||
limit,
|
||||
ticketName,
|
||||
statusFilter,
|
||||
}: UseExtensionRequestsQueryParams): TicketExtensionRequestsParams {
|
||||
return {
|
||||
skip,
|
||||
limit,
|
||||
ticket_name: ticketName || undefined,
|
||||
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||
};
|
||||
}
|
||||
|
||||
export function useExtensionRequestsQuery(
|
||||
params: UseExtensionRequestsQueryParams,
|
||||
) {
|
||||
const { skip, limit, ticketName, statusFilter } = params;
|
||||
const queryParams = useMemo(
|
||||
() =>
|
||||
buildExtensionRequestParams({ skip, limit, ticketName, statusFilter }),
|
||||
[limit, skip, statusFilter, ticketName],
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: extensionRequestKeys.list(queryParams),
|
||||
queryFn: () => ticketService.getTicketExtensionRequests(queryParams),
|
||||
});
|
||||
}
|
||||
16
src/app/(modules)/extension-requests/layout.tsx
Normal file
16
src/app/(modules)/extension-requests/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionRouteGuard } from '@/guards';
|
||||
|
||||
export default function ExtensionRequestsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PermissionRouteGuard permission={PERMISSIONS.TICKET.ASSIGN}>
|
||||
{children}
|
||||
</PermissionRouteGuard>
|
||||
);
|
||||
}
|
||||
153
src/app/(modules)/extension-requests/page.tsx
Normal file
153
src/app/(modules)/extension-requests/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { AlertCircle, CalendarClock, Check, RotateCcw, X } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { DataTableAction } from '@/components/data-table';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import type { TicketExtensionRequest } from '@/types';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
import { useExtensionRequestColumns } from './components/ExtensionRequestColumns';
|
||||
import { ExtensionRequestDecisionDialog } from './components/ExtensionRequestDecisionDialog';
|
||||
import { ExtensionRequestFilters } from './components/ExtensionRequestFilters';
|
||||
import { ExtensionRequestTable } from './components/ExtensionRequestTable';
|
||||
import type { ExtensionRequestDecisionAction } from './hooks/useExtensionRequestDecision';
|
||||
import { useExtensionRequestFilters } from './hooks/useExtensionRequestFilters';
|
||||
import { useExtensionRequestsQuery } from './hooks/useExtensionRequestQueries';
|
||||
|
||||
export default function ExtensionRequestsPage() {
|
||||
const router = useRouter();
|
||||
const [decision, setDecision] = useState<{
|
||||
request: TicketExtensionRequest;
|
||||
action: ExtensionRequestDecisionAction;
|
||||
} | null>(null);
|
||||
const {
|
||||
skip,
|
||||
setSkip,
|
||||
limit,
|
||||
setLimit,
|
||||
ticketName,
|
||||
debouncedTicketName,
|
||||
setTicketName,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
clearFilters,
|
||||
} = useExtensionRequestFilters();
|
||||
const extensionRequestsQuery = useExtensionRequestsQuery({
|
||||
skip,
|
||||
limit,
|
||||
ticketName: debouncedTicketName,
|
||||
statusFilter,
|
||||
});
|
||||
const columns = useExtensionRequestColumns();
|
||||
const requests = extensionRequestsQuery.data?.items ?? [];
|
||||
const total = extensionRequestsQuery.data?.total ?? 0;
|
||||
|
||||
const actions = useMemo<DataTableAction<TicketExtensionRequest>[]>(
|
||||
() => [
|
||||
{
|
||||
label: 'Approve extension',
|
||||
icon: <Check className="text-emerald-600" />,
|
||||
permission: PERMISSIONS.TICKET.ASSIGN,
|
||||
hidden: (request) => request.status !== 'pending',
|
||||
className:
|
||||
'border-emerald-200 hover:bg-emerald-50 dark:border-emerald-900 dark:hover:bg-emerald-950/50',
|
||||
onClick: (request) => setDecision({ request, action: 'approve' }),
|
||||
},
|
||||
{
|
||||
label: 'Reject extension',
|
||||
icon: <X className="text-destructive" />,
|
||||
permission: PERMISSIONS.TICKET.ASSIGN,
|
||||
hidden: (request) => request.status !== 'pending',
|
||||
className:
|
||||
'border-destructive/30 hover:bg-destructive/5 hover:text-destructive',
|
||||
onClick: (request) => setDecision({ request, action: 'reject' }),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const openTicket = useCallback(
|
||||
(request: TicketExtensionRequest) => {
|
||||
router.push(ROUTES.TICKET_DETAIL(request.ticket_id));
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const filterToolbar = useMemo(
|
||||
() => (
|
||||
<ExtensionRequestFilters
|
||||
ticketName={ticketName}
|
||||
statusFilter={statusFilter}
|
||||
onTicketNameChange={setTicketName}
|
||||
onStatusChange={setStatusFilter}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
),
|
||||
[clearFilters, setTicketName, setStatusFilter, statusFilter, ticketName],
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="relative z-10 space-y-5">
|
||||
<PageHeader
|
||||
title="Extension Requests"
|
||||
description="Track deadline extension requests across all ticket assignments."
|
||||
icon={CalendarClock}
|
||||
/>
|
||||
|
||||
{extensionRequestsQuery.isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex min-h-48 flex-col items-center justify-center rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 text-center"
|
||||
>
|
||||
<AlertCircle className="mb-2 size-6 text-destructive" />
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
Failed to load extension requests.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
disabled={extensionRequestsQuery.isFetching}
|
||||
onClick={() => extensionRequestsQuery.refetch()}
|
||||
>
|
||||
<RotateCcw
|
||||
className={
|
||||
extensionRequestsQuery.isFetching ? 'animate-spin' : undefined
|
||||
}
|
||||
/>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ExtensionRequestTable
|
||||
columns={columns}
|
||||
requests={requests}
|
||||
isLoading={extensionRequestsQuery.isLoading}
|
||||
toolbar={filterToolbar}
|
||||
skip={skip}
|
||||
limit={limit}
|
||||
total={total}
|
||||
onPageChange={setSkip}
|
||||
onLimitChange={setLimit}
|
||||
onView={openTicket}
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ExtensionRequestDecisionDialog
|
||||
request={decision?.request ?? null}
|
||||
action={decision?.action ?? null}
|
||||
open={Boolean(decision)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDecision(null);
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { TicketExtensionRequestsParams } from '@/types';
|
||||
|
||||
export const extensionRequestKeys = {
|
||||
all: ['extension-requests'] as const,
|
||||
lists: () => [...extensionRequestKeys.all, 'list'] as const,
|
||||
list: (params: TicketExtensionRequestsParams) =>
|
||||
[...extensionRequestKeys.lists(), params] as const,
|
||||
};
|
||||
@@ -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,
|
||||
@@ -13,18 +11,13 @@ const ModulesLayout = ({
|
||||
}>) => {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<SidebarProvider>
|
||||
<SidebarProvider defaultOpen={false}>
|
||||
<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 h-screen min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="scroll-stable min-w-0 flex-1 overflow-auto">
|
||||
<div className="mx-auto flex min-h-full w-full min-w-0 max-w-380 flex-col">
|
||||
<ModuleShellHeader />
|
||||
<div className="min-w-0 flex-1 p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||
import { Globe, Loader2, Package as PackageIcon } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { FormField } from '@/components/form';
|
||||
import { ProjectSelect } from '@/components/lookups/ProjectSelect';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -15,14 +18,6 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { Project } from '@/types';
|
||||
|
||||
import type { PackageFormValues } from '../hooks/usePackageForm';
|
||||
|
||||
@@ -30,8 +25,6 @@ interface PackageDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
packageId?: string;
|
||||
projects: Project[];
|
||||
isProjectsLoading: boolean;
|
||||
projectId: string;
|
||||
onProjectChange: (projectId: string) => void;
|
||||
register: UseFormRegister<PackageFormValues>;
|
||||
@@ -46,8 +39,6 @@ export function PackageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
packageId,
|
||||
projects,
|
||||
isProjectsLoading,
|
||||
projectId,
|
||||
onProjectChange,
|
||||
register,
|
||||
@@ -57,6 +48,7 @@ export function PackageDialog({
|
||||
canSubmit,
|
||||
isSaving,
|
||||
}: PackageDialogProps) {
|
||||
const projectSelectPortalRef = useRef<HTMLDivElement | null>(null);
|
||||
const projectErrorMessage = isSubmitted
|
||||
? errors.project_id?.message
|
||||
: undefined;
|
||||
@@ -71,45 +63,41 @@ export function PackageDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-2xl"
|
||||
className="sm:max-w-2xl"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
|
||||
<PackageIcon className="size-5" />
|
||||
</div>
|
||||
{packageId ? 'Edit Package Details' : 'Create New Package'}
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{packageId ? 'Edit Package' : 'Create Package'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{packageId
|
||||
? 'Update the technical specifications for your road infrastructure package.'
|
||||
: 'Select a project and provide the essential data to establish a new package.'}
|
||||
Manage package details and optional chainage range.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<DialogBody className="space-y-6">
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Basic details</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Project binding, package name, and region.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!packageId ? (
|
||||
<FormField label="Project" required error={projectErrorMessage}>
|
||||
<Select value={projectId} onValueChange={onProjectChange}>
|
||||
<SelectTrigger aria-invalid={!!projectErrorMessage}>
|
||||
{isProjectsLoading ? (
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</span>
|
||||
) : (
|
||||
<SelectValue placeholder="Choose a project" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ProjectSelect
|
||||
value={projectId}
|
||||
onValueChange={onProjectChange}
|
||||
enabled={open}
|
||||
disabled={isSaving}
|
||||
portalContainer={projectSelectPortalRef}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
{...register('project_id')}
|
||||
@@ -119,7 +107,7 @@ export function PackageDialog({
|
||||
</FormField>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
id="package-name"
|
||||
label="Package Name"
|
||||
@@ -128,38 +116,42 @@ export function PackageDialog({
|
||||
>
|
||||
<Input
|
||||
id="package-name"
|
||||
placeholder="e.g. Package 01"
|
||||
placeholder="Enter package name"
|
||||
aria-invalid={!!nameErrorMessage}
|
||||
{...register('name')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="package-region"
|
||||
label={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Globe className="size-3.5 opacity-60" />
|
||||
Region
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<FormField id="package-region" label="Region">
|
||||
<Input
|
||||
id="package-region"
|
||||
placeholder="e.g. North Zone"
|
||||
placeholder="Enter region"
|
||||
{...register('region')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Chainage range</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
start and end kilometre values.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
id="package-start"
|
||||
label="Segment Start (km)"
|
||||
label="Start Chainage"
|
||||
error={startErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="package-start"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
step="1"
|
||||
min="0"
|
||||
placeholder="Enter start chainage"
|
||||
aria-invalid={!!startErrorMessage}
|
||||
{...register('chainage_start_km')}
|
||||
/>
|
||||
@@ -167,19 +159,22 @@ export function PackageDialog({
|
||||
|
||||
<FormField
|
||||
id="package-end"
|
||||
label="Segment End (km)"
|
||||
label="End Chainage"
|
||||
error={endErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="package-end"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
step="1"
|
||||
min="0"
|
||||
placeholder="Enter end chainage"
|
||||
aria-invalid={!!endErrorMessage}
|
||||
{...register('chainage_end_km')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -191,11 +186,15 @@ export function PackageDialog({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||
{isSaving ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
{packageId ? 'Update Package' : 'Create Package'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
<div ref={projectSelectPortalRef} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -7,29 +7,25 @@ import { useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
optionalIntegerNumber,
|
||||
validateChainageRange,
|
||||
} from '@/lib/validation/numberField';
|
||||
import { packageService } from '@/services/api';
|
||||
import type { Package, PackageCreate, PackageUpdate } from '@/types';
|
||||
|
||||
import { packageKeys } from '../queries/packageKeys';
|
||||
|
||||
const optionalNumber = z.string().refine(
|
||||
(value) => {
|
||||
if (!value.trim()) {
|
||||
return true;
|
||||
}
|
||||
return Number.isFinite(Number(value));
|
||||
},
|
||||
{ message: 'Enter a valid number' },
|
||||
);
|
||||
|
||||
const packageFormSchema = z.object({
|
||||
const packageFormSchema = z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
project_id: z.string().trim().min(1, 'Project is required'),
|
||||
name: z.string().trim().min(1, 'Package name is required'),
|
||||
region: z.string().optional(),
|
||||
chainage_start_km: optionalNumber,
|
||||
chainage_end_km: optionalNumber,
|
||||
});
|
||||
chainage_start_km: optionalIntegerNumber,
|
||||
chainage_end_km: optionalIntegerNumber,
|
||||
})
|
||||
.superRefine(validateChainageRange);
|
||||
|
||||
export type PackageFormValues = z.infer<typeof packageFormSchema>;
|
||||
|
||||
@@ -103,18 +99,7 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
(values) => saveMutation.mutate(values),
|
||||
(formErrors) => {
|
||||
if (formErrors.project_id?.message) {
|
||||
toast.error(formErrors.project_id.message);
|
||||
return;
|
||||
}
|
||||
if (formErrors.name?.message) {
|
||||
toast.error(formErrors.name.message);
|
||||
}
|
||||
},
|
||||
);
|
||||
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
reset(defaultValues);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toast } from 'sonner';
|
||||
import { packageService, projectService } from '@/services/api';
|
||||
import type { Package, PaginationParams } from '@/types';
|
||||
|
||||
import { projectKeys } from '../../project/queries/projectKeys';
|
||||
import { packageKeys } from '../queries/packageKeys';
|
||||
|
||||
interface UsePackagesQueryParams {
|
||||
@@ -28,7 +29,7 @@ export function usePackagesQuery({ skip, limit }: UsePackagesQueryParams) {
|
||||
|
||||
export function useProjectOptionsQuery() {
|
||||
return useQuery({
|
||||
queryKey: ['projects', 'options'],
|
||||
queryKey: projectKeys.options(),
|
||||
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
|
||||
});
|
||||
}
|
||||
|
||||
16
src/app/(modules)/package/layout.tsx
Normal file
16
src/app/(modules)/package/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionRouteGuard } from '@/guards';
|
||||
|
||||
export default function PackageLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PermissionRouteGuard permission={PERMISSIONS.PACKAGE.READ}>
|
||||
{children}
|
||||
</PermissionRouteGuard>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,16 @@ import { useCallback, useState } from 'react';
|
||||
import { Package as PackageIcon, Plus } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Package } from '@/types';
|
||||
|
||||
@@ -20,6 +30,7 @@ import {
|
||||
|
||||
export default function PackagePage() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [packageToDelete, setPackageToDelete] = useState<Package | null>(null);
|
||||
const { skip, setSkip, limit, setLimit } = usePackageFilters();
|
||||
const packagesQuery = usePackagesQuery({ skip, limit });
|
||||
const projectsQuery = useProjectOptionsQuery();
|
||||
@@ -67,14 +78,25 @@ export default function PackagePage() {
|
||||
|
||||
const deletePackage = useCallback(
|
||||
(pkg: Package) => {
|
||||
if (!confirm(`Are you sure you want to delete package "${pkg.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
deletePackageMutation.mutate(pkg);
|
||||
setPackageToDelete(pkg);
|
||||
},
|
||||
[deletePackageMutation],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setPackageToDelete(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const confirmDeletePackage = useCallback(() => {
|
||||
if (!packageToDelete) return;
|
||||
|
||||
deletePackageMutation.mutate(packageToDelete, {
|
||||
onSettled: () => setPackageToDelete(null),
|
||||
});
|
||||
}, [deletePackageMutation, packageToDelete]);
|
||||
|
||||
const projects = projectsQuery.data?.items ?? [];
|
||||
const packages = packagesQuery.data?.items ?? [];
|
||||
const total = packagesQuery.data?.totalItems ?? 0;
|
||||
@@ -113,8 +135,6 @@ export default function PackagePage() {
|
||||
open={isDialogOpen}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
packageId={packageId}
|
||||
projects={projects}
|
||||
isProjectsLoading={projectsQuery.isLoading}
|
||||
projectId={projectId}
|
||||
onProjectChange={setProjectId}
|
||||
register={register}
|
||||
@@ -124,6 +144,36 @@ export default function PackagePage() {
|
||||
canSubmit={canSubmit}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(packageToDelete)}
|
||||
onOpenChange={handleDeleteDialogOpenChange}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete package?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{packageToDelete?.name}
|
||||
</span>
|
||||
. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deletePackageMutation.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deletePackageMutation.isPending}
|
||||
onClick={confirmDeletePackage}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,4 +4,7 @@ export const packageKeys = {
|
||||
all: ['packages'] as const,
|
||||
lists: () => [...packageKeys.all, 'list'] as const,
|
||||
list: (params: PaginationParams) => [...packageKeys.lists(), params] as const,
|
||||
options: () => [...packageKeys.all, 'options'] as const,
|
||||
details: () => [...packageKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...packageKeys.details(), id] as const,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { AppLoader } from '@/components/AppLoader';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
|
||||
export default function HomePage() {
|
||||
redirect(ROUTES.DASHBOARD);
|
||||
const router = useRouter();
|
||||
const defaultRoute = useAppStore((state) => state.defaultRoute);
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(defaultRoute ?? '/access');
|
||||
}, [defaultRoute, router]);
|
||||
|
||||
return <AppLoader label="Opening workspace" />;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { Control, UseFormRegister } from 'react-hook-form';
|
||||
import type { Control, FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { FormField } from '@/components/form';
|
||||
import { PermissionTree } from '@/components/permission-tree';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -34,7 +35,10 @@ interface PlanSheetProps {
|
||||
planId?: number;
|
||||
register: UseFormRegister<PlanFormValues>;
|
||||
control: Control<PlanFormValues>;
|
||||
errors: FieldErrors<PlanFormValues>;
|
||||
isSubmitted: boolean;
|
||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||
canSubmit: boolean;
|
||||
permissionTree: PermissionTreeItem[];
|
||||
permissionIds: number[];
|
||||
onPermissionIdsChange: (ids: number[]) => void;
|
||||
@@ -48,70 +52,105 @@ export function PlanSheet({
|
||||
planId,
|
||||
register,
|
||||
control,
|
||||
errors,
|
||||
isSubmitted,
|
||||
onSubmit,
|
||||
canSubmit,
|
||||
permissionTree,
|
||||
permissionIds,
|
||||
onPermissionIdsChange,
|
||||
isPermissionsLoading,
|
||||
isSaving,
|
||||
}: PlanSheetProps) {
|
||||
const fieldError = (field: keyof PlanFormValues) =>
|
||||
isSubmitted ? errors[field]?.message : undefined;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full overflow-y-auto sm:max-w-3xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{planId ? 'Edit Plan' : 'Create Plan'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
<SheetContent className="w-full! gap-0 p-0 sm:max-w-3xl! lg:max-w-4xl!">
|
||||
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
{planId ? 'Edit Plan' : 'Create Plan'}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-sm">
|
||||
Configure subscription limits, billing, and permission access.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
||||
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plan-name">Name</Label>
|
||||
<FormField
|
||||
id="plan-name"
|
||||
label="Name"
|
||||
required
|
||||
error={fieldError('name')}
|
||||
>
|
||||
<Input
|
||||
id="plan-name"
|
||||
placeholder="Starter"
|
||||
{...register('name', { required: true })}
|
||||
required
|
||||
placeholder="Enter plan name"
|
||||
aria-invalid={!!fieldError('name')}
|
||||
{...register('name')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plan-slug">Slug</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="plan-slug"
|
||||
label="Slug"
|
||||
required
|
||||
error={fieldError('slug')}
|
||||
>
|
||||
<Input
|
||||
id="plan-slug"
|
||||
placeholder="starter"
|
||||
{...register('slug', { required: true })}
|
||||
required
|
||||
aria-invalid={!!fieldError('slug')}
|
||||
{...register('slug')}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plan-description">Description</Label>
|
||||
<textarea
|
||||
<FormField
|
||||
id="plan-description"
|
||||
label="Description"
|
||||
required
|
||||
error={fieldError('description')}
|
||||
>
|
||||
<Textarea
|
||||
id="plan-description"
|
||||
placeholder="Basic plan for small teams"
|
||||
{...register('description', { required: true })}
|
||||
required
|
||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
|
||||
aria-invalid={!!fieldError('description')}
|
||||
{...register('description')}
|
||||
className="min-h-20"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plan-price">Price</Label>
|
||||
<FormField
|
||||
id="plan-price"
|
||||
label="Price"
|
||||
required
|
||||
error={fieldError('price')}
|
||||
>
|
||||
<Input
|
||||
id="plan-price"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="29.99"
|
||||
{...register('price', { required: true })}
|
||||
required
|
||||
aria-invalid={!!fieldError('price')}
|
||||
{...register('price')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Billing Cycle</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Billing Cycle"
|
||||
required
|
||||
error={fieldError('billing_cycle')}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="billing_cycle"
|
||||
@@ -128,62 +167,86 @@ export function PlanSheet({
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plan-trial-days">Trial Days</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="plan-trial-days"
|
||||
label="Trial Days"
|
||||
error={fieldError('trial_days')}
|
||||
>
|
||||
<Input
|
||||
id="plan-trial-days"
|
||||
type="number"
|
||||
min="0"
|
||||
aria-invalid={!!fieldError('trial_days')}
|
||||
{...register('trial_days', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-projects">Projects</Label>
|
||||
<FormField
|
||||
id="max-projects"
|
||||
label="Projects"
|
||||
error={fieldError('max_projects')}
|
||||
>
|
||||
<Input
|
||||
id="max-projects"
|
||||
type="number"
|
||||
min="0"
|
||||
aria-invalid={!!fieldError('max_projects')}
|
||||
{...register('max_projects', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-organizations">Organizations</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="max-organizations"
|
||||
label="Organizations"
|
||||
error={fieldError('max_organizations')}
|
||||
>
|
||||
<Input
|
||||
id="max-organizations"
|
||||
type="number"
|
||||
min="0"
|
||||
aria-invalid={!!fieldError('max_organizations')}
|
||||
{...register('max_organizations', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-users">Users</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="max-users"
|
||||
label="Users"
|
||||
error={fieldError('max_users')}
|
||||
>
|
||||
<Input
|
||||
id="max-users"
|
||||
type="number"
|
||||
min="0"
|
||||
aria-invalid={!!fieldError('max_users')}
|
||||
{...register('max_users', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-roles">Roles</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="max-roles"
|
||||
label="Roles"
|
||||
error={fieldError('max_roles')}
|
||||
>
|
||||
<Input
|
||||
id="max-roles"
|
||||
type="number"
|
||||
min="0"
|
||||
aria-invalid={!!fieldError('max_roles')}
|
||||
{...register('max_roles', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-5">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-border accent-primary"
|
||||
className="size-4 rounded-lg border-border accent-primary"
|
||||
{...register('is_active')}
|
||||
/>
|
||||
Active
|
||||
@@ -191,22 +254,26 @@ export function PlanSheet({
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-border accent-primary"
|
||||
className="size-4 rounded-lg border-border accent-primary"
|
||||
{...register('is_custom')}
|
||||
/>
|
||||
Custom plan
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label>Permissions</Label>
|
||||
<FormField
|
||||
label="Permissions"
|
||||
required
|
||||
error={fieldError('permission_ids')}
|
||||
className="space-y-3"
|
||||
labelEnd={
|
||||
<span className="text-muted-foreground">
|
||||
{permissionIds.length} selected
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isPermissionsLoading ? (
|
||||
<div className="rounded-md border p-6 text-muted-foreground">
|
||||
<div className="rounded-lg border p-6 text-muted-foreground">
|
||||
Loading permissions...
|
||||
</div>
|
||||
) : (
|
||||
@@ -216,9 +283,10 @@ export function PlanSheet({
|
||||
onChange={onPermissionIdsChange}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-0">
|
||||
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -227,7 +295,7 @@ export function PlanSheet({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
collectDefaultPermissionIds,
|
||||
collectPermissionIdsByKeys,
|
||||
@@ -7,26 +8,39 @@ import {
|
||||
import type { PermissionTreeItem, Plan } from '@/types';
|
||||
import { useCallback } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useSavePlanMutation } from './usePlanMutations';
|
||||
|
||||
export interface PlanFormValues {
|
||||
id?: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
price: string;
|
||||
billing_cycle: string;
|
||||
trial_days: number;
|
||||
max_projects: number;
|
||||
max_organizations: number;
|
||||
max_users: number;
|
||||
max_roles: number;
|
||||
permission_ids: number[];
|
||||
is_active: boolean;
|
||||
is_custom: boolean;
|
||||
}
|
||||
const nonNegativeNumber = z.number().refine(
|
||||
(value) => Number.isFinite(value) && value >= 0,
|
||||
{ message: 'Enter a valid number' },
|
||||
);
|
||||
|
||||
const planFormSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
name: z.string().trim().min(1, 'Plan name is required'),
|
||||
slug: z.string().trim().min(1, 'Slug is required'),
|
||||
description: z.string().trim().min(1, 'Description is required'),
|
||||
price: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Price is required')
|
||||
.refine((value) => Number.isFinite(Number(value)) && Number(value) >= 0, {
|
||||
message: 'Enter a valid price',
|
||||
}),
|
||||
billing_cycle: z.string().trim().min(1, 'Billing cycle is required'),
|
||||
trial_days: nonNegativeNumber,
|
||||
max_projects: nonNegativeNumber,
|
||||
max_organizations: nonNegativeNumber,
|
||||
max_users: nonNegativeNumber,
|
||||
max_roles: nonNegativeNumber,
|
||||
permission_ids: z.array(z.number()).min(1, 'Select at least one permission'),
|
||||
is_active: z.boolean(),
|
||||
is_custom: z.boolean(),
|
||||
});
|
||||
|
||||
export type PlanFormValues = z.infer<typeof planFormSchema>;
|
||||
|
||||
const defaultValues: PlanFormValues = {
|
||||
name: '',
|
||||
@@ -62,9 +76,12 @@ export function usePlanForm({
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
formState: { errors, isSubmitting, isSubmitted },
|
||||
} = useForm<PlanFormValues>({
|
||||
defaultValues,
|
||||
mode: 'onSubmit',
|
||||
reValidateMode: 'onChange',
|
||||
resolver: zodResolver(planFormSchema),
|
||||
});
|
||||
const saveMutation = useSavePlanMutation({
|
||||
onSaved: () => {
|
||||
@@ -75,13 +92,18 @@ export function usePlanForm({
|
||||
|
||||
const planId = useWatch({ control, name: 'id' });
|
||||
const permissionIds = useWatch({ control, name: 'permission_ids' }) || [];
|
||||
const name = useWatch({ control, name: 'name' }) || '';
|
||||
const slug = useWatch({ control, name: 'slug' }) || '';
|
||||
const description = useWatch({ control, name: 'description' }) || '';
|
||||
const price = useWatch({ control, name: 'price' }) || '';
|
||||
const canSubmit =
|
||||
name.trim().length > 0 &&
|
||||
slug.trim().length > 0 &&
|
||||
description.trim().length > 0 &&
|
||||
price.trim().length > 0 &&
|
||||
permissionIds.length > 0;
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
if (values.permission_ids.length === 0) {
|
||||
toast.error('Select at least one permission');
|
||||
return;
|
||||
}
|
||||
|
||||
saveMutation.mutate({
|
||||
...values,
|
||||
name: values.name.trim(),
|
||||
@@ -146,6 +168,9 @@ export function usePlanForm({
|
||||
planId,
|
||||
permissionIds,
|
||||
setPermissionIds,
|
||||
errors,
|
||||
isSubmitted,
|
||||
canSubmit,
|
||||
isSaving: isSubmitting || saveMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
16
src/app/(modules)/plans/layout.tsx
Normal file
16
src/app/(modules)/plans/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionRouteGuard } from '@/guards';
|
||||
|
||||
export default function PlansLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PermissionRouteGuard permission={PERMISSIONS.PLAN.READ}>
|
||||
{children}
|
||||
</PermissionRouteGuard>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,9 @@ export default function PlansPage() {
|
||||
planId,
|
||||
permissionIds,
|
||||
setPermissionIds,
|
||||
errors,
|
||||
isSubmitted,
|
||||
canSubmit,
|
||||
isSaving,
|
||||
} = planForm;
|
||||
const statusMutation = usePlanStatusMutation();
|
||||
@@ -183,7 +186,10 @@ export default function PlansPage() {
|
||||
planId={planId}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
isSubmitted={isSubmitted}
|
||||
onSubmit={onSubmit}
|
||||
canSubmit={canSubmit}
|
||||
permissionTree={permissionsQuery.permissionTree}
|
||||
permissionIds={permissionIds}
|
||||
onPermissionIdsChange={setPermissionIds}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||
import { Layers, Loader2, MapPin, Route } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { FormField } from '@/components/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -54,24 +55,32 @@ export function ProjectDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-2xl"
|
||||
className="sm:max-w-2xl"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
|
||||
<Layers className="size-5" />
|
||||
</div>
|
||||
{projectId ? 'Edit Project Details' : 'Create New Project'}
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{projectId ? 'Edit Project' : 'Create Project'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{projectId
|
||||
? 'Update the technical specifications for your road infrastructure project.'
|
||||
: 'Provide the essential road data to establish a new analysis project.'}
|
||||
Manage road project identity and optional coordinate boundaries.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<DialogBody className="space-y-6">
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Basic details</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Project name and corridor information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
id="project-name"
|
||||
label="Project Name"
|
||||
@@ -80,116 +89,111 @@ export function ProjectDialog({
|
||||
>
|
||||
<Input
|
||||
id="project-name"
|
||||
placeholder="Enter a descriptive project name"
|
||||
placeholder="Enter project name"
|
||||
aria-invalid={!!nameErrorMessage}
|
||||
{...register('name')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField id="project-state" label="State">
|
||||
<Input
|
||||
id="project-state"
|
||||
placeholder="e.g. Maharashtra"
|
||||
placeholder="Enter state"
|
||||
{...register('state')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="project-corridor"
|
||||
label={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Route className="size-3.5 opacity-60" />
|
||||
Corridor Name
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<FormField id="project-corridor" label="Corridor Name">
|
||||
<Input
|
||||
id="project-corridor"
|
||||
placeholder="e.g. Mumbai-Goa Highway"
|
||||
placeholder="Enter corridor name"
|
||||
{...register('corridor_name')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-8 pt-2 md:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<p className="flex items-center gap-2 text-muted-foreground">
|
||||
<MapPin className="size-3.5 opacity-60" />
|
||||
START POINT
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Coordinates</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Optional start and end points for the project boundary.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
id="project-start-lat"
|
||||
label="Lat"
|
||||
label="Start Latitude"
|
||||
error={startLatErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="project-start-lat"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="0.0000"
|
||||
className="font-mono"
|
||||
min="-90"
|
||||
max="90"
|
||||
placeholder="Enter start latitude"
|
||||
aria-invalid={!!startLatErrorMessage}
|
||||
{...register('start_lat')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="project-start-lng"
|
||||
label="Lng"
|
||||
label="Start Longitude"
|
||||
error={startLngErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="project-start-lng"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="0.0000"
|
||||
className="font-mono"
|
||||
min="-180"
|
||||
max="180"
|
||||
placeholder="Enter start longitude"
|
||||
aria-invalid={!!startLngErrorMessage}
|
||||
{...register('start_lng')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="flex items-center gap-2 text-muted-foreground">
|
||||
<MapPin className="size-3.5 opacity-60" />
|
||||
END POINT
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
id="project-end-lat"
|
||||
label="Lat"
|
||||
label="End Latitude"
|
||||
error={endLatErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="project-end-lat"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="0.0000"
|
||||
className="font-mono"
|
||||
min="-90"
|
||||
max="90"
|
||||
placeholder="Enter end latitude"
|
||||
aria-invalid={!!endLatErrorMessage}
|
||||
{...register('end_lat')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="project-end-lng"
|
||||
label="Lng"
|
||||
label="End Longitude"
|
||||
error={endLngErrorMessage}
|
||||
>
|
||||
<Input
|
||||
id="project-end-lng"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="0.0000"
|
||||
className="font-mono"
|
||||
min="-180"
|
||||
max="180"
|
||||
placeholder="Enter end longitude"
|
||||
aria-invalid={!!endLngErrorMessage}
|
||||
{...register('end_lng')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -201,7 +205,9 @@ export function ProjectDialog({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||
{isSaving ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
{projectId ? 'Update Project' : 'Create Project'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -7,30 +7,24 @@ import { useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
optionalLatitude,
|
||||
optionalLongitude,
|
||||
} from '@/lib/validation/numberField';
|
||||
import { projectService } from '@/services/api';
|
||||
import type { Project, ProjectCreate, ProjectUpdate } from '@/types';
|
||||
|
||||
import { projectKeys } from '../queries/projectKeys';
|
||||
|
||||
const optionalCoordinate = z.string().refine(
|
||||
(value) => {
|
||||
if (!value.trim()) {
|
||||
return true;
|
||||
}
|
||||
return Number.isFinite(Number(value));
|
||||
},
|
||||
{ message: 'Enter a valid number' },
|
||||
);
|
||||
|
||||
const projectFormSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().trim().min(1, 'Project name is required'),
|
||||
state: z.string().optional(),
|
||||
corridor_name: z.string().optional(),
|
||||
start_lat: optionalCoordinate,
|
||||
start_lng: optionalCoordinate,
|
||||
end_lat: optionalCoordinate,
|
||||
end_lng: optionalCoordinate,
|
||||
start_lat: optionalLatitude,
|
||||
start_lng: optionalLongitude,
|
||||
end_lat: optionalLatitude,
|
||||
end_lng: optionalLongitude,
|
||||
});
|
||||
|
||||
export type ProjectFormValues = z.infer<typeof projectFormSchema>;
|
||||
@@ -106,14 +100,7 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
(values) => saveMutation.mutate(values),
|
||||
(formErrors) => {
|
||||
if (formErrors.name?.message) {
|
||||
toast.error(formErrors.name.message);
|
||||
}
|
||||
},
|
||||
);
|
||||
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
reset(defaultValues);
|
||||
|
||||
16
src/app/(modules)/project/layout.tsx
Normal file
16
src/app/(modules)/project/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionRouteGuard } from '@/guards';
|
||||
|
||||
export default function ProjectLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PermissionRouteGuard permission={PERMISSIONS.PROJECT.READ}>
|
||||
{children}
|
||||
</PermissionRouteGuard>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,16 @@ import { useCallback, useState } from 'react';
|
||||
import { Layers, Plus } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Project } from '@/types';
|
||||
|
||||
@@ -19,6 +29,7 @@ import {
|
||||
|
||||
export default function ProjectPage() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
|
||||
const { skip, setSkip, limit, setLimit } = useProjectFilters();
|
||||
const projectsQuery = useProjectsQuery({ skip, limit });
|
||||
const deleteProjectMutation = useDeleteProjectMutation();
|
||||
@@ -63,16 +74,25 @@ export default function ProjectPage() {
|
||||
|
||||
const deleteProject = useCallback(
|
||||
(project: Project) => {
|
||||
if (
|
||||
!confirm(`Are you sure you want to delete project "${project.name}"?`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deleteProjectMutation.mutate(project);
|
||||
setProjectToDelete(project);
|
||||
},
|
||||
[deleteProjectMutation],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setProjectToDelete(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const confirmDeleteProject = useCallback(() => {
|
||||
if (!projectToDelete) return;
|
||||
|
||||
deleteProjectMutation.mutate(projectToDelete, {
|
||||
onSettled: () => setProjectToDelete(null),
|
||||
});
|
||||
}, [deleteProjectMutation, projectToDelete]);
|
||||
|
||||
const columns = useProjectColumns();
|
||||
const projects = projectsQuery.data?.items ?? [];
|
||||
const total = projectsQuery.data?.totalItems ?? 0;
|
||||
@@ -117,6 +137,36 @@ export default function ProjectPage() {
|
||||
canSubmit={canSubmit}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(projectToDelete)}
|
||||
onOpenChange={handleDeleteDialogOpenChange}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete project?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{projectToDelete?.name}
|
||||
</span>
|
||||
. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteProjectMutation.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleteProjectMutation.isPending}
|
||||
onClick={confirmDeleteProject}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,4 +4,7 @@ export const projectKeys = {
|
||||
all: ['projects'] as const,
|
||||
lists: () => [...projectKeys.all, 'list'] as const,
|
||||
list: (params: PaginationParams) => [...projectKeys.lists(), params] as const,
|
||||
options: () => [...projectKeys.all, 'options'] as const,
|
||||
details: () => [...projectKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...projectKeys.details(), id] as const,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
function StatSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col items-center rounded-lg border border-muted bg-muted/30 p-4 text-center">
|
||||
<Skeleton className="mb-2 size-9 rounded-lg" />
|
||||
<Skeleton className="h-6 w-12" />
|
||||
<Skeleton className="mt-2 h-3 w-20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetectionLogSkeleton() {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-20 shrink-0 rounded-lg" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-28" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-3 w-10" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-3 w-44" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VideoResultsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Skeleton className="size-10 rounded-lg" />
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-7 w-60 max-w-full" />
|
||||
<Skeleton className="h-3 w-80 max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
|
||||
<section className="overflow-hidden rounded-lg border bg-card">
|
||||
<div className="border-b p-6 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-lg" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-44" />
|
||||
<Skeleton className="h-3 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<Skeleton className="aspect-video w-full rounded-lg border" />
|
||||
|
||||
<section className="overflow-hidden rounded-lg border bg-card/80">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-72 max-w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 p-3 sm:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-16 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-5 w-36" />
|
||||
</div>
|
||||
<div className="h-107.5 rounded-lg border bg-muted/20 p-4">
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<DetectionLogSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-3 md:grid-cols-5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<StatSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, TrendingUp } 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">
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title={getTitle()}
|
||||
title="Detection Results"
|
||||
description={`Video ID: ${videoId}`}
|
||||
icon={TrendingUp}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft />
|
||||
Back
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{detectionData && (
|
||||
<VideoPlayerSection
|
||||
data={detectionData}
|
||||
videoId={videoId}
|
||||
detectionType={detectionType}
|
||||
projectId={session?.projectId || undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
{detectionData && <VideoPlayerSection data={detectionData} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { videoService } from '@/services/api';
|
||||
import type { VideoAnnotationFramesParams } from '@/types';
|
||||
|
||||
import { videoKeys } from '../queries/videoKeys';
|
||||
|
||||
@@ -18,6 +19,20 @@ export function useVideoResultsQuery(videoId: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useVideoAnnotationFramesQuery(
|
||||
videoId: string | undefined,
|
||||
params: VideoAnnotationFramesParams,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: videoKeys.annotationFrames(videoId ?? '', params),
|
||||
queryFn: () =>
|
||||
videoService.getVideoAnnotationFrames(videoId as string, params),
|
||||
enabled: Boolean(videoId),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
gcTime: 1000 * 60 * 30,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the annotated video through the authenticated axios client and
|
||||
* expose a local object URL that the native `<video>` element can play.
|
||||
@@ -26,10 +41,10 @@ export function useVideoResultsQuery(videoId: string | undefined) {
|
||||
* directly is what fixes the 401. The browser cannot attach the Bearer token to
|
||||
* a media request, but axios can.
|
||||
*/
|
||||
export function useAnnotatedVideoQuery(url: string | undefined) {
|
||||
export function useProtectedVideoQuery(url: string | undefined) {
|
||||
const query = useQuery({
|
||||
queryKey: videoKeys.annotatedVideo(url ?? ''),
|
||||
queryFn: () => videoService.getAnnotatedVideo(url as string),
|
||||
queryFn: () => videoService.getProtectedVideo(url as string),
|
||||
enabled: Boolean(url),
|
||||
staleTime: Infinity,
|
||||
gcTime: 1000 * 60 * 30,
|
||||
@@ -59,3 +74,5 @@ export function useAnnotatedVideoQuery(url: string | undefined) {
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export const useAnnotatedVideoQuery = useProtectedVideoQuery;
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { sessionService } from '@/services/api';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
export default function ResultsPage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const storedSession = sessionService.loadSession();
|
||||
const videoData = sessionService.loadVideoData();
|
||||
|
||||
if (!sessionService.isSessionValid(storedSession) || !videoData?.videoId) {
|
||||
router.replace(ROUTES.UPLOAD);
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(`${ROUTES.RESULTS}/${videoData.videoId}`);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Card className="flex flex-col items-center gap-4 p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">Loading results...</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,10 @@ export const videoKeys = {
|
||||
all: ['videos'] as const,
|
||||
results: () => [...videoKeys.all, 'results'] as const,
|
||||
result: (videoId: string) => [...videoKeys.results(), videoId] as const,
|
||||
annotationFrames: (
|
||||
videoId: string,
|
||||
params: { start_time_ms: number; end_time_ms: number },
|
||||
) => [...videoKeys.result(videoId), 'annotation-frames', params] as const,
|
||||
annotatedVideos: () => [...videoKeys.all, 'annotated'] as const,
|
||||
annotatedVideo: (url: string) =>
|
||||
[...videoKeys.annotatedVideos(), url] as const,
|
||||
|
||||
@@ -62,16 +62,22 @@ export function RoleSheet({
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex h-full flex-col gap-0 p-0 sm:max-w-3xl lg:max-w-2xl"
|
||||
className="flex h-full w-full! flex-col gap-0 p-0 sm:max-w-3xl!"
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b px-6 py-4">
|
||||
<SheetTitle>{roleId ? 'Edit Role' : 'Create Role'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||
{roleId ? 'Edit Role' : 'Create Role'}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-sm">
|
||||
Assign the role details and permission access for this organization.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
@@ -123,7 +129,7 @@ export function RoleSheet({
|
||||
}
|
||||
>
|
||||
{isPermissionsLoading ? (
|
||||
<div className="rounded-md border p-6 text-muted-foreground">
|
||||
<div className="rounded-lg border p-6 text-muted-foreground">
|
||||
Loading permissions...
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Edit, RotateCcw } from 'lucide-react';
|
||||
import { Edit, ShieldCheck, ShieldX } from 'lucide-react';
|
||||
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
@@ -58,7 +58,12 @@ export function RoleTable({
|
||||
},
|
||||
{
|
||||
label: (role) => (role.effective_status ? 'Deactivate' : 'Activate'),
|
||||
icon: <RotateCcw className="size-4" />,
|
||||
icon: (role) =>
|
||||
role.effective_status ? (
|
||||
<ShieldX className="size-4 text-destructive" />
|
||||
) : (
|
||||
<ShieldCheck className="size-4 text-emerald-600" />
|
||||
),
|
||||
permission: PERMISSIONS.ROLE.DELETE,
|
||||
disabled: () => isStatusPending,
|
||||
onClick: onToggleStatus,
|
||||
|
||||
@@ -89,22 +89,7 @@ export function useRoleForm({
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
(values) => saveMutation.mutate(values),
|
||||
(formErrors) => {
|
||||
if (formErrors.name?.message) {
|
||||
toast.error(formErrors.name.message);
|
||||
return;
|
||||
}
|
||||
if (formErrors.display_name?.message) {
|
||||
toast.error(formErrors.display_name.message);
|
||||
return;
|
||||
}
|
||||
if (formErrors.permission_ids?.message) {
|
||||
toast.error(formErrors.permission_ids.message);
|
||||
}
|
||||
},
|
||||
);
|
||||
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
reset({
|
||||
|
||||
16
src/app/(modules)/roles/layout.tsx
Normal file
16
src/app/(modules)/roles/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionRouteGuard } from '@/guards';
|
||||
|
||||
export default function RolesLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PermissionRouteGuard permission={PERMISSIONS.ROLE.READ}>
|
||||
{children}
|
||||
</PermissionRouteGuard>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||
import { Loader2, MapPin, Milestone } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { FormField } from '@/components/form';
|
||||
import { PackageSelect } from '@/components/lookups/PackageSelect';
|
||||
import { ProjectSelect } from '@/components/lookups/ProjectSelect';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -22,7 +26,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { Package, Project } from '@/types';
|
||||
|
||||
import type { SegmentFormValues } from '../hooks/useSegmentForm';
|
||||
|
||||
@@ -30,10 +33,6 @@ interface SegmentDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
segmentId?: string;
|
||||
projects: Project[];
|
||||
packages: Package[];
|
||||
isProjectsLoading: boolean;
|
||||
isPackagesLoading: boolean;
|
||||
projectId: string;
|
||||
onProjectChange: (projectId: string) => void;
|
||||
packageId: string;
|
||||
@@ -52,10 +51,6 @@ export function SegmentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
segmentId,
|
||||
projects,
|
||||
packages,
|
||||
isProjectsLoading,
|
||||
isPackagesLoading,
|
||||
projectId,
|
||||
onProjectChange,
|
||||
packageId,
|
||||
@@ -69,56 +64,54 @@ export function SegmentDialog({
|
||||
canSubmit,
|
||||
isSaving,
|
||||
}: SegmentDialogProps) {
|
||||
const projectSelectPortalRef = useRef<HTMLDivElement | null>(null);
|
||||
const packageSelectPortalRef = useRef<HTMLDivElement | null>(null);
|
||||
const getError = (field: keyof SegmentFormValues) =>
|
||||
isSubmitted ? errors[field]?.message : undefined;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-2xl"
|
||||
className="sm:max-w-2xl"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="gap-2">
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
|
||||
<Milestone className="size-5" />
|
||||
</div>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{segmentId ? 'Edit Segment' : 'Create Segment'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{segmentId
|
||||
? 'Update the technical specifications for your road segment.'
|
||||
: 'Select a project and package, then provide the segment data.'}
|
||||
Manage segment binding, chainage values, direction, and coordinates.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<DialogBody className="space-y-6">
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Basic details</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Project, package, segment name, and direction.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!segmentId ? (
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
label="Project"
|
||||
required
|
||||
error={getError('project_id')}
|
||||
>
|
||||
<Select value={projectId} onValueChange={onProjectChange}>
|
||||
<SelectTrigger aria-invalid={!!getError('project_id')}>
|
||||
{isProjectsLoading ? (
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</span>
|
||||
) : (
|
||||
<SelectValue placeholder="Choose a project" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ProjectSelect
|
||||
value={projectId}
|
||||
onValueChange={onProjectChange}
|
||||
enabled={open}
|
||||
disabled={isSaving}
|
||||
portalContainer={projectSelectPortalRef}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
{...register('project_id')}
|
||||
@@ -132,35 +125,14 @@ export function SegmentDialog({
|
||||
required
|
||||
error={getError('package_id')}
|
||||
>
|
||||
<Select
|
||||
<PackageSelect
|
||||
value={packageId}
|
||||
onValueChange={onPackageChange}
|
||||
disabled={!projectId || isPackagesLoading}
|
||||
>
|
||||
<SelectTrigger aria-invalid={!!getError('package_id')}>
|
||||
{isPackagesLoading ? (
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</span>
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={
|
||||
projectId
|
||||
? 'Choose a package'
|
||||
: 'Select project first'
|
||||
}
|
||||
projectId={projectId}
|
||||
enabled={open && Boolean(projectId)}
|
||||
disabled={!projectId || isSaving}
|
||||
portalContainer={packageSelectPortalRef}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{packages.map((pkg) => (
|
||||
<SelectItem key={pkg.id} value={pkg.id}>
|
||||
{pkg.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input
|
||||
type="hidden"
|
||||
{...register('package_id')}
|
||||
@@ -171,7 +143,7 @@ export function SegmentDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
id="segment-name"
|
||||
label="Segment Name"
|
||||
@@ -180,7 +152,7 @@ export function SegmentDialog({
|
||||
>
|
||||
<Input
|
||||
id="segment-name"
|
||||
placeholder="e.g. Mumbai to Pune"
|
||||
placeholder="Enter segment name"
|
||||
aria-invalid={!!getError('segment_name')}
|
||||
{...register('segment_name')}
|
||||
/>
|
||||
@@ -197,19 +169,30 @@ export function SegmentDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Chainage</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Required start and end kilometre values.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
id="segment-start-km"
|
||||
label="Start (km)"
|
||||
label="Start Chainage"
|
||||
required
|
||||
error={getError('chainage_start_km')}
|
||||
>
|
||||
<Input
|
||||
id="segment-start-km"
|
||||
type="number"
|
||||
step="any"
|
||||
step="1"
|
||||
min="0"
|
||||
placeholder="0.0"
|
||||
placeholder="Enter start chainage"
|
||||
aria-invalid={!!getError('chainage_start_km')}
|
||||
{...register('chainage_start_km')}
|
||||
/>
|
||||
@@ -217,32 +200,36 @@ export function SegmentDialog({
|
||||
|
||||
<FormField
|
||||
id="segment-end-km"
|
||||
label="End (km)"
|
||||
label="End Chainage"
|
||||
required
|
||||
error={getError('chainage_end_km')}
|
||||
>
|
||||
<Input
|
||||
id="segment-end-km"
|
||||
type="number"
|
||||
step="any"
|
||||
step="1"
|
||||
min="0"
|
||||
placeholder="1.0"
|
||||
placeholder="Enter end chainage"
|
||||
aria-invalid={!!getError('chainage_end_km')}
|
||||
{...register('chainage_end_km')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<p className="flex items-center gap-2 text-muted-foreground">
|
||||
<MapPin className="size-3.5 opacity-60" />
|
||||
START COORDINATES
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Coordinates</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Required start and end point latitude and longitude.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
id="start-lat"
|
||||
label="Latitude"
|
||||
label="Start Latitude"
|
||||
required
|
||||
error={getError('start_lat')}
|
||||
>
|
||||
@@ -259,7 +246,7 @@ export function SegmentDialog({
|
||||
</FormField>
|
||||
<FormField
|
||||
id="start-lng"
|
||||
label="Longitude"
|
||||
label="Start Longitude"
|
||||
required
|
||||
error={getError('start_lng')}
|
||||
>
|
||||
@@ -275,17 +262,11 @@ export function SegmentDialog({
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="flex items-center gap-2 text-muted-foreground">
|
||||
<MapPin className="size-3.5 opacity-60" />
|
||||
END COORDINATES
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
id="end-lat"
|
||||
label="Latitude"
|
||||
label="End Latitude"
|
||||
required
|
||||
error={getError('end_lat')}
|
||||
>
|
||||
@@ -302,7 +283,7 @@ export function SegmentDialog({
|
||||
</FormField>
|
||||
<FormField
|
||||
id="end-lng"
|
||||
label="Longitude"
|
||||
label="End Longitude"
|
||||
required
|
||||
error={getError('end_lng')}
|
||||
>
|
||||
@@ -319,7 +300,8 @@ export function SegmentDialog({
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -331,11 +313,16 @@ export function SegmentDialog({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||
{isSaving ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
{segmentId ? 'Update Segment' : 'Create Segment'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
<div ref={projectSelectPortalRef} />
|
||||
<div ref={packageSelectPortalRef} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -7,49 +7,32 @@ import { useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
requiredIntegerNumber,
|
||||
requiredLatitude,
|
||||
requiredLongitude,
|
||||
validateChainageRange,
|
||||
} from '@/lib/validation/numberField';
|
||||
import { chainageService } from '@/services/api';
|
||||
import type { Chainage, ChainageCreate, ChainageUpdate } from '@/types';
|
||||
|
||||
import { segmentKeys } from '../queries/segmentKeys';
|
||||
|
||||
const requiredNumber = (message: string) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, message)
|
||||
.refine((value) => Number.isFinite(Number(value)), {
|
||||
message: 'Enter a valid number',
|
||||
});
|
||||
|
||||
const latitude = requiredNumber('Latitude is required').refine(
|
||||
(value) => {
|
||||
const numberValue = Number(value);
|
||||
return numberValue >= -90 && numberValue <= 90;
|
||||
},
|
||||
{ message: 'Latitude must be between -90 and 90' },
|
||||
);
|
||||
|
||||
const longitude = requiredNumber('Longitude is required').refine(
|
||||
(value) => {
|
||||
const numberValue = Number(value);
|
||||
return numberValue >= -180 && numberValue <= 180;
|
||||
},
|
||||
{ message: 'Longitude must be between -180 and 180' },
|
||||
);
|
||||
|
||||
const segmentFormSchema = z.object({
|
||||
const segmentFormSchema = z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
project_id: z.string().trim().min(1, 'Project is required'),
|
||||
package_id: z.string().trim().min(1, 'Package is required'),
|
||||
segment_name: z.string().trim().min(1, 'Segment name is required'),
|
||||
chainage_start_km: requiredNumber('Start km is required'),
|
||||
chainage_end_km: requiredNumber('End km is required'),
|
||||
start_lat: latitude,
|
||||
start_lng: longitude,
|
||||
end_lat: latitude,
|
||||
end_lng: longitude,
|
||||
chainage_start_km: requiredIntegerNumber('Start km is required'),
|
||||
chainage_end_km: requiredIntegerNumber('End km is required'),
|
||||
start_lat: requiredLatitude,
|
||||
start_lng: requiredLongitude,
|
||||
end_lat: requiredLatitude,
|
||||
end_lng: requiredLongitude,
|
||||
direction: z.enum(['UP', 'DOWN']),
|
||||
});
|
||||
})
|
||||
.superRefine(validateChainageRange);
|
||||
|
||||
export type SegmentFormValues = z.infer<typeof segmentFormSchema>;
|
||||
|
||||
@@ -141,17 +124,7 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
(values) => saveMutation.mutate(values),
|
||||
(formErrors) => {
|
||||
const firstMessage = Object.values(formErrors).find(
|
||||
(error) => error?.message,
|
||||
)?.message;
|
||||
if (firstMessage) {
|
||||
toast.error(String(firstMessage));
|
||||
}
|
||||
},
|
||||
);
|
||||
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
reset(defaultValues);
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from '@/services/api';
|
||||
import type { Chainage, PaginationParams } from '@/types';
|
||||
|
||||
import { packageKeys } from '../../package/queries/packageKeys';
|
||||
import { projectKeys } from '../../project/queries/projectKeys';
|
||||
import { segmentKeys } from '../queries/segmentKeys';
|
||||
|
||||
interface UseSegmentsQueryParams {
|
||||
@@ -32,27 +34,18 @@ export function useSegmentsQuery({ skip, limit }: UseSegmentsQueryParams) {
|
||||
|
||||
export function useProjectOptionsQuery() {
|
||||
return useQuery({
|
||||
queryKey: ['projects', 'options'],
|
||||
queryKey: projectKeys.options(),
|
||||
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAllPackageOptionsQuery() {
|
||||
return useQuery({
|
||||
queryKey: ['packages', 'options'],
|
||||
queryKey: packageKeys.options(),
|
||||
queryFn: () => packageService.getPackages({ skip: 0, limit: 1000 }),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePackagesByProjectQuery(projectId: string, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['packages', 'by-project', projectId],
|
||||
queryFn: () =>
|
||||
packageService.getPackagesByProject(projectId, { skip: 0, limit: 1000 }),
|
||||
enabled: enabled && Boolean(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSegmentMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
16
src/app/(modules)/segment/layout.tsx
Normal file
16
src/app/(modules)/segment/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionRouteGuard } from '@/guards';
|
||||
|
||||
export default function SegmentLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PermissionRouteGuard permission={PERMISSIONS.SEGMENT.READ}>
|
||||
{children}
|
||||
</PermissionRouteGuard>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,16 @@ import { useCallback, useState } from 'react';
|
||||
import { Milestone, Plus } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Chainage } from '@/types';
|
||||
|
||||
@@ -15,13 +25,13 @@ import { useSegmentForm } from './hooks/useSegmentForm';
|
||||
import {
|
||||
useAllPackageOptionsQuery,
|
||||
useDeleteSegmentMutation,
|
||||
usePackagesByProjectQuery,
|
||||
useProjectOptionsQuery,
|
||||
useSegmentsQuery,
|
||||
} from './hooks/useSegmentQueries';
|
||||
|
||||
export default function SegmentPage() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [segmentToDelete, setSegmentToDelete] = useState<Chainage | null>(null);
|
||||
const { skip, setSkip, limit, setLimit } = useSegmentFilters();
|
||||
const segmentsQuery = useSegmentsQuery({ skip, limit });
|
||||
const projectsQuery = useProjectOptionsQuery();
|
||||
@@ -48,11 +58,6 @@ export default function SegmentPage() {
|
||||
canSubmit,
|
||||
isSaving,
|
||||
} = segmentForm;
|
||||
const projectPackagesQuery = usePackagesByProjectQuery(
|
||||
projectId,
|
||||
isDialogOpen,
|
||||
);
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
prepareCreateSegment();
|
||||
setIsDialogOpen(true);
|
||||
@@ -81,21 +86,27 @@ export default function SegmentPage() {
|
||||
|
||||
const deleteSegment = useCallback(
|
||||
(segment: Chainage) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to delete segment "${segment.segment_name}"?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deleteSegmentMutation.mutate(segment);
|
||||
setSegmentToDelete(segment);
|
||||
},
|
||||
[deleteSegmentMutation],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setSegmentToDelete(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const confirmDeleteSegment = useCallback(() => {
|
||||
if (!segmentToDelete) return;
|
||||
|
||||
deleteSegmentMutation.mutate(segmentToDelete, {
|
||||
onSettled: () => setSegmentToDelete(null),
|
||||
});
|
||||
}, [deleteSegmentMutation, segmentToDelete]);
|
||||
|
||||
const projects = projectsQuery.data?.items ?? [];
|
||||
const allPackages = allPackagesQuery.data?.items ?? [];
|
||||
const projectPackages = projectPackagesQuery.data?.items ?? [];
|
||||
const segments = segmentsQuery.data?.items ?? [];
|
||||
const total = segmentsQuery.data?.totalItems ?? 0;
|
||||
const columns = useSegmentColumns(projects, allPackages);
|
||||
@@ -133,10 +144,6 @@ export default function SegmentPage() {
|
||||
open={isDialogOpen}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
segmentId={segmentId}
|
||||
projects={projects}
|
||||
packages={projectPackages}
|
||||
isProjectsLoading={projectsQuery.isLoading}
|
||||
isPackagesLoading={projectPackagesQuery.isLoading}
|
||||
projectId={projectId}
|
||||
onProjectChange={setProjectId}
|
||||
packageId={packageId}
|
||||
@@ -150,6 +157,36 @@ export default function SegmentPage() {
|
||||
canSubmit={canSubmit}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(segmentToDelete)}
|
||||
onOpenChange={handleDeleteDialogOpenChange}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete segment?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{segmentToDelete?.segment_name}
|
||||
</span>
|
||||
. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteSegmentMutation.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleteSegmentMutation.isPending}
|
||||
onClick={confirmDeleteSegment}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,4 +4,6 @@ export const segmentKeys = {
|
||||
all: ['segments'] as const,
|
||||
lists: () => [...segmentKeys.all, 'list'] as const,
|
||||
list: (params: PaginationParams) => [...segmentKeys.lists(), params] as const,
|
||||
details: () => [...segmentKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...segmentKeys.details(), id] as const,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { UseFormRegister } from 'react-hook-form';
|
||||
import { useMemo, useRef, type ComponentProps } from 'react';
|
||||
import {
|
||||
Controller,
|
||||
type Control,
|
||||
type FieldErrors,
|
||||
type UseFormRegister,
|
||||
} from 'react-hook-form';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { FormField, PhoneInput, SelectPopover } from '@/components/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -14,17 +21,12 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { Client, Plan } from '@/types';
|
||||
|
||||
import type { TenantFormValues } from '../hooks/useTenantForm';
|
||||
import type {
|
||||
TenantAdminFieldLocks,
|
||||
TenantFormValues,
|
||||
} from '../hooks/useTenantForm';
|
||||
|
||||
interface TenantSheetProps {
|
||||
open: boolean;
|
||||
@@ -32,7 +34,11 @@ interface TenantSheetProps {
|
||||
tenantId?: number;
|
||||
isEditMode: boolean;
|
||||
register: UseFormRegister<TenantFormValues>;
|
||||
control: Control<TenantFormValues>;
|
||||
errors: FieldErrors<TenantFormValues>;
|
||||
isSubmitted: boolean;
|
||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||
canSubmit: boolean;
|
||||
clientId: string;
|
||||
planId: string;
|
||||
onClientChange: (clientId: string) => void;
|
||||
@@ -43,6 +49,7 @@ interface TenantSheetProps {
|
||||
isLookupsLoading: boolean;
|
||||
isSaving: boolean;
|
||||
adminEmail?: string;
|
||||
adminFieldLocks: TenantAdminFieldLocks;
|
||||
}
|
||||
|
||||
export function TenantSheet({
|
||||
@@ -51,7 +58,11 @@ export function TenantSheet({
|
||||
tenantId,
|
||||
isEditMode,
|
||||
register,
|
||||
control,
|
||||
errors,
|
||||
isSubmitted,
|
||||
onSubmit,
|
||||
canSubmit,
|
||||
clientId,
|
||||
planId,
|
||||
onClientChange,
|
||||
@@ -62,10 +73,33 @@ export function TenantSheet({
|
||||
isLookupsLoading,
|
||||
isSaving,
|
||||
adminEmail,
|
||||
adminFieldLocks,
|
||||
}: TenantSheetProps) {
|
||||
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
|
||||
const fieldError = (field: keyof TenantFormValues) =>
|
||||
isSubmitted ? errors[field]?.message : undefined;
|
||||
|
||||
const clientOptions = useMemo(
|
||||
() =>
|
||||
clients.map((client) => ({
|
||||
value: String(client.id),
|
||||
label: [client.first_name, client.last_name].filter(Boolean).join(' '),
|
||||
})),
|
||||
[clients],
|
||||
);
|
||||
|
||||
const planOptions = useMemo(
|
||||
() =>
|
||||
plans.map((plan) => ({
|
||||
value: String(plan.id),
|
||||
label: plan.name,
|
||||
})),
|
||||
[plans],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{tenantId ? 'Edit Tenant' : 'Create Tenant'}
|
||||
@@ -75,7 +109,13 @@ export function TenantSheet({
|
||||
administrator.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
||||
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={onSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<DialogBody className="space-y-5">
|
||||
<div className="space-y-1">
|
||||
<p>Basic Information</p>
|
||||
<p className="text-muted-foreground">
|
||||
@@ -84,97 +124,113 @@ export function TenantSheet({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-name">Tenant Name</Label>
|
||||
<FormField
|
||||
id="tenant-name"
|
||||
label="Tenant Name"
|
||||
required
|
||||
error={fieldError('name')}
|
||||
>
|
||||
<Input
|
||||
id="tenant-name"
|
||||
placeholder="Acme Corp"
|
||||
placeholder="Enter tenant name"
|
||||
aria-invalid={!!fieldError('name')}
|
||||
{...register('name', {
|
||||
required: true,
|
||||
onChange: (event) => onNameChange(event.target.value),
|
||||
})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-slug">Slug</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="tenant-slug"
|
||||
label="Slug"
|
||||
required
|
||||
error={fieldError('slug')}
|
||||
>
|
||||
<Input
|
||||
id="tenant-slug"
|
||||
placeholder="acme-corp"
|
||||
{...register('slug', { required: true })}
|
||||
required
|
||||
aria-invalid={!!fieldError('slug')}
|
||||
{...register('slug')}
|
||||
readOnly={isEditMode}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Client</Label>
|
||||
<Select
|
||||
<FormField
|
||||
id="tenant-client"
|
||||
label="Client"
|
||||
required
|
||||
error={fieldError('client_id')}
|
||||
>
|
||||
<SelectPopover
|
||||
options={clientOptions}
|
||||
value={clientId}
|
||||
onValueChange={onClientChange}
|
||||
disabled={isLookupsLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
portalContainer={dropdownPortalRef}
|
||||
placeholder={
|
||||
isLookupsLoading ? 'Loading clients...' : 'Select client'
|
||||
}
|
||||
searchPlaceholder="Search clients"
|
||||
emptyMessage="No clients found."
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={String(client.id)}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Subscription Plan</Label>
|
||||
<Select
|
||||
<input
|
||||
type="hidden"
|
||||
{...register('client_id')}
|
||||
value={clientId}
|
||||
readOnly
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="tenant-plan"
|
||||
label="Subscription Plan"
|
||||
required
|
||||
error={fieldError('plan_id')}
|
||||
>
|
||||
<SelectPopover
|
||||
options={planOptions}
|
||||
value={planId}
|
||||
onValueChange={onPlanChange}
|
||||
disabled={isLookupsLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
portalContainer={dropdownPortalRef}
|
||||
placeholder={
|
||||
isLookupsLoading ? 'Loading plans...' : 'Select plan'
|
||||
}
|
||||
searchPlaceholder="Search plans"
|
||||
emptyMessage="No plans found."
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{plans.map((plan) => (
|
||||
<SelectItem key={plan.id} value={String(plan.id)}>
|
||||
{plan.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
{...register('plan_id')}
|
||||
value={planId}
|
||||
readOnly
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-domain">Domain</Label>
|
||||
<FormField id="tenant-domain" label="Domain">
|
||||
<Input
|
||||
id="tenant-domain"
|
||||
placeholder="acme.example.com"
|
||||
{...register('domain')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="tenant-description">Description</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="tenant-description"
|
||||
label="Description"
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<textarea
|
||||
id="tenant-description"
|
||||
placeholder="North zone operations"
|
||||
{...register('description')}
|
||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
|
||||
className="min-h-20 w-full rounded-lg border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{!isEditMode ? (
|
||||
@@ -187,57 +243,89 @@ export function TenantSheet({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-first-name">First Name</Label>
|
||||
<FormField
|
||||
id="admin-first-name"
|
||||
label="First Name"
|
||||
required
|
||||
error={fieldError('admin_first_name')}
|
||||
>
|
||||
<Input
|
||||
id="admin-first-name"
|
||||
placeholder="Jane"
|
||||
{...register('admin_first_name', { required: !isEditMode })}
|
||||
required
|
||||
placeholder="Enter first name"
|
||||
aria-invalid={!!fieldError('admin_first_name')}
|
||||
readOnly={adminFieldLocks.admin_first_name}
|
||||
{...register('admin_first_name')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-last-name">Last Name</Label>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="admin-last-name"
|
||||
label="Last Name"
|
||||
required
|
||||
error={fieldError('admin_last_name')}
|
||||
>
|
||||
<Input
|
||||
id="admin-last-name"
|
||||
placeholder="Doe"
|
||||
{...register('admin_last_name', { required: !isEditMode })}
|
||||
required
|
||||
placeholder="Enter last name"
|
||||
aria-invalid={!!fieldError('admin_last_name')}
|
||||
readOnly={adminFieldLocks.admin_last_name}
|
||||
{...register('admin_last_name')}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-email">Admin Email</Label>
|
||||
<FormField
|
||||
id="admin-email"
|
||||
label="Admin Email"
|
||||
required
|
||||
error={fieldError('admin_email')}
|
||||
>
|
||||
<Input
|
||||
id="admin-email"
|
||||
type="email"
|
||||
placeholder="jane@acme.com"
|
||||
{...register('admin_email', { required: !isEditMode })}
|
||||
required
|
||||
placeholder="admin@example.com"
|
||||
aria-invalid={!!fieldError('admin_email')}
|
||||
readOnly={adminFieldLocks.admin_email}
|
||||
{...register('admin_email')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-phone">Phone Number</Label>
|
||||
<Input
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="admin-phone"
|
||||
placeholder="+91-9000011111"
|
||||
{...register('admin_phone_number')}
|
||||
label="Phone Number"
|
||||
error={fieldError('admin_phone_number')}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="admin_phone_number"
|
||||
render={({ field }) => (
|
||||
<PhoneInput
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="Enter phone number"
|
||||
aria-invalid={!!fieldError('admin_phone_number')}
|
||||
disabled={adminFieldLocks.admin_phone_number}
|
||||
portalContainer={dropdownPortalRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</>
|
||||
) : adminEmail ? (
|
||||
<div className="rounded-md border bg-muted/40 p-4 text-muted-foreground">
|
||||
Admin invitation details cannot be changed after tenant creation.
|
||||
<div className="rounded-lg border bg-muted/40 p-4 text-muted-foreground">
|
||||
Admin invitation details cannot be changed after tenant
|
||||
creation.
|
||||
<p className="mt-1 text-foreground">
|
||||
Current admin email: <span>{adminEmail}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="px-0">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -246,7 +334,10 @@ export function TenantSheet({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving || isLookupsLoading}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving || isLookupsLoading || !canSubmit}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
@@ -254,6 +345,8 @@ export function TenantSheet({
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
<div ref={dropdownPortalRef} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,26 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Tenant } from '@/types';
|
||||
|
||||
import { useSaveTenantMutation } from './useTenantMutations';
|
||||
|
||||
export interface TenantFormValues {
|
||||
id?: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
client_id: string;
|
||||
plan_id: string;
|
||||
domain: string;
|
||||
description: string;
|
||||
admin_first_name: string;
|
||||
admin_last_name: string;
|
||||
admin_email: string;
|
||||
admin_phone_number: string;
|
||||
}
|
||||
const requiredText = (message: string) => z.string().trim().min(1, message);
|
||||
|
||||
const tenantFormSchema = z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
name: requiredText('Tenant name is required'),
|
||||
slug: requiredText('Slug is required'),
|
||||
client_id: requiredText('Client is required'),
|
||||
plan_id: requiredText('Plan is required'),
|
||||
domain: z.string().trim(),
|
||||
description: z.string().trim(),
|
||||
admin_first_name: z.string().trim(),
|
||||
admin_last_name: z.string().trim(),
|
||||
admin_email: z.string().trim(),
|
||||
admin_phone_number: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => value === '' || isValidPhoneNumber(value), {
|
||||
message: 'Enter a valid phone number',
|
||||
}),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
if (values.id) return;
|
||||
|
||||
if (!values.admin_first_name.trim()) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['admin_first_name'],
|
||||
message: 'First name is required',
|
||||
});
|
||||
}
|
||||
|
||||
if (!values.admin_last_name.trim()) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['admin_last_name'],
|
||||
message: 'Last name is required',
|
||||
});
|
||||
}
|
||||
|
||||
if (!values.admin_email.trim()) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['admin_email'],
|
||||
message: 'Admin email is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!z.string().email().safeParse(values.admin_email).success) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['admin_email'],
|
||||
message: 'Invalid admin email',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type TenantFormValues = z.infer<typeof tenantFormSchema>;
|
||||
|
||||
const defaultValues: TenantFormValues = {
|
||||
name: '',
|
||||
@@ -50,6 +98,20 @@ function splitContactName(contactName: string) {
|
||||
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
|
||||
}
|
||||
|
||||
export type TenantAdminFieldLocks = {
|
||||
admin_first_name: boolean;
|
||||
admin_last_name: boolean;
|
||||
admin_email: boolean;
|
||||
admin_phone_number: boolean;
|
||||
};
|
||||
|
||||
const defaultAdminFieldLocks: TenantAdminFieldLocks = {
|
||||
admin_first_name: false,
|
||||
admin_last_name: false,
|
||||
admin_email: false,
|
||||
admin_phone_number: false,
|
||||
};
|
||||
|
||||
export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
||||
const {
|
||||
register,
|
||||
@@ -57,48 +119,49 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
formState: { errors, isSubmitting, isSubmitted },
|
||||
} = useForm<TenantFormValues>({
|
||||
defaultValues,
|
||||
mode: 'onSubmit',
|
||||
reValidateMode: 'onChange',
|
||||
resolver: zodResolver(tenantFormSchema),
|
||||
});
|
||||
const saveMutation = useSaveTenantMutation({
|
||||
onSaved: () => {
|
||||
reset(defaultValues);
|
||||
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||
onSaved();
|
||||
},
|
||||
});
|
||||
|
||||
const [adminFieldLocks, setAdminFieldLocks] = useState<TenantAdminFieldLocks>(
|
||||
defaultAdminFieldLocks,
|
||||
);
|
||||
|
||||
const tenantId = useWatch({ control, name: 'id' });
|
||||
const clientId = useWatch({ control, name: 'client_id' }) || '';
|
||||
const planId = useWatch({ control, name: 'plan_id' }) || '';
|
||||
const name = useWatch({ control, name: 'name' }) || '';
|
||||
const slug = useWatch({ control, name: 'slug' }) || '';
|
||||
const adminFirstName = useWatch({ control, name: 'admin_first_name' }) || '';
|
||||
const adminLastName = useWatch({ control, name: 'admin_last_name' }) || '';
|
||||
const adminEmail = useWatch({ control, name: 'admin_email' }) || '';
|
||||
const isEditMode = Boolean(tenantId);
|
||||
const canSubmit =
|
||||
name.trim().length > 0 &&
|
||||
slug.trim().length > 0 &&
|
||||
clientId.trim().length > 0 &&
|
||||
planId.trim().length > 0 &&
|
||||
(isEditMode ||
|
||||
(adminFirstName.trim().length > 0 &&
|
||||
adminLastName.trim().length > 0 &&
|
||||
adminEmail.trim().length > 0));
|
||||
|
||||
const handleSubmit = submitForm(
|
||||
(values) => {
|
||||
if (!values.client_id || !values.plan_id) {
|
||||
toast.error('Select a client and plan');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEditMode) {
|
||||
if (
|
||||
!values.admin_first_name.trim() ||
|
||||
!values.admin_last_name.trim() ||
|
||||
!values.admin_email.trim()
|
||||
) {
|
||||
toast.error('Complete all required admin fields');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
saveMutation.mutate(values);
|
||||
},
|
||||
() => toast.error('Complete all required tenant fields'),
|
||||
);
|
||||
const handleSubmit = submitForm((values) => saveMutation.mutate(values));
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
reset(defaultValues);
|
||||
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||
}, [reset]);
|
||||
|
||||
const openEdit = useCallback(
|
||||
@@ -116,19 +179,37 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
||||
admin_email: tenant.admin_email || '',
|
||||
admin_phone_number: '',
|
||||
});
|
||||
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||
},
|
||||
[reset],
|
||||
);
|
||||
|
||||
const clearAdminFields = useCallback(() => {
|
||||
setValue('admin_first_name', '', { shouldDirty: true });
|
||||
setValue('admin_last_name', '', { shouldDirty: true });
|
||||
setValue('admin_email', '', { shouldDirty: true });
|
||||
setValue('admin_phone_number', '', { shouldDirty: true });
|
||||
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||
}, [setValue]);
|
||||
|
||||
const applyClientAdminDefaults = useCallback(
|
||||
(contactName: string, contactEmail: string, contactPhone: string) => {
|
||||
if (isEditMode) return;
|
||||
|
||||
const { firstName, lastName } = splitContactName(contactName);
|
||||
const trimmedEmail = contactEmail.trim();
|
||||
const trimmedPhone = contactPhone.trim();
|
||||
|
||||
setValue('admin_first_name', firstName, { shouldDirty: true });
|
||||
setValue('admin_last_name', lastName, { shouldDirty: true });
|
||||
setValue('admin_email', contactEmail, { shouldDirty: true });
|
||||
setValue('admin_phone_number', contactPhone, { shouldDirty: true });
|
||||
setValue('admin_email', trimmedEmail, { shouldDirty: true });
|
||||
setValue('admin_phone_number', trimmedPhone, { shouldDirty: true });
|
||||
setAdminFieldLocks({
|
||||
admin_first_name: firstName.trim().length > 0,
|
||||
admin_last_name: lastName.trim().length > 0,
|
||||
admin_email: trimmedEmail.length > 0,
|
||||
admin_phone_number: trimmedPhone.length > 0,
|
||||
});
|
||||
},
|
||||
[isEditMode, setValue],
|
||||
);
|
||||
@@ -143,9 +224,9 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
||||
|
||||
return {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
openCreate,
|
||||
openEdit,
|
||||
@@ -154,7 +235,12 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
||||
planId,
|
||||
adminEmail,
|
||||
isEditMode,
|
||||
errors,
|
||||
isSubmitted,
|
||||
canSubmit,
|
||||
adminFieldLocks,
|
||||
applyClientAdminDefaults,
|
||||
clearAdminFields,
|
||||
syncSlugFromName,
|
||||
isSaving: isSubmitting || saveMutation.isPending,
|
||||
};
|
||||
|
||||
16
src/app/(modules)/tenants/layout.tsx
Normal file
16
src/app/(modules)/tenants/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionRouteGuard } from '@/guards';
|
||||
|
||||
export default function TenantsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PermissionRouteGuard permission={PERMISSIONS.TENANT.READ}>
|
||||
{children}
|
||||
</PermissionRouteGuard>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export default function TenantsPage() {
|
||||
});
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
openCreate: prepareCreateTenant,
|
||||
openEdit: prepareEditTenant,
|
||||
@@ -62,7 +63,12 @@ export default function TenantsPage() {
|
||||
planId,
|
||||
adminEmail,
|
||||
isEditMode,
|
||||
errors,
|
||||
isSubmitted,
|
||||
canSubmit,
|
||||
adminFieldLocks,
|
||||
applyClientAdminDefaults,
|
||||
clearAdminFields,
|
||||
syncSlugFromName,
|
||||
setValue,
|
||||
isSaving,
|
||||
@@ -74,7 +80,11 @@ export default function TenantsPage() {
|
||||
(value: string) => {
|
||||
setValue('client_id', value, { shouldDirty: true, shouldValidate: true });
|
||||
|
||||
if (!value || isEditMode) return;
|
||||
if (isEditMode) return;
|
||||
|
||||
clearAdminFields();
|
||||
|
||||
if (!value) return;
|
||||
|
||||
clientService.getClientById(Number(value)).then((client) => {
|
||||
applyClientAdminDefaults(
|
||||
@@ -84,7 +94,7 @@ export default function TenantsPage() {
|
||||
);
|
||||
});
|
||||
},
|
||||
[applyClientAdminDefaults, isEditMode, setValue],
|
||||
[applyClientAdminDefaults, clearAdminFields, isEditMode, setValue],
|
||||
);
|
||||
|
||||
const handlePlanChange = useCallback(
|
||||
@@ -180,7 +190,11 @@ export default function TenantsPage() {
|
||||
tenantId={tenantId}
|
||||
isEditMode={isEditMode}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
isSubmitted={isSubmitted}
|
||||
onSubmit={handleSubmit}
|
||||
canSubmit={canSubmit}
|
||||
clientId={clientId}
|
||||
planId={planId}
|
||||
onClientChange={handleClientChange}
|
||||
@@ -191,6 +205,7 @@ export default function TenantsPage() {
|
||||
isLookupsLoading={isLookupsLoading}
|
||||
isSaving={isSaving}
|
||||
adminEmail={adminEmail}
|
||||
adminFieldLocks={adminFieldLocks}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowRight,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
|
||||
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
|
||||
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 type { AssignableTicketUser } from '@/types';
|
||||
|
||||
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
|
||||
import {
|
||||
formatCompactRangeCoordinate,
|
||||
formatRangeCoordinate,
|
||||
type AssignmentRangePoint,
|
||||
} from './assignmentRange';
|
||||
|
||||
interface AssignTicketFormProps {
|
||||
ticketId: string;
|
||||
selectedClassNames: string[];
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onAssigned: () => void;
|
||||
}
|
||||
|
||||
export function AssignTicketForm({
|
||||
ticketId,
|
||||
selectedClassNames,
|
||||
startPoint,
|
||||
endPoint,
|
||||
onAssigned,
|
||||
}: AssignTicketFormProps) {
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
const [selectedContractor, setSelectedContractor] =
|
||||
useState<AssignableTicketUser | null>(null);
|
||||
const [dueDate, setDueDate] = useState<Date>();
|
||||
const [assignNote, setAssignNote] = useState('');
|
||||
|
||||
const assignMutation = useAssignTicketMutation(ticketId);
|
||||
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],
|
||||
);
|
||||
const hasIncompleteLocationRange = Boolean(startPoint) !== Boolean(endPoint);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Users className="text-primary" />
|
||||
Contractor and schedule
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Contractor <span className="text-destructive">*</span>
|
||||
</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-4">
|
||||
<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}
|
||||
|
||||
{startPoint || endPoint ? (
|
||||
<div className="space-y-2">
|
||||
<Label>Location</Label>
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-2 rounded-lg border px-3 py-2 text-xs"
|
||||
title={[
|
||||
startPoint ? formatRangeCoordinate(startPoint) : null,
|
||||
endPoint ? formatRangeCoordinate(endPoint) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' to ')}
|
||||
>
|
||||
<MapPin className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 font-mono">
|
||||
{startPoint
|
||||
? formatCompactRangeCoordinate(startPoint)
|
||||
: endPoint
|
||||
? formatCompactRangeCoordinate(endPoint)
|
||||
: null}
|
||||
</span>
|
||||
{startPoint && endPoint ? (
|
||||
<>
|
||||
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 font-mono">
|
||||
{formatCompactRangeCoordinate(endPoint)}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{hasIncompleteLocationRange ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{startPoint
|
||||
? 'Select an end location to complete the range.'
|
||||
: 'Select a start location to complete the range.'}
|
||||
</p>
|
||||
) : null}
|
||||
</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 htmlFor="assign-note">Assignment note (optional)</Label>
|
||||
<Textarea
|
||||
id="assign-note"
|
||||
value={assignNote}
|
||||
onChange={(event) => setAssignNote(event.target.value)}
|
||||
placeholder="Add instructions or notes for the contractor..."
|
||||
disabled={assignMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
!selectedUserId ||
|
||||
!selectedContractor ||
|
||||
!dueDate ||
|
||||
hasIncompleteLocationRange ||
|
||||
assignMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
!selectedUserId ||
|
||||
!selectedContractor ||
|
||||
!dueDate ||
|
||||
hasIncompleteLocationRange
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const note = assignNote.trim() || undefined;
|
||||
const hasCriteria =
|
||||
selectedClassNames.length > 0 ||
|
||||
Boolean(startPoint && endPoint);
|
||||
|
||||
assignMutation.mutate(
|
||||
{
|
||||
assigned_to_user_id: Number(selectedUserId),
|
||||
assigned_to_email: selectedContractor.email,
|
||||
due_at: dueDate.toISOString(),
|
||||
note,
|
||||
...(hasCriteria
|
||||
? {
|
||||
criteria: {
|
||||
...(selectedClassNames.length > 0
|
||||
? { class_names: selectedClassNames }
|
||||
: {}),
|
||||
...(startPoint && endPoint
|
||||
? {
|
||||
start_lat: startPoint.latitude,
|
||||
start_lng: startPoint.longitude,
|
||||
end_lat: endPoint.latitude,
|
||||
end_lng: endPoint.longitude,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelectedUserId('');
|
||||
setSelectedContractor(null);
|
||||
setDueDate(undefined);
|
||||
setAssignNote('');
|
||||
onAssigned();
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Send />
|
||||
{assignMutation.isPending ? 'Assigning...' : 'Assign ticket'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { AlertTriangle, MapPinned, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import type {
|
||||
TicketAssignmentDetectionItem,
|
||||
TicketAssignmentDetectionsResponse,
|
||||
} from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
import type { AssignmentRangePoint } from './assignmentRange';
|
||||
|
||||
type AssignmentIssuesMapProps = {
|
||||
data?: TicketAssignmentDetectionsResponse;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
type AssignmentMapItem = {
|
||||
id: number;
|
||||
class_name: string;
|
||||
display_name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
sequence: number;
|
||||
};
|
||||
|
||||
type AssignmentMapClass = {
|
||||
class_name: string;
|
||||
display_name: string;
|
||||
};
|
||||
|
||||
type ClientAssignmentIssuesMapProps = {
|
||||
items: AssignmentMapItem[];
|
||||
classes: AssignmentMapClass[];
|
||||
selectedDetectionId: number | null;
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onSelectDetection: (detectionId: number) => void;
|
||||
};
|
||||
|
||||
function toMapItem(
|
||||
item: TicketAssignmentDetectionItem,
|
||||
sequence: number,
|
||||
): AssignmentMapItem | null {
|
||||
const { latitude, longitude } = item.location;
|
||||
if (
|
||||
typeof latitude !== 'number' ||
|
||||
typeof longitude !== 'number' ||
|
||||
!Number.isFinite(latitude) ||
|
||||
!Number.isFinite(longitude) ||
|
||||
latitude < -90 ||
|
||||
latitude > 90 ||
|
||||
longitude < -180 ||
|
||||
longitude > 180
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.detection.id,
|
||||
class_name: item.detection.class_name,
|
||||
display_name: item.detection.display_name,
|
||||
latitude,
|
||||
longitude,
|
||||
sequence,
|
||||
};
|
||||
}
|
||||
|
||||
const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
|
||||
async () => {
|
||||
const {
|
||||
CircleMarker,
|
||||
MapContainer,
|
||||
Polyline,
|
||||
Popup,
|
||||
TileLayer,
|
||||
Tooltip,
|
||||
useMap,
|
||||
} = await import('react-leaflet');
|
||||
const { canvas } = await import('leaflet');
|
||||
|
||||
function FitMapToIssues({ items }: { items: AssignmentMapItem[] }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length === 0) return;
|
||||
|
||||
if (items.length === 1) {
|
||||
map.setView([items[0].latitude, items[0].longitude], 17);
|
||||
return;
|
||||
}
|
||||
|
||||
map.fitBounds(
|
||||
items.map(
|
||||
(item) => [item.latitude, item.longitude] as [number, number],
|
||||
),
|
||||
{
|
||||
padding: [32, 32],
|
||||
maxZoom: 17,
|
||||
},
|
||||
);
|
||||
}, [items, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return function ClientAssignmentIssuesMapInner({
|
||||
items,
|
||||
classes,
|
||||
selectedDetectionId,
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSelectDetection,
|
||||
}: ClientAssignmentIssuesMapProps) {
|
||||
const markerRenderer = useMemo(() => canvas({ tolerance: 12 }), []);
|
||||
const displayNames = new Map(
|
||||
classes.map((item) => [item.class_name, item.display_name]),
|
||||
);
|
||||
const initialCenter: [number, number] = [
|
||||
items[0].latitude,
|
||||
items[0].longitude,
|
||||
];
|
||||
const orderedItems = [...items].sort(
|
||||
(first, second) =>
|
||||
first.sequence - second.sequence || first.id - second.id,
|
||||
);
|
||||
const startIndex = orderedItems.findIndex(
|
||||
(item) => item.id === startPoint?.detectionId,
|
||||
);
|
||||
const endIndex = orderedItems.findIndex(
|
||||
(item) => item.id === endPoint?.detectionId,
|
||||
);
|
||||
const hasSelectedRange = startIndex >= 0 && endIndex >= 0;
|
||||
const rangeItems = hasSelectedRange
|
||||
? orderedItems.slice(
|
||||
Math.min(startIndex, endIndex),
|
||||
Math.max(startIndex, endIndex) + 1,
|
||||
)
|
||||
: [];
|
||||
const rangeDetectionIds = new Set(rangeItems.map((item) => item.id));
|
||||
const rangePath = rangeItems.map(
|
||||
(item) => [item.latitude, item.longitude] as [number, number],
|
||||
);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={initialCenter}
|
||||
zoom={15}
|
||||
scrollWheelZoom
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TileLayer
|
||||
attribution="© OpenStreetMap contributors"
|
||||
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<FitMapToIssues items={hasSelectedRange ? rangeItems : items} />
|
||||
|
||||
{rangePath.length >= 2 ? (
|
||||
<>
|
||||
<Polyline
|
||||
positions={rangePath}
|
||||
interactive={false}
|
||||
pathOptions={{
|
||||
color: '#3b82f6',
|
||||
opacity: 0.18,
|
||||
weight: 26,
|
||||
}}
|
||||
/>
|
||||
<Polyline
|
||||
positions={rangePath}
|
||||
interactive={false}
|
||||
pathOptions={{
|
||||
color: '#2563eb',
|
||||
opacity: 0.9,
|
||||
weight: 4,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{items.map((item) => {
|
||||
const visual = getDefectVisual(item.class_name);
|
||||
const IssueIcon = visual.icon;
|
||||
const isSelected = selectedDetectionId === item.id;
|
||||
const isStart = startPoint?.detectionId === item.id;
|
||||
const isEnd = endPoint?.detectionId === item.id;
|
||||
const rangeLabel = isStart ? 'Start' : isEnd ? 'End' : null;
|
||||
const rangeColor = isStart
|
||||
? '#16a34a'
|
||||
: isEnd
|
||||
? '#dc2626'
|
||||
: visual.boundingBoxColor;
|
||||
const isRangeIssue = isStart || isEnd;
|
||||
const isInsideRange = rangeDetectionIds.has(item.id);
|
||||
const displayName =
|
||||
displayNames.get(item.class_name) ??
|
||||
item.display_name ??
|
||||
item.class_name.replaceAll('_', ' ');
|
||||
return (
|
||||
<CircleMarker
|
||||
key={item.id}
|
||||
center={[item.latitude, item.longitude]}
|
||||
renderer={markerRenderer}
|
||||
radius={isSelected || isRangeIssue ? 11 : isInsideRange ? 9 : 7}
|
||||
pathOptions={{
|
||||
color:
|
||||
isSelected || isRangeIssue
|
||||
? '#ffffff'
|
||||
: visual.boundingBoxColor,
|
||||
fillColor: rangeColor,
|
||||
fillOpacity:
|
||||
isSelected || isRangeIssue
|
||||
? 1
|
||||
: hasSelectedRange && !isInsideRange
|
||||
? 0.22
|
||||
: 0.82,
|
||||
opacity: hasSelectedRange && !isInsideRange ? 0.3 : 1,
|
||||
weight: isSelected || isRangeIssue ? 4 : 2,
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: () => onSelectDetection(item.id),
|
||||
}}
|
||||
>
|
||||
<Popup minWidth={240} maxWidth={280}>
|
||||
<div className="w-60">
|
||||
<div className="flex items-start gap-3 border-b pb-3 pr-4">
|
||||
<span
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${visual.boundingBoxColor}1f`,
|
||||
color: visual.boundingBoxColor,
|
||||
}}
|
||||
>
|
||||
<IssueIcon className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="!m-0 truncate text-sm font-semibold leading-5">
|
||||
{displayName}
|
||||
</p>
|
||||
<p className="!m-0 text-xs leading-4 text-muted-foreground">
|
||||
Detection #{item.id}
|
||||
</p>
|
||||
</div>
|
||||
{rangeLabel ? (
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs font-semibold">
|
||||
<span
|
||||
className="size-3 rounded-full"
|
||||
style={{ backgroundColor: rangeColor }}
|
||||
/>
|
||||
{rangeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<dl className="!m-0 space-y-2 py-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-muted-foreground">Latitude</dt>
|
||||
<dd className="!m-0 font-mono text-xs tabular-nums">
|
||||
{item.latitude.toFixed(6)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-muted-foreground">Longitude</dt>
|
||||
<dd className="!m-0 font-mono text-xs tabular-nums">
|
||||
{item.longitude.toFixed(6)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</Popup>
|
||||
{rangeLabel ? (
|
||||
<Tooltip
|
||||
permanent
|
||||
direction="top"
|
||||
offset={[0, -10]}
|
||||
opacity={1}
|
||||
className="range-point-label"
|
||||
>
|
||||
<span style={{ color: rangeColor }}>{rangeLabel}</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
);
|
||||
};
|
||||
},
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="size-full animate-pulse bg-muted" />,
|
||||
},
|
||||
);
|
||||
|
||||
export function AssignmentIssuesMap({
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
startPoint,
|
||||
endPoint,
|
||||
onRetry,
|
||||
}: AssignmentIssuesMapProps) {
|
||||
const [selectedDetectionId, setSelectedDetectionId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const validItems = useMemo(
|
||||
() =>
|
||||
(data?.items ?? []).flatMap((item, index) => {
|
||||
const mapItem = toMapItem(item, index);
|
||||
return mapItem ? [mapItem] : [];
|
||||
}),
|
||||
[data?.items],
|
||||
);
|
||||
const classes = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Map(
|
||||
validItems.map((item) => [
|
||||
item.class_name,
|
||||
{
|
||||
class_name: item.class_name,
|
||||
display_name: item.display_name,
|
||||
},
|
||||
]),
|
||||
).values(),
|
||||
),
|
||||
[validItems],
|
||||
);
|
||||
const pageItemCount = data?.items.length ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedDetectionId !== null &&
|
||||
!validItems.some((item) => item.id === selectedDetectionId)
|
||||
) {
|
||||
setSelectedDetectionId(null);
|
||||
}
|
||||
}, [selectedDetectionId, validItems]);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-secondary p-2">
|
||||
<MapPinned className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">Issues on Map</CardTitle>
|
||||
<CardDescription>
|
||||
Select Start and End in the table to preview the covered range.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{data ? (
|
||||
<span className="shrink-0 text-sm text-muted-foreground">
|
||||
{validItems.length} mapped
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="h-[440px] animate-pulse bg-muted" />
|
||||
) : isError ? (
|
||||
<div className="flex min-h-72 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<AlertTriangle className="size-7 text-destructive" />
|
||||
<div>
|
||||
<p className="font-medium">Unable to load issue locations.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Check the connection and try again.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={onRetry}>
|
||||
<RefreshCw />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : validItems.length === 0 ? (
|
||||
<div className="flex min-h-72 flex-col items-center justify-center px-6 text-center">
|
||||
<MapPinned className="size-7 text-muted-foreground" />
|
||||
<p className="mt-3 font-medium">No issue locations available.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Detections without valid coordinates cannot be placed on the map.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-[440px] min-w-0 bg-muted">
|
||||
<ClientAssignmentIssuesMap
|
||||
items={validItems}
|
||||
classes={classes}
|
||||
selectedDetectionId={selectedDetectionId}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onSelectDetection={setSelectedDetectionId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-x-4 gap-y-2 border-t px-4 py-3"
|
||||
aria-label="Map legend"
|
||||
>
|
||||
<span className="text-sm font-medium">Map legend</span>
|
||||
{classes.map((item) => {
|
||||
const visual = getDefectVisual(item.class_name);
|
||||
const IssueIcon = visual.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.class_name}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
className="flex size-6 items-center justify-center rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${visual.boundingBoxColor}1f`,
|
||||
color: visual.boundingBoxColor,
|
||||
}}
|
||||
>
|
||||
<IssueIcon className="size-3.5" />
|
||||
</span>
|
||||
<span>{item.display_name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{startPoint ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="size-4 rounded-full border-2 border-white bg-green-600 shadow-sm" />
|
||||
<span>Start</span>
|
||||
</div>
|
||||
) : null}
|
||||
{endPoint ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="size-4 rounded-full border-2 border-white bg-red-600 shadow-sm" />
|
||||
<span>End</span>
|
||||
</div>
|
||||
) : null}
|
||||
{startPoint && endPoint ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="relative h-4 w-8 rounded-full bg-blue-500/20">
|
||||
<span className="absolute top-1/2 right-0 left-0 h-0.5 -translate-y-1/2 bg-blue-600" />
|
||||
</span>
|
||||
<span>Coverage area</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="border-t px-4 py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{validItems.length} mapped from {pageItemCount} cached{' '}
|
||||
{pageItemCount === 1 ? 'issue' : 'issues'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { AssignmentRangePoint } from './assignmentRange';
|
||||
|
||||
interface AssignmentRangeActionsProps {
|
||||
point: AssignmentRangePoint;
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onSetStart: (point: AssignmentRangePoint) => void;
|
||||
onSetEnd: (point: AssignmentRangePoint) => void;
|
||||
stretch?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AssignmentRangeActions({
|
||||
point,
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSetStart,
|
||||
onSetEnd,
|
||||
stretch = false,
|
||||
className,
|
||||
}: AssignmentRangeActionsProps) {
|
||||
const isStart = point.detectionId === startPoint?.detectionId;
|
||||
const isEnd = point.detectionId === endPoint?.detectionId;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant={isStart ? 'secondary' : 'outline'}
|
||||
className={cn(stretch && 'flex-1')}
|
||||
disabled={isStart}
|
||||
aria-pressed={isStart}
|
||||
title={isStart ? 'Selected as start' : 'Set as start'}
|
||||
onClick={() => onSetStart(point)}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant={isEnd ? 'secondary' : 'outline'}
|
||||
className={cn(stretch && 'flex-1')}
|
||||
disabled={startPoint === null || isStart || isEnd}
|
||||
aria-pressed={isEnd}
|
||||
title={
|
||||
startPoint === null
|
||||
? 'Select a start issue first'
|
||||
: isStart
|
||||
? 'Choose another issue for the end'
|
||||
: isEnd
|
||||
? 'Selected as end'
|
||||
: 'Set as end'
|
||||
}
|
||||
onClick={() => onSetEnd(point)}
|
||||
>
|
||||
End
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FilterX } from 'lucide-react';
|
||||
|
||||
import { MultiSelectPopover } from '@/components/form/MultiSelectPopover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { TicketAssignmentDetectionsResponse } from '@/types';
|
||||
|
||||
import { useTicketAssignmentDetectionsQuery } from '../../../hooks/useTicketQueries';
|
||||
import {
|
||||
formatCompactRangeCoordinate,
|
||||
formatRangeCoordinate,
|
||||
type AssignmentRangePoint,
|
||||
} from './assignmentRange';
|
||||
import { AssignmentIssuesMap } from './AssignmentIssuesMap';
|
||||
import { useIssueColumns } from './IssueColumns';
|
||||
import { IssueTable } from './IssueTable';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
interface IssueTypeOption {
|
||||
class_name: string;
|
||||
display_name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ChooseIssuesSectionProps {
|
||||
ticketId: string;
|
||||
issueTypes: IssueTypeOption[];
|
||||
selectedIssueTypes: string[];
|
||||
cacheRevision: number;
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onSelectedIssueTypesChange: (values: string[]) => void;
|
||||
onStartPointChange: (point: AssignmentRangePoint | null) => void;
|
||||
onEndPointChange: (point: AssignmentRangePoint | null) => void;
|
||||
}
|
||||
|
||||
export function ChooseIssuesSection({
|
||||
ticketId,
|
||||
issueTypes,
|
||||
selectedIssueTypes,
|
||||
cacheRevision,
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSelectedIssueTypesChange,
|
||||
onStartPointChange,
|
||||
onEndPointChange,
|
||||
}: ChooseIssuesSectionProps) {
|
||||
const [skip, setSkip] = useState(0);
|
||||
const [limit, setLimit] = useState(DEFAULT_PAGE_SIZE);
|
||||
const handleSetStart = useCallback(
|
||||
(point: AssignmentRangePoint) => {
|
||||
onStartPointChange(point);
|
||||
onEndPointChange(null);
|
||||
},
|
||||
[onEndPointChange, onStartPointChange],
|
||||
);
|
||||
const handleSetEnd = useCallback(
|
||||
(point: AssignmentRangePoint) => {
|
||||
if (point.detectionId === startPoint?.detectionId) return;
|
||||
|
||||
onEndPointChange(point);
|
||||
},
|
||||
[onEndPointChange, startPoint?.detectionId],
|
||||
);
|
||||
const columns = useIssueColumns({
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSetStart: handleSetStart,
|
||||
onSetEnd: handleSetEnd,
|
||||
});
|
||||
const issueTypeOptions = useMemo(
|
||||
() =>
|
||||
issueTypes.map((issue) => ({
|
||||
value: issue.class_name,
|
||||
label: issue.display_name,
|
||||
})),
|
||||
[issueTypes],
|
||||
);
|
||||
const handleIssueTypesChange = useCallback(
|
||||
(values: string[]) => {
|
||||
setSkip(0);
|
||||
onSelectedIssueTypesChange(values);
|
||||
onStartPointChange(null);
|
||||
onEndPointChange(null);
|
||||
},
|
||||
[onEndPointChange, onSelectedIssueTypesChange, onStartPointChange],
|
||||
);
|
||||
const hasActiveFilters =
|
||||
selectedIssueTypes.length > 0 || startPoint !== null || endPoint !== null;
|
||||
const clearFilters = useCallback(() => {
|
||||
setSkip(0);
|
||||
onSelectedIssueTypesChange([]);
|
||||
onStartPointChange(null);
|
||||
onEndPointChange(null);
|
||||
}, [onEndPointChange, onSelectedIssueTypesChange, onStartPointChange]);
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
assignment_status: 'unassigned' as const,
|
||||
skip,
|
||||
limit,
|
||||
class_name:
|
||||
selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined,
|
||||
}),
|
||||
[limit, selectedIssueTypes, skip],
|
||||
);
|
||||
const detectionsQuery = useTicketAssignmentDetectionsQuery(
|
||||
ticketId,
|
||||
queryParams,
|
||||
);
|
||||
const result = detectionsQuery.data;
|
||||
const mapCacheKey = useMemo(
|
||||
() => JSON.stringify([ticketId, selectedIssueTypes, cacheRevision]),
|
||||
[cacheRevision, selectedIssueTypes, ticketId],
|
||||
);
|
||||
const [mapCache, setMapCache] = useState<
|
||||
Record<string, TicketAssignmentDetectionsResponse>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!result) return;
|
||||
|
||||
setMapCache((current) => {
|
||||
const itemsById = new Map(
|
||||
(current[mapCacheKey]?.items ?? []).map((item) => [
|
||||
item.detection.id,
|
||||
item,
|
||||
]),
|
||||
);
|
||||
result.items.forEach((item) => itemsById.set(item.detection.id, item));
|
||||
|
||||
return {
|
||||
...current,
|
||||
[mapCacheKey]: {
|
||||
...result,
|
||||
skip: 0,
|
||||
limit: itemsById.size,
|
||||
items: Array.from(itemsById.values()),
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [mapCacheKey, result]);
|
||||
|
||||
const mapData = mapCache[mapCacheKey];
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-5">
|
||||
<Card size="sm" aria-label="Issue filters">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<CardTitle>Filters</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!hasActiveFilters}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<FilterX />
|
||||
Clear Filters
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<MultiSelectPopover
|
||||
label="Issue Types"
|
||||
options={issueTypeOptions}
|
||||
values={selectedIssueTypes}
|
||||
onValuesChange={handleIssueTypesChange}
|
||||
searchPlaceholder="Search issue types"
|
||||
emptyMessage="No issue types found."
|
||||
emptySelectionLabel="All"
|
||||
align="start"
|
||||
/>
|
||||
|
||||
{startPoint ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-8 gap-1.5 px-2.5 font-normal"
|
||||
title={formatRangeCoordinate(startPoint)}
|
||||
>
|
||||
<span className="font-medium">Start</span>
|
||||
<span className="font-mono">
|
||||
{formatCompactRangeCoordinate(startPoint)}
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
{endPoint ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-8 gap-1.5 px-2.5 font-normal"
|
||||
title={formatRangeCoordinate(endPoint)}
|
||||
>
|
||||
<span className="font-medium">End</span>
|
||||
<span className="font-mono">
|
||||
{formatCompactRangeCoordinate(endPoint)}
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<IssueTable
|
||||
columns={columns}
|
||||
issues={result?.items ?? []}
|
||||
isLoading={detectionsQuery.isLoading}
|
||||
isError={detectionsQuery.isError}
|
||||
toolbar={null}
|
||||
skip={skip}
|
||||
limit={limit}
|
||||
total={result?.total ?? 0}
|
||||
onPageChange={setSkip}
|
||||
onLimitChange={setLimit}
|
||||
/>
|
||||
|
||||
<AssignmentIssuesMap
|
||||
data={mapData}
|
||||
isLoading={detectionsQuery.isLoading && !mapData}
|
||||
isError={detectionsQuery.isError && !mapData}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onRetry={() => void detectionsQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import type { TicketAssignmentDetectionItem } from '@/types';
|
||||
|
||||
import type { AssignmentRangePoint } from './assignmentRange';
|
||||
import { AssignmentRangeActions } from './AssignmentRangeActions';
|
||||
|
||||
interface UseIssueColumnsOptions {
|
||||
startPoint: AssignmentRangePoint | null;
|
||||
endPoint: AssignmentRangePoint | null;
|
||||
onSetStart: (point: AssignmentRangePoint) => void;
|
||||
onSetEnd: (point: AssignmentRangePoint) => void;
|
||||
}
|
||||
|
||||
export function useIssueColumns({
|
||||
startPoint,
|
||||
endPoint,
|
||||
onSetStart,
|
||||
onSetEnd,
|
||||
}: UseIssueColumnsOptions): ColumnDef<TicketAssignmentDetectionItem>[] {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'detection_id',
|
||||
header: 'Detection ID',
|
||||
size: 140,
|
||||
cell: ({ row }) => `#${row.original.detection.id}`,
|
||||
},
|
||||
{
|
||||
id: 'issue_type',
|
||||
header: 'Issue Type',
|
||||
size: 180,
|
||||
cell: ({ row }) => {
|
||||
const { class_name: className, display_name: displayName } =
|
||||
row.original.detection;
|
||||
const visual = getDefectVisual(className);
|
||||
const IssueIcon = visual.icon;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 gap-1.5 px-2 font-normal"
|
||||
style={{
|
||||
borderColor: `${visual.boundingBoxColor}66`,
|
||||
backgroundColor: `${visual.boundingBoxColor}14`,
|
||||
color: visual.boundingBoxColor,
|
||||
}}
|
||||
>
|
||||
<IssueIcon />
|
||||
{displayName}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'coordinates',
|
||||
header: 'Coordinates',
|
||||
size: 220,
|
||||
cell: ({ row }) => {
|
||||
const issueId = row.original.detection.id;
|
||||
const { latitude, longitude } = row.original.location;
|
||||
|
||||
if (latitude === null || longitude === null) {
|
||||
return <span className="text-muted-foreground">Unavailable</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="font-mono text-xs">
|
||||
{latitude.toFixed(6)}, {longitude.toFixed(6)}
|
||||
</p>
|
||||
{issueId === startPoint?.detectionId ? (
|
||||
<p className="text-xs font-medium text-green-600">Start</p>
|
||||
) : issueId === endPoint?.detectionId ? (
|
||||
<p className="text-xs font-medium text-red-600">End</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'range_actions',
|
||||
header: 'Set range',
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const detectionId = row.original.detection.id;
|
||||
const { latitude, longitude } = row.original.location;
|
||||
const hasValidCoordinate =
|
||||
latitude !== null &&
|
||||
longitude !== null &&
|
||||
Number.isFinite(latitude) &&
|
||||
Number.isFinite(longitude) &&
|
||||
latitude >= -90 &&
|
||||
latitude <= 90 &&
|
||||
longitude >= -180 &&
|
||||
longitude <= 180;
|
||||
|
||||
if (!hasValidCoordinate) {
|
||||
return <span className="text-muted-foreground">Unavailable</span>;
|
||||
}
|
||||
|
||||
const point: AssignmentRangePoint = {
|
||||
detectionId,
|
||||
latitude,
|
||||
longitude,
|
||||
};
|
||||
return (
|
||||
<AssignmentRangeActions
|
||||
point={point}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onSetStart={onSetStart}
|
||||
onSetEnd={onSetEnd}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[endPoint, onSetEnd, onSetStart, startPoint],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import type { TicketAssignmentDetectionItem } from '@/types';
|
||||
|
||||
interface IssueTableProps {
|
||||
columns: ColumnDef<TicketAssignmentDetectionItem>[];
|
||||
issues: TicketAssignmentDetectionItem[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
toolbar: ReactNode;
|
||||
skip: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
onPageChange: (skip: number) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
}
|
||||
|
||||
export function IssueTable({
|
||||
columns,
|
||||
issues,
|
||||
isLoading,
|
||||
isError,
|
||||
toolbar,
|
||||
skip,
|
||||
limit,
|
||||
total,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
}: IssueTableProps) {
|
||||
return (
|
||||
<DataTable
|
||||
title="Issue details"
|
||||
columns={columns}
|
||||
data={issues}
|
||||
isLoading={isLoading}
|
||||
toolbar={toolbar}
|
||||
emptyTitle={isError ? 'Failed to load issues.' : 'No issues found.'}
|
||||
emptyDescription={
|
||||
isError
|
||||
? 'Please try again.'
|
||||
: 'No detections match the current filters.'
|
||||
}
|
||||
pagination={{
|
||||
skip,
|
||||
limit,
|
||||
totalItems: total,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface AssignmentRangePoint {
|
||||
detectionId: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export function formatRangeCoordinate(point: AssignmentRangePoint) {
|
||||
return `${point.latitude.toFixed(6)}, ${point.longitude.toFixed(6)}`;
|
||||
}
|
||||
|
||||
export function formatCompactRangeCoordinate(point: AssignmentRangePoint) {
|
||||
return `${point.latitude.toFixed(4)}, ${point.longitude.toFixed(4)}`;
|
||||
}
|
||||
167
src/app/(modules)/ticket/[ticketId]/assign/page.tsx
Normal file
167
src/app/(modules)/ticket/[ticketId]/assign/page.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, ArrowLeft, LockKeyhole } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
import { useTicketOverviewQuery } from '../../hooks/useTicketQueries';
|
||||
import type { AssignmentRangePoint } from './components/assignmentRange';
|
||||
import { AssignTicketForm } from './components/AssignTicketForm';
|
||||
import { ChooseIssuesSection } from './components/ChooseIssuesSection';
|
||||
|
||||
function AssignmentPageSkeleton() {
|
||||
return (
|
||||
<div className="space-y-5" aria-label="Loading assignment workspace">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-lg" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-56" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<Skeleton className="h-[540px] rounded-xl" />
|
||||
<Skeleton className="h-[430px] rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TicketAssignmentPage() {
|
||||
const router = useRouter();
|
||||
const { ticketId } = useParams() as { ticketId: string };
|
||||
const [startPoint, setStartPoint] = useState<AssignmentRangePoint | null>(
|
||||
null,
|
||||
);
|
||||
const [endPoint, setEndPoint] = useState<AssignmentRangePoint | null>(null);
|
||||
const [selectedIssueTypes, setSelectedIssueTypes] = useState<string[]>([]);
|
||||
const [assignmentRevision, setAssignmentRevision] = useState(0);
|
||||
const overviewQuery = useTicketOverviewQuery(ticketId);
|
||||
const overview = overviewQuery.data;
|
||||
const ticketDetailUrl = ROUTES.TICKET_DETAIL(ticketId);
|
||||
const backToTicket = () => router.push(ticketDetailUrl);
|
||||
|
||||
if (overviewQuery.isLoading) {
|
||||
return <AssignmentPageSkeleton />;
|
||||
}
|
||||
|
||||
if (overviewQuery.isError || !overview) {
|
||||
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 assignment workspace.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Ticket information is unavailable.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={backToTicket}>
|
||||
Back to ticket
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void overviewQuery.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const ticketName = overview.ticket_name || 'Unnamed ticket';
|
||||
|
||||
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">
|
||||
<header className="flex items-start gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={backToTicket}
|
||||
aria-label="Back to ticket"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Assign Ticket
|
||||
</h1>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-full font-mono text-xs"
|
||||
title={ticketName}
|
||||
>
|
||||
<span className="truncate">{ticketName}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid min-w-0 items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-stretch xl:overflow-hidden">
|
||||
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
<ChooseIssuesSection
|
||||
ticketId={ticketId}
|
||||
issueTypes={overview.ai_result.detections_by_class}
|
||||
selectedIssueTypes={selectedIssueTypes}
|
||||
cacheRevision={assignmentRevision}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onSelectedIssueTypesChange={setSelectedIssueTypes}
|
||||
onStartPointChange={setStartPoint}
|
||||
onEndPointChange={setEndPoint}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||
fallback={
|
||||
<Card>
|
||||
<CardContent className="flex min-h-72 flex-col items-center justify-center text-center">
|
||||
<LockKeyhole className="size-8 text-muted-foreground" />
|
||||
<p className="mt-3 font-semibold">
|
||||
Assignment access required
|
||||
</p>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
You do not have permission to assign this ticket.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={backToTicket}
|
||||
>
|
||||
Back to ticket
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<AssignTicketForm
|
||||
ticketId={ticketId}
|
||||
selectedClassNames={selectedIssueTypes}
|
||||
startPoint={startPoint}
|
||||
endPoint={endPoint}
|
||||
onAssigned={() => {
|
||||
setStartPoint(null);
|
||||
setEndPoint(null);
|
||||
setAssignmentRevision((revision) => revision + 1);
|
||||
}}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
</aside>
|
||||
</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,316 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
BriefcaseBusiness,
|
||||
CalendarClock,
|
||||
ChevronDown,
|
||||
Images,
|
||||
ListChecks,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PersonInfo } from '@/components/person-avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TicketActor, TicketAssignmentSummary } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { useTicketAssignmentsQuery } from '../../hooks/useTicketQueries';
|
||||
import { TicketDetectionPreview } from './TicketDetectionPreview';
|
||||
|
||||
interface TicketAssignmentsCardProps {
|
||||
ticketId: string;
|
||||
videoId?: string | null;
|
||||
initialAssignments?: TicketAssignmentSummary[];
|
||||
selectedAssignmentId?: number;
|
||||
onAssignmentChange: (assignmentId: number) => void;
|
||||
}
|
||||
|
||||
function formatLabel(value: string) {
|
||||
return value
|
||||
.replaceAll('_', ' ')
|
||||
.replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
function AssignmentClassBadge({ className }: { className: string }) {
|
||||
const visual = getDefectVisual(className);
|
||||
const IssueIcon = visual.icon;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-6 gap-1.5 px-2 font-normal"
|
||||
style={{
|
||||
borderColor: `${visual.boundingBoxColor}66`,
|
||||
backgroundColor: `${visual.boundingBoxColor}14`,
|
||||
color: visual.boundingBoxColor,
|
||||
}}
|
||||
>
|
||||
<IssueIcon />
|
||||
{formatLabel(className)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentProgress({
|
||||
assignment,
|
||||
}: {
|
||||
assignment: TicketAssignmentSummary;
|
||||
}) {
|
||||
const total = Math.max(assignment.item_count, 0);
|
||||
const approved = Math.max(assignment.approved_detections, 0);
|
||||
const progress = total > 0 ? Math.min((approved / total) * 100, 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex min-w-0 flex-col gap-1 text-xs sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<span className="font-medium text-foreground">Repair progress</span>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{approved} of {total} approved
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={assignment.is_complete ? 100 : progress} />
|
||||
<div className="flex min-w-0 flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
<span className="whitespace-nowrap text-emerald-600">
|
||||
{assignment.approved_detections} approved
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-amber-600">
|
||||
{assignment.pending_detections} pending
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-muted-foreground">
|
||||
{assignment.not_submitted_detections} not submitted
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentOverview({
|
||||
assignment,
|
||||
}: {
|
||||
assignment: TicketAssignmentSummary;
|
||||
}) {
|
||||
const criteria = assignment.criteria;
|
||||
const classNames = criteria?.class_names?.length
|
||||
? criteria.class_names
|
||||
: assignment.defect_class
|
||||
? [assignment.defect_class]
|
||||
: [];
|
||||
const worker: TicketActor = {
|
||||
user_id: assignment.assigned_to_user_id,
|
||||
name: assignment.assigned_to_name,
|
||||
email: assignment.assigned_to_email ?? null,
|
||||
avatar_url: assignment.assigned_to_avatar_url ?? null,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-4 rounded-lg border p-3 sm:p-4">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Assignment {assignment.id}
|
||||
</p>
|
||||
<PersonInfo person={worker} />
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
|
||||
{classNames.map((className) => (
|
||||
<AssignmentClassBadge key={className} className={className} />
|
||||
))}
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ListChecks className="size-3.5" />
|
||||
{assignment.item_count}{' '}
|
||||
{assignment.item_count === 1 ? 'issue' : 'issues'}
|
||||
</span>
|
||||
{assignment.due_at ? (
|
||||
<span className="flex min-w-0 max-w-full items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<CalendarClock className="size-3.5" />
|
||||
Due {formatDate(assignment.due_at)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<AssignmentProgress assignment={assignment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketAssignmentsCard({
|
||||
ticketId,
|
||||
videoId,
|
||||
initialAssignments,
|
||||
selectedAssignmentId,
|
||||
onAssignmentChange,
|
||||
}: TicketAssignmentsCardProps) {
|
||||
const assignmentsQuery = useTicketAssignmentsQuery(
|
||||
ticketId,
|
||||
initialAssignments,
|
||||
);
|
||||
const assignments = (assignmentsQuery.data?.items ?? []).filter(
|
||||
(assignment) => assignment.item_count > 0,
|
||||
);
|
||||
const selectedAssignment =
|
||||
assignments.find((assignment) => assignment.id === selectedAssignmentId) ??
|
||||
assignments[0];
|
||||
const activeAssignmentId = selectedAssignment
|
||||
? String(selectedAssignment.id)
|
||||
: '';
|
||||
const resolvedAssignmentId = selectedAssignment?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
resolvedAssignmentId !== undefined &&
|
||||
resolvedAssignmentId !== selectedAssignmentId
|
||||
) {
|
||||
onAssignmentChange(resolvedAssignmentId);
|
||||
}
|
||||
}, [onAssignmentChange, resolvedAssignmentId, selectedAssignmentId]);
|
||||
|
||||
return (
|
||||
<Card className="min-w-0 max-w-full">
|
||||
<CardHeader className="min-w-0 px-3 sm:px-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BriefcaseBusiness className="size-5 text-primary" />
|
||||
Work Assignments
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Issues grouped by assignee, scope, and repair progress.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{assignmentsQuery.data ? (
|
||||
<Badge variant="default">
|
||||
{assignments.length}{' '}
|
||||
{assignments.length === 1 ? 'Assignment' : 'Assignments'}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="min-w-0 space-y-3 overflow-hidden px-3 sm:px-6">
|
||||
{assignmentsQuery.isLoading ? (
|
||||
Array.from({ length: 2 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-40 animate-pulse rounded-lg bg-muted"
|
||||
/>
|
||||
))
|
||||
) : assignmentsQuery.isError ? (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center">
|
||||
<p className="text-sm font-medium">
|
||||
Unable to load work assignments.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => void assignmentsQuery.refetch()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : selectedAssignment ? (
|
||||
<div className="min-w-0 space-y-3">
|
||||
<nav
|
||||
aria-label="Work assignments"
|
||||
className="w-full max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain touch-pan-x"
|
||||
>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-orientation="horizontal"
|
||||
className="flex w-max min-w-full border-b"
|
||||
>
|
||||
{assignments.map((assignment) => {
|
||||
const assignmentId = String(assignment.id);
|
||||
const isActive = assignmentId === activeAssignmentId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={assignment.id}
|
||||
id={`assignment-tab-${assignmentId}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`assignment-panel-${assignmentId}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
className={cn(
|
||||
'relative shrink-0 px-3 py-2.5 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||
isActive
|
||||
? 'text-foreground after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={() => onAssignmentChange(assignment.id)}
|
||||
>
|
||||
Assignment {assignment.id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
id={`assignment-panel-${activeAssignmentId}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`assignment-tab-${activeAssignmentId}`}
|
||||
className="min-w-0"
|
||||
>
|
||||
<AssignmentOverview assignment={selectedAssignment} />
|
||||
{videoId ? (
|
||||
<Collapsible
|
||||
key={selectedAssignment.id}
|
||||
className="group mt-4 overflow-hidden rounded-lg border"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-between rounded-none px-4 py-3"
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<Images className="size-4 text-primary" />
|
||||
Assigned issue images
|
||||
</span>
|
||||
<ChevronDown className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t p-4">
|
||||
<TicketDetectionPreview
|
||||
key={`${videoId}:${selectedAssignment.id}`}
|
||||
ticketId={ticketId}
|
||||
videoId={videoId}
|
||||
assignmentId={selectedAssignment.id}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<p className="text-sm font-medium">No work assignments yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Work assignments will appear here after issues are allocated.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import type { TicketOverviewDetail } from '@/types';
|
||||
|
||||
import { AssignTicketAction } from './actions/AssignTicketAction';
|
||||
import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
|
||||
|
||||
export function TicketDetailHeader({
|
||||
ticket,
|
||||
backHref,
|
||||
}: {
|
||||
ticket: TicketOverviewDetail;
|
||||
backHref: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const ticketName = ticket.ticket_name || 'Unnamed ticket';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => router.push(backHref)}
|
||||
aria-label="Back to ticket list"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Ticket Detail
|
||||
</h1>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-full font-mono text-xs"
|
||||
title={ticketName}
|
||||
>
|
||||
<span className="truncate">{ticketName}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.REVIEW}>
|
||||
<OpenRepairReviewAction
|
||||
ticketId={ticket.id}
|
||||
hasPendingReview={ticket.has_pending_review}
|
||||
pendingReviewCount={ticket.pending_review_count}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||
<AssignTicketAction ticketId={ticket.id} />
|
||||
</PermissionGuard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
|
||||
|
||||
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
||||
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,
|
||||
useTicketDetectionsQuery,
|
||||
} from '../../hooks/useTicketQueries';
|
||||
import { DetectionDiscardDialog } from './DetectionDiscardDialog';
|
||||
|
||||
interface TicketDetectionPreviewProps {
|
||||
ticketId: string;
|
||||
videoId?: string | null;
|
||||
assignmentId?: number;
|
||||
assignmentStatus?: 'unassigned';
|
||||
onDetectionCountChange?: (count: number | undefined) => void;
|
||||
}
|
||||
|
||||
const DETECTION_PAGE_SIZE = 10;
|
||||
|
||||
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 TicketDetectionPreviewSkeleton() {
|
||||
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 TicketDetectionPreview({
|
||||
ticketId,
|
||||
videoId,
|
||||
assignmentId,
|
||||
assignmentStatus,
|
||||
onDetectionCountChange,
|
||||
}: TicketDetectionPreviewProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
|
||||
const isUnassignedPreview = assignmentStatus === 'unassigned';
|
||||
const pageStart =
|
||||
Math.floor(currentIndex / DETECTION_PAGE_SIZE) * DETECTION_PAGE_SIZE;
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentIndex(0);
|
||||
}, [assignmentId, assignmentStatus, videoId]);
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
skip: pageStart,
|
||||
limit: DETECTION_PAGE_SIZE,
|
||||
assignment_id: isUnassignedPreview ? undefined : assignmentId,
|
||||
assignment_status: isUnassignedPreview
|
||||
? ('unassigned' as const)
|
||||
: undefined,
|
||||
sort: 'timestamp_asc',
|
||||
}),
|
||||
[assignmentId, isUnassignedPreview, pageStart],
|
||||
);
|
||||
|
||||
const detectionsQuery = useTicketDetectionsQuery(
|
||||
videoId ?? undefined,
|
||||
queryParams,
|
||||
{ keepPreviousPage: true },
|
||||
);
|
||||
const detectionResult = detectionsQuery.data;
|
||||
const activeDetection = detectionsQuery.isPlaceholderData
|
||||
? undefined
|
||||
: detectionResult?.items[currentIndex - pageStart];
|
||||
const confirmedDetectionCount = detectionResult?.total;
|
||||
const detectionCount = confirmedDetectionCount ?? 0;
|
||||
const activeVisual = getDefectVisual(
|
||||
activeDetection?.detection.class_name ?? '',
|
||||
);
|
||||
const ActiveIssueIcon = activeVisual.icon;
|
||||
const activeDisplayName =
|
||||
activeDetection?.detection.display_name ?? 'Detection';
|
||||
const discardMutation = useDiscardDetectionMutation(
|
||||
ticketId,
|
||||
videoId ?? undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (confirmedDetectionCount === undefined) return;
|
||||
|
||||
if (confirmedDetectionCount === 0 && currentIndex !== 0) {
|
||||
setCurrentIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
confirmedDetectionCount > 0 &&
|
||||
currentIndex > confirmedDetectionCount - 1
|
||||
) {
|
||||
setCurrentIndex(confirmedDetectionCount - 1);
|
||||
}
|
||||
}, [confirmedDetectionCount, currentIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
onDetectionCountChange?.(detectionCount);
|
||||
}, [detectionCount, onDetectionCountChange]);
|
||||
|
||||
const isNavigationDisabled =
|
||||
detectionCount === 0 ||
|
||||
detectionsQuery.isLoading ||
|
||||
detectionsQuery.isPlaceholderData;
|
||||
const isInitialLoading =
|
||||
detectionsQuery.isLoading || detectionsQuery.isPlaceholderData;
|
||||
const isError = detectionsQuery.isError;
|
||||
const retry = () => void detectionsQuery.refetch();
|
||||
|
||||
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',
|
||||
activeVisual.cardClassName,
|
||||
)}
|
||||
>
|
||||
<ActiveIssueIcon
|
||||
className={cn('size-5', activeVisual.colorClassName)}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h3>{activeDisplayName}</h3>
|
||||
</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>
|
||||
|
||||
{isInitialLoading ? (
|
||||
<TicketDetectionPreviewSkeleton />
|
||||
) : 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={retry}>
|
||||
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">
|
||||
{detectionCount > 0
|
||||
? 'The selected detection details are unavailable. Try refreshing the preview.'
|
||||
: isUnassignedPreview
|
||||
? 'No unassigned issues remain on this ticket.'
|
||||
: '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={`${activeDetection.detection.display_name} - 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`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{isUnassignedPreview ? (
|
||||
<>
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
import { TicketDetectionPreview } from './TicketDetectionPreview';
|
||||
|
||||
interface TicketDetectionPreviewCardProps {
|
||||
ticketId: string;
|
||||
videoId?: string | null;
|
||||
assignmentId: number;
|
||||
}
|
||||
|
||||
export function TicketDetectionPreviewCard({
|
||||
ticketId,
|
||||
videoId,
|
||||
assignmentId,
|
||||
}: TicketDetectionPreviewCardProps) {
|
||||
if (!videoId) return null;
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardContent className="p-5">
|
||||
<div className="min-w-0">
|
||||
<TicketDetectionPreview
|
||||
key={`${videoId}:${assignmentId}`}
|
||||
ticketId={ticketId}
|
||||
videoId={videoId}
|
||||
assignmentId={assignmentId}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
import { History, MessageSquareText, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { PersonInfo } from '@/components/person-avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Timeline, TimelineItem } from '@/components/ui/timeline';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
TicketTimelineEventType,
|
||||
TicketTimelineItem,
|
||||
TicketTimelineNoteKind,
|
||||
TicketTimelineSource,
|
||||
TicketTimelineUser,
|
||||
} from '@/types';
|
||||
import { formatTimelineDateTime } from '@/utils/date';
|
||||
import { getPersonLabel } from '@/utils/person';
|
||||
|
||||
import { useTicketTimelineQuery } from '../../hooks/useTicketQueries';
|
||||
import { TicketTimelineSkeleton } from './TicketTimelineSkeleton';
|
||||
|
||||
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 getNoteKindLabel(kind: TicketTimelineNoteKind) {
|
||||
switch (kind) {
|
||||
case 'system_note':
|
||||
return 'System note';
|
||||
case 'repair_note':
|
||||
return 'Repair note';
|
||||
case 'review_comment':
|
||||
return 'Review comment';
|
||||
default:
|
||||
return 'Note';
|
||||
}
|
||||
}
|
||||
|
||||
function HistoryNote({ item }: { item: TicketTimelineItem }) {
|
||||
const note = item.note;
|
||||
if (!note) return null;
|
||||
|
||||
const noteText = 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>
|
||||
{getNoteKindLabel(note.kind)} by {getPersonLabel(note.author)}
|
||||
</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: TicketTimelineSource) {
|
||||
return source === 'system' ? 'System Generated' : 'User Action';
|
||||
}
|
||||
|
||||
function SystemHistoryEntry({ item }: { item: TicketTimelineItem }) {
|
||||
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 isSameUser(actor: TicketTimelineUser, target: TicketTimelineUser) {
|
||||
if (actor.user_id !== null && target.user_id !== null) {
|
||||
return actor.user_id === target.user_id;
|
||||
}
|
||||
|
||||
const actorEmail = actor.email?.trim().toLowerCase();
|
||||
const targetEmail = target.email?.trim().toLowerCase();
|
||||
if (actorEmail && targetEmail) return actorEmail === targetEmail;
|
||||
|
||||
return (
|
||||
Boolean(actor.name?.trim()) &&
|
||||
actor.name?.trim().toLowerCase() === target.name?.trim().toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
function getTargetLabel(eventType: TicketTimelineEventType | null) {
|
||||
switch (eventType) {
|
||||
case 'assignment_added':
|
||||
return 'Assigned to';
|
||||
case 'assignment_replaced':
|
||||
return 'Replacement worker';
|
||||
case 'assignment_updated':
|
||||
return 'Assigned worker';
|
||||
case 'assignment_deleted':
|
||||
return 'Removed worker';
|
||||
default:
|
||||
return 'Affected worker';
|
||||
}
|
||||
}
|
||||
|
||||
function UserHistoryEntry({ item }: { item: TicketTimelineItem }) {
|
||||
const noteText = item.note?.text?.trim();
|
||||
const showTargetUser =
|
||||
item.target_user && !isSameUser(item.actor, item.target_user);
|
||||
|
||||
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} />
|
||||
{showTargetUser && item.target_user ? (
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getTargetLabel(item.event_type)}
|
||||
</p>
|
||||
<PersonInfo person={item.target_user} />
|
||||
</div>
|
||||
) : null}
|
||||
{noteText ? <HistoryNote item={item} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineHeader() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="size-5 text-primary" />
|
||||
<h2 className="text-base font-semibold">Audit & Lifecycle Timeline</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketHistoryCard({ ticketId }: { ticketId: string }) {
|
||||
const timelineQuery = useTicketTimelineQuery(ticketId);
|
||||
|
||||
if (timelineQuery.isLoading) {
|
||||
return <TicketTimelineSkeleton />;
|
||||
}
|
||||
|
||||
const history = timelineQuery.data?.history ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<TimelineHeader />
|
||||
|
||||
{timelineQuery.isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-6 text-center"
|
||||
>
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load the ticket timeline.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
disabled={timelineQuery.isFetching}
|
||||
onClick={() => timelineQuery.refetch()}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'size-4',
|
||||
timelineQuery.isFetching && 'animate-spin',
|
||||
)}
|
||||
/>
|
||||
Retry
|
||||
</Button>
|
||||
</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,255 @@
|
||||
'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 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}
|
||||
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="size-5 text-primary" />
|
||||
AI Detection Summary
|
||||
</CardTitle>
|
||||
{analysisVideoId ? (
|
||||
<CardAction>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setIsAnalysisOpen(true)}
|
||||
>
|
||||
<Play className="mr-2 size-4" />
|
||||
View Video
|
||||
</Button>
|
||||
</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>
|
||||
</>
|
||||
</PermissionGuard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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="flex min-w-0 items-start gap-3">
|
||||
<Skeleton className="size-10 shrink-0 rounded-lg" />
|
||||
<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>
|
||||
|
||||
<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,102 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import type { TicketAssignmentSummary } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { NoTicketAction } from './actions/NoTicketAction';
|
||||
import { RequestExtensionAction } from './actions/RequestExtensionAction';
|
||||
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
|
||||
|
||||
interface TicketStatusActionsProps {
|
||||
ticketId: string;
|
||||
assignment?: TicketAssignmentSummary;
|
||||
}
|
||||
|
||||
function OverdueLotAlert({
|
||||
lotId,
|
||||
dueAt,
|
||||
}: {
|
||||
lotId: number;
|
||||
dueAt: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-destructive"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold">Lot {lotId} is overdue</p>
|
||||
<p className="mt-0.5 text-xs text-destructive/80">
|
||||
The due date expired on {formatDate(dueAt)}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTicketAction(
|
||||
ticketId: string,
|
||||
assignment?: TicketAssignmentSummary,
|
||||
) {
|
||||
if (assignment) {
|
||||
return (
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.ASSIGN}
|
||||
fallback={
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.WORK}
|
||||
fallback={
|
||||
<PermissionGuard
|
||||
permissions={PERMISSIONS.TICKET.READ}
|
||||
fallback={<NoTicketAction />}
|
||||
>
|
||||
<RequestExtensionAction
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignment.id}
|
||||
dueAt={assignment.due_at}
|
||||
canRequest={false}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
}
|
||||
>
|
||||
<RequestExtensionAction
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignment.id}
|
||||
dueAt={assignment.due_at}
|
||||
canRequest={!assignment.is_complete}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
}
|
||||
>
|
||||
<ReviewExtensionRequestAction
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignment.id}
|
||||
canReview={!assignment.is_complete}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TicketStatusActions({
|
||||
ticketId,
|
||||
assignment,
|
||||
}: TicketStatusActionsProps) {
|
||||
const action = getTicketAction(ticketId, assignment);
|
||||
|
||||
return action ? (
|
||||
<div className="space-y-3">
|
||||
{assignment?.is_overdue ? (
|
||||
<OverdueLotAlert lotId={assignment.id} dueAt={assignment.due_at} />
|
||||
) : null}
|
||||
{action}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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 TicketTimelineSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4" aria-label="Loading ticket timeline">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ListChecks } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
import { TicketDetectionPreview } from './TicketDetectionPreview';
|
||||
|
||||
interface TicketUnassignedIssuesCardProps {
|
||||
ticketId: string;
|
||||
videoId?: string | null;
|
||||
}
|
||||
|
||||
export function TicketUnassignedIssuesCard({
|
||||
ticketId,
|
||||
videoId,
|
||||
}: TicketUnassignedIssuesCardProps) {
|
||||
const [detectionCount, setDetectionCount] = useState<number>();
|
||||
|
||||
if (!videoId) return null;
|
||||
|
||||
return (
|
||||
<Card className={'w-full'}>
|
||||
<CardHeader className="min-w-0 px-3 sm:px-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ListChecks className="size-5 text-primary" />
|
||||
Unassigned Issues
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Review detections awaiting assignment and discard invalid AI
|
||||
results.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={'default'}>{detectionCount ?? 0} Unassigned</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className={'p-5'}>
|
||||
<div className={'min-w-0'}>
|
||||
<TicketDetectionPreview
|
||||
key={`${videoId}:unassigned`}
|
||||
ticketId={ticketId}
|
||||
videoId={videoId}
|
||||
assignmentStatus={'unassigned'}
|
||||
onDetectionCountChange={setDetectionCount}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { Users } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
export function AssignTicketAction({ ticketId }: { ticketId: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => router.push(ROUTES.TICKET_ASSIGN(ticketId))}
|
||||
>
|
||||
<Users />
|
||||
Assign Ticket
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowRight, History } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
TicketExtensionRequest,
|
||||
TicketExtensionRequestStatus,
|
||||
} from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
const statusConfig: Record<
|
||||
TicketExtensionRequestStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
pending: {
|
||||
label: 'Pending',
|
||||
className:
|
||||
'border-amber-200 bg-amber-100 text-amber-800 dark:border-amber-900 dark:bg-amber-950/50 dark:text-amber-300',
|
||||
},
|
||||
approved: {
|
||||
label: 'Approved',
|
||||
className:
|
||||
'border-emerald-200 bg-emerald-100 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/50 dark:text-emerald-300',
|
||||
},
|
||||
rejected: {
|
||||
label: 'Rejected',
|
||||
className:
|
||||
'border-red-200 bg-red-100 text-red-800 dark:border-red-900 dark:bg-red-950/50 dark:text-red-300',
|
||||
},
|
||||
cancelled: {
|
||||
label: 'Cancelled',
|
||||
className: 'border-border bg-muted text-muted-foreground',
|
||||
},
|
||||
};
|
||||
|
||||
function HistoryItem({ request }: { request: TicketExtensionRequest }) {
|
||||
const status = statusConfig[request.status];
|
||||
|
||||
return (
|
||||
<article className="space-y-3 rounded-lg border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Badge variant="outline" className={cn('border', status.className)}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Requested {formatDate(request.requested_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Requested By</p>
|
||||
<p className="mt-0.5 text-sm font-medium">
|
||||
{request.requested_by?.name ?? 'Assigned worker'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-3 rounded-lg bg-muted/40 p-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Previous Due Date</p>
|
||||
<p className="mt-1 text-sm font-medium">
|
||||
{formatDate(request.current_due_at)}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="mt-5 size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Requested Due Date</p>
|
||||
<p className="mt-1 text-sm font-medium">
|
||||
{formatDate(request.requested_due_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Reason</p>
|
||||
<p className="mt-1 text-sm leading-relaxed">{request.reason}</p>
|
||||
</div>
|
||||
|
||||
{request.resolved_at || request.resolved_by || request.decision_note ? (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<p className="text-xs font-semibold">Manager Response</p>
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
{request.resolved_by ? (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Resolved By</p>
|
||||
<p className="mt-0.5 font-medium">
|
||||
{request.resolved_by.name} · {request.resolved_by.role.name}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{request.resolved_at ? (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Resolved On</p>
|
||||
<p className="mt-0.5 font-medium">
|
||||
{formatDate(request.resolved_at)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{request.decision_note ? (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Manager Comments</p>
|
||||
<p className="mt-1 text-sm leading-relaxed">
|
||||
{request.decision_note}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExtensionRequestHistoryDialog({
|
||||
requests,
|
||||
}: {
|
||||
requests: TicketExtensionRequest[];
|
||||
}) {
|
||||
const sortedRequests = [...requests].sort(
|
||||
(left, right) =>
|
||||
new Date(right.requested_at).getTime() -
|
||||
new Date(left.requested_at).getTime(),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" className="w-full">
|
||||
<History />
|
||||
View Request History ({requests.length})
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Extension Request History</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review every deadline extension request and manager decision for
|
||||
this ticket assignment.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody className="space-y-3">
|
||||
{sortedRequests.map((request) => (
|
||||
<HistoryItem key={request.id} request={request} />
|
||||
))}
|
||||
</DialogBody>
|
||||
<DialogFooter showCloseButton />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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,45 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClipboardCheck } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
interface OpenRepairReviewActionProps {
|
||||
ticketId: string;
|
||||
hasPendingReview: boolean;
|
||||
pendingReviewCount: number;
|
||||
}
|
||||
|
||||
export function OpenRepairReviewAction({
|
||||
ticketId,
|
||||
hasPendingReview,
|
||||
pendingReviewCount,
|
||||
}: OpenRepairReviewActionProps) {
|
||||
const router = useRouter();
|
||||
const count = Math.max(0, pendingReviewCount ?? 0);
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!hasPendingReview}
|
||||
title={
|
||||
hasPendingReview
|
||||
? `${count} ${count === 1 ? 'repair is' : 'repairs are'} awaiting review`
|
||||
: 'No repairs are awaiting review'
|
||||
}
|
||||
onClick={() => router.push(ROUTES.TICKET_REVIEW(ticketId))}
|
||||
>
|
||||
<ClipboardCheck />
|
||||
Review Repairs
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 min-w-5 rounded-full px-1.5 font-semibold tabular-nums"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Clock3 } from 'lucide-react';
|
||||
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
|
||||
|
||||
export function RequestExtensionAction({
|
||||
ticketId,
|
||||
assignmentId,
|
||||
dueAt,
|
||||
canRequest,
|
||||
}: {
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
dueAt: string | null;
|
||||
canRequest: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TicketActionCard
|
||||
icon={Clock3}
|
||||
title={canRequest ? 'Request Extension' : 'Extension Request History'}
|
||||
contextLabel={`Assignment ${assignmentId}`}
|
||||
>
|
||||
<TicketExtensionRequestPanel
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignmentId}
|
||||
dueAt={dueAt}
|
||||
canRequest={canRequest}
|
||||
/>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
'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 { useRequestTicketExtensionMutation } from '../../../hooks/useTicketQueries';
|
||||
|
||||
const MAX_REASON_LENGTH = 300;
|
||||
|
||||
interface RequestExtensionFormProps {
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
currentDueAt: string | null;
|
||||
}
|
||||
|
||||
export function RequestExtensionForm({
|
||||
ticketId,
|
||||
assignmentId,
|
||||
currentDueAt,
|
||||
}: RequestExtensionFormProps) {
|
||||
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 requestExtensionMutation = useRequestTicketExtensionMutation(
|
||||
ticketId,
|
||||
assignmentId,
|
||||
);
|
||||
const isPending = requestExtensionMutation.isPending;
|
||||
const isRequestedDateValid = Boolean(
|
||||
requestedDueDate &&
|
||||
(!currentDueDate || requestedDueDate.getTime() > currentDueDate.getTime()),
|
||||
);
|
||||
const canSubmit =
|
||||
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,316 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
Check,
|
||||
Clock3,
|
||||
Loader2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type {
|
||||
TicketExtensionRequest,
|
||||
TicketExtensionRequestsResponse,
|
||||
} from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import {
|
||||
MAX_EXTENSION_DECISION_NOTE_LENGTH,
|
||||
useExtensionRequestDecision,
|
||||
} from '../../../../extension-requests/hooks/useExtensionRequestDecision';
|
||||
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
|
||||
import { ExtensionRequestHistoryDialog } from './ExtensionRequestHistoryDialog';
|
||||
import {
|
||||
ExtensionRequestDetails,
|
||||
getLatestRequest,
|
||||
} from './TicketExtensionRequestPanel';
|
||||
import { TicketActionCard } from './TicketActionCard';
|
||||
|
||||
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({
|
||||
ticketId,
|
||||
assignmentId,
|
||||
request,
|
||||
requests,
|
||||
}: {
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
request: TicketExtensionRequest;
|
||||
requests: TicketExtensionRequest[];
|
||||
}) {
|
||||
const {
|
||||
note,
|
||||
setNote,
|
||||
noteError,
|
||||
selectedAction,
|
||||
isPending,
|
||||
isRejectConfirmationOpen,
|
||||
setIsRejectConfirmationOpen,
|
||||
submitDecision,
|
||||
requestRejection,
|
||||
} = useExtensionRequestDecision({ ticketId, assignmentId });
|
||||
const extensionDays = getExtensionDays(request);
|
||||
|
||||
return (
|
||||
<TicketActionCard
|
||||
icon={CalendarClock}
|
||||
title="Extension Request Details"
|
||||
contextLabel={`Assignment ${assignmentId}`}
|
||||
>
|
||||
<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">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="extension-manager-note">Add Manager Comments</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required for rejection and optional for approval.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
id="extension-manager-note"
|
||||
value={note}
|
||||
onChange={(event) => {
|
||||
setNote(event.target.value);
|
||||
}}
|
||||
placeholder="Add a note explaining your decision..."
|
||||
disabled={isPending}
|
||||
aria-invalid={Boolean(noteError)}
|
||||
aria-describedby={
|
||||
noteError ? 'extension-manager-note-error' : undefined
|
||||
}
|
||||
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_EXTENSION_DECISION_NOTE_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
{noteError ? (
|
||||
<p
|
||||
id="extension-manager-note-error"
|
||||
className="text-xs text-destructive"
|
||||
>
|
||||
{noteError}
|
||||
</p>
|
||||
) : null}
|
||||
</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={isPending}
|
||||
onClick={requestRejection}
|
||||
>
|
||||
{selectedAction === 'reject' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<X />
|
||||
)}
|
||||
Reject Request
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => void submitDecision('approve')}
|
||||
>
|
||||
{selectedAction === 'approve' ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Check />
|
||||
)}
|
||||
Approve Extension
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ExtensionRequestHistoryDialog requests={requests} />
|
||||
|
||||
<AlertDialog
|
||||
open={isRejectConfirmationOpen}
|
||||
onOpenChange={setIsRejectConfirmationOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reject extension request?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to reject this extension request? This
|
||||
decision cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>No</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => void submitDecision('reject')}
|
||||
>
|
||||
Yes
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReviewExtensionRequestAction({
|
||||
ticketId,
|
||||
assignmentId,
|
||||
canReview,
|
||||
}: {
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
canReview: boolean;
|
||||
}) {
|
||||
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
||||
ticketId,
|
||||
assignmentId,
|
||||
);
|
||||
|
||||
if (extensionRequestQuery.isError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (extensionRequestQuery.isLoading) {
|
||||
return (
|
||||
<TicketActionCard
|
||||
icon={Clock3}
|
||||
title="Extension Request"
|
||||
contextLabel={`Assignment ${assignmentId}`}
|
||||
>
|
||||
<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;
|
||||
|
||||
const requests = extensionRequestQuery.data?.items ?? [];
|
||||
|
||||
if (!pendingRequest || !canReview) {
|
||||
const latestRequest = extensionRequestQuery.data
|
||||
? getLatestRequest(extensionRequestQuery.data)
|
||||
: null;
|
||||
|
||||
if (!latestRequest) return null;
|
||||
|
||||
return (
|
||||
<TicketActionCard
|
||||
icon={CalendarClock}
|
||||
title="Extension Request"
|
||||
contextLabel={`Assignment ${assignmentId}`}
|
||||
>
|
||||
<ExtensionRequestDetails request={latestRequest} requests={requests} />
|
||||
</TicketActionCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExtensionReviewForm
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignmentId}
|
||||
request={pendingRequest}
|
||||
requests={requests}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TicketActionCardProps {
|
||||
icon: LucideIcon;
|
||||
iconClassName?: string;
|
||||
title: string;
|
||||
contextLabel?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TicketActionCard({
|
||||
icon: Icon,
|
||||
iconClassName,
|
||||
title,
|
||||
contextLabel,
|
||||
children,
|
||||
}: TicketActionCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="flex min-w-0 items-center gap-2 text-base">
|
||||
<Icon className={cn('shrink-0 text-primary', iconClassName)} />
|
||||
<span className="truncate">{title}</span>
|
||||
</CardTitle>
|
||||
{contextLabel ? (
|
||||
<Badge variant="default" className="shrink-0">
|
||||
{contextLabel}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Loader2,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
TicketExtensionRequest,
|
||||
TicketExtensionRequestsResponse,
|
||||
} from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
|
||||
import { ExtensionRequestHistoryDialog } from './ExtensionRequestHistoryDialog';
|
||||
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: 'This 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: 'This extension request was 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: 'This extension request was 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;
|
||||
}
|
||||
>;
|
||||
|
||||
export 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)));
|
||||
}
|
||||
|
||||
export function ExtensionRequestDetails({
|
||||
request,
|
||||
requests,
|
||||
onRequestAnother,
|
||||
}: {
|
||||
request: TicketExtensionRequest;
|
||||
requests: TicketExtensionRequest[];
|
||||
onRequestAnother?: () => void;
|
||||
}) {
|
||||
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 By"
|
||||
value={request.requested_by?.name ?? 'Assigned worker'}
|
||||
/>
|
||||
<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}
|
||||
{request.decision_note ? (
|
||||
<DetailRow
|
||||
label="Manager Comments"
|
||||
value={request.decision_note}
|
||||
/>
|
||||
) : 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>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ExtensionRequestHistoryDialog requests={requests} />
|
||||
{request.status !== 'pending' && onRequestAnother ? (
|
||||
<Button type="button" className="w-full" onClick={onRequestAnother}>
|
||||
<Plus />
|
||||
Request Another Extension
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketExtensionRequestPanel({
|
||||
ticketId,
|
||||
assignmentId,
|
||||
dueAt,
|
||||
canRequest,
|
||||
}: {
|
||||
ticketId: string;
|
||||
assignmentId: number;
|
||||
dueAt: string | null;
|
||||
canRequest: boolean;
|
||||
}) {
|
||||
const [isRequestFormOpen, setIsRequestFormOpen] = useState(false);
|
||||
const extensionRequestQuery = useTicketExtensionRequestQuery(
|
||||
ticketId,
|
||||
assignmentId,
|
||||
);
|
||||
|
||||
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) {
|
||||
if (!canRequest) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No extension request history is available for this ticket assignment.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RequestExtensionForm
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignmentId}
|
||||
currentDueAt={dueAt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRequestFormOpen && latestRequest.status !== 'pending') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsRequestFormOpen(false)}
|
||||
>
|
||||
Back to latest request
|
||||
</Button>
|
||||
<RequestExtensionForm
|
||||
ticketId={ticketId}
|
||||
assignmentId={assignmentId}
|
||||
currentDueAt={dueAt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExtensionRequestDetails
|
||||
request={latestRequest}
|
||||
requests={extensionRequestQuery.data?.items ?? []}
|
||||
onRequestAnother={
|
||||
canRequest ? () => setIsRequestFormOpen(true) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
125
src/app/(modules)/ticket/[ticketId]/page.tsx
Normal file
125
src/app/(modules)/ticket/[ticketId]/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
import { TicketDetailHeader } from './components/TicketDetailHeader';
|
||||
import {
|
||||
TicketOverviewCardSkeleton,
|
||||
TicketOverviewHeaderSkeleton,
|
||||
} from './components/TicketOverviewSkeleton';
|
||||
import { TicketAssignmentsCard } from './components/TicketAssignmentsCard';
|
||||
import { TicketOverviewCard } from './components/TicketOverviewCard';
|
||||
import { TicketStatusActions } from './components/TicketStatusActions';
|
||||
import { TicketHistoryCard } from './components/TicketHistoryCard';
|
||||
import { TicketUnassignedIssuesCard } from './components/TicketUnassignedIssuesCard';
|
||||
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
|
||||
import {
|
||||
useTicketAssignmentsQuery,
|
||||
useTicketOverviewQuery,
|
||||
} from '../hooks/useTicketQueries';
|
||||
|
||||
export default function TicketDetailPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { ticketId } = useParams() as { ticketId: string };
|
||||
const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>();
|
||||
const listParams = new URLSearchParams();
|
||||
|
||||
for (const key of ['page', 'limit', 'segment']) {
|
||||
const value = searchParams.get(key);
|
||||
if (value) listParams.set(key, value);
|
||||
}
|
||||
|
||||
const listSearch = listParams.toString();
|
||||
const ticketListHref = listSearch
|
||||
? `${ROUTES.TICKET}?${listSearch}`
|
||||
: ROUTES.TICKET;
|
||||
|
||||
const overviewQuery = useTicketOverviewQuery(ticketId);
|
||||
const overview = overviewQuery.data;
|
||||
const assignmentsQuery = useTicketAssignmentsQuery(
|
||||
ticketId,
|
||||
overview?.assignments,
|
||||
);
|
||||
const assignments = (assignmentsQuery.data?.items ?? []).filter(
|
||||
(assignment) => assignment.item_count > 0,
|
||||
);
|
||||
const activeAssignment =
|
||||
assignments.find((assignment) => assignment.id === selectedAssignmentId) ??
|
||||
assignments[0];
|
||||
const activeAssignmentId = activeAssignment?.id;
|
||||
|
||||
useTicketDetailEvents(ticketId, 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(ticketListHref)}
|
||||
>
|
||||
<ArrowLeft />
|
||||
Back to Tickets
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative z-10 min-w-0 max-w-full 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} backHref={ticketListHref} />
|
||||
) : (
|
||||
<TicketOverviewHeaderSkeleton />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 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="min-w-0 space-y-5 xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
{overview ? (
|
||||
<TicketOverviewCard ticket={overview} />
|
||||
) : (
|
||||
<TicketOverviewCardSkeleton />
|
||||
)}
|
||||
{overview ? (
|
||||
<TicketAssignmentsCard
|
||||
ticketId={ticketId}
|
||||
videoId={overview.video_id ?? overview.video?.id}
|
||||
initialAssignments={overview.assignments}
|
||||
selectedAssignmentId={activeAssignmentId}
|
||||
onAssignmentChange={setSelectedAssignmentId}
|
||||
/>
|
||||
) : null}
|
||||
{overview ? (
|
||||
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
|
||||
<TicketUnassignedIssuesCard
|
||||
ticketId={ticketId}
|
||||
videoId={overview.video_id ?? overview.video?.id}
|
||||
/>
|
||||
</PermissionGuard>
|
||||
) : null}
|
||||
<TicketHistoryCard ticketId={ticketId} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-3 self-start xl:min-h-0 xl:self-stretch xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
{activeAssignment ? (
|
||||
<TicketStatusActions
|
||||
ticketId={ticketId}
|
||||
assignment={activeAssignment}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user