Compare commits
133 Commits
182d4ac293
...
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 |
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;
|
||||||
3592
package-lock.json
generated
3592
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -6,19 +6,11 @@
|
|||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
|
||||||
"*.+(js|jsx|ts|tsx)": [
|
|
||||||
"npm run lint:fix"
|
|
||||||
],
|
|
||||||
"*.+(json|md|css|scss|html)": [
|
|
||||||
"npm run format"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.5.0",
|
"@base-ui/react": "^1.5.0",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
@@ -35,22 +27,29 @@
|
|||||||
"@tanstack/react-query-devtools": "^5.101.0",
|
"@tanstack/react-query-devtools": "^5.101.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/react-virtual": "^3.14.3",
|
"@tanstack/react-virtual": "^3.14.3",
|
||||||
|
"@vidstack/react": "^1.15.6",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"libphonenumber-js": "^1.13.7",
|
||||||
|
"lightgallery": "^2.9.0",
|
||||||
"lucide-react": "^0.548.0",
|
"lucide-react": "^0.548.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.6.0",
|
"radix-ui": "^1.6.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-hook-form": "^7.79.0",
|
"react-hook-form": "^7.79.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "^3.8.0",
|
||||||
|
"shadcn": "^4.13.0",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
@@ -63,11 +62,10 @@
|
|||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"baseline-browser-mapping": "^2.10.37",
|
"baseline-browser-mapping": "^2.10.37",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.1.7",
|
"eslint-config-next": "^16.0.10",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.5",
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.4.0",
|
|
||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"tailwindcss": "^4.1.9",
|
"tailwindcss": "^4.1.9",
|
||||||
|
|||||||
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>
|
|
||||||
<Input
|
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
label="Password"
|
||||||
|
required
|
||||||
|
labelEnd={
|
||||||
|
<Link href={ROUTES.FORGOT_PASSWORD} className="text-primary">
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
placeholder="Enter password"
|
placeholder="Enter password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
aria-invalid={!!errors.password}
|
aria-invalid={!!errors.password}
|
||||||
|
error={errors.password?.message}
|
||||||
{...register('password', {
|
{...register('password', {
|
||||||
required: 'Password is required',
|
required: 'Password is required',
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
{errors.password && (
|
|
||||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
</main>
|
</div>
|
||||||
|
</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,38 +63,5 @@ export function useClientColumns({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}, []);
|
||||||
if (!canEdit && !canDelete) return columns;
|
|
||||||
|
|
||||||
columns.push({
|
|
||||||
id: 'actions',
|
|
||||||
header: () => <div className="text-right">Actions</div>,
|
|
||||||
enableSorting: false,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const client = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{canEdit ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onEdit(client)}>
|
|
||||||
<Edit className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{canDelete ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pendingClientId === client.id}
|
|
||||||
onClick={() => onToggleStatus(client)}
|
|
||||||
>
|
|
||||||
<RotateCcw className="mr-2 size-4" />
|
|
||||||
{client.is_active ? 'Deactivate' : 'Activate'}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingClientId]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
|
||||||
|
<form
|
||||||
|
noValidate
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
|
<DialogBody className="space-y-5">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="client-name">Name</Label>
|
id="client-first-name"
|
||||||
<Input
|
label="First Name"
|
||||||
id="client-name"
|
|
||||||
placeholder="Acme Corp"
|
|
||||||
{...register('name', { required: true })}
|
|
||||||
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="client-email">Email</Label>
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
id="client-email"
|
||||||
|
label="Company Email"
|
||||||
|
required
|
||||||
|
error={emailErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="client-email"
|
id="client-email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="info@acme.com"
|
placeholder="Enter company email"
|
||||||
{...register('email', { required: true })}
|
aria-invalid={!!emailErrorMessage}
|
||||||
required
|
{...register('email')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<FormField
|
||||||
<div className="space-y-2">
|
id="landline-number"
|
||||||
<Label htmlFor="landline-number">Landline Number</Label>
|
label="Landline Number"
|
||||||
|
required
|
||||||
|
error={landlineErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="landline-number"
|
id="landline-number"
|
||||||
placeholder="+91-22-12345678"
|
placeholder="Enter landline number"
|
||||||
{...register('landline_number', { required: true })}
|
aria-invalid={!!landlineErrorMessage}
|
||||||
required
|
{...register('landline_number')}
|
||||||
/>
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="address">Address</Label>
|
<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 className="grid gap-4 md:grid-cols-3">
|
||||||
|
<FormField id="gst" label="GST">
|
||||||
|
<Input
|
||||||
|
id="gst"
|
||||||
|
placeholder="Enter GST number"
|
||||||
|
{...register('gst')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField id="pan" label="PAN">
|
||||||
|
<Input
|
||||||
|
id="pan"
|
||||||
|
placeholder="Enter PAN number"
|
||||||
|
{...register('pan')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField id="tan" label="TAN">
|
||||||
|
<Input
|
||||||
|
id="tan"
|
||||||
|
placeholder="Enter TAN number"
|
||||||
|
{...register('tan')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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="gst">GST</Label>
|
id="contact-name"
|
||||||
<Input id="gst" placeholder="27ABCDE1234F1Z5" {...register('gst')} />
|
label="Contact Name"
|
||||||
</div>
|
required
|
||||||
<div className="space-y-2">
|
error={contactNameErrorMessage}
|
||||||
<Label htmlFor="pan">PAN</Label>
|
>
|
||||||
<Input id="pan" placeholder="ABCDE1234F" {...register('pan')} />
|
|
||||||
</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">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="contact-name">Contact Name</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="contact-name"
|
id="contact-name"
|
||||||
placeholder="Jane Doe"
|
placeholder="Enter contact name"
|
||||||
{...register('contact_name', { required: true })}
|
aria-invalid={!!contactNameErrorMessage}
|
||||||
required
|
{...register('contact_name')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="contact-phone-number">Contact Phone</Label>
|
<FormField
|
||||||
<Input
|
|
||||||
id="contact-phone-number"
|
id="contact-phone-number"
|
||||||
placeholder="+91-9876543210"
|
label="Contact Phone"
|
||||||
{...register('contact_phone_number', { required: true })}
|
|
||||||
required
|
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}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="space-y-2">
|
/>
|
||||||
<Label htmlFor="contact-email">Contact Email</Label>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="contact-email"
|
||||||
|
label="Contact Email"
|
||||||
|
required
|
||||||
|
error={contactEmailErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="contact-email"
|
id="contact-email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="jane.doe@acme.com"
|
placeholder="Enter contact email"
|
||||||
{...register('contact_email', { required: true })}
|
aria-invalid={!!contactEmailErrorMessage}
|
||||||
required
|
{...register('contact_email')}
|
||||||
/>
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogBody>
|
||||||
|
|
||||||
<DialogFooter className="px-0">
|
<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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
import { Edit, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import type { Client } from '@/types';
|
import type { Client } from '@/types';
|
||||||
|
|
||||||
interface ClientTableProps {
|
interface ClientTableProps {
|
||||||
@@ -18,6 +20,9 @@ interface ClientTableProps {
|
|||||||
onPageChange: (skip: number) => void;
|
onPageChange: (skip: number) => void;
|
||||||
onLimitChange: (limit: number) => void;
|
onLimitChange: (limit: number) => void;
|
||||||
onSortingChange: (sorting: SortingState) => void;
|
onSortingChange: (sorting: SortingState) => void;
|
||||||
|
onEdit: (client: Client) => void;
|
||||||
|
onToggleStatus: (client: Client) => void;
|
||||||
|
pendingClientId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClientTable({
|
export function ClientTable({
|
||||||
@@ -32,6 +37,9 @@ export function ClientTable({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
onSortingChange,
|
onSortingChange,
|
||||||
|
onEdit,
|
||||||
|
onToggleStatus,
|
||||||
|
pendingClientId,
|
||||||
}: ClientTableProps) {
|
}: ClientTableProps) {
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -41,6 +49,21 @@ export function ClientTable({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
toolbar={toolbar}
|
toolbar={toolbar}
|
||||||
emptyTitle="No clients found."
|
emptyTitle="No clients found."
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit className="size-4" />,
|
||||||
|
permission: PERMISSIONS.CLIENT.UPDATE,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (client) => (client.is_active ? 'Deactivate' : 'Activate'),
|
||||||
|
icon: <RotateCcw className="size-4" />,
|
||||||
|
permission: PERMISSIONS.CLIENT.DELETE,
|
||||||
|
disabled: (client) => pendingClientId === client.id,
|
||||||
|
onClick: onToggleStatus,
|
||||||
|
},
|
||||||
|
]}
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export function useClientFilters() {
|
|||||||
const [limit, setLimitValue] = useState(10);
|
const [limit, setLimitValue] = useState(10);
|
||||||
const [sorting, setSortingValue] = useState<SortingState>([]);
|
const [sorting, setSortingValue] = useState<SortingState>([]);
|
||||||
const [searchTerm, setSearchTermValue] = useState('');
|
const [searchTerm, setSearchTermValue] = useState('');
|
||||||
const [statusFilter, setStatusFilterValue] = useState<ClientStatusFilter>('all');
|
const [statusFilter, setStatusFilterValue] =
|
||||||
|
useState<ClientStatusFilter>('all');
|
||||||
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
|
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
|
||||||
|
|
||||||
const setSearchTerm = useCallback((value: string) => {
|
const setSearchTerm = useCallback((value: string) => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ function buildClientListParams({
|
|||||||
export function useClientsQuery(params: UseClientsQueryParams) {
|
export function useClientsQuery(params: UseClientsQueryParams) {
|
||||||
const { skip, limit, searchTerm, statusFilter, sorting } = params;
|
const { skip, limit, searchTerm, statusFilter, sorting } = params;
|
||||||
const listParams = useMemo(
|
const listParams = useMemo(
|
||||||
() => buildClientListParams({ skip, limit, searchTerm, statusFilter, sorting }),
|
() =>
|
||||||
|
buildClientListParams({ skip, limit, searchTerm, statusFilter, sorting }),
|
||||||
[limit, searchTerm, skip, sorting, statusFilter],
|
[limit, searchTerm, skip, sorting, statusFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
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';
|
||||||
@@ -45,8 +44,18 @@ export default function ClientsPage() {
|
|||||||
const clientForm = useClientForm({
|
const clientForm = useClientForm({
|
||||||
onSaved: () => setIsSheetOpen(false),
|
onSaved: () => setIsSheetOpen(false),
|
||||||
});
|
});
|
||||||
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } = clientForm;
|
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } =
|
||||||
const { register, handleSubmit, clientId, isSaving } = clientForm;
|
clientForm;
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
clientId,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
|
canSubmit,
|
||||||
|
isSaving,
|
||||||
|
} = clientForm;
|
||||||
const statusMutation = useClientStatusMutation();
|
const statusMutation = useClientStatusMutation();
|
||||||
const { mutate: updateClientStatus, pendingClientId } = statusMutation;
|
const { mutate: updateClientStatus, pendingClientId } = statusMutation;
|
||||||
|
|
||||||
@@ -70,11 +79,7 @@ export default function ClientsPage() {
|
|||||||
[updateClientStatus],
|
[updateClientStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useClientColumns({
|
const columns = useClientColumns();
|
||||||
onEdit: openEdit,
|
|
||||||
onToggleStatus: toggleStatus,
|
|
||||||
pendingClientId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = clientsQuery.data?.total ?? 0;
|
const total = clientsQuery.data?.total ?? 0;
|
||||||
const clients = clientsQuery.data?.items ?? [];
|
const clients = clientsQuery.data?.items ?? [];
|
||||||
@@ -119,9 +124,10 @@ export default function ClientsPage() {
|
|||||||
onPageChange={setSkip}
|
onPageChange={setSkip}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={setLimit}
|
||||||
onSortingChange={setSorting}
|
onSortingChange={setSorting}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onToggleStatus={toggleStatus}
|
||||||
|
pendingClientId={pendingClientId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<ClientSheet
|
<ClientSheet
|
||||||
@@ -129,7 +135,11 @@ export default function ClientsPage() {
|
|||||||
onOpenChange={setIsSheetOpen}
|
onOpenChange={setIsSheetOpen}
|
||||||
clientId={clientId}
|
clientId={clientId}
|
||||||
register={register}
|
register={register}
|
||||||
|
control={control}
|
||||||
|
errors={errors}
|
||||||
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
canSubmit={canSubmit}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,645 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
Activity,
|
|
||||||
MapPin as MapPinIcon,
|
|
||||||
Milestone,
|
|
||||||
BarChart3,
|
|
||||||
AlertTriangle,
|
|
||||||
Zap,
|
|
||||||
PencilLine,
|
|
||||||
CheckCircle2,
|
|
||||||
Settings2,
|
|
||||||
Droplets,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { StatsCard } from '@/components/dashboard/stats-card';
|
|
||||||
import { FilterSelector } from '@/components/dashboard/filter-selector';
|
|
||||||
import { DetectionDonutChart } from '@/components/dashboard/detection-donut-chart';
|
|
||||||
import { ChainageBarChart } from '@/components/dashboard/chainage-bar-chart';
|
|
||||||
import { DashboardMap } from '@/components/dashboard/dashboard-map';
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
|
||||||
import { projectService, projectSummaryService } from '@/services/api';
|
|
||||||
import { Project } from '@/types';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton';
|
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Reveal, StaggerContainer, StaggerItem } from '@/components/ui/reveal';
|
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
|
||||||
|
|
||||||
interface ProjectSummary {
|
|
||||||
project: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
corridor_name: string | null;
|
|
||||||
state: string | null;
|
|
||||||
};
|
|
||||||
packages: {
|
|
||||||
[key: string]: {
|
|
||||||
package_id: string;
|
|
||||||
region: string | null;
|
|
||||||
chainages: {
|
|
||||||
[key: string]: {
|
|
||||||
chainage_id: string;
|
|
||||||
chainage: string | null;
|
|
||||||
detection_count: number;
|
|
||||||
detections: Array<{
|
|
||||||
id: number;
|
|
||||||
type: string;
|
|
||||||
class: string;
|
|
||||||
confidence: number;
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DetectionStats {
|
|
||||||
totalDefectedSignboard: number;
|
|
||||||
totalPothole: number;
|
|
||||||
totalRoadCrack: number;
|
|
||||||
totalDamagedRoadMarking: number;
|
|
||||||
totalGoodSignboard: number;
|
|
||||||
totalDrainIssue: number;
|
|
||||||
totalDefectiveCulvert: number;
|
|
||||||
totalRoadDamage: number;
|
|
||||||
chainageData: Array<{
|
|
||||||
name: string;
|
|
||||||
defected_sign_board: number;
|
|
||||||
pothole: number;
|
|
||||||
road_crack: number;
|
|
||||||
damaged_road_marking: number;
|
|
||||||
// good_sign_board: number;
|
|
||||||
drain_issue: number;
|
|
||||||
defective_culvert: number;
|
|
||||||
total: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateStats(summary: ProjectSummary | null): DetectionStats {
|
|
||||||
if (!summary) {
|
|
||||||
return {
|
|
||||||
totalDefectedSignboard: 0,
|
|
||||||
totalPothole: 0,
|
|
||||||
totalRoadCrack: 0,
|
|
||||||
totalDamagedRoadMarking: 0,
|
|
||||||
totalGoodSignboard: 0,
|
|
||||||
totalDrainIssue: 0,
|
|
||||||
totalDefectiveCulvert: 0,
|
|
||||||
totalRoadDamage: 0,
|
|
||||||
chainageData: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let total_defected_sign_board = 0;
|
|
||||||
let total_pothole = 0;
|
|
||||||
let total_road_crack = 0;
|
|
||||||
let total_damaged_road_marking = 0;
|
|
||||||
let total_good_sign_board = 0;
|
|
||||||
let total_drain_issue = 0;
|
|
||||||
let total_defective_culvert = 0;
|
|
||||||
let total_road_damage = 0;
|
|
||||||
const chainageData: DetectionStats['chainageData'] = [];
|
|
||||||
|
|
||||||
for (const pkg of Object.values(summary.packages || {})) {
|
|
||||||
for (const [chnName, chn] of Object.entries(pkg.chainages || {})) {
|
|
||||||
let chnDefectedSignboard = 0;
|
|
||||||
let chnPothole = 0;
|
|
||||||
let chnRoadCrack = 0;
|
|
||||||
let chnDamagedRoadMarking = 0;
|
|
||||||
let chnGoodSignboard = 0;
|
|
||||||
let chnDrainIssue = 0;
|
|
||||||
let chnDefectiveCulvert = 0;
|
|
||||||
|
|
||||||
for (const detection of chn.detections || []) {
|
|
||||||
const type = (detection.type || '').toLowerCase();
|
|
||||||
if (type === 'defected_sign_board') {
|
|
||||||
chnDefectedSignboard++;
|
|
||||||
total_defected_sign_board++;
|
|
||||||
} else if (type === 'pothole') {
|
|
||||||
chnPothole++;
|
|
||||||
total_pothole++;
|
|
||||||
} else if (type === 'road_crack') {
|
|
||||||
chnRoadCrack++;
|
|
||||||
total_road_crack++;
|
|
||||||
} else if (type === 'damaged_road_marking') {
|
|
||||||
chnDamagedRoadMarking++;
|
|
||||||
total_damaged_road_marking++;
|
|
||||||
} else if (type === 'good_sign_board') {
|
|
||||||
chnGoodSignboard++;
|
|
||||||
// total_good_sign_board++;
|
|
||||||
} else if (type === 'drain_issue') {
|
|
||||||
chnDrainIssue++;
|
|
||||||
total_drain_issue++;
|
|
||||||
} else if (type === 'defective_culvert') {
|
|
||||||
chnDefectiveCulvert++;
|
|
||||||
total_defective_culvert++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
chnDefectedSignboard > 0 ||
|
|
||||||
chnPothole > 0 ||
|
|
||||||
chnRoadCrack > 0 ||
|
|
||||||
chnDamagedRoadMarking > 0 ||
|
|
||||||
chnGoodSignboard > 0 ||
|
|
||||||
chnDrainIssue > 0 ||
|
|
||||||
chnDefectiveCulvert > 0
|
|
||||||
) {
|
|
||||||
const shortName = chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
|
|
||||||
chainageData.push({
|
|
||||||
name: shortName,
|
|
||||||
defected_sign_board: chnDefectedSignboard,
|
|
||||||
pothole: chnPothole,
|
|
||||||
road_crack: chnRoadCrack,
|
|
||||||
damaged_road_marking: chnDamagedRoadMarking,
|
|
||||||
// good_sign_board: chnGoodSignboard,
|
|
||||||
drain_issue: chnDrainIssue,
|
|
||||||
defective_culvert: chnDefectiveCulvert,
|
|
||||||
total:
|
|
||||||
chnDefectedSignboard +
|
|
||||||
chnPothole +
|
|
||||||
chnRoadCrack +
|
|
||||||
chnDamagedRoadMarking +
|
|
||||||
// chnGoodSignboard +
|
|
||||||
chnDrainIssue +
|
|
||||||
chnDefectiveCulvert,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
total_road_damage =
|
|
||||||
total_defected_sign_board +
|
|
||||||
total_pothole +
|
|
||||||
total_road_crack +
|
|
||||||
total_damaged_road_marking +
|
|
||||||
total_drain_issue +
|
|
||||||
total_defective_culvert;
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalDefectedSignboard: total_defected_sign_board,
|
|
||||||
totalPothole: total_pothole,
|
|
||||||
totalRoadCrack: total_road_crack,
|
|
||||||
totalDamagedRoadMarking: total_damaged_road_marking,
|
|
||||||
totalGoodSignboard: total_good_sign_board,
|
|
||||||
totalDrainIssue: total_drain_issue,
|
|
||||||
totalDefectiveCulvert: total_defective_culvert,
|
|
||||||
totalRoadDamage: total_road_damage,
|
|
||||||
chainageData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
|
||||||
const [projectSummary, setProjectSummary] = useState<ProjectSummary | null>(null);
|
|
||||||
const [stats, setStats] = useState<DetectionStats>({
|
|
||||||
totalDefectedSignboard: 0,
|
|
||||||
totalPothole: 0,
|
|
||||||
totalRoadCrack: 0,
|
|
||||||
totalDamagedRoadMarking: 0,
|
|
||||||
totalGoodSignboard: 0,
|
|
||||||
totalDrainIssue: 0,
|
|
||||||
totalDefectiveCulvert: 0,
|
|
||||||
totalRoadDamage: 0,
|
|
||||||
chainageData: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load projects on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const loadProjects = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const projectsData = await projectService.getProjects();
|
|
||||||
setProjects(projectsData.items);
|
|
||||||
|
|
||||||
if (projectsData.items.length > 0) {
|
|
||||||
setSelectedProjectId(projectsData.items[projectsData.items.length - 1].id);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load projects:', err);
|
|
||||||
toast.error('Failed to load projects', {
|
|
||||||
description: 'Please check if the backend is running.',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadProjects();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectedProject = projects.find((p) => p.id === selectedProjectId);
|
|
||||||
|
|
||||||
// Extract packages from project summary
|
|
||||||
const packages = projectSummary
|
|
||||||
? Object.keys(projectSummary.packages || {}).map((pkgName) => ({
|
|
||||||
id: pkgName,
|
|
||||||
name: pkgName,
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Extract chainages from selected package
|
|
||||||
const [selectedPackageId, setSelectedPackageId] = useState<string | null>(null);
|
|
||||||
const [selectedChainageId, setSelectedChainageId] = useState<string | null>(null); // Changed 'selectedLocationId' to 'selectedChainageId'
|
|
||||||
|
|
||||||
const chainages =
|
|
||||||
projectSummary && selectedPackageId && selectedPackageId !== 'all'
|
|
||||||
? Object.keys(projectSummary.packages[selectedPackageId]?.chainages || {}).map((chnName) => ({
|
|
||||||
id: chnName,
|
|
||||||
name: chnName,
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Load project summary when project changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedProjectId) {
|
|
||||||
setProjectSummary(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setProjectSummary(null);
|
|
||||||
|
|
||||||
const loadProjectSummary = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const summary = await projectSummaryService.getProjectSummary<ProjectSummary>(
|
|
||||||
selectedProjectId,
|
|
||||||
);
|
|
||||||
setProjectSummary(summary);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load project summary:', err);
|
|
||||||
toast.error('Failed to load project summary');
|
|
||||||
setProjectSummary(null);
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadProjectSummary();
|
|
||||||
}, [selectedProjectId]);
|
|
||||||
|
|
||||||
// Reset package and chainage when project changes
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedPackageId(null);
|
|
||||||
setSelectedChainageId(null);
|
|
||||||
}, [selectedProjectId]);
|
|
||||||
|
|
||||||
// Auto-select last package when project summary is loaded
|
|
||||||
useEffect(() => {
|
|
||||||
if (projectSummary && selectedPackageId === null) {
|
|
||||||
const packageIds = Object.keys(projectSummary.packages || {});
|
|
||||||
if (packageIds.length > 0) {
|
|
||||||
setSelectedPackageId(packageIds[packageIds.length - 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [projectSummary, selectedPackageId]);
|
|
||||||
|
|
||||||
// Reset chainage when package changes
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedChainageId(null);
|
|
||||||
}, [selectedPackageId]);
|
|
||||||
|
|
||||||
// Auto-select last chainage when package is selected
|
|
||||||
useEffect(() => {
|
|
||||||
if (projectSummary && selectedPackageId && selectedPackageId !== 'all' && selectedChainageId === null) {
|
|
||||||
const chainageIds = Object.keys(projectSummary.packages[selectedPackageId]?.chainages || {});
|
|
||||||
if (chainageIds.length > 0) {
|
|
||||||
setSelectedChainageId(chainageIds[chainageIds.length - 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [projectSummary, selectedPackageId, selectedChainageId]);
|
|
||||||
|
|
||||||
// Filter stats based on selections
|
|
||||||
useEffect(() => {
|
|
||||||
if (!projectSummary) {
|
|
||||||
setStats({
|
|
||||||
totalDefectedSignboard: 0,
|
|
||||||
totalPothole: 0,
|
|
||||||
totalRoadCrack: 0,
|
|
||||||
totalDamagedRoadMarking: 0,
|
|
||||||
totalGoodSignboard: 0,
|
|
||||||
totalDrainIssue: 0,
|
|
||||||
totalDefectiveCulvert: 0,
|
|
||||||
totalRoadDamage: 0,
|
|
||||||
chainageData: [], // Changed 'locationData' to 'chainageData'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalDefectedSignboard = 0;
|
|
||||||
let totalPothole = 0;
|
|
||||||
let totalRoadCrack = 0;
|
|
||||||
let totalDamagedRoadMarking = 0;
|
|
||||||
let totalGoodSignboard = 0;
|
|
||||||
let totalDrainIssue = 0;
|
|
||||||
let totalDefectiveCulvert = 0;
|
|
||||||
const chainageData: DetectionStats['chainageData'] = [];
|
|
||||||
|
|
||||||
const packagesToProcess =
|
|
||||||
selectedPackageId && selectedPackageId !== 'all'
|
|
||||||
? { [selectedPackageId]: projectSummary.packages[selectedPackageId] }
|
|
||||||
: projectSummary.packages || {};
|
|
||||||
|
|
||||||
for (const [pkgName, pkg] of Object.entries(packagesToProcess)) {
|
|
||||||
if (!pkg) continue;
|
|
||||||
|
|
||||||
const chainagesToProcess =
|
|
||||||
selectedChainageId && selectedChainageId !== 'all'
|
|
||||||
? { [selectedChainageId]: pkg.chainages?.[selectedChainageId] }
|
|
||||||
: pkg.chainages || {};
|
|
||||||
|
|
||||||
for (const [chnName, chn] of Object.entries(chainagesToProcess)) {
|
|
||||||
if (!chn) continue;
|
|
||||||
|
|
||||||
let chnDefectedSignboard = 0;
|
|
||||||
let chnPothole = 0;
|
|
||||||
let chnRoadCrack = 0;
|
|
||||||
let chnDamagedRoadMarking = 0;
|
|
||||||
let chnGoodSignboard = 0;
|
|
||||||
let chnDrainIssue = 0;
|
|
||||||
let chnDefectiveCulvert = 0;
|
|
||||||
|
|
||||||
for (const detection of chn.detections || []) {
|
|
||||||
const type = (detection.type || '').toLowerCase();
|
|
||||||
if (type === 'defected_sign_board') {
|
|
||||||
chnDefectedSignboard++;
|
|
||||||
totalDefectedSignboard++;
|
|
||||||
} else if (type === 'pothole') {
|
|
||||||
chnPothole++;
|
|
||||||
totalPothole++;
|
|
||||||
} else if (type === 'road_crack') {
|
|
||||||
chnRoadCrack++;
|
|
||||||
totalRoadCrack++;
|
|
||||||
} else if (type === 'damaged_road_marking') {
|
|
||||||
chnDamagedRoadMarking++;
|
|
||||||
totalDamagedRoadMarking++;
|
|
||||||
} else if (type === 'good_sign_board') {
|
|
||||||
// chnGoodSignboard++;
|
|
||||||
// totalGoodSignboard++;
|
|
||||||
} else if (type === 'drain_issue') {
|
|
||||||
chnDrainIssue++;
|
|
||||||
totalDrainIssue++;
|
|
||||||
} else if (type === 'defective_culvert') {
|
|
||||||
chnDefectiveCulvert++;
|
|
||||||
totalDefectiveCulvert++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
chnDefectedSignboard > 0 ||
|
|
||||||
chnPothole > 0 ||
|
|
||||||
chnRoadCrack > 0 ||
|
|
||||||
chnDamagedRoadMarking > 0 ||
|
|
||||||
chnGoodSignboard > 0 ||
|
|
||||||
chnDrainIssue > 0 ||
|
|
||||||
chnDefectiveCulvert > 0
|
|
||||||
) {
|
|
||||||
const shortName = chnName.length > 20 ? chnName.substring(0, 20) + '...' : chnName;
|
|
||||||
chainageData.push({
|
|
||||||
name: shortName,
|
|
||||||
defected_sign_board: chnDefectedSignboard,
|
|
||||||
pothole: chnPothole,
|
|
||||||
road_crack: chnRoadCrack,
|
|
||||||
damaged_road_marking: chnDamagedRoadMarking,
|
|
||||||
// good_sign_board: chnGoodSignboard,
|
|
||||||
drain_issue: chnDrainIssue,
|
|
||||||
defective_culvert: chnDefectiveCulvert,
|
|
||||||
total:
|
|
||||||
chnDefectedSignboard +
|
|
||||||
chnPothole +
|
|
||||||
chnRoadCrack +
|
|
||||||
chnDamagedRoadMarking +
|
|
||||||
// chnGoodSignboard +
|
|
||||||
chnDrainIssue +
|
|
||||||
chnDefectiveCulvert,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setStats({
|
|
||||||
totalDefectedSignboard,
|
|
||||||
totalPothole,
|
|
||||||
totalRoadCrack,
|
|
||||||
totalDamagedRoadMarking,
|
|
||||||
totalGoodSignboard,
|
|
||||||
totalDrainIssue,
|
|
||||||
totalDefectiveCulvert,
|
|
||||||
totalRoadDamage:
|
|
||||||
totalDefectedSignboard +
|
|
||||||
totalPothole +
|
|
||||||
totalRoadCrack +
|
|
||||||
totalDamagedRoadMarking +
|
|
||||||
totalDrainIssue +
|
|
||||||
totalDefectiveCulvert,
|
|
||||||
chainageData,
|
|
||||||
});
|
|
||||||
}, [projectSummary, selectedPackageId, selectedChainageId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Main Content */}
|
|
||||||
<main>
|
|
||||||
<div>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-8 flex items-center justify-between">
|
|
||||||
<PageHeader
|
|
||||||
title="Dashboard"
|
|
||||||
description="A Comprehensive Overview Of Your Road Infrastructure Analysis"
|
|
||||||
icon={BarChart3}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Reveal direction="down" delay={0.2}>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" className="gap-2 font-semibold">
|
|
||||||
<Settings2 className="h-4 w-4" />
|
|
||||||
<span>Filters</span>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[320px] p-0 overflow-hidden" align="end">
|
|
||||||
<div className="p-4 border-b bg-muted/30">
|
|
||||||
<h3 className="font-bold text-sm flex items-center gap-2 text-foreground">
|
|
||||||
<Milestone className="h-4 w-4 text-primary" />
|
|
||||||
Filter Analysis
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs mt-1 text-muted-foreground">
|
|
||||||
Refine your view by project, package, or segment
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FilterSelector
|
|
||||||
projects={projects}
|
|
||||||
selectedProjectId={selectedProjectId}
|
|
||||||
selectedPackageId={selectedPackageId}
|
|
||||||
selectedChainageId={selectedChainageId} // Changed 'selectedLocationId' to 'selectedChainageId'
|
|
||||||
onProjectChange={setSelectedProjectId}
|
|
||||||
onPackageChange={setSelectedPackageId}
|
|
||||||
onChainageChange={setSelectedChainageId} // Changed 'onLocationChange' to 'onChainageChange'
|
|
||||||
packages={packages}
|
|
||||||
chainages={chainages} // Changed 'locations' to 'chainages'
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</Reveal>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading && !projectSummary ? (
|
|
||||||
<DashboardSkeleton />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Stats Cards - Top Row */}
|
|
||||||
<StaggerContainer className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
|
||||||
<StaggerItem>
|
|
||||||
<StatsCard
|
|
||||||
title="Total Road Damage"
|
|
||||||
subtitle="Combined damage detections"
|
|
||||||
value={stats.totalRoadDamage}
|
|
||||||
icon={Activity}
|
|
||||||
/>
|
|
||||||
</StaggerItem>
|
|
||||||
<StaggerItem>
|
|
||||||
<StatsCard
|
|
||||||
title="Potholes"
|
|
||||||
subtitle="Surface depressions"
|
|
||||||
value={stats.totalPothole}
|
|
||||||
icon={AlertCircle}
|
|
||||||
/>
|
|
||||||
</StaggerItem>
|
|
||||||
<StaggerItem>
|
|
||||||
<StatsCard
|
|
||||||
title="Defected Signboards"
|
|
||||||
subtitle="Damaged traffic signs"
|
|
||||||
value={stats.totalDefectedSignboard}
|
|
||||||
icon={AlertTriangle}
|
|
||||||
/>
|
|
||||||
</StaggerItem>
|
|
||||||
<StaggerItem>
|
|
||||||
<StatsCard
|
|
||||||
title="Road Cracks"
|
|
||||||
subtitle="Surface fissures"
|
|
||||||
value={stats.totalRoadCrack}
|
|
||||||
icon={Zap}
|
|
||||||
/>
|
|
||||||
</StaggerItem>
|
|
||||||
<StaggerItem>
|
|
||||||
<StatsCard
|
|
||||||
title="Damaged Markings"
|
|
||||||
subtitle="Worn road lines"
|
|
||||||
value={stats.totalDamagedRoadMarking}
|
|
||||||
icon={PencilLine}
|
|
||||||
/>
|
|
||||||
</StaggerItem>
|
|
||||||
<StaggerItem>
|
|
||||||
<StatsCard
|
|
||||||
title="Drain Issues"
|
|
||||||
subtitle="Clogged or broken drainage"
|
|
||||||
value={stats.totalDrainIssue}
|
|
||||||
icon={Droplets}
|
|
||||||
/>
|
|
||||||
</StaggerItem>
|
|
||||||
{/* <StaggerItem>
|
|
||||||
<StatsCard
|
|
||||||
title="Good Signboards"
|
|
||||||
subtitle="Informational markers"
|
|
||||||
value={stats.totalGoodSignboard}
|
|
||||||
icon={CheckCircle2}
|
|
||||||
/>
|
|
||||||
</StaggerItem> */}
|
|
||||||
</StaggerContainer>
|
|
||||||
|
|
||||||
{/* Charts Row - Side by Side */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
|
||||||
<Reveal delay={0.2} direction="left" className="flex flex-col">
|
|
||||||
<Card className="flex-1 h-full">
|
|
||||||
<CardHeader className="items-center pb-0">
|
|
||||||
<CardTitle>Detection Distribution</CardTitle>
|
|
||||||
<CardDescription>Breakdown of all detected road conditions</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex-1 pb-0">
|
|
||||||
<DetectionDonutChart
|
|
||||||
defectedSignboard={stats.totalDefectedSignboard}
|
|
||||||
pothole={stats.totalPothole}
|
|
||||||
roadCrack={stats.totalRoadCrack}
|
|
||||||
damagedRoadMarking={stats.totalDamagedRoadMarking}
|
|
||||||
// goodSignboard={stats.totalGoodSignboard}
|
|
||||||
drainIssue={stats.totalDrainIssue}
|
|
||||||
defectiveCulvert={stats.totalDefectiveCulvert}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex-col gap-2 text-sm">
|
|
||||||
<div className="leading-none text-muted-foreground">
|
|
||||||
Total detections:{' '}
|
|
||||||
{stats.totalRoadDamage +
|
|
||||||
// stats.totalGoodSignboard +
|
|
||||||
stats.totalDrainIssue +
|
|
||||||
stats.totalDefectiveCulvert}
|
|
||||||
</div>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
<Reveal delay={0.3} direction="right" className="flex flex-col">
|
|
||||||
<Card className="flex-1 h-full">
|
|
||||||
<CardHeader className="items-center pb-0">
|
|
||||||
<CardTitle className="text-base font-bold">Severity by Segment</CardTitle>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
Detections grouped by road segment
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="h-[300px]">
|
|
||||||
<ChainageBarChart data={stats.chainageData || []} isLoading={isLoading} />
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex-col gap-2 text-sm">
|
|
||||||
<div className="leading-none text-muted-foreground">
|
|
||||||
Analysis based on latest processed sequence
|
|
||||||
</div>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</Reveal>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Map - Full Width Below Charts */}
|
|
||||||
<Reveal delay={0.4} direction="up">
|
|
||||||
<div>
|
|
||||||
<DashboardMap
|
|
||||||
selectedProjectId={selectedProjectId}
|
|
||||||
selectedPackageId={selectedPackageId}
|
|
||||||
selectedChainageId={selectedChainageId} // Changed 'selectedLocationId' to 'selectedChainageId'
|
|
||||||
projectSummary={projectSummary}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { useMemo } from 'react';
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
import type { Package, Project } from '@/types';
|
import type { Package, Project } from '@/types';
|
||||||
|
|
||||||
function EmptyValue() {
|
function EmptyValue() {
|
||||||
@@ -35,16 +39,14 @@ function StateBadges({ value }: { value: string | null }) {
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-[10px] font-bold text-muted-foreground transition-colors hover:bg-muted"
|
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-muted-foreground transition-colors hover:bg-muted"
|
||||||
>
|
>
|
||||||
+{remainingStates.length}
|
+{remainingStates.length}
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-2" align="start">
|
<PopoverContent className="w-auto p-2" align="start">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="mb-0.5 px-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
|
||||||
Other States
|
|
||||||
</p>
|
|
||||||
{remainingStates.map((state) => (
|
{remainingStates.map((state) => (
|
||||||
<Badge key={state} variant="secondary">
|
<Badge key={state} variant="secondary">
|
||||||
{state}
|
{state}
|
||||||
@@ -64,20 +66,22 @@ export function usePackageColumns(projects: Project[]) {
|
|||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Package Name',
|
header: 'Package Name',
|
||||||
cell: ({ row }) => <div className="font-semibold">{row.original.name}</div>,
|
cell: ({ row }) => <div>{row.original.name}</div>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'project_id',
|
accessorKey: 'project_id',
|
||||||
header: 'Project',
|
header: 'Project',
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
projects.find((project) => project.id === row.original.project_id)?.name ||
|
projects.find((project) => project.id === row.original.project_id)
|
||||||
row.original.project_id,
|
?.name || row.original.project_id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'project_state',
|
id: 'project_state',
|
||||||
header: 'Project State',
|
header: 'Project State',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const project = projects.find((item) => item.id === row.original.project_id);
|
const project = projects.find(
|
||||||
|
(item) => item.id === row.original.project_id,
|
||||||
|
);
|
||||||
return <StateBadges value={project?.state ?? null} />;
|
return <StateBadges value={project?.state ?? null} />;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form';
|
import { useRef } from 'react';
|
||||||
import { Globe, Loader2, Package as PackageIcon } from 'lucide-react';
|
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { FormField } from '@/components/form';
|
import { FormField } from '@/components/form';
|
||||||
|
import { ProjectSelect } from '@/components/lookups/ProjectSelect';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
@@ -15,14 +18,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import type { Project } from '@/types';
|
|
||||||
|
|
||||||
import type { PackageFormValues } from '../hooks/usePackageForm';
|
import type { PackageFormValues } from '../hooks/usePackageForm';
|
||||||
|
|
||||||
@@ -30,13 +25,11 @@ interface PackageDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
packageId?: string;
|
packageId?: string;
|
||||||
projects: Project[];
|
|
||||||
isProjectsLoading: boolean;
|
|
||||||
projectId: string;
|
projectId: string;
|
||||||
onProjectChange: (projectId: string) => void;
|
onProjectChange: (projectId: string) => void;
|
||||||
register: UseFormRegister<PackageFormValues>;
|
register: UseFormRegister<PackageFormValues>;
|
||||||
errors: FieldErrors<PackageFormValues>;
|
errors: FieldErrors<PackageFormValues>;
|
||||||
touchedFields: UseFormReturn<PackageFormValues>['formState']['touchedFields'];
|
isSubmitted: boolean;
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
canSubmit: boolean;
|
canSubmit: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
@@ -46,124 +39,162 @@ export function PackageDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
packageId,
|
packageId,
|
||||||
projects,
|
|
||||||
isProjectsLoading,
|
|
||||||
projectId,
|
projectId,
|
||||||
onProjectChange,
|
onProjectChange,
|
||||||
register,
|
register,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: PackageDialogProps) {
|
}: PackageDialogProps) {
|
||||||
const projectErrorMessage = touchedFields.project_id ? errors.project_id?.message : undefined;
|
const projectSelectPortalRef = useRef<HTMLDivElement | null>(null);
|
||||||
const nameErrorMessage = touchedFields.name ? errors.name?.message : undefined;
|
const projectErrorMessage = isSubmitted
|
||||||
const startErrorMessage = touchedFields.chainage_start_km
|
? errors.project_id?.message
|
||||||
|
: undefined;
|
||||||
|
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
|
||||||
|
const startErrorMessage = isSubmitted
|
||||||
? errors.chainage_start_km?.message
|
? errors.chainage_start_km?.message
|
||||||
: undefined;
|
: undefined;
|
||||||
const endErrorMessage = touchedFields.chainage_end_km
|
const endErrorMessage = isSubmitted
|
||||||
? errors.chainage_end_km?.message
|
? errors.chainage_end_km?.message
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-2xl" onOpenAutoFocus={(event) => event.preventDefault()}>
|
<DialogContent
|
||||||
<DialogHeader className="gap-2">
|
className="sm:max-w-2xl"
|
||||||
<DialogTitle className="flex items-center gap-3 text-xl">
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
|
>
|
||||||
<PackageIcon className="size-5" />
|
<DialogHeader>
|
||||||
</div>
|
<DialogTitle>
|
||||||
{packageId ? 'Edit Package Details' : 'Create New Package'}
|
{packageId ? 'Edit Package' : 'Create Package'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm">
|
<DialogDescription>
|
||||||
{packageId
|
Manage package details and optional chainage range.
|
||||||
? 'Update the technical specifications for your road infrastructure package.'
|
|
||||||
: 'Select a project and provide the essential data to establish a new package.'}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="space-y-6">
|
<form
|
||||||
|
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 ? (
|
{!packageId ? (
|
||||||
<FormField label="Project" required error={projectErrorMessage}>
|
<FormField label="Project" required error={projectErrorMessage}>
|
||||||
<Select value={projectId} onValueChange={onProjectChange}>
|
<ProjectSelect
|
||||||
<SelectTrigger aria-invalid={!!projectErrorMessage}>
|
value={projectId}
|
||||||
{isProjectsLoading ? (
|
onValueChange={onProjectChange}
|
||||||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
enabled={open}
|
||||||
<Loader2 className="size-4 animate-spin" />
|
disabled={isSaving}
|
||||||
Loading...
|
portalContainer={projectSelectPortalRef}
|
||||||
</span>
|
/>
|
||||||
) : (
|
<input
|
||||||
<SelectValue placeholder="Choose a project" />
|
type="hidden"
|
||||||
)}
|
{...register('project_id')}
|
||||||
</SelectTrigger>
|
value={projectId}
|
||||||
<SelectContent>
|
readOnly
|
||||||
{projects.map((project) => (
|
/>
|
||||||
<SelectItem key={project.id} value={project.id}>
|
|
||||||
{project.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" {...register('project_id')} value={projectId} readOnly />
|
|
||||||
</FormField>
|
</FormField>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField id="package-name" label="Package Name" required error={nameErrorMessage}>
|
<FormField
|
||||||
|
id="package-name"
|
||||||
|
label="Package Name"
|
||||||
|
required
|
||||||
|
error={nameErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="package-name"
|
id="package-name"
|
||||||
placeholder="e.g. Package 01"
|
placeholder="Enter package name"
|
||||||
aria-invalid={!!nameErrorMessage}
|
aria-invalid={!!nameErrorMessage}
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField id="package-region" label="Region">
|
||||||
|
<Input
|
||||||
id="package-region"
|
id="package-region"
|
||||||
label={
|
placeholder="Enter region"
|
||||||
<span className="inline-flex items-center gap-2">
|
{...register('region')}
|
||||||
<Globe className="size-3.5 opacity-60" />
|
/>
|
||||||
Region
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input id="package-region" placeholder="e.g. North Zone" {...register('region')} />
|
|
||||||
</FormField>
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<FormField id="package-start" label="Segment Start (km)" error={startErrorMessage}>
|
<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
|
<Input
|
||||||
id="package-start"
|
id="package-start"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="1"
|
||||||
placeholder="0.00"
|
min="0"
|
||||||
|
placeholder="Enter start chainage"
|
||||||
aria-invalid={!!startErrorMessage}
|
aria-invalid={!!startErrorMessage}
|
||||||
{...register('chainage_start_km')}
|
{...register('chainage_start_km')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField id="package-end" label="Segment End (km)" error={endErrorMessage}>
|
<FormField
|
||||||
|
id="package-end"
|
||||||
|
label="End Chainage"
|
||||||
|
error={endErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="package-end"
|
id="package-end"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="1"
|
||||||
placeholder="0.00"
|
min="0"
|
||||||
|
placeholder="Enter end chainage"
|
||||||
aria-invalid={!!endErrorMessage}
|
aria-invalid={!!endErrorMessage}
|
||||||
{...register('chainage_end_km')}
|
{...register('chainage_end_km')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||||
{isSaving ? <Loader2 className="size-4 animate-spin" /> : null}
|
{isSaving ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
{packageId ? 'Update Package' : 'Create Package'}
|
{packageId ? 'Update Package' : 'Create Package'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div ref={projectSelectPortalRef} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Edit3, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import type { Package } from '@/types';
|
import type { Package } from '@/types';
|
||||||
@@ -35,8 +36,19 @@ export function PackageTable({
|
|||||||
title="Packages"
|
title="Packages"
|
||||||
data={packages}
|
data={packages}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
onEdit={onEdit}
|
actions={[
|
||||||
onDelete={onDelete}
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit3 className="size-4" />,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
icon: <Trash2 className="size-4" />,
|
||||||
|
className: 'text-destructive',
|
||||||
|
onClick: onDelete,
|
||||||
|
},
|
||||||
|
]}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyTitle="No packages found."
|
emptyTitle="No packages found."
|
||||||
pagination={{
|
pagination={{
|
||||||
@@ -46,6 +58,7 @@ export function PackageTable({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
}}
|
}}
|
||||||
|
actionsSticky
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,29 +7,25 @@ import { useForm, useWatch } from 'react-hook-form';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
optionalIntegerNumber,
|
||||||
|
validateChainageRange,
|
||||||
|
} from '@/lib/validation/numberField';
|
||||||
import { packageService } from '@/services/api';
|
import { packageService } from '@/services/api';
|
||||||
import type { Package, PackageCreate, PackageUpdate } from '@/types';
|
import type { Package, PackageCreate, PackageUpdate } from '@/types';
|
||||||
|
|
||||||
import { packageKeys } from '../queries/packageKeys';
|
import { packageKeys } from '../queries/packageKeys';
|
||||||
|
|
||||||
const optionalNumber = z.string().refine(
|
const packageFormSchema = z
|
||||||
(value) => {
|
.object({
|
||||||
if (!value.trim()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return Number.isFinite(Number(value));
|
|
||||||
},
|
|
||||||
{ message: 'Enter a valid number' },
|
|
||||||
);
|
|
||||||
|
|
||||||
const packageFormSchema = z.object({
|
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
project_id: z.string().trim().min(1, 'Project is required'),
|
project_id: z.string().trim().min(1, 'Project is required'),
|
||||||
name: z.string().trim().min(1, 'Package name is required'),
|
name: z.string().trim().min(1, 'Package name is required'),
|
||||||
region: z.string().optional(),
|
region: z.string().optional(),
|
||||||
chainage_start_km: optionalNumber,
|
chainage_start_km: optionalIntegerNumber,
|
||||||
chainage_end_km: optionalNumber,
|
chainage_end_km: optionalIntegerNumber,
|
||||||
});
|
})
|
||||||
|
.superRefine(validateChainageRange);
|
||||||
|
|
||||||
export type PackageFormValues = z.infer<typeof packageFormSchema>;
|
export type PackageFormValues = z.infer<typeof packageFormSchema>;
|
||||||
|
|
||||||
@@ -49,7 +45,9 @@ function toNumberOrZero(value: string) {
|
|||||||
return value.trim() ? Number(value) : 0;
|
return value.trim() ? Number(value) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPackagePayload(values: PackageFormValues): PackageCreate | PackageUpdate {
|
function toPackagePayload(
|
||||||
|
values: PackageFormValues,
|
||||||
|
): PackageCreate | PackageUpdate {
|
||||||
return {
|
return {
|
||||||
project_id: values.project_id,
|
project_id: values.project_id,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
@@ -67,10 +65,10 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
control,
|
control,
|
||||||
formState: { errors, isSubmitting, touchedFields },
|
formState: { errors, isSubmitting, isSubmitted },
|
||||||
} = useForm<PackageFormValues>({
|
} = useForm<PackageFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
mode: 'onTouched',
|
mode: 'onSubmit',
|
||||||
reValidateMode: 'onChange',
|
reValidateMode: 'onChange',
|
||||||
resolver: zodResolver(packageFormSchema),
|
resolver: zodResolver(packageFormSchema),
|
||||||
});
|
});
|
||||||
@@ -78,7 +76,8 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
const packageId = useWatch({ control, name: 'id' });
|
const packageId = useWatch({ control, name: 'id' });
|
||||||
const projectId = useWatch({ control, name: 'project_id' }) || '';
|
const projectId = useWatch({ control, name: 'project_id' }) || '';
|
||||||
const packageName = useWatch({ control, name: 'name' }) || '';
|
const packageName = useWatch({ control, name: 'name' }) || '';
|
||||||
const canSubmit = projectId.trim().length > 0 && packageName.trim().length > 0;
|
const canSubmit =
|
||||||
|
projectId.trim().length > 0 && packageName.trim().length > 0;
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (values: PackageFormValues) => {
|
mutationFn: (values: PackageFormValues) => {
|
||||||
@@ -94,22 +93,13 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
queryClient.invalidateQueries({ queryKey: packageKeys.all });
|
queryClient.invalidateQueries({ queryKey: packageKeys.all });
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
onError: (_error, values) => {
|
||||||
toast.error(values.id ? 'Failed to update package' : 'Failed to create package');
|
toast.error(
|
||||||
|
values.id ? 'Failed to update package' : 'Failed to create package',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = handleSubmit(
|
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
|
||||||
(values) => saveMutation.mutate(values),
|
|
||||||
(formErrors) => {
|
|
||||||
if (formErrors.project_id?.message) {
|
|
||||||
toast.error(formErrors.project_id.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (formErrors.name?.message) {
|
|
||||||
toast.error(formErrors.name.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
@@ -130,7 +120,11 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const setProjectId = useCallback(
|
const setProjectId = useCallback(
|
||||||
(value: string) => setValue('project_id', value, { shouldDirty: true, shouldValidate: true }),
|
(value: string) =>
|
||||||
|
setValue('project_id', value, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
}),
|
||||||
[setValue],
|
[setValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -144,7 +138,7 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
projectId,
|
projectId,
|
||||||
setProjectId,
|
setProjectId,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving: isSubmitting || saveMutation.isPending,
|
isSaving: isSubmitting || saveMutation.isPending,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { toast } from 'sonner';
|
|||||||
import { packageService, projectService } from '@/services/api';
|
import { packageService, projectService } from '@/services/api';
|
||||||
import type { Package, PaginationParams } from '@/types';
|
import type { Package, PaginationParams } from '@/types';
|
||||||
|
|
||||||
|
import { projectKeys } from '../../project/queries/projectKeys';
|
||||||
import { packageKeys } from '../queries/packageKeys';
|
import { packageKeys } from '../queries/packageKeys';
|
||||||
|
|
||||||
interface UsePackagesQueryParams {
|
interface UsePackagesQueryParams {
|
||||||
@@ -15,7 +16,10 @@ interface UsePackagesQueryParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePackagesQuery({ skip, limit }: UsePackagesQueryParams) {
|
export function usePackagesQuery({ skip, limit }: UsePackagesQueryParams) {
|
||||||
const listParams = useMemo<PaginationParams>(() => ({ skip, limit }), [limit, skip]);
|
const listParams = useMemo<PaginationParams>(
|
||||||
|
() => ({ skip, limit }),
|
||||||
|
[limit, skip],
|
||||||
|
);
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: packageKeys.list(listParams),
|
queryKey: packageKeys.list(listParams),
|
||||||
@@ -25,7 +29,7 @@ export function usePackagesQuery({ skip, limit }: UsePackagesQueryParams) {
|
|||||||
|
|
||||||
export function useProjectOptionsQuery() {
|
export function useProjectOptionsQuery() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['projects', 'options'],
|
queryKey: projectKeys.options(),
|
||||||
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
|
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,16 @@ import { useCallback, useState } from 'react';
|
|||||||
import { Package as PackageIcon, Plus } from 'lucide-react';
|
import { Package as PackageIcon, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { Package } from '@/types';
|
import type { Package } from '@/types';
|
||||||
|
|
||||||
@@ -21,6 +30,7 @@ import {
|
|||||||
|
|
||||||
export default function PackagePage() {
|
export default function PackagePage() {
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [packageToDelete, setPackageToDelete] = useState<Package | null>(null);
|
||||||
const { skip, setSkip, limit, setLimit } = usePackageFilters();
|
const { skip, setSkip, limit, setLimit } = usePackageFilters();
|
||||||
const packagesQuery = usePackagesQuery({ skip, limit });
|
const packagesQuery = usePackagesQuery({ skip, limit });
|
||||||
const projectsQuery = useProjectOptionsQuery();
|
const projectsQuery = useProjectOptionsQuery();
|
||||||
@@ -38,7 +48,7 @@ export default function PackagePage() {
|
|||||||
projectId,
|
projectId,
|
||||||
setProjectId,
|
setProjectId,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
} = packageForm;
|
} = packageForm;
|
||||||
@@ -68,14 +78,25 @@ export default function PackagePage() {
|
|||||||
|
|
||||||
const deletePackage = useCallback(
|
const deletePackage = useCallback(
|
||||||
(pkg: Package) => {
|
(pkg: Package) => {
|
||||||
if (!confirm(`Are you sure you want to delete package "${pkg.name}"?`)) {
|
setPackageToDelete(pkg);
|
||||||
return;
|
|
||||||
}
|
|
||||||
deletePackageMutation.mutate(pkg);
|
|
||||||
},
|
},
|
||||||
[deletePackageMutation],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
setPackageToDelete(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmDeletePackage = useCallback(() => {
|
||||||
|
if (!packageToDelete) return;
|
||||||
|
|
||||||
|
deletePackageMutation.mutate(packageToDelete, {
|
||||||
|
onSettled: () => setPackageToDelete(null),
|
||||||
|
});
|
||||||
|
}, [deletePackageMutation, packageToDelete]);
|
||||||
|
|
||||||
const projects = projectsQuery.data?.items ?? [];
|
const projects = projectsQuery.data?.items ?? [];
|
||||||
const packages = packagesQuery.data?.items ?? [];
|
const packages = packagesQuery.data?.items ?? [];
|
||||||
const total = packagesQuery.data?.totalItems ?? 0;
|
const total = packagesQuery.data?.totalItems ?? 0;
|
||||||
@@ -108,25 +129,51 @@ export default function PackagePage() {
|
|||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDelete={deletePackage}
|
onDelete={deletePackage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<PackageDialog
|
<PackageDialog
|
||||||
open={isDialogOpen}
|
open={isDialogOpen}
|
||||||
onOpenChange={handleDialogOpenChange}
|
onOpenChange={handleDialogOpenChange}
|
||||||
packageId={packageId}
|
packageId={packageId}
|
||||||
projects={projects}
|
|
||||||
isProjectsLoading={projectsQuery.isLoading}
|
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onProjectChange={setProjectId}
|
onProjectChange={setProjectId}
|
||||||
register={register}
|
register={register}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
touchedFields={touchedFields}
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
canSubmit={canSubmit}
|
canSubmit={canSubmit}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={Boolean(packageToDelete)}
|
||||||
|
onOpenChange={handleDeleteDialogOpenChange}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete package?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{packageToDelete?.name}
|
||||||
|
</span>
|
||||||
|
. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deletePackageMutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deletePackageMutation.isPending}
|
||||||
|
onClick={confirmDeletePackage}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,7 @@ export const packageKeys = {
|
|||||||
all: ['packages'] as const,
|
all: ['packages'] as const,
|
||||||
lists: () => [...packageKeys.all, 'list'] as const,
|
lists: () => [...packageKeys.all, 'list'] as const,
|
||||||
list: (params: PaginationParams) => [...packageKeys.lists(), params] as const,
|
list: (params: PaginationParams) => [...packageKeys.lists(), params] as const,
|
||||||
|
options: () => [...packageKeys.all, 'options'] as const,
|
||||||
|
details: () => [...packageKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...packageKeys.details(), id] as const,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -67,7 +50,7 @@ export function usePlanColumns({
|
|||||||
header: 'Limits',
|
header: 'Limits',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
<p>{row.original.max_projects} projects</p>
|
<p>{row.original.max_projects} projects</p>
|
||||||
<p>{row.original.max_users} users</p>
|
<p>{row.original.max_users} users</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,43 +69,12 @@ export function usePlanColumns({
|
|||||||
<Badge variant={row.original.is_active ? 'default' : 'secondary'}>
|
<Badge variant={row.original.is_active ? 'default' : 'secondary'}>
|
||||||
{row.original.is_active ? 'Active' : 'Inactive'}
|
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||||
</Badge>
|
</Badge>
|
||||||
{row.original.is_custom ? <Badge variant="outline">Custom</Badge> : null}
|
{row.original.is_custom ? (
|
||||||
|
<Badge variant="outline">Custom</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}, []);
|
||||||
if (!canEdit && !canDelete) return columns;
|
|
||||||
|
|
||||||
columns.push({
|
|
||||||
id: 'actions',
|
|
||||||
header: () => <div className="text-right">Actions</div>,
|
|
||||||
enableSorting: false,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const plan = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{canEdit ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onEdit(plan)}>
|
|
||||||
<Edit className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{canDelete ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pendingPlanId === plan.id}
|
|
||||||
onClick={() => onToggleStatus(plan)}
|
|
||||||
>
|
|
||||||
<RotateCcw className="mr-2 size-4" />
|
|
||||||
{plan.is_active ? 'Deactivate' : 'Activate'}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
}, [canDelete, canEdit, onEdit, onToggleStatus, pendingPlanId]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ export function PlanFilters({
|
|||||||
placeholder="Search plans"
|
placeholder="Search plans"
|
||||||
className="w-full sm:w-72"
|
className="w-full sm:w-72"
|
||||||
/>
|
/>
|
||||||
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as PlanStatusFilter)}>
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(value) => onStatusChange(value as PlanStatusFilter)}
|
||||||
|
>
|
||||||
<SelectTrigger className="w-full sm:w-40">
|
<SelectTrigger className="w-full sm:w-40">
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder="Status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -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,70 +52,105 @@ 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">
|
|
||||||
|
<form
|
||||||
|
noValidate
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
|
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="plan-name">Name</Label>
|
id="plan-name"
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
error={fieldError('name')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="plan-name"
|
id="plan-name"
|
||||||
placeholder="Starter"
|
placeholder="Enter plan name"
|
||||||
{...register('name', { required: true })}
|
aria-invalid={!!fieldError('name')}
|
||||||
required
|
{...register('name')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="plan-slug">Slug</Label>
|
<FormField
|
||||||
|
id="plan-slug"
|
||||||
|
label="Slug"
|
||||||
|
required
|
||||||
|
error={fieldError('slug')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="plan-slug"
|
id="plan-slug"
|
||||||
placeholder="starter"
|
placeholder="starter"
|
||||||
{...register('slug', { required: true })}
|
aria-invalid={!!fieldError('slug')}
|
||||||
required
|
{...register('slug')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="plan-description">Description</Label>
|
id="plan-description"
|
||||||
<textarea
|
label="Description"
|
||||||
|
required
|
||||||
|
error={fieldError('description')}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
id="plan-description"
|
id="plan-description"
|
||||||
placeholder="Basic plan for small teams"
|
placeholder="Basic plan for small teams"
|
||||||
{...register('description', { required: true })}
|
aria-invalid={!!fieldError('description')}
|
||||||
required
|
{...register('description')}
|
||||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="min-h-20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
id="plan-price"
|
||||||
|
label="Price"
|
||||||
|
required
|
||||||
|
error={fieldError('price')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="plan-price"
|
id="plan-price"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
placeholder="29.99"
|
placeholder="29.99"
|
||||||
{...register('price', { required: true })}
|
aria-invalid={!!fieldError('price')}
|
||||||
required
|
{...register('price')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Billing Cycle</Label>
|
<FormField
|
||||||
|
label="Billing Cycle"
|
||||||
|
required
|
||||||
|
error={fieldError('billing_cycle')}
|
||||||
|
>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="billing_cycle"
|
name="billing_cycle"
|
||||||
@@ -128,83 +167,113 @@ export function PlanSheet({
|
|||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="plan-trial-days">Trial Days</Label>
|
<FormField
|
||||||
|
id="plan-trial-days"
|
||||||
|
label="Trial Days"
|
||||||
|
error={fieldError('trial_days')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="plan-trial-days"
|
id="plan-trial-days"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
|
aria-invalid={!!fieldError('trial_days')}
|
||||||
{...register('trial_days', { valueAsNumber: true })}
|
{...register('trial_days', { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</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>
|
id="max-projects"
|
||||||
|
label="Projects"
|
||||||
|
error={fieldError('max_projects')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="max-projects"
|
id="max-projects"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
|
aria-invalid={!!fieldError('max_projects')}
|
||||||
{...register('max_projects', { valueAsNumber: true })}
|
{...register('max_projects', { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="max-organizations">Organizations</Label>
|
<FormField
|
||||||
|
id="max-organizations"
|
||||||
|
label="Organizations"
|
||||||
|
error={fieldError('max_organizations')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="max-organizations"
|
id="max-organizations"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
|
aria-invalid={!!fieldError('max_organizations')}
|
||||||
{...register('max_organizations', { valueAsNumber: true })}
|
{...register('max_organizations', { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="max-users">Users</Label>
|
<FormField
|
||||||
|
id="max-users"
|
||||||
|
label="Users"
|
||||||
|
error={fieldError('max_users')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="max-users"
|
id="max-users"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
|
aria-invalid={!!fieldError('max_users')}
|
||||||
{...register('max_users', { valueAsNumber: true })}
|
{...register('max_users', { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="max-roles">Roles</Label>
|
<FormField
|
||||||
|
id="max-roles"
|
||||||
|
label="Roles"
|
||||||
|
error={fieldError('max_roles')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="max-roles"
|
id="max-roles"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
|
aria-invalid={!!fieldError('max_roles')}
|
||||||
{...register('max_roles', { valueAsNumber: true })}
|
{...register('max_roles', { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<FormField
|
||||||
<div className="flex items-center justify-between gap-3">
|
label="Permissions"
|
||||||
<Label>Permissions</Label>
|
required
|
||||||
<span className="text-xs text-muted-foreground">{permissionIds.length} selected</span>
|
error={fieldError('permission_ids')}
|
||||||
</div>
|
className="space-y-3"
|
||||||
|
labelEnd={
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{permissionIds.length} selected
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
{isPermissionsLoading ? (
|
{isPermissionsLoading ? (
|
||||||
<div className="rounded-md border p-6 text-sm text-muted-foreground">
|
<div className="rounded-lg border p-6 text-muted-foreground">
|
||||||
Loading permissions...
|
Loading permissions...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -214,9 +283,10 @@ export function PlanSheet({
|
|||||||
onChange={onPermissionIdsChange}
|
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>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
import { Edit, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import type { Plan } from '@/types';
|
import type { Plan } from '@/types';
|
||||||
|
|
||||||
interface PlanTableProps {
|
interface PlanTableProps {
|
||||||
@@ -18,6 +20,9 @@ interface PlanTableProps {
|
|||||||
onPageChange: (skip: number) => void;
|
onPageChange: (skip: number) => void;
|
||||||
onLimitChange: (limit: number) => void;
|
onLimitChange: (limit: number) => void;
|
||||||
onSortingChange: (sorting: SortingState) => void;
|
onSortingChange: (sorting: SortingState) => void;
|
||||||
|
onEdit: (plan: Plan) => void;
|
||||||
|
onToggleStatus: (plan: Plan) => void;
|
||||||
|
pendingPlanId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlanTable({
|
export function PlanTable({
|
||||||
@@ -32,6 +37,9 @@ export function PlanTable({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
onSortingChange,
|
onSortingChange,
|
||||||
|
onEdit,
|
||||||
|
onToggleStatus,
|
||||||
|
pendingPlanId,
|
||||||
}: PlanTableProps) {
|
}: PlanTableProps) {
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -41,6 +49,21 @@ export function PlanTable({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
toolbar={toolbar}
|
toolbar={toolbar}
|
||||||
emptyTitle="No plans found."
|
emptyTitle="No plans found."
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit className="size-4" />,
|
||||||
|
permission: PERMISSIONS.PLAN.UPDATE,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (plan) => (plan.is_active ? 'Deactivate' : 'Activate'),
|
||||||
|
icon: <RotateCcw className="size-4" />,
|
||||||
|
permission: PERMISSIONS.PLAN.DELETE,
|
||||||
|
disabled: (plan) => pendingPlanId === plan.id,
|
||||||
|
onClick: onToggleStatus,
|
||||||
|
},
|
||||||
|
]}
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
@@ -50,6 +73,7 @@ export function PlanTable({
|
|||||||
}}
|
}}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
onSortingChange={onSortingChange}
|
onSortingChange={onSortingChange}
|
||||||
|
actionsSticky
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
|||||||
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);
|
||||||
@@ -65,6 +67,9 @@ export default function PlansPage() {
|
|||||||
planId,
|
planId,
|
||||||
permissionIds,
|
permissionIds,
|
||||||
setPermissionIds,
|
setPermissionIds,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
} = planForm;
|
} = planForm;
|
||||||
const statusMutation = usePlanStatusMutation();
|
const statusMutation = usePlanStatusMutation();
|
||||||
@@ -124,11 +129,7 @@ export default function PlansPage() {
|
|||||||
[statusMutation],
|
[statusMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = usePlanColumns({
|
const columns = usePlanColumns();
|
||||||
onEdit: openEdit,
|
|
||||||
onToggleStatus: toggleStatus,
|
|
||||||
pendingPlanId: statusMutation.variables?.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const plans = plansQuery.data?.items ?? [];
|
const plans = plansQuery.data?.items ?? [];
|
||||||
const total = plansQuery.data?.total ?? plansQuery.data?.totalItems ?? 0;
|
const total = plansQuery.data?.total ?? plansQuery.data?.totalItems ?? 0;
|
||||||
@@ -173,9 +174,10 @@ export default function PlansPage() {
|
|||||||
onPageChange={setSkip}
|
onPageChange={setSkip}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={setLimit}
|
||||||
onSortingChange={setSorting}
|
onSortingChange={setSorting}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onToggleStatus={toggleStatus}
|
||||||
|
pendingPlanId={statusMutation.variables?.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<PlanSheet
|
<PlanSheet
|
||||||
@@ -184,7 +186,10 @@ export default function PlansPage() {
|
|||||||
planId={planId}
|
planId={planId}
|
||||||
register={register}
|
register={register}
|
||||||
control={control}
|
control={control}
|
||||||
|
errors={errors}
|
||||||
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
canSubmit={canSubmit}
|
||||||
permissionTree={permissionsQuery.permissionTree}
|
permissionTree={permissionsQuery.permissionTree}
|
||||||
permissionIds={permissionIds}
|
permissionIds={permissionIds}
|
||||||
onPermissionIdsChange={setPermissionIds}
|
onPermissionIdsChange={setPermissionIds}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { useMemo } from 'react';
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
import type { Project } from '@/types';
|
import type { Project } from '@/types';
|
||||||
|
|
||||||
function EmptyValue() {
|
function EmptyValue() {
|
||||||
@@ -35,16 +39,14 @@ function StateBadges({ value }: { value: string | null }) {
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-[10px] font-bold text-muted-foreground transition-colors hover:bg-muted"
|
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-muted-foreground transition-colors hover:bg-muted"
|
||||||
>
|
>
|
||||||
+{remainingStates.length}
|
+{remainingStates.length}
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-2" align="start">
|
<PopoverContent className="w-auto p-2" align="start">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="mb-0.5 px-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
|
||||||
Other States
|
|
||||||
</p>
|
|
||||||
{remainingStates.map((state) => (
|
{remainingStates.map((state) => (
|
||||||
<Badge key={state} variant="secondary">
|
<Badge key={state} variant="secondary">
|
||||||
{state}
|
{state}
|
||||||
@@ -64,7 +66,7 @@ export function useProjectColumns() {
|
|||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Project Name',
|
header: 'Project Name',
|
||||||
cell: ({ row }) => <div className="font-semibold">{row.original.name}</div>,
|
cell: ({ row }) => <div>{row.original.name}</div>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'state',
|
accessorKey: 'state',
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form';
|
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||||
import { Layers, Loader2, MapPin, Route } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { FormField } from '@/components/form';
|
import { FormField } from '@/components/form';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
@@ -24,7 +25,7 @@ interface ProjectDialogProps {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
register: UseFormRegister<ProjectFormValues>;
|
register: UseFormRegister<ProjectFormValues>;
|
||||||
errors: FieldErrors<ProjectFormValues>;
|
errors: FieldErrors<ProjectFormValues>;
|
||||||
touchedFields: UseFormReturn<ProjectFormValues>['formState']['touchedFields'];
|
isSubmitted: boolean;
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
canSubmit: boolean;
|
canSubmit: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
@@ -36,128 +37,163 @@ export function ProjectDialog({
|
|||||||
projectId,
|
projectId,
|
||||||
register,
|
register,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: ProjectDialogProps) {
|
}: ProjectDialogProps) {
|
||||||
const nameErrorMessage = touchedFields.name ? errors.name?.message : undefined;
|
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
|
||||||
const startLatErrorMessage = touchedFields.start_lat ? errors.start_lat?.message : undefined;
|
const startLatErrorMessage = isSubmitted
|
||||||
const startLngErrorMessage = touchedFields.start_lng ? errors.start_lng?.message : undefined;
|
? errors.start_lat?.message
|
||||||
const endLatErrorMessage = touchedFields.end_lat ? errors.end_lat?.message : undefined;
|
: undefined;
|
||||||
const endLngErrorMessage = touchedFields.end_lng ? errors.end_lng?.message : undefined;
|
const startLngErrorMessage = isSubmitted
|
||||||
|
? errors.start_lng?.message
|
||||||
|
: undefined;
|
||||||
|
const endLatErrorMessage = isSubmitted ? errors.end_lat?.message : undefined;
|
||||||
|
const endLngErrorMessage = isSubmitted ? errors.end_lng?.message : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-2xl" onOpenAutoFocus={(event) => event.preventDefault()}>
|
<DialogContent
|
||||||
<DialogHeader className="gap-2">
|
className="sm:max-w-2xl"
|
||||||
<DialogTitle className="flex items-center gap-3 text-xl">
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
|
>
|
||||||
<Layers className="size-5" />
|
<DialogHeader>
|
||||||
</div>
|
<DialogTitle>
|
||||||
{projectId ? 'Edit Project Details' : 'Create New Project'}
|
{projectId ? 'Edit Project' : 'Create Project'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm">
|
<DialogDescription>
|
||||||
{projectId
|
Manage road project identity and optional coordinate boundaries.
|
||||||
? 'Update the technical specifications for your road infrastructure project.'
|
|
||||||
: 'Provide the essential road data to establish a new analysis project.'}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="space-y-6">
|
<form
|
||||||
<FormField id="project-name" label="Project Name" required error={nameErrorMessage}>
|
noValidate
|
||||||
|
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
|
<Input
|
||||||
id="project-name"
|
id="project-name"
|
||||||
placeholder="Enter a descriptive project name"
|
placeholder="Enter project name"
|
||||||
aria-invalid={!!nameErrorMessage}
|
aria-invalid={!!nameErrorMessage}
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField id="project-state" label="State">
|
<FormField id="project-state" label="State">
|
||||||
<Input id="project-state" placeholder="e.g. Maharashtra" {...register('state')} />
|
<Input
|
||||||
|
id="project-state"
|
||||||
|
placeholder="Enter state"
|
||||||
|
{...register('state')}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField id="project-corridor" label="Corridor Name">
|
||||||
id="project-corridor"
|
|
||||||
label={
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<Route className="size-3.5 opacity-60" />
|
|
||||||
Corridor Name
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
id="project-corridor"
|
id="project-corridor"
|
||||||
placeholder="e.g. Mumbai-Goa Highway"
|
placeholder="Enter corridor name"
|
||||||
{...register('corridor_name')}
|
{...register('corridor_name')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="grid gap-8 pt-2 md:grid-cols-2">
|
<section className="space-y-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-1">
|
||||||
<p className="flex items-center gap-2 text-[11px] font-black tracking-widest text-muted-foreground">
|
<h3 className="text-sm font-semibold">Coordinates</h3>
|
||||||
<MapPin className="size-3.5 opacity-60" />
|
<p className="text-sm text-muted-foreground">
|
||||||
START POINT
|
Optional start and end points for the project boundary.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField id="project-start-lat" label="Lat" error={startLatErrorMessage}>
|
<FormField
|
||||||
|
id="project-start-lat"
|
||||||
|
label="Start Latitude"
|
||||||
|
error={startLatErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="project-start-lat"
|
id="project-start-lat"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="0.0000"
|
min="-90"
|
||||||
className="font-mono text-xs"
|
max="90"
|
||||||
|
placeholder="Enter start latitude"
|
||||||
aria-invalid={!!startLatErrorMessage}
|
aria-invalid={!!startLatErrorMessage}
|
||||||
{...register('start_lat')}
|
{...register('start_lat')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField id="project-start-lng" label="Lng" error={startLngErrorMessage}>
|
<FormField
|
||||||
|
id="project-start-lng"
|
||||||
|
label="Start Longitude"
|
||||||
|
error={startLngErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="project-start-lng"
|
id="project-start-lng"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="0.0000"
|
min="-180"
|
||||||
className="font-mono text-xs"
|
max="180"
|
||||||
|
placeholder="Enter start longitude"
|
||||||
aria-invalid={!!startLngErrorMessage}
|
aria-invalid={!!startLngErrorMessage}
|
||||||
{...register('start_lng')}
|
{...register('start_lng')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="flex items-center gap-2 text-[11px] font-black tracking-widest text-muted-foreground">
|
|
||||||
<MapPin className="size-3.5 opacity-60" />
|
|
||||||
END POINT
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField id="project-end-lat" label="Lat" error={endLatErrorMessage}>
|
<FormField
|
||||||
|
id="project-end-lat"
|
||||||
|
label="End Latitude"
|
||||||
|
error={endLatErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="project-end-lat"
|
id="project-end-lat"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="0.0000"
|
min="-90"
|
||||||
className="font-mono text-xs"
|
max="90"
|
||||||
|
placeholder="Enter end latitude"
|
||||||
aria-invalid={!!endLatErrorMessage}
|
aria-invalid={!!endLatErrorMessage}
|
||||||
{...register('end_lat')}
|
{...register('end_lat')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField id="project-end-lng" label="Lng" error={endLngErrorMessage}>
|
<FormField
|
||||||
|
id="project-end-lng"
|
||||||
|
label="End Longitude"
|
||||||
|
error={endLngErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="project-end-lng"
|
id="project-end-lng"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="0.0000"
|
min="-180"
|
||||||
className="font-mono text-xs"
|
max="180"
|
||||||
|
placeholder="Enter end longitude"
|
||||||
aria-invalid={!!endLngErrorMessage}
|
aria-invalid={!!endLngErrorMessage}
|
||||||
{...register('end_lng')}
|
{...register('end_lng')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
@@ -169,7 +205,9 @@ export function ProjectDialog({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||||
{isSaving ? <Loader2 className="size-4 animate-spin" /> : null}
|
{isSaving ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
{projectId ? 'Update Project' : 'Create Project'}
|
{projectId ? 'Update Project' : 'Create Project'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Edit3, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import type { Project } from '@/types';
|
import type { Project } from '@/types';
|
||||||
@@ -35,8 +36,19 @@ export function ProjectTable({
|
|||||||
title="Projects"
|
title="Projects"
|
||||||
data={projects}
|
data={projects}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
onEdit={onEdit}
|
actions={[
|
||||||
onDelete={onDelete}
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit3 className="size-4" />,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
icon: <Trash2 className="size-4" />,
|
||||||
|
className: 'text-destructive',
|
||||||
|
onClick: onDelete,
|
||||||
|
},
|
||||||
|
]}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyTitle="No projects found."
|
emptyTitle="No projects found."
|
||||||
pagination={{
|
pagination={{
|
||||||
@@ -46,6 +58,7 @@ export function ProjectTable({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
}}
|
}}
|
||||||
|
actionsSticky
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,30 +7,24 @@ import { useForm, useWatch } from 'react-hook-form';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
optionalLatitude,
|
||||||
|
optionalLongitude,
|
||||||
|
} from '@/lib/validation/numberField';
|
||||||
import { projectService } from '@/services/api';
|
import { projectService } from '@/services/api';
|
||||||
import type { Project, ProjectCreate, ProjectUpdate } from '@/types';
|
import type { Project, ProjectCreate, ProjectUpdate } from '@/types';
|
||||||
|
|
||||||
import { projectKeys } from '../queries/projectKeys';
|
import { projectKeys } from '../queries/projectKeys';
|
||||||
|
|
||||||
const optionalCoordinate = z.string().refine(
|
|
||||||
(value) => {
|
|
||||||
if (!value.trim()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return Number.isFinite(Number(value));
|
|
||||||
},
|
|
||||||
{ message: 'Enter a valid number' },
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectFormSchema = z.object({
|
const projectFormSchema = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
name: z.string().trim().min(1, 'Project name is required'),
|
name: z.string().trim().min(1, 'Project name is required'),
|
||||||
state: z.string().optional(),
|
state: z.string().optional(),
|
||||||
corridor_name: z.string().optional(),
|
corridor_name: z.string().optional(),
|
||||||
start_lat: optionalCoordinate,
|
start_lat: optionalLatitude,
|
||||||
start_lng: optionalCoordinate,
|
start_lng: optionalLongitude,
|
||||||
end_lat: optionalCoordinate,
|
end_lat: optionalLatitude,
|
||||||
end_lng: optionalCoordinate,
|
end_lng: optionalLongitude,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ProjectFormValues = z.infer<typeof projectFormSchema>;
|
export type ProjectFormValues = z.infer<typeof projectFormSchema>;
|
||||||
@@ -53,7 +47,9 @@ function toNullableNumber(value: string) {
|
|||||||
return value.trim() ? Number(value) : null;
|
return value.trim() ? Number(value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toProjectPayload(values: ProjectFormValues): ProjectCreate | ProjectUpdate {
|
function toProjectPayload(
|
||||||
|
values: ProjectFormValues,
|
||||||
|
): ProjectCreate | ProjectUpdate {
|
||||||
return {
|
return {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
state: trimOptional(values.state),
|
state: trimOptional(values.state),
|
||||||
@@ -72,10 +68,10 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
control,
|
control,
|
||||||
formState: { errors, isSubmitting, touchedFields },
|
formState: { errors, isSubmitting, isSubmitted },
|
||||||
} = useForm<ProjectFormValues>({
|
} = useForm<ProjectFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
mode: 'onTouched',
|
mode: 'onSubmit',
|
||||||
reValidateMode: 'onChange',
|
reValidateMode: 'onChange',
|
||||||
resolver: zodResolver(projectFormSchema),
|
resolver: zodResolver(projectFormSchema),
|
||||||
});
|
});
|
||||||
@@ -98,18 +94,13 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
queryClient.invalidateQueries({ queryKey: projectKeys.all });
|
queryClient.invalidateQueries({ queryKey: projectKeys.all });
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
onError: (_error, values) => {
|
||||||
toast.error(values.id ? 'Failed to update project' : 'Failed to create project');
|
toast.error(
|
||||||
|
values.id ? 'Failed to update project' : 'Failed to create project',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = handleSubmit(
|
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
|
||||||
(values) => saveMutation.mutate(values),
|
|
||||||
(formErrors) => {
|
|
||||||
if (formErrors.name?.message) {
|
|
||||||
toast.error(formErrors.name.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
@@ -139,7 +130,7 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
resetForm: () => reset(defaultValues),
|
resetForm: () => reset(defaultValues),
|
||||||
projectId,
|
projectId,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving: isSubmitting || saveMutation.isPending,
|
isSaving: isSubmitting || saveMutation.isPending,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ interface UseProjectsQueryParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useProjectsQuery({ skip, limit }: UseProjectsQueryParams) {
|
export function useProjectsQuery({ skip, limit }: UseProjectsQueryParams) {
|
||||||
const listParams = useMemo<PaginationParams>(() => ({ skip, limit }), [limit, skip]);
|
const listParams = useMemo<PaginationParams>(
|
||||||
|
() => ({ skip, limit }),
|
||||||
|
[limit, skip],
|
||||||
|
);
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: projectKeys.list(listParams),
|
queryKey: projectKeys.list(listParams),
|
||||||
@@ -36,7 +39,8 @@ export function useDeleteProjectMutation() {
|
|||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error('Deletion failed', {
|
toast.error('Deletion failed', {
|
||||||
description: 'The project could not be removed. Please try again or check your permissions.',
|
description:
|
||||||
|
'The project could not be removed. Please try again or check your permissions.',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,16 @@ import { useCallback, useState } from 'react';
|
|||||||
import { Layers, Plus } from 'lucide-react';
|
import { Layers, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { Project } from '@/types';
|
import type { Project } from '@/types';
|
||||||
|
|
||||||
@@ -13,10 +22,14 @@ import { ProjectTable } from './components/ProjectTable';
|
|||||||
import { useProjectColumns } from './components/ProjectColumns';
|
import { useProjectColumns } from './components/ProjectColumns';
|
||||||
import { useProjectFilters } from './hooks/useProjectFilters';
|
import { useProjectFilters } from './hooks/useProjectFilters';
|
||||||
import { useProjectForm } from './hooks/useProjectForm';
|
import { useProjectForm } from './hooks/useProjectForm';
|
||||||
import { useDeleteProjectMutation, useProjectsQuery } from './hooks/useProjectQueries';
|
import {
|
||||||
|
useDeleteProjectMutation,
|
||||||
|
useProjectsQuery,
|
||||||
|
} from './hooks/useProjectQueries';
|
||||||
|
|
||||||
export default function ProjectPage() {
|
export default function ProjectPage() {
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
|
||||||
const { skip, setSkip, limit, setLimit } = useProjectFilters();
|
const { skip, setSkip, limit, setLimit } = useProjectFilters();
|
||||||
const projectsQuery = useProjectsQuery({ skip, limit });
|
const projectsQuery = useProjectsQuery({ skip, limit });
|
||||||
const deleteProjectMutation = useDeleteProjectMutation();
|
const deleteProjectMutation = useDeleteProjectMutation();
|
||||||
@@ -31,7 +44,7 @@ export default function ProjectPage() {
|
|||||||
resetForm,
|
resetForm,
|
||||||
projectId,
|
projectId,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
} = projectForm;
|
} = projectForm;
|
||||||
@@ -61,14 +74,25 @@ export default function ProjectPage() {
|
|||||||
|
|
||||||
const deleteProject = useCallback(
|
const deleteProject = useCallback(
|
||||||
(project: Project) => {
|
(project: Project) => {
|
||||||
if (!confirm(`Are you sure you want to delete project "${project.name}"?`)) {
|
setProjectToDelete(project);
|
||||||
return;
|
|
||||||
}
|
|
||||||
deleteProjectMutation.mutate(project);
|
|
||||||
},
|
},
|
||||||
[deleteProjectMutation],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
setProjectToDelete(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmDeleteProject = useCallback(() => {
|
||||||
|
if (!projectToDelete) return;
|
||||||
|
|
||||||
|
deleteProjectMutation.mutate(projectToDelete, {
|
||||||
|
onSettled: () => setProjectToDelete(null),
|
||||||
|
});
|
||||||
|
}, [deleteProjectMutation, projectToDelete]);
|
||||||
|
|
||||||
const columns = useProjectColumns();
|
const columns = useProjectColumns();
|
||||||
const projects = projectsQuery.data?.items ?? [];
|
const projects = projectsQuery.data?.items ?? [];
|
||||||
const total = projectsQuery.data?.totalItems ?? 0;
|
const total = projectsQuery.data?.totalItems ?? 0;
|
||||||
@@ -100,8 +124,6 @@ export default function ProjectPage() {
|
|||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDelete={deleteProject}
|
onDelete={deleteProject}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<ProjectDialog
|
<ProjectDialog
|
||||||
@@ -110,11 +132,41 @@ export default function ProjectPage() {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
register={register}
|
register={register}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
touchedFields={touchedFields}
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
canSubmit={canSubmit}
|
canSubmit={canSubmit}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={Boolean(projectToDelete)}
|
||||||
|
onOpenChange={handleDeleteDialogOpenChange}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete project?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{projectToDelete?.name}
|
||||||
|
</span>
|
||||||
|
. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deleteProjectMutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deleteProjectMutation.isPending}
|
||||||
|
onClick={confirmDeleteProject}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,7 @@ export const projectKeys = {
|
|||||||
all: ['projects'] as const,
|
all: ['projects'] as const,
|
||||||
lists: () => [...projectKeys.all, 'list'] as const,
|
lists: () => [...projectKeys.all, 'list'] as const,
|
||||||
list: (params: PaginationParams) => [...projectKeys.lists(), params] as const,
|
list: (params: PaginationParams) => [...projectKeys.lists(), params] as const,
|
||||||
|
options: () => [...projectKeys.all, 'options'] as const,
|
||||||
|
details: () => [...projectKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...projectKeys.details(), id] as const,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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()}>
|
||||||
{session && (
|
<ArrowLeft />
|
||||||
<div className="mb-6">
|
Back
|
||||||
<Card className="p-0 border shadow-sm overflow-hidden">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between py-4 px-6 gap-6 bg-card">
|
|
||||||
<div className="flex flex-wrap items-center gap-x-12 gap-y-4">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Project
|
|
||||||
</span>
|
|
||||||
<span className="text-base font-bold leading-tight">
|
|
||||||
{session.projectName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col border-l pl-12 border-border/60">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Package
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
|
||||||
{session.packageName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col border-l pl-12 border-border/60">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Segment
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-muted-foreground leading-tight flex items-center gap-1.5">
|
|
||||||
{session.chainageName}
|
|
||||||
{session.chainageDirection && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-1 py-0.5 rounded bg-muted text-[10px] font-bold uppercase border">
|
|
||||||
{session.chainageDirection === 'UP' ? (
|
|
||||||
<ArrowUp className="h-2.5 w-2.5" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown className="h-2.5 w-2.5" />
|
|
||||||
)}
|
|
||||||
{session.chainageDirection}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleNewAnalysis}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="font-semibold px-6 shrink-0 h-9"
|
|
||||||
>
|
|
||||||
Start New Analysis
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detectionData && (
|
|
||||||
<VideoPlayerSection
|
|
||||||
data={detectionData}
|
|
||||||
videoId={videoId}
|
|
||||||
videoFile={videoFile}
|
|
||||||
detectionType={detectionType}
|
|
||||||
projectId={session?.projectId || undefined}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<PoweredBy />
|
{detectionData && <VideoPlayerSection data={detectionData} />}
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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,44 +36,13 @@ export function useRoleColumns({
|
|||||||
accessorKey: 'effective_status',
|
accessorKey: 'effective_status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge variant={row.original.effective_status ? 'default' : 'secondary'}>
|
<Badge
|
||||||
|
variant={row.original.effective_status ? 'default' : 'secondary'}
|
||||||
|
>
|
||||||
{row.original.effective_status ? 'Active' : 'Inactive'}
|
{row.original.effective_status ? 'Active' : 'Inactive'}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}, []);
|
||||||
if (!canEdit && !canDelete) return columns;
|
|
||||||
|
|
||||||
columns.push({
|
|
||||||
id: 'actions',
|
|
||||||
header: () => <div className="text-right">Actions</div>,
|
|
||||||
enableSorting: false,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const role = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{canEdit ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onEdit(role)}>
|
|
||||||
<Edit className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{canDelete ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={isStatusPending}
|
|
||||||
onClick={() => onToggleStatus(role)}
|
|
||||||
>
|
|
||||||
<RotateCcw className="mr-2 size-4" />
|
|
||||||
{role.effective_status ? 'Deactivate' : 'Activate'}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
}, [canDelete, canEdit, isStatusPending, onEdit, onToggleStatus]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ export function RoleFilters({
|
|||||||
onChange={onSearchChange}
|
onChange={onSearchChange}
|
||||||
placeholder="Search roles"
|
placeholder="Search roles"
|
||||||
/>
|
/>
|
||||||
<Select value={statusFilter} onValueChange={(value) => onStatusChange(value as StatusFilter)}>
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(value) => onStatusChange(value as StatusFilter)}
|
||||||
|
>
|
||||||
<SelectTrigger className="md:w-44">
|
<SelectTrigger className="md:w-44">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form';
|
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { FormField } from '@/components/form';
|
import { FormField } from '@/components/form';
|
||||||
@@ -28,7 +28,7 @@ interface RoleSheetProps {
|
|||||||
roleId?: number;
|
roleId?: number;
|
||||||
register: UseFormRegister<RoleFormValues>;
|
register: UseFormRegister<RoleFormValues>;
|
||||||
errors: FieldErrors<RoleFormValues>;
|
errors: FieldErrors<RoleFormValues>;
|
||||||
touchedFields: UseFormReturn<RoleFormValues>['formState']['touchedFields'];
|
isSubmitted: boolean;
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
permissionTree: PermissionTreeItem[];
|
permissionTree: PermissionTreeItem[];
|
||||||
permissionIds: number[];
|
permissionIds: number[];
|
||||||
@@ -44,7 +44,7 @@ export function RoleSheet({
|
|||||||
roleId,
|
roleId,
|
||||||
register,
|
register,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
permissionTree,
|
permissionTree,
|
||||||
permissionIds,
|
permissionIds,
|
||||||
@@ -53,8 +53,8 @@ export function RoleSheet({
|
|||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: RoleSheetProps) {
|
}: RoleSheetProps) {
|
||||||
const nameErrorMessage = touchedFields.name ? errors.name?.message : undefined;
|
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
|
||||||
const displayNameErrorMessage = touchedFields.display_name
|
const displayNameErrorMessage = isSubmitted
|
||||||
? errors.display_name?.message
|
? errors.display_name?.message
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -62,16 +62,22 @@ export function RoleSheet({
|
|||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side="right"
|
side="right"
|
||||||
className="flex h-full flex-col gap-0 p-0 sm:max-w-3xl lg:max-w-2xl"
|
className="flex h-full w-full! flex-col gap-0 p-0 sm:max-w-3xl!"
|
||||||
>
|
>
|
||||||
<SheetHeader className="shrink-0 border-b px-6 py-4">
|
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||||
<SheetTitle>{roleId ? 'Edit Role' : 'Create Role'}</SheetTitle>
|
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||||
<SheetDescription>
|
{roleId ? 'Edit Role' : 'Create Role'}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription className="text-sm">
|
||||||
Assign the role details and permission access for this organization.
|
Assign the role details and permission access for this organization.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
<form
|
||||||
|
noValidate
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -114,14 +120,16 @@ export function RoleSheet({
|
|||||||
<FormField
|
<FormField
|
||||||
label="Permissions"
|
label="Permissions"
|
||||||
required
|
required
|
||||||
error={errors.permission_ids?.message}
|
error={isSubmitted ? errors.permission_ids?.message : undefined}
|
||||||
className="space-y-3"
|
className="space-y-3"
|
||||||
labelEnd={
|
labelEnd={
|
||||||
<span className="text-xs text-muted-foreground">{permissionIds.length} selected</span>
|
<span className="text-muted-foreground">
|
||||||
|
{permissionIds.length} selected
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isPermissionsLoading ? (
|
{isPermissionsLoading ? (
|
||||||
<div className="rounded-md border p-6 text-sm text-muted-foreground">
|
<div className="rounded-lg border p-6 text-muted-foreground">
|
||||||
Loading permissions...
|
Loading permissions...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -136,7 +144,9 @@ export function RoleSheet({
|
|||||||
|
|
||||||
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
|
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
|
||||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||||
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
{isSaving ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
{roleId ? 'Update Role' : 'Create Role'}
|
{roleId ? 'Update Role' : 'Create Role'}
|
||||||
</Button>
|
</Button>
|
||||||
<SheetClose asChild>
|
<SheetClose asChild>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { Edit, 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 {
|
||||||
@@ -18,6 +20,9 @@ interface RoleTableProps {
|
|||||||
onPageChange: (skip: number) => void;
|
onPageChange: (skip: number) => void;
|
||||||
onLimitChange: (limit: number) => void;
|
onLimitChange: (limit: number) => void;
|
||||||
onSortingChange: (sorting: SortingState) => void;
|
onSortingChange: (sorting: SortingState) => void;
|
||||||
|
onEdit: (role: Role) => void;
|
||||||
|
onToggleStatus: (role: Role) => void;
|
||||||
|
isStatusPending: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoleTable({
|
export function RoleTable({
|
||||||
@@ -32,6 +37,9 @@ export function RoleTable({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
onSortingChange,
|
onSortingChange,
|
||||||
|
onEdit,
|
||||||
|
onToggleStatus,
|
||||||
|
isStatusPending,
|
||||||
}: RoleTableProps) {
|
}: RoleTableProps) {
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -41,6 +49,26 @@ 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,
|
||||||
@@ -50,6 +78,7 @@ export function RoleTable({
|
|||||||
}}
|
}}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
onSortingChange={onSortingChange}
|
onSortingChange={onSortingChange}
|
||||||
|
actionsSticky
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,10 +48,10 @@ export function useRoleForm({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
formState: { errors, isSubmitting, touchedFields },
|
formState: { errors, isSubmitting, isSubmitted },
|
||||||
} = useForm<RoleFormValues>({
|
} = useForm<RoleFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
mode: 'onTouched',
|
mode: 'onSubmit',
|
||||||
reValidateMode: 'onChange',
|
reValidateMode: 'onChange',
|
||||||
resolver: zodResolver(roleFormSchema),
|
resolver: zodResolver(roleFormSchema),
|
||||||
});
|
});
|
||||||
@@ -61,7 +61,9 @@ export function useRoleForm({
|
|||||||
const roleName = useWatch({ control, name: 'name' }) || '';
|
const roleName = useWatch({ control, name: 'name' }) || '';
|
||||||
const displayName = useWatch({ control, name: 'display_name' }) || '';
|
const displayName = useWatch({ control, name: 'display_name' }) || '';
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
roleName.trim().length > 0 && displayName.trim().length > 0 && permissionIds.length > 0;
|
roleName.trim().length > 0 &&
|
||||||
|
displayName.trim().length > 0 &&
|
||||||
|
permissionIds.length > 0;
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (values: RoleFormValues) =>
|
mutationFn: (values: RoleFormValues) =>
|
||||||
@@ -81,26 +83,13 @@ export function useRoleForm({
|
|||||||
queryClient.invalidateQueries({ queryKey: roleKeys.all });
|
queryClient.invalidateQueries({ queryKey: roleKeys.all });
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
onError: (_error, values) => {
|
||||||
toast.error(values.id ? 'Failed to update role' : 'Failed to create role');
|
toast.error(
|
||||||
|
values.id ? 'Failed to update role' : 'Failed to create role',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = handleSubmit(
|
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
|
||||||
(values) => saveMutation.mutate(values),
|
|
||||||
(formErrors) => {
|
|
||||||
if (formErrors.name?.message) {
|
|
||||||
toast.error(formErrors.name.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (formErrors.display_name?.message) {
|
|
||||||
toast.error(formErrors.display_name.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (formErrors.permission_ids?.message) {
|
|
||||||
toast.error(formErrors.permission_ids.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
reset({
|
reset({
|
||||||
@@ -116,14 +105,21 @@ export function useRoleForm({
|
|||||||
name: role.name || '',
|
name: role.name || '',
|
||||||
display_name: role.display_name || '',
|
display_name: role.display_name || '',
|
||||||
description: role.description || '',
|
description: role.description || '',
|
||||||
permission_ids: collectPermissionIdsByKeys(permissionTree, role.permissions || []),
|
permission_ids: collectPermissionIdsByKeys(
|
||||||
|
permissionTree,
|
||||||
|
role.permissions || [],
|
||||||
|
),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[permissionTree, reset],
|
[permissionTree, reset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setPermissionIds = useCallback(
|
const setPermissionIds = useCallback(
|
||||||
(ids: number[]) => setValue('permission_ids', ids, { shouldDirty: true, shouldValidate: true }),
|
(ids: number[]) =>
|
||||||
|
setValue('permission_ids', ids, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
}),
|
||||||
[setValue],
|
[setValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -136,7 +132,7 @@ export function useRoleForm({
|
|||||||
permissionIds,
|
permissionIds,
|
||||||
roleId,
|
roleId,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving: isSubmitting || saveMutation.isPending,
|
isSaving: isSubmitting || saveMutation.isPending,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ function buildRoleListParams({
|
|||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
search_term: searchTerm || undefined,
|
search_term: searchTerm || undefined,
|
||||||
effective_status: statusFilter === 'all' ? undefined : statusFilter === 'active',
|
effective_status:
|
||||||
|
statusFilter === 'all' ? undefined : statusFilter === 'active',
|
||||||
...getServerSortParams(sorting),
|
...getServerSortParams(sorting),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -40,7 +41,8 @@ function buildRoleListParams({
|
|||||||
export function useRolesQuery(params: UseRolesQueryParams) {
|
export function useRolesQuery(params: UseRolesQueryParams) {
|
||||||
const { skip, limit, searchTerm, statusFilter, sorting } = params;
|
const { skip, limit, searchTerm, statusFilter, sorting } = params;
|
||||||
const listParams = useMemo(
|
const listParams = useMemo(
|
||||||
() => buildRoleListParams({ skip, limit, searchTerm, statusFilter, sorting }),
|
() =>
|
||||||
|
buildRoleListParams({ skip, limit, searchTerm, statusFilter, sorting }),
|
||||||
[limit, searchTerm, skip, sorting, statusFilter],
|
[limit, searchTerm, skip, sorting, statusFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -71,7 +73,8 @@ export function useRoleStatusMutation() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (role: Role) => roleService.updateRoleStatus(role.id, !role.effective_status),
|
mutationFn: (role: Role) =>
|
||||||
|
roleService.updateRoleStatus(role.id, !role.effective_status),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Role status updated');
|
toast.success('Role status updated');
|
||||||
queryClient.invalidateQueries({ queryKey: roleKeys.all });
|
queryClient.invalidateQueries({ queryKey: roleKeys.all });
|
||||||
|
|||||||
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,8 +23,11 @@ import {
|
|||||||
} from './hooks/useRoleQueries';
|
} from './hooks/useRoleQueries';
|
||||||
|
|
||||||
export default function RolesPage() {
|
export default function RolesPage() {
|
||||||
const organizationId = useAppStore((state) => state.user?.organization_id ?? null);
|
const organizationId = useAppStore(
|
||||||
|
(state) => state.user?.organization_id ?? null,
|
||||||
|
);
|
||||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
|
const [activeRole, setActiveRole] = useState<Role | null>(null);
|
||||||
const {
|
const {
|
||||||
skip,
|
skip,
|
||||||
setSkip,
|
setSkip,
|
||||||
@@ -48,6 +50,7 @@ export default function RolesPage() {
|
|||||||
sorting,
|
sorting,
|
||||||
});
|
});
|
||||||
const permissionsQuery = usePermissionsTreeQuery(isSheetOpen);
|
const permissionsQuery = usePermissionsTreeQuery(isSheetOpen);
|
||||||
|
|
||||||
const roleForm = useRoleForm({
|
const roleForm = useRoleForm({
|
||||||
organizationId,
|
organizationId,
|
||||||
permissionTree: permissionsQuery.permissionTree,
|
permissionTree: permissionsQuery.permissionTree,
|
||||||
@@ -62,26 +65,41 @@ export default function RolesPage() {
|
|||||||
permissionIds,
|
permissionIds,
|
||||||
setPermissionIds,
|
setPermissionIds,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
} = roleForm;
|
} = roleForm;
|
||||||
const statusMutation = useRoleStatusMutation();
|
const statusMutation = useRoleStatusMutation();
|
||||||
const { mutate: updateRoleStatus, isPending: isStatusPending } = statusMutation;
|
const { mutate: updateRoleStatus, isPending: isStatusPending } =
|
||||||
|
statusMutation;
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
prepareCreateRole();
|
setActiveRole(null);
|
||||||
setIsSheetOpen(true);
|
setIsSheetOpen(true);
|
||||||
}, [prepareCreateRole]);
|
}, []);
|
||||||
|
|
||||||
const openEdit = useCallback(
|
const openEdit = useCallback(
|
||||||
(role: Role) => {
|
(role: Role) => {
|
||||||
prepareEditRole(role);
|
setActiveRole(role);
|
||||||
setIsSheetOpen(true);
|
setIsSheetOpen(true);
|
||||||
},
|
},
|
||||||
[prepareEditRole],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSheetOpen || permissionsQuery.permissionTree.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeRole ? prepareEditRole(activeRole) : prepareCreateRole();
|
||||||
|
}, [
|
||||||
|
activeRole,
|
||||||
|
isSheetOpen,
|
||||||
|
permissionsQuery.permissionTree,
|
||||||
|
prepareCreateRole,
|
||||||
|
prepareEditRole,
|
||||||
|
]);
|
||||||
|
|
||||||
const toggleStatus = useCallback(
|
const toggleStatus = useCallback(
|
||||||
(role: Role) => {
|
(role: Role) => {
|
||||||
updateRoleStatus(role);
|
updateRoleStatus(role);
|
||||||
@@ -89,11 +107,7 @@ export default function RolesPage() {
|
|||||||
[updateRoleStatus],
|
[updateRoleStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useRoleColumns({
|
const columns = useRoleColumns();
|
||||||
onEdit: openEdit,
|
|
||||||
onToggleStatus: toggleStatus,
|
|
||||||
isStatusPending,
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = rolesQuery.data?.total ?? 0;
|
const total = rolesQuery.data?.total ?? 0;
|
||||||
const roles = rolesQuery.data?.items ?? [];
|
const roles = rolesQuery.data?.items ?? [];
|
||||||
@@ -135,9 +149,10 @@ export default function RolesPage() {
|
|||||||
onPageChange={setSkip}
|
onPageChange={setSkip}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={setLimit}
|
||||||
onSortingChange={setSorting}
|
onSortingChange={setSorting}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onToggleStatus={toggleStatus}
|
||||||
|
isStatusPending={isStatusPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<RoleSheet
|
<RoleSheet
|
||||||
@@ -146,7 +161,7 @@ export default function RolesPage() {
|
|||||||
roleId={roleId}
|
roleId={roleId}
|
||||||
register={register}
|
register={register}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
touchedFields={touchedFields}
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
permissionTree={permissionsQuery.permissionTree}
|
permissionTree={permissionsQuery.permissionTree}
|
||||||
permissionIds={permissionIds}
|
permissionIds={permissionIds}
|
||||||
|
|||||||
@@ -2,10 +2,19 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { ArrowDownCircle, ArrowUpCircle, MapPin, Milestone } from 'lucide-react';
|
import {
|
||||||
|
ArrowDownCircle,
|
||||||
|
ArrowUpCircle,
|
||||||
|
MapPin,
|
||||||
|
Milestone,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
import type { Chainage, Package, Project } from '@/types';
|
import type { Chainage, Package, Project } from '@/types';
|
||||||
|
|
||||||
function EmptyValue() {
|
function EmptyValue() {
|
||||||
@@ -36,16 +45,14 @@ function StateBadges({ value }: { value: string | null }) {
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-[10px] font-bold text-muted-foreground transition-colors hover:bg-muted"
|
className="flex items-center justify-center rounded-full border border-border/40 bg-muted/50 px-1.5 py-0.5 text-muted-foreground transition-colors hover:bg-muted"
|
||||||
>
|
>
|
||||||
+{remainingStates.length}
|
+{remainingStates.length}
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-2" align="start">
|
<PopoverContent className="w-auto p-2" align="start">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="mb-0.5 px-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
<p className="mb-0.5 px-1 text-muted-foreground">Other States</p>
|
||||||
Other States
|
|
||||||
</p>
|
|
||||||
{remainingStates.map((state) => (
|
{remainingStates.map((state) => (
|
||||||
<Badge key={state} variant="secondary">
|
<Badge key={state} variant="secondary">
|
||||||
{state}
|
{state}
|
||||||
@@ -65,7 +72,7 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
|
|||||||
{
|
{
|
||||||
accessorKey: 'segment_name',
|
accessorKey: 'segment_name',
|
||||||
header: 'Segment Name',
|
header: 'Segment Name',
|
||||||
cell: ({ row }) => <div className="font-semibold">{row.original.segment_name}</div>,
|
cell: ({ row }) => <div>{row.original.segment_name}</div>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'package_id',
|
accessorKey: 'package_id',
|
||||||
@@ -78,7 +85,9 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
|
|||||||
id: 'project',
|
id: 'project',
|
||||||
header: 'Project',
|
header: 'Project',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const pkg = packages.find((item) => item.id === row.original.package_id);
|
const pkg = packages.find(
|
||||||
|
(item) => item.id === row.original.package_id,
|
||||||
|
);
|
||||||
const project = projects.find((item) => item.id === pkg?.project_id);
|
const project = projects.find((item) => item.id === pkg?.project_id);
|
||||||
return project?.name || <EmptyValue />;
|
return project?.name || <EmptyValue />;
|
||||||
},
|
},
|
||||||
@@ -87,7 +96,9 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
|
|||||||
id: 'project_state',
|
id: 'project_state',
|
||||||
header: 'Project State',
|
header: 'Project State',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const pkg = packages.find((item) => item.id === row.original.package_id);
|
const pkg = packages.find(
|
||||||
|
(item) => item.id === row.original.package_id,
|
||||||
|
);
|
||||||
const project = projects.find((item) => item.id === pkg?.project_id);
|
const project = projects.find((item) => item.id === pkg?.project_id);
|
||||||
return <StateBadges value={project?.state ?? null} />;
|
return <StateBadges value={project?.state ?? null} />;
|
||||||
},
|
},
|
||||||
@@ -101,7 +112,7 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
|
|||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex items-center gap-1.5 border-amber-500/50 font-bold text-amber-500"
|
className="flex items-center gap-1.5 border-amber-500/50 text-amber-500"
|
||||||
>
|
>
|
||||||
<Milestone className="size-3" />
|
<Milestone className="size-3" />
|
||||||
{segment.chainage_start_km} - {segment.chainage_end_km}
|
{segment.chainage_start_km} - {segment.chainage_end_km}
|
||||||
@@ -124,10 +135,11 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex items-center gap-1.5 border-blue-500/50 font-bold text-blue-500"
|
className="flex items-center gap-1.5 border-blue-500/50 text-blue-500"
|
||||||
>
|
>
|
||||||
<MapPin className="size-3" />
|
<MapPin className="size-3" />
|
||||||
{row.original.start_lat.toFixed(4)}, {row.original.start_lng.toFixed(4)}
|
{row.original.start_lat.toFixed(4)},{' '}
|
||||||
|
{row.original.start_lng.toFixed(4)}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -137,7 +149,7 @@ export function useSegmentColumns(projects: Project[], packages: Package[]) {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex items-center gap-1.5 border-blue-500/50 font-bold text-blue-500"
|
className="flex items-center gap-1.5 border-blue-500/50 text-blue-500"
|
||||||
>
|
>
|
||||||
<MapPin className="size-3" />
|
<MapPin className="size-3" />
|
||||||
{row.original.end_lat.toFixed(4)}, {row.original.end_lng.toFixed(4)}
|
{row.original.end_lat.toFixed(4)}, {row.original.end_lng.toFixed(4)}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form';
|
import { useRef } from 'react';
|
||||||
import { Loader2, MapPin, Milestone } from 'lucide-react';
|
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { FormField } from '@/components/form';
|
import { FormField } from '@/components/form';
|
||||||
|
import { PackageSelect } from '@/components/lookups/PackageSelect';
|
||||||
|
import { ProjectSelect } from '@/components/lookups/ProjectSelect';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
@@ -22,7 +26,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import type { Package, Project } from '@/types';
|
|
||||||
|
|
||||||
import type { SegmentFormValues } from '../hooks/useSegmentForm';
|
import type { SegmentFormValues } from '../hooks/useSegmentForm';
|
||||||
|
|
||||||
@@ -30,10 +33,6 @@ interface SegmentDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
segmentId?: string;
|
segmentId?: string;
|
||||||
projects: Project[];
|
|
||||||
packages: Package[];
|
|
||||||
isProjectsLoading: boolean;
|
|
||||||
isPackagesLoading: boolean;
|
|
||||||
projectId: string;
|
projectId: string;
|
||||||
onProjectChange: (projectId: string) => void;
|
onProjectChange: (projectId: string) => void;
|
||||||
packageId: string;
|
packageId: string;
|
||||||
@@ -42,7 +41,7 @@ interface SegmentDialogProps {
|
|||||||
onDirectionChange: (direction: 'UP' | 'DOWN') => void;
|
onDirectionChange: (direction: 'UP' | 'DOWN') => void;
|
||||||
register: UseFormRegister<SegmentFormValues>;
|
register: UseFormRegister<SegmentFormValues>;
|
||||||
errors: FieldErrors<SegmentFormValues>;
|
errors: FieldErrors<SegmentFormValues>;
|
||||||
touchedFields: UseFormReturn<SegmentFormValues>['formState']['touchedFields'];
|
isSubmitted: boolean;
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
canSubmit: boolean;
|
canSubmit: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
@@ -52,10 +51,6 @@ export function SegmentDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
segmentId,
|
segmentId,
|
||||||
projects,
|
|
||||||
packages,
|
|
||||||
isProjectsLoading,
|
|
||||||
isPackagesLoading,
|
|
||||||
projectId,
|
projectId,
|
||||||
onProjectChange,
|
onProjectChange,
|
||||||
packageId,
|
packageId,
|
||||||
@@ -64,90 +59,91 @@ export function SegmentDialog({
|
|||||||
onDirectionChange,
|
onDirectionChange,
|
||||||
register,
|
register,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: SegmentDialogProps) {
|
}: SegmentDialogProps) {
|
||||||
|
const projectSelectPortalRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const packageSelectPortalRef = useRef<HTMLDivElement | null>(null);
|
||||||
const getError = (field: keyof SegmentFormValues) =>
|
const getError = (field: keyof SegmentFormValues) =>
|
||||||
touchedFields[field] ? errors[field]?.message : undefined;
|
isSubmitted ? errors[field]?.message : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-2xl"
|
className="sm:max-w-2xl"
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
<DialogHeader className="gap-2">
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-3 text-xl">
|
<DialogTitle>
|
||||||
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
|
|
||||||
<Milestone className="size-5" />
|
|
||||||
</div>
|
|
||||||
{segmentId ? 'Edit Segment' : 'Create Segment'}
|
{segmentId ? 'Edit Segment' : 'Create Segment'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm">
|
<DialogDescription>
|
||||||
{segmentId
|
Manage segment binding, chainage values, direction, and coordinates.
|
||||||
? 'Update the technical specifications for your road segment.'
|
|
||||||
: 'Select a project and package, then provide the segment data.'}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="space-y-6">
|
<form
|
||||||
|
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 ? (
|
{!segmentId ? (
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField label="Project" required error={getError('project_id')}>
|
<FormField
|
||||||
<Select value={projectId} onValueChange={onProjectChange}>
|
label="Project"
|
||||||
<SelectTrigger aria-invalid={!!getError('project_id')}>
|
required
|
||||||
{isProjectsLoading ? (
|
error={getError('project_id')}
|
||||||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<ProjectSelect
|
||||||
Loading...
|
value={projectId}
|
||||||
</span>
|
onValueChange={onProjectChange}
|
||||||
) : (
|
enabled={open}
|
||||||
<SelectValue placeholder="Choose a project" />
|
disabled={isSaving}
|
||||||
)}
|
portalContainer={projectSelectPortalRef}
|
||||||
</SelectTrigger>
|
/>
|
||||||
<SelectContent>
|
<input
|
||||||
{projects.map((project) => (
|
type="hidden"
|
||||||
<SelectItem key={project.id} value={project.id}>
|
{...register('project_id')}
|
||||||
{project.name}
|
value={projectId}
|
||||||
</SelectItem>
|
readOnly
|
||||||
))}
|
/>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" {...register('project_id')} value={projectId} readOnly />
|
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="Package" required error={getError('package_id')}>
|
<FormField
|
||||||
<Select
|
label="Package"
|
||||||
|
required
|
||||||
|
error={getError('package_id')}
|
||||||
|
>
|
||||||
|
<PackageSelect
|
||||||
value={packageId}
|
value={packageId}
|
||||||
onValueChange={onPackageChange}
|
onValueChange={onPackageChange}
|
||||||
disabled={!projectId || isPackagesLoading}
|
projectId={projectId}
|
||||||
>
|
enabled={open && Boolean(projectId)}
|
||||||
<SelectTrigger aria-invalid={!!getError('package_id')}>
|
disabled={!projectId || isSaving}
|
||||||
{isPackagesLoading ? (
|
portalContainer={packageSelectPortalRef}
|
||||||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
/>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<input
|
||||||
Loading...
|
type="hidden"
|
||||||
</span>
|
{...register('package_id')}
|
||||||
) : (
|
value={packageId}
|
||||||
<SelectValue placeholder={projectId ? 'Choose a package' : 'Select project first'} />
|
readOnly
|
||||||
)}
|
/>
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{packages.map((pkg) => (
|
|
||||||
<SelectItem key={pkg.id} value={pkg.id}>
|
|
||||||
{pkg.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" {...register('package_id')} value={packageId} readOnly />
|
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
id="segment-name"
|
id="segment-name"
|
||||||
label="Segment Name"
|
label="Segment Name"
|
||||||
@@ -156,7 +152,7 @@ export function SegmentDialog({
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="segment-name"
|
id="segment-name"
|
||||||
placeholder="e.g. Mumbai to Pune"
|
placeholder="Enter segment name"
|
||||||
aria-invalid={!!getError('segment_name')}
|
aria-invalid={!!getError('segment_name')}
|
||||||
{...register('segment_name')}
|
{...register('segment_name')}
|
||||||
/>
|
/>
|
||||||
@@ -173,19 +169,30 @@ export function SegmentDialog({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormField>
|
</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
|
<FormField
|
||||||
id="segment-start-km"
|
id="segment-start-km"
|
||||||
label="Start (km)"
|
label="Start Chainage"
|
||||||
required
|
required
|
||||||
error={getError('chainage_start_km')}
|
error={getError('chainage_start_km')}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="segment-start-km"
|
id="segment-start-km"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="1"
|
||||||
min="0"
|
min="0"
|
||||||
placeholder="0.0"
|
placeholder="Enter start chainage"
|
||||||
aria-invalid={!!getError('chainage_start_km')}
|
aria-invalid={!!getError('chainage_start_km')}
|
||||||
{...register('chainage_start_km')}
|
{...register('chainage_start_km')}
|
||||||
/>
|
/>
|
||||||
@@ -193,30 +200,39 @@ export function SegmentDialog({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
id="segment-end-km"
|
id="segment-end-km"
|
||||||
label="End (km)"
|
label="End Chainage"
|
||||||
required
|
required
|
||||||
error={getError('chainage_end_km')}
|
error={getError('chainage_end_km')}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="segment-end-km"
|
id="segment-end-km"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="1"
|
||||||
min="0"
|
min="0"
|
||||||
placeholder="1.0"
|
placeholder="Enter end chainage"
|
||||||
aria-invalid={!!getError('chainage_end_km')}
|
aria-invalid={!!getError('chainage_end_km')}
|
||||||
{...register('chainage_end_km')}
|
{...register('chainage_end_km')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<section className="space-y-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-1">
|
||||||
<p className="flex items-center gap-2 text-[11px] font-black tracking-widest text-muted-foreground">
|
<h3 className="text-sm font-semibold">Coordinates</h3>
|
||||||
<MapPin className="size-3.5 opacity-60" />
|
<p className="text-sm text-muted-foreground">
|
||||||
START COORDINATES
|
Required start and end point latitude and longitude.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField id="start-lat" label="Latitude" required error={getError('start_lat')}>
|
<FormField
|
||||||
|
id="start-lat"
|
||||||
|
label="Start Latitude"
|
||||||
|
required
|
||||||
|
error={getError('start_lat')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="start-lat"
|
id="start-lat"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -228,7 +244,12 @@ export function SegmentDialog({
|
|||||||
{...register('start_lat')}
|
{...register('start_lat')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField id="start-lng" label="Longitude" required error={getError('start_lng')}>
|
<FormField
|
||||||
|
id="start-lng"
|
||||||
|
label="Start Longitude"
|
||||||
|
required
|
||||||
|
error={getError('start_lng')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="start-lng"
|
id="start-lng"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -241,15 +262,14 @@ export function SegmentDialog({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="flex items-center gap-2 text-[11px] font-black tracking-widest text-muted-foreground">
|
|
||||||
<MapPin className="size-3.5 opacity-60" />
|
|
||||||
END COORDINATES
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField id="end-lat" label="Latitude" required error={getError('end_lat')}>
|
<FormField
|
||||||
|
id="end-lat"
|
||||||
|
label="End Latitude"
|
||||||
|
required
|
||||||
|
error={getError('end_lat')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="end-lat"
|
id="end-lat"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -261,7 +281,12 @@ export function SegmentDialog({
|
|||||||
{...register('end_lat')}
|
{...register('end_lat')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField id="end-lng" label="Longitude" required error={getError('end_lng')}>
|
<FormField
|
||||||
|
id="end-lng"
|
||||||
|
label="End Longitude"
|
||||||
|
required
|
||||||
|
error={getError('end_lng')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="end-lng"
|
id="end-lng"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -275,18 +300,29 @@ export function SegmentDialog({
|
|||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSaving || !canSubmit}>
|
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||||
{isSaving ? <Loader2 className="size-4 animate-spin" /> : null}
|
{isSaving ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
{segmentId ? 'Update Segment' : 'Create Segment'}
|
{segmentId ? 'Update Segment' : 'Create Segment'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div ref={projectSelectPortalRef} />
|
||||||
|
<div ref={packageSelectPortalRef} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Edit3, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import type { Chainage } from '@/types';
|
import type { Chainage } from '@/types';
|
||||||
@@ -35,8 +36,19 @@ export function SegmentTable({
|
|||||||
title="Segments"
|
title="Segments"
|
||||||
data={segments}
|
data={segments}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
onEdit={onEdit}
|
actions={[
|
||||||
onDelete={onDelete}
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit3 className="size-4" />,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
icon: <Trash2 className="size-4" />,
|
||||||
|
className: 'text-destructive',
|
||||||
|
onClick: onDelete,
|
||||||
|
},
|
||||||
|
]}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyTitle="No segments found."
|
emptyTitle="No segments found."
|
||||||
pagination={{
|
pagination={{
|
||||||
@@ -46,6 +58,7 @@ export function SegmentTable({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
}}
|
}}
|
||||||
|
actionsSticky
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,47 +7,32 @@ import { useForm, useWatch } from 'react-hook-form';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
requiredIntegerNumber,
|
||||||
|
requiredLatitude,
|
||||||
|
requiredLongitude,
|
||||||
|
validateChainageRange,
|
||||||
|
} from '@/lib/validation/numberField';
|
||||||
import { chainageService } from '@/services/api';
|
import { chainageService } from '@/services/api';
|
||||||
import type { Chainage, ChainageCreate, ChainageUpdate } from '@/types';
|
import type { Chainage, ChainageCreate, ChainageUpdate } from '@/types';
|
||||||
|
|
||||||
import { segmentKeys } from '../queries/segmentKeys';
|
import { segmentKeys } from '../queries/segmentKeys';
|
||||||
|
|
||||||
const requiredNumber = (message: string) =>
|
const segmentFormSchema = z
|
||||||
z
|
.object({
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1, message)
|
|
||||||
.refine((value) => Number.isFinite(Number(value)), { message: 'Enter a valid number' });
|
|
||||||
|
|
||||||
const latitude = requiredNumber('Latitude is required').refine(
|
|
||||||
(value) => {
|
|
||||||
const numberValue = Number(value);
|
|
||||||
return numberValue >= -90 && numberValue <= 90;
|
|
||||||
},
|
|
||||||
{ message: 'Latitude must be between -90 and 90' },
|
|
||||||
);
|
|
||||||
|
|
||||||
const longitude = requiredNumber('Longitude is required').refine(
|
|
||||||
(value) => {
|
|
||||||
const numberValue = Number(value);
|
|
||||||
return numberValue >= -180 && numberValue <= 180;
|
|
||||||
},
|
|
||||||
{ message: 'Longitude must be between -180 and 180' },
|
|
||||||
);
|
|
||||||
|
|
||||||
const segmentFormSchema = z.object({
|
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
project_id: z.string().trim().min(1, 'Project is required'),
|
project_id: z.string().trim().min(1, 'Project is required'),
|
||||||
package_id: z.string().trim().min(1, 'Package is required'),
|
package_id: z.string().trim().min(1, 'Package is required'),
|
||||||
segment_name: z.string().trim().min(1, 'Segment name is required'),
|
segment_name: z.string().trim().min(1, 'Segment name is required'),
|
||||||
chainage_start_km: requiredNumber('Start km is required'),
|
chainage_start_km: requiredIntegerNumber('Start km is required'),
|
||||||
chainage_end_km: requiredNumber('End km is required'),
|
chainage_end_km: requiredIntegerNumber('End km is required'),
|
||||||
start_lat: latitude,
|
start_lat: requiredLatitude,
|
||||||
start_lng: longitude,
|
start_lng: requiredLongitude,
|
||||||
end_lat: latitude,
|
end_lat: requiredLatitude,
|
||||||
end_lng: longitude,
|
end_lng: requiredLongitude,
|
||||||
direction: z.enum(['UP', 'DOWN']),
|
direction: z.enum(['UP', 'DOWN']),
|
||||||
});
|
})
|
||||||
|
.superRefine(validateChainageRange);
|
||||||
|
|
||||||
export type SegmentFormValues = z.infer<typeof segmentFormSchema>;
|
export type SegmentFormValues = z.infer<typeof segmentFormSchema>;
|
||||||
|
|
||||||
@@ -64,7 +49,9 @@ const defaultValues: SegmentFormValues = {
|
|||||||
direction: 'UP',
|
direction: 'UP',
|
||||||
};
|
};
|
||||||
|
|
||||||
function toSegmentPayload(values: SegmentFormValues): ChainageCreate | ChainageUpdate {
|
function toSegmentPayload(
|
||||||
|
values: SegmentFormValues,
|
||||||
|
): ChainageCreate | ChainageUpdate {
|
||||||
return {
|
return {
|
||||||
package_id: values.package_id,
|
package_id: values.package_id,
|
||||||
segment_name: values.segment_name.trim(),
|
segment_name: values.segment_name.trim(),
|
||||||
@@ -86,10 +73,10 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
control,
|
control,
|
||||||
formState: { errors, isSubmitting, touchedFields },
|
formState: { errors, isSubmitting, isSubmitted },
|
||||||
} = useForm<SegmentFormValues>({
|
} = useForm<SegmentFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
mode: 'onTouched',
|
mode: 'onSubmit',
|
||||||
reValidateMode: 'onChange',
|
reValidateMode: 'onChange',
|
||||||
resolver: zodResolver(segmentFormSchema),
|
resolver: zodResolver(segmentFormSchema),
|
||||||
});
|
});
|
||||||
@@ -99,7 +86,8 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
const packageId = useWatch({ control, name: 'package_id' }) || '';
|
const packageId = useWatch({ control, name: 'package_id' }) || '';
|
||||||
const direction = useWatch({ control, name: 'direction' }) || 'UP';
|
const direction = useWatch({ control, name: 'direction' }) || 'UP';
|
||||||
const segmentName = useWatch({ control, name: 'segment_name' }) || '';
|
const segmentName = useWatch({ control, name: 'segment_name' }) || '';
|
||||||
const chainageStartKm = useWatch({ control, name: 'chainage_start_km' }) || '';
|
const chainageStartKm =
|
||||||
|
useWatch({ control, name: 'chainage_start_km' }) || '';
|
||||||
const chainageEndKm = useWatch({ control, name: 'chainage_end_km' }) || '';
|
const chainageEndKm = useWatch({ control, name: 'chainage_end_km' }) || '';
|
||||||
const startLat = useWatch({ control, name: 'start_lat' }) || '';
|
const startLat = useWatch({ control, name: 'start_lat' }) || '';
|
||||||
const startLng = useWatch({ control, name: 'start_lng' }) || '';
|
const startLng = useWatch({ control, name: 'start_lng' }) || '';
|
||||||
@@ -130,19 +118,13 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
queryClient.invalidateQueries({ queryKey: segmentKeys.all });
|
queryClient.invalidateQueries({ queryKey: segmentKeys.all });
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
onError: (_error, values) => {
|
||||||
toast.error(values.id ? 'Failed to update segment' : 'Failed to create segment');
|
toast.error(
|
||||||
|
values.id ? 'Failed to update segment' : 'Failed to create segment',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = handleSubmit(
|
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
|
||||||
(values) => saveMutation.mutate(values),
|
|
||||||
(formErrors) => {
|
|
||||||
const firstMessage = Object.values(formErrors).find((error) => error?.message)?.message;
|
|
||||||
if (firstMessage) {
|
|
||||||
toast.error(String(firstMessage));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
@@ -169,14 +151,21 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
|
|
||||||
const setProjectId = useCallback(
|
const setProjectId = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
setValue('project_id', value, { shouldDirty: true, shouldValidate: true });
|
setValue('project_id', value, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
setValue('package_id', '', { shouldDirty: true, shouldValidate: true });
|
setValue('package_id', '', { shouldDirty: true, shouldValidate: true });
|
||||||
},
|
},
|
||||||
[setValue],
|
[setValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setPackageId = useCallback(
|
const setPackageId = useCallback(
|
||||||
(value: string) => setValue('package_id', value, { shouldDirty: true, shouldValidate: true }),
|
(value: string) =>
|
||||||
|
setValue('package_id', value, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
}),
|
||||||
[setValue],
|
[setValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -200,7 +189,7 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
direction,
|
direction,
|
||||||
setDirection,
|
setDirection,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving: isSubmitting || saveMutation.isPending,
|
isSaving: isSubmitting || saveMutation.isPending,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { chainageService, packageService, projectService } from '@/services/api';
|
import {
|
||||||
|
chainageService,
|
||||||
|
packageService,
|
||||||
|
projectService,
|
||||||
|
} from '@/services/api';
|
||||||
import type { Chainage, PaginationParams } from '@/types';
|
import type { Chainage, PaginationParams } from '@/types';
|
||||||
|
|
||||||
|
import { packageKeys } from '../../package/queries/packageKeys';
|
||||||
|
import { projectKeys } from '../../project/queries/projectKeys';
|
||||||
import { segmentKeys } from '../queries/segmentKeys';
|
import { segmentKeys } from '../queries/segmentKeys';
|
||||||
|
|
||||||
interface UseSegmentsQueryParams {
|
interface UseSegmentsQueryParams {
|
||||||
@@ -15,7 +21,10 @@ interface UseSegmentsQueryParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSegmentsQuery({ skip, limit }: UseSegmentsQueryParams) {
|
export function useSegmentsQuery({ skip, limit }: UseSegmentsQueryParams) {
|
||||||
const listParams = useMemo<PaginationParams>(() => ({ skip, limit }), [limit, skip]);
|
const listParams = useMemo<PaginationParams>(
|
||||||
|
() => ({ skip, limit }),
|
||||||
|
[limit, skip],
|
||||||
|
);
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: segmentKeys.list(listParams),
|
queryKey: segmentKeys.list(listParams),
|
||||||
@@ -25,31 +34,24 @@ export function useSegmentsQuery({ skip, limit }: UseSegmentsQueryParams) {
|
|||||||
|
|
||||||
export function useProjectOptionsQuery() {
|
export function useProjectOptionsQuery() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['projects', 'options'],
|
queryKey: projectKeys.options(),
|
||||||
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
|
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAllPackageOptionsQuery() {
|
export function useAllPackageOptionsQuery() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['packages', 'options'],
|
queryKey: packageKeys.options(),
|
||||||
queryFn: () => packageService.getPackages({ skip: 0, limit: 1000 }),
|
queryFn: () => packageService.getPackages({ skip: 0, limit: 1000 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePackagesByProjectQuery(projectId: string, enabled: boolean) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['packages', 'by-project', projectId],
|
|
||||||
queryFn: () => packageService.getPackagesByProject(projectId, { skip: 0, limit: 1000 }),
|
|
||||||
enabled: enabled && Boolean(projectId),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteSegmentMutation() {
|
export function useDeleteSegmentMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (segment: Chainage) => chainageService.deleteChainage(segment.id),
|
mutationFn: (segment: Chainage) =>
|
||||||
|
chainageService.deleteChainage(segment.id),
|
||||||
onSuccess: (_data, segment) => {
|
onSuccess: (_data, segment) => {
|
||||||
toast.success('Segment deleted', {
|
toast.success('Segment deleted', {
|
||||||
description: `${segment.segment_name} has been removed from the system.`,
|
description: `${segment.segment_name} has been removed from the system.`,
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,16 @@ import { useCallback, useState } from 'react';
|
|||||||
import { Milestone, Plus } from 'lucide-react';
|
import { Milestone, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { Chainage } from '@/types';
|
import type { Chainage } from '@/types';
|
||||||
|
|
||||||
@@ -16,13 +25,13 @@ import { useSegmentForm } from './hooks/useSegmentForm';
|
|||||||
import {
|
import {
|
||||||
useAllPackageOptionsQuery,
|
useAllPackageOptionsQuery,
|
||||||
useDeleteSegmentMutation,
|
useDeleteSegmentMutation,
|
||||||
usePackagesByProjectQuery,
|
|
||||||
useProjectOptionsQuery,
|
useProjectOptionsQuery,
|
||||||
useSegmentsQuery,
|
useSegmentsQuery,
|
||||||
} from './hooks/useSegmentQueries';
|
} from './hooks/useSegmentQueries';
|
||||||
|
|
||||||
export default function SegmentPage() {
|
export default function SegmentPage() {
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [segmentToDelete, setSegmentToDelete] = useState<Chainage | null>(null);
|
||||||
const { skip, setSkip, limit, setLimit } = useSegmentFilters();
|
const { skip, setSkip, limit, setLimit } = useSegmentFilters();
|
||||||
const segmentsQuery = useSegmentsQuery({ skip, limit });
|
const segmentsQuery = useSegmentsQuery({ skip, limit });
|
||||||
const projectsQuery = useProjectOptionsQuery();
|
const projectsQuery = useProjectOptionsQuery();
|
||||||
@@ -45,12 +54,10 @@ export default function SegmentPage() {
|
|||||||
direction,
|
direction,
|
||||||
setDirection,
|
setDirection,
|
||||||
errors,
|
errors,
|
||||||
touchedFields,
|
isSubmitted,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
} = segmentForm;
|
} = segmentForm;
|
||||||
const projectPackagesQuery = usePackagesByProjectQuery(projectId, isDialogOpen);
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
prepareCreateSegment();
|
prepareCreateSegment();
|
||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
@@ -58,7 +65,9 @@ export default function SegmentPage() {
|
|||||||
|
|
||||||
const openEdit = useCallback(
|
const openEdit = useCallback(
|
||||||
(segment: Chainage) => {
|
(segment: Chainage) => {
|
||||||
const pkg = allPackagesQuery.data?.items.find((item) => item.id === segment.package_id);
|
const pkg = allPackagesQuery.data?.items.find(
|
||||||
|
(item) => item.id === segment.package_id,
|
||||||
|
);
|
||||||
prepareEditSegment(segment, pkg?.project_id || '');
|
prepareEditSegment(segment, pkg?.project_id || '');
|
||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
},
|
},
|
||||||
@@ -77,17 +86,27 @@ export default function SegmentPage() {
|
|||||||
|
|
||||||
const deleteSegment = useCallback(
|
const deleteSegment = useCallback(
|
||||||
(segment: Chainage) => {
|
(segment: Chainage) => {
|
||||||
if (!confirm(`Are you sure you want to delete segment "${segment.segment_name}"?`)) {
|
setSegmentToDelete(segment);
|
||||||
return;
|
|
||||||
}
|
|
||||||
deleteSegmentMutation.mutate(segment);
|
|
||||||
},
|
},
|
||||||
[deleteSegmentMutation],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDeleteDialogOpenChange = useCallback((open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
setSegmentToDelete(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmDeleteSegment = useCallback(() => {
|
||||||
|
if (!segmentToDelete) return;
|
||||||
|
|
||||||
|
deleteSegmentMutation.mutate(segmentToDelete, {
|
||||||
|
onSettled: () => setSegmentToDelete(null),
|
||||||
|
});
|
||||||
|
}, [deleteSegmentMutation, segmentToDelete]);
|
||||||
|
|
||||||
const projects = projectsQuery.data?.items ?? [];
|
const projects = projectsQuery.data?.items ?? [];
|
||||||
const allPackages = allPackagesQuery.data?.items ?? [];
|
const allPackages = allPackagesQuery.data?.items ?? [];
|
||||||
const projectPackages = projectPackagesQuery.data?.items ?? [];
|
|
||||||
const segments = segmentsQuery.data?.items ?? [];
|
const segments = segmentsQuery.data?.items ?? [];
|
||||||
const total = segmentsQuery.data?.totalItems ?? 0;
|
const total = segmentsQuery.data?.totalItems ?? 0;
|
||||||
const columns = useSegmentColumns(projects, allPackages);
|
const columns = useSegmentColumns(projects, allPackages);
|
||||||
@@ -119,18 +138,12 @@ export default function SegmentPage() {
|
|||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDelete={deleteSegment}
|
onDelete={deleteSegment}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SegmentDialog
|
<SegmentDialog
|
||||||
open={isDialogOpen}
|
open={isDialogOpen}
|
||||||
onOpenChange={handleDialogOpenChange}
|
onOpenChange={handleDialogOpenChange}
|
||||||
segmentId={segmentId}
|
segmentId={segmentId}
|
||||||
projects={projects}
|
|
||||||
packages={projectPackages}
|
|
||||||
isProjectsLoading={projectsQuery.isLoading}
|
|
||||||
isPackagesLoading={projectPackagesQuery.isLoading}
|
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onProjectChange={setProjectId}
|
onProjectChange={setProjectId}
|
||||||
packageId={packageId}
|
packageId={packageId}
|
||||||
@@ -139,11 +152,41 @@ export default function SegmentPage() {
|
|||||||
onDirectionChange={setDirection}
|
onDirectionChange={setDirection}
|
||||||
register={register}
|
register={register}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
touchedFields={touchedFields}
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
canSubmit={canSubmit}
|
canSubmit={canSubmit}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={Boolean(segmentToDelete)}
|
||||||
|
onOpenChange={handleDeleteDialogOpenChange}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete segment?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{segmentToDelete?.segment_name}
|
||||||
|
</span>
|
||||||
|
. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deleteSegmentMutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deleteSegmentMutation.isPending}
|
||||||
|
onClick={confirmDeleteSegment}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,6 @@ export const segmentKeys = {
|
|||||||
all: ['segments'] as const,
|
all: ['segments'] as const,
|
||||||
lists: () => [...segmentKeys.all, 'list'] as const,
|
lists: () => [...segmentKeys.all, 'list'] as const,
|
||||||
list: (params: PaginationParams) => [...segmentKeys.lists(), params] as const,
|
list: (params: PaginationParams) => [...segmentKeys.lists(), params] as const,
|
||||||
|
details: () => [...segmentKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...segmentKeys.details(), id] as const,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,12 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Edit, Trash2 } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
|
||||||
import { usePermissions } from '@/hooks/usePermissions';
|
|
||||||
import type { Tenant } from '@/types';
|
import type { Tenant } from '@/types';
|
||||||
|
|
||||||
function formatDate(value?: string | null) {
|
function formatDate(value?: string | null) {
|
||||||
@@ -19,30 +15,16 @@ function formatDate(value?: string | null) {
|
|||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseTenantColumnsParams {
|
export function useTenantColumns(): ColumnDef<Tenant>[] {
|
||||||
onEdit: (tenant: Tenant) => void;
|
|
||||||
onDelete: (tenant: Tenant) => void;
|
|
||||||
pendingDeleteId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTenantColumns({
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
pendingDeleteId,
|
|
||||||
}: UseTenantColumnsParams): ColumnDef<Tenant>[] {
|
|
||||||
const { hasPermission } = usePermissions();
|
|
||||||
const canEdit = hasPermission(PERMISSIONS.TENANT.UPDATE);
|
|
||||||
const canDelete = hasPermission(PERMISSIONS.TENANT.DELETE);
|
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const columns: ColumnDef<Tenant>[] = [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Tenant',
|
header: 'Tenant',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{row.original.name}</p>
|
<p>{row.original.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{row.original.slug}</p>
|
<p className="text-muted-foreground">{row.original.slug}</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -76,37 +58,5 @@ export function useTenantColumns({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}, []);
|
||||||
if (!canEdit && !canDelete) return columns;
|
|
||||||
|
|
||||||
columns.push({
|
|
||||||
id: 'actions',
|
|
||||||
header: () => <div className="text-right">Actions</div>,
|
|
||||||
enableSorting: false,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const tenant = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{canEdit ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onEdit(tenant)}>
|
|
||||||
<Edit className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{canDelete ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pendingDeleteId === tenant.id}
|
|
||||||
onClick={() => onDelete(tenant)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
}, [canDelete, canEdit, onDelete, onEdit, pendingDeleteId]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import { useMemo, useRef, type ComponentProps } from 'react';
|
||||||
import type { UseFormRegister } from 'react-hook-form';
|
import {
|
||||||
|
Controller,
|
||||||
|
type Control,
|
||||||
|
type FieldErrors,
|
||||||
|
type UseFormRegister,
|
||||||
|
} from 'react-hook-form';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { FormField, PhoneInput, SelectPopover } from '@/components/form';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
@@ -14,17 +21,12 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import type { Client, Plan } from '@/types';
|
import type { Client, Plan } from '@/types';
|
||||||
|
|
||||||
import type { TenantFormValues } from '../hooks/useTenantForm';
|
import type {
|
||||||
|
TenantAdminFieldLocks,
|
||||||
|
TenantFormValues,
|
||||||
|
} from '../hooks/useTenantForm';
|
||||||
|
|
||||||
interface TenantSheetProps {
|
interface TenantSheetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -32,7 +34,11 @@ interface TenantSheetProps {
|
|||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
register: UseFormRegister<TenantFormValues>;
|
register: UseFormRegister<TenantFormValues>;
|
||||||
|
control: Control<TenantFormValues>;
|
||||||
|
errors: FieldErrors<TenantFormValues>;
|
||||||
|
isSubmitted: boolean;
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
|
canSubmit: boolean;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
planId: string;
|
planId: string;
|
||||||
onClientChange: (clientId: string) => void;
|
onClientChange: (clientId: string) => void;
|
||||||
@@ -43,6 +49,7 @@ interface TenantSheetProps {
|
|||||||
isLookupsLoading: boolean;
|
isLookupsLoading: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
adminEmail?: string;
|
adminEmail?: string;
|
||||||
|
adminFieldLocks: TenantAdminFieldLocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TenantSheet({
|
export function TenantSheet({
|
||||||
@@ -51,7 +58,11 @@ export function TenantSheet({
|
|||||||
tenantId,
|
tenantId,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
canSubmit,
|
||||||
clientId,
|
clientId,
|
||||||
planId,
|
planId,
|
||||||
onClientChange,
|
onClientChange,
|
||||||
@@ -62,157 +73,259 @@ export function TenantSheet({
|
|||||||
isLookupsLoading,
|
isLookupsLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
adminEmail,
|
adminEmail,
|
||||||
|
adminFieldLocks,
|
||||||
}: TenantSheetProps) {
|
}: TenantSheetProps) {
|
||||||
|
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const fieldError = (field: keyof TenantFormValues) =>
|
||||||
|
isSubmitted ? errors[field]?.message : undefined;
|
||||||
|
|
||||||
|
const clientOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
clients.map((client) => ({
|
||||||
|
value: String(client.id),
|
||||||
|
label: [client.first_name, client.last_name].filter(Boolean).join(' '),
|
||||||
|
})),
|
||||||
|
[clients],
|
||||||
|
);
|
||||||
|
|
||||||
|
const planOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
plans.map((plan) => ({
|
||||||
|
value: String(plan.id),
|
||||||
|
label: plan.name,
|
||||||
|
})),
|
||||||
|
[plans],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
|
<DialogContent className="sm:max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{tenantId ? 'Edit Tenant' : 'Create Tenant'}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{tenantId ? 'Edit Tenant' : 'Create Tenant'}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Bind a client and subscription plan, then invite the tenant administrator.
|
Bind a client and subscription plan, then invite the tenant
|
||||||
|
administrator.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
|
||||||
|
<form
|
||||||
|
noValidate
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
|
<DialogBody className="space-y-5">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium">Basic Information</p>
|
<p>Basic Information</p>
|
||||||
<p className="text-xs text-muted-foreground">Tenant identity and subscription binding.</p>
|
<p className="text-muted-foreground">
|
||||||
|
Tenant identity and subscription binding.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="tenant-name">Tenant Name</Label>
|
id="tenant-name"
|
||||||
|
label="Tenant Name"
|
||||||
|
required
|
||||||
|
error={fieldError('name')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="tenant-name"
|
id="tenant-name"
|
||||||
placeholder="Acme Corp"
|
placeholder="Enter tenant name"
|
||||||
|
aria-invalid={!!fieldError('name')}
|
||||||
{...register('name', {
|
{...register('name', {
|
||||||
required: true,
|
|
||||||
onChange: (event) => onNameChange(event.target.value),
|
onChange: (event) => onNameChange(event.target.value),
|
||||||
})}
|
})}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="tenant-slug">Slug</Label>
|
<FormField
|
||||||
|
id="tenant-slug"
|
||||||
|
label="Slug"
|
||||||
|
required
|
||||||
|
error={fieldError('slug')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="tenant-slug"
|
id="tenant-slug"
|
||||||
placeholder="acme-corp"
|
placeholder="acme-corp"
|
||||||
{...register('slug', { required: true })}
|
aria-invalid={!!fieldError('slug')}
|
||||||
required
|
{...register('slug')}
|
||||||
readOnly={isEditMode}
|
readOnly={isEditMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label>Client</Label>
|
id="tenant-client"
|
||||||
<Select value={clientId} onValueChange={onClientChange} disabled={isLookupsLoading}>
|
label="Client"
|
||||||
<SelectTrigger>
|
required
|
||||||
<SelectValue placeholder={isLookupsLoading ? 'Loading clients...' : 'Select client'} />
|
error={fieldError('client_id')}
|
||||||
</SelectTrigger>
|
>
|
||||||
<SelectContent>
|
<SelectPopover
|
||||||
{clients.map((client) => (
|
options={clientOptions}
|
||||||
<SelectItem key={client.id} value={String(client.id)}>
|
value={clientId}
|
||||||
{client.name}
|
onValueChange={onClientChange}
|
||||||
</SelectItem>
|
disabled={isLookupsLoading}
|
||||||
))}
|
portalContainer={dropdownPortalRef}
|
||||||
</SelectContent>
|
placeholder={
|
||||||
</Select>
|
isLookupsLoading ? 'Loading clients...' : 'Select client'
|
||||||
</div>
|
}
|
||||||
<div className="space-y-2">
|
searchPlaceholder="Search clients"
|
||||||
<Label>Subscription Plan</Label>
|
emptyMessage="No clients found."
|
||||||
<Select value={planId} onValueChange={onPlanChange} disabled={isLookupsLoading}>
|
/>
|
||||||
<SelectTrigger>
|
<input
|
||||||
<SelectValue placeholder={isLookupsLoading ? 'Loading plans...' : 'Select plan'} />
|
type="hidden"
|
||||||
</SelectTrigger>
|
{...register('client_id')}
|
||||||
<SelectContent>
|
value={clientId}
|
||||||
{plans.map((plan) => (
|
readOnly
|
||||||
<SelectItem key={plan.id} value={String(plan.id)}>
|
/>
|
||||||
{plan.name}
|
</FormField>
|
||||||
</SelectItem>
|
|
||||||
))}
|
<FormField
|
||||||
</SelectContent>
|
id="tenant-plan"
|
||||||
</Select>
|
label="Subscription Plan"
|
||||||
</div>
|
required
|
||||||
|
error={fieldError('plan_id')}
|
||||||
|
>
|
||||||
|
<SelectPopover
|
||||||
|
options={planOptions}
|
||||||
|
value={planId}
|
||||||
|
onValueChange={onPlanChange}
|
||||||
|
disabled={isLookupsLoading}
|
||||||
|
portalContainer={dropdownPortalRef}
|
||||||
|
placeholder={
|
||||||
|
isLookupsLoading ? 'Loading plans...' : 'Select plan'
|
||||||
|
}
|
||||||
|
searchPlaceholder="Search plans"
|
||||||
|
emptyMessage="No plans found."
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
{...register('plan_id')}
|
||||||
|
value={planId}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<FormField id="tenant-domain" label="Domain">
|
||||||
<Label htmlFor="tenant-domain">Domain</Label>
|
<Input
|
||||||
<Input id="tenant-domain" placeholder="acme.example.com" {...register('domain')} />
|
id="tenant-domain"
|
||||||
</div>
|
placeholder="acme.example.com"
|
||||||
<div className="space-y-2 md:col-span-2">
|
{...register('domain')}
|
||||||
<Label htmlFor="tenant-description">Description</Label>
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="tenant-description"
|
||||||
|
label="Description"
|
||||||
|
className="md:col-span-2"
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
id="tenant-description"
|
id="tenant-description"
|
||||||
placeholder="North zone operations"
|
placeholder="North zone operations"
|
||||||
{...register('description')}
|
{...register('description')}
|
||||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="min-h-20 w-full rounded-lg border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isEditMode ? (
|
{!isEditMode ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium">Tenant Administrator</p>
|
<p>Tenant Administrator</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Invitation details for the primary tenant admin account.
|
Invitation details for the primary tenant admin account.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="admin-first-name">First Name</Label>
|
id="admin-first-name"
|
||||||
|
label="First Name"
|
||||||
|
required
|
||||||
|
error={fieldError('admin_first_name')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="admin-first-name"
|
id="admin-first-name"
|
||||||
placeholder="Jane"
|
placeholder="Enter first name"
|
||||||
{...register('admin_first_name', { required: !isEditMode })}
|
aria-invalid={!!fieldError('admin_first_name')}
|
||||||
required
|
readOnly={adminFieldLocks.admin_first_name}
|
||||||
|
{...register('admin_first_name')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="admin-last-name">Last Name</Label>
|
<FormField
|
||||||
|
id="admin-last-name"
|
||||||
|
label="Last Name"
|
||||||
|
required
|
||||||
|
error={fieldError('admin_last_name')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="admin-last-name"
|
id="admin-last-name"
|
||||||
placeholder="Doe"
|
placeholder="Enter last name"
|
||||||
{...register('admin_last_name', { required: !isEditMode })}
|
aria-invalid={!!fieldError('admin_last_name')}
|
||||||
required
|
readOnly={adminFieldLocks.admin_last_name}
|
||||||
|
{...register('admin_last_name')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="admin-email">Admin Email</Label>
|
id="admin-email"
|
||||||
|
label="Admin Email"
|
||||||
|
required
|
||||||
|
error={fieldError('admin_email')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="admin-email"
|
id="admin-email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="jane@acme.com"
|
placeholder="admin@example.com"
|
||||||
{...register('admin_email', { required: !isEditMode })}
|
aria-invalid={!!fieldError('admin_email')}
|
||||||
required
|
readOnly={adminFieldLocks.admin_email}
|
||||||
|
{...register('admin_email')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="admin-phone">Phone Number</Label>
|
<FormField
|
||||||
<Input
|
|
||||||
id="admin-phone"
|
id="admin-phone"
|
||||||
placeholder="+91-9000011111"
|
label="Phone Number"
|
||||||
{...register('admin_phone_number')}
|
error={fieldError('admin_phone_number')}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="admin_phone_number"
|
||||||
|
render={({ field }) => (
|
||||||
|
<PhoneInput
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="Enter phone number"
|
||||||
|
aria-invalid={!!fieldError('admin_phone_number')}
|
||||||
|
disabled={adminFieldLocks.admin_phone_number}
|
||||||
|
portalContainer={dropdownPortalRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : adminEmail ? (
|
) : adminEmail ? (
|
||||||
<div className="rounded-md border bg-muted/40 p-4 text-sm text-muted-foreground">
|
<div className="rounded-lg border bg-muted/40 p-4 text-muted-foreground">
|
||||||
Admin invitation details cannot be changed after tenant creation.
|
Admin invitation details cannot be changed after tenant
|
||||||
|
creation.
|
||||||
<p className="mt-1 text-foreground">
|
<p className="mt-1 text-foreground">
|
||||||
Current admin email: <span className="font-medium">{adminEmail}</span>
|
Current admin email: <span>{adminEmail}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
<DialogFooter className="px-0">
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -221,12 +334,19 @@ export function TenantSheet({
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSaving || isLookupsLoading}>
|
<Button
|
||||||
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
type="submit"
|
||||||
|
disabled={isSaving || isLookupsLoading || !canSubmit}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
{tenantId ? 'Update Tenant' : 'Create Tenant'}
|
{tenantId ? 'Update Tenant' : 'Create Tenant'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div ref={dropdownPortalRef} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
|
import { Edit, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import type { Tenant } from '@/types';
|
import type { Tenant } from '@/types';
|
||||||
|
|
||||||
interface TenantTableProps {
|
interface TenantTableProps {
|
||||||
@@ -18,6 +20,9 @@ interface TenantTableProps {
|
|||||||
onPageChange: (skip: number) => void;
|
onPageChange: (skip: number) => void;
|
||||||
onLimitChange: (limit: number) => void;
|
onLimitChange: (limit: number) => void;
|
||||||
onSortingChange: (sorting: SortingState) => void;
|
onSortingChange: (sorting: SortingState) => void;
|
||||||
|
onEdit: (tenant: Tenant) => void;
|
||||||
|
onDelete: (tenant: Tenant) => void;
|
||||||
|
pendingDeleteId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TenantTable({
|
export function TenantTable({
|
||||||
@@ -32,6 +37,9 @@ export function TenantTable({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
onSortingChange,
|
onSortingChange,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
pendingDeleteId,
|
||||||
}: TenantTableProps) {
|
}: TenantTableProps) {
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -41,6 +49,22 @@ export function TenantTable({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
toolbar={toolbar}
|
toolbar={toolbar}
|
||||||
emptyTitle="No tenants found."
|
emptyTitle="No tenants found."
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Edit className="size-4" />,
|
||||||
|
permission: PERMISSIONS.TENANT.UPDATE,
|
||||||
|
onClick: onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
icon: <Trash2 className="size-4" />,
|
||||||
|
permission: PERMISSIONS.TENANT.DELETE,
|
||||||
|
className: 'text-destructive',
|
||||||
|
disabled: (tenant) => pendingDeleteId === tenant.id,
|
||||||
|
onClick: onDelete,
|
||||||
|
},
|
||||||
|
]}
|
||||||
pagination={{
|
pagination={{
|
||||||
skip,
|
skip,
|
||||||
limit,
|
limit,
|
||||||
@@ -50,6 +74,7 @@ export function TenantTable({
|
|||||||
}}
|
}}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
onSortingChange={onSortingChange}
|
onSortingChange={onSortingChange}
|
||||||
|
actionsSticky
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export function useTenantFilters() {
|
|||||||
const [limit, setLimitValue] = useState(10);
|
const [limit, setLimitValue] = useState(10);
|
||||||
const [sorting, setSortingValue] = useState<SortingState>([]);
|
const [sorting, setSortingValue] = useState<SortingState>([]);
|
||||||
const [searchTerm, setSearchTermValue] = useState('');
|
const [searchTerm, setSearchTermValue] = useState('');
|
||||||
const [statusFilter, setStatusFilterValue] = useState<TenantStatusFilter>('all');
|
const [statusFilter, setStatusFilterValue] =
|
||||||
|
useState<TenantStatusFilter>('all');
|
||||||
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
|
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
|
||||||
|
|
||||||
const setSearchTerm = useCallback((value: string) => {
|
const setSearchTerm = useCallback((value: string) => {
|
||||||
|
|||||||
@@ -1,26 +1,74 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { Tenant } from '@/types';
|
import type { Tenant } from '@/types';
|
||||||
|
|
||||||
import { useSaveTenantMutation } from './useTenantMutations';
|
import { useSaveTenantMutation } from './useTenantMutations';
|
||||||
|
|
||||||
export interface TenantFormValues {
|
const requiredText = (message: string) => z.string().trim().min(1, message);
|
||||||
id?: number;
|
|
||||||
name: string;
|
const tenantFormSchema = z
|
||||||
slug: string;
|
.object({
|
||||||
client_id: string;
|
id: z.number().optional(),
|
||||||
plan_id: string;
|
name: requiredText('Tenant name is required'),
|
||||||
domain: string;
|
slug: requiredText('Slug is required'),
|
||||||
description: string;
|
client_id: requiredText('Client is required'),
|
||||||
admin_first_name: string;
|
plan_id: requiredText('Plan is required'),
|
||||||
admin_last_name: string;
|
domain: z.string().trim(),
|
||||||
admin_email: string;
|
description: z.string().trim(),
|
||||||
admin_phone_number: string;
|
admin_first_name: z.string().trim(),
|
||||||
}
|
admin_last_name: z.string().trim(),
|
||||||
|
admin_email: z.string().trim(),
|
||||||
|
admin_phone_number: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((value) => value === '' || isValidPhoneNumber(value), {
|
||||||
|
message: 'Enter a valid phone number',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.superRefine((values, ctx) => {
|
||||||
|
if (values.id) return;
|
||||||
|
|
||||||
|
if (!values.admin_first_name.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['admin_first_name'],
|
||||||
|
message: 'First name is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.admin_last_name.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['admin_last_name'],
|
||||||
|
message: 'Last name is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.admin_email.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['admin_email'],
|
||||||
|
message: 'Admin email is required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!z.string().email().safeParse(values.admin_email).success) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['admin_email'],
|
||||||
|
message: 'Invalid admin email',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TenantFormValues = z.infer<typeof tenantFormSchema>;
|
||||||
|
|
||||||
const defaultValues: TenantFormValues = {
|
const defaultValues: TenantFormValues = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -50,6 +98,20 @@ function splitContactName(contactName: string) {
|
|||||||
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
|
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TenantAdminFieldLocks = {
|
||||||
|
admin_first_name: boolean;
|
||||||
|
admin_last_name: boolean;
|
||||||
|
admin_email: boolean;
|
||||||
|
admin_phone_number: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultAdminFieldLocks: TenantAdminFieldLocks = {
|
||||||
|
admin_first_name: false,
|
||||||
|
admin_last_name: false,
|
||||||
|
admin_email: false,
|
||||||
|
admin_phone_number: false,
|
||||||
|
};
|
||||||
|
|
||||||
export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -57,44 +119,49 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
reset,
|
reset,
|
||||||
control,
|
control,
|
||||||
setValue,
|
setValue,
|
||||||
formState: { isSubmitting },
|
formState: { errors, isSubmitting, isSubmitted },
|
||||||
} = useForm<TenantFormValues>({
|
} = useForm<TenantFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
|
mode: 'onSubmit',
|
||||||
|
reValidateMode: 'onChange',
|
||||||
|
resolver: zodResolver(tenantFormSchema),
|
||||||
});
|
});
|
||||||
const saveMutation = useSaveTenantMutation({
|
const saveMutation = useSaveTenantMutation({
|
||||||
onSaved: () => {
|
onSaved: () => {
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
|
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||||
onSaved();
|
onSaved();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [adminFieldLocks, setAdminFieldLocks] = useState<TenantAdminFieldLocks>(
|
||||||
|
defaultAdminFieldLocks,
|
||||||
|
);
|
||||||
|
|
||||||
const tenantId = useWatch({ control, name: 'id' });
|
const tenantId = useWatch({ control, name: 'id' });
|
||||||
const clientId = useWatch({ control, name: 'client_id' }) || '';
|
const clientId = useWatch({ control, name: 'client_id' }) || '';
|
||||||
const planId = useWatch({ control, name: 'plan_id' }) || '';
|
const planId = useWatch({ control, name: 'plan_id' }) || '';
|
||||||
|
const name = useWatch({ control, name: 'name' }) || '';
|
||||||
|
const slug = useWatch({ control, name: 'slug' }) || '';
|
||||||
|
const adminFirstName = useWatch({ control, name: 'admin_first_name' }) || '';
|
||||||
|
const adminLastName = useWatch({ control, name: 'admin_last_name' }) || '';
|
||||||
const adminEmail = useWatch({ control, name: 'admin_email' }) || '';
|
const adminEmail = useWatch({ control, name: 'admin_email' }) || '';
|
||||||
const isEditMode = Boolean(tenantId);
|
const isEditMode = Boolean(tenantId);
|
||||||
|
const canSubmit =
|
||||||
|
name.trim().length > 0 &&
|
||||||
|
slug.trim().length > 0 &&
|
||||||
|
clientId.trim().length > 0 &&
|
||||||
|
planId.trim().length > 0 &&
|
||||||
|
(isEditMode ||
|
||||||
|
(adminFirstName.trim().length > 0 &&
|
||||||
|
adminLastName.trim().length > 0 &&
|
||||||
|
adminEmail.trim().length > 0));
|
||||||
|
|
||||||
const handleSubmit = submitForm(
|
const handleSubmit = submitForm((values) => saveMutation.mutate(values));
|
||||||
(values) => {
|
|
||||||
if (!values.client_id || !values.plan_id) {
|
|
||||||
toast.error('Select a client and plan');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isEditMode) {
|
|
||||||
if (!values.admin_first_name.trim() || !values.admin_last_name.trim() || !values.admin_email.trim()) {
|
|
||||||
toast.error('Complete all required admin fields');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveMutation.mutate(values);
|
|
||||||
},
|
|
||||||
() => toast.error('Complete all required tenant fields'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
|
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||||
}, [reset]);
|
}, [reset]);
|
||||||
|
|
||||||
const openEdit = useCallback(
|
const openEdit = useCallback(
|
||||||
@@ -112,19 +179,37 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
admin_email: tenant.admin_email || '',
|
admin_email: tenant.admin_email || '',
|
||||||
admin_phone_number: '',
|
admin_phone_number: '',
|
||||||
});
|
});
|
||||||
|
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||||
},
|
},
|
||||||
[reset],
|
[reset],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clearAdminFields = useCallback(() => {
|
||||||
|
setValue('admin_first_name', '', { shouldDirty: true });
|
||||||
|
setValue('admin_last_name', '', { shouldDirty: true });
|
||||||
|
setValue('admin_email', '', { shouldDirty: true });
|
||||||
|
setValue('admin_phone_number', '', { shouldDirty: true });
|
||||||
|
setAdminFieldLocks(defaultAdminFieldLocks);
|
||||||
|
}, [setValue]);
|
||||||
|
|
||||||
const applyClientAdminDefaults = useCallback(
|
const applyClientAdminDefaults = useCallback(
|
||||||
(contactName: string, contactEmail: string, contactPhone: string) => {
|
(contactName: string, contactEmail: string, contactPhone: string) => {
|
||||||
if (isEditMode) return;
|
if (isEditMode) return;
|
||||||
|
|
||||||
const { firstName, lastName } = splitContactName(contactName);
|
const { firstName, lastName } = splitContactName(contactName);
|
||||||
|
const trimmedEmail = contactEmail.trim();
|
||||||
|
const trimmedPhone = contactPhone.trim();
|
||||||
|
|
||||||
setValue('admin_first_name', firstName, { shouldDirty: true });
|
setValue('admin_first_name', firstName, { shouldDirty: true });
|
||||||
setValue('admin_last_name', lastName, { shouldDirty: true });
|
setValue('admin_last_name', lastName, { shouldDirty: true });
|
||||||
setValue('admin_email', contactEmail, { shouldDirty: true });
|
setValue('admin_email', trimmedEmail, { shouldDirty: true });
|
||||||
setValue('admin_phone_number', contactPhone, { shouldDirty: true });
|
setValue('admin_phone_number', trimmedPhone, { shouldDirty: true });
|
||||||
|
setAdminFieldLocks({
|
||||||
|
admin_first_name: firstName.trim().length > 0,
|
||||||
|
admin_last_name: lastName.trim().length > 0,
|
||||||
|
admin_email: trimmedEmail.length > 0,
|
||||||
|
admin_phone_number: trimmedPhone.length > 0,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[isEditMode, setValue],
|
[isEditMode, setValue],
|
||||||
);
|
);
|
||||||
@@ -139,9 +224,9 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
control,
|
|
||||||
setValue,
|
setValue,
|
||||||
openCreate,
|
openCreate,
|
||||||
openEdit,
|
openEdit,
|
||||||
@@ -150,7 +235,12 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
planId,
|
planId,
|
||||||
adminEmail,
|
adminEmail,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
|
canSubmit,
|
||||||
|
adminFieldLocks,
|
||||||
applyClientAdminDefaults,
|
applyClientAdminDefaults,
|
||||||
|
clearAdminFields,
|
||||||
syncSlugFromName,
|
syncSlugFromName,
|
||||||
isSaving: isSubmitting || saveMutation.isPending,
|
isSaving: isSubmitting || saveMutation.isPending,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ export function useSaveTenantMutation({ onSaved }: { onSaved: () => void }) {
|
|||||||
onSaved();
|
onSaved();
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
onError: (_error, values) => {
|
||||||
toast.error(values.id ? 'Failed to update tenant' : 'Failed to create tenant');
|
toast.error(
|
||||||
|
values.id ? 'Failed to update tenant' : 'Failed to create tenant',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/app/(modules)/tenants/layout.tsx
Normal file
16
src/app/(modules)/tenants/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
|
import { PermissionRouteGuard } from '@/guards';
|
||||||
|
|
||||||
|
export default function TenantsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PermissionRouteGuard permission={PERMISSIONS.TENANT.READ}>
|
||||||
|
{children}
|
||||||
|
</PermissionRouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { useCallback, useMemo, useState } from 'react';
|
|||||||
import { Globe, Plus } from 'lucide-react';
|
import { Globe, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { PoweredBy } from '@/components/powered-by';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PERMISSIONS } from '@/constants/permissions';
|
import { PERMISSIONS } from '@/constants/permissions';
|
||||||
import { PermissionGuard } from '@/guards';
|
import { PermissionGuard } from '@/guards';
|
||||||
@@ -55,6 +54,7 @@ export default function TenantsPage() {
|
|||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
openCreate: prepareCreateTenant,
|
openCreate: prepareCreateTenant,
|
||||||
openEdit: prepareEditTenant,
|
openEdit: prepareEditTenant,
|
||||||
@@ -63,7 +63,12 @@ export default function TenantsPage() {
|
|||||||
planId,
|
planId,
|
||||||
adminEmail,
|
adminEmail,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
|
errors,
|
||||||
|
isSubmitted,
|
||||||
|
canSubmit,
|
||||||
|
adminFieldLocks,
|
||||||
applyClientAdminDefaults,
|
applyClientAdminDefaults,
|
||||||
|
clearAdminFields,
|
||||||
syncSlugFromName,
|
syncSlugFromName,
|
||||||
setValue,
|
setValue,
|
||||||
isSaving,
|
isSaving,
|
||||||
@@ -75,7 +80,11 @@ export default function TenantsPage() {
|
|||||||
(value: string) => {
|
(value: string) => {
|
||||||
setValue('client_id', value, { shouldDirty: true, shouldValidate: true });
|
setValue('client_id', value, { shouldDirty: true, shouldValidate: true });
|
||||||
|
|
||||||
if (!value || isEditMode) return;
|
if (isEditMode) return;
|
||||||
|
|
||||||
|
clearAdminFields();
|
||||||
|
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
clientService.getClientById(Number(value)).then((client) => {
|
clientService.getClientById(Number(value)).then((client) => {
|
||||||
applyClientAdminDefaults(
|
applyClientAdminDefaults(
|
||||||
@@ -85,7 +94,7 @@ export default function TenantsPage() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[applyClientAdminDefaults, isEditMode, setValue],
|
[applyClientAdminDefaults, clearAdminFields, isEditMode, setValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePlanChange = useCallback(
|
const handlePlanChange = useCallback(
|
||||||
@@ -117,17 +126,14 @@ export default function TenantsPage() {
|
|||||||
[deleteMutation],
|
[deleteMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useTenantColumns({
|
const columns = useTenantColumns();
|
||||||
onEdit: openEdit,
|
|
||||||
onDelete: handleDelete,
|
|
||||||
pendingDeleteId: deleteMutation.isPending ? deleteMutation.variables : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tenants = tenantsQuery.data?.items ?? [];
|
const tenants = tenantsQuery.data?.items ?? [];
|
||||||
const total = tenantsQuery.data?.total ?? 0;
|
const total = tenantsQuery.data?.total ?? 0;
|
||||||
const clients = clientsLookupQuery.data?.items ?? [];
|
const clients = clientsLookupQuery.data?.items ?? [];
|
||||||
const plans = plansLookupQuery.data?.items ?? [];
|
const plans = plansLookupQuery.data?.items ?? [];
|
||||||
const isLookupsLoading = clientsLookupQuery.isLoading || plansLookupQuery.isLoading;
|
const isLookupsLoading =
|
||||||
|
clientsLookupQuery.isLoading || plansLookupQuery.isLoading;
|
||||||
|
|
||||||
const toolbar = useMemo(
|
const toolbar = useMemo(
|
||||||
() => (
|
() => (
|
||||||
@@ -170,9 +176,12 @@ export default function TenantsPage() {
|
|||||||
onPageChange={setSkip}
|
onPageChange={setSkip}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={setLimit}
|
||||||
onSortingChange={setSorting}
|
onSortingChange={setSorting}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
pendingDeleteId={
|
||||||
|
deleteMutation.isPending ? deleteMutation.variables : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PoweredBy />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<TenantSheet
|
<TenantSheet
|
||||||
@@ -181,7 +190,11 @@ export default function TenantsPage() {
|
|||||||
tenantId={tenantId}
|
tenantId={tenantId}
|
||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
register={register}
|
register={register}
|
||||||
|
control={control}
|
||||||
|
errors={errors}
|
||||||
|
isSubmitted={isSubmitted}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
canSubmit={canSubmit}
|
||||||
clientId={clientId}
|
clientId={clientId}
|
||||||
planId={planId}
|
planId={planId}
|
||||||
onClientChange={handleClientChange}
|
onClientChange={handleClientChange}
|
||||||
@@ -192,6 +205,7 @@ export default function TenantsPage() {
|
|||||||
isLookupsLoading={isLookupsLoading}
|
isLookupsLoading={isLookupsLoading}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
adminEmail={adminEmail}
|
adminEmail={adminEmail}
|
||||||
|
adminFieldLocks={adminFieldLocks}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Phone,
|
||||||
|
Send,
|
||||||
|
ShieldCheck,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { DatePickerSimple } from '@/components/form/DatePickerSimple';
|
||||||
|
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import type { AssignableTicketUser } from '@/types';
|
||||||
|
|
||||||
|
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
|
||||||
|
import {
|
||||||
|
formatCompactRangeCoordinate,
|
||||||
|
formatRangeCoordinate,
|
||||||
|
type AssignmentRangePoint,
|
||||||
|
} from './assignmentRange';
|
||||||
|
|
||||||
|
interface AssignTicketFormProps {
|
||||||
|
ticketId: string;
|
||||||
|
selectedClassNames: string[];
|
||||||
|
startPoint: AssignmentRangePoint | null;
|
||||||
|
endPoint: AssignmentRangePoint | null;
|
||||||
|
onAssigned: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssignTicketForm({
|
||||||
|
ticketId,
|
||||||
|
selectedClassNames,
|
||||||
|
startPoint,
|
||||||
|
endPoint,
|
||||||
|
onAssigned,
|
||||||
|
}: AssignTicketFormProps) {
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState('');
|
||||||
|
const [selectedContractor, setSelectedContractor] =
|
||||||
|
useState<AssignableTicketUser | null>(null);
|
||||||
|
const [dueDate, setDueDate] = useState<Date>();
|
||||||
|
const [assignNote, setAssignNote] = useState('');
|
||||||
|
|
||||||
|
const assignMutation = useAssignTicketMutation(ticketId);
|
||||||
|
const today = useMemo(() => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
}, []);
|
||||||
|
const maxDueMonth = useMemo(
|
||||||
|
() => new Date(today.getFullYear() + 20, 11),
|
||||||
|
[today],
|
||||||
|
);
|
||||||
|
const hasIncompleteLocationRange = Boolean(startPoint) !== Boolean(endPoint);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Users className="text-primary" />
|
||||||
|
Contractor and schedule
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
Contractor <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<AssignableWorkerSelect
|
||||||
|
value={selectedUserId}
|
||||||
|
onValueChange={(userId, user) => {
|
||||||
|
setSelectedUserId(userId);
|
||||||
|
setSelectedContractor(user);
|
||||||
|
}}
|
||||||
|
disabled={assignMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedContractor ? (
|
||||||
|
<div className="space-y-2 rounded-lg border bg-muted/30 p-4">
|
||||||
|
<p className="text-sm font-medium">Contractor details</p>
|
||||||
|
{selectedContractor.phone_number ? (
|
||||||
|
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Phone className="size-4 shrink-0" />
|
||||||
|
{selectedContractor.phone_number}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Mail className="size-4 shrink-0" />
|
||||||
|
<span className="truncate">{selectedContractor.email}</span>
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<ShieldCheck className="size-4 shrink-0" />
|
||||||
|
{selectedContractor.role.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{startPoint || endPoint ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Location</Label>
|
||||||
|
<div
|
||||||
|
className="flex min-w-0 items-center gap-2 rounded-lg border px-3 py-2 text-xs"
|
||||||
|
title={[
|
||||||
|
startPoint ? formatRangeCoordinate(startPoint) : null,
|
||||||
|
endPoint ? formatRangeCoordinate(endPoint) : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' to ')}
|
||||||
|
>
|
||||||
|
<MapPin className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 font-mono">
|
||||||
|
{startPoint
|
||||||
|
? formatCompactRangeCoordinate(startPoint)
|
||||||
|
: endPoint
|
||||||
|
? formatCompactRangeCoordinate(endPoint)
|
||||||
|
: null}
|
||||||
|
</span>
|
||||||
|
{startPoint && endPoint ? (
|
||||||
|
<>
|
||||||
|
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 font-mono">
|
||||||
|
{formatCompactRangeCoordinate(endPoint)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{hasIncompleteLocationRange ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{startPoint
|
||||||
|
? 'Select an end location to complete the range.'
|
||||||
|
: 'Select a start location to complete the range.'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="assign-due-date">
|
||||||
|
Due date <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<DatePickerSimple
|
||||||
|
id="assign-due-date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={setDueDate}
|
||||||
|
showLabel={false}
|
||||||
|
placeholder="Select due date"
|
||||||
|
disabled={assignMutation.isPending}
|
||||||
|
className="w-full"
|
||||||
|
startMonth={today}
|
||||||
|
endMonth={maxDueMonth}
|
||||||
|
disabledDates={{ before: today }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="assign-note">Assignment note (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="assign-note"
|
||||||
|
value={assignNote}
|
||||||
|
onChange={(event) => setAssignNote(event.target.value)}
|
||||||
|
placeholder="Add instructions or notes for the contractor..."
|
||||||
|
disabled={assignMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
!selectedUserId ||
|
||||||
|
!selectedContractor ||
|
||||||
|
!dueDate ||
|
||||||
|
hasIncompleteLocationRange ||
|
||||||
|
assignMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
!selectedUserId ||
|
||||||
|
!selectedContractor ||
|
||||||
|
!dueDate ||
|
||||||
|
hasIncompleteLocationRange
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = assignNote.trim() || undefined;
|
||||||
|
const hasCriteria =
|
||||||
|
selectedClassNames.length > 0 ||
|
||||||
|
Boolean(startPoint && endPoint);
|
||||||
|
|
||||||
|
assignMutation.mutate(
|
||||||
|
{
|
||||||
|
assigned_to_user_id: Number(selectedUserId),
|
||||||
|
assigned_to_email: selectedContractor.email,
|
||||||
|
due_at: dueDate.toISOString(),
|
||||||
|
note,
|
||||||
|
...(hasCriteria
|
||||||
|
? {
|
||||||
|
criteria: {
|
||||||
|
...(selectedClassNames.length > 0
|
||||||
|
? { class_names: selectedClassNames }
|
||||||
|
: {}),
|
||||||
|
...(startPoint && endPoint
|
||||||
|
? {
|
||||||
|
start_lat: startPoint.latitude,
|
||||||
|
start_lng: startPoint.longitude,
|
||||||
|
end_lat: endPoint.latitude,
|
||||||
|
end_lng: endPoint.longitude,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedUserId('');
|
||||||
|
setSelectedContractor(null);
|
||||||
|
setDueDate(undefined);
|
||||||
|
setAssignNote('');
|
||||||
|
onAssigned();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<Send />
|
||||||
|
{assignMutation.isPending ? 'Assigning...' : 'Assign ticket'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user