Compare commits
89 Commits
6f4399a048
...
migration
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d7f956c3b | |||
| 0026824f84 | |||
| a18eb64dac | |||
| 6c71d8f8a7 | |||
| 0e2172366b | |||
| 56180f8d18 | |||
| 5269b29aac | |||
| 9fe7109faf | |||
| 9720546455 | |||
| 8727963c54 | |||
| 0e8108b875 | |||
| 87dde1c8d1 | |||
| a376558b2f | |||
| 4fe93460d9 | |||
| cbfb6e040b | |||
| 7a82e17bbb | |||
| 23186ffd29 | |||
| fb9a654aea | |||
| d88978a830 | |||
| ba9731de86 | |||
| c0f3a718ef | |||
| 14220445ad | |||
| 79272c7abc | |||
| 26e15360f7 | |||
| b6a0f04f1e | |||
| 9cf4f0e795 | |||
| fdcfc3a8db | |||
| 278b9591d7 | |||
| 02adbb4c10 | |||
| 53fe4e11fe | |||
| 2eb58856af | |||
| c73ba0a01f | |||
| 1a94ff80e6 | |||
| b1174c8825 | |||
| d4a7a7c139 | |||
| 57b33bd061 | |||
| 81df3a9973 | |||
| 09ce57c9e0 | |||
| ea684fc9fe | |||
| ec77696157 | |||
| adff2e2c41 | |||
| 3e5dbd5893 | |||
| 11f95c4727 | |||
| e805cdee09 | |||
| 57461a05f2 | |||
| 8a080cb051 | |||
| 063f963fd4 | |||
| 1f8ca87bd0 | |||
| 8665762ba2 | |||
| 81a53b692d | |||
| 82050372c6 | |||
| 2fc01f74e2 | |||
| 67237982c8 | |||
| c1c81687d8 | |||
| 81b68fdb61 | |||
| 5439a5ff45 | |||
| 6e16d4f2c8 | |||
| dfb95c5213 | |||
| 5e4cf61d6e | |||
| b282fdf56d | |||
| 066374bebc | |||
| 27cf5c9d3f | |||
| a731fca5e0 | |||
| 664d80809a | |||
| fffd8e3e12 | |||
| a567a52e26 | |||
| 8c3d19a429 | |||
| a18fff4d6f | |||
| dff9e2222f | |||
| d8fa02dc5d | |||
| 62ebf1ebf6 | |||
| 9e76b8e6d0 | |||
| 6cf363dce1 | |||
| 4add7a6c83 | |||
| af4d7fae97 | |||
| defb956751 | |||
| 9695c80e20 | |||
| c6894bfea0 | |||
| d7c4027328 | |||
| 463ee0835f | |||
| b325975282 | |||
| 4c2241ff72 | |||
| b54242cf7e | |||
| 15f54372f4 | |||
| 182d4ac293 | |||
| 9bb740e446 | |||
| bd0782a55f | |||
| db11a512bf | |||
| 1325afdab2 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"dual-graph": {
|
"dual-graph": {
|
||||||
"url": "http://127.0.0.1:8080/mcp"
|
"url": "http://127.0.0.1:8080/mcp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Backend API base URL (no trailing slash)
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
.next
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
build
|
|
||||||
coverage
|
|
||||||
public
|
|
||||||
.env*
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,6 +44,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
# Test / coverage
|
# Test / coverage
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
@@ -1,4 +1,2 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
# . "$(dirname "$0")/_/husky.sh"
|
# npm run lint
|
||||||
|
|
||||||
# npx lint-staged
|
|
||||||
|
|||||||
11
.prettierrc
11
.prettierrc
@@ -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
94
CLAUDE.md
Normal 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.
|
||||||
BIN
build_log.txt
BIN
build_log.txt
Binary file not shown.
@@ -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": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
6
next-env.d.ts
vendored
@@ -1,6 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
import "./.next/types/routes.d.ts";
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
||||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
reactStrictMode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
5222
package-lock.json
generated
5222
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -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",
|
||||||
@@ -32,22 +26,31 @@
|
|||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@tanstack/react-query-devtools": "^5.101.0",
|
"@tanstack/react-query-devtools": "^5.101.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.14.3",
|
||||||
|
"@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-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"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -59,11 +62,10 @@
|
|||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"baseline-browser-mapping": "^2.10.37",
|
"baseline-browser-mapping": "^2.10.37",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.1.7",
|
"eslint-config-next": "^16.0.10",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.5",
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.4.0",
|
|
||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"tailwindcss": "^4.1.9",
|
"tailwindcss": "^4.1.9",
|
||||||
|
|||||||
740
public/flags/flags.css
Normal file
740
public/flags/flags.css
Normal file
@@ -0,0 +1,740 @@
|
|||||||
|
span.flag {
|
||||||
|
width: 44px;
|
||||||
|
height: 30px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
img.flag {
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
.flag {
|
||||||
|
background: url(./flags_responsive.png) no-repeat;
|
||||||
|
background-size: 100%;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.flag-ad {
|
||||||
|
background-position: 0 0.413223%;
|
||||||
|
}
|
||||||
|
.flag-ae {
|
||||||
|
background-position: 0 0.826446%;
|
||||||
|
}
|
||||||
|
.flag-af {
|
||||||
|
background-position: 0 1.239669%;
|
||||||
|
}
|
||||||
|
.flag-ag {
|
||||||
|
background-position: 0 1.652893%;
|
||||||
|
}
|
||||||
|
.flag-ai {
|
||||||
|
background-position: 0 2.066116%;
|
||||||
|
}
|
||||||
|
.flag-al {
|
||||||
|
background-position: 0 2.479339%;
|
||||||
|
}
|
||||||
|
.flag-am {
|
||||||
|
background-position: 0 2.892562%;
|
||||||
|
}
|
||||||
|
.flag-an {
|
||||||
|
background-position: 0 3.305785%;
|
||||||
|
}
|
||||||
|
.flag-ao {
|
||||||
|
background-position: 0 3.719008%;
|
||||||
|
}
|
||||||
|
.flag-aq {
|
||||||
|
background-position: 0 4.132231%;
|
||||||
|
}
|
||||||
|
.flag-ar {
|
||||||
|
background-position: 0 4.545455%;
|
||||||
|
}
|
||||||
|
.flag-as {
|
||||||
|
background-position: 0 4.958678%;
|
||||||
|
}
|
||||||
|
.flag-at {
|
||||||
|
background-position: 0 5.371901%;
|
||||||
|
}
|
||||||
|
.flag-au {
|
||||||
|
background-position: 0 5.785124%;
|
||||||
|
}
|
||||||
|
.flag-aw {
|
||||||
|
background-position: 0 6.198347%;
|
||||||
|
}
|
||||||
|
.flag-az {
|
||||||
|
background-position: 0 6.61157%;
|
||||||
|
}
|
||||||
|
.flag-ba {
|
||||||
|
background-position: 0 7.024793%;
|
||||||
|
}
|
||||||
|
.flag-bb {
|
||||||
|
background-position: 0 7.438017%;
|
||||||
|
}
|
||||||
|
.flag-bd {
|
||||||
|
background-position: 0 7.85124%;
|
||||||
|
}
|
||||||
|
.flag-be {
|
||||||
|
background-position: 0 8.264463%;
|
||||||
|
}
|
||||||
|
.flag-bf {
|
||||||
|
background-position: 0 8.677686%;
|
||||||
|
}
|
||||||
|
.flag-bg {
|
||||||
|
background-position: 0 9.090909%;
|
||||||
|
}
|
||||||
|
.flag-bh {
|
||||||
|
background-position: 0 9.504132%;
|
||||||
|
}
|
||||||
|
.flag-bi {
|
||||||
|
background-position: 0 9.917355%;
|
||||||
|
}
|
||||||
|
.flag-bj {
|
||||||
|
background-position: 0 10.330579%;
|
||||||
|
}
|
||||||
|
.flag-bm {
|
||||||
|
background-position: 0 10.743802%;
|
||||||
|
}
|
||||||
|
.flag-bn {
|
||||||
|
background-position: 0 11.157025%;
|
||||||
|
}
|
||||||
|
.flag-bo {
|
||||||
|
background-position: 0 11.570248%;
|
||||||
|
}
|
||||||
|
.flag-br {
|
||||||
|
background-position: 0 11.983471%;
|
||||||
|
}
|
||||||
|
.flag-bs {
|
||||||
|
background-position: 0 12.396694%;
|
||||||
|
}
|
||||||
|
.flag-bt {
|
||||||
|
background-position: 0 12.809917%;
|
||||||
|
}
|
||||||
|
.flag-bv {
|
||||||
|
background-position: 0 13.22314%;
|
||||||
|
}
|
||||||
|
.flag-bw {
|
||||||
|
background-position: 0 13.636364%;
|
||||||
|
}
|
||||||
|
.flag-by {
|
||||||
|
background-position: 0 14.049587%;
|
||||||
|
}
|
||||||
|
.flag-bz {
|
||||||
|
background-position: 0 14.46281%;
|
||||||
|
}
|
||||||
|
.flag-ca {
|
||||||
|
background-position: 0 14.876033%;
|
||||||
|
}
|
||||||
|
.flag-cc {
|
||||||
|
background-position: 0 15.289256%;
|
||||||
|
}
|
||||||
|
.flag-cd {
|
||||||
|
background-position: 0 15.702479%;
|
||||||
|
}
|
||||||
|
.flag-cf {
|
||||||
|
background-position: 0 16.115702%;
|
||||||
|
}
|
||||||
|
.flag-cg {
|
||||||
|
background-position: 0 16.528926%;
|
||||||
|
}
|
||||||
|
.flag-ch {
|
||||||
|
background-position: 0 16.942149%;
|
||||||
|
}
|
||||||
|
.flag-ci {
|
||||||
|
background-position: 0 17.355372%;
|
||||||
|
}
|
||||||
|
.flag-ck {
|
||||||
|
background-position: 0 17.768595%;
|
||||||
|
}
|
||||||
|
.flag-cl {
|
||||||
|
background-position: 0 18.181818%;
|
||||||
|
}
|
||||||
|
.flag-cm {
|
||||||
|
background-position: 0 18.595041%;
|
||||||
|
}
|
||||||
|
.flag-cn {
|
||||||
|
background-position: 0 19.008264%;
|
||||||
|
}
|
||||||
|
.flag-co {
|
||||||
|
background-position: 0 19.421488%;
|
||||||
|
}
|
||||||
|
.flag-cr {
|
||||||
|
background-position: 0 19.834711%;
|
||||||
|
}
|
||||||
|
.flag-cu {
|
||||||
|
background-position: 0 20.247934%;
|
||||||
|
}
|
||||||
|
.flag-cv {
|
||||||
|
background-position: 0 20.661157%;
|
||||||
|
}
|
||||||
|
.flag-cx {
|
||||||
|
background-position: 0 21.07438%;
|
||||||
|
}
|
||||||
|
.flag-cy {
|
||||||
|
background-position: 0 21.487603%;
|
||||||
|
}
|
||||||
|
.flag-cz {
|
||||||
|
background-position: 0 21.900826%;
|
||||||
|
}
|
||||||
|
.flag-de {
|
||||||
|
background-position: 0 22.31405%;
|
||||||
|
}
|
||||||
|
.flag-dj {
|
||||||
|
background-position: 0 22.727273%;
|
||||||
|
}
|
||||||
|
.flag-dk {
|
||||||
|
background-position: 0 23.140496%;
|
||||||
|
}
|
||||||
|
.flag-dm {
|
||||||
|
background-position: 0 23.553719%;
|
||||||
|
}
|
||||||
|
.flag-do {
|
||||||
|
background-position: 0 23.966942%;
|
||||||
|
}
|
||||||
|
.flag-dz {
|
||||||
|
background-position: 0 24.380165%;
|
||||||
|
}
|
||||||
|
.flag-ec {
|
||||||
|
background-position: 0 24.793388%;
|
||||||
|
}
|
||||||
|
.flag-ee {
|
||||||
|
background-position: 0 25.206612%;
|
||||||
|
}
|
||||||
|
.flag-eg {
|
||||||
|
background-position: 0 25.619835%;
|
||||||
|
}
|
||||||
|
.flag-eh {
|
||||||
|
background-position: 0 26.033058%;
|
||||||
|
}
|
||||||
|
.flag-er {
|
||||||
|
background-position: 0 26.446281%;
|
||||||
|
}
|
||||||
|
.flag-es {
|
||||||
|
background-position: 0 26.859504%;
|
||||||
|
}
|
||||||
|
.flag-et {
|
||||||
|
background-position: 0 27.272727%;
|
||||||
|
}
|
||||||
|
.flag-fi {
|
||||||
|
background-position: 0 27.68595%;
|
||||||
|
}
|
||||||
|
.flag-fj {
|
||||||
|
background-position: 0 28.099174%;
|
||||||
|
}
|
||||||
|
.flag-fk {
|
||||||
|
background-position: 0 28.512397%;
|
||||||
|
}
|
||||||
|
.flag-fm {
|
||||||
|
background-position: 0 28.92562%;
|
||||||
|
}
|
||||||
|
.flag-fo {
|
||||||
|
background-position: 0 29.338843%;
|
||||||
|
}
|
||||||
|
.flag-fr {
|
||||||
|
background-position: 0 29.752066%;
|
||||||
|
}
|
||||||
|
.flag-ga {
|
||||||
|
background-position: 0 30.165289%;
|
||||||
|
}
|
||||||
|
.flag-gd {
|
||||||
|
background-position: 0 30.578512%;
|
||||||
|
}
|
||||||
|
.flag-ge {
|
||||||
|
background-position: 0 30.991736%;
|
||||||
|
}
|
||||||
|
.flag-gf {
|
||||||
|
background-position: 0 31.404959%;
|
||||||
|
}
|
||||||
|
.flag-gh {
|
||||||
|
background-position: 0 31.818182%;
|
||||||
|
}
|
||||||
|
.flag-gi {
|
||||||
|
background-position: 0 32.231405%;
|
||||||
|
}
|
||||||
|
.flag-gl {
|
||||||
|
background-position: 0 32.644628%;
|
||||||
|
}
|
||||||
|
.flag-gm {
|
||||||
|
background-position: 0 33.057851%;
|
||||||
|
}
|
||||||
|
.flag-gn {
|
||||||
|
background-position: 0 33.471074%;
|
||||||
|
}
|
||||||
|
.flag-gp {
|
||||||
|
background-position: 0 33.884298%;
|
||||||
|
}
|
||||||
|
.flag-gq {
|
||||||
|
background-position: 0 34.297521%;
|
||||||
|
}
|
||||||
|
.flag-gr {
|
||||||
|
background-position: 0 34.710744%;
|
||||||
|
}
|
||||||
|
.flag-gs {
|
||||||
|
background-position: 0 35.123967%;
|
||||||
|
}
|
||||||
|
.flag-gt {
|
||||||
|
background-position: 0 35.53719%;
|
||||||
|
}
|
||||||
|
.flag-gu {
|
||||||
|
background-position: 0 35.950413%;
|
||||||
|
}
|
||||||
|
.flag-gw {
|
||||||
|
background-position: 0 36.363636%;
|
||||||
|
}
|
||||||
|
.flag-gy {
|
||||||
|
background-position: 0 36.77686%;
|
||||||
|
}
|
||||||
|
.flag-hk {
|
||||||
|
background-position: 0 37.190083%;
|
||||||
|
}
|
||||||
|
.flag-hm {
|
||||||
|
background-position: 0 37.603306%;
|
||||||
|
}
|
||||||
|
.flag-hn {
|
||||||
|
background-position: 0 38.016529%;
|
||||||
|
}
|
||||||
|
.flag-hr {
|
||||||
|
background-position: 0 38.429752%;
|
||||||
|
}
|
||||||
|
.flag-ht {
|
||||||
|
background-position: 0 38.842975%;
|
||||||
|
}
|
||||||
|
.flag-hu {
|
||||||
|
background-position: 0 39.256198%;
|
||||||
|
}
|
||||||
|
.flag-id {
|
||||||
|
background-position: 0 39.669421%;
|
||||||
|
}
|
||||||
|
.flag-ie {
|
||||||
|
background-position: 0 40.082645%;
|
||||||
|
}
|
||||||
|
.flag-il {
|
||||||
|
background-position: 0 40.495868%;
|
||||||
|
}
|
||||||
|
.flag-in {
|
||||||
|
background-position: 0 40.909091%;
|
||||||
|
}
|
||||||
|
.flag-io {
|
||||||
|
background-position: 0 41.322314%;
|
||||||
|
}
|
||||||
|
.flag-iq {
|
||||||
|
background-position: 0 41.735537%;
|
||||||
|
}
|
||||||
|
.flag-ir {
|
||||||
|
background-position: 0 42.14876%;
|
||||||
|
}
|
||||||
|
.flag-is {
|
||||||
|
background-position: 0 42.561983%;
|
||||||
|
}
|
||||||
|
.flag-it {
|
||||||
|
background-position: 0 42.975207%;
|
||||||
|
}
|
||||||
|
.flag-jm {
|
||||||
|
background-position: 0 43.38843%;
|
||||||
|
}
|
||||||
|
.flag-jo {
|
||||||
|
background-position: 0 43.801653%;
|
||||||
|
}
|
||||||
|
.flag-jp {
|
||||||
|
background-position: 0 44.214876%;
|
||||||
|
}
|
||||||
|
.flag-ke {
|
||||||
|
background-position: 0 44.628099%;
|
||||||
|
}
|
||||||
|
.flag-kg {
|
||||||
|
background-position: 0 45.041322%;
|
||||||
|
}
|
||||||
|
.flag-kh {
|
||||||
|
background-position: 0 45.454545%;
|
||||||
|
}
|
||||||
|
.flag-ki {
|
||||||
|
background-position: 0 45.867769%;
|
||||||
|
}
|
||||||
|
.flag-km {
|
||||||
|
background-position: 0 46.280992%;
|
||||||
|
}
|
||||||
|
.flag-kn {
|
||||||
|
background-position: 0 46.694215%;
|
||||||
|
}
|
||||||
|
.flag-kp {
|
||||||
|
background-position: 0 47.107438%;
|
||||||
|
}
|
||||||
|
.flag-kr {
|
||||||
|
background-position: 0 47.520661%;
|
||||||
|
}
|
||||||
|
.flag-kw {
|
||||||
|
background-position: 0 47.933884%;
|
||||||
|
}
|
||||||
|
.flag-ky {
|
||||||
|
background-position: 0 48.347107%;
|
||||||
|
}
|
||||||
|
.flag-kz {
|
||||||
|
background-position: 0 48.760331%;
|
||||||
|
}
|
||||||
|
.flag-la {
|
||||||
|
background-position: 0 49.173554%;
|
||||||
|
}
|
||||||
|
.flag-lb {
|
||||||
|
background-position: 0 49.586777%;
|
||||||
|
}
|
||||||
|
.flag-lc {
|
||||||
|
background-position: 0 50%;
|
||||||
|
}
|
||||||
|
.flag-li {
|
||||||
|
background-position: 0 50.413223%;
|
||||||
|
}
|
||||||
|
.flag-lk {
|
||||||
|
background-position: 0 50.826446%;
|
||||||
|
}
|
||||||
|
.flag-lr {
|
||||||
|
background-position: 0 51.239669%;
|
||||||
|
}
|
||||||
|
.flag-ls {
|
||||||
|
background-position: 0 51.652893%;
|
||||||
|
}
|
||||||
|
.flag-lt {
|
||||||
|
background-position: 0 52.066116%;
|
||||||
|
}
|
||||||
|
.flag-lu {
|
||||||
|
background-position: 0 52.479339%;
|
||||||
|
}
|
||||||
|
.flag-lv {
|
||||||
|
background-position: 0 52.892562%;
|
||||||
|
}
|
||||||
|
.flag-ly {
|
||||||
|
background-position: 0 53.305785%;
|
||||||
|
}
|
||||||
|
.flag-ma {
|
||||||
|
background-position: 0 53.719008%;
|
||||||
|
}
|
||||||
|
.flag-mc {
|
||||||
|
background-position: 0 54.132231%;
|
||||||
|
}
|
||||||
|
.flag-md {
|
||||||
|
background-position: 0 54.545455%;
|
||||||
|
}
|
||||||
|
.flag-me {
|
||||||
|
background-position: 0 54.958678%;
|
||||||
|
}
|
||||||
|
.flag-mg {
|
||||||
|
background-position: 0 55.371901%;
|
||||||
|
}
|
||||||
|
.flag-mh {
|
||||||
|
background-position: 0 55.785124%;
|
||||||
|
}
|
||||||
|
.flag-mk {
|
||||||
|
background-position: 0 56.198347%;
|
||||||
|
}
|
||||||
|
.flag-ml {
|
||||||
|
background-position: 0 56.61157%;
|
||||||
|
}
|
||||||
|
.flag-mm {
|
||||||
|
background-position: 0 57.024793%;
|
||||||
|
}
|
||||||
|
.flag-mn {
|
||||||
|
background-position: 0 57.438017%;
|
||||||
|
}
|
||||||
|
.flag-mo {
|
||||||
|
background-position: 0 57.85124%;
|
||||||
|
}
|
||||||
|
.flag-mp {
|
||||||
|
background-position: 0 58.264463%;
|
||||||
|
}
|
||||||
|
.flag-mq {
|
||||||
|
background-position: 0 58.677686%;
|
||||||
|
}
|
||||||
|
.flag-mr {
|
||||||
|
background-position: 0 59.090909%;
|
||||||
|
}
|
||||||
|
.flag-ms {
|
||||||
|
background-position: 0 59.504132%;
|
||||||
|
}
|
||||||
|
.flag-mt {
|
||||||
|
background-position: 0 59.917355%;
|
||||||
|
}
|
||||||
|
.flag-mu {
|
||||||
|
background-position: 0 60.330579%;
|
||||||
|
}
|
||||||
|
.flag-mv {
|
||||||
|
background-position: 0 60.743802%;
|
||||||
|
}
|
||||||
|
.flag-mw {
|
||||||
|
background-position: 0 61.157025%;
|
||||||
|
}
|
||||||
|
.flag-mx {
|
||||||
|
background-position: 0 61.570248%;
|
||||||
|
}
|
||||||
|
.flag-my {
|
||||||
|
background-position: 0 61.983471%;
|
||||||
|
}
|
||||||
|
.flag-mz {
|
||||||
|
background-position: 0 62.396694%;
|
||||||
|
}
|
||||||
|
.flag-na {
|
||||||
|
background-position: 0 62.809917%;
|
||||||
|
}
|
||||||
|
.flag-nc {
|
||||||
|
background-position: 0 63.22314%;
|
||||||
|
}
|
||||||
|
.flag-ne {
|
||||||
|
background-position: 0 63.636364%;
|
||||||
|
}
|
||||||
|
.flag-nf {
|
||||||
|
background-position: 0 64.049587%;
|
||||||
|
}
|
||||||
|
.flag-ng {
|
||||||
|
background-position: 0 64.46281%;
|
||||||
|
}
|
||||||
|
.flag-ni {
|
||||||
|
background-position: 0 64.876033%;
|
||||||
|
}
|
||||||
|
.flag-nl {
|
||||||
|
background-position: 0 65.289256%;
|
||||||
|
}
|
||||||
|
.flag-no {
|
||||||
|
background-position: 0 65.702479%;
|
||||||
|
}
|
||||||
|
.flag-np {
|
||||||
|
background-position: 0 66.115702%;
|
||||||
|
}
|
||||||
|
.flag-nr {
|
||||||
|
background-position: 0 66.528926%;
|
||||||
|
}
|
||||||
|
.flag-nu {
|
||||||
|
background-position: 0 66.942149%;
|
||||||
|
}
|
||||||
|
.flag-nz {
|
||||||
|
background-position: 0 67.355372%;
|
||||||
|
}
|
||||||
|
.flag-om {
|
||||||
|
background-position: 0 67.768595%;
|
||||||
|
}
|
||||||
|
.flag-pa {
|
||||||
|
background-position: 0 68.181818%;
|
||||||
|
}
|
||||||
|
.flag-pe {
|
||||||
|
background-position: 0 68.595041%;
|
||||||
|
}
|
||||||
|
.flag-pf {
|
||||||
|
background-position: 0 69.008264%;
|
||||||
|
}
|
||||||
|
.flag-pg {
|
||||||
|
background-position: 0 69.421488%;
|
||||||
|
}
|
||||||
|
.flag-ph {
|
||||||
|
background-position: 0 69.834711%;
|
||||||
|
}
|
||||||
|
.flag-pk {
|
||||||
|
background-position: 0 70.247934%;
|
||||||
|
}
|
||||||
|
.flag-pl {
|
||||||
|
background-position: 0 70.661157%;
|
||||||
|
}
|
||||||
|
.flag-pm {
|
||||||
|
background-position: 0 71.07438%;
|
||||||
|
}
|
||||||
|
.flag-pn {
|
||||||
|
background-position: 0 71.487603%;
|
||||||
|
}
|
||||||
|
.flag-pr {
|
||||||
|
background-position: 0 71.900826%;
|
||||||
|
}
|
||||||
|
.flag-pt {
|
||||||
|
background-position: 0 72.31405%;
|
||||||
|
}
|
||||||
|
.flag-pw {
|
||||||
|
background-position: 0 72.727273%;
|
||||||
|
}
|
||||||
|
.flag-py {
|
||||||
|
background-position: 0 73.140496%;
|
||||||
|
}
|
||||||
|
.flag-qa {
|
||||||
|
background-position: 0 73.553719%;
|
||||||
|
}
|
||||||
|
.flag-re {
|
||||||
|
background-position: 0 73.966942%;
|
||||||
|
}
|
||||||
|
.flag-ro {
|
||||||
|
background-position: 0 74.380165%;
|
||||||
|
}
|
||||||
|
.flag-rs {
|
||||||
|
background-position: 0 74.793388%;
|
||||||
|
}
|
||||||
|
.flag-ru {
|
||||||
|
background-position: 0 75.206612%;
|
||||||
|
}
|
||||||
|
.flag-rw {
|
||||||
|
background-position: 0 75.619835%;
|
||||||
|
}
|
||||||
|
.flag-sa {
|
||||||
|
background-position: 0 76.033058%;
|
||||||
|
}
|
||||||
|
.flag-sb {
|
||||||
|
background-position: 0 76.446281%;
|
||||||
|
}
|
||||||
|
.flag-sc {
|
||||||
|
background-position: 0 76.859504%;
|
||||||
|
}
|
||||||
|
.flag-sd {
|
||||||
|
background-position: 0 77.272727%;
|
||||||
|
}
|
||||||
|
.flag-se {
|
||||||
|
background-position: 0 77.68595%;
|
||||||
|
}
|
||||||
|
.flag-sg {
|
||||||
|
background-position: 0 78.099174%;
|
||||||
|
}
|
||||||
|
.flag-sh {
|
||||||
|
background-position: 0 78.512397%;
|
||||||
|
}
|
||||||
|
.flag-si {
|
||||||
|
background-position: 0 78.92562%;
|
||||||
|
}
|
||||||
|
.flag-sj {
|
||||||
|
background-position: 0 79.338843%;
|
||||||
|
}
|
||||||
|
.flag-sk {
|
||||||
|
background-position: 0 79.752066%;
|
||||||
|
}
|
||||||
|
.flag-sl {
|
||||||
|
background-position: 0 80.165289%;
|
||||||
|
}
|
||||||
|
.flag-sm {
|
||||||
|
background-position: 0 80.578512%;
|
||||||
|
}
|
||||||
|
.flag-sn {
|
||||||
|
background-position: 0 80.991736%;
|
||||||
|
}
|
||||||
|
.flag-so {
|
||||||
|
background-position: 0 81.404959%;
|
||||||
|
}
|
||||||
|
.flag-sr {
|
||||||
|
background-position: 0 81.818182%;
|
||||||
|
}
|
||||||
|
.flag-ss {
|
||||||
|
background-position: 0 82.231405%;
|
||||||
|
}
|
||||||
|
.flag-st {
|
||||||
|
background-position: 0 82.644628%;
|
||||||
|
}
|
||||||
|
.flag-sv {
|
||||||
|
background-position: 0 83.057851%;
|
||||||
|
}
|
||||||
|
.flag-sy {
|
||||||
|
background-position: 0 83.471074%;
|
||||||
|
}
|
||||||
|
.flag-sz {
|
||||||
|
background-position: 0 83.884298%;
|
||||||
|
}
|
||||||
|
.flag-tc {
|
||||||
|
background-position: 0 84.297521%;
|
||||||
|
}
|
||||||
|
.flag-td {
|
||||||
|
background-position: 0 84.710744%;
|
||||||
|
}
|
||||||
|
.flag-tf {
|
||||||
|
background-position: 0 85.123967%;
|
||||||
|
}
|
||||||
|
.flag-tg {
|
||||||
|
background-position: 0 85.53719%;
|
||||||
|
}
|
||||||
|
.flag-th {
|
||||||
|
background-position: 0 85.950413%;
|
||||||
|
}
|
||||||
|
.flag-tj {
|
||||||
|
background-position: 0 86.363636%;
|
||||||
|
}
|
||||||
|
.flag-tk {
|
||||||
|
background-position: 0 86.77686%;
|
||||||
|
}
|
||||||
|
.flag-tl {
|
||||||
|
background-position: 0 87.190083%;
|
||||||
|
}
|
||||||
|
.flag-tm {
|
||||||
|
background-position: 0 87.603306%;
|
||||||
|
}
|
||||||
|
.flag-tn {
|
||||||
|
background-position: 0 88.016529%;
|
||||||
|
}
|
||||||
|
.flag-to {
|
||||||
|
background-position: 0 88.429752%;
|
||||||
|
}
|
||||||
|
.flag-tp {
|
||||||
|
background-position: 0 88.842975%;
|
||||||
|
}
|
||||||
|
.flag-tr {
|
||||||
|
background-position: 0 89.256198%;
|
||||||
|
}
|
||||||
|
.flag-tt {
|
||||||
|
background-position: 0 89.669421%;
|
||||||
|
}
|
||||||
|
.flag-tv {
|
||||||
|
background-position: 0 90.082645%;
|
||||||
|
}
|
||||||
|
.flag-tw {
|
||||||
|
background-position: 0 90.495868%;
|
||||||
|
}
|
||||||
|
.flag-ty {
|
||||||
|
background-position: 0 90.909091%;
|
||||||
|
}
|
||||||
|
.flag-tz {
|
||||||
|
background-position: 0 91.322314%;
|
||||||
|
}
|
||||||
|
.flag-ua {
|
||||||
|
background-position: 0 91.735537%;
|
||||||
|
}
|
||||||
|
.flag-ug {
|
||||||
|
background-position: 0 92.14876%;
|
||||||
|
}
|
||||||
|
.flag-gb,
|
||||||
|
.flag-uk {
|
||||||
|
background-position: 0 92.561983%;
|
||||||
|
}
|
||||||
|
.flag-um {
|
||||||
|
background-position: 0 92.975207%;
|
||||||
|
}
|
||||||
|
.flag-us {
|
||||||
|
background-position: 0 93.38843%;
|
||||||
|
}
|
||||||
|
.flag-uy {
|
||||||
|
background-position: 0 93.801653%;
|
||||||
|
}
|
||||||
|
.flag-uz {
|
||||||
|
background-position: 0 94.214876%;
|
||||||
|
}
|
||||||
|
.flag-va {
|
||||||
|
background-position: 0 94.628099%;
|
||||||
|
}
|
||||||
|
.flag-vc {
|
||||||
|
background-position: 0 95.041322%;
|
||||||
|
}
|
||||||
|
.flag-ve {
|
||||||
|
background-position: 0 95.454545%;
|
||||||
|
}
|
||||||
|
.flag-vg {
|
||||||
|
background-position: 0 95.867769%;
|
||||||
|
}
|
||||||
|
.flag-vi {
|
||||||
|
background-position: 0 96.280992%;
|
||||||
|
}
|
||||||
|
.flag-vn {
|
||||||
|
background-position: 0 96.694215%;
|
||||||
|
}
|
||||||
|
.flag-vu {
|
||||||
|
background-position: 0 97.107438%;
|
||||||
|
}
|
||||||
|
.flag-wf {
|
||||||
|
background-position: 0 97.520661%;
|
||||||
|
}
|
||||||
|
.flag-ws {
|
||||||
|
background-position: 0 97.933884%;
|
||||||
|
}
|
||||||
|
.flag-ye {
|
||||||
|
background-position: 0 98.347107%;
|
||||||
|
}
|
||||||
|
.flag-za {
|
||||||
|
background-position: 0 98.760331%;
|
||||||
|
}
|
||||||
|
.flag-zm {
|
||||||
|
background-position: 0 99.173554%;
|
||||||
|
}
|
||||||
|
.flag-zr {
|
||||||
|
background-position: 0 99.586777%;
|
||||||
|
}
|
||||||
|
.flag-zw {
|
||||||
|
background-position: 0 100%;
|
||||||
|
}
|
||||||
BIN
public/flags/flags_responsive.png
Normal file
BIN
public/flags/flags_responsive.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 278 KiB |
98
src/app/(full-page)/forgot-password/page.tsx
Normal file
98
src/app/(full-page)/forgot-password/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,50 +1,31 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { PasswordField } from '@/components/form';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { AuthBackground } from '@/components/auth/AuthBackground';
|
||||||
import { GuestGuard } from '@/guards';
|
import { GuestGuard } from '@/guards';
|
||||||
import { useLoginForm } from '@/hooks/useLoginForm';
|
import { useLoginForm } from '@/hooks/useLoginForm';
|
||||||
|
import { ROUTES } from '@/utils/routes';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { register, handleSubmit, errors, isLoading } = useLoginForm();
|
const { register, handleSubmit, errors, isLoading } = useLoginForm();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GuestGuard>
|
<GuestGuard>
|
||||||
<main className="grid min-h-screen bg-background text-foreground lg:grid-cols-[1.05fr_0.95fr]">
|
<div className="flex min-h-screen w-full">
|
||||||
<section className="relative hidden overflow-hidden border-r border-border/60 lg:block">
|
<AuthBackground />
|
||||||
<Image
|
<div className="flex flex-1 md:w-1/2 items-center justify-center p-6 border-l border-border h-screen">
|
||||||
src="/background.png"
|
<section className="w-full max-w-sm space-y-7">
|
||||||
alt=""
|
<header className="space-y-2 text-center md:text-left">
|
||||||
fill
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
priority
|
RoadMonitor
|
||||||
className="object-cover"
|
|
||||||
sizes="55vw"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-background/35" />
|
|
||||||
<div className="absolute inset-x-0 bottom-0 p-10">
|
|
||||||
<div className="max-w-lg space-y-3">
|
|
||||||
<p className="text-sm font-medium uppercase tracking-[0.18em] text-primary">
|
|
||||||
VisionRoad
|
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-4xl font-semibold tracking-normal text-foreground">
|
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
|
||||||
Road intelligence for faster inspection decisions
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm leading-6 text-muted-foreground">
|
|
||||||
Review projects, uploads, detections, and chainage insights from one secure
|
|
||||||
workspace.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="flex min-h-screen items-center justify-center px-6 py-10">
|
|
||||||
<div className="w-full max-w-sm space-y-7">
|
|
||||||
<header className="space-y-2">
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">VisionRoad</p>
|
|
||||||
<h2 className="text-2xl font-semibold tracking-normal">Sign in</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Use your email and password to continue.
|
Use your email and password to continue.
|
||||||
</p>
|
</p>
|
||||||
@@ -65,27 +46,30 @@ export default function LoginPage() {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.email && (
|
||||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
<p className="text-sm text-destructive">
|
||||||
|
{errors.email.message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<PasswordField
|
||||||
<Label htmlFor="password">Password</Label>
|
id="password"
|
||||||
<Input
|
label="Password"
|
||||||
id="password"
|
required
|
||||||
type="password"
|
labelEnd={
|
||||||
placeholder="Enter password"
|
<Link href={ROUTES.FORGOT_PASSWORD} className="text-primary">
|
||||||
autoComplete="current-password"
|
Forgot password?
|
||||||
disabled={isLoading}
|
</Link>
|
||||||
aria-invalid={!!errors.password}
|
}
|
||||||
{...register('password', {
|
placeholder="Enter password"
|
||||||
required: 'Password is required',
|
autoComplete="current-password"
|
||||||
})}
|
disabled={isLoading}
|
||||||
/>
|
aria-invalid={!!errors.password}
|
||||||
{errors.password && (
|
error={errors.password?.message}
|
||||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
{...register('password', {
|
||||||
)}
|
required: 'Password is required',
|
||||||
</div>
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -98,9 +82,9 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
</GuestGuard>
|
</GuestGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
92
src/app/(full-page)/reset-password/page.tsx
Normal file
92
src/app/(full-page)/reset-password/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/(full-page)/set-password/page.tsx
Normal file
1
src/app/(full-page)/set-password/page.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from '../reset-password/page';
|
||||||
27
src/app/(modules)/_components/module-shell-header.tsx
Normal file
27
src/app/(modules)/_components/module-shell-header.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { BreadcrumbBasic } from '@/components/app-breadcrumb';
|
||||||
|
import { ModeToggle } from '@/components/mode-toogle';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { SidebarTrigger, useSidebar } from '@/components/ui/sidebar';
|
||||||
|
|
||||||
|
export function ModuleShellHeader() {
|
||||||
|
const { isMobile } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-50 flex h-14 items-center justify-between gap-4 border-b border-border bg-background/95 px-6 backdrop-blur supports-backdrop-filter:bg-background/80">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
{isMobile ? (
|
||||||
|
<>
|
||||||
|
<SidebarTrigger className="shrink-0 bg-background/80 text-muted-foreground shadow-xs backdrop-blur-sm" />
|
||||||
|
<Separator orientation="vertical" className="h-4 shrink-0" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<BreadcrumbBasic />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ModeToggle />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ export default function AccessPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold">Access denied</h1>
|
<h1 className="text-2xl font-semibold">Access denied</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
You do not have permission to access any available module. Contact your administrator if
|
You do not have permission to access any available module. Contact
|
||||||
you need access.
|
your administrator if you need access.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -2,12 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Edit, RotateCcw } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
|
||||||
import { usePermissions } from '@/hooks/usePermissions';
|
|
||||||
import type { Client } from '@/types';
|
import type { Client } from '@/types';
|
||||||
|
|
||||||
function formatDate(value?: string | null) {
|
function formatDate(value?: string | null) {
|
||||||
@@ -19,27 +15,17 @@ function formatDate(value?: string | null) {
|
|||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseClientColumnsParams {
|
export function useClientColumns(): ColumnDef<Client>[] {
|
||||||
onEdit: (client: Client) => void;
|
|
||||||
onToggleStatus: (client: Client) => void;
|
|
||||||
pendingClientId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useClientColumns({
|
|
||||||
onEdit,
|
|
||||||
onToggleStatus,
|
|
||||||
pendingClientId,
|
|
||||||
}: UseClientColumnsParams): ColumnDef<Client>[] {
|
|
||||||
const { hasPermission } = usePermissions();
|
|
||||||
const canEdit = hasPermission(PERMISSIONS.CLIENT.UPDATE);
|
|
||||||
const canDelete = hasPermission(PERMISSIONS.CLIENT.DELETE);
|
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const columns: ColumnDef<Client>[] = [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'first_name',
|
||||||
header: 'Client',
|
header: 'Client',
|
||||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium">
|
||||||
|
{`${row.original.first_name} ${row.original.last_name}`.trim()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'email',
|
accessorKey: 'email',
|
||||||
@@ -51,7 +37,9 @@ export function useClientColumns({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{row.original.contact_name}</p>
|
<p className="font-medium">{row.original.contact_name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{row.original.contact_phone_number}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{row.original.contact_phone_number}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -75,37 +63,5 @@ export function useClientColumns({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}, []);
|
||||||
if (!canEdit && !canDelete) return columns;
|
|
||||||
|
|
||||||
columns.push({
|
|
||||||
id: 'actions',
|
|
||||||
header: () => <div className="text-right">Actions</div>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const client = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{canEdit ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onEdit(client)}>
|
|
||||||
<Edit className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{canDelete ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pendingClientId === client.id}
|
|
||||||
onClick={() => onToggleStatus(client)}
|
|
||||||
>
|
|
||||||
<RotateCcw className="mr-2 size-4" />
|
|
||||||
{client.is_active ? 'Deactivate' : 'Activate'}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingClientId]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { TableSearchInput } from '@/components/data-table/TableSearchInput';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -25,11 +25,10 @@ export function ClientFilters({
|
|||||||
}: ClientFiltersProps) {
|
}: ClientFiltersProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<Input
|
<TableSearchInput
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(event) => onSearchChange(event.target.value)}
|
onChange={onSearchChange}
|
||||||
placeholder="Search clients"
|
placeholder="Search clients"
|
||||||
className="md:max-w-sm"
|
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import { useRef, type ComponentProps } from 'react';
|
||||||
import type { UseFormRegister } from 'react-hook-form';
|
import {
|
||||||
|
Controller,
|
||||||
|
type Control,
|
||||||
|
type FieldErrors,
|
||||||
|
type UseFormRegister,
|
||||||
|
} from 'react-hook-form';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { FormField, PhoneInput } from '@/components/form';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
@@ -14,7 +21,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import type { ClientFormValues } from '../hooks/useClientForm';
|
import type { ClientFormValues } from '../hooks/useClientForm';
|
||||||
|
|
||||||
interface ClientSheetProps {
|
interface ClientSheetProps {
|
||||||
@@ -22,7 +28,11 @@ interface ClientSheetProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
clientId?: number;
|
clientId?: number;
|
||||||
register: UseFormRegister<ClientFormValues>;
|
register: UseFormRegister<ClientFormValues>;
|
||||||
|
control: Control<ClientFormValues>;
|
||||||
|
errors: FieldErrors<ClientFormValues>;
|
||||||
|
isSubmitted: boolean;
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
|
canSubmit: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,107 +41,209 @@ export function ClientSheet({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
clientId,
|
clientId,
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: ClientSheetProps) {
|
}: ClientSheetProps) {
|
||||||
|
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const firstNameErrorMessage = isSubmitted
|
||||||
|
? errors.first_name?.message
|
||||||
|
: undefined;
|
||||||
|
const lastNameErrorMessage = isSubmitted
|
||||||
|
? errors.last_name?.message
|
||||||
|
: undefined;
|
||||||
|
const emailErrorMessage = isSubmitted ? errors.email?.message : undefined;
|
||||||
|
const landlineErrorMessage = isSubmitted
|
||||||
|
? errors.landline_number?.message
|
||||||
|
: undefined;
|
||||||
|
const addressErrorMessage = isSubmitted ? errors.address?.message : undefined;
|
||||||
|
const contactNameErrorMessage = isSubmitted
|
||||||
|
? errors.contact_name?.message
|
||||||
|
: undefined;
|
||||||
|
const contactPhoneErrorMessage = isSubmitted
|
||||||
|
? errors.contact_phone_number?.message
|
||||||
|
: undefined;
|
||||||
|
const contactEmailErrorMessage = isSubmitted
|
||||||
|
? errors.contact_email?.message
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
|
<DialogContent className="sm:max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{clientId ? 'Edit Client' : 'Create Client'}</DialogTitle>
|
<DialogTitle>
|
||||||
<DialogDescription>Manage company and primary contact details.</DialogDescription>
|
{clientId ? 'Edit Client' : 'Create Client'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Manage company and primary contact details.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="client-name">Name</Label>
|
|
||||||
<Input
|
|
||||||
id="client-name"
|
|
||||||
placeholder="Acme Corp"
|
|
||||||
{...register('name', { required: true })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="client-email">Email</Label>
|
|
||||||
<Input
|
|
||||||
id="client-email"
|
|
||||||
type="email"
|
|
||||||
placeholder="info@acme.com"
|
|
||||||
{...register('email', { required: true })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<form
|
||||||
<div className="space-y-2">
|
noValidate
|
||||||
<Label htmlFor="landline-number">Landline Number</Label>
|
onSubmit={onSubmit}
|
||||||
<Input
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
id="landline-number"
|
>
|
||||||
placeholder="+91-22-12345678"
|
<DialogBody className="space-y-5">
|
||||||
{...register('landline_number', { required: true })}
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
id="client-first-name"
|
||||||
|
label="First Name"
|
||||||
required
|
required
|
||||||
/>
|
error={firstNameErrorMessage}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="client-first-name"
|
||||||
|
placeholder="Enter first name"
|
||||||
|
aria-invalid={!!firstNameErrorMessage}
|
||||||
|
{...register('first_name')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="client-last-name"
|
||||||
|
label="Last Name"
|
||||||
|
required
|
||||||
|
error={lastNameErrorMessage}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="client-last-name"
|
||||||
|
placeholder="Enter last name"
|
||||||
|
aria-invalid={!!lastNameErrorMessage}
|
||||||
|
{...register('last_name')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="address">Address</Label>
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
id="client-email"
|
||||||
|
label="Company Email"
|
||||||
|
required
|
||||||
|
error={emailErrorMessage}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="client-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Enter company email"
|
||||||
|
aria-invalid={!!emailErrorMessage}
|
||||||
|
{...register('email')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="landline-number"
|
||||||
|
label="Landline Number"
|
||||||
|
required
|
||||||
|
error={landlineErrorMessage}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="landline-number"
|
||||||
|
placeholder="Enter landline number"
|
||||||
|
aria-invalid={!!landlineErrorMessage}
|
||||||
|
{...register('landline_number')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="address"
|
||||||
|
label="Address"
|
||||||
|
required
|
||||||
|
error={addressErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="address"
|
id="address"
|
||||||
placeholder="12 MG Road, Mumbai, MH 400001"
|
placeholder="Enter address"
|
||||||
{...register('address', { required: true })}
|
aria-invalid={!!addressErrorMessage}
|
||||||
required
|
{...register('address')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<div className="space-y-2">
|
<FormField id="gst" label="GST">
|
||||||
<Label htmlFor="gst">GST</Label>
|
<Input
|
||||||
<Input id="gst" placeholder="27ABCDE1234F1Z5" {...register('gst')} />
|
id="gst"
|
||||||
</div>
|
placeholder="Enter GST number"
|
||||||
<div className="space-y-2">
|
{...register('gst')}
|
||||||
<Label htmlFor="pan">PAN</Label>
|
/>
|
||||||
<Input id="pan" placeholder="ABCDE1234F" {...register('pan')} />
|
</FormField>
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="tan">TAN</Label>
|
|
||||||
<Input id="tan" placeholder="MUMA12345B" {...register('tan')} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<FormField id="pan" label="PAN">
|
||||||
<div className="space-y-2">
|
<Input
|
||||||
<Label htmlFor="contact-name">Contact Name</Label>
|
id="pan"
|
||||||
<Input
|
placeholder="Enter PAN number"
|
||||||
|
{...register('pan')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField id="tan" label="TAN">
|
||||||
|
<Input
|
||||||
|
id="tan"
|
||||||
|
placeholder="Enter TAN number"
|
||||||
|
{...register('tan')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<FormField
|
||||||
id="contact-name"
|
id="contact-name"
|
||||||
placeholder="Jane Doe"
|
label="Contact Name"
|
||||||
{...register('contact_name', { required: true })}
|
|
||||||
required
|
required
|
||||||
/>
|
error={contactNameErrorMessage}
|
||||||
</div>
|
>
|
||||||
<div className="space-y-2">
|
<Input
|
||||||
<Label htmlFor="contact-phone-number">Contact Phone</Label>
|
id="contact-name"
|
||||||
<Input
|
placeholder="Enter contact name"
|
||||||
id="contact-phone-number"
|
aria-invalid={!!contactNameErrorMessage}
|
||||||
placeholder="+91-9876543210"
|
{...register('contact_name')}
|
||||||
{...register('contact_phone_number', { required: true })}
|
/>
|
||||||
required
|
</FormField>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="contact-email">Contact Email</Label>
|
|
||||||
<Input
|
|
||||||
id="contact-email"
|
|
||||||
type="email"
|
|
||||||
placeholder="jane.doe@acme.com"
|
|
||||||
{...register('contact_email', { required: true })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="px-0">
|
<FormField
|
||||||
|
id="contact-phone-number"
|
||||||
|
label="Contact Phone"
|
||||||
|
required
|
||||||
|
error={contactPhoneErrorMessage}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="contact_phone_number"
|
||||||
|
render={({ field }) => (
|
||||||
|
<PhoneInput
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="Enter contact phone"
|
||||||
|
aria-invalid={!!contactPhoneErrorMessage}
|
||||||
|
portalContainer={dropdownPortalRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="contact-email"
|
||||||
|
label="Contact Email"
|
||||||
|
required
|
||||||
|
error={contactEmailErrorMessage}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="contact-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Enter contact email"
|
||||||
|
aria-invalid={!!contactEmailErrorMessage}
|
||||||
|
{...register('contact_email')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -140,12 +252,17 @@ export function ClientSheet({
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSaving}>
|
|
||||||
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
{clientId ? 'Update Client' : 'Create Client'}
|
{clientId ? 'Update Client' : 'Create Client'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div ref={dropdownPortalRef} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
import { Edit, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import type { Client } from '@/types';
|
import type { Client } from '@/types';
|
||||||
|
|
||||||
interface ClientTableProps {
|
interface ClientTableProps {
|
||||||
@@ -14,7 +16,13 @@ interface ClientTableProps {
|
|||||||
skip: number;
|
skip: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
sorting: SortingState;
|
||||||
onPageChange: (skip: number) => void;
|
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({
|
export function ClientTable({
|
||||||
@@ -25,7 +33,13 @@ export function ClientTable({
|
|||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
|
sorting,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
onSortingChange,
|
||||||
|
onEdit,
|
||||||
|
onToggleStatus,
|
||||||
|
pendingClientId,
|
||||||
}: ClientTableProps) {
|
}: ClientTableProps) {
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -35,13 +49,30 @@ export function ClientTable({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
toolbar={toolbar}
|
toolbar={toolbar}
|
||||||
emptyTitle="No clients found."
|
emptyTitle="No clients found."
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit className="size-4" />,
|
||||||
|
permission: PERMISSIONS.CLIENT.UPDATE,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (client) => (client.is_active ? 'Deactivate' : 'Activate'),
|
||||||
|
icon: <RotateCcw className="size-4" />,
|
||||||
|
permission: PERMISSIONS.CLIENT.DELETE,
|
||||||
|
disabled: (client) => pendingClientId === client.id,
|
||||||
|
onClick: onToggleStatus,
|
||||||
|
},
|
||||||
|
]}
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
totalItems: total,
|
totalItems: total,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange: () => undefined,
|
onLimitChange,
|
||||||
}}
|
}}
|
||||||
|
sorting={sorting}
|
||||||
|
onSortingChange={onSortingChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
import type { SortingState } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { useDebounce } from '@/hooks/useDebounce';
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
|
|
||||||
@@ -8,9 +9,11 @@ export type ClientStatusFilter = 'all' | 'active' | 'inactive';
|
|||||||
|
|
||||||
export function useClientFilters() {
|
export function useClientFilters() {
|
||||||
const [skip, setSkip] = useState(0);
|
const [skip, setSkip] = useState(0);
|
||||||
const [limit] = useState(10);
|
const [limit, setLimitValue] = useState(10);
|
||||||
|
const [sorting, setSortingValue] = useState<SortingState>([]);
|
||||||
const [searchTerm, setSearchTermValue] = useState('');
|
const [searchTerm, setSearchTermValue] = useState('');
|
||||||
const [statusFilter, setStatusFilterValue] = useState<ClientStatusFilter>('all');
|
const [statusFilter, setStatusFilterValue] =
|
||||||
|
useState<ClientStatusFilter>('all');
|
||||||
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
|
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
|
||||||
|
|
||||||
const setSearchTerm = useCallback((value: string) => {
|
const setSearchTerm = useCallback((value: string) => {
|
||||||
@@ -23,10 +26,23 @@ export function useClientFilters() {
|
|||||||
setSkip(0);
|
setSkip(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setLimit = useCallback((value: number) => {
|
||||||
|
setLimitValue(value);
|
||||||
|
setSkip(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSorting = useCallback((value: SortingState) => {
|
||||||
|
setSortingValue(value);
|
||||||
|
setSkip(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
skip,
|
skip,
|
||||||
setSkip,
|
setSkip,
|
||||||
limit,
|
limit,
|
||||||
|
setLimit,
|
||||||
|
sorting,
|
||||||
|
setSorting,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
debouncedSearchTerm,
|
debouncedSearchTerm,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
|
|||||||
@@ -1,29 +1,43 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { Client } from '@/types';
|
import type { Client } from '@/types';
|
||||||
import { useSaveClientMutation } from './useClientMutations';
|
import { useSaveClientMutation } from './useClientMutations';
|
||||||
|
|
||||||
export interface ClientFormValues {
|
const requiredText = (message: string) => z.string().trim().min(1, message);
|
||||||
id?: number;
|
const optionalTaxIdSchema = z.string().trim();
|
||||||
name: string;
|
|
||||||
email: string;
|
const clientFormSchema = z.object({
|
||||||
landline_number: string;
|
id: z.number().optional(),
|
||||||
address: string;
|
first_name: requiredText('First name is required'),
|
||||||
gst: string;
|
last_name: requiredText('Last name is required'),
|
||||||
pan: string;
|
email: requiredText('Email is required').email('Invalid email'),
|
||||||
tan: string;
|
landline_number: requiredText('Landline number is required'),
|
||||||
contact_name: string;
|
address: requiredText('Address is required'),
|
||||||
contact_phone_number: string;
|
gst: optionalTaxIdSchema,
|
||||||
contact_email: string;
|
pan: optionalTaxIdSchema,
|
||||||
is_active: boolean;
|
tan: optionalTaxIdSchema,
|
||||||
}
|
contact_name: requiredText('Contact name is required'),
|
||||||
|
contact_phone_number: requiredText('Contact phone number is required').refine(
|
||||||
|
(value) => isValidPhoneNumber(value),
|
||||||
|
'Enter a valid phone number',
|
||||||
|
),
|
||||||
|
contact_email: requiredText('Contact email is required').email(
|
||||||
|
'Invalid contact email',
|
||||||
|
),
|
||||||
|
is_active: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ClientFormValues = z.infer<typeof clientFormSchema>;
|
||||||
|
|
||||||
const defaultValues: ClientFormValues = {
|
const defaultValues: ClientFormValues = {
|
||||||
name: '',
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
email: '',
|
email: '',
|
||||||
landline_number: '',
|
landline_number: '',
|
||||||
address: '',
|
address: '',
|
||||||
@@ -42,9 +56,12 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
handleSubmit: submitForm,
|
handleSubmit: submitForm,
|
||||||
reset,
|
reset,
|
||||||
control,
|
control,
|
||||||
formState: { isSubmitting, errors },
|
formState: { isSubmitting, errors, isSubmitted },
|
||||||
} = useForm<ClientFormValues>({
|
} = useForm<ClientFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
|
mode: 'onSubmit',
|
||||||
|
reValidateMode: 'onChange',
|
||||||
|
resolver: zodResolver(clientFormSchema),
|
||||||
});
|
});
|
||||||
const saveMutation = useSaveClientMutation({
|
const saveMutation = useSaveClientMutation({
|
||||||
onSaved: () => {
|
onSaved: () => {
|
||||||
@@ -54,23 +71,26 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const clientId = useWatch({ control, name: 'id' });
|
const clientId = useWatch({ control, name: 'id' });
|
||||||
|
const firstName = useWatch({ control, name: 'first_name' }) || '';
|
||||||
|
const lastName = useWatch({ control, name: 'last_name' }) || '';
|
||||||
|
const email = useWatch({ control, name: 'email' }) || '';
|
||||||
|
const landlineNumber = useWatch({ control, name: 'landline_number' }) || '';
|
||||||
|
const address = useWatch({ control, name: 'address' }) || '';
|
||||||
|
const contactName = useWatch({ control, name: 'contact_name' }) || '';
|
||||||
|
const contactPhoneNumber =
|
||||||
|
useWatch({ control, name: 'contact_phone_number' }) || '';
|
||||||
|
const contactEmail = useWatch({ control, name: 'contact_email' }) || '';
|
||||||
|
const canSubmit =
|
||||||
|
firstName.trim().length > 0 &&
|
||||||
|
lastName.trim().length > 0 &&
|
||||||
|
email.trim().length > 0 &&
|
||||||
|
landlineNumber.trim().length > 0 &&
|
||||||
|
address.trim().length > 0 &&
|
||||||
|
contactName.trim().length > 0 &&
|
||||||
|
contactPhoneNumber.trim().length > 0 &&
|
||||||
|
contactEmail.trim().length > 0;
|
||||||
|
|
||||||
const handleSubmit = submitForm(
|
const handleSubmit = submitForm((values) => saveMutation.mutate(values));
|
||||||
(values) => saveMutation.mutate(values),
|
|
||||||
(formErrors) => {
|
|
||||||
if (
|
|
||||||
formErrors.name ||
|
|
||||||
formErrors.email ||
|
|
||||||
formErrors.landline_number ||
|
|
||||||
formErrors.address ||
|
|
||||||
formErrors.contact_name ||
|
|
||||||
formErrors.contact_phone_number ||
|
|
||||||
formErrors.contact_email
|
|
||||||
) {
|
|
||||||
toast.error('Complete all required client fields');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
@@ -80,7 +100,8 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
(client: Client) => {
|
(client: Client) => {
|
||||||
reset({
|
reset({
|
||||||
id: client.id,
|
id: client.id,
|
||||||
name: client.name || '',
|
first_name: client.first_name || '',
|
||||||
|
last_name: client.last_name || '',
|
||||||
email: client.email || '',
|
email: client.email || '',
|
||||||
landline_number: client.landline_number || '',
|
landline_number: client.landline_number || '',
|
||||||
address: client.address || '',
|
address: client.address || '',
|
||||||
@@ -98,12 +119,15 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
openCreate,
|
openCreate,
|
||||||
openEdit,
|
openEdit,
|
||||||
clientId,
|
clientId,
|
||||||
errors,
|
errors,
|
||||||
|
isSubmitted,
|
||||||
|
canSubmit,
|
||||||
isSaving: isSubmitting || saveMutation.isPending,
|
isSaving: isSubmitting || saveMutation.isPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
|
|||||||
mutationFn: (values: ClientFormValues) =>
|
mutationFn: (values: ClientFormValues) =>
|
||||||
clientService.saveClient({
|
clientService.saveClient({
|
||||||
id: values.id,
|
id: values.id,
|
||||||
name: values.name.trim(),
|
first_name: values.first_name.trim(),
|
||||||
|
last_name: values.last_name.trim(),
|
||||||
email: values.email.trim(),
|
email: values.email.trim(),
|
||||||
landline_number: values.landline_number.trim(),
|
landline_number: values.landline_number.trim(),
|
||||||
address: values.address.trim(),
|
address: values.address.trim(),
|
||||||
@@ -38,7 +39,9 @@ export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
|
|||||||
queryClient.invalidateQueries({ queryKey: clientKeys.all });
|
queryClient.invalidateQueries({ queryKey: clientKeys.all });
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
onError: (_error, values) => {
|
||||||
toast.error(values.id ? 'Failed to update client' : 'Failed to create client');
|
toast.error(
|
||||||
|
values.id ? 'Failed to update client' : 'Failed to create client',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -46,7 +49,8 @@ export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
|
|||||||
export function useClientStatusMutation() {
|
export function useClientStatusMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (client: Client) => clientService.updateClientStatus(client.id, !client.is_active),
|
mutationFn: (client: Client) =>
|
||||||
|
clientService.updateClientStatus(client.id, !client.is_active),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Client status updated');
|
toast.success('Client status updated');
|
||||||
queryClient.invalidateQueries({ queryKey: clientKeys.all });
|
queryClient.invalidateQueries({ queryKey: clientKeys.all });
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { clientService } from '@/services/api';
|
||||||
import type { ClientListParams } from '@/types';
|
import type { ClientListParams } from '@/types';
|
||||||
import { clientKeys } from '../queries/clientKeys';
|
import { clientKeys } from '../queries/clientKeys';
|
||||||
@@ -13,6 +15,7 @@ interface UseClientsQueryParams {
|
|||||||
limit: number;
|
limit: number;
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
statusFilter: ClientStatusFilter;
|
statusFilter: ClientStatusFilter;
|
||||||
|
sorting: SortingState;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildClientListParams({
|
function buildClientListParams({
|
||||||
@@ -20,22 +23,23 @@ function buildClientListParams({
|
|||||||
limit,
|
limit,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
sorting,
|
||||||
}: UseClientsQueryParams): ClientListParams {
|
}: UseClientsQueryParams): ClientListParams {
|
||||||
return {
|
return {
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
search_term: searchTerm || undefined,
|
search_term: searchTerm || undefined,
|
||||||
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
|
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
|
||||||
sort_by: 'created_at',
|
...getServerSortParams(sorting),
|
||||||
sort_order: 'desc',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useClientsQuery(params: UseClientsQueryParams) {
|
export function useClientsQuery(params: UseClientsQueryParams) {
|
||||||
const { skip, limit, searchTerm, statusFilter } = params;
|
const { skip, limit, searchTerm, statusFilter, sorting } = params;
|
||||||
const listParams = useMemo(
|
const listParams = useMemo(
|
||||||
() => buildClientListParams({ skip, limit, searchTerm, statusFilter }),
|
() =>
|
||||||
[limit, searchTerm, skip, statusFilter],
|
buildClientListParams({ skip, limit, searchTerm, statusFilter, sorting }),
|
||||||
|
[limit, searchTerm, skip, sorting, statusFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
16
src/app/(modules)/clients/layout.tsx
Normal file
16
src/app/(modules)/clients/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function ClientsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.CLIENT.READ}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { useCallback, useMemo, useState } from 'react';
|
|||||||
import { Building2, Plus } from 'lucide-react';
|
import { Building2, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import { PermissionGuard } from '@/guards';
|
import { PermissionGuard } from '@/guards';
|
||||||
@@ -25,6 +24,9 @@ export default function ClientsPage() {
|
|||||||
skip,
|
skip,
|
||||||
setSkip,
|
setSkip,
|
||||||
limit,
|
limit,
|
||||||
|
setLimit,
|
||||||
|
sorting,
|
||||||
|
setSorting,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
debouncedSearchTerm,
|
debouncedSearchTerm,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
@@ -37,12 +39,23 @@ export default function ClientsPage() {
|
|||||||
limit,
|
limit,
|
||||||
searchTerm: debouncedSearchTerm,
|
searchTerm: debouncedSearchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
sorting,
|
||||||
});
|
});
|
||||||
const clientForm = useClientForm({
|
const clientForm = useClientForm({
|
||||||
onSaved: () => setIsSheetOpen(false),
|
onSaved: () => setIsSheetOpen(false),
|
||||||
});
|
});
|
||||||
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } = clientForm;
|
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } =
|
||||||
const { register, handleSubmit, clientId, isSaving } = clientForm;
|
clientForm;
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
clientId,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
|
canSubmit,
|
||||||
|
isSaving,
|
||||||
|
} = clientForm;
|
||||||
const statusMutation = useClientStatusMutation();
|
const statusMutation = useClientStatusMutation();
|
||||||
const { mutate: updateClientStatus, pendingClientId } = statusMutation;
|
const { mutate: updateClientStatus, pendingClientId } = statusMutation;
|
||||||
|
|
||||||
@@ -66,11 +79,7 @@ export default function ClientsPage() {
|
|||||||
[updateClientStatus],
|
[updateClientStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useClientColumns({
|
const columns = useClientColumns();
|
||||||
onEdit: openEdit,
|
|
||||||
onToggleStatus: toggleStatus,
|
|
||||||
pendingClientId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = clientsQuery.data?.total ?? 0;
|
const total = clientsQuery.data?.total ?? 0;
|
||||||
const clients = clientsQuery.data?.items ?? [];
|
const clients = clientsQuery.data?.items ?? [];
|
||||||
@@ -111,10 +120,14 @@ export default function ClientsPage() {
|
|||||||
skip={skip}
|
skip={skip}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
total={total}
|
total={total}
|
||||||
|
sorting={sorting}
|
||||||
onPageChange={setSkip}
|
onPageChange={setSkip}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
onSortingChange={setSorting}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onToggleStatus={toggleStatus}
|
||||||
|
pendingClientId={pendingClientId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<ClientSheet
|
<ClientSheet
|
||||||
@@ -122,7 +135,11 @@ export default function ClientsPage() {
|
|||||||
onOpenChange={setIsSheetOpen}
|
onOpenChange={setIsSheetOpen}
|
||||||
clientId={clientId}
|
clientId={clientId}
|
||||||
register={register}
|
register={register}
|
||||||
|
control={control}
|
||||||
|
errors={errors}
|
||||||
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
canSubmit={canSubmit}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { BreadcrumbBasic } from '@/components/app-breadcrumb';
|
|
||||||
import { AppSidebar } from '@/components/app-sidebar';
|
import { AppSidebar } from '@/components/app-sidebar';
|
||||||
import { ModeToggle } from '@/components/mode-toogle';
|
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||||
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { AuthGuard } from '@/guards';
|
import { AuthGuard } from '@/guards';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { ModuleShellHeader } from './_components/module-shell-header';
|
||||||
|
|
||||||
const ModulesLayout = ({
|
const ModulesLayout = ({
|
||||||
children,
|
children,
|
||||||
@@ -13,19 +11,13 @@ const ModulesLayout = ({
|
|||||||
}>) => {
|
}>) => {
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<SidebarProvider className="bg-transparent overflow-hidden">
|
<SidebarProvider>
|
||||||
<AppSidebar className="bg-transparent" />
|
<AppSidebar />
|
||||||
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden bg-transparent">
|
<main className="flex flex-1 h-screen w-full flex-col overflow-hidden">
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="scroll-stable flex-1 overflow-auto">
|
||||||
{/* Centering container wrapper */}
|
<div className="mx-auto flex min-h-full w-full max-w-380 flex-col">
|
||||||
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full">
|
<ModuleShellHeader />
|
||||||
<div className="flex gap-3 items-center sticky top-0 bg-background/30 backdrop-blur-xl z-30 py-3 px-6 border-b border-border/10">
|
<div className="flex-1 p-6">{children}</div>
|
||||||
<SidebarTrigger />
|
|
||||||
<ModeToggle />
|
|
||||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
|
||||||
<BreadcrumbBasic />
|
|
||||||
</div>
|
|
||||||
<div className="p-6 flex-1">{children}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
106
src/app/(modules)/package/components/PackageColumns.tsx
Normal file
106
src/app/(modules)/package/components/PackageColumns.tsx
Normal 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],
|
||||||
|
);
|
||||||
|
}
|
||||||
201
src/app/(modules)/package/components/PackageDialog.tsx
Normal file
201
src/app/(modules)/package/components/PackageDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/app/(modules)/package/components/PackageTable.tsx
Normal file
64
src/app/(modules)/package/components/PackageTable.tsx
Normal 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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/app/(modules)/package/hooks/usePackageFilters.ts
Normal file
20
src/app/(modules)/package/hooks/usePackageFilters.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
145
src/app/(modules)/package/hooks/usePackageForm.ts
Normal file
145
src/app/(modules)/package/hooks/usePackageForm.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
54
src/app/(modules)/package/hooks/usePackageQueries.ts
Normal file
54
src/app/(modules)/package/hooks/usePackageQueries.ts
Normal 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.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
16
src/app/(modules)/package/layout.tsx
Normal file
16
src/app/(modules)/package/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function PackageLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.PACKAGE.READ}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
onSubmit,
|
||||||
|
openCreate: prepareCreatePackage,
|
||||||
|
openEdit: prepareEditPackage,
|
||||||
|
resetForm,
|
||||||
|
packageId,
|
||||||
|
projectId,
|
||||||
|
setProjectId,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
|
canSubmit,
|
||||||
|
isSaving,
|
||||||
|
} = packageForm;
|
||||||
|
|
||||||
// Pagination state
|
const openCreate = useCallback(() => {
|
||||||
const [skip, setSkip] = useState(0);
|
prepareCreatePackage();
|
||||||
const [limit, setLimit] = useState(10);
|
setIsDialogOpen(true);
|
||||||
|
}, [prepareCreatePackage]);
|
||||||
|
|
||||||
// Editing state
|
const openEdit = useCallback(
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
(pkg: Package) => {
|
||||||
const [currentPackage, setCurrentPackage] = useState<PackageType | null>(null);
|
prepareEditPackage(pkg);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
},
|
||||||
|
[prepareEditPackage],
|
||||||
|
);
|
||||||
|
|
||||||
// Form fields
|
const handleDialogOpenChange = useCallback(
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState('');
|
(open: boolean) => {
|
||||||
const [name, setName] = useState('');
|
setIsDialogOpen(open);
|
||||||
const [region, setRegion] = useState('');
|
if (!open) {
|
||||||
const [chainageStartKm, setChainageStartKm] = useState<string>('');
|
resetForm();
|
||||||
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 data: PackageCreate = {
|
|
||||||
project_id: selectedProjectId,
|
|
||||||
name: name.trim(),
|
|
||||||
region: region.trim() || null,
|
|
||||||
chainage_start_km: chainageStartKm ? parseFloat(chainageStartKm) : 0,
|
|
||||||
chainage_end_km: chainageEndKm ? parseFloat(chainageEndKm) : 0,
|
|
||||||
};
|
|
||||||
await packageService.createPackage(data);
|
|
||||||
toast.success('Package Created', {
|
|
||||||
description: `${name} has been established successfully at ${new Date().toLocaleTimeString()}`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[resetForm],
|
||||||
|
);
|
||||||
|
|
||||||
// Refresh packages list
|
const deletePackage = useCallback(
|
||||||
await loadPackages();
|
(pkg: Package) => {
|
||||||
|
setPackageToDelete(pkg);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Close modal and reset form immediately
|
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
|
||||||
setIsModalOpen(false);
|
if (!open) {
|
||||||
resetForm();
|
setPackageToDelete(null);
|
||||||
} catch (err) {
|
|
||||||
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) => {
|
const confirmDeletePackage = useCallback(() => {
|
||||||
setIsEditing(true);
|
if (!packageToDelete) return;
|
||||||
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) => {
|
deletePackageMutation.mutate(packageToDelete, {
|
||||||
if (!confirm(`Are you sure you want to delete package "${pkg.name}"?`)) return;
|
onSettled: () => setPackageToDelete(null),
|
||||||
|
});
|
||||||
|
}, [deletePackageMutation, packageToDelete]);
|
||||||
|
|
||||||
try {
|
const projects = projectsQuery.data?.items ?? [];
|
||||||
setIsLoading(true);
|
const packages = packagesQuery.data?.items ?? [];
|
||||||
await packageService.deletePackage(pkg.id);
|
const total = packagesQuery.data?.totalItems ?? 0;
|
||||||
toast.success('Package Deleted', {
|
const columns = usePackageColumns(projects);
|
||||||
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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'region',
|
|
||||||
header: 'Region',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="relative z-10">
|
<main className="relative z-10 space-y-5">
|
||||||
{/* Refined Header */}
|
<PageHeader
|
||||||
<div className="mb-8">
|
title="Package Management"
|
||||||
<PageHeader
|
description="Manage project packages"
|
||||||
title="Package Management"
|
icon={PackageIcon}
|
||||||
description="Manage project packages"
|
actions={
|
||||||
icon={Package}
|
<Button onClick={openCreate} size="sm">
|
||||||
actions={
|
<Plus />
|
||||||
<Button
|
Add New Package
|
||||||
onClick={() => {
|
</Button>
|
||||||
setIsEditing(false);
|
}
|
||||||
setIsModalOpen(true);
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Package className="mr-2 h-4 w-4" />
|
|
||||||
Add New Package
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Data Table */}
|
<PackageTable
|
||||||
<div>
|
columns={columns}
|
||||||
<DataTable
|
packages={packages}
|
||||||
title="Packages"
|
isLoading={packagesQuery.isLoading || deletePackageMutation.isPending}
|
||||||
data={packages}
|
skip={skip}
|
||||||
columns={columns}
|
limit={limit}
|
||||||
onEdit={handleEdit}
|
total={total}
|
||||||
onDelete={handleDelete}
|
onPageChange={setSkip}
|
||||||
isLoading={isLoading}
|
onLimitChange={setLimit}
|
||||||
pagination={{
|
onEdit={openEdit}
|
||||||
skip,
|
onDelete={deletePackage}
|
||||||
limit,
|
/>
|
||||||
totalItems,
|
|
||||||
onPageChange: setSkip,
|
|
||||||
onLimitChange: (newLimit) => {
|
|
||||||
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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={Boolean(packageToDelete)}
|
||||||
|
onOpenChange={handleDeleteDialogOpenChange}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-2xl" onOpenAutoFocus={(e) => e.preventDefault()}>
|
<AlertDialogContent>
|
||||||
<DialogHeader className="gap-2">
|
<AlertDialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-3 text-xl">
|
<AlertDialogTitle>Delete package?</AlertDialogTitle>
|
||||||
<div className="p-2 rounded-lg bg-primary text-primary-foreground shadow-sm">
|
<AlertDialogDescription>
|
||||||
<Package className="h-5 w-5" />
|
This will permanently delete{' '}
|
||||||
</div>
|
<span className="font-medium text-foreground">
|
||||||
{isEditing ? 'Edit Package Details' : 'Create New Package'}
|
{packageToDelete?.name}
|
||||||
</DialogTitle>
|
</span>
|
||||||
<DialogDescription className="text-sm">
|
. This action cannot be undone.
|
||||||
{isEditing
|
</AlertDialogDescription>
|
||||||
? 'Update the technical specifications for your road infrastructure package.'
|
</AlertDialogHeader>
|
||||||
: 'Select a project and provide the essential data to establish a new package.'}
|
<AlertDialogFooter>
|
||||||
</DialogDescription>
|
<AlertDialogCancel disabled={deletePackageMutation.isPending}>
|
||||||
</DialogHeader>
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
<AlertDialogAction
|
||||||
{/* Step 1: Select Project */}
|
variant="destructive"
|
||||||
{!isEditing && (
|
disabled={deletePackageMutation.isPending}
|
||||||
<div className="space-y-3">
|
onClick={confirmDeletePackage}
|
||||||
<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 && (
|
Delete
|
||||||
<div className="flex items-center gap-2.5">
|
</AlertDialogAction>
|
||||||
<div
|
</AlertDialogFooter>
|
||||||
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'}`}
|
</AlertDialogContent>
|
||||||
>
|
</AlertDialog>
|
||||||
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">
|
|
||||||
<Label
|
|
||||||
htmlFor="region"
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting || !name.trim() || !selectedProjectId}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
<span>{isEditing ? 'Updating...' : 'Creating...'}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/app/(modules)/package/queries/packageKeys.ts
Normal file
10
src/app/(modules)/package/queries/packageKeys.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -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" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Edit, RotateCcw } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
|
||||||
import { usePermissions } from '@/hooks/usePermissions';
|
|
||||||
import type { Plan } from '@/types';
|
import type { Plan } from '@/types';
|
||||||
|
|
||||||
function formatDate(value?: string | null) {
|
function formatDate(value?: string | null) {
|
||||||
@@ -25,37 +21,24 @@ function formatPrice(value: string, cycle: string) {
|
|||||||
return `${price} / ${cycle}`;
|
return `${price} / ${cycle}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UsePlanColumnsParams {
|
export function usePlanColumns(): ColumnDef<Plan>[] {
|
||||||
onEdit: (plan: Plan) => void;
|
|
||||||
onToggleStatus: (plan: Plan) => void;
|
|
||||||
pendingPlanId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePlanColumns({
|
|
||||||
onEdit,
|
|
||||||
onToggleStatus,
|
|
||||||
pendingPlanId,
|
|
||||||
}: UsePlanColumnsParams): ColumnDef<Plan>[] {
|
|
||||||
const { hasPermission } = usePermissions();
|
|
||||||
const canEdit = hasPermission(PERMISSIONS.PLAN.UPDATE);
|
|
||||||
const canDelete = hasPermission(PERMISSIONS.PLAN.DELETE);
|
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const columns: ColumnDef<Plan>[] = [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Plan',
|
header: 'Plan',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{row.original.name}</p>
|
<p>{row.original.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{row.original.slug}</p>
|
<p className="text-muted-foreground">{row.original.slug}</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'price',
|
accessorKey: 'price',
|
||||||
header: 'Price',
|
header: 'Price',
|
||||||
cell: ({ row }) => formatPrice(row.original.price, row.original.billing_cycle),
|
cell: ({ row }) =>
|
||||||
|
formatPrice(row.original.price, row.original.billing_cycle),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'trial_days',
|
accessorKey: 'trial_days',
|
||||||
@@ -65,8 +48,9 @@ export function usePlanColumns({
|
|||||||
{
|
{
|
||||||
id: 'limits',
|
id: 'limits',
|
||||||
header: 'Limits',
|
header: 'Limits',
|
||||||
|
enableSorting: false,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
<p>{row.original.max_projects} projects</p>
|
<p>{row.original.max_projects} projects</p>
|
||||||
<p>{row.original.max_users} users</p>
|
<p>{row.original.max_users} users</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,42 +69,12 @@ export function usePlanColumns({
|
|||||||
<Badge variant={row.original.is_active ? 'default' : 'secondary'}>
|
<Badge variant={row.original.is_active ? 'default' : 'secondary'}>
|
||||||
{row.original.is_active ? 'Active' : 'Inactive'}
|
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||||
</Badge>
|
</Badge>
|
||||||
{row.original.is_custom ? <Badge variant="outline">Custom</Badge> : null}
|
{row.original.is_custom ? (
|
||||||
|
<Badge variant="outline">Custom</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}, []);
|
||||||
if (!canEdit && !canDelete) return columns;
|
|
||||||
|
|
||||||
columns.push({
|
|
||||||
id: 'actions',
|
|
||||||
header: () => <div className="text-right">Actions</div>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const plan = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{canEdit ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onEdit(plan)}>
|
|
||||||
<Edit className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{canDelete ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pendingPlanId === plan.id}
|
|
||||||
onClick={() => onToggleStatus(plan)}
|
|
||||||
>
|
|
||||||
<RotateCcw className="mr-2 size-4" />
|
|
||||||
{plan.is_active ? 'Deactivate' : 'Activate'}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingPlanId]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Search } from 'lucide-react';
|
import { TableSearchInput } from '@/components/data-table/TableSearchInput';
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -27,16 +25,16 @@ export function PlanFilters({
|
|||||||
}: PlanFiltersProps) {
|
}: PlanFiltersProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<div className="relative w-full sm:w-72">
|
<TableSearchInput
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
value={searchTerm}
|
||||||
<Input
|
onChange={onSearchChange}
|
||||||
value={searchTerm}
|
placeholder="Search plans"
|
||||||
onChange={(event) => onSearchChange(event.target.value)}
|
className="w-full sm:w-72"
|
||||||
placeholder="Search plans"
|
/>
|
||||||
className="pl-9"
|
<Select
|
||||||
/>
|
value={statusFilter}
|
||||||
</div>
|
onValueChange={(value) => onStatusChange(value as PlanStatusFilter)}
|
||||||
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as PlanStatusFilter)}>
|
>
|
||||||
<SelectTrigger className="w-full sm:w-40">
|
<SelectTrigger className="w-full sm:w-40">
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder="Status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import type { Control, UseFormRegister } from 'react-hook-form';
|
import type { Control, FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||||
import { Controller } from 'react-hook-form';
|
import { Controller } from 'react-hook-form';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { FormField } from '@/components/form';
|
||||||
import { PermissionTree } from '@/components/permission-tree';
|
import { PermissionTree } from '@/components/permission-tree';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -34,7 +35,10 @@ interface PlanSheetProps {
|
|||||||
planId?: number;
|
planId?: number;
|
||||||
register: UseFormRegister<PlanFormValues>;
|
register: UseFormRegister<PlanFormValues>;
|
||||||
control: Control<PlanFormValues>;
|
control: Control<PlanFormValues>;
|
||||||
|
errors: FieldErrors<PlanFormValues>;
|
||||||
|
isSubmitted: boolean;
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
|
canSubmit: boolean;
|
||||||
permissionTree: PermissionTreeItem[];
|
permissionTree: PermissionTreeItem[];
|
||||||
permissionIds: number[];
|
permissionIds: number[];
|
||||||
onPermissionIdsChange: (ids: number[]) => void;
|
onPermissionIdsChange: (ids: number[]) => void;
|
||||||
@@ -48,175 +52,241 @@ export function PlanSheet({
|
|||||||
planId,
|
planId,
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
canSubmit,
|
||||||
permissionTree,
|
permissionTree,
|
||||||
permissionIds,
|
permissionIds,
|
||||||
onPermissionIdsChange,
|
onPermissionIdsChange,
|
||||||
isPermissionsLoading,
|
isPermissionsLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: PlanSheetProps) {
|
}: PlanSheetProps) {
|
||||||
|
const fieldError = (field: keyof PlanFormValues) =>
|
||||||
|
isSubmitted ? errors[field]?.message : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent className="w-full overflow-y-auto sm:max-w-3xl">
|
<SheetContent className="w-full! gap-0 p-0 sm:max-w-3xl! lg:max-w-4xl!">
|
||||||
<SheetHeader>
|
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||||
<SheetTitle>{planId ? 'Edit Plan' : 'Create Plan'}</SheetTitle>
|
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||||
<SheetDescription>
|
{planId ? 'Edit Plan' : 'Create Plan'}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription className="text-sm">
|
||||||
Configure subscription limits, billing, and permission access.
|
Configure subscription limits, billing, and permission access.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<form
|
||||||
<div className="space-y-2">
|
noValidate
|
||||||
<Label htmlFor="plan-name">Name</Label>
|
onSubmit={onSubmit}
|
||||||
<Input
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
|
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<FormField
|
||||||
id="plan-name"
|
id="plan-name"
|
||||||
placeholder="Starter"
|
label="Name"
|
||||||
{...register('name', { required: true })}
|
|
||||||
required
|
required
|
||||||
/>
|
error={fieldError('name')}
|
||||||
</div>
|
>
|
||||||
<div className="space-y-2">
|
<Input
|
||||||
<Label htmlFor="plan-slug">Slug</Label>
|
id="plan-name"
|
||||||
<Input
|
placeholder="Enter plan name"
|
||||||
|
aria-invalid={!!fieldError('name')}
|
||||||
|
{...register('name')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
id="plan-slug"
|
id="plan-slug"
|
||||||
placeholder="starter"
|
label="Slug"
|
||||||
{...register('slug', { required: true })}
|
|
||||||
required
|
required
|
||||||
/>
|
error={fieldError('slug')}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="plan-slug"
|
||||||
|
placeholder="starter"
|
||||||
|
aria-invalid={!!fieldError('slug')}
|
||||||
|
{...register('slug')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="plan-description">Description</Label>
|
|
||||||
<textarea
|
|
||||||
id="plan-description"
|
id="plan-description"
|
||||||
placeholder="Basic plan for small teams"
|
label="Description"
|
||||||
{...register('description', { required: true })}
|
|
||||||
required
|
required
|
||||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
error={fieldError('description')}
|
||||||
/>
|
>
|
||||||
</div>
|
<Textarea
|
||||||
|
id="plan-description"
|
||||||
|
placeholder="Basic plan for small teams"
|
||||||
|
aria-invalid={!!fieldError('description')}
|
||||||
|
{...register('description')}
|
||||||
|
className="min-h-20"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="plan-price">Price</Label>
|
|
||||||
<Input
|
|
||||||
id="plan-price"
|
id="plan-price"
|
||||||
type="number"
|
label="Price"
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
placeholder="29.99"
|
|
||||||
{...register('price', { required: true })}
|
|
||||||
required
|
required
|
||||||
/>
|
error={fieldError('price')}
|
||||||
</div>
|
>
|
||||||
<div className="space-y-2">
|
<Input
|
||||||
<Label>Billing Cycle</Label>
|
id="plan-price"
|
||||||
<Controller
|
type="number"
|
||||||
control={control}
|
min="0"
|
||||||
name="billing_cycle"
|
step="0.01"
|
||||||
render={({ field }) => (
|
placeholder="29.99"
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
aria-invalid={!!fieldError('price')}
|
||||||
<SelectTrigger>
|
{...register('price')}
|
||||||
<SelectValue placeholder="Billing cycle" />
|
/>
|
||||||
</SelectTrigger>
|
</FormField>
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="monthly">Monthly</SelectItem>
|
<FormField
|
||||||
<SelectItem value="quarterly">Quarterly</SelectItem>
|
label="Billing Cycle"
|
||||||
<SelectItem value="yearly">Yearly</SelectItem>
|
required
|
||||||
</SelectContent>
|
error={fieldError('billing_cycle')}
|
||||||
</Select>
|
>
|
||||||
)}
|
<Controller
|
||||||
/>
|
control={control}
|
||||||
</div>
|
name="billing_cycle"
|
||||||
<div className="space-y-2">
|
render={({ field }) => (
|
||||||
<Label htmlFor="plan-trial-days">Trial Days</Label>
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
<Input
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Billing cycle" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="monthly">Monthly</SelectItem>
|
||||||
|
<SelectItem value="quarterly">Quarterly</SelectItem>
|
||||||
|
<SelectItem value="yearly">Yearly</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
id="plan-trial-days"
|
id="plan-trial-days"
|
||||||
type="number"
|
label="Trial Days"
|
||||||
min="0"
|
error={fieldError('trial_days')}
|
||||||
{...register('trial_days', { valueAsNumber: true })}
|
>
|
||||||
/>
|
<Input
|
||||||
|
id="plan-trial-days"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
aria-invalid={!!fieldError('trial_days')}
|
||||||
|
{...register('trial_days', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="max-projects">Projects</Label>
|
|
||||||
<Input
|
|
||||||
id="max-projects"
|
id="max-projects"
|
||||||
type="number"
|
label="Projects"
|
||||||
min="0"
|
error={fieldError('max_projects')}
|
||||||
{...register('max_projects', { valueAsNumber: true })}
|
>
|
||||||
/>
|
<Input
|
||||||
</div>
|
id="max-projects"
|
||||||
<div className="space-y-2">
|
type="number"
|
||||||
<Label htmlFor="max-organizations">Organizations</Label>
|
min="0"
|
||||||
<Input
|
aria-invalid={!!fieldError('max_projects')}
|
||||||
|
{...register('max_projects', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
id="max-organizations"
|
id="max-organizations"
|
||||||
type="number"
|
label="Organizations"
|
||||||
min="0"
|
error={fieldError('max_organizations')}
|
||||||
{...register('max_organizations', { valueAsNumber: true })}
|
>
|
||||||
/>
|
<Input
|
||||||
</div>
|
id="max-organizations"
|
||||||
<div className="space-y-2">
|
type="number"
|
||||||
<Label htmlFor="max-users">Users</Label>
|
min="0"
|
||||||
<Input
|
aria-invalid={!!fieldError('max_organizations')}
|
||||||
|
{...register('max_organizations', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
id="max-users"
|
id="max-users"
|
||||||
type="number"
|
label="Users"
|
||||||
min="0"
|
error={fieldError('max_users')}
|
||||||
{...register('max_users', { valueAsNumber: true })}
|
>
|
||||||
/>
|
<Input
|
||||||
</div>
|
id="max-users"
|
||||||
<div className="space-y-2">
|
type="number"
|
||||||
<Label htmlFor="max-roles">Roles</Label>
|
min="0"
|
||||||
<Input
|
aria-invalid={!!fieldError('max_users')}
|
||||||
|
{...register('max_users', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
id="max-roles"
|
id="max-roles"
|
||||||
type="number"
|
label="Roles"
|
||||||
min="0"
|
error={fieldError('max_roles')}
|
||||||
{...register('max_roles', { valueAsNumber: true })}
|
>
|
||||||
/>
|
<Input
|
||||||
|
id="max-roles"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
aria-invalid={!!fieldError('max_roles')}
|
||||||
|
{...register('max_roles', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-5">
|
<div className="flex flex-wrap gap-5">
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="size-4 rounded border-border accent-primary"
|
className="size-4 rounded-lg border-border accent-primary"
|
||||||
{...register('is_active')}
|
{...register('is_active')}
|
||||||
/>
|
/>
|
||||||
Active
|
Active
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="size-4 rounded border-border accent-primary"
|
className="size-4 rounded-lg border-border accent-primary"
|
||||||
{...register('is_custom')}
|
{...register('is_custom')}
|
||||||
/>
|
/>
|
||||||
Custom plan
|
Custom plan
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<Label>Permissions</Label>
|
|
||||||
<span className="text-xs text-muted-foreground">{permissionIds.length} selected</span>
|
|
||||||
</div>
|
</div>
|
||||||
{isPermissionsLoading ? (
|
|
||||||
<div className="rounded-md border p-6 text-sm text-muted-foreground">
|
<FormField
|
||||||
Loading permissions...
|
label="Permissions"
|
||||||
</div>
|
required
|
||||||
) : (
|
error={fieldError('permission_ids')}
|
||||||
<PermissionTree
|
className="space-y-3"
|
||||||
items={permissionTree}
|
labelEnd={
|
||||||
selectedIds={permissionIds}
|
<span className="text-muted-foreground">
|
||||||
onChange={onPermissionIdsChange}
|
{permissionIds.length} selected
|
||||||
/>
|
</span>
|
||||||
)}
|
}
|
||||||
|
>
|
||||||
|
{isPermissionsLoading ? (
|
||||||
|
<div className="rounded-lg border p-6 text-muted-foreground">
|
||||||
|
Loading permissions...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PermissionTree
|
||||||
|
items={permissionTree}
|
||||||
|
selectedIds={permissionIds}
|
||||||
|
onChange={onPermissionIdsChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SheetFooter className="px-0">
|
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -225,8 +295,10 @@ export function PlanSheet({
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSaving}>
|
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||||
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
{isSaving ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
{planId ? 'Update Plan' : 'Create Plan'}
|
{planId ? 'Update Plan' : 'Create Plan'}
|
||||||
</Button>
|
</Button>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
import { Edit, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import type { Plan } from '@/types';
|
import type { Plan } from '@/types';
|
||||||
|
|
||||||
interface PlanTableProps {
|
interface PlanTableProps {
|
||||||
@@ -14,7 +16,13 @@ interface PlanTableProps {
|
|||||||
skip: number;
|
skip: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
sorting: SortingState;
|
||||||
onPageChange: (skip: number) => void;
|
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({
|
export function PlanTable({
|
||||||
@@ -25,7 +33,13 @@ export function PlanTable({
|
|||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
|
sorting,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
onSortingChange,
|
||||||
|
onEdit,
|
||||||
|
onToggleStatus,
|
||||||
|
pendingPlanId,
|
||||||
}: PlanTableProps) {
|
}: PlanTableProps) {
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -35,13 +49,31 @@ export function PlanTable({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
toolbar={toolbar}
|
toolbar={toolbar}
|
||||||
emptyTitle="No plans found."
|
emptyTitle="No plans found."
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit className="size-4" />,
|
||||||
|
permission: PERMISSIONS.PLAN.UPDATE,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (plan) => (plan.is_active ? 'Deactivate' : 'Activate'),
|
||||||
|
icon: <RotateCcw className="size-4" />,
|
||||||
|
permission: PERMISSIONS.PLAN.DELETE,
|
||||||
|
disabled: (plan) => pendingPlanId === plan.id,
|
||||||
|
onClick: onToggleStatus,
|
||||||
|
},
|
||||||
|
]}
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
totalItems: total,
|
totalItems: total,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange: () => undefined,
|
onLimitChange,
|
||||||
}}
|
}}
|
||||||
|
sorting={sorting}
|
||||||
|
onSortingChange={onSortingChange}
|
||||||
|
actionsSticky
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import type { SortingState } from '@tanstack/react-table';
|
||||||
import { useDebounce } from '@/hooks/useDebounce';
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
|
|
||||||
export type PlanStatusFilter = 'all' | 'active' | 'inactive';
|
export type PlanStatusFilter = 'all' | 'active' | 'inactive';
|
||||||
|
|
||||||
export function usePlanFilters() {
|
export function usePlanFilters() {
|
||||||
const [skip, setSkip] = useState(0);
|
const [skip, setSkip] = useState(0);
|
||||||
const [limit] = useState(10);
|
const [limit, setLimitValue] = useState(10);
|
||||||
|
const [sorting, setSortingValue] = useState<SortingState>([]);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<PlanStatusFilter>('all');
|
const [statusFilter, setStatusFilter] = useState<PlanStatusFilter>('all');
|
||||||
const debouncedSearchTerm = useDebounce(searchTerm, 400);
|
const debouncedSearchTerm = useDebounce(searchTerm, 400);
|
||||||
@@ -22,10 +24,23 @@ export function usePlanFilters() {
|
|||||||
setSkip(0);
|
setSkip(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setLimit = (value: number) => {
|
||||||
|
setLimitValue(value);
|
||||||
|
setSkip(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSorting = (value: SortingState) => {
|
||||||
|
setSortingValue(value);
|
||||||
|
setSkip(0);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
skip,
|
skip,
|
||||||
setSkip,
|
setSkip,
|
||||||
limit,
|
limit,
|
||||||
|
setLimit,
|
||||||
|
sorting,
|
||||||
|
setSorting,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
debouncedSearchTerm,
|
debouncedSearchTerm,
|
||||||
setSearchTerm: updateSearchTerm,
|
setSearchTerm: updateSearchTerm,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import {
|
import {
|
||||||
collectDefaultPermissionIds,
|
collectDefaultPermissionIds,
|
||||||
collectPermissionIdsByKeys,
|
collectPermissionIdsByKeys,
|
||||||
@@ -7,26 +8,39 @@ import {
|
|||||||
import type { PermissionTreeItem, Plan } from '@/types';
|
import type { PermissionTreeItem, Plan } from '@/types';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useSavePlanMutation } from './usePlanMutations';
|
import { useSavePlanMutation } from './usePlanMutations';
|
||||||
|
|
||||||
export interface PlanFormValues {
|
const nonNegativeNumber = z.number().refine(
|
||||||
id?: number;
|
(value) => Number.isFinite(value) && value >= 0,
|
||||||
name: string;
|
{ message: 'Enter a valid number' },
|
||||||
slug: string;
|
);
|
||||||
description: string;
|
|
||||||
price: string;
|
const planFormSchema = z.object({
|
||||||
billing_cycle: string;
|
id: z.number().optional(),
|
||||||
trial_days: number;
|
name: z.string().trim().min(1, 'Plan name is required'),
|
||||||
max_projects: number;
|
slug: z.string().trim().min(1, 'Slug is required'),
|
||||||
max_organizations: number;
|
description: z.string().trim().min(1, 'Description is required'),
|
||||||
max_users: number;
|
price: z
|
||||||
max_roles: number;
|
.string()
|
||||||
permission_ids: number[];
|
.trim()
|
||||||
is_active: boolean;
|
.min(1, 'Price is required')
|
||||||
is_custom: boolean;
|
.refine((value) => Number.isFinite(Number(value)) && Number(value) >= 0, {
|
||||||
}
|
message: 'Enter a valid price',
|
||||||
|
}),
|
||||||
|
billing_cycle: z.string().trim().min(1, 'Billing cycle is required'),
|
||||||
|
trial_days: nonNegativeNumber,
|
||||||
|
max_projects: nonNegativeNumber,
|
||||||
|
max_organizations: nonNegativeNumber,
|
||||||
|
max_users: nonNegativeNumber,
|
||||||
|
max_roles: nonNegativeNumber,
|
||||||
|
permission_ids: z.array(z.number()).min(1, 'Select at least one permission'),
|
||||||
|
is_active: z.boolean(),
|
||||||
|
is_custom: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PlanFormValues = z.infer<typeof planFormSchema>;
|
||||||
|
|
||||||
const defaultValues: PlanFormValues = {
|
const defaultValues: PlanFormValues = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -62,9 +76,12 @@ export function usePlanForm({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
formState: { isSubmitting },
|
formState: { errors, isSubmitting, isSubmitted },
|
||||||
} = useForm<PlanFormValues>({
|
} = useForm<PlanFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
|
mode: 'onSubmit',
|
||||||
|
reValidateMode: 'onChange',
|
||||||
|
resolver: zodResolver(planFormSchema),
|
||||||
});
|
});
|
||||||
const saveMutation = useSavePlanMutation({
|
const saveMutation = useSavePlanMutation({
|
||||||
onSaved: () => {
|
onSaved: () => {
|
||||||
@@ -75,13 +92,18 @@ export function usePlanForm({
|
|||||||
|
|
||||||
const planId = useWatch({ control, name: 'id' });
|
const planId = useWatch({ control, name: 'id' });
|
||||||
const permissionIds = useWatch({ control, name: 'permission_ids' }) || [];
|
const permissionIds = useWatch({ control, name: 'permission_ids' }) || [];
|
||||||
|
const name = useWatch({ control, name: 'name' }) || '';
|
||||||
|
const slug = useWatch({ control, name: 'slug' }) || '';
|
||||||
|
const description = useWatch({ control, name: 'description' }) || '';
|
||||||
|
const price = useWatch({ control, name: 'price' }) || '';
|
||||||
|
const canSubmit =
|
||||||
|
name.trim().length > 0 &&
|
||||||
|
slug.trim().length > 0 &&
|
||||||
|
description.trim().length > 0 &&
|
||||||
|
price.trim().length > 0 &&
|
||||||
|
permissionIds.length > 0;
|
||||||
|
|
||||||
const onSubmit = handleSubmit((values) => {
|
const onSubmit = handleSubmit((values) => {
|
||||||
if (values.permission_ids.length === 0) {
|
|
||||||
toast.error('Select at least one permission');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveMutation.mutate({
|
saveMutation.mutate({
|
||||||
...values,
|
...values,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
@@ -117,7 +139,10 @@ export function usePlanForm({
|
|||||||
max_organizations: plan.max_organizations ?? 0,
|
max_organizations: plan.max_organizations ?? 0,
|
||||||
max_users: plan.max_users ?? 0,
|
max_users: plan.max_users ?? 0,
|
||||||
max_roles: plan.max_roles ?? 0,
|
max_roles: plan.max_roles ?? 0,
|
||||||
permission_ids: collectPermissionIdsByKeys(permissionTree, plan.permissions || []),
|
permission_ids: collectPermissionIdsByKeys(
|
||||||
|
permissionTree,
|
||||||
|
plan.permissions || [],
|
||||||
|
),
|
||||||
is_active: plan.is_active,
|
is_active: plan.is_active,
|
||||||
is_custom: plan.is_custom,
|
is_custom: plan.is_custom,
|
||||||
});
|
});
|
||||||
@@ -126,7 +151,11 @@ export function usePlanForm({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const setPermissionIds = useCallback(
|
const setPermissionIds = useCallback(
|
||||||
(ids: number[]) => setValue('permission_ids', ids, { shouldDirty: true, shouldValidate: true }),
|
(ids: number[]) =>
|
||||||
|
setValue('permission_ids', ids, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
}),
|
||||||
[setValue],
|
[setValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -139,6 +168,9 @@ export function usePlanForm({
|
|||||||
planId,
|
planId,
|
||||||
permissionIds,
|
permissionIds,
|
||||||
setPermissionIds,
|
setPermissionIds,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
|
canSubmit,
|
||||||
isSaving: isSubmitting || saveMutation.isPending,
|
isSaving: isSubmitting || saveMutation.isPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ export function useSavePlanMutation({ onSaved }: { onSaved: () => void }) {
|
|||||||
onSaved();
|
onSaved();
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
onError: (_error, values) => {
|
||||||
toast.error(values.id ? 'Failed to update plan' : 'Failed to create plan');
|
toast.error(
|
||||||
|
values.id ? 'Failed to update plan' : 'Failed to create plan',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -28,7 +30,8 @@ export function usePlanStatusMutation() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (plan: Plan) => planService.updatePlanStatus(plan.id, !plan.is_active),
|
mutationFn: (plan: Plan) =>
|
||||||
|
planService.updatePlanStatus(plan.id, !plan.is_active),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Plan status updated');
|
toast.success('Plan status updated');
|
||||||
queryClient.invalidateQueries({ queryKey: planKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: planKeys.lists() });
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { mapPermissionTree } from '@/components/permission-tree';
|
||||||
import { permissionService, planService } from '@/services/api';
|
import { permissionService, planService } from '@/services/api';
|
||||||
import type { PlanListParams } from '@/types';
|
import type { PlanListParams } from '@/types';
|
||||||
@@ -15,6 +17,7 @@ interface UsePlansQueryParams {
|
|||||||
limit: number;
|
limit: number;
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
statusFilter: PlanStatusFilter;
|
statusFilter: PlanStatusFilter;
|
||||||
|
sorting: SortingState;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPlanListParams({
|
function buildPlanListParams({
|
||||||
@@ -22,14 +25,14 @@ function buildPlanListParams({
|
|||||||
limit,
|
limit,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
sorting,
|
||||||
}: UsePlansQueryParams): PlanListParams {
|
}: UsePlansQueryParams): PlanListParams {
|
||||||
return {
|
return {
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
search_term: searchTerm || undefined,
|
search_term: searchTerm || undefined,
|
||||||
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
|
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
|
||||||
sort_by: 'created_at',
|
...getServerSortParams(sorting),
|
||||||
sort_order: 'desc',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
src/app/(modules)/plans/layout.tsx
Normal file
16
src/app/(modules)/plans/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function PlansLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.PLAN.READ}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { BadgeIndianRupee, Plus } from 'lucide-react';
|
import { BadgeIndianRupee, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import { PermissionGuard } from '@/guards';
|
import { PermissionGuard } from '@/guards';
|
||||||
@@ -17,7 +16,10 @@ import { PlanTable } from './components/PlanTable';
|
|||||||
import { usePlanFilters } from './hooks/usePlanFilters';
|
import { usePlanFilters } from './hooks/usePlanFilters';
|
||||||
import { usePlanForm } from './hooks/usePlanForm';
|
import { usePlanForm } from './hooks/usePlanForm';
|
||||||
import { usePlanStatusMutation } from './hooks/usePlanMutations';
|
import { usePlanStatusMutation } from './hooks/usePlanMutations';
|
||||||
import { useOrganizationPermissionTreeQuery, usePlansQuery } from './hooks/usePlanQueries';
|
import {
|
||||||
|
useOrganizationPermissionTreeQuery,
|
||||||
|
usePlansQuery,
|
||||||
|
} from './hooks/usePlanQueries';
|
||||||
|
|
||||||
export default function PlansPage() {
|
export default function PlansPage() {
|
||||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
@@ -27,6 +29,9 @@ export default function PlansPage() {
|
|||||||
skip,
|
skip,
|
||||||
setSkip,
|
setSkip,
|
||||||
limit,
|
limit,
|
||||||
|
setLimit,
|
||||||
|
sorting,
|
||||||
|
setSorting,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
debouncedSearchTerm,
|
debouncedSearchTerm,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
@@ -39,6 +44,7 @@ export default function PlansPage() {
|
|||||||
limit,
|
limit,
|
||||||
searchTerm: debouncedSearchTerm,
|
searchTerm: debouncedSearchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
sorting,
|
||||||
});
|
});
|
||||||
const permissionsQuery = useOrganizationPermissionTreeQuery(isSheetOpen);
|
const permissionsQuery = useOrganizationPermissionTreeQuery(isSheetOpen);
|
||||||
|
|
||||||
@@ -61,6 +67,9 @@ export default function PlansPage() {
|
|||||||
planId,
|
planId,
|
||||||
permissionIds,
|
permissionIds,
|
||||||
setPermissionIds,
|
setPermissionIds,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
} = planForm;
|
} = planForm;
|
||||||
const statusMutation = usePlanStatusMutation();
|
const statusMutation = usePlanStatusMutation();
|
||||||
@@ -120,11 +129,7 @@ export default function PlansPage() {
|
|||||||
[statusMutation],
|
[statusMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = usePlanColumns({
|
const columns = usePlanColumns();
|
||||||
onEdit: openEdit,
|
|
||||||
onToggleStatus: toggleStatus,
|
|
||||||
pendingPlanId: statusMutation.variables?.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const plans = plansQuery.data?.items ?? [];
|
const plans = plansQuery.data?.items ?? [];
|
||||||
const total = plansQuery.data?.total ?? plansQuery.data?.totalItems ?? 0;
|
const total = plansQuery.data?.total ?? plansQuery.data?.totalItems ?? 0;
|
||||||
@@ -165,10 +170,14 @@ export default function PlansPage() {
|
|||||||
skip={skip}
|
skip={skip}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
total={total}
|
total={total}
|
||||||
|
sorting={sorting}
|
||||||
onPageChange={setSkip}
|
onPageChange={setSkip}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
onSortingChange={setSorting}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onToggleStatus={toggleStatus}
|
||||||
|
pendingPlanId={statusMutation.variables?.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<PlanSheet
|
<PlanSheet
|
||||||
@@ -177,7 +186,10 @@ export default function PlansPage() {
|
|||||||
planId={planId}
|
planId={planId}
|
||||||
register={register}
|
register={register}
|
||||||
control={control}
|
control={control}
|
||||||
|
errors={errors}
|
||||||
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
canSubmit={canSubmit}
|
||||||
permissionTree={permissionsQuery.permissionTree}
|
permissionTree={permissionsQuery.permissionTree}
|
||||||
permissionIds={permissionIds}
|
permissionIds={permissionIds}
|
||||||
onPermissionIdsChange={setPermissionIds}
|
onPermissionIdsChange={setPermissionIds}
|
||||||
|
|||||||
84
src/app/(modules)/project/components/ProjectColumns.tsx
Normal file
84
src/app/(modules)/project/components/ProjectColumns.tsx
Normal 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 />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
218
src/app/(modules)/project/components/ProjectDialog.tsx
Normal file
218
src/app/(modules)/project/components/ProjectDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/app/(modules)/project/components/ProjectTable.tsx
Normal file
64
src/app/(modules)/project/components/ProjectTable.tsx
Normal 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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/app/(modules)/project/hooks/useProjectFilters.ts
Normal file
20
src/app/(modules)/project/hooks/useProjectFilters.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
137
src/app/(modules)/project/hooks/useProjectForm.ts
Normal file
137
src/app/(modules)/project/hooks/useProjectForm.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
47
src/app/(modules)/project/hooks/useProjectQueries.ts
Normal file
47
src/app/(modules)/project/hooks/useProjectQueries.ts
Normal 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.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
16
src/app/(modules)/project/layout.tsx
Normal file
16
src/app/(modules)/project/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function ProjectLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.PROJECT.READ}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
onSubmit,
|
||||||
|
openCreate: prepareCreateProject,
|
||||||
|
openEdit: prepareEditProject,
|
||||||
|
resetForm,
|
||||||
|
projectId,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
|
canSubmit,
|
||||||
|
isSaving,
|
||||||
|
} = projectForm;
|
||||||
|
|
||||||
// Pagination state
|
const openCreate = useCallback(() => {
|
||||||
const [skip, setSkip] = useState(0);
|
prepareCreateProject();
|
||||||
const [limit, setLimit] = useState(10);
|
setIsDialogOpen(true);
|
||||||
|
}, [prepareCreateProject]);
|
||||||
|
|
||||||
// Editing state
|
const openEdit = useCallback(
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
(project: Project) => {
|
||||||
const [currentProject, setCurrentProject] = useState<Project | null>(null);
|
prepareEditProject(project);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
},
|
||||||
|
[prepareEditProject],
|
||||||
|
);
|
||||||
|
|
||||||
// Form fields
|
const handleDialogOpenChange = useCallback(
|
||||||
const [name, setName] = useState('');
|
(open: boolean) => {
|
||||||
const [state, setState] = useState('');
|
setIsDialogOpen(open);
|
||||||
const [corridorName, setCorridorName] = useState('');
|
if (!open) {
|
||||||
const [startLat, setStartLat] = useState('');
|
resetForm();
|
||||||
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 data: ProjectCreate = {
|
|
||||||
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.createProject(data);
|
|
||||||
toast.success('Project Created', {
|
|
||||||
description: `${name} has been established successfully at ${new Date().toLocaleTimeString()}`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[resetForm],
|
||||||
|
);
|
||||||
|
|
||||||
// Refresh projects list
|
const deleteProject = useCallback(
|
||||||
await loadProjects();
|
(project: Project) => {
|
||||||
|
setProjectToDelete(project);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Close modal and reset form immediately
|
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
|
||||||
setIsModalOpen(false);
|
if (!open) {
|
||||||
resetForm();
|
setProjectToDelete(null);
|
||||||
} catch (err) {
|
|
||||||
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) => {
|
const confirmDeleteProject = useCallback(() => {
|
||||||
setIsEditing(true);
|
if (!projectToDelete) return;
|
||||||
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) => {
|
deleteProjectMutation.mutate(projectToDelete, {
|
||||||
if (!confirm(`Are you sure you want to delete project "${project.name}"?`)) return;
|
onSettled: () => setProjectToDelete(null),
|
||||||
|
});
|
||||||
|
}, [deleteProjectMutation, projectToDelete]);
|
||||||
|
|
||||||
try {
|
const columns = useProjectColumns();
|
||||||
setIsLoading(true);
|
const projects = projectsQuery.data?.items ?? [];
|
||||||
await projectService.deleteProject(project.id);
|
const total = projectsQuery.data?.totalItems ?? 0;
|
||||||
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>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'corridor_name',
|
|
||||||
header: 'Corridor',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="relative z-10">
|
<main className="relative z-10 space-y-5">
|
||||||
{/* Refined Header */}
|
<PageHeader
|
||||||
<div className="mb-8">
|
title="Project Management"
|
||||||
<PageHeader
|
description="Manage road infrastructure projects"
|
||||||
title="Project Management"
|
icon={Layers}
|
||||||
description="Manage road infrastructure projects"
|
actions={
|
||||||
icon={Layers}
|
<Button onClick={openCreate} size="sm">
|
||||||
actions={
|
<Plus />
|
||||||
<Button
|
Add Project
|
||||||
onClick={() => {
|
</Button>
|
||||||
setIsEditing(false);
|
}
|
||||||
setIsModalOpen(true);
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Layers className="mr-2 h-5 w-5" />
|
|
||||||
Add New Project
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Data Table */}
|
<ProjectTable
|
||||||
<div>
|
columns={columns}
|
||||||
<DataTable
|
projects={projects}
|
||||||
title="Projects"
|
isLoading={projectsQuery.isLoading || deleteProjectMutation.isPending}
|
||||||
data={projects}
|
skip={skip}
|
||||||
columns={columns}
|
limit={limit}
|
||||||
onEdit={handleEdit}
|
total={total}
|
||||||
onDelete={handleDelete}
|
onPageChange={setSkip}
|
||||||
isLoading={isLoading}
|
onLimitChange={setLimit}
|
||||||
pagination={{
|
onEdit={openEdit}
|
||||||
skip,
|
onDelete={deleteProject}
|
||||||
limit,
|
/>
|
||||||
totalItems,
|
|
||||||
onPageChange: setSkip,
|
|
||||||
onLimitChange: (newLimit) => {
|
|
||||||
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}
|
||||||
}}
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={Boolean(projectToDelete)}
|
||||||
|
onOpenChange={handleDeleteDialogOpenChange}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-2xl" onOpenAutoFocus={(e) => e.preventDefault()}>
|
<AlertDialogContent>
|
||||||
<DialogHeader className="gap-2">
|
<AlertDialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-3 text-xl">
|
<AlertDialogTitle>Delete project?</AlertDialogTitle>
|
||||||
<div className="p-2 rounded-lg bg-primary text-primary-foreground shadow-sm">
|
<AlertDialogDescription>
|
||||||
<Layers className="h-5 w-5" />
|
This will permanently delete{' '}
|
||||||
</div>
|
<span className="font-medium text-foreground">
|
||||||
{isEditing ? 'Edit Project Details' : 'Create New Project'}
|
{projectToDelete?.name}
|
||||||
</DialogTitle>
|
</span>
|
||||||
<DialogDescription className="text-sm">
|
. This action cannot be undone.
|
||||||
{isEditing
|
</AlertDialogDescription>
|
||||||
? 'Update the technical specifications for your road infrastructure project.'
|
</AlertDialogHeader>
|
||||||
: 'Provide the essential road data to establish a new analysis project.'}
|
<AlertDialogFooter>
|
||||||
</DialogDescription>
|
<AlertDialogCancel disabled={deleteProjectMutation.isPending}>
|
||||||
</DialogHeader>
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<AlertDialogAction
|
||||||
{/* Project Name */}
|
variant="destructive"
|
||||||
<div className="space-y-2">
|
disabled={deleteProjectMutation.isPending}
|
||||||
<Label
|
onClick={confirmDeleteProject}
|
||||||
htmlFor="name"
|
>
|
||||||
className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider"
|
Delete
|
||||||
>
|
</AlertDialogAction>
|
||||||
Project Name <span className="text-destructive">*</span>
|
</AlertDialogFooter>
|
||||||
</Label>
|
</AlertDialogContent>
|
||||||
<Input
|
</AlertDialog>
|
||||||
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"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting || !name.trim()}
|
|
||||||
className="flex-1 h-12 font-bold uppercase tracking-wider text-xs shadow-lg shadow-primary/20"
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
<span>{isEditing ? 'Updating...' : 'Creating...'}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/app/(modules)/project/queries/projectKeys.ts
Normal file
10
src/app/(modules)/project/queries/projectKeys.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
function StatSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center rounded-lg border border-muted bg-muted/30 p-4 text-center">
|
||||||
|
<Skeleton className="mb-2 size-9 rounded-lg" />
|
||||||
|
<Skeleton className="h-6 w-12" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetectionLogSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card p-3">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Skeleton className="size-20 shrink-0 rounded-lg" />
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-5 w-28" />
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-12" />
|
||||||
|
<Skeleton className="h-3 w-10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
<Skeleton className="h-3 w-44" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoResultsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<Skeleton className="size-10 rounded-lg" />
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<Skeleton className="h-7 w-60 max-w-full" />
|
||||||
|
<Skeleton className="h-3 w-80 max-w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="overflow-hidden rounded-lg border bg-card">
|
||||||
|
<div className="border-b p-6 pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="size-10 rounded-lg" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-5 w-44" />
|
||||||
|
<Skeleton className="h-3 w-64" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
<Skeleton className="aspect-video w-full rounded-lg border" />
|
||||||
|
|
||||||
|
<section className="overflow-hidden rounded-lg border bg-card/80">
|
||||||
|
<div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
<Skeleton className="h-3 w-72 max-w-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2 p-3 sm:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="h-16 w-full rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton className="h-5 w-36" />
|
||||||
|
</div>
|
||||||
|
<div className="h-107.5 rounded-lg border bg-muted/20 p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<DetectionLogSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-2 gap-3 md:grid-cols-5">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<StatSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,190 +1,57 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { ArrowLeft, TrendingUp } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import VideoPlayerSection from '@/components/videoPlayerSection';
|
||||||
import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react';
|
|
||||||
import VideoPlayerSection from '@/components/video-player-section';
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
import { Button } from '@/components/ui/button';
|
||||||
import { sessionService, videoService } from '@/services/api';
|
import { useVideoResultsQuery } from '../hooks/useVideoResults';
|
||||||
import { SessionContext, DetectionData, DetectionType } from '@/types';
|
import { VideoResultsSkeleton } from './components/VideoResultsSkeleton';
|
||||||
import { getVideoFile, clearVideoFile } from '@/lib/video-storage';
|
|
||||||
import { ROUTES } from '@/utils/routes';
|
|
||||||
import { Card } from '@/components/ui/card';
|
|
||||||
import { getDetectionModeConfig } from '@/constants/detectionModeConfig';
|
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
|
||||||
|
|
||||||
export default function VideoResultsPage() {
|
export default function VideoResultsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { videoId } = useParams() as { videoId: string };
|
const { videoId } = useParams() as { videoId: string };
|
||||||
const [session, setSession] = useState<SessionContext | null>(null);
|
|
||||||
const [detectionData, setDetectionData] = useState<DetectionData | null>(null);
|
|
||||||
const [detectionType, setDetectionType] = useState<DetectionType>('pothole-detection');
|
|
||||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const {
|
||||||
const storedSession = sessionService.loadSession();
|
data: detectionData,
|
||||||
setSession(storedSession);
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error: queryError,
|
||||||
|
} = useVideoResultsQuery(videoId);
|
||||||
|
|
||||||
const fetchResults = async () => {
|
const error = isError
|
||||||
try {
|
? queryError instanceof Error
|
||||||
// Fetch detection data from backend
|
? queryError.message
|
||||||
const data = await videoService.getVideoResults(videoId);
|
: 'Failed to load results'
|
||||||
setDetectionData(data as any);
|
: null;
|
||||||
|
|
||||||
// Try to infer detection type from results if possible
|
|
||||||
if (data.detection_mode) {
|
|
||||||
setDetectionType(data.detection_mode);
|
|
||||||
} else if (
|
|
||||||
data.summary?.unique_signboards !== undefined &&
|
|
||||||
data.summary?.unique_signboards > 0
|
|
||||||
) {
|
|
||||||
setDetectionType('sign-board-detection');
|
|
||||||
} else if (
|
|
||||||
data.summary?.unique_potholes !== undefined &&
|
|
||||||
data.summary?.unique_potholes > 0
|
|
||||||
) {
|
|
||||||
setDetectionType('pothole-detection');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve video file from IndexedDB
|
|
||||||
const storedVideoFile = await getVideoFile(videoId);
|
|
||||||
if (storedVideoFile) {
|
|
||||||
setVideoFile(storedVideoFile);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load results');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (videoId) {
|
|
||||||
fetchResults();
|
|
||||||
}
|
|
||||||
}, [videoId]);
|
|
||||||
|
|
||||||
const handleNewAnalysis = async () => {
|
|
||||||
if (videoId) {
|
|
||||||
try {
|
|
||||||
await clearVideoFile(videoId);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to clear video file:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sessionService.clearSession();
|
|
||||||
router.push(ROUTES.UPLOAD);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTitle = () => {
|
|
||||||
const config = getDetectionModeConfig(detectionType);
|
|
||||||
return `${config.label} Results`;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <VideoResultsSkeleton />;
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<Card className="flex flex-col items-center gap-4 p-8">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
||||||
<p className="text-sm text-muted-foreground">Loading detection results...</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
<Card className="flex flex-col items-center gap-6 p-8 text-center max-w-md">
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
<p className="text-destructive font-medium">{error}</p>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<Button onClick={() => router.push(ROUTES.UPLOAD)} variant="outline">
|
|
||||||
Back to Upload
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleNewAnalysis}>New Analysis</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="space-y-8">
|
||||||
<main className="min-h-screen">
|
<PageHeader
|
||||||
<div className="container mx-auto px-6 py-10 max-w-[1600px]">
|
title="Detection Results"
|
||||||
<div className="mb-8">
|
description={`Video ID: ${videoId}`}
|
||||||
<PageHeader title={getTitle()} description={`Video ID: ${videoId}`} icon={TrendingUp} />
|
icon={TrendingUp}
|
||||||
</div>
|
actions={
|
||||||
|
<Button variant="outline" size="sm" onClick={() => router.back()}>
|
||||||
|
<ArrowLeft />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{session && (
|
{detectionData && <VideoPlayerSection data={detectionData} />}
|
||||||
<div className="mb-6">
|
|
||||||
<Card className="p-0 border shadow-sm overflow-hidden">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between py-4 px-6 gap-6 bg-card">
|
|
||||||
<div className="flex flex-wrap items-center gap-x-12 gap-y-4">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Project
|
|
||||||
</span>
|
|
||||||
<span className="text-base font-bold leading-tight">
|
|
||||||
{session.projectName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col border-l pl-12 border-border/60">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Package
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
|
||||||
{session.packageName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col border-l pl-12 border-border/60">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Segment
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-muted-foreground leading-tight flex items-center gap-1.5">
|
|
||||||
{session.chainageName}
|
|
||||||
{session.chainageDirection && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-1 py-0.5 rounded bg-muted text-[10px] font-bold uppercase border">
|
|
||||||
{session.chainageDirection === 'UP' ? (
|
|
||||||
<ArrowUp className="h-2.5 w-2.5" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown className="h-2.5 w-2.5" />
|
|
||||||
)}
|
|
||||||
{session.chainageDirection}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleNewAnalysis}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="font-semibold px-6 shrink-0 h-9"
|
|
||||||
>
|
|
||||||
Start New Analysis
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detectionData && (
|
|
||||||
<VideoPlayerSection
|
|
||||||
data={detectionData}
|
|
||||||
videoId={videoId}
|
|
||||||
videoFile={videoFile}
|
|
||||||
detectionType={detectionType}
|
|
||||||
projectId={session?.projectId || undefined}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<PoweredBy />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
78
src/app/(modules)/results/hooks/useVideoResults.ts
Normal file
78
src/app/(modules)/results/hooks/useVideoResults.ts
Normal 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;
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
12
src/app/(modules)/results/queries/videoKeys.ts
Normal file
12
src/app/(modules)/results/queries/videoKeys.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -2,35 +2,17 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Edit, RotateCcw } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
|
||||||
import { usePermissions } from '@/hooks/usePermissions';
|
|
||||||
import type { Role } from '@/types';
|
import type { Role } from '@/types';
|
||||||
|
|
||||||
interface UseRoleColumnsParams {
|
export function useRoleColumns(): ColumnDef<Role>[] {
|
||||||
onEdit: (role: Role) => void;
|
|
||||||
onToggleStatus: (role: Role) => void;
|
|
||||||
isStatusPending: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRoleColumns({
|
|
||||||
onEdit,
|
|
||||||
onToggleStatus,
|
|
||||||
isStatusPending,
|
|
||||||
}: UseRoleColumnsParams): ColumnDef<Role>[] {
|
|
||||||
const { hasPermission } = usePermissions();
|
|
||||||
const canEdit = hasPermission(PERMISSIONS.ROLE.UPDATE);
|
|
||||||
const canDelete = hasPermission(PERMISSIONS.ROLE.DELETE);
|
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const columns: ColumnDef<Role>[] = [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Name',
|
header: 'Name',
|
||||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
cell: ({ row }) => <span>{row.original.name}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'display_name',
|
accessorKey: 'display_name',
|
||||||
@@ -54,43 +36,13 @@ export function useRoleColumns({
|
|||||||
accessorKey: 'effective_status',
|
accessorKey: 'effective_status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge variant={row.original.effective_status ? 'default' : 'secondary'}>
|
<Badge
|
||||||
|
variant={row.original.effective_status ? 'default' : 'secondary'}
|
||||||
|
>
|
||||||
{row.original.effective_status ? 'Active' : 'Inactive'}
|
{row.original.effective_status ? 'Active' : 'Inactive'}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}, []);
|
||||||
if (!canEdit && !canDelete) return columns;
|
|
||||||
|
|
||||||
columns.push({
|
|
||||||
id: 'actions',
|
|
||||||
header: () => <div className="text-right">Actions</div>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const role = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{canEdit ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onEdit(role)}>
|
|
||||||
<Edit className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{canDelete ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={isStatusPending}
|
|
||||||
onClick={() => onToggleStatus(role)}
|
|
||||||
>
|
|
||||||
<RotateCcw className="mr-2 size-4" />
|
|
||||||
{role.effective_status ? 'Deactivate' : 'Activate'}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
}, [canDelete, canEdit, isStatusPending, onEdit, onToggleStatus]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { TableSearchInput } from '@/components/data-table/TableSearchInput';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -26,13 +26,15 @@ export function RoleFilters({
|
|||||||
}: RoleFiltersProps) {
|
}: RoleFiltersProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<Input
|
<TableSearchInput
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(event) => onSearchChange(event.target.value)}
|
onChange={onSearchChange}
|
||||||
placeholder="Search roles"
|
placeholder="Search roles"
|
||||||
className="md:max-w-sm"
|
|
||||||
/>
|
/>
|
||||||
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as StatusFilter)}>
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(value) => onStatusChange(value as StatusFilter)}
|
||||||
|
>
|
||||||
<SelectTrigger className="md:w-44">
|
<SelectTrigger className="md:w-44">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import type { UseFormRegister } from 'react-hook-form';
|
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { FormField } from '@/components/form';
|
||||||
import { PermissionTree } from '@/components/permission-tree';
|
import { PermissionTree } from '@/components/permission-tree';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
|
SheetClose,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
SheetFooter,
|
SheetFooter,
|
||||||
@@ -25,11 +27,14 @@ interface RoleSheetProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
roleId?: number;
|
roleId?: number;
|
||||||
register: UseFormRegister<RoleFormValues>;
|
register: UseFormRegister<RoleFormValues>;
|
||||||
|
errors: FieldErrors<RoleFormValues>;
|
||||||
|
isSubmitted: boolean;
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
permissionTree: PermissionTreeItem[];
|
permissionTree: PermissionTreeItem[];
|
||||||
permissionIds: number[];
|
permissionIds: number[];
|
||||||
onPermissionIdsChange: (ids: number[]) => void;
|
onPermissionIdsChange: (ids: number[]) => void;
|
||||||
isPermissionsLoading: boolean;
|
isPermissionsLoading: boolean;
|
||||||
|
canSubmit: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,85 +43,117 @@ export function RoleSheet({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
roleId,
|
roleId,
|
||||||
register,
|
register,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
permissionTree,
|
permissionTree,
|
||||||
permissionIds,
|
permissionIds,
|
||||||
onPermissionIdsChange,
|
onPermissionIdsChange,
|
||||||
isPermissionsLoading,
|
isPermissionsLoading,
|
||||||
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: RoleSheetProps) {
|
}: RoleSheetProps) {
|
||||||
|
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
|
||||||
|
const displayNameErrorMessage = isSubmitted
|
||||||
|
? errors.display_name?.message
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent className="w-full overflow-y-auto sm:max-w-2xl">
|
<SheetContent
|
||||||
<SheetHeader>
|
side="right"
|
||||||
<SheetTitle>{roleId ? 'Edit Role' : 'Create Role'}</SheetTitle>
|
className="flex h-full w-full! flex-col gap-0 p-0 sm:max-w-3xl!"
|
||||||
<SheetDescription>
|
>
|
||||||
|
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||||
|
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||||
|
{roleId ? 'Edit Role' : 'Create Role'}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription className="text-sm">
|
||||||
Assign the role details and permission access for this organization.
|
Assign the role details and permission access for this organization.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<form
|
||||||
<div className="space-y-2">
|
noValidate
|
||||||
<Label htmlFor="role-name">Role Name</Label>
|
onSubmit={onSubmit}
|
||||||
<Input
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
|
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<FormField
|
||||||
id="role-name"
|
id="role-name"
|
||||||
placeholder="Enter role name"
|
label="Role Name"
|
||||||
{...register('name', { required: true })}
|
|
||||||
required
|
required
|
||||||
/>
|
error={nameErrorMessage}
|
||||||
</div>
|
>
|
||||||
<div className="space-y-2">
|
<Input
|
||||||
<Label htmlFor="role-display-name">Display Name</Label>
|
id="role-name"
|
||||||
<Input
|
placeholder="Enter role name"
|
||||||
|
aria-invalid={!!nameErrorMessage}
|
||||||
|
{...register('name')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
id="role-display-name"
|
id="role-display-name"
|
||||||
placeholder="Enter display name"
|
label="Display Name"
|
||||||
{...register('display_name', { required: true })}
|
|
||||||
required
|
required
|
||||||
/>
|
error={displayNameErrorMessage}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="role-display-name"
|
||||||
|
placeholder="Enter display name"
|
||||||
|
aria-invalid={!!displayNameErrorMessage}
|
||||||
|
{...register('display_name')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<FormField id="role-description" label="Description">
|
||||||
<Label htmlFor="role-description">Description</Label>
|
<Textarea
|
||||||
<textarea
|
id="role-description"
|
||||||
id="role-description"
|
placeholder="Describe this role"
|
||||||
placeholder="Describe this role"
|
{...register('description')}
|
||||||
{...register('description')}
|
className="min-h-24"
|
||||||
className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<Label>Permissions</Label>
|
|
||||||
<span className="text-xs text-muted-foreground">{permissionIds.length} selected</span>
|
|
||||||
</div>
|
|
||||||
{isPermissionsLoading ? (
|
|
||||||
<div className="rounded-md border p-6 text-sm text-muted-foreground">
|
|
||||||
Loading permissions...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<PermissionTree
|
|
||||||
items={permissionTree}
|
|
||||||
selectedIds={permissionIds}
|
|
||||||
onChange={onPermissionIdsChange}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</FormField>
|
||||||
</div>
|
|
||||||
|
|
||||||
<SheetFooter className="px-0">
|
<FormField
|
||||||
<Button
|
label="Permissions"
|
||||||
type="button"
|
required
|
||||||
variant="outline"
|
error={isSubmitted ? errors.permission_ids?.message : undefined}
|
||||||
onClick={() => onOpenChange(false)}
|
className="space-y-3"
|
||||||
disabled={isSaving}
|
labelEnd={
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{permissionIds.length} selected
|
||||||
|
</span>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Cancel
|
{isPermissionsLoading ? (
|
||||||
</Button>
|
<div className="rounded-lg border p-6 text-muted-foreground">
|
||||||
<Button type="submit" disabled={isSaving}>
|
Loading permissions...
|
||||||
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
</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'}
|
{roleId ? 'Update Role' : 'Create Role'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<SheetClose asChild>
|
||||||
|
<Button type="button" variant="outline" disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</SheetClose>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</form>
|
</form>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { Edit, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import type { Role } from '@/types';
|
import type { Role } from '@/types';
|
||||||
|
|
||||||
interface RoleTableProps {
|
interface RoleTableProps {
|
||||||
@@ -14,7 +16,13 @@ interface RoleTableProps {
|
|||||||
skip: number;
|
skip: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
sorting: SortingState;
|
||||||
onPageChange: (skip: number) => void;
|
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({
|
export function RoleTable({
|
||||||
@@ -25,7 +33,13 @@ export function RoleTable({
|
|||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
|
sorting,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
onSortingChange,
|
||||||
|
onEdit,
|
||||||
|
onToggleStatus,
|
||||||
|
isStatusPending,
|
||||||
}: RoleTableProps) {
|
}: RoleTableProps) {
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -35,13 +49,31 @@ export function RoleTable({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
toolbar={toolbar}
|
toolbar={toolbar}
|
||||||
emptyTitle="No roles found."
|
emptyTitle="No roles found."
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit className="size-4" />,
|
||||||
|
permission: PERMISSIONS.ROLE.UPDATE,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (role) => (role.effective_status ? 'Deactivate' : 'Activate'),
|
||||||
|
icon: <RotateCcw className="size-4" />,
|
||||||
|
permission: PERMISSIONS.ROLE.DELETE,
|
||||||
|
disabled: () => isStatusPending,
|
||||||
|
onClick: onToggleStatus,
|
||||||
|
},
|
||||||
|
]}
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
totalItems: total,
|
totalItems: total,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange: () => undefined,
|
onLimitChange,
|
||||||
}}
|
}}
|
||||||
|
sorting={sorting}
|
||||||
|
onSortingChange={onSortingChange}
|
||||||
|
actionsSticky
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { SortingState } from '@tanstack/react-table';
|
||||||
|
|
||||||
export type StatusFilter = 'all' | 'active' | 'inactive';
|
export type StatusFilter = 'all' | 'active' | 'inactive';
|
||||||
|
|
||||||
@@ -8,7 +9,8 @@ const SEARCH_DEBOUNCE_MS = 400;
|
|||||||
|
|
||||||
export function useRoleFilters() {
|
export function useRoleFilters() {
|
||||||
const [skip, setSkip] = useState(0);
|
const [skip, setSkip] = useState(0);
|
||||||
const [limit] = useState(10);
|
const [limit, setLimitValue] = useState(10);
|
||||||
|
const [sorting, setSortingValue] = useState<SortingState>([]);
|
||||||
const [searchTerm, setSearchTermValue] = useState('');
|
const [searchTerm, setSearchTermValue] = useState('');
|
||||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilterValue] = useState<StatusFilter>('all');
|
const [statusFilter, setStatusFilterValue] = useState<StatusFilter>('all');
|
||||||
@@ -31,10 +33,23 @@ export function useRoleFilters() {
|
|||||||
setSkip(0);
|
setSkip(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setLimit = (value: number) => {
|
||||||
|
setLimitValue(value);
|
||||||
|
setSkip(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSorting = (value: SortingState) => {
|
||||||
|
setSortingValue(value);
|
||||||
|
setSkip(0);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
skip,
|
skip,
|
||||||
setSkip,
|
setSkip,
|
||||||
limit,
|
limit,
|
||||||
|
setLimit,
|
||||||
|
sorting,
|
||||||
|
setSorting,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
debouncedSearchTerm,
|
debouncedSearchTerm,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
|
|||||||
@@ -6,20 +6,24 @@ import {
|
|||||||
} from '@/components/permission-tree';
|
} from '@/components/permission-tree';
|
||||||
import { roleService } from '@/services/api';
|
import { roleService } from '@/services/api';
|
||||||
import type { PermissionTreeItem, Role } from '@/types';
|
import type { PermissionTreeItem, Role } from '@/types';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { roleKeys } from '../queries/roleKeys';
|
import { roleKeys } from '../queries/roleKeys';
|
||||||
|
|
||||||
export interface RoleFormValues {
|
const roleFormSchema = z.object({
|
||||||
id?: number;
|
id: z.number().optional(),
|
||||||
name: string;
|
name: z.string().trim().min(1, 'Role name is required'),
|
||||||
display_name: string;
|
display_name: z.string().trim().min(1, 'Display name is required'),
|
||||||
description: string;
|
description: z.string().optional(),
|
||||||
permission_ids: number[];
|
permission_ids: z.array(z.number()).min(1, 'Select at least one permission'),
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export type RoleFormValues = z.infer<typeof roleFormSchema>;
|
||||||
|
|
||||||
const defaultValues: RoleFormValues = {
|
const defaultValues: RoleFormValues = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -44,13 +48,22 @@ export function useRoleForm({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
formState: { isSubmitting },
|
formState: { errors, isSubmitting, isSubmitted },
|
||||||
} = useForm<RoleFormValues>({
|
} = useForm<RoleFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
|
mode: 'onSubmit',
|
||||||
|
reValidateMode: 'onChange',
|
||||||
|
resolver: zodResolver(roleFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const permissionIds = useWatch({ control, name: 'permission_ids' }) || [];
|
const permissionIds = useWatch({ control, name: 'permission_ids' }) || [];
|
||||||
const roleId = useWatch({ control, name: 'id' });
|
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({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (values: RoleFormValues) =>
|
mutationFn: (values: RoleFormValues) =>
|
||||||
@@ -58,7 +71,7 @@ export function useRoleForm({
|
|||||||
id: values.id,
|
id: values.id,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
display_name: values.display_name.trim(),
|
display_name: values.display_name.trim(),
|
||||||
description: values.description.trim(),
|
description: values.description?.trim() ?? '',
|
||||||
organization_id: organizationId,
|
organization_id: organizationId,
|
||||||
permission_ids: values.permission_ids,
|
permission_ids: values.permission_ids,
|
||||||
is_default: false,
|
is_default: false,
|
||||||
@@ -70,17 +83,13 @@ export function useRoleForm({
|
|||||||
queryClient.invalidateQueries({ queryKey: roleKeys.all });
|
queryClient.invalidateQueries({ queryKey: roleKeys.all });
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
onError: (_error, values) => {
|
||||||
toast.error(values.id ? 'Failed to update role' : 'Failed to create role');
|
toast.error(
|
||||||
|
values.id ? 'Failed to update role' : 'Failed to create role',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = handleSubmit((values) => {
|
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
|
||||||
if (values.permission_ids.length === 0) {
|
|
||||||
toast.error('Select at least one permission');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saveMutation.mutate(values);
|
|
||||||
});
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
reset({
|
reset({
|
||||||
@@ -96,14 +105,21 @@ export function useRoleForm({
|
|||||||
name: role.name || '',
|
name: role.name || '',
|
||||||
display_name: role.display_name || '',
|
display_name: role.display_name || '',
|
||||||
description: role.description || '',
|
description: role.description || '',
|
||||||
permission_ids: collectPermissionIdsByKeys(permissionTree, role.permissions || []),
|
permission_ids: collectPermissionIdsByKeys(
|
||||||
|
permissionTree,
|
||||||
|
role.permissions || [],
|
||||||
|
),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[permissionTree, reset],
|
[permissionTree, reset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setPermissionIds = useCallback(
|
const setPermissionIds = useCallback(
|
||||||
(ids: number[]) => setValue('permission_ids', ids, { shouldDirty: true, shouldValidate: true }),
|
(ids: number[]) =>
|
||||||
|
setValue('permission_ids', ids, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
}),
|
||||||
[setValue],
|
[setValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -115,6 +131,9 @@ export function useRoleForm({
|
|||||||
setPermissionIds,
|
setPermissionIds,
|
||||||
permissionIds,
|
permissionIds,
|
||||||
roleId,
|
roleId,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
|
canSubmit,
|
||||||
isSaving: isSubmitting || saveMutation.isPending,
|
isSaving: isSubmitting || saveMutation.isPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { SortingState } from '@tanstack/react-table';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { getServerSortParams } from '@/components/data-table/sorting';
|
||||||
import { mapPermissionTree } from '@/components/permission-tree';
|
import { mapPermissionTree } from '@/components/permission-tree';
|
||||||
import { permissionService, roleService } from '@/services/api';
|
import { permissionService, roleService } from '@/services/api';
|
||||||
import type { Role, RoleListParams } from '@/types';
|
import type { Role, RoleListParams } from '@/types';
|
||||||
@@ -16,6 +18,7 @@ interface UseRolesQueryParams {
|
|||||||
limit: number;
|
limit: number;
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
statusFilter: StatusFilter;
|
statusFilter: StatusFilter;
|
||||||
|
sorting: SortingState;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRoleListParams({
|
function buildRoleListParams({
|
||||||
@@ -23,22 +26,24 @@ function buildRoleListParams({
|
|||||||
limit,
|
limit,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
sorting,
|
||||||
}: UseRolesQueryParams): RoleListParams {
|
}: UseRolesQueryParams): RoleListParams {
|
||||||
return {
|
return {
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
search_term: searchTerm || undefined,
|
search_term: searchTerm || undefined,
|
||||||
effective_status: statusFilter === 'all' ? undefined : statusFilter === 'active',
|
effective_status:
|
||||||
sort_by: 'created_at',
|
statusFilter === 'all' ? undefined : statusFilter === 'active',
|
||||||
sort_order: 'desc',
|
...getServerSortParams(sorting),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRolesQuery(params: UseRolesQueryParams) {
|
export function useRolesQuery(params: UseRolesQueryParams) {
|
||||||
const { skip, limit, searchTerm, statusFilter } = params;
|
const { skip, limit, searchTerm, statusFilter, sorting } = params;
|
||||||
const listParams = useMemo(
|
const listParams = useMemo(
|
||||||
() => buildRoleListParams({ skip, limit, searchTerm, statusFilter }),
|
() =>
|
||||||
[limit, searchTerm, skip, statusFilter],
|
buildRoleListParams({ skip, limit, searchTerm, statusFilter, sorting }),
|
||||||
|
[limit, searchTerm, skip, sorting, statusFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -68,7 +73,8 @@ export function useRoleStatusMutation() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (role: Role) => roleService.updateRoleStatus(role.id, !role.effective_status),
|
mutationFn: (role: Role) =>
|
||||||
|
roleService.updateRoleStatus(role.id, !role.effective_status),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Role status updated');
|
toast.success('Role status updated');
|
||||||
queryClient.invalidateQueries({ queryKey: roleKeys.all });
|
queryClient.invalidateQueries({ queryKey: roleKeys.all });
|
||||||
|
|||||||
16
src/app/(modules)/roles/layout.tsx
Normal file
16
src/app/(modules)/roles/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function RolesLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.ROLE.READ}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Plus, ShieldCheck } from 'lucide-react';
|
import { Plus, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import { PermissionGuard } from '@/guards';
|
import { PermissionGuard } from '@/guards';
|
||||||
@@ -24,12 +23,18 @@ import {
|
|||||||
} from './hooks/useRoleQueries';
|
} from './hooks/useRoleQueries';
|
||||||
|
|
||||||
export default function RolesPage() {
|
export default function RolesPage() {
|
||||||
const organizationId = useAppStore((state) => state.user?.organization_id ?? null);
|
const organizationId = useAppStore(
|
||||||
|
(state) => state.user?.organization_id ?? null,
|
||||||
|
);
|
||||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
|
const [activeRole, setActiveRole] = useState<Role | null>(null);
|
||||||
const {
|
const {
|
||||||
skip,
|
skip,
|
||||||
setSkip,
|
setSkip,
|
||||||
limit,
|
limit,
|
||||||
|
setLimit,
|
||||||
|
sorting,
|
||||||
|
setSorting,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
debouncedSearchTerm,
|
debouncedSearchTerm,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
@@ -42,8 +47,10 @@ export default function RolesPage() {
|
|||||||
limit,
|
limit,
|
||||||
searchTerm: debouncedSearchTerm,
|
searchTerm: debouncedSearchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
sorting,
|
||||||
});
|
});
|
||||||
const permissionsQuery = usePermissionsTreeQuery(isSheetOpen);
|
const permissionsQuery = usePermissionsTreeQuery(isSheetOpen);
|
||||||
|
|
||||||
const roleForm = useRoleForm({
|
const roleForm = useRoleForm({
|
||||||
organizationId,
|
organizationId,
|
||||||
permissionTree: permissionsQuery.permissionTree,
|
permissionTree: permissionsQuery.permissionTree,
|
||||||
@@ -57,24 +64,42 @@ export default function RolesPage() {
|
|||||||
roleId,
|
roleId,
|
||||||
permissionIds,
|
permissionIds,
|
||||||
setPermissionIds,
|
setPermissionIds,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
} = roleForm;
|
} = roleForm;
|
||||||
const statusMutation = useRoleStatusMutation();
|
const statusMutation = useRoleStatusMutation();
|
||||||
const { mutate: updateRoleStatus, isPending: isStatusPending } = statusMutation;
|
const { mutate: updateRoleStatus, isPending: isStatusPending } =
|
||||||
|
statusMutation;
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
prepareCreateRole();
|
setActiveRole(null);
|
||||||
setIsSheetOpen(true);
|
setIsSheetOpen(true);
|
||||||
}, [prepareCreateRole]);
|
}, []);
|
||||||
|
|
||||||
const openEdit = useCallback(
|
const openEdit = useCallback(
|
||||||
(role: Role) => {
|
(role: Role) => {
|
||||||
prepareEditRole(role);
|
setActiveRole(role);
|
||||||
setIsSheetOpen(true);
|
setIsSheetOpen(true);
|
||||||
},
|
},
|
||||||
[prepareEditRole],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSheetOpen || permissionsQuery.permissionTree.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeRole ? prepareEditRole(activeRole) : prepareCreateRole();
|
||||||
|
}, [
|
||||||
|
activeRole,
|
||||||
|
isSheetOpen,
|
||||||
|
permissionsQuery.permissionTree,
|
||||||
|
prepareCreateRole,
|
||||||
|
prepareEditRole,
|
||||||
|
]);
|
||||||
|
|
||||||
const toggleStatus = useCallback(
|
const toggleStatus = useCallback(
|
||||||
(role: Role) => {
|
(role: Role) => {
|
||||||
updateRoleStatus(role);
|
updateRoleStatus(role);
|
||||||
@@ -82,11 +107,7 @@ export default function RolesPage() {
|
|||||||
[updateRoleStatus],
|
[updateRoleStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useRoleColumns({
|
const columns = useRoleColumns();
|
||||||
onEdit: openEdit,
|
|
||||||
onToggleStatus: toggleStatus,
|
|
||||||
isStatusPending,
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = rolesQuery.data?.total ?? 0;
|
const total = rolesQuery.data?.total ?? 0;
|
||||||
const roles = rolesQuery.data?.items ?? [];
|
const roles = rolesQuery.data?.items ?? [];
|
||||||
@@ -108,8 +129,8 @@ export default function RolesPage() {
|
|||||||
icon={ShieldCheck}
|
icon={ShieldCheck}
|
||||||
actions={
|
actions={
|
||||||
<PermissionGuard permissions={PERMISSIONS.ROLE.CREATE}>
|
<PermissionGuard permissions={PERMISSIONS.ROLE.CREATE}>
|
||||||
<Button onClick={openCreate}>
|
<Button onClick={openCreate} size="sm">
|
||||||
<Plus className="mr-2 size-4" />
|
<Plus />
|
||||||
Add Role
|
Add Role
|
||||||
</Button>
|
</Button>
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
@@ -124,10 +145,14 @@ export default function RolesPage() {
|
|||||||
skip={skip}
|
skip={skip}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
total={total}
|
total={total}
|
||||||
|
sorting={sorting}
|
||||||
onPageChange={setSkip}
|
onPageChange={setSkip}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
onSortingChange={setSorting}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onToggleStatus={toggleStatus}
|
||||||
|
isStatusPending={isStatusPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<RoleSheet
|
<RoleSheet
|
||||||
@@ -135,11 +160,14 @@ export default function RolesPage() {
|
|||||||
onOpenChange={setIsSheetOpen}
|
onOpenChange={setIsSheetOpen}
|
||||||
roleId={roleId}
|
roleId={roleId}
|
||||||
register={register}
|
register={register}
|
||||||
|
errors={errors}
|
||||||
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
permissionTree={permissionsQuery.permissionTree}
|
permissionTree={permissionsQuery.permissionTree}
|
||||||
permissionIds={permissionIds}
|
permissionIds={permissionIds}
|
||||||
onPermissionIdsChange={setPermissionIds}
|
onPermissionIdsChange={setPermissionIds}
|
||||||
isPermissionsLoading={permissionsQuery.isLoading}
|
isPermissionsLoading={permissionsQuery.isLoading}
|
||||||
|
canSubmit={canSubmit}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
162
src/app/(modules)/segment/components/SegmentColumns.tsx
Normal file
162
src/app/(modules)/segment/components/SegmentColumns.tsx
Normal 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],
|
||||||
|
);
|
||||||
|
}
|
||||||
329
src/app/(modules)/segment/components/SegmentDialog.tsx
Normal file
329
src/app/(modules)/segment/components/SegmentDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/app/(modules)/segment/components/SegmentTable.tsx
Normal file
64
src/app/(modules)/segment/components/SegmentTable.tsx
Normal 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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/app/(modules)/segment/hooks/useSegmentFilters.ts
Normal file
20
src/app/(modules)/segment/hooks/useSegmentFilters.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
196
src/app/(modules)/segment/hooks/useSegmentForm.ts
Normal file
196
src/app/(modules)/segment/hooks/useSegmentForm.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
67
src/app/(modules)/segment/hooks/useSegmentQueries.ts
Normal file
67
src/app/(modules)/segment/hooks/useSegmentQueries.ts
Normal 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.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
16
src/app/(modules)/segment/layout.tsx
Normal file
16
src/app/(modules)/segment/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function SegmentLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.SEGMENT.READ}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,829 +1,192 @@
|
|||||||
'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 { Milestone, 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,
|
|
||||||
MapPin,
|
|
||||||
Navigation,
|
|
||||||
Milestone,
|
|
||||||
ArrowUpCircle,
|
|
||||||
ArrowDownCircle,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { DataTable } from '@/components/data-table';
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
import {
|
||||||
import { projectService, packageService, chainageService } from '@/services/api';
|
AlertDialog,
|
||||||
import { Project, Package as PackageType, Chainage, ChainageCreate, ChainageUpdate } from '@/types';
|
AlertDialogAction,
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
AlertDialogCancel,
|
||||||
import { toast } from 'sonner';
|
AlertDialogContent,
|
||||||
import { Badge } from '@/components/ui/badge';
|
AlertDialogDescription,
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import type { Chainage } from '@/types';
|
||||||
|
|
||||||
export default function ChainagePage() {
|
import { SegmentDialog } from './components/SegmentDialog';
|
||||||
const [chainages, setChainages] = useState<Chainage[]>([]);
|
import { SegmentTable } from './components/SegmentTable';
|
||||||
const [totalItems, setTotalItems] = useState(0);
|
import { useSegmentColumns } from './components/SegmentColumns';
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
import { useSegmentFilters } from './hooks/useSegmentFilters';
|
||||||
const [packages, setPackages] = useState<PackageType[]>([]);
|
import { useSegmentForm } from './hooks/useSegmentForm';
|
||||||
const [allPackages, setAllPackages] = useState<PackageType[]>([]);
|
import {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
useAllPackageOptionsQuery,
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
useDeleteSegmentMutation,
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
useProjectOptionsQuery,
|
||||||
const [error, setError] = useState<string | null>(null);
|
useSegmentsQuery,
|
||||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
} from './hooks/useSegmentQueries';
|
||||||
const [loadingPackages, setLoadingPackages] = useState(false);
|
|
||||||
|
|
||||||
// Pagination state
|
export default function SegmentPage() {
|
||||||
const [skip, setSkip] = useState(0);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [limit, setLimit] = useState(10);
|
const [segmentToDelete, setSegmentToDelete] = useState<Chainage | null>(null);
|
||||||
|
const { skip, setSkip, limit, setLimit } = useSegmentFilters();
|
||||||
|
const segmentsQuery = useSegmentsQuery({ skip, limit });
|
||||||
|
const projectsQuery = useProjectOptionsQuery();
|
||||||
|
const allPackagesQuery = useAllPackageOptionsQuery();
|
||||||
|
const deleteSegmentMutation = useDeleteSegmentMutation();
|
||||||
|
const segmentForm = useSegmentForm({
|
||||||
|
onSaved: () => setIsDialogOpen(false),
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
onSubmit,
|
||||||
|
openCreate: prepareCreateSegment,
|
||||||
|
openEdit: prepareEditSegment,
|
||||||
|
resetForm,
|
||||||
|
segmentId,
|
||||||
|
projectId,
|
||||||
|
setProjectId,
|
||||||
|
packageId,
|
||||||
|
setPackageId,
|
||||||
|
direction,
|
||||||
|
setDirection,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
|
canSubmit,
|
||||||
|
isSaving,
|
||||||
|
} = segmentForm;
|
||||||
|
const openCreate = useCallback(() => {
|
||||||
|
prepareCreateSegment();
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}, [prepareCreateSegment]);
|
||||||
|
|
||||||
// Editing state
|
const openEdit = useCallback(
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
(segment: Chainage) => {
|
||||||
const [currentChainage, setCurrentChainage] = useState<Chainage | null>(null);
|
const pkg = allPackagesQuery.data?.items.find(
|
||||||
|
(item) => item.id === segment.package_id,
|
||||||
|
);
|
||||||
|
prepareEditSegment(segment, pkg?.project_id || '');
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
},
|
||||||
|
[allPackagesQuery.data?.items, prepareEditSegment],
|
||||||
|
);
|
||||||
|
|
||||||
// Form fields
|
const handleDialogOpenChange = useCallback(
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState('');
|
(open: boolean) => {
|
||||||
const [selectedPackageId, setSelectedPackageId] = useState('');
|
setIsDialogOpen(open);
|
||||||
const [segmentName, setSegmentName] = useState('');
|
if (!open) {
|
||||||
const [chainageStartKm, setChainageStartKm] = useState('');
|
resetForm();
|
||||||
const [chainageEndKm, setChainageEndKm] = useState('');
|
|
||||||
const [startLat, setStartLat] = useState('');
|
|
||||||
const [startLng, setStartLng] = useState('');
|
|
||||||
const [endLat, setEndLat] = useState('');
|
|
||||||
const [endLng, setEndLng] = useState('');
|
|
||||||
const [direction, setDirection] = useState<'UP' | 'DOWN'>('UP');
|
|
||||||
|
|
||||||
// Load chainages and projects
|
|
||||||
const loadChainages = async (currentSkip = skip, currentLimit = limit) => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const data = await chainageService.getChainages({ skip: currentSkip, limit: currentLimit });
|
|
||||||
setChainages(data.items);
|
|
||||||
setTotalItems(data.totalItems);
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to load segments. 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 });
|
|
||||||
setProjects(data.items);
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to load projects.');
|
|
||||||
} finally {
|
|
||||||
setLoadingProjects(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadAllPackages = async () => {
|
|
||||||
try {
|
|
||||||
const data = await packageService.getPackages({ skip: 0, limit: 1000 });
|
|
||||||
setAllPackages(data.items);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load all packages');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadChainages(skip, limit);
|
|
||||||
loadProjects();
|
|
||||||
loadAllPackages();
|
|
||||||
}, [skip, limit]);
|
|
||||||
|
|
||||||
// Load packages when project changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedProjectId) {
|
|
||||||
setPackages([]);
|
|
||||||
if (!isEditing) setSelectedPackageId('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadPackagesForProject = async () => {
|
|
||||||
try {
|
|
||||||
setLoadingPackages(true);
|
|
||||||
if (!isEditing) setSelectedPackageId('');
|
|
||||||
const data = await packageService.getPackagesByProject(selectedProjectId, {
|
|
||||||
skip: 0,
|
|
||||||
limit: 1000,
|
|
||||||
});
|
|
||||||
setPackages(data.items);
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to load packages for the selected project.');
|
|
||||||
} finally {
|
|
||||||
setLoadingPackages(false);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
loadPackagesForProject();
|
|
||||||
}, [selectedProjectId, isEditing]);
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setSelectedProjectId('');
|
|
||||||
setSelectedPackageId('');
|
|
||||||
setSegmentName('');
|
|
||||||
setChainageStartKm('');
|
|
||||||
setChainageEndKm('');
|
|
||||||
setStartLat('');
|
|
||||||
setStartLng('');
|
|
||||||
setEndLat('');
|
|
||||||
setEndLng('');
|
|
||||||
setDirection('UP');
|
|
||||||
setError(null);
|
|
||||||
setIsEditing(false);
|
|
||||||
setCurrentChainage(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!selectedPackageId && !isEditing) {
|
|
||||||
setError('Please select a project and package first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!segmentName.trim()) {
|
|
||||||
setError('Segment name is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!startLat || !startLng || !endLat || !endLng) {
|
|
||||||
setError('All GPS coordinates are required for segments');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parsedStartLat = parseFloat(startLat);
|
|
||||||
const parsedStartLng = parseFloat(startLng);
|
|
||||||
const parsedEndLat = parseFloat(endLat);
|
|
||||||
const parsedEndLng = parseFloat(endLng);
|
|
||||||
|
|
||||||
if (parsedStartLat < -90 || parsedStartLat > 90 || parsedEndLat < -90 || parsedEndLat > 90) {
|
|
||||||
setError('Latitude values must be between -90 and 90');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
parsedStartLng < -180 ||
|
|
||||||
parsedStartLng > 180 ||
|
|
||||||
parsedEndLng < -180 ||
|
|
||||||
parsedEndLng > 180
|
|
||||||
) {
|
|
||||||
setError('Longitude values must be between -180 and 180');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!chainageStartKm || !chainageEndKm) {
|
|
||||||
setError('Segment start and end values are required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isEditing && currentChainage) {
|
|
||||||
const data: ChainageUpdate = {
|
|
||||||
segment_name: segmentName.trim(),
|
|
||||||
chainage_start_km: parseFloat(chainageStartKm),
|
|
||||||
chainage_end_km: parseFloat(chainageEndKm),
|
|
||||||
start_lat: parsedStartLat,
|
|
||||||
start_lng: parsedStartLng,
|
|
||||||
end_lat: parsedEndLat,
|
|
||||||
end_lng: parsedEndLng,
|
|
||||||
direction: direction as 'UP' | 'DOWN',
|
|
||||||
};
|
|
||||||
await chainageService.updateChainage(currentChainage.id, data);
|
|
||||||
toast.success('Segment Updated', {
|
|
||||||
description: `${segmentName} has been updated successfully at ${new Date().toLocaleTimeString()}`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const data: ChainageCreate = {
|
|
||||||
package_id: selectedPackageId,
|
|
||||||
segment_name: segmentName.trim(),
|
|
||||||
chainage_start_km: parseFloat(chainageStartKm),
|
|
||||||
chainage_end_km: parseFloat(chainageEndKm),
|
|
||||||
start_lat: parsedStartLat,
|
|
||||||
start_lng: parsedStartLng,
|
|
||||||
end_lat: parsedEndLat,
|
|
||||||
end_lng: parsedEndLng,
|
|
||||||
direction: direction,
|
|
||||||
};
|
|
||||||
await chainageService.createChainage(data);
|
|
||||||
toast.success('Segment Created', {
|
|
||||||
description: `${segmentName} has been established successfully at ${new Date().toLocaleTimeString()}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh chainages list
|
|
||||||
await loadChainages();
|
|
||||||
|
|
||||||
// Close modal and reset form immediately
|
|
||||||
setIsModalOpen(false);
|
|
||||||
resetForm();
|
|
||||||
} catch (err) {
|
|
||||||
const message =
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: `Failed to ${isEditing ? 'update' : 'create'} segment`;
|
|
||||||
setError(message);
|
|
||||||
toast.error('Operation Failed', {
|
|
||||||
description: message,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (chainage: Chainage) => {
|
|
||||||
setIsEditing(true);
|
|
||||||
setCurrentChainage(chainage);
|
|
||||||
|
|
||||||
// Find project for this package
|
|
||||||
const pkg = allPackages.find((p) => p.id === chainage.package_id);
|
|
||||||
if (pkg) {
|
|
||||||
setSelectedProjectId(pkg.project_id);
|
|
||||||
setSelectedPackageId(chainage.package_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSegmentName(chainage.segment_name || '');
|
|
||||||
setChainageStartKm(chainage.chainage_start_km?.toString() || '');
|
|
||||||
setChainageEndKm(chainage.chainage_end_km?.toString() || '');
|
|
||||||
setStartLat(chainage.start_lat.toString());
|
|
||||||
setStartLng(chainage.start_lng.toString());
|
|
||||||
setEndLat(chainage.end_lat.toString());
|
|
||||||
setEndLng(chainage.end_lng.toString());
|
|
||||||
setDirection(chainage.direction);
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (chainage: Chainage) => {
|
|
||||||
if (!confirm(`Are you sure you want to delete segment "${chainage.segment_name}"?`)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
await chainageService.deleteChainage(chainage.id);
|
|
||||||
toast.success('Segment Deleted', {
|
|
||||||
description: `${chainage.segment_name} has been removed from the system.`,
|
|
||||||
});
|
|
||||||
await loadChainages();
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to delete segment');
|
|
||||||
toast.error('Deletion Failed', {
|
|
||||||
description: 'The segment could not be removed. Please try again.',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPackageName = (packageId: string) => {
|
|
||||||
return allPackages.find((p) => p.id === packageId)?.name || packageId;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isFormComplete =
|
|
||||||
selectedPackageId &&
|
|
||||||
segmentName.trim() &&
|
|
||||||
startLat &&
|
|
||||||
startLng &&
|
|
||||||
endLat &&
|
|
||||||
endLng &&
|
|
||||||
chainageStartKm &&
|
|
||||||
chainageEndKm;
|
|
||||||
|
|
||||||
const columns: ColumnDef<Chainage>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: 'segment_name',
|
|
||||||
header: 'Segment Name',
|
|
||||||
cell: ({ row }) => <div className="font-semibold">{row.original.segment_name}</div>,
|
|
||||||
},
|
},
|
||||||
{
|
[resetForm],
|
||||||
accessorKey: 'package_id',
|
);
|
||||||
header: 'Package',
|
|
||||||
cell: ({ row }) => getPackageName(row.original.package_id),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'project',
|
|
||||||
header: 'Project',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const chainage = row.original;
|
|
||||||
const pkg = allPackages.find((p) => p.id === chainage.package_id);
|
|
||||||
const project = projects.find((p) => p.id === pkg?.project_id);
|
|
||||||
return project?.name || '—';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'project_state',
|
|
||||||
header: 'Project State',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const chainage = row.original;
|
|
||||||
const pkg = allPackages.find((p) => p.id === chainage.package_id);
|
|
||||||
const project = projects.find((p) => p.id === pkg?.project_id);
|
|
||||||
if (!project?.state) return <span className="">—</span>;
|
|
||||||
|
|
||||||
const states = project.state
|
const deleteSegment = useCallback(
|
||||||
.split(',')
|
(segment: Chainage) => {
|
||||||
.map((s) => s.trim())
|
setSegmentToDelete(segment);
|
||||||
.filter(Boolean);
|
},
|
||||||
if (states.length === 0) return <span className="">—</span>;
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const firstState = states[0];
|
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
|
||||||
const remainingStates = states.slice(1);
|
if (!open) {
|
||||||
|
setSegmentToDelete(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
const confirmDeleteSegment = useCallback(() => {
|
||||||
<div className="flex items-center gap-1.5">
|
if (!segmentToDelete) return;
|
||||||
<Badge variant="secondary">{firstState}</Badge>
|
|
||||||
|
|
||||||
{remainingStates.length > 0 && (
|
deleteSegmentMutation.mutate(segmentToDelete, {
|
||||||
<Popover>
|
onSettled: () => setSegmentToDelete(null),
|
||||||
<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">
|
}, [deleteSegmentMutation, segmentToDelete]);
|
||||||
+{remainingStates.length}
|
|
||||||
</button>
|
const projects = projectsQuery.data?.items ?? [];
|
||||||
</PopoverTrigger>
|
const allPackages = allPackagesQuery.data?.items ?? [];
|
||||||
<PopoverContent className="w-auto p-2" align="start">
|
const segments = segmentsQuery.data?.items ?? [];
|
||||||
<div className="flex flex-col gap-1.5">
|
const total = segmentsQuery.data?.totalItems ?? 0;
|
||||||
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-0.5 px-1">
|
const columns = useSegmentColumns(projects, allPackages);
|
||||||
Other States
|
|
||||||
</p>
|
|
||||||
{remainingStates.map((item, idx) => (
|
|
||||||
<Badge key={idx} variant="secondary">
|
|
||||||
{item}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'chainage',
|
|
||||||
header: 'Segment Range (km)',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const chainage = 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 font-bold whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<Milestone className="h-3 w-3" />
|
|
||||||
{chainage.chainage_start_km} - {chainage.chainage_end_km}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{chainage.direction === 'UP' ? (
|
|
||||||
<ArrowUpCircle className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<ArrowDownCircle className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
{chainage.direction}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'start_gps',
|
|
||||||
header: 'Start GPS',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center gap-1.5 border-blue-500/50 text-blue-500 font-bold whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<MapPin className="h-3 w-3" />
|
|
||||||
{row.original.start_lat.toFixed(4)}, {row.original.start_lng.toFixed(4)}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'end_gps',
|
|
||||||
header: 'End GPS',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center gap-1.5 border-blue-500/50 text-blue-500 font-bold whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<MapPin className="h-3 w-3" />
|
|
||||||
{row.original.end_lat.toFixed(4)}, {row.original.end_lng.toFixed(4)}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="relative z-10">
|
<main className="relative z-10 space-y-5">
|
||||||
{/* Refined Header */}
|
<PageHeader
|
||||||
<div className="mb-8">
|
title="Segment Management"
|
||||||
<PageHeader
|
description="Manage road segments"
|
||||||
title="Segment Management"
|
icon={Milestone}
|
||||||
description="Manage road segments"
|
actions={
|
||||||
icon={Milestone}
|
<Button onClick={openCreate} size="sm">
|
||||||
actions={
|
<Plus />
|
||||||
<Button
|
Add Segment
|
||||||
onClick={() => {
|
</Button>
|
||||||
setIsEditing(false);
|
}
|
||||||
setIsModalOpen(true);
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Milestone className="mr-2 h-4 w-4" />
|
|
||||||
Add Segment
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Data Table */}
|
<SegmentTable
|
||||||
<div>
|
columns={columns}
|
||||||
<DataTable
|
segments={segments}
|
||||||
title="Segments"
|
isLoading={segmentsQuery.isLoading || deleteSegmentMutation.isPending}
|
||||||
data={chainages}
|
skip={skip}
|
||||||
columns={columns}
|
limit={limit}
|
||||||
onEdit={handleEdit}
|
total={total}
|
||||||
onDelete={handleDelete}
|
onPageChange={setSkip}
|
||||||
isLoading={isLoading}
|
onLimitChange={setLimit}
|
||||||
pagination={{
|
onEdit={openEdit}
|
||||||
skip,
|
onDelete={deleteSegment}
|
||||||
limit,
|
/>
|
||||||
totalItems,
|
|
||||||
onPageChange: setSkip,
|
|
||||||
onLimitChange: (newLimit) => {
|
|
||||||
setLimit(newLimit);
|
|
||||||
setSkip(0); // Reset skip when limit changes
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Modal Dialog */}
|
<SegmentDialog
|
||||||
<Dialog
|
open={isDialogOpen}
|
||||||
open={isModalOpen}
|
onOpenChange={handleDialogOpenChange}
|
||||||
onOpenChange={(open) => {
|
segmentId={segmentId}
|
||||||
if (!open) {
|
projectId={projectId}
|
||||||
setIsModalOpen(false);
|
onProjectChange={setProjectId}
|
||||||
resetForm();
|
packageId={packageId}
|
||||||
} else {
|
onPackageChange={setPackageId}
|
||||||
setIsModalOpen(true);
|
direction={direction}
|
||||||
}
|
onDirectionChange={setDirection}
|
||||||
}}
|
register={register}
|
||||||
|
errors={errors}
|
||||||
|
isSubmitted={isSubmitted}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
canSubmit={canSubmit}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={Boolean(segmentToDelete)}
|
||||||
|
onOpenChange={handleDeleteDialogOpenChange}
|
||||||
>
|
>
|
||||||
<DialogContent
|
<AlertDialogContent>
|
||||||
className="max-h-[90vh] overflow-y-auto sm:max-w-2xl"
|
<AlertDialogHeader>
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
<AlertDialogTitle>Delete segment?</AlertDialogTitle>
|
||||||
>
|
<AlertDialogDescription>
|
||||||
<DialogHeader className="gap-2">
|
This will permanently delete{' '}
|
||||||
<DialogTitle className="flex items-center gap-3 text-xl">
|
<span className="font-medium text-foreground">
|
||||||
<div className="p-2 rounded-lg bg-primary text-primary-foreground shadow-sm">
|
{segmentToDelete?.segment_name}
|
||||||
<Milestone className="h-5 w-5" />
|
</span>
|
||||||
</div>
|
. This action cannot be undone.
|
||||||
{isEditing ? 'Edit Segment' : 'Create Segment'}
|
</AlertDialogDescription>
|
||||||
</DialogTitle>
|
</AlertDialogHeader>
|
||||||
<DialogDescription className="text-sm">
|
<AlertDialogFooter>
|
||||||
{isEditing
|
<AlertDialogCancel disabled={deleteSegmentMutation.isPending}>
|
||||||
? 'Update the technical specifications for your road segment.'
|
Cancel
|
||||||
: 'Select a project & package, then provide the essential segment data.'}
|
</AlertDialogCancel>
|
||||||
</DialogDescription>
|
<AlertDialogAction
|
||||||
</DialogHeader>
|
variant="destructive"
|
||||||
|
disabled={deleteSegmentMutation.isPending}
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
onClick={confirmDeleteSegment}
|
||||||
{!isEditing && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{/* Step 1: Select Project */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="w-5 h-5 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 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-xs">Loading...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<SelectValue placeholder="Choose a project..." />
|
|
||||||
)}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{projects.map((project) => (
|
|
||||||
<SelectItem key={project.id} value={project.id} className="py-2.5">
|
|
||||||
<span className="font-semibold">{project.name}</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 2: Select Package */}
|
|
||||||
<div
|
|
||||||
className={`space-y-3 ${selectedProjectId ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div
|
|
||||||
className={`w-5 h-5 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">
|
|
||||||
Select Package
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={selectedPackageId}
|
|
||||||
onValueChange={setSelectedPackageId}
|
|
||||||
disabled={!selectedProjectId}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-11 bg-muted/10 border-border/40 focus:ring-primary/20">
|
|
||||||
{loadingPackages ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
|
||||||
<span className="text-muted-foreground text-xs">Loading...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<SelectValue
|
|
||||||
placeholder={
|
|
||||||
selectedProjectId ? 'Choose a package...' : 'Select project first'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{packages.map((pkg) => (
|
|
||||||
<SelectItem key={pkg.id} value={pkg.id} className="py-2.5">
|
|
||||||
<span className="font-semibold">{pkg.name}</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 3: Chainage Details */}
|
|
||||||
<div
|
|
||||||
className={`space-y-4 ${selectedPackageId || isEditing ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}
|
|
||||||
>
|
>
|
||||||
{!isEditing && (
|
Delete
|
||||||
<div className="flex items-center gap-2.5">
|
</AlertDialogAction>
|
||||||
<div
|
</AlertDialogFooter>
|
||||||
className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold uppercase ${selectedPackageId ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}
|
</AlertDialogContent>
|
||||||
>
|
</AlertDialog>
|
||||||
03
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] font-black text-muted-foreground uppercase tracking-widest">
|
|
||||||
Segment Information
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Segment Name & Chainage Row */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="segment"
|
|
||||||
className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Segment Name <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="segment"
|
|
||||||
value={segmentName}
|
|
||||||
onChange={(e) => setSegmentName(e.target.value)}
|
|
||||||
placeholder="e.g. Mumbai to Pune"
|
|
||||||
className="h-11 bg-muted/20 border-border/60"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="ch-start"
|
|
||||||
className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Start (km) <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="ch-start"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
min="0"
|
|
||||||
value={chainageStartKm}
|
|
||||||
onChange={(e) => setChainageStartKm(e.target.value)}
|
|
||||||
placeholder="0.0"
|
|
||||||
className="h-11 bg-muted/20 border-border/60 text-sm font-mono"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="ch-end"
|
|
||||||
className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
End (km) <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="ch-end"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
min="0"
|
|
||||||
value={chainageEndKm}
|
|
||||||
onChange={(e) => setChainageEndKm(e.target.value)}
|
|
||||||
placeholder="1.0"
|
|
||||||
className="h-11 bg-muted/20 border-border/60 text-sm font-mono"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Direction Row */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label
|
|
||||||
htmlFor="direction"
|
|
||||||
className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Direction <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Select value={direction} onValueChange={(val: 'UP' | 'DOWN') => setDirection(val)}>
|
|
||||||
<SelectTrigger className="h-11 bg-muted/20 border-border/60 focus:ring-primary/20">
|
|
||||||
<SelectValue placeholder="Select Direction" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="UP" className="py-2.5">
|
|
||||||
<span>UP</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="DOWN" className="py-2.5">
|
|
||||||
<span>DOWN</span>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* GPS Coordinates Grid */}
|
|
||||||
<div className="space-y-6 pt-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<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 COORDINATES
|
|
||||||
<span className="text-destructive">*</span>
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="s-lat"
|
|
||||||
className="text-[10px] font-bold text-muted-foreground uppercase opacity-70"
|
|
||||||
>
|
|
||||||
Latitude
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="s-lat"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
min="-90"
|
|
||||||
max="90"
|
|
||||||
value={startLat}
|
|
||||||
onChange={(e) => setStartLat(e.target.value)}
|
|
||||||
placeholder="-90 to 90"
|
|
||||||
className="h-10 bg-background/70 border-border/40 text-xs font-mono"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="s-lng"
|
|
||||||
className="text-[10px] font-bold text-muted-foreground uppercase opacity-70"
|
|
||||||
>
|
|
||||||
Longitude
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="s-lng"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
min="-180"
|
|
||||||
max="180"
|
|
||||||
value={startLng}
|
|
||||||
onChange={(e) => setStartLng(e.target.value)}
|
|
||||||
placeholder="-180 to 180"
|
|
||||||
className="h-10 bg-background/70 border-border/40 text-xs font-mono"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<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 COORDINATES
|
|
||||||
<span className="text-destructive">*</span>
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="e-lat"
|
|
||||||
className="text-[10px] font-bold text-muted-foreground uppercase opacity-70"
|
|
||||||
>
|
|
||||||
Latitude
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="e-lat"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
min="-90"
|
|
||||||
max="90"
|
|
||||||
value={endLat}
|
|
||||||
onChange={(e) => setEndLat(e.target.value)}
|
|
||||||
placeholder="-90 to 90"
|
|
||||||
className="h-10 bg-background/70 border-border/40 text-xs font-mono"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="e-lng"
|
|
||||||
className="text-[10px] font-bold text-muted-foreground uppercase opacity-70"
|
|
||||||
>
|
|
||||||
Longitude
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="e-lng"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
min="-180"
|
|
||||||
max="180"
|
|
||||||
value={endLng}
|
|
||||||
onChange={(e) => setEndLng(e.target.value)}
|
|
||||||
placeholder="-180 to 180"
|
|
||||||
className="h-10 bg-background/70 border-border/40 text-xs font-mono"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<div className="flex gap-4 pt-4">
|
|
||||||
<Button
|
|
||||||
className="flex-1"
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setIsModalOpen(false);
|
|
||||||
resetForm();
|
|
||||||
}}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isSubmitting || !isFormComplete} className="flex-1">
|
|
||||||
{isSubmitting ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
<span>{isEditing ? 'Updating...' : 'Creating...'}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isEditing ? (
|
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Milestone className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span>{isEditing ? 'Update Segment' : 'Create Segment'}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/app/(modules)/segment/queries/segmentKeys.ts
Normal file
9
src/app/(modules)/segment/queries/segmentKeys.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { PaginationParams } from '@/types';
|
||||||
|
|
||||||
|
export const segmentKeys = {
|
||||||
|
all: ['segments'] as const,
|
||||||
|
lists: () => [...segmentKeys.all, 'list'] as const,
|
||||||
|
list: (params: PaginationParams) => [...segmentKeys.lists(), params] as const,
|
||||||
|
details: () => [...segmentKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...segmentKeys.details(), id] as const,
|
||||||
|
};
|
||||||
@@ -2,12 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Edit, Trash2 } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
|
||||||
import { usePermissions } from '@/hooks/usePermissions';
|
|
||||||
import type { Tenant } from '@/types';
|
import type { Tenant } from '@/types';
|
||||||
|
|
||||||
function formatDate(value?: string | null) {
|
function formatDate(value?: string | null) {
|
||||||
@@ -19,30 +15,16 @@ function formatDate(value?: string | null) {
|
|||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseTenantColumnsParams {
|
export function useTenantColumns(): ColumnDef<Tenant>[] {
|
||||||
onEdit: (tenant: Tenant) => void;
|
|
||||||
onDelete: (tenant: Tenant) => void;
|
|
||||||
pendingDeleteId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTenantColumns({
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
pendingDeleteId,
|
|
||||||
}: UseTenantColumnsParams): ColumnDef<Tenant>[] {
|
|
||||||
const { hasPermission } = usePermissions();
|
|
||||||
const canEdit = hasPermission(PERMISSIONS.TENANT.UPDATE);
|
|
||||||
const canDelete = hasPermission(PERMISSIONS.TENANT.DELETE);
|
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const columns: ColumnDef<Tenant>[] = [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Tenant',
|
header: 'Tenant',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{row.original.name}</p>
|
<p>{row.original.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{row.original.slug}</p>
|
<p className="text-muted-foreground">{row.original.slug}</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -76,36 +58,5 @@ export function useTenantColumns({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}, []);
|
||||||
if (!canEdit && !canDelete) return columns;
|
|
||||||
|
|
||||||
columns.push({
|
|
||||||
id: 'actions',
|
|
||||||
header: () => <div className="text-right">Actions</div>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const tenant = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{canEdit ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onEdit(tenant)}>
|
|
||||||
<Edit className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{canDelete ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pendingDeleteId === tenant.id}
|
|
||||||
onClick={() => onDelete(tenant)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
}, [canDelete, canEdit, onDelete, onEdit, pendingDeleteId]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { TableSearchInput } from '@/components/data-table/TableSearchInput';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -25,11 +25,10 @@ export function TenantFilters({
|
|||||||
}: TenantFiltersProps) {
|
}: TenantFiltersProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<Input
|
<TableSearchInput
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(event) => onSearchChange(event.target.value)}
|
onChange={onSearchChange}
|
||||||
placeholder="Search tenants"
|
placeholder="Search tenants"
|
||||||
className="md:max-w-sm"
|
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import { useMemo, useRef, type ComponentProps } from 'react';
|
||||||
import type { UseFormRegister } from 'react-hook-form';
|
import {
|
||||||
|
Controller,
|
||||||
|
type Control,
|
||||||
|
type FieldErrors,
|
||||||
|
type UseFormRegister,
|
||||||
|
} from 'react-hook-form';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { FormField, PhoneInput, SelectPopover } from '@/components/form';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
@@ -14,17 +21,12 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import type { Client, Plan } from '@/types';
|
import type { Client, Plan } from '@/types';
|
||||||
|
|
||||||
import type { TenantFormValues } from '../hooks/useTenantForm';
|
import type {
|
||||||
|
TenantAdminFieldLocks,
|
||||||
|
TenantFormValues,
|
||||||
|
} from '../hooks/useTenantForm';
|
||||||
|
|
||||||
interface TenantSheetProps {
|
interface TenantSheetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -32,7 +34,11 @@ interface TenantSheetProps {
|
|||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
register: UseFormRegister<TenantFormValues>;
|
register: UseFormRegister<TenantFormValues>;
|
||||||
|
control: Control<TenantFormValues>;
|
||||||
|
errors: FieldErrors<TenantFormValues>;
|
||||||
|
isSubmitted: boolean;
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
|
canSubmit: boolean;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
planId: string;
|
planId: string;
|
||||||
onClientChange: (clientId: string) => void;
|
onClientChange: (clientId: string) => void;
|
||||||
@@ -43,6 +49,7 @@ interface TenantSheetProps {
|
|||||||
isLookupsLoading: boolean;
|
isLookupsLoading: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
adminEmail?: string;
|
adminEmail?: string;
|
||||||
|
adminFieldLocks: TenantAdminFieldLocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TenantSheet({
|
export function TenantSheet({
|
||||||
@@ -51,7 +58,11 @@ export function TenantSheet({
|
|||||||
tenantId,
|
tenantId,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
canSubmit,
|
||||||
clientId,
|
clientId,
|
||||||
planId,
|
planId,
|
||||||
onClientChange,
|
onClientChange,
|
||||||
@@ -62,157 +73,259 @@ export function TenantSheet({
|
|||||||
isLookupsLoading,
|
isLookupsLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
adminEmail,
|
adminEmail,
|
||||||
|
adminFieldLocks,
|
||||||
}: TenantSheetProps) {
|
}: TenantSheetProps) {
|
||||||
|
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const fieldError = (field: keyof TenantFormValues) =>
|
||||||
|
isSubmitted ? errors[field]?.message : undefined;
|
||||||
|
|
||||||
|
const clientOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
clients.map((client) => ({
|
||||||
|
value: String(client.id),
|
||||||
|
label: [client.first_name, client.last_name].filter(Boolean).join(' '),
|
||||||
|
})),
|
||||||
|
[clients],
|
||||||
|
);
|
||||||
|
|
||||||
|
const planOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
plans.map((plan) => ({
|
||||||
|
value: String(plan.id),
|
||||||
|
label: plan.name,
|
||||||
|
})),
|
||||||
|
[plans],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
|
<DialogContent className="sm:max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{tenantId ? 'Edit Tenant' : 'Create Tenant'}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{tenantId ? 'Edit Tenant' : 'Create Tenant'}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Bind a client and subscription plan, then invite the tenant administrator.
|
Bind a client and subscription plan, then invite the tenant
|
||||||
|
administrator.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium">Basic Information</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Tenant identity and subscription binding.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<form
|
||||||
<div className="space-y-2">
|
noValidate
|
||||||
<Label htmlFor="tenant-name">Tenant Name</Label>
|
onSubmit={onSubmit}
|
||||||
<Input
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
id="tenant-name"
|
>
|
||||||
placeholder="Acme Corp"
|
<DialogBody className="space-y-5">
|
||||||
{...register('name', {
|
<div className="space-y-1">
|
||||||
required: true,
|
<p>Basic Information</p>
|
||||||
onChange: (event) => onNameChange(event.target.value),
|
<p className="text-muted-foreground">
|
||||||
})}
|
Tenant identity and subscription binding.
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="tenant-slug">Slug</Label>
|
|
||||||
<Input
|
|
||||||
id="tenant-slug"
|
|
||||||
placeholder="acme-corp"
|
|
||||||
{...register('slug', { required: true })}
|
|
||||||
required
|
|
||||||
readOnly={isEditMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Client</Label>
|
|
||||||
<Select value={clientId} onValueChange={onClientChange} disabled={isLookupsLoading}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={isLookupsLoading ? 'Loading clients...' : 'Select client'} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{clients.map((client) => (
|
|
||||||
<SelectItem key={client.id} value={String(client.id)}>
|
|
||||||
{client.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Subscription Plan</Label>
|
|
||||||
<Select value={planId} onValueChange={onPlanChange} disabled={isLookupsLoading}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={isLookupsLoading ? 'Loading plans...' : 'Select plan'} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{plans.map((plan) => (
|
|
||||||
<SelectItem key={plan.id} value={String(plan.id)}>
|
|
||||||
{plan.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="tenant-domain">Domain</Label>
|
|
||||||
<Input id="tenant-domain" placeholder="acme.example.com" {...register('domain')} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label htmlFor="tenant-description">Description</Label>
|
|
||||||
<textarea
|
|
||||||
id="tenant-description"
|
|
||||||
placeholder="North zone operations"
|
|
||||||
{...register('description')}
|
|
||||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isEditMode ? (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium">Tenant Administrator</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Invitation details for the primary tenant admin account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="admin-first-name">First Name</Label>
|
|
||||||
<Input
|
|
||||||
id="admin-first-name"
|
|
||||||
placeholder="Jane"
|
|
||||||
{...register('admin_first_name', { required: !isEditMode })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="admin-last-name">Last Name</Label>
|
|
||||||
<Input
|
|
||||||
id="admin-last-name"
|
|
||||||
placeholder="Doe"
|
|
||||||
{...register('admin_last_name', { required: !isEditMode })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="admin-email">Admin Email</Label>
|
|
||||||
<Input
|
|
||||||
id="admin-email"
|
|
||||||
type="email"
|
|
||||||
placeholder="jane@acme.com"
|
|
||||||
{...register('admin_email', { required: !isEditMode })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="admin-phone">Phone Number</Label>
|
|
||||||
<Input
|
|
||||||
id="admin-phone"
|
|
||||||
placeholder="+91-9000011111"
|
|
||||||
{...register('admin_phone_number')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : adminEmail ? (
|
|
||||||
<div className="rounded-md border bg-muted/40 p-4 text-sm text-muted-foreground">
|
|
||||||
Admin invitation details cannot be changed after tenant creation.
|
|
||||||
<p className="mt-1 text-foreground">
|
|
||||||
Current admin email: <span className="font-medium">{adminEmail}</span>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<DialogFooter className="px-0">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
id="tenant-name"
|
||||||
|
label="Tenant Name"
|
||||||
|
required
|
||||||
|
error={fieldError('name')}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="tenant-name"
|
||||||
|
placeholder="Enter tenant name"
|
||||||
|
aria-invalid={!!fieldError('name')}
|
||||||
|
{...register('name', {
|
||||||
|
onChange: (event) => onNameChange(event.target.value),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="tenant-slug"
|
||||||
|
label="Slug"
|
||||||
|
required
|
||||||
|
error={fieldError('slug')}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="tenant-slug"
|
||||||
|
placeholder="acme-corp"
|
||||||
|
aria-invalid={!!fieldError('slug')}
|
||||||
|
{...register('slug')}
|
||||||
|
readOnly={isEditMode}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
id="tenant-client"
|
||||||
|
label="Client"
|
||||||
|
required
|
||||||
|
error={fieldError('client_id')}
|
||||||
|
>
|
||||||
|
<SelectPopover
|
||||||
|
options={clientOptions}
|
||||||
|
value={clientId}
|
||||||
|
onValueChange={onClientChange}
|
||||||
|
disabled={isLookupsLoading}
|
||||||
|
portalContainer={dropdownPortalRef}
|
||||||
|
placeholder={
|
||||||
|
isLookupsLoading ? 'Loading clients...' : 'Select client'
|
||||||
|
}
|
||||||
|
searchPlaceholder="Search clients"
|
||||||
|
emptyMessage="No clients found."
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
{...register('client_id')}
|
||||||
|
value={clientId}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="tenant-plan"
|
||||||
|
label="Subscription Plan"
|
||||||
|
required
|
||||||
|
error={fieldError('plan_id')}
|
||||||
|
>
|
||||||
|
<SelectPopover
|
||||||
|
options={planOptions}
|
||||||
|
value={planId}
|
||||||
|
onValueChange={onPlanChange}
|
||||||
|
disabled={isLookupsLoading}
|
||||||
|
portalContainer={dropdownPortalRef}
|
||||||
|
placeholder={
|
||||||
|
isLookupsLoading ? 'Loading plans...' : 'Select plan'
|
||||||
|
}
|
||||||
|
searchPlaceholder="Search plans"
|
||||||
|
emptyMessage="No plans found."
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
{...register('plan_id')}
|
||||||
|
value={planId}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<FormField id="tenant-domain" label="Domain">
|
||||||
|
<Input
|
||||||
|
id="tenant-domain"
|
||||||
|
placeholder="acme.example.com"
|
||||||
|
{...register('domain')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="tenant-description"
|
||||||
|
label="Description"
|
||||||
|
className="md:col-span-2"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
id="tenant-description"
|
||||||
|
placeholder="North zone operations"
|
||||||
|
{...register('description')}
|
||||||
|
className="min-h-20 w-full rounded-lg border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isEditMode ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p>Tenant Administrator</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Invitation details for the primary tenant admin account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
id="admin-first-name"
|
||||||
|
label="First Name"
|
||||||
|
required
|
||||||
|
error={fieldError('admin_first_name')}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="admin-first-name"
|
||||||
|
placeholder="Enter first name"
|
||||||
|
aria-invalid={!!fieldError('admin_first_name')}
|
||||||
|
readOnly={adminFieldLocks.admin_first_name}
|
||||||
|
{...register('admin_first_name')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="admin-last-name"
|
||||||
|
label="Last Name"
|
||||||
|
required
|
||||||
|
error={fieldError('admin_last_name')}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="admin-last-name"
|
||||||
|
placeholder="Enter last name"
|
||||||
|
aria-invalid={!!fieldError('admin_last_name')}
|
||||||
|
readOnly={adminFieldLocks.admin_last_name}
|
||||||
|
{...register('admin_last_name')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
id="admin-email"
|
||||||
|
label="Admin Email"
|
||||||
|
required
|
||||||
|
error={fieldError('admin_email')}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="admin-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
aria-invalid={!!fieldError('admin_email')}
|
||||||
|
readOnly={adminFieldLocks.admin_email}
|
||||||
|
{...register('admin_email')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="admin-phone"
|
||||||
|
label="Phone Number"
|
||||||
|
error={fieldError('admin_phone_number')}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="admin_phone_number"
|
||||||
|
render={({ field }) => (
|
||||||
|
<PhoneInput
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="Enter phone number"
|
||||||
|
aria-invalid={!!fieldError('admin_phone_number')}
|
||||||
|
disabled={adminFieldLocks.admin_phone_number}
|
||||||
|
portalContainer={dropdownPortalRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : adminEmail ? (
|
||||||
|
<div className="rounded-lg border bg-muted/40 p-4 text-muted-foreground">
|
||||||
|
Admin invitation details cannot be changed after tenant
|
||||||
|
creation.
|
||||||
|
<p className="mt-1 text-foreground">
|
||||||
|
Current admin email: <span>{adminEmail}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -221,12 +334,19 @@ export function TenantSheet({
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSaving || isLookupsLoading}>
|
<Button
|
||||||
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
type="submit"
|
||||||
|
disabled={isSaving || isLookupsLoading || !canSubmit}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
{tenantId ? 'Update Tenant' : 'Create Tenant'}
|
{tenantId ? 'Update Tenant' : 'Create Tenant'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div ref={dropdownPortalRef} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
import { Edit, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import type { Tenant } from '@/types';
|
import type { Tenant } from '@/types';
|
||||||
|
|
||||||
interface TenantTableProps {
|
interface TenantTableProps {
|
||||||
@@ -14,7 +16,13 @@ interface TenantTableProps {
|
|||||||
skip: number;
|
skip: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
sorting: SortingState;
|
||||||
onPageChange: (skip: number) => void;
|
onPageChange: (skip: number) => void;
|
||||||
|
onLimitChange: (limit: number) => void;
|
||||||
|
onSortingChange: (sorting: SortingState) => void;
|
||||||
|
onEdit: (tenant: Tenant) => void;
|
||||||
|
onDelete: (tenant: Tenant) => void;
|
||||||
|
pendingDeleteId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TenantTable({
|
export function TenantTable({
|
||||||
@@ -25,7 +33,13 @@ export function TenantTable({
|
|||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
|
sorting,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
onSortingChange,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
pendingDeleteId,
|
||||||
}: TenantTableProps) {
|
}: TenantTableProps) {
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -35,13 +49,32 @@ export function TenantTable({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
toolbar={toolbar}
|
toolbar={toolbar}
|
||||||
emptyTitle="No tenants found."
|
emptyTitle="No tenants found."
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit className="size-4" />,
|
||||||
|
permission: PERMISSIONS.TENANT.UPDATE,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
icon: <Trash2 className="size-4" />,
|
||||||
|
permission: PERMISSIONS.TENANT.DELETE,
|
||||||
|
className: 'text-destructive',
|
||||||
|
disabled: (tenant) => pendingDeleteId === tenant.id,
|
||||||
|
onClick: onDelete,
|
||||||
|
},
|
||||||
|
]}
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
totalItems: total,
|
totalItems: total,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange: () => undefined,
|
onLimitChange,
|
||||||
}}
|
}}
|
||||||
|
sorting={sorting}
|
||||||
|
onSortingChange={onSortingChange}
|
||||||
|
actionsSticky
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
import type { SortingState } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { useDebounce } from '@/hooks/useDebounce';
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
|
|
||||||
@@ -8,9 +9,11 @@ export type TenantStatusFilter = 'all' | 'active' | 'inactive';
|
|||||||
|
|
||||||
export function useTenantFilters() {
|
export function useTenantFilters() {
|
||||||
const [skip, setSkip] = useState(0);
|
const [skip, setSkip] = useState(0);
|
||||||
const [limit] = useState(10);
|
const [limit, setLimitValue] = useState(10);
|
||||||
|
const [sorting, setSortingValue] = useState<SortingState>([]);
|
||||||
const [searchTerm, setSearchTermValue] = useState('');
|
const [searchTerm, setSearchTermValue] = useState('');
|
||||||
const [statusFilter, setStatusFilterValue] = useState<TenantStatusFilter>('all');
|
const [statusFilter, setStatusFilterValue] =
|
||||||
|
useState<TenantStatusFilter>('all');
|
||||||
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
|
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
|
||||||
|
|
||||||
const setSearchTerm = useCallback((value: string) => {
|
const setSearchTerm = useCallback((value: string) => {
|
||||||
@@ -23,10 +26,23 @@ export function useTenantFilters() {
|
|||||||
setSkip(0);
|
setSkip(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setLimit = useCallback((value: number) => {
|
||||||
|
setLimitValue(value);
|
||||||
|
setSkip(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSorting = useCallback((value: SortingState) => {
|
||||||
|
setSortingValue(value);
|
||||||
|
setSkip(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
skip,
|
skip,
|
||||||
setSkip,
|
setSkip,
|
||||||
limit,
|
limit,
|
||||||
|
setLimit,
|
||||||
|
sorting,
|
||||||
|
setSorting,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
debouncedSearchTerm,
|
debouncedSearchTerm,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
|
|||||||
@@ -1,26 +1,74 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { Tenant } from '@/types';
|
import type { Tenant } from '@/types';
|
||||||
|
|
||||||
import { useSaveTenantMutation } from './useTenantMutations';
|
import { useSaveTenantMutation } from './useTenantMutations';
|
||||||
|
|
||||||
export interface TenantFormValues {
|
const requiredText = (message: string) => z.string().trim().min(1, message);
|
||||||
id?: number;
|
|
||||||
name: string;
|
const tenantFormSchema = z
|
||||||
slug: string;
|
.object({
|
||||||
client_id: string;
|
id: z.number().optional(),
|
||||||
plan_id: string;
|
name: requiredText('Tenant name is required'),
|
||||||
domain: string;
|
slug: requiredText('Slug is required'),
|
||||||
description: string;
|
client_id: requiredText('Client is required'),
|
||||||
admin_first_name: string;
|
plan_id: requiredText('Plan is required'),
|
||||||
admin_last_name: string;
|
domain: z.string().trim(),
|
||||||
admin_email: string;
|
description: z.string().trim(),
|
||||||
admin_phone_number: string;
|
admin_first_name: z.string().trim(),
|
||||||
}
|
admin_last_name: z.string().trim(),
|
||||||
|
admin_email: z.string().trim(),
|
||||||
|
admin_phone_number: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((value) => value === '' || isValidPhoneNumber(value), {
|
||||||
|
message: 'Enter a valid phone number',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.superRefine((values, ctx) => {
|
||||||
|
if (values.id) return;
|
||||||
|
|
||||||
|
if (!values.admin_first_name.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['admin_first_name'],
|
||||||
|
message: 'First name is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.admin_last_name.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['admin_last_name'],
|
||||||
|
message: 'Last name is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.admin_email.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['admin_email'],
|
||||||
|
message: 'Admin email is required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!z.string().email().safeParse(values.admin_email).success) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['admin_email'],
|
||||||
|
message: 'Invalid admin email',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TenantFormValues = z.infer<typeof tenantFormSchema>;
|
||||||
|
|
||||||
const defaultValues: TenantFormValues = {
|
const defaultValues: TenantFormValues = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -50,6 +98,20 @@ function splitContactName(contactName: string) {
|
|||||||
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
|
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TenantAdminFieldLocks = {
|
||||||
|
admin_first_name: boolean;
|
||||||
|
admin_last_name: boolean;
|
||||||
|
admin_email: boolean;
|
||||||
|
admin_phone_number: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultAdminFieldLocks: TenantAdminFieldLocks = {
|
||||||
|
admin_first_name: false,
|
||||||
|
admin_last_name: false,
|
||||||
|
admin_email: false,
|
||||||
|
admin_phone_number: false,
|
||||||
|
};
|
||||||
|
|
||||||
export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -57,44 +119,49 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
reset,
|
reset,
|
||||||
control,
|
control,
|
||||||
setValue,
|
setValue,
|
||||||
formState: { isSubmitting },
|
formState: { errors, isSubmitting, isSubmitted },
|
||||||
} = useForm<TenantFormValues>({
|
} = useForm<TenantFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
|
mode: 'onSubmit',
|
||||||
|
reValidateMode: 'onChange',
|
||||||
|
resolver: zodResolver(tenantFormSchema),
|
||||||
});
|
});
|
||||||
const saveMutation = useSaveTenantMutation({
|
const saveMutation = useSaveTenantMutation({
|
||||||
onSaved: () => {
|
onSaved: () => {
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
|
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||||
onSaved();
|
onSaved();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [adminFieldLocks, setAdminFieldLocks] = useState<TenantAdminFieldLocks>(
|
||||||
|
defaultAdminFieldLocks,
|
||||||
|
);
|
||||||
|
|
||||||
const tenantId = useWatch({ control, name: 'id' });
|
const tenantId = useWatch({ control, name: 'id' });
|
||||||
const clientId = useWatch({ control, name: 'client_id' }) || '';
|
const clientId = useWatch({ control, name: 'client_id' }) || '';
|
||||||
const planId = useWatch({ control, name: 'plan_id' }) || '';
|
const planId = useWatch({ control, name: 'plan_id' }) || '';
|
||||||
|
const name = useWatch({ control, name: 'name' }) || '';
|
||||||
|
const slug = useWatch({ control, name: 'slug' }) || '';
|
||||||
|
const adminFirstName = useWatch({ control, name: 'admin_first_name' }) || '';
|
||||||
|
const adminLastName = useWatch({ control, name: 'admin_last_name' }) || '';
|
||||||
const adminEmail = useWatch({ control, name: 'admin_email' }) || '';
|
const adminEmail = useWatch({ control, name: 'admin_email' }) || '';
|
||||||
const isEditMode = Boolean(tenantId);
|
const isEditMode = Boolean(tenantId);
|
||||||
|
const canSubmit =
|
||||||
|
name.trim().length > 0 &&
|
||||||
|
slug.trim().length > 0 &&
|
||||||
|
clientId.trim().length > 0 &&
|
||||||
|
planId.trim().length > 0 &&
|
||||||
|
(isEditMode ||
|
||||||
|
(adminFirstName.trim().length > 0 &&
|
||||||
|
adminLastName.trim().length > 0 &&
|
||||||
|
adminEmail.trim().length > 0));
|
||||||
|
|
||||||
const handleSubmit = submitForm(
|
const handleSubmit = submitForm((values) => saveMutation.mutate(values));
|
||||||
(values) => {
|
|
||||||
if (!values.client_id || !values.plan_id) {
|
|
||||||
toast.error('Select a client and plan');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isEditMode) {
|
|
||||||
if (!values.admin_first_name.trim() || !values.admin_last_name.trim() || !values.admin_email.trim()) {
|
|
||||||
toast.error('Complete all required admin fields');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveMutation.mutate(values);
|
|
||||||
},
|
|
||||||
() => toast.error('Complete all required tenant fields'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
|
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||||
}, [reset]);
|
}, [reset]);
|
||||||
|
|
||||||
const openEdit = useCallback(
|
const openEdit = useCallback(
|
||||||
@@ -112,19 +179,37 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
admin_email: tenant.admin_email || '',
|
admin_email: tenant.admin_email || '',
|
||||||
admin_phone_number: '',
|
admin_phone_number: '',
|
||||||
});
|
});
|
||||||
|
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||||
},
|
},
|
||||||
[reset],
|
[reset],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clearAdminFields = useCallback(() => {
|
||||||
|
setValue('admin_first_name', '', { shouldDirty: true });
|
||||||
|
setValue('admin_last_name', '', { shouldDirty: true });
|
||||||
|
setValue('admin_email', '', { shouldDirty: true });
|
||||||
|
setValue('admin_phone_number', '', { shouldDirty: true });
|
||||||
|
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||||
|
}, [setValue]);
|
||||||
|
|
||||||
const applyClientAdminDefaults = useCallback(
|
const applyClientAdminDefaults = useCallback(
|
||||||
(contactName: string, contactEmail: string, contactPhone: string) => {
|
(contactName: string, contactEmail: string, contactPhone: string) => {
|
||||||
if (isEditMode) return;
|
if (isEditMode) return;
|
||||||
|
|
||||||
const { firstName, lastName } = splitContactName(contactName);
|
const { firstName, lastName } = splitContactName(contactName);
|
||||||
|
const trimmedEmail = contactEmail.trim();
|
||||||
|
const trimmedPhone = contactPhone.trim();
|
||||||
|
|
||||||
setValue('admin_first_name', firstName, { shouldDirty: true });
|
setValue('admin_first_name', firstName, { shouldDirty: true });
|
||||||
setValue('admin_last_name', lastName, { shouldDirty: true });
|
setValue('admin_last_name', lastName, { shouldDirty: true });
|
||||||
setValue('admin_email', contactEmail, { shouldDirty: true });
|
setValue('admin_email', trimmedEmail, { shouldDirty: true });
|
||||||
setValue('admin_phone_number', contactPhone, { shouldDirty: true });
|
setValue('admin_phone_number', trimmedPhone, { shouldDirty: true });
|
||||||
|
setAdminFieldLocks({
|
||||||
|
admin_first_name: firstName.trim().length > 0,
|
||||||
|
admin_last_name: lastName.trim().length > 0,
|
||||||
|
admin_email: trimmedEmail.length > 0,
|
||||||
|
admin_phone_number: trimmedPhone.length > 0,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[isEditMode, setValue],
|
[isEditMode, setValue],
|
||||||
);
|
);
|
||||||
@@ -139,9 +224,9 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
control,
|
|
||||||
setValue,
|
setValue,
|
||||||
openCreate,
|
openCreate,
|
||||||
openEdit,
|
openEdit,
|
||||||
@@ -150,7 +235,12 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
planId,
|
planId,
|
||||||
adminEmail,
|
adminEmail,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
|
canSubmit,
|
||||||
|
adminFieldLocks,
|
||||||
applyClientAdminDefaults,
|
applyClientAdminDefaults,
|
||||||
|
clearAdminFields,
|
||||||
syncSlugFromName,
|
syncSlugFromName,
|
||||||
isSaving: isSubmitting || saveMutation.isPending,
|
isSaving: isSubmitting || saveMutation.isPending,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ export function useSaveTenantMutation({ onSaved }: { onSaved: () => void }) {
|
|||||||
onSaved();
|
onSaved();
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
onError: (_error, values) => {
|
||||||
toast.error(values.id ? 'Failed to update tenant' : 'Failed to create tenant');
|
toast.error(
|
||||||
|
values.id ? 'Failed to update tenant' : 'Failed to create tenant',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { SortingState } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { getServerSortParams } from '@/components/data-table/sorting';
|
||||||
import { clientService, planService, tenantService } from '@/services/api';
|
import { clientService, planService, tenantService } from '@/services/api';
|
||||||
import type { TenantListParams } from '@/types';
|
import type { TenantListParams } from '@/types';
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ interface UseTenantsQueryParams {
|
|||||||
limit: number;
|
limit: number;
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
statusFilter: TenantStatusFilter;
|
statusFilter: TenantStatusFilter;
|
||||||
|
sorting: SortingState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientLookupParams = {
|
const clientLookupParams = {
|
||||||
@@ -37,14 +40,14 @@ function buildTenantListParams({
|
|||||||
limit,
|
limit,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
sorting,
|
||||||
}: UseTenantsQueryParams): TenantListParams {
|
}: UseTenantsQueryParams): TenantListParams {
|
||||||
return {
|
return {
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
search_term: searchTerm || undefined,
|
search_term: searchTerm || undefined,
|
||||||
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
|
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
|
||||||
sort_by: 'created_at',
|
...getServerSortParams(sorting),
|
||||||
sort_order: 'desc',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
src/app/(modules)/tenants/layout.tsx
Normal file
16
src/app/(modules)/tenants/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function TenantsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.TENANT.READ}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { useCallback, useMemo, useState } from 'react';
|
|||||||
import { Globe, Plus } from 'lucide-react';
|
import { Globe, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import { PermissionGuard } from '@/guards';
|
import { PermissionGuard } from '@/guards';
|
||||||
@@ -30,6 +29,9 @@ export default function TenantsPage() {
|
|||||||
skip,
|
skip,
|
||||||
setSkip,
|
setSkip,
|
||||||
limit,
|
limit,
|
||||||
|
setLimit,
|
||||||
|
sorting,
|
||||||
|
setSorting,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
debouncedSearchTerm,
|
debouncedSearchTerm,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
@@ -42,6 +44,7 @@ export default function TenantsPage() {
|
|||||||
limit,
|
limit,
|
||||||
searchTerm: debouncedSearchTerm,
|
searchTerm: debouncedSearchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
sorting,
|
||||||
});
|
});
|
||||||
const clientsLookupQuery = useClientLookupQuery(isSheetOpen);
|
const clientsLookupQuery = useClientLookupQuery(isSheetOpen);
|
||||||
const plansLookupQuery = usePlanLookupQuery(isSheetOpen);
|
const plansLookupQuery = usePlanLookupQuery(isSheetOpen);
|
||||||
@@ -51,6 +54,7 @@ export default function TenantsPage() {
|
|||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
openCreate: prepareCreateTenant,
|
openCreate: prepareCreateTenant,
|
||||||
openEdit: prepareEditTenant,
|
openEdit: prepareEditTenant,
|
||||||
@@ -59,7 +63,12 @@ export default function TenantsPage() {
|
|||||||
planId,
|
planId,
|
||||||
adminEmail,
|
adminEmail,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
|
canSubmit,
|
||||||
|
adminFieldLocks,
|
||||||
applyClientAdminDefaults,
|
applyClientAdminDefaults,
|
||||||
|
clearAdminFields,
|
||||||
syncSlugFromName,
|
syncSlugFromName,
|
||||||
setValue,
|
setValue,
|
||||||
isSaving,
|
isSaving,
|
||||||
@@ -71,7 +80,11 @@ export default function TenantsPage() {
|
|||||||
(value: string) => {
|
(value: string) => {
|
||||||
setValue('client_id', value, { shouldDirty: true, shouldValidate: true });
|
setValue('client_id', value, { shouldDirty: true, shouldValidate: true });
|
||||||
|
|
||||||
if (!value || isEditMode) return;
|
if (isEditMode) return;
|
||||||
|
|
||||||
|
clearAdminFields();
|
||||||
|
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
clientService.getClientById(Number(value)).then((client) => {
|
clientService.getClientById(Number(value)).then((client) => {
|
||||||
applyClientAdminDefaults(
|
applyClientAdminDefaults(
|
||||||
@@ -81,7 +94,7 @@ export default function TenantsPage() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[applyClientAdminDefaults, isEditMode, setValue],
|
[applyClientAdminDefaults, clearAdminFields, isEditMode, setValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePlanChange = useCallback(
|
const handlePlanChange = useCallback(
|
||||||
@@ -113,17 +126,14 @@ export default function TenantsPage() {
|
|||||||
[deleteMutation],
|
[deleteMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useTenantColumns({
|
const columns = useTenantColumns();
|
||||||
onEdit: openEdit,
|
|
||||||
onDelete: handleDelete,
|
|
||||||
pendingDeleteId: deleteMutation.isPending ? deleteMutation.variables : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tenants = tenantsQuery.data?.items ?? [];
|
const tenants = tenantsQuery.data?.items ?? [];
|
||||||
const total = tenantsQuery.data?.total ?? 0;
|
const total = tenantsQuery.data?.total ?? 0;
|
||||||
const clients = clientsLookupQuery.data?.items ?? [];
|
const clients = clientsLookupQuery.data?.items ?? [];
|
||||||
const plans = plansLookupQuery.data?.items ?? [];
|
const plans = plansLookupQuery.data?.items ?? [];
|
||||||
const isLookupsLoading = clientsLookupQuery.isLoading || plansLookupQuery.isLoading;
|
const isLookupsLoading =
|
||||||
|
clientsLookupQuery.isLoading || plansLookupQuery.isLoading;
|
||||||
|
|
||||||
const toolbar = useMemo(
|
const toolbar = useMemo(
|
||||||
() => (
|
() => (
|
||||||
@@ -162,10 +172,16 @@ export default function TenantsPage() {
|
|||||||
skip={skip}
|
skip={skip}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
total={total}
|
total={total}
|
||||||
|
sorting={sorting}
|
||||||
onPageChange={setSkip}
|
onPageChange={setSkip}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
onSortingChange={setSorting}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
pendingDeleteId={
|
||||||
|
deleteMutation.isPending ? deleteMutation.variables : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<TenantSheet
|
<TenantSheet
|
||||||
@@ -174,7 +190,11 @@ export default function TenantsPage() {
|
|||||||
tenantId={tenantId}
|
tenantId={tenantId}
|
||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
register={register}
|
register={register}
|
||||||
|
control={control}
|
||||||
|
errors={errors}
|
||||||
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
canSubmit={canSubmit}
|
||||||
clientId={clientId}
|
clientId={clientId}
|
||||||
planId={planId}
|
planId={planId}
|
||||||
onClientChange={handleClientChange}
|
onClientChange={handleClientChange}
|
||||||
@@ -185,6 +205,7 @@ export default function TenantsPage() {
|
|||||||
isLookupsLoading={isLookupsLoading}
|
isLookupsLoading={isLookupsLoading}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
adminEmail={adminEmail}
|
adminEmail={adminEmail}
|
||||||
|
adminFieldLocks={adminFieldLocks}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Timeline, TimelineItem } from '@/components/ui/timeline';
|
||||||
|
|
||||||
|
function TimelineEntrySkeleton({
|
||||||
|
index,
|
||||||
|
isLast,
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
isLast: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TimelineItem
|
||||||
|
layout="left"
|
||||||
|
isLast={isLast}
|
||||||
|
opposite={
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="ml-auto h-4 w-16" />
|
||||||
|
<Skeleton className="ml-auto h-3 w-12" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
marker={<Skeleton className="size-4 rounded-full" />}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
index === 0
|
||||||
|
? 'rounded-lg border border-dashed border-primary/25 bg-primary/5 px-4 py-3.5'
|
||||||
|
: 'rounded-lg border bg-card p-4'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{index === 0 ? <Skeleton className="h-5 w-28 rounded-lg" /> : null}
|
||||||
|
<Skeleton className={index === 0 ? 'mt-3 h-4 w-44' : 'h-4 w-44'} />
|
||||||
|
{index === 0 ? (
|
||||||
|
<Skeleton className="mt-2 h-3 w-full max-w-md" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 flex items-center gap-3">
|
||||||
|
<Skeleton className="size-9 rounded-full" />
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-36" />
|
||||||
|
<Skeleton className="h-3 w-48 max-w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="mt-3 h-16 w-full rounded-lg" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TimelineItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketClassContentSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" aria-label="Loading defect class details">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="size-5 rounded-lg" />
|
||||||
|
<Skeleton className="h-5 w-56 max-w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Timeline>
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<TimelineEntrySkeleton
|
||||||
|
key={index}
|
||||||
|
index={index}
|
||||||
|
isLast={index === 2}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Timeline>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketClassActionsSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card aria-label="Loading defect class actions">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="size-4 rounded-lg" />
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-12" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { AlertTriangle, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
|
||||||
|
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { useTicketClassDetectionsQuery } from '../../hooks/useTicketQueries';
|
||||||
|
|
||||||
|
interface TicketClassDetectionPreviewProps {
|
||||||
|
videoId?: string | null;
|
||||||
|
defectClassName: string;
|
||||||
|
displayName: string;
|
||||||
|
totalCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetectionMetadataBar({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: { label: string; value: string | number }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-muted/40 px-4 py-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:divide-x sm:divide-border/70">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className={cn(
|
||||||
|
'space-y-1',
|
||||||
|
index === 0 && 'sm:pr-4',
|
||||||
|
index === 1 && 'sm:px-4',
|
||||||
|
index === 2 && 'sm:pl-4',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-xs text-muted-foreground">{item.label}</p>
|
||||||
|
<div className="text-sm font-semibold text-foreground">
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TicketClassDetectionPreviewSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
<Skeleton className="aspect-video w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<Skeleton className="h-3 w-28" />
|
||||||
|
<Skeleton
|
||||||
|
className="w-full rounded-lg"
|
||||||
|
style={{ aspectRatio: '16 / 9' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-muted/40 px-4 py-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:divide-x sm:divide-border/70">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
'space-y-1',
|
||||||
|
index === 0 && 'sm:pr-4',
|
||||||
|
index === 1 && 'sm:px-4',
|
||||||
|
index === 2 && 'sm:pl-4',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketClassDetectionPreview({
|
||||||
|
videoId,
|
||||||
|
defectClassName,
|
||||||
|
displayName,
|
||||||
|
totalCount,
|
||||||
|
}: TicketClassDetectionPreviewProps) {
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const visual = getDefectVisual(defectClassName);
|
||||||
|
const IssueIcon = visual.icon;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentIndex(0);
|
||||||
|
}, [defectClassName]);
|
||||||
|
|
||||||
|
const queryParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
class_name: defectClassName,
|
||||||
|
skip: currentIndex,
|
||||||
|
limit: 1,
|
||||||
|
sort: 'timestamp_asc',
|
||||||
|
}),
|
||||||
|
[currentIndex, defectClassName],
|
||||||
|
);
|
||||||
|
|
||||||
|
const detectionsQuery = useTicketClassDetectionsQuery(
|
||||||
|
videoId ?? undefined,
|
||||||
|
queryParams,
|
||||||
|
);
|
||||||
|
const detectionResult = detectionsQuery.data;
|
||||||
|
const activeDetection = detectionResult?.items[0];
|
||||||
|
const detectionCount = detectionResult?.total ?? totalCount ?? 0;
|
||||||
|
const mediaAspectRatio =
|
||||||
|
detectionResult &&
|
||||||
|
detectionResult.video_width > 0 &&
|
||||||
|
detectionResult.video_height > 0
|
||||||
|
? `${detectionResult.video_width} / ${detectionResult.video_height}`
|
||||||
|
: '16 / 9';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (detectionCount === 0 && currentIndex !== 0) {
|
||||||
|
setCurrentIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectionCount > 0 && currentIndex > detectionCount - 1) {
|
||||||
|
setCurrentIndex(detectionCount - 1);
|
||||||
|
}
|
||||||
|
}, [currentIndex, detectionCount]);
|
||||||
|
|
||||||
|
const isNavigationDisabled =
|
||||||
|
detectionCount === 0 || detectionsQuery.isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex size-10 shrink-0 self-start items-center justify-center rounded-lg',
|
||||||
|
visual.cardClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IssueIcon className={cn('size-5', visual.colorClassName)} />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<h3>{displayName}</h3>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg',
|
||||||
|
visual.cardClassName,
|
||||||
|
visual.colorClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{detectionCount} Detections
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Review detections in ascending timestamp order.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 self-start sm:self-auto">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentIndex((index) =>
|
||||||
|
detectionCount === 0
|
||||||
|
? 0
|
||||||
|
: (index - 1 + detectionCount) % detectionCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={isNavigationDisabled}
|
||||||
|
aria-label="Previous detection"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-16 text-center text-sm font-medium text-foreground">
|
||||||
|
{detectionCount === 0
|
||||||
|
? '0 of 0'
|
||||||
|
: `${currentIndex + 1} of ${detectionCount}`}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentIndex((index) =>
|
||||||
|
detectionCount === 0 ? 0 : (index + 1) % detectionCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={isNavigationDisabled}
|
||||||
|
aria-label="Next detection"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detectionsQuery.isLoading && !detectionsQuery.data ? (
|
||||||
|
<TicketClassDetectionPreviewSkeleton />
|
||||||
|
) : detectionsQuery.isError ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-8 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<AlertTriangle className="size-6 text-destructive" />
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
Failed to load detection preview.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void detectionsQuery.refetch()}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : !activeDetection || !detectionResult ? (
|
||||||
|
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||||
|
No detections are available for this issue.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2">
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<p className="text-muted-foreground">Detection Image</p>
|
||||||
|
<div className="overflow-hidden rounded-lg">
|
||||||
|
<AnnotatedDetectionImage
|
||||||
|
detection={activeDetection}
|
||||||
|
videoWidth={detectionResult.video_width}
|
||||||
|
videoHeight={detectionResult.video_height}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DetectionLocationMap
|
||||||
|
className="min-w-0"
|
||||||
|
latitude={activeDetection.location.latitude}
|
||||||
|
longitude={activeDetection.location.longitude}
|
||||||
|
label={`${displayName} - Frame ${activeDetection.frame.number}`}
|
||||||
|
title="Location on Map"
|
||||||
|
variant="plain"
|
||||||
|
aspectRatio={mediaAspectRatio}
|
||||||
|
showCoordinates={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DetectionMetadataBar
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: 'Confidence',
|
||||||
|
value: `${Math.round(activeDetection.detection.confidence * 100)}%`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Frame',
|
||||||
|
value: activeDetection.frame.number,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Timestamp',
|
||||||
|
value: `${activeDetection.frame.timestamp_seconds}s`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Component, ListChecks } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { DEFECT_CLASS_STATUS_CONFIG } from '@/constants/defectClassStatus';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { TicketOverviewDetail } from '@/types';
|
||||||
|
|
||||||
|
import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries';
|
||||||
|
import { TicketClassDetectionPreview } from './TicketClassDetectionPreview';
|
||||||
|
|
||||||
|
interface TicketDefectClassTabsProps {
|
||||||
|
ticketId: string;
|
||||||
|
ticket?: TicketOverviewDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketDefectClassTabs({
|
||||||
|
ticketId,
|
||||||
|
ticket,
|
||||||
|
}: TicketDefectClassTabsProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const defectClassParam = searchParams.get('defect_class');
|
||||||
|
const defectClassesQuery = useTicketDefectClassesQuery(ticketId);
|
||||||
|
const defectClasses = useMemo(
|
||||||
|
() => defectClassesQuery.data?.defect_classes ?? [],
|
||||||
|
[defectClassesQuery.data?.defect_classes],
|
||||||
|
);
|
||||||
|
const [selectedClass, setSelectedClass] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!defectClasses.length) return;
|
||||||
|
|
||||||
|
const hasUrlClass = defectClasses.some(
|
||||||
|
(item) => item.class_name === defectClassParam,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasUrlClass) {
|
||||||
|
setSelectedClass(defectClassParam ?? undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstClass = defectClasses[0].class_name;
|
||||||
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
|
nextParams.set('defect_class', firstClass);
|
||||||
|
setSelectedClass(firstClass);
|
||||||
|
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
|
||||||
|
}, [defectClassParam, defectClasses, pathname, router, searchParams]);
|
||||||
|
|
||||||
|
const handleClassChange = (className: string) => {
|
||||||
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
|
nextParams.set('defect_class', className);
|
||||||
|
setSelectedClass(className);
|
||||||
|
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
|
||||||
|
};
|
||||||
|
const selectedIssue = ticket?.ai_result.detections_by_class.find(
|
||||||
|
(item) => item.class_name === selectedClass,
|
||||||
|
);
|
||||||
|
const selectedClassItem = defectClasses.find(
|
||||||
|
(item) => item.class_name === selectedClass,
|
||||||
|
);
|
||||||
|
const previewDisplayName =
|
||||||
|
selectedIssue?.display_name ??
|
||||||
|
selectedClassItem?.display_name ??
|
||||||
|
selectedClass ??
|
||||||
|
'Detection';
|
||||||
|
const previewVideoId = ticket?.video_id ?? defectClassesQuery.data?.video_id;
|
||||||
|
|
||||||
|
if (defectClassesQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full rounded-lg">
|
||||||
|
<CardContent className="space-y-4 p-5">
|
||||||
|
<div className="h-5 w-24 animate-pulse rounded-lg bg-muted" />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="h-11 w-full animate-pulse rounded-lg bg-muted"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defectClassesQuery.isError || !defectClasses.length || !selectedClass) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
value={selectedClass}
|
||||||
|
onValueChange={handleClassChange}
|
||||||
|
orientation="vertical"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardContent className="grid gap-0 p-0 lg:grid-cols-[minmax(220px,280px)_1fr] lg:divide-x lg:divide-border">
|
||||||
|
<div className="space-y-4 px-5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ListChecks className="size-5 shrink-0 text-primary" />
|
||||||
|
<h2 className="text-base font-semibold">Detected Issues</h2>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="min-w-5 min-h-5 justify-center rounded-full"
|
||||||
|
>
|
||||||
|
{defectClasses.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsList className="flex h-auto w-full flex-col items-stretch gap-2 bg-transparent p-0">
|
||||||
|
{defectClasses.map((item) => {
|
||||||
|
const statusConfig =
|
||||||
|
DEFECT_CLASS_STATUS_CONFIG[item.assignment_status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsTrigger
|
||||||
|
key={item.class_name}
|
||||||
|
value={item.class_name}
|
||||||
|
className={cn(
|
||||||
|
'group h-auto w-full flex-none items-center justify-between! gap-2 rounded-lg border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground shadow-none after:hidden',
|
||||||
|
'data-[state=active]:bg-muted data-[state=active]:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate text-left font-medium">
|
||||||
|
{item.display_name}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-full',
|
||||||
|
statusConfig.badgeClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Component className="size-3" />
|
||||||
|
{statusConfig.label}
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 px-5">
|
||||||
|
<TicketClassDetectionPreview
|
||||||
|
videoId={previewVideoId}
|
||||||
|
defectClassName={selectedClass}
|
||||||
|
displayName={previewDisplayName}
|
||||||
|
totalCount={selectedIssue?.count}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user