custom RBAC integration
This commit is contained in:
parent
95f5d37878
commit
3ecd35f960
3
.env
3
.env
|
|
@ -1,2 +1,3 @@
|
||||||
VITE_API_BASE_URL=http://api.yimaru.yaltopia.com/
|
# VITE_API_BASE_URL=https://api.yimaru.yaltopia.com/api/v1
|
||||||
|
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||||
VITE_GOOGLE_CLIENT_ID=
|
VITE_GOOGLE_CLIENT_ID=
|
||||||
|
|
|
||||||
76
package-lock.json
generated
76
package-lock.json
generated
|
|
@ -8,6 +8,9 @@
|
||||||
"name": "yimaru-admin",
|
"name": "yimaru-admin",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
|
@ -88,6 +91,7 @@
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
|
|
@ -339,6 +343,60 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||||
|
|
@ -2753,6 +2811,7 @@
|
||||||
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2763,6 +2822,7 @@
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
|
|
@ -2773,6 +2833,7 @@
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2828,6 +2889,7 @@
|
||||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.50.0",
|
"@typescript-eslint/scope-manager": "8.50.0",
|
||||||
"@typescript-eslint/types": "8.50.0",
|
"@typescript-eslint/types": "8.50.0",
|
||||||
|
|
@ -3079,6 +3141,7 @@
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -3317,6 +3380,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
|
|
@ -3893,6 +3957,7 @@
|
||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -5007,6 +5072,7 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -5054,6 +5120,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
|
|
@ -5242,6 +5309,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -5251,6 +5319,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
|
|
@ -5270,6 +5339,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
|
@ -5475,7 +5545,8 @@
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/redux-thunk": {
|
"node_modules/redux-thunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
|
|
@ -5879,6 +5950,7 @@
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -6046,6 +6118,7 @@
|
||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|
@ -6183,6 +6256,7 @@
|
||||||
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ import type {
|
||||||
CreateQuestionResponse,
|
CreateQuestionResponse,
|
||||||
CreateVimeoVideoRequest,
|
CreateVimeoVideoRequest,
|
||||||
CreateCourseCategoryRequest,
|
CreateCourseCategoryRequest,
|
||||||
|
GetSubCoursePrerequisitesResponse,
|
||||||
|
AddSubCoursePrerequisiteRequest,
|
||||||
|
GetLearningPathResponse,
|
||||||
|
ReorderItem,
|
||||||
} from "../types/course.types"
|
} from "../types/course.types"
|
||||||
|
|
||||||
export const getCourseCategories = () =>
|
export const getCourseCategories = () =>
|
||||||
|
|
@ -195,3 +199,20 @@ export const deleteQuestionSet = (questionSetId: number) =>
|
||||||
|
|
||||||
export const createVimeoVideo = (data: CreateVimeoVideoRequest) =>
|
export const createVimeoVideo = (data: CreateVimeoVideoRequest) =>
|
||||||
http.post("/course-management/videos/vimeo", data)
|
http.post("/course-management/videos/vimeo", data)
|
||||||
|
|
||||||
|
// Sub-course Prerequisite APIs
|
||||||
|
export const getSubCoursePrerequisites = (subCourseId: number) =>
|
||||||
|
http.get<GetSubCoursePrerequisitesResponse>(`/course-management/sub-courses/${subCourseId}/prerequisites`)
|
||||||
|
|
||||||
|
export const addSubCoursePrerequisite = (subCourseId: number, data: AddSubCoursePrerequisiteRequest) =>
|
||||||
|
http.post(`/course-management/sub-courses/${subCourseId}/prerequisites`, data)
|
||||||
|
|
||||||
|
export const removeSubCoursePrerequisite = (subCourseId: number, prerequisiteId: number) =>
|
||||||
|
http.delete(`/course-management/sub-courses/${subCourseId}/prerequisites/${prerequisiteId}`)
|
||||||
|
|
||||||
|
// Learning Path APIs
|
||||||
|
export const getLearningPath = (courseId: number) =>
|
||||||
|
http.get<GetLearningPathResponse>(`/course-management/courses/${courseId}/learning-path`)
|
||||||
|
|
||||||
|
export const reorderSubCourses = (courseId: number, items: ReorderItem[]) =>
|
||||||
|
http.put(`/course-management/courses/${courseId}/reorder-sub-courses`, { items })
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import type {
|
||||||
IssueFilters,
|
IssueFilters,
|
||||||
} from "../types/issue.types";
|
} from "../types/issue.types";
|
||||||
|
|
||||||
|
import type { CreateIssueRequest, CreateIssueResponse } from "../types/issue.types";
|
||||||
|
|
||||||
export const getIssues = (filters?: IssueFilters) =>
|
export const getIssues = (filters?: IssueFilters) =>
|
||||||
http.get<GetIssuesResponse>("/issues", {
|
http.get<GetIssuesResponse>("/issues", {
|
||||||
params: filters,
|
params: filters,
|
||||||
|
|
@ -18,6 +20,9 @@ export const getIssuesByUserId = (userId: number) =>
|
||||||
export const getIssueById = (id: number) =>
|
export const getIssueById = (id: number) =>
|
||||||
http.get<GetIssueResponse>(`/issues/${id}`);
|
http.get<GetIssueResponse>(`/issues/${id}`);
|
||||||
|
|
||||||
|
export const createIssue = (payload: CreateIssueRequest) =>
|
||||||
|
http.post<CreateIssueResponse>("/issues", payload);
|
||||||
|
|
||||||
export const updateIssueStatus = (id: number, status: string) =>
|
export const updateIssueStatus = (id: number, status: string) =>
|
||||||
http.patch<UpdateIssueStatusResponse>(`/issues/${id}/status`, { status });
|
http.patch<UpdateIssueStatusResponse>(`/issues/${id}/status`, { status });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,3 +20,16 @@ export const markAllRead = () =>
|
||||||
|
|
||||||
export const markAllUnread = () =>
|
export const markAllUnread = () =>
|
||||||
http.post("/notifications/mark-all-unread");
|
http.post("/notifications/mark-all-unread");
|
||||||
|
|
||||||
|
export const sendBulkSms = (data: { message: string; user_ids: number[]; scheduled_at?: string }) =>
|
||||||
|
http.post("/notifications/bulk-sms", data);
|
||||||
|
|
||||||
|
export const sendBulkEmail = (formData: FormData) =>
|
||||||
|
http.post("/notifications/bulk-email", formData, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sendBulkPush = (formData: FormData) =>
|
||||||
|
http.post("/notifications/bulk-push", formData, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
|
|
||||||
25
src/api/rbac.api.ts
Normal file
25
src/api/rbac.api.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import http from "./http"
|
||||||
|
import type {
|
||||||
|
GetRolesResponse,
|
||||||
|
GetRoleDetailResponse,
|
||||||
|
GetRolesParams,
|
||||||
|
CreateRoleRequest,
|
||||||
|
CreateRoleResponse,
|
||||||
|
SetRolePermissionsRequest,
|
||||||
|
GetPermissionsResponse,
|
||||||
|
} from "../types/rbac.types"
|
||||||
|
|
||||||
|
export const getRoles = (params?: GetRolesParams) =>
|
||||||
|
http.get<GetRolesResponse>("/rbac/roles", { params })
|
||||||
|
|
||||||
|
export const getRoleDetail = (roleId: number) =>
|
||||||
|
http.get<GetRoleDetailResponse>(`/rbac/roles/${roleId}`)
|
||||||
|
|
||||||
|
export const createRole = (data: CreateRoleRequest) =>
|
||||||
|
http.post<CreateRoleResponse>("/rbac/roles", data)
|
||||||
|
|
||||||
|
export const setRolePermissions = (roleId: number, data: SetRolePermissionsRequest) =>
|
||||||
|
http.put(`/rbac/roles/${roleId}/permissions`, data)
|
||||||
|
|
||||||
|
export const getAllPermissions = () =>
|
||||||
|
http.get<GetPermissionsResponse>("/rbac/permissions")
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
import http from "./http";
|
import http from "./http";
|
||||||
import { type UserProfileResponse, type GetUsersResponse } from "../types/user.types";
|
import { type UserProfileResponse, type GetUsersResponse } from "../types/user.types";
|
||||||
|
|
||||||
export const getUsers = (page?: number, pageSize?: number) =>
|
export const getUsers = (
|
||||||
|
page?: number,
|
||||||
|
pageSize?: number,
|
||||||
|
role?: string,
|
||||||
|
status?: string,
|
||||||
|
query?: string,
|
||||||
|
) =>
|
||||||
http.get<GetUsersResponse>("/users", {
|
http.get<GetUsersResponse>("/users", {
|
||||||
params: {
|
params: {
|
||||||
|
role,
|
||||||
|
status,
|
||||||
|
query,
|
||||||
page,
|
page,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -291,15 +291,12 @@ export function CourseCategoryPage() {
|
||||||
setCreating(true)
|
setCreating(true)
|
||||||
try {
|
try {
|
||||||
const name = newCategoryName.trim()
|
const name = newCategoryName.trim()
|
||||||
const parentPayloadId = parentCategoryId ?? null
|
const parentRes = await createCourseCategory({ name })
|
||||||
const parentRes = await createCourseCategory({
|
|
||||||
name: newCategoryName.trim(),
|
|
||||||
parent_id: parentPayloadId,
|
|
||||||
})
|
|
||||||
let createdCategoryId: number | null = null
|
let createdCategoryId: number | null = null
|
||||||
try {
|
try {
|
||||||
const data: any = parentRes?.data
|
const data: any = parentRes?.data
|
||||||
createdCategoryId =
|
createdCategoryId =
|
||||||
|
data?.data?.id ??
|
||||||
data?.data?.category?.id ??
|
data?.data?.category?.id ??
|
||||||
data?.data?.id ??
|
data?.data?.id ??
|
||||||
data?.category?.id ??
|
data?.category?.id ??
|
||||||
|
|
@ -312,10 +309,7 @@ export function CourseCategoryPage() {
|
||||||
if (createdCategoryId && pendingSubCategories.length > 0) {
|
if (createdCategoryId && pendingSubCategories.length > 0) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
pendingSubCategories.map((subName) =>
|
pendingSubCategories.map((subName) =>
|
||||||
createCourseCategory({
|
createCourseCategory({ name: subName }),
|
||||||
name: subName,
|
|
||||||
parent_id: createdCategoryId,
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,34 +1,24 @@
|
||||||
import { useEffect, useState, useRef } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Link, useParams, useNavigate } from "react-router-dom"
|
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||||
import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, MoreVertical, Edit, AlertCircle } from "lucide-react"
|
import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle } from "lucide-react"
|
||||||
import practiceSrc from "../../assets/Practice.svg"
|
import practiceSrc from "../../assets/Practice.svg"
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||||
import { Card, CardContent } from "../../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import alertSrc from "../../assets/Alert.svg"
|
import alertSrc from "../../assets/Alert.svg"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Badge } from "../../components/ui/badge"
|
import { Badge } from "../../components/ui/badge"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { FileUpload } from "../../components/ui/file-upload"
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table"
|
||||||
import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse } from "../../api/courses.api"
|
import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse } from "../../api/courses.api"
|
||||||
import type { Course, CourseCategory } from "../../types/course.types"
|
import type { Course, CourseCategory } from "../../types/course.types"
|
||||||
|
|
||||||
function CourseThumbnail({ src, alt, gradient }: { src?: string; alt: string; gradient: string }) {
|
|
||||||
const [imgError, setImgError] = useState(false)
|
|
||||||
|
|
||||||
if (!src || imgError) {
|
|
||||||
return <div className={`h-full w-full rounded-t-lg ${gradient}`} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={alt}
|
|
||||||
className="h-full w-full object-cover rounded-t-lg"
|
|
||||||
onError={() => setImgError(true)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CoursesPage() {
|
export function CoursesPage() {
|
||||||
const { categoryId } = useParams<{ categoryId: string }>()
|
const { categoryId } = useParams<{ categoryId: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -42,16 +32,10 @@ export function CoursesPage() {
|
||||||
const [description, setDescription] = useState("")
|
const [description, setDescription] = useState("")
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
const [newThumbnailFile, setNewThumbnailFile] = useState<File | null>(null)
|
|
||||||
const [newVideoFile, setNewVideoFile] = useState<File | null>(null)
|
|
||||||
|
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||||
const [courseToDelete, setCourseToDelete] = useState<Course | null>(null)
|
const [courseToDelete, setCourseToDelete] = useState<Course | null>(null)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||||
const [openMenuId, setOpenMenuId] = useState<number | null>(null)
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const [showEditModal, setShowEditModal] = useState(false)
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
const [courseToEdit, setCourseToEdit] = useState<Course | null>(null)
|
const [courseToEdit, setCourseToEdit] = useState<Course | null>(null)
|
||||||
const [editTitle, setEditTitle] = useState("")
|
const [editTitle, setEditTitle] = useState("")
|
||||||
|
|
@ -60,19 +44,6 @@ export function CoursesPage() {
|
||||||
const [updating, setUpdating] = useState(false)
|
const [updating, setUpdating] = useState(false)
|
||||||
const [updateError, setUpdateError] = useState<string | null>(null)
|
const [updateError, setUpdateError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
|
||||||
setOpenMenuId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (openMenuId !== null) {
|
|
||||||
document.addEventListener("mousedown", handleClickOutside)
|
|
||||||
}
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
||||||
}, [openMenuId])
|
|
||||||
|
|
||||||
const fetchCourses = async () => {
|
const fetchCourses = async () => {
|
||||||
if (!categoryId) return
|
if (!categoryId) return
|
||||||
|
|
||||||
|
|
@ -95,7 +66,7 @@ export function CoursesPage() {
|
||||||
getCourseCategories(),
|
getCourseCategories(),
|
||||||
])
|
])
|
||||||
|
|
||||||
setCourses(coursesRes.data.data.courses)
|
setCourses(coursesRes.data.data.courses ?? [])
|
||||||
const foundCategory = categoriesRes.data.data.categories.find(
|
const foundCategory = categoriesRes.data.data.categories.find(
|
||||||
(c) => c.id === Number(categoryId)
|
(c) => c.id === Number(categoryId)
|
||||||
)
|
)
|
||||||
|
|
@ -115,8 +86,6 @@ export function CoursesPage() {
|
||||||
setTitle("")
|
setTitle("")
|
||||||
setDescription("")
|
setDescription("")
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
setNewThumbnailFile(null)
|
|
||||||
setNewVideoFile(null)
|
|
||||||
setShowModal(true)
|
setShowModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,8 +94,6 @@ export function CoursesPage() {
|
||||||
setTitle("")
|
setTitle("")
|
||||||
setDescription("")
|
setDescription("")
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
setNewThumbnailFile(null)
|
|
||||||
setNewVideoFile(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
|
@ -296,127 +263,123 @@ export function CoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Course grid or empty state */}
|
{/* Course table or empty state */}
|
||||||
|
<Card className="shadow-soft">
|
||||||
|
<CardHeader className="border-b border-grayScale-200 pb-3">
|
||||||
|
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||||
|
Course Management
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
{courses.length === 0 ? (
|
{courses.length === 0 ? (
|
||||||
<Card className="border-dashed border-grayScale-200 shadow-none">
|
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-20">
|
<img src={practiceSrc} alt="" className="h-16 w-16" />
|
||||||
<img src={practiceSrc} alt="" className="h-20 w-20" />
|
<h3 className="mt-4 text-base font-semibold text-grayScale-600">No courses yet</h3>
|
||||||
<h3 className="mt-5 text-base font-semibold text-grayScale-600">No courses yet</h3>
|
<p className="mt-1.5 text-sm text-grayScale-400">
|
||||||
<p className="mt-1.5 text-sm text-grayScale-400">No courses found in this category</p>
|
No courses found in this category.
|
||||||
<Button variant="outline" className="mt-6 border-brand-200 text-brand-600 transition-colors hover:bg-brand-50" onClick={handleOpenModal}>
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-5 border-brand-200 text-brand-600 transition-colors hover:bg-brand-50"
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add your first course
|
Add your first course
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
|
||||||
{courses.map((course, index) => {
|
<Table>
|
||||||
const gradients = [
|
<TableHeader>
|
||||||
"bg-gradient-to-br from-blue-100 to-blue-200",
|
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
|
||||||
"bg-gradient-to-br from-purple-100 to-purple-200",
|
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||||
"bg-gradient-to-br from-green-100 to-green-200",
|
Course
|
||||||
"bg-gradient-to-br from-yellow-100 to-yellow-200",
|
</TableHead>
|
||||||
]
|
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
||||||
return (
|
Status
|
||||||
<Card
|
</TableHead>
|
||||||
|
<TableHead className="py-3 text-right text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||||
|
Actions
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{courses.map((course, index) => (
|
||||||
|
<TableRow
|
||||||
key={course.id}
|
key={course.id}
|
||||||
className="group cursor-pointer overflow-hidden border border-grayScale-100 bg-white shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-grayScale-200"
|
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
|
||||||
|
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/40"
|
||||||
|
}`}
|
||||||
onClick={() => handleCourseClick(course.id)}
|
onClick={() => handleCourseClick(course.id)}
|
||||||
>
|
>
|
||||||
{/* Thumbnail */}
|
<TableCell className="max-w-md py-3.5">
|
||||||
<div className="relative aspect-video w-full overflow-hidden">
|
<div className="truncate text-sm font-semibold text-grayScale-700">
|
||||||
<CourseThumbnail
|
{course.title}
|
||||||
src={course.thumbnail}
|
|
||||||
alt={course.title}
|
|
||||||
gradient={gradients[index % gradients.length]}
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
|
|
||||||
</div>
|
</div>
|
||||||
|
{course.description && (
|
||||||
{/* Content */}
|
<div className="mt-1 truncate text-xs text-grayScale-400">
|
||||||
<div className="space-y-3 border-t border-grayScale-50 p-4">
|
{course.description}
|
||||||
{/* Status and menu */}
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden py-3.5 md:table-cell">
|
||||||
<Badge
|
<Badge
|
||||||
className={`rounded-full px-2.5 py-0.5 text-[11px] font-semibold tracking-wide ${
|
variant={course.is_active ? "success" : "secondary"}
|
||||||
course.is_active
|
className="text-[11px] font-semibold"
|
||||||
? "border-0 bg-emerald-50 text-emerald-700"
|
|
||||||
: "border-0 bg-grayScale-100 text-grayScale-500"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${course.is_active ? "bg-emerald-500" : "bg-grayScale-400"}`} />
|
{course.is_active ? "Active" : "Inactive"}
|
||||||
{course.is_active ? "ACTIVE" : "INACTIVE"}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className="relative" ref={openMenuId === course.id ? menuRef : undefined} onClick={(e) => e.stopPropagation()}>
|
</TableCell>
|
||||||
<button
|
<TableCell className="py-3.5 text-right">
|
||||||
onClick={() => setOpenMenuId(openMenuId === course.id ? null : course.id)}
|
<div className="flex items-center justify-end gap-1">
|
||||||
className="grid h-7 w-7 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
|
||||||
>
|
|
||||||
<MoreVertical className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
{openMenuId === course.id && (
|
|
||||||
<div className="absolute right-0 top-full z-10 mt-1.5 w-44 animate-in fade-in slide-in-from-top-1 rounded-xl border border-grayScale-100 bg-white py-1.5 shadow-lg">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
handleToggleStatus(course)
|
|
||||||
setOpenMenuId(null)
|
|
||||||
}}
|
|
||||||
disabled={togglingId === course.id}
|
|
||||||
className="flex w-full items-center gap-2.5 px-4 py-2 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{course.is_active ? (
|
|
||||||
<>
|
|
||||||
<ToggleLeft className="h-4 w-4" />
|
|
||||||
Deactivate
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ToggleRight className="h-4 w-4" />
|
|
||||||
Activate
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<div className="mx-3 my-1 border-t border-grayScale-100" />
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
handleDeleteClick(course)
|
|
||||||
setOpenMenuId(null)
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center gap-2.5 px-4 py-2 text-sm text-red-500 transition-colors hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<h3 className="font-semibold text-grayScale-700 line-clamp-1">{course.title}</h3>
|
|
||||||
<p className="text-sm leading-relaxed text-grayScale-400 line-clamp-2">
|
|
||||||
{course.description || "No description available"}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Edit button */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
className="w-full border-grayScale-200 text-grayScale-600 transition-all hover:border-brand-200 hover:bg-brand-50 hover:text-brand-600"
|
size="icon"
|
||||||
|
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleEditClick(course)
|
handleEditClick(course)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
Edit
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
|
||||||
|
disabled={togglingId === course.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleToggleStatus(course)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{course.is_active ? (
|
||||||
|
<ToggleLeft className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ToggleRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-600"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDeleteClick(course)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</TableCell>
|
||||||
)
|
</TableRow>
|
||||||
})}
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Add Course Modal */}
|
{/* Add Course Modal */}
|
||||||
{showModal && (
|
{showModal && (
|
||||||
|
|
@ -472,29 +435,6 @@ export function CoursesPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-sm font-medium text-grayScale-600">Thumbnail image</p>
|
|
||||||
<FileUpload
|
|
||||||
accept="image/*"
|
|
||||||
onFileSelect={setNewThumbnailFile}
|
|
||||||
label="Upload thumbnail"
|
|
||||||
description="Optional course cover image"
|
|
||||||
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-sm font-medium text-grayScale-600">Intro video</p>
|
|
||||||
<FileUpload
|
|
||||||
accept="video/*"
|
|
||||||
onFileSelect={setNewVideoFile}
|
|
||||||
label="Upload intro video"
|
|
||||||
description="Optional overview for this course"
|
|
||||||
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-400">
|
<div className="rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-400">
|
||||||
Category: <span className="font-semibold text-grayScale-600">{category?.name}</span>
|
Category: <span className="font-semibold text-grayScale-600">{category?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { useEffect, useState, useRef } from "react"
|
import { useEffect, useState, useRef } from "react"
|
||||||
import { Link, useParams, useNavigate } from "react-router-dom"
|
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||||
import { ArrowLeft, ToggleLeft, ToggleRight, MoreVertical, X, Trash2, AlertCircle, Edit } from "lucide-react"
|
import { ArrowLeft, ToggleLeft, ToggleRight, MoreVertical, X, Trash2, AlertCircle, Edit, Link2, Plus, Loader2, LayoutGrid, GitBranch, ChevronDown, Lock, ArrowRight } from "lucide-react"
|
||||||
import practiceSrc from "../../assets/Practice.svg"
|
import practiceSrc from "../../assets/Practice.svg"
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||||
import { Card, CardContent } from "../../components/ui/card"
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
import alertSrc from "../../assets/Alert.svg"
|
import alertSrc from "../../assets/Alert.svg"
|
||||||
import { Badge } from "../../components/ui/badge"
|
import { Badge } from "../../components/ui/badge"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { getSubCoursesByCourse, getCoursesByCategory, getCourseCategories, createSubCourse, updateSubCourse, updateSubCourseStatus, deleteSubCourse } from "../../api/courses.api"
|
import { getSubCoursesByCourse, getCoursesByCategory, getCourseCategories, createSubCourse, updateSubCourse, updateSubCourseStatus, deleteSubCourse, getSubCoursePrerequisites, addSubCoursePrerequisite, removeSubCoursePrerequisite } from "../../api/courses.api"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import type { SubCourse, Course, CourseCategory } from "../../types/course.types"
|
import type { SubCourse, Course, CourseCategory, SubCoursePrerequisite } from "../../types/course.types"
|
||||||
|
|
||||||
export function SubCoursesPage() {
|
export function SubCoursesPage() {
|
||||||
const { categoryId, courseId } = useParams<{ categoryId: string; courseId: string }>()
|
const { categoryId, courseId } = useParams<{ categoryId: string; courseId: string }>()
|
||||||
|
|
@ -36,6 +36,22 @@ export function SubCoursesPage() {
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// View mode
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "flow">("grid")
|
||||||
|
|
||||||
|
// All prerequisites map: subCourseId -> prerequisites[]
|
||||||
|
const [allPrereqMap, setAllPrereqMap] = useState<Record<number, SubCoursePrerequisite[]>>({})
|
||||||
|
const [allPrereqLoading, setAllPrereqLoading] = useState(false)
|
||||||
|
|
||||||
|
// Prerequisites state
|
||||||
|
const [showPrereqModal, setShowPrereqModal] = useState(false)
|
||||||
|
const [prereqSubCourse, setPrereqSubCourse] = useState<SubCourse | null>(null)
|
||||||
|
const [prerequisites, setPrerequisites] = useState<SubCoursePrerequisite[]>([])
|
||||||
|
const [prereqLoading, setPrereqLoading] = useState(false)
|
||||||
|
const [prereqAdding, setPrereqAdding] = useState(false)
|
||||||
|
const [prereqRemoving, setPrereqRemoving] = useState<number | null>(null)
|
||||||
|
const [selectedPrereqId, setSelectedPrereqId] = useState<number | 0>(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
|
|
@ -60,6 +76,25 @@ export function SubCoursesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchAllPrerequisites = async (scs: SubCourse[]) => {
|
||||||
|
if (scs.length === 0) return
|
||||||
|
setAllPrereqLoading(true)
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
scs.map((sc) => getSubCoursePrerequisites(sc.id).then((res) => ({ id: sc.id, data: res.data.data ?? [] })))
|
||||||
|
)
|
||||||
|
const map: Record<number, SubCoursePrerequisite[]> = {}
|
||||||
|
for (const r of results) {
|
||||||
|
map[r.id] = r.data
|
||||||
|
}
|
||||||
|
setAllPrereqMap(map)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch all prerequisites:", err)
|
||||||
|
} finally {
|
||||||
|
setAllPrereqLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
if (!courseId || !categoryId) return
|
if (!courseId || !categoryId) return
|
||||||
|
|
@ -93,6 +128,12 @@ export function SubCoursesPage() {
|
||||||
fetchData()
|
fetchData()
|
||||||
}, [courseId, categoryId])
|
}, [courseId, categoryId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (subCourses.length > 0) {
|
||||||
|
fetchAllPrerequisites(subCourses)
|
||||||
|
}
|
||||||
|
}, [subCourses])
|
||||||
|
|
||||||
const handleToggleStatus = async (subCourse: SubCourse) => {
|
const handleToggleStatus = async (subCourse: SubCourse) => {
|
||||||
setTogglingId(subCourse.id)
|
setTogglingId(subCourse.id)
|
||||||
try {
|
try {
|
||||||
|
|
@ -199,6 +240,112 @@ export function SubCoursesPage() {
|
||||||
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)
|
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePrereqClick = async (subCourse: SubCourse) => {
|
||||||
|
setPrereqSubCourse(subCourse)
|
||||||
|
setShowPrereqModal(true)
|
||||||
|
setPrereqLoading(true)
|
||||||
|
setSelectedPrereqId(0)
|
||||||
|
try {
|
||||||
|
const res = await getSubCoursePrerequisites(subCourse.id)
|
||||||
|
setPrerequisites(res.data.data ?? [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch prerequisites:", err)
|
||||||
|
setPrerequisites([])
|
||||||
|
} finally {
|
||||||
|
setPrereqLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddPrerequisite = async () => {
|
||||||
|
if (!prereqSubCourse || !selectedPrereqId) return
|
||||||
|
setPrereqAdding(true)
|
||||||
|
try {
|
||||||
|
await addSubCoursePrerequisite(prereqSubCourse.id, {
|
||||||
|
prerequisite_sub_course_id: selectedPrereqId,
|
||||||
|
})
|
||||||
|
const res = await getSubCoursePrerequisites(prereqSubCourse.id)
|
||||||
|
setPrerequisites(res.data.data ?? [])
|
||||||
|
setSelectedPrereqId(0)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to add prerequisite:", err)
|
||||||
|
} finally {
|
||||||
|
setPrereqAdding(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemovePrerequisite = async (prereqId: number) => {
|
||||||
|
if (!prereqSubCourse) return
|
||||||
|
setPrereqRemoving(prereqId)
|
||||||
|
try {
|
||||||
|
await removeSubCoursePrerequisite(prereqSubCourse.id, prereqId)
|
||||||
|
const res = await getSubCoursePrerequisites(prereqSubCourse.id)
|
||||||
|
setPrerequisites(res.data.data ?? [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to remove prerequisite:", err)
|
||||||
|
} finally {
|
||||||
|
setPrereqRemoving(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build flow layers using topological sort
|
||||||
|
const flowLayers = (() => {
|
||||||
|
if (subCourses.length === 0) return []
|
||||||
|
|
||||||
|
// Find sub-courses with no prerequisites (roots)
|
||||||
|
const hasPrereqs = new Set<number>()
|
||||||
|
const isPrereqOf = new Map<number, number[]>() // prereqId -> [subCourseIds that depend on it]
|
||||||
|
|
||||||
|
for (const sc of subCourses) {
|
||||||
|
const prereqs = allPrereqMap[sc.id] ?? []
|
||||||
|
if (prereqs.length > 0) {
|
||||||
|
hasPrereqs.add(sc.id)
|
||||||
|
}
|
||||||
|
for (const p of prereqs) {
|
||||||
|
const dependents = isPrereqOf.get(p.prerequisite_sub_course_id) ?? []
|
||||||
|
dependents.push(sc.id)
|
||||||
|
isPrereqOf.set(p.prerequisite_sub_course_id, dependents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BFS-based layering
|
||||||
|
const layers: SubCourse[][] = []
|
||||||
|
const placed = new Set<number>()
|
||||||
|
|
||||||
|
// Layer 0: no prerequisites
|
||||||
|
const roots = subCourses.filter((sc) => !hasPrereqs.has(sc.id))
|
||||||
|
if (roots.length > 0) {
|
||||||
|
layers.push(roots)
|
||||||
|
roots.forEach((sc) => placed.add(sc.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subsequent layers: all prereqs already placed
|
||||||
|
let maxIterations = subCourses.length
|
||||||
|
while (placed.size < subCourses.length && maxIterations-- > 0) {
|
||||||
|
const nextLayer = subCourses.filter((sc) => {
|
||||||
|
if (placed.has(sc.id)) return false
|
||||||
|
const prereqs = allPrereqMap[sc.id] ?? []
|
||||||
|
return prereqs.every((p) => placed.has(p.prerequisite_sub_course_id))
|
||||||
|
})
|
||||||
|
if (nextLayer.length === 0) {
|
||||||
|
// Remaining have circular deps or missing prereqs — just add them
|
||||||
|
const remaining = subCourses.filter((sc) => !placed.has(sc.id))
|
||||||
|
if (remaining.length > 0) layers.push(remaining)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
layers.push(nextLayer)
|
||||||
|
nextLayer.forEach((sc) => placed.add(sc.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return layers
|
||||||
|
})()
|
||||||
|
|
||||||
|
const availablePrerequisites = subCourses.filter(
|
||||||
|
(sc) =>
|
||||||
|
prereqSubCourse &&
|
||||||
|
sc.id !== prereqSubCourse.id &&
|
||||||
|
!prerequisites.some((p) => p.prerequisite_sub_course_id === sc.id)
|
||||||
|
)
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-24">
|
<div className="flex flex-col items-center justify-center py-24">
|
||||||
|
|
@ -243,10 +390,38 @@ export function SubCoursesPage() {
|
||||||
<p className="mt-0.5 text-sm text-grayScale-400">{subCourses.length} sub-course{subCourses.length !== 1 ? "s" : ""} available</p>
|
<p className="mt-0.5 text-sm text-grayScale-400">{subCourses.length} sub-course{subCourses.length !== 1 ? "s" : ""} available</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{subCourses.length > 0 && (
|
||||||
|
<div className="flex rounded-xl border border-grayScale-200 bg-white p-0.5 shadow-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("grid")}
|
||||||
|
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
|
viewMode === "grid"
|
||||||
|
? "bg-brand-500 text-white shadow-sm"
|
||||||
|
: "text-grayScale-500 hover:text-grayScale-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-3.5 w-3.5" />
|
||||||
|
Grid
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("flow")}
|
||||||
|
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
|
viewMode === "flow"
|
||||||
|
? "bg-brand-500 text-white shadow-sm"
|
||||||
|
: "text-grayScale-500 hover:text-grayScale-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GitBranch className="h-3.5 w-3.5" />
|
||||||
|
Flow
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button className="w-full rounded-xl bg-brand-500 px-5 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto" onClick={handleAddSubCourse}>
|
<Button className="w-full rounded-xl bg-brand-500 px-5 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto" onClick={handleAddSubCourse}>
|
||||||
Add New Sub-course
|
Add New Sub-course
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sub-course grid or empty state */}
|
{/* Sub-course grid or empty state */}
|
||||||
{subCourses.length === 0 ? (
|
{subCourses.length === 0 ? (
|
||||||
|
|
@ -320,6 +495,17 @@ export function SubCoursesPage() {
|
||||||
</button>
|
</button>
|
||||||
{openMenuId === subCourse.id && (
|
{openMenuId === subCourse.id && (
|
||||||
<div className="absolute right-0 top-full z-10 mt-1.5 w-44 overflow-hidden rounded-xl border border-grayScale-100 bg-white py-1 shadow-lg">
|
<div className="absolute right-0 top-full z-10 mt-1.5 w-44 overflow-hidden rounded-xl border border-grayScale-100 bg-white py-1 shadow-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handlePrereqClick(subCourse)
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50"
|
||||||
|
>
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
Prerequisites
|
||||||
|
</button>
|
||||||
|
<div className="mx-3 border-t border-grayScale-100" />
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleToggleStatus(subCourse)
|
handleToggleStatus(subCourse)
|
||||||
|
|
@ -499,6 +685,118 @@ export function SubCoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Prerequisites Modal */}
|
||||||
|
{showPrereqModal && prereqSubCourse && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
|
<div className="mx-4 w-full max-w-lg rounded-2xl bg-white shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-grayScale-700">Prerequisites</h2>
|
||||||
|
<p className="mt-0.5 text-sm text-grayScale-400">
|
||||||
|
Manage prerequisites for <span className="font-medium text-grayScale-600">{prereqSubCourse.title}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPrereqModal(false)}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
{/* Add prerequisite */}
|
||||||
|
{availablePrerequisites.length > 0 && (
|
||||||
|
<div className="mb-5">
|
||||||
|
<label className="mb-1.5 block text-sm font-semibold text-grayScale-600">Add Prerequisite</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={selectedPrereqId}
|
||||||
|
onChange={(e) => setSelectedPrereqId(Number(e.target.value))}
|
||||||
|
className="flex-1 rounded-lg border border-grayScale-200 bg-white px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
||||||
|
>
|
||||||
|
<option value={0}>Select a sub-course...</option>
|
||||||
|
{availablePrerequisites.map((sc) => (
|
||||||
|
<option key={sc.id} value={sc.id}>
|
||||||
|
{sc.title} {sc.level ? `(${sc.level})` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
className="shrink-0 rounded-lg bg-brand-500 px-4 shadow-sm hover:bg-brand-600"
|
||||||
|
onClick={handleAddPrerequisite}
|
||||||
|
disabled={prereqAdding || !selectedPrereqId}
|
||||||
|
>
|
||||||
|
{prereqAdding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current prerequisites list */}
|
||||||
|
{prereqLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : prerequisites.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-grayScale-200 px-4 py-8 text-center">
|
||||||
|
<Link2 className="mx-auto h-8 w-8 text-grayScale-300" />
|
||||||
|
<p className="mt-2 text-sm font-medium text-grayScale-500">No prerequisites</p>
|
||||||
|
<p className="mt-0.5 text-xs text-grayScale-400">This sub-course is accessible without completing others first</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-semibold text-grayScale-600">
|
||||||
|
Current Prerequisites ({prerequisites.length})
|
||||||
|
</p>
|
||||||
|
{prerequisites.map((prereq) => (
|
||||||
|
<div
|
||||||
|
key={prereq.id}
|
||||||
|
className="flex items-center justify-between rounded-xl border border-grayScale-100 bg-grayScale-25 px-4 py-3 transition-colors hover:border-grayScale-200"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-grayScale-700 truncate">{prereq.prerequisite_title}</p>
|
||||||
|
<div className="mt-0.5 flex items-center gap-2">
|
||||||
|
{prereq.prerequisite_level && (
|
||||||
|
<span className="rounded bg-brand-50 px-1.5 py-0.5 text-[11px] font-medium text-brand-600">
|
||||||
|
{prereq.prerequisite_level}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[11px] text-grayScale-400">
|
||||||
|
Order: {prereq.prerequisite_display_order}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemovePrerequisite(prereq.id)}
|
||||||
|
disabled={prereqRemoving === prereq.id}
|
||||||
|
className="ml-3 grid h-8 w-8 shrink-0 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{prereqRemoving === prereq.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end border-t border-grayScale-100 px-6 py-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowPrereqModal(false)}
|
||||||
|
className="rounded-lg"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Edit Sub-course Modal */}
|
{/* Edit Sub-course Modal */}
|
||||||
{showEditModal && subCourseToEdit && (
|
{showEditModal && subCourseToEdit && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import {
|
||||||
getIssueById,
|
getIssueById,
|
||||||
updateIssueStatus,
|
updateIssueStatus,
|
||||||
deleteIssue,
|
deleteIssue,
|
||||||
|
createIssue,
|
||||||
} from "../../api/issues.api";
|
} from "../../api/issues.api";
|
||||||
import type { Issue, IssueFilters } from "../../types/issue.types";
|
import type { Issue, IssueFilters } from "../../types/issue.types";
|
||||||
|
|
||||||
|
|
@ -207,6 +208,9 @@ export function IssuesPage() {
|
||||||
const [createSubject, setCreateSubject] = useState("");
|
const [createSubject, setCreateSubject] = useState("");
|
||||||
const [createType, setCreateType] = useState<string>("bug");
|
const [createType, setCreateType] = useState<string>("bug");
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
const [createDescription, setCreateDescription] = useState("");
|
||||||
|
const [createDevice, setCreateDevice] = useState("");
|
||||||
|
const [createBrowser, setCreateBrowser] = useState("");
|
||||||
|
const [createSubmitting, setCreateSubmitting] = useState(false);
|
||||||
|
|
||||||
const fetchIssues = useCallback(async () => {
|
const fetchIssues = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -522,7 +526,6 @@ export function IssuesPage() {
|
||||||
const typeConfig = getIssueTypeConfig(issue.issue_type);
|
const typeConfig = getIssueTypeConfig(issue.issue_type);
|
||||||
const statusConfig = getStatusConfig(issue.status);
|
const statusConfig = getStatusConfig(issue.status);
|
||||||
const TypeIcon = typeConfig.icon;
|
const TypeIcon = typeConfig.icon;
|
||||||
const StatusIcon = statusConfig.icon;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={issue.id} className="group">
|
<TableRow key={issue.id} className="group">
|
||||||
|
|
@ -907,6 +910,29 @@ export function IssuesPage() {
|
||||||
onChange={(e) => setCreateDescription(e.target.value)}
|
onChange={(e) => setCreateDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||||
|
Device (optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. iPhone 14"
|
||||||
|
value={createDevice}
|
||||||
|
onChange={(e) => setCreateDevice(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||||
|
Browser (optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. Safari 17"
|
||||||
|
value={createBrowser}
|
||||||
|
onChange={(e) => setCreateBrowser(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-end gap-2">
|
<div className="mt-5 flex items-center justify-end gap-2">
|
||||||
|
|
@ -917,24 +943,49 @@ export function IssuesPage() {
|
||||||
setCreateSubject("");
|
setCreateSubject("");
|
||||||
setCreateDescription("");
|
setCreateDescription("");
|
||||||
setCreateType("bug");
|
setCreateType("bug");
|
||||||
|
setCreateDevice("");
|
||||||
|
setCreateBrowser("");
|
||||||
}}
|
}}
|
||||||
|
disabled={createSubmitting}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="bg-brand-500 text-white hover:bg-brand-600"
|
className="bg-brand-500 text-white hover:bg-brand-600"
|
||||||
onClick={() => {
|
disabled={createSubmitting || !createSubject.trim() || !createDescription.trim()}
|
||||||
// Hook to create-issue API here; currently UI-only.
|
onClick={async () => {
|
||||||
if (!createSubject.trim() || !createDescription.trim()) {
|
if (!createSubject.trim() || !createDescription.trim()) return;
|
||||||
return;
|
setCreateSubmitting(true);
|
||||||
|
try {
|
||||||
|
const payload: any = {
|
||||||
|
subject: createSubject.trim(),
|
||||||
|
description: createDescription.trim(),
|
||||||
|
issue_type: createType,
|
||||||
|
};
|
||||||
|
const metadata: Record<string, string> = {};
|
||||||
|
if (createDevice.trim()) metadata.device = createDevice.trim();
|
||||||
|
if (createBrowser.trim()) metadata.browser = createBrowser.trim();
|
||||||
|
if (Object.keys(metadata).length > 0) {
|
||||||
|
payload.metadata = metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await createIssue(payload);
|
||||||
|
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setCreateSubject("");
|
setCreateSubject("");
|
||||||
setCreateDescription("");
|
setCreateDescription("");
|
||||||
setCreateType("bug");
|
setCreateType("bug");
|
||||||
|
setCreateDevice("");
|
||||||
|
setCreateBrowser("");
|
||||||
|
fetchIssues();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create issue:", error);
|
||||||
|
} finally {
|
||||||
|
setCreateSubmitting(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Create Issue
|
{createSubmitting ? "Creating..." : "Create Issue"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,14 @@ import {
|
||||||
markAsUnread,
|
markAsUnread,
|
||||||
markAllRead,
|
markAllRead,
|
||||||
markAllUnread,
|
markAllUnread,
|
||||||
|
sendBulkSms,
|
||||||
|
sendBulkEmail,
|
||||||
|
sendBulkPush,
|
||||||
} from "../../api/notifications.api"
|
} from "../../api/notifications.api"
|
||||||
import { getTeamMembers } from "../../api/team.api"
|
import { getTeamMembers } from "../../api/team.api"
|
||||||
import type { Notification } from "../../types/notification.types"
|
import type { Notification } from "../../types/notification.types"
|
||||||
import type { TeamMember } from "../../types/team.types"
|
import type { TeamMember } from "../../types/team.types"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
|
@ -261,6 +265,16 @@ export function NotificationsPage() {
|
||||||
const [composeOpen, setComposeOpen] = useState(false)
|
const [composeOpen, setComposeOpen] = useState(false)
|
||||||
const [composeImage, setComposeImage] = useState<File | null>(null)
|
const [composeImage, setComposeImage] = useState<File | null>(null)
|
||||||
|
|
||||||
|
const [bulkOpen, setBulkOpen] = useState(false)
|
||||||
|
const [bulkChannel, setBulkChannel] = useState<"sms" | "email" | "push">("sms")
|
||||||
|
const [bulkTitle, setBulkTitle] = useState("")
|
||||||
|
const [bulkMessage, setBulkMessage] = useState("")
|
||||||
|
const [bulkRole, setBulkRole] = useState("")
|
||||||
|
const [bulkUserIds, setBulkUserIds] = useState("")
|
||||||
|
const [bulkScheduledAt, setBulkScheduledAt] = useState("")
|
||||||
|
const [bulkFile, setBulkFile] = useState<File | null>(null)
|
||||||
|
const [bulkSending, setBulkSending] = useState(false)
|
||||||
|
|
||||||
const fetchData = useCallback(async (currentOffset: number) => {
|
const fetchData = useCallback(async (currentOffset: number) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(false)
|
setError(false)
|
||||||
|
|
@ -418,10 +432,10 @@ export function NotificationsPage() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-brand-500 text-white hover:bg-brand-600"
|
className="bg-brand-500 text-white hover:bg-brand-600"
|
||||||
onClick={() => setComposeOpen(true)}
|
onClick={() => setBulkOpen(true)}
|
||||||
>
|
>
|
||||||
<Megaphone className="mr-2 h-3.5 w-3.5" />
|
<Mail className="mr-2 h-3.5 w-3.5" />
|
||||||
New notification
|
Send notification
|
||||||
</Button>
|
</Button>
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -1073,6 +1087,243 @@ export function NotificationsPage() {
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Bulk send dialog */}
|
||||||
|
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Megaphone className="h-5 w-5 text-brand-500" />
|
||||||
|
<span>Send notification</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Send a bulk SMS, email, or push notification to users.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!bulkMessage.trim()) {
|
||||||
|
toast.error("Message is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const trimmedIds = bulkUserIds
|
||||||
|
.split(",")
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
const userIds = trimmedIds.map((id) => Number(id)).filter((id) => !Number.isNaN(id))
|
||||||
|
|
||||||
|
try {
|
||||||
|
setBulkSending(true)
|
||||||
|
|
||||||
|
if (bulkChannel === "sms") {
|
||||||
|
if (userIds.length === 0) {
|
||||||
|
toast.error("User IDs are required for bulk SMS")
|
||||||
|
setBulkSending(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await sendBulkSms({
|
||||||
|
message: bulkMessage.trim(),
|
||||||
|
user_ids: userIds,
|
||||||
|
...(bulkScheduledAt ? { scheduled_at: bulkScheduledAt } : {}),
|
||||||
|
})
|
||||||
|
} else if (bulkChannel === "email") {
|
||||||
|
const form = new FormData()
|
||||||
|
if (!bulkTitle.trim()) {
|
||||||
|
toast.error("Subject is required for bulk email")
|
||||||
|
setBulkSending(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.append("subject", bulkTitle.trim())
|
||||||
|
form.append("message", bulkMessage.trim())
|
||||||
|
if (bulkRole.trim()) form.append("role", bulkRole.trim())
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
form.append("user_ids", JSON.stringify(userIds))
|
||||||
|
}
|
||||||
|
if (bulkScheduledAt) form.append("scheduled_at", bulkScheduledAt)
|
||||||
|
if (bulkFile) form.append("file", bulkFile)
|
||||||
|
await sendBulkEmail(form)
|
||||||
|
} else {
|
||||||
|
const form = new FormData()
|
||||||
|
if (!bulkTitle.trim()) {
|
||||||
|
toast.error("Title is required for bulk push")
|
||||||
|
setBulkSending(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.append("title", bulkTitle.trim())
|
||||||
|
form.append("message", bulkMessage.trim())
|
||||||
|
if (bulkRole.trim()) form.append("role", bulkRole.trim())
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
form.append("user_ids", JSON.stringify(userIds))
|
||||||
|
}
|
||||||
|
if (bulkScheduledAt) form.append("scheduled_at", bulkScheduledAt)
|
||||||
|
if (bulkFile) form.append("file", bulkFile)
|
||||||
|
await sendBulkPush(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Notification scheduled", {
|
||||||
|
description: bulkScheduledAt
|
||||||
|
? "Notification has been scheduled successfully."
|
||||||
|
: "Notification has been sent successfully.",
|
||||||
|
})
|
||||||
|
|
||||||
|
setBulkTitle("")
|
||||||
|
setBulkMessage("")
|
||||||
|
setBulkRole("")
|
||||||
|
setBulkUserIds("")
|
||||||
|
setBulkScheduledAt("")
|
||||||
|
setBulkFile(null)
|
||||||
|
setBulkChannel("sms")
|
||||||
|
setBulkOpen(false)
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg =
|
||||||
|
err?.response?.data?.message ||
|
||||||
|
"Failed to send notification. Please try again."
|
||||||
|
toast.error("Failed to send notification", { description: msg })
|
||||||
|
} finally {
|
||||||
|
setBulkSending(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1.3fr)]">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||||
|
Channel
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={bulkChannel}
|
||||||
|
onChange={(e) => setBulkChannel(e.target.value as typeof bulkChannel)}
|
||||||
|
>
|
||||||
|
<option value="sms">Bulk SMS</option>
|
||||||
|
<option value="email">Bulk email</option>
|
||||||
|
<option value="push">Bulk push</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||||
|
{bulkChannel === "email" ? "Subject" : "Title (push only)"}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder={
|
||||||
|
bulkChannel === "email"
|
||||||
|
? `e.g. "System Update"`
|
||||||
|
: `e.g. "System Update"`
|
||||||
|
}
|
||||||
|
value={bulkTitle}
|
||||||
|
onChange={(e) => setBulkTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||||
|
Message
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder={
|
||||||
|
bulkChannel === "sms"
|
||||||
|
? "Text body to send by SMS."
|
||||||
|
: "Notification body for email or push."
|
||||||
|
}
|
||||||
|
value={bulkMessage}
|
||||||
|
onChange={(e) => setBulkMessage(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||||
|
Role (optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder='e.g. "student"'
|
||||||
|
value={bulkRole}
|
||||||
|
onChange={(e) => setBulkRole(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||||
|
User IDs (comma separated)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. 1,2,3"
|
||||||
|
value={bulkUserIds}
|
||||||
|
onChange={(e) => setBulkUserIds(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1.2fr)]">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||||
|
File attachment (optional)
|
||||||
|
</label>
|
||||||
|
<FileUpload
|
||||||
|
accept="image/*"
|
||||||
|
onFileSelect={setBulkFile}
|
||||||
|
label="Upload image or file"
|
||||||
|
description="Optional image or asset to attach"
|
||||||
|
className="min-h-[110px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||||
|
Scheduled at (optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={bulkScheduledAt}
|
||||||
|
onChange={(e) => setBulkScheduledAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-grayScale-400">
|
||||||
|
Leave empty to send immediately. When set, the notification is stored in{" "}
|
||||||
|
<code>scheduled_notifications</code> and sent at the specified time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setBulkTitle("")
|
||||||
|
setBulkMessage("")
|
||||||
|
setBulkRole("")
|
||||||
|
setBulkUserIds("")
|
||||||
|
setBulkScheduledAt("")
|
||||||
|
setBulkFile(null)
|
||||||
|
setBulkChannel("sms")
|
||||||
|
setBulkOpen(false)
|
||||||
|
}}
|
||||||
|
disabled={bulkSending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" size="sm" disabled={bulkSending || !bulkMessage.trim()}>
|
||||||
|
{bulkSending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||||
|
Sending…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MailOpen className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Send
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,104 +1,337 @@
|
||||||
import { useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { ArrowLeft } from "lucide-react"
|
import { ArrowLeft, Loader2, Search, X, Check } from "lucide-react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Card } from "../../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
import { Textarea } from "../../components/ui/textarea"
|
||||||
|
import { Badge } from "../../components/ui/badge"
|
||||||
const permissions = [
|
import { createRole, setRolePermissions, getAllPermissions } from "../../api/rbac.api"
|
||||||
"View Dashboard",
|
import type { RolePermission } from "../../types/rbac.types"
|
||||||
"Manage Users",
|
import { cn } from "../../lib/utils"
|
||||||
"Manage Roles",
|
import { toast } from "sonner"
|
||||||
"Manage Practices",
|
|
||||||
"View Reports",
|
|
||||||
"Manage Content",
|
|
||||||
"Manage Settings",
|
|
||||||
]
|
|
||||||
|
|
||||||
export function AddRolePage() {
|
export function AddRolePage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [roleName, setRoleName] = useState("")
|
const [roleName, setRoleName] = useState("")
|
||||||
const [roleDescription, setRoleDescription] = useState("")
|
const [roleDescription, setRoleDescription] = useState("")
|
||||||
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([])
|
const [selectedPermissionIds, setSelectedPermissionIds] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
const togglePermission = (permission: string) => {
|
// Permissions from API (already grouped by group_name)
|
||||||
setSelectedPermissions((prev) =>
|
const [permissionsMap, setPermissionsMap] = useState<Record<string, RolePermission[]>>({})
|
||||||
prev.includes(permission)
|
const [permLoading, setPermLoading] = useState(true)
|
||||||
? prev.filter((p) => p !== permission)
|
const [permSearch, setPermSearch] = useState("")
|
||||||
: [...prev, permission],
|
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// Load all available permissions
|
||||||
|
useEffect(() => {
|
||||||
|
const fetch = async () => {
|
||||||
|
setPermLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await getAllPermissions()
|
||||||
|
setPermissionsMap(res.data.data ?? {})
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to load permissions.")
|
||||||
|
} finally {
|
||||||
|
setPermLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetch()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Flat list of all permissions (for select-all / count)
|
||||||
|
const allPermissions = useMemo(
|
||||||
|
() => Object.values(permissionsMap).flat(),
|
||||||
|
[permissionsMap],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Filtered & sorted groups
|
||||||
|
const permissionGroups = useMemo(() => {
|
||||||
|
const q = permSearch.toLowerCase()
|
||||||
|
const entries: [string, RolePermission[]][] = []
|
||||||
|
|
||||||
|
for (const [groupName, perms] of Object.entries(permissionsMap)) {
|
||||||
|
const filtered = q
|
||||||
|
? perms.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(q) ||
|
||||||
|
p.key.toLowerCase().includes(q) ||
|
||||||
|
groupName.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
: perms
|
||||||
|
if (filtered.length > 0) entries.push([groupName, filtered])
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
return entries.sort(([a], [b]) => a.localeCompare(b))
|
||||||
console.log("Add role:", { roleName, roleDescription, selectedPermissions })
|
}, [permissionsMap, permSearch])
|
||||||
|
|
||||||
|
const togglePermission = (id: number) => {
|
||||||
|
setSelectedPermissionIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleGroup = (perms: RolePermission[]) => {
|
||||||
|
const allSelected = perms.every((p) => selectedPermissionIds.has(p.id))
|
||||||
|
setSelectedPermissionIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
for (const p of perms) {
|
||||||
|
if (allSelected) next.delete(p.id)
|
||||||
|
else next.add(p.id)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
setSelectedPermissionIds(new Set(allPermissions.map((p) => p.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
setSelectedPermissionIds(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!roleName.trim()) {
|
||||||
|
toast.error("Role name is required.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
// 1. Create the role
|
||||||
|
const res = await createRole({
|
||||||
|
name: roleName.trim(),
|
||||||
|
description: roleDescription.trim(),
|
||||||
|
})
|
||||||
|
const newRoleId = res.data.data.id
|
||||||
|
|
||||||
|
// 2. Assign permissions if any selected
|
||||||
|
if (selectedPermissionIds.size > 0) {
|
||||||
|
await setRolePermissions(newRoleId, {
|
||||||
|
permission_ids: Array.from(selectedPermissionIds),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`Role "${res.data.data.name}" created successfully.`)
|
||||||
navigate("/roles")
|
navigate("/roles")
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||||
|
"Failed to create role."
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate("/roles")} className="h-8 w-8">
|
<Button variant="ghost" size="icon" onClick={() => navigate("/roles")} className="h-8 w-8">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">Add New Role</h1>
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-grayScale-700">Add New Role</h1>
|
||||||
|
<p className="text-xs text-grayScale-400">Create a role and assign permissions.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="p-6">
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
|
||||||
<div className="space-y-6">
|
{/* Left – Role info */}
|
||||||
|
<Card className="h-fit shadow-soft">
|
||||||
|
<CardHeader className="border-b border-grayScale-200 pb-3">
|
||||||
|
<CardTitle className="text-sm font-semibold text-grayScale-600">
|
||||||
|
Role Information
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 pt-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">Role Name</label>
|
<label className="mb-1.5 block text-xs font-medium text-grayScale-500">
|
||||||
|
Role Name
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={roleName}
|
value={roleName}
|
||||||
onChange={(e) => setRoleName(e.target.value)}
|
onChange={(e) => setRoleName(e.target.value)}
|
||||||
placeholder="Enter role name"
|
placeholder="e.g. CONTENT_MANAGER"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
<label className="mb-1.5 block text-xs font-medium text-grayScale-500">
|
||||||
Role Description
|
Description
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={roleDescription}
|
value={roleDescription}
|
||||||
onChange={(e) => setRoleDescription(e.target.value)}
|
onChange={(e) => setRoleDescription(e.target.value)}
|
||||||
placeholder="Enter role description"
|
placeholder="Describe what this role can do…"
|
||||||
rows={3}
|
rows={3}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="border-t border-grayScale-100 pt-4">
|
||||||
<label className="mb-4 block text-sm font-medium text-grayScale-700">
|
<div className="flex items-center justify-between text-xs text-grayScale-400">
|
||||||
Permissions
|
<span>{selectedPermissionIds.size} permission{selectedPermissionIds.size !== 1 ? "s" : ""} selected</span>
|
||||||
</label>
|
<span>{allPermissions.length} available</span>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
{permissions.map((permission) => (
|
</div>
|
||||||
<label
|
|
||||||
key={permission}
|
<Button
|
||||||
className="flex items-center gap-2 rounded-lg border p-3 hover:bg-grayScale-50 cursor-pointer"
|
onClick={handleSubmit}
|
||||||
|
disabled={saving || !roleName.trim()}
|
||||||
|
className="w-full bg-brand-500 hover:bg-brand-600"
|
||||||
>
|
>
|
||||||
<input
|
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
type="checkbox"
|
{saving ? "Creating…" : "Create Role"}
|
||||||
checked={selectedPermissions.includes(permission)}
|
</Button>
|
||||||
onChange={() => togglePermission(permission)}
|
</CardContent>
|
||||||
className="h-4 w-4 rounded border-grayScale-300 text-brand-500 focus:ring-brand-500"
|
</Card>
|
||||||
/>
|
|
||||||
<span className="text-sm text-grayScale-700">{permission}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
{/* Right – Permissions picker */}
|
||||||
<Button onClick={handleSubmit} className="bg-brand-500 hover:bg-brand-600">
|
<Card className="shadow-soft">
|
||||||
Add Role
|
<CardHeader className="border-b border-grayScale-200 pb-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<CardTitle className="text-sm font-semibold text-grayScale-600">
|
||||||
|
Permissions
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-[11px]"
|
||||||
|
onClick={selectAll}
|
||||||
|
>
|
||||||
|
Select all
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-[11px]"
|
||||||
|
onClick={clearAll}
|
||||||
|
disabled={selectedPermissionIds.size === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4 space-y-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||||
|
<Input
|
||||||
|
value={permSearch}
|
||||||
|
onChange={(e) => setPermSearch(e.target.value)}
|
||||||
|
placeholder="Filter permissions…"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
{permSearch && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPermSearch("")}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{permLoading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-brand-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Permission groups */}
|
||||||
|
{!permLoading && (
|
||||||
|
<div className="max-h-[500px] space-y-5 overflow-y-auto pr-1">
|
||||||
|
{permissionGroups.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-xs text-grayScale-400">
|
||||||
|
{permSearch ? "No permissions match your search." : "No permissions available."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
permissionGroups.map(([groupName, perms]) => {
|
||||||
|
const allSelected = perms.every((p) => selectedPermissionIds.has(p.id))
|
||||||
|
const someSelected = perms.some((p) => selectedPermissionIds.has(p.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={groupName}>
|
||||||
|
{/* Group header */}
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleGroup(perms)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
|
||||||
|
allSelected
|
||||||
|
? "border-brand-500 bg-brand-500 text-white"
|
||||||
|
: someSelected
|
||||||
|
? "border-brand-300 bg-brand-50"
|
||||||
|
: "border-grayScale-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{allSelected && <Check className="h-3 w-3" />}
|
||||||
|
{someSelected && !allSelected && (
|
||||||
|
<div className="h-1.5 w-1.5 rounded-sm bg-brand-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||||
|
{groupName}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||||
|
{perms.filter((p) => selectedPermissionIds.has(p.id)).length}/{perms.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permission items */}
|
||||||
|
<div className="ml-6 grid gap-1">
|
||||||
|
{perms.map((perm) => {
|
||||||
|
const isSelected = selectedPermissionIds.has(perm.id)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={perm.id}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center gap-2.5 rounded-lg border px-3 py-2 transition-colors",
|
||||||
|
isSelected
|
||||||
|
? "border-brand-200 bg-brand-50/50"
|
||||||
|
: "border-grayScale-100 hover:bg-grayScale-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => togglePermission(perm.id)}
|
||||||
|
className="h-3.5 w-3.5 rounded border-grayScale-300 text-brand-500 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-medium text-grayScale-700">
|
||||||
|
{perm.name}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-[10px] text-grayScale-400">
|
||||||
|
{perm.key}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,110 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { Plus, Edit } from "lucide-react"
|
import {
|
||||||
|
Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight,
|
||||||
|
Loader2, AlertCircle, Eye, X,
|
||||||
|
} from "lucide-react"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Card, CardContent } from "../../components/ui/card"
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
|
import { Badge } from "../../components/ui/badge"
|
||||||
const mockRoles = [
|
import { Input } from "../../components/ui/input"
|
||||||
{ id: "1", name: "Admin", userCount: 10 },
|
import {
|
||||||
{ id: "2", name: "User", userCount: 5 },
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
]
|
} from "../../components/ui/dialog"
|
||||||
|
import { getRoles, getRoleDetail } from "../../api/rbac.api"
|
||||||
|
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export function RolesListPage() {
|
export function RolesListPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
// List state
|
||||||
|
const [roles, setRoles] = useState<Role[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize] = useState(20)
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState("")
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Detail modal state
|
||||||
|
const [selectedRole, setSelectedRole] = useState<RoleDetail | null>(null)
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
|
|
||||||
|
// Debounce search query
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedQuery(query)
|
||||||
|
setPage(1)
|
||||||
|
}, 400)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
// Fetch roles
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRoles = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await getRoles({
|
||||||
|
query: debouncedQuery || undefined,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
})
|
||||||
|
setRoles(res.data.data.roles ?? [])
|
||||||
|
setTotal(res.data.data.total ?? 0)
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load roles.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchRoles()
|
||||||
|
}, [debouncedQuery, page, pageSize])
|
||||||
|
|
||||||
|
// Open role detail
|
||||||
|
const handleViewRole = async (roleId: number) => {
|
||||||
|
setDetailOpen(true)
|
||||||
|
setDetailLoading(true)
|
||||||
|
setSelectedRole(null)
|
||||||
|
try {
|
||||||
|
const res = await getRoleDetail(roleId)
|
||||||
|
setSelectedRole(res.data.data)
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to load role details.")
|
||||||
|
setDetailOpen(false)
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group permissions by group_name
|
||||||
|
const permissionGroups = useMemo(() => {
|
||||||
|
if (!selectedRole?.permissions) return []
|
||||||
|
const map = new Map<string, RolePermission[]>()
|
||||||
|
for (const p of selectedRole.permissions) {
|
||||||
|
const group = map.get(p.group_name) ?? []
|
||||||
|
group.push(p)
|
||||||
|
map.set(p.group_name, group)
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
}, [selectedRole])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
{/* Header */}
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">Role Management</h1>
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Role Management</h1>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
|
Manage roles and their permissions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate("/roles/add")}
|
onClick={() => navigate("/roles/add")}
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
className="bg-brand-500 hover:bg-brand-600"
|
||||||
|
|
@ -23,22 +114,226 @@ export function RolesListPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
{/* Search */}
|
||||||
{mockRoles.map((role) => (
|
<div className="relative max-w-sm">
|
||||||
<Card key={role.id} className="overflow-hidden shadow-sm">
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||||
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
|
<Input
|
||||||
<CardContent className="p-6">
|
value={query}
|
||||||
<h3 className="mb-2 text-lg font-semibold text-grayScale-900">{role.name}</h3>
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
<p className="mb-4 text-sm text-grayScale-600">{role.userCount} Users</p>
|
placeholder="Search roles…"
|
||||||
<Button variant="outline" className="w-full">
|
className="pl-9"
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
/>
|
||||||
Edit Role
|
{query && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuery("")}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 rounded-2xl border border-red-100 bg-red-50 px-5 py-4">
|
||||||
|
<AlertCircle className="h-5 w-5 shrink-0 text-red-500" />
|
||||||
|
<p className="text-sm font-medium text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Roles grid */}
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{roles.length === 0 ? (
|
||||||
|
<Card className="shadow-none border border-dashed border-grayScale-200 bg-grayScale-50/60">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
||||||
|
<Shield className="h-10 w-10 text-grayScale-300" />
|
||||||
|
<p className="text-sm font-semibold text-grayScale-600">No roles found.</p>
|
||||||
|
<p className="text-xs text-grayScale-400">
|
||||||
|
{debouncedQuery
|
||||||
|
? `No roles match "${debouncedQuery}".`
|
||||||
|
: "Create a new role to get started."}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<Card
|
||||||
|
key={role.id}
|
||||||
|
className="overflow-hidden shadow-sm transition-shadow hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-1.5",
|
||||||
|
role.is_system
|
||||||
|
? "bg-gradient-to-r from-amber-400 to-amber-500"
|
||||||
|
: "bg-gradient-to-r from-brand-500 to-brand-600",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-9 items-center justify-center rounded-lg",
|
||||||
|
role.is_system
|
||||||
|
? "bg-amber-50 text-amber-600"
|
||||||
|
: "bg-brand-50 text-brand-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{role.is_system ? (
|
||||||
|
<ShieldCheck className="h-4.5 w-4.5" />
|
||||||
|
) : (
|
||||||
|
<Shield className="h-4.5 w-4.5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-grayScale-700">{role.name}</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-grayScale-400 line-clamp-1">
|
||||||
|
{role.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{role.is_system && (
|
||||||
|
<Badge variant="warning" className="shrink-0 text-[10px]">
|
||||||
|
System
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] text-grayScale-400">
|
||||||
|
Created {new Date(role.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 text-xs"
|
||||||
|
onClick={() => handleViewRole(role.id)}
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
View
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between border-t border-grayScale-100 pt-4">
|
||||||
|
<p className="text-xs text-grayScale-400">
|
||||||
|
Showing {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total} roles
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="px-3 text-xs font-medium text-grayScale-600">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Role detail dialog */}
|
||||||
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
|
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{selectedRole?.is_system ? (
|
||||||
|
<ShieldCheck className="h-5 w-5 text-amber-500" />
|
||||||
|
) : (
|
||||||
|
<Shield className="h-5 w-5 text-brand-500" />
|
||||||
|
)}
|
||||||
|
{selectedRole?.name ?? "Role Details"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{selectedRole?.description}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{detailLoading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-brand-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!detailLoading && selectedRole && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Meta row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs text-grayScale-400">
|
||||||
|
{selectedRole.is_system && (
|
||||||
|
<Badge variant="warning" className="text-[10px]">System Role</Badge>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
Created {new Date(selectedRole.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{selectedRole.permissions.length} permission{selectedRole.permissions.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permissions grouped */}
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-grayScale-600">Permissions</h4>
|
||||||
|
{permissionGroups.length === 0 ? (
|
||||||
|
<p className="text-xs italic text-grayScale-400">No permissions assigned.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{permissionGroups.map(([groupName, perms]) => (
|
||||||
|
<div key={groupName}>
|
||||||
|
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-400">
|
||||||
|
{groupName}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{perms.map((p) => (
|
||||||
|
<span
|
||||||
|
key={p.id}
|
||||||
|
title={`${p.key} — ${p.description}`}
|
||||||
|
className="inline-flex items-center rounded-md border border-grayScale-200 bg-grayScale-50 px-2 py-0.5 text-[11px] font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,19 @@ export function UsersListPage() {
|
||||||
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||||
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({})
|
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({})
|
||||||
const [countryFilter, setCountryFilter] = useState("")
|
const [roleFilter, setRoleFilter] = useState("")
|
||||||
const [regionFilter, setRegionFilter] = useState("")
|
const [statusFilter, setStatusFilter] = useState("")
|
||||||
const [subscriptionFilter, setSubscriptionFilter] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getUsers(page, pageSize)
|
const res = await getUsers(
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
roleFilter || undefined,
|
||||||
|
statusFilter || undefined,
|
||||||
|
search || undefined,
|
||||||
|
)
|
||||||
const apiUsers = res.data.data.users
|
const apiUsers = res.data.data.users
|
||||||
|
|
||||||
const mapped = apiUsers.map(mapUserApiToUser)
|
const mapped = apiUsers.map(mapUserApiToUser)
|
||||||
|
|
@ -53,7 +58,7 @@ export function UsersListPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchUsers()
|
fetchUsers()
|
||||||
}, [page, pageSize, setUsers, setTotal])
|
}, [page, pageSize, roleFilter, statusFilter, search, setUsers, setTotal])
|
||||||
|
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||||
const safePage = Math.min(page, pageCount)
|
const safePage = Math.min(page, pageCount)
|
||||||
|
|
@ -134,45 +139,27 @@ export function UsersListPage() {
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="relative w-full sm:w-auto">
|
<div className="relative w-full sm:w-auto">
|
||||||
<select
|
<select
|
||||||
value={countryFilter}
|
value={roleFilter}
|
||||||
onChange={(e) => setCountryFilter(e.target.value)}
|
onChange={(e) => setRoleFilter(e.target.value)}
|
||||||
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
>
|
>
|
||||||
<option value="">Country</option>
|
<option value="">All roles</option>
|
||||||
<option value="USA">USA</option>
|
<option value="STUDENT">Student</option>
|
||||||
<option value="UK">UK</option>
|
<option value="TEACHER">Teacher</option>
|
||||||
<option value="Canada">Canada</option>
|
<option value="ADMIN">Admin</option>
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative w-full sm:w-auto">
|
<div className="relative w-full sm:w-auto">
|
||||||
<select
|
<select
|
||||||
value={regionFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setRegionFilter(e.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
>
|
>
|
||||||
<option value="">Region</option>
|
<option value="">All statuses</option>
|
||||||
<option value="North">North</option>
|
<option value="ACTIVE">Active</option>
|
||||||
<option value="South">South</option>
|
<option value="INACTIVE">Inactive</option>
|
||||||
<option value="East">East</option>
|
|
||||||
<option value="West">West</option>
|
|
||||||
</select>
|
|
||||||
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative w-full sm:w-auto">
|
|
||||||
<select
|
|
||||||
value={subscriptionFilter}
|
|
||||||
onChange={(e) => setSubscriptionFilter(e.target.value)}
|
|
||||||
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
|
||||||
>
|
|
||||||
<option value="">Subscription</option>
|
|
||||||
<option value="Monthly">Monthly</option>
|
|
||||||
<option value="Free">Free</option>
|
|
||||||
<option value="3-Month">3-Month</option>
|
|
||||||
<option value="6-Month">6-Month</option>
|
|
||||||
<option value="Expired">Expired</option>
|
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -439,3 +439,65 @@ export interface CreateQuestionSetResponse {
|
||||||
status_code: number
|
status_code: number
|
||||||
metadata: unknown
|
metadata: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sub-course Prerequisites
|
||||||
|
export interface SubCoursePrerequisite {
|
||||||
|
id: number
|
||||||
|
sub_course_id: number
|
||||||
|
prerequisite_sub_course_id: number
|
||||||
|
prerequisite_title: string
|
||||||
|
prerequisite_level: string
|
||||||
|
prerequisite_display_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetSubCoursePrerequisitesResponse {
|
||||||
|
message: string
|
||||||
|
data: SubCoursePrerequisite[]
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddSubCoursePrerequisiteRequest {
|
||||||
|
prerequisite_sub_course_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Learning Path (full tree from GET /courses/:courseId/learning-path)
|
||||||
|
export interface LearningPathSubCourse {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
thumbnail: string
|
||||||
|
display_order: number
|
||||||
|
level: string
|
||||||
|
prerequisite_count: number
|
||||||
|
video_count: number
|
||||||
|
practice_count: number
|
||||||
|
prerequisites: { sub_course_id: number; title: string; level: string }[]
|
||||||
|
videos: unknown[]
|
||||||
|
practices: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LearningPath {
|
||||||
|
course_id: number
|
||||||
|
course_title: string
|
||||||
|
description: string
|
||||||
|
thumbnail: string
|
||||||
|
intro_video_url: string
|
||||||
|
category_id: number
|
||||||
|
category_name: string
|
||||||
|
sub_courses: LearningPathSubCourse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetLearningPathResponse {
|
||||||
|
message: string
|
||||||
|
data: LearningPath
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReorderItem {
|
||||||
|
sub_course_id: number
|
||||||
|
display_order: number
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,21 @@ export interface GetIssueResponse {
|
||||||
metadata: null;
|
metadata: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateIssueRequest {
|
||||||
|
subject: string;
|
||||||
|
description: string;
|
||||||
|
issue_type: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateIssueResponse {
|
||||||
|
message: string;
|
||||||
|
data: Issue;
|
||||||
|
success: boolean;
|
||||||
|
status_code: number;
|
||||||
|
metadata: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateIssueStatusResponse {
|
export interface UpdateIssueStatusResponse {
|
||||||
message: string;
|
message: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
|
||||||
73
src/types/rbac.types.ts
Normal file
73
src/types/rbac.types.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
export interface Role {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
is_system: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RolePermission {
|
||||||
|
id: number
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
group_name: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleDetail extends Role {
|
||||||
|
permissions: RolePermission[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetRolesResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
roles: Role[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetRoleDetailResponse {
|
||||||
|
message: string
|
||||||
|
data: RoleDetail
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetRolesParams {
|
||||||
|
query?: string
|
||||||
|
is_system?: boolean
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRoleRequest {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRoleResponse {
|
||||||
|
message: string
|
||||||
|
data: Role
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetRolePermissionsRequest {
|
||||||
|
permission_ids: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetPermissionsResponse {
|
||||||
|
message: string
|
||||||
|
data: Record<string, RolePermission[]>
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user