148 Commits

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

7
.cursor/mcp.json Normal file
View File

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

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
# Server-only backend origin. Do not expose this as a NEXT_PUBLIC variable.
BACKEND_ORIGIN=http://localhost:8000

View File

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

2
.gitignore vendored
View File

@@ -44,9 +44,11 @@ Thumbs.db
# TypeScript # TypeScript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts
# Test / coverage # Test / coverage
coverage/ coverage/
# Misc # Misc
.vercel .vercel
.dual-graph/

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/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

21
next.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { NextConfig } from 'next';
const backendOrigin = process.env.BACKEND_ORIGIN?.replace(/\/+$/, '');
if (!backendOrigin) {
throw new Error('BACKEND_ORIGIN is required');
}
const nextConfig: NextConfig = {
reactStrictMode: false,
async rewrites() {
return [
{
source: '/:namespace(api|biz)/:path*',
destination: `${backendOrigin}/:namespace/:path*`,
},
];
},
};
export default nextConfig;

5339
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,20 +6,14 @@
"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",
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@@ -29,22 +23,35 @@
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^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",
"@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.4.3", "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-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",
"zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.9", "@tailwindcss/postcss": "^4.1.9",
@@ -53,12 +60,12 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"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",

5
public/color_logo.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="#372AAC"/>
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8026C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0642C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="#372AAC"/>
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="#372AAC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

740
public/flags/flags.css Normal file
View File

@@ -0,0 +1,740 @@
span.flag {
width: 44px;
height: 30px;
display: inline-block;
}
img.flag {
width: 30px;
}
.flag {
background: url(./flags_responsive.png) no-repeat;
background-size: 100%;
vertical-align: middle;
}
.flag-ad {
background-position: 0 0.413223%;
}
.flag-ae {
background-position: 0 0.826446%;
}
.flag-af {
background-position: 0 1.239669%;
}
.flag-ag {
background-position: 0 1.652893%;
}
.flag-ai {
background-position: 0 2.066116%;
}
.flag-al {
background-position: 0 2.479339%;
}
.flag-am {
background-position: 0 2.892562%;
}
.flag-an {
background-position: 0 3.305785%;
}
.flag-ao {
background-position: 0 3.719008%;
}
.flag-aq {
background-position: 0 4.132231%;
}
.flag-ar {
background-position: 0 4.545455%;
}
.flag-as {
background-position: 0 4.958678%;
}
.flag-at {
background-position: 0 5.371901%;
}
.flag-au {
background-position: 0 5.785124%;
}
.flag-aw {
background-position: 0 6.198347%;
}
.flag-az {
background-position: 0 6.61157%;
}
.flag-ba {
background-position: 0 7.024793%;
}
.flag-bb {
background-position: 0 7.438017%;
}
.flag-bd {
background-position: 0 7.85124%;
}
.flag-be {
background-position: 0 8.264463%;
}
.flag-bf {
background-position: 0 8.677686%;
}
.flag-bg {
background-position: 0 9.090909%;
}
.flag-bh {
background-position: 0 9.504132%;
}
.flag-bi {
background-position: 0 9.917355%;
}
.flag-bj {
background-position: 0 10.330579%;
}
.flag-bm {
background-position: 0 10.743802%;
}
.flag-bn {
background-position: 0 11.157025%;
}
.flag-bo {
background-position: 0 11.570248%;
}
.flag-br {
background-position: 0 11.983471%;
}
.flag-bs {
background-position: 0 12.396694%;
}
.flag-bt {
background-position: 0 12.809917%;
}
.flag-bv {
background-position: 0 13.22314%;
}
.flag-bw {
background-position: 0 13.636364%;
}
.flag-by {
background-position: 0 14.049587%;
}
.flag-bz {
background-position: 0 14.46281%;
}
.flag-ca {
background-position: 0 14.876033%;
}
.flag-cc {
background-position: 0 15.289256%;
}
.flag-cd {
background-position: 0 15.702479%;
}
.flag-cf {
background-position: 0 16.115702%;
}
.flag-cg {
background-position: 0 16.528926%;
}
.flag-ch {
background-position: 0 16.942149%;
}
.flag-ci {
background-position: 0 17.355372%;
}
.flag-ck {
background-position: 0 17.768595%;
}
.flag-cl {
background-position: 0 18.181818%;
}
.flag-cm {
background-position: 0 18.595041%;
}
.flag-cn {
background-position: 0 19.008264%;
}
.flag-co {
background-position: 0 19.421488%;
}
.flag-cr {
background-position: 0 19.834711%;
}
.flag-cu {
background-position: 0 20.247934%;
}
.flag-cv {
background-position: 0 20.661157%;
}
.flag-cx {
background-position: 0 21.07438%;
}
.flag-cy {
background-position: 0 21.487603%;
}
.flag-cz {
background-position: 0 21.900826%;
}
.flag-de {
background-position: 0 22.31405%;
}
.flag-dj {
background-position: 0 22.727273%;
}
.flag-dk {
background-position: 0 23.140496%;
}
.flag-dm {
background-position: 0 23.553719%;
}
.flag-do {
background-position: 0 23.966942%;
}
.flag-dz {
background-position: 0 24.380165%;
}
.flag-ec {
background-position: 0 24.793388%;
}
.flag-ee {
background-position: 0 25.206612%;
}
.flag-eg {
background-position: 0 25.619835%;
}
.flag-eh {
background-position: 0 26.033058%;
}
.flag-er {
background-position: 0 26.446281%;
}
.flag-es {
background-position: 0 26.859504%;
}
.flag-et {
background-position: 0 27.272727%;
}
.flag-fi {
background-position: 0 27.68595%;
}
.flag-fj {
background-position: 0 28.099174%;
}
.flag-fk {
background-position: 0 28.512397%;
}
.flag-fm {
background-position: 0 28.92562%;
}
.flag-fo {
background-position: 0 29.338843%;
}
.flag-fr {
background-position: 0 29.752066%;
}
.flag-ga {
background-position: 0 30.165289%;
}
.flag-gd {
background-position: 0 30.578512%;
}
.flag-ge {
background-position: 0 30.991736%;
}
.flag-gf {
background-position: 0 31.404959%;
}
.flag-gh {
background-position: 0 31.818182%;
}
.flag-gi {
background-position: 0 32.231405%;
}
.flag-gl {
background-position: 0 32.644628%;
}
.flag-gm {
background-position: 0 33.057851%;
}
.flag-gn {
background-position: 0 33.471074%;
}
.flag-gp {
background-position: 0 33.884298%;
}
.flag-gq {
background-position: 0 34.297521%;
}
.flag-gr {
background-position: 0 34.710744%;
}
.flag-gs {
background-position: 0 35.123967%;
}
.flag-gt {
background-position: 0 35.53719%;
}
.flag-gu {
background-position: 0 35.950413%;
}
.flag-gw {
background-position: 0 36.363636%;
}
.flag-gy {
background-position: 0 36.77686%;
}
.flag-hk {
background-position: 0 37.190083%;
}
.flag-hm {
background-position: 0 37.603306%;
}
.flag-hn {
background-position: 0 38.016529%;
}
.flag-hr {
background-position: 0 38.429752%;
}
.flag-ht {
background-position: 0 38.842975%;
}
.flag-hu {
background-position: 0 39.256198%;
}
.flag-id {
background-position: 0 39.669421%;
}
.flag-ie {
background-position: 0 40.082645%;
}
.flag-il {
background-position: 0 40.495868%;
}
.flag-in {
background-position: 0 40.909091%;
}
.flag-io {
background-position: 0 41.322314%;
}
.flag-iq {
background-position: 0 41.735537%;
}
.flag-ir {
background-position: 0 42.14876%;
}
.flag-is {
background-position: 0 42.561983%;
}
.flag-it {
background-position: 0 42.975207%;
}
.flag-jm {
background-position: 0 43.38843%;
}
.flag-jo {
background-position: 0 43.801653%;
}
.flag-jp {
background-position: 0 44.214876%;
}
.flag-ke {
background-position: 0 44.628099%;
}
.flag-kg {
background-position: 0 45.041322%;
}
.flag-kh {
background-position: 0 45.454545%;
}
.flag-ki {
background-position: 0 45.867769%;
}
.flag-km {
background-position: 0 46.280992%;
}
.flag-kn {
background-position: 0 46.694215%;
}
.flag-kp {
background-position: 0 47.107438%;
}
.flag-kr {
background-position: 0 47.520661%;
}
.flag-kw {
background-position: 0 47.933884%;
}
.flag-ky {
background-position: 0 48.347107%;
}
.flag-kz {
background-position: 0 48.760331%;
}
.flag-la {
background-position: 0 49.173554%;
}
.flag-lb {
background-position: 0 49.586777%;
}
.flag-lc {
background-position: 0 50%;
}
.flag-li {
background-position: 0 50.413223%;
}
.flag-lk {
background-position: 0 50.826446%;
}
.flag-lr {
background-position: 0 51.239669%;
}
.flag-ls {
background-position: 0 51.652893%;
}
.flag-lt {
background-position: 0 52.066116%;
}
.flag-lu {
background-position: 0 52.479339%;
}
.flag-lv {
background-position: 0 52.892562%;
}
.flag-ly {
background-position: 0 53.305785%;
}
.flag-ma {
background-position: 0 53.719008%;
}
.flag-mc {
background-position: 0 54.132231%;
}
.flag-md {
background-position: 0 54.545455%;
}
.flag-me {
background-position: 0 54.958678%;
}
.flag-mg {
background-position: 0 55.371901%;
}
.flag-mh {
background-position: 0 55.785124%;
}
.flag-mk {
background-position: 0 56.198347%;
}
.flag-ml {
background-position: 0 56.61157%;
}
.flag-mm {
background-position: 0 57.024793%;
}
.flag-mn {
background-position: 0 57.438017%;
}
.flag-mo {
background-position: 0 57.85124%;
}
.flag-mp {
background-position: 0 58.264463%;
}
.flag-mq {
background-position: 0 58.677686%;
}
.flag-mr {
background-position: 0 59.090909%;
}
.flag-ms {
background-position: 0 59.504132%;
}
.flag-mt {
background-position: 0 59.917355%;
}
.flag-mu {
background-position: 0 60.330579%;
}
.flag-mv {
background-position: 0 60.743802%;
}
.flag-mw {
background-position: 0 61.157025%;
}
.flag-mx {
background-position: 0 61.570248%;
}
.flag-my {
background-position: 0 61.983471%;
}
.flag-mz {
background-position: 0 62.396694%;
}
.flag-na {
background-position: 0 62.809917%;
}
.flag-nc {
background-position: 0 63.22314%;
}
.flag-ne {
background-position: 0 63.636364%;
}
.flag-nf {
background-position: 0 64.049587%;
}
.flag-ng {
background-position: 0 64.46281%;
}
.flag-ni {
background-position: 0 64.876033%;
}
.flag-nl {
background-position: 0 65.289256%;
}
.flag-no {
background-position: 0 65.702479%;
}
.flag-np {
background-position: 0 66.115702%;
}
.flag-nr {
background-position: 0 66.528926%;
}
.flag-nu {
background-position: 0 66.942149%;
}
.flag-nz {
background-position: 0 67.355372%;
}
.flag-om {
background-position: 0 67.768595%;
}
.flag-pa {
background-position: 0 68.181818%;
}
.flag-pe {
background-position: 0 68.595041%;
}
.flag-pf {
background-position: 0 69.008264%;
}
.flag-pg {
background-position: 0 69.421488%;
}
.flag-ph {
background-position: 0 69.834711%;
}
.flag-pk {
background-position: 0 70.247934%;
}
.flag-pl {
background-position: 0 70.661157%;
}
.flag-pm {
background-position: 0 71.07438%;
}
.flag-pn {
background-position: 0 71.487603%;
}
.flag-pr {
background-position: 0 71.900826%;
}
.flag-pt {
background-position: 0 72.31405%;
}
.flag-pw {
background-position: 0 72.727273%;
}
.flag-py {
background-position: 0 73.140496%;
}
.flag-qa {
background-position: 0 73.553719%;
}
.flag-re {
background-position: 0 73.966942%;
}
.flag-ro {
background-position: 0 74.380165%;
}
.flag-rs {
background-position: 0 74.793388%;
}
.flag-ru {
background-position: 0 75.206612%;
}
.flag-rw {
background-position: 0 75.619835%;
}
.flag-sa {
background-position: 0 76.033058%;
}
.flag-sb {
background-position: 0 76.446281%;
}
.flag-sc {
background-position: 0 76.859504%;
}
.flag-sd {
background-position: 0 77.272727%;
}
.flag-se {
background-position: 0 77.68595%;
}
.flag-sg {
background-position: 0 78.099174%;
}
.flag-sh {
background-position: 0 78.512397%;
}
.flag-si {
background-position: 0 78.92562%;
}
.flag-sj {
background-position: 0 79.338843%;
}
.flag-sk {
background-position: 0 79.752066%;
}
.flag-sl {
background-position: 0 80.165289%;
}
.flag-sm {
background-position: 0 80.578512%;
}
.flag-sn {
background-position: 0 80.991736%;
}
.flag-so {
background-position: 0 81.404959%;
}
.flag-sr {
background-position: 0 81.818182%;
}
.flag-ss {
background-position: 0 82.231405%;
}
.flag-st {
background-position: 0 82.644628%;
}
.flag-sv {
background-position: 0 83.057851%;
}
.flag-sy {
background-position: 0 83.471074%;
}
.flag-sz {
background-position: 0 83.884298%;
}
.flag-tc {
background-position: 0 84.297521%;
}
.flag-td {
background-position: 0 84.710744%;
}
.flag-tf {
background-position: 0 85.123967%;
}
.flag-tg {
background-position: 0 85.53719%;
}
.flag-th {
background-position: 0 85.950413%;
}
.flag-tj {
background-position: 0 86.363636%;
}
.flag-tk {
background-position: 0 86.77686%;
}
.flag-tl {
background-position: 0 87.190083%;
}
.flag-tm {
background-position: 0 87.603306%;
}
.flag-tn {
background-position: 0 88.016529%;
}
.flag-to {
background-position: 0 88.429752%;
}
.flag-tp {
background-position: 0 88.842975%;
}
.flag-tr {
background-position: 0 89.256198%;
}
.flag-tt {
background-position: 0 89.669421%;
}
.flag-tv {
background-position: 0 90.082645%;
}
.flag-tw {
background-position: 0 90.495868%;
}
.flag-ty {
background-position: 0 90.909091%;
}
.flag-tz {
background-position: 0 91.322314%;
}
.flag-ua {
background-position: 0 91.735537%;
}
.flag-ug {
background-position: 0 92.14876%;
}
.flag-gb,
.flag-uk {
background-position: 0 92.561983%;
}
.flag-um {
background-position: 0 92.975207%;
}
.flag-us {
background-position: 0 93.38843%;
}
.flag-uy {
background-position: 0 93.801653%;
}
.flag-uz {
background-position: 0 94.214876%;
}
.flag-va {
background-position: 0 94.628099%;
}
.flag-vc {
background-position: 0 95.041322%;
}
.flag-ve {
background-position: 0 95.454545%;
}
.flag-vg {
background-position: 0 95.867769%;
}
.flag-vi {
background-position: 0 96.280992%;
}
.flag-vn {
background-position: 0 96.694215%;
}
.flag-vu {
background-position: 0 97.107438%;
}
.flag-wf {
background-position: 0 97.520661%;
}
.flag-ws {
background-position: 0 97.933884%;
}
.flag-ye {
background-position: 0 98.347107%;
}
.flag-za {
background-position: 0 98.760331%;
}
.flag-zm {
background-position: 0 99.173554%;
}
.flag-zr {
background-position: 0 99.586777%;
}
.flag-zw {
background-position: 0 100%;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

5
public/logo.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="white"/>
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8025C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0641C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="white"/>
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

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

@@ -0,0 +1,90 @@
'use client';
import Link from 'next/link';
import { PasswordField } from '@/components/form';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AuthBackground } from '@/components/auth/AuthBackground';
import { GuestGuard } from '@/guards';
import { useLoginForm } from '@/hooks/useLoginForm';
import { ROUTES } from '@/utils/routes';
import { Loader2 } from 'lucide-react';
export default function LoginPage() {
const { register, handleSubmit, errors, isLoading } = useLoginForm();
return (
<GuestGuard>
<div className="flex min-h-screen w-full">
<AuthBackground />
<div className="flex flex-1 md:w-1/2 items-center justify-center p-6 border-l border-border h-screen">
<section className="w-full max-w-sm space-y-7">
<header className="space-y-2 text-center md:text-left">
<p className="text-sm font-medium text-muted-foreground">
RoadMonitor
</p>
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
<p className="text-sm text-muted-foreground">
Use your email and password to continue.
</p>
</header>
<form className="space-y-5" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="admin@visionroad.ai"
autoComplete="email"
disabled={isLoading}
aria-invalid={!!errors.email}
{...register('email', {
required: 'Email is required',
})}
/>
{errors.email && (
<p className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
<PasswordField
id="password"
label="Password"
required
labelEnd={
<Link href={ROUTES.FORGOT_PASSWORD} className="text-primary">
Forgot password?
</Link>
}
placeholder="Enter password"
autoComplete="current-password"
disabled={isLoading}
aria-invalid={!!errors.password}
error={errors.password?.message}
{...register('password', {
required: 'Password is required',
})}
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="size-4 animate-spin" />
Signing in
</>
) : (
'Sign in'
)}
</Button>
</form>
</section>
</div>
</div>
</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

@@ -0,0 +1,20 @@
'use client';
import { ShieldX } from 'lucide-react';
export default function AccessPage() {
return (
<main className="flex min-h-[calc(100vh-9rem)] items-center justify-center">
<div className="flex max-w-md flex-col items-center text-center">
<div className="mb-4 flex size-14 items-center justify-center rounded-full border bg-card">
<ShieldX className="size-7 text-muted-foreground" />
</div>
<h1 className="text-2xl font-semibold">Access denied</h1>
<p className="mt-2 text-sm text-muted-foreground">
You do not have permission to access any available module. Contact
your administrator if you need access.
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import type { Client } from '@/types';
function formatDate(value?: string | null) {
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(new Date(value));
}
export function useClientColumns(): ColumnDef<Client>[] {
return useMemo(() => {
return [
{
accessorKey: 'first_name',
header: 'Client',
cell: ({ row }) => (
<span className="font-medium">
{`${row.original.first_name} ${row.original.last_name}`.trim()}
</span>
),
},
{
accessorKey: 'email',
header: 'Email',
},
{
accessorKey: 'contact_name',
header: 'Contact',
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.contact_name}</p>
<p className="text-xs text-muted-foreground">
{row.original.contact_phone_number}
</p>
</div>
),
},
{
accessorKey: 'gst',
header: 'GST',
cell: ({ row }) => row.original.gst || '-',
},
{
accessorKey: 'created_at',
header: 'Created',
cell: ({ row }) => formatDate(row.original.created_at),
},
{
accessorKey: 'is_active',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.original.is_active ? 'default' : 'secondary'}>
{row.original.is_active ? 'Active' : 'Inactive'}
</Badge>
),
},
];
}, []);
}

View File

@@ -0,0 +1,48 @@
'use client';
import { TableSearchInput } from '@/components/data-table/TableSearchInput';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { ClientStatusFilter } from '../hooks/useClientFilters';
interface ClientFiltersProps {
searchTerm: string;
statusFilter: ClientStatusFilter;
onSearchChange: (value: string) => void;
onStatusChange: (value: ClientStatusFilter) => void;
}
export function ClientFilters({
searchTerm,
statusFilter,
onSearchChange,
onStatusChange,
}: ClientFiltersProps) {
return (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<TableSearchInput
value={searchTerm}
onChange={onSearchChange}
placeholder="Search clients"
/>
<Select
value={statusFilter}
onValueChange={(value) => onStatusChange(value as ClientStatusFilter)}
>
<SelectTrigger className="md:w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,269 @@
'use client';
import { useRef, type ComponentProps } from 'react';
import {
Controller,
type Control,
type FieldErrors,
type UseFormRegister,
} from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { FormField, PhoneInput } from '@/components/form';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import type { ClientFormValues } from '../hooks/useClientForm';
interface ClientSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
clientId?: number;
register: UseFormRegister<ClientFormValues>;
control: Control<ClientFormValues>;
errors: FieldErrors<ClientFormValues>;
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean;
isSaving: boolean;
}
export function ClientSheet({
open,
onOpenChange,
clientId,
register,
control,
errors,
isSubmitted,
onSubmit,
canSubmit,
isSaving,
}: ClientSheetProps) {
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
const firstNameErrorMessage = isSubmitted
? errors.first_name?.message
: undefined;
const lastNameErrorMessage = isSubmitted
? errors.last_name?.message
: undefined;
const emailErrorMessage = isSubmitted ? errors.email?.message : undefined;
const landlineErrorMessage = isSubmitted
? errors.landline_number?.message
: undefined;
const addressErrorMessage = isSubmitted ? errors.address?.message : undefined;
const contactNameErrorMessage = isSubmitted
? errors.contact_name?.message
: undefined;
const contactPhoneErrorMessage = isSubmitted
? errors.contact_phone_number?.message
: undefined;
const contactEmailErrorMessage = isSubmitted
? errors.contact_email?.message
: undefined;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>
{clientId ? 'Edit Client' : 'Create Client'}
</DialogTitle>
<DialogDescription>
Manage company and primary contact details.
</DialogDescription>
</DialogHeader>
<form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<DialogBody className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="client-first-name"
label="First Name"
required
error={firstNameErrorMessage}
>
<Input
id="client-first-name"
placeholder="Enter first name"
aria-invalid={!!firstNameErrorMessage}
{...register('first_name')}
/>
</FormField>
<FormField
id="client-last-name"
label="Last Name"
required
error={lastNameErrorMessage}
>
<Input
id="client-last-name"
placeholder="Enter last name"
aria-invalid={!!lastNameErrorMessage}
{...register('last_name')}
/>
</FormField>
</div>
<div className="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
id="address"
placeholder="Enter address"
aria-invalid={!!addressErrorMessage}
{...register('address')}
/>
</FormField>
<div className="grid gap-4 md:grid-cols-3">
<FormField id="gst" label="GST">
<Input
id="gst"
placeholder="Enter GST number"
{...register('gst')}
/>
</FormField>
<FormField id="pan" label="PAN">
<Input
id="pan"
placeholder="Enter PAN number"
{...register('pan')}
/>
</FormField>
<FormField id="tan" label="TAN">
<Input
id="tan"
placeholder="Enter TAN number"
{...register('tan')}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-3">
<FormField
id="contact-name"
label="Contact Name"
required
error={contactNameErrorMessage}
>
<Input
id="contact-name"
placeholder="Enter contact name"
aria-invalid={!!contactNameErrorMessage}
{...register('contact_name')}
/>
</FormField>
<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
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{clientId ? 'Update Client' : 'Create Client'}
</Button>
</DialogFooter>
</form>
<div ref={dropdownPortalRef} />
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import type { ReactNode } from 'react';
import type { ColumnDef, SortingState } from '@tanstack/react-table';
import { Edit, RotateCcw } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import { PERMISSIONS } from '@/constants/permissions';
import type { Client } from '@/types';
interface ClientTableProps {
columns: ColumnDef<Client>[];
clients: Client[];
isLoading: boolean;
toolbar: ReactNode;
skip: number;
limit: number;
total: number;
sorting: SortingState;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onSortingChange: (sorting: SortingState) => void;
onEdit: (client: Client) => void;
onToggleStatus: (client: Client) => void;
pendingClientId?: number;
}
export function ClientTable({
columns,
clients,
isLoading,
toolbar,
skip,
limit,
total,
sorting,
onPageChange,
onLimitChange,
onSortingChange,
onEdit,
onToggleStatus,
pendingClientId,
}: ClientTableProps) {
return (
<DataTable
title="Clients"
columns={columns}
data={clients}
isLoading={isLoading}
toolbar={toolbar}
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={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange,
}}
sorting={sorting}
onSortingChange={onSortingChange}
/>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import { useCallback, useState } from 'react';
import type { SortingState } from '@tanstack/react-table';
import { useDebounce } from '@/hooks/useDebounce';
export type ClientStatusFilter = 'all' | 'active' | 'inactive';
export function useClientFilters() {
const [skip, setSkip] = useState(0);
const [limit, setLimitValue] = useState(10);
const [sorting, setSortingValue] = useState<SortingState>([]);
const [searchTerm, setSearchTermValue] = useState('');
const [statusFilter, setStatusFilterValue] =
useState<ClientStatusFilter>('all');
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
const setSearchTerm = useCallback((value: string) => {
setSearchTermValue(value);
setSkip(0);
}, []);
const setStatusFilter = useCallback((value: ClientStatusFilter) => {
setStatusFilterValue(value);
setSkip(0);
}, []);
const setLimit = useCallback((value: number) => {
setLimitValue(value);
setSkip(0);
}, []);
const setSorting = useCallback((value: SortingState) => {
setSortingValue(value);
setSkip(0);
}, []);
return {
skip,
setSkip,
limit,
setLimit,
sorting,
setSorting,
searchTerm,
debouncedSearchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
};
}

View File

@@ -0,0 +1,133 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { isValidPhoneNumber } from 'libphonenumber-js';
import { useCallback } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import type { Client } from '@/types';
import { useSaveClientMutation } from './useClientMutations';
const requiredText = (message: string) => z.string().trim().min(1, message);
const optionalTaxIdSchema = z.string().trim();
const clientFormSchema = z.object({
id: z.number().optional(),
first_name: requiredText('First name is required'),
last_name: requiredText('Last name is required'),
email: requiredText('Email is required').email('Invalid email'),
landline_number: requiredText('Landline number is required'),
address: requiredText('Address is required'),
gst: optionalTaxIdSchema,
pan: optionalTaxIdSchema,
tan: optionalTaxIdSchema,
contact_name: requiredText('Contact name is required'),
contact_phone_number: requiredText('Contact phone number is required').refine(
(value) => isValidPhoneNumber(value),
'Enter a valid phone number',
),
contact_email: requiredText('Contact email is required').email(
'Invalid contact email',
),
is_active: z.boolean(),
});
export type ClientFormValues = z.infer<typeof clientFormSchema>;
const defaultValues: ClientFormValues = {
first_name: '',
last_name: '',
email: '',
landline_number: '',
address: '',
gst: '',
pan: '',
tan: '',
contact_name: '',
contact_phone_number: '',
contact_email: '',
is_active: true,
};
export function useClientForm({ onSaved }: { onSaved: () => void }) {
const {
register,
handleSubmit: submitForm,
reset,
control,
formState: { isSubmitting, errors, isSubmitted },
} = useForm<ClientFormValues>({
defaultValues,
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(clientFormSchema),
});
const saveMutation = useSaveClientMutation({
onSaved: () => {
reset(defaultValues);
onSaved();
},
});
const clientId = useWatch({ control, name: 'id' });
const firstName = useWatch({ control, name: 'first_name' }) || '';
const lastName = useWatch({ control, name: 'last_name' }) || '';
const email = useWatch({ control, name: 'email' }) || '';
const landlineNumber = useWatch({ control, name: 'landline_number' }) || '';
const address = useWatch({ control, name: 'address' }) || '';
const contactName = useWatch({ control, name: 'contact_name' }) || '';
const contactPhoneNumber =
useWatch({ control, name: 'contact_phone_number' }) || '';
const contactEmail = useWatch({ control, name: 'contact_email' }) || '';
const canSubmit =
firstName.trim().length > 0 &&
lastName.trim().length > 0 &&
email.trim().length > 0 &&
landlineNumber.trim().length > 0 &&
address.trim().length > 0 &&
contactName.trim().length > 0 &&
contactPhoneNumber.trim().length > 0 &&
contactEmail.trim().length > 0;
const handleSubmit = submitForm((values) => saveMutation.mutate(values));
const openCreate = useCallback(() => {
reset(defaultValues);
}, [reset]);
const openEdit = useCallback(
(client: Client) => {
reset({
id: client.id,
first_name: client.first_name || '',
last_name: client.last_name || '',
email: client.email || '',
landline_number: client.landline_number || '',
address: client.address || '',
gst: client.gst || '',
pan: client.pan || '',
tan: client.tan || '',
contact_name: client.contact_name || '',
contact_phone_number: client.contact_phone_number || '',
contact_email: client.contact_email || '',
is_active: client.is_active,
});
},
[reset],
);
return {
register,
control,
handleSubmit,
reset,
openCreate,
openEdit,
clientId,
errors,
isSubmitted,
canSubmit,
isSaving: isSubmitting || saveMutation.isPending,
};
}

View File

@@ -0,0 +1,65 @@
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { clientService } from '@/services/api';
import type { Client } from '@/types';
import { clientKeys } from '../queries/clientKeys';
import type { ClientFormValues } from './useClientForm';
function optionalValue(value: string) {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: ClientFormValues) =>
clientService.saveClient({
id: values.id,
first_name: values.first_name.trim(),
last_name: values.last_name.trim(),
email: values.email.trim(),
landline_number: values.landline_number.trim(),
address: values.address.trim(),
gst: optionalValue(values.gst),
pan: optionalValue(values.pan),
tan: optionalValue(values.tan),
contact_name: values.contact_name.trim(),
contact_phone_number: values.contact_phone_number.trim(),
contact_email: values.contact_email.trim(),
is_active: values.is_active,
}),
onSuccess: (_data, values) => {
toast.success(values.id ? 'Client updated' : 'Client created');
onSaved();
queryClient.invalidateQueries({ queryKey: clientKeys.all });
},
onError: (_error, values) => {
toast.error(
values.id ? 'Failed to update client' : 'Failed to create client',
);
},
});
}
export function useClientStatusMutation() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (client: Client) =>
clientService.updateClientStatus(client.id, !client.is_active),
onSuccess: () => {
toast.success('Client status updated');
queryClient.invalidateQueries({ queryKey: clientKeys.all });
},
onError: () => toast.error('Failed to update client status'),
});
return {
...mutation,
pendingClientId: mutation.isPending ? mutation.variables?.id : undefined,
};
}

View File

@@ -0,0 +1,49 @@
'use client';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { SortingState } from '@tanstack/react-table';
import { getServerSortParams } from '@/components/data-table/sorting';
import { clientService } from '@/services/api';
import type { ClientListParams } from '@/types';
import { clientKeys } from '../queries/clientKeys';
import type { ClientStatusFilter } from './useClientFilters';
interface UseClientsQueryParams {
skip: number;
limit: number;
searchTerm: string;
statusFilter: ClientStatusFilter;
sorting: SortingState;
}
function buildClientListParams({
skip,
limit,
searchTerm,
statusFilter,
sorting,
}: UseClientsQueryParams): ClientListParams {
return {
skip,
limit,
search_term: searchTerm || undefined,
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
...getServerSortParams(sorting),
};
}
export function useClientsQuery(params: UseClientsQueryParams) {
const { skip, limit, searchTerm, statusFilter, sorting } = params;
const listParams = useMemo(
() =>
buildClientListParams({ skip, limit, searchTerm, statusFilter, sorting }),
[limit, searchTerm, skip, sorting, statusFilter],
);
return useQuery({
queryKey: clientKeys.list(listParams),
queryFn: () => clientService.getClients(listParams),
});
}

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

@@ -0,0 +1,147 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { Building2, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { Client } from '@/types';
import { useClientColumns } from './components/ClientColumns';
import { ClientFilters } from './components/ClientFilters';
import { ClientSheet } from './components/ClientSheet';
import { ClientTable } from './components/ClientTable';
import { useClientFilters } from './hooks/useClientFilters';
import { useClientForm } from './hooks/useClientForm';
import { useClientStatusMutation } from './hooks/useClientMutations';
import { useClientsQuery } from './hooks/useClientQueries';
export default function ClientsPage() {
const [isSheetOpen, setIsSheetOpen] = useState(false);
const {
skip,
setSkip,
limit,
setLimit,
sorting,
setSorting,
searchTerm,
debouncedSearchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
} = useClientFilters();
const clientsQuery = useClientsQuery({
skip,
limit,
searchTerm: debouncedSearchTerm,
statusFilter,
sorting,
});
const clientForm = useClientForm({
onSaved: () => setIsSheetOpen(false),
});
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } =
clientForm;
const {
register,
control,
handleSubmit,
clientId,
errors,
isSubmitted,
canSubmit,
isSaving,
} = clientForm;
const statusMutation = useClientStatusMutation();
const { mutate: updateClientStatus, pendingClientId } = statusMutation;
const openCreate = useCallback(() => {
prepareCreateClient();
setIsSheetOpen(true);
}, [prepareCreateClient]);
const openEdit = useCallback(
(client: Client) => {
prepareEditClient(client);
setIsSheetOpen(true);
},
[prepareEditClient],
);
const toggleStatus = useCallback(
(client: Client) => {
updateClientStatus(client);
},
[updateClientStatus],
);
const columns = useClientColumns();
const total = clientsQuery.data?.total ?? 0;
const clients = clientsQuery.data?.items ?? [];
const toolbar = useMemo(
() => (
<ClientFilters
searchTerm={searchTerm}
statusFilter={statusFilter}
onSearchChange={setSearchTerm}
onStatusChange={setStatusFilter}
/>
),
[searchTerm, setSearchTerm, setStatusFilter, statusFilter],
);
return (
<>
<main className="relative z-10 space-y-5">
<PageHeader
title="Client Management"
description="Manage client company and contact details"
icon={Building2}
actions={
<PermissionGuard permissions={PERMISSIONS.CLIENT.CREATE}>
<Button onClick={openCreate}>
<Plus className="mr-2 size-4" />
Add Client
</Button>
</PermissionGuard>
}
/>
<ClientTable
columns={columns}
clients={clients}
isLoading={clientsQuery.isLoading}
toolbar={toolbar}
skip={skip}
limit={limit}
total={total}
sorting={sorting}
onPageChange={setSkip}
onLimitChange={setLimit}
onSortingChange={setSorting}
onEdit={openEdit}
onToggleStatus={toggleStatus}
pendingClientId={pendingClientId}
/>
</main>
<ClientSheet
open={isSheetOpen}
onOpenChange={setIsSheetOpen}
clientId={clientId}
register={register}
control={control}
errors={errors}
isSubmitted={isSubmitted}
onSubmit={handleSubmit}
canSubmit={canSubmit}
isSaving={isSaving}
/>
</>
);
}

View File

@@ -0,0 +1,7 @@
import type { ClientListParams } from '@/types';
export const clientKeys = {
all: ['clients'] as const,
lists: () => [...clientKeys.all, 'list'] as const,
list: (params: ClientListParams) => [...clientKeys.lists(), params] as const,
};

View File

@@ -1,643 +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 } 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: ProjectSummary = await projectService.getProjectSummary(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

@@ -0,0 +1,155 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type {
TicketExtensionRequest,
TicketExtensionRequestStatus,
} from '@/types';
import { formatDate } from '@/utils/date';
const statusConfig: Record<
TicketExtensionRequestStatus,
{ label: string; className: string }
> = {
pending: {
label: 'Pending',
className:
'border-amber-200 bg-amber-100 text-amber-800 dark:border-amber-900 dark:bg-amber-950/50 dark:text-amber-300',
},
approved: {
label: 'Approved',
className:
'border-emerald-200 bg-emerald-100 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/50 dark:text-emerald-300',
},
rejected: {
label: 'Rejected',
className:
'border-red-200 bg-red-100 text-red-800 dark:border-red-900 dark:bg-red-950/50 dark:text-red-300',
},
cancelled: {
label: 'Cancelled',
className: 'border-border bg-muted text-muted-foreground',
},
};
function StatusBadge({ status }: { status: TicketExtensionRequestStatus }) {
const config = statusConfig[status];
return (
<Badge variant="outline" className={cn('border', config.className)}>
{config.label}
</Badge>
);
}
function PersonCell({
name,
role,
}: {
name: string | null | undefined;
role: string | null | undefined;
}) {
return (
<div className="min-w-0">
<p className="truncate font-medium">{name || '-'}</p>
{role ? (
<p className="truncate text-xs text-muted-foreground">{role}</p>
) : null}
</div>
);
}
export function useExtensionRequestColumns(): ColumnDef<TicketExtensionRequest>[] {
return useMemo(
() => [
{
accessorKey: 'ticket_name',
header: 'Ticket / Assignment',
size: 170,
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.ticket_name}</p>
<p className="text-xs text-muted-foreground">
Assignment #{row.original.assignment_id}
</p>
</div>
),
},
{
id: 'requested_by',
header: 'Requested By',
size: 170,
meta: { disableTruncate: true },
cell: ({ row }) => (
<PersonCell
name={row.original.requested_by?.name}
role={row.original.requested_by?.role.name}
/>
),
},
{
accessorKey: 'status',
header: 'Status',
size: 125,
meta: { disableTruncate: true },
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
accessorKey: 'current_due_at',
header: 'Current Due Date',
size: 175,
cell: ({ row }) => formatDate(row.original.current_due_at),
},
{
accessorKey: 'requested_due_at',
header: 'Requested Due Date',
size: 175,
cell: ({ row }) => formatDate(row.original.requested_due_at),
},
{
accessorKey: 'reason',
header: 'Request Reason',
size: 260,
},
{
id: 'resolved_by',
header: 'Resolved By',
size: 170,
meta: { disableTruncate: true },
cell: ({ row }) => (
<PersonCell
name={row.original.resolved_by?.name}
role={row.original.resolved_by?.role.name}
/>
),
},
{
accessorKey: 'decision_note',
header: 'Decision Note',
size: 220,
cell: ({ row }) => row.original.decision_note || '-',
},
{
id: 'previous_requests',
header: 'Previous Requests',
size: 150,
cell: ({ row }) => (
<Badge variant="secondary">
{row.original.history_meta.previous_request_count}
</Badge>
),
},
{
accessorKey: 'resolved_at',
header: 'Resolved On',
size: 175,
cell: ({ row }) => formatDate(row.original.resolved_at),
},
],
[],
);
}

View File

@@ -0,0 +1,229 @@
'use client';
import { useCallback, useEffect } from 'react';
import { ArrowRight, Check, Loader2, X } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import type { TicketExtensionRequest } from '@/types';
import { formatDate } from '@/utils/date';
import {
MAX_EXTENSION_DECISION_NOTE_LENGTH,
type ExtensionRequestDecisionAction,
useExtensionRequestDecision,
} from '../hooks/useExtensionRequestDecision';
interface ExtensionRequestDecisionDialogProps {
request: TicketExtensionRequest | null;
action: ExtensionRequestDecisionAction | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ExtensionRequestDecisionDialog({
request,
action,
open,
onOpenChange,
}: ExtensionRequestDecisionDialogProps) {
const handleSuccess = useCallback(() => onOpenChange(false), [onOpenChange]);
const {
note,
setNote,
noteError,
selectedAction,
isPending,
isRejectConfirmationOpen,
setIsRejectConfirmationOpen,
submitDecision,
requestRejection,
resetDecision,
} = useExtensionRequestDecision({
ticketId: request?.ticket_id ?? '',
assignmentId: request?.assignment_id ?? 0,
onSuccess: handleSuccess,
});
useEffect(() => {
resetDecision();
}, [action, open, request?.id, resetDecision]);
const handleOpenChange = (nextOpen: boolean) => {
if (isPending) return;
onOpenChange(nextOpen);
};
if (!request || !action) return null;
const isApproval = action === 'approve';
const noteErrorId = `extension-manager-note-error-${request.id}`;
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-xl" showCloseButton={!isPending}>
<DialogHeader>
<DialogTitle>
{isApproval
? 'Approve extension request'
: 'Reject extension request'}
</DialogTitle>
<DialogDescription>
Review {request.ticket_name}, Assignment #{request.assignment_id}{' '}
before confirming this decision.
</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-4">
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-3 rounded-lg border p-3">
<div>
<p className="text-xs text-muted-foreground">
Current Due Date
</p>
<p className="mt-1 font-medium">
{formatDate(request.current_due_at)}
</p>
</div>
<ArrowRight className="mt-5 size-4 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">
Requested Due Date
</p>
<p className="mt-1 font-medium">
{formatDate(request.requested_due_at)}
</p>
</div>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Requested By</p>
<p className="mt-1 font-medium">
{request.requested_by?.name ?? 'Assigned worker'}
</p>
<p className="mt-3 text-xs text-muted-foreground">Reason</p>
<p className="mt-1 leading-relaxed">{request.reason}</p>
</div>
<div className="space-y-2">
<div className="space-y-0.5">
<Label htmlFor="extension-manager-note">
Add Manager Comments
</Label>
<p className="text-xs text-muted-foreground">
{isApproval
? 'Optional for approval.'
: 'Required for rejection.'}
</p>
</div>
<div className="relative">
<Textarea
id="extension-manager-note"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="Add a note explaining your decision..."
disabled={isPending}
aria-invalid={Boolean(noteError)}
aria-describedby={noteError ? noteErrorId : undefined}
className="min-h-24 resize-none pb-8"
/>
<span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground">
{note.length} / {MAX_EXTENSION_DECISION_NOTE_LENGTH}
</span>
</div>
{noteError ? (
<p id={noteErrorId} className="text-xs text-destructive">
{noteError}
</p>
) : null}
</div>
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
{isApproval ? (
<Button
type="button"
disabled={isPending}
onClick={() => void submitDecision('approve')}
>
{selectedAction === 'approve' ? (
<Loader2 className="animate-spin" />
) : (
<Check />
)}
Approve Extension
</Button>
) : (
<Button
type="button"
variant="destructive"
disabled={isPending}
onClick={requestRejection}
>
<X />
Reject Request
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={isRejectConfirmationOpen}
onOpenChange={setIsRejectConfirmationOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reject extension request?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to reject the extension request for{' '}
{request.ticket_name}, Assignment #{request.assignment_id}? This
decision cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>No</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={isPending}
onClick={() => void submitDecision('reject')}
>
{selectedAction === 'reject' ? (
<Loader2 className="animate-spin" />
) : null}
Yes, Reject
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import { FilterX } from 'lucide-react';
import { TableSearchInput } from '@/components/data-table/TableSearchInput';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { ExtensionRequestStatusFilter } from '../hooks/useExtensionRequestFilters';
interface ExtensionRequestFiltersProps {
ticketName: string;
statusFilter: ExtensionRequestStatusFilter;
onTicketNameChange: (value: string) => void;
onStatusChange: (value: ExtensionRequestStatusFilter) => void;
onClear: () => void;
}
export function ExtensionRequestFilters({
ticketName,
statusFilter,
onTicketNameChange,
onStatusChange,
onClear,
}: ExtensionRequestFiltersProps) {
return (
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center">
<TableSearchInput
value={ticketName}
onChange={onTicketNameChange}
placeholder="Search by ticket name"
className="w-full sm:max-w-sm"
/>
<Select
value={statusFilter}
onValueChange={(value) =>
onStatusChange(value as ExtensionRequestStatusFilter)
}
>
<SelectTrigger className="w-full sm:w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!ticketName && statusFilter === 'pending'}
onClick={onClear}
>
<FilterX />
Clear Filters
</Button>
</div>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable, type DataTableAction } from '@/components/data-table';
import type { TicketExtensionRequest } from '@/types';
interface ExtensionRequestTableProps {
columns: ColumnDef<TicketExtensionRequest>[];
requests: TicketExtensionRequest[];
isLoading: boolean;
toolbar: ReactNode;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onView: (request: TicketExtensionRequest) => void;
actions: DataTableAction<TicketExtensionRequest>[];
}
export function ExtensionRequestTable({
columns,
requests,
isLoading,
toolbar,
skip,
limit,
total,
onPageChange,
onLimitChange,
onView,
actions,
}: ExtensionRequestTableProps) {
return (
<DataTable
title="Extension Requests"
columns={columns}
data={requests}
isLoading={isLoading}
toolbar={toolbar}
emptyTitle="No extension requests found."
emptyDescription="Try changing the ticket or status filters."
onRowClick={onView}
actions={actions}
actionsSticky
minColumnSize={130}
pagination={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange,
}}
/>
);
}

View File

@@ -0,0 +1,124 @@
'use client';
import { useCallback, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ticketService } from '@/services/api';
import type { ReviewTicketExtensionPayload } from '@/types';
import { ticketKeys } from '../../ticket/queries/ticketKeys';
import { extensionRequestKeys } from '../queries/extensionRequestKeys';
export const MAX_EXTENSION_DECISION_NOTE_LENGTH = 300;
export type ExtensionRequestDecisionAction = 'approve' | 'reject';
interface UseExtensionRequestDecisionOptions {
ticketId: string;
assignmentId: number;
onSuccess?: () => void;
}
export function useExtensionRequestDecision({
ticketId,
assignmentId,
onSuccess,
}: UseExtensionRequestDecisionOptions) {
const queryClient = useQueryClient();
const [note, setNoteValue] = useState('');
const [noteError, setNoteError] = useState<string | null>(null);
const [selectedAction, setSelectedAction] =
useState<ExtensionRequestDecisionAction | null>(null);
const [isRejectConfirmationOpen, setIsRejectConfirmationOpen] =
useState(false);
const reviewMutation = useMutation({
mutationFn: (payload: ReviewTicketExtensionPayload) =>
ticketService.reviewTicketExtension(ticketId, assignmentId, payload),
onSuccess: (_response, payload) => {
toast.success(
payload.action === 'approve'
? 'Extension request approved'
: 'Extension request rejected',
);
queryClient.invalidateQueries({
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
});
queryClient.invalidateQueries({
queryKey: extensionRequestKeys.lists(),
});
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
});
queryClient.invalidateQueries({
queryKey: ticketKeys.timeline(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
onSuccess?.();
},
onError: () => toast.error('Failed to review extension request'),
});
const setNote = useCallback((value: string) => {
const nextNote = value.slice(0, MAX_EXTENSION_DECISION_NOTE_LENGTH);
setNoteValue(nextNote);
if (nextNote.trim()) setNoteError(null);
}, []);
const submitDecision = useCallback(
async (action: ExtensionRequestDecisionAction) => {
const trimmedNote = note.trim();
if (action === 'reject' && !trimmedNote) {
setNoteError('Manager comments are required to reject this request.');
setIsRejectConfirmationOpen(false);
return;
}
setSelectedAction(action);
try {
await reviewMutation.mutateAsync({
action,
...(trimmedNote ? { note: trimmedNote } : {}),
});
} catch {
// The mutation displays the error toast and leaves the form open.
} finally {
setSelectedAction(null);
}
},
[note, reviewMutation],
);
const requestRejection = useCallback(() => {
if (!note.trim()) {
setNoteError('Manager comments are required to reject this request.');
return;
}
setNoteError(null);
setIsRejectConfirmationOpen(true);
}, [note]);
const resetDecision = useCallback(() => {
setNoteValue('');
setNoteError(null);
setSelectedAction(null);
setIsRejectConfirmationOpen(false);
}, []);
return {
note,
setNote,
noteError,
selectedAction,
isPending: reviewMutation.isPending,
isRejectConfirmationOpen,
setIsRejectConfirmationOpen,
submitDecision,
requestRejection,
resetDecision,
};
}

View File

@@ -0,0 +1,51 @@
'use client';
import { useCallback, useState } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
import type { TicketExtensionRequestStatus } from '@/types';
export type ExtensionRequestStatusFilter = 'all' | TicketExtensionRequestStatus;
export function useExtensionRequestFilters() {
const [skip, setSkip] = useState(0);
const [limit, setLimitValue] = useState(10);
const [ticketName, setTicketNameValue] = useState('');
const [statusFilter, setStatusFilterValue] =
useState<ExtensionRequestStatusFilter>('pending');
const debouncedTicketName = useDebounce(ticketName.trim(), 400);
const setLimit = useCallback((value: number) => {
setLimitValue(value);
setSkip(0);
}, []);
const setTicketName = useCallback((value: string) => {
setTicketNameValue(value);
setSkip(0);
}, []);
const setStatusFilter = useCallback((value: ExtensionRequestStatusFilter) => {
setStatusFilterValue(value);
setSkip(0);
}, []);
const clearFilters = useCallback(() => {
setTicketNameValue('');
setStatusFilterValue('pending');
setSkip(0);
}, []);
return {
skip,
setSkip,
limit,
setLimit,
ticketName,
debouncedTicketName,
setTicketName,
statusFilter,
setStatusFilter,
clearFilters,
};
}

View File

@@ -0,0 +1,47 @@
'use client';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ticketService } from '@/services/api';
import type { TicketExtensionRequestsParams } from '@/types';
import type { ExtensionRequestStatusFilter } from './useExtensionRequestFilters';
import { extensionRequestKeys } from '../queries/extensionRequestKeys';
interface UseExtensionRequestsQueryParams {
skip: number;
limit: number;
ticketName: string;
statusFilter: ExtensionRequestStatusFilter;
}
function buildExtensionRequestParams({
skip,
limit,
ticketName,
statusFilter,
}: UseExtensionRequestsQueryParams): TicketExtensionRequestsParams {
return {
skip,
limit,
ticket_name: ticketName || undefined,
status: statusFilter === 'all' ? undefined : statusFilter,
};
}
export function useExtensionRequestsQuery(
params: UseExtensionRequestsQueryParams,
) {
const { skip, limit, ticketName, statusFilter } = params;
const queryParams = useMemo(
() =>
buildExtensionRequestParams({ skip, limit, ticketName, statusFilter }),
[limit, skip, statusFilter, ticketName],
);
return useQuery({
queryKey: extensionRequestKeys.list(queryParams),
queryFn: () => ticketService.getTicketExtensionRequests(queryParams),
});
}

View File

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

View File

@@ -0,0 +1,153 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { AlertCircle, CalendarClock, Check, RotateCcw, X } from 'lucide-react';
import { useRouter } from 'next/navigation';
import type { DataTableAction } from '@/components/data-table';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import type { TicketExtensionRequest } from '@/types';
import { ROUTES } from '@/utils/routes';
import { useExtensionRequestColumns } from './components/ExtensionRequestColumns';
import { ExtensionRequestDecisionDialog } from './components/ExtensionRequestDecisionDialog';
import { ExtensionRequestFilters } from './components/ExtensionRequestFilters';
import { ExtensionRequestTable } from './components/ExtensionRequestTable';
import type { ExtensionRequestDecisionAction } from './hooks/useExtensionRequestDecision';
import { useExtensionRequestFilters } from './hooks/useExtensionRequestFilters';
import { useExtensionRequestsQuery } from './hooks/useExtensionRequestQueries';
export default function ExtensionRequestsPage() {
const router = useRouter();
const [decision, setDecision] = useState<{
request: TicketExtensionRequest;
action: ExtensionRequestDecisionAction;
} | null>(null);
const {
skip,
setSkip,
limit,
setLimit,
ticketName,
debouncedTicketName,
setTicketName,
statusFilter,
setStatusFilter,
clearFilters,
} = useExtensionRequestFilters();
const extensionRequestsQuery = useExtensionRequestsQuery({
skip,
limit,
ticketName: debouncedTicketName,
statusFilter,
});
const columns = useExtensionRequestColumns();
const requests = extensionRequestsQuery.data?.items ?? [];
const total = extensionRequestsQuery.data?.total ?? 0;
const actions = useMemo<DataTableAction<TicketExtensionRequest>[]>(
() => [
{
label: 'Approve extension',
icon: <Check className="text-emerald-600" />,
permission: PERMISSIONS.TICKET.ASSIGN,
hidden: (request) => request.status !== 'pending',
className:
'border-emerald-200 hover:bg-emerald-50 dark:border-emerald-900 dark:hover:bg-emerald-950/50',
onClick: (request) => setDecision({ request, action: 'approve' }),
},
{
label: 'Reject extension',
icon: <X className="text-destructive" />,
permission: PERMISSIONS.TICKET.ASSIGN,
hidden: (request) => request.status !== 'pending',
className:
'border-destructive/30 hover:bg-destructive/5 hover:text-destructive',
onClick: (request) => setDecision({ request, action: 'reject' }),
},
],
[],
);
const openTicket = useCallback(
(request: TicketExtensionRequest) => {
router.push(ROUTES.TICKET_DETAIL(request.ticket_id));
},
[router],
);
const filterToolbar = useMemo(
() => (
<ExtensionRequestFilters
ticketName={ticketName}
statusFilter={statusFilter}
onTicketNameChange={setTicketName}
onStatusChange={setStatusFilter}
onClear={clearFilters}
/>
),
[clearFilters, setTicketName, setStatusFilter, statusFilter, ticketName],
);
return (
<main className="relative z-10 space-y-5">
<PageHeader
title="Extension Requests"
description="Track deadline extension requests across all ticket assignments."
icon={CalendarClock}
/>
{extensionRequestsQuery.isError ? (
<div
role="alert"
className="flex min-h-48 flex-col items-center justify-center rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 text-center"
>
<AlertCircle className="mb-2 size-6 text-destructive" />
<p className="text-sm font-medium text-destructive">
Failed to load extension requests.
</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
disabled={extensionRequestsQuery.isFetching}
onClick={() => extensionRequestsQuery.refetch()}
>
<RotateCcw
className={
extensionRequestsQuery.isFetching ? 'animate-spin' : undefined
}
/>
Retry
</Button>
</div>
) : (
<ExtensionRequestTable
columns={columns}
requests={requests}
isLoading={extensionRequestsQuery.isLoading}
toolbar={filterToolbar}
skip={skip}
limit={limit}
total={total}
onPageChange={setSkip}
onLimitChange={setLimit}
onView={openTicket}
actions={actions}
/>
)}
<ExtensionRequestDecisionDialog
request={decision?.request ?? null}
action={decision?.action ?? null}
open={Boolean(decision)}
onOpenChange={(open) => {
if (!open) setDecision(null);
}}
/>
</main>
);
}

View File

@@ -0,0 +1,8 @@
import type { TicketExtensionRequestsParams } from '@/types';
export const extensionRequestKeys = {
all: ['extension-requests'] as const,
lists: () => [...extensionRequestKeys.all, 'list'] as const,
list: (params: TicketExtensionRequestsParams) =>
[...extensionRequestKeys.lists(), params] as const,
};

View File

@@ -1,9 +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 { AuthGuard } from '@/guards';
import { Separator } from '@/components/ui/separator';
import React from 'react'; import React from 'react';
import { ModuleShellHeader } from './_components/module-shell-header';
const ModulesLayout = ({ const ModulesLayout = ({
children, children,
@@ -11,23 +10,19 @@ const ModulesLayout = ({
children: React.ReactNode; children: React.ReactNode;
}>) => { }>) => {
return ( return (
<SidebarProvider className="bg-transparent overflow-hidden"> <AuthGuard>
<AppSidebar className="bg-transparent" /> <SidebarProvider defaultOpen={false}>
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden bg-transparent"> <AppSidebar />
<div className="flex-1 overflow-auto"> <main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden">
{/* Centering container wrapper */} <div className="scroll-stable min-w-0 flex-1 overflow-auto">
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full"> <div className="mx-auto flex min-h-full w-full min-w-0 max-w-380 flex-col">
<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"> <ModuleShellHeader />
<SidebarTrigger /> <div className="min-w-0 flex-1 p-6">{children}</div>
<ModeToggle />
<Separator orientation="vertical" className="mx-2 h-4" />
<BreadcrumbBasic />
</div>
<div className="p-6 flex-1">{children}</div>
</div> </div>
</div> </div>
</main> </main>
</SidebarProvider> </SidebarProvider>
</AuthGuard>
); );
}; };

View File

@@ -0,0 +1,106 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Package, Project } from '@/types';
function EmptyValue() {
return <span className="text-muted-foreground">-</span>;
}
function StateBadges({ value }: { value: string | null }) {
if (!value) {
return <EmptyValue />;
}
const states = value
.split(',')
.map((state) => state.trim())
.filter(Boolean);
if (!states.length) {
return <EmptyValue />;
}
const [firstState, ...remainingStates] = states;
return (
<div className="flex items-center gap-1.5">
<Badge variant="secondary">{firstState}</Badge>
{remainingStates.length > 0 ? (
<Popover>
<PopoverTrigger asChild>
<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-muted-foreground transition-colors hover:bg-muted"
>
+{remainingStates.length}
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
{remainingStates.map((state) => (
<Badge key={state} variant="secondary">
{state}
</Badge>
))}
</div>
</PopoverContent>
</Popover>
) : null}
</div>
);
}
export function usePackageColumns(projects: Project[]) {
return useMemo<ColumnDef<Package>[]>(
() => [
{
accessorKey: 'name',
header: 'Package Name',
cell: ({ row }) => <div>{row.original.name}</div>,
},
{
accessorKey: 'project_id',
header: 'Project',
cell: ({ row }) =>
projects.find((project) => project.id === row.original.project_id)
?.name || row.original.project_id,
},
{
id: 'project_state',
header: 'Project State',
cell: ({ row }) => {
const project = projects.find(
(item) => item.id === row.original.project_id,
);
return <StateBadges value={project?.state ?? null} />;
},
},
{
accessorKey: 'region',
header: 'Region',
cell: ({ row }) => row.original.region || <EmptyValue />,
},
{
accessorKey: 'chainage_start_km',
header: 'Start (km)',
cell: ({ row }) => row.original.chainage_start_km?.toFixed(2) ?? '0.00',
},
{
accessorKey: 'chainage_end_km',
header: 'End (km)',
cell: ({ row }) => row.original.chainage_end_km?.toFixed(2) ?? '0.00',
},
],
[projects],
);
}

View File

@@ -0,0 +1,201 @@
'use client';
import type { ComponentProps } from 'react';
import { useRef } from 'react';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
import { ProjectSelect } from '@/components/lookups/ProjectSelect';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import type { PackageFormValues } from '../hooks/usePackageForm';
interface PackageDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
packageId?: string;
projectId: string;
onProjectChange: (projectId: string) => void;
register: UseFormRegister<PackageFormValues>;
errors: FieldErrors<PackageFormValues>;
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean;
isSaving: boolean;
}
export function PackageDialog({
open,
onOpenChange,
packageId,
projectId,
onProjectChange,
register,
errors,
isSubmitted,
onSubmit,
canSubmit,
isSaving,
}: PackageDialogProps) {
const projectSelectPortalRef = useRef<HTMLDivElement | null>(null);
const projectErrorMessage = isSubmitted
? errors.project_id?.message
: undefined;
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const startErrorMessage = isSubmitted
? errors.chainage_start_km?.message
: undefined;
const endErrorMessage = isSubmitted
? errors.chainage_end_km?.message
: undefined;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle>
{packageId ? 'Edit Package' : 'Create Package'}
</DialogTitle>
<DialogDescription>
Manage package details and optional chainage range.
</DialogDescription>
</DialogHeader>
<form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<DialogBody className="space-y-6">
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Basic details</h3>
<p className="text-sm text-muted-foreground">
Project binding, package name, and region.
</p>
</div>
{!packageId ? (
<FormField label="Project" required error={projectErrorMessage}>
<ProjectSelect
value={projectId}
onValueChange={onProjectChange}
enabled={open}
disabled={isSaving}
portalContainer={projectSelectPortalRef}
/>
<input
type="hidden"
{...register('project_id')}
value={projectId}
readOnly
/>
</FormField>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="package-name"
label="Package Name"
required
error={nameErrorMessage}
>
<Input
id="package-name"
placeholder="Enter package name"
aria-invalid={!!nameErrorMessage}
{...register('name')}
/>
</FormField>
<FormField id="package-region" label="Region">
<Input
id="package-region"
placeholder="Enter region"
{...register('region')}
/>
</FormField>
</div>
</section>
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Chainage range</h3>
<p className="text-sm text-muted-foreground">
start and end kilometre values.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="package-start"
label="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>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{packageId ? 'Update Package' : 'Create Package'}
</Button>
</DialogFooter>
</form>
<div ref={projectSelectPortalRef} />
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import type { ColumnDef } from '@tanstack/react-table';
import { Edit3, Trash2 } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import type { Package } from '@/types';
interface PackageTableProps {
columns: ColumnDef<Package>[];
packages: Package[];
isLoading: boolean;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onEdit: (pkg: Package) => void;
onDelete: (pkg: Package) => void;
}
export function PackageTable({
columns,
packages,
isLoading,
skip,
limit,
total,
onPageChange,
onLimitChange,
onEdit,
onDelete,
}: PackageTableProps) {
return (
<DataTable
title="Packages"
data={packages}
columns={columns}
actions={[
{
label: 'Edit',
icon: <Edit3 className="size-4" />,
onClick: onEdit,
},
{
label: 'Delete',
icon: <Trash2 className="size-4" />,
className: 'text-destructive',
onClick: onDelete,
},
]}
isLoading={isLoading}
emptyTitle="No packages found."
pagination={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange,
}}
actionsSticky
/>
);
}

View File

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

View File

@@ -0,0 +1,145 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import {
optionalIntegerNumber,
validateChainageRange,
} from '@/lib/validation/numberField';
import { packageService } from '@/services/api';
import type { Package, PackageCreate, PackageUpdate } from '@/types';
import { packageKeys } from '../queries/packageKeys';
const packageFormSchema = z
.object({
id: z.string().optional(),
project_id: z.string().trim().min(1, 'Project is required'),
name: z.string().trim().min(1, 'Package name is required'),
region: z.string().optional(),
chainage_start_km: optionalIntegerNumber,
chainage_end_km: optionalIntegerNumber,
})
.superRefine(validateChainageRange);
export type PackageFormValues = z.infer<typeof packageFormSchema>;
const defaultValues: PackageFormValues = {
project_id: '',
name: '',
region: '',
chainage_start_km: '',
chainage_end_km: '',
};
function trimOptional(value?: string) {
return value?.trim() || null;
}
function toNumberOrZero(value: string) {
return value.trim() ? Number(value) : 0;
}
function toPackagePayload(
values: PackageFormValues,
): PackageCreate | PackageUpdate {
return {
project_id: values.project_id,
name: values.name.trim(),
region: trimOptional(values.region),
chainage_start_km: toNumberOrZero(values.chainage_start_km),
chainage_end_km: toNumberOrZero(values.chainage_end_km),
};
}
export function usePackageForm({ onSaved }: { onSaved: () => void }) {
const queryClient = useQueryClient();
const {
register,
handleSubmit,
reset,
setValue,
control,
formState: { errors, isSubmitting, isSubmitted },
} = useForm<PackageFormValues>({
defaultValues,
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(packageFormSchema),
});
const packageId = useWatch({ control, name: 'id' });
const projectId = useWatch({ control, name: 'project_id' }) || '';
const packageName = useWatch({ control, name: 'name' }) || '';
const canSubmit =
projectId.trim().length > 0 && packageName.trim().length > 0;
const saveMutation = useMutation({
mutationFn: (values: PackageFormValues) => {
const payload = toPackagePayload(values);
return values.id
? packageService.updatePackage(values.id, payload)
: packageService.createPackage(payload as PackageCreate);
},
onSuccess: (_data, values) => {
toast.success(values.id ? 'Package updated' : 'Package created');
reset(defaultValues);
onSaved();
queryClient.invalidateQueries({ queryKey: packageKeys.all });
},
onError: (_error, values) => {
toast.error(
values.id ? 'Failed to update package' : 'Failed to create package',
);
},
});
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
const openCreate = useCallback(() => {
reset(defaultValues);
}, [reset]);
const openEdit = useCallback(
(pkg: Package) => {
reset({
id: pkg.id,
project_id: pkg.project_id,
name: pkg.name || '',
region: pkg.region || '',
chainage_start_km: pkg.chainage_start_km?.toString() || '',
chainage_end_km: pkg.chainage_end_km?.toString() || '',
});
},
[reset],
);
const setProjectId = useCallback(
(value: string) =>
setValue('project_id', value, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue],
);
return {
register,
onSubmit,
openCreate,
openEdit,
resetForm: () => reset(defaultValues),
packageId,
projectId,
setProjectId,
errors,
isSubmitted,
canSubmit,
isSaving: isSubmitting || saveMutation.isPending,
};
}

View File

@@ -0,0 +1,54 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { toast } from 'sonner';
import { packageService, projectService } from '@/services/api';
import type { Package, PaginationParams } from '@/types';
import { projectKeys } from '../../project/queries/projectKeys';
import { packageKeys } from '../queries/packageKeys';
interface UsePackagesQueryParams {
skip: number;
limit: number;
}
export function usePackagesQuery({ skip, limit }: UsePackagesQueryParams) {
const listParams = useMemo<PaginationParams>(
() => ({ skip, limit }),
[limit, skip],
);
return useQuery({
queryKey: packageKeys.list(listParams),
queryFn: () => packageService.getPackages(listParams),
});
}
export function useProjectOptionsQuery() {
return useQuery({
queryKey: projectKeys.options(),
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
});
}
export function useDeletePackageMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (pkg: Package) => packageService.deletePackage(pkg.id),
onSuccess: (_data, pkg) => {
toast.success('Package deleted', {
description: `${pkg.name} has been removed from the system.`,
});
queryClient.invalidateQueries({ queryKey: packageKeys.all });
},
onError: () => {
toast.error('Deletion failed', {
description: 'The package could not be removed. Please try again.',
});
},
});
}

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

@@ -1,513 +1,179 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useCallback, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Package as PackageIcon, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Loader2, CheckCircle2, Package, FolderKanban, Globe } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { projectService, packageService } from '@/services/api'; import {
import { Project, Package as PackageType, PackageCreate, PackageUpdate } from '@/types'; AlertDialog,
import { ColumnDef } from '@tanstack/react-table'; AlertDialogAction,
import { toast } from 'sonner'; AlertDialogCancel,
import { Badge } from '@/components/ui/badge'; AlertDialogContent,
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; AlertDialogDescription,
import { PoweredBy } from '@/components/powered-by'; AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import type { Package } from '@/types';
import { PackageDialog } from './components/PackageDialog';
import { PackageTable } from './components/PackageTable';
import { usePackageColumns } from './components/PackageColumns';
import { usePackageFilters } from './hooks/usePackageFilters';
import { usePackageForm } from './hooks/usePackageForm';
import {
useDeletePackageMutation,
usePackagesQuery,
useProjectOptionsQuery,
} from './hooks/usePackageQueries';
export default function PackagePage() { export default function PackagePage() {
const [packages, setPackages] = useState<PackageType[]>([]); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [totalItems, setTotalItems] = useState(0); const [packageToDelete, setPackageToDelete] = useState<Package | null>(null);
const [projects, setProjects] = useState<Project[]>([]); const { skip, setSkip, limit, setLimit } = usePackageFilters();
const [isLoading, setIsLoading] = useState(true); const packagesQuery = usePackagesQuery({ skip, limit });
const [isModalOpen, setIsModalOpen] = useState(false); const projectsQuery = useProjectOptionsQuery();
const [isSubmitting, setIsSubmitting] = useState(false); const deletePackageMutation = useDeletePackageMutation();
const [error, setError] = useState<string | null>(null); const packageForm = usePackageForm({
const [loadingProjects, setLoadingProjects] = useState(false); onSaved: () => setIsDialogOpen(false),
// Pagination state
const [skip, setSkip] = useState(0);
const [limit, setLimit] = useState(10);
// Editing state
const [isEditing, setIsEditing] = useState(false);
const [currentPackage, setCurrentPackage] = useState<PackageType | null>(null);
// Form fields
const [selectedProjectId, setSelectedProjectId] = useState('');
const [name, setName] = useState('');
const [region, setRegion] = useState('');
const [chainageStartKm, setChainageStartKm] = useState<string>('');
const [chainageEndKm, setChainageEndKm] = useState<string>('');
// Load packages and projects
const loadPackages = async (currentSkip = skip, currentLimit = limit) => {
try {
setIsLoading(true);
setError(null);
const data = await packageService.getPackages({ skip: currentSkip, limit: currentLimit });
setPackages(data.items);
setTotalItems(data.totalItems);
} catch (err) {
setError('Failed to load packages. Please check if the backend is running.');
} finally {
// Add a small delay for animation stability
setTimeout(() => {
setIsLoading(false);
}, 800);
}
};
const loadProjects = async () => {
try {
setLoadingProjects(true);
const data = await projectService.getProjects({ skip: 0, limit: 1000 }); // Load all projects for selector
setProjects(data.items);
} catch (err) {
setError('Failed to load projects.');
} finally {
setLoadingProjects(false);
}
};
useEffect(() => {
loadPackages(skip, limit);
loadProjects();
}, [skip, limit]);
const resetForm = () => {
setSelectedProjectId('');
setName('');
setRegion('');
setChainageStartKm('');
setChainageEndKm('');
setError(null);
setIsEditing(false);
setCurrentPackage(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedProjectId) {
setError('Please select a project first');
return;
}
if (!name.trim()) {
setError('Package name is required');
return;
}
setIsSubmitting(true);
setError(null);
try {
if (isEditing && currentPackage) {
const data: PackageUpdate = {
name: name.trim(),
region: region.trim() || null,
chainage_start_km: chainageStartKm ? parseFloat(chainageStartKm) : 0,
chainage_end_km: chainageEndKm ? parseFloat(chainageEndKm) : 0,
};
await packageService.updatePackage(currentPackage.id, data);
toast.success('Package Updated', {
description: `${name} has been updated successfully at ${new Date().toLocaleTimeString()}`,
}); });
} else { const {
const data: PackageCreate = { register,
project_id: selectedProjectId, onSubmit,
name: name.trim(), openCreate: prepareCreatePackage,
region: region.trim() || null, openEdit: prepareEditPackage,
chainage_start_km: chainageStartKm ? parseFloat(chainageStartKm) : 0, resetForm,
chainage_end_km: chainageEndKm ? parseFloat(chainageEndKm) : 0, packageId,
}; projectId,
await packageService.createPackage(data); setProjectId,
toast.success('Package Created', { errors,
description: `${name} has been established successfully at ${new Date().toLocaleTimeString()}`, isSubmitted,
}); canSubmit,
} isSaving,
} = packageForm;
// Refresh packages list const openCreate = useCallback(() => {
await loadPackages(); prepareCreatePackage();
setIsDialogOpen(true);
}, [prepareCreatePackage]);
// Close modal and reset form immediately const openEdit = useCallback(
setIsModalOpen(false); (pkg: Package) => {
resetForm(); prepareEditPackage(pkg);
} catch (err) { setIsDialogOpen(true);
const message =
err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} package`;
setError(message);
toast.error('Operation Failed', {
description: message,
});
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (pkg: PackageType) => {
setIsEditing(true);
setCurrentPackage(pkg);
setSelectedProjectId(pkg.project_id);
setName(pkg.name || '');
setRegion(pkg.region || '');
setChainageStartKm(pkg.chainage_start_km?.toString() || '0');
setChainageEndKm(pkg.chainage_end_km?.toString() || '0');
setIsModalOpen(true);
};
const handleDelete = async (pkg: PackageType) => {
if (!confirm(`Are you sure you want to delete package "${pkg.name}"?`)) return;
try {
setIsLoading(true);
await packageService.deletePackage(pkg.id);
toast.success('Package Deleted', {
description: `${pkg.name} has been removed from the system.`,
});
await loadPackages();
} catch (err) {
setError('Failed to delete package');
toast.error('Deletion Failed', {
description: 'The package could not be removed. Please try again.',
});
} finally {
setIsLoading(false);
}
};
const getProjectName = (projectId: string) => {
return projects.find((p) => p.id === projectId)?.name || projectId;
};
const columns: ColumnDef<PackageType>[] = [
{
accessorKey: 'name',
header: 'Package Name',
cell: ({ row }) => (
<div className="font-semibold text-gray-900 dark:text-gray-100">{row.original.name}</div>
),
}, },
{ [prepareEditPackage],
accessorKey: 'project_id',
header: 'Project',
cell: ({ row }) => getProjectName(row.original.project_id),
},
{
accessorKey: 'project_state',
header: 'Project State',
cell: ({ row }) => {
const pkg = row.original;
const project = projects.find((p) => p.id === pkg.project_id);
if (!project?.state) return <span className="text-gray-400"></span>;
const states = project.state
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (states.length === 0) return <span className="text-gray-400"></span>;
const firstState = states[0];
const remainingStates = states.slice(1);
return (
<div className="flex items-center gap-1.5">
<Badge variant="secondary">{firstState}</Badge>
{remainingStates.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<button className="flex items-center justify-center rounded-full bg-muted/50 hover:bg-muted px-1.5 py-0.5 text-[10px] font-bold text-muted-foreground transition-colors border border-border/40">
+{remainingStates.length}
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5">
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-0.5 px-1">
Other States
</p>
{remainingStates.map((item, idx) => (
<Badge key={idx} variant="secondary">
{item}
</Badge>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
); );
const handleDialogOpenChange = useCallback(
(open: boolean) => {
setIsDialogOpen(open);
if (!open) {
resetForm();
}
}, },
[resetForm],
);
const deletePackage = useCallback(
(pkg: Package) => {
setPackageToDelete(pkg);
}, },
{ [],
accessorKey: 'region', );
header: 'Region',
}, const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
{ if (!open) {
accessorKey: 'chainage_start_km', setPackageToDelete(null);
header: 'Start (km)', }
cell: ({ row }) => row.original.chainage_start_km?.toFixed(2) ?? '0.00', }, []);
},
{ const confirmDeletePackage = useCallback(() => {
accessorKey: 'chainage_end_km', if (!packageToDelete) return;
header: 'End (km)',
cell: ({ row }) => row.original.chainage_end_km?.toFixed(2) ?? '0.00', deletePackageMutation.mutate(packageToDelete, {
}, onSettled: () => setPackageToDelete(null),
]; });
}, [deletePackageMutation, packageToDelete]);
const projects = projectsQuery.data?.items ?? [];
const packages = packagesQuery.data?.items ?? [];
const total = packagesQuery.data?.totalItems ?? 0;
const columns = usePackageColumns(projects);
return ( return (
<> <>
<main className="relative z-10"> <main className="relative z-10 space-y-5">
{/* Refined Header */}
<div className="mb-8">
<PageHeader <PageHeader
title="Package Management" title="Package Management"
description="Manage project packages" description="Manage project packages"
icon={Package} icon={PackageIcon}
actions={ actions={
<Button <Button onClick={openCreate} size="sm">
onClick={() => { <Plus />
setIsEditing(false);
setIsModalOpen(true);
}}
>
<Package className="mr-2 h-4 w-4" />
Add New Package Add New Package
</Button> </Button>
} }
/> />
</div>
{/* Data Table */} <PackageTable
<div>
<DataTable
title="Packages"
data={packages}
columns={columns} columns={columns}
onEdit={handleEdit} packages={packages}
onDelete={handleDelete} isLoading={packagesQuery.isLoading || deletePackageMutation.isPending}
isLoading={isLoading} skip={skip}
pagination={{ limit={limit}
skip, total={total}
limit, onPageChange={setSkip}
totalItems, onLimitChange={setLimit}
onPageChange: setSkip, onEdit={openEdit}
onLimitChange: (newLimit) => { onDelete={deletePackage}
setLimit(newLimit);
setSkip(0); // Reset skip when limit changes
},
}}
/> />
</div>
<PoweredBy />
</main> </main>
{/* Modal Dialog */} <PackageDialog
<Dialog open={isDialogOpen}
open={isModalOpen} onOpenChange={handleDialogOpenChange}
onOpenChange={(open) => { packageId={packageId}
if (!open) { projectId={projectId}
setIsModalOpen(false); onProjectChange={setProjectId}
resetForm(); register={register}
} else { errors={errors}
setIsModalOpen(true); isSubmitted={isSubmitted}
} onSubmit={onSubmit}
}} canSubmit={canSubmit}
> isSaving={isSaving}
<DialogContent className="max-w-2xl" onOpenAutoFocus={(e) => e.preventDefault()}>
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3 text-xl">
<div className="p-2 rounded-lg bg-primary text-primary-foreground shadow-sm">
<Package className="h-5 w-5" />
</div>
{isEditing ? 'Edit Package Details' : 'Create New Package'}
</DialogTitle>
<DialogDescription className="text-sm">
{isEditing
? 'Update the technical specifications for your road infrastructure package.'
: 'Select a project and provide the essential data to establish a new package.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-8">
{/* Step 1: Select Project */}
{!isEditing && (
<div className="space-y-3">
<div className="flex items-center gap-2.5">
<div className="w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-[10px] font-bold uppercase">
01
</div>
<p className="text-[11px] font-black text-muted-foreground uppercase tracking-widest">
Select Parent Project
</p>
</div>
<Select value={selectedProjectId} onValueChange={setSelectedProjectId}>
<SelectTrigger className="h-11 bg-muted/10 border-border/40 focus:ring-primary/20">
{loadingProjects ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-muted-foreground text-sm">Loading...</span>
</div>
) : (
<SelectValue placeholder="Choose a project..." />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id} className="py-3">
<span className="font-semibold">{project.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Step 2: Package Info */}
<div
className={`space-y-4 ${selectedProjectId || isEditing ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}
>
{!isEditing && (
<div className="flex items-center gap-2.5">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold uppercase ${selectedProjectId ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}
>
02
</div>
<p className="text-[11px] font-black text-muted-foreground uppercase tracking-widest">
Package Information
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-2">
<Label
htmlFor="pkg-name"
className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider"
>
Package Name <span className="text-destructive">*</span>
</Label>
<Input
id="pkg-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Package 01"
className="h-11 bg-muted/20 border-border/60"
required
/> />
</div>
<div className="space-y-2"> <AlertDialog
<Label open={Boolean(packageToDelete)}
htmlFor="region" onOpenChange={handleDeleteDialogOpenChange}
className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2"
>
<Globe className="h-3.5 w-3.5 opacity-60" />
Region{' '}
<span className="text-[10px] lowercase font-normal opacity-70">(Optional)</span>
</Label>
<Input
id="region"
value={region}
onChange={(e) => setRegion(e.target.value)}
placeholder="e.g. North Zone"
className="h-11 bg-muted/20 border-border/60"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="chainage-start"
className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider"
>
Segment Start (km)
</Label>
<Input
id="chainage-start"
type="number"
step="0.01"
value={chainageStartKm}
onChange={(e) => setChainageStartKm(e.target.value)}
placeholder="0.00"
className="h-11 bg-muted/20 border-border/60"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="chainage-end"
className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider"
>
Segment End (km)
</Label>
<Input
id="chainage-end"
type="number"
step="0.01"
value={chainageEndKm}
onChange={(e) => setChainageEndKm(e.target.value)}
placeholder="0.00"
className="h-11 bg-muted/20 border-border/60"
/>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex gap-4 pt-4">
<Button
type="button"
variant="outline"
onClick={() => {
setIsModalOpen(false);
resetForm();
}}
disabled={isSubmitting}
className="flex-1"
> >
<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 Cancel
</Button> </AlertDialogCancel>
<Button <AlertDialogAction
type="submit" variant="destructive"
disabled={isSubmitting || !name.trim() || !selectedProjectId} disabled={deletePackageMutation.isPending}
className="flex-1" onClick={confirmDeletePackage}
> >
{isSubmitting ? ( Delete
<div className="flex items-center gap-2"> </AlertDialogAction>
<Loader2 className="h-4 w-4 animate-spin" /> </AlertDialogFooter>
<span>{isEditing ? 'Updating...' : 'Creating...'}</span> </AlertDialogContent>
</div> </AlertDialog>
) : (
<div className="flex items-center gap-2">
{isEditing ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Package className="h-4 w-4" />
)}
<span>{isEditing ? 'Update Package' : 'Create Package'}</span>
</div>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</> </>
); );
} }

View File

@@ -0,0 +1,10 @@
import type { PaginationParams } from '@/types';
export const packageKeys = {
all: ['packages'] as const,
lists: () => [...packageKeys.all, 'list'] as const,
list: (params: PaginationParams) => [...packageKeys.lists(), params] as const,
options: () => [...packageKeys.all, 'options'] as const,
details: () => [...packageKeys.all, 'detail'] as const,
detail: (id: string) => [...packageKeys.details(), id] as const,
};

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

@@ -0,0 +1,80 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import type { Plan } from '@/types';
function formatDate(value?: string | null) {
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(new Date(value));
}
function formatPrice(value: string, cycle: string) {
const amount = Number(value);
const price = Number.isFinite(amount) ? amount.toFixed(2) : value;
return `${price} / ${cycle}`;
}
export function usePlanColumns(): ColumnDef<Plan>[] {
return useMemo(() => {
return [
{
accessorKey: 'name',
header: 'Plan',
cell: ({ row }) => (
<div>
<p>{row.original.name}</p>
<p className="text-muted-foreground">{row.original.slug}</p>
</div>
),
},
{
accessorKey: 'price',
header: 'Price',
cell: ({ row }) =>
formatPrice(row.original.price, row.original.billing_cycle),
},
{
accessorKey: 'trial_days',
header: 'Trial',
cell: ({ row }) => `${row.original.trial_days} days`,
},
{
id: 'limits',
header: 'Limits',
enableSorting: false,
cell: ({ row }) => (
<div className="text-muted-foreground">
<p>{row.original.max_projects} projects</p>
<p>{row.original.max_users} users</p>
</div>
),
},
{
accessorKey: 'created_at',
header: 'Created',
cell: ({ row }) => formatDate(row.original.created_at),
},
{
accessorKey: 'is_active',
header: 'Status',
cell: ({ row }) => (
<div className="flex flex-wrap gap-1.5">
<Badge variant={row.original.is_active ? 'default' : 'secondary'}>
{row.original.is_active ? 'Active' : 'Inactive'}
</Badge>
{row.original.is_custom ? (
<Badge variant="outline">Custom</Badge>
) : null}
</div>
),
},
];
}, []);
}

View File

@@ -0,0 +1,49 @@
'use client';
import { TableSearchInput } from '@/components/data-table/TableSearchInput';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { PlanStatusFilter } from '../hooks/usePlanFilters';
interface PlanFiltersProps {
searchTerm: string;
statusFilter: PlanStatusFilter;
onSearchChange: (value: string) => void;
onStatusChange: (value: PlanStatusFilter) => void;
}
export function PlanFilters({
searchTerm,
statusFilter,
onSearchChange,
onStatusChange,
}: PlanFiltersProps) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<TableSearchInput
value={searchTerm}
onChange={onSearchChange}
placeholder="Search plans"
className="w-full sm:w-72"
/>
<Select
value={statusFilter}
onValueChange={(value) => onStatusChange(value as PlanStatusFilter)}
>
<SelectTrigger className="w-full sm:w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,309 @@
'use client';
import type { ComponentProps } from 'react';
import type { Control, FieldErrors, UseFormRegister } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
import { PermissionTree } from '@/components/permission-tree';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import type { PermissionTreeItem } from '@/types';
import type { PlanFormValues } from '../hooks/usePlanForm';
interface PlanSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
planId?: number;
register: UseFormRegister<PlanFormValues>;
control: Control<PlanFormValues>;
errors: FieldErrors<PlanFormValues>;
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean;
permissionTree: PermissionTreeItem[];
permissionIds: number[];
onPermissionIdsChange: (ids: number[]) => void;
isPermissionsLoading: boolean;
isSaving: boolean;
}
export function PlanSheet({
open,
onOpenChange,
planId,
register,
control,
errors,
isSubmitted,
onSubmit,
canSubmit,
permissionTree,
permissionIds,
onPermissionIdsChange,
isPermissionsLoading,
isSaving,
}: PlanSheetProps) {
const fieldError = (field: keyof PlanFormValues) =>
isSubmitted ? errors[field]?.message : undefined;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full! gap-0 p-0 sm:max-w-3xl! lg:max-w-4xl!">
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
{planId ? 'Edit Plan' : 'Create Plan'}
</SheetTitle>
<SheetDescription className="text-sm">
Configure subscription limits, billing, and permission access.
</SheetDescription>
</SheetHeader>
<form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="plan-name"
label="Name"
required
error={fieldError('name')}
>
<Input
id="plan-name"
placeholder="Enter plan name"
aria-invalid={!!fieldError('name')}
{...register('name')}
/>
</FormField>
<FormField
id="plan-slug"
label="Slug"
required
error={fieldError('slug')}
>
<Input
id="plan-slug"
placeholder="starter"
aria-invalid={!!fieldError('slug')}
{...register('slug')}
/>
</FormField>
</div>
<FormField
id="plan-description"
label="Description"
required
error={fieldError('description')}
>
<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">
<FormField
id="plan-price"
label="Price"
required
error={fieldError('price')}
>
<Input
id="plan-price"
type="number"
min="0"
step="0.01"
placeholder="29.99"
aria-invalid={!!fieldError('price')}
{...register('price')}
/>
</FormField>
<FormField
label="Billing Cycle"
required
error={fieldError('billing_cycle')}
>
<Controller
control={control}
name="billing_cycle"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<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"
label="Trial Days"
error={fieldError('trial_days')}
>
<Input
id="plan-trial-days"
type="number"
min="0"
aria-invalid={!!fieldError('trial_days')}
{...register('trial_days', { valueAsNumber: true })}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-4">
<FormField
id="max-projects"
label="Projects"
error={fieldError('max_projects')}
>
<Input
id="max-projects"
type="number"
min="0"
aria-invalid={!!fieldError('max_projects')}
{...register('max_projects', { valueAsNumber: true })}
/>
</FormField>
<FormField
id="max-organizations"
label="Organizations"
error={fieldError('max_organizations')}
>
<Input
id="max-organizations"
type="number"
min="0"
aria-invalid={!!fieldError('max_organizations')}
{...register('max_organizations', { valueAsNumber: true })}
/>
</FormField>
<FormField
id="max-users"
label="Users"
error={fieldError('max_users')}
>
<Input
id="max-users"
type="number"
min="0"
aria-invalid={!!fieldError('max_users')}
{...register('max_users', { valueAsNumber: true })}
/>
</FormField>
<FormField
id="max-roles"
label="Roles"
error={fieldError('max_roles')}
>
<Input
id="max-roles"
type="number"
min="0"
aria-invalid={!!fieldError('max_roles')}
{...register('max_roles', { valueAsNumber: true })}
/>
</FormField>
</div>
<div className="flex flex-wrap gap-5">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="size-4 rounded-lg border-border accent-primary"
{...register('is_active')}
/>
Active
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
className="size-4 rounded-lg border-border accent-primary"
{...register('is_custom')}
/>
Custom plan
</label>
</div>
<FormField
label="Permissions"
required
error={fieldError('permission_ids')}
className="space-y-3"
labelEnd={
<span className="text-muted-foreground">
{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>
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{planId ? 'Update Plan' : 'Create Plan'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import type { ReactNode } from 'react';
import type { ColumnDef, SortingState } from '@tanstack/react-table';
import { Edit, RotateCcw } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import { PERMISSIONS } from '@/constants/permissions';
import type { Plan } from '@/types';
interface PlanTableProps {
columns: ColumnDef<Plan>[];
plans: Plan[];
isLoading: boolean;
toolbar: ReactNode;
skip: number;
limit: number;
total: number;
sorting: SortingState;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onSortingChange: (sorting: SortingState) => void;
onEdit: (plan: Plan) => void;
onToggleStatus: (plan: Plan) => void;
pendingPlanId?: number;
}
export function PlanTable({
columns,
plans,
isLoading,
toolbar,
skip,
limit,
total,
sorting,
onPageChange,
onLimitChange,
onSortingChange,
onEdit,
onToggleStatus,
pendingPlanId,
}: PlanTableProps) {
return (
<DataTable
title="Plans"
columns={columns}
data={plans}
isLoading={isLoading}
toolbar={toolbar}
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={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange,
}}
sorting={sorting}
onSortingChange={onSortingChange}
actionsSticky
/>
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import { useState } from 'react';
import type { SortingState } from '@tanstack/react-table';
import { useDebounce } from '@/hooks/useDebounce';
export type PlanStatusFilter = 'all' | 'active' | 'inactive';
export function usePlanFilters() {
const [skip, setSkip] = useState(0);
const [limit, setLimitValue] = useState(10);
const [sorting, setSortingValue] = useState<SortingState>([]);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<PlanStatusFilter>('all');
const debouncedSearchTerm = useDebounce(searchTerm, 400);
const updateSearchTerm = (value: string) => {
setSearchTerm(value);
setSkip(0);
};
const updateStatusFilter = (value: PlanStatusFilter) => {
setStatusFilter(value);
setSkip(0);
};
const setLimit = (value: number) => {
setLimitValue(value);
setSkip(0);
};
const setSorting = (value: SortingState) => {
setSortingValue(value);
setSkip(0);
};
return {
skip,
setSkip,
limit,
setLimit,
sorting,
setSorting,
searchTerm,
debouncedSearchTerm,
setSearchTerm: updateSearchTerm,
statusFilter,
setStatusFilter: updateStatusFilter,
};
}

View File

@@ -0,0 +1,176 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import {
collectDefaultPermissionIds,
collectPermissionIdsByKeys,
} from '@/components/permission-tree';
import type { PermissionTreeItem, Plan } from '@/types';
import { useCallback } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { useSavePlanMutation } from './usePlanMutations';
const nonNegativeNumber = z.number().refine(
(value) => Number.isFinite(value) && value >= 0,
{ message: 'Enter a valid number' },
);
const planFormSchema = z.object({
id: z.number().optional(),
name: z.string().trim().min(1, 'Plan name is required'),
slug: z.string().trim().min(1, 'Slug is required'),
description: z.string().trim().min(1, 'Description is required'),
price: z
.string()
.trim()
.min(1, 'Price is required')
.refine((value) => Number.isFinite(Number(value)) && Number(value) >= 0, {
message: 'Enter a valid price',
}),
billing_cycle: z.string().trim().min(1, 'Billing cycle is required'),
trial_days: nonNegativeNumber,
max_projects: nonNegativeNumber,
max_organizations: nonNegativeNumber,
max_users: nonNegativeNumber,
max_roles: nonNegativeNumber,
permission_ids: z.array(z.number()).min(1, 'Select at least one permission'),
is_active: z.boolean(),
is_custom: z.boolean(),
});
export type PlanFormValues = z.infer<typeof planFormSchema>;
const defaultValues: PlanFormValues = {
name: '',
slug: '',
description: '',
price: '',
billing_cycle: 'monthly',
trial_days: 0,
max_projects: 0,
max_organizations: 0,
max_users: 0,
max_roles: 0,
permission_ids: [],
is_active: true,
is_custom: false,
};
function toNumber(value: unknown) {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : 0;
}
export function usePlanForm({
permissionTree,
onSaved,
}: {
permissionTree: PermissionTreeItem[];
onSaved: () => void;
}) {
const {
control,
register,
handleSubmit,
reset,
setValue,
formState: { errors, isSubmitting, isSubmitted },
} = useForm<PlanFormValues>({
defaultValues,
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(planFormSchema),
});
const saveMutation = useSavePlanMutation({
onSaved: () => {
reset(defaultValues);
onSaved();
},
});
const planId = useWatch({ control, name: 'id' });
const permissionIds = useWatch({ control, name: 'permission_ids' }) || [];
const name = useWatch({ control, name: 'name' }) || '';
const slug = useWatch({ control, name: 'slug' }) || '';
const description = useWatch({ control, name: 'description' }) || '';
const price = useWatch({ control, name: 'price' }) || '';
const canSubmit =
name.trim().length > 0 &&
slug.trim().length > 0 &&
description.trim().length > 0 &&
price.trim().length > 0 &&
permissionIds.length > 0;
const onSubmit = handleSubmit((values) => {
saveMutation.mutate({
...values,
name: values.name.trim(),
slug: values.slug.trim(),
description: values.description.trim(),
price: values.price.trim(),
trial_days: toNumber(values.trial_days),
max_projects: toNumber(values.max_projects),
max_organizations: toNumber(values.max_organizations),
max_users: toNumber(values.max_users),
max_roles: toNumber(values.max_roles),
});
});
const openCreate = useCallback(() => {
reset({
...defaultValues,
permission_ids: collectDefaultPermissionIds(permissionTree),
});
}, [permissionTree, reset]);
const openEdit = useCallback(
(plan: Plan) => {
reset({
id: plan.id,
name: plan.name || '',
slug: plan.slug || '',
description: plan.description || '',
price: plan.price || '',
billing_cycle: plan.billing_cycle || 'monthly',
trial_days: plan.trial_days ?? 0,
max_projects: plan.max_projects ?? 0,
max_organizations: plan.max_organizations ?? 0,
max_users: plan.max_users ?? 0,
max_roles: plan.max_roles ?? 0,
permission_ids: collectPermissionIdsByKeys(
permissionTree,
plan.permissions || [],
),
is_active: plan.is_active,
is_custom: plan.is_custom,
});
},
[permissionTree, reset],
);
const setPermissionIds = useCallback(
(ids: number[]) =>
setValue('permission_ids', ids, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue],
);
return {
register,
control,
onSubmit,
openCreate,
openEdit,
planId,
permissionIds,
setPermissionIds,
errors,
isSubmitted,
canSubmit,
isSaving: isSubmitting || saveMutation.isPending,
};
}

View File

@@ -0,0 +1,41 @@
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { planService } from '@/services/api';
import type { Plan, PlanRequest } from '@/types';
import { planKeys } from '../queries/planKeys';
export function useSavePlanMutation({ onSaved }: { onSaved: () => void }) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: PlanRequest) => planService.savePlan(payload),
onSuccess: (_data, values) => {
toast.success(values.id ? 'Plan updated' : 'Plan created');
queryClient.invalidateQueries({ queryKey: planKeys.lists() });
onSaved();
},
onError: (_error, values) => {
toast.error(
values.id ? 'Failed to update plan' : 'Failed to create plan',
);
},
});
}
export function usePlanStatusMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (plan: Plan) =>
planService.updatePlanStatus(plan.id, !plan.is_active),
onSuccess: () => {
toast.success('Plan status updated');
queryClient.invalidateQueries({ queryKey: planKeys.lists() });
},
onError: () => toast.error('Failed to update plan status'),
});
}

View File

@@ -0,0 +1,67 @@
'use client';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { SortingState } from '@tanstack/react-table';
import { getServerSortParams } from '@/components/data-table/sorting';
import { mapPermissionTree } from '@/components/permission-tree';
import { permissionService, planService } from '@/services/api';
import type { PlanListParams } from '@/types';
import { planKeys, planPermissionTreeKeys } from '../queries/planKeys';
import type { PlanStatusFilter } from './usePlanFilters';
interface UsePlansQueryParams {
skip: number;
limit: number;
searchTerm: string;
statusFilter: PlanStatusFilter;
sorting: SortingState;
}
function buildPlanListParams({
skip,
limit,
searchTerm,
statusFilter,
sorting,
}: UsePlansQueryParams): PlanListParams {
return {
skip,
limit,
search_term: searchTerm || undefined,
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
...getServerSortParams(sorting),
};
}
export function usePlansQuery(params: UsePlansQueryParams) {
const listParams = useMemo(() => buildPlanListParams(params), [params]);
return useQuery({
queryKey: planKeys.list(listParams),
queryFn: () => planService.getPlans(listParams),
});
}
export function useOrganizationPermissionTreeQuery(enabled: boolean) {
const permissionsQuery = useQuery({
queryKey: planPermissionTreeKeys.all,
queryFn: permissionService.getOrganizationPermissionTree,
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnMount: false,
refetchOnReconnect: false,
});
const permissionTree = useMemo(
() => mapPermissionTree(permissionsQuery.data || []),
[permissionsQuery.data],
);
return {
...permissionsQuery,
permissionTree,
};
}

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

@@ -0,0 +1,201 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BadgeIndianRupee, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { Plan } from '@/types';
import { usePlanColumns } from './components/PlanColumns';
import { PlanFilters } from './components/PlanFilters';
import { PlanSheet } from './components/PlanSheet';
import { PlanTable } from './components/PlanTable';
import { usePlanFilters } from './hooks/usePlanFilters';
import { usePlanForm } from './hooks/usePlanForm';
import { usePlanStatusMutation } from './hooks/usePlanMutations';
import {
useOrganizationPermissionTreeQuery,
usePlansQuery,
} from './hooks/usePlanQueries';
export default function PlansPage() {
const [isSheetOpen, setIsSheetOpen] = useState(false);
const pendingPlanRef = useRef<Plan | null>(null);
const hasSyncedPermissionsRef = useRef(false);
const {
skip,
setSkip,
limit,
setLimit,
sorting,
setSorting,
searchTerm,
debouncedSearchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
} = usePlanFilters();
const plansQuery = usePlansQuery({
skip,
limit,
searchTerm: debouncedSearchTerm,
statusFilter,
sorting,
});
const permissionsQuery = useOrganizationPermissionTreeQuery(isSheetOpen);
const handleSheetOpenChange = useCallback((open: boolean) => {
if (!open) {
pendingPlanRef.current = null;
}
setIsSheetOpen(open);
}, []);
const planForm = usePlanForm({
permissionTree: permissionsQuery.permissionTree,
onSaved: () => handleSheetOpenChange(false),
});
const { openCreate: prepareCreatePlan, openEdit: prepareEditPlan } = planForm;
const {
register,
control,
onSubmit,
planId,
permissionIds,
setPermissionIds,
errors,
isSubmitted,
canSubmit,
isSaving,
} = planForm;
const statusMutation = usePlanStatusMutation();
const openCreate = useCallback(() => {
pendingPlanRef.current = null;
setIsSheetOpen(true);
prepareCreatePlan();
}, [prepareCreatePlan]);
const openEdit = useCallback(
(plan: Plan) => {
pendingPlanRef.current = plan;
setIsSheetOpen(true);
prepareEditPlan(plan);
},
[prepareEditPlan],
);
useEffect(() => {
if (!isSheetOpen) {
hasSyncedPermissionsRef.current = false;
return;
}
if (
hasSyncedPermissionsRef.current ||
permissionsQuery.isLoading ||
permissionsQuery.permissionTree.length === 0
) {
return;
}
hasSyncedPermissionsRef.current = true;
if (pendingPlanRef.current) {
prepareEditPlan(pendingPlanRef.current);
return;
}
if (!planId) {
prepareCreatePlan();
}
}, [
isSheetOpen,
permissionsQuery.isLoading,
permissionsQuery.permissionTree,
planId,
prepareCreatePlan,
prepareEditPlan,
]);
const toggleStatus = useCallback(
(plan: Plan) => {
statusMutation.mutate(plan);
},
[statusMutation],
);
const columns = usePlanColumns();
const plans = plansQuery.data?.items ?? [];
const total = plansQuery.data?.total ?? plansQuery.data?.totalItems ?? 0;
const toolbar = useMemo(
() => (
<PlanFilters
searchTerm={searchTerm}
statusFilter={statusFilter}
onSearchChange={setSearchTerm}
onStatusChange={setStatusFilter}
/>
),
[searchTerm, setSearchTerm, setStatusFilter, statusFilter],
);
return (
<>
<main className="relative z-10 space-y-5">
<PageHeader
title="Plan Management"
description="Manage subscription plans, limits, and permission access"
icon={BadgeIndianRupee}
actions={
<PermissionGuard permissions={PERMISSIONS.PLAN.CREATE}>
<Button onClick={openCreate}>
<Plus className="mr-2 size-4" />
Add Plan
</Button>
</PermissionGuard>
}
/>
<PlanTable
columns={columns}
plans={plans}
isLoading={plansQuery.isLoading}
toolbar={toolbar}
skip={skip}
limit={limit}
total={total}
sorting={sorting}
onPageChange={setSkip}
onLimitChange={setLimit}
onSortingChange={setSorting}
onEdit={openEdit}
onToggleStatus={toggleStatus}
pendingPlanId={statusMutation.variables?.id}
/>
</main>
<PlanSheet
open={isSheetOpen}
onOpenChange={handleSheetOpenChange}
planId={planId}
register={register}
control={control}
errors={errors}
isSubmitted={isSubmitted}
onSubmit={onSubmit}
canSubmit={canSubmit}
permissionTree={permissionsQuery.permissionTree}
permissionIds={permissionIds}
onPermissionIdsChange={setPermissionIds}
isPermissionsLoading={permissionsQuery.isLoading}
isSaving={isSaving}
/>
</>
);
}

View File

@@ -0,0 +1,11 @@
import type { PlanListParams } from '@/types';
export const planKeys = {
all: ['plans'] as const,
lists: () => [...planKeys.all, 'list'] as const,
list: (params: PlanListParams) => [...planKeys.lists(), params] as const,
};
export const planPermissionTreeKeys = {
all: ['plan-permission-tree'] as const,
};

View File

@@ -0,0 +1,84 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Project } from '@/types';
function EmptyValue() {
return <span className="text-muted-foreground">-</span>;
}
function StateBadges({ value }: { value: string | null }) {
if (!value) {
return <EmptyValue />;
}
const states = value
.split(',')
.map((state) => state.trim())
.filter(Boolean);
if (!states.length) {
return <EmptyValue />;
}
const [firstState, ...remainingStates] = states;
return (
<div className="flex items-center gap-1.5">
<Badge variant="secondary">{firstState}</Badge>
{remainingStates.length > 0 ? (
<Popover>
<PopoverTrigger asChild>
<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-muted-foreground transition-colors hover:bg-muted"
>
+{remainingStates.length}
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
{remainingStates.map((state) => (
<Badge key={state} variant="secondary">
{state}
</Badge>
))}
</div>
</PopoverContent>
</Popover>
) : null}
</div>
);
}
export function useProjectColumns() {
return useMemo<ColumnDef<Project>[]>(
() => [
{
accessorKey: 'name',
header: 'Project Name',
cell: ({ row }) => <div>{row.original.name}</div>,
},
{
accessorKey: 'state',
header: 'State',
cell: ({ row }) => <StateBadges value={row.original.state} />,
},
{
accessorKey: 'corridor_name',
header: 'Corridor',
cell: ({ row }) => row.original.corridor_name || <EmptyValue />,
},
],
[],
);
}

View File

@@ -0,0 +1,218 @@
'use client';
import type { ComponentProps } from 'react';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import type { ProjectFormValues } from '../hooks/useProjectForm';
interface ProjectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId?: string;
register: UseFormRegister<ProjectFormValues>;
errors: FieldErrors<ProjectFormValues>;
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean;
isSaving: boolean;
}
export function ProjectDialog({
open,
onOpenChange,
projectId,
register,
errors,
isSubmitted,
onSubmit,
canSubmit,
isSaving,
}: ProjectDialogProps) {
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const startLatErrorMessage = isSubmitted
? errors.start_lat?.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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle>
{projectId ? 'Edit Project' : 'Create Project'}
</DialogTitle>
<DialogDescription>
Manage road project identity and optional coordinate boundaries.
</DialogDescription>
</DialogHeader>
<form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<DialogBody className="space-y-6">
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Basic details</h3>
<p className="text-sm text-muted-foreground">
Project name and corridor information.
</p>
</div>
<FormField
id="project-name"
label="Project Name"
required
error={nameErrorMessage}
>
<Input
id="project-name"
placeholder="Enter project name"
aria-invalid={!!nameErrorMessage}
{...register('name')}
/>
</FormField>
<div className="grid gap-4 md:grid-cols-2">
<FormField id="project-state" label="State">
<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"
label="Start Latitude"
error={startLatErrorMessage}
>
<Input
id="project-start-lat"
type="number"
step="any"
min="-90"
max="90"
placeholder="Enter start latitude"
aria-invalid={!!startLatErrorMessage}
{...register('start_lat')}
/>
</FormField>
<FormField
id="project-start-lng"
label="Start Longitude"
error={startLngErrorMessage}
>
<Input
id="project-start-lng"
type="number"
step="any"
min="-180"
max="180"
placeholder="Enter start longitude"
aria-invalid={!!startLngErrorMessage}
{...register('start_lng')}
/>
</FormField>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
id="project-end-lat"
label="End Latitude"
error={endLatErrorMessage}
>
<Input
id="project-end-lat"
type="number"
step="any"
min="-90"
max="90"
placeholder="Enter end latitude"
aria-invalid={!!endLatErrorMessage}
{...register('end_lat')}
/>
</FormField>
<FormField
id="project-end-lng"
label="End Longitude"
error={endLngErrorMessage}
>
<Input
id="project-end-lng"
type="number"
step="any"
min="-180"
max="180"
placeholder="Enter end longitude"
aria-invalid={!!endLngErrorMessage}
{...register('end_lng')}
/>
</FormField>
</div>
</div>
</section>
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{projectId ? 'Update Project' : 'Create Project'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import type { ColumnDef } from '@tanstack/react-table';
import { Edit3, Trash2 } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import type { Project } from '@/types';
interface ProjectTableProps {
columns: ColumnDef<Project>[];
projects: Project[];
isLoading: boolean;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onEdit: (project: Project) => void;
onDelete: (project: Project) => void;
}
export function ProjectTable({
columns,
projects,
isLoading,
skip,
limit,
total,
onPageChange,
onLimitChange,
onEdit,
onDelete,
}: ProjectTableProps) {
return (
<DataTable
title="Projects"
data={projects}
columns={columns}
actions={[
{
label: 'Edit',
icon: <Edit3 className="size-4" />,
onClick: onEdit,
},
{
label: 'Delete',
icon: <Trash2 className="size-4" />,
className: 'text-destructive',
onClick: onDelete,
},
]}
isLoading={isLoading}
emptyTitle="No projects found."
pagination={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange,
}}
actionsSticky
/>
);
}

View File

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

View File

@@ -0,0 +1,137 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import {
optionalLatitude,
optionalLongitude,
} from '@/lib/validation/numberField';
import { projectService } from '@/services/api';
import type { Project, ProjectCreate, ProjectUpdate } from '@/types';
import { projectKeys } from '../queries/projectKeys';
const projectFormSchema = z.object({
id: z.string().optional(),
name: z.string().trim().min(1, 'Project name is required'),
state: z.string().optional(),
corridor_name: z.string().optional(),
start_lat: optionalLatitude,
start_lng: optionalLongitude,
end_lat: optionalLatitude,
end_lng: optionalLongitude,
});
export type ProjectFormValues = z.infer<typeof projectFormSchema>;
const defaultValues: ProjectFormValues = {
name: '',
state: '',
corridor_name: '',
start_lat: '',
start_lng: '',
end_lat: '',
end_lng: '',
};
function trimOptional(value?: string) {
return value?.trim() || null;
}
function toNullableNumber(value: string) {
return value.trim() ? Number(value) : null;
}
function toProjectPayload(
values: ProjectFormValues,
): ProjectCreate | ProjectUpdate {
return {
name: values.name.trim(),
state: trimOptional(values.state),
corridor_name: trimOptional(values.corridor_name),
start_lat: toNullableNumber(values.start_lat),
start_lng: toNullableNumber(values.start_lng),
end_lat: toNullableNumber(values.end_lat),
end_lng: toNullableNumber(values.end_lng),
};
}
export function useProjectForm({ onSaved }: { onSaved: () => void }) {
const queryClient = useQueryClient();
const {
register,
handleSubmit,
reset,
control,
formState: { errors, isSubmitting, isSubmitted },
} = useForm<ProjectFormValues>({
defaultValues,
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(projectFormSchema),
});
const projectId = useWatch({ control, name: 'id' });
const projectName = useWatch({ control, name: 'name' }) || '';
const canSubmit = projectName.trim().length > 0;
const saveMutation = useMutation({
mutationFn: (values: ProjectFormValues) => {
const payload = toProjectPayload(values);
return values.id
? projectService.updateProject(values.id, payload)
: projectService.createProject(payload as ProjectCreate);
},
onSuccess: (_data, values) => {
toast.success(values.id ? 'Project updated' : 'Project created');
reset(defaultValues);
onSaved();
queryClient.invalidateQueries({ queryKey: projectKeys.all });
},
onError: (_error, values) => {
toast.error(
values.id ? 'Failed to update project' : 'Failed to create project',
);
},
});
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
const openCreate = useCallback(() => {
reset(defaultValues);
}, [reset]);
const openEdit = useCallback(
(project: Project) => {
reset({
id: project.id,
name: project.name || '',
state: project.state || '',
corridor_name: project.corridor_name || '',
start_lat: project.start_lat?.toString() ?? '',
start_lng: project.start_lng?.toString() ?? '',
end_lat: project.end_lat?.toString() ?? '',
end_lng: project.end_lng?.toString() ?? '',
});
},
[reset],
);
return {
register,
onSubmit,
openCreate,
openEdit,
resetForm: () => reset(defaultValues),
projectId,
errors,
isSubmitted,
canSubmit,
isSaving: isSubmitting || saveMutation.isPending,
};
}

View File

@@ -0,0 +1,47 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { toast } from 'sonner';
import { projectService } from '@/services/api';
import type { PaginationParams, Project } from '@/types';
import { projectKeys } from '../queries/projectKeys';
interface UseProjectsQueryParams {
skip: number;
limit: number;
}
export function useProjectsQuery({ skip, limit }: UseProjectsQueryParams) {
const listParams = useMemo<PaginationParams>(
() => ({ skip, limit }),
[limit, skip],
);
return useQuery({
queryKey: projectKeys.list(listParams),
queryFn: () => projectService.getProjects(listParams),
});
}
export function useDeleteProjectMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (project: Project) => projectService.deleteProject(project.id),
onSuccess: (_data, project) => {
toast.success('Project deleted', {
description: `${project.name} has been removed from the system.`,
});
queryClient.invalidateQueries({ queryKey: projectKeys.all });
},
onError: () => {
toast.error('Deletion failed', {
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

@@ -1,497 +1,172 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useCallback, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Layers, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Loader2, CheckCircle2, Layers, MapPin, Building2, Route, X } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { projectService } from '@/services/api'; import {
import { ProjectCreate, Project, ProjectUpdate } from '@/types'; AlertDialog,
import { ColumnDef } from '@tanstack/react-table'; AlertDialogAction,
import { toast } from 'sonner'; AlertDialogCancel,
import { Badge } from '@/components/ui/badge'; AlertDialogContent,
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; AlertDialogDescription,
import { PoweredBy } from '@/components/powered-by'; AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import type { Project } from '@/types';
import { ProjectDialog } from './components/ProjectDialog';
import { ProjectTable } from './components/ProjectTable';
import { useProjectColumns } from './components/ProjectColumns';
import { useProjectFilters } from './hooks/useProjectFilters';
import { useProjectForm } from './hooks/useProjectForm';
import {
useDeleteProjectMutation,
useProjectsQuery,
} from './hooks/useProjectQueries';
export default function ProjectPage() { export default function ProjectPage() {
const [projects, setProjects] = useState<Project[]>([]); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [totalItems, setTotalItems] = useState(0); const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
const [isLoading, setIsLoading] = useState(true); const { skip, setSkip, limit, setLimit } = useProjectFilters();
const [isModalOpen, setIsModalOpen] = useState(false); const projectsQuery = useProjectsQuery({ skip, limit });
const [isSubmitting, setIsSubmitting] = useState(false); const deleteProjectMutation = useDeleteProjectMutation();
const [error, setError] = useState<string | null>(null); const projectForm = useProjectForm({
onSaved: () => setIsDialogOpen(false),
// Pagination state
const [skip, setSkip] = useState(0);
const [limit, setLimit] = useState(10);
// Editing state
const [isEditing, setIsEditing] = useState(false);
const [currentProject, setCurrentProject] = useState<Project | null>(null);
// Form fields
const [name, setName] = useState('');
const [state, setState] = useState('');
const [corridorName, setCorridorName] = useState('');
const [startLat, setStartLat] = useState('');
const [startLng, setStartLng] = useState('');
const [endLat, setEndLat] = useState('');
const [endLng, setEndLng] = useState('');
// Load projects
const loadProjects = async (currentSkip = skip, currentLimit = limit) => {
try {
setIsLoading(true);
setError(null);
const data = await projectService.getProjects({ skip: currentSkip, limit: currentLimit });
setProjects(data.items);
setTotalItems(data.totalItems);
} catch (err) {
setError('Failed to load projects. Please check if the backend is running.');
} finally {
// Add a small delay for animation stability
setTimeout(() => {
setIsLoading(false);
}, 800);
}
};
useEffect(() => {
loadProjects(skip, limit);
}, [skip, limit]);
const resetForm = () => {
setName('');
setState('');
setCorridorName('');
setStartLat('');
setStartLng('');
setEndLat('');
setEndLng('');
setError(null);
setIsEditing(false);
setCurrentProject(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
setError('Project name is required');
return;
}
setIsSubmitting(true);
setError(null);
try {
if (isEditing && currentProject) {
const data: ProjectUpdate = {
name: name.trim(),
state: state.trim() || null,
corridor_name: corridorName.trim() || null,
start_lat: startLat ? parseFloat(startLat) : null,
start_lng: startLng ? parseFloat(startLng) : null,
end_lat: endLat ? parseFloat(endLat) : null,
end_lng: endLng ? parseFloat(endLng) : null,
};
await projectService.updateProject(currentProject.id, data);
toast.success('Project Updated', {
description: `${name} has been updated successfully at ${new Date().toLocaleTimeString()}`,
}); });
} else { const {
const data: ProjectCreate = { register,
name: name.trim(), onSubmit,
state: state.trim() || null, openCreate: prepareCreateProject,
corridor_name: corridorName.trim() || null, openEdit: prepareEditProject,
start_lat: startLat ? parseFloat(startLat) : null, resetForm,
start_lng: startLng ? parseFloat(startLng) : null, projectId,
end_lat: endLat ? parseFloat(endLat) : null, errors,
end_lng: endLng ? parseFloat(endLng) : null, isSubmitted,
}; canSubmit,
await projectService.createProject(data); isSaving,
toast.success('Project Created', { } = projectForm;
description: `${name} has been established successfully at ${new Date().toLocaleTimeString()}`,
});
}
// Refresh projects list const openCreate = useCallback(() => {
await loadProjects(); prepareCreateProject();
setIsDialogOpen(true);
}, [prepareCreateProject]);
// Close modal and reset form immediately const openEdit = useCallback(
setIsModalOpen(false); (project: Project) => {
resetForm(); prepareEditProject(project);
} catch (err) { setIsDialogOpen(true);
const message =
err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} project`;
setError(message);
toast.error('Operation Failed', {
description: message,
});
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (project: Project) => {
setIsEditing(true);
setCurrentProject(project);
setName(project.name || '');
setState(project.state || '');
setCorridorName(project.corridor_name || '');
setStartLat(project.start_lat?.toString() || '');
setStartLng(project.start_lng?.toString() || '');
setEndLat(project.end_lat?.toString() || '');
setEndLng(project.end_lng?.toString() || '');
setIsModalOpen(true);
};
const handleDelete = async (project: Project) => {
if (!confirm(`Are you sure you want to delete project "${project.name}"?`)) return;
try {
setIsLoading(true);
await projectService.deleteProject(project.id);
toast.success('Project Deleted', {
description: `${project.name} has been removed from the system.`,
});
await loadProjects();
} catch (err) {
setError('Failed to delete project');
toast.error('Deletion Failed', {
description:
'The project could not be removed. Please try again or check your permissions.',
});
} finally {
setIsLoading(false);
}
};
const columns: ColumnDef<Project>[] = [
{
accessorKey: 'name',
header: 'Project Name',
cell: ({ row }) => <div className="font-semibold">{row.original.name}</div>,
}, },
{ [prepareEditProject],
accessorKey: 'state',
header: 'State',
cell: ({ row }) => {
const project = row.original;
if (!project.state) return <span></span>;
const states = project.state
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (states.length === 0) return <span></span>;
const firstState = states[0];
const remainingStates = states.slice(1);
return (
<div className="flex items-center gap-1.5">
<Badge variant="secondary">{firstState}</Badge>
{remainingStates.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<button className="flex items-center justify-center rounded-full bg-muted/50 hover:bg-muted px-1.5 py-0.5 text-[10px] font-bold text-muted-foreground transition-colors border border-border/40">
+{remainingStates.length}
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5">
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-0.5 px-1">
Other States
</p>
{remainingStates.map((item, idx) => (
<Badge key={idx} variant="secondary">
{item}
</Badge>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
); );
const handleDialogOpenChange = useCallback(
(open: boolean) => {
setIsDialogOpen(open);
if (!open) {
resetForm();
}
}, },
[resetForm],
);
const deleteProject = useCallback(
(project: Project) => {
setProjectToDelete(project);
}, },
{ [],
accessorKey: 'corridor_name', );
header: 'Corridor',
}, const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
]; if (!open) {
setProjectToDelete(null);
}
}, []);
const confirmDeleteProject = useCallback(() => {
if (!projectToDelete) return;
deleteProjectMutation.mutate(projectToDelete, {
onSettled: () => setProjectToDelete(null),
});
}, [deleteProjectMutation, projectToDelete]);
const columns = useProjectColumns();
const projects = projectsQuery.data?.items ?? [];
const total = projectsQuery.data?.totalItems ?? 0;
return ( return (
<> <>
<main className="relative z-10"> <main className="relative z-10 space-y-5">
{/* Refined Header */}
<div className="mb-8">
<PageHeader <PageHeader
title="Project Management" title="Project Management"
description="Manage road infrastructure projects" description="Manage road infrastructure projects"
icon={Layers} icon={Layers}
actions={ actions={
<Button <Button onClick={openCreate} size="sm">
onClick={() => { <Plus />
setIsEditing(false); Add Project
setIsModalOpen(true);
}}
>
<Layers className="mr-2 h-5 w-5" />
Add New Project
</Button> </Button>
} }
/> />
</div>
{/* Data Table */} <ProjectTable
<div>
<DataTable
title="Projects"
data={projects}
columns={columns} columns={columns}
onEdit={handleEdit} projects={projects}
onDelete={handleDelete} isLoading={projectsQuery.isLoading || deleteProjectMutation.isPending}
isLoading={isLoading} skip={skip}
pagination={{ limit={limit}
skip, total={total}
limit, onPageChange={setSkip}
totalItems, onLimitChange={setLimit}
onPageChange: setSkip, onEdit={openEdit}
onLimitChange: (newLimit) => { onDelete={deleteProject}
setLimit(newLimit);
setSkip(0); // Reset skip when limit changes
},
}}
/> />
</div>
<PoweredBy />
</main> </main>
{/* Modal Dialog */} <ProjectDialog
<Dialog open={isDialogOpen}
open={isModalOpen} onOpenChange={handleDialogOpenChange}
onOpenChange={(open) => { projectId={projectId}
if (!open) { register={register}
setIsModalOpen(false); errors={errors}
resetForm(); isSubmitted={isSubmitted}
} else { onSubmit={onSubmit}
setIsModalOpen(true); canSubmit={canSubmit}
} isSaving={isSaving}
}} />
>
<DialogContent className="max-w-2xl" onOpenAutoFocus={(e) => e.preventDefault()}>
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3 text-xl">
<div className="p-2 rounded-lg bg-primary text-primary-foreground shadow-sm">
<Layers className="h-5 w-5" />
</div>
{isEditing ? 'Edit Project Details' : 'Create New Project'}
</DialogTitle>
<DialogDescription className="text-sm">
{isEditing
? 'Update the technical specifications for your road infrastructure project.'
: 'Provide the essential road data to establish a new analysis project.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6"> <AlertDialog
{/* Project Name */} open={Boolean(projectToDelete)}
<div className="space-y-2"> onOpenChange={handleDeleteDialogOpenChange}
<Label
htmlFor="name"
className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider"
>
Project Name <span className="text-destructive">*</span>
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter a descriptive project name..."
className="h-11 bg-muted/20 border-border/60"
required
/>
</div>
{/* State & Corridor Row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-2">
<Label
htmlFor="state"
className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider"
>
State{' '}
<span className="text-[10px] lowercase font-normal opacity-70">(Optional)</span>
</Label>
<Input
id="state"
value={state}
onChange={(e) => setState(e.target.value)}
placeholder="e.g. Maharashtra"
className="h-11 bg-muted/20 border-border/60"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="corridor"
className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2"
>
<Route className="h-3.5 w-3.5 opacity-60" />
Corridor Name{' '}
<span className="text-[10px] lowercase font-normal opacity-70">(Optional)</span>
</Label>
<Input
id="corridor"
value={corridorName}
onChange={(e) => setCorridorName(e.target.value)}
placeholder="e.g. Mumbai-Goa Highway"
className="h-11 bg-muted/20 border-border/60"
/>
</div>
</div>
{/* GPS Coordinates Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-2">
{/* Start Point */}
<div className="space-y-4">
<p className="text-[11px] font-black text-muted-foreground tracking-widest flex items-center gap-2">
<MapPin className="h-3.5 w-3.5 opacity-60" /> START POINT
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label
htmlFor="start-lat"
className="text-[10px] font-bold text-muted-foreground uppercase opacity-70"
>
Lat
</Label>
<Input
id="start-lat"
type="number"
step="any"
value={startLat}
onChange={(e) => setStartLat(e.target.value)}
placeholder="0.0000"
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="start-lng"
className="text-[10px] font-bold text-muted-foreground uppercase opacity-70"
>
Lng
</Label>
<Input
id="start-lng"
type="number"
step="any"
value={startLng}
onChange={(e) => setStartLng(e.target.value)}
placeholder="0.0000"
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
/>
</div>
</div>
</div>
{/* End Point */}
<div className="space-y-4">
<p className="text-[11px] font-black text-muted-foreground tracking-widest flex items-center gap-2">
<MapPin className="h-3.5 w-3.5 opacity-60" /> END POINT
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label
htmlFor="end-lat"
className="text-[10px] font-bold text-muted-foreground uppercase opacity-70"
>
Lat
</Label>
<Input
id="end-lat"
type="number"
step="any"
value={endLat}
onChange={(e) => setEndLat(e.target.value)}
placeholder="0.0000"
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="end-lng"
className="text-[10px] font-bold text-muted-foreground uppercase opacity-70"
>
Lng
</Label>
<Input
id="end-lng"
type="number"
step="any"
value={endLng}
onChange={(e) => setEndLng(e.target.value)}
placeholder="0.0000"
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
/>
</div>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex gap-4 pt-2">
<Button
type="button"
variant="outline"
onClick={() => {
setIsModalOpen(false);
resetForm();
}}
disabled={isSubmitting}
className="flex-1 h-12 font-bold uppercase tracking-wider text-xs border-border/80 hover:bg-muted/50"
> >
<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 Cancel
</Button> </AlertDialogCancel>
<Button <AlertDialogAction
type="submit" variant="destructive"
disabled={isSubmitting || !name.trim()} disabled={deleteProjectMutation.isPending}
className="flex-1 h-12 font-bold uppercase tracking-wider text-xs shadow-lg shadow-primary/20" onClick={confirmDeleteProject}
> >
{isSubmitting ? ( Delete
<div className="flex items-center gap-2"> </AlertDialogAction>
<Loader2 className="h-4 w-4 animate-spin" /> </AlertDialogFooter>
<span>{isEditing ? 'Updating...' : 'Creating...'}</span> </AlertDialogContent>
</div> </AlertDialog>
) : (
<div className="flex items-center gap-2">
{isEditing ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Layers className="h-4 w-4" />
)}
<span>{isEditing ? 'Update Project' : 'Create Project'}</span>
</div>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</> </>
); );
} }

View File

@@ -0,0 +1,10 @@
import type { PaginationParams } from '@/types';
export const projectKeys = {
all: ['projects'] as const,
lists: () => [...projectKeys.all, 'list'] as const,
list: (params: PaginationParams) => [...projectKeys.lists(), params] as const,
options: () => [...projectKeys.all, 'options'] as const,
details: () => [...projectKeys.all, 'detail'] as const,
detail: (id: string) => [...projectKeys.details(), id] as const,
};

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()}>
{session && ( <ArrowLeft />
<div className="mb-6"> Back
<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> </Button>
</div> }
</Card>
</div>
)}
{detectionData && (
<VideoPlayerSection
data={detectionData}
videoId={videoId}
videoFile={videoFile}
detectionType={detectionType}
projectId={session?.projectId || undefined}
/> />
)}
<PoweredBy /> {detectionData && <VideoPlayerSection data={detectionData} />}
</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

@@ -0,0 +1,48 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import type { Role } from '@/types';
export function useRoleColumns(): ColumnDef<Role>[] {
return useMemo(() => {
return [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => <span>{row.original.name}</span>,
},
{
accessorKey: 'display_name',
header: 'Display Name',
},
{
accessorKey: 'description',
header: 'Description',
cell: ({ row }) => (
<span className="block max-w-sm truncate text-muted-foreground">
{row.original.description || '-'}
</span>
),
},
{
accessorKey: 'user_count',
header: 'Users',
cell: ({ row }) => row.original.user_count ?? 0,
},
{
accessorKey: 'effective_status',
header: 'Status',
cell: ({ row }) => (
<Badge
variant={row.original.effective_status ? 'default' : 'secondary'}
>
{row.original.effective_status ? 'Active' : 'Inactive'}
</Badge>
),
},
];
}, []);
}

View File

@@ -0,0 +1,49 @@
'use client';
import { TableSearchInput } from '@/components/data-table/TableSearchInput';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { StatusFilter } from '../hooks/useRoleFilters';
interface RoleFiltersProps {
searchTerm: string;
statusFilter: StatusFilter;
onSearchChange: (value: string) => void;
onStatusChange: (value: StatusFilter) => void;
}
export function RoleFilters({
searchTerm,
statusFilter,
onSearchChange,
onStatusChange,
}: RoleFiltersProps) {
return (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<TableSearchInput
value={searchTerm}
onChange={onSearchChange}
placeholder="Search roles"
/>
<Select
value={statusFilter}
onValueChange={(value) => onStatusChange(value as StatusFilter)}
>
<SelectTrigger className="md:w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,162 @@
'use client';
import type { ComponentProps } from 'react';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
import { PermissionTree } from '@/components/permission-tree';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import type { PermissionTreeItem } from '@/types';
import type { RoleFormValues } from '../hooks/useRoleForm';
interface RoleSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
roleId?: number;
register: UseFormRegister<RoleFormValues>;
errors: FieldErrors<RoleFormValues>;
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
permissionTree: PermissionTreeItem[];
permissionIds: number[];
onPermissionIdsChange: (ids: number[]) => void;
isPermissionsLoading: boolean;
canSubmit: boolean;
isSaving: boolean;
}
export function RoleSheet({
open,
onOpenChange,
roleId,
register,
errors,
isSubmitted,
onSubmit,
permissionTree,
permissionIds,
onPermissionIdsChange,
isPermissionsLoading,
canSubmit,
isSaving,
}: RoleSheetProps) {
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const displayNameErrorMessage = isSubmitted
? errors.display_name?.message
: undefined;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex h-full w-full! flex-col gap-0 p-0 sm:max-w-3xl!"
>
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
{roleId ? 'Edit Role' : 'Create Role'}
</SheetTitle>
<SheetDescription className="text-sm">
Assign the role details and permission access for this organization.
</SheetDescription>
</SheetHeader>
<form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="role-name"
label="Role Name"
required
error={nameErrorMessage}
>
<Input
id="role-name"
placeholder="Enter role name"
aria-invalid={!!nameErrorMessage}
{...register('name')}
/>
</FormField>
<FormField
id="role-display-name"
label="Display Name"
required
error={displayNameErrorMessage}
>
<Input
id="role-display-name"
placeholder="Enter display name"
aria-invalid={!!displayNameErrorMessage}
{...register('display_name')}
/>
</FormField>
</div>
<FormField id="role-description" label="Description">
<Textarea
id="role-description"
placeholder="Describe this role"
{...register('description')}
className="min-h-24"
/>
</FormField>
<FormField
label="Permissions"
required
error={isSubmitted ? errors.permission_ids?.message : undefined}
className="space-y-3"
labelEnd={
<span className="text-muted-foreground">
{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>
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{roleId ? 'Update Role' : 'Create Role'}
</Button>
<SheetClose asChild>
<Button type="button" variant="outline" disabled={isSaving}>
Cancel
</Button>
</SheetClose>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import type { ColumnDef, SortingState } from '@tanstack/react-table';
import type { ReactNode } from 'react';
import { Edit, ShieldCheck, ShieldX } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import { PERMISSIONS } from '@/constants/permissions';
import type { Role } from '@/types';
interface RoleTableProps {
columns: ColumnDef<Role>[];
roles: Role[];
isLoading: boolean;
toolbar: ReactNode;
skip: number;
limit: number;
total: number;
sorting: SortingState;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onSortingChange: (sorting: SortingState) => void;
onEdit: (role: Role) => void;
onToggleStatus: (role: Role) => void;
isStatusPending: boolean;
}
export function RoleTable({
columns,
roles,
isLoading,
toolbar,
skip,
limit,
total,
sorting,
onPageChange,
onLimitChange,
onSortingChange,
onEdit,
onToggleStatus,
isStatusPending,
}: RoleTableProps) {
return (
<DataTable
title="Roles"
columns={columns}
data={roles}
isLoading={isLoading}
toolbar={toolbar}
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: (role) =>
role.effective_status ? (
<ShieldX className="size-4 text-destructive" />
) : (
<ShieldCheck className="size-4 text-emerald-600" />
),
permission: PERMISSIONS.ROLE.DELETE,
disabled: () => isStatusPending,
onClick: onToggleStatus,
},
]}
pagination={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange,
}}
sorting={sorting}
onSortingChange={onSortingChange}
actionsSticky
/>
);
}

View File

@@ -0,0 +1,59 @@
'use client';
import { useEffect, useState } from 'react';
import type { SortingState } from '@tanstack/react-table';
export type StatusFilter = 'all' | 'active' | 'inactive';
const SEARCH_DEBOUNCE_MS = 400;
export function useRoleFilters() {
const [skip, setSkip] = useState(0);
const [limit, setLimitValue] = useState(10);
const [sorting, setSortingValue] = useState<SortingState>([]);
const [searchTerm, setSearchTermValue] = useState('');
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
const [statusFilter, setStatusFilterValue] = useState<StatusFilter>('all');
useEffect(() => {
const timer = window.setTimeout(() => {
setDebouncedSearchTerm(searchTerm.trim());
}, SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [searchTerm]);
const setSearchTerm = (value: string) => {
setSearchTermValue(value);
setSkip(0);
};
const setStatusFilter = (value: StatusFilter) => {
setStatusFilterValue(value);
setSkip(0);
};
const setLimit = (value: number) => {
setLimitValue(value);
setSkip(0);
};
const setSorting = (value: SortingState) => {
setSortingValue(value);
setSkip(0);
};
return {
skip,
setSkip,
limit,
setLimit,
sorting,
setSorting,
searchTerm,
debouncedSearchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
};
}

View File

@@ -0,0 +1,139 @@
'use client';
import {
collectDefaultPermissionIds,
collectPermissionIdsByKeys,
} from '@/components/permission-tree';
import { roleService } from '@/services/api';
import type { PermissionTreeItem, Role } from '@/types';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { roleKeys } from '../queries/roleKeys';
const roleFormSchema = z.object({
id: z.number().optional(),
name: z.string().trim().min(1, 'Role name is required'),
display_name: z.string().trim().min(1, 'Display name is required'),
description: z.string().optional(),
permission_ids: z.array(z.number()).min(1, 'Select at least one permission'),
});
export type RoleFormValues = z.infer<typeof roleFormSchema>;
const defaultValues: RoleFormValues = {
name: '',
display_name: '',
description: '',
permission_ids: [],
};
export function useRoleForm({
organizationId,
permissionTree,
onSaved,
}: {
organizationId: number | null;
permissionTree: PermissionTreeItem[];
onSaved: () => void;
}) {
const queryClient = useQueryClient();
const {
control,
register,
handleSubmit,
reset,
setValue,
formState: { errors, isSubmitting, isSubmitted },
} = useForm<RoleFormValues>({
defaultValues,
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(roleFormSchema),
});
const permissionIds = useWatch({ control, name: 'permission_ids' }) || [];
const roleId = useWatch({ control, name: 'id' });
const roleName = useWatch({ control, name: 'name' }) || '';
const displayName = useWatch({ control, name: 'display_name' }) || '';
const canSubmit =
roleName.trim().length > 0 &&
displayName.trim().length > 0 &&
permissionIds.length > 0;
const saveMutation = useMutation({
mutationFn: (values: RoleFormValues) =>
roleService.saveRole({
id: values.id,
name: values.name.trim(),
display_name: values.display_name.trim(),
description: values.description?.trim() ?? '',
organization_id: organizationId,
permission_ids: values.permission_ids,
is_default: false,
}),
onSuccess: (_data, values) => {
toast.success(values.id ? 'Role updated' : 'Role created');
reset(defaultValues);
onSaved();
queryClient.invalidateQueries({ queryKey: roleKeys.all });
},
onError: (_error, values) => {
toast.error(
values.id ? 'Failed to update role' : 'Failed to create role',
);
},
});
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
const openCreate = useCallback(() => {
reset({
...defaultValues,
permission_ids: collectDefaultPermissionIds(permissionTree),
});
}, [permissionTree, reset]);
const openEdit = useCallback(
(role: Role) => {
reset({
id: role.id,
name: role.name || '',
display_name: role.display_name || '',
description: role.description || '',
permission_ids: collectPermissionIdsByKeys(
permissionTree,
role.permissions || [],
),
});
},
[permissionTree, reset],
);
const setPermissionIds = useCallback(
(ids: number[]) =>
setValue('permission_ids', ids, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue],
);
return {
register,
onSubmit,
openCreate,
openEdit,
setPermissionIds,
permissionIds,
roleId,
errors,
isSubmitted,
canSubmit,
isSaving: isSubmitting || saveMutation.isPending,
};
}

View File

@@ -0,0 +1,84 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { SortingState } from '@tanstack/react-table';
import { useMemo } from 'react';
import { toast } from 'sonner';
import { getServerSortParams } from '@/components/data-table/sorting';
import { mapPermissionTree } from '@/components/permission-tree';
import { permissionService, roleService } from '@/services/api';
import type { Role, RoleListParams } from '@/types';
import { roleKeys } from '../queries/roleKeys';
import type { StatusFilter } from './useRoleFilters';
interface UseRolesQueryParams {
skip: number;
limit: number;
searchTerm: string;
statusFilter: StatusFilter;
sorting: SortingState;
}
function buildRoleListParams({
skip,
limit,
searchTerm,
statusFilter,
sorting,
}: UseRolesQueryParams): RoleListParams {
return {
skip,
limit,
search_term: searchTerm || undefined,
effective_status:
statusFilter === 'all' ? undefined : statusFilter === 'active',
...getServerSortParams(sorting),
};
}
export function useRolesQuery(params: UseRolesQueryParams) {
const { skip, limit, searchTerm, statusFilter, sorting } = params;
const listParams = useMemo(
() =>
buildRoleListParams({ skip, limit, searchTerm, statusFilter, sorting }),
[limit, searchTerm, skip, sorting, statusFilter],
);
return useQuery({
queryKey: roleKeys.list(listParams),
queryFn: () => roleService.getRoles(listParams),
});
}
export function usePermissionsTreeQuery(enabled: boolean) {
const permissionsQuery = useQuery({
queryKey: ['permissions', 'tree'],
queryFn: permissionService.getPermissions,
enabled,
});
const permissionTree = useMemo(
() => mapPermissionTree(permissionsQuery.data?.tree || []),
[permissionsQuery.data?.tree],
);
return {
...permissionsQuery,
permissionTree,
};
}
export function useRoleStatusMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (role: Role) =>
roleService.updateRoleStatus(role.id, !role.effective_status),
onSuccess: () => {
toast.success('Role status updated');
queryClient.invalidateQueries({ queryKey: roleKeys.all });
},
onError: () => toast.error('Failed to update role status'),
});
}

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

@@ -0,0 +1,175 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Plus, ShieldCheck } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import { useAppStore } from '@/store/app.store';
import type { Role } from '@/types';
import { useRoleColumns } from './components/RoleColumns';
import { RoleFilters } from './components/RoleFilters';
import { RoleSheet } from './components/RoleSheet';
import { RoleTable } from './components/RoleTable';
import { useRoleFilters } from './hooks/useRoleFilters';
import { useRoleForm } from './hooks/useRoleForm';
import {
usePermissionsTreeQuery,
useRolesQuery,
useRoleStatusMutation,
} from './hooks/useRoleQueries';
export default function RolesPage() {
const organizationId = useAppStore(
(state) => state.user?.organization_id ?? null,
);
const [isSheetOpen, setIsSheetOpen] = useState(false);
const [activeRole, setActiveRole] = useState<Role | null>(null);
const {
skip,
setSkip,
limit,
setLimit,
sorting,
setSorting,
searchTerm,
debouncedSearchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
} = useRoleFilters();
const rolesQuery = useRolesQuery({
skip,
limit,
searchTerm: debouncedSearchTerm,
statusFilter,
sorting,
});
const permissionsQuery = usePermissionsTreeQuery(isSheetOpen);
const roleForm = useRoleForm({
organizationId,
permissionTree: permissionsQuery.permissionTree,
onSaved: () => setIsSheetOpen(false),
});
const {
openCreate: prepareCreateRole,
openEdit: prepareEditRole,
register,
onSubmit,
roleId,
permissionIds,
setPermissionIds,
errors,
isSubmitted,
canSubmit,
isSaving,
} = roleForm;
const statusMutation = useRoleStatusMutation();
const { mutate: updateRoleStatus, isPending: isStatusPending } =
statusMutation;
const openCreate = useCallback(() => {
setActiveRole(null);
setIsSheetOpen(true);
}, []);
const openEdit = useCallback(
(role: Role) => {
setActiveRole(role);
setIsSheetOpen(true);
},
[],
);
useEffect(() => {
if (!isSheetOpen || permissionsQuery.permissionTree.length === 0) {
return;
}
activeRole ? prepareEditRole(activeRole) : prepareCreateRole();
}, [
activeRole,
isSheetOpen,
permissionsQuery.permissionTree,
prepareCreateRole,
prepareEditRole,
]);
const toggleStatus = useCallback(
(role: Role) => {
updateRoleStatus(role);
},
[updateRoleStatus],
);
const columns = useRoleColumns();
const total = rolesQuery.data?.total ?? 0;
const roles = rolesQuery.data?.items ?? [];
const toolbar = (
<RoleFilters
searchTerm={searchTerm}
statusFilter={statusFilter}
onSearchChange={setSearchTerm}
onStatusChange={setStatusFilter}
/>
);
return (
<>
<main className="relative z-10 space-y-5">
<PageHeader
title="Role Management"
description="Manage roles and permission access"
icon={ShieldCheck}
actions={
<PermissionGuard permissions={PERMISSIONS.ROLE.CREATE}>
<Button onClick={openCreate} size="sm">
<Plus />
Add Role
</Button>
</PermissionGuard>
}
/>
<RoleTable
columns={columns}
roles={roles}
isLoading={rolesQuery.isLoading}
toolbar={toolbar}
skip={skip}
limit={limit}
total={total}
sorting={sorting}
onPageChange={setSkip}
onLimitChange={setLimit}
onSortingChange={setSorting}
onEdit={openEdit}
onToggleStatus={toggleStatus}
isStatusPending={isStatusPending}
/>
</main>
<RoleSheet
open={isSheetOpen}
onOpenChange={setIsSheetOpen}
roleId={roleId}
register={register}
errors={errors}
isSubmitted={isSubmitted}
onSubmit={onSubmit}
permissionTree={permissionsQuery.permissionTree}
permissionIds={permissionIds}
onPermissionIdsChange={setPermissionIds}
isPermissionsLoading={permissionsQuery.isLoading}
canSubmit={canSubmit}
isSaving={isSaving}
/>
</>
);
}

View File

@@ -0,0 +1,9 @@
import type { RoleListParams } from '@/types';
export const roleKeys = {
all: ['roles'] as const,
lists: () => [...roleKeys.all, 'list'] as const,
list: (params: RoleListParams) => [...roleKeys.lists(), params] as const,
details: () => [...roleKeys.all, 'detail'] as const,
detail: (id: number) => [...roleKeys.details(), id] as const,
};

View File

@@ -0,0 +1,162 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import {
ArrowDownCircle,
ArrowUpCircle,
MapPin,
Milestone,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Chainage, Package, Project } from '@/types';
function EmptyValue() {
return <span className="text-muted-foreground">-</span>;
}
function StateBadges({ value }: { value: string | null }) {
if (!value) {
return <EmptyValue />;
}
const states = value
.split(',')
.map((state) => state.trim())
.filter(Boolean);
if (!states.length) {
return <EmptyValue />;
}
const [firstState, ...remainingStates] = states;
return (
<div className="flex items-center gap-1.5">
<Badge variant="secondary">{firstState}</Badge>
{remainingStates.length > 0 ? (
<Popover>
<PopoverTrigger asChild>
<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-muted-foreground transition-colors hover:bg-muted"
>
+{remainingStates.length}
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex flex-col gap-1.5">
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
{remainingStates.map((state) => (
<Badge key={state} variant="secondary">
{state}
</Badge>
))}
</div>
</PopoverContent>
</Popover>
) : null}
</div>
);
}
export function useSegmentColumns(projects: Project[], packages: Package[]) {
return useMemo<ColumnDef<Chainage>[]>(
() => [
{
accessorKey: 'segment_name',
header: 'Segment Name',
cell: ({ row }) => <div>{row.original.segment_name}</div>,
},
{
accessorKey: 'package_id',
header: 'Package',
cell: ({ row }) =>
packages.find((pkg) => pkg.id === row.original.package_id)?.name ||
row.original.package_id,
},
{
id: 'project',
header: 'Project',
cell: ({ row }) => {
const pkg = packages.find(
(item) => item.id === row.original.package_id,
);
const project = projects.find((item) => item.id === pkg?.project_id);
return project?.name || <EmptyValue />;
},
},
{
id: 'project_state',
header: 'Project State',
cell: ({ row }) => {
const pkg = packages.find(
(item) => item.id === row.original.package_id,
);
const project = projects.find((item) => item.id === pkg?.project_id);
return <StateBadges value={project?.state ?? null} />;
},
},
{
id: 'chainage',
header: 'Segment Range (km)',
cell: ({ row }) => {
const segment = row.original;
return (
<div className="flex flex-row gap-2">
<Badge
variant="outline"
className="flex items-center gap-1.5 border-amber-500/50 text-amber-500"
>
<Milestone className="size-3" />
{segment.chainage_start_km} - {segment.chainage_end_km}
</Badge>
<Badge variant="secondary" className="gap-1.5">
{segment.direction === 'UP' ? (
<ArrowUpCircle className="size-3" />
) : (
<ArrowDownCircle className="size-3" />
)}
{segment.direction}
</Badge>
</div>
);
},
},
{
id: 'start_gps',
header: 'Start GPS',
cell: ({ row }) => (
<Badge
variant="outline"
className="flex items-center gap-1.5 border-blue-500/50 text-blue-500"
>
<MapPin className="size-3" />
{row.original.start_lat.toFixed(4)},{' '}
{row.original.start_lng.toFixed(4)}
</Badge>
),
},
{
id: 'end_gps',
header: 'End GPS',
cell: ({ row }) => (
<Badge
variant="outline"
className="flex items-center gap-1.5 border-blue-500/50 text-blue-500"
>
<MapPin className="size-3" />
{row.original.end_lat.toFixed(4)}, {row.original.end_lng.toFixed(4)}
</Badge>
),
},
],
[packages, projects],
);
}

View File

@@ -0,0 +1,329 @@
'use client';
import type { ComponentProps } from 'react';
import { useRef } from 'react';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
import { PackageSelect } from '@/components/lookups/PackageSelect';
import { ProjectSelect } from '@/components/lookups/ProjectSelect';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { SegmentFormValues } from '../hooks/useSegmentForm';
interface SegmentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
segmentId?: string;
projectId: string;
onProjectChange: (projectId: string) => void;
packageId: string;
onPackageChange: (packageId: string) => void;
direction: 'UP' | 'DOWN';
onDirectionChange: (direction: 'UP' | 'DOWN') => void;
register: UseFormRegister<SegmentFormValues>;
errors: FieldErrors<SegmentFormValues>;
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean;
isSaving: boolean;
}
export function SegmentDialog({
open,
onOpenChange,
segmentId,
projectId,
onProjectChange,
packageId,
onPackageChange,
direction,
onDirectionChange,
register,
errors,
isSubmitted,
onSubmit,
canSubmit,
isSaving,
}: SegmentDialogProps) {
const projectSelectPortalRef = useRef<HTMLDivElement | null>(null);
const packageSelectPortalRef = useRef<HTMLDivElement | null>(null);
const getError = (field: keyof SegmentFormValues) =>
isSubmitted ? errors[field]?.message : undefined;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle>
{segmentId ? 'Edit Segment' : 'Create Segment'}
</DialogTitle>
<DialogDescription>
Manage segment binding, chainage values, direction, and coordinates.
</DialogDescription>
</DialogHeader>
<form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<DialogBody className="space-y-6">
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Basic details</h3>
<p className="text-sm text-muted-foreground">
Project, package, segment name, and direction.
</p>
</div>
{!segmentId ? (
<div className="grid gap-4 md:grid-cols-2">
<FormField
label="Project"
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')}
>
<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"
label="Start Latitude"
required
error={getError('start_lat')}
>
<Input
id="start-lat"
type="number"
step="any"
min="-90"
max="90"
placeholder="-90 to 90"
aria-invalid={!!getError('start_lat')}
{...register('start_lat')}
/>
</FormField>
<FormField
id="start-lng"
label="Start Longitude"
required
error={getError('start_lng')}
>
<Input
id="start-lng"
type="number"
step="any"
min="-180"
max="180"
placeholder="-180 to 180"
aria-invalid={!!getError('start_lng')}
{...register('start_lng')}
/>
</FormField>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
id="end-lat"
label="End Latitude"
required
error={getError('end_lat')}
>
<Input
id="end-lat"
type="number"
step="any"
min="-90"
max="90"
placeholder="-90 to 90"
aria-invalid={!!getError('end_lat')}
{...register('end_lat')}
/>
</FormField>
<FormField
id="end-lng"
label="End Longitude"
required
error={getError('end_lng')}
>
<Input
id="end-lng"
type="number"
step="any"
min="-180"
max="180"
placeholder="-180 to 180"
aria-invalid={!!getError('end_lng')}
{...register('end_lng')}
/>
</FormField>
</div>
</div>
</section>
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{segmentId ? 'Update Segment' : 'Create Segment'}
</Button>
</DialogFooter>
</form>
<div ref={projectSelectPortalRef} />
<div ref={packageSelectPortalRef} />
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import type { ColumnDef } from '@tanstack/react-table';
import { Edit3, Trash2 } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import type { Chainage } from '@/types';
interface SegmentTableProps {
columns: ColumnDef<Chainage>[];
segments: Chainage[];
isLoading: boolean;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onEdit: (segment: Chainage) => void;
onDelete: (segment: Chainage) => void;
}
export function SegmentTable({
columns,
segments,
isLoading,
skip,
limit,
total,
onPageChange,
onLimitChange,
onEdit,
onDelete,
}: SegmentTableProps) {
return (
<DataTable
title="Segments"
data={segments}
columns={columns}
actions={[
{
label: 'Edit',
icon: <Edit3 className="size-4" />,
onClick: onEdit,
},
{
label: 'Delete',
icon: <Trash2 className="size-4" />,
className: 'text-destructive',
onClick: onDelete,
},
]}
isLoading={isLoading}
emptyTitle="No segments found."
pagination={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange,
}}
actionsSticky
/>
);
}

View File

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

View File

@@ -0,0 +1,196 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import {
requiredIntegerNumber,
requiredLatitude,
requiredLongitude,
validateChainageRange,
} from '@/lib/validation/numberField';
import { chainageService } from '@/services/api';
import type { Chainage, ChainageCreate, ChainageUpdate } from '@/types';
import { segmentKeys } from '../queries/segmentKeys';
const segmentFormSchema = z
.object({
id: z.string().optional(),
project_id: z.string().trim().min(1, 'Project is required'),
package_id: z.string().trim().min(1, 'Package is required'),
segment_name: z.string().trim().min(1, 'Segment name is required'),
chainage_start_km: requiredIntegerNumber('Start km is required'),
chainage_end_km: requiredIntegerNumber('End km is required'),
start_lat: requiredLatitude,
start_lng: requiredLongitude,
end_lat: requiredLatitude,
end_lng: requiredLongitude,
direction: z.enum(['UP', 'DOWN']),
})
.superRefine(validateChainageRange);
export type SegmentFormValues = z.infer<typeof segmentFormSchema>;
const defaultValues: SegmentFormValues = {
project_id: '',
package_id: '',
segment_name: '',
chainage_start_km: '',
chainage_end_km: '',
start_lat: '',
start_lng: '',
end_lat: '',
end_lng: '',
direction: 'UP',
};
function toSegmentPayload(
values: SegmentFormValues,
): ChainageCreate | ChainageUpdate {
return {
package_id: values.package_id,
segment_name: values.segment_name.trim(),
chainage_start_km: Number(values.chainage_start_km),
chainage_end_km: Number(values.chainage_end_km),
start_lat: Number(values.start_lat),
start_lng: Number(values.start_lng),
end_lat: Number(values.end_lat),
end_lng: Number(values.end_lng),
direction: values.direction,
};
}
export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
const queryClient = useQueryClient();
const {
register,
handleSubmit,
reset,
setValue,
control,
formState: { errors, isSubmitting, isSubmitted },
} = useForm<SegmentFormValues>({
defaultValues,
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(segmentFormSchema),
});
const segmentId = useWatch({ control, name: 'id' });
const projectId = useWatch({ control, name: 'project_id' }) || '';
const packageId = useWatch({ control, name: 'package_id' }) || '';
const direction = useWatch({ control, name: 'direction' }) || 'UP';
const segmentName = useWatch({ control, name: 'segment_name' }) || '';
const chainageStartKm =
useWatch({ control, name: 'chainage_start_km' }) || '';
const chainageEndKm = useWatch({ control, name: 'chainage_end_km' }) || '';
const startLat = useWatch({ control, name: 'start_lat' }) || '';
const startLng = useWatch({ control, name: 'start_lng' }) || '';
const endLat = useWatch({ control, name: 'end_lat' }) || '';
const endLng = useWatch({ control, name: 'end_lng' }) || '';
const canSubmit =
projectId.trim().length > 0 &&
packageId.trim().length > 0 &&
segmentName.trim().length > 0 &&
chainageStartKm.trim().length > 0 &&
chainageEndKm.trim().length > 0 &&
startLat.trim().length > 0 &&
startLng.trim().length > 0 &&
endLat.trim().length > 0 &&
endLng.trim().length > 0;
const saveMutation = useMutation({
mutationFn: (values: SegmentFormValues) => {
const payload = toSegmentPayload(values);
return values.id
? chainageService.updateChainage(values.id, payload)
: chainageService.createChainage(payload as ChainageCreate);
},
onSuccess: (_data, values) => {
toast.success(values.id ? 'Segment updated' : 'Segment created');
reset(defaultValues);
onSaved();
queryClient.invalidateQueries({ queryKey: segmentKeys.all });
},
onError: (_error, values) => {
toast.error(
values.id ? 'Failed to update segment' : 'Failed to create segment',
);
},
});
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
const openCreate = useCallback(() => {
reset(defaultValues);
}, [reset]);
const openEdit = useCallback(
(segment: Chainage, projectIdForSegment: string) => {
reset({
id: segment.id,
project_id: projectIdForSegment,
package_id: segment.package_id,
segment_name: segment.segment_name || '',
chainage_start_km: segment.chainage_start_km?.toString() || '',
chainage_end_km: segment.chainage_end_km?.toString() || '',
start_lat: segment.start_lat?.toString() || '',
start_lng: segment.start_lng?.toString() || '',
end_lat: segment.end_lat?.toString() || '',
end_lng: segment.end_lng?.toString() || '',
direction: segment.direction || 'UP',
});
},
[reset],
);
const setProjectId = useCallback(
(value: string) => {
setValue('project_id', value, {
shouldDirty: true,
shouldValidate: true,
});
setValue('package_id', '', { shouldDirty: true, shouldValidate: true });
},
[setValue],
);
const setPackageId = useCallback(
(value: string) =>
setValue('package_id', value, {
shouldDirty: true,
shouldValidate: true,
}),
[setValue],
);
const setDirection = useCallback(
(value: 'UP' | 'DOWN') =>
setValue('direction', value, { shouldDirty: true, shouldValidate: true }),
[setValue],
);
return {
register,
onSubmit,
openCreate,
openEdit,
resetForm: () => reset(defaultValues),
segmentId,
projectId,
setProjectId,
packageId,
setPackageId,
direction,
setDirection,
errors,
isSubmitted,
canSubmit,
isSaving: isSubmitting || saveMutation.isPending,
};
}

View File

@@ -0,0 +1,67 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { toast } from 'sonner';
import {
chainageService,
packageService,
projectService,
} from '@/services/api';
import type { Chainage, PaginationParams } from '@/types';
import { packageKeys } from '../../package/queries/packageKeys';
import { projectKeys } from '../../project/queries/projectKeys';
import { segmentKeys } from '../queries/segmentKeys';
interface UseSegmentsQueryParams {
skip: number;
limit: number;
}
export function useSegmentsQuery({ skip, limit }: UseSegmentsQueryParams) {
const listParams = useMemo<PaginationParams>(
() => ({ skip, limit }),
[limit, skip],
);
return useQuery({
queryKey: segmentKeys.list(listParams),
queryFn: () => chainageService.getChainages(listParams),
});
}
export function useProjectOptionsQuery() {
return useQuery({
queryKey: projectKeys.options(),
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
});
}
export function useAllPackageOptionsQuery() {
return useQuery({
queryKey: packageKeys.options(),
queryFn: () => packageService.getPackages({ skip: 0, limit: 1000 }),
});
}
export function useDeleteSegmentMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (segment: Chainage) =>
chainageService.deleteChainage(segment.id),
onSuccess: (_data, segment) => {
toast.success('Segment deleted', {
description: `${segment.segment_name} has been removed from the system.`,
});
queryClient.invalidateQueries({ queryKey: segmentKeys.all });
},
onError: () => {
toast.error('Deletion failed', {
description: 'The segment could not be removed. Please try again.',
});
},
});
}

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

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