Compare commits
138 Commits
6f4399a048
...
global-ass
| Author | SHA1 | Date | |
|---|---|---|---|
| c13afa7554 | |||
| 8033450321 | |||
| 98de8e948b | |||
| 3697119656 | |||
| e268b55392 | |||
| da5b1009e3 | |||
| 3d04995f97 | |||
| d3069e1f25 | |||
| 47697b4421 | |||
| c8899c191e | |||
| 8ab9240b77 | |||
| f6436862c0 | |||
| 0ff5792670 | |||
| 0349891f52 | |||
| 7ee621b790 | |||
| 44e9dc4ec8 | |||
| d261404903 | |||
| bad9c50dbf | |||
| 6169bbdc28 | |||
| 0a4d9512cb | |||
| faefd34cd4 | |||
| 514b42ea91 | |||
| 9cd7186357 | |||
| 87fa04e1b5 | |||
| 2caeae7c91 | |||
| 44d3be5d27 | |||
| 58098af0fe | |||
| 1cbad4074c | |||
| 5e0d641575 | |||
| e894676d3b | |||
| 31839d1c70 | |||
| e8f2955acf | |||
| 5e66b70611 | |||
| 7ffe3f0708 | |||
| 8e8803396d | |||
| 0601322361 | |||
| 20f611f35a | |||
| 224361f144 | |||
| 77b6755d05 | |||
| 2f25fd49be | |||
| 3891500cf2 | |||
| 062e89fcba | |||
| 725c256791 | |||
| 5d3dac0b21 | |||
| e077467bce | |||
| e94da54aa9 | |||
| 76c87811d8 | |||
| 830883c6b7 | |||
| 68829dc72b | |||
| 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 @@
|
|||||||
|
# Server-only backend origin. Do not expose this as a NEXT_PUBLIC variable.
|
||||||
|
BACKEND_ORIGIN=http://localhost:8000
|
||||||
@@ -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.
|
|
||||||
21
next.config.ts
Normal file
21
next.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const backendOrigin = process.env.BACKEND_ORIGIN?.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
if (!backendOrigin) {
|
||||||
|
throw new Error('BACKEND_ORIGIN is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
reactStrictMode: false,
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/:namespace(api|biz)/:path*',
|
||||||
|
destination: `${backendOrigin}/:namespace/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
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",
|
||||||
|
|||||||
5
public/color_logo.svg
Normal file
5
public/color_logo.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="#372AAC"/>
|
||||||
|
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8026C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0642C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="#372AAC"/>
|
||||||
|
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="#372AAC"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
740
public/flags/flags.css
Normal file
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 |
5
public/logo.svg
Normal file
5
public/logo.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="white"/>
|
||||||
|
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8025C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0641C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="white"/>
|
||||||
|
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type {
|
||||||
|
TicketExtensionRequest,
|
||||||
|
TicketExtensionRequestStatus,
|
||||||
|
} from '@/types';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
|
const statusConfig: Record<
|
||||||
|
TicketExtensionRequestStatus,
|
||||||
|
{ label: string; className: string }
|
||||||
|
> = {
|
||||||
|
pending: {
|
||||||
|
label: 'Pending',
|
||||||
|
className:
|
||||||
|
'border-amber-200 bg-amber-100 text-amber-800 dark:border-amber-900 dark:bg-amber-950/50 dark:text-amber-300',
|
||||||
|
},
|
||||||
|
approved: {
|
||||||
|
label: 'Approved',
|
||||||
|
className:
|
||||||
|
'border-emerald-200 bg-emerald-100 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/50 dark:text-emerald-300',
|
||||||
|
},
|
||||||
|
rejected: {
|
||||||
|
label: 'Rejected',
|
||||||
|
className:
|
||||||
|
'border-red-200 bg-red-100 text-red-800 dark:border-red-900 dark:bg-red-950/50 dark:text-red-300',
|
||||||
|
},
|
||||||
|
cancelled: {
|
||||||
|
label: 'Cancelled',
|
||||||
|
className: 'border-border bg-muted text-muted-foreground',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: TicketExtensionRequestStatus }) {
|
||||||
|
const config = statusConfig[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={cn('border', config.className)}>
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonCell({
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
}: {
|
||||||
|
name: string | null | undefined;
|
||||||
|
role: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{name || '-'}</p>
|
||||||
|
{role ? (
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{role}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExtensionRequestColumns(): ColumnDef<TicketExtensionRequest>[] {
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'ticket_name',
|
||||||
|
header: 'Ticket / Assignment',
|
||||||
|
size: 170,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{row.original.ticket_name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Assignment #{row.original.assignment_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'requested_by',
|
||||||
|
header: 'Requested By',
|
||||||
|
size: 170,
|
||||||
|
meta: { disableTruncate: true },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<PersonCell
|
||||||
|
name={row.original.requested_by?.name}
|
||||||
|
role={row.original.requested_by?.role.name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
size: 125,
|
||||||
|
meta: { disableTruncate: true },
|
||||||
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'current_due_at',
|
||||||
|
header: 'Current Due Date',
|
||||||
|
size: 175,
|
||||||
|
cell: ({ row }) => formatDate(row.original.current_due_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'requested_due_at',
|
||||||
|
header: 'Requested Due Date',
|
||||||
|
size: 175,
|
||||||
|
cell: ({ row }) => formatDate(row.original.requested_due_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'reason',
|
||||||
|
header: 'Request Reason',
|
||||||
|
size: 260,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'resolved_by',
|
||||||
|
header: 'Resolved By',
|
||||||
|
size: 170,
|
||||||
|
meta: { disableTruncate: true },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<PersonCell
|
||||||
|
name={row.original.resolved_by?.name}
|
||||||
|
role={row.original.resolved_by?.role.name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'decision_note',
|
||||||
|
header: 'Decision Note',
|
||||||
|
size: 220,
|
||||||
|
cell: ({ row }) => row.original.decision_note || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'previous_requests',
|
||||||
|
header: 'Previous Requests',
|
||||||
|
size: 150,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{row.original.history_meta.previous_request_count}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'resolved_at',
|
||||||
|
header: 'Resolved On',
|
||||||
|
size: 175,
|
||||||
|
cell: ({ row }) => formatDate(row.original.resolved_at),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { ArrowRight, Check, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import type { TicketExtensionRequest } from '@/types';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MAX_EXTENSION_DECISION_NOTE_LENGTH,
|
||||||
|
type ExtensionRequestDecisionAction,
|
||||||
|
useExtensionRequestDecision,
|
||||||
|
} from '../hooks/useExtensionRequestDecision';
|
||||||
|
|
||||||
|
interface ExtensionRequestDecisionDialogProps {
|
||||||
|
request: TicketExtensionRequest | null;
|
||||||
|
action: ExtensionRequestDecisionAction | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExtensionRequestDecisionDialog({
|
||||||
|
request,
|
||||||
|
action,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: ExtensionRequestDecisionDialogProps) {
|
||||||
|
const handleSuccess = useCallback(() => onOpenChange(false), [onOpenChange]);
|
||||||
|
const {
|
||||||
|
note,
|
||||||
|
setNote,
|
||||||
|
noteError,
|
||||||
|
selectedAction,
|
||||||
|
isPending,
|
||||||
|
isRejectConfirmationOpen,
|
||||||
|
setIsRejectConfirmationOpen,
|
||||||
|
submitDecision,
|
||||||
|
requestRejection,
|
||||||
|
resetDecision,
|
||||||
|
} = useExtensionRequestDecision({
|
||||||
|
ticketId: request?.ticket_id ?? '',
|
||||||
|
assignmentId: request?.assignment_id ?? 0,
|
||||||
|
onSuccess: handleSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetDecision();
|
||||||
|
}, [action, open, request?.id, resetDecision]);
|
||||||
|
|
||||||
|
const handleOpenChange = (nextOpen: boolean) => {
|
||||||
|
if (isPending) return;
|
||||||
|
onOpenChange(nextOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!request || !action) return null;
|
||||||
|
|
||||||
|
const isApproval = action === 'approve';
|
||||||
|
const noteErrorId = `extension-manager-note-error-${request.id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-xl" showCloseButton={!isPending}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{isApproval
|
||||||
|
? 'Approve extension request'
|
||||||
|
: 'Reject extension request'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Review {request.ticket_name}, Assignment #{request.assignment_id}{' '}
|
||||||
|
before confirming this decision.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody className="space-y-4">
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-3 rounded-lg border p-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Current Due Date
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-medium">
|
||||||
|
{formatDate(request.current_due_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="mt-5 size-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Requested Due Date
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-medium">
|
||||||
|
{formatDate(request.requested_due_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">Requested By</p>
|
||||||
|
<p className="mt-1 font-medium">
|
||||||
|
{request.requested_by?.name ?? 'Assigned worker'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground">Reason</p>
|
||||||
|
<p className="mt-1 leading-relaxed">{request.reason}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="extension-manager-note">
|
||||||
|
Add Manager Comments
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{isApproval
|
||||||
|
? 'Optional for approval.'
|
||||||
|
: 'Required for rejection.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Textarea
|
||||||
|
id="extension-manager-note"
|
||||||
|
value={note}
|
||||||
|
onChange={(event) => setNote(event.target.value)}
|
||||||
|
placeholder="Add a note explaining your decision..."
|
||||||
|
disabled={isPending}
|
||||||
|
aria-invalid={Boolean(noteError)}
|
||||||
|
aria-describedby={noteError ? noteErrorId : undefined}
|
||||||
|
className="min-h-24 resize-none pb-8"
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute right-3 bottom-2 text-xs text-muted-foreground">
|
||||||
|
{note.length} / {MAX_EXTENSION_DECISION_NOTE_LENGTH}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{noteError ? (
|
||||||
|
<p id={noteErrorId} className="text-xs text-destructive">
|
||||||
|
{noteError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{isApproval ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => void submitDecision('approve')}
|
||||||
|
>
|
||||||
|
{selectedAction === 'approve' ? (
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check />
|
||||||
|
)}
|
||||||
|
Approve Extension
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={requestRejection}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
Reject Request
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={isRejectConfirmationOpen}
|
||||||
|
onOpenChange={setIsRejectConfirmationOpen}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Reject extension request?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to reject the extension request for{' '}
|
||||||
|
{request.ticket_name}, Assignment #{request.assignment_id}? This
|
||||||
|
decision cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isPending}>No</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => void submitDecision('reject')}
|
||||||
|
>
|
||||||
|
{selectedAction === 'reject' ? (
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Yes, Reject
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FilterX } from 'lucide-react';
|
||||||
|
|
||||||
|
import { TableSearchInput } from '@/components/data-table/TableSearchInput';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
|
import type { ExtensionRequestStatusFilter } from '../hooks/useExtensionRequestFilters';
|
||||||
|
|
||||||
|
interface ExtensionRequestFiltersProps {
|
||||||
|
ticketName: string;
|
||||||
|
statusFilter: ExtensionRequestStatusFilter;
|
||||||
|
onTicketNameChange: (value: string) => void;
|
||||||
|
onStatusChange: (value: ExtensionRequestStatusFilter) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExtensionRequestFilters({
|
||||||
|
ticketName,
|
||||||
|
statusFilter,
|
||||||
|
onTicketNameChange,
|
||||||
|
onStatusChange,
|
||||||
|
onClear,
|
||||||
|
}: ExtensionRequestFiltersProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<TableSearchInput
|
||||||
|
value={ticketName}
|
||||||
|
onChange={onTicketNameChange}
|
||||||
|
placeholder="Search by ticket name"
|
||||||
|
className="w-full sm:max-w-sm"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onStatusChange(value as ExtensionRequestStatusFilter)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full sm:w-44">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
<SelectItem value="approved">Approved</SelectItem>
|
||||||
|
<SelectItem value="rejected">Rejected</SelectItem>
|
||||||
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={!ticketName && statusFilter === 'pending'}
|
||||||
|
onClick={onClear}
|
||||||
|
>
|
||||||
|
<FilterX />
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { DataTable, type DataTableAction } from '@/components/data-table';
|
||||||
|
import type { TicketExtensionRequest } from '@/types';
|
||||||
|
|
||||||
|
interface ExtensionRequestTableProps {
|
||||||
|
columns: ColumnDef<TicketExtensionRequest>[];
|
||||||
|
requests: TicketExtensionRequest[];
|
||||||
|
isLoading: boolean;
|
||||||
|
toolbar: ReactNode;
|
||||||
|
skip: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
onPageChange: (skip: number) => void;
|
||||||
|
onLimitChange: (limit: number) => void;
|
||||||
|
onView: (request: TicketExtensionRequest) => void;
|
||||||
|
actions: DataTableAction<TicketExtensionRequest>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExtensionRequestTable({
|
||||||
|
columns,
|
||||||
|
requests,
|
||||||
|
isLoading,
|
||||||
|
toolbar,
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
onView,
|
||||||
|
actions,
|
||||||
|
}: ExtensionRequestTableProps) {
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
title="Extension Requests"
|
||||||
|
columns={columns}
|
||||||
|
data={requests}
|
||||||
|
isLoading={isLoading}
|
||||||
|
toolbar={toolbar}
|
||||||
|
emptyTitle="No extension requests found."
|
||||||
|
emptyDescription="Try changing the ticket or status filters."
|
||||||
|
onRowClick={onView}
|
||||||
|
actions={actions}
|
||||||
|
actionsSticky
|
||||||
|
minColumnSize={130}
|
||||||
|
pagination={{
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
totalItems: total,
|
||||||
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { ticketService } from '@/services/api';
|
||||||
|
import type { ReviewTicketExtensionPayload } from '@/types';
|
||||||
|
|
||||||
|
import { ticketKeys } from '../../ticket/queries/ticketKeys';
|
||||||
|
import { extensionRequestKeys } from '../queries/extensionRequestKeys';
|
||||||
|
|
||||||
|
export const MAX_EXTENSION_DECISION_NOTE_LENGTH = 300;
|
||||||
|
|
||||||
|
export type ExtensionRequestDecisionAction = 'approve' | 'reject';
|
||||||
|
|
||||||
|
interface UseExtensionRequestDecisionOptions {
|
||||||
|
ticketId: string;
|
||||||
|
assignmentId: number;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExtensionRequestDecision({
|
||||||
|
ticketId,
|
||||||
|
assignmentId,
|
||||||
|
onSuccess,
|
||||||
|
}: UseExtensionRequestDecisionOptions) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [note, setNoteValue] = useState('');
|
||||||
|
const [noteError, setNoteError] = useState<string | null>(null);
|
||||||
|
const [selectedAction, setSelectedAction] =
|
||||||
|
useState<ExtensionRequestDecisionAction | null>(null);
|
||||||
|
const [isRejectConfirmationOpen, setIsRejectConfirmationOpen] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
const reviewMutation = useMutation({
|
||||||
|
mutationFn: (payload: ReviewTicketExtensionPayload) =>
|
||||||
|
ticketService.reviewTicketExtension(ticketId, assignmentId, payload),
|
||||||
|
onSuccess: (_response, payload) => {
|
||||||
|
toast.success(
|
||||||
|
payload.action === 'approve'
|
||||||
|
? 'Extension request approved'
|
||||||
|
: 'Extension request rejected',
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.extensionRequest(ticketId, assignmentId),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: extensionRequestKeys.lists(),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.assignments(ticketId),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ticketKeys.timeline(ticketId),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to review extension request'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const setNote = useCallback((value: string) => {
|
||||||
|
const nextNote = value.slice(0, MAX_EXTENSION_DECISION_NOTE_LENGTH);
|
||||||
|
setNoteValue(nextNote);
|
||||||
|
if (nextNote.trim()) setNoteError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitDecision = useCallback(
|
||||||
|
async (action: ExtensionRequestDecisionAction) => {
|
||||||
|
const trimmedNote = note.trim();
|
||||||
|
|
||||||
|
if (action === 'reject' && !trimmedNote) {
|
||||||
|
setNoteError('Manager comments are required to reject this request.');
|
||||||
|
setIsRejectConfirmationOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedAction(action);
|
||||||
|
try {
|
||||||
|
await reviewMutation.mutateAsync({
|
||||||
|
action,
|
||||||
|
...(trimmedNote ? { note: trimmedNote } : {}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// The mutation displays the error toast and leaves the form open.
|
||||||
|
} finally {
|
||||||
|
setSelectedAction(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[note, reviewMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestRejection = useCallback(() => {
|
||||||
|
if (!note.trim()) {
|
||||||
|
setNoteError('Manager comments are required to reject this request.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNoteError(null);
|
||||||
|
setIsRejectConfirmationOpen(true);
|
||||||
|
}, [note]);
|
||||||
|
|
||||||
|
const resetDecision = useCallback(() => {
|
||||||
|
setNoteValue('');
|
||||||
|
setNoteError(null);
|
||||||
|
setSelectedAction(null);
|
||||||
|
setIsRejectConfirmationOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
note,
|
||||||
|
setNote,
|
||||||
|
noteError,
|
||||||
|
selectedAction,
|
||||||
|
isPending: reviewMutation.isPending,
|
||||||
|
isRejectConfirmationOpen,
|
||||||
|
setIsRejectConfirmationOpen,
|
||||||
|
submitDecision,
|
||||||
|
requestRejection,
|
||||||
|
resetDecision,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
|
import type { TicketExtensionRequestStatus } from '@/types';
|
||||||
|
|
||||||
|
export type ExtensionRequestStatusFilter = 'all' | TicketExtensionRequestStatus;
|
||||||
|
|
||||||
|
export function useExtensionRequestFilters() {
|
||||||
|
const [skip, setSkip] = useState(0);
|
||||||
|
const [limit, setLimitValue] = useState(10);
|
||||||
|
const [ticketName, setTicketNameValue] = useState('');
|
||||||
|
const [statusFilter, setStatusFilterValue] =
|
||||||
|
useState<ExtensionRequestStatusFilter>('pending');
|
||||||
|
const debouncedTicketName = useDebounce(ticketName.trim(), 400);
|
||||||
|
|
||||||
|
const setLimit = useCallback((value: number) => {
|
||||||
|
setLimitValue(value);
|
||||||
|
setSkip(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setTicketName = useCallback((value: string) => {
|
||||||
|
setTicketNameValue(value);
|
||||||
|
setSkip(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setStatusFilter = useCallback((value: ExtensionRequestStatusFilter) => {
|
||||||
|
setStatusFilterValue(value);
|
||||||
|
setSkip(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearFilters = useCallback(() => {
|
||||||
|
setTicketNameValue('');
|
||||||
|
setStatusFilterValue('pending');
|
||||||
|
setSkip(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
skip,
|
||||||
|
setSkip,
|
||||||
|
limit,
|
||||||
|
setLimit,
|
||||||
|
ticketName,
|
||||||
|
debouncedTicketName,
|
||||||
|
setTicketName,
|
||||||
|
statusFilter,
|
||||||
|
setStatusFilter,
|
||||||
|
clearFilters,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { ticketService } from '@/services/api';
|
||||||
|
import type { TicketExtensionRequestsParams } from '@/types';
|
||||||
|
|
||||||
|
import type { ExtensionRequestStatusFilter } from './useExtensionRequestFilters';
|
||||||
|
import { extensionRequestKeys } from '../queries/extensionRequestKeys';
|
||||||
|
|
||||||
|
interface UseExtensionRequestsQueryParams {
|
||||||
|
skip: number;
|
||||||
|
limit: number;
|
||||||
|
ticketName: string;
|
||||||
|
statusFilter: ExtensionRequestStatusFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExtensionRequestParams({
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
ticketName,
|
||||||
|
statusFilter,
|
||||||
|
}: UseExtensionRequestsQueryParams): TicketExtensionRequestsParams {
|
||||||
|
return {
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
ticket_name: ticketName || undefined,
|
||||||
|
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExtensionRequestsQuery(
|
||||||
|
params: UseExtensionRequestsQueryParams,
|
||||||
|
) {
|
||||||
|
const { skip, limit, ticketName, statusFilter } = params;
|
||||||
|
const queryParams = useMemo(
|
||||||
|
() =>
|
||||||
|
buildExtensionRequestParams({ skip, limit, ticketName, statusFilter }),
|
||||||
|
[limit, skip, statusFilter, ticketName],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: extensionRequestKeys.list(queryParams),
|
||||||
|
queryFn: () => ticketService.getTicketExtensionRequests(queryParams),
|
||||||
|
});
|
||||||
|
}
|
||||||
16
src/app/(modules)/extension-requests/layout.tsx
Normal file
16
src/app/(modules)/extension-requests/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function ExtensionRequestsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.TICKET.ASSIGN}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
src/app/(modules)/extension-requests/page.tsx
Normal file
153
src/app/(modules)/extension-requests/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { AlertCircle, CalendarClock, Check, RotateCcw, X } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import type { DataTableAction } from '@/components/data-table';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import type { TicketExtensionRequest } from '@/types';
|
||||||
|
import { ROUTES } from '@/utils/routes';
|
||||||
|
|
||||||
|
import { useExtensionRequestColumns } from './components/ExtensionRequestColumns';
|
||||||
|
import { ExtensionRequestDecisionDialog } from './components/ExtensionRequestDecisionDialog';
|
||||||
|
import { ExtensionRequestFilters } from './components/ExtensionRequestFilters';
|
||||||
|
import { ExtensionRequestTable } from './components/ExtensionRequestTable';
|
||||||
|
import type { ExtensionRequestDecisionAction } from './hooks/useExtensionRequestDecision';
|
||||||
|
import { useExtensionRequestFilters } from './hooks/useExtensionRequestFilters';
|
||||||
|
import { useExtensionRequestsQuery } from './hooks/useExtensionRequestQueries';
|
||||||
|
|
||||||
|
export default function ExtensionRequestsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [decision, setDecision] = useState<{
|
||||||
|
request: TicketExtensionRequest;
|
||||||
|
action: ExtensionRequestDecisionAction;
|
||||||
|
} | null>(null);
|
||||||
|
const {
|
||||||
|
skip,
|
||||||
|
setSkip,
|
||||||
|
limit,
|
||||||
|
setLimit,
|
||||||
|
ticketName,
|
||||||
|
debouncedTicketName,
|
||||||
|
setTicketName,
|
||||||
|
statusFilter,
|
||||||
|
setStatusFilter,
|
||||||
|
clearFilters,
|
||||||
|
} = useExtensionRequestFilters();
|
||||||
|
const extensionRequestsQuery = useExtensionRequestsQuery({
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
ticketName: debouncedTicketName,
|
||||||
|
statusFilter,
|
||||||
|
});
|
||||||
|
const columns = useExtensionRequestColumns();
|
||||||
|
const requests = extensionRequestsQuery.data?.items ?? [];
|
||||||
|
const total = extensionRequestsQuery.data?.total ?? 0;
|
||||||
|
|
||||||
|
const actions = useMemo<DataTableAction<TicketExtensionRequest>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: 'Approve extension',
|
||||||
|
icon: <Check className="text-emerald-600" />,
|
||||||
|
permission: PERMISSIONS.TICKET.ASSIGN,
|
||||||
|
hidden: (request) => request.status !== 'pending',
|
||||||
|
className:
|
||||||
|
'border-emerald-200 hover:bg-emerald-50 dark:border-emerald-900 dark:hover:bg-emerald-950/50',
|
||||||
|
onClick: (request) => setDecision({ request, action: 'approve' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Reject extension',
|
||||||
|
icon: <X className="text-destructive" />,
|
||||||
|
permission: PERMISSIONS.TICKET.ASSIGN,
|
||||||
|
hidden: (request) => request.status !== 'pending',
|
||||||
|
className:
|
||||||
|
'border-destructive/30 hover:bg-destructive/5 hover:text-destructive',
|
||||||
|
onClick: (request) => setDecision({ request, action: 'reject' }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openTicket = useCallback(
|
||||||
|
(request: TicketExtensionRequest) => {
|
||||||
|
router.push(ROUTES.TICKET_DETAIL(request.ticket_id));
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterToolbar = useMemo(
|
||||||
|
() => (
|
||||||
|
<ExtensionRequestFilters
|
||||||
|
ticketName={ticketName}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
onTicketNameChange={setTicketName}
|
||||||
|
onStatusChange={setStatusFilter}
|
||||||
|
onClear={clearFilters}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[clearFilters, setTicketName, setStatusFilter, statusFilter, ticketName],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative z-10 space-y-5">
|
||||||
|
<PageHeader
|
||||||
|
title="Extension Requests"
|
||||||
|
description="Track deadline extension requests across all ticket assignments."
|
||||||
|
icon={CalendarClock}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{extensionRequestsQuery.isError ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex min-h-48 flex-col items-center justify-center rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 text-center"
|
||||||
|
>
|
||||||
|
<AlertCircle className="mb-2 size-6 text-destructive" />
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
Failed to load extension requests.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-3"
|
||||||
|
disabled={extensionRequestsQuery.isFetching}
|
||||||
|
onClick={() => extensionRequestsQuery.refetch()}
|
||||||
|
>
|
||||||
|
<RotateCcw
|
||||||
|
className={
|
||||||
|
extensionRequestsQuery.isFetching ? 'animate-spin' : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ExtensionRequestTable
|
||||||
|
columns={columns}
|
||||||
|
requests={requests}
|
||||||
|
isLoading={extensionRequestsQuery.isLoading}
|
||||||
|
toolbar={filterToolbar}
|
||||||
|
skip={skip}
|
||||||
|
limit={limit}
|
||||||
|
total={total}
|
||||||
|
onPageChange={setSkip}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
onView={openTicket}
|
||||||
|
actions={actions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ExtensionRequestDecisionDialog
|
||||||
|
request={decision?.request ?? null}
|
||||||
|
action={decision?.action ?? null}
|
||||||
|
open={Boolean(decision)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDecision(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { TicketExtensionRequestsParams } from '@/types';
|
||||||
|
|
||||||
|
export const extensionRequestKeys = {
|
||||||
|
all: ['extension-requests'] as const,
|
||||||
|
lists: () => [...extensionRequestKeys.all, 'list'] as const,
|
||||||
|
list: (params: TicketExtensionRequestsParams) =>
|
||||||
|
[...extensionRequestKeys.lists(), params] as const,
|
||||||
|
};
|
||||||
@@ -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 defaultOpen={false}>
|
||||||
<AppSidebar className="bg-transparent" />
|
<AppSidebar />
|
||||||
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden bg-transparent">
|
<main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="scroll-stable min-w-0 flex-1 overflow-auto">
|
||||||
{/* Centering container wrapper */}
|
<div className="mx-auto flex min-h-full w-full min-w-0 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="min-w-0 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, ShieldCheck, ShieldX } 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,36 @@ 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: (role) =>
|
||||||
|
role.effective_status ? (
|
||||||
|
<ShieldX className="size-4 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<ShieldCheck className="size-4 text-emerald-600" />
|
||||||
|
),
|
||||||
|
permission: PERMISSIONS.ROLE.DELETE,
|
||||||
|
disabled: () => isStatusPending,
|
||||||
|
onClick: onToggleStatus,
|
||||||
|
},
|
||||||
|
]}
|
||||||
pagination={{
|
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]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user