77 Commits

Author SHA1 Message Date
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
a18fff4d6f feat: add My Uploads listing with card and table views 2026-06-18 21:22:44 +05:30
dff9e2222f refactor: change analysis to upload 2026-06-18 18:24:19 +05:30
d8fa02dc5d style: restore default heading and paragraph typography 2026-06-18 17:20:33 +05:30
62ebf1ebf6 refactor: modularize upload page into location, input, and settings sections 2026-06-18 17:11:26 +05:30
9e76b8e6d0 refactor: implement project package segment lazy select 2026-06-18 16:30:06 +05:30
6cf363dce1 refactor: remove the legacy detailed summary section and simplify the results playback props, keeping detection counts and stats in the main playback card layout. 2026-06-18 14:49:49 +05:30
4add7a6c83 Merge branch 'feat/rbac' of https://git.convexsol.co/santasri.pachhal/visionRoad-ui into feat/upload 2026-06-18 13:44:45 +05:30
af4d7fae97 refactor video results playback and management form UX 2026-06-18 13:22:30 +05:30
defb956751 refactor: pause video when click on logs 2026-06-18 11:41:25 +05:30
c6894bfea0 feat(video): base of video refactoring 2026-06-18 02:23:25 +05:30
265 changed files with 17611 additions and 7658 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,16 +1,17 @@
{ {
"$schema": "https://ui.shadcn.com/schema.json", "$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york", "style": "radix-vega",
"rsc": true, "rsc": true,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "", "config": "",
"css": "src/app/globals.css", "css": "src/app/globals.css",
"baseColor": "slate", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"iconLibrary": "lucide", "iconLibrary": "lucide",
"rtl": false,
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils", "utils": "@/lib/utils",
@@ -18,5 +19,7 @@
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
"menuColor": "default",
"menuAccent": "subtle",
"registries": {} "registries": {}
} }

6
next-env.d.ts vendored
View File

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

3423
package-lock.json generated

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-query-devtools": "^5.101.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.3", "@tanstack/react-virtual": "^3.14.3",
"@vidstack/react": "^1.15.6",
"axios": "^1.13.6", "axios": "^1.13.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"libphonenumber-js": "^1.13.7",
"lightgallery": "^2.9.0",
"lucide-react": "^0.548.0", "lucide-react": "^0.548.0",
"motion": "^12.38.0", "motion": "^12.38.0",
"next": "16.0.10", "next": "16.0.10",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.6.0", "radix-ui": "^1.6.0",
"react": "19.2.0", "react": "19.2.0",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.79.0", "react-hook-form": "^7.79.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"recharts": "2.15.4", "recharts": "^3.8.0",
"shadcn": "^4.13.0",
"sonner": "^1.7.4", "sonner": "^1.7.4",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^4.4.3", "zod": "^4.4.3",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },

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

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%]"> <div className="flex h-screen flex-1 items-center justify-center border-l border-border p-6 md:w-[60%]">
<section className="w-full max-w-sm space-y-7"> <section className="w-full max-w-sm space-y-7">
<header className="space-y-2 text-center md:text-left"> <header className="space-y-2 text-center md:text-left">
<p className="text-muted-foreground">VisionRoad</p> <p className="text-muted-foreground">RoadMonitor</p>
<h1>{displayInfo.title}</h1> <h1>{displayInfo.title}</h1>
<p className="text-muted-foreground">{displayInfo.description}</p> <p className="text-muted-foreground">{displayInfo.description}</p>
</header> </header>
@@ -60,7 +60,11 @@ function ForgotPasswordContent() {
className="w-full" className="w-full"
disabled={isLoading || !canSubmit} disabled={isLoading || !canSubmit}
> >
{isLoading ? <Loader2 className="size-4 animate-spin" /> : <Send />} {isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send />
)}
{displayInfo.buttonLabel} {displayInfo.buttonLabel}
</Button> </Button>

View File

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

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%]"> <div className="flex h-screen flex-1 items-center justify-center border-l border-border p-6 md:w-[60%]">
<section className="w-full max-w-sm space-y-7"> <section className="w-full max-w-sm space-y-7">
<header className="space-y-2 text-center md:text-left"> <header className="space-y-2 text-center md:text-left">
<p className="text-muted-foreground">VisionRoad</p> <p className="text-muted-foreground">RoadMonitor</p>
<h1>{displayInfo.title}</h1> <h1>{displayInfo.title}</h1>
<p className="text-muted-foreground">{displayInfo.description}</p> <p className="text-muted-foreground">{displayInfo.description}</p>
</header> </header>
{!hasToken ? ( {!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. Password reset token is missing.
</div> </div>
) : ( ) : (
@@ -69,7 +69,9 @@ function ResetPasswordContent() {
className="w-full" className="w-full"
disabled={isLoading || !canSubmit} disabled={isLoading || !canSubmit}
> >
{isLoading ? <Loader2 className="size-4 animate-spin" /> : null} {isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : null}
{displayInfo.buttonLabel} {displayInfo.buttonLabel}
</Button> </Button>
</form> </form>

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 useMemo(() => {
return [ return [
{ {
accessorKey: 'name', accessorKey: 'first_name',
header: 'Client', header: 'Client',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-medium">{row.original.name}</span> <span className="font-medium">
{`${row.original.first_name} ${row.original.last_name}`.trim()}
</span>
), ),
}, },
{ {

View File

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

View File

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

View File

@@ -20,7 +20,8 @@ export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
mutationFn: (values: ClientFormValues) => mutationFn: (values: ClientFormValues) =>
clientService.saveClient({ clientService.saveClient({
id: values.id, id: values.id,
name: values.name.trim(), first_name: values.first_name.trim(),
last_name: values.last_name.trim(),
email: values.email.trim(), email: values.email.trim(),
landline_number: values.landline_number.trim(), landline_number: values.landline_number.trim(),
address: values.address.trim(), address: values.address.trim(),

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

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

@@ -1,10 +1,8 @@
import { BreadcrumbBasic } from '@/components/app-breadcrumb';
import { AppSidebar } from '@/components/app-sidebar'; import { AppSidebar } from '@/components/app-sidebar';
import { ModeToggle } from '@/components/mode-toogle'; import { SidebarProvider } from '@/components/ui/sidebar';
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { Separator } from '@/components/ui/separator';
import { AuthGuard } from '@/guards'; import { AuthGuard } from '@/guards';
import React from 'react'; import React from 'react';
import { ModuleShellHeader } from './_components/module-shell-header';
const ModulesLayout = ({ const ModulesLayout = ({
children, children,
@@ -15,16 +13,11 @@ const ModulesLayout = ({
<AuthGuard> <AuthGuard>
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden"> <main className="flex flex-1 h-screen w-full flex-col overflow-hidden">
<div className="flex-1 overflow-auto"> <div className="scroll-stable flex-1 overflow-auto">
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full"> <div className="mx-auto flex min-h-full w-full max-w-380 flex-col">
<div className="flex gap-3 items-center sticky top-0 bg-background z-50 py-3 px-6 border-b border-border"> <ModuleShellHeader />
<SidebarTrigger /> <div className="flex-1 p-6">{children}</div>
<ModeToggle />
<Separator orientation="vertical" className="mx-2 h-4" />
<BreadcrumbBasic />
</div>
<div className="p-6 flex-1">{children}</div>
</div> </div>
</div> </div>
</main> </main>

View File

@@ -1,13 +1,16 @@
'use client'; 'use client';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import { useRef } from 'react';
import type { FieldErrors, UseFormRegister } from 'react-hook-form'; import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form'; import { FormField } from '@/components/form';
import { ProjectSelect } from '@/components/lookups/ProjectSelect';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
@@ -15,14 +18,6 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { Project } from '@/types';
import type { PackageFormValues } from '../hooks/usePackageForm'; import type { PackageFormValues } from '../hooks/usePackageForm';
@@ -30,8 +25,6 @@ interface PackageDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
packageId?: string; packageId?: string;
projects: Project[];
isProjectsLoading: boolean;
projectId: string; projectId: string;
onProjectChange: (projectId: string) => void; onProjectChange: (projectId: string) => void;
register: UseFormRegister<PackageFormValues>; register: UseFormRegister<PackageFormValues>;
@@ -46,8 +39,6 @@ export function PackageDialog({
open, open,
onOpenChange, onOpenChange,
packageId, packageId,
projects,
isProjectsLoading,
projectId, projectId,
onProjectChange, onProjectChange,
register, register,
@@ -57,6 +48,7 @@ export function PackageDialog({
canSubmit, canSubmit,
isSaving, isSaving,
}: PackageDialogProps) { }: PackageDialogProps) {
const projectSelectPortalRef = useRef<HTMLDivElement | null>(null);
const projectErrorMessage = isSubmitted const projectErrorMessage = isSubmitted
? errors.project_id?.message ? errors.project_id?.message
: undefined; : undefined;
@@ -71,20 +63,24 @@ export function PackageDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <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()} onOpenAutoFocus={(event) => event.preventDefault()}
> >
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12"> <DialogHeader>
<DialogTitle className="text-lg leading-none font-semibold tracking-tight"> <DialogTitle>
{packageId ? 'Edit Package' : 'Create Package'} {packageId ? 'Edit Package' : 'Create Package'}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm"> <DialogDescription>
Manage package details and optional chainage range. Manage package details and optional chainage range.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col"> <form
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4"> noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<DialogBody className="space-y-6">
<section className="space-y-4"> <section className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-semibold">Basic details</h3> <h3 className="text-sm font-semibold">Basic details</h3>
@@ -95,25 +91,13 @@ export function PackageDialog({
{!packageId ? ( {!packageId ? (
<FormField label="Project" required error={projectErrorMessage}> <FormField label="Project" required error={projectErrorMessage}>
<Select value={projectId} onValueChange={onProjectChange}> <ProjectSelect
<SelectTrigger aria-invalid={!!projectErrorMessage}> value={projectId}
{isProjectsLoading ? ( onValueChange={onProjectChange}
<span className="flex items-center gap-2 text-muted-foreground"> enabled={open}
<Loader2 className="size-4 animate-spin" /> disabled={isSaving}
Loading... portalContainer={projectSelectPortalRef}
</span> />
) : (
<SelectValue placeholder="Choose a project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input <input
type="hidden" type="hidden"
{...register('project_id')} {...register('project_id')}
@@ -141,7 +125,7 @@ export function PackageDialog({
<FormField id="package-region" label="Region"> <FormField id="package-region" label="Region">
<Input <Input
id="package-region" id="package-region"
placeholder="North Zone" placeholder="Enter region"
{...register('region')} {...register('region')}
/> />
</FormField> </FormField>
@@ -152,21 +136,22 @@ export function PackageDialog({
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-semibold">Chainage range</h3> <h3 className="text-sm font-semibold">Chainage range</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Optional start and end kilometre values. start and end kilometre values.
</p> </p>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<FormField <FormField
id="package-start" id="package-start"
label="Start (km)" label="Start Chainage"
error={startErrorMessage} error={startErrorMessage}
> >
<Input <Input
id="package-start" id="package-start"
type="number" type="number"
step="0.01" step="1"
placeholder="0.00" min="0"
placeholder="Enter start chainage"
aria-invalid={!!startErrorMessage} aria-invalid={!!startErrorMessage}
{...register('chainage_start_km')} {...register('chainage_start_km')}
/> />
@@ -174,23 +159,24 @@ export function PackageDialog({
<FormField <FormField
id="package-end" id="package-end"
label="End (km)" label="End Chainage"
error={endErrorMessage} error={endErrorMessage}
> >
<Input <Input
id="package-end" id="package-end"
type="number" type="number"
step="0.01" step="1"
placeholder="0.00" min="0"
placeholder="Enter end chainage"
aria-invalid={!!endErrorMessage} aria-invalid={!!endErrorMessage}
{...register('chainage_end_km')} {...register('chainage_end_km')}
/> />
</FormField> </FormField>
</div> </div>
</section> </section>
</div> </DialogBody>
<DialogFooter className="shrink-0 border-t px-6 py-4"> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -200,11 +186,15 @@ export function PackageDialog({
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSaving || !canSubmit}> <Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null} {isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{packageId ? 'Update Package' : 'Create Package'} {packageId ? 'Update Package' : 'Create Package'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
<div ref={projectSelectPortalRef} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

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

View File

@@ -7,6 +7,7 @@ import { toast } from 'sonner';
import { packageService, projectService } from '@/services/api'; import { packageService, projectService } from '@/services/api';
import type { Package, PaginationParams } from '@/types'; import type { Package, PaginationParams } from '@/types';
import { projectKeys } from '../../project/queries/projectKeys';
import { packageKeys } from '../queries/packageKeys'; import { packageKeys } from '../queries/packageKeys';
interface UsePackagesQueryParams { interface UsePackagesQueryParams {
@@ -28,7 +29,7 @@ export function usePackagesQuery({ skip, limit }: UsePackagesQueryParams) {
export function useProjectOptionsQuery() { export function useProjectOptionsQuery() {
return useQuery({ return useQuery({
queryKey: ['projects', 'options'], queryKey: projectKeys.options(),
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }), queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
}); });
} }

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 { Package as PackageIcon, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header'; 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 { Button } from '@/components/ui/button';
import type { Package } from '@/types'; import type { Package } from '@/types';
@@ -20,6 +30,7 @@ import {
export default function PackagePage() { export default function PackagePage() {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [packageToDelete, setPackageToDelete] = useState<Package | null>(null);
const { skip, setSkip, limit, setLimit } = usePackageFilters(); const { skip, setSkip, limit, setLimit } = usePackageFilters();
const packagesQuery = usePackagesQuery({ skip, limit }); const packagesQuery = usePackagesQuery({ skip, limit });
const projectsQuery = useProjectOptionsQuery(); const projectsQuery = useProjectOptionsQuery();
@@ -67,14 +78,25 @@ export default function PackagePage() {
const deletePackage = useCallback( const deletePackage = useCallback(
(pkg: Package) => { (pkg: Package) => {
if (!confirm(`Are you sure you want to delete package "${pkg.name}"?`)) { setPackageToDelete(pkg);
return;
}
deletePackageMutation.mutate(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 projects = projectsQuery.data?.items ?? [];
const packages = packagesQuery.data?.items ?? []; const packages = packagesQuery.data?.items ?? [];
const total = packagesQuery.data?.totalItems ?? 0; const total = packagesQuery.data?.totalItems ?? 0;
@@ -113,8 +135,6 @@ export default function PackagePage() {
open={isDialogOpen} open={isDialogOpen}
onOpenChange={handleDialogOpenChange} onOpenChange={handleDialogOpenChange}
packageId={packageId} packageId={packageId}
projects={projects}
isProjectsLoading={projectsQuery.isLoading}
projectId={projectId} projectId={projectId}
onProjectChange={setProjectId} onProjectChange={setProjectId}
register={register} register={register}
@@ -124,6 +144,36 @@ export default function PackagePage() {
canSubmit={canSubmit} canSubmit={canSubmit}
isSaving={isSaving} 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

@@ -4,4 +4,7 @@ export const packageKeys = {
all: ['packages'] as const, all: ['packages'] as const,
lists: () => [...packageKeys.all, 'list'] as const, lists: () => [...packageKeys.all, 'list'] as const,
list: (params: PaginationParams) => [...packageKeys.lists(), params] as const, list: (params: PaginationParams) => [...packageKeys.lists(), params] as const,
options: () => [...packageKeys.all, 'options'] as const,
details: () => [...packageKeys.all, 'detail'] as const,
detail: (id: string) => [...packageKeys.details(), id] as const,
}; };

View File

@@ -1,6 +1,18 @@
import { redirect } from 'next/navigation'; 'use client';
import { ROUTES } from '@/utils/routes';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { AppLoader } from '@/components/AppLoader';
import { useAppStore } from '@/store/app.store';
export default function HomePage() { export default function HomePage() {
redirect(ROUTES.DASHBOARD); const router = useRouter();
const defaultRoute = useAppStore((state) => state.defaultRoute);
useEffect(() => {
router.replace(defaultRoute ?? '/access');
}, [defaultRoute, router]);
return <AppLoader label="Opening workspace" />;
} }

View File

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

View File

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

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

View File

@@ -7,30 +7,24 @@ import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { z } from 'zod'; import { z } from 'zod';
import {
optionalLatitude,
optionalLongitude,
} from '@/lib/validation/numberField';
import { projectService } from '@/services/api'; import { projectService } from '@/services/api';
import type { Project, ProjectCreate, ProjectUpdate } from '@/types'; import type { Project, ProjectCreate, ProjectUpdate } from '@/types';
import { projectKeys } from '../queries/projectKeys'; import { projectKeys } from '../queries/projectKeys';
const optionalCoordinate = z.string().refine(
(value) => {
if (!value.trim()) {
return true;
}
return Number.isFinite(Number(value));
},
{ message: 'Enter a valid number' },
);
const projectFormSchema = z.object({ const projectFormSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
name: z.string().trim().min(1, 'Project name is required'), name: z.string().trim().min(1, 'Project name is required'),
state: z.string().optional(), state: z.string().optional(),
corridor_name: z.string().optional(), corridor_name: z.string().optional(),
start_lat: optionalCoordinate, start_lat: optionalLatitude,
start_lng: optionalCoordinate, start_lng: optionalLongitude,
end_lat: optionalCoordinate, end_lat: optionalLatitude,
end_lng: optionalCoordinate, end_lng: optionalLongitude,
}); });
export type ProjectFormValues = z.infer<typeof projectFormSchema>; export type ProjectFormValues = z.infer<typeof projectFormSchema>;
@@ -106,14 +100,7 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
}, },
}); });
const onSubmit = handleSubmit( const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
(values) => saveMutation.mutate(values),
(formErrors) => {
if (formErrors.name?.message) {
toast.error(formErrors.name.message);
}
},
);
const openCreate = useCallback(() => { const openCreate = useCallback(() => {
reset(defaultValues); reset(defaultValues);

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 { Layers, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header'; 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 { Button } from '@/components/ui/button';
import type { Project } from '@/types'; import type { Project } from '@/types';
@@ -19,6 +29,7 @@ import {
export default function ProjectPage() { export default function ProjectPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
const { skip, setSkip, limit, setLimit } = useProjectFilters(); const { skip, setSkip, limit, setLimit } = useProjectFilters();
const projectsQuery = useProjectsQuery({ skip, limit }); const projectsQuery = useProjectsQuery({ skip, limit });
const deleteProjectMutation = useDeleteProjectMutation(); const deleteProjectMutation = useDeleteProjectMutation();
@@ -63,16 +74,25 @@ export default function ProjectPage() {
const deleteProject = useCallback( const deleteProject = useCallback(
(project: Project) => { (project: Project) => {
if ( setProjectToDelete(project);
!confirm(`Are you sure you want to delete project "${project.name}"?`)
) {
return;
}
deleteProjectMutation.mutate(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 columns = useProjectColumns();
const projects = projectsQuery.data?.items ?? []; const projects = projectsQuery.data?.items ?? [];
const total = projectsQuery.data?.totalItems ?? 0; const total = projectsQuery.data?.totalItems ?? 0;
@@ -117,6 +137,36 @@ export default function ProjectPage() {
canSubmit={canSubmit} canSubmit={canSubmit}
isSaving={isSaving} 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

@@ -4,4 +4,7 @@ export const projectKeys = {
all: ['projects'] as const, all: ['projects'] as const,
lists: () => [...projectKeys.all, 'list'] as const, lists: () => [...projectKeys.all, 'list'] as const,
list: (params: PaginationParams) => [...projectKeys.lists(), params] as const, list: (params: PaginationParams) => [...projectKeys.lists(), params] as const,
options: () => [...projectKeys.all, 'options'] as const,
details: () => [...projectKeys.all, 'detail'] as const,
detail: (id: string) => [...projectKeys.details(), id] as const,
}; };

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,200 +1,57 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useParams, useRouter } from 'next/navigation';
import { useRouter, useParams } from 'next/navigation'; import { ArrowLeft, TrendingUp } from 'lucide-react';
import { Button } from '@/components/ui/button'; import VideoPlayerSection from '@/components/videoPlayerSection';
import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react';
import VideoPlayerSection from '@/components/video-player-section';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { sessionService, videoService } from '@/services/api'; import { Button } from '@/components/ui/button';
import { SessionContext, DetectionData, DetectionType } from '@/types'; import { useVideoResultsQuery } from '../hooks/useVideoResults';
import { getVideoFile, clearVideoFile } from '@/lib/video-storage'; import { VideoResultsSkeleton } from './components/VideoResultsSkeleton';
import { ROUTES } from '@/utils/routes';
import { Card } from '@/components/ui/card';
import { getDetectionModeConfig } from '@/constants/detectionModeConfig';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
export default function VideoResultsPage() { export default function VideoResultsPage() {
const router = useRouter(); const router = useRouter();
const { videoId } = useParams() as { videoId: string }; const { videoId } = useParams() as { videoId: string };
const [session, setSession] = useState<SessionContext | null>(null);
const [detectionData, setDetectionData] = useState<DetectionData | null>(
null,
);
const [detectionType, setDetectionType] =
useState<DetectionType>('pothole-detection');
const [videoFile, setVideoFile] = useState<File | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => { const {
const storedSession = sessionService.loadSession(); data: detectionData,
setSession(storedSession); isLoading,
isError,
error: queryError,
} = useVideoResultsQuery(videoId);
const fetchResults = async () => { const error = isError
try { ? queryError instanceof Error
// Fetch detection data from backend ? queryError.message
const data = await videoService.getVideoResults(videoId); : 'Failed to load results'
setDetectionData(data as any); : null;
// Try to infer detection type from results if possible
if (data.detection_mode) {
setDetectionType(data.detection_mode);
} else if (
data.summary?.unique_signboards !== undefined &&
data.summary?.unique_signboards > 0
) {
setDetectionType('sign-board-detection');
} else if (
data.summary?.unique_potholes !== undefined &&
data.summary?.unique_potholes > 0
) {
setDetectionType('pothole-detection');
}
// Retrieve video file from IndexedDB
const storedVideoFile = await getVideoFile(videoId);
if (storedVideoFile) {
setVideoFile(storedVideoFile);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load results');
} finally {
setIsLoading(false);
}
};
if (videoId) {
fetchResults();
}
}, [videoId]);
const handleNewAnalysis = async () => {
if (videoId) {
try {
await clearVideoFile(videoId);
} catch (err) {
console.error('Failed to clear video file:', err);
}
}
sessionService.clearSession();
router.push(ROUTES.UPLOAD);
};
const getTitle = () => {
const config = getDetectionModeConfig(detectionType);
return `${config.label} Results`;
};
if (isLoading) { if (isLoading) {
return ( return <VideoResultsSkeleton />;
<div className="min-h-screen flex items-center justify-center">
<Card className="flex flex-col items-center gap-4 p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">
Loading detection results...
</p>
</Card>
</div>
);
} }
if (error) { if (error) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="flex min-h-[60vh] items-center justify-center">
<Card className="flex flex-col items-center gap-6 p-8 text-center max-w-md"> <p className="text-sm text-destructive">{error}</p>
<p className="text-destructive font-medium">{error}</p>
<div className="flex gap-4">
<Button
onClick={() => router.push(ROUTES.UPLOAD)}
variant="outline"
>
Back to Upload
</Button>
<Button onClick={handleNewAnalysis}>New Analysis</Button>
</div>
</Card>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen"> <div className="space-y-8">
<main className="min-h-screen"> <PageHeader
<div className="container mx-auto px-6 py-10 max-w-[1600px]"> title="Detection Results"
<div className="mb-8"> description={`Video ID: ${videoId}`}
<PageHeader icon={TrendingUp}
title={getTitle()} actions={
description={`Video ID: ${videoId}`} <Button variant="outline" size="sm" onClick={() => router.back()}>
icon={TrendingUp} <ArrowLeft />
/> Back
</div> </Button>
}
/>
{session && ( {detectionData && <VideoPlayerSection data={detectionData} />}
<div className="mb-6">
<Card className="p-0 border shadow-sm overflow-hidden">
<div className="flex flex-col md:flex-row md:items-center justify-between py-4 px-6 gap-6 bg-card">
<div className="flex flex-wrap items-center gap-x-12 gap-y-4">
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
Project
</span>
<span className="text-base font-bold leading-tight">
{session.projectName}
</span>
</div>
<div className="flex flex-col border-l pl-12 border-border/60">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
Package
</span>
<span className="text-sm font-semibold text-muted-foreground leading-tight">
{session.packageName}
</span>
</div>
<div className="flex flex-col border-l pl-12 border-border/60">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
Segment
</span>
<span className="text-sm font-semibold text-muted-foreground leading-tight flex items-center gap-1.5">
{session.chainageName}
{session.chainageDirection && (
<span className="inline-flex items-center gap-1 px-1 py-0.5 rounded bg-muted text-[10px] font-bold uppercase border">
{session.chainageDirection === 'UP' ? (
<ArrowUp className="h-2.5 w-2.5" />
) : (
<ArrowDown className="h-2.5 w-2.5" />
)}
{session.chainageDirection}
</span>
)}
</span>
</div>
</div>
<Button
onClick={handleNewAnalysis}
variant="outline"
size="sm"
className="font-semibold px-6 shrink-0 h-9"
>
Start New Analysis
</Button>
</div>
</Card>
</div>
)}
{detectionData && (
<VideoPlayerSection
data={detectionData}
videoId={videoId}
videoFile={videoFile}
detectionType={detectionType}
projectId={session?.projectId || undefined}
/>
)}
</div>
</main>
</div> </div>
); );
} }

View File

@@ -0,0 +1,78 @@
'use client';
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';
/**
* Fetch the completed detection results for a video.
*/
export function useVideoResultsQuery(videoId: string | undefined) {
return useQuery({
queryKey: videoKeys.result(videoId ?? ''),
queryFn: () => videoService.getVideoResults(videoId as string),
enabled: Boolean(videoId),
});
}
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.
*
* Fetching the blob instead of pointing `<video src>` at the protected endpoint
* directly is what fixes the 401. The browser cannot attach the Bearer token to
* a media request, but axios can.
*/
export function useProtectedVideoQuery(url: string | undefined) {
const query = useQuery({
queryKey: videoKeys.annotatedVideo(url ?? ''),
queryFn: () => videoService.getProtectedVideo(url as string),
enabled: Boolean(url),
staleTime: Infinity,
gcTime: 1000 * 60 * 30,
});
const [objectUrl, setObjectUrl] = useState<string | null>(null);
useEffect(() => {
if (!query.data) {
setObjectUrl(null);
return;
}
const nextUrl = URL.createObjectURL(query.data);
setObjectUrl(nextUrl);
return () => {
URL.revokeObjectURL(nextUrl);
};
}, [query.data]);
return {
videoUrl: objectUrl,
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
refetch: query.refetch,
};
}
export const useAnnotatedVideoQuery = useProtectedVideoQuery;

View File

@@ -1,155 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Loader2, TrendingUp } from 'lucide-react';
import VideoPlayerSection from '@/components/video-player-section';
import { PageHeader } from '@/components/page-header';
import { sessionService } from '@/services/api';
import { SessionContext, DetectionData, DetectionType } from '@/types';
import { clearVideoFile } from '@/lib/video-storage';
import { ROUTES } from '@/utils/routes';
import { Card } from '@/components/ui/card';
export default function ResultsPage() {
const router = useRouter();
const [session, setSession] = useState<SessionContext | null>(null);
const [detectionData] = useState<DetectionData | null>(null);
const [detectionType] = useState<DetectionType>('pothole-detection');
const [videoId] = useState<string | null>(null);
const [videoFile] = useState<File | null>(null);
const [isLoading] = useState(true);
const [error] = useState<string | null>(null);
// Load session and video data on mount
useEffect(() => {
const storedSession = sessionService.loadSession();
const videoData = sessionService.loadVideoData();
if (!sessionService.isSessionValid(storedSession) || !videoData) {
router.replace(ROUTES.UPLOAD);
return;
}
// If we have a videoId, redirect to the dynamic results page
if (videoData.videoId) {
router.replace(`${ROUTES.RESULTS}/${videoData.videoId}`);
return;
}
setSession(storedSession);
}, [router]);
const handleNewAnalysis = async () => {
// Clear video from IndexedDB
if (videoId) {
try {
await clearVideoFile(videoId);
} catch (err) {
console.error('Failed to clear video file:', err);
}
}
sessionService.clearSession();
router.push(ROUTES.UPLOAD);
};
const getTitle = () => {
if (detectionType === 'pothole-detection')
return 'Pothole Detection Results';
if (detectionType === 'sign-board-detection')
return 'Signboard Detection Results';
return 'Pothole & Signboard Detection Results';
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="flex flex-col items-center gap-4 p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading results...</p>
</Card>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="flex flex-col items-center gap-6 p-8 text-center max-w-md">
<p className="text-destructive font-medium">{error}</p>
<Button onClick={handleNewAnalysis}>Start New Analysis</Button>
</Card>
</div>
);
}
return (
<div className="min-h-screen">
<main className="min-h-screen">
<div className="container mx-auto px-6 py-10 max-w-[1600px]">
<div className="mb-8">
<PageHeader
title={getTitle()}
description="View your AI-powered road analysis results"
icon={TrendingUp}
/>
</div>
{session && (
<div className="mb-6">
<Card className="p-0 border shadow-sm overflow-hidden">
<div className="flex flex-col md:flex-row md:items-center justify-between py-4 px-6 gap-6 bg-card">
<div className="flex flex-wrap items-center gap-x-12 gap-y-4">
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
Project
</span>
<span className="text-base font-bold leading-tight">
{session.projectName}
</span>
</div>
<div className="flex flex-col border-l pl-12 border-border/60">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
Package
</span>
<span className="text-sm font-semibold text-muted-foreground leading-tight">
{session.packageName}
</span>
</div>
<div className="flex flex-col border-l pl-12 border-border/60">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
Segment
</span>
<span className="text-sm font-semibold text-muted-foreground leading-tight">
{session.chainageName}
</span>
</div>
</div>
<Button
onClick={handleNewAnalysis}
variant="outline"
size="sm"
className="font-semibold px-6 shrink-0 h-9"
>
Start New Analysis
</Button>
</div>
</Card>
</div>
)}
{detectionData && videoId && (
<VideoPlayerSection
data={detectionData}
videoId={videoId}
videoFile={videoFile}
detectionType={detectionType}
projectId={session?.projectId || undefined}
/>
)}
</div>
</main>
</div>
);
}

View File

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

View File

@@ -89,22 +89,7 @@ export function useRoleForm({
}, },
}); });
const onSubmit = handleSubmit( const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
(values) => saveMutation.mutate(values),
(formErrors) => {
if (formErrors.name?.message) {
toast.error(formErrors.name.message);
return;
}
if (formErrors.display_name?.message) {
toast.error(formErrors.display_name.message);
return;
}
if (formErrors.permission_ids?.message) {
toast.error(formErrors.permission_ids.message);
}
},
);
const openCreate = useCallback(() => { const openCreate = useCallback(() => {
reset({ reset({

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

@@ -1,13 +1,17 @@
'use client'; 'use client';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import { useRef } from 'react';
import type { FieldErrors, UseFormRegister } from 'react-hook-form'; import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form'; import { FormField } from '@/components/form';
import { PackageSelect } from '@/components/lookups/PackageSelect';
import { ProjectSelect } from '@/components/lookups/ProjectSelect';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
@@ -22,7 +26,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import type { Package, Project } from '@/types';
import type { SegmentFormValues } from '../hooks/useSegmentForm'; import type { SegmentFormValues } from '../hooks/useSegmentForm';
@@ -30,10 +33,6 @@ interface SegmentDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
segmentId?: string; segmentId?: string;
projects: Project[];
packages: Package[];
isProjectsLoading: boolean;
isPackagesLoading: boolean;
projectId: string; projectId: string;
onProjectChange: (projectId: string) => void; onProjectChange: (projectId: string) => void;
packageId: string; packageId: string;
@@ -52,10 +51,6 @@ export function SegmentDialog({
open, open,
onOpenChange, onOpenChange,
segmentId, segmentId,
projects,
packages,
isProjectsLoading,
isPackagesLoading,
projectId, projectId,
onProjectChange, onProjectChange,
packageId, packageId,
@@ -69,26 +64,32 @@ export function SegmentDialog({
canSubmit, canSubmit,
isSaving, isSaving,
}: SegmentDialogProps) { }: SegmentDialogProps) {
const projectSelectPortalRef = useRef<HTMLDivElement | null>(null);
const packageSelectPortalRef = useRef<HTMLDivElement | null>(null);
const getError = (field: keyof SegmentFormValues) => const getError = (field: keyof SegmentFormValues) =>
isSubmitted ? errors[field]?.message : undefined; isSubmitted ? errors[field]?.message : undefined;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <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()} onOpenAutoFocus={(event) => event.preventDefault()}
> >
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12"> <DialogHeader>
<DialogTitle className="text-lg leading-none font-semibold tracking-tight"> <DialogTitle>
{segmentId ? 'Edit Segment' : 'Create Segment'} {segmentId ? 'Edit Segment' : 'Create Segment'}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm"> <DialogDescription>
Manage segment binding, chainage values, direction, and coordinates. Manage segment binding, chainage values, direction, and coordinates.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col"> <form
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4"> noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<DialogBody className="space-y-6">
<section className="space-y-4"> <section className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-semibold">Basic details</h3> <h3 className="text-sm font-semibold">Basic details</h3>
@@ -104,25 +105,13 @@ export function SegmentDialog({
required required
error={getError('project_id')} error={getError('project_id')}
> >
<Select value={projectId} onValueChange={onProjectChange}> <ProjectSelect
<SelectTrigger aria-invalid={!!getError('project_id')}> value={projectId}
{isProjectsLoading ? ( onValueChange={onProjectChange}
<span className="flex items-center gap-2 text-muted-foreground"> enabled={open}
<Loader2 className="size-4 animate-spin" /> disabled={isSaving}
Loading... portalContainer={projectSelectPortalRef}
</span> />
) : (
<SelectValue placeholder="Choose a project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input <input
type="hidden" type="hidden"
{...register('project_id')} {...register('project_id')}
@@ -136,35 +125,14 @@ export function SegmentDialog({
required required
error={getError('package_id')} error={getError('package_id')}
> >
<Select <PackageSelect
value={packageId} value={packageId}
onValueChange={onPackageChange} onValueChange={onPackageChange}
disabled={!projectId || isPackagesLoading} projectId={projectId}
> enabled={open && Boolean(projectId)}
<SelectTrigger aria-invalid={!!getError('package_id')}> disabled={!projectId || isSaving}
{isPackagesLoading ? ( portalContainer={packageSelectPortalRef}
<span className="flex items-center gap-2 text-muted-foreground"> />
<Loader2 className="size-4 animate-spin" />
Loading...
</span>
) : (
<SelectValue
placeholder={
projectId
? 'Choose a package'
: 'Select project first'
}
/>
)}
</SelectTrigger>
<SelectContent>
{packages.map((pkg) => (
<SelectItem key={pkg.id} value={pkg.id}>
{pkg.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input <input
type="hidden" type="hidden"
{...register('package_id')} {...register('package_id')}
@@ -215,16 +183,16 @@ export function SegmentDialog({
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<FormField <FormField
id="segment-start-km" id="segment-start-km"
label="Start (km)" label="Start Chainage"
required required
error={getError('chainage_start_km')} error={getError('chainage_start_km')}
> >
<Input <Input
id="segment-start-km" id="segment-start-km"
type="number" type="number"
step="any" step="1"
min="0" min="0"
placeholder="0.0" placeholder="Enter start chainage"
aria-invalid={!!getError('chainage_start_km')} aria-invalid={!!getError('chainage_start_km')}
{...register('chainage_start_km')} {...register('chainage_start_km')}
/> />
@@ -232,16 +200,16 @@ export function SegmentDialog({
<FormField <FormField
id="segment-end-km" id="segment-end-km"
label="End (km)" label="End Chainage"
required required
error={getError('chainage_end_km')} error={getError('chainage_end_km')}
> >
<Input <Input
id="segment-end-km" id="segment-end-km"
type="number" type="number"
step="any" step="1"
min="0" min="0"
placeholder="1.0" placeholder="Enter end chainage"
aria-invalid={!!getError('chainage_end_km')} aria-invalid={!!getError('chainage_end_km')}
{...register('chainage_end_km')} {...register('chainage_end_km')}
/> />
@@ -333,9 +301,9 @@ export function SegmentDialog({
</div> </div>
</div> </div>
</section> </section>
</div> </DialogBody>
<DialogFooter className="shrink-0 border-t px-6 py-4"> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -345,11 +313,16 @@ export function SegmentDialog({
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSaving || !canSubmit}> <Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null} {isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{segmentId ? 'Update Segment' : 'Create Segment'} {segmentId ? 'Update Segment' : 'Create Segment'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
<div ref={projectSelectPortalRef} />
<div ref={packageSelectPortalRef} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

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

View File

@@ -11,6 +11,8 @@ import {
} from '@/services/api'; } from '@/services/api';
import type { Chainage, PaginationParams } from '@/types'; import type { Chainage, PaginationParams } from '@/types';
import { packageKeys } from '../../package/queries/packageKeys';
import { projectKeys } from '../../project/queries/projectKeys';
import { segmentKeys } from '../queries/segmentKeys'; import { segmentKeys } from '../queries/segmentKeys';
interface UseSegmentsQueryParams { interface UseSegmentsQueryParams {
@@ -32,27 +34,18 @@ export function useSegmentsQuery({ skip, limit }: UseSegmentsQueryParams) {
export function useProjectOptionsQuery() { export function useProjectOptionsQuery() {
return useQuery({ return useQuery({
queryKey: ['projects', 'options'], queryKey: projectKeys.options(),
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }), queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
}); });
} }
export function useAllPackageOptionsQuery() { export function useAllPackageOptionsQuery() {
return useQuery({ return useQuery({
queryKey: ['packages', 'options'], queryKey: packageKeys.options(),
queryFn: () => packageService.getPackages({ skip: 0, limit: 1000 }), queryFn: () => packageService.getPackages({ skip: 0, limit: 1000 }),
}); });
} }
export function usePackagesByProjectQuery(projectId: string, enabled: boolean) {
return useQuery({
queryKey: ['packages', 'by-project', projectId],
queryFn: () =>
packageService.getPackagesByProject(projectId, { skip: 0, limit: 1000 }),
enabled: enabled && Boolean(projectId),
});
}
export function useDeleteSegmentMutation() { export function useDeleteSegmentMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();

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 { Milestone, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header'; 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 { Button } from '@/components/ui/button';
import type { Chainage } from '@/types'; import type { Chainage } from '@/types';
@@ -15,13 +25,13 @@ import { useSegmentForm } from './hooks/useSegmentForm';
import { import {
useAllPackageOptionsQuery, useAllPackageOptionsQuery,
useDeleteSegmentMutation, useDeleteSegmentMutation,
usePackagesByProjectQuery,
useProjectOptionsQuery, useProjectOptionsQuery,
useSegmentsQuery, useSegmentsQuery,
} from './hooks/useSegmentQueries'; } from './hooks/useSegmentQueries';
export default function SegmentPage() { export default function SegmentPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [segmentToDelete, setSegmentToDelete] = useState<Chainage | null>(null);
const { skip, setSkip, limit, setLimit } = useSegmentFilters(); const { skip, setSkip, limit, setLimit } = useSegmentFilters();
const segmentsQuery = useSegmentsQuery({ skip, limit }); const segmentsQuery = useSegmentsQuery({ skip, limit });
const projectsQuery = useProjectOptionsQuery(); const projectsQuery = useProjectOptionsQuery();
@@ -48,11 +58,6 @@ export default function SegmentPage() {
canSubmit, canSubmit,
isSaving, isSaving,
} = segmentForm; } = segmentForm;
const projectPackagesQuery = usePackagesByProjectQuery(
projectId,
isDialogOpen,
);
const openCreate = useCallback(() => { const openCreate = useCallback(() => {
prepareCreateSegment(); prepareCreateSegment();
setIsDialogOpen(true); setIsDialogOpen(true);
@@ -81,21 +86,27 @@ export default function SegmentPage() {
const deleteSegment = useCallback( const deleteSegment = useCallback(
(segment: Chainage) => { (segment: Chainage) => {
if ( setSegmentToDelete(segment);
!confirm(
`Are you sure you want to delete segment "${segment.segment_name}"?`,
)
) {
return;
}
deleteSegmentMutation.mutate(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 projects = projectsQuery.data?.items ?? [];
const allPackages = allPackagesQuery.data?.items ?? []; const allPackages = allPackagesQuery.data?.items ?? [];
const projectPackages = projectPackagesQuery.data?.items ?? [];
const segments = segmentsQuery.data?.items ?? []; const segments = segmentsQuery.data?.items ?? [];
const total = segmentsQuery.data?.totalItems ?? 0; const total = segmentsQuery.data?.totalItems ?? 0;
const columns = useSegmentColumns(projects, allPackages); const columns = useSegmentColumns(projects, allPackages);
@@ -133,10 +144,6 @@ export default function SegmentPage() {
open={isDialogOpen} open={isDialogOpen}
onOpenChange={handleDialogOpenChange} onOpenChange={handleDialogOpenChange}
segmentId={segmentId} segmentId={segmentId}
projects={projects}
packages={projectPackages}
isProjectsLoading={projectsQuery.isLoading}
isPackagesLoading={projectPackagesQuery.isLoading}
projectId={projectId} projectId={projectId}
onProjectChange={setProjectId} onProjectChange={setProjectId}
packageId={packageId} packageId={packageId}
@@ -150,6 +157,36 @@ export default function SegmentPage() {
canSubmit={canSubmit} canSubmit={canSubmit}
isSaving={isSaving} 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

@@ -4,4 +4,6 @@ export const segmentKeys = {
all: ['segments'] as const, all: ['segments'] as const,
lists: () => [...segmentKeys.all, 'list'] as const, lists: () => [...segmentKeys.all, 'list'] as const,
list: (params: PaginationParams) => [...segmentKeys.lists(), params] as const, list: (params: PaginationParams) => [...segmentKeys.lists(), params] as const,
details: () => [...segmentKeys.all, 'detail'] as const,
detail: (id: string) => [...segmentKeys.details(), id] as const,
}; };

View File

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

View File

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

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

View File

@@ -0,0 +1,97 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Timeline, TimelineItem } from '@/components/ui/timeline';
function TimelineEntrySkeleton({
index,
isLast,
}: {
index: number;
isLast: boolean;
}) {
return (
<TimelineItem
layout="left"
isLast={isLast}
opposite={
<div className="space-y-2">
<Skeleton className="ml-auto h-4 w-16" />
<Skeleton className="ml-auto h-3 w-12" />
</div>
}
marker={<Skeleton className="size-4 rounded-full" />}
>
<div
className={
index === 0
? 'rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5'
: 'rounded-lg border bg-card p-4'
}
>
{index === 0 ? <Skeleton className="h-5 w-28 rounded-lg" /> : null}
<Skeleton className={index === 0 ? 'mt-3 h-4 w-44' : 'h-4 w-44'} />
{index === 0 ? (
<Skeleton className="mt-2 h-3 w-full max-w-md" />
) : (
<>
<div className="mt-3 flex items-center gap-3">
<Skeleton className="size-9 rounded-full" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-3 w-48 max-w-full" />
</div>
</div>
<Skeleton className="mt-3 h-16 w-full rounded-lg" />
</>
)}
</div>
</TimelineItem>
);
}
export function TicketClassContentSkeleton() {
return (
<div className="space-y-4" aria-label="Loading defect class details">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-lg" />
<Skeleton className="h-5 w-56 max-w-full" />
</div>
<Timeline>
{Array.from({ length: 3 }).map((_, index) => (
<TimelineEntrySkeleton
key={index}
index={index}
isLast={index === 2}
/>
))}
</Timeline>
</div>
);
}
export function TicketClassActionsSkeleton() {
return (
<Card aria-label="Loading defect class actions">
<CardHeader>
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded-lg" />
<Skeleton className="h-5 w-32" />
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-20 w-full" />
</div>
<Skeleton className="h-10 w-full" />
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,281 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, ChevronRight } from 'lucide-react';
import DetectionLocationMap from '@/components/map/detectionLocationMap';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import { cn } from '@/lib/utils';
import { useTicketClassDetectionsQuery } from '../../hooks/useTicketQueries';
interface TicketClassDetectionPreviewProps {
videoId?: string | null;
defectClassName: string;
displayName: string;
totalCount?: number;
}
function DetectionMetadataBar({
items,
}: {
items: { label: string; value: string | number }[];
}) {
return (
<div className="rounded-lg bg-muted/40 px-4 py-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:divide-x sm:divide-border/70">
{items.map((item, index) => (
<div
key={item.label}
className={cn(
'space-y-1',
index === 0 && 'sm:pr-4',
index === 1 && 'sm:px-4',
index === 2 && 'sm:pl-4',
)}
>
<p className="text-xs text-muted-foreground">{item.label}</p>
<div className="text-sm font-semibold text-foreground">
{item.value}
</div>
</div>
))}
</div>
</div>
);
}
function TicketClassDetectionPreviewSkeleton() {
return (
<>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="min-w-0 space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="aspect-video w-full rounded-lg" />
</div>
<div className="min-w-0 space-y-2">
<Skeleton className="h-3 w-28" />
<Skeleton
className="w-full rounded-lg"
style={{ aspectRatio: '16 / 9' }}
/>
</div>
</div>
<div className="rounded-lg bg-muted/40 px-4 py-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:divide-x sm:divide-border/70">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className={cn(
'space-y-1',
index === 0 && 'sm:pr-4',
index === 1 && 'sm:px-4',
index === 2 && 'sm:pl-4',
)}
>
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
</div>
</>
);
}
export function TicketClassDetectionPreview({
videoId,
defectClassName,
displayName,
totalCount,
}: TicketClassDetectionPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const visual = getDefectVisual(defectClassName);
const IssueIcon = visual.icon;
useEffect(() => {
setCurrentIndex(0);
}, [defectClassName]);
const queryParams = useMemo(
() => ({
class_name: defectClassName,
skip: currentIndex,
limit: 1,
sort: 'timestamp_asc',
}),
[currentIndex, defectClassName],
);
const detectionsQuery = useTicketClassDetectionsQuery(
videoId ?? undefined,
queryParams,
);
const detectionResult = detectionsQuery.data;
const activeDetection = detectionResult?.items[0];
const detectionCount = detectionResult?.total ?? totalCount ?? 0;
const mediaAspectRatio =
detectionResult &&
detectionResult.video_width > 0 &&
detectionResult.video_height > 0
? `${detectionResult.video_width} / ${detectionResult.video_height}`
: '16 / 9';
useEffect(() => {
if (detectionCount === 0 && currentIndex !== 0) {
setCurrentIndex(0);
return;
}
if (detectionCount > 0 && currentIndex > detectionCount - 1) {
setCurrentIndex(detectionCount - 1);
}
}, [currentIndex, detectionCount]);
const isNavigationDisabled =
detectionCount === 0 || detectionsQuery.isLoading;
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<span
className={cn(
'flex size-10 shrink-0 self-start items-center justify-center rounded-lg',
visual.cardClassName,
)}
>
<IssueIcon className={cn('size-5', visual.colorClassName)} />
</span>
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3>{displayName}</h3>
<Badge
variant="secondary"
className={cn(
'rounded-lg',
visual.cardClassName,
visual.colorClassName,
)}
>
{detectionCount} Detections
</Badge>
</div>
<p className="text-muted-foreground">
Review detections in ascending timestamp order.
</p>
</div>
</div>
<div className="flex items-center gap-2 self-start sm:self-auto">
<Button
type="button"
variant="secondary"
onClick={() =>
setCurrentIndex((index) =>
detectionCount === 0
? 0
: (index - 1 + detectionCount) % detectionCount,
)
}
disabled={isNavigationDisabled}
aria-label="Previous detection"
>
<ChevronLeft className="size-4" />
</Button>
<div className="min-w-16 text-center text-sm font-medium text-foreground">
{detectionCount === 0
? '0 of 0'
: `${currentIndex + 1} of ${detectionCount}`}
</div>
<Button
type="button"
variant="secondary"
onClick={() =>
setCurrentIndex((index) =>
detectionCount === 0 ? 0 : (index + 1) % detectionCount,
)
}
disabled={isNavigationDisabled}
aria-label="Next detection"
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
{detectionsQuery.isLoading && !detectionsQuery.data ? (
<TicketClassDetectionPreviewSkeleton />
) : detectionsQuery.isError ? (
<div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-8 text-center">
<div className="flex flex-col items-center gap-2">
<AlertTriangle className="size-6 text-destructive" />
<p className="text-sm font-medium text-destructive">
Failed to load detection preview.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void detectionsQuery.refetch()}
>
Retry
</Button>
</div>
</div>
) : !activeDetection || !detectionResult ? (
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
No detections are available for this issue.
</div>
) : (
<>
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2">
<div className="min-w-0 space-y-2">
<p className="text-muted-foreground">Detection Image</p>
<div className="overflow-hidden rounded-lg">
<AnnotatedDetectionImage
detection={activeDetection}
videoWidth={detectionResult.video_width}
videoHeight={detectionResult.video_height}
/>
</div>
</div>
<DetectionLocationMap
className="min-w-0"
latitude={activeDetection.location.latitude}
longitude={activeDetection.location.longitude}
label={`${displayName} - Frame ${activeDetection.frame.number}`}
title="Location on Map"
variant="plain"
aspectRatio={mediaAspectRatio}
showCoordinates={false}
/>
</div>
<DetectionMetadataBar
items={[
{
label: 'Confidence',
value: `${Math.round(activeDetection.detection.confidence * 100)}%`,
},
{
label: 'Frame',
value: activeDetection.frame.number,
},
{
label: 'Timestamp',
value: `${activeDetection.frame.timestamp_seconds}s`,
},
]}
/>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,163 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Component, ListChecks } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DEFECT_CLASS_STATUS_CONFIG } from '@/constants/defectClassStatus';
import { cn } from '@/lib/utils';
import type { TicketOverviewDetail } from '@/types';
import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries';
import { TicketClassDetectionPreview } from './TicketClassDetectionPreview';
interface TicketDefectClassTabsProps {
ticketId: string;
ticket?: TicketOverviewDetail;
}
export function TicketDefectClassTabs({
ticketId,
ticket,
}: TicketDefectClassTabsProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const defectClassParam = searchParams.get('defect_class');
const defectClassesQuery = useTicketDefectClassesQuery(ticketId);
const defectClasses = useMemo(
() => defectClassesQuery.data?.defect_classes ?? [],
[defectClassesQuery.data?.defect_classes],
);
const [selectedClass, setSelectedClass] = useState<string>();
useEffect(() => {
if (!defectClasses.length) return;
const hasUrlClass = defectClasses.some(
(item) => item.class_name === defectClassParam,
);
if (hasUrlClass) {
setSelectedClass(defectClassParam ?? undefined);
return;
}
const firstClass = defectClasses[0].class_name;
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set('defect_class', firstClass);
setSelectedClass(firstClass);
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
}, [defectClassParam, defectClasses, pathname, router, searchParams]);
const handleClassChange = (className: string) => {
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set('defect_class', className);
setSelectedClass(className);
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
};
const selectedIssue = ticket?.ai_result.detections_by_class.find(
(item) => item.class_name === selectedClass,
);
const selectedClassItem = defectClasses.find(
(item) => item.class_name === selectedClass,
);
const previewDisplayName =
selectedIssue?.display_name ??
selectedClassItem?.display_name ??
selectedClass ??
'Detection';
const previewVideoId = ticket?.video_id ?? defectClassesQuery.data?.video_id;
if (defectClassesQuery.isLoading) {
return (
<Card className="w-full rounded-lg">
<CardContent className="space-y-4 p-5">
<div className="h-5 w-24 animate-pulse rounded-lg bg-muted" />
<div className="flex flex-col gap-2">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="h-11 w-full animate-pulse rounded-lg bg-muted"
/>
))}
</div>
</CardContent>
</Card>
);
}
if (defectClassesQuery.isError || !defectClasses.length || !selectedClass) {
return null;
}
return (
<Tabs
value={selectedClass}
onValueChange={handleClassChange}
orientation="vertical"
className="w-full"
>
<Card className="w-full">
<CardContent className="grid gap-0 p-0 lg:grid-cols-[minmax(220px,280px)_1fr] lg:divide-x lg:divide-border">
<div className="space-y-4 px-5">
<div className="flex items-center gap-2">
<ListChecks className="size-5 shrink-0 text-primary" />
<h2 className="text-base font-semibold">Detected Issues</h2>
<Badge
variant="secondary"
className="min-w-5 min-h-5 justify-center rounded-full"
>
{defectClasses.length}
</Badge>
</div>
<TabsList className="flex h-auto w-full flex-col items-stretch gap-2 bg-transparent p-0">
{defectClasses.map((item) => {
const statusConfig =
DEFECT_CLASS_STATUS_CONFIG[item.assignment_status];
return (
<TabsTrigger
key={item.class_name}
value={item.class_name}
className={cn(
'group h-auto w-full flex-none items-center justify-between! gap-2 rounded-lg border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground shadow-none after:hidden',
'data-[state=active]:bg-muted data-[state=active]:text-foreground',
)}
>
<span className="min-w-0 truncate text-left font-medium">
{item.display_name}
</span>
<Badge
className={cn(
'shrink-0 rounded-full',
statusConfig.badgeClassName,
)}
>
<Component className="size-3" />
{statusConfig.label}
</Badge>
</TabsTrigger>
);
})}
</TabsList>
</div>
<div className="min-w-0 px-5">
<TicketClassDetectionPreview
videoId={previewVideoId}
defectClassName={selectedClass}
displayName={previewDisplayName}
totalCount={selectedIssue?.count}
/>
</div>
</CardContent>
</Card>
</Tabs>
);
}

View File

@@ -0,0 +1,35 @@
'use client';
import { ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import type { TicketOverviewDetail } from '@/types';
export function TicketDetailHeader({
ticket,
}: {
ticket: TicketOverviewDetail;
}) {
const router = useRouter();
const ticketLabel = ticket.ticket_name || ticket.id;
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Ticket Detail</h1>
<p className="text-xs ">Ticket: {ticketLabel}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="secondary"
onClick={() => router.push('/ticket')}
>
<ArrowLeft className="size-4" />
Back to List
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,135 @@
'use client';
import { History, MessageSquareText } from 'lucide-react';
import { PersonInfo } from '@/components/person-avatar';
import { Badge } from '@/components/ui/badge';
import { Timeline, TimelineItem } from '@/components/ui/timeline';
import { cn } from '@/lib/utils';
import type { TicketDetail, TicketHistoryItem, TicketHistorySource } from '@/types';
import { formatTimelineDateTime } from '@/utils/date';
import { getPersonLabel } from '@/utils/person';
function TimelineDate({ value }: { value: string }) {
const { date, time } = formatTimelineDateTime(value);
return (
<time dateTime={value} className="block tabular-nums">
<span className="block text-sm font-semibold text-foreground">{date}</span>
<span className="block text-xs text-muted-foreground">{time}</span>
</time>
);
}
function HistoryNote({ item }: { item: TicketHistoryItem }) {
const noteText = item.note?.text?.trim();
if (!noteText) return null;
return (
<div className="mt-3 rounded-lg border border-amber-300/80 bg-amber-50/90 px-3 py-2.5 dark:border-amber-500/30 dark:bg-amber-500/10">
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-900 dark:text-amber-200">
<MessageSquareText className="size-3.5 shrink-0 text-amber-700 dark:text-amber-300" />
<span>
Note by {getPersonLabel(item.note?.author ?? item.actor)}
</span>
</div>
<p className="mt-1.5 text-sm leading-relaxed text-amber-950/80 dark:text-amber-100/90">
{noteText}
</p>
</div>
);
}
function getSourceLabel(source: TicketHistorySource) {
return source === 'system' ? 'System Generated' : 'User Action';
}
function SystemHistoryEntry({ item }: { item: TicketHistoryItem }) {
const noteText = item.note?.text?.trim();
return (
<div className="rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5">
<Badge
className="rounded-lg px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase"
>
{getSourceLabel(item.source)}
</Badge>
<h3 className="mt-2 text-sm font-semibold text-foreground">{item.title}</h3>
{noteText ? (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
{noteText}
</p>
) : null}
</div>
);
}
function UserHistoryEntry({ item }: { item: TicketHistoryItem }) {
const noteText = item.note?.text?.trim();
const isAssigned = item.event_type === 'assigned';
return (
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-3 text-sm font-semibold text-foreground">{item.title}</h3>
<PersonInfo person={item.actor} />
{isAssigned && item.target_user ? (
<div className="mt-3 space-y-1">
<p className="text-xs text-muted-foreground">Assigned to</p>
<PersonInfo person={item.target_user} />
</div>
) : null}
{noteText ? <HistoryNote item={item} /> : null}
</div>
);
}
export function TicketHistoryCard({ ticket }: { ticket: TicketDetail }) {
const history = ticket.history ?? [];
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<History className="size-5 text-primary" />
<h2 className="text-base font-semibold">Audit & Lifecycle Timeline</h2>
</div>
{history.length ? (
<Timeline>
{history.map((item, index) => (
<TimelineItem
key={item.id}
layout="left"
isLast={index === history.length - 1}
opposite={<TimelineDate value={item.created_at} />}
marker={
<span className="flex size-4 items-center justify-center rounded-full border bg-background shadow-xs">
<span
className={cn(
'size-1.5 rounded-full',
item.source === 'system'
? 'bg-muted-foreground'
: 'bg-primary',
)}
/>
</span>
}
>
{item.source === 'system' ? (
<SystemHistoryEntry item={item} />
) : (
<UserHistoryEntry item={item} />
)}
</TimelineItem>
))}
</Timeline>
) : (
<div className="rounded-lg border border-dashed border-border/80 bg-muted/10 px-4 py-8 text-center">
<p className="text-sm font-medium text-muted-foreground">
No timeline entries yet
</p>
</div>
)}
</div>
);
}

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,250 @@
'use client';
import {
Activity,
CalendarDays,
Clock3,
Gauge,
MapPin,
Monitor,
UserRound,
Play
} from 'lucide-react';
import { useState } from 'react';
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import { cn } from '@/lib/utils';
import type { TicketOverviewDetail } from '@/types';
import { formatDate } from '@/utils/date';
import { TicketInlineAnalysisVideo } from './TicketInlineAnalysisVideo';
type IconComponent = React.ComponentType<{ className?: string }>;
function MetaLabel({ children }: { children: React.ReactNode }) {
return (
<p className="text-xs font-medium tracking-wide text-muted-foreground">
{children}
</p>
);
}
function MetaValue({ children }: { children: React.ReactNode }) {
return <p className="text-sm font-semibold text-foreground">{children}</p>;
}
function OverviewField({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="min-w-0 space-y-1">
<MetaLabel>{label}</MetaLabel>
{children}
</div>
);
}
function SummaryCard({
label,
value,
colorClassName,
cardClassName,
}: {
label: string;
value: number;
colorClassName: string;
cardClassName: string;
}) {
return (
<div
className={cn(
'rounded-lg bg-muted/40 p-4 hover:scale-105 transition-transform ease-in-out',
cardClassName,
)}
>
<p className={cn('text-2xl font-bold tracking-tight', colorClassName)}>
{value}
</p>
<p className="mt-2 truncate text-xs font-semibold text-foreground">
{label}
</p>
<p className="mt-0.5 text-[11px] text-muted-foreground">Detected</p>
</div>
);
}
function VideoMetric({
icon: Icon,
label,
}: {
icon: IconComponent;
label: string;
}) {
return (
<div className="flex items-center justify-center gap-2 font-medium">
<Icon className="size-4 text-muted-foreground" />
<span>{label}</span>
</div>
);
}
export function TicketOverviewCard({
ticket,
}: {
ticket: TicketOverviewDetail;
}) {
const [isAnalysisOpen, setIsAnalysisOpen] = useState(false);
const videoMetadata = ticket.video_metadata;
const duration =
typeof videoMetadata?.duration_seconds === 'number'
? `${Math.floor(videoMetadata.duration_seconds / 60)}:${Math.floor(
videoMetadata.duration_seconds % 60,
)
.toString()
.padStart(2, '0')}`
: null;
const chainageName =
ticket.location?.chainage?.name || ticket.chainage_name || null;
const packageName = ticket.location?.package?.name || null;
const locationLabel =
[chainageName, packageName].filter(Boolean).join(', ') || null;
const uploadedOn =
ticket.timestamps?.created_at || ticket.ai_result.completed_at || null;
const uploaderName = ticket.uploader?.name || null;
const analysisVideoId = ticket.video_id || ticket.video?.id || null;
const videoName = ticket.video?.name || null;
const analysisTitle = videoName || ticket.ticket_name || 'Analysis Video';
const hasVideoMetrics = Boolean(
videoMetadata?.fps || duration || videoMetadata?.resolution?.label,
);
return (
<div className="space-y-5">
{uploadedOn || uploaderName || locationLabel ? (
<Card>
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x">
{uploadedOn ? (
<div className="flex items-center gap-3 sm:pr-4">
<CalendarDays className="size-5 shrink-0 text-muted-foreground" />
<OverviewField label="Uploaded On">
<MetaValue>{formatDate(uploadedOn)}</MetaValue>
</OverviewField>
</div>
) : null}
{uploaderName ? (
<div className="flex items-center gap-3 sm:px-4">
<UserRound className="size-5 shrink-0 text-muted-foreground" />
<OverviewField label="Uploaded By">
<MetaValue>{uploaderName}</MetaValue>
</OverviewField>
</div>
) : null}
{locationLabel ? (
<div className="flex items-center gap-3 sm:pl-4">
<MapPin className="size-5 shrink-0 text-muted-foreground" />
<OverviewField label="Location">
<MetaValue>{locationLabel}</MetaValue>
</OverviewField>
</div>
) : null}
</CardContent>
</Card>
) : null}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="size-5 text-primary" />
AI Detection Summary
</CardTitle>
{analysisVideoId ? (
<CardAction>
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<Button
variant="secondary"
onClick={() => setIsAnalysisOpen(true)}
>
<Play className="mr-2 size-4" />
View Video
</Button>
</PermissionGuard>
</CardAction>
) : null}
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
<SummaryCard
label="Total Detections"
value={ticket.ai_result.detection_count}
colorClassName="text-violet-600"
cardClassName="bg-violet-500/8"
/>
{ticket.ai_result.detections_by_class.map((item) => {
const visual = getDefectVisual(item.class_name);
return (
<SummaryCard
key={item.class_name}
label={item.display_name}
value={item.count}
colorClassName={visual.colorClassName}
cardClassName={visual.cardClassName}
/>
);
})}
</div>
{hasVideoMetrics ? (
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3">
{videoMetadata?.fps ? (
<VideoMetric icon={Gauge} label={`${videoMetadata.fps} FPS`} />
) : null}
{duration ? (
<VideoMetric icon={Clock3} label={`${duration} Duration`} />
) : null}
{videoMetadata?.resolution?.label ? (
<VideoMetric
icon={Monitor}
label={`${videoMetadata.resolution.label} Resolution`}
/>
) : null}
</div>
) : null}
</CardContent>
</Card>
<Dialog open={isAnalysisOpen} onOpenChange={setIsAnalysisOpen}>
<DialogContent className="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>{analysisTitle}</DialogTitle>
<DialogDescription>
Video analysis with annotation overlay
</DialogDescription>
</DialogHeader>
<DialogBody>
<TicketInlineAnalysisVideo ticket={ticket} />
</DialogBody>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import {
Card,
CardAction,
CardContent,
CardHeader,
} from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
function OverviewMetaSkeletonField({ className }: { className?: string }) {
return (
<div className={`flex items-center gap-3 ${className ?? ''}`}>
<Skeleton className="size-5 shrink-0 rounded-lg" />
<div className="min-w-0 space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-32 max-w-full" />
</div>
</div>
);
}
function SummarySkeletonCard() {
return (
<div className="rounded-lg bg-muted/40 p-4">
<Skeleton className="h-7 w-10" />
<Skeleton className="mt-3 h-3 w-24 max-w-full" />
<Skeleton className="mt-2 h-3 w-16" />
</div>
);
}
export function TicketOverviewHeaderSkeleton() {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-3 w-36 max-w-full" />
</div>
<div className="flex shrink-0 items-center gap-2">
<Skeleton className="h-8 w-28" />
</div>
</div>
);
}
export function TicketOverviewCardSkeleton() {
return (
<div className="space-y-5" aria-label="Loading ticket overview">
<Card>
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x">
<OverviewMetaSkeletonField className="sm:pr-4" />
<OverviewMetaSkeletonField className="sm:px-4" />
<OverviewMetaSkeletonField className="sm:pl-4" />
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-lg" />
<Skeleton className="h-5 w-40" />
</div>
<CardAction>
<Skeleton className="h-9 w-28" />
</CardAction>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
{Array.from({ length: 6 }).map((_, index) => (
<SummarySkeletonCard key={index} />
))}
</div>
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="flex items-center justify-center gap-2"
>
<Skeleton className="size-4 rounded-lg" />
<Skeleton className="h-4 w-24" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { TicketDetail } from '@/types';
import { AssignedContractorAction } from './actions/AssignedContractorAction';
import { AssignTicketAction } from './actions/AssignTicketAction';
import { ClosedTicketAction } from './actions/ClosedTicketAction';
import { NoTicketAction } from './actions/NoTicketAction';
import { ReviewExtensionRequestAction } from './actions/ReviewExtensionRequestAction';
import { ReviewRepairAction } from './actions/ReviewRepairAction';
import { SubmittedRepairProofAction } from './actions/SubmittedRepairProofAction';
import { SubmitRepairAction } from './actions/SubmitRepairAction';
interface TicketStatusActionsProps {
ticket: TicketDetail;
}
function getTicketAction(ticket: TicketDetail) {
if (ticket.assignment_status === 'unassigned') {
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={<NoTicketAction />}
>
<AssignTicketAction ticket={ticket} />
</PermissionGuard>
);
}
if (ticket.assignment_status === 'assigned') {
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={
<PermissionGuard
permissions={PERMISSIONS.TICKET.WORK}
fallback={<NoTicketAction />}
>
<SubmitRepairAction ticket={ticket} />
</PermissionGuard>
}
>
<>
<AssignedContractorAction ticket={ticket} />
<ReviewExtensionRequestAction ticket={ticket} />
</>
</PermissionGuard>
);
}
if (ticket.assignment_status === 'under_review') {
return (
<PermissionGuard
permissions={PERMISSIONS.TICKET.REVIEW}
fallback={<NoTicketAction />}
>
<ReviewRepairAction ticket={ticket} />
</PermissionGuard>
);
}
if (
ticket.assignment_status === 'approved' ||
ticket.assignment_status === 'rejected'
) {
return <ClosedTicketAction ticket={ticket} />;
}
return <NoTicketAction />;
}
export function TicketStatusActions({ ticket }: TicketStatusActionsProps) {
const action = getTicketAction(ticket);
const submittedProof = ticket.repair?.submitted_at ? (
<SubmittedRepairProofAction ticket={ticket} />
) : null;
return (
<div className="space-y-3">
{submittedProof}
{action}
</div>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import { useMemo, useState } from 'react';
import { Users, Mail, Phone, Send, ShieldCheck } from 'lucide-react';
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
import type { AssignableTicketUser, TicketDetail } from '@/types';
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
import { TicketActionCard } from './TicketActionCard';
export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
const [selectedUserId, setSelectedUserId] = useState('');
const [selectedContractor, setSelectedContractor] =
useState<AssignableTicketUser | null>(null);
const [dueDate, setDueDate] = useState<Date>();
const [assignNote, setAssignNote] = useState('');
const assignMutation = useAssignTicketMutation(ticket.id);
const today = useMemo(() => {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}, []);
const maxDueMonth = useMemo(
() => new Date(today.getFullYear() + 20, 11),
[today],
);
return (
<TicketActionCard icon={Users} title="Assign to Contractor">
<div className="space-y-4">
<div className="space-y-2">
<Label>Contractor *</Label>
<AssignableWorkerSelect
value={selectedUserId}
onValueChange={(userId, user) => {
setSelectedUserId(userId);
setSelectedContractor(user);
}}
disabled={assignMutation.isPending}
/>
</div>
{selectedContractor ? (
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
<p className="text-sm font-medium">Contractor Details</p>
{selectedContractor.phone_number ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Phone className="size-4 shrink-0" />
{selectedContractor.phone_number}
</p>
) : null}
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="size-4 shrink-0" />
<span className="truncate">{selectedContractor.email}</span>
</p>
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<ShieldCheck className="size-4 shrink-0" />
{selectedContractor.role.name}
</p>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor="assign-due-date">
Due Date <span className="text-destructive">*</span>
</Label>
<DatePickerSimple
id="assign-due-date"
value={dueDate}
onChange={setDueDate}
showLabel={false}
placeholder="Select due date"
disabled={assignMutation.isPending}
className="w-full"
startMonth={today}
endMonth={maxDueMonth}
disabledDates={{ before: today }}
/>
</div>
<div className="space-y-2">
<Label>Assignment Note (Optional)</Label>
<Textarea
value={assignNote}
onChange={(event) => setAssignNote(event.target.value)}
placeholder="Add any instructions or notes for the contractor..."
/>
</div>
<Button
disabled={!selectedUserId || !dueDate || assignMutation.isPending}
onClick={() => {
if (!selectedUserId || !dueDate) return;
assignMutation.mutate({
assigned_to_user_id: Number(selectedUserId),
defect_class: ticket.defect_class,
due_at: dueDate.toISOString(),
note: assignNote || undefined,
});
}}
className="w-full"
>
<Send />
Assign Ticket
</Button>
</div>
</TicketActionCard>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import { CalendarClock, UserCheck } from 'lucide-react';
import { PersonInfo } from '@/components/person-avatar';
import { Badge } from '@/components/ui/badge';
import type { TicketDetail } from '@/types';
import { formatDate } from '@/utils/date';
import { TicketActionCard } from './TicketActionCard';
function AssignmentField({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="truncate text-sm font-medium text-foreground">{value}</p>
</div>
);
}
export function AssignedContractorAction({ ticket }: { ticket: TicketDetail }) {
const worker = ticket.worker;
return (
<TicketActionCard icon={UserCheck} title="Assigned Contractor">
{worker ? (
<div className="space-y-5">
<div className="flex items-center justify-between gap-3">
<PersonInfo person={worker} size="lg" />
<Badge>
Assigned
</Badge>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-5 border-t pt-4">
<AssignmentField label="Role" value={worker.role?.name ?? '-'} />
<AssignmentField
label="Assigned By"
value={worker.assigned_by_name ?? '-'}
/>
<AssignmentField
label="Assigned On"
value={formatDate(worker.assigned_at)}
/>
<div className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">Due Date</p>
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<CalendarClock className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{formatDate(worker.due_at)}</span>
</p>
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
Assignment details are not available.
</p>
)}
</TicketActionCard>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
import type { ReactNode } from 'react';
import { Lock, MessageSquare } from 'lucide-react';
import { PersonInfo } from '@/components/person-avatar';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { cn } from '@/lib/utils';
import type { TicketDetail } from '@/types';
import { formatDateOnly } from '@/utils/date';
function getClosedAt(ticket: TicketDetail) {
const approvedEntry = [...(ticket.history ?? [])]
.reverse()
.find(
(item) =>
item.event_type === 'repair_approved' ||
item.to_class_status === 'approved',
);
return (
approvedEntry?.created_at ??
ticket.reviewer?.reviewed_at ??
ticket.timestamps.updated_at
);
}
function MetaField({
label,
value,
valueClassName,
}: {
label: string;
value: ReactNode;
valueClassName?: string;
}) {
return (
<div className="space-y-1">
<p className="text-xs font-medium tracking-wide text-muted-foreground">
{label}
</p>
<div
className={cn('text-sm font-semibold text-foreground', valueClassName)}
>
{value}
</div>
</div>
);
}
function FinalCommentsPanel({
comment,
}: {
comment: string | null | undefined;
}) {
if (!comment?.trim()) {
return (
<div className="flex min-h-28 flex-col items-center justify-center rounded-lg border border-dashed border-border bg-muted/10 px-4 py-6 text-center">
<MessageSquare className="mb-2 size-5 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No comments added yet</p>
</div>
);
}
return (
<div className="rounded-lg border bg-muted/20 px-4 py-3">
<p className="text-sm leading-relaxed text-foreground">
{comment.trim()}
</p>
</div>
);
}
export function ClosedTicketAction({ ticket }: { ticket: TicketDetail }) {
const reviewer = ticket.reviewer;
const reviewedAt = reviewer?.reviewed_at ?? getClosedAt(ticket);
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Resolution Details</CardTitle>
<CardDescription>Review record for {ticket.ticket_name}</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="space-y-4 rounded-lg border bg-muted/20 p-4">
<MetaField
label="Reviewed By"
value={<PersonInfo person={reviewer} size="lg" />}
/>
<MetaField
label="Reviewed Date"
value={formatDateOnly(reviewedAt)}
/>
</div>
<div className="space-y-2">
<p className="text-xs font-medium tracking-wide text-muted-foreground">
Review Comment
</p>
<FinalCommentsPanel comment={reviewer?.review_comment} />
</div>
<Button
type="button"
variant="secondary"
className="w-full text-muted-foreground"
disabled
>
<Lock className="size-4" />
Ticket Finalized
</Button>
</CardContent>
</Card>
);
}

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,485 @@
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
Camera,
ImageIcon,
Loader2,
Play,
Plus,
Send,
Video,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { useSubmitRepairUploadMutation } from '../../../hooks/useTicketQueries';
const MAX_IMAGES = 30;
const MAX_NOTES = 500;
function RequiredMark() {
return <span className="text-destructive">*</span>;
}
function formatVideoDuration(seconds: number) {
const totalSeconds = Math.floor(seconds);
const minutes = Math.floor(totalSeconds / 60);
const remainingSeconds = totalSeconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
}
interface MediaSectionHeaderProps {
icon: ReactNode;
title: string;
addLabel: string;
onAdd: () => void;
disabled?: boolean;
}
function MediaSectionHeader({
icon,
title,
addLabel,
onAdd,
disabled = false,
}: MediaSectionHeaderProps) {
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-primary">{icon}</span>
<span className="text-sm font-semibold">{title}</span>
</div>
<Button
type="button"
variant="secondary"
size="sm"
disabled={disabled}
onClick={onAdd}
className="shrink-0"
>
<Plus className="size-3.5" />
{addLabel}
</Button>
</div>
);
}
function MediaHint({ children }: { children: ReactNode }) {
return (
<p className="rounded-lg bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
{children}
</p>
);
}
interface EmptyMediaPlaceholderProps {
icon: ReactNode;
title: string;
description: string;
className?: string;
disabled?: boolean;
onClick: () => void;
}
function EmptyMediaPlaceholder({
icon,
title,
description,
className,
disabled = false,
onClick,
}: EmptyMediaPlaceholderProps) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
'flex w-full min-w-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border/80 bg-muted/15 px-4 py-6 text-center transition-colors',
'hover:border-primary/40 hover:bg-primary/5',
disabled && 'cursor-not-allowed opacity-60',
className,
)}
>
<span className="flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary">
{icon}
</span>
<span className="text-sm font-medium text-foreground">{title}</span>
<span className="max-w-xs text-xs text-muted-foreground">
{description}
</span>
</button>
);
}
interface ImageThumbnailProps {
file: File;
previewUrl: string;
disabled?: boolean;
onRemove: () => void;
}
function ImageThumbnail({
file,
previewUrl,
disabled = false,
onRemove,
}: ImageThumbnailProps) {
return (
<div className="group relative size-20 shrink-0 overflow-hidden rounded-lg border bg-muted/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl}
alt={file.name}
className="size-full object-cover"
/>
<button
type="button"
disabled={disabled}
onClick={onRemove}
className="absolute top-1 right-1 flex size-5 cursor-pointer items-center justify-center rounded-full bg-foreground/80 text-background shadow-sm transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Remove ${file.name}`}
>
<X className="size-3" />
</button>
</div>
);
}
interface VideoThumbnailProps {
file: File;
previewUrl: string;
duration: string | null;
disabled?: boolean;
onRemove: () => void;
}
function VideoThumbnail({
file,
previewUrl,
duration,
disabled = false,
onRemove,
}: VideoThumbnailProps) {
return (
<div className="group relative h-20 w-36 shrink-0 overflow-hidden rounded-lg border bg-muted/30">
<video
src={previewUrl}
className="size-full object-cover"
muted
playsInline
preload="metadata"
aria-label={file.name}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20">
<span className="flex size-8 items-center justify-center rounded-full bg-background/90 text-foreground shadow-sm">
<Play className="size-3.5 fill-current" />
</span>
</div>
{duration ? (
<span className="pointer-events-none absolute bottom-1.5 right-1.5 rounded-lg bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white">
{duration}
</span>
) : null}
<button
type="button"
disabled={disabled}
onClick={onRemove}
className="absolute top-1 right-1 flex size-5 cursor-pointer items-center justify-center rounded-full bg-foreground/80 text-background shadow-sm transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Remove ${file.name}`}
>
<X className="size-3" />
</button>
</div>
);
}
interface RepairEvidenceFormProps {
ticketId: string;
defectClass: string;
}
export function RepairEvidenceForm({
ticketId,
defectClass,
}: RepairEvidenceFormProps) {
const imageInputRef = useRef<HTMLInputElement | null>(null);
const videoInputRef = useRef<HTMLInputElement | null>(null);
const [notes, setNotes] = useState('');
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoPreview, setVideoPreview] = useState<string | null>(null);
const [videoDuration, setVideoDuration] = useState<string | null>(null);
const [imageFiles, setImageFiles] = useState<File[]>([]);
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const submitRepairUploadMutation = useSubmitRepairUploadMutation(ticketId);
const isSubmitting = submitRepairUploadMutation.isPending;
const canSubmit = Boolean(notes.trim() && videoFile);
const canAddMoreImages = imageFiles.length < MAX_IMAGES;
useEffect(() => {
const urls = imageFiles.map((file) => URL.createObjectURL(file));
setImagePreviews(urls);
return () => {
urls.forEach((url) => URL.revokeObjectURL(url));
};
}, [imageFiles]);
useEffect(() => {
if (!videoFile) {
setVideoPreview(null);
setVideoDuration(null);
return;
}
const url = URL.createObjectURL(videoFile);
setVideoPreview(url);
setVideoDuration(null);
let cancelled = false;
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
if (cancelled || !Number.isFinite(video.duration)) return;
setVideoDuration(formatVideoDuration(video.duration));
};
video.src = url;
return () => {
cancelled = true;
URL.revokeObjectURL(url);
};
}, [videoFile]);
const addImages = (files: FileList | null) => {
if (!files?.length) return;
const remaining = MAX_IMAGES - imageFiles.length;
if (remaining <= 0) {
setError(`Only ${MAX_IMAGES} images can be uploaded.`);
return;
}
const nextImages = [
...imageFiles,
...Array.from(files).slice(0, remaining),
];
setImageFiles(nextImages);
setError(
files.length > remaining
? `Only ${MAX_IMAGES} images can be uploaded.`
: null,
);
};
const selectVideo = (files: FileList | null) => {
setVideoFile(files?.[0] ?? null);
setError(null);
};
const removeVideo = () => {
setVideoFile(null);
setError(null);
};
const removeImage = (index: number) => {
setImageFiles((current) =>
current.filter((_, itemIndex) => itemIndex !== index),
);
setError(null);
};
const handleSubmit = async () => {
if (!notes.trim() || !videoFile) {
setError('Repair notes and a video are required.');
return;
}
setError(null);
try {
await submitRepairUploadMutation.mutateAsync({
defect_class: defectClass,
notes: notes.trim(),
video: videoFile,
images: imageFiles,
});
setNotes('');
setVideoFile(null);
setImageFiles([]);
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed');
}
};
const openImagePicker = () => {
if (!isSubmitting && canAddMoreImages) {
imageInputRef.current?.click();
}
};
const openVideoPicker = () => {
if (!isSubmitting) {
videoInputRef.current?.click();
}
};
return (
<div className="space-y-5">
<div className="flex gap-3 rounded-lg border border-primary/15 bg-primary/5 p-4">
<Camera className="mt-0.5 size-5 shrink-0 text-primary" />
<div className="space-y-1">
<p className="text-sm font-semibold">Instructions</p>
<p className="text-sm text-muted-foreground">
Upload clear photos and one video of the repaired area and provide
relevant details.
</p>
</div>
</div>
<input
ref={imageInputRef}
type="file"
accept="image/jpeg,image/png,image/*"
multiple
className="hidden"
disabled={isSubmitting || !canAddMoreImages}
onChange={(event) => {
addImages(event.target.files);
event.target.value = '';
}}
/>
<input
ref={videoInputRef}
type="file"
accept="video/mp4,video/quicktime,video/x-msvideo,video/*"
className="hidden"
disabled={isSubmitting}
onChange={(event) => {
selectVideo(event.target.files);
event.target.value = '';
}}
/>
<section className="space-y-3">
<MediaSectionHeader
icon={<Camera className="size-4" />}
title="Photos"
addLabel="Add Photos"
onAdd={openImagePicker}
disabled={isSubmitting || !canAddMoreImages}
/>
<div className="overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{imageFiles.length === 0 ? (
<EmptyMediaPlaceholder
icon={<ImageIcon className="size-5" />}
title="No photos added yet"
description="Tap to upload repair photos"
disabled={isSubmitting}
onClick={openImagePicker}
className="min-h-26"
/>
) : (
<div className="flex min-w-min gap-3">
{imageFiles.map((file, index) => (
<ImageThumbnail
key={`${file.name}-${file.lastModified}-${index}`}
file={file}
previewUrl={imagePreviews[index]}
disabled={isSubmitting}
onRemove={() => removeImage(index)}
/>
))}
</div>
)}
</div>
<MediaHint>
You can upload up to {MAX_IMAGES} images (JPG, PNG)
</MediaHint>
</section>
<section className="space-y-3">
<div className="flex items-center gap-2">
<Video className="size-4 text-primary" />
<span className="text-sm font-semibold">Video</span>
</div>
<div>
{!videoFile || !videoPreview ? (
<EmptyMediaPlaceholder
icon={<Video className="size-5" />}
title="No video added yet"
description="Tap to upload a repair video"
disabled={isSubmitting}
onClick={openVideoPicker}
className="min-h-[104px]"
/>
) : (
<VideoThumbnail
file={videoFile}
previewUrl={videoPreview}
duration={videoDuration}
disabled={isSubmitting}
onRemove={removeVideo}
/>
)}
</div>
<MediaHint>You can upload one video (MP4, MOV, AVI)</MediaHint>
</section>
<div className="space-y-2">
<Label htmlFor="repair-notes">
Repair Notes <RequiredMark />
</Label>
<div className="relative">
<Textarea
id="repair-notes"
value={notes}
onChange={(event) => {
setNotes(event.target.value.slice(0, MAX_NOTES));
setError(null);
}}
placeholder="Describe the repair work completed, materials used, and any additional observations."
disabled={isSubmitting}
className="min-h-28 resize-none pb-8"
/>
<span className="pointer-events-none absolute bottom-2 right-3 text-xs text-muted-foreground">
{notes.length} / {MAX_NOTES}
</span>
</div>
</div>
{error ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
<Button
type="button"
className="w-full"
disabled={!canSubmit || isSubmitting}
onClick={handleSubmit}
>
{isSubmitting ? (
<>
<Loader2 className="size-4 animate-spin" />
Submitting...
</>
) : (
<>
<Send className="size-4" />
Submit for Review
</>
)}
</Button>
</div>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import { useMemo, useState } from 'react';
import { Clock3, Loader2, Send } from 'lucide-react';
import { DatePicker } from '@/components/form/DatePicker';
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import type { TicketDetail } from '@/types';
import { useRequestTicketExtensionMutation } from '../../../hooks/useTicketQueries';
const MAX_REASON_LENGTH = 300;
interface RequestExtensionFormProps {
ticket: TicketDetail;
onCancel: () => void;
}
export function RequestExtensionForm({
ticket,
onCancel,
}: RequestExtensionFormProps) {
const currentDueAt = ticket.worker?.due_at;
const currentDueDate = useMemo(
() => (currentDueAt ? new Date(currentDueAt) : undefined),
[currentDueAt],
);
const earliestRequestedDate = useMemo(() => {
const date = currentDueDate ? new Date(currentDueDate) : new Date();
date.setHours(0, 0, 0, 0);
if (currentDueDate) date.setDate(date.getDate() + 1);
return date;
}, [currentDueDate]);
const latestRequestedMonth = useMemo(
() => new Date(earliestRequestedDate.getFullYear() + 20, 11),
[earliestRequestedDate],
);
const [requestedDueDate, setRequestedDueDate] = useState<Date>();
const [reason, setReason] = useState('');
const assignmentId = ticket.worker?.assignment_id;
const requestExtensionMutation = useRequestTicketExtensionMutation(
ticket.id,
assignmentId,
ticket.defect_class,
);
const isPending = requestExtensionMutation.isPending;
const isRequestedDateValid = Boolean(
requestedDueDate &&
(!currentDueDate || requestedDueDate.getTime() > currentDueDate.getTime()),
);
const canSubmit =
Boolean(assignmentId) &&
isRequestedDateValid &&
Boolean(reason.trim()) &&
!isPending;
const handleSubmit = async () => {
if (!requestedDueDate || !canSubmit) return;
await requestExtensionMutation.mutateAsync({
requested_due_at: requestedDueDate.toISOString(),
reason: reason.trim(),
});
setRequestedDueDate(undefined);
setReason('');
onCancel();
};
return (
<div className="space-y-5">
<div className="flex gap-3 rounded-lg border border-primary/15 bg-primary/5 p-4">
<Clock3 className="mt-0.5 size-5 shrink-0 text-primary" />
<p className="text-sm text-muted-foreground">
Request more time to complete the repair. Your manager will review the
request.
</p>
</div>
<div className="space-y-2">
<Label>Current Due Date</Label>
<DatePicker
value={currentDueDate}
showTimeZone={false}
placeholder="No due date available"
disabled
className="w-full"
/>
</div>
<div className="space-y-2">
<Label>
Requested Due Date <span className="text-destructive">*</span>
</Label>
<DatePickerSimple
id="requested-due-date"
value={requestedDueDate}
onChange={setRequestedDueDate}
showLabel={false}
placeholder="Select requested due date"
disabled={isPending}
className="w-full"
startMonth={earliestRequestedDate}
endMonth={latestRequestedMonth}
disabledDates={{ before: earliestRequestedDate }}
/>
{requestedDueDate && !isRequestedDateValid ? (
<p className="text-xs text-destructive">
Requested date must be after the current due date.
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="extension-reason">
Reason for Extension <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Textarea
id="extension-reason"
value={reason}
onChange={(event) =>
setReason(event.target.value.slice(0, MAX_REASON_LENGTH))
}
placeholder="Explain why additional time is required to complete the repair."
disabled={isPending}
className="min-h-28 resize-none pb-8"
/>
<span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground">
{reason.length} / {MAX_REASON_LENGTH}
</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
This request will be sent to your manager for approval.
</p>
<div className="grid grid-cols-2 gap-3">
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={onCancel}
>
Cancel
</Button>
<Button type="button" disabled={!canSubmit} onClick={handleSubmit}>
{isPending ? <Loader2 className="animate-spin" /> : <Send />}
{isPending ? 'Submitting...' : 'Submit Request'}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,231 @@
'use client';
import { useState } from 'react';
import {
ArrowRight,
CalendarClock,
Check,
Clock3,
Loader2,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import type {
TicketDetail,
TicketExtensionRequest,
TicketExtensionRequestsResponse,
} from '@/types';
import { formatDate } from '@/utils/date';
import {
useReviewTicketExtensionMutation,
useTicketExtensionRequestQuery,
} from '../../../hooks/useTicketQueries';
import { TicketActionCard } from './TicketActionCard';
const MAX_NOTE_LENGTH = 300;
function getPendingRequest(response: TicketExtensionRequestsResponse) {
return response.items
.filter((request) => request.status === 'pending')
.reduce<TicketExtensionRequest | null>((latest, request) => {
if (!latest) return request;
return new Date(request.requested_at) > new Date(latest.requested_at)
? request
: latest;
}, null);
}
function getExtensionDays(request: TicketExtensionRequest) {
const difference =
new Date(request.requested_due_at).getTime() -
new Date(request.current_due_at).getTime();
return Math.max(0, Math.ceil(difference / (24 * 60 * 60 * 1000)));
}
function Requester({ request }: { request: TicketExtensionRequest }) {
const name = request.requested_by?.name ?? 'Assigned worker';
const initials = name
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join('');
return (
<div className="grid grid-cols-2 gap-3 rounded-lg bg-muted/40 p-3">
<div className="flex items-center gap-2">
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
{initials || 'W'}
</span>
<div className="min-w-0">
<p className="text-[11px] text-muted-foreground">Requested By</p>
<p className="truncate text-sm font-medium">{name}</p>
</div>
</div>
<div>
<p className="text-[11px] text-muted-foreground">Requested On</p>
<p className="text-sm font-medium">
{formatDate(request.requested_at)}
</p>
</div>
</div>
);
}
function ExtensionReviewForm({
ticket,
request,
}: {
ticket: TicketDetail;
request: TicketExtensionRequest;
}) {
const [note, setNote] = useState('');
const [selectedAction, setSelectedAction] = useState<
'approve' | 'reject' | null
>(null);
const assignmentId = ticket.worker?.assignment_id as number;
const reviewMutation = useReviewTicketExtensionMutation(
ticket.id,
assignmentId,
ticket.defect_class,
);
const extensionDays = getExtensionDays(request);
const submitDecision = async (action: 'approve' | 'reject') => {
setSelectedAction(action);
try {
await reviewMutation.mutateAsync({ action, note: note.trim() });
} finally {
setSelectedAction(null);
}
};
return (
<TicketActionCard icon={CalendarClock} title="Extension Request Details">
<div className="space-y-4">
<Requester request={request} />
<section className="space-y-2">
<p className="text-xs font-semibold">Request Summary</p>
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-3 rounded-lg border p-3">
<div>
<p className="text-[11px] text-muted-foreground">
Current Due Date
</p>
<p className="text-xs font-medium">
{formatDate(request.current_due_at)}
</p>
</div>
<ArrowRight className="mt-6 size-4 text-muted-foreground" />
<div>
<p className="text-[11px] text-muted-foreground">
Requested Due Date
</p>
<p className="text-xs font-medium">
{formatDate(request.requested_due_at)}
</p>
{extensionDays > 0 ? (
<span className="mt-1 inline-flex rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300">
+{extensionDays} {extensionDays === 1 ? 'Day' : 'Days'}
</span>
) : null}
</div>
</div>
<div className="rounded-lg border p-3">
<p className="text-[11px] text-muted-foreground">Reason</p>
<p className="mt-1 text-sm leading-relaxed">{request.reason}</p>
</div>
</section>
<div className="space-y-2">
<Label htmlFor="extension-manager-note">
Add Manager Comments{' '}
<span className="text-muted-foreground">(Optional)</span>
</Label>
<div className="relative">
<Textarea
id="extension-manager-note"
value={note}
onChange={(event) =>
setNote(event.target.value.slice(0, MAX_NOTE_LENGTH))
}
placeholder="Add a note explaining your decision..."
disabled={reviewMutation.isPending}
className="min-h-24 resize-none pb-8"
/>
<span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground">
{note.length} / {MAX_NOTE_LENGTH}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Button
type="button"
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/5 hover:text-destructive"
disabled={reviewMutation.isPending}
onClick={() => submitDecision('reject')}
>
{selectedAction === 'reject' ? (
<Loader2 className="animate-spin" />
) : (
<X />
)}
Reject Request
</Button>
<Button
type="button"
disabled={reviewMutation.isPending}
onClick={() => submitDecision('approve')}
>
{selectedAction === 'approve' ? (
<Loader2 className="animate-spin" />
) : (
<Check />
)}
Approve Extension
</Button>
</div>
</div>
</TicketActionCard>
);
}
export function ReviewExtensionRequestAction({
ticket,
}: {
ticket: TicketDetail;
}) {
const assignmentId = ticket.worker?.assignment_id;
const extensionRequestQuery = useTicketExtensionRequestQuery(
ticket.id,
assignmentId,
);
if (!assignmentId || extensionRequestQuery.isError) {
return null;
}
if (extensionRequestQuery.isLoading) {
return (
<TicketActionCard icon={Clock3} title="Extension Request">
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />
<span className="text-sm">Checking for requests...</span>
</div>
</TicketActionCard>
);
}
const pendingRequest = extensionRequestQuery.data
? getPendingRequest(extensionRequestQuery.data)
: null;
if (!pendingRequest) return null;
return <ExtensionReviewForm ticket={ticket} request={pendingRequest} />;
}

View File

@@ -0,0 +1,66 @@
'use client';
import { useEffect, useState } from 'react';
import { Check, ClipboardCheck, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import type { TicketDetail } from '@/types';
import { useReviewRepairMutation } from '../../../hooks/useTicketQueries';
import { TicketActionCard } from './TicketActionCard';
export function ReviewRepairAction({ ticket }: { ticket: TicketDetail }) {
const [reviewComment, setReviewComment] = useState('');
const reviewRepairMutation = useReviewRepairMutation(ticket.id);
useEffect(() => {
setReviewComment(ticket.reviewer?.review_comment ?? '');
}, [ticket]);
return (
<TicketActionCard icon={ClipboardCheck} title="Review Repair">
<div className="space-y-4">
<div className="space-y-2">
<Label>Comment</Label>
<Textarea
value={reviewComment}
onChange={(event) => setReviewComment(event.target.value)}
placeholder="Repair verified"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
variant={'secondary'}
disabled={!reviewComment || reviewRepairMutation.isPending}
onClick={() =>
reviewRepairMutation.mutate({
defect_class: ticket.defect_class,
action: 'approve',
comment: reviewComment,
})
}
>
<Check />
Approve
</Button>
<Button
variant="destructive"
disabled={!reviewComment || reviewRepairMutation.isPending}
onClick={() =>
reviewRepairMutation.mutate({
defect_class: ticket.defect_class,
action: 'reject',
comment: reviewComment,
})
}
>
<X />
Reject
</Button>
</div>
</div>
</TicketActionCard>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import { useState } from 'react';
import { Wrench } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { TicketDetail } from '@/types';
import { RepairEvidenceForm } from './RepairEvidenceForm';
import { TicketExtensionRequestPanel } from './TicketExtensionRequestPanel';
import { TicketActionCard } from './TicketActionCard';
type RepairActionTab = 'repair-proof' | 'request-extension';
export function SubmitRepairAction({ ticket }: { ticket: TicketDetail }) {
const [activeTab, setActiveTab] = useState<RepairActionTab>('repair-proof');
if (ticket.repair?.submitted_at) {
return null;
}
return (
<TicketActionCard icon={Wrench} title="Repair Proof">
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as RepairActionTab)}
className="gap-5"
>
<TabsList variant="line" className="grid h-auto w-full grid-cols-2 p-0">
<TabsTrigger value="repair-proof" className="pb-3">
Upload Repair Proof
</TabsTrigger>
<TabsTrigger value="request-extension" className="pb-3">
Request Extension
</TabsTrigger>
</TabsList>
<TabsContent value="repair-proof">
<RepairEvidenceForm
ticketId={ticket.id}
defectClass={ticket.defect_class}
/>
</TabsContent>
<TabsContent value="request-extension">
<TicketExtensionRequestPanel
ticket={ticket}
onCancelRequest={() => setActiveTab('repair-proof')}
/>
</TabsContent>
</Tabs>
</TicketActionCard>
);
}

View File

@@ -0,0 +1 @@
export { RepairProofSummary as SubmittedRepairProofAction } from '../repair/RepairProofSummary';

View File

@@ -0,0 +1,32 @@
'use client';
import type { LucideIcon } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
interface TicketActionCardProps {
icon: LucideIcon;
iconClassName?: string;
title: string;
children: React.ReactNode;
}
export function TicketActionCard({
icon: Icon,
iconClassName,
title,
children,
}: TicketActionCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Icon className={cn('text-primary', iconClassName)} />
{title}
</CardTitle>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,248 @@
'use client';
import {
AlertCircle,
CalendarDays,
CheckCircle2,
Clock3,
History,
Loader2,
RotateCcw,
XCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type {
TicketDetail,
TicketExtensionRequest,
TicketExtensionRequestsResponse,
} from '@/types';
import { formatDate } from '@/utils/date';
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
import { RequestExtensionForm } from './RequestExtensionForm';
interface TicketExtensionRequestPanelProps {
ticket: TicketDetail;
onCancelRequest: () => void;
}
interface DetailRowProps {
label: string;
value: string;
children?: React.ReactNode;
className?: string;
}
function DetailRow({ label, value, children, className }: DetailRowProps) {
return (
<div className={cn('space-y-1 px-3 py-2.5', className)}>
<p className="text-[11px] font-medium text-muted-foreground">{label}</p>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">{value}</p>
{children}
</div>
</div>
);
}
const statusConfig = {
pending: {
title: 'Request Pending',
description: 'Your extension request is waiting for manager approval.',
icon: Clock3,
className:
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300',
},
approved: {
title: 'Request Approved',
description: 'Your extension request has been approved by the manager.',
icon: CheckCircle2,
className:
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300',
},
rejected: {
title: 'Request Rejected',
description: 'Your extension request has been rejected by the manager.',
icon: AlertCircle,
className:
'border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300',
},
cancelled: {
title: 'Request Cancelled',
description: 'This extension request was cancelled.',
icon: XCircle,
className: 'border-border bg-muted/50 text-muted-foreground',
},
} satisfies Record<
TicketExtensionRequest['status'],
{
title: string;
description: string;
icon: typeof Clock3;
className: string;
}
>;
function getLatestRequest(response: TicketExtensionRequestsResponse) {
return response.items.reduce<TicketExtensionRequest | null>(
(latest, item) => {
if (!latest) return item;
return new Date(item.requested_at) > new Date(latest.requested_at)
? item
: latest;
},
null,
);
}
function getExtensionDays(request: TicketExtensionRequest) {
const difference =
new Date(request.requested_due_at).getTime() -
new Date(request.current_due_at).getTime();
return Math.max(0, Math.ceil(difference / (24 * 60 * 60 * 1000)));
}
function ExtensionRequestDetails({
request,
}: {
request: TicketExtensionRequest;
}) {
const config = statusConfig[request.status];
const StatusIcon = config.icon;
const extensionDays = getExtensionDays(request);
return (
<div className="space-y-4">
<div className={cn('flex gap-3 rounded-lg border p-4', config.className)}>
<StatusIcon className="mt-0.5 size-5 shrink-0" />
<div className="space-y-1">
<p className="text-sm font-semibold">{config.title}</p>
<p className="text-xs leading-relaxed">{config.description}</p>
</div>
</div>
<section className="space-y-2">
<p className="text-xs font-semibold">Request Details</p>
<div className="divide-y overflow-hidden rounded-lg border">
<DetailRow
label="Requested On"
value={formatDate(request.requested_at)}
/>
<DetailRow
label="Requested Due Date"
value={formatDate(request.requested_due_at)}
>
{extensionDays > 0 ? (
<span className="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary">
+{extensionDays} {extensionDays === 1 ? 'Day' : 'Days'}
</span>
) : null}
</DetailRow>
<DetailRow label="Reason for Extension" value={request.reason} />
</div>
</section>
{request.resolved_at || request.resolved_by ? (
<section className="space-y-2">
<p className="text-xs font-semibold">Manager Response</p>
<div className="divide-y overflow-hidden rounded-lg border">
{request.resolved_at ? (
<DetailRow
label="Responded On"
value={formatDate(request.resolved_at)}
/>
) : null}
{request.resolved_by ? (
<DetailRow
label="Resolved By"
value={`${request.resolved_by.name} · ${request.resolved_by.role.name}`}
/>
) : null}
{request.status === 'approved' ? (
<DetailRow
label="Approved Due Date"
value={formatDate(request.requested_due_at)}
/>
) : null}
</div>
</section>
) : null}
<section className="space-y-2">
<p className="text-xs font-semibold">Original Due Date</p>
<div className="rounded-lg border">
<DetailRow
label="Original schedule"
value={formatDate(request.current_due_at)}
>
<CalendarDays className="size-4 text-muted-foreground" />
</DetailRow>
</div>
</section>
<Button type="button" variant="outline" className="w-full">
<History />
View Approval History
</Button>
</div>
);
}
export function TicketExtensionRequestPanel({
ticket,
onCancelRequest,
}: TicketExtensionRequestPanelProps) {
const assignmentId = ticket.worker?.assignment_id;
const extensionRequestQuery = useTicketExtensionRequestQuery(
ticket.id,
assignmentId,
);
if (!assignmentId) {
return (
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
No active assignment is available for this ticket.
</div>
);
}
if (extensionRequestQuery.isLoading) {
return (
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<span className="ml-2 text-sm">Loading extension request...</span>
</div>
);
}
if (extensionRequestQuery.isError) {
return (
<div className="space-y-3 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<p className="text-sm text-destructive">
Extension request details could not be loaded.
</p>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => extensionRequestQuery.refetch()}
>
<RotateCcw />
Try Again
</Button>
</div>
);
}
const latestRequest = extensionRequestQuery.data
? getLatestRequest(extensionRequestQuery.data)
: null;
if (!latestRequest) {
return <RequestExtensionForm ticket={ticket} onCancel={onCancelRequest} />;
}
return <ExtensionRequestDetails request={latestRequest} />;
}

View File

@@ -0,0 +1,114 @@
'use client';
import { useEffect, useMemo } from 'react';
import { useQueries } from '@tanstack/react-query';
import LightGallery from 'lightgallery/react';
import lgThumbnail from 'lightgallery/plugins/thumbnail';
import lgZoom from 'lightgallery/plugins/zoom';
import { Skeleton } from '@/components/ui/skeleton';
import { protectedMediaService } from '@/services/api';
interface RepairProofGalleryProps {
imageUrls: string[];
}
const MAX_VISIBLE_IMAGES = 8;
export function RepairProofGallery({ imageUrls }: RepairProofGalleryProps) {
const queries = useQueries({
queries: imageUrls.map((url) => ({
queryKey: ['protected-media', 'v2', url],
queryFn: () => protectedMediaService.getBlob(url),
staleTime: Infinity,
gcTime: 1000 * 60 * 30,
})),
});
const blobs = queries.map((query) => query.data);
const objectUrls = useMemo(
() => blobs.map((blob) => (blob ? URL.createObjectURL(blob) : null)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[...blobs],
);
useEffect(
() => () => {
objectUrls.forEach((url) => {
if (url) URL.revokeObjectURL(url);
});
},
[objectUrls],
);
if (queries.some((query) => query.isLoading)) {
return (
<div className="grid grid-cols-4 gap-1 overflow-hidden rounded-lg">
{imageUrls.slice(0, MAX_VISIBLE_IMAGES).map((url, index) => (
<Skeleton
key={`${url}-${index}`}
className="aspect-square rounded-lg"
/>
))}
</div>
);
}
const images = objectUrls.flatMap((url, index) =>
url ? [{ url, originalIndex: index }] : [],
);
const visibleCount = Math.min(MAX_VISIBLE_IMAGES, images.length);
const overflowCount = images.length - visibleCount;
if (images.length === 0) {
return (
<div className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
Repair images are unavailable.
</div>
);
}
return (
<LightGallery
elementClassNames="grid grid-cols-4 gap-1 overflow-hidden rounded-lg"
mode="lg-fade"
speed={400}
plugins={[lgThumbnail, lgZoom]}
thumbnail
download={false}
mobileSettings={{ controls: true, showCloseIcon: true, download: false }}
>
{images.map(({ url, originalIndex }, index) => {
const isVisible = index < visibleCount;
const isOverflowTile = overflowCount > 0 && index === visibleCount - 1;
return (
<a
key={imageUrls[originalIndex]}
data-src={url}
data-sub-html={`<p>Repair evidence ${originalIndex + 1}</p>`}
className={isVisible ? 'group relative aspect-square' : 'hidden'}
aria-label={
isOverflowTile
? `View all ${images.length} repair images`
: `View repair image ${originalIndex + 1} of ${images.length}`
}
>
{/* Blob URLs cannot be handled by next/image. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={`Repair evidence ${originalIndex + 1}`}
className="size-full object-cover transition duration-300 group-hover:scale-[1.03] group-hover:brightness-90"
/>
{isOverflowTile ? (
<span className="absolute inset-0 flex items-center justify-center bg-black/55 text-xl font-semibold text-white transition-colors group-hover:bg-black/65">
+{overflowCount}
</span>
) : null}
</a>
);
})}
</LightGallery>
);
}

View File

@@ -0,0 +1,103 @@
import { CheckCircle2, HardHat, Wrench, ToolCase } from 'lucide-react';
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { TicketDetail } from '@/types';
import { formatDate } from '@/utils/date';
import { RepairProofGallery } from './RepairProofGallery';
function SubmittedBanner({
submittedAt,
submitterName,
}: {
submittedAt: string;
submitterName: string;
}) {
return (
<div className="flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-3 dark:border-emerald-900/60 dark:bg-emerald-950/30">
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
<div className="min-w-0 space-y-0.5">
<p className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
Repair Submitted
</p>
<p className="text-xs text-emerald-700/80 dark:text-emerald-300/80">
{formatDate(submittedAt)} by {submitterName}
</p>
</div>
</div>
);
}
function ImageGallery({
imageUrls,
hasVideo,
}: {
imageUrls: string[];
hasVideo: boolean;
}) {
if (imageUrls.length === 0) {
return hasVideo ? null : (
<div className="flex items-center gap-2 rounded-lg border border-dashed px-3 py-4 text-sm text-muted-foreground">
<HardHat className="size-4 shrink-0" />
No media attached
</div>
);
}
return <RepairProofGallery imageUrls={imageUrls} />;
}
function Notes({ notes }: { notes: string | null | undefined }) {
return (
<div className="space-y-1.5">
<p className="text-xs font-semibold text-foreground">Repair Notes</p>
<p className="text-sm leading-relaxed text-muted-foreground">
{notes?.trim() || 'No repair notes provided.'}
</p>
</div>
);
}
export function RepairProofSummary({ ticket }: { ticket: TicketDetail }) {
const repair = ticket.repair;
if (!repair?.submitted_at) {
return null;
}
const submitterName = ticket.worker?.name?.trim() || 'Assigned worker';
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ToolCase className="text-primary" />
Repair Proof
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<SubmittedBanner
submittedAt={repair.submitted_at}
submitterName={submitterName}
/>
<ProtectedVideoPlayer
url={repair.proof_video_url}
meta="Contractor evidence video"
emptyTitle="No repair video uploaded"
emptyDescription="The assigned worker has not uploaded a repair video."
className="rounded-lg"
/>
<ImageGallery
imageUrls={repair.proof_image_urls}
hasVideo={Boolean(repair.proof_video_url)}
/>
<Notes notes={repair.notes} />
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TicketDetailHeader } from './components/TicketDetailHeader';
import {
TicketOverviewCardSkeleton,
TicketOverviewHeaderSkeleton,
} from './components/TicketOverviewSkeleton';
import {
TicketClassActionsSkeleton,
TicketClassContentSkeleton,
} from './components/TicketClassDetailSkeleton';
import { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketOverviewCard } from './components/TicketOverviewCard';
import { TicketStatusActions } from './components/TicketStatusActions';
import { TicketDefectClassTabs } from './components/TicketDefectClassTabs';
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
import {
useTicketClassDetailQuery,
useTicketOverviewQuery,
} from '../hooks/useTicketQueries';
export default function TicketDetailPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { ticketId } = useParams() as { ticketId: string };
const defectClass = searchParams.get('defect_class') ?? undefined;
const overviewQuery = useTicketOverviewQuery(ticketId);
const classDetailQuery = useTicketClassDetailQuery(ticketId, defectClass);
const overview = overviewQuery.data;
const classDetail = classDetailQuery.data;
const isClassDetailLoading = Boolean(
defectClass && classDetailQuery.isLoading,
);
useTicketDetailEvents(ticketId, defectClass, Boolean(ticketId));
if (overviewQuery.isError) {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3">
<p className="text-sm text-destructive">Failed to load ticket.</p>
<Button
variant="outline"
size="sm"
onClick={() => router.push('/ticket')}
>
<ArrowLeft />
Back to Tickets
</Button>
</div>
);
}
return (
<main className="relative z-10 space-y-5">
{overview ? (
<TicketDetailHeader ticket={overview} />
) : (
<TicketOverviewHeaderSkeleton />
)}
<div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]">
<div className="space-y-5">
{overview ? (
<TicketOverviewCard ticket={overview} />
) : (
<TicketOverviewCardSkeleton />
)}
<TicketDefectClassTabs ticketId={ticketId} ticket={overview} />
{isClassDetailLoading ? <TicketClassContentSkeleton /> : null}
{!isClassDetailLoading && classDetail ? (
<TicketHistoryCard ticket={classDetail} />
) : null}
</div>
<div className="space-y-3 self-start">
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
{!isClassDetailLoading && classDetail ? (
<TicketStatusActions ticket={classDetail} />
) : null}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import type { TicketListItem } from '@/types';
import { formatDate } from '@/utils/date';
export function useTicketColumns(): ColumnDef<TicketListItem>[] {
return useMemo(
() => [
{
accessorKey: 'ticket_name',
header: 'Ticket',
size: 180,
cell: ({ row }) => (
<span className="font-medium">{row.original.ticket_name}</span>
),
},
{
accessorKey: 'chainage_name',
header: 'Segment',
size: 180,
cell: ({ row }) => row.original.chainage_name || 'N/A',
},
{
accessorKey: 'detection_count',
header: 'Detections',
size: 120,
},
{
id: 'created_by',
header: 'Uploaded By',
size: 220,
cell: ({ row }) => (
<div>
<p className="font-medium">
{row.original.created_by_name || 'N/A'}
</p>
{row.original.created_by_email ? (
<p className="text-xs text-muted-foreground">
{row.original.created_by_email}
</p>
) : null}
</div>
),
},
{
accessorKey: 'updated_at',
header: 'Updated',
size: 180,
cell: ({ row }) => formatDate(row.original.updated_at),
},
],
[],
);
}

View File

@@ -0,0 +1,19 @@
'use client';
import { SegmentSelect } from '@/components/lookups/SegmentSelect';
interface TicketFiltersProps {
segmentId: string;
onSegmentChange: (segmentId: string) => void;
}
export function TicketFilters({
segmentId,
onSegmentChange,
}: TicketFiltersProps) {
return (
<div className="w-full sm:w-64">
<SegmentSelect value={segmentId} onValueChange={onSegmentChange} />
</div>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { TicketStatus } from '@/types';
const statusClasses: Record<TicketStatus, string> = {
unassigned: 'bg-amber-100 text-amber-800 border-amber-200',
assigned: 'bg-indigo-100 text-indigo-700 border-indigo-200',
under_review: 'bg-violet-100 text-violet-700 border-violet-200',
closed: 'bg-stone-100 text-stone-700 border-stone-200',
};
export function formatTicketStatus(status: TicketStatus) {
return status
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
export function TicketStatusBadge({ status }: { status: TicketStatus }) {
return (
<Badge variant="outline" className={cn('border', statusClasses[status])}>
{formatTicketStatus(status)}
</Badge>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table';
import type { TicketListItem } from '@/types';
interface TicketTableProps {
columns: ColumnDef<TicketListItem>[];
tickets: TicketListItem[];
isLoading: boolean;
toolbar: ReactNode;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onView: (ticket: TicketListItem) => void;
}
export function TicketTable({
columns,
tickets,
isLoading,
toolbar,
skip,
limit,
total,
onPageChange,
onLimitChange,
onView,
}: TicketTableProps) {
return (
<DataTable
title="Tickets"
columns={columns}
data={tickets}
isLoading={isLoading}
toolbar={toolbar}
emptyTitle="No tickets found."
emptyDescription="Tickets will appear when road issues are detected."
pagination={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange,
}}
onRowClick={onView}
/>
);
}

View File

@@ -0,0 +1,178 @@
'use client';
import { useCallback, useMemo, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { API_ROUTES } from '@/constants/apiRoutes';
import { useSseWithToken } from '@/hooks/sse/useSseWithToken';
import { ticketService } from '@/services/api';
import type {
TicketListItem,
TicketListParams,
TicketListResponse,
TicketTableStatusEvent,
} from '@/types';
import { ticketKeys } from '../queries/ticketKeys';
function patchTicketLists(
current: TicketListResponse | undefined,
event: TicketTableStatusEvent,
params?: TicketListParams,
) {
if (!current || !event.id) return current;
const nextRow = buildTicketListItem(event);
const existingIndex = current.items.findIndex(
(ticket) => ticket.id === event.id,
);
if (existingIndex >= 0) {
return {
...current,
items: current.items.map((ticket) =>
ticket.id === event.id ? mergeTicketListItem(ticket, event) : ticket,
),
};
}
if (event.kind !== 'created') return current;
if (!shouldCountCreatedTicket(event, params)) return current;
if (!nextRow || !isFirstPage(params)) {
return {
...current,
total: current.total + 1,
};
}
const pageLimit = params?.limit ?? current.items.length + 1;
return {
...current,
items: [nextRow, ...current.items].slice(0, pageLimit),
total: current.total + 1,
};
}
function shouldCountCreatedTicket(
event: TicketTableStatusEvent,
params?: TicketListParams,
) {
if (params?.chainage_id && event.chainage_id !== params.chainage_id) {
return false;
}
return true;
}
function isFirstPage(params?: TicketListParams) {
return (params?.skip ?? 0) === 0;
}
function getTicketListParams(queryKey: readonly unknown[]) {
const params = queryKey[2];
if (!params || typeof params !== 'object' || Array.isArray(params)) {
return undefined;
}
return params as TicketListParams;
}
function buildTicketListItem(
event: TicketTableStatusEvent,
): TicketListItem | null {
if (
!event.id ||
!event.ticket_name ||
event.detection_count === undefined ||
!event.updated_at
) {
return null;
}
return {
id: event.id,
ticket_name: event.ticket_name,
chainage_id: event.chainage_id ?? null,
chainage_name: event.chainage_name ?? null,
default_defect_class: event.default_defect_class ?? null,
detection_count: event.detection_count,
created_by_name: event.created_by_name ?? null,
created_by_email: event.created_by_email ?? null,
created_at: event.created_at ?? event.updated_at,
updated_at: event.updated_at,
};
}
function mergeTicketListItem(
current: TicketListItem,
event: TicketTableStatusEvent,
): TicketListItem {
return {
...current,
ticket_name: event.ticket_name ?? current.ticket_name,
chainage_id: event.chainage_id ?? current.chainage_id,
chainage_name: event.chainage_name ?? current.chainage_name,
default_defect_class:
event.default_defect_class ?? current.default_defect_class,
detection_count: event.detection_count ?? current.detection_count,
created_by_name: event.created_by_name ?? current.created_by_name,
created_by_email: event.created_by_email ?? current.created_by_email,
created_at: event.created_at ?? current.created_at,
updated_at: event.updated_at ?? current.updated_at,
};
}
export function useTenantTicketTableEvents(enabled = true) {
const queryClient = useQueryClient();
const hasInvalidatedAfterConnectionErrorRef = useRef(false);
const events = useMemo(
() => ({
ticket_status: (event: TicketTableStatusEvent) => {
queryClient
.getQueryCache()
.findAll({ queryKey: ticketKeys.lists() })
.forEach((query) => {
queryClient.setQueryData<TicketListResponse>(
query.queryKey,
(current) =>
patchTicketLists(
current,
event,
getTicketListParams(query.queryKey),
),
);
});
},
}),
[queryClient],
);
const getPath = useCallback(
(token: string) => API_ROUTES.TICKET_EVENTS.TENANT(token),
[],
);
const onConnectionError = useCallback(() => {
if (hasInvalidatedAfterConnectionErrorRef.current) return;
hasInvalidatedAfterConnectionErrorRef.current = true;
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
}, [queryClient]);
const onConnectionOpen = useCallback(() => {
hasInvalidatedAfterConnectionErrorRef.current = false;
}, []);
useSseWithToken({
enabled,
getToken: ticketService.createTenantTicketEventsToken,
getPath,
events,
onConnectionError,
onConnectionOpen,
});
}

View File

@@ -0,0 +1,76 @@
'use client';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { API_ROUTES } from '@/constants/apiRoutes';
import { useSseWithToken } from '@/hooks/sse/useSseWithToken';
import { ticketService } from '@/services/api';
import type { TicketDetailStatusEvent } from '@/types';
import { ticketKeys } from '../queries/ticketKeys';
const TICKET_DETAIL_RECONNECT_EVENTS = ['error'] as const;
function isHeartbeatEvent(event: { type?: string } | null | undefined) {
return event?.type === 'heartbeat';
}
export function useTicketDetailEvents(
ticketId: string | undefined,
defectClass: string | undefined,
enabled = true,
): void {
const queryClient = useQueryClient();
const defectClassRef = useRef(defectClass);
useEffect(() => {
defectClassRef.current = defectClass;
}, [defectClass]);
const refetchTicket = useCallback(() => {
if (!ticketId) return;
queryClient.refetchQueries({ queryKey: ticketKeys.overview(ticketId) });
if (defectClassRef.current) {
queryClient.refetchQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClassRef.current),
});
}
queryClient.invalidateQueries({
queryKey: ticketKeys.defectClasses(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
}, [queryClient, ticketId]);
const getToken = useCallback(
() => ticketService.createTicketEventsToken(ticketId as string),
[ticketId],
);
const getPath = useCallback(
(token: string) =>
API_ROUTES.TICKETS.DETAIL_EVENTS(ticketId as string, token),
[ticketId],
);
const events = useMemo(
() => ({
ticket_status: (event: TicketDetailStatusEvent) => {
if (isHeartbeatEvent(event)) return;
refetchTicket();
},
error: () => undefined,
heartbeat: () => undefined,
}),
[refetchTicket],
);
useSseWithToken({
enabled: Boolean(enabled && ticketId),
getToken,
getPath,
events,
reconnectOnEvents: TICKET_DETAIL_RECONNECT_EVENTS,
});
}

View File

@@ -0,0 +1,304 @@
'use client';
import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ticketService, videoService } from '@/services/api';
import type {
AssignTicketPayload,
CloseTicketPayload,
RequestTicketExtensionPayload,
ReviewTicketExtensionPayload,
ReviewRepairPayload,
SubmitRepairPayload,
SubmitRepairUploadPayload,
TicketDetail,
TicketListParams,
VideoDetectionsParams,
} from '@/types';
import { ticketKeys } from '../queries/ticketKeys';
const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000;
interface UseTicketsQueryParams {
skip: number;
limit: number;
chainageId?: string;
}
function buildTicketListParams({
skip,
limit,
chainageId,
}: UseTicketsQueryParams): TicketListParams {
return {
skip,
limit,
chainage_id: chainageId || undefined,
};
}
export function useTicketsQuery(params: UseTicketsQueryParams) {
const { skip, limit, chainageId } = params;
const listParams = useMemo(
() => buildTicketListParams({ skip, limit, chainageId }),
[chainageId, limit, skip],
);
return useQuery({
queryKey: ticketKeys.list(listParams),
queryFn: () => ticketService.getTickets(listParams),
staleTime: TICKET_TABLE_REFRESH_MS,
refetchInterval: TICKET_TABLE_REFRESH_MS,
});
}
export function useTicketOverviewQuery(ticketId: string | undefined) {
return useQuery({
queryKey: ticketKeys.overview(ticketId ?? ''),
queryFn: () => ticketService.getTicketOverview(ticketId as string),
enabled: Boolean(ticketId),
});
}
export function useTicketClassDetailQuery(
ticketId: string | undefined,
defectClass: string | undefined,
) {
return useQuery({
queryKey: ticketKeys.classDetail(ticketId ?? '', defectClass ?? ''),
queryFn: () =>
ticketService.getTicketClassDetail(
ticketId as string,
defectClass as string,
),
enabled: Boolean(ticketId && defectClass),
});
}
export function useTicketDefectClassesQuery(ticketId: string | undefined) {
return useQuery({
queryKey: ticketKeys.defectClasses(ticketId ?? ''),
queryFn: () => ticketService.getTicketDefectClasses(ticketId as string),
enabled: Boolean(ticketId),
});
}
export function useTicketClassDetectionsQuery(
videoId: string | undefined,
params: VideoDetectionsParams,
) {
const queryParams = useMemo(
() => ({
skip: params.skip ?? 0,
limit: params.limit ?? 1,
class_name: params.class_name,
min_confidence: params.min_confidence,
sort: params.sort ?? 'timestamp_asc',
search: params.search,
}),
[
params.class_name,
params.limit,
params.min_confidence,
params.search,
params.skip,
params.sort,
],
);
return useQuery({
queryKey: ticketKeys.classDetections(videoId ?? '', queryParams),
queryFn: () =>
videoService.getVideoDetections(videoId as string, queryParams),
enabled: Boolean(videoId && queryParams.class_name),
});
}
export function useTicketExtensionRequestQuery(
ticketId: string | undefined,
assignmentId: number | undefined,
) {
return useQuery({
queryKey: ticketKeys.extensionRequest(ticketId ?? '', assignmentId),
queryFn: () =>
ticketService.getTicketExtensionRequest(
ticketId as string,
assignmentId as number,
),
enabled: Boolean(ticketId && assignmentId),
});
}
export function useAssignableTicketUsersQuery(enabled: boolean) {
return useQuery({
queryKey: ticketKeys.assignableUsers(),
queryFn: () => ticketService.getAssignableUsers(),
enabled,
});
}
function mergeTicketDetailResponse(
current: TicketDetail | undefined,
next: TicketDetail,
) {
if (!current) return next;
return {
...current,
...next,
history: next.history ?? current.history,
};
}
export function useAssignTicketMutation(ticketId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: AssignTicketPayload) =>
ticketService.assignTicket(ticketId, payload),
onSuccess: (ticket, payload) => {
toast.success('Ticket assigned');
queryClient.setQueryData<TicketDetail>(
ticketKeys.classDetail(ticketId, payload.defect_class),
(current) => mergeTicketDetailResponse(current, ticket),
);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to assign ticket'),
});
}
export function useSubmitRepairMutation(ticketId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: SubmitRepairPayload) =>
ticketService.submitRepair(ticketId, payload),
onSuccess: (ticket) => {
toast.success('Repair submitted');
queryClient.setQueryData<TicketDetail>(
ticketKeys.classDetail(ticketId, ticket.defect_class),
(current) => mergeTicketDetailResponse(current, ticket),
);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to submit repair'),
});
}
export function useSubmitRepairUploadMutation(ticketId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: SubmitRepairUploadPayload) =>
ticketService.submitRepairUpload(ticketId, payload),
onSuccess: (ticket, payload) => {
toast.success('Repair evidence uploaded');
queryClient.setQueryData<TicketDetail>(
ticketKeys.classDetail(ticketId, payload.defect_class),
(current) => mergeTicketDetailResponse(current, ticket),
);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to upload repair evidence'),
});
}
export function useRequestTicketExtensionMutation(
ticketId: string,
assignmentId: number | undefined,
defectClass: string,
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: RequestTicketExtensionPayload) => {
if (assignmentId === undefined) {
throw new Error('No active assignment is available for this ticket.');
}
return ticketService.requestTicketExtension(
ticketId,
assignmentId,
payload,
);
},
onSuccess: () => {
toast.success('Extension request submitted');
queryClient.invalidateQueries({
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
});
queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to request an extension'),
});
}
export function useReviewTicketExtensionMutation(
ticketId: string,
assignmentId: number,
defectClass: string,
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: ReviewTicketExtensionPayload) =>
ticketService.reviewTicketExtension(ticketId, assignmentId, payload),
onSuccess: (_response, payload) => {
toast.success(
payload.action === 'approve'
? 'Extension request approved'
: 'Extension request rejected',
);
queryClient.invalidateQueries({
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
});
queryClient.invalidateQueries({
queryKey: ticketKeys.classDetail(ticketId, defectClass),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to review extension request'),
});
}
export function useReviewRepairMutation(ticketId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: ReviewRepairPayload) =>
ticketService.reviewRepair(ticketId, payload),
onSuccess: (ticket, payload) => {
toast.success(
payload.action === 'approve' ? 'Repair approved' : 'Repair rejected',
);
queryClient.setQueryData<TicketDetail>(
ticketKeys.classDetail(ticketId, payload.defect_class),
(current) => mergeTicketDetailResponse(current, ticket),
);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to review repair'),
});
}
export function useCloseTicketMutation(ticketId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: CloseTicketPayload) =>
ticketService.closeTicket(ticketId, payload),
onSuccess: (ticket, payload) => {
toast.success('Ticket closed');
queryClient.setQueryData<TicketDetail>(
ticketKeys.classDetail(ticketId, payload.defect_class),
(current) => mergeTicketDetailResponse(current, ticket),
);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
onError: () => toast.error('Failed to close ticket'),
});
}

View File

@@ -0,0 +1,16 @@
'use client';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionRouteGuard } from '@/guards';
export default function TicketLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PermissionRouteGuard permission={PERMISSIONS.TICKET.READ}>
{children}
</PermissionRouteGuard>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { PageHeader } from '@/components/page-header';
import { Ticket } from 'lucide-react';
import type { TicketListItem } from '@/types';
import { TicketFilters } from './components/TicketFilters';
import { TicketTable } from './components/TicketTable';
import { useTicketColumns } from './components/TicketColumns';
import { useTenantTicketTableEvents } from './hooks/useTenantTicketTableEvents';
import { useTicketsQuery } from './hooks/useTicketQueries';
export default function TicketPage() {
const router = useRouter();
const [skip, setSkip] = useState(0);
const [limit, setLimit] = useState(10);
const [segmentId, setSegmentId] = useState('');
useTenantTicketTableEvents();
const ticketsQuery = useTicketsQuery({
skip,
limit,
chainageId: segmentId,
});
const columns = useTicketColumns();
const tickets = ticketsQuery.data?.items ?? [];
const total = ticketsQuery.data?.total ?? 0;
const handleSegmentChange = useCallback((nextSegmentId: string) => {
setSegmentId(nextSegmentId);
setSkip(0);
}, []);
const handleView = useCallback(
(ticket: TicketListItem) => {
const defectClassQuery = ticket.default_defect_class
? `?defect_class=${encodeURIComponent(ticket.default_defect_class)}`
: '';
router.push(`/ticket/${ticket.id}${defectClassQuery}`);
},
[router],
);
const toolbar = useMemo(
() => (
<TicketFilters
segmentId={segmentId}
onSegmentChange={handleSegmentChange}
/>
),
[handleSegmentChange, segmentId],
);
return (
<main className="relative z-10 space-y-5">
<PageHeader
title="Tickets"
description="Track detection, assignment, repair, review, and closure."
icon={Ticket}
/>
<TicketTable
columns={columns}
tickets={tickets}
isLoading={ticketsQuery.isLoading}
toolbar={toolbar}
skip={skip}
limit={limit}
total={total}
onPageChange={setSkip}
onLimitChange={setLimit}
onView={handleView}
/>
</main>
);
}

View File

@@ -0,0 +1,25 @@
import type { TicketListParams, VideoDetectionsParams } from '@/types';
export const ticketKeys = {
all: ['tickets'] as const,
lists: () => [...ticketKeys.all, 'list'] as const,
list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const,
details: () => [...ticketKeys.all, 'detail'] as const,
overview: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'overview'] as const,
classDetail: (ticketId: string, defectClass: string) =>
[...ticketKeys.details(), ticketId, 'class', defectClass] as const,
classDetections: (videoId: string, params: VideoDetectionsParams) =>
[...ticketKeys.details(), 'video', videoId, 'detections', params] as const,
defectClasses: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'defect-classes'] as const,
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
[
...ticketKeys.details(),
ticketId,
'assignments',
assignmentId,
'extension-request',
] as const,
assignableUsers: () => [...ticketKeys.all, 'assignable-users'] as const,
};

View File

@@ -1,260 +0,0 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { sessionService, videoService } from '@/services/api';
import { SessionContext } from '@/types';
import { ROUTES } from '@/utils/routes';
import { Button } from '@/components/ui/button';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const WS_URL = API_URL?.replace(/^https:\/\//, 'wss://').replace(
/^http:\/\//,
'ws://',
);
export default function VideoProcessingPage() {
const router = useRouter();
const { videoId } = useParams() as { videoId: string };
const [session, setSession] = useState<SessionContext | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Processing states
const [progress, setProgress] = useState(0);
const [statusMessage, setStatusMessage] = useState('Initializing...');
const [error, setError] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const connectWebSocket = useCallback(
(vid: string) => {
if (wsRef.current) return wsRef.current;
const ws = new WebSocket(`${WS_URL}/ws/${vid}`);
wsRef.current = ws;
ws.onmessage = async (event) => {
const data = JSON.parse(event.data);
if (data.type === 'progress' || data.progress !== undefined) {
setProgress(data.progress || 0);
let message = data.message || 'Processing...';
const uniqueCount =
data.unique_potholes ??
data.unique_pothole ??
data.unique_signboards ??
data.unique_signboard ??
data.unique_culverts ??
data.unique_culvert ??
data.unique_drain_issue;
if (uniqueCount !== undefined) {
message += ` | Unique: ${uniqueCount} | Total: ${data.total_detections || 0}`;
}
setStatusMessage(message);
}
if (data.type === 'complete' || data.status === 'completed') {
setStatusMessage('Processing completed! Finalizing...');
ws.close();
// Navigate to results
setTimeout(() => router.push(`/results/${vid}`), 1000);
}
if (data.type === 'error') {
setError('Error: ' + data.message);
setStatusMessage('');
ws.close();
}
};
ws.onerror = () => {
setStatusMessage('Connection lost. Reconnecting...');
wsRef.current = null;
setTimeout(() => connectWebSocket(vid), 3000);
};
ws.onclose = () => {
wsRef.current = null;
};
return ws;
},
[router],
);
useEffect(() => {
const storedSession = sessionService.loadSession();
setSession(storedSession);
let isMounted = true;
const checkStatus = async () => {
try {
const statusData = await videoService.getVideoStatus(videoId);
if (!isMounted) return;
if (statusData.status === 'completed') {
router.replace(`/results/${videoId}`);
return;
}
if (statusData.status === 'error') {
setError(
statusData.message || 'An error occurred during processing.',
);
setIsLoading(false);
return;
}
// If processing, start WebSocket
setProgress(statusData.progress || 0);
setStatusMessage(statusData.message || 'Resuming processing...');
connectWebSocket(videoId);
setIsLoading(false);
} catch (err) {
console.error('Status check failed:', err);
if (isMounted) {
setError('Failed to connect to server.');
setIsLoading(false);
}
}
};
if (videoId) {
checkStatus();
}
return () => {
isMounted = false;
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, [videoId, router, connectWebSocket]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</Card>
</div>
);
}
return (
<div className="min-h-screen">
<main className="min-h-screen flex flex-col">
<div className="flex-1 container mx-auto px-6 py-6 max-w-340 flex flex-col">
<div className="mb-6">
<PageHeader
title="Uploading Analysis"
description={`Real-time analysis progress for video ID: ${videoId}`}
icon={TrendingUp}
/>
</div>
{session && (
<div className="mb-6">
<Card className="p-0 border shadow-sm overflow-hidden">
<div className="flex flex-col md:flex-row md:items-center py-4 px-6 gap-6 bg-card">
<div className="flex flex-wrap items-center gap-x-12 gap-y-4 flex-1">
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
Project
</span>
<span className="text-base font-bold leading-tight">
{session.projectName}
</span>
</div>
<div className="flex flex-col border-l pl-12 border-border/60">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
Package
</span>
<span className="text-sm font-semibold text-muted-foreground leading-tight">
{session.packageName}
</span>
</div>
<div className="flex flex-col border-l pl-12 border-border/60">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
Segment
</span>
<span className="text-sm font-semibold text-muted-foreground leading-tight flex items-center gap-1.5">
{session.chainageName}
{session.chainageDirection && (
<span className="inline-flex items-center gap-1 px-1 py-0.5 rounded bg-muted text-[10px] font-bold uppercase border">
{session.chainageDirection === 'UP' ? (
<ArrowUp className="h-2.5 w-2.5" />
) : (
<ArrowDown className="h-2.5 w-2.5" />
)}
{session.chainageDirection}
</span>
)}
</span>
</div>
</div>
</div>
</Card>
</div>
)}
<Card className="flex-1 flex flex-col items-center justify-center py-12 px-8 min-h-[450px]">
<div className="flex flex-col items-center justify-center w-full max-w-2xl space-y-10">
<div className="text-center space-y-4">
<h2 className="text-3xl font-bold tracking-tight">
Uploading ...
</h2>
<p className="text-sm font-mono text-muted-foreground tracking-widest">
ID: {videoId}
</p>
</div>
<div className="w-full space-y-4">
<div className="flex items-center justify-between text-sm">
<span className="font-bold text-muted-foreground text-xs uppercase tracking-widest">
Progress
</span>
<span className="font-bold text-primary text-lg">
{progress}%
</span>
</div>
<div className="h-4 rounded-full bg-secondary overflow-hidden border">
<div
className="h-full bg-primary transition-all duration-1000 ease-in-out"
style={{ width: `${progress}%` }}
/>
</div>
<div className="text-center pt-2">
<div className="inline-flex items-center gap-3 px-4 py-2 rounded-full border bg-secondary/30">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<p className="text-sm font-semibold">{statusMessage}</p>
</div>
</div>
</div>
{error && (
<div className="w-full p-6 rounded-md bg-destructive/10 border border-destructive/20 text-center space-y-3">
<p className="text-destructive font-medium">{error}</p>
<Button
variant="outline"
size="sm"
onClick={() => window.location.reload()}
>
Retry Connection
</Button>
</div>
)}
</div>
</Card>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,171 @@
'use client';
import { useRef, useState } from 'react';
import { Loader2, Play } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Separator } from '@/components/ui/separator';
import type { SessionContext } from '@/types';
import { useCreateUploadMutation } from '../hooks/useUploadQueries';
import { InputDataSection } from './InputDataSection';
import { LocationSection } from './LocationSection';
import { UploadSettingsSection } from './UploadSettingsSection';
interface CreateUploadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CreateUploadDialog({
open,
onOpenChange,
}: CreateUploadDialogProps) {
const [session, setSession] = useState<SessionContext | null>(null);
const [videoFile, setVideoFile] = useState<File | null>(null);
const [gpsFile, setGpsFile] = useState<File | null>(null);
const [detectionMode, setDetectionMode] = useState('yolo');
const [error, setError] = useState<string | null>(null);
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
const createUploadMutation = useCreateUploadMutation();
const isSubmitting = createUploadMutation.isPending;
const resetForm = () => {
setSession(null);
setVideoFile(null);
setGpsFile(null);
setDetectionMode('yolo');
setError(null);
createUploadMutation.reset();
};
const canSubmit = Boolean(
session?.chainageId && videoFile && gpsFile && detectionMode,
);
const handleOpenChange = (nextOpen: boolean) => {
onOpenChange(nextOpen);
if (!nextOpen) {
resetForm();
}
};
const handleSubmit = async () => {
if (!session?.chainageId || !videoFile || !gpsFile) {
setError('Complete location and input data before uploading.');
return;
}
const formData = new FormData();
formData.append('file', videoFile);
formData.append('detection_mode', detectionMode);
formData.append('chainage_id', session.chainageId);
formData.append('json_file', gpsFile);
setError(null);
try {
await createUploadMutation.mutateAsync(formData);
handleOpenChange(false);
} catch (err) {
if (err instanceof TypeError && err.message === 'Failed to fetch') {
setError(
'Cannot connect to server. Please check if backend is running.',
);
return;
}
setError(err instanceof Error ? err.message : 'Upload failed');
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="sm:max-w-5xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle>Upload Video</DialogTitle>
<DialogDescription>
Configure location and upload road inspection data.
</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-5">
<LocationSection
value={session}
onChange={setSession}
disabled={isSubmitting}
portalContainer={dropdownPortalRef}
/>
<Separator />
<InputDataSection
videoFile={videoFile}
gpsFile={gpsFile}
onVideoFileChange={(file) => {
setVideoFile(file);
setError(null);
}}
onGpsFileChange={(file) => {
setGpsFile(file);
setError(null);
}}
disabled={isSubmitting}
/>
<Separator />
<UploadSettingsSection
value={detectionMode}
onValueChange={setDetectionMode}
disabled={isSubmitting}
portalContainer={dropdownPortalRef}
/>
{error ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isSubmitting}
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
<Button
type="button"
disabled={!canSubmit || isSubmitting}
onClick={handleSubmit}
className="gap-2"
>
{isSubmitting ? (
<>
<Loader2 className="size-4 animate-spin" />
Uploading...
</>
) : (
<>
<Play className="size-4" />
Upload
</>
)}
</Button>
</DialogFooter>
<div ref={dropdownPortalRef} />
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import { UploadFileDropzone } from './UploadFileDropzone';
interface InputDataSectionProps {
videoFile: File | null;
gpsFile: File | null;
onVideoFileChange: (file: File | null) => void;
onGpsFileChange: (file: File | null) => void;
disabled?: boolean;
}
export function InputDataSection({
videoFile,
gpsFile,
onVideoFileChange,
onGpsFileChange,
disabled = false,
}: InputDataSectionProps) {
return (
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold text-foreground">Input Data</h2>
<p className="text-xs text-muted-foreground">
Attach the road video and GPS telemetry for processing.
</p>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<UploadFileDropzone
title="Drop video here or browse"
description="Accepted formats: MP4, MOV, AVI, MKV"
accept="video/*"
file={videoFile}
onFileChange={onVideoFileChange}
disabled={disabled}
/>
<UploadFileDropzone
title="Drop GPS JSON here or browse"
description="Accepted format: JSON telemetry file"
accept=".json,application/json"
file={gpsFile}
onFileChange={onGpsFileChange}
disabled={disabled}
/>
</div>
</section>
);
}

View File

@@ -0,0 +1,159 @@
'use client';
import type { RefObject } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { FormField } from '@/components/form';
import { PackageSelect } from '@/components/lookups/PackageSelect';
import { ProjectSelect } from '@/components/lookups/ProjectSelect';
import { SegmentSelect } from '@/components/lookups/SegmentSelect';
import {
packageService,
projectService,
chainageService,
} from '@/services/api';
import type {
Chainage,
Package as PackageType,
Project,
SessionContext,
} from '@/types';
interface LocationSectionProps {
value: SessionContext | null;
onChange: (session: SessionContext | null) => void;
disabled?: boolean;
portalContainer?: RefObject<HTMLDivElement | null>;
}
export function LocationSection({
value,
onChange,
disabled = false,
portalContainer,
}: LocationSectionProps) {
const [projectId, setProjectId] = useState(value?.projectId ?? '');
const [packageId, setPackageId] = useState(value?.packageId ?? '');
const [segmentId, setSegmentId] = useState(value?.chainageId ?? '');
const [project, setProject] = useState<Project | null>(null);
const [selectedPackage, setSelectedPackage] = useState<PackageType | null>(
null,
);
const [segment, setSegment] = useState<Chainage | null>(null);
const latestProjectIdRef = useRef('');
const latestPackageIdRef = useRef('');
const latestSegmentIdRef = useRef('');
useEffect(() => {
if (project && selectedPackage && segment) {
onChange({
projectId: project.id,
projectName: project.name,
packageId: selectedPackage.id,
packageName: selectedPackage.name,
chainageId: segment.id,
chainageName: segment.segment_name,
chainageDirection: segment.direction,
});
return;
}
onChange(null);
}, [onChange, project, selectedPackage, segment]);
const handleProjectChange = useCallback((nextProjectId: string) => {
latestProjectIdRef.current = nextProjectId;
latestPackageIdRef.current = '';
latestSegmentIdRef.current = '';
setProjectId(nextProjectId);
setPackageId('');
setSegmentId('');
setProject(null);
setSelectedPackage(null);
setSegment(null);
if (!nextProjectId) return;
void projectService.getProjectById(nextProjectId).then((nextProject) => {
if (latestProjectIdRef.current === nextProjectId) {
setProject(nextProject);
}
});
}, []);
const handlePackageChange = useCallback((nextPackageId: string) => {
latestPackageIdRef.current = nextPackageId;
latestSegmentIdRef.current = '';
setPackageId(nextPackageId);
setSegmentId('');
setSelectedPackage(null);
setSegment(null);
if (!nextPackageId) return;
void packageService.getPackageById(nextPackageId).then((nextPackage) => {
if (latestPackageIdRef.current === nextPackageId) {
setSelectedPackage(nextPackage);
}
});
}, []);
const handleSegmentChange = useCallback((nextSegmentId: string) => {
latestSegmentIdRef.current = nextSegmentId;
setSegmentId(nextSegmentId);
setSegment(null);
if (!nextSegmentId) return;
void chainageService.getChainageById(nextSegmentId).then((nextSegment) => {
if (latestSegmentIdRef.current === nextSegmentId) {
setSegment(nextSegment);
}
});
}, []);
return (
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold text-foreground">Location</h2>
<p className="text-xs text-muted-foreground">
Select the project hierarchy for this upload.
</p>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<FormField label="Project" required>
<ProjectSelect
value={projectId}
onValueChange={handleProjectChange}
disabled={disabled}
portalContainer={portalContainer}
/>
</FormField>
<FormField label="Package" required>
<PackageSelect
value={packageId}
onValueChange={handlePackageChange}
projectId={projectId}
enabled={Boolean(projectId)}
disabled={disabled || !projectId}
portalContainer={portalContainer}
/>
</FormField>
<FormField label="Segment" required>
<SegmentSelect
value={segmentId}
onValueChange={handleSegmentChange}
packageId={packageId}
enabled={Boolean(packageId)}
disabled={disabled || !packageId}
portalContainer={portalContainer}
/>
</FormField>
</div>
</section>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import { useRouter } from 'next/navigation';
import {
Download,
Eye,
MoreVertical,
PlayCircle,
RotateCcw,
Ticket,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { UploadListItem } from '@/types';
interface UploadActionsMenuProps {
upload: UploadListItem;
onViewVideo: (upload: UploadListItem) => void;
}
export function UploadActionsMenu({
upload,
onViewVideo,
}: UploadActionsMenuProps) {
const router = useRouter();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={(event) => event.stopPropagation()}
>
<MoreVertical className="size-4" />
<span className="sr-only">Open actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{upload.raw_video_url ? (
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onViewVideo(upload);
}}
>
<PlayCircle className="size-4" />
View Video
</DropdownMenuItem>
) : null}
<DropdownMenuItem>
<Eye /> View Details
</DropdownMenuItem>
<DropdownMenuItem>
<RotateCcw /> Retry Analysis
</DropdownMenuItem>
<DropdownMenuItem
disabled={!upload.ticket_id}
onClick={(event) => {
event.stopPropagation();
if (upload.ticket_id) {
router.push(`/ticket/${upload.ticket_id}`);
}
}}
>
<Ticket /> Open Ticket
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2 /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import { useRef, useState } from 'react';
import { FileCheck2, UploadCloud } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
interface UploadFileDropzoneProps {
title: string;
description: string;
accept: string;
file: File | null;
onFileChange: (file: File | null) => void;
disabled?: boolean;
}
export function UploadFileDropzone({
title,
description,
accept,
file,
onFileChange,
disabled = false,
}: UploadFileDropzoneProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = useState(false);
const handleFiles = (files: FileList | null) => {
if (disabled) return;
onFileChange(files?.[0] ?? null);
};
return (
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
onClick={() => {
if (!disabled) inputRef.current?.click();
}}
onKeyDown={(event) => {
if (!disabled && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
inputRef.current?.click();
}
}}
onDragOver={(event) => {
event.preventDefault();
if (!disabled) setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(event) => {
event.preventDefault();
setIsDragging(false);
handleFiles(event.dataTransfer.files);
}}
className={cn(
'group flex min-h-32 cursor-pointer flex-col justify-between rounded-lg border border-dashed bg-card/60 p-4 transition-colors',
'hover:border-primary/60 hover:bg-secondary/40',
isDragging && 'border-primary bg-secondary/60',
disabled && 'pointer-events-none cursor-not-allowed opacity-60',
)}
>
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
onChange={(event) => handleFiles(event.target.files)}
disabled={disabled}
/>
<div className="space-y-3">
<div className="flex size-9 items-center justify-center rounded-lg border bg-background">
<UploadCloud className="size-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium text-foreground">{title}</p>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</div>
</div>
{file ? (
<Badge variant="secondary" className="mt-4 max-w-full justify-start">
<FileCheck2 className="size-3" />
<span className="truncate">{file.name}</span>
</Badge>
) : null}
</div>
);
}

View File

@@ -0,0 +1,156 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Video } from 'lucide-react';
import { ENV_CONSTANT } from '@/constants/secrect.constant';
import { Badge } from '@/components/ui/badge';
import type { UploadListItem } from '@/types';
import { formatDate } from '@/utils/date';
import { UploadActionsMenu } from './UploadActionsMenu';
import { UploadResultCell } from './UploadResultCell';
import { UploadStatusBadge } from './UploadStatusBadge';
function resolveThumbnailUrl(value: string | null) {
if (!value) return null;
if (/^https?:\/\//i.test(value)) return value;
const baseUrl = ENV_CONSTANT.BASE_API_URL?.replace(/\/$/, '');
const path = value.startsWith('/') ? value : `/${value}`;
return baseUrl ? `${baseUrl}${path}` : path;
}
export function useUploadListColumns(
onViewVideo: (upload: UploadListItem) => void,
): ColumnDef<UploadListItem>[] {
return useMemo(
() => [
{
id: 'thumbnail',
header: 'Thumbnail',
size: 104,
minSize: 96,
enableSorting: false,
meta: { disableTruncate: true },
cell: ({ row }) => {
const thumbnail = resolveThumbnailUrl(row.original.thumbnail);
return (
<div
className="flex aspect-video w-20 items-center justify-center overflow-hidden rounded-lg border bg-muted bg-cover bg-center"
style={
thumbnail
? { backgroundImage: `url("${thumbnail}")` }
: undefined
}
role={thumbnail ? 'img' : undefined}
aria-label={thumbnail ? 'Upload thumbnail' : undefined}
>
{!thumbnail ? (
<Video className="size-5 text-muted-foreground" />
) : null}
</div>
);
},
},
{
id: 'uploaded_by',
header: 'Uploader',
size: 190,
cell: ({ row }) => (
<div>
<p className="font-medium">
{row.original.uploaded_by.name ?? '—'}
</p>
<p className="text-xs text-muted-foreground">
{row.original.uploaded_by.email ?? '—'}
</p>
</div>
),
},
{
accessorKey: 'project',
header: 'Project',
size: 165,
cell: ({ row }) => row.original.project ?? '—',
},
{
accessorKey: 'package',
header: 'Package',
size: 110,
cell: ({ row }) => row.original.package ?? '—',
},
{
accessorKey: 'segment',
header: 'Segment',
size: 140,
cell: ({ row }) => row.original.segment ?? '—',
},
{
accessorKey: 'chainage',
header: 'Chainage',
size: 155,
cell: ({ row }) => row.original.chainage ?? '—',
},
{
accessorKey: 'uploaded_at',
header: 'Uploaded At',
size: 175,
cell: ({ row }) => formatDate(row.original.uploaded_at),
},
{
accessorKey: 'is_uploaded',
header: 'Upload Status',
size: 125,
meta: { disableTruncate: true },
cell: ({ row }) =>
row.original.is_uploaded ? (
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
Uploaded
</Badge>
) : (
<span className="text-muted-foreground"></span>
),
},
{
id: 'status',
header: 'Analysis Status',
size: 145,
meta: { disableTruncate: true },
cell: ({ row }) => (
<UploadStatusBadge
status={row.original.status}
progress={row.original.progress}
/>
),
},
{
id: 'result',
header: 'Result',
size: 170,
meta: { disableTruncate: true },
cell: ({ row }) => <UploadResultCell item={row.original} />,
},
{
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
size: 64,
minSize: 64,
enableSorting: false,
enableHiding: false,
meta: { disableTruncate: true },
cell: ({ row }) => (
<div className="flex justify-center">
<UploadActionsMenu
upload={row.original}
onViewVideo={onViewVideo}
/>
</div>
),
},
],
[onViewVideo],
);
}

View File

@@ -0,0 +1,46 @@
import { CircleMinus, CircleX, TicketCheck } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import type { UploadListItem } from '@/types';
export function UploadResultCell({ item }: { item: UploadListItem }) {
if (item.result === 'ticket_created') {
return (
<div className="space-y-1">
<Badge className="bg-violet-500/15 text-violet-600 dark:text-violet-400">
<TicketCheck /> Ticket Created
</Badge>
<p className="text-xs font-medium">
{item.ticket_name ?? item.ticket_id}
</p>
</div>
);
}
if (item.result === 'analysis_failed') {
return (
<div className="space-y-1 text-red-600 dark:text-red-400">
<div className="flex items-center gap-1.5 text-xs font-medium">
<CircleX className="size-3.5" /> Analysis Failed
</div>
<p className="text-xs text-muted-foreground">
{item.error_message ?? 'Processing failed'}
</p>
</div>
);
}
if (item.result === 'no_defects_found') {
return (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-500">
<CircleMinus className="size-3.5" /> No Defects Found
</div>
<p className="text-xs text-muted-foreground">
{item.total_detections ?? 0} issues detected
</p>
</div>
);
}
return <span className="text-muted-foreground"></span>;
}

View File

@@ -0,0 +1,56 @@
'use client';
import type { RefObject } from 'react';
import { FormField, SelectPopover } from '@/components/form';
const options = [
{ value: 'yolo', label: 'Road Defect Detection' },
{ value: 'yoloe', label: 'YOLOE Open-Vocabulary Detection' },
{ value: 'yoloe_trained_vl', label: 'YOLOE With Vision Language Model' },
{ value: 'culvert_detection', label: 'Culvert Detection' },
{ value: 'combined', label: 'Road Defect Detection with vl' },
{ value: 'yoloe_seg', label: 'Improved Road Defect Detection' },
] as const;
interface UploadSettingsSectionProps {
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
portalContainer?: RefObject<HTMLDivElement | null>;
}
export function UploadSettingsSection({
value,
onValueChange,
disabled = false,
portalContainer,
}: UploadSettingsSectionProps) {
return (
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold text-foreground">
Upload Settings
</h2>
<p className="text-xs text-muted-foreground">
Select the detection workflow for this upload.
</p>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<FormField id="detection-mode" label="Detection Mode">
<SelectPopover
options={options}
value={value}
onValueChange={onValueChange}
disabled={disabled}
portalContainer={portalContainer}
placeholder="Select method"
searchPlaceholder="Search methods"
emptyMessage="No methods found."
/>
</FormField>
</div>
</section>
);
}

View File

@@ -0,0 +1,65 @@
import { CheckCircle2, Clock3, LoaderCircle, XCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import type { UploadStatus } from '@/types';
interface UploadStatusBadgeProps {
status: UploadStatus;
progress: number;
}
const statusConfig = {
queued: {
label: 'Queued',
icon: Clock3,
className: 'bg-zinc-500/15 text-zinc-500 dark:text-zinc-300',
},
processing: {
label: 'Processing',
icon: LoaderCircle,
className: 'bg-blue-500/15 text-blue-600 dark:text-blue-400',
},
completed: {
label: 'Completed',
icon: CheckCircle2,
className: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
},
failed: {
label: 'Failed',
icon: XCircle,
className: 'bg-red-500/15 text-red-600 dark:text-red-400',
},
} satisfies Record<
UploadStatus,
{ label: string; icon: typeof Clock3; className: string }
>;
export function UploadStatusBadge({
status,
progress,
}: UploadStatusBadgeProps) {
const config = statusConfig[status];
const Icon = config.icon;
return (
<div className="space-y-1.5">
<Badge variant="secondary" className={config.className}>
<Icon
className={status === 'processing' ? 'animate-spin' : undefined}
/>
{config.label}
</Badge>
{status === 'processing' ? (
<div className="w-24 space-y-1">
<span className="text-xs text-muted-foreground">
{progress}% complete
</span>
<Progress value={progress} className="h-1.5" />
</div>
) : status === 'completed' ? (
<p className="text-xs text-muted-foreground">100%</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table';
import type { UploadListItem } from '@/types';
interface UploadTableProps {
columns: ColumnDef<UploadListItem>[];
uploads: UploadListItem[];
isLoading: boolean;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
}
export function UploadTable({
columns,
uploads,
isLoading,
skip,
limit,
total,
onPageChange,
onLimitChange,
}: UploadTableProps) {
return (
<DataTable
data={uploads}
columns={columns}
isLoading={isLoading}
emptyTitle="No uploads found"
emptyDescription="Upload your first road inspection video."
pagination={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange,
}}
enableColumnControls={false}
minColumnSize={120}
/>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { UploadListItem } from '@/types';
interface VideoPreviewDialogProps {
upload: UploadListItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function VideoPreviewDialog({
upload,
open,
onOpenChange,
}: VideoPreviewDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Video Preview</DialogTitle>
</DialogHeader>
<DialogBody>
<ProtectedVideoPlayer
url={open ? upload?.raw_video_url : undefined}
className="rounded-lg"
unavailableTitle="Video preview unavailable"
/>
</DialogBody>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { useState } from 'react';
const DEFAULT_LIMIT = 20;
export function useUploadFilters() {
const [skip, setSkip] = useState(0);
const [limit, setLimitValue] = useState(DEFAULT_LIMIT);
const setLimit = (nextLimit: number) => {
setLimitValue(nextLimit);
setSkip(0);
};
return {
skip,
setSkip,
limit,
setLimit,
};
}

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