86 Commits

Author SHA1 Message Date
830883c6b7 fix(auth): prevent logout on transient API failures 2026-07-13 19:29:04 +05:30
68829dc72b feat(tickets): add detection discard workflow 2026-07-13 19:27:54 +05:30
9d7f956c3b feat(ticket): show assigned contractor details on in-progress tickets 2026-07-10 18:27:26 +05:30
0026824f84 Assign to Contractor // here in due date also change the year to 20 2026-07-10 16:17:53 +05:30
a18eb64dac feat(ticket): enable circular detection preview navigation 2026-07-10 16:12:30 +05:30
6c71d8f8a7 fix(ticket): widen extension due date picker range 2026-07-10 15:56:00 +05:30
0e2172366b refactor(ui): replace theme dropdown with toggle button 2026-07-10 13:38:17 +05:30
56180f8d18 style(ui): use pointer cursor for dropdown menu items 2026-07-10 13:25:28 +05:30
5269b29aac feat(ticket): use grid picker for extension due date 2026-07-10 12:24:44 +05:30
9fe7109faf style: widen plan and role sheet panels 2026-07-10 11:31:36 +05:30
9720546455 refactor(ticket): update ticket detail skeleton loaders 2026-07-10 10:42:40 +05:30
8727963c54 style: refine shared UI primitives and management flows 2026-07-09 23:06:25 +05:30
0e8108b875 feat: modify table design 2026-07-09 17:14:37 +05:30
87dde1c8d1 feat: sync codebase with latest radix pattern 2026-07-09 17:07:11 +05:30
a376558b2f refactor(layout): improve module header and sidebar toggle UX 2026-07-09 11:54:16 +05:30
4fe93460d9 feat(ui): use grid calendar picker for modern ui look 2026-07-09 10:31:36 +05:30
cbfb6e040b refactor(ticket): rename View Analysis button to View Video 2026-07-08 17:51:42 +05:30
7a82e17bbb feat(results): add live annotation overlay on raw video playback 2026-07-08 17:24:20 +05:30
23186ffd29 feat(ticket): add paginated class detection preview with image and map 2026-07-08 10:48:03 +05:30
fb9a654aea feat(results): replace video playback with detection image preview and map 2026-07-07 15:36:27 +05:30
d88978a830 chore: class location change 2026-07-07 12:02:29 +05:30
ba9731de86 refactor: refactor report proff section 2026-07-06 17:04:05 +05:30
c0f3a718ef feat(upload): display no-defects analysis result 2026-07-06 14:17:32 +05:30
14220445ad chore: ignore auto-generated next-env.d.ts 2026-07-04 00:57:42 +05:30
79272c7abc feat(ticket): add extension requests and improve repair proof upload UI 2026-07-04 00:56:08 +05:30
26e15360f7 feat(ticket): add due date picker and contractor details to assign flow 2026-07-03 16:07:08 +05:30
b6a0f04f1e Merge branch 'videoplayer' of https://git.convexsol.co/santasri.pachhal/visionRoad-ui into feat/mod 2026-07-03 10:36:32 +05:30
9cf4f0e795 feat(ticket): redesign overview with AI summary, location, and shared defect visuals 2026-07-03 10:30:59 +05:30
fdcfc3a8db feat: add action to open ticket action in upload 2026-07-02 15:44:26 +05:30
278b9591d7 feat: added sse for upload section 2026-07-02 14:28:28 +05:30
02adbb4c10 feat: introduce custom video player for video analysis 2026-07-02 14:23:00 +05:30
53fe4e11fe feat: change interface of analysis video log 2026-07-02 13:57:18 +05:30
2eb58856af feat(ticket): add section icons, theme-aware system timeline, and nested sidebar active routes 2026-07-02 11:33:53 +05:30
c73ba0a01f feat(ticket): redesign audit timeline with structured history events 2026-07-01 22:23:49 +05:30
1a94ff80e6 fix: scrollbar gutter shifting issue 2026-07-01 17:28:25 +05:30
b1174c8825 feat(ticket): split overview/class detail queries and add loading skeletons 2026-07-01 16:13:42 +05:30
d4a7a7c139 chore(ticket) : add uploader email to ticket list 2026-07-01 14:49:48 +05:30
57b33bd061 feat(upload): add upload status column and rename analysis status 2026-07-01 12:38:53 +05:30
81df3a9973 refactor: remove all trace of status from ticket 2026-07-01 10:14:19 +05:30
09ce57c9e0 refactor(ticket): remove ticket status from list events and use assignment status for closure 2026-06-30 19:30:44 +05:30
ea684fc9fe refactor(upload): replace upload card views with list table and addition details in table 2026-06-30 18:03:50 +05:30
ec77696157 refactor: simplify ticket lifecycle flow, remove processing and completed state 2026-06-30 13:01:08 +05:30
adff2e2c41 refactor: standardize ticket detail card and tab styling 2026-06-29 16:47:22 +05:30
3e5dbd5893 fix: keep ticket event stream active across status changes 2026-06-26 19:01:15 +05:30
11f95c4727 fix: hide assign action while ticket is processing 2026-06-26 17:58:25 +05:30
e805cdee09 fix: refresh ticket detail data with the active defect class, select the emitted defect class after analysis completes, and animate the processing loader icon. 2026-06-26 16:31:50 +05:30
57461a05f2 refactor(results): support class-based detection summaries 2026-06-26 14:55:29 +05:30
8a080cb051 fix: ticket detail api fallback handle 2026-06-26 12:46:04 +05:30
063f963fd4 refactor(ticket): extract defect class status metadata 2026-06-26 10:38:38 +05:30
1f8ca87bd0 feat(ticket): support defect-class ticket workflows 2026-06-26 00:50:52 +05:30
8665762ba2 chore: change worker name with contractor 2026-06-24 15:42:22 +05:30
81a53b692d chore: remove dynamic title from result page and change favicon 2026-06-24 15:27:25 +05:30
82050372c6 feat(branding): add RoadMonitor logo and update auth/sidebar branding 2026-06-24 12:54:33 +05:30
2fc01f74e2 refactor(ticket): remove redundant View action from ticket table 2026-06-24 00:55:59 +05:30
67237982c8 feat(results): add video results skeleton and refresh detection summary UI 2026-06-24 00:36:27 +05:30
c1c81687d8 feat(ticket): add skeleton loading state for ticket detail page 2026-06-24 00:22:34 +05:30
81b68fdb61 feat(upload): preview uploaded videos with ProtectedVideoPlayer 2026-06-24 00:14:18 +05:30
5439a5ff45 fix(ui): add pointer cursors to repair evidence and multi-select controls 2026-06-23 23:51:54 +05:30
6e16d4f2c8 fix(auth): clear query cache when user logs out from nav menu 2026-06-23 23:19:09 +05:30
dfb95c5213 fix(ui): use pointer cursor on interactive controls and not-allowed when disabled 2026-06-23 23:14:32 +05:30
5e4cf61d6e refactor: remove static routes and stop multiple token api call in sse 2026-06-23 22:48:34 +05:30
b282fdf56d feat: add shared validation for chainage and coordinates 2026-06-23 18:32:38 +05:30
066374bebc feat(forms): add PhoneInput and unify module form validation UX 2026-06-23 16:28:28 +05:30
27cf5c9d3f feat(clients): align client form with first and last name API fields 2026-06-23 12:56:23 +05:30
a731fca5e0 refactor: video section ui modification 2026-06-23 10:29:09 +05:30
664d80809a refactor: modify route guard pattern 2026-06-19 18:36:05 +05:30
fffd8e3e12 chore: ticket section implement sse 2026-06-19 18:06:11 +05:30
a567a52e26 feat(ticket): refine repair report and history timeline UI 2026-06-19 11:22:41 +05:30
8c3d19a429 feat: ticket system base implementation 2026-06-19 01:19:01 +05:30
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
9695c80e20 refactor: Align form validation UX across management dialogs 2026-06-18 11:05:31 +05:30
c6894bfea0 feat(video): base of video refactoring 2026-06-18 02:23:25 +05:30
d7c4027328 fix(async-select): fix select loading bugs 2026-06-18 01:24:52 +05:30
463ee0835f refactor: add reusable select controls and enhance data table columns 2026-06-17 20:32:50 +05:30
b325975282 feat: add forgot password flow and reusable password field 2026-06-17 16:55:10 +05:30
4c2241ff72 chore: update eslint and prettier configuration 2026-06-17 16:23:28 +05:30
b54242cf7e style: do inputs design modification 2026-06-17 16:14:50 +05:30
15f54372f4 styles: refactor codebase base style 2026-06-17 14:46:12 +05:30
338 changed files with 21748 additions and 8693 deletions

View File

@@ -1,7 +1,7 @@
{ {
"mcpServers": { "mcpServers": {
"dual-graph": { "dual-graph": {
"url": "http://127.0.0.1:8080/mcp" "url": "http://127.0.0.1:8080/mcp"
} }
} }
} }

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
# Backend API base URL (no trailing slash)
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1

View File

@@ -1,7 +0,0 @@
.next
node_modules
dist
build
coverage
public
.env*

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,4 +1,2 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# . "$(dirname "$0")/_/husky.sh" # npm run lint
# npx lint-staged

View File

@@ -1,9 +1,12 @@
{ {
"semi": true,
"singleQuote": true, "singleQuote": true,
"trailingComma": "all", "trailingComma": "all",
"printWidth": 100, "printWidth": 80,
"useTabs": false,
"tabWidth": 2, "tabWidth": 2,
"semi": true, "useTabs": false,
"endOfLine": "auto" "endOfLine": "auto",
"arrowParens": "always",
"bracketSpacing": true,
"jsxSingleQuote": false
} }

94
CLAUDE.md Normal file
View File

@@ -0,0 +1,94 @@
<!-- dgc-policy-v11 -->
# Dual-Graph Context Policy
This project uses a local dual-graph MCP server for efficient context retrieval.
## MANDATORY: Adaptive graph_continue rule
**Call `graph_continue` ONLY when you do NOT already know the relevant files.**
### Call `graph_continue` when:
- This is the first message of a new task / conversation
- The task shifts to a completely different area of the codebase
- You need files you haven't read yet in this session
### SKIP `graph_continue` when:
- You already identified the relevant files earlier in this conversation
- You are doing follow-up work on files already read (verify, refactor, test, docs, cleanup, commit)
- The task is pure text (writing a commit message, summarising, explaining)
**If skipping, go directly to `graph_read` on the already-known `file::symbol`.**
## When you DO call graph_continue
1. **If `graph_continue` returns `needs_project=true`**: call `graph_scan` with `pwd`. Do NOT ask the user.
2. **If `graph_continue` returns `skip=true`**: fewer than 5 files - read only specifically named files.
3. **Read `recommended_files`** using `graph_read`.
- Always use `file::symbol` notation (e.g. `src/auth.ts::handleLogin`) - never read whole files.
- `recommended_files` entries that already contain `::` must be passed verbatim.
4. **Obey confidence caps:**
- `confidence=high` -> Stop. Do NOT grep or explore further.
- `confidence=medium` -> `fallback_rg` at most `max_supplementary_greps` times, then `graph_read` at most `max_supplementary_files` more symbols. Stop.
- `confidence=low` -> same as medium. Stop.
## Session State (compact, update after every turn)
Maintain a short JSON block in your working memory. Update it after each turn:
```json
{
"files_identified": ["path/to/file.py"],
"symbols_changed": ["module::function"],
"fix_applied": true,
"features_added": ["description"],
"open_issues": ["one-line note"]
}
```
Use this state - not prose summaries - to remember what's been done across turns.
## Token Usage
A `token-counter` MCP is available for tracking live token usage.
- Before reading a large file: `count_tokens({text: "<content>"})` to check cost first.
- To show running session cost: `get_session_stats()`
- To log completed task: `log_usage({input_tokens: N, output_tokens: N, description: "task"})`
## Rules
- Do NOT use `rg`, `grep`, or bash file exploration before calling `graph_continue` (when required).
- Do NOT do broad/recursive exploration at any confidence level.
- `max_supplementary_greps` and `max_supplementary_files` are hard caps - never exceed them.
- Do NOT call `graph_continue` more than once per turn.
- Always use `file::symbol` notation with `graph_read` - never bare filenames.
- After edits, call `graph_register_edit` with changed files using `file::symbol` notation.
## Context Store
Whenever you make a decision, identify a task, note a next step, fact, or blocker during a conversation, append it to `.dual-graph/context-store.json`.
**Entry format:**
```json
{"type": "decision|task|next|fact|blocker", "content": "one sentence max 15 words", "tags": ["topic"], "files": ["relevant/file.ts"], "date": "YYYY-MM-DD"}
```
**To append:** Read the file -> add the new entry to the array -> Write it back -> call `graph_register_edit` on `.dual-graph/context-store.json`.
**Rules:**
- Only log things worth remembering across sessions (not every minor detail)
- `content` must be under 15 words
- `files` lists the files this decision/task relates to (can be empty)
- Log immediately when the item arises - not at session end
## Session End
When the user signals they are done (e.g. "bye", "done", "wrap up", "end session"), proactively update `CONTEXT.md` in the project root with:
- **Current Task**: one sentence on what was being worked on
- **Key Decisions**: bullet list, max 3 items
- **Next Steps**: bullet list, max 3 items
Keep `CONTEXT.md` under 20 lines total. Do NOT summarize the full conversation - only what's needed to resume next session.

Binary file not shown.

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": {}
} }

View File

@@ -1,21 +1,8 @@
import nextConfig from 'eslint-config-next'; import nextConfig from 'eslint-config-next';
import prettierPlugin from 'eslint-plugin-prettier'; import prettierPlugin from 'eslint-plugin-prettier';
const prettierRules = {
'prettier/prettier': [
'error',
{
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
endOfLine: 'auto',
},
],
};
const additionalRules = { const additionalRules = {
...prettierRules, 'prettier/prettier': 'error',
// Some React rules are intentionally relaxed for this project.
'react-hooks/set-state-in-effect': 'off', 'react-hooks/set-state-in-effect': 'off',
'react-hooks/exhaustive-deps': 'warn', 'react-hooks/exhaustive-deps': 'warn',
'react-hooks/immutability': 'off', 'react-hooks/immutability': 'off',
@@ -24,7 +11,17 @@ const additionalRules = {
'import/no-anonymous-default-export': 'off', 'import/no-anonymous-default-export': 'off',
}; };
export default [ const eslintConfig = [
{
ignores: [
'.next/**',
'node_modules/**',
'dist/**',
'build/**',
'coverage/**',
'public/**',
],
},
...nextConfig, ...nextConfig,
{ {
files: ['**/*.{js,jsx,ts,tsx}'], files: ['**/*.{js,jsx,ts,tsx}'],
@@ -34,3 +31,5 @@ export default [
rules: additionalRules, rules: additionalRules,
}, },
]; ];
export default eslintConfig;

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.

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
reactStrictMode: false,
};
export default nextConfig;

3594
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,19 +6,11 @@
"prepare": "husky install", "prepare": "husky install",
"build": "next build", "build": "next build",
"dev": "next dev", "dev": "next dev",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx", "lint": "eslint .",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write .", "format": "prettier --write .",
"start": "next start" "start": "next start"
}, },
"lint-staged": {
"*.+(js|jsx|ts|tsx)": [
"npm run lint:fix"
],
"*.+(json|md|css|scss|html)": [
"npm run format"
]
},
"dependencies": { "dependencies": {
"@base-ui/react": "^1.5.0", "@base-ui/react": "^1.5.0",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
@@ -35,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"
}, },
@@ -63,11 +62,10 @@
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"baseline-browser-mapping": "^2.10.37", "baseline-browser-mapping": "^2.10.37",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-next": "^16.1.7", "eslint-config-next": "^16.0.10",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5", "eslint-plugin-prettier": "^5.5.5",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.4.0",
"postcss": "^8.5", "postcss": "^8.5",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"tailwindcss": "^4.1.9", "tailwindcss": "^4.1.9",

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

@@ -0,0 +1,98 @@
'use client';
import Link from 'next/link';
import { MailCheck, Send } from 'lucide-react';
import { Suspense } from 'react';
import { FormField } from '@/components/form';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { GuestGuard } from '@/guards';
import { useForgotPasswordForm } from '@/hooks/useForgotPasswordForm';
import { ROUTES } from '@/utils/routes';
import { Loader2 } from 'lucide-react';
function ForgotPasswordContent() {
const {
register,
handleSubmit,
errors,
isLoading,
isSubmitted,
isFormSubmitted,
canSubmit,
displayInfo,
} = useForgotPasswordForm();
return (
<GuestGuard>
<div className="flex min-h-screen w-full">
<div className="hidden w-[40%] md:flex" />
<div className="flex h-screen flex-1 items-center justify-center border-l border-border p-6 md:w-[60%]">
<section className="w-full max-w-sm space-y-7">
<header className="space-y-2 text-center md:text-left">
<p className="text-muted-foreground">RoadMonitor</p>
<h1>{displayInfo.title}</h1>
<p className="text-muted-foreground">{displayInfo.description}</p>
</header>
{!isSubmitted ? (
<form className="space-y-5" onSubmit={handleSubmit}>
<FormField
id="email"
label="Email"
required
error={isFormSubmitted ? errors.email?.message : undefined}
>
<Input
id="email"
type="email"
placeholder="name@example.com"
autoComplete="email"
disabled={isLoading}
aria-invalid={!!errors.email}
{...register('email')}
/>
</FormField>
<Button
type="submit"
className="w-full"
disabled={isLoading || !canSubmit}
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send />
)}
{displayInfo.buttonLabel}
</Button>
<div className="text-center">
<Link className="text-primary" href={ROUTES.LOGIN}>
Back to Login
</Link>
</div>
</form>
) : (
<Button asChild className="w-full">
<Link href={ROUTES.LOGIN}>
<MailCheck />
{displayInfo.buttonLabel}
</Link>
</Button>
)}
</section>
</div>
</div>
</GuestGuard>
);
}
export default function ForgotPasswordPage() {
return (
<Suspense fallback={null}>
<ForgotPasswordContent />
</Suspense>
);
}

View File

@@ -1,50 +1,31 @@
'use client'; 'use client';
import Link from 'next/link';
import { PasswordField } from '@/components/form';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { AuthBackground } from '@/components/auth/AuthBackground';
import { GuestGuard } from '@/guards'; import { GuestGuard } from '@/guards';
import { useLoginForm } from '@/hooks/useLoginForm'; import { useLoginForm } from '@/hooks/useLoginForm';
import { ROUTES } from '@/utils/routes';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import Image from 'next/image';
export default function LoginPage() { export default function LoginPage() {
const { register, handleSubmit, errors, isLoading } = useLoginForm(); const { register, handleSubmit, errors, isLoading } = useLoginForm();
return ( return (
<GuestGuard> <GuestGuard>
<main className="grid min-h-screen bg-background text-foreground lg:grid-cols-[1.05fr_0.95fr]"> <div className="flex min-h-screen w-full">
<section className="relative hidden overflow-hidden border-r border-border/60 lg:block"> <AuthBackground />
<Image <div className="flex flex-1 md:w-1/2 items-center justify-center p-6 border-l border-border h-screen">
src="/background.png" <section className="w-full max-w-sm space-y-7">
alt="" <header className="space-y-2 text-center md:text-left">
fill <p className="text-sm font-medium text-muted-foreground">
priority RoadMonitor
className="object-cover"
sizes="55vw"
/>
<div className="absolute inset-0 bg-background/35" />
<div className="absolute inset-x-0 bottom-0 p-10">
<div className="max-w-lg space-y-3">
<p className="text-sm font-medium uppercase tracking-[0.18em] text-primary">
VisionRoad
</p> </p>
<h1 className="text-4xl font-semibold tracking-normal text-foreground"> <h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
Road intelligence for faster inspection decisions
</h1>
<p className="text-sm leading-6 text-muted-foreground">
Review projects, uploads, detections, and chainage insights from one secure
workspace.
</p>
</div>
</div>
</section>
<section className="flex min-h-screen items-center justify-center px-6 py-10">
<div className="w-full max-w-sm space-y-7">
<header className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">VisionRoad</p>
<h2 className="text-2xl font-semibold tracking-normal">Sign in</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Use your email and password to continue. Use your email and password to continue.
</p> </p>
@@ -65,27 +46,30 @@ export default function LoginPage() {
})} })}
/> />
{errors.email && ( {errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p> <p className="text-sm text-destructive">
{errors.email.message}
</p>
)} )}
</div> </div>
<div className="space-y-2"> <PasswordField
<Label htmlFor="password">Password</Label> id="password"
<Input label="Password"
id="password" required
type="password" labelEnd={
placeholder="Enter password" <Link href={ROUTES.FORGOT_PASSWORD} className="text-primary">
autoComplete="current-password" Forgot password?
disabled={isLoading} </Link>
aria-invalid={!!errors.password} }
{...register('password', { placeholder="Enter password"
required: 'Password is required', autoComplete="current-password"
})} disabled={isLoading}
/> aria-invalid={!!errors.password}
{errors.password && ( error={errors.password?.message}
<p className="text-sm text-destructive">{errors.password.message}</p> {...register('password', {
)} required: 'Password is required',
</div> })}
/>
<Button type="submit" className="w-full" disabled={isLoading}> <Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? ( {isLoading ? (
@@ -98,9 +82,9 @@ export default function LoginPage() {
)} )}
</Button> </Button>
</form> </form>
</div> </section>
</section> </div>
</main> </div>
</GuestGuard> </GuestGuard>
); );
} }

View File

@@ -0,0 +1,92 @@
'use client';
import { Loader2 } from 'lucide-react';
import { Suspense } from 'react';
import { PasswordField } from '@/components/form';
import { Button } from '@/components/ui/button';
import { GuestGuard } from '@/guards';
import { useSetPasswordForm } from '@/hooks/useSetPasswordForm';
function ResetPasswordContent() {
const {
register,
handleSubmit,
errors,
isLoading,
isSubmitted,
canSubmit,
displayInfo,
hasToken,
} = useSetPasswordForm();
return (
<GuestGuard>
<div className="flex min-h-screen w-full">
<div className="hidden w-[40%] md:flex" />
<div className="flex h-screen flex-1 items-center justify-center border-l border-border p-6 md:w-[60%]">
<section className="w-full max-w-sm space-y-7">
<header className="space-y-2 text-center md:text-left">
<p className="text-muted-foreground">RoadMonitor</p>
<h1>{displayInfo.title}</h1>
<p className="text-muted-foreground">{displayInfo.description}</p>
</header>
{!hasToken ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-destructive">
Password reset token is missing.
</div>
) : (
<form className="space-y-5" onSubmit={handleSubmit}>
<PasswordField
id="password"
label="New Password"
required
error={isSubmitted ? errors.password?.message : undefined}
placeholder="Enter new password"
autoComplete="new-password"
disabled={isLoading}
aria-invalid={!!errors.password}
{...register('password')}
/>
<PasswordField
id="confirm-password"
label="Confirm Password"
required
error={
isSubmitted ? errors.confirmPassword?.message : undefined
}
placeholder="Confirm new password"
autoComplete="new-password"
disabled={isLoading}
aria-invalid={!!errors.confirmPassword}
{...register('confirmPassword')}
/>
<Button
type="submit"
className="w-full"
disabled={isLoading || !canSubmit}
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : null}
{displayInfo.buttonLabel}
</Button>
</form>
)}
</section>
</div>
</div>
</GuestGuard>
);
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={null}>
<ResetPasswordContent />
</Suspense>
);
}

View File

@@ -0,0 +1 @@
export { default } from '../reset-password/page';

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

@@ -11,8 +11,8 @@ export default function AccessPage() {
</div> </div>
<h1 className="text-2xl font-semibold">Access denied</h1> <h1 className="text-2xl font-semibold">Access denied</h1>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
You do not have permission to access any available module. Contact your administrator if You do not have permission to access any available module. Contact
you need access. your administrator if you need access.
</p> </p>
</div> </div>
</main> </main>

View File

@@ -2,12 +2,8 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Edit, RotateCcw } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { usePermissions } from '@/hooks/usePermissions';
import type { Client } from '@/types'; import type { Client } from '@/types';
function formatDate(value?: string | null) { function formatDate(value?: string | null) {
@@ -19,27 +15,17 @@ function formatDate(value?: string | null) {
}).format(new Date(value)); }).format(new Date(value));
} }
interface UseClientColumnsParams { export function useClientColumns(): ColumnDef<Client>[] {
onEdit: (client: Client) => void;
onToggleStatus: (client: Client) => void;
pendingClientId?: number;
}
export function useClientColumns({
onEdit,
onToggleStatus,
pendingClientId,
}: UseClientColumnsParams): ColumnDef<Client>[] {
const { hasPermission } = usePermissions();
const canEdit = hasPermission(PERMISSIONS.CLIENT.UPDATE);
const canDelete = hasPermission(PERMISSIONS.CLIENT.DELETE);
return useMemo(() => { return useMemo(() => {
const columns: ColumnDef<Client>[] = [ return [
{ {
accessorKey: 'name', accessorKey: 'first_name',
header: 'Client', header: 'Client',
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>, cell: ({ row }) => (
<span className="font-medium">
{`${row.original.first_name} ${row.original.last_name}`.trim()}
</span>
),
}, },
{ {
accessorKey: 'email', accessorKey: 'email',
@@ -51,7 +37,9 @@ export function useClientColumns({
cell: ({ row }) => ( cell: ({ row }) => (
<div> <div>
<p className="font-medium">{row.original.contact_name}</p> <p className="font-medium">{row.original.contact_name}</p>
<p className="text-xs text-muted-foreground">{row.original.contact_phone_number}</p> <p className="text-xs text-muted-foreground">
{row.original.contact_phone_number}
</p>
</div> </div>
), ),
}, },
@@ -75,38 +63,5 @@ export function useClientColumns({
), ),
}, },
]; ];
}, []);
if (!canEdit && !canDelete) return columns;
columns.push({
id: 'actions',
header: () => <div className="text-right">Actions</div>,
enableSorting: false,
cell: ({ row }) => {
const client = row.original;
return (
<div className="flex justify-end gap-2">
{canEdit ? (
<Button variant="outline" size="sm" onClick={() => onEdit(client)}>
<Edit className="size-4" />
</Button>
) : null}
{canDelete ? (
<Button
variant="outline"
size="sm"
disabled={pendingClientId === client.id}
onClick={() => onToggleStatus(client)}
>
<RotateCcw className="mr-2 size-4" />
{client.is_active ? 'Deactivate' : 'Activate'}
</Button>
) : null}
</div>
);
},
});
return columns;
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingClientId]);
} }

View File

@@ -1,12 +1,19 @@
'use client'; 'use client';
import type { ComponentProps } from 'react'; import { useRef, type ComponentProps } from 'react';
import type { 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, 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,
@@ -14,7 +21,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 { Label } from '@/components/ui/label';
import type { ClientFormValues } from '../hooks/useClientForm'; import type { ClientFormValues } from '../hooks/useClientForm';
interface ClientSheetProps { interface ClientSheetProps {
@@ -22,7 +28,11 @@ 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>;
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit']; onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean;
isSaving: boolean; isSaving: boolean;
} }
@@ -31,107 +41,209 @@ export function ClientSheet({
onOpenChange, onOpenChange,
clientId, clientId,
register, register,
control,
errors,
isSubmitted,
onSubmit, onSubmit,
canSubmit,
isSaving, isSaving,
}: ClientSheetProps) { }: ClientSheetProps) {
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
const firstNameErrorMessage = isSubmitted
? errors.first_name?.message
: undefined;
const lastNameErrorMessage = isSubmitted
? errors.last_name?.message
: undefined;
const emailErrorMessage = isSubmitted ? errors.email?.message : undefined;
const landlineErrorMessage = isSubmitted
? errors.landline_number?.message
: undefined;
const addressErrorMessage = isSubmitted ? errors.address?.message : undefined;
const contactNameErrorMessage = isSubmitted
? errors.contact_name?.message
: undefined;
const contactPhoneErrorMessage = isSubmitted
? errors.contact_phone_number?.message
: undefined;
const contactEmailErrorMessage = isSubmitted
? errors.contact_email?.message
: undefined;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl"> <DialogContent className="sm:max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{clientId ? 'Edit Client' : 'Create Client'}</DialogTitle> <DialogTitle>
<DialogDescription>Manage company and primary contact details.</DialogDescription> {clientId ? 'Edit Client' : 'Create Client'}
</DialogTitle>
<DialogDescription>
Manage company and primary contact details.
</DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="client-name">Name</Label>
<Input
id="client-name"
placeholder="Acme Corp"
{...register('name', { required: true })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="client-email">Email</Label>
<Input
id="client-email"
type="email"
placeholder="info@acme.com"
{...register('email', { required: true })}
required
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2"> <form
<div className="space-y-2"> noValidate
<Label htmlFor="landline-number">Landline Number</Label> onSubmit={onSubmit}
<Input className="flex min-h-0 flex-1 flex-col"
id="landline-number" >
placeholder="+91-22-12345678" <DialogBody className="space-y-5">
{...register('landline_number', { required: true })} <div className="grid gap-4 md:grid-cols-2">
<FormField
id="client-first-name"
label="First Name"
required required
/> error={firstNameErrorMessage}
>
<Input
id="client-first-name"
placeholder="Enter first name"
aria-invalid={!!firstNameErrorMessage}
{...register('first_name')}
/>
</FormField>
<FormField
id="client-last-name"
label="Last Name"
required
error={lastNameErrorMessage}
>
<Input
id="client-last-name"
placeholder="Enter last name"
aria-invalid={!!lastNameErrorMessage}
{...register('last_name')}
/>
</FormField>
</div> </div>
<div className="space-y-2">
<Label htmlFor="address">Address</Label> <div className="grid gap-4 md:grid-cols-2">
<FormField
id="client-email"
label="Company Email"
required
error={emailErrorMessage}
>
<Input
id="client-email"
type="email"
placeholder="Enter company email"
aria-invalid={!!emailErrorMessage}
{...register('email')}
/>
</FormField>
<FormField
id="landline-number"
label="Landline Number"
required
error={landlineErrorMessage}
>
<Input
id="landline-number"
placeholder="Enter landline number"
aria-invalid={!!landlineErrorMessage}
{...register('landline_number')}
/>
</FormField>
</div>
<FormField
id="address"
label="Address"
required
error={addressErrorMessage}
>
<Input <Input
id="address" id="address"
placeholder="12 MG Road, Mumbai, MH 400001" placeholder="Enter address"
{...register('address', { required: true })} aria-invalid={!!addressErrorMessage}
required {...register('address')}
/> />
</div> </FormField>
</div>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2"> <FormField id="gst" label="GST">
<Label htmlFor="gst">GST</Label> <Input
<Input id="gst" placeholder="27ABCDE1234F1Z5" {...register('gst')} /> id="gst"
</div> placeholder="Enter GST number"
<div className="space-y-2"> {...register('gst')}
<Label htmlFor="pan">PAN</Label> />
<Input id="pan" placeholder="ABCDE1234F" {...register('pan')} /> </FormField>
</div>
<div className="space-y-2">
<Label htmlFor="tan">TAN</Label>
<Input id="tan" placeholder="MUMA12345B" {...register('tan')} />
</div>
</div>
<div className="grid gap-4 md:grid-cols-3"> <FormField id="pan" label="PAN">
<div className="space-y-2"> <Input
<Label htmlFor="contact-name">Contact Name</Label> id="pan"
<Input placeholder="Enter PAN number"
{...register('pan')}
/>
</FormField>
<FormField id="tan" label="TAN">
<Input
id="tan"
placeholder="Enter TAN number"
{...register('tan')}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-3">
<FormField
id="contact-name" id="contact-name"
placeholder="Jane Doe" label="Contact Name"
{...register('contact_name', { required: true })}
required required
/> error={contactNameErrorMessage}
</div> >
<div className="space-y-2"> <Input
<Label htmlFor="contact-phone-number">Contact Phone</Label> id="contact-name"
<Input placeholder="Enter contact name"
id="contact-phone-number" aria-invalid={!!contactNameErrorMessage}
placeholder="+91-9876543210" {...register('contact_name')}
{...register('contact_phone_number', { required: true })} />
required </FormField>
/>
</div>
<div className="space-y-2">
<Label htmlFor="contact-email">Contact Email</Label>
<Input
id="contact-email"
type="email"
placeholder="jane.doe@acme.com"
{...register('contact_email', { required: true })}
required
/>
</div>
</div>
<DialogFooter className="px-0"> <FormField
id="contact-phone-number"
label="Contact Phone"
required
error={contactPhoneErrorMessage}
>
<Controller
control={control}
name="contact_phone_number"
render={({ field }) => (
<PhoneInput
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Enter contact phone"
aria-invalid={!!contactPhoneErrorMessage}
portalContainer={dropdownPortalRef}
/>
)}
/>
</FormField>
<FormField
id="contact-email"
label="Contact Email"
required
error={contactEmailErrorMessage}
>
<Input
id="contact-email"
type="email"
placeholder="Enter contact email"
aria-invalid={!!contactEmailErrorMessage}
{...register('contact_email')}
/>
</FormField>
</div>
</DialogBody>
<DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -140,12 +252,17 @@ export function ClientSheet({
> >
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null} <Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{clientId ? 'Update Client' : 'Create Client'} {clientId ? 'Update Client' : 'Create Client'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
<div ref={dropdownPortalRef} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -2,8 +2,10 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { ColumnDef, SortingState } from '@tanstack/react-table'; import type { ColumnDef, SortingState } from '@tanstack/react-table';
import { Edit, RotateCcw } from 'lucide-react';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { PERMISSIONS } from '@/constants/permissions';
import type { Client } from '@/types'; import type { Client } from '@/types';
interface ClientTableProps { interface ClientTableProps {
@@ -18,6 +20,9 @@ interface ClientTableProps {
onPageChange: (skip: number) => void; onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void; onLimitChange: (limit: number) => void;
onSortingChange: (sorting: SortingState) => void; onSortingChange: (sorting: SortingState) => void;
onEdit: (client: Client) => void;
onToggleStatus: (client: Client) => void;
pendingClientId?: number;
} }
export function ClientTable({ export function ClientTable({
@@ -32,6 +37,9 @@ export function ClientTable({
onPageChange, onPageChange,
onLimitChange, onLimitChange,
onSortingChange, onSortingChange,
onEdit,
onToggleStatus,
pendingClientId,
}: ClientTableProps) { }: ClientTableProps) {
return ( return (
<DataTable <DataTable
@@ -41,6 +49,21 @@ export function ClientTable({
isLoading={isLoading} isLoading={isLoading}
toolbar={toolbar} toolbar={toolbar}
emptyTitle="No clients found." emptyTitle="No clients found."
actions={[
{
label: 'Edit',
icon: <Edit className="size-4" />,
permission: PERMISSIONS.CLIENT.UPDATE,
onClick: onEdit,
},
{
label: (client) => (client.is_active ? 'Deactivate' : 'Activate'),
icon: <RotateCcw className="size-4" />,
permission: PERMISSIONS.CLIENT.DELETE,
disabled: (client) => pendingClientId === client.id,
onClick: onToggleStatus,
},
]}
pagination={{ pagination={{
skip, skip,
limit, limit,

View File

@@ -12,7 +12,8 @@ export function useClientFilters() {
const [limit, setLimitValue] = useState(10); const [limit, setLimitValue] = useState(10);
const [sorting, setSortingValue] = useState<SortingState>([]); const [sorting, setSortingValue] = useState<SortingState>([]);
const [searchTerm, setSearchTermValue] = useState(''); const [searchTerm, setSearchTermValue] = useState('');
const [statusFilter, setStatusFilterValue] = useState<ClientStatusFilter>('all'); const [statusFilter, setStatusFilterValue] =
useState<ClientStatusFilter>('all');
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400); const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
const setSearchTerm = useCallback((value: string) => { const setSearchTerm = useCallback((value: string) => {

View File

@@ -1,29 +1,43 @@
'use client'; 'use client';
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 type { Client } from '@/types'; import type { Client } from '@/types';
import { useSaveClientMutation } from './useClientMutations'; import { useSaveClientMutation } from './useClientMutations';
export interface ClientFormValues { const requiredText = (message: string) => z.string().trim().min(1, message);
id?: number; const optionalTaxIdSchema = z.string().trim();
name: string;
email: string; const clientFormSchema = z.object({
landline_number: string; id: z.number().optional(),
address: string; first_name: requiredText('First name is required'),
gst: string; last_name: requiredText('Last name is required'),
pan: string; email: requiredText('Email is required').email('Invalid email'),
tan: string; landline_number: requiredText('Landline number is required'),
contact_name: string; address: requiredText('Address is required'),
contact_phone_number: string; gst: optionalTaxIdSchema,
contact_email: string; pan: optionalTaxIdSchema,
is_active: boolean; tan: optionalTaxIdSchema,
} contact_name: requiredText('Contact name is required'),
contact_phone_number: requiredText('Contact phone number is required').refine(
(value) => isValidPhoneNumber(value),
'Enter a valid phone number',
),
contact_email: requiredText('Contact email is required').email(
'Invalid contact email',
),
is_active: z.boolean(),
});
export type ClientFormValues = z.infer<typeof clientFormSchema>;
const defaultValues: ClientFormValues = { const defaultValues: ClientFormValues = {
name: '', first_name: '',
last_name: '',
email: '', email: '',
landline_number: '', landline_number: '',
address: '', address: '',
@@ -42,9 +56,12 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
handleSubmit: submitForm, handleSubmit: submitForm,
reset, reset,
control, control,
formState: { isSubmitting, errors }, formState: { isSubmitting, errors, isSubmitted },
} = useForm<ClientFormValues>({ } = useForm<ClientFormValues>({
defaultValues, defaultValues,
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(clientFormSchema),
}); });
const saveMutation = useSaveClientMutation({ const saveMutation = useSaveClientMutation({
onSaved: () => { onSaved: () => {
@@ -54,23 +71,26 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
}); });
const clientId = useWatch({ control, name: 'id' }); const clientId = useWatch({ control, name: 'id' });
const firstName = useWatch({ control, name: 'first_name' }) || '';
const lastName = useWatch({ control, name: 'last_name' }) || '';
const email = useWatch({ control, name: 'email' }) || '';
const landlineNumber = useWatch({ control, name: 'landline_number' }) || '';
const address = useWatch({ control, name: 'address' }) || '';
const contactName = useWatch({ control, name: 'contact_name' }) || '';
const contactPhoneNumber =
useWatch({ control, name: 'contact_phone_number' }) || '';
const contactEmail = useWatch({ control, name: 'contact_email' }) || '';
const canSubmit =
firstName.trim().length > 0 &&
lastName.trim().length > 0 &&
email.trim().length > 0 &&
landlineNumber.trim().length > 0 &&
address.trim().length > 0 &&
contactName.trim().length > 0 &&
contactPhoneNumber.trim().length > 0 &&
contactEmail.trim().length > 0;
const handleSubmit = submitForm( const handleSubmit = submitForm((values) => saveMutation.mutate(values));
(values) => saveMutation.mutate(values),
(formErrors) => {
if (
formErrors.name ||
formErrors.email ||
formErrors.landline_number ||
formErrors.address ||
formErrors.contact_name ||
formErrors.contact_phone_number ||
formErrors.contact_email
) {
toast.error('Complete all required client fields');
}
},
);
const openCreate = useCallback(() => { const openCreate = useCallback(() => {
reset(defaultValues); reset(defaultValues);
@@ -80,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 || '',
@@ -98,12 +119,15 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
return { return {
register, register,
control,
handleSubmit, handleSubmit,
reset, reset,
openCreate, openCreate,
openEdit, openEdit,
clientId, clientId,
errors, errors,
isSubmitted,
canSubmit,
isSaving: isSubmitting || saveMutation.isPending, isSaving: isSubmitting || saveMutation.isPending,
}; };
} }

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(),
@@ -38,7 +39,9 @@ export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: clientKeys.all }); queryClient.invalidateQueries({ queryKey: clientKeys.all });
}, },
onError: (_error, values) => { onError: (_error, values) => {
toast.error(values.id ? 'Failed to update client' : 'Failed to create client'); toast.error(
values.id ? 'Failed to update client' : 'Failed to create client',
);
}, },
}); });
} }
@@ -46,7 +49,8 @@ export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
export function useClientStatusMutation() { export function useClientStatusMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (client: Client) => clientService.updateClientStatus(client.id, !client.is_active), mutationFn: (client: Client) =>
clientService.updateClientStatus(client.id, !client.is_active),
onSuccess: () => { onSuccess: () => {
toast.success('Client status updated'); toast.success('Client status updated');
queryClient.invalidateQueries({ queryKey: clientKeys.all }); queryClient.invalidateQueries({ queryKey: clientKeys.all });

View File

@@ -37,7 +37,8 @@ function buildClientListParams({
export function useClientsQuery(params: UseClientsQueryParams) { export function useClientsQuery(params: UseClientsQueryParams) {
const { skip, limit, searchTerm, statusFilter, sorting } = params; const { skip, limit, searchTerm, statusFilter, sorting } = params;
const listParams = useMemo( const listParams = useMemo(
() => buildClientListParams({ skip, limit, searchTerm, statusFilter, sorting }), () =>
buildClientListParams({ skip, limit, searchTerm, statusFilter, sorting }),
[limit, searchTerm, skip, sorting, statusFilter], [limit, searchTerm, skip, sorting, statusFilter],
); );

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

@@ -4,7 +4,6 @@ import { useCallback, useMemo, useState } from 'react';
import { Building2, Plus } from 'lucide-react'; import { Building2, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards'; import { PermissionGuard } from '@/guards';
@@ -45,8 +44,18 @@ export default function ClientsPage() {
const clientForm = useClientForm({ const clientForm = useClientForm({
onSaved: () => setIsSheetOpen(false), onSaved: () => setIsSheetOpen(false),
}); });
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } = clientForm; const { openCreate: prepareCreateClient, openEdit: prepareEditClient } =
const { register, handleSubmit, clientId, isSaving } = clientForm; clientForm;
const {
register,
control,
handleSubmit,
clientId,
errors,
isSubmitted,
canSubmit,
isSaving,
} = clientForm;
const statusMutation = useClientStatusMutation(); const statusMutation = useClientStatusMutation();
const { mutate: updateClientStatus, pendingClientId } = statusMutation; const { mutate: updateClientStatus, pendingClientId } = statusMutation;
@@ -70,11 +79,7 @@ export default function ClientsPage() {
[updateClientStatus], [updateClientStatus],
); );
const columns = useClientColumns({ const columns = useClientColumns();
onEdit: openEdit,
onToggleStatus: toggleStatus,
pendingClientId,
});
const total = clientsQuery.data?.total ?? 0; const total = clientsQuery.data?.total ?? 0;
const clients = clientsQuery.data?.items ?? []; const clients = clientsQuery.data?.items ?? [];
@@ -119,9 +124,10 @@ export default function ClientsPage() {
onPageChange={setSkip} onPageChange={setSkip}
onLimitChange={setLimit} onLimitChange={setLimit}
onSortingChange={setSorting} onSortingChange={setSorting}
onEdit={openEdit}
onToggleStatus={toggleStatus}
pendingClientId={pendingClientId}
/> />
<PoweredBy />
</main> </main>
<ClientSheet <ClientSheet
@@ -129,7 +135,11 @@ export default function ClientsPage() {
onOpenChange={setIsSheetOpen} onOpenChange={setIsSheetOpen}
clientId={clientId} clientId={clientId}
register={register} register={register}
control={control}
errors={errors}
isSubmitted={isSubmitted}
onSubmit={handleSubmit} onSubmit={handleSubmit}
canSubmit={canSubmit}
isSaving={isSaving} isSaving={isSaving}
/> />
</> </>

View File

@@ -1,645 +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 { PoweredBy } from '@/components/powered-by';
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>
</>
)}
<PoweredBy />
</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,
@@ -13,19 +11,13 @@ const ModulesLayout = ({
}>) => { }>) => {
return ( return (
<AuthGuard> <AuthGuard>
<SidebarProvider className="bg-transparent overflow-hidden"> <SidebarProvider>
<AppSidebar className="bg-transparent" /> <AppSidebar />
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden bg-transparent"> <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">
{/* Centering container wrapper */} <div className="mx-auto flex min-h-full w-full max-w-380 flex-col">
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full"> <ModuleShellHeader />
<div className="flex gap-3 items-center sticky top-0 bg-background/30 backdrop-blur-xl z-30 py-3 px-6 border-b border-border/10"> <div className="flex-1 p-6">{children}</div>
<SidebarTrigger />
<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

@@ -4,7 +4,11 @@ import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Package, Project } from '@/types'; import type { Package, Project } from '@/types';
function EmptyValue() { function EmptyValue() {
@@ -35,16 +39,14 @@ function StateBadges({ value }: { value: string | null }) {
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-[10px] font-bold text-muted-foreground transition-colors hover:bg-muted" className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-muted-foreground transition-colors hover:bg-muted"
> >
+{remainingStates.length} +{remainingStates.length}
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start"> <PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground"> <p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
Other States
</p>
{remainingStates.map((state) => ( {remainingStates.map((state) => (
<Badge key={state} variant="secondary"> <Badge key={state} variant="secondary">
{state} {state}
@@ -64,20 +66,22 @@ export function usePackageColumns(projects: Project[]) {
{ {
accessorKey: 'name', accessorKey: 'name',
header: 'Package Name', header: 'Package Name',
cell: ({ row }) => <div className="font-semibold">{row.original.name}</div>, cell: ({ row }) => <div>{row.original.name}</div>,
}, },
{ {
accessorKey: 'project_id', accessorKey: 'project_id',
header: 'Project', header: 'Project',
cell: ({ row }) => cell: ({ row }) =>
projects.find((project) => project.id === row.original.project_id)?.name || projects.find((project) => project.id === row.original.project_id)
row.original.project_id, ?.name || row.original.project_id,
}, },
{ {
id: 'project_state', id: 'project_state',
header: 'Project State', header: 'Project State',
cell: ({ row }) => { cell: ({ row }) => {
const project = projects.find((item) => item.id === row.original.project_id); const project = projects.find(
(item) => item.id === row.original.project_id,
);
return <StateBadges value={project?.state ?? null} />; return <StateBadges value={project?.state ?? null} />;
}, },
}, },

View File

@@ -1,13 +1,16 @@
'use client'; 'use client';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form'; import { useRef } from 'react';
import { Globe, Loader2, Package as PackageIcon } from 'lucide-react'; import type { FieldErrors, UseFormRegister } from 'react-hook-form';
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,13 +25,11 @@ 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>;
errors: FieldErrors<PackageFormValues>; errors: FieldErrors<PackageFormValues>;
touchedFields: UseFormReturn<PackageFormValues>['formState']['touchedFields']; isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit']; onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean; canSubmit: boolean;
isSaving: boolean; isSaving: boolean;
@@ -46,124 +39,162 @@ export function PackageDialog({
open, open,
onOpenChange, onOpenChange,
packageId, packageId,
projects,
isProjectsLoading,
projectId, projectId,
onProjectChange, onProjectChange,
register, register,
errors, errors,
touchedFields, isSubmitted,
onSubmit, onSubmit,
canSubmit, canSubmit,
isSaving, isSaving,
}: PackageDialogProps) { }: PackageDialogProps) {
const projectErrorMessage = touchedFields.project_id ? errors.project_id?.message : undefined; const projectSelectPortalRef = useRef<HTMLDivElement | null>(null);
const nameErrorMessage = touchedFields.name ? errors.name?.message : undefined; const projectErrorMessage = isSubmitted
const startErrorMessage = touchedFields.chainage_start_km ? errors.project_id?.message
: undefined;
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const startErrorMessage = isSubmitted
? errors.chainage_start_km?.message ? errors.chainage_start_km?.message
: undefined; : undefined;
const endErrorMessage = touchedFields.chainage_end_km const endErrorMessage = isSubmitted
? errors.chainage_end_km?.message ? errors.chainage_end_km?.message
: undefined; : undefined;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl" onOpenAutoFocus={(event) => event.preventDefault()}> <DialogContent
<DialogHeader className="gap-2"> className="sm:max-w-2xl"
<DialogTitle className="flex items-center gap-3 text-xl"> onOpenAutoFocus={(event) => event.preventDefault()}
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm"> >
<PackageIcon className="size-5" /> <DialogHeader>
</div> <DialogTitle>
{packageId ? 'Edit Package Details' : 'Create New Package'} {packageId ? 'Edit Package' : 'Create Package'}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm"> <DialogDescription>
{packageId Manage package details and optional chainage range.
? 'Update the technical specifications for your road infrastructure package.'
: 'Select a project and provide the essential data to establish a new package.'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={onSubmit} className="space-y-6"> <form
{!packageId ? ( noValidate
<FormField label="Project" required error={projectErrorMessage}> onSubmit={onSubmit}
<Select value={projectId} onValueChange={onProjectChange}> className="flex min-h-0 flex-1 flex-col"
<SelectTrigger aria-invalid={!!projectErrorMessage}> >
{isProjectsLoading ? ( <DialogBody className="space-y-6">
<span className="flex items-center gap-2 text-sm text-muted-foreground"> <section className="space-y-4">
<Loader2 className="size-4 animate-spin" /> <div className="space-y-1">
Loading... <h3 className="text-sm font-semibold">Basic details</h3>
</span> <p className="text-sm text-muted-foreground">
) : ( Project binding, package name, and region.
<SelectValue placeholder="Choose a project" /> </p>
)} </div>
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" {...register('project_id')} value={projectId} readOnly />
</FormField>
) : null}
<div className="grid gap-5 md:grid-cols-2"> {!packageId ? (
<FormField id="package-name" label="Package Name" required error={nameErrorMessage}> <FormField label="Project" required error={projectErrorMessage}>
<Input <ProjectSelect
id="package-name" value={projectId}
placeholder="e.g. Package 01" onValueChange={onProjectChange}
aria-invalid={!!nameErrorMessage} enabled={open}
{...register('name')} disabled={isSaving}
/> portalContainer={projectSelectPortalRef}
</FormField> />
<input
type="hidden"
{...register('project_id')}
value={projectId}
readOnly
/>
</FormField>
) : null}
<FormField <div className="grid gap-4 md:grid-cols-2">
id="package-region" <FormField
label={ id="package-name"
<span className="inline-flex items-center gap-2"> label="Package Name"
<Globe className="size-3.5 opacity-60" /> required
Region error={nameErrorMessage}
</span> >
} <Input
> id="package-name"
<Input id="package-region" placeholder="e.g. North Zone" {...register('region')} /> placeholder="Enter package name"
</FormField> aria-invalid={!!nameErrorMessage}
{...register('name')}
/>
</FormField>
<FormField id="package-start" label="Segment Start (km)" error={startErrorMessage}> <FormField id="package-region" label="Region">
<Input <Input
id="package-start" id="package-region"
type="number" placeholder="Enter region"
step="0.01" {...register('region')}
placeholder="0.00" />
aria-invalid={!!startErrorMessage} </FormField>
{...register('chainage_start_km')} </div>
/> </section>
</FormField>
<FormField id="package-end" label="Segment End (km)" error={endErrorMessage}> <section className="space-y-4">
<Input <div className="space-y-1">
id="package-end" <h3 className="text-sm font-semibold">Chainage range</h3>
type="number" <p className="text-sm text-muted-foreground">
step="0.01" start and end kilometre values.
placeholder="0.00" </p>
aria-invalid={!!endErrorMessage} </div>
{...register('chainage_end_km')}
/> <div className="grid gap-4 md:grid-cols-2">
</FormField> <FormField
</div> id="package-start"
label="Start Chainage"
error={startErrorMessage}
>
<Input
id="package-start"
type="number"
step="1"
min="0"
placeholder="Enter start chainage"
aria-invalid={!!startErrorMessage}
{...register('chainage_start_km')}
/>
</FormField>
<FormField
id="package-end"
label="End Chainage"
error={endErrorMessage}
>
<Input
id="package-end"
type="number"
step="1"
min="0"
placeholder="Enter end chainage"
aria-invalid={!!endErrorMessage}
{...register('chainage_end_km')}
/>
</FormField>
</div>
</section>
</DialogBody>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}> <Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSaving || !canSubmit}> <Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="size-4 animate-spin" /> : null} {isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{packageId ? 'Update Package' : 'Create Package'} {packageId ? 'Update Package' : 'Create Package'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
<div ref={projectSelectPortalRef} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Edit3, Trash2 } from 'lucide-react';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import type { Package } from '@/types'; import type { Package } from '@/types';
@@ -35,8 +36,19 @@ export function PackageTable({
title="Packages" title="Packages"
data={packages} data={packages}
columns={columns} columns={columns}
onEdit={onEdit} actions={[
onDelete={onDelete} {
label: 'Edit',
icon: <Edit3 className="size-4" />,
onClick: onEdit,
},
{
label: 'Delete',
icon: <Trash2 className="size-4" />,
className: 'text-destructive',
onClick: onDelete,
},
]}
isLoading={isLoading} isLoading={isLoading}
emptyTitle="No packages found." emptyTitle="No packages found."
pagination={{ pagination={{
@@ -46,6 +58,7 @@ export function PackageTable({
onPageChange, onPageChange,
onLimitChange, onLimitChange,
}} }}
actionsSticky
/> />
); );
} }

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>;
@@ -49,7 +45,9 @@ function toNumberOrZero(value: string) {
return value.trim() ? Number(value) : 0; return value.trim() ? Number(value) : 0;
} }
function toPackagePayload(values: PackageFormValues): PackageCreate | PackageUpdate { function toPackagePayload(
values: PackageFormValues,
): PackageCreate | PackageUpdate {
return { return {
project_id: values.project_id, project_id: values.project_id,
name: values.name.trim(), name: values.name.trim(),
@@ -67,10 +65,10 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
reset, reset,
setValue, setValue,
control, control,
formState: { errors, isSubmitting, touchedFields }, formState: { errors, isSubmitting, isSubmitted },
} = useForm<PackageFormValues>({ } = useForm<PackageFormValues>({
defaultValues, defaultValues,
mode: 'onTouched', mode: 'onSubmit',
reValidateMode: 'onChange', reValidateMode: 'onChange',
resolver: zodResolver(packageFormSchema), resolver: zodResolver(packageFormSchema),
}); });
@@ -78,7 +76,8 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
const packageId = useWatch({ control, name: 'id' }); const packageId = useWatch({ control, name: 'id' });
const projectId = useWatch({ control, name: 'project_id' }) || ''; const projectId = useWatch({ control, name: 'project_id' }) || '';
const packageName = useWatch({ control, name: 'name' }) || ''; const packageName = useWatch({ control, name: 'name' }) || '';
const canSubmit = projectId.trim().length > 0 && packageName.trim().length > 0; const canSubmit =
projectId.trim().length > 0 && packageName.trim().length > 0;
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: (values: PackageFormValues) => { mutationFn: (values: PackageFormValues) => {
@@ -94,22 +93,13 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: packageKeys.all }); queryClient.invalidateQueries({ queryKey: packageKeys.all });
}, },
onError: (_error, values) => { onError: (_error, values) => {
toast.error(values.id ? 'Failed to update package' : 'Failed to create package'); toast.error(
values.id ? 'Failed to update package' : 'Failed to create package',
);
}, },
}); });
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);
@@ -130,7 +120,11 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
); );
const setProjectId = useCallback( const setProjectId = useCallback(
(value: string) => setValue('project_id', value, { shouldDirty: true, shouldValidate: true }), (value: string) =>
setValue('project_id', value, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue], [setValue],
); );
@@ -144,7 +138,7 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
projectId, projectId,
setProjectId, setProjectId,
errors, errors,
touchedFields, isSubmitted,
canSubmit, canSubmit,
isSaving: isSubmitting || saveMutation.isPending, isSaving: isSubmitting || saveMutation.isPending,
}; };

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 {
@@ -15,7 +16,10 @@ interface UsePackagesQueryParams {
} }
export function usePackagesQuery({ skip, limit }: UsePackagesQueryParams) { export function usePackagesQuery({ skip, limit }: UsePackagesQueryParams) {
const listParams = useMemo<PaginationParams>(() => ({ skip, limit }), [limit, skip]); const listParams = useMemo<PaginationParams>(
() => ({ skip, limit }),
[limit, skip],
);
return useQuery({ return useQuery({
queryKey: packageKeys.list(listParams), queryKey: packageKeys.list(listParams),
@@ -25,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,7 +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 { PoweredBy } from '@/components/powered-by'; 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';
@@ -21,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();
@@ -38,7 +48,7 @@ export default function PackagePage() {
projectId, projectId,
setProjectId, setProjectId,
errors, errors,
touchedFields, isSubmitted,
canSubmit, canSubmit,
isSaving, isSaving,
} = packageForm; } = packageForm;
@@ -68,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;
@@ -108,25 +129,51 @@ export default function PackagePage() {
onEdit={openEdit} onEdit={openEdit}
onDelete={deletePackage} onDelete={deletePackage}
/> />
<PoweredBy />
</main> </main>
<PackageDialog <PackageDialog
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}
errors={errors} errors={errors}
touchedFields={touchedFields} isSubmitted={isSubmitted}
onSubmit={onSubmit} onSubmit={onSubmit}
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

@@ -2,12 +2,8 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Edit, RotateCcw } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { usePermissions } from '@/hooks/usePermissions';
import type { Plan } from '@/types'; import type { Plan } from '@/types';
function formatDate(value?: string | null) { function formatDate(value?: string | null) {
@@ -25,37 +21,24 @@ function formatPrice(value: string, cycle: string) {
return `${price} / ${cycle}`; return `${price} / ${cycle}`;
} }
interface UsePlanColumnsParams { export function usePlanColumns(): ColumnDef<Plan>[] {
onEdit: (plan: Plan) => void;
onToggleStatus: (plan: Plan) => void;
pendingPlanId?: number;
}
export function usePlanColumns({
onEdit,
onToggleStatus,
pendingPlanId,
}: UsePlanColumnsParams): ColumnDef<Plan>[] {
const { hasPermission } = usePermissions();
const canEdit = hasPermission(PERMISSIONS.PLAN.UPDATE);
const canDelete = hasPermission(PERMISSIONS.PLAN.DELETE);
return useMemo(() => { return useMemo(() => {
const columns: ColumnDef<Plan>[] = [ return [
{ {
accessorKey: 'name', accessorKey: 'name',
header: 'Plan', header: 'Plan',
cell: ({ row }) => ( cell: ({ row }) => (
<div> <div>
<p className="font-medium">{row.original.name}</p> <p>{row.original.name}</p>
<p className="text-xs text-muted-foreground">{row.original.slug}</p> <p className="text-muted-foreground">{row.original.slug}</p>
</div> </div>
), ),
}, },
{ {
accessorKey: 'price', accessorKey: 'price',
header: 'Price', header: 'Price',
cell: ({ row }) => formatPrice(row.original.price, row.original.billing_cycle), cell: ({ row }) =>
formatPrice(row.original.price, row.original.billing_cycle),
}, },
{ {
accessorKey: 'trial_days', accessorKey: 'trial_days',
@@ -67,7 +50,7 @@ export function usePlanColumns({
header: 'Limits', header: 'Limits',
enableSorting: false, enableSorting: false,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="text-xs text-muted-foreground"> <div className="text-muted-foreground">
<p>{row.original.max_projects} projects</p> <p>{row.original.max_projects} projects</p>
<p>{row.original.max_users} users</p> <p>{row.original.max_users} users</p>
</div> </div>
@@ -86,43 +69,12 @@ export function usePlanColumns({
<Badge variant={row.original.is_active ? 'default' : 'secondary'}> <Badge variant={row.original.is_active ? 'default' : 'secondary'}>
{row.original.is_active ? 'Active' : 'Inactive'} {row.original.is_active ? 'Active' : 'Inactive'}
</Badge> </Badge>
{row.original.is_custom ? <Badge variant="outline">Custom</Badge> : null} {row.original.is_custom ? (
<Badge variant="outline">Custom</Badge>
) : null}
</div> </div>
), ),
}, },
]; ];
}, []);
if (!canEdit && !canDelete) return columns;
columns.push({
id: 'actions',
header: () => <div className="text-right">Actions</div>,
enableSorting: false,
cell: ({ row }) => {
const plan = row.original;
return (
<div className="flex justify-end gap-2">
{canEdit ? (
<Button variant="outline" size="sm" onClick={() => onEdit(plan)}>
<Edit className="size-4" />
</Button>
) : null}
{canDelete ? (
<Button
variant="outline"
size="sm"
disabled={pendingPlanId === plan.id}
onClick={() => onToggleStatus(plan)}
>
<RotateCcw className="mr-2 size-4" />
{plan.is_active ? 'Deactivate' : 'Activate'}
</Button>
) : null}
</div>
);
},
});
return columns;
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingPlanId]);
} }

View File

@@ -31,7 +31,10 @@ export function PlanFilters({
placeholder="Search plans" placeholder="Search plans"
className="w-full sm:w-72" className="w-full sm:w-72"
/> />
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as PlanStatusFilter)}> <Select
value={statusFilter}
onValueChange={(value) => onStatusChange(value as PlanStatusFilter)}
>
<SelectTrigger className="w-full sm:w-40"> <SelectTrigger className="w-full sm:w-40">
<SelectValue placeholder="Status" /> <SelectValue placeholder="Status" />
</SelectTrigger> </SelectTrigger>

View File

@@ -1,14 +1,15 @@
'use client'; 'use client';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import type { Control, UseFormRegister } from 'react-hook-form'; import type { Control, FieldErrors, UseFormRegister } from 'react-hook-form';
import { Controller } from 'react-hook-form'; import { Controller } from 'react-hook-form';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
import { PermissionTree } from '@/components/permission-tree'; import { PermissionTree } from '@/components/permission-tree';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -34,7 +35,10 @@ interface PlanSheetProps {
planId?: number; planId?: number;
register: UseFormRegister<PlanFormValues>; register: UseFormRegister<PlanFormValues>;
control: Control<PlanFormValues>; control: Control<PlanFormValues>;
errors: FieldErrors<PlanFormValues>;
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit']; onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean;
permissionTree: PermissionTreeItem[]; permissionTree: PermissionTreeItem[];
permissionIds: number[]; permissionIds: number[];
onPermissionIdsChange: (ids: number[]) => void; onPermissionIdsChange: (ids: number[]) => void;
@@ -48,175 +52,241 @@ export function PlanSheet({
planId, planId,
register, register,
control, control,
errors,
isSubmitted,
onSubmit, onSubmit,
canSubmit,
permissionTree, permissionTree,
permissionIds, permissionIds,
onPermissionIdsChange, onPermissionIdsChange,
isPermissionsLoading, isPermissionsLoading,
isSaving, isSaving,
}: PlanSheetProps) { }: PlanSheetProps) {
const fieldError = (field: keyof PlanFormValues) =>
isSubmitted ? errors[field]?.message : undefined;
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full overflow-y-auto sm:max-w-3xl"> <SheetContent className="w-full! gap-0 p-0 sm:max-w-3xl! lg:max-w-4xl!">
<SheetHeader> <SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
<SheetTitle>{planId ? 'Edit Plan' : 'Create Plan'}</SheetTitle> <SheetTitle className="text-lg leading-none font-semibold tracking-tight">
<SheetDescription> {planId ? 'Edit Plan' : 'Create Plan'}
</SheetTitle>
<SheetDescription className="text-sm">
Configure subscription limits, billing, and permission access. Configure subscription limits, billing, and permission access.
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="grid gap-4 md:grid-cols-2"> <form
<div className="space-y-2"> noValidate
<Label htmlFor="plan-name">Name</Label> onSubmit={onSubmit}
<Input className="flex min-h-0 flex-1 flex-col"
>
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="plan-name" id="plan-name"
placeholder="Starter" label="Name"
{...register('name', { required: true })}
required required
/> error={fieldError('name')}
</div> >
<div className="space-y-2"> <Input
<Label htmlFor="plan-slug">Slug</Label> id="plan-name"
<Input placeholder="Enter plan name"
aria-invalid={!!fieldError('name')}
{...register('name')}
/>
</FormField>
<FormField
id="plan-slug" id="plan-slug"
placeholder="starter" label="Slug"
{...register('slug', { required: true })}
required required
/> error={fieldError('slug')}
>
<Input
id="plan-slug"
placeholder="starter"
aria-invalid={!!fieldError('slug')}
{...register('slug')}
/>
</FormField>
</div> </div>
</div>
<div className="space-y-2"> <FormField
<Label htmlFor="plan-description">Description</Label>
<textarea
id="plan-description" id="plan-description"
placeholder="Basic plan for small teams" label="Description"
{...register('description', { required: true })}
required required
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" error={fieldError('description')}
/> >
</div> <Textarea
id="plan-description"
placeholder="Basic plan for small teams"
aria-invalid={!!fieldError('description')}
{...register('description')}
className="min-h-20"
/>
</FormField>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2"> <FormField
<Label htmlFor="plan-price">Price</Label>
<Input
id="plan-price" id="plan-price"
type="number" label="Price"
min="0"
step="0.01"
placeholder="29.99"
{...register('price', { required: true })}
required required
/> error={fieldError('price')}
</div> >
<div className="space-y-2"> <Input
<Label>Billing Cycle</Label> id="plan-price"
<Controller type="number"
control={control} min="0"
name="billing_cycle" step="0.01"
render={({ field }) => ( placeholder="29.99"
<Select value={field.value} onValueChange={field.onChange}> aria-invalid={!!fieldError('price')}
<SelectTrigger> {...register('price')}
<SelectValue placeholder="Billing cycle" /> />
</SelectTrigger> </FormField>
<SelectContent>
<SelectItem value="monthly">Monthly</SelectItem> <FormField
<SelectItem value="quarterly">Quarterly</SelectItem> label="Billing Cycle"
<SelectItem value="yearly">Yearly</SelectItem> required
</SelectContent> error={fieldError('billing_cycle')}
</Select> >
)} <Controller
/> control={control}
</div> name="billing_cycle"
<div className="space-y-2"> render={({ field }) => (
<Label htmlFor="plan-trial-days">Trial Days</Label> <Select value={field.value} onValueChange={field.onChange}>
<Input <SelectTrigger>
<SelectValue placeholder="Billing cycle" />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="quarterly">Quarterly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
</SelectContent>
</Select>
)}
/>
</FormField>
<FormField
id="plan-trial-days" id="plan-trial-days"
type="number" label="Trial Days"
min="0" error={fieldError('trial_days')}
{...register('trial_days', { valueAsNumber: true })} >
/> <Input
id="plan-trial-days"
type="number"
min="0"
aria-invalid={!!fieldError('trial_days')}
{...register('trial_days', { valueAsNumber: true })}
/>
</FormField>
</div> </div>
</div>
<div className="grid gap-4 md:grid-cols-4"> <div className="grid gap-4 md:grid-cols-4">
<div className="space-y-2"> <FormField
<Label htmlFor="max-projects">Projects</Label>
<Input
id="max-projects" id="max-projects"
type="number" label="Projects"
min="0" error={fieldError('max_projects')}
{...register('max_projects', { valueAsNumber: true })} >
/> <Input
</div> id="max-projects"
<div className="space-y-2"> type="number"
<Label htmlFor="max-organizations">Organizations</Label> min="0"
<Input aria-invalid={!!fieldError('max_projects')}
{...register('max_projects', { valueAsNumber: true })}
/>
</FormField>
<FormField
id="max-organizations" id="max-organizations"
type="number" label="Organizations"
min="0" error={fieldError('max_organizations')}
{...register('max_organizations', { valueAsNumber: true })} >
/> <Input
</div> id="max-organizations"
<div className="space-y-2"> type="number"
<Label htmlFor="max-users">Users</Label> min="0"
<Input aria-invalid={!!fieldError('max_organizations')}
{...register('max_organizations', { valueAsNumber: true })}
/>
</FormField>
<FormField
id="max-users" id="max-users"
type="number" label="Users"
min="0" error={fieldError('max_users')}
{...register('max_users', { valueAsNumber: true })} >
/> <Input
</div> id="max-users"
<div className="space-y-2"> type="number"
<Label htmlFor="max-roles">Roles</Label> min="0"
<Input aria-invalid={!!fieldError('max_users')}
{...register('max_users', { valueAsNumber: true })}
/>
</FormField>
<FormField
id="max-roles" id="max-roles"
type="number" label="Roles"
min="0" error={fieldError('max_roles')}
{...register('max_roles', { valueAsNumber: true })} >
/> <Input
id="max-roles"
type="number"
min="0"
aria-invalid={!!fieldError('max_roles')}
{...register('max_roles', { valueAsNumber: true })}
/>
</FormField>
</div> </div>
</div>
<div className="flex flex-wrap gap-5"> <div className="flex flex-wrap gap-5">
<label className="flex items-center gap-2 text-sm"> <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
</label> </label>
<label className="flex items-center gap-2 text-sm"> <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
</label> </label>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<Label>Permissions</Label>
<span className="text-xs text-muted-foreground">{permissionIds.length} selected</span>
</div> </div>
{isPermissionsLoading ? (
<div className="rounded-md border p-6 text-sm text-muted-foreground"> <FormField
Loading permissions... label="Permissions"
</div> required
) : ( error={fieldError('permission_ids')}
<PermissionTree className="space-y-3"
items={permissionTree} labelEnd={
selectedIds={permissionIds} <span className="text-muted-foreground">
onChange={onPermissionIdsChange} {permissionIds.length} selected
/> </span>
)} }
>
{isPermissionsLoading ? (
<div className="rounded-lg border p-6 text-muted-foreground">
Loading permissions...
</div>
) : (
<PermissionTree
items={permissionTree}
selectedIds={permissionIds}
onChange={onPermissionIdsChange}
/>
)}
</FormField>
</div> </div>
<SheetFooter className="px-0"> <SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -225,8 +295,10 @@ export function PlanSheet({
> >
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSaving}> <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}
{planId ? 'Update Plan' : 'Create Plan'} {planId ? 'Update Plan' : 'Create Plan'}
</Button> </Button>
</SheetFooter> </SheetFooter>

View File

@@ -2,8 +2,10 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { ColumnDef, SortingState } from '@tanstack/react-table'; import type { ColumnDef, SortingState } from '@tanstack/react-table';
import { Edit, RotateCcw } from 'lucide-react';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { PERMISSIONS } from '@/constants/permissions';
import type { Plan } from '@/types'; import type { Plan } from '@/types';
interface PlanTableProps { interface PlanTableProps {
@@ -18,6 +20,9 @@ interface PlanTableProps {
onPageChange: (skip: number) => void; onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void; onLimitChange: (limit: number) => void;
onSortingChange: (sorting: SortingState) => void; onSortingChange: (sorting: SortingState) => void;
onEdit: (plan: Plan) => void;
onToggleStatus: (plan: Plan) => void;
pendingPlanId?: number;
} }
export function PlanTable({ export function PlanTable({
@@ -32,6 +37,9 @@ export function PlanTable({
onPageChange, onPageChange,
onLimitChange, onLimitChange,
onSortingChange, onSortingChange,
onEdit,
onToggleStatus,
pendingPlanId,
}: PlanTableProps) { }: PlanTableProps) {
return ( return (
<DataTable <DataTable
@@ -41,6 +49,21 @@ export function PlanTable({
isLoading={isLoading} isLoading={isLoading}
toolbar={toolbar} toolbar={toolbar}
emptyTitle="No plans found." emptyTitle="No plans found."
actions={[
{
label: 'Edit',
icon: <Edit className="size-4" />,
permission: PERMISSIONS.PLAN.UPDATE,
onClick: onEdit,
},
{
label: (plan) => (plan.is_active ? 'Deactivate' : 'Activate'),
icon: <RotateCcw className="size-4" />,
permission: PERMISSIONS.PLAN.DELETE,
disabled: (plan) => pendingPlanId === plan.id,
onClick: onToggleStatus,
},
]}
pagination={{ pagination={{
skip, skip,
limit, limit,
@@ -50,6 +73,7 @@ export function PlanTable({
}} }}
sorting={sorting} sorting={sorting}
onSortingChange={onSortingChange} onSortingChange={onSortingChange}
actionsSticky
/> />
); );
} }

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { import {
collectDefaultPermissionIds, collectDefaultPermissionIds,
collectPermissionIdsByKeys, collectPermissionIdsByKeys,
@@ -7,26 +8,39 @@ 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 { useSavePlanMutation } from './usePlanMutations'; import { useSavePlanMutation } from './usePlanMutations';
export interface PlanFormValues { const nonNegativeNumber = z.number().refine(
id?: number; (value) => Number.isFinite(value) && value >= 0,
name: string; { message: 'Enter a valid number' },
slug: string; );
description: string;
price: string; const planFormSchema = z.object({
billing_cycle: string; id: z.number().optional(),
trial_days: number; name: z.string().trim().min(1, 'Plan name is required'),
max_projects: number; slug: z.string().trim().min(1, 'Slug is required'),
max_organizations: number; description: z.string().trim().min(1, 'Description is required'),
max_users: number; price: z
max_roles: number; .string()
permission_ids: number[]; .trim()
is_active: boolean; .min(1, 'Price is required')
is_custom: boolean; .refine((value) => Number.isFinite(Number(value)) && Number(value) >= 0, {
} message: 'Enter a valid price',
}),
billing_cycle: z.string().trim().min(1, 'Billing cycle is required'),
trial_days: nonNegativeNumber,
max_projects: nonNegativeNumber,
max_organizations: nonNegativeNumber,
max_users: nonNegativeNumber,
max_roles: nonNegativeNumber,
permission_ids: z.array(z.number()).min(1, 'Select at least one permission'),
is_active: z.boolean(),
is_custom: z.boolean(),
});
export type PlanFormValues = z.infer<typeof planFormSchema>;
const defaultValues: PlanFormValues = { const defaultValues: PlanFormValues = {
name: '', name: '',
@@ -62,9 +76,12 @@ export function usePlanForm({
handleSubmit, handleSubmit,
reset, reset,
setValue, setValue,
formState: { isSubmitting }, formState: { errors, isSubmitting, isSubmitted },
} = useForm<PlanFormValues>({ } = useForm<PlanFormValues>({
defaultValues, defaultValues,
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(planFormSchema),
}); });
const saveMutation = useSavePlanMutation({ const saveMutation = useSavePlanMutation({
onSaved: () => { onSaved: () => {
@@ -75,13 +92,18 @@ export function usePlanForm({
const planId = useWatch({ control, name: 'id' }); const planId = useWatch({ control, name: 'id' });
const permissionIds = useWatch({ control, name: 'permission_ids' }) || []; const permissionIds = useWatch({ control, name: 'permission_ids' }) || [];
const name = useWatch({ control, name: 'name' }) || '';
const slug = useWatch({ control, name: 'slug' }) || '';
const description = useWatch({ control, name: 'description' }) || '';
const price = useWatch({ control, name: 'price' }) || '';
const canSubmit =
name.trim().length > 0 &&
slug.trim().length > 0 &&
description.trim().length > 0 &&
price.trim().length > 0 &&
permissionIds.length > 0;
const onSubmit = handleSubmit((values) => { const onSubmit = handleSubmit((values) => {
if (values.permission_ids.length === 0) {
toast.error('Select at least one permission');
return;
}
saveMutation.mutate({ saveMutation.mutate({
...values, ...values,
name: values.name.trim(), name: values.name.trim(),
@@ -117,7 +139,10 @@ export function usePlanForm({
max_organizations: plan.max_organizations ?? 0, max_organizations: plan.max_organizations ?? 0,
max_users: plan.max_users ?? 0, max_users: plan.max_users ?? 0,
max_roles: plan.max_roles ?? 0, max_roles: plan.max_roles ?? 0,
permission_ids: collectPermissionIdsByKeys(permissionTree, plan.permissions || []), permission_ids: collectPermissionIdsByKeys(
permissionTree,
plan.permissions || [],
),
is_active: plan.is_active, is_active: plan.is_active,
is_custom: plan.is_custom, is_custom: plan.is_custom,
}); });
@@ -126,7 +151,11 @@ export function usePlanForm({
); );
const setPermissionIds = useCallback( const setPermissionIds = useCallback(
(ids: number[]) => setValue('permission_ids', ids, { shouldDirty: true, shouldValidate: true }), (ids: number[]) =>
setValue('permission_ids', ids, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue], [setValue],
); );
@@ -139,6 +168,9 @@ export function usePlanForm({
planId, planId,
permissionIds, permissionIds,
setPermissionIds, setPermissionIds,
errors,
isSubmitted,
canSubmit,
isSaving: isSubmitting || saveMutation.isPending, isSaving: isSubmitting || saveMutation.isPending,
}; };
} }

View File

@@ -19,7 +19,9 @@ export function useSavePlanMutation({ onSaved }: { onSaved: () => void }) {
onSaved(); onSaved();
}, },
onError: (_error, values) => { onError: (_error, values) => {
toast.error(values.id ? 'Failed to update plan' : 'Failed to create plan'); toast.error(
values.id ? 'Failed to update plan' : 'Failed to create plan',
);
}, },
}); });
} }
@@ -28,7 +30,8 @@ export function usePlanStatusMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (plan: Plan) => planService.updatePlanStatus(plan.id, !plan.is_active), mutationFn: (plan: Plan) =>
planService.updatePlanStatus(plan.id, !plan.is_active),
onSuccess: () => { onSuccess: () => {
toast.success('Plan status updated'); toast.success('Plan status updated');
queryClient.invalidateQueries({ queryKey: planKeys.lists() }); queryClient.invalidateQueries({ queryKey: planKeys.lists() });

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

@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BadgeIndianRupee, Plus } from 'lucide-react'; import { BadgeIndianRupee, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards'; import { PermissionGuard } from '@/guards';
@@ -17,7 +16,10 @@ import { PlanTable } from './components/PlanTable';
import { usePlanFilters } from './hooks/usePlanFilters'; import { usePlanFilters } from './hooks/usePlanFilters';
import { usePlanForm } from './hooks/usePlanForm'; import { usePlanForm } from './hooks/usePlanForm';
import { usePlanStatusMutation } from './hooks/usePlanMutations'; import { usePlanStatusMutation } from './hooks/usePlanMutations';
import { useOrganizationPermissionTreeQuery, usePlansQuery } from './hooks/usePlanQueries'; import {
useOrganizationPermissionTreeQuery,
usePlansQuery,
} from './hooks/usePlanQueries';
export default function PlansPage() { export default function PlansPage() {
const [isSheetOpen, setIsSheetOpen] = useState(false); const [isSheetOpen, setIsSheetOpen] = useState(false);
@@ -65,6 +67,9 @@ export default function PlansPage() {
planId, planId,
permissionIds, permissionIds,
setPermissionIds, setPermissionIds,
errors,
isSubmitted,
canSubmit,
isSaving, isSaving,
} = planForm; } = planForm;
const statusMutation = usePlanStatusMutation(); const statusMutation = usePlanStatusMutation();
@@ -124,11 +129,7 @@ export default function PlansPage() {
[statusMutation], [statusMutation],
); );
const columns = usePlanColumns({ const columns = usePlanColumns();
onEdit: openEdit,
onToggleStatus: toggleStatus,
pendingPlanId: statusMutation.variables?.id,
});
const plans = plansQuery.data?.items ?? []; const plans = plansQuery.data?.items ?? [];
const total = plansQuery.data?.total ?? plansQuery.data?.totalItems ?? 0; const total = plansQuery.data?.total ?? plansQuery.data?.totalItems ?? 0;
@@ -173,9 +174,10 @@ export default function PlansPage() {
onPageChange={setSkip} onPageChange={setSkip}
onLimitChange={setLimit} onLimitChange={setLimit}
onSortingChange={setSorting} onSortingChange={setSorting}
onEdit={openEdit}
onToggleStatus={toggleStatus}
pendingPlanId={statusMutation.variables?.id}
/> />
<PoweredBy />
</main> </main>
<PlanSheet <PlanSheet
@@ -184,7 +186,10 @@ export default function PlansPage() {
planId={planId} planId={planId}
register={register} register={register}
control={control} control={control}
errors={errors}
isSubmitted={isSubmitted}
onSubmit={onSubmit} onSubmit={onSubmit}
canSubmit={canSubmit}
permissionTree={permissionsQuery.permissionTree} permissionTree={permissionsQuery.permissionTree}
permissionIds={permissionIds} permissionIds={permissionIds}
onPermissionIdsChange={setPermissionIds} onPermissionIdsChange={setPermissionIds}

View File

@@ -4,7 +4,11 @@ import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Project } from '@/types'; import type { Project } from '@/types';
function EmptyValue() { function EmptyValue() {
@@ -35,16 +39,14 @@ function StateBadges({ value }: { value: string | null }) {
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-[10px] font-bold text-muted-foreground transition-colors hover:bg-muted" className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-muted-foreground transition-colors hover:bg-muted"
> >
+{remainingStates.length} +{remainingStates.length}
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start"> <PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground"> <p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
Other States
</p>
{remainingStates.map((state) => ( {remainingStates.map((state) => (
<Badge key={state} variant="secondary"> <Badge key={state} variant="secondary">
{state} {state}
@@ -64,7 +66,7 @@ export function useProjectColumns() {
{ {
accessorKey: 'name', accessorKey: 'name',
header: 'Project Name', header: 'Project Name',
cell: ({ row }) => <div className="font-semibold">{row.original.name}</div>, cell: ({ row }) => <div>{row.original.name}</div>,
}, },
{ {
accessorKey: 'state', accessorKey: 'state',

View File

@@ -1,13 +1,14 @@
'use client'; 'use client';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form'; import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Layers, Loader2, MapPin, Route } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form'; import { 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,
@@ -24,7 +25,7 @@ interface ProjectDialogProps {
projectId?: string; projectId?: string;
register: UseFormRegister<ProjectFormValues>; register: UseFormRegister<ProjectFormValues>;
errors: FieldErrors<ProjectFormValues>; errors: FieldErrors<ProjectFormValues>;
touchedFields: UseFormReturn<ProjectFormValues>['formState']['touchedFields']; isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit']; onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean; canSubmit: boolean;
isSaving: boolean; isSaving: boolean;
@@ -36,128 +37,163 @@ export function ProjectDialog({
projectId, projectId,
register, register,
errors, errors,
touchedFields, isSubmitted,
onSubmit, onSubmit,
canSubmit, canSubmit,
isSaving, isSaving,
}: ProjectDialogProps) { }: ProjectDialogProps) {
const nameErrorMessage = touchedFields.name ? errors.name?.message : undefined; const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const startLatErrorMessage = touchedFields.start_lat ? errors.start_lat?.message : undefined; const startLatErrorMessage = isSubmitted
const startLngErrorMessage = touchedFields.start_lng ? errors.start_lng?.message : undefined; ? errors.start_lat?.message
const endLatErrorMessage = touchedFields.end_lat ? errors.end_lat?.message : undefined; : undefined;
const endLngErrorMessage = touchedFields.end_lng ? errors.end_lng?.message : undefined; const startLngErrorMessage = isSubmitted
? errors.start_lng?.message
: undefined;
const endLatErrorMessage = isSubmitted ? errors.end_lat?.message : undefined;
const endLngErrorMessage = isSubmitted ? errors.end_lng?.message : undefined;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl" onOpenAutoFocus={(event) => event.preventDefault()}> <DialogContent
<DialogHeader className="gap-2"> className="sm:max-w-2xl"
<DialogTitle className="flex items-center gap-3 text-xl"> onOpenAutoFocus={(event) => event.preventDefault()}
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm"> >
<Layers className="size-5" /> <DialogHeader>
</div> <DialogTitle>
{projectId ? 'Edit Project Details' : 'Create New Project'} {projectId ? 'Edit Project' : 'Create Project'}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm"> <DialogDescription>
{projectId Manage road project identity and optional coordinate boundaries.
? 'Update the technical specifications for your road infrastructure project.'
: 'Provide the essential road data to establish a new analysis project.'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={onSubmit} className="space-y-6"> <form
<FormField id="project-name" label="Project Name" required error={nameErrorMessage}> noValidate
<Input onSubmit={onSubmit}
id="project-name" className="flex min-h-0 flex-1 flex-col"
placeholder="Enter a descriptive project name" >
aria-invalid={!!nameErrorMessage} <DialogBody className="space-y-6">
{...register('name')} <section className="space-y-4">
/> <div className="space-y-1">
</FormField> <h3 className="text-sm font-semibold">Basic details</h3>
<p className="text-sm text-muted-foreground">
Project name and corridor information.
</p>
</div>
<div className="grid gap-5 md:grid-cols-2"> <FormField
<FormField id="project-state" label="State"> id="project-name"
<Input id="project-state" placeholder="e.g. Maharashtra" {...register('state')} /> label="Project Name"
</FormField> required
<FormField error={nameErrorMessage}
id="project-corridor" >
label={ <Input
<span className="inline-flex items-center gap-2"> id="project-name"
<Route className="size-3.5 opacity-60" /> placeholder="Enter project name"
Corridor Name aria-invalid={!!nameErrorMessage}
</span> {...register('name')}
} />
> </FormField>
<Input
id="project-corridor"
placeholder="e.g. Mumbai-Goa Highway"
{...register('corridor_name')}
/>
</FormField>
</div>
<div className="grid gap-8 pt-2 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-4"> <FormField id="project-state" label="State">
<p className="flex items-center gap-2 text-[11px] font-black tracking-widest text-muted-foreground">
<MapPin className="size-3.5 opacity-60" />
START POINT
</p>
<div className="grid grid-cols-2 gap-4">
<FormField id="project-start-lat" label="Lat" error={startLatErrorMessage}>
<Input <Input
id="project-state"
placeholder="Enter state"
{...register('state')}
/>
</FormField>
<FormField id="project-corridor" label="Corridor Name">
<Input
id="project-corridor"
placeholder="Enter corridor name"
{...register('corridor_name')}
/>
</FormField>
</div>
</section>
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Coordinates</h3>
<p className="text-sm text-muted-foreground">
Optional start and end points for the project boundary.
</p>
</div>
<div className="space-y-5">
<div className="grid grid-cols-2 gap-4">
<FormField
id="project-start-lat" id="project-start-lat"
type="number" label="Start Latitude"
step="any" error={startLatErrorMessage}
placeholder="0.0000" >
className="font-mono text-xs" <Input
aria-invalid={!!startLatErrorMessage} id="project-start-lat"
{...register('start_lat')} type="number"
/> step="any"
</FormField> min="-90"
<FormField id="project-start-lng" label="Lng" error={startLngErrorMessage}> max="90"
<Input placeholder="Enter start latitude"
aria-invalid={!!startLatErrorMessage}
{...register('start_lat')}
/>
</FormField>
<FormField
id="project-start-lng" id="project-start-lng"
type="number" label="Start Longitude"
step="any" error={startLngErrorMessage}
placeholder="0.0000" >
className="font-mono text-xs" <Input
aria-invalid={!!startLngErrorMessage} id="project-start-lng"
{...register('start_lng')} type="number"
/> step="any"
</FormField> min="-180"
</div> max="180"
</div> placeholder="Enter start longitude"
aria-invalid={!!startLngErrorMessage}
{...register('start_lng')}
/>
</FormField>
</div>
<div className="space-y-4"> <div className="grid grid-cols-2 gap-4">
<p className="flex items-center gap-2 text-[11px] font-black tracking-widest text-muted-foreground"> <FormField
<MapPin className="size-3.5 opacity-60" />
END POINT
</p>
<div className="grid grid-cols-2 gap-4">
<FormField id="project-end-lat" label="Lat" error={endLatErrorMessage}>
<Input
id="project-end-lat" id="project-end-lat"
type="number" label="End Latitude"
step="any" error={endLatErrorMessage}
placeholder="0.0000" >
className="font-mono text-xs" <Input
aria-invalid={!!endLatErrorMessage} id="project-end-lat"
{...register('end_lat')} type="number"
/> step="any"
</FormField> min="-90"
<FormField id="project-end-lng" label="Lng" error={endLngErrorMessage}> max="90"
<Input placeholder="Enter end latitude"
aria-invalid={!!endLatErrorMessage}
{...register('end_lat')}
/>
</FormField>
<FormField
id="project-end-lng" id="project-end-lng"
type="number" label="End Longitude"
step="any" error={endLngErrorMessage}
placeholder="0.0000" >
className="font-mono text-xs" <Input
aria-invalid={!!endLngErrorMessage} id="project-end-lng"
{...register('end_lng')} type="number"
/> step="any"
</FormField> min="-180"
max="180"
placeholder="Enter end longitude"
aria-invalid={!!endLngErrorMessage}
{...register('end_lng')}
/>
</FormField>
</div>
</div> </div>
</div> </section>
</div> </DialogBody>
<DialogFooter> <DialogFooter>
<Button <Button
@@ -169,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="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

@@ -1,6 +1,7 @@
'use client'; 'use client';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Edit3, Trash2 } from 'lucide-react';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import type { Project } from '@/types'; import type { Project } from '@/types';
@@ -35,8 +36,19 @@ export function ProjectTable({
title="Projects" title="Projects"
data={projects} data={projects}
columns={columns} columns={columns}
onEdit={onEdit} actions={[
onDelete={onDelete} {
label: 'Edit',
icon: <Edit3 className="size-4" />,
onClick: onEdit,
},
{
label: 'Delete',
icon: <Trash2 className="size-4" />,
className: 'text-destructive',
onClick: onDelete,
},
]}
isLoading={isLoading} isLoading={isLoading}
emptyTitle="No projects found." emptyTitle="No projects found."
pagination={{ pagination={{
@@ -46,6 +58,7 @@ export function ProjectTable({
onPageChange, onPageChange,
onLimitChange, onLimitChange,
}} }}
actionsSticky
/> />
); );
} }

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>;
@@ -53,7 +47,9 @@ function toNullableNumber(value: string) {
return value.trim() ? Number(value) : null; return value.trim() ? Number(value) : null;
} }
function toProjectPayload(values: ProjectFormValues): ProjectCreate | ProjectUpdate { function toProjectPayload(
values: ProjectFormValues,
): ProjectCreate | ProjectUpdate {
return { return {
name: values.name.trim(), name: values.name.trim(),
state: trimOptional(values.state), state: trimOptional(values.state),
@@ -72,10 +68,10 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
handleSubmit, handleSubmit,
reset, reset,
control, control,
formState: { errors, isSubmitting, touchedFields }, formState: { errors, isSubmitting, isSubmitted },
} = useForm<ProjectFormValues>({ } = useForm<ProjectFormValues>({
defaultValues, defaultValues,
mode: 'onTouched', mode: 'onSubmit',
reValidateMode: 'onChange', reValidateMode: 'onChange',
resolver: zodResolver(projectFormSchema), resolver: zodResolver(projectFormSchema),
}); });
@@ -98,18 +94,13 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: projectKeys.all }); queryClient.invalidateQueries({ queryKey: projectKeys.all });
}, },
onError: (_error, values) => { onError: (_error, values) => {
toast.error(values.id ? 'Failed to update project' : 'Failed to create project'); toast.error(
values.id ? 'Failed to update project' : 'Failed to create project',
);
}, },
}); });
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);
@@ -139,7 +130,7 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
resetForm: () => reset(defaultValues), resetForm: () => reset(defaultValues),
projectId, projectId,
errors, errors,
touchedFields, isSubmitted,
canSubmit, canSubmit,
isSaving: isSubmitting || saveMutation.isPending, isSaving: isSubmitting || saveMutation.isPending,
}; };

View File

@@ -15,7 +15,10 @@ interface UseProjectsQueryParams {
} }
export function useProjectsQuery({ skip, limit }: UseProjectsQueryParams) { export function useProjectsQuery({ skip, limit }: UseProjectsQueryParams) {
const listParams = useMemo<PaginationParams>(() => ({ skip, limit }), [limit, skip]); const listParams = useMemo<PaginationParams>(
() => ({ skip, limit }),
[limit, skip],
);
return useQuery({ return useQuery({
queryKey: projectKeys.list(listParams), queryKey: projectKeys.list(listParams),
@@ -36,7 +39,8 @@ export function useDeleteProjectMutation() {
}, },
onError: () => { onError: () => {
toast.error('Deletion failed', { toast.error('Deletion failed', {
description: 'The project could not be removed. Please try again or check your permissions.', description:
'The project could not be removed. Please try again or check your permissions.',
}); });
}, },
}); });

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,7 +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 { PoweredBy } from '@/components/powered-by'; 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';
@@ -13,10 +22,14 @@ import { ProjectTable } from './components/ProjectTable';
import { useProjectColumns } from './components/ProjectColumns'; import { useProjectColumns } from './components/ProjectColumns';
import { useProjectFilters } from './hooks/useProjectFilters'; import { useProjectFilters } from './hooks/useProjectFilters';
import { useProjectForm } from './hooks/useProjectForm'; import { useProjectForm } from './hooks/useProjectForm';
import { useDeleteProjectMutation, useProjectsQuery } from './hooks/useProjectQueries'; import {
useDeleteProjectMutation,
useProjectsQuery,
} from './hooks/useProjectQueries';
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();
@@ -31,7 +44,7 @@ export default function ProjectPage() {
resetForm, resetForm,
projectId, projectId,
errors, errors,
touchedFields, isSubmitted,
canSubmit, canSubmit,
isSaving, isSaving,
} = projectForm; } = projectForm;
@@ -61,14 +74,25 @@ export default function ProjectPage() {
const deleteProject = useCallback( const deleteProject = useCallback(
(project: Project) => { (project: Project) => {
if (!confirm(`Are you sure you want to delete project "${project.name}"?`)) { setProjectToDelete(project);
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;
@@ -100,8 +124,6 @@ export default function ProjectPage() {
onEdit={openEdit} onEdit={openEdit}
onDelete={deleteProject} onDelete={deleteProject}
/> />
<PoweredBy />
</main> </main>
<ProjectDialog <ProjectDialog
@@ -110,11 +132,41 @@ export default function ProjectPage() {
projectId={projectId} projectId={projectId}
register={register} register={register}
errors={errors} errors={errors}
touchedFields={touchedFields} isSubmitted={isSubmitted}
onSubmit={onSubmit} onSubmit={onSubmit}
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,190 +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 { PoweredBy } from '@/components/powered-by'; import { Button } from '@/components/ui/button';
import { sessionService, videoService } from '@/services/api'; import { useVideoResultsQuery } from '../hooks/useVideoResults';
import { SessionContext, DetectionData, DetectionType } from '@/types'; import { VideoResultsSkeleton } from './components/VideoResultsSkeleton';
import { getVideoFile, clearVideoFile } from '@/lib/video-storage';
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 title={getTitle()} description={`Video ID: ${videoId}`} icon={TrendingUp} /> icon={TrendingUp}
</div> actions={
<Button variant="outline" size="sm" onClick={() => router.back()}>
<ArrowLeft />
Back
</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}
/>
)}
<PoweredBy />
</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 { PoweredBy } from '@/components/powered-by';
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}
/>
)}
<PoweredBy />
</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

@@ -2,35 +2,17 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Edit, RotateCcw } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { usePermissions } from '@/hooks/usePermissions';
import type { Role } from '@/types'; import type { Role } from '@/types';
interface UseRoleColumnsParams { export function useRoleColumns(): ColumnDef<Role>[] {
onEdit: (role: Role) => void;
onToggleStatus: (role: Role) => void;
isStatusPending: boolean;
}
export function useRoleColumns({
onEdit,
onToggleStatus,
isStatusPending,
}: UseRoleColumnsParams): ColumnDef<Role>[] {
const { hasPermission } = usePermissions();
const canEdit = hasPermission(PERMISSIONS.ROLE.UPDATE);
const canDelete = hasPermission(PERMISSIONS.ROLE.DELETE);
return useMemo(() => { return useMemo(() => {
const columns: ColumnDef<Role>[] = [ return [
{ {
accessorKey: 'name', accessorKey: 'name',
header: 'Name', header: 'Name',
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>, cell: ({ row }) => <span>{row.original.name}</span>,
}, },
{ {
accessorKey: 'display_name', accessorKey: 'display_name',
@@ -54,44 +36,13 @@ export function useRoleColumns({
accessorKey: 'effective_status', accessorKey: 'effective_status',
header: 'Status', header: 'Status',
cell: ({ row }) => ( cell: ({ row }) => (
<Badge variant={row.original.effective_status ? 'default' : 'secondary'}> <Badge
variant={row.original.effective_status ? 'default' : 'secondary'}
>
{row.original.effective_status ? 'Active' : 'Inactive'} {row.original.effective_status ? 'Active' : 'Inactive'}
</Badge> </Badge>
), ),
}, },
]; ];
}, []);
if (!canEdit && !canDelete) return columns;
columns.push({
id: 'actions',
header: () => <div className="text-right">Actions</div>,
enableSorting: false,
cell: ({ row }) => {
const role = row.original;
return (
<div className="flex justify-end gap-2">
{canEdit ? (
<Button variant="outline" size="sm" onClick={() => onEdit(role)}>
<Edit className="size-4" />
</Button>
) : null}
{canDelete ? (
<Button
variant="outline"
size="sm"
disabled={isStatusPending}
onClick={() => onToggleStatus(role)}
>
<RotateCcw className="mr-2 size-4" />
{role.effective_status ? 'Deactivate' : 'Activate'}
</Button>
) : null}
</div>
);
},
});
return columns;
}, [canDelete, canEdit, isStatusPending, onEdit, onToggleStatus]);
} }

View File

@@ -31,7 +31,10 @@ export function RoleFilters({
onChange={onSearchChange} onChange={onSearchChange}
placeholder="Search roles" placeholder="Search roles"
/> />
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as StatusFilter)}> <Select
value={statusFilter}
onValueChange={(value) => onStatusChange(value as StatusFilter)}
>
<SelectTrigger className="md:w-44"> <SelectTrigger className="md:w-44">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import type { FieldErrors, UseFormRegister, UseFormReturn } 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';
@@ -28,7 +28,7 @@ interface RoleSheetProps {
roleId?: number; roleId?: number;
register: UseFormRegister<RoleFormValues>; register: UseFormRegister<RoleFormValues>;
errors: FieldErrors<RoleFormValues>; errors: FieldErrors<RoleFormValues>;
touchedFields: UseFormReturn<RoleFormValues>['formState']['touchedFields']; isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit']; onSubmit: ComponentProps<'form'>['onSubmit'];
permissionTree: PermissionTreeItem[]; permissionTree: PermissionTreeItem[];
permissionIds: number[]; permissionIds: number[];
@@ -44,7 +44,7 @@ export function RoleSheet({
roleId, roleId,
register, register,
errors, errors,
touchedFields, isSubmitted,
onSubmit, onSubmit,
permissionTree, permissionTree,
permissionIds, permissionIds,
@@ -53,8 +53,8 @@ export function RoleSheet({
canSubmit, canSubmit,
isSaving, isSaving,
}: RoleSheetProps) { }: RoleSheetProps) {
const nameErrorMessage = touchedFields.name ? errors.name?.message : undefined; const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const displayNameErrorMessage = touchedFields.display_name const displayNameErrorMessage = isSubmitted
? errors.display_name?.message ? errors.display_name?.message
: undefined; : undefined;
@@ -62,16 +62,22 @@ 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"> <SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
<SheetTitle>{roleId ? 'Edit Role' : 'Create Role'}</SheetTitle> <SheetTitle className="text-lg leading-none font-semibold tracking-tight">
<SheetDescription> {roleId ? 'Edit Role' : 'Create Role'}
</SheetTitle>
<SheetDescription className="text-sm">
Assign the role details and permission access for this organization. Assign the role details and permission access for this organization.
</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
@@ -114,14 +120,16 @@ export function RoleSheet({
<FormField <FormField
label="Permissions" label="Permissions"
required required
error={errors.permission_ids?.message} error={isSubmitted ? errors.permission_ids?.message : undefined}
className="space-y-3" className="space-y-3"
labelEnd={ labelEnd={
<span className="text-xs text-muted-foreground">{permissionIds.length} selected</span> <span className="text-muted-foreground">
{permissionIds.length} selected
</span>
} }
> >
{isPermissionsLoading ? ( {isPermissionsLoading ? (
<div className="rounded-md border p-6 text-sm text-muted-foreground"> <div className="rounded-lg border p-6 text-muted-foreground">
Loading permissions... Loading permissions...
</div> </div>
) : ( ) : (
@@ -136,7 +144,9 @@ export function RoleSheet({
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end"> <SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
<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}
{roleId ? 'Update Role' : 'Create Role'} {roleId ? 'Update Role' : 'Create Role'}
</Button> </Button>
<SheetClose asChild> <SheetClose asChild>

View File

@@ -2,8 +2,10 @@
import type { ColumnDef, SortingState } from '@tanstack/react-table'; import type { ColumnDef, SortingState } from '@tanstack/react-table';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Edit, RotateCcw } from 'lucide-react';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { PERMISSIONS } from '@/constants/permissions';
import type { Role } from '@/types'; import type { Role } from '@/types';
interface RoleTableProps { interface RoleTableProps {
@@ -18,6 +20,9 @@ interface RoleTableProps {
onPageChange: (skip: number) => void; onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void; onLimitChange: (limit: number) => void;
onSortingChange: (sorting: SortingState) => void; onSortingChange: (sorting: SortingState) => void;
onEdit: (role: Role) => void;
onToggleStatus: (role: Role) => void;
isStatusPending: boolean;
} }
export function RoleTable({ export function RoleTable({
@@ -32,6 +37,9 @@ export function RoleTable({
onPageChange, onPageChange,
onLimitChange, onLimitChange,
onSortingChange, onSortingChange,
onEdit,
onToggleStatus,
isStatusPending,
}: RoleTableProps) { }: RoleTableProps) {
return ( return (
<DataTable <DataTable
@@ -41,6 +49,21 @@ export function RoleTable({
isLoading={isLoading} isLoading={isLoading}
toolbar={toolbar} toolbar={toolbar}
emptyTitle="No roles found." emptyTitle="No roles found."
actions={[
{
label: 'Edit',
icon: <Edit className="size-4" />,
permission: PERMISSIONS.ROLE.UPDATE,
onClick: onEdit,
},
{
label: (role) => (role.effective_status ? 'Deactivate' : 'Activate'),
icon: <RotateCcw className="size-4" />,
permission: PERMISSIONS.ROLE.DELETE,
disabled: () => isStatusPending,
onClick: onToggleStatus,
},
]}
pagination={{ pagination={{
skip, skip,
limit, limit,
@@ -50,6 +73,7 @@ export function RoleTable({
}} }}
sorting={sorting} sorting={sorting}
onSortingChange={onSortingChange} onSortingChange={onSortingChange}
actionsSticky
/> />
); );
} }

View File

@@ -48,10 +48,10 @@ export function useRoleForm({
handleSubmit, handleSubmit,
reset, reset,
setValue, setValue,
formState: { errors, isSubmitting, touchedFields }, formState: { errors, isSubmitting, isSubmitted },
} = useForm<RoleFormValues>({ } = useForm<RoleFormValues>({
defaultValues, defaultValues,
mode: 'onTouched', mode: 'onSubmit',
reValidateMode: 'onChange', reValidateMode: 'onChange',
resolver: zodResolver(roleFormSchema), resolver: zodResolver(roleFormSchema),
}); });
@@ -61,7 +61,9 @@ export function useRoleForm({
const roleName = useWatch({ control, name: 'name' }) || ''; const roleName = useWatch({ control, name: 'name' }) || '';
const displayName = useWatch({ control, name: 'display_name' }) || ''; const displayName = useWatch({ control, name: 'display_name' }) || '';
const canSubmit = const canSubmit =
roleName.trim().length > 0 && displayName.trim().length > 0 && permissionIds.length > 0; roleName.trim().length > 0 &&
displayName.trim().length > 0 &&
permissionIds.length > 0;
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: (values: RoleFormValues) => mutationFn: (values: RoleFormValues) =>
@@ -81,26 +83,13 @@ export function useRoleForm({
queryClient.invalidateQueries({ queryKey: roleKeys.all }); queryClient.invalidateQueries({ queryKey: roleKeys.all });
}, },
onError: (_error, values) => { onError: (_error, values) => {
toast.error(values.id ? 'Failed to update role' : 'Failed to create role'); toast.error(
values.id ? 'Failed to update role' : 'Failed to create role',
);
}, },
}); });
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({
@@ -116,14 +105,21 @@ export function useRoleForm({
name: role.name || '', name: role.name || '',
display_name: role.display_name || '', display_name: role.display_name || '',
description: role.description || '', description: role.description || '',
permission_ids: collectPermissionIdsByKeys(permissionTree, role.permissions || []), permission_ids: collectPermissionIdsByKeys(
permissionTree,
role.permissions || [],
),
}); });
}, },
[permissionTree, reset], [permissionTree, reset],
); );
const setPermissionIds = useCallback( const setPermissionIds = useCallback(
(ids: number[]) => setValue('permission_ids', ids, { shouldDirty: true, shouldValidate: true }), (ids: number[]) =>
setValue('permission_ids', ids, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue], [setValue],
); );
@@ -136,7 +132,7 @@ export function useRoleForm({
permissionIds, permissionIds,
roleId, roleId,
errors, errors,
touchedFields, isSubmitted,
canSubmit, canSubmit,
isSaving: isSubmitting || saveMutation.isPending, isSaving: isSubmitting || saveMutation.isPending,
}; };

View File

@@ -32,7 +32,8 @@ function buildRoleListParams({
skip, skip,
limit, limit,
search_term: searchTerm || undefined, search_term: searchTerm || undefined,
effective_status: statusFilter === 'all' ? undefined : statusFilter === 'active', effective_status:
statusFilter === 'all' ? undefined : statusFilter === 'active',
...getServerSortParams(sorting), ...getServerSortParams(sorting),
}; };
} }
@@ -40,7 +41,8 @@ function buildRoleListParams({
export function useRolesQuery(params: UseRolesQueryParams) { export function useRolesQuery(params: UseRolesQueryParams) {
const { skip, limit, searchTerm, statusFilter, sorting } = params; const { skip, limit, searchTerm, statusFilter, sorting } = params;
const listParams = useMemo( const listParams = useMemo(
() => buildRoleListParams({ skip, limit, searchTerm, statusFilter, sorting }), () =>
buildRoleListParams({ skip, limit, searchTerm, statusFilter, sorting }),
[limit, searchTerm, skip, sorting, statusFilter], [limit, searchTerm, skip, sorting, statusFilter],
); );
@@ -71,7 +73,8 @@ export function useRoleStatusMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (role: Role) => roleService.updateRoleStatus(role.id, !role.effective_status), mutationFn: (role: Role) =>
roleService.updateRoleStatus(role.id, !role.effective_status),
onSuccess: () => { onSuccess: () => {
toast.success('Role status updated'); toast.success('Role status updated');
queryClient.invalidateQueries({ queryKey: roleKeys.all }); queryClient.invalidateQueries({ queryKey: roleKeys.all });

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,10 +1,9 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Plus, ShieldCheck } from 'lucide-react'; import { Plus, ShieldCheck } from 'lucide-react';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards'; import { PermissionGuard } from '@/guards';
@@ -24,8 +23,11 @@ import {
} from './hooks/useRoleQueries'; } from './hooks/useRoleQueries';
export default function RolesPage() { export default function RolesPage() {
const organizationId = useAppStore((state) => state.user?.organization_id ?? null); const organizationId = useAppStore(
(state) => state.user?.organization_id ?? null,
);
const [isSheetOpen, setIsSheetOpen] = useState(false); const [isSheetOpen, setIsSheetOpen] = useState(false);
const [activeRole, setActiveRole] = useState<Role | null>(null);
const { const {
skip, skip,
setSkip, setSkip,
@@ -48,6 +50,7 @@ export default function RolesPage() {
sorting, sorting,
}); });
const permissionsQuery = usePermissionsTreeQuery(isSheetOpen); const permissionsQuery = usePermissionsTreeQuery(isSheetOpen);
const roleForm = useRoleForm({ const roleForm = useRoleForm({
organizationId, organizationId,
permissionTree: permissionsQuery.permissionTree, permissionTree: permissionsQuery.permissionTree,
@@ -62,26 +65,41 @@ export default function RolesPage() {
permissionIds, permissionIds,
setPermissionIds, setPermissionIds,
errors, errors,
touchedFields, isSubmitted,
canSubmit, canSubmit,
isSaving, isSaving,
} = roleForm; } = roleForm;
const statusMutation = useRoleStatusMutation(); const statusMutation = useRoleStatusMutation();
const { mutate: updateRoleStatus, isPending: isStatusPending } = statusMutation; const { mutate: updateRoleStatus, isPending: isStatusPending } =
statusMutation;
const openCreate = useCallback(() => { const openCreate = useCallback(() => {
prepareCreateRole(); setActiveRole(null);
setIsSheetOpen(true); setIsSheetOpen(true);
}, [prepareCreateRole]); }, []);
const openEdit = useCallback( const openEdit = useCallback(
(role: Role) => { (role: Role) => {
prepareEditRole(role); setActiveRole(role);
setIsSheetOpen(true); setIsSheetOpen(true);
}, },
[prepareEditRole], [],
); );
useEffect(() => {
if (!isSheetOpen || permissionsQuery.permissionTree.length === 0) {
return;
}
activeRole ? prepareEditRole(activeRole) : prepareCreateRole();
}, [
activeRole,
isSheetOpen,
permissionsQuery.permissionTree,
prepareCreateRole,
prepareEditRole,
]);
const toggleStatus = useCallback( const toggleStatus = useCallback(
(role: Role) => { (role: Role) => {
updateRoleStatus(role); updateRoleStatus(role);
@@ -89,11 +107,7 @@ export default function RolesPage() {
[updateRoleStatus], [updateRoleStatus],
); );
const columns = useRoleColumns({ const columns = useRoleColumns();
onEdit: openEdit,
onToggleStatus: toggleStatus,
isStatusPending,
});
const total = rolesQuery.data?.total ?? 0; const total = rolesQuery.data?.total ?? 0;
const roles = rolesQuery.data?.items ?? []; const roles = rolesQuery.data?.items ?? [];
@@ -135,9 +149,10 @@ export default function RolesPage() {
onPageChange={setSkip} onPageChange={setSkip}
onLimitChange={setLimit} onLimitChange={setLimit}
onSortingChange={setSorting} onSortingChange={setSorting}
onEdit={openEdit}
onToggleStatus={toggleStatus}
isStatusPending={isStatusPending}
/> />
<PoweredBy />
</main> </main>
<RoleSheet <RoleSheet
@@ -146,7 +161,7 @@ export default function RolesPage() {
roleId={roleId} roleId={roleId}
register={register} register={register}
errors={errors} errors={errors}
touchedFields={touchedFields} isSubmitted={isSubmitted}
onSubmit={onSubmit} onSubmit={onSubmit}
permissionTree={permissionsQuery.permissionTree} permissionTree={permissionsQuery.permissionTree}
permissionIds={permissionIds} permissionIds={permissionIds}

View File

@@ -2,10 +2,19 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { ArrowDownCircle, ArrowUpCircle, MapPin, Milestone } from 'lucide-react'; import {
ArrowDownCircle,
ArrowUpCircle,
MapPin,
Milestone,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Chainage, Package, Project } from '@/types'; import type { Chainage, Package, Project } from '@/types';
function EmptyValue() { function EmptyValue() {
@@ -36,16 +45,14 @@ function StateBadges({ value }: { value: string | null }) {
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-[10px] font-bold text-muted-foreground transition-colors hover:bg-muted" className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-muted-foreground transition-colors hover:bg-muted"
> >
+{remainingStates.length} +{remainingStates.length}
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start"> <PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground"> <p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
Other States
</p>
{remainingStates.map((state) => ( {remainingStates.map((state) => (
<Badge key={state} variant="secondary"> <Badge key={state} variant="secondary">
{state} {state}
@@ -65,7 +72,7 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
{ {
accessorKey: 'segment_name', accessorKey: 'segment_name',
header: 'Segment Name', header: 'Segment Name',
cell: ({ row }) => <div className="font-semibold">{row.original.segment_name}</div>, cell: ({ row }) => <div>{row.original.segment_name}</div>,
}, },
{ {
accessorKey: 'package_id', accessorKey: 'package_id',
@@ -78,7 +85,9 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
id: 'project', id: 'project',
header: 'Project', header: 'Project',
cell: ({ row }) => { cell: ({ row }) => {
const pkg = packages.find((item) => item.id === row.original.package_id); const pkg = packages.find(
(item) => item.id === row.original.package_id,
);
const project = projects.find((item) => item.id === pkg?.project_id); const project = projects.find((item) => item.id === pkg?.project_id);
return project?.name || <EmptyValue />; return project?.name || <EmptyValue />;
}, },
@@ -87,7 +96,9 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
id: 'project_state', id: 'project_state',
header: 'Project State', header: 'Project State',
cell: ({ row }) => { cell: ({ row }) => {
const pkg = packages.find((item) => item.id === row.original.package_id); const pkg = packages.find(
(item) => item.id === row.original.package_id,
);
const project = projects.find((item) => item.id === pkg?.project_id); const project = projects.find((item) => item.id === pkg?.project_id);
return <StateBadges value={project?.state ?? null} />; return <StateBadges value={project?.state ?? null} />;
}, },
@@ -101,7 +112,7 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<Badge <Badge
variant="outline" variant="outline"
className="flex items-center gap-1.5 border-amber-500/50 font-bold text-amber-500" className="flex items-center gap-1.5 border-amber-500/50 text-amber-500"
> >
<Milestone className="size-3" /> <Milestone className="size-3" />
{segment.chainage_start_km} - {segment.chainage_end_km} {segment.chainage_start_km} - {segment.chainage_end_km}
@@ -124,10 +135,11 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
cell: ({ row }) => ( cell: ({ row }) => (
<Badge <Badge
variant="outline" variant="outline"
className="flex items-center gap-1.5 border-blue-500/50 font-bold text-blue-500" className="flex items-center gap-1.5 border-blue-500/50 text-blue-500"
> >
<MapPin className="size-3" /> <MapPin className="size-3" />
{row.original.start_lat.toFixed(4)}, {row.original.start_lng.toFixed(4)} {row.original.start_lat.toFixed(4)},{' '}
{row.original.start_lng.toFixed(4)}
</Badge> </Badge>
), ),
}, },
@@ -137,7 +149,7 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
cell: ({ row }) => ( cell: ({ row }) => (
<Badge <Badge
variant="outline" variant="outline"
className="flex items-center gap-1.5 border-blue-500/50 font-bold text-blue-500" className="flex items-center gap-1.5 border-blue-500/50 text-blue-500"
> >
<MapPin className="size-3" /> <MapPin className="size-3" />
{row.original.end_lat.toFixed(4)}, {row.original.end_lng.toFixed(4)} {row.original.end_lat.toFixed(4)}, {row.original.end_lng.toFixed(4)}

View File

@@ -1,13 +1,17 @@
'use client'; 'use client';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form'; import { useRef } from 'react';
import { Loader2, MapPin, Milestone } from 'lucide-react'; import type { FieldErrors, UseFormRegister } from 'react-hook-form';
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;
@@ -42,7 +41,7 @@ interface SegmentDialogProps {
onDirectionChange: (direction: 'UP' | 'DOWN') => void; onDirectionChange: (direction: 'UP' | 'DOWN') => void;
register: UseFormRegister<SegmentFormValues>; register: UseFormRegister<SegmentFormValues>;
errors: FieldErrors<SegmentFormValues>; errors: FieldErrors<SegmentFormValues>;
touchedFields: UseFormReturn<SegmentFormValues>['formState']['touchedFields']; isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit']; onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean; canSubmit: boolean;
isSaving: boolean; isSaving: boolean;
@@ -52,10 +51,6 @@ export function SegmentDialog({
open, open,
onOpenChange, onOpenChange,
segmentId, segmentId,
projects,
packages,
isProjectsLoading,
isPackagesLoading,
projectId, projectId,
onProjectChange, onProjectChange,
packageId, packageId,
@@ -64,229 +59,270 @@ export function SegmentDialog({
onDirectionChange, onDirectionChange,
register, register,
errors, errors,
touchedFields, isSubmitted,
onSubmit, onSubmit,
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) =>
touchedFields[field] ? errors[field]?.message : undefined; isSubmitted ? errors[field]?.message : undefined;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <DialogContent
className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-2xl" className="sm:max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()}
> >
<DialogHeader className="gap-2"> <DialogHeader>
<DialogTitle className="flex items-center gap-3 text-xl"> <DialogTitle>
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
<Milestone className="size-5" />
</div>
{segmentId ? 'Edit Segment' : 'Create Segment'} {segmentId ? 'Edit Segment' : 'Create Segment'}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm"> <DialogDescription>
{segmentId Manage segment binding, chainage values, direction, and coordinates.
? 'Update the technical specifications for your road segment.'
: 'Select a project and package, then provide the segment data.'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={onSubmit} className="space-y-6"> <form
{!segmentId ? ( noValidate
<div className="grid gap-5 md:grid-cols-2"> onSubmit={onSubmit}
<FormField label="Project" required error={getError('project_id')}> className="flex min-h-0 flex-1 flex-col"
<Select value={projectId} onValueChange={onProjectChange}> >
<SelectTrigger aria-invalid={!!getError('project_id')}> <DialogBody className="space-y-6">
{isProjectsLoading ? ( <section className="space-y-4">
<span className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="space-y-1">
<Loader2 className="size-4 animate-spin" /> <h3 className="text-sm font-semibold">Basic details</h3>
Loading... <p className="text-sm text-muted-foreground">
</span> Project, package, segment name, and direction.
) : ( </p>
<SelectValue placeholder="Choose a project" /> </div>
)}
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" {...register('project_id')} value={projectId} readOnly />
</FormField>
<FormField label="Package" required error={getError('package_id')}> {!segmentId ? (
<Select <div className="grid gap-4 md:grid-cols-2">
value={packageId} <FormField
onValueChange={onPackageChange} label="Project"
disabled={!projectId || isPackagesLoading} required
error={getError('project_id')}
>
<ProjectSelect
value={projectId}
onValueChange={onProjectChange}
enabled={open}
disabled={isSaving}
portalContainer={projectSelectPortalRef}
/>
<input
type="hidden"
{...register('project_id')}
value={projectId}
readOnly
/>
</FormField>
<FormField
label="Package"
required
error={getError('package_id')}
>
<PackageSelect
value={packageId}
onValueChange={onPackageChange}
projectId={projectId}
enabled={open && Boolean(projectId)}
disabled={!projectId || isSaving}
portalContainer={packageSelectPortalRef}
/>
<input
type="hidden"
{...register('package_id')}
value={packageId}
readOnly
/>
</FormField>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="segment-name"
label="Segment Name"
required
error={getError('segment_name')}
> >
<SelectTrigger aria-invalid={!!getError('package_id')}>
{isPackagesLoading ? (
<span className="flex items-center gap-2 text-sm 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 type="hidden" {...register('package_id')} value={packageId} readOnly />
</FormField>
</div>
) : null}
<div className="grid gap-5 md:grid-cols-2">
<FormField
id="segment-name"
label="Segment Name"
required
error={getError('segment_name')}
>
<Input
id="segment-name"
placeholder="e.g. Mumbai to Pune"
aria-invalid={!!getError('segment_name')}
{...register('segment_name')}
/>
</FormField>
<FormField label="Direction" required>
<Select value={direction} onValueChange={onDirectionChange}>
<SelectTrigger>
<SelectValue placeholder="Select direction" />
</SelectTrigger>
<SelectContent>
<SelectItem value="UP">UP</SelectItem>
<SelectItem value="DOWN">DOWN</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField
id="segment-start-km"
label="Start (km)"
required
error={getError('chainage_start_km')}
>
<Input
id="segment-start-km"
type="number"
step="any"
min="0"
placeholder="0.0"
aria-invalid={!!getError('chainage_start_km')}
{...register('chainage_start_km')}
/>
</FormField>
<FormField
id="segment-end-km"
label="End (km)"
required
error={getError('chainage_end_km')}
>
<Input
id="segment-end-km"
type="number"
step="any"
min="0"
placeholder="1.0"
aria-invalid={!!getError('chainage_end_km')}
{...register('chainage_end_km')}
/>
</FormField>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-4">
<p className="flex items-center gap-2 text-[11px] font-black tracking-widest text-muted-foreground">
<MapPin className="size-3.5 opacity-60" />
START COORDINATES
</p>
<div className="grid grid-cols-2 gap-4">
<FormField id="start-lat" label="Latitude" required error={getError('start_lat')}>
<Input <Input
id="segment-name"
placeholder="Enter segment name"
aria-invalid={!!getError('segment_name')}
{...register('segment_name')}
/>
</FormField>
<FormField label="Direction" required>
<Select value={direction} onValueChange={onDirectionChange}>
<SelectTrigger>
<SelectValue placeholder="Select direction" />
</SelectTrigger>
<SelectContent>
<SelectItem value="UP">UP</SelectItem>
<SelectItem value="DOWN">DOWN</SelectItem>
</SelectContent>
</Select>
</FormField>
</div>
</section>
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Chainage</h3>
<p className="text-sm text-muted-foreground">
Required start and end kilometre values.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="segment-start-km"
label="Start Chainage"
required
error={getError('chainage_start_km')}
>
<Input
id="segment-start-km"
type="number"
step="1"
min="0"
placeholder="Enter start chainage"
aria-invalid={!!getError('chainage_start_km')}
{...register('chainage_start_km')}
/>
</FormField>
<FormField
id="segment-end-km"
label="End Chainage"
required
error={getError('chainage_end_km')}
>
<Input
id="segment-end-km"
type="number"
step="1"
min="0"
placeholder="Enter end chainage"
aria-invalid={!!getError('chainage_end_km')}
{...register('chainage_end_km')}
/>
</FormField>
</div>
</section>
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Coordinates</h3>
<p className="text-sm text-muted-foreground">
Required start and end point latitude and longitude.
</p>
</div>
<div className="space-y-5">
<div className="grid grid-cols-2 gap-4">
<FormField
id="start-lat" id="start-lat"
type="number" label="Start Latitude"
step="any" required
min="-90" error={getError('start_lat')}
max="90" >
placeholder="-90 to 90" <Input
aria-invalid={!!getError('start_lat')} id="start-lat"
{...register('start_lat')} type="number"
/> step="any"
</FormField> min="-90"
<FormField id="start-lng" label="Longitude" required error={getError('start_lng')}> max="90"
<Input placeholder="-90 to 90"
aria-invalid={!!getError('start_lat')}
{...register('start_lat')}
/>
</FormField>
<FormField
id="start-lng" id="start-lng"
type="number" label="Start Longitude"
step="any" required
min="-180" error={getError('start_lng')}
max="180" >
placeholder="-180 to 180" <Input
aria-invalid={!!getError('start_lng')} id="start-lng"
{...register('start_lng')} type="number"
/> step="any"
</FormField> min="-180"
</div> max="180"
</div> placeholder="-180 to 180"
aria-invalid={!!getError('start_lng')}
{...register('start_lng')}
/>
</FormField>
</div>
<div className="space-y-4"> <div className="grid grid-cols-2 gap-4">
<p className="flex items-center gap-2 text-[11px] font-black tracking-widest text-muted-foreground"> <FormField
<MapPin className="size-3.5 opacity-60" />
END COORDINATES
</p>
<div className="grid grid-cols-2 gap-4">
<FormField id="end-lat" label="Latitude" required error={getError('end_lat')}>
<Input
id="end-lat" id="end-lat"
type="number" label="End Latitude"
step="any" required
min="-90" error={getError('end_lat')}
max="90" >
placeholder="-90 to 90" <Input
aria-invalid={!!getError('end_lat')} id="end-lat"
{...register('end_lat')} type="number"
/> step="any"
</FormField> min="-90"
<FormField id="end-lng" label="Longitude" required error={getError('end_lng')}> max="90"
<Input placeholder="-90 to 90"
aria-invalid={!!getError('end_lat')}
{...register('end_lat')}
/>
</FormField>
<FormField
id="end-lng" id="end-lng"
type="number" label="End Longitude"
step="any" required
min="-180" error={getError('end_lng')}
max="180" >
placeholder="-180 to 180" <Input
aria-invalid={!!getError('end_lng')} id="end-lng"
{...register('end_lng')} type="number"
/> step="any"
</FormField> min="-180"
max="180"
placeholder="-180 to 180"
aria-invalid={!!getError('end_lng')}
{...register('end_lng')}
/>
</FormField>
</div>
</div> </div>
</div> </section>
</div> </DialogBody>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}> <Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSaving || !canSubmit}> <Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="size-4 animate-spin" /> : null} {isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{segmentId ? 'Update Segment' : 'Create Segment'} {segmentId ? 'Update Segment' : 'Create Segment'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
<div ref={projectSelectPortalRef} />
<div ref={packageSelectPortalRef} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Edit3, Trash2 } from 'lucide-react';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import type { Chainage } from '@/types'; import type { Chainage } from '@/types';
@@ -35,8 +36,19 @@ export function SegmentTable({
title="Segments" title="Segments"
data={segments} data={segments}
columns={columns} columns={columns}
onEdit={onEdit} actions={[
onDelete={onDelete} {
label: 'Edit',
icon: <Edit3 className="size-4" />,
onClick: onEdit,
},
{
label: 'Delete',
icon: <Trash2 className="size-4" />,
className: 'text-destructive',
onClick: onDelete,
},
]}
isLoading={isLoading} isLoading={isLoading}
emptyTitle="No segments found." emptyTitle="No segments found."
pagination={{ pagination={{
@@ -46,6 +58,7 @@ export function SegmentTable({
onPageChange, onPageChange,
onLimitChange, onLimitChange,
}} }}
actionsSticky
/> />
); );
} }

View File

@@ -7,47 +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)), { message: 'Enter a valid number' }); segment_name: z.string().trim().min(1, 'Segment name is required'),
chainage_start_km: requiredIntegerNumber('Start km is required'),
const latitude = requiredNumber('Latitude is required').refine( chainage_end_km: requiredIntegerNumber('End km is required'),
(value) => { start_lat: requiredLatitude,
const numberValue = Number(value); start_lng: requiredLongitude,
return numberValue >= -90 && numberValue <= 90; end_lat: requiredLatitude,
}, end_lng: requiredLongitude,
{ message: 'Latitude must be between -90 and 90' }, direction: z.enum(['UP', 'DOWN']),
); })
.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>;
@@ -64,7 +49,9 @@ const defaultValues: SegmentFormValues = {
direction: 'UP', direction: 'UP',
}; };
function toSegmentPayload(values: SegmentFormValues): ChainageCreate | ChainageUpdate { function toSegmentPayload(
values: SegmentFormValues,
): ChainageCreate | ChainageUpdate {
return { return {
package_id: values.package_id, package_id: values.package_id,
segment_name: values.segment_name.trim(), segment_name: values.segment_name.trim(),
@@ -86,10 +73,10 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
reset, reset,
setValue, setValue,
control, control,
formState: { errors, isSubmitting, touchedFields }, formState: { errors, isSubmitting, isSubmitted },
} = useForm<SegmentFormValues>({ } = useForm<SegmentFormValues>({
defaultValues, defaultValues,
mode: 'onTouched', mode: 'onSubmit',
reValidateMode: 'onChange', reValidateMode: 'onChange',
resolver: zodResolver(segmentFormSchema), resolver: zodResolver(segmentFormSchema),
}); });
@@ -99,7 +86,8 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
const packageId = useWatch({ control, name: 'package_id' }) || ''; const packageId = useWatch({ control, name: 'package_id' }) || '';
const direction = useWatch({ control, name: 'direction' }) || 'UP'; const direction = useWatch({ control, name: 'direction' }) || 'UP';
const segmentName = useWatch({ control, name: 'segment_name' }) || ''; const segmentName = useWatch({ control, name: 'segment_name' }) || '';
const chainageStartKm = useWatch({ control, name: 'chainage_start_km' }) || ''; const chainageStartKm =
useWatch({ control, name: 'chainage_start_km' }) || '';
const chainageEndKm = useWatch({ control, name: 'chainage_end_km' }) || ''; const chainageEndKm = useWatch({ control, name: 'chainage_end_km' }) || '';
const startLat = useWatch({ control, name: 'start_lat' }) || ''; const startLat = useWatch({ control, name: 'start_lat' }) || '';
const startLng = useWatch({ control, name: 'start_lng' }) || ''; const startLng = useWatch({ control, name: 'start_lng' }) || '';
@@ -130,19 +118,13 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
queryClient.invalidateQueries({ queryKey: segmentKeys.all }); queryClient.invalidateQueries({ queryKey: segmentKeys.all });
}, },
onError: (_error, values) => { onError: (_error, values) => {
toast.error(values.id ? 'Failed to update segment' : 'Failed to create segment'); toast.error(
values.id ? 'Failed to update segment' : 'Failed to create segment',
);
}, },
}); });
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);
@@ -169,14 +151,21 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
const setProjectId = useCallback( const setProjectId = useCallback(
(value: string) => { (value: string) => {
setValue('project_id', value, { shouldDirty: true, shouldValidate: true }); setValue('project_id', value, {
shouldDirty: true,
shouldValidate: true,
});
setValue('package_id', '', { shouldDirty: true, shouldValidate: true }); setValue('package_id', '', { shouldDirty: true, shouldValidate: true });
}, },
[setValue], [setValue],
); );
const setPackageId = useCallback( const setPackageId = useCallback(
(value: string) => setValue('package_id', value, { shouldDirty: true, shouldValidate: true }), (value: string) =>
setValue('package_id', value, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue], [setValue],
); );
@@ -200,7 +189,7 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
direction, direction,
setDirection, setDirection,
errors, errors,
touchedFields, isSubmitted,
canSubmit, canSubmit,
isSaving: isSubmitting || saveMutation.isPending, isSaving: isSubmitting || saveMutation.isPending,
}; };

View File

@@ -4,9 +4,15 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { chainageService, packageService, projectService } from '@/services/api'; import {
chainageService,
packageService,
projectService,
} 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 {
@@ -15,7 +21,10 @@ interface UseSegmentsQueryParams {
} }
export function useSegmentsQuery({ skip, limit }: UseSegmentsQueryParams) { export function useSegmentsQuery({ skip, limit }: UseSegmentsQueryParams) {
const listParams = useMemo<PaginationParams>(() => ({ skip, limit }), [limit, skip]); const listParams = useMemo<PaginationParams>(
() => ({ skip, limit }),
[limit, skip],
);
return useQuery({ return useQuery({
queryKey: segmentKeys.list(listParams), queryKey: segmentKeys.list(listParams),
@@ -25,31 +34,24 @@ 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();
return useMutation({ return useMutation({
mutationFn: (segment: Chainage) => chainageService.deleteChainage(segment.id), mutationFn: (segment: Chainage) =>
chainageService.deleteChainage(segment.id),
onSuccess: (_data, segment) => { onSuccess: (_data, segment) => {
toast.success('Segment deleted', { toast.success('Segment deleted', {
description: `${segment.segment_name} has been removed from the system.`, description: `${segment.segment_name} has been removed from the system.`,

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,7 +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 { PoweredBy } from '@/components/powered-by'; 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';
@@ -16,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();
@@ -45,12 +54,10 @@ export default function SegmentPage() {
direction, direction,
setDirection, setDirection,
errors, errors,
touchedFields, isSubmitted,
canSubmit, canSubmit,
isSaving, isSaving,
} = segmentForm; } = segmentForm;
const projectPackagesQuery = usePackagesByProjectQuery(projectId, isDialogOpen);
const openCreate = useCallback(() => { const openCreate = useCallback(() => {
prepareCreateSegment(); prepareCreateSegment();
setIsDialogOpen(true); setIsDialogOpen(true);
@@ -58,7 +65,9 @@ export default function SegmentPage() {
const openEdit = useCallback( const openEdit = useCallback(
(segment: Chainage) => { (segment: Chainage) => {
const pkg = allPackagesQuery.data?.items.find((item) => item.id === segment.package_id); const pkg = allPackagesQuery.data?.items.find(
(item) => item.id === segment.package_id,
);
prepareEditSegment(segment, pkg?.project_id || ''); prepareEditSegment(segment, pkg?.project_id || '');
setIsDialogOpen(true); setIsDialogOpen(true);
}, },
@@ -77,17 +86,27 @@ export default function SegmentPage() {
const deleteSegment = useCallback( const deleteSegment = useCallback(
(segment: Chainage) => { (segment: Chainage) => {
if (!confirm(`Are you sure you want to delete segment "${segment.segment_name}"?`)) { setSegmentToDelete(segment);
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);
@@ -119,18 +138,12 @@ export default function SegmentPage() {
onEdit={openEdit} onEdit={openEdit}
onDelete={deleteSegment} onDelete={deleteSegment}
/> />
<PoweredBy />
</main> </main>
<SegmentDialog <SegmentDialog
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}
@@ -139,11 +152,41 @@ export default function SegmentPage() {
onDirectionChange={setDirection} onDirectionChange={setDirection}
register={register} register={register}
errors={errors} errors={errors}
touchedFields={touchedFields} isSubmitted={isSubmitted}
onSubmit={onSubmit} onSubmit={onSubmit}
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

@@ -2,12 +2,8 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Edit, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { usePermissions } from '@/hooks/usePermissions';
import type { Tenant } from '@/types'; import type { Tenant } from '@/types';
function formatDate(value?: string | null) { function formatDate(value?: string | null) {
@@ -19,30 +15,16 @@ function formatDate(value?: string | null) {
}).format(new Date(value)); }).format(new Date(value));
} }
interface UseTenantColumnsParams { export function useTenantColumns(): ColumnDef<Tenant>[] {
onEdit: (tenant: Tenant) => void;
onDelete: (tenant: Tenant) => void;
pendingDeleteId?: number;
}
export function useTenantColumns({
onEdit,
onDelete,
pendingDeleteId,
}: UseTenantColumnsParams): ColumnDef<Tenant>[] {
const { hasPermission } = usePermissions();
const canEdit = hasPermission(PERMISSIONS.TENANT.UPDATE);
const canDelete = hasPermission(PERMISSIONS.TENANT.DELETE);
return useMemo(() => { return useMemo(() => {
const columns: ColumnDef<Tenant>[] = [ return [
{ {
accessorKey: 'name', accessorKey: 'name',
header: 'Tenant', header: 'Tenant',
cell: ({ row }) => ( cell: ({ row }) => (
<div> <div>
<p className="font-medium">{row.original.name}</p> <p>{row.original.name}</p>
<p className="text-xs text-muted-foreground">{row.original.slug}</p> <p className="text-muted-foreground">{row.original.slug}</p>
</div> </div>
), ),
}, },
@@ -76,37 +58,5 @@ export function useTenantColumns({
), ),
}, },
]; ];
}, []);
if (!canEdit && !canDelete) return columns;
columns.push({
id: 'actions',
header: () => <div className="text-right">Actions</div>,
enableSorting: false,
cell: ({ row }) => {
const tenant = row.original;
return (
<div className="flex justify-end gap-2">
{canEdit ? (
<Button variant="outline" size="sm" onClick={() => onEdit(tenant)}>
<Edit className="size-4" />
</Button>
) : null}
{canDelete ? (
<Button
variant="outline"
size="sm"
disabled={pendingDeleteId === tenant.id}
onClick={() => onDelete(tenant)}
>
<Trash2 className="size-4" />
</Button>
) : null}
</div>
);
},
});
return columns;
}, [canDelete, canEdit, onDelete, onEdit, pendingDeleteId]);
} }

View File

@@ -1,12 +1,19 @@
'use client'; 'use client';
import type { ComponentProps } from 'react'; import { useMemo, useRef, type ComponentProps } from 'react';
import type { 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, 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,
@@ -14,17 +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 { Label } from '@/components/ui/label';
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,7 +34,11 @@ interface TenantSheetProps {
tenantId?: number; tenantId?: number;
isEditMode: boolean; isEditMode: boolean;
register: UseFormRegister<TenantFormValues>; register: UseFormRegister<TenantFormValues>;
control: Control<TenantFormValues>;
errors: FieldErrors<TenantFormValues>;
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit']; onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean;
clientId: string; clientId: string;
planId: string; planId: string;
onClientChange: (clientId: string) => void; onClientChange: (clientId: string) => void;
@@ -43,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({
@@ -51,7 +58,11 @@ export function TenantSheet({
tenantId, tenantId,
isEditMode, isEditMode,
register, register,
control,
errors,
isSubmitted,
onSubmit, onSubmit,
canSubmit,
clientId, clientId,
planId, planId,
onClientChange, onClientChange,
@@ -62,157 +73,259 @@ export function TenantSheet({
isLookupsLoading, isLookupsLoading,
isSaving, isSaving,
adminEmail, adminEmail,
adminFieldLocks,
}: TenantSheetProps) { }: TenantSheetProps) {
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
const fieldError = (field: keyof TenantFormValues) =>
isSubmitted ? errors[field]?.message : undefined;
const clientOptions = useMemo(
() =>
clients.map((client) => ({
value: String(client.id),
label: [client.first_name, client.last_name].filter(Boolean).join(' '),
})),
[clients],
);
const planOptions = useMemo(
() =>
plans.map((plan) => ({
value: String(plan.id),
label: plan.name,
})),
[plans],
);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl"> <DialogContent className="sm:max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{tenantId ? 'Edit Tenant' : 'Create Tenant'}</DialogTitle> <DialogTitle>
{tenantId ? 'Edit Tenant' : 'Create Tenant'}
</DialogTitle>
<DialogDescription> <DialogDescription>
Bind a client and subscription plan, then invite the tenant administrator. Bind a client and subscription plan, then invite the tenant
administrator.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="space-y-1">
<p className="text-sm font-medium">Basic Information</p>
<p className="text-xs text-muted-foreground">Tenant identity and subscription binding.</p>
</div>
<div className="grid gap-4 md:grid-cols-2"> <form
<div className="space-y-2"> noValidate
<Label htmlFor="tenant-name">Tenant Name</Label> onSubmit={onSubmit}
<Input className="flex min-h-0 flex-1 flex-col"
id="tenant-name" >
placeholder="Acme Corp" <DialogBody className="space-y-5">
{...register('name', { <div className="space-y-1">
required: true, <p>Basic Information</p>
onChange: (event) => onNameChange(event.target.value), <p className="text-muted-foreground">
})} Tenant identity and subscription binding.
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-slug">Slug</Label>
<Input
id="tenant-slug"
placeholder="acme-corp"
{...register('slug', { required: true })}
required
readOnly={isEditMode}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Client</Label>
<Select value={clientId} onValueChange={onClientChange} disabled={isLookupsLoading}>
<SelectTrigger>
<SelectValue placeholder={isLookupsLoading ? 'Loading clients...' : 'Select client'} />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={String(client.id)}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Subscription Plan</Label>
<Select value={planId} onValueChange={onPlanChange} disabled={isLookupsLoading}>
<SelectTrigger>
<SelectValue placeholder={isLookupsLoading ? 'Loading plans...' : 'Select plan'} />
</SelectTrigger>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.id} value={String(plan.id)}>
{plan.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tenant-domain">Domain</Label>
<Input id="tenant-domain" placeholder="acme.example.com" {...register('domain')} />
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="tenant-description">Description</Label>
<textarea
id="tenant-description"
placeholder="North zone operations"
{...register('description')}
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</div>
{!isEditMode ? (
<>
<div className="space-y-1">
<p className="text-sm font-medium">Tenant Administrator</p>
<p className="text-xs text-muted-foreground">
Invitation details for the primary tenant admin account.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="admin-first-name">First Name</Label>
<Input
id="admin-first-name"
placeholder="Jane"
{...register('admin_first_name', { required: !isEditMode })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="admin-last-name">Last Name</Label>
<Input
id="admin-last-name"
placeholder="Doe"
{...register('admin_last_name', { required: !isEditMode })}
required
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="admin-email">Admin Email</Label>
<Input
id="admin-email"
type="email"
placeholder="jane@acme.com"
{...register('admin_email', { required: !isEditMode })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="admin-phone">Phone Number</Label>
<Input
id="admin-phone"
placeholder="+91-9000011111"
{...register('admin_phone_number')}
/>
</div>
</div>
</>
) : adminEmail ? (
<div className="rounded-md border bg-muted/40 p-4 text-sm text-muted-foreground">
Admin invitation details cannot be changed after tenant creation.
<p className="mt-1 text-foreground">
Current admin email: <span className="font-medium">{adminEmail}</span>
</p> </p>
</div> </div>
) : null}
<DialogFooter className="px-0"> <div className="grid gap-4 md:grid-cols-2">
<FormField
id="tenant-name"
label="Tenant Name"
required
error={fieldError('name')}
>
<Input
id="tenant-name"
placeholder="Enter tenant name"
aria-invalid={!!fieldError('name')}
{...register('name', {
onChange: (event) => onNameChange(event.target.value),
})}
/>
</FormField>
<FormField
id="tenant-slug"
label="Slug"
required
error={fieldError('slug')}
>
<Input
id="tenant-slug"
placeholder="acme-corp"
aria-invalid={!!fieldError('slug')}
{...register('slug')}
readOnly={isEditMode}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="tenant-client"
label="Client"
required
error={fieldError('client_id')}
>
<SelectPopover
options={clientOptions}
value={clientId}
onValueChange={onClientChange}
disabled={isLookupsLoading}
portalContainer={dropdownPortalRef}
placeholder={
isLookupsLoading ? 'Loading clients...' : 'Select client'
}
searchPlaceholder="Search clients"
emptyMessage="No clients found."
/>
<input
type="hidden"
{...register('client_id')}
value={clientId}
readOnly
/>
</FormField>
<FormField
id="tenant-plan"
label="Subscription Plan"
required
error={fieldError('plan_id')}
>
<SelectPopover
options={planOptions}
value={planId}
onValueChange={onPlanChange}
disabled={isLookupsLoading}
portalContainer={dropdownPortalRef}
placeholder={
isLookupsLoading ? 'Loading plans...' : 'Select plan'
}
searchPlaceholder="Search plans"
emptyMessage="No plans found."
/>
<input
type="hidden"
{...register('plan_id')}
value={planId}
readOnly
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField id="tenant-domain" label="Domain">
<Input
id="tenant-domain"
placeholder="acme.example.com"
{...register('domain')}
/>
</FormField>
<FormField
id="tenant-description"
label="Description"
className="md:col-span-2"
>
<textarea
id="tenant-description"
placeholder="North zone operations"
{...register('description')}
className="min-h-20 w-full rounded-lg border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
/>
</FormField>
</div>
{!isEditMode ? (
<>
<div className="space-y-1">
<p>Tenant Administrator</p>
<p className="text-muted-foreground">
Invitation details for the primary tenant admin account.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="admin-first-name"
label="First Name"
required
error={fieldError('admin_first_name')}
>
<Input
id="admin-first-name"
placeholder="Enter first name"
aria-invalid={!!fieldError('admin_first_name')}
readOnly={adminFieldLocks.admin_first_name}
{...register('admin_first_name')}
/>
</FormField>
<FormField
id="admin-last-name"
label="Last Name"
required
error={fieldError('admin_last_name')}
>
<Input
id="admin-last-name"
placeholder="Enter last name"
aria-invalid={!!fieldError('admin_last_name')}
readOnly={adminFieldLocks.admin_last_name}
{...register('admin_last_name')}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="admin-email"
label="Admin Email"
required
error={fieldError('admin_email')}
>
<Input
id="admin-email"
type="email"
placeholder="admin@example.com"
aria-invalid={!!fieldError('admin_email')}
readOnly={adminFieldLocks.admin_email}
{...register('admin_email')}
/>
</FormField>
<FormField
id="admin-phone"
label="Phone Number"
error={fieldError('admin_phone_number')}
>
<Controller
control={control}
name="admin_phone_number"
render={({ field }) => (
<PhoneInput
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Enter phone number"
aria-invalid={!!fieldError('admin_phone_number')}
disabled={adminFieldLocks.admin_phone_number}
portalContainer={dropdownPortalRef}
/>
)}
/>
</FormField>
</div>
</>
) : adminEmail ? (
<div className="rounded-lg border bg-muted/40 p-4 text-muted-foreground">
Admin invitation details cannot be changed after tenant
creation.
<p className="mt-1 text-foreground">
Current admin email: <span>{adminEmail}</span>
</p>
</div>
) : null}
</DialogBody>
<DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -221,12 +334,19 @@ export function TenantSheet({
> >
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSaving || isLookupsLoading}> <Button
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null} type="submit"
disabled={isSaving || isLookupsLoading || !canSubmit}
>
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{tenantId ? 'Update Tenant' : 'Create Tenant'} {tenantId ? 'Update Tenant' : 'Create Tenant'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
<div ref={dropdownPortalRef} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -2,8 +2,10 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { ColumnDef, SortingState } from '@tanstack/react-table'; import type { ColumnDef, SortingState } from '@tanstack/react-table';
import { Edit, Trash2 } from 'lucide-react';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { PERMISSIONS } from '@/constants/permissions';
import type { Tenant } from '@/types'; import type { Tenant } from '@/types';
interface TenantTableProps { interface TenantTableProps {
@@ -18,6 +20,9 @@ interface TenantTableProps {
onPageChange: (skip: number) => void; onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void; onLimitChange: (limit: number) => void;
onSortingChange: (sorting: SortingState) => void; onSortingChange: (sorting: SortingState) => void;
onEdit: (tenant: Tenant) => void;
onDelete: (tenant: Tenant) => void;
pendingDeleteId?: number;
} }
export function TenantTable({ export function TenantTable({
@@ -32,6 +37,9 @@ export function TenantTable({
onPageChange, onPageChange,
onLimitChange, onLimitChange,
onSortingChange, onSortingChange,
onEdit,
onDelete,
pendingDeleteId,
}: TenantTableProps) { }: TenantTableProps) {
return ( return (
<DataTable <DataTable
@@ -41,6 +49,22 @@ export function TenantTable({
isLoading={isLoading} isLoading={isLoading}
toolbar={toolbar} toolbar={toolbar}
emptyTitle="No tenants found." emptyTitle="No tenants found."
actions={[
{
label: 'Edit',
icon: <Edit className="size-4" />,
permission: PERMISSIONS.TENANT.UPDATE,
onClick: onEdit,
},
{
label: 'Delete',
icon: <Trash2 className="size-4" />,
permission: PERMISSIONS.TENANT.DELETE,
className: 'text-destructive',
disabled: (tenant) => pendingDeleteId === tenant.id,
onClick: onDelete,
},
]}
pagination={{ pagination={{
skip, skip,
limit, limit,
@@ -50,6 +74,7 @@ export function TenantTable({
}} }}
sorting={sorting} sorting={sorting}
onSortingChange={onSortingChange} onSortingChange={onSortingChange}
actionsSticky
/> />
); );
} }

View File

@@ -12,7 +12,8 @@ export function useTenantFilters() {
const [limit, setLimitValue] = useState(10); const [limit, setLimitValue] = useState(10);
const [sorting, setSortingValue] = useState<SortingState>([]); const [sorting, setSortingValue] = useState<SortingState>([]);
const [searchTerm, setSearchTermValue] = useState(''); const [searchTerm, setSearchTermValue] = useState('');
const [statusFilter, setStatusFilterValue] = useState<TenantStatusFilter>('all'); const [statusFilter, setStatusFilterValue] =
useState<TenantStatusFilter>('all');
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400); const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
const setSearchTerm = useCallback((value: string) => { const setSearchTerm = useCallback((value: string) => {

View File

@@ -1,26 +1,74 @@
'use client'; 'use client';
import { useCallback } from 'react'; import { zodResolver } from '@hookform/resolvers/zod';
import { isValidPhoneNumber } from 'libphonenumber-js';
import { useCallback, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner'; import { z } from 'zod';
import type { Tenant } from '@/types'; import type { Tenant } from '@/types';
import { useSaveTenantMutation } from './useTenantMutations'; import { useSaveTenantMutation } from './useTenantMutations';
export interface TenantFormValues { const requiredText = (message: string) => z.string().trim().min(1, message);
id?: number;
name: string; const tenantFormSchema = z
slug: string; .object({
client_id: string; id: z.number().optional(),
plan_id: string; name: requiredText('Tenant name is required'),
domain: string; slug: requiredText('Slug is required'),
description: string; client_id: requiredText('Client is required'),
admin_first_name: string; plan_id: requiredText('Plan is required'),
admin_last_name: string; domain: z.string().trim(),
admin_email: string; description: z.string().trim(),
admin_phone_number: string; admin_first_name: z.string().trim(),
} admin_last_name: z.string().trim(),
admin_email: z.string().trim(),
admin_phone_number: z
.string()
.trim()
.refine((value) => value === '' || isValidPhoneNumber(value), {
message: 'Enter a valid phone number',
}),
})
.superRefine((values, ctx) => {
if (values.id) return;
if (!values.admin_first_name.trim()) {
ctx.addIssue({
code: 'custom',
path: ['admin_first_name'],
message: 'First name is required',
});
}
if (!values.admin_last_name.trim()) {
ctx.addIssue({
code: 'custom',
path: ['admin_last_name'],
message: 'Last name is required',
});
}
if (!values.admin_email.trim()) {
ctx.addIssue({
code: 'custom',
path: ['admin_email'],
message: 'Admin email is required',
});
return;
}
if (!z.string().email().safeParse(values.admin_email).success) {
ctx.addIssue({
code: 'custom',
path: ['admin_email'],
message: 'Invalid admin email',
});
}
});
export type TenantFormValues = z.infer<typeof tenantFormSchema>;
const defaultValues: TenantFormValues = { const defaultValues: TenantFormValues = {
name: '', name: '',
@@ -50,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,
@@ -57,44 +119,49 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
reset, reset,
control, control,
setValue, setValue,
formState: { isSubmitting }, formState: { errors, isSubmitting, isSubmitted },
} = useForm<TenantFormValues>({ } = useForm<TenantFormValues>({
defaultValues, defaultValues,
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(tenantFormSchema),
}); });
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' }) || '';
const name = useWatch({ control, name: 'name' }) || '';
const slug = useWatch({ control, name: 'slug' }) || '';
const adminFirstName = useWatch({ control, name: 'admin_first_name' }) || '';
const adminLastName = useWatch({ control, name: 'admin_last_name' }) || '';
const adminEmail = useWatch({ control, name: 'admin_email' }) || ''; const adminEmail = useWatch({ control, name: 'admin_email' }) || '';
const isEditMode = Boolean(tenantId); const isEditMode = Boolean(tenantId);
const canSubmit =
name.trim().length > 0 &&
slug.trim().length > 0 &&
clientId.trim().length > 0 &&
planId.trim().length > 0 &&
(isEditMode ||
(adminFirstName.trim().length > 0 &&
adminLastName.trim().length > 0 &&
adminEmail.trim().length > 0));
const handleSubmit = submitForm( const handleSubmit = submitForm((values) => saveMutation.mutate(values));
(values) => {
if (!values.client_id || !values.plan_id) {
toast.error('Select a client and plan');
return;
}
if (!isEditMode) {
if (!values.admin_first_name.trim() || !values.admin_last_name.trim() || !values.admin_email.trim()) {
toast.error('Complete all required admin fields');
return;
}
}
saveMutation.mutate(values);
},
() => toast.error('Complete all required tenant fields'),
);
const openCreate = useCallback(() => { const openCreate = useCallback(() => {
reset(defaultValues); reset(defaultValues);
setAdminFieldLocks(defaultAdminFieldLocks);
}, [reset]); }, [reset]);
const openEdit = useCallback( const openEdit = useCallback(
@@ -112,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],
); );
@@ -139,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,
@@ -150,7 +235,12 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
planId, planId,
adminEmail, adminEmail,
isEditMode, isEditMode,
errors,
isSubmitted,
canSubmit,
adminFieldLocks,
applyClientAdminDefaults, applyClientAdminDefaults,
clearAdminFields,
syncSlugFromName, syncSlugFromName,
isSaving: isSubmitting || saveMutation.isPending, isSaving: isSubmitting || saveMutation.isPending,
}; };

View File

@@ -58,7 +58,9 @@ export function useSaveTenantMutation({ onSaved }: { onSaved: () => void }) {
onSaved(); onSaved();
}, },
onError: (_error, values) => { onError: (_error, values) => {
toast.error(values.id ? 'Failed to update tenant' : 'Failed to create tenant'); toast.error(
values.id ? 'Failed to update tenant' : 'Failed to create tenant',
);
}, },
}); });
} }

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

@@ -4,7 +4,6 @@ import { useCallback, useMemo, useState } from 'react';
import { Globe, Plus } from 'lucide-react'; import { Globe, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards'; import { PermissionGuard } from '@/guards';
@@ -55,6 +54,7 @@ export default function TenantsPage() {
}); });
const { const {
register, register,
control,
handleSubmit, handleSubmit,
openCreate: prepareCreateTenant, openCreate: prepareCreateTenant,
openEdit: prepareEditTenant, openEdit: prepareEditTenant,
@@ -63,7 +63,12 @@ export default function TenantsPage() {
planId, planId,
adminEmail, adminEmail,
isEditMode, isEditMode,
errors,
isSubmitted,
canSubmit,
adminFieldLocks,
applyClientAdminDefaults, applyClientAdminDefaults,
clearAdminFields,
syncSlugFromName, syncSlugFromName,
setValue, setValue,
isSaving, isSaving,
@@ -75,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(
@@ -85,7 +94,7 @@ export default function TenantsPage() {
); );
}); });
}, },
[applyClientAdminDefaults, isEditMode, setValue], [applyClientAdminDefaults, clearAdminFields, isEditMode, setValue],
); );
const handlePlanChange = useCallback( const handlePlanChange = useCallback(
@@ -117,17 +126,14 @@ export default function TenantsPage() {
[deleteMutation], [deleteMutation],
); );
const columns = useTenantColumns({ const columns = useTenantColumns();
onEdit: openEdit,
onDelete: handleDelete,
pendingDeleteId: deleteMutation.isPending ? deleteMutation.variables : undefined,
});
const tenants = tenantsQuery.data?.items ?? []; const tenants = tenantsQuery.data?.items ?? [];
const total = tenantsQuery.data?.total ?? 0; const total = tenantsQuery.data?.total ?? 0;
const clients = clientsLookupQuery.data?.items ?? []; const clients = clientsLookupQuery.data?.items ?? [];
const plans = plansLookupQuery.data?.items ?? []; const plans = plansLookupQuery.data?.items ?? [];
const isLookupsLoading = clientsLookupQuery.isLoading || plansLookupQuery.isLoading; const isLookupsLoading =
clientsLookupQuery.isLoading || plansLookupQuery.isLoading;
const toolbar = useMemo( const toolbar = useMemo(
() => ( () => (
@@ -170,9 +176,12 @@ export default function TenantsPage() {
onPageChange={setSkip} onPageChange={setSkip}
onLimitChange={setLimit} onLimitChange={setLimit}
onSortingChange={setSorting} onSortingChange={setSorting}
onEdit={openEdit}
onDelete={handleDelete}
pendingDeleteId={
deleteMutation.isPending ? deleteMutation.variables : undefined
}
/> />
<PoweredBy />
</main> </main>
<TenantSheet <TenantSheet
@@ -181,7 +190,11 @@ export default function TenantsPage() {
tenantId={tenantId} tenantId={tenantId}
isEditMode={isEditMode} isEditMode={isEditMode}
register={register} register={register}
control={control}
errors={errors}
isSubmitted={isSubmitted}
onSubmit={handleSubmit} onSubmit={handleSubmit}
canSubmit={canSubmit}
clientId={clientId} clientId={clientId}
planId={planId} planId={planId}
onClientChange={handleClientChange} onClientChange={handleClientChange}
@@ -192,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,161 @@
'use client';
import { useState, type FormEvent } from 'react';
import { Loader2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import type { DetectionDiscardReason, DiscardDetectionPayload } from '@/types';
const DISCARD_REASON_OPTIONS: Array<{
value: DetectionDiscardReason;
label: string;
}> = [
{ value: 'not_a_defect', label: 'Not a defect' },
{ value: 'wrong_class', label: 'Wrong classification' },
{ value: 'duplicate', label: 'Duplicate detection' },
{ value: 'poor_image_quality', label: 'Poor image quality' },
{ value: 'incorrect_location', label: 'Incorrect location' },
{ value: 'already_repaired', label: 'Already repaired' },
{ value: 'other', label: 'Other' },
];
interface DetectionDiscardDialogProps {
open: boolean;
detectionLabel: string;
isPending: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: DiscardDetectionPayload) => Promise<void>;
}
export function DetectionDiscardDialog({
open,
detectionLabel,
isPending,
onOpenChange,
onSubmit,
}: DetectionDiscardDialogProps) {
const [reason, setReason] = useState<DetectionDiscardReason | ''>('');
const [comment, setComment] = useState('');
const resetForm = () => {
setReason('');
setComment('');
};
const handleOpenChange = (nextOpen: boolean) => {
if (isPending) return;
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!reason || isPending) return;
try {
await onSubmit({
reason,
comment: comment.trim() || undefined,
});
resetForm();
} catch {
// The mutation displays the request error and keeps the dialog open.
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent showCloseButton={!isPending}>
<form className="flex min-h-0 flex-1 flex-col" onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Discard detection</DialogTitle>
<DialogDescription>
Remove {detectionLabel} from this ticket and record why the AI
result is incorrect.
</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-5">
<div className="space-y-2">
<Label htmlFor="discard-reason">Reason</Label>
<Select
value={reason}
onValueChange={(value) =>
setReason(value as DetectionDiscardReason)
}
disabled={isPending}
>
<SelectTrigger id="discard-reason" className="w-full">
<SelectValue placeholder="Select a reason" />
</SelectTrigger>
<SelectContent>
{DISCARD_REASON_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="discard-comment">Comment</Label>
<span className="text-xs text-muted-foreground">
Optional | {comment.length}/500
</span>
</div>
<Textarea
id="discard-comment"
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder="Add details that can help improve future detections"
rows={4}
maxLength={500}
disabled={isPending}
/>
</div>
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
disabled={!reason || isPending}
>
{isPending ? <Loader2 className="animate-spin" /> : <Trash2 />}
{isPending ? 'Discarding' : 'Discard detection'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,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,318 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
import DetectionLocationMap from '@/components/map/detectionLocationMap';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import { cn } from '@/lib/utils';
import {
useDiscardDetectionMutation,
useTicketClassDetectionsQuery,
} from '../../hooks/useTicketQueries';
import { DetectionDiscardDialog } from './DetectionDiscardDialog';
interface TicketClassDetectionPreviewProps {
ticketId: string;
videoId?: string | null;
defectClassName: string;
displayName: string;
totalCount?: number;
}
function DetectionMetadataBar({
items,
}: {
items: { label: string; value: string | number }[];
}) {
return (
<div className="rounded-lg bg-muted/40 px-4 py-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:divide-x sm:divide-border/70">
{items.map((item, index) => (
<div
key={item.label}
className={cn(
'space-y-1',
index === 0 && 'sm:pr-4',
index === 1 && 'sm:px-4',
index === 2 && 'sm:pl-4',
)}
>
<p className="text-xs text-muted-foreground">{item.label}</p>
<div className="text-sm font-semibold text-foreground">
{item.value}
</div>
</div>
))}
</div>
</div>
);
}
function TicketClassDetectionPreviewSkeleton() {
return (
<>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="min-w-0 space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="aspect-video w-full rounded-lg" />
</div>
<div className="min-w-0 space-y-2">
<Skeleton className="h-3 w-28" />
<Skeleton
className="w-full rounded-lg"
style={{ aspectRatio: '16 / 9' }}
/>
</div>
</div>
<div className="rounded-lg bg-muted/40 px-4 py-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:divide-x sm:divide-border/70">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className={cn(
'space-y-1',
index === 0 && 'sm:pr-4',
index === 1 && 'sm:px-4',
index === 2 && 'sm:pl-4',
)}
>
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
</div>
</>
);
}
export function TicketClassDetectionPreview({
ticketId,
videoId,
defectClassName,
displayName,
totalCount,
}: TicketClassDetectionPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const visual = getDefectVisual(defectClassName);
const IssueIcon = visual.icon;
useEffect(() => {
setCurrentIndex(0);
}, [defectClassName]);
const queryParams = useMemo(
() => ({
class_name: defectClassName,
skip: currentIndex,
limit: 1,
sort: 'timestamp_asc',
}),
[currentIndex, defectClassName],
);
const detectionsQuery = useTicketClassDetectionsQuery(
videoId ?? undefined,
queryParams,
);
const discardMutation = useDiscardDetectionMutation(
ticketId,
videoId ?? undefined,
defectClassName,
);
const detectionResult = detectionsQuery.data;
const activeDetection = detectionResult?.items[0];
const detectionCount = detectionResult?.total ?? totalCount ?? 0;
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 className="flex justify-end">
<Button
type="button"
variant="destructive"
onClick={() => setIsDiscardDialogOpen(true)}
>
<Trash2 />
Discard detection
</Button>
</div>
<DetectionDiscardDialog
open={isDiscardDialogOpen}
detectionLabel={activeDetection.detection.display_name}
isPending={discardMutation.isPending}
onOpenChange={setIsDiscardDialogOpen}
onSubmit={async (payload) => {
await discardMutation.mutateAsync({
detectionId: activeDetection.detection.id,
payload,
});
setIsDiscardDialogOpen(false);
}}
/>
</>
)}
</div>
);
}

View File

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

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

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