116 Commits

Author SHA1 Message Date
c13afa7554 feat(ui): improve collapsed sidebar and status action icons 2026-08-10 16:08:50 +05:30
8033450321 fix(extension-requests): replace lot terminology with assignment 2026-08-10 11:01:56 +05:30
98de8e948b feat(ticket): preserve list state across detail navigation 2026-08-07 17:56:55 +05:30
3697119656 feat(ticket): show ticket name in assignment header 2026-08-07 17:10:31 +05:30
e268b55392 style(ui): add modern thin scrollbar styling 2026-08-07 16:47:15 +05:30
da5b1009e3 feat(ticket): make assigned issue images collapsible 2026-08-07 16:43:44 +05:30
3d04995f97 perf(ticket): paginate and cache detection previews 2026-08-07 16:38:38 +05:30
d3069e1f25 feat(ui): improve review actions, table headers, and detection previews 2026-08-07 16:16:03 +05:30
47697b4421 feat(tickets): add unassigned issue triage to ticket details
- embed detection previews within work assignments
- allow discarding only unassigned detections
- improve ticket detail labels, counts, and layout
2026-08-07 15:31:47 +05:30
c8899c191e feat(ticket): show open and closed status in ticket list 2026-08-06 17:10:10 +05:30
8ab9240b77 style(ticket): use primary variants for overview actions and badges 2026-08-06 15:56:40 +05:30
f6436862c0 refactor(ticket): move back navigation before detail header 2026-08-06 15:19:56 +05:30
0ff5792670 fix(review): show generic Pothole Review heading 2026-08-06 10:46:33 +05:30
0349891f52 feat(tickets): show pending repair review count in ticket details 2026-08-05 18:17:31 +05:30
7ee621b790 feat(tickets): add summary cards above ticket list 2026-08-05 16:57:54 +05:30
44e9dc4ec8 feat(ui): add ticket attention badges and sharpen border radius 2026-08-05 16:57:12 +05:30
d261404903 feat(ui): add ticket filter reset and update border radius 2026-08-05 11:58:45 +05:30
bad9c50dbf feat(ticket): use assignment detections API for unassigned issues 2026-08-04 15:46:22 +05:30
6169bbdc28 feat(ticket): add shared extension request history for workers and managers 2026-08-03 16:04:15 +05:30
0a4d9512cb feat(ticket-actions): show backend-driven overdue lot warning 2026-07-31 19:38:00 +05:30
faefd34cd4 feat(ticket-actions): show lot context on extension cards 2026-07-31 19:37:20 +05:30
514b42ea91 refactor(ticket): use video detections for assignment map and remove coordinates API 2026-07-31 19:02:25 +05:30
9cd7186357 refactor(ticket): remove defect-classes endpoint and related hooks 2026-07-31 17:25:53 +05:30
87fa04e1b5 feat(extension-requests): add manager queue with quick decisions 2026-07-31 16:33:20 +05:30
2caeae7c91 refactor(ticket): make details lot-based and remove class-detail flow 2026-07-31 12:49:38 +05:30
44d3be5d27 feat(ticket): add audit timeline and safeguard extension rejection 2026-07-31 11:51:32 +05:30
58098af0fe refactor(ticket): make repair review ticket-level 2026-07-30 20:29:49 +05:30
1cbad4074c feat(ticket): show repair review action and hide audit timeline 2026-07-30 17:08:27 +05:30
5e0d641575 fix(ticket): hide details for empty assignment lots 2026-07-30 16:43:16 +05:30
e894676d3b fix(ticket): gate AI detection summary behind assign permission 2026-07-29 19:07:22 +05:30
31839d1c70 fix(ticket): stabilize detection preview index during loading 2026-07-29 18:58:24 +05:30
e8f2955acf feat(ticket): scope issue preview to selected assignment lot 2026-07-29 18:43:22 +05:30
5e66b70611 feat(ticket): scope issue preview to selected assignment lot 2026-07-29 15:44:19 +05:30
7ffe3f0708 feat(ticket): add assignment lots card to ticket detail 2026-07-29 12:16:27 +05:30
8e8803396d feat(ticket): support criteria-based global ticket assignment 2026-07-28 18:47:29 +05:30
0601322361 feat(ticket): add issues map to ticket assignment page 2026-07-28 12:19:38 +05:30
20f611f35a feat(ticket): add global ticket assignment page 2026-07-27 18:31:12 +05:30
224361f144 fix(ui): prevent card borders from being clipped 2026-07-24 15:17:04 +05:30
77b6755d05 refactor(ui): use icon fallback for sidebar user avatar 2026-07-23 10:16:34 +05:30
2f25fd49be fix(ticket): improve ticket detail page scrolling layout 2026-07-22 19:19:50 +05:30
3891500cf2 feat(upload): open upload details dialog from table rows 2026-07-22 19:12:52 +05:30
062e89fcba refactor(ui): update branding assets and clean up repair proof UI 2026-07-22 18:47:00 +05:30
725c256791 refactor(api): simplify backend proxy to api and biz paths 2026-07-22 17:07:05 +05:30
5d3dac0b21 refactor(api): add same-origin backend proxy for auth cookies 2026-07-22 16:18:17 +05:30
e077467bce refactor(ticket): remove web repair upload flow 2026-07-22 15:41:38 +05:30
e94da54aa9 feat(ticket): add issue-by-issue repair review page 2026-07-22 14:46:34 +05:30
76c87811d8 feat(tickets): add repair proof status filter for class issues 2026-07-21 19:24:40 +05:30
830883c6b7 fix(auth): prevent logout on transient API failures 2026-07-13 19:29:04 +05:30
68829dc72b feat(tickets): add detection discard workflow 2026-07-13 19:27:54 +05:30
9d7f956c3b feat(ticket): show assigned contractor details on in-progress tickets 2026-07-10 18:27:26 +05:30
0026824f84 Assign to Contractor // here in due date also change the year to 20 2026-07-10 16:17:53 +05:30
a18eb64dac feat(ticket): enable circular detection preview navigation 2026-07-10 16:12:30 +05:30
6c71d8f8a7 fix(ticket): widen extension due date picker range 2026-07-10 15:56:00 +05:30
0e2172366b refactor(ui): replace theme dropdown with toggle button 2026-07-10 13:38:17 +05:30
56180f8d18 style(ui): use pointer cursor for dropdown menu items 2026-07-10 13:25:28 +05:30
5269b29aac feat(ticket): use grid picker for extension due date 2026-07-10 12:24:44 +05:30
9fe7109faf style: widen plan and role sheet panels 2026-07-10 11:31:36 +05:30
9720546455 refactor(ticket): update ticket detail skeleton loaders 2026-07-10 10:42:40 +05:30
8727963c54 style: refine shared UI primitives and management flows 2026-07-09 23:06:25 +05:30
0e8108b875 feat: modify table design 2026-07-09 17:14:37 +05:30
87dde1c8d1 feat: sync codebase with latest radix pattern 2026-07-09 17:07:11 +05:30
a376558b2f refactor(layout): improve module header and sidebar toggle UX 2026-07-09 11:54:16 +05:30
4fe93460d9 feat(ui): use grid calendar picker for modern ui look 2026-07-09 10:31:36 +05:30
cbfb6e040b refactor(ticket): rename View Analysis button to View Video 2026-07-08 17:51:42 +05:30
7a82e17bbb feat(results): add live annotation overlay on raw video playback 2026-07-08 17:24:20 +05:30
23186ffd29 feat(ticket): add paginated class detection preview with image and map 2026-07-08 10:48:03 +05:30
fb9a654aea feat(results): replace video playback with detection image preview and map 2026-07-07 15:36:27 +05:30
d88978a830 chore: class location change 2026-07-07 12:02:29 +05:30
ba9731de86 refactor: refactor report proff section 2026-07-06 17:04:05 +05:30
c0f3a718ef feat(upload): display no-defects analysis result 2026-07-06 14:17:32 +05:30
14220445ad chore: ignore auto-generated next-env.d.ts 2026-07-04 00:57:42 +05:30
79272c7abc feat(ticket): add extension requests and improve repair proof upload UI 2026-07-04 00:56:08 +05:30
26e15360f7 feat(ticket): add due date picker and contractor details to assign flow 2026-07-03 16:07:08 +05:30
b6a0f04f1e Merge branch 'videoplayer' of https://git.convexsol.co/santasri.pachhal/visionRoad-ui into feat/mod 2026-07-03 10:36:32 +05:30
9cf4f0e795 feat(ticket): redesign overview with AI summary, location, and shared defect visuals 2026-07-03 10:30:59 +05:30
fdcfc3a8db feat: add action to open ticket action in upload 2026-07-02 15:44:26 +05:30
278b9591d7 feat: added sse for upload section 2026-07-02 14:28:28 +05:30
02adbb4c10 feat: introduce custom video player for video analysis 2026-07-02 14:23:00 +05:30
53fe4e11fe feat: change interface of analysis video log 2026-07-02 13:57:18 +05:30
2eb58856af feat(ticket): add section icons, theme-aware system timeline, and nested sidebar active routes 2026-07-02 11:33:53 +05:30
c73ba0a01f feat(ticket): redesign audit timeline with structured history events 2026-07-01 22:23:49 +05:30
1a94ff80e6 fix: scrollbar gutter shifting issue 2026-07-01 17:28:25 +05:30
b1174c8825 feat(ticket): split overview/class detail queries and add loading skeletons 2026-07-01 16:13:42 +05:30
d4a7a7c139 chore(ticket) : add uploader email to ticket list 2026-07-01 14:49:48 +05:30
57b33bd061 feat(upload): add upload status column and rename analysis status 2026-07-01 12:38:53 +05:30
81df3a9973 refactor: remove all trace of status from ticket 2026-07-01 10:14:19 +05:30
09ce57c9e0 refactor(ticket): remove ticket status from list events and use assignment status for closure 2026-06-30 19:30:44 +05:30
ea684fc9fe refactor(upload): replace upload card views with list table and addition details in table 2026-06-30 18:03:50 +05:30
ec77696157 refactor: simplify ticket lifecycle flow, remove processing and completed state 2026-06-30 13:01:08 +05:30
adff2e2c41 refactor: standardize ticket detail card and tab styling 2026-06-29 16:47:22 +05:30
3e5dbd5893 fix: keep ticket event stream active across status changes 2026-06-26 19:01:15 +05:30
11f95c4727 fix: hide assign action while ticket is processing 2026-06-26 17:58:25 +05:30
e805cdee09 fix: refresh ticket detail data with the active defect class, select the emitted defect class after analysis completes, and animate the processing loader icon. 2026-06-26 16:31:50 +05:30
57461a05f2 refactor(results): support class-based detection summaries 2026-06-26 14:55:29 +05:30
8a080cb051 fix: ticket detail api fallback handle 2026-06-26 12:46:04 +05:30
063f963fd4 refactor(ticket): extract defect class status metadata 2026-06-26 10:38:38 +05:30
1f8ca87bd0 feat(ticket): support defect-class ticket workflows 2026-06-26 00:50:52 +05:30
8665762ba2 chore: change worker name with contractor 2026-06-24 15:42:22 +05:30
81a53b692d chore: remove dynamic title from result page and change favicon 2026-06-24 15:27:25 +05:30
82050372c6 feat(branding): add RoadMonitor logo and update auth/sidebar branding 2026-06-24 12:54:33 +05:30
2fc01f74e2 refactor(ticket): remove redundant View action from ticket table 2026-06-24 00:55:59 +05:30
67237982c8 feat(results): add video results skeleton and refresh detection summary UI 2026-06-24 00:36:27 +05:30
c1c81687d8 feat(ticket): add skeleton loading state for ticket detail page 2026-06-24 00:22:34 +05:30
81b68fdb61 feat(upload): preview uploaded videos with ProtectedVideoPlayer 2026-06-24 00:14:18 +05:30
5439a5ff45 fix(ui): add pointer cursors to repair evidence and multi-select controls 2026-06-23 23:51:54 +05:30
6e16d4f2c8 fix(auth): clear query cache when user logs out from nav menu 2026-06-23 23:19:09 +05:30
dfb95c5213 fix(ui): use pointer cursor on interactive controls and not-allowed when disabled 2026-06-23 23:14:32 +05:30
5e4cf61d6e refactor: remove static routes and stop multiple token api call in sse 2026-06-23 22:48:34 +05:30
b282fdf56d feat: add shared validation for chainage and coordinates 2026-06-23 18:32:38 +05:30
066374bebc feat(forms): add PhoneInput and unify module form validation UX 2026-06-23 16:28:28 +05:30
27cf5c9d3f feat(clients): align client form with first and last name API fields 2026-06-23 12:56:23 +05:30
a731fca5e0 refactor: video section ui modification 2026-06-23 10:29:09 +05:30
664d80809a refactor: modify route guard pattern 2026-06-19 18:36:05 +05:30
fffd8e3e12 chore: ticket section implement sse 2026-06-19 18:06:11 +05:30
a567a52e26 feat(ticket): refine repair report and history timeline UI 2026-06-19 11:22:41 +05:30
8c3d19a429 feat: ticket system base implementation 2026-06-19 01:19:01 +05:30
272 changed files with 20187 additions and 5262 deletions

View File

@@ -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
View File

@@ -44,6 +44,7 @@ Thumbs.db
# TypeScript
*.tsbuildinfo
next-env.d.ts
# Test / coverage
coverage/

View File

@@ -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
View File

@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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%;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

5
public/logo.svg Normal file
View 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

View File

@@ -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>

View File

@@ -23,7 +23,7 @@ export default function LoginPage() {
<section className="w-full max-w-sm space-y-7">
<header className="space-y-2 text-center md:text-left">
<p className="text-sm font-medium text-muted-foreground">
VisionRoad
RoadMonitor
</p>
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
<p className="text-sm text-muted-foreground">

View File

@@ -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>

View 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>
);
}

View File

@@ -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>
),
},
{

View File

@@ -1,13 +1,19 @@
'use client';
import type { ComponentProps } from 'react';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { useRef, type ComponentProps } from 'react';
import {
Controller,
type Control,
type FieldErrors,
type UseFormRegister,
} from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
import { FormField, PhoneInput } from '@/components/form';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
@@ -22,6 +28,7 @@ interface ClientSheetProps {
onOpenChange: (open: boolean) => void;
clientId?: number;
register: UseFormRegister<ClientFormValues>;
control: Control<ClientFormValues>;
errors: FieldErrors<ClientFormValues>;
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
@@ -34,13 +41,20 @@ export function ClientSheet({
onOpenChange,
clientId,
register,
control,
errors,
isSubmitted,
onSubmit,
canSubmit,
isSaving,
}: ClientSheetProps) {
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
const firstNameErrorMessage = isSubmitted
? errors.first_name?.message
: undefined;
const lastNameErrorMessage = isSubmitted
? errors.last_name?.message
: undefined;
const emailErrorMessage = isSubmitted ? errors.email?.message : undefined;
const landlineErrorMessage = isSubmitted
? errors.landline_number?.message
@@ -58,33 +72,53 @@ export function ClientSheet({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>
{clientId ? 'Edit Client' : 'Create Client'}
</DialogTitle>
<DialogDescription className="text-sm">
<DialogDescription>
Manage company and primary contact details.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="max-h-[58vh] space-y-5 overflow-y-auto px-6 py-4">
<form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<DialogBody className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="client-name"
label="Client Name"
id="client-first-name"
label="First Name"
required
error={nameErrorMessage}
error={firstNameErrorMessage}
>
<Input
id="client-name"
placeholder="Enter client name"
aria-invalid={!!nameErrorMessage}
{...register('name')}
id="client-first-name"
placeholder="Enter first name"
aria-invalid={!!firstNameErrorMessage}
{...register('first_name')}
/>
</FormField>
<FormField
id="client-last-name"
label="Last Name"
required
error={lastNameErrorMessage}
>
<Input
id="client-last-name"
placeholder="Enter last name"
aria-invalid={!!lastNameErrorMessage}
{...register('last_name')}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="client-email"
label="Company Email"
@@ -94,14 +128,12 @@ export function ClientSheet({
<Input
id="client-email"
type="email"
placeholder="info@example.com"
placeholder="Enter company email"
aria-invalid={!!emailErrorMessage}
{...register('email')}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="landline-number"
label="Landline Number"
@@ -110,42 +142,50 @@ export function ClientSheet({
>
<Input
id="landline-number"
placeholder="+91-22-12345678"
placeholder="Enter landline number"
aria-invalid={!!landlineErrorMessage}
{...register('landline_number')}
/>
</FormField>
<FormField
id="address"
label="Address"
required
error={addressErrorMessage}
>
<Input
id="address"
placeholder="12 MG Road, Mumbai, MH 400001"
aria-invalid={!!addressErrorMessage}
{...register('address')}
/>
</FormField>
</div>
<FormField
id="address"
label="Address"
required
error={addressErrorMessage}
>
<Input
id="address"
placeholder="Enter address"
aria-invalid={!!addressErrorMessage}
{...register('address')}
/>
</FormField>
<div className="grid gap-4 md:grid-cols-3">
<FormField id="gst" label="GST">
<Input
id="gst"
placeholder="27ABCDE1234F1Z5"
placeholder="Enter GST number"
{...register('gst')}
/>
</FormField>
<FormField id="pan" label="PAN">
<Input id="pan" placeholder="ABCDE1234F" {...register('pan')} />
<Input
id="pan"
placeholder="Enter PAN number"
{...register('pan')}
/>
</FormField>
<FormField id="tan" label="TAN">
<Input id="tan" placeholder="MUMA12345B" {...register('tan')} />
<Input
id="tan"
placeholder="Enter TAN number"
{...register('tan')}
/>
</FormField>
</div>
@@ -170,11 +210,19 @@ export function ClientSheet({
required
error={contactPhoneErrorMessage}
>
<Input
id="contact-phone-number"
placeholder="+919876543210"
aria-invalid={!!contactPhoneErrorMessage}
{...register('contact_phone_number')}
<Controller
control={control}
name="contact_phone_number"
render={({ field }) => (
<PhoneInput
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Enter contact phone"
aria-invalid={!!contactPhoneErrorMessage}
portalContainer={dropdownPortalRef}
/>
)}
/>
</FormField>
@@ -187,15 +235,15 @@ export function ClientSheet({
<Input
id="contact-email"
type="email"
placeholder="contact@example.com"
placeholder="Enter contact email"
aria-invalid={!!contactEmailErrorMessage}
{...register('contact_email')}
/>
</FormField>
</div>
</div>
</DialogBody>
<DialogFooter className="shrink-0 border-t px-6 py-4">
<DialogFooter>
<Button
type="button"
variant="outline"
@@ -213,6 +261,8 @@ export function ClientSheet({
</Button>
</DialogFooter>
</form>
<div ref={dropdownPortalRef} />
</DialogContent>
</Dialog>
);

View File

@@ -1,45 +1,43 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { isValidPhoneNumber } from 'libphonenumber-js';
import { useCallback } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { Client } from '@/types';
import { useSaveClientMutation } from './useClientMutations';
const phoneSchema = z
.string()
.trim()
.min(1, 'Phone number is required')
.regex(/^\+(?:[0-9] ?|-){6,18}[0-9]$/, 'Please enter a valid phone number');
const requiredText = (message: string) => z.string().trim().min(1, message);
const optionalTaxIdSchema = z.string().trim();
const clientFormSchema = z.object({
id: z.number().optional(),
name: z.string().trim().min(1, 'Client name is required'),
email: z.string().trim().min(1, 'Email is required').email('Invalid email'),
landline_number: phoneSchema,
address: z.string().trim().min(1, 'Address is required'),
first_name: requiredText('First name is required'),
last_name: requiredText('Last name is required'),
email: requiredText('Email is required').email('Invalid email'),
landline_number: requiredText('Landline number is required'),
address: requiredText('Address is required'),
gst: optionalTaxIdSchema,
pan: optionalTaxIdSchema,
tan: optionalTaxIdSchema,
contact_name: z.string().trim().min(1, 'Contact name is required'),
contact_phone_number: phoneSchema,
contact_email: z
.string()
.trim()
.min(1, 'Contact email is required')
.email('Invalid contact email'),
contact_name: requiredText('Contact name is required'),
contact_phone_number: requiredText('Contact phone number is required').refine(
(value) => isValidPhoneNumber(value),
'Enter a valid phone number',
),
contact_email: requiredText('Contact email is required').email(
'Invalid contact email',
),
is_active: z.boolean(),
});
export type ClientFormValues = z.infer<typeof clientFormSchema>;
const defaultValues: ClientFormValues = {
name: '',
first_name: '',
last_name: '',
email: '',
landline_number: '',
address: '',
@@ -73,7 +71,8 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
});
const clientId = useWatch({ control, name: 'id' });
const name = useWatch({ control, name: 'name' }) || '';
const firstName = useWatch({ control, name: 'first_name' }) || '';
const lastName = useWatch({ control, name: 'last_name' }) || '';
const email = useWatch({ control, name: 'email' }) || '';
const landlineNumber = useWatch({ control, name: 'landline_number' }) || '';
const address = useWatch({ control, name: 'address' }) || '';
@@ -82,7 +81,8 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
useWatch({ control, name: 'contact_phone_number' }) || '';
const contactEmail = useWatch({ control, name: 'contact_email' }) || '';
const canSubmit =
name.trim().length > 0 &&
firstName.trim().length > 0 &&
lastName.trim().length > 0 &&
email.trim().length > 0 &&
landlineNumber.trim().length > 0 &&
address.trim().length > 0 &&
@@ -90,18 +90,7 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
contactPhoneNumber.trim().length > 0 &&
contactEmail.trim().length > 0;
const handleSubmit = submitForm(
(values) => saveMutation.mutate(values),
(formErrors) => {
const firstError = Object.values(formErrors).find(
(error) => error?.message,
);
if (firstError?.message) {
toast.error(firstError.message);
}
},
);
const handleSubmit = submitForm((values) => saveMutation.mutate(values));
const openCreate = useCallback(() => {
reset(defaultValues);
@@ -111,7 +100,8 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
(client: Client) => {
reset({
id: client.id,
name: client.name || '',
first_name: client.first_name || '',
last_name: client.last_name || '',
email: client.email || '',
landline_number: client.landline_number || '',
address: client.address || '',
@@ -129,6 +119,7 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
return {
register,
control,
handleSubmit,
reset,
openCreate,

View File

@@ -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(),

View 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>
);
}

View File

@@ -48,6 +48,7 @@ export default function ClientsPage() {
clientForm;
const {
register,
control,
handleSubmit,
clientId,
errors,
@@ -134,6 +135,7 @@ export default function ClientsPage() {
onOpenChange={setIsSheetOpen}
clientId={clientId}
register={register}
control={control}
errors={errors}
isSubmitted={isSubmitted}
onSubmit={handleSubmit}

View File

@@ -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>
</>
);
}

View File

@@ -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),
},
],
[],
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
}}
/>
);
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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),
});
}

View 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>
);
}

View 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>
);
}

View File

@@ -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,
};

View File

@@ -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>

View File

@@ -10,6 +10,7 @@ import { ProjectSelect } from '@/components/lookups/ProjectSelect';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
@@ -62,20 +63,24 @@ export function PackageDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-2xl"
className="sm:max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
<DialogHeader>
<DialogTitle>
{packageId ? 'Edit Package' : 'Create Package'}
</DialogTitle>
<DialogDescription className="text-sm">
<DialogDescription>
Manage package details and optional chainage range.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
<form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<DialogBody className="space-y-6">
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Basic details</h3>
@@ -120,7 +125,7 @@ export function PackageDialog({
<FormField id="package-region" label="Region">
<Input
id="package-region"
placeholder="North Zone"
placeholder="Enter region"
{...register('region')}
/>
</FormField>
@@ -131,21 +136,22 @@ export function PackageDialog({
<div className="space-y-1">
<h3 className="text-sm font-semibold">Chainage range</h3>
<p className="text-sm text-muted-foreground">
Optional start and end kilometre values.
start and end kilometre values.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="package-start"
label="Start (km)"
label="Start Chainage"
error={startErrorMessage}
>
<Input
id="package-start"
type="number"
step="0.01"
placeholder="0.00"
step="1"
min="0"
placeholder="Enter start chainage"
aria-invalid={!!startErrorMessage}
{...register('chainage_start_km')}
/>
@@ -153,23 +159,24 @@ export function PackageDialog({
<FormField
id="package-end"
label="End (km)"
label="End Chainage"
error={endErrorMessage}
>
<Input
id="package-end"
type="number"
step="0.01"
placeholder="0.00"
step="1"
min="0"
placeholder="Enter end chainage"
aria-invalid={!!endErrorMessage}
{...register('chainage_end_km')}
/>
</FormField>
</div>
</section>
</div>
</DialogBody>
<DialogFooter className="shrink-0 border-t px-6 py-4">
<DialogFooter>
<Button
type="button"
variant="outline"
@@ -179,7 +186,9 @@ export function PackageDialog({
Cancel
</Button>
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{packageId ? 'Update Package' : 'Create Package'}
</Button>
</DialogFooter>

View File

@@ -7,29 +7,25 @@ import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import {
optionalIntegerNumber,
validateChainageRange,
} from '@/lib/validation/numberField';
import { packageService } from '@/services/api';
import type { Package, PackageCreate, PackageUpdate } from '@/types';
import { packageKeys } from '../queries/packageKeys';
const optionalNumber = z.string().refine(
(value) => {
if (!value.trim()) {
return true;
}
return Number.isFinite(Number(value));
},
{ message: 'Enter a valid number' },
);
const packageFormSchema = z.object({
id: z.string().optional(),
project_id: z.string().trim().min(1, 'Project is required'),
name: z.string().trim().min(1, 'Package name is required'),
region: z.string().optional(),
chainage_start_km: optionalNumber,
chainage_end_km: optionalNumber,
});
const packageFormSchema = z
.object({
id: z.string().optional(),
project_id: z.string().trim().min(1, 'Project is required'),
name: z.string().trim().min(1, 'Package name is required'),
region: z.string().optional(),
chainage_start_km: optionalIntegerNumber,
chainage_end_km: optionalIntegerNumber,
})
.superRefine(validateChainageRange);
export type PackageFormValues = z.infer<typeof packageFormSchema>;
@@ -103,18 +99,7 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
},
});
const onSubmit = handleSubmit(
(values) => saveMutation.mutate(values),
(formErrors) => {
if (formErrors.project_id?.message) {
toast.error(formErrors.project_id.message);
return;
}
if (formErrors.name?.message) {
toast.error(formErrors.name.message);
}
},
);
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
const openCreate = useCallback(() => {
reset(defaultValues);

View 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>
);
}

View File

@@ -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;
@@ -122,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>
</>
);
}

View File

@@ -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" />;
}

View File

@@ -67,7 +67,7 @@ export function PlanSheet({
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full gap-0 p-0 sm:max-w-3xl">
<SheetContent className="w-full! gap-0 p-0 sm:max-w-3xl! lg:max-w-4xl!">
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
{planId ? 'Edit Plan' : 'Create Plan'}
@@ -77,7 +77,11 @@ export function PlanSheet({
</SheetDescription>
</SheetHeader>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField
@@ -242,7 +246,7 @@ export function PlanSheet({
<label className="flex items-center gap-2">
<input
type="checkbox"
className="size-4 rounded border-border accent-primary"
className="size-4 rounded-lg border-border accent-primary"
{...register('is_active')}
/>
Active
@@ -250,7 +254,7 @@ export function PlanSheet({
<label className="flex items-center gap-2">
<input
type="checkbox"
className="size-4 rounded border-border accent-primary"
className="size-4 rounded-lg border-border accent-primary"
{...register('is_custom')}
/>
Custom plan
@@ -269,7 +273,7 @@ export function PlanSheet({
}
>
{isPermissionsLoading ? (
<div className="rounded-md border p-6 text-muted-foreground">
<div className="rounded-lg border p-6 text-muted-foreground">
Loading permissions...
</div>
) : (

View File

@@ -8,7 +8,6 @@ import {
import type { PermissionTreeItem, Plan } from '@/types';
import { useCallback } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { useSavePlanMutation } from './usePlanMutations';
@@ -104,31 +103,20 @@ export function usePlanForm({
price.trim().length > 0 &&
permissionIds.length > 0;
const onSubmit = handleSubmit(
(values) => {
saveMutation.mutate({
...values,
name: values.name.trim(),
slug: values.slug.trim(),
description: values.description.trim(),
price: values.price.trim(),
trial_days: toNumber(values.trial_days),
max_projects: toNumber(values.max_projects),
max_organizations: toNumber(values.max_organizations),
max_users: toNumber(values.max_users),
max_roles: toNumber(values.max_roles),
});
},
(formErrors) => {
const firstMessage = Object.values(formErrors).find(
(error) => error?.message,
)?.message;
if (firstMessage) {
toast.error(String(firstMessage));
}
},
);
const onSubmit = handleSubmit((values) => {
saveMutation.mutate({
...values,
name: values.name.trim(),
slug: values.slug.trim(),
description: values.description.trim(),
price: values.price.trim(),
trial_days: toNumber(values.trial_days),
max_projects: toNumber(values.max_projects),
max_organizations: toNumber(values.max_organizations),
max_users: toNumber(values.max_users),
max_roles: toNumber(values.max_roles),
});
});
const openCreate = useCallback(() => {
reset({

View 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>
);
}

View File

@@ -8,6 +8,7 @@ import { FormField } from '@/components/form';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
@@ -54,20 +55,24 @@ export function ProjectDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-2xl"
className="sm:max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
<DialogHeader>
<DialogTitle>
{projectId ? 'Edit Project' : 'Create Project'}
</DialogTitle>
<DialogDescription className="text-sm">
<DialogDescription>
Manage road project identity and optional coordinate boundaries.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
<form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<DialogBody className="space-y-6">
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Basic details</h3>
@@ -94,14 +99,14 @@ export function ProjectDialog({
<FormField id="project-state" label="State">
<Input
id="project-state"
placeholder="Maharashtra"
placeholder="Enter state"
{...register('state')}
/>
</FormField>
<FormField id="project-corridor" label="Corridor Name">
<Input
id="project-corridor"
placeholder="Mumbai-Goa Highway"
placeholder="Enter corridor name"
{...register('corridor_name')}
/>
</FormField>
@@ -127,7 +132,9 @@ export function ProjectDialog({
id="project-start-lat"
type="number"
step="any"
placeholder="0.0000"
min="-90"
max="90"
placeholder="Enter start latitude"
aria-invalid={!!startLatErrorMessage}
{...register('start_lat')}
/>
@@ -141,7 +148,9 @@ export function ProjectDialog({
id="project-start-lng"
type="number"
step="any"
placeholder="0.0000"
min="-180"
max="180"
placeholder="Enter start longitude"
aria-invalid={!!startLngErrorMessage}
{...register('start_lng')}
/>
@@ -158,7 +167,9 @@ export function ProjectDialog({
id="project-end-lat"
type="number"
step="any"
placeholder="0.0000"
min="-90"
max="90"
placeholder="Enter end latitude"
aria-invalid={!!endLatErrorMessage}
{...register('end_lat')}
/>
@@ -172,7 +183,9 @@ export function ProjectDialog({
id="project-end-lng"
type="number"
step="any"
placeholder="0.0000"
min="-180"
max="180"
placeholder="Enter end longitude"
aria-invalid={!!endLngErrorMessage}
{...register('end_lng')}
/>
@@ -180,9 +193,9 @@ export function ProjectDialog({
</div>
</div>
</section>
</div>
</DialogBody>
<DialogFooter className="shrink-0 border-t px-6 py-4">
<DialogFooter>
<Button
type="button"
variant="outline"
@@ -192,7 +205,9 @@ export function ProjectDialog({
Cancel
</Button>
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{projectId ? 'Update Project' : 'Create Project'}
</Button>
</DialogFooter>

View File

@@ -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);

View 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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -1,26 +1,15 @@
'use client';
import { useMemo } from 'react';
import { useParams } from 'next/navigation';
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 { CompletedVideoResult, DetectionType } from '@/types';
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 {
@@ -30,33 +19,19 @@ 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;
const getTitle = () => {
const config = getDetectionModeConfig(detectionType);
return `${config.label} Results`;
};
if (isLoading) {
return (
<div className="min-h-[60vh] flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
return <VideoResultsSkeleton />;
}
if (error) {
return (
<div className="min-h-[60vh] flex items-center justify-center">
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-sm text-destructive">{error}</p>
</div>
);
@@ -65,9 +40,15 @@ export default function VideoResultsPage() {
return (
<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>
}
/>
{detectionData && <VideoPlayerSection data={detectionData} />}

View File

@@ -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;

View File

@@ -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,

View File

@@ -62,7 +62,7 @@ export function RoleSheet({
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex h-full flex-col gap-0 p-0 sm:max-w-3xl lg:max-w-2xl"
className="flex h-full w-full! flex-col gap-0 p-0 sm:max-w-3xl!"
>
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
@@ -73,7 +73,11 @@ export function RoleSheet({
</SheetDescription>
</SheetHeader>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField
@@ -125,7 +129,7 @@ export function RoleSheet({
}
>
{isPermissionsLoading ? (
<div className="rounded-md border p-6 text-muted-foreground">
<div className="rounded-lg border p-6 text-muted-foreground">
Loading permissions...
</div>
) : (

View File

@@ -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,

View File

@@ -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({

View 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>
);
}

View File

@@ -11,6 +11,7 @@ import { ProjectSelect } from '@/components/lookups/ProjectSelect';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
@@ -71,20 +72,24 @@ export function SegmentDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-2xl"
className="sm:max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
<DialogHeader>
<DialogTitle>
{segmentId ? 'Edit Segment' : 'Create Segment'}
</DialogTitle>
<DialogDescription className="text-sm">
<DialogDescription>
Manage segment binding, chainage values, direction, and coordinates.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
<form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<DialogBody className="space-y-6">
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Basic details</h3>
@@ -178,16 +183,16 @@ export function SegmentDialog({
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="segment-start-km"
label="Start (km)"
label="Start Chainage"
required
error={getError('chainage_start_km')}
>
<Input
id="segment-start-km"
type="number"
step="any"
step="1"
min="0"
placeholder="0.0"
placeholder="Enter start chainage"
aria-invalid={!!getError('chainage_start_km')}
{...register('chainage_start_km')}
/>
@@ -195,16 +200,16 @@ export function SegmentDialog({
<FormField
id="segment-end-km"
label="End (km)"
label="End Chainage"
required
error={getError('chainage_end_km')}
>
<Input
id="segment-end-km"
type="number"
step="any"
step="1"
min="0"
placeholder="1.0"
placeholder="Enter end chainage"
aria-invalid={!!getError('chainage_end_km')}
{...register('chainage_end_km')}
/>
@@ -296,9 +301,9 @@ export function SegmentDialog({
</div>
</div>
</section>
</div>
</DialogBody>
<DialogFooter className="shrink-0 border-t px-6 py-4">
<DialogFooter>
<Button
type="button"
variant="outline"
@@ -308,7 +313,9 @@ export function SegmentDialog({
Cancel
</Button>
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{segmentId ? 'Update Segment' : 'Create Segment'}
</Button>
</DialogFooter>

View File

@@ -7,49 +7,32 @@ import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import {
requiredIntegerNumber,
requiredLatitude,
requiredLongitude,
validateChainageRange,
} from '@/lib/validation/numberField';
import { chainageService } from '@/services/api';
import type { Chainage, ChainageCreate, ChainageUpdate } from '@/types';
import { segmentKeys } from '../queries/segmentKeys';
const requiredNumber = (message: string) =>
z
.string()
.trim()
.min(1, message)
.refine((value) => Number.isFinite(Number(value)), {
message: 'Enter a valid number',
});
const latitude = requiredNumber('Latitude is required').refine(
(value) => {
const numberValue = Number(value);
return numberValue >= -90 && numberValue <= 90;
},
{ message: 'Latitude must be between -90 and 90' },
);
const longitude = requiredNumber('Longitude is required').refine(
(value) => {
const numberValue = Number(value);
return numberValue >= -180 && numberValue <= 180;
},
{ message: 'Longitude must be between -180 and 180' },
);
const segmentFormSchema = z.object({
id: z.string().optional(),
project_id: z.string().trim().min(1, 'Project is required'),
package_id: z.string().trim().min(1, 'Package is required'),
segment_name: z.string().trim().min(1, 'Segment name is required'),
chainage_start_km: requiredNumber('Start km is required'),
chainage_end_km: requiredNumber('End km is required'),
start_lat: latitude,
start_lng: longitude,
end_lat: latitude,
end_lng: longitude,
direction: z.enum(['UP', 'DOWN']),
});
const segmentFormSchema = z
.object({
id: z.string().optional(),
project_id: z.string().trim().min(1, 'Project is required'),
package_id: z.string().trim().min(1, 'Package is required'),
segment_name: z.string().trim().min(1, 'Segment name is required'),
chainage_start_km: requiredIntegerNumber('Start km is required'),
chainage_end_km: requiredIntegerNumber('End km is required'),
start_lat: requiredLatitude,
start_lng: requiredLongitude,
end_lat: requiredLatitude,
end_lng: requiredLongitude,
direction: z.enum(['UP', 'DOWN']),
})
.superRefine(validateChainageRange);
export type SegmentFormValues = z.infer<typeof segmentFormSchema>;
@@ -141,17 +124,7 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
},
});
const onSubmit = handleSubmit(
(values) => saveMutation.mutate(values),
(formErrors) => {
const firstMessage = Object.values(formErrors).find(
(error) => error?.message,
)?.message;
if (firstMessage) {
toast.error(String(firstMessage));
}
},
);
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
const openCreate = useCallback(() => {
reset(defaultValues);

View 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>
);
}

View File

@@ -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';
@@ -21,6 +31,7 @@ import {
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();
@@ -75,18 +86,25 @@ 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 segments = segmentsQuery.data?.items ?? [];
@@ -139,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>
</>
);
}

View File

@@ -1,13 +1,19 @@
'use client';
import type { ComponentProps } from 'react';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { useMemo, useRef, type ComponentProps } from 'react';
import {
Controller,
type Control,
type FieldErrors,
type UseFormRegister,
} from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
import { FormField, PhoneInput, SelectPopover } from '@/components/form';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
@@ -15,16 +21,12 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { Client, Plan } from '@/types';
import type { TenantFormValues } from '../hooks/useTenantForm';
import type {
TenantAdminFieldLocks,
TenantFormValues,
} from '../hooks/useTenantForm';
interface TenantSheetProps {
open: boolean;
@@ -32,6 +34,7 @@ interface TenantSheetProps {
tenantId?: number;
isEditMode: boolean;
register: UseFormRegister<TenantFormValues>;
control: Control<TenantFormValues>;
errors: FieldErrors<TenantFormValues>;
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
@@ -46,6 +49,7 @@ interface TenantSheetProps {
isLookupsLoading: boolean;
isSaving: boolean;
adminEmail?: string;
adminFieldLocks: TenantAdminFieldLocks;
}
export function TenantSheet({
@@ -54,6 +58,7 @@ export function TenantSheet({
tenantId,
isEditMode,
register,
control,
errors,
isSubmitted,
onSubmit,
@@ -68,25 +73,49 @@ export function TenantSheet({
isLookupsLoading,
isSaving,
adminEmail,
adminFieldLocks,
}: TenantSheetProps) {
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
const fieldError = (field: keyof TenantFormValues) =>
isSubmitted ? errors[field]?.message : undefined;
const clientOptions = useMemo(
() =>
clients.map((client) => ({
value: String(client.id),
label: [client.first_name, client.last_name].filter(Boolean).join(' '),
})),
[clients],
);
const planOptions = useMemo(
() =>
plans.map((plan) => ({
value: String(plan.id),
label: plan.name,
})),
[plans],
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>
{tenantId ? 'Edit Tenant' : 'Create Tenant'}
</DialogTitle>
<DialogDescription className="text-sm">
<DialogDescription>
Bind a client and subscription plan, then invite the tenant
administrator.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="max-h-[50vh] space-y-5 overflow-y-auto px-6 py-4">
<form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<DialogBody className="space-y-5">
<div className="space-y-1">
<p>Basic Information</p>
<p className="text-muted-foreground">
@@ -128,29 +157,24 @@ export function TenantSheet({
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Client" required error={fieldError('client_id')}>
<Select
<FormField
id="tenant-client"
label="Client"
required
error={fieldError('client_id')}
>
<SelectPopover
options={clientOptions}
value={clientId}
onValueChange={onClientChange}
disabled={isLookupsLoading}
>
<SelectTrigger>
<SelectValue
placeholder={
isLookupsLoading
? 'Loading clients...'
: 'Select client'
}
/>
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={String(client.id)}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
portalContainer={dropdownPortalRef}
placeholder={
isLookupsLoading ? 'Loading clients...' : 'Select client'
}
searchPlaceholder="Search clients"
emptyMessage="No clients found."
/>
<input
type="hidden"
{...register('client_id')}
@@ -160,30 +184,23 @@ export function TenantSheet({
</FormField>
<FormField
id="tenant-plan"
label="Subscription Plan"
required
error={fieldError('plan_id')}
>
<Select
<SelectPopover
options={planOptions}
value={planId}
onValueChange={onPlanChange}
disabled={isLookupsLoading}
>
<SelectTrigger>
<SelectValue
placeholder={
isLookupsLoading ? 'Loading plans...' : 'Select plan'
}
/>
</SelectTrigger>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.id} value={String(plan.id)}>
{plan.name}
</SelectItem>
))}
</SelectContent>
</Select>
portalContainer={dropdownPortalRef}
placeholder={
isLookupsLoading ? 'Loading plans...' : 'Select plan'
}
searchPlaceholder="Search plans"
emptyMessage="No plans found."
/>
<input
type="hidden"
{...register('plan_id')}
@@ -211,7 +228,7 @@ export function TenantSheet({
id="tenant-description"
placeholder="North zone operations"
{...register('description')}
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
className="min-h-20 w-full rounded-lg border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
/>
</FormField>
</div>
@@ -236,6 +253,7 @@ export function TenantSheet({
id="admin-first-name"
placeholder="Enter first name"
aria-invalid={!!fieldError('admin_first_name')}
readOnly={adminFieldLocks.admin_first_name}
{...register('admin_first_name')}
/>
</FormField>
@@ -250,6 +268,7 @@ export function TenantSheet({
id="admin-last-name"
placeholder="Enter last name"
aria-invalid={!!fieldError('admin_last_name')}
readOnly={adminFieldLocks.admin_last_name}
{...register('admin_last_name')}
/>
</FormField>
@@ -267,6 +286,7 @@ export function TenantSheet({
type="email"
placeholder="admin@example.com"
aria-invalid={!!fieldError('admin_email')}
readOnly={adminFieldLocks.admin_email}
{...register('admin_email')}
/>
</FormField>
@@ -276,17 +296,26 @@ export function TenantSheet({
label="Phone Number"
error={fieldError('admin_phone_number')}
>
<Input
id="admin-phone"
placeholder="+91-9000011111"
aria-invalid={!!fieldError('admin_phone_number')}
{...register('admin_phone_number')}
<Controller
control={control}
name="admin_phone_number"
render={({ field }) => (
<PhoneInput
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Enter phone number"
aria-invalid={!!fieldError('admin_phone_number')}
disabled={adminFieldLocks.admin_phone_number}
portalContainer={dropdownPortalRef}
/>
)}
/>
</FormField>
</div>
</>
) : adminEmail ? (
<div className="rounded-md border bg-muted/40 p-4 text-muted-foreground">
<div className="rounded-lg border bg-muted/40 p-4 text-muted-foreground">
Admin invitation details cannot be changed after tenant
creation.
<p className="mt-1 text-foreground">
@@ -294,9 +323,9 @@ export function TenantSheet({
</p>
</div>
) : null}
</div>
</DialogBody>
<DialogFooter className="shrink-0 border-t px-6 py-4">
<DialogFooter>
<Button
type="button"
variant="outline"
@@ -316,6 +345,8 @@ export function TenantSheet({
</Button>
</DialogFooter>
</form>
<div ref={dropdownPortalRef} />
</DialogContent>
</Dialog>
);

View File

@@ -1,35 +1,35 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback } from 'react';
import { isValidPhoneNumber } from 'libphonenumber-js';
import { useCallback, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { Tenant } from '@/types';
import { useSaveTenantMutation } from './useTenantMutations';
const optionalPhoneSchema = z
.string()
.trim()
.refine((value) => value === '' || /^\+(?:[0-9] ?|-){6,18}[0-9]$/.test(value), {
message: 'Please enter a valid phone number',
});
const requiredText = (message: string) => z.string().trim().min(1, message);
const tenantFormSchema = z
.object({
id: z.number().optional(),
name: z.string().trim().min(1, 'Tenant name is required'),
slug: z.string().trim().min(1, 'Slug is required'),
client_id: z.string().trim().min(1, 'Client is required'),
plan_id: z.string().trim().min(1, 'Plan is required'),
name: requiredText('Tenant name is required'),
slug: requiredText('Slug is required'),
client_id: requiredText('Client is required'),
plan_id: requiredText('Plan is required'),
domain: z.string().trim(),
description: z.string().trim(),
admin_first_name: z.string().trim(),
admin_last_name: z.string().trim(),
admin_email: z.string().trim(),
admin_phone_number: optionalPhoneSchema,
admin_phone_number: z
.string()
.trim()
.refine((value) => value === '' || isValidPhoneNumber(value), {
message: 'Enter a valid phone number',
}),
})
.superRefine((values, ctx) => {
if (values.id) return;
@@ -38,7 +38,7 @@ const tenantFormSchema = z
ctx.addIssue({
code: 'custom',
path: ['admin_first_name'],
message: 'Admin first name is required',
message: 'First name is required',
});
}
@@ -46,7 +46,7 @@ const tenantFormSchema = z
ctx.addIssue({
code: 'custom',
path: ['admin_last_name'],
message: 'Admin last name is required',
message: 'Last name is required',
});
}
@@ -98,6 +98,20 @@ function splitContactName(contactName: string) {
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
}
export type TenantAdminFieldLocks = {
admin_first_name: boolean;
admin_last_name: boolean;
admin_email: boolean;
admin_phone_number: boolean;
};
const defaultAdminFieldLocks: TenantAdminFieldLocks = {
admin_first_name: false,
admin_last_name: false,
admin_email: false,
admin_phone_number: false,
};
export function useTenantForm({ onSaved }: { onSaved: () => void }) {
const {
register,
@@ -115,10 +129,15 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
const saveMutation = useSaveTenantMutation({
onSaved: () => {
reset(defaultValues);
setAdminFieldLocks(defaultAdminFieldLocks);
onSaved();
},
});
const [adminFieldLocks, setAdminFieldLocks] = useState<TenantAdminFieldLocks>(
defaultAdminFieldLocks,
);
const tenantId = useWatch({ control, name: 'id' });
const clientId = useWatch({ control, name: 'client_id' }) || '';
const planId = useWatch({ control, name: 'plan_id' }) || '';
@@ -138,21 +157,11 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
adminLastName.trim().length > 0 &&
adminEmail.trim().length > 0));
const handleSubmit = submitForm(
(values) => saveMutation.mutate(values),
(formErrors) => {
const firstMessage = Object.values(formErrors).find(
(error) => error?.message,
)?.message;
if (firstMessage) {
toast.error(String(firstMessage));
}
},
);
const handleSubmit = submitForm((values) => saveMutation.mutate(values));
const openCreate = useCallback(() => {
reset(defaultValues);
setAdminFieldLocks(defaultAdminFieldLocks);
}, [reset]);
const openEdit = useCallback(
@@ -170,19 +179,37 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
admin_email: tenant.admin_email || '',
admin_phone_number: '',
});
setAdminFieldLocks(defaultAdminFieldLocks);
},
[reset],
);
const clearAdminFields = useCallback(() => {
setValue('admin_first_name', '', { shouldDirty: true });
setValue('admin_last_name', '', { shouldDirty: true });
setValue('admin_email', '', { shouldDirty: true });
setValue('admin_phone_number', '', { shouldDirty: true });
setAdminFieldLocks(defaultAdminFieldLocks);
}, [setValue]);
const applyClientAdminDefaults = useCallback(
(contactName: string, contactEmail: string, contactPhone: string) => {
if (isEditMode) return;
const { firstName, lastName } = splitContactName(contactName);
const trimmedEmail = contactEmail.trim();
const trimmedPhone = contactPhone.trim();
setValue('admin_first_name', firstName, { shouldDirty: true });
setValue('admin_last_name', lastName, { shouldDirty: true });
setValue('admin_email', contactEmail, { shouldDirty: true });
setValue('admin_phone_number', contactPhone, { shouldDirty: true });
setValue('admin_email', trimmedEmail, { shouldDirty: true });
setValue('admin_phone_number', trimmedPhone, { shouldDirty: true });
setAdminFieldLocks({
admin_first_name: firstName.trim().length > 0,
admin_last_name: lastName.trim().length > 0,
admin_email: trimmedEmail.length > 0,
admin_phone_number: trimmedPhone.length > 0,
});
},
[isEditMode, setValue],
);
@@ -197,9 +224,9 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
return {
register,
control,
handleSubmit,
reset,
control,
setValue,
openCreate,
openEdit,
@@ -211,7 +238,9 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
errors,
isSubmitted,
canSubmit,
adminFieldLocks,
applyClientAdminDefaults,
clearAdminFields,
syncSlugFromName,
isSaving: isSubmitting || saveMutation.isPending,
};

View 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>
);
}

View File

@@ -54,6 +54,7 @@ export default function TenantsPage() {
});
const {
register,
control,
handleSubmit,
openCreate: prepareCreateTenant,
openEdit: prepareEditTenant,
@@ -65,7 +66,9 @@ export default function TenantsPage() {
errors,
isSubmitted,
canSubmit,
adminFieldLocks,
applyClientAdminDefaults,
clearAdminFields,
syncSlugFromName,
setValue,
isSaving,
@@ -77,7 +80,11 @@ export default function TenantsPage() {
(value: string) => {
setValue('client_id', value, { shouldDirty: true, shouldValidate: true });
if (!value || isEditMode) return;
if (isEditMode) return;
clearAdminFields();
if (!value) return;
clientService.getClientById(Number(value)).then((client) => {
applyClientAdminDefaults(
@@ -87,7 +94,7 @@ export default function TenantsPage() {
);
});
},
[applyClientAdminDefaults, isEditMode, setValue],
[applyClientAdminDefaults, clearAdminFields, isEditMode, setValue],
);
const handlePlanChange = useCallback(
@@ -183,6 +190,7 @@ export default function TenantsPage() {
tenantId={tenantId}
isEditMode={isEditMode}
register={register}
control={control}
errors={errors}
isSubmitted={isSubmitted}
onSubmit={handleSubmit}
@@ -197,6 +205,7 @@ export default function TenantsPage() {
isLookupsLoading={isLookupsLoading}
isSaving={isSaving}
adminEmail={adminEmail}
adminFieldLocks={adminFieldLocks}
/>
</>
);

View File

@@ -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>
);
}

View File

@@ -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="&copy; 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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],
);
}

View File

@@ -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,
}}
/>
);
}

View File

@@ -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)}`;
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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
}
/>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,180 @@
import { CalendarCheck2, ImageIcon, MapPin, Smartphone } from 'lucide-react';
import DetectionLocationMap from '@/components/map/detectionLocationMap';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
import { cn } from '@/lib/utils';
import type { DetectionResultItem } from '@/types';
import { formatDate } from '@/utils/date';
import { RepairProofGallery } from '../../components/repair/RepairProofGallery';
import { formatVideoTimestamp, type IssueReviewStatus } from './reviewTypes';
const REVIEW_STATUS_LABEL: Record<IssueReviewStatus, string> = {
not_submitted: 'Awaiting Repair',
pending: 'Under Review',
approved: 'Approved',
rejected: 'Rejected',
};
const REVIEW_STATUS_CLASS: Record<IssueReviewStatus, string> = {
not_submitted: 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-300',
pending: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
approved: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
rejected: 'bg-destructive/10 text-destructive',
};
function MetadataItem({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
return (
<div className="min-w-0 space-y-1 px-4 first:pl-0 last:pr-0">
<p className="text-xs text-muted-foreground">{label}</p>
<div className="truncate text-sm font-semibold text-foreground">
{value}
</div>
</div>
);
}
interface IssueEvidencePanelProps {
detection: DetectionResultItem;
issueNumber: number;
videoWidth: number;
videoHeight: number;
reviewStatus: IssueReviewStatus;
}
export function IssueEvidencePanel({
detection,
issueNumber,
videoWidth,
videoHeight,
reviewStatus,
}: IssueEvidencePanelProps) {
const repairProof = detection.repair_proof;
const displayName = detection.detection.display_name;
const confidence = Math.round(detection.detection.confidence * 100);
return (
<div className="min-w-0 space-y-4">
<Card>
<CardHeader className="border-b">
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle className="text-base">
{displayName} #{issueNumber}
</CardTitle>
<Badge
variant="secondary"
className={cn(REVIEW_STATUS_CLASS[reviewStatus])}
>
{REVIEW_STATUS_LABEL[reviewStatus]}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 divide-x sm:grid-cols-4">
<MetadataItem
label="Video Timestamp"
value={formatVideoTimestamp(detection.frame.timestamp_seconds)}
/>
<MetadataItem label="Frame" value={detection.frame.number} />
<MetadataItem label="Confidence" value={`${confidence}%`} />
<MetadataItem label="Issue ID" value={detection.detection.id} />
</div>
</CardContent>
</Card>
<div className="grid items-start gap-4 2xl:grid-cols-2">
<section className="min-w-0 space-y-2">
<p className="flex items-center gap-2 text-sm font-semibold">
<ImageIcon className="size-4 text-primary" />
Detection Image (Before)
</p>
<AnnotatedDetectionImage
detection={detection}
videoWidth={videoWidth}
videoHeight={videoHeight}
aspectRatio="16 / 9"
enableLightbox
/>
</section>
<section className="min-w-0 space-y-2">
<p className="flex items-center gap-2 text-sm font-semibold">
<MapPin className="size-4 text-primary" />
Detection Location
</p>
<DetectionLocationMap
latitude={detection.location.latitude}
longitude={detection.location.longitude}
label={`${displayName} #${issueNumber} - Frame ${detection.frame.number}`}
title=""
variant="plain"
aspectRatio="16 / 9"
showCoordinates={false}
/>
</section>
</div>
<section className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="flex items-center gap-2 text-sm font-semibold">
<CalendarCheck2 className="size-4 text-primary" />
Repair Proof (After Repair)
</p>
{repairProof?.submitted ? (
<Badge className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<Smartphone />
Uploaded from App
</Badge>
) : null}
</div>
<div className="space-y-5">
{!repairProof?.submitted ? (
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center">
<p className="text-sm font-medium">
Repair proof is not submitted yet.
</p>
<p className="mt-1 text-xs text-muted-foreground">
This issue cannot be reviewed until repair evidence is
available.
</p>
</div>
) : (
<>
{repairProof.submitted_at ? (
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-4 py-3">
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-300">
Uploaded from the field app on{' '}
{formatDate(repairProof.submitted_at)}
</p>
</div>
) : null}
<RepairProofGallery
imageUrls={repairProof.proof_image_urls}
maxVisibleImages={4}
/>
<div className="space-y-1.5 rounded-lg border bg-card p-4 shadow-xs">
<p className="text-xs font-semibold text-foreground">
Repair Notes
</p>
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
{repairProof.notes?.trim() ||
'No repair notes were provided.'}
</p>
</div>
</>
)}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,208 @@
'use client';
import { useEffect, useState } from 'react';
import {
Check,
CheckCircle2,
ClipboardCheck,
Info,
Loader2,
X,
XCircle,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import type {
DetectionResultItem,
ReviewDetectionRepairProofPayload,
} from '@/types';
import { formatDate } from '@/utils/date';
import type { IssueReviewStatus } from './reviewTypes';
type ReviewDecision = ReviewDetectionRepairProofPayload['action'];
interface IssueReviewActionProps {
detection: DetectionResultItem;
issueLabel: string;
status: IssueReviewStatus;
isPending: boolean;
onSave: (payload: ReviewDetectionRepairProofPayload) => Promise<void>;
}
export function IssueReviewAction({
detection,
issueLabel,
status,
isPending,
onSave,
}: IssueReviewActionProps) {
const [comment, setComment] = useState('');
const [decision, setDecision] = useState<ReviewDecision>();
useEffect(() => {
setComment('');
setDecision(undefined);
}, [detection.detection.id]);
if (status === 'not_submitted') {
return (
<Card>
<CardHeader className="border-b">
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardCheck className="size-5 text-primary" />
Review & Action
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-lg border border-dashed bg-muted/30 px-4 py-8 text-center">
<Info className="mx-auto size-6 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">Repair proof required</p>
<p className="mt-1 text-xs text-muted-foreground">
{issueLabel} is not ready for review.
</p>
</div>
</CardContent>
</Card>
);
}
if (status === 'approved' || status === 'rejected') {
const isApproved = status === 'approved';
const repairProof = detection.repair_proof;
return (
<Card>
<CardHeader className="border-b">
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardCheck className="size-5 text-primary" />
Review Result
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div
className={cn(
'rounded-lg border px-4 py-5 text-center',
isApproved
? 'border-emerald-500/20 bg-emerald-500/5'
: 'border-destructive/20 bg-destructive/5',
)}
>
{isApproved ? (
<CheckCircle2 className="mx-auto size-8 text-emerald-600" />
) : (
<XCircle className="mx-auto size-8 text-destructive" />
)}
<Badge
className={cn(
'mt-3',
isApproved
? 'bg-emerald-500/10 text-emerald-600'
: 'bg-destructive/10 text-destructive',
)}
>
{isApproved ? 'Approved' : 'Rejected'}
</Badge>
{repairProof?.reviewed_by_name || repairProof?.reviewed_at ? (
<p className="mt-2 text-xs text-muted-foreground">
{repairProof.reviewed_by_name
? `By ${repairProof.reviewed_by_name}`
: 'Reviewed'}
{repairProof.reviewed_at
? ` on ${formatDate(repairProof.reviewed_at)}`
: ''}
</p>
) : null}
</div>
{repairProof?.review_comment ? (
<div className="space-y-1.5 rounded-lg bg-muted/30 p-3">
<p className="text-xs font-semibold">Review Comment</p>
<p className="text-sm whitespace-pre-wrap text-muted-foreground">
{repairProof.review_comment}
</p>
</div>
) : null}
</CardContent>
</Card>
);
}
const submitDecision = async (nextDecision: ReviewDecision) => {
setDecision(nextDecision);
try {
await onSave({
action: nextDecision,
comment: comment.trim() || undefined,
});
} catch {
// The mutation owns user-facing error feedback.
}
};
return (
<Card>
<CardHeader className="border-b">
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardCheck className="size-5 text-primary" />
Review & Action
</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<div className="space-y-2">
<Label htmlFor="review-comment">Comment (Optional)</Label>
<Textarea
id="review-comment"
value={comment}
maxLength={4000}
rows={5}
disabled={isPending}
onChange={(event) => setComment(event.target.value)}
placeholder="Enter your review comments..."
/>
<p className="text-right text-xs text-muted-foreground">
{comment.length} / 4000
</p>
</div>
<div className="space-y-2">
<Label>Decision</Label>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="default"
disabled={isPending}
onClick={() => void submitDecision('approve')}
>
{isPending && decision === 'approve' ? (
<Loader2 className="animate-spin" />
) : (
<Check />
)}
Approve
</Button>
<Button
type="button"
variant="destructive"
disabled={isPending}
onClick={() => void submitDecision('reject')}
>
{isPending && decision === 'reject' ? (
<Loader2 className="animate-spin" />
) : (
<X />
)}
Reject
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,111 @@
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import type { ProofStatusFilter } from './reviewTypes';
const PROOF_STATUS_FILTERS: Array<{
value: ProofStatusFilter;
label: string;
}> = [
{ value: 'all', label: 'All' },
{ value: 'pending', label: 'Pending' },
{ value: 'approved', label: 'Approved' },
{ value: 'rejected', label: 'Rejected' },
];
interface IssueReviewHeaderProps {
ticketLabel: string;
currentIndex: number;
total: number;
proofStatus: ProofStatusFilter;
onBack: () => void;
onPrevious: () => void;
onNext: () => void;
onProofStatusChange: (status: ProofStatusFilter) => void;
}
export function IssueReviewHeader({
ticketLabel,
currentIndex,
total,
proofStatus,
onBack,
onPrevious,
onNext,
onProofStatusChange,
}: IssueReviewHeaderProps) {
return (
<header className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 items-start gap-3">
<Button
type="button"
variant="secondary"
size="icon"
onClick={onBack}
aria-label="Back to ticket"
className="shrink-0"
>
<ArrowLeft />
</Button>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="truncate text-2xl font-semibold tracking-tight">
Repair Review
</h1>
<Badge variant="secondary">{ticketLabel}</Badge>
</div>
<div
className="flex max-w-full flex-nowrap items-center gap-1.5 overflow-x-auto pt-1 pb-1"
aria-label="Filter issues by repair proof status"
>
{PROOF_STATUS_FILTERS.map((filter) => {
const isActive = proofStatus === filter.value;
return (
<Button
key={filter.value}
type="button"
size="sm"
variant={isActive ? 'default' : 'outline'}
aria-pressed={isActive}
onClick={() => onProofStatusChange(filter.value)}
className="h-7 shrink-0 rounded-full px-3 text-xs"
>
{filter.label}
</Button>
);
})}
</div>
</div>
</div>
<div className="flex items-center gap-2 self-end lg:self-auto">
<Button
type="button"
variant="outline"
size="icon"
disabled={total === 0 || currentIndex <= 0}
onClick={onPrevious}
aria-label="Previous issue"
>
<ChevronLeft />
</Button>
<div className="min-w-16 text-center text-sm font-semibold">
{total === 0 ? '0 of 0' : `${currentIndex + 1} of ${total}`}
</div>
<Button
type="button"
variant="outline"
size="icon"
disabled={total === 0 || currentIndex >= total - 1}
onClick={onNext}
aria-label="Next issue"
>
<ChevronRight />
</Button>
</div>
</header>
);
}

View File

@@ -0,0 +1,192 @@
'use client';
import { useEffect, useRef } from 'react';
import {
CheckCircle2,
Clock3,
Hourglass,
ListChecks,
Wrench,
XCircle,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { DetectionResultItem } from '@/types';
import {
formatVideoTimestamp,
getIssueId,
getIssueReviewStatus,
type IssueReviewStatus,
} from './reviewTypes';
const STATUS_CONFIG: Record<
IssueReviewStatus,
{
label: string;
icon: typeof Hourglass;
className: string;
}
> = {
not_submitted: {
label: 'Awaiting Repair',
icon: Wrench,
className: 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-300',
},
pending: {
label: 'Under Review',
icon: Hourglass,
className: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
},
approved: {
label: 'Approved',
icon: CheckCircle2,
className: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
},
rejected: {
label: 'Rejected',
icon: XCircle,
className: 'bg-destructive/10 text-destructive',
},
};
interface IssueReviewQueueProps {
detections: DetectionResultItem[];
totalCount: number;
selectedIssueId?: string;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onSelect: (issueId: string) => void;
onLoadMore: () => void;
}
export function IssueReviewQueue({
detections,
totalCount,
selectedIssueId,
hasNextPage,
isFetchingNextPage,
onSelect,
onLoadMore,
}: IssueReviewQueueProps) {
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const target = loadMoreRef.current;
if (!target || !hasNextPage || isFetchingNextPage) return;
const root = target.closest<HTMLElement>(
'[data-slot="scroll-area-viewport"]',
);
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) onLoadMore();
},
{ root, rootMargin: '160px 0px' },
);
observer.observe(target);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, onLoadMore]);
return (
<Card className="overflow-hidden xl:h-full xl:min-h-0">
<CardHeader className="border-b">
<CardTitle className="flex items-center gap-2 text-base">
<ListChecks className="size-5 text-primary" />
Repair Issues
<Badge variant="secondary" className="ml-auto rounded-full">
{totalCount}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="min-h-0 flex-1 p-2">
<ScrollArea className="h-112 pr-2 xl:h-full">
<div className="space-y-2 p-1">
{detections.map((detection, index) => {
const issueId = getIssueId(detection);
const defectLabel = detection.detection.display_name;
const status = getIssueReviewStatus(detection);
const config = STATUS_CONFIG[status];
const StatusIcon = config.icon;
const isSelected = issueId === selectedIssueId;
return (
<button
key={detection.id}
type="button"
onClick={() => onSelect(issueId)}
aria-current={isSelected ? 'true' : undefined}
className={cn(
'w-full rounded-lg border p-3 text-left transition-colors',
'hover:border-primary/40 hover:bg-muted/50',
isSelected
? 'border-primary bg-primary/6 shadow-sm'
: 'border-border bg-card',
)}
>
<div className="min-w-0 space-y-2">
<div className="flex min-w-0 items-center justify-between gap-2">
<Tooltip>
<TooltipTrigger asChild>
<p className="min-w-0 flex-1 truncate text-sm font-semibold">
{defectLabel} #{index + 1}
</p>
</TooltipTrigger>
<TooltipContent side="top">
{defectLabel} #{index + 1}
</TooltipContent>
</Tooltip>
<span className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
<Clock3 className="size-3.5" />
{formatVideoTimestamp(
detection.frame.timestamp_seconds,
)}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge
variant="secondary"
className={cn('shrink-0 px-1.5', config.className)}
>
<StatusIcon />
{config.label}
</Badge>
<span aria-hidden="true">|</span>
<span>Frame {detection.frame.number}</span>
</div>
</div>
</button>
);
})}
<div ref={loadMoreRef} className="space-y-2 py-1">
{isFetchingNextPage ? (
<>
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</>
) : hasNextPage ? (
<p className="py-3 text-center text-xs text-muted-foreground">
Scroll to load more issues
</p>
) : detections.length > 0 ? (
<p className="py-3 text-center text-xs text-muted-foreground">
All {totalCount} issues loaded
</p>
) : null}
</div>
</div>
</ScrollArea>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export function IssueReviewSkeleton() {
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-4">
<div className="space-y-2">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-4 w-36" />
</div>
<Skeleton className="h-9 w-32" />
</div>
<div className="grid gap-5 xl:grid-cols-[280px_minmax(0,1fr)_330px]">
{[280, 600, 330].map((width) => (
<Card key={width}>
<CardHeader className="border-b">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton key={index} className="h-20 w-full" />
))}
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import type { DetectionProofStatus, DetectionResultItem } from '@/types';
export type ProofStatusFilter =
| 'all'
| Extract<DetectionProofStatus, 'pending' | 'approved' | 'rejected'>;
export type IssueReviewStatus =
| 'not_submitted'
| 'pending'
| 'approved'
| 'rejected';
export function getIssueId(detection: DetectionResultItem) {
return String(detection.detection.id);
}
export function getIssueReviewStatus(
detection: DetectionResultItem,
): IssueReviewStatus {
const repairProof = detection.repair_proof;
if (!repairProof?.submitted) return 'not_submitted';
return repairProof.review_status;
}
export function formatVideoTimestamp(totalSeconds: number) {
const seconds = Math.max(0, Math.floor(totalSeconds));
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return `${minutes}:${remainder.toString().padStart(2, '0')}`;
}

View File

@@ -0,0 +1,317 @@
'use client';
import { useCallback, useEffect, useMemo } from 'react';
import {
useParams,
usePathname,
useRouter,
useSearchParams,
} from 'next/navigation';
import { AlertTriangle, ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import { ROUTES } from '@/utils/routes';
import type { ReviewDetectionRepairProofPayload } from '@/types';
import {
useReviewDetectionRepairProofMutation,
useTicketReviewDetectionsQuery,
useTicketOverviewQuery,
} from '../../hooks/useTicketQueries';
import { IssueEvidencePanel } from './components/IssueEvidencePanel';
import { IssueReviewAction } from './components/IssueReviewAction';
import { IssueReviewHeader } from './components/IssueReviewHeader';
import { IssueReviewQueue } from './components/IssueReviewQueue';
import { IssueReviewSkeleton } from './components/IssueReviewSkeleton';
import {
getIssueId,
getIssueReviewStatus,
type ProofStatusFilter,
} from './components/reviewTypes';
const PROOF_STATUS_FILTERS: ProofStatusFilter[] = [
'all',
'pending',
'approved',
'rejected',
];
export default function TicketReviewPage() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { ticketId } = useParams() as { ticketId: string };
const proofStatusParam = searchParams.get('proof_status');
const proofStatus: ProofStatusFilter = PROOF_STATUS_FILTERS.includes(
proofStatusParam as ProofStatusFilter,
)
? (proofStatusParam as ProofStatusFilter)
: 'all';
const overviewQuery = useTicketOverviewQuery(ticketId);
const ticket = overviewQuery.data;
const videoId = ticket?.video_id ?? ticket?.video?.id ?? undefined;
const detectionsQuery = useTicketReviewDetectionsQuery(
videoId,
proofStatus === 'all' ? 'submitted' : proofStatus,
);
const detectionResult = detectionsQuery.data?.pages[0];
const detections = useMemo(
() => detectionsQuery.data?.pages.flatMap((page) => page.items) ?? [],
[detectionsQuery.data?.pages],
);
const totalCount = detectionResult?.total ?? 0;
const selectedIssueParam = searchParams.get('issue_id') ?? undefined;
const reviewMutation = useReviewDetectionRepairProofMutation({
ticketId,
videoId,
});
const selectIssue = useCallback(
(issueId: string) => {
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set('issue_id', issueId);
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
},
[pathname, router, searchParams],
);
const handleProofStatusChange = useCallback(
(status: ProofStatusFilter) => {
const nextParams = new URLSearchParams(searchParams.toString());
if (status === 'all') {
nextParams.delete('proof_status');
} else {
nextParams.set('proof_status', status);
}
nextParams.delete('issue_id');
const query = nextParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, {
scroll: false,
});
},
[pathname, router, searchParams],
);
useEffect(() => {
if (!detections.length) return;
const hasSelectedIssue = detections.some(
(detection) => getIssueId(detection) === selectedIssueParam,
);
if (hasSelectedIssue) return;
if (
selectedIssueParam &&
detectionsQuery.hasNextPage &&
!detectionsQuery.isFetchingNextPage
) {
void detectionsQuery.fetchNextPage();
return;
}
const firstReviewable = detections.find(
(detection) => getIssueReviewStatus(detection) === 'pending',
);
selectIssue(getIssueId(firstReviewable ?? detections[0]));
}, [detections, detectionsQuery, selectIssue, selectedIssueParam]);
const activeIndex = Math.max(
0,
detections.findIndex(
(detection) => getIssueId(detection) === selectedIssueParam,
),
);
const activeDetection = detections[activeIndex];
const activeIssueId = activeDetection
? getIssueId(activeDetection)
: undefined;
const activeReviewStatus = activeDetection
? getIssueReviewStatus(activeDetection)
: 'not_submitted';
const handleSaveReview = useCallback(
async (payload: ReviewDetectionRepairProofPayload) => {
if (!activeDetection) return;
await reviewMutation.mutateAsync({
detectionId: activeDetection.detection.id,
payload,
});
},
[activeDetection, reviewMutation],
);
const handleNext = useCallback(async () => {
if (activeIndex < detections.length - 1) {
selectIssue(getIssueId(detections[activeIndex + 1]));
return;
}
if (!detectionsQuery.hasNextPage) return;
const nextResult = await detectionsQuery.fetchNextPage();
const nextDetections =
nextResult.data?.pages.flatMap((page) => page.items) ?? [];
const nextDetection = nextDetections[activeIndex + 1];
if (nextDetection) selectIssue(getIssueId(nextDetection));
}, [activeIndex, detections, detectionsQuery, selectIssue]);
const handleLoadMore = useCallback(() => {
if (detectionsQuery.hasNextPage && !detectionsQuery.isFetchingNextPage) {
void detectionsQuery.fetchNextPage();
}
}, [detectionsQuery]);
const backToTicket = () => {
router.push(ROUTES.TICKET_DETAIL(ticketId));
};
if (overviewQuery.isLoading || (videoId && detectionsQuery.isLoading)) {
return <IssueReviewSkeleton />;
}
if (overviewQuery.isError || !ticket || !videoId) {
return (
<Card>
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
<AlertTriangle className="size-8 text-destructive" />
<div>
<p className="font-semibold">
Unable to open the review workspace.
</p>
<p className="mt-1 text-sm text-muted-foreground">
Ticket or video information is unavailable.
</p>
</div>
<Button type="button" variant="outline" onClick={backToTicket}>
Back to Ticket
</Button>
</CardContent>
</Card>
);
}
if (detectionsQuery.isError) {
return (
<Card>
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
<AlertTriangle className="size-8 text-destructive" />
<div>
<p className="font-semibold">Failed to load detections.</p>
<p className="mt-1 text-sm text-muted-foreground">
The review screen uses the detection API for its queue and
evidence.
</p>
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={backToTicket}>
Back
</Button>
<Button
type="button"
onClick={() => void detectionsQuery.refetch()}
>
Retry
</Button>
</div>
</CardContent>
</Card>
);
}
if (!activeDetection || !detectionResult) {
return (
<div className="space-y-5">
<IssueReviewHeader
ticketLabel={ticket.ticket_name || ticket.id}
currentIndex={0}
total={0}
proofStatus={proofStatus}
onBack={backToTicket}
onPrevious={() => undefined}
onNext={() => undefined}
onProofStatusChange={handleProofStatusChange}
/>
<Card>
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
<ClipboardCheck className="size-8 text-muted-foreground" />
<div>
<p className="font-semibold">No detections found.</p>
<p className="mt-1 text-sm text-muted-foreground">
There are no issues available for review.
</p>
</div>
</CardContent>
</Card>
</div>
);
}
const defectLabel = activeDetection.detection.display_name;
return (
<main className="relative z-10 space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
<IssueReviewHeader
ticketLabel={ticket.ticket_name || ticket.id}
currentIndex={activeIndex}
total={totalCount}
proofStatus={proofStatus}
onBack={backToTicket}
onPrevious={() =>
selectIssue(getIssueId(detections[Math.max(0, activeIndex - 1)]))
}
onNext={() => void handleNext()}
onProofStatusChange={handleProofStatusChange}
/>
<div className="grid items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[280px_minmax(0,1fr)_330px] xl:items-stretch xl:overflow-hidden">
<IssueReviewQueue
detections={detections}
totalCount={totalCount}
selectedIssueId={activeIssueId}
hasNextPage={Boolean(detectionsQuery.hasNextPage)}
isFetchingNextPage={detectionsQuery.isFetchingNextPage}
onSelect={selectIssue}
onLoadMore={handleLoadMore}
/>
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-2">
<IssueEvidencePanel
detection={activeDetection}
issueNumber={activeIndex + 1}
videoWidth={detectionResult.video_width}
videoHeight={detectionResult.video_height}
reviewStatus={activeReviewStatus}
/>
</div>
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-2">
<PermissionGuard
permissions={PERMISSIONS.TICKET.REVIEW}
fallback={
<Card>
<CardContent className="py-8 text-center">
<ClipboardCheck className="mx-auto size-7 text-muted-foreground" />
<p className="mt-3 text-sm font-semibold">View-only access</p>
<p className="mt-1 text-xs text-muted-foreground">
Review permission is required to take action.
</p>
</CardContent>
</Card>
}
>
<IssueReviewAction
detection={activeDetection}
issueLabel={`${defectLabel} #${activeIndex + 1}`}
status={activeReviewStatus}
isPending={reviewMutation.isPending}
onSave={handleSaveReview}
/>
</PermissionGuard>
</div>
</div>
</main>
);
}

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