Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b77a3157cf | |||
|
|
bd36b3c9d1 | ||
| 9b175a2d33 | |||
|
|
26eca02d29 | ||
| e0f32f2ede | |||
|
|
b7fa790d6e | ||
| 742d18ffb9 | |||
|
|
04a2c4c529 | ||
|
|
b13d7562e6 | ||
| 0c564e5a94 | |||
| a4b8de32de | |||
| d96c7ea424 | |||
| d75740b6a4 | |||
| 80c75b73e4 | |||
|
|
e1f1ee5ea2 | ||
|
|
f1d231d101 | ||
|
|
399b860077 | ||
|
|
9823bf9a9e |
34
.gitea/workflows/build.yml
Normal file
34
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Build-Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- beta
|
||||
- testing
|
||||
- client
|
||||
- staging
|
||||
- production
|
||||
|
||||
jobs:
|
||||
build-test:
|
||||
name: Build and Test PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build Check
|
||||
run: npm run build
|
||||
|
||||
- name: Audit Dependencies
|
||||
run: npm audit --audit-level=critical
|
||||
67
.gitea/workflows/compressimage.yml
Normal file
67
.gitea/workflows/compressimage.yml
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Enforce Image Standards
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- beta
|
||||
- testing
|
||||
- client
|
||||
- staging
|
||||
- production
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- '**/*.jpg'
|
||||
- '**/*.jpeg'
|
||||
- '**/*.png'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
optimize:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ gitea.head_ref }} # IMPORTANT
|
||||
|
||||
- name: Install Image Tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y imagemagick jpegoptim pngquant
|
||||
|
||||
- name: Resize Oversized Images
|
||||
run: |
|
||||
find . -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" \) \
|
||||
-exec mogrify -resize 1920x1920\> {} \;
|
||||
|
||||
- name: Optimize JPEG
|
||||
run: |
|
||||
find . -type f \( -iname "*.jpg" -o -iname "*.jpeg" \) \
|
||||
-exec jpegoptim --strip-all --max=85 {} \;
|
||||
|
||||
- name: Optimize PNG
|
||||
run: |
|
||||
find . -type f -iname "*.png" \
|
||||
-exec pngquant --force --ext .png --quality=75-90 {} \;
|
||||
|
||||
# Commit changes if any
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git config --global user.name "CI Bot"
|
||||
git config --global user.email "ci@local"
|
||||
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
git add .
|
||||
git commit -m "chore: optimize images via CI"
|
||||
else
|
||||
echo "No changes to commit"
|
||||
fi
|
||||
|
||||
# Push back to PR branch
|
||||
- name: Push changes
|
||||
if: success()
|
||||
run: |
|
||||
git push origin HEAD:${{ gitea.head_ref }}
|
||||
77
.gitea/workflows/deploy.yml
Normal file
77
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,77 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- beta
|
||||
- testing
|
||||
- client
|
||||
- staging
|
||||
- production
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploying code in Server
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Code in Runner
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Determine Project Folder
|
||||
run: |
|
||||
echo "Detected Branch Name : ${{ gitea.ref_name }}"
|
||||
BRANCH_NAME=${{ gitea.ref_name }}
|
||||
|
||||
case "$BRANCH_NAME" in
|
||||
beta)
|
||||
echo "PROJECT_FOLDER=null" >> $GITHUB_ENV
|
||||
;;
|
||||
testing)
|
||||
echo "PROJECT_FOLDER=/home/klc/klc-frontend/Test_Release/KLC-Hr-Dashboard-Frontend" >> $GITHUB_ENV
|
||||
;;
|
||||
client)
|
||||
echo "PROJECT_FOLDER=null" >> $GITHUB_ENV
|
||||
;;
|
||||
staging)
|
||||
echo "PROJECT_FOLDER=null" >> $GITHUB_ENV
|
||||
;;
|
||||
production)
|
||||
echo "PROJECT_FOLDER=null" >> $GITHUB_ENV
|
||||
;;
|
||||
*)
|
||||
echo "UNKNOWN BRANCH"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "BRANCH_NAME=${{ gitea.ref_name }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Deploy Using SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.BETA_SERVER_HOST }}
|
||||
username: ${{ secrets.BETA_SERVER_USERNAME }}
|
||||
password: ${{ secrets.BETA_SERVER_PASSWORD }}
|
||||
port: ${{ secrets.BETA_SERVER_PORT }}
|
||||
envs: BRANCH_NAME,PROJECT_FOLDER
|
||||
script: |
|
||||
set -e
|
||||
|
||||
if [ "$PROJECT_FOLDER" = "null" ]; then
|
||||
echo "No deployment configured for this branch"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd $PROJECT_FOLDER
|
||||
|
||||
git fetch
|
||||
git reset --hard origin/$BRANCH_NAME
|
||||
git stash && git stash clear || true
|
||||
git pull origin $BRANCH_NAME
|
||||
|
||||
echo "Installing Node Modules..."
|
||||
npm install
|
||||
|
||||
echo "Building..."
|
||||
npm run build
|
||||
39
.gitea/workflows/sonar.yml
Normal file
39
.gitea/workflows/sonar.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Sonar Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- beta
|
||||
- testing
|
||||
- client
|
||||
- staging
|
||||
- production
|
||||
|
||||
jobs:
|
||||
sonarqube:
|
||||
name: SonarQube Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
container:
|
||||
image: sonarsource/sonar-scanner-cli:12.0.0.3214_8.0.1
|
||||
options: --user root
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Sonar Scan
|
||||
run: |
|
||||
REPO_NAME=${{ gitea.event.repository.name }}
|
||||
|
||||
sonar-scanner \
|
||||
-Dsonar.projectKey=$REPO_NAME \
|
||||
-Dsonar.projectName=$REPO_NAME \
|
||||
-Dsonar.sources=. \
|
||||
-Dsonar.host.url=${{ secrets.SONARQUBE_HOST }} \
|
||||
-Dsonar.token=${{ secrets.SONARQUBE_TOKEN }} \
|
||||
-Dsonar.exclusions=node_modules/**,dist/**,coverage/** \
|
||||
-Dsonar.qualitygate.wait=true
|
||||
@@ -7,7 +7,7 @@
|
||||
<title>HR Portal Dashboard version 0.1</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body style="overflow: hidden;">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
158
package-lock.json
generated
158
package-lock.json
generated
@@ -34,6 +34,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "*",
|
||||
@@ -47,7 +48,9 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"recharts": "^2.15.2",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "*",
|
||||
@@ -1889,6 +1892,32 @@
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -2176,6 +2205,18 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/core": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz",
|
||||
@@ -2763,6 +2804,12 @@
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react-swc": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz",
|
||||
@@ -2835,6 +2882,19 @@
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
@@ -3129,6 +3189,16 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/input-otp": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
|
||||
@@ -3612,6 +3682,29 @@
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
|
||||
@@ -3669,6 +3762,44 @@
|
||||
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
|
||||
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
||||
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.13.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
@@ -3754,6 +3885,27 @@
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.49.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.49.0.tgz",
|
||||
@@ -3803,6 +3955,12 @@
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "*",
|
||||
@@ -42,7 +43,9 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"recharts": "^2.15.2",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "*",
|
||||
|
||||
2145
src/App.tsx
2145
src/App.tsx
File diff suppressed because it is too large
Load Diff
0
src/App_new.tsx
Normal file
0
src/App_new.tsx
Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 14 KiB |
379
src/components/ActiveProgrammesTable.tsx
Normal file
379
src/components/ActiveProgrammesTable.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Input } from './ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Download,
|
||||
Users,
|
||||
Calendar,
|
||||
BookOpen,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
UserPlus,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Assignment {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
interface Programme {
|
||||
programmeId: string;
|
||||
title: string;
|
||||
status: 'Active' | 'Upcoming' | 'Completed';
|
||||
coursesCount: number;
|
||||
contentCount: number;
|
||||
assignment: Assignment;
|
||||
learnersAssigned: number;
|
||||
}
|
||||
|
||||
interface ProgrammesTableProps {
|
||||
programmes?: Programme[];
|
||||
onViewProgramme?: (programmeId: string) => void;
|
||||
onAssignLearners?: (programmeId: string) => void;
|
||||
onDownloadTracker?: (programmeId: string) => void;
|
||||
}
|
||||
|
||||
const mockProgrammes: Programme[] = [
|
||||
{
|
||||
programmeId: 'prog-001',
|
||||
title: 'Leadership Development Program',
|
||||
status: 'Active',
|
||||
coursesCount: 8,
|
||||
contentCount: 24,
|
||||
assignment: {
|
||||
startDate: new Date('2024-01-15'),
|
||||
endDate: new Date('2024-06-30')
|
||||
},
|
||||
learnersAssigned: 45
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-002',
|
||||
title: 'Technical Skills Bootcamp',
|
||||
status: 'Active',
|
||||
coursesCount: 12,
|
||||
contentCount: 36,
|
||||
assignment: {
|
||||
startDate: new Date('2024-02-01'),
|
||||
endDate: new Date('2024-08-31')
|
||||
},
|
||||
learnersAssigned: 38
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-003',
|
||||
title: 'Communication Excellence',
|
||||
status: 'Upcoming',
|
||||
coursesCount: 6,
|
||||
contentCount: 18,
|
||||
assignment: {
|
||||
startDate: new Date('2024-03-01'),
|
||||
endDate: new Date('2024-05-31')
|
||||
},
|
||||
learnersAssigned: 28
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-004',
|
||||
title: 'Project Management Certification',
|
||||
status: 'Active',
|
||||
coursesCount: 10,
|
||||
contentCount: 30,
|
||||
assignment: {
|
||||
startDate: new Date('2024-01-01'),
|
||||
endDate: new Date('2024-12-31')
|
||||
},
|
||||
learnersAssigned: 52
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-005',
|
||||
title: 'Digital Marketing Mastery',
|
||||
status: 'Completed',
|
||||
coursesCount: 5,
|
||||
contentCount: 15,
|
||||
assignment: {
|
||||
startDate: new Date('2023-09-01'),
|
||||
endDate: new Date('2023-12-31')
|
||||
},
|
||||
learnersAssigned: 32
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-006',
|
||||
title: 'Data Analytics Fundamentals',
|
||||
status: 'Completed',
|
||||
coursesCount: 7,
|
||||
contentCount: 21,
|
||||
assignment: {
|
||||
startDate: new Date('2023-10-15'),
|
||||
endDate: new Date('2024-01-15')
|
||||
},
|
||||
learnersAssigned: 29
|
||||
}
|
||||
];
|
||||
|
||||
export const ProgrammesTable: React.FC<ProgrammesTableProps> = ({
|
||||
programmes = mockProgrammes,
|
||||
onViewProgramme,
|
||||
onAssignLearners,
|
||||
onDownloadTracker
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('Active');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isAssignModalOpen, setIsAssignModalOpen] = useState(false);
|
||||
const [selectedProgramme, setSelectedProgramme] = useState<Programme | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const itemsPerPage = 5;
|
||||
|
||||
const filteredProgrammes = programmes.filter(prog => {
|
||||
const matchesSearch = prog.title.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || prog.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
// Pagination logic
|
||||
const totalPages = Math.ceil(filteredProgrammes.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedProgrammes = filteredProgrammes.slice(startIndex, endIndex);
|
||||
|
||||
// Reset to first page when filters change
|
||||
React.useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, statusFilter]);
|
||||
|
||||
const formatDateRange = (assignment: Assignment) => {
|
||||
const startDate = assignment.startDate.toLocaleDateString('en-AU', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
const endDate = assignment.endDate.toLocaleDateString('en-AU', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
return `${startDate} → ${endDate}`;
|
||||
};
|
||||
|
||||
const handleViewProgramme = (programmeId: string) => {
|
||||
onViewProgramme?.(programmeId);
|
||||
console.log(`Viewing programme: ${programmeId}`);
|
||||
};
|
||||
|
||||
const handleAssignLearners = (programme: Programme) => {
|
||||
setSelectedProgramme(programme);
|
||||
setIsAssignModalOpen(true);
|
||||
onAssignLearners?.(programme.programmeId);
|
||||
};
|
||||
|
||||
const handleDownloadTracker = async (programmeId: string) => {
|
||||
setIsExporting(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setIsExporting(false);
|
||||
onDownloadTracker?.(programmeId);
|
||||
console.log(`Downloaded tracker for programme: ${programmeId}`);
|
||||
};
|
||||
|
||||
const getStatusBadgeProps = (status: Programme['status']) => {
|
||||
switch (status) {
|
||||
case 'Active':
|
||||
return { variant: 'default' as const, className: 'bg-status-success text-status-success-foreground' };
|
||||
case 'Upcoming':
|
||||
return { variant: 'secondary' as const, className: 'bg-status-warn text-status-warn-foreground' };
|
||||
case 'Completed':
|
||||
return { variant: 'outline' as const, className: 'bg-muted text-muted-foreground' };
|
||||
default:
|
||||
return { variant: 'secondary' as const };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Programmes</CardTitle>
|
||||
<CardDescription>Manage programme assignments and track progress</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search programmes..."
|
||||
className="pl-10 w-[200px]"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
aria-label="Search programmes by title"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="Active">Active</SelectItem>
|
||||
<SelectItem value="Upcoming">Upcoming</SelectItem>
|
||||
<SelectItem value="Completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border" style={{ maxWidth: '1200px' }}>
|
||||
<Table>
|
||||
<TableHeader className="sticky-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[300px]">Programme Title</TableHead>
|
||||
<TableHead className="w-[150px]">Courses / Content</TableHead>
|
||||
<TableHead className="w-[200px]">Start → End</TableHead>
|
||||
<TableHead className="w-[120px]">Learners Assigned</TableHead>
|
||||
<TableHead className="w-[200px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedProgrammes.map((programme) => (
|
||||
<TableRow key={programme.programmeId} className="min-h-[44px]">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{programme.title}</span>
|
||||
<Badge
|
||||
{...getStatusBadgeProps(programme.status)}
|
||||
aria-label={`Programme status: ${programme.status}`}
|
||||
>
|
||||
{programme.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<span>{programme.coursesCount} • {programme.contentCount}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{formatDateRange(programme.assignment)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{programme.learnersAssigned}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewProgramme(programme.programmeId)}
|
||||
className="min-tap-44"
|
||||
aria-label={`View programme details for ${programme.title}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="sr-only">View Programme</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleAssignLearners(programme)}
|
||||
className="min-tap-44"
|
||||
aria-label={`Assign learners to ${programme.title}`}
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
<span className="sr-only">Assign Learners</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadTracker(programme.programmeId)}
|
||||
disabled={isExporting}
|
||||
className="min-tap-44"
|
||||
aria-label={`Download tracker for ${programme.title}`}
|
||||
>
|
||||
{isExporting ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">Download Tracker</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {startIndex + 1} to {Math.min(endIndex, filteredProgrammes.length)} of {filteredProgrammes.length} programmes
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="min-tap-44"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredProgrammes.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">No programmes found matching your criteria.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignment Modal */}
|
||||
<Dialog open={isAssignModalOpen} onOpenChange={setIsAssignModalOpen}>
|
||||
<DialogContent className="sm:max-w-[600px]" role="dialog" aria-modal="true">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Learners to Programme</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedProgramme && `Assign learners to "${selectedProgramme.title}"`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<UserPlus className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p>Assignment wizard would be displayed here</p>
|
||||
<p className="text-sm mt-2">Including org/individual selection, dates, HR contacts, and participant upload</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
79
src/components/BreadcrumbNav.tsx
Normal file
79
src/components/BreadcrumbNav.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight, Home } from 'lucide-react';
|
||||
|
||||
export const BreadcrumbNav: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const pathSegments = location.pathname.split('/').filter(Boolean);
|
||||
|
||||
const getDisplayName = (segment: string): string => {
|
||||
const names: Record<string, string> = {
|
||||
'hr': 'HR Portal',
|
||||
'dashboard': 'Dashboard',
|
||||
'learners': 'Learners',
|
||||
'reports': 'Reports',
|
||||
'discussions': 'Discussion Forums',
|
||||
'programme': 'Programme',
|
||||
'course': 'Course',
|
||||
'profile': 'Profile',
|
||||
'settings': 'Settings'
|
||||
};
|
||||
|
||||
// Handle dynamic segments (like programme IDs)
|
||||
if (segment.match(/^[0-9a-f-]+$/)) {
|
||||
return 'Details';
|
||||
}
|
||||
|
||||
return names[segment] || segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ');
|
||||
};
|
||||
|
||||
if (pathSegments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<nav className="flex items-center space-x-2 text-sm mb-6" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center flex-wrap gap-1">
|
||||
{/* Home icon */}
|
||||
<li>
|
||||
<button
|
||||
onClick={() => navigate('/hr/dashboard')}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1"
|
||||
aria-label="Go to dashboard"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{pathSegments.map((segment, index) => {
|
||||
const isLast = index === pathSegments.length - 1;
|
||||
const path = '/' + pathSegments.slice(0, index + 1).join('/');
|
||||
const displayName = getDisplayName(segment);
|
||||
|
||||
return (
|
||||
<li key={path} className="flex items-center">
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground mx-1" aria-hidden="true" />
|
||||
{isLast ? (
|
||||
<span
|
||||
className="font-medium text-foreground"
|
||||
aria-current="page"
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => navigate(path)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{displayName}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default BreadcrumbNav;
|
||||
839
src/components/CourseHRView.tsx
Normal file
839
src/components/CourseHRView.tsx
Normal file
@@ -0,0 +1,839 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Input } from './ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from './ui/sheet';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
||||
import { Progress } from './ui/progress';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './ui/accordion';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
BarChart3,
|
||||
MoreHorizontal,
|
||||
Users,
|
||||
Clock,
|
||||
BookOpen,
|
||||
Award,
|
||||
Search,
|
||||
Eye,
|
||||
Mail,
|
||||
FileText,
|
||||
Play,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
XCircle,
|
||||
Video,
|
||||
FileQuestion,
|
||||
Activity,
|
||||
Building2
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'Published' | 'Draft' | 'Archived';
|
||||
code: string;
|
||||
owner: string;
|
||||
version: number;
|
||||
duration: string;
|
||||
description: string;
|
||||
objectives: string[];
|
||||
tags: string[];
|
||||
modules: CourseModule[];
|
||||
linkedProgrammes: LinkedProgramme[];
|
||||
}
|
||||
|
||||
interface CourseModule {
|
||||
id: string;
|
||||
title: string;
|
||||
lessons: CourseLesson[];
|
||||
}
|
||||
|
||||
interface CourseLesson {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'video' | 'quiz' | 'read' | 'assignment';
|
||||
eta: string;
|
||||
dueDate?: string;
|
||||
status?: 'Not Started' | 'In Progress' | 'Completed';
|
||||
}
|
||||
|
||||
interface LinkedProgramme {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface CourseAssignment {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
orgId: string;
|
||||
orgName: string;
|
||||
}
|
||||
|
||||
interface CourseCounts {
|
||||
learners: number;
|
||||
avgProgress: number;
|
||||
modules: number;
|
||||
lessons: number;
|
||||
}
|
||||
|
||||
interface CourseLearner {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
progressPct: number;
|
||||
currentLesson: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'Not Started' | 'In-Progress' | 'Completed';
|
||||
};
|
||||
lastActivity: string;
|
||||
attempts?: number;
|
||||
avgScore?: number;
|
||||
}
|
||||
|
||||
interface CourseHRViewProps {
|
||||
courseId: string;
|
||||
onBack: () => void;
|
||||
onAssignLearners: (courseId: string) => void;
|
||||
onDownloadTracker: (courseId: string) => void;
|
||||
onOpenAnalytics: (courseId: string) => void;
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockCourse: Course = {
|
||||
id: 'crs_456',
|
||||
title: 'Strategic Thinking and Decision Making',
|
||||
status: 'Published',
|
||||
code: 'STDM-2024',
|
||||
owner: 'Prof. Michael Chen',
|
||||
version: 1,
|
||||
duration: '6 hours',
|
||||
description: 'This course develops strategic thinking capabilities and decision-making frameworks for leaders at all levels. Participants will learn to analyze complex situations, evaluate options, and make informed decisions.',
|
||||
objectives: [
|
||||
'Apply strategic thinking frameworks to business challenges',
|
||||
'Develop systematic approaches to decision making',
|
||||
'Evaluate risks and opportunities effectively',
|
||||
'Create actionable strategic plans'
|
||||
],
|
||||
tags: ['Strategy', 'Leadership', 'Decision Making', 'Critical Thinking'],
|
||||
modules: [
|
||||
{
|
||||
id: 'm1',
|
||||
title: 'Foundations of Strategic Thinking',
|
||||
lessons: [
|
||||
{ id: 'l1', title: 'Introduction to Strategic Thinking', type: 'video', eta: '15 mins', status: 'Completed' },
|
||||
{ id: 'l2', title: 'Strategic Frameworks Overview', type: 'read', eta: '20 mins', status: 'Completed' },
|
||||
{ id: 'l3', title: 'Knowledge Check', type: 'quiz', eta: '10 mins', status: 'In Progress' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
title: 'Decision Making Models',
|
||||
lessons: [
|
||||
{ id: 'l4', title: 'Rational Decision Making', type: 'video', eta: '25 mins', status: 'Not Started' },
|
||||
{ id: 'l5', title: 'Intuitive vs Analytical Approaches', type: 'read', eta: '15 mins', status: 'Not Started' },
|
||||
{ id: 'l6', title: 'Case Study Analysis', type: 'assignment', eta: '45 mins', dueDate: '2024-01-25', status: 'Not Started' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'm3',
|
||||
title: 'Risk Assessment and Management',
|
||||
lessons: [
|
||||
{ id: 'l7', title: 'Risk Identification Techniques', type: 'video', eta: '20 mins', status: 'Not Started' },
|
||||
{ id: 'l8', title: 'Risk Matrix and Evaluation', type: 'read', eta: '25 mins', status: 'Not Started' },
|
||||
{ id: 'l9', title: 'Final Assessment', type: 'quiz', eta: '30 mins', dueDate: '2024-01-30', status: 'Not Started' }
|
||||
]
|
||||
}
|
||||
],
|
||||
linkedProgrammes: [
|
||||
{ id: 'prg_123', title: 'Executive Leadership Development Programme' },
|
||||
{ id: 'prg_124', title: 'Management Excellence Programme' }
|
||||
]
|
||||
};
|
||||
|
||||
const mockAssignment: CourseAssignment = {
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-02-15',
|
||||
orgId: 'org_123',
|
||||
orgName: 'Tech Solutions Pvt Ltd'
|
||||
};
|
||||
|
||||
const mockCounts: CourseCounts = {
|
||||
learners: 15,
|
||||
avgProgress: 58,
|
||||
modules: 3,
|
||||
lessons: 9
|
||||
};
|
||||
|
||||
const mockLearners: CourseLearner[] = [
|
||||
{
|
||||
id: 'l1',
|
||||
name: 'Sarah Chen',
|
||||
email: 'sarah.chen@company.com',
|
||||
progressPct: 75,
|
||||
currentLesson: { id: 'l3', title: 'Knowledge Check', status: 'In-Progress' },
|
||||
lastActivity: '2 hours ago',
|
||||
attempts: 2,
|
||||
avgScore: 87
|
||||
},
|
||||
{
|
||||
id: 'l2',
|
||||
name: 'Michael Rodriguez',
|
||||
email: 'michael.r@company.com',
|
||||
progressPct: 45,
|
||||
currentLesson: { id: 'l2', title: 'Strategic Frameworks Overview', status: 'In-Progress' },
|
||||
lastActivity: '1 day ago',
|
||||
attempts: 1,
|
||||
avgScore: 92
|
||||
},
|
||||
{
|
||||
id: 'l3',
|
||||
name: 'Emma Thompson',
|
||||
email: 'emma.thompson@company.com',
|
||||
progressPct: 89,
|
||||
currentLesson: { id: 'l6', title: 'Case Study Analysis', status: 'In-Progress' },
|
||||
lastActivity: '3 hours ago',
|
||||
attempts: 3,
|
||||
avgScore: 94
|
||||
},
|
||||
{
|
||||
id: 'l4',
|
||||
name: 'David Kim',
|
||||
email: 'david.kim@company.com',
|
||||
progressPct: 23,
|
||||
currentLesson: { id: 'l1', title: 'Introduction to Strategic Thinking', status: 'In-Progress' },
|
||||
lastActivity: '5 hours ago',
|
||||
attempts: 1,
|
||||
avgScore: 78
|
||||
}
|
||||
];
|
||||
|
||||
export const CourseHRView: React.FC<CourseHRViewProps> = ({
|
||||
courseId,
|
||||
onBack,
|
||||
onAssignLearners,
|
||||
onDownloadTracker,
|
||||
onOpenAnalytics
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [selectedLearner, setSelectedLearner] = useState<CourseLearner | null>(null);
|
||||
const [showLearnerDrawer, setShowLearnerDrawer] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
// Simulate loading
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setLoading(false), 800);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'video': return <Video className="h-4 w-4" />;
|
||||
case 'quiz': return <FileQuestion className="h-4 w-4" />;
|
||||
case 'read': return <FileText className="h-4 w-4" />;
|
||||
case 'assignment': return <Activity className="h-4 w-4" />;
|
||||
default: return <BookOpen className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'Completed':
|
||||
return <Badge variant="default" className="bg-status-success text-white"><CheckCircle className="h-3 w-3 mr-1" />Completed</Badge>;
|
||||
case 'In Progress':
|
||||
return <Badge variant="secondary" className="bg-status-warn text-black"><AlertCircle className="h-3 w-3 mr-1" />In Progress</Badge>;
|
||||
case 'Not Started':
|
||||
return <Badge variant="outline"><XCircle className="h-3 w-3 mr-1" />Not Started</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLearners = mockLearners.filter(learner => {
|
||||
const matchesSearch = learner.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
learner.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || learner.currentLesson.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const handleViewLearner = (learner: CourseLearner) => {
|
||||
setSelectedLearner(learner);
|
||||
setShowLearnerDrawer(true);
|
||||
};
|
||||
|
||||
const handleExport = async (format: 'excel' | 'csv' | 'pdf') => {
|
||||
setExporting(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setExporting(false);
|
||||
console.log(`Exported course tracker as ${format.toUpperCase()}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div className="h-16 bg-muted rounded-lg"></div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-24 bg-muted rounded-lg"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-96 bg-muted rounded-lg"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<Card className="sticky top-0 z-20 bg-background border-b shadow-sm">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
className="min-tap-44"
|
||||
aria-label="Go back to courses list"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h1 className="text-2xl font-bold">{mockCourse.title}</h1>
|
||||
<Badge
|
||||
variant={mockCourse.status === 'Published' ? 'default' : 'secondary'}
|
||||
className={mockCourse.status === 'Published' ? 'bg-status-success' : ''}
|
||||
>
|
||||
{mockCourse.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{mockCourse.code} • {mockCourse.owner} • Version {mockCourse.version} • Duration: {mockCourse.duration}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => onAssignLearners(courseId)}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Assign Learners
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onDownloadTracker(courseId)}
|
||||
disabled={exporting}
|
||||
className="min-tap-44"
|
||||
>
|
||||
{exporting ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Download Tracker
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenAnalytics(courseId)}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
Open Analytics
|
||||
</Button>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-tap-44"
|
||||
aria-label="More actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Course Actions</DialogTitle>
|
||||
<DialogDescription>Additional actions for this course</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
View Syllabus
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Audit Trail
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Assignment Window</p>
|
||||
<p className="font-semibold">{new Date(mockAssignment.startDate).toLocaleDateString()} → {new Date(mockAssignment.endDate).toLocaleDateString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{mockAssignment.orgName}</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-brand-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Enrolled Learners</p>
|
||||
<p className="text-2xl font-bold">{mockCounts.learners}</p>
|
||||
<Button variant="link" className="p-0 h-auto text-xs">Manage</Button>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-brand-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Avg Progress</p>
|
||||
<p className="text-2xl font-bold">{mockCounts.avgProgress}%</p>
|
||||
<Progress value={mockCounts.avgProgress} className="w-16 mt-1" />
|
||||
</div>
|
||||
<Award className="h-8 w-8 text-brand-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Course Structure</p>
|
||||
<div className="flex gap-2 text-sm">
|
||||
<span>{mockCounts.modules} Modules</span>
|
||||
<span>•</span>
|
||||
<span>{mockCounts.lessons} Lessons</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Duration: {mockCourse.duration}</p>
|
||||
</div>
|
||||
<BookOpen className="h-8 w-8 text-brand-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="syllabus">Syllabus</TabsTrigger>
|
||||
<TabsTrigger value="learners">Learners</TabsTrigger>
|
||||
<TabsTrigger value="reports">Reports</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="space-y-6 mt-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Course Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Description</h4>
|
||||
<p className="text-sm text-muted-foreground">{mockCourse.description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Learning Objectives</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
{mockCourse.objectives.map((objective, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-status-success mt-0.5 flex-shrink-0" />
|
||||
{objective}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Tags</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mockCourse.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Linked Resources</CardTitle>
|
||||
<CardDescription>Read-only view of course resources</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Associated Programmes</h4>
|
||||
{mockCourse.linkedProgrammes.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Appears in {mockCourse.linkedProgrammes.length} programmes:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{mockCourse.linkedProgrammes.map((programme, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start h-auto p-2"
|
||||
>
|
||||
<Building2 className="h-4 w-4 mr-2" />
|
||||
{programme.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No linked programmes</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Course Metadata</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total Duration:</span>
|
||||
<span>{mockCourse.duration}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Modules:</span>
|
||||
<span>{mockCourse.modules.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total Lessons:</span>
|
||||
<span>{mockCourse.modules.reduce((acc, module) => acc + module.lessons.length, 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Syllabus Tab */}
|
||||
<TabsContent value="syllabus" className="space-y-6 mt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Course Syllabus</h3>
|
||||
<p className="text-muted-foreground">Read-only view of the course structure and lessons</p>
|
||||
</div>
|
||||
<Badge variant="outline">Read Only</Badge>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="space-y-2">
|
||||
{mockCourse.modules.map((module, index) => (
|
||||
<AccordionItem key={index} value={`module-${index}`} className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline">Module {index + 1}</Badge>
|
||||
<span className="font-medium">{module.title}</span>
|
||||
<span className="text-muted-foreground">({module.lessons.length} lessons)</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 pb-4">
|
||||
{module.lessons.map((lesson, lessonIndex) => (
|
||||
<div key={lessonIndex} className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
{getTypeIcon(lesson.type)}
|
||||
<div>
|
||||
<p className="font-medium">{lesson.title}</p>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{lesson.eta}
|
||||
</span>
|
||||
{lesson.dueDate && (
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Due: {lesson.dueDate}
|
||||
</span>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{lesson.type}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Learners Tab */}
|
||||
<TabsContent value="learners" className="space-y-6 mt-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Learner Progress</h3>
|
||||
<p className="text-muted-foreground">Track individual progress through the course</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleExport('excel')}
|
||||
disabled={exporting}
|
||||
className="min-tap-44"
|
||||
>
|
||||
{exporting ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Export Data
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search learners..."
|
||||
className="pl-10"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="Not Started">Not Started</SelectItem>
|
||||
<SelectItem value="In-Progress">In Progress</SelectItem>
|
||||
<SelectItem value="Completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Learners Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader className="sticky-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">Learner</TableHead>
|
||||
<TableHead className="w-[100px]">Progress</TableHead>
|
||||
<TableHead className="w-[200px]">Current Lesson</TableHead>
|
||||
<TableHead className="w-[120px]">Last Activity</TableHead>
|
||||
<TableHead className="w-[100px]">Performance</TableHead>
|
||||
<TableHead className="w-[80px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredLearners.map((learner) => (
|
||||
<TableRow key={learner.id} className="min-h-[48px]">
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{learner.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{learner.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Progress value={learner.progressPct} className="w-16" />
|
||||
<span className="text-sm">{learner.progressPct}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{getTypeIcon('video')}
|
||||
<div>
|
||||
<p className="font-medium text-sm">{learner.currentLesson.title}</p>
|
||||
{getStatusBadge(learner.currentLesson.status)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{learner.lastActivity}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
{learner.avgScore && (
|
||||
<div>
|
||||
<span className="font-medium">{learner.avgScore}%</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{learner.attempts} attempt{learner.attempts !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewLearner(learner)}
|
||||
className="min-tap-44"
|
||||
aria-label={`View details for ${learner.name}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-tap-44"
|
||||
aria-label={`Send reminder to ${learner.name}`}
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Reports Tab */}
|
||||
<TabsContent value="reports" className="space-y-6 mt-6">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Course Reports</h3>
|
||||
<p>Detailed analytics and reporting for this course would be displayed here</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Activity Tab */}
|
||||
<TabsContent value="activity" className="space-y-6 mt-6">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Activity className="h-12 w-12 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Activity Log</h3>
|
||||
<p>Course activity audit trail would be displayed here</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Learner Details Drawer */}
|
||||
<Sheet open={showLearnerDrawer} onOpenChange={setShowLearnerDrawer}>
|
||||
<SheetContent className="w-[480px] sm:w-[540px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{selectedLearner?.name}</SheetTitle>
|
||||
<SheetDescription>Course progress and performance details</SheetDescription>
|
||||
</SheetHeader>
|
||||
{selectedLearner && (
|
||||
<div className="space-y-6 mt-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Course Progress</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Overall Progress:</span>
|
||||
<span className="font-medium">{selectedLearner.progressPct}%</span>
|
||||
</div>
|
||||
<Progress value={selectedLearner.progressPct} className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Lesson Checklist</h4>
|
||||
<div className="space-y-3">
|
||||
{mockCourse.modules.flatMap(module =>
|
||||
module.lessons.map((lesson, index) => (
|
||||
<div key={index} className="flex items-center gap-3 p-2 rounded-lg bg-muted/30">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
lesson.status === 'Completed' ? 'bg-status-success border-status-success' :
|
||||
lesson.status === 'In Progress' ? 'border-brand-primary bg-brand-primary' :
|
||||
'border-muted'
|
||||
}`}>
|
||||
{lesson.status === 'Completed' && <CheckCircle className="w-4 h-4 text-white" />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{getTypeIcon(lesson.type)}
|
||||
<span className={`text-sm ${
|
||||
lesson.id === selectedLearner.currentLesson.id ? 'font-medium' : 'text-muted-foreground'
|
||||
}`}>
|
||||
{lesson.title}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{lesson.eta}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedLearner.avgScore && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Performance Summary</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Average Score:</span>
|
||||
<span className="font-medium">{selectedLearner.avgScore}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Quiz Attempts:</span>
|
||||
<span>{selectedLearner.attempts}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View Detailed Report
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1">
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Send Reminder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
226
src/components/DiscussionForumFeed.tsx
Normal file
226
src/components/DiscussionForumFeed.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import {
|
||||
MessageSquare,
|
||||
Eye,
|
||||
Check,
|
||||
Filter,
|
||||
Users,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ForumThread {
|
||||
threadId: string;
|
||||
title: string;
|
||||
programmeId: string;
|
||||
programmeName: string;
|
||||
lastReplyAt: Date;
|
||||
lastAuthor: string;
|
||||
unread: boolean;
|
||||
href: string;
|
||||
replyCount?: number;
|
||||
participantCount?: number;
|
||||
}
|
||||
|
||||
interface DiscussionForumFeedProps {
|
||||
forumFeed?: ForumThread[];
|
||||
onOpenThread?: (threadId: string) => void;
|
||||
onMarkAsRead?: (threadId: string) => void;
|
||||
}
|
||||
|
||||
const mockForumFeed: ForumThread[] = [
|
||||
{
|
||||
threadId: 'thread-001',
|
||||
title: 'Leadership in Remote Teams - Best Practices Discussion',
|
||||
programmeId: 'prog-001',
|
||||
programmeName: 'Leadership Development',
|
||||
lastReplyAt: new Date('2024-12-27T15:30:00'),
|
||||
lastAuthor: 'Sarah Chen',
|
||||
unread: true,
|
||||
href: '/discussions/leadership-remote-teams',
|
||||
replyCount: 12,
|
||||
participantCount: 8
|
||||
},
|
||||
{
|
||||
threadId: 'thread-002',
|
||||
title: 'JavaScript ES6 Features - Questions and Examples',
|
||||
programmeId: 'prog-002',
|
||||
programmeName: 'Technical Skills Bootcamp',
|
||||
lastReplyAt: new Date('2024-12-27T14:15:00'),
|
||||
lastAuthor: 'David Kim',
|
||||
unread: true,
|
||||
href: '/discussions/javascript-es6',
|
||||
replyCount: 18,
|
||||
participantCount: 15
|
||||
},
|
||||
{
|
||||
threadId: 'thread-003',
|
||||
title: 'Effective Presentation Techniques - Share Your Tips',
|
||||
programmeId: 'prog-003',
|
||||
programmeName: 'Communication Excellence',
|
||||
lastReplyAt: new Date('2024-12-27T11:45:00'),
|
||||
lastAuthor: 'Emma Thompson',
|
||||
unread: false,
|
||||
href: '/discussions/presentation-techniques',
|
||||
replyCount: 7,
|
||||
participantCount: 6
|
||||
},
|
||||
{
|
||||
threadId: 'thread-004',
|
||||
title: 'Agile vs Waterfall - When to Use Each Methodology',
|
||||
programmeId: 'prog-004',
|
||||
programmeName: 'Project Management Certification',
|
||||
lastReplyAt: new Date('2024-12-27T09:20:00'),
|
||||
lastAuthor: 'Michael Rodriguez',
|
||||
unread: false,
|
||||
href: '/discussions/agile-vs-waterfall',
|
||||
replyCount: 25,
|
||||
participantCount: 19
|
||||
},
|
||||
{
|
||||
threadId: 'thread-005',
|
||||
title: 'Building Trust in Virtual Teams',
|
||||
programmeId: 'prog-001',
|
||||
programmeName: 'Leadership Development',
|
||||
lastReplyAt: new Date('2024-12-26T16:30:00'),
|
||||
lastAuthor: 'Lisa Wang',
|
||||
unread: true,
|
||||
href: '/discussions/trust-virtual-teams',
|
||||
replyCount: 9,
|
||||
participantCount: 7
|
||||
},
|
||||
{
|
||||
threadId: 'thread-006',
|
||||
title: 'Code Review Best Practices - Peer Learning',
|
||||
programmeId: 'prog-002',
|
||||
programmeName: 'Technical Skills Bootcamp',
|
||||
lastReplyAt: new Date('2024-12-26T14:10:00'),
|
||||
lastAuthor: 'James Wilson',
|
||||
unread: false,
|
||||
href: '/discussions/code-review-practices',
|
||||
replyCount: 14,
|
||||
participantCount: 11
|
||||
}
|
||||
];
|
||||
|
||||
const programmeColors = {
|
||||
'prog-001': '#04045B',
|
||||
'prog-002': '#F8C301',
|
||||
'prog-003': '#21a36a',
|
||||
'prog-004': '#89002D'
|
||||
};
|
||||
|
||||
export const DiscussionForumFeed: React.FC<DiscussionForumFeedProps> = ({
|
||||
forumFeed = mockForumFeed,
|
||||
onOpenThread,
|
||||
onMarkAsRead
|
||||
}) => {
|
||||
// Only show unread threads by default to make it concise
|
||||
const unreadThreads = forumFeed.filter(thread => thread.unread).slice(0, 5);
|
||||
|
||||
const formatTimeAgo = (date: Date) => {
|
||||
const now = new Date();
|
||||
const diffHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
|
||||
|
||||
if (diffHours < 1) {
|
||||
return 'Just now';
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours}h ago`;
|
||||
} else {
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenThread = (thread: ForumThread) => {
|
||||
onOpenThread?.(thread.threadId);
|
||||
console.log(`Opening thread: ${thread.title}`);
|
||||
};
|
||||
|
||||
const handleMarkAsRead = (threadId: string, event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onMarkAsRead?.(threadId);
|
||||
console.log(`Marked thread as read: ${threadId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Discussion Forums
|
||||
{unreadThreads.length > 0 && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{unreadThreads.length} new
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>Recent activity from programme discussions</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{unreadThreads.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<MessageSquare className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm">No new discussion activity</p>
|
||||
</div>
|
||||
) : (
|
||||
unreadThreads.map((thread) => (
|
||||
<div
|
||||
key={thread.threadId}
|
||||
className="flex items-center justify-between p-3 border rounded-lg cursor-pointer transition-colors min-tap-44 bg-blue-50 border-blue-200 hover:bg-blue-100"
|
||||
onClick={() => handleOpenThread(thread)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Open discussion: ${thread.title}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleOpenThread(thread);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-sm text-blue-900 truncate">
|
||||
{thread.title}
|
||||
</h4>
|
||||
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-800 flex-shrink-0">
|
||||
New
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="truncate">{thread.programmeName}</span>
|
||||
<span>•</span>
|
||||
<span>{formatTimeAgo(thread.lastReplyAt)}</span>
|
||||
<span>•</span>
|
||||
<span>by {thread.lastAuthor}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => handleMarkAsRead(thread.threadId, e)}
|
||||
className="min-tap-44 ml-2"
|
||||
aria-label={`Mark "${thread.title}" as read`}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
502
src/components/LearningAnalyticsTable.tsx
Normal file
502
src/components/LearningAnalyticsTable.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
||||
import { Progress } from './ui/progress';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from './ui/sheet';
|
||||
import {
|
||||
Eye,
|
||||
Send,
|
||||
BarChart3,
|
||||
User,
|
||||
BookOpen,
|
||||
Clock,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
|
||||
interface CurrentItem {
|
||||
type: 'course' | 'content';
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'Not Started' | 'In-Progress' | 'Completed';
|
||||
}
|
||||
|
||||
interface NextItem {
|
||||
type: 'course' | 'content';
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface LearnerAnalytics {
|
||||
learnerId: string;
|
||||
learnerName: string;
|
||||
learnerEmail: string;
|
||||
currentItem?: CurrentItem;
|
||||
progressPct: number;
|
||||
nextItem?: NextItem;
|
||||
lastActivity: Date;
|
||||
}
|
||||
|
||||
interface LearningAnalyticsData {
|
||||
programmeId: string;
|
||||
rows: LearnerAnalytics[];
|
||||
}
|
||||
|
||||
interface LearningAnalyticsTableProps {
|
||||
analyticsData?: LearningAnalyticsData[];
|
||||
onViewLearner?: (learnerId: string) => void;
|
||||
onNudgeLearner?: (learnerId: string) => void;
|
||||
onViewAllAnalytics?: (programmeId: string) => void;
|
||||
}
|
||||
|
||||
const mockAnalyticsData: LearningAnalyticsData[] = [
|
||||
{
|
||||
programmeId: 'prog-001',
|
||||
rows: [
|
||||
{
|
||||
learnerId: 'learner-001',
|
||||
learnerName: 'Sarah Chen',
|
||||
learnerEmail: 'sarah.chen@company.com',
|
||||
currentItem: {
|
||||
type: 'course',
|
||||
id: 'course-001',
|
||||
title: 'Strategic Thinking',
|
||||
status: 'In-Progress'
|
||||
},
|
||||
progressPct: 85,
|
||||
nextItem: {
|
||||
type: 'course',
|
||||
id: 'course-002',
|
||||
title: 'Decision Making'
|
||||
},
|
||||
lastActivity: new Date('2024-12-27T14:30:00')
|
||||
},
|
||||
{
|
||||
learnerId: 'learner-002',
|
||||
learnerName: 'Michael Rodriguez',
|
||||
learnerEmail: 'michael.r@company.com',
|
||||
currentItem: {
|
||||
type: 'content',
|
||||
id: 'content-003',
|
||||
title: 'Leadership Styles Assessment',
|
||||
status: 'Not Started'
|
||||
},
|
||||
progressPct: 62,
|
||||
nextItem: {
|
||||
type: 'course',
|
||||
id: 'course-003',
|
||||
title: 'Team Management'
|
||||
},
|
||||
lastActivity: new Date('2024-12-26T09:15:00')
|
||||
},
|
||||
{
|
||||
learnerId: 'learner-003',
|
||||
learnerName: 'Emma Thompson',
|
||||
learnerEmail: 'emma.thompson@company.com',
|
||||
currentItem: {
|
||||
type: 'course',
|
||||
id: 'course-002',
|
||||
title: 'Decision Making',
|
||||
status: 'Completed'
|
||||
},
|
||||
progressPct: 94,
|
||||
nextItem: {
|
||||
type: 'content',
|
||||
id: 'content-005',
|
||||
title: 'Leadership Reflection Journal'
|
||||
},
|
||||
lastActivity: new Date('2024-12-27T16:45:00')
|
||||
},
|
||||
{
|
||||
learnerId: 'learner-004',
|
||||
learnerName: 'David Kim',
|
||||
learnerEmail: 'david.kim@company.com',
|
||||
currentItem: {
|
||||
type: 'course',
|
||||
id: 'course-001',
|
||||
title: 'Strategic Thinking',
|
||||
status: 'In-Progress'
|
||||
},
|
||||
progressPct: 78,
|
||||
nextItem: {
|
||||
type: 'course',
|
||||
id: 'course-002',
|
||||
title: 'Decision Making'
|
||||
},
|
||||
lastActivity: new Date('2024-12-27T11:20:00')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-002',
|
||||
rows: [
|
||||
{
|
||||
learnerId: 'learner-005',
|
||||
learnerName: 'Lisa Wang',
|
||||
learnerEmail: 'lisa.wang@company.com',
|
||||
currentItem: {
|
||||
type: 'course',
|
||||
id: 'course-101',
|
||||
title: 'JavaScript Fundamentals',
|
||||
status: 'In-Progress'
|
||||
},
|
||||
progressPct: 56,
|
||||
nextItem: {
|
||||
type: 'course',
|
||||
id: 'course-102',
|
||||
title: 'React Basics'
|
||||
},
|
||||
lastActivity: new Date('2024-12-27T13:10:00')
|
||||
},
|
||||
{
|
||||
learnerId: 'learner-006',
|
||||
learnerName: 'James Wilson',
|
||||
learnerEmail: 'james.wilson@company.com',
|
||||
currentItem: {
|
||||
type: 'content',
|
||||
id: 'content-201',
|
||||
title: 'API Design Best Practices',
|
||||
status: 'Not Started'
|
||||
},
|
||||
progressPct: 34,
|
||||
nextItem: {
|
||||
type: 'course',
|
||||
id: 'course-103',
|
||||
title: 'Database Design'
|
||||
},
|
||||
lastActivity: new Date('2024-12-25T15:30:00')
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const programmeNames = {
|
||||
'prog-001': 'Leadership Development',
|
||||
'prog-002': 'Technical Skills Bootcamp',
|
||||
'prog-003': 'Communication Excellence',
|
||||
'prog-004': 'Project Management Certification'
|
||||
};
|
||||
|
||||
export const LearningAnalyticsTable: React.FC<LearningAnalyticsTableProps> = ({
|
||||
analyticsData = mockAnalyticsData,
|
||||
onViewLearner,
|
||||
onNudgeLearner,
|
||||
onViewAllAnalytics
|
||||
}) => {
|
||||
const [selectedProgramme, setSelectedProgramme] = useState(analyticsData[0]?.programmeId || '');
|
||||
const [selectedLearner, setSelectedLearner] = useState<LearnerAnalytics | null>(null);
|
||||
const [isLearnerDrawerOpen, setIsLearnerDrawerOpen] = useState(false);
|
||||
|
||||
const currentProgrammeData = analyticsData.find(data => data.programmeId === selectedProgramme);
|
||||
const currentProgrammeName = programmeNames[selectedProgramme as keyof typeof programmeNames] || 'Unknown Programme';
|
||||
|
||||
const formatLastActivity = (date: Date) => {
|
||||
const now = new Date();
|
||||
const diffHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
|
||||
|
||||
if (diffHours < 1) {
|
||||
return 'Just now';
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours} hours ago`;
|
||||
} else {
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeProps = (status: CurrentItem['status']) => {
|
||||
switch (status) {
|
||||
case 'Completed':
|
||||
return { variant: 'default' as const, className: 'bg-status-success text-status-success-foreground' };
|
||||
case 'In-Progress':
|
||||
return { variant: 'secondary' as const, className: 'bg-status-warn text-status-warn-foreground' };
|
||||
case 'Not Started':
|
||||
return { variant: 'outline' as const, className: 'border-status-error text-status-error' };
|
||||
default:
|
||||
return { variant: 'secondary' as const };
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewLearner = (learner: LearnerAnalytics) => {
|
||||
setSelectedLearner(learner);
|
||||
setIsLearnerDrawerOpen(true);
|
||||
onViewLearner?.(learner.learnerId);
|
||||
};
|
||||
|
||||
const handleNudgeLearner = (learnerId: string) => {
|
||||
onNudgeLearner?.(learnerId);
|
||||
console.log(`Sent nudge to learner: ${learnerId}`);
|
||||
};
|
||||
|
||||
const handleViewAllAnalytics = () => {
|
||||
onViewAllAnalytics?.(selectedProgramme);
|
||||
console.log(`Viewing all analytics for programme: ${selectedProgramme}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Learning Analytics</CardTitle>
|
||||
<CardDescription>Per-programme learner progress and current activities</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedProgramme} onValueChange={setSelectedProgramme}>
|
||||
<SelectTrigger className="w-[250px]">
|
||||
<SelectValue placeholder="Select programme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{analyticsData.map((data) => (
|
||||
<SelectItem key={data.programmeId} value={data.programmeId}>
|
||||
{programmeNames[data.programmeId as keyof typeof programmeNames] || data.programmeId}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleViewAllAnalytics}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
View All in Analytics
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Programme Summary */}
|
||||
<div className="flex items-center justify-between p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-medium">{currentProgrammeName}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentProgrammeData?.rows.length || 0} learners enrolled
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold">
|
||||
{currentProgrammeData ? Math.round(
|
||||
currentProgrammeData.rows.reduce((sum, learner) => sum + learner.progressPct, 0) /
|
||||
currentProgrammeData.rows.length
|
||||
) : 0}%
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Average Progress</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader className="sticky-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">Learner</TableHead>
|
||||
<TableHead className="w-[250px]">Current Item</TableHead>
|
||||
<TableHead className="w-[120px]">Progress</TableHead>
|
||||
<TableHead className="w-[200px]">Next Item</TableHead>
|
||||
<TableHead className="w-[120px]">Last Activity</TableHead>
|
||||
<TableHead className="w-[120px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentProgrammeData?.rows.map((learner) => (
|
||||
<TableRow key={learner.learnerId} className="min-h-[44px]">
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{learner.learnerName}</p>
|
||||
<p className="text-sm text-muted-foreground">{learner.learnerEmail}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{learner.currentItem ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{learner.currentItem.type === 'course' ? (
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<div className="h-4 w-4 bg-muted-foreground rounded-sm" />
|
||||
)}
|
||||
<span className="text-sm font-medium">{learner.currentItem.title}</span>
|
||||
</div>
|
||||
<Badge
|
||||
{...getStatusBadgeProps(learner.currentItem.status)}
|
||||
className="text-xs"
|
||||
>
|
||||
{learner.currentItem.status}
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">No current item</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={learner.progressPct}
|
||||
className="w-16"
|
||||
aria-describedby={`progress-${learner.learnerId}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">{learner.progressPct}%</span>
|
||||
</div>
|
||||
<span id={`progress-${learner.learnerId}`} className="sr-only">
|
||||
Progress: {learner.progressPct} percent complete
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{learner.nextItem ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{learner.nextItem.type === 'course' ? (
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<div className="h-4 w-4 bg-muted-foreground rounded-sm" />
|
||||
)}
|
||||
<span className="text-sm">{learner.nextItem.title}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">Programme complete</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{formatLastActivity(learner.lastActivity)}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewLearner(learner)}
|
||||
className="min-tap-44"
|
||||
aria-label={`View details for ${learner.learnerName}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleNudgeLearner(learner.learnerId)}
|
||||
className="min-tap-44"
|
||||
aria-label={`Send reminder to ${learner.learnerName}`}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)) || (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||
No learner data available for this programme.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Learner Detail Drawer */}
|
||||
<Sheet open={isLearnerDrawerOpen} onOpenChange={setIsLearnerDrawerOpen}>
|
||||
<SheetContent
|
||||
className="w-[500px] sm:w-[600px]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="learner-detail-title"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle id="learner-detail-title">
|
||||
{selectedLearner?.learnerName}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Detailed progress and activity in {currentProgrammeName}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{selectedLearner && (
|
||||
<div className="mt-6 space-y-6">
|
||||
{/* Contact Info */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Contact Information</h4>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">{selectedLearner.learnerEmail}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last active: {formatLastActivity(selectedLearner.lastActivity)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Summary */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Progress Summary</h4>
|
||||
<div className="p-4 bg-muted/30 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm">Overall Progress</span>
|
||||
<span className="font-medium">{selectedLearner.progressPct}%</span>
|
||||
</div>
|
||||
<Progress value={selectedLearner.progressPct} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Activity */}
|
||||
{selectedLearner.currentItem && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Current Activity</h4>
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{selectedLearner.currentItem.type === 'course' ? (
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<div className="h-4 w-4 bg-muted-foreground rounded-sm" />
|
||||
)}
|
||||
<span className="font-medium">{selectedLearner.currentItem.title}</span>
|
||||
</div>
|
||||
<Badge {...getStatusBadgeProps(selectedLearner.currentItem.status)}>
|
||||
{selectedLearner.currentItem.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next Up */}
|
||||
{selectedLearner.nextItem && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Next Up</h4>
|
||||
<div className="p-4 border border-dashed rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedLearner.nextItem.type === 'course' ? (
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<div className="h-4 w-4 bg-muted-foreground rounded-sm" />
|
||||
)}
|
||||
<span>{selectedLearner.nextItem.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button className="flex-1">
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Send Reminder
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1">
|
||||
<TrendingUp className="h-4 w-4 mr-2" />
|
||||
View Full Analytics
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
445
src/components/ProgrammeCalendar.tsx
Normal file
445
src/components/ProgrammeCalendar.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Video,
|
||||
School,
|
||||
Clock,
|
||||
Filter
|
||||
} from 'lucide-react';
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
programmeId: string;
|
||||
type: 'webinar' | 'class' | 'course_end' | 'content_end' | 'programme_end';
|
||||
title: string;
|
||||
start: Date;
|
||||
end?: Date;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface ProgrammeLegend {
|
||||
programmeId: string;
|
||||
programmeName: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface CalendarData {
|
||||
legend: ProgrammeLegend[];
|
||||
events: CalendarEvent[];
|
||||
}
|
||||
|
||||
interface ProgrammeCalendarProps {
|
||||
calendarData?: CalendarData;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
const mockCalendarData: CalendarData = {
|
||||
legend: [
|
||||
{ programmeId: 'prog-001', programmeName: 'Leadership Development', color: '#04045B' },
|
||||
{ programmeId: 'prog-002', programmeName: 'Technical Skills', color: '#F8C301' },
|
||||
{ programmeId: 'prog-003', programmeName: 'Communication', color: '#21a36a' },
|
||||
{ programmeId: 'prog-004', programmeName: 'Project Management', color: '#89002D' },
|
||||
{ programmeId: 'prog-005', programmeName: 'Sales Training', color: '#C0C0C0' },
|
||||
{ programmeId: 'prog-006', programmeName: 'Analytics Program', color: '#6366f1' }
|
||||
],
|
||||
events: [
|
||||
{
|
||||
id: 'event-001',
|
||||
programmeId: 'prog-001',
|
||||
type: 'webinar',
|
||||
title: 'Leadership Fundamentals Webinar',
|
||||
start: new Date('2024-12-28T14:00:00'),
|
||||
end: new Date('2024-12-28T15:30:00'),
|
||||
href: '/webinars/leadership-fundamentals'
|
||||
},
|
||||
{
|
||||
id: 'event-002',
|
||||
programmeId: 'prog-002',
|
||||
type: 'class',
|
||||
title: 'Hands-on Coding Workshop',
|
||||
start: new Date('2024-12-30T09:00:00'),
|
||||
end: new Date('2024-12-30T17:00:00'),
|
||||
href: '/classes/coding-workshop'
|
||||
},
|
||||
{
|
||||
id: 'event-003',
|
||||
programmeId: 'prog-001',
|
||||
type: 'course_end',
|
||||
title: 'Strategic Thinking Course Due',
|
||||
start: new Date('2025-01-02T23:59:00'),
|
||||
href: '/courses/strategic-thinking'
|
||||
},
|
||||
{
|
||||
id: 'event-004',
|
||||
programmeId: 'prog-003',
|
||||
type: 'webinar',
|
||||
title: 'Public Speaking Masterclass',
|
||||
start: new Date('2025-01-05T11:00:00'),
|
||||
end: new Date('2025-01-05T12:30:00'),
|
||||
href: '/webinars/public-speaking'
|
||||
},
|
||||
{
|
||||
id: 'event-005',
|
||||
programmeId: 'prog-004',
|
||||
type: 'programme_end',
|
||||
title: 'Project Management Certification Due',
|
||||
start: new Date('2025-01-10T23:59:00'),
|
||||
href: '/programmes/project-management'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const ProgrammeCalendar: React.FC<ProgrammeCalendarProps> = ({
|
||||
calendarData = mockCalendarData,
|
||||
onEventClick
|
||||
}) => {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [viewMode, setViewMode] = useState<'month' | 'week'>('month');
|
||||
const [selectedProgrammes, setSelectedProgrammes] = useState<string[]>(
|
||||
calendarData.legend.slice(0, 6).map(p => p.programmeId)
|
||||
);
|
||||
const [selectedEventTypes, setSelectedEventTypes] = useState<string[]>([
|
||||
'webinar', 'class', 'course_end', 'content_end', 'programme_end'
|
||||
]);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const visibleLegend = calendarData.legend.slice(0, 6);
|
||||
const additionalProgrammes = calendarData.legend.length - 6;
|
||||
|
||||
const getEventIcon = (type: CalendarEvent['type']) => {
|
||||
switch (type) {
|
||||
case 'webinar':
|
||||
return <Video className="h-3 w-3" />;
|
||||
case 'class':
|
||||
return <School className="h-3 w-3" />;
|
||||
default:
|
||||
return <Clock className="h-3 w-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getEventTypeLabel = (type: CalendarEvent['type']) => {
|
||||
switch (type) {
|
||||
case 'webinar':
|
||||
return 'Webinar';
|
||||
case 'class':
|
||||
return 'Offline Class';
|
||||
case 'course_end':
|
||||
return 'Course End';
|
||||
case 'content_end':
|
||||
return 'Content End';
|
||||
case 'programme_end':
|
||||
return 'Programme End';
|
||||
default:
|
||||
return 'Event';
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEvents = calendarData.events.filter(event =>
|
||||
selectedProgrammes.includes(event.programmeId) &&
|
||||
selectedEventTypes.includes(event.type)
|
||||
);
|
||||
|
||||
const formatEventTime = (start: Date, end?: Date) => {
|
||||
const startTime = start.toLocaleTimeString('en-AU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
|
||||
if (end) {
|
||||
const endTime = end.toLocaleTimeString('en-AU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
return `${startTime} - ${endTime}`;
|
||||
}
|
||||
|
||||
return `Due ${startTime}`;
|
||||
};
|
||||
|
||||
const getProgrammeColor = (programmeId: string) => {
|
||||
const programme = calendarData.legend.find(p => p.programmeId === programmeId);
|
||||
return programme?.color || '#6b7280';
|
||||
};
|
||||
|
||||
const navigateMonth = (direction: 'prev' | 'next') => {
|
||||
setCurrentDate(prev => {
|
||||
const newDate = new Date(prev);
|
||||
if (direction === 'prev') {
|
||||
newDate.setMonth(prev.getMonth() - 1);
|
||||
} else {
|
||||
newDate.setMonth(prev.getMonth() + 1);
|
||||
}
|
||||
return newDate;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleProgrammeFilter = (programmeId: string) => {
|
||||
setSelectedProgrammes(prev =>
|
||||
prev.includes(programmeId)
|
||||
? prev.filter(id => id !== programmeId)
|
||||
: [...prev, programmeId]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleEventTypeFilter = (eventType: string) => {
|
||||
setSelectedEventTypes(prev =>
|
||||
prev.includes(eventType)
|
||||
? prev.filter(type => type !== eventType)
|
||||
: [...prev, eventType]
|
||||
);
|
||||
};
|
||||
|
||||
const handleEventClick = (event: CalendarEvent) => {
|
||||
onEventClick?.(event);
|
||||
console.log(`Clicked event: ${event.title}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Programme Schedule</CardTitle>
|
||||
<CardDescription>Webinars, classes, and important deadlines</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="min-tap-44"
|
||||
aria-label="Toggle filters"
|
||||
>
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filters
|
||||
</Button>
|
||||
<div className="flex border rounded-md">
|
||||
<Button
|
||||
variant={viewMode === 'month' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('month')}
|
||||
className="rounded-r-none"
|
||||
>
|
||||
Month
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'week' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('week')}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
Week
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateMonth('prev')}
|
||||
className="min-tap-44"
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{currentDate.toLocaleDateString('en-AU', {
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateMonth('next')}
|
||||
className="min-tap-44"
|
||||
aria-label="Next month"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentDate(new Date())}
|
||||
className="min-tap-44"
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Programme Legend */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Programmes</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visibleLegend.map((programme) => (
|
||||
<div
|
||||
key={programme.programmeId}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: programme.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm">{programme.programmeName}</span>
|
||||
</div>
|
||||
))}
|
||||
{additionalProgrammes > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{additionalProgrammes} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters Panel */}
|
||||
{showFilters && (
|
||||
<div className="border rounded-lg p-4 space-y-4 bg-muted/30">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Filter by Programme</h4>
|
||||
<div className="space-y-2">
|
||||
{calendarData.legend.map((programme) => (
|
||||
<div key={programme.programmeId} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`prog-${programme.programmeId}`}
|
||||
checked={selectedProgrammes.includes(programme.programmeId)}
|
||||
onCheckedChange={() => toggleProgrammeFilter(programme.programmeId)}
|
||||
className="min-tap-44"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`prog-${programme.programmeId}`}
|
||||
className="text-sm font-medium cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: programme.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{programme.programmeName}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Filter by Type</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ value: 'webinar', label: 'Webinars' },
|
||||
{ value: 'class', label: 'Offline Classes' },
|
||||
{ value: 'course_end', label: 'Course End Dates' },
|
||||
{ value: 'content_end', label: 'Content End Dates' },
|
||||
{ value: 'programme_end', label: 'Programme End Dates' }
|
||||
].map((eventType) => (
|
||||
<div key={eventType.value} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`type-${eventType.value}`}
|
||||
checked={selectedEventTypes.includes(eventType.value)}
|
||||
onCheckedChange={() => toggleEventTypeFilter(eventType.value)}
|
||||
className="min-tap-44"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`type-${eventType.value}`}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{eventType.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/* Calendar Grid */}
|
||||
<div
|
||||
className="border rounded-lg p-4 min-h-[400px]"
|
||||
role="grid"
|
||||
aria-label={`Calendar for ${currentDate.toLocaleDateString('en-AU', { month: 'long', year: 'numeric' })}`}
|
||||
>
|
||||
{/* Events List View (simplified for demo) */}
|
||||
<div className="space-y-3">
|
||||
{filteredEvents.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<CalendarIcon className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p>No events found for the selected filters.</p>
|
||||
<p className="text-sm mt-1">Try adjusting your programme or event type filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredEvents.map((event) => {
|
||||
const programme = calendarData.legend.find(p => p.programmeId === event.programmeId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 cursor-pointer transition-colors min-tap-44"
|
||||
onClick={() => handleEventClick(event)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${event.title} on ${event.start.toLocaleDateString()}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleEventClick(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex items-center justify-center text-white"
|
||||
style={{ backgroundColor: getProgrammeColor(event.programmeId) }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{getEventIcon(event.type)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{event.title}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getEventTypeLabel(event.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{programme?.programmeName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">
|
||||
{event.start.toLocaleDateString('en-AU', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatEventTime(event.start, event.end)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
902
src/components/ProgrammeHRView.tsx
Normal file
902
src/components/ProgrammeHRView.tsx
Normal file
@@ -0,0 +1,902 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Input } from './ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from './ui/sheet';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
||||
import { Progress } from './ui/progress';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './ui/accordion';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
BarChart3,
|
||||
MoreHorizontal,
|
||||
Users,
|
||||
Calendar,
|
||||
Clock,
|
||||
BookOpen,
|
||||
Award,
|
||||
Bell,
|
||||
Search,
|
||||
Filter,
|
||||
Eye,
|
||||
Mail,
|
||||
FileText,
|
||||
Play,
|
||||
MapPin,
|
||||
Video,
|
||||
FileQuestion,
|
||||
Activity,
|
||||
Building2,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
Plus,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
XCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
interface Programme {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'Active' | 'Upcoming' | 'Completed';
|
||||
code: string;
|
||||
owner: string;
|
||||
version: number;
|
||||
description: string;
|
||||
goals: string[];
|
||||
tags: string[];
|
||||
structure: {
|
||||
preAssessment: ProgrammeItem[];
|
||||
preLearning: ProgrammeItem[];
|
||||
classroom: ProgrammeItem[];
|
||||
postLearning: ProgrammeItem[];
|
||||
};
|
||||
}
|
||||
|
||||
interface ProgrammeItem {
|
||||
id: string;
|
||||
type: 'Profiler' | 'Course' | 'Content' | 'Webinar' | 'OfflineSession';
|
||||
title: string;
|
||||
duration?: string;
|
||||
dueDate?: string;
|
||||
venue?: string;
|
||||
room?: string;
|
||||
date?: string;
|
||||
capacity?: number;
|
||||
status?: 'Not Started' | 'In Progress' | 'Completed';
|
||||
}
|
||||
|
||||
interface Assignment {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
orgId: string;
|
||||
orgName: string;
|
||||
}
|
||||
|
||||
interface ProgrammeCounts {
|
||||
learners: number;
|
||||
completionPct: number;
|
||||
courses: number;
|
||||
content: number;
|
||||
webinars: number;
|
||||
classes: number;
|
||||
}
|
||||
|
||||
interface ProgrammeLearner {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
currentItem: {
|
||||
type: string;
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'Not Started' | 'In-Progress' | 'Completed';
|
||||
};
|
||||
progressPct: number;
|
||||
nextItem?: {
|
||||
type: string;
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
lastActivity: string;
|
||||
stage: 'Pre-assessment' | 'Pre-learning' | 'Classroom' | 'Post-learning';
|
||||
cohort?: string;
|
||||
}
|
||||
|
||||
interface ProgrammeEvent {
|
||||
id: string;
|
||||
programmeId: string;
|
||||
type: 'webinar' | 'class' | 'course_end' | 'content_end' | 'programme_end';
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
venue?: string;
|
||||
room?: string;
|
||||
}
|
||||
|
||||
interface ProgrammeHRViewProps {
|
||||
programmeId: string;
|
||||
onBack: () => void;
|
||||
onAssignLearners: (programmeId: string) => void;
|
||||
onDownloadTracker: (programmeId: string) => void;
|
||||
onOpenAnalytics: (programmeId: string) => void;
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockProgramme: Programme = {
|
||||
id: 'prg_123',
|
||||
title: 'Executive Leadership Development Programme',
|
||||
status: 'Active',
|
||||
code: 'ELDP-2024',
|
||||
owner: 'Dr. Sarah Johnson',
|
||||
version: 2,
|
||||
description: 'A comprehensive leadership development programme designed to build strategic thinking, emotional intelligence, and decision-making capabilities for senior executives.',
|
||||
goals: [
|
||||
'Develop strategic thinking and planning capabilities',
|
||||
'Enhance emotional intelligence and self-awareness',
|
||||
'Build effective communication and influence skills',
|
||||
'Master change management and innovation leadership'
|
||||
],
|
||||
tags: ['Leadership', 'Executive', 'Strategic Thinking', 'Management'],
|
||||
structure: {
|
||||
preAssessment: [
|
||||
{ id: 'pa1', type: 'Profiler', title: 'Leadership Style Assessment', status: 'Completed' },
|
||||
{ id: 'pa2', type: 'Profiler', title: '360-Degree Feedback', status: 'In Progress' }
|
||||
],
|
||||
preLearning: [
|
||||
{ id: 'pl1', type: 'Course', title: 'Strategic Thinking Fundamentals', duration: '4 hours', dueDate: '2024-01-15', status: 'Completed' },
|
||||
{ id: 'pl2', type: 'Content', title: 'Leadership in Crisis Webcast', duration: '45 mins', status: 'In Progress' },
|
||||
{ id: 'pl3', type: 'Webinar', title: 'Future of Leadership', date: '2024-01-20 10:00 AM AEDT', status: 'Not Started' }
|
||||
],
|
||||
classroom: [
|
||||
{ id: 'c1', type: 'OfflineSession', title: 'Strategic Leadership Workshop', venue: 'Sydney Campus', room: 'Executive Suite A', date: '2024-02-05', capacity: 20 },
|
||||
{ id: 'c2', type: 'OfflineSession', title: 'Case Study Analysis', venue: 'Sydney Campus', room: 'Conference Room B', date: '2024-02-06', capacity: 20 }
|
||||
],
|
||||
postLearning: [
|
||||
{ id: 'po1', type: 'Course', title: 'Advanced Decision Making', duration: '6 hours', dueDate: '2024-02-20', status: 'Not Started' },
|
||||
{ id: 'po2', type: 'Content', title: 'Leadership Reflection Journal', duration: '2 weeks', status: 'Not Started' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const mockAssignment: Assignment = {
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-03-31',
|
||||
orgId: 'org_123',
|
||||
orgName: 'Tech Solutions Pvt Ltd'
|
||||
};
|
||||
|
||||
const mockCounts: ProgrammeCounts = {
|
||||
learners: 28,
|
||||
completionPct: 64,
|
||||
courses: 2,
|
||||
content: 2,
|
||||
webinars: 1,
|
||||
classes: 2
|
||||
};
|
||||
|
||||
const mockLearners: ProgrammeLearner[] = [
|
||||
{
|
||||
id: 'l1',
|
||||
name: 'Sarah Chen',
|
||||
email: 'sarah.chen@company.com',
|
||||
currentItem: { type: 'Course', id: 'pl1', title: 'Strategic Thinking Fundamentals', status: 'In-Progress' },
|
||||
progressPct: 75,
|
||||
nextItem: { type: 'Content', id: 'pl2', title: 'Leadership in Crisis Webcast' },
|
||||
lastActivity: '2 hours ago',
|
||||
stage: 'Pre-learning',
|
||||
cohort: 'Cohort A'
|
||||
},
|
||||
{
|
||||
id: 'l2',
|
||||
name: 'Michael Rodriguez',
|
||||
email: 'michael.r@company.com',
|
||||
currentItem: { type: 'Profiler', id: 'pa2', title: '360-Degree Feedback', status: 'In-Progress' },
|
||||
progressPct: 45,
|
||||
nextItem: { type: 'Course', id: 'pl1', title: 'Strategic Thinking Fundamentals' },
|
||||
lastActivity: '1 day ago',
|
||||
stage: 'Pre-assessment',
|
||||
cohort: 'Cohort A'
|
||||
},
|
||||
{
|
||||
id: 'l3',
|
||||
name: 'Emma Thompson',
|
||||
email: 'emma.thompson@company.com',
|
||||
currentItem: { type: 'OfflineSession', id: 'c1', title: 'Strategic Leadership Workshop', status: 'Completed' },
|
||||
progressPct: 89,
|
||||
nextItem: { type: 'Course', id: 'po1', title: 'Advanced Decision Making' },
|
||||
lastActivity: '3 hours ago',
|
||||
stage: 'Classroom',
|
||||
cohort: 'Cohort B'
|
||||
}
|
||||
];
|
||||
|
||||
const mockEvents: ProgrammeEvent[] = [
|
||||
{
|
||||
id: 'e1',
|
||||
programmeId: 'prg_123',
|
||||
type: 'webinar',
|
||||
title: 'Future of Leadership',
|
||||
start: '2024-01-20T10:00:00',
|
||||
end: '2024-01-20T11:30:00'
|
||||
},
|
||||
{
|
||||
id: 'e2',
|
||||
programmeId: 'prg_123',
|
||||
type: 'class',
|
||||
title: 'Strategic Leadership Workshop',
|
||||
start: '2024-02-05T09:00:00',
|
||||
end: '2024-02-05T17:00:00',
|
||||
venue: 'Sydney Campus',
|
||||
room: 'Executive Suite A'
|
||||
}
|
||||
];
|
||||
|
||||
export const ProgrammeHRView: React.FC<ProgrammeHRViewProps> = ({
|
||||
programmeId,
|
||||
onBack,
|
||||
onAssignLearners,
|
||||
onDownloadTracker,
|
||||
onOpenAnalytics
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [stageFilter, setStageFilter] = useState('all');
|
||||
const [cohortFilter, setCohortFilter] = useState('all');
|
||||
const [selectedLearner, setSelectedLearner] = useState<ProgrammeLearner | null>(null);
|
||||
const [showLearnerDrawer, setShowLearnerDrawer] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
// Simulate loading
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setLoading(false), 800);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'Course': return <BookOpen className="h-4 w-4" />;
|
||||
case 'Content': return <FileText className="h-4 w-4" />;
|
||||
case 'Webinar': return <Video className="h-4 w-4" />;
|
||||
case 'Profiler': return <FileQuestion className="h-4 w-4" />;
|
||||
case 'OfflineSession': return <Building2 className="h-4 w-4" />;
|
||||
default: return <BookOpen className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'Completed':
|
||||
return <Badge variant="default" className="bg-status-success text-white"><CheckCircle className="h-3 w-3 mr-1" />Completed</Badge>;
|
||||
case 'In Progress':
|
||||
return <Badge variant="secondary" className="bg-status-warn text-black"><AlertCircle className="h-3 w-3 mr-1" />In Progress</Badge>;
|
||||
case 'Not Started':
|
||||
return <Badge variant="outline"><XCircle className="h-3 w-3 mr-1" />Not Started</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLearners = mockLearners.filter(learner => {
|
||||
const matchesSearch = learner.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
learner.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || learner.currentItem.status === statusFilter;
|
||||
const matchesStage = stageFilter === 'all' || learner.stage === stageFilter;
|
||||
const matchesCohort = cohortFilter === 'all' || learner.cohort === cohortFilter;
|
||||
return matchesSearch && matchesStatus && matchesStage && matchesCohort;
|
||||
});
|
||||
|
||||
const handleViewLearner = (learner: ProgrammeLearner) => {
|
||||
setSelectedLearner(learner);
|
||||
setShowLearnerDrawer(true);
|
||||
};
|
||||
|
||||
const handleExport = async (format: 'excel' | 'csv' | 'pdf') => {
|
||||
setExporting(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setExporting(false);
|
||||
console.log(`Exported programme tracker as ${format.toUpperCase()}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div className="h-16 bg-muted rounded-lg"></div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-24 bg-muted rounded-lg"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-96 bg-muted rounded-lg"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<Card className="sticky top-0 z-20 bg-background border-b shadow-sm">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
className="min-tap-44"
|
||||
aria-label="Go back to programmes list"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h1 className="text-2xl font-bold">{mockProgramme.title}</h1>
|
||||
<Badge
|
||||
variant={mockProgramme.status === 'Active' ? 'default' : 'secondary'}
|
||||
className={mockProgramme.status === 'Active' ? 'bg-status-success' : ''}
|
||||
>
|
||||
{mockProgramme.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{mockProgramme.code} • {mockProgramme.owner} • Version {mockProgramme.version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => onAssignLearners(programmeId)}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Assign Learners
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onDownloadTracker(programmeId)}
|
||||
disabled={exporting}
|
||||
className="min-tap-44"
|
||||
>
|
||||
{exporting ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Download Tracker
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenAnalytics(programmeId)}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
Open Analytics
|
||||
</Button>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-tap-44"
|
||||
aria-label="More actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Programme Actions</DialogTitle>
|
||||
<DialogDescription>Additional actions for this programme</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
View Structure JSON
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Audit Trail
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Assignment Window</p>
|
||||
<p className="font-semibold">{new Date(mockAssignment.startDate).toLocaleDateString()} → {new Date(mockAssignment.endDate).toLocaleDateString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{mockAssignment.orgName}</p>
|
||||
</div>
|
||||
<Calendar className="h-8 w-8 text-brand-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Enrolled Learners</p>
|
||||
<p className="text-2xl font-bold">{mockCounts.learners}</p>
|
||||
<Button variant="link" className="p-0 h-auto text-xs">Manage</Button>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-brand-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Completion Rate</p>
|
||||
<p className="text-2xl font-bold">{mockCounts.completionPct}%</p>
|
||||
<Progress value={mockCounts.completionPct} className="w-16 mt-1" />
|
||||
</div>
|
||||
<Award className="h-8 w-8 text-brand-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Items in Programme</p>
|
||||
<div className="flex gap-2 text-sm">
|
||||
<span>{mockCounts.courses} Courses</span>
|
||||
<span>•</span>
|
||||
<span>{mockCounts.content} Content</span>
|
||||
</div>
|
||||
<div className="flex gap-2 text-sm text-muted-foreground">
|
||||
<span>{mockCounts.webinars} Webinars</span>
|
||||
<span>•</span>
|
||||
<span>{mockCounts.classes} Classes</span>
|
||||
</div>
|
||||
</div>
|
||||
<BookOpen className="h-8 w-8 text-brand-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-6">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="structure">Structure</TabsTrigger>
|
||||
<TabsTrigger value="learners">Learners</TabsTrigger>
|
||||
<TabsTrigger value="calendar">Calendar</TabsTrigger>
|
||||
<TabsTrigger value="reports">Reports</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="space-y-6 mt-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Programme Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Description</h4>
|
||||
<p className="text-sm text-muted-foreground">{mockProgramme.description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Learning Goals</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
{mockProgramme.goals.map((goal, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-status-success mt-0.5 flex-shrink-0" />
|
||||
{goal}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Tags</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mockProgramme.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stage Breakdown</CardTitle>
|
||||
<CardDescription>Programme structure as designed by Super Admin</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ name: 'Pre-assessment', items: mockProgramme.structure.preAssessment, color: 'bg-chart-1' },
|
||||
{ name: 'Pre-learning', items: mockProgramme.structure.preLearning, color: 'bg-chart-2' },
|
||||
{ name: 'Classroom sessions', items: mockProgramme.structure.classroom, color: 'bg-chart-3' },
|
||||
{ name: 'Post-learning', items: mockProgramme.structure.postLearning, color: 'bg-chart-4' }
|
||||
].map((stage, index) => (
|
||||
<div key={index} className="border-l-4 border-l-brand-primary pl-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium">{stage.name}</h4>
|
||||
<Badge variant="outline">{stage.items.length} items</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{stage.items.slice(0, 2).map((item, itemIndex) => (
|
||||
<div key={itemIndex} className="flex items-center gap-2 text-sm">
|
||||
{getTypeIcon(item.type)}
|
||||
<span>{item.title}</span>
|
||||
{item.status && getStatusBadge(item.status)}
|
||||
</div>
|
||||
))}
|
||||
{stage.items.length > 2 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
+{stage.items.length - 2} more items
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Bell className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>Guardrails:</strong> Dates and enrolments shown are scoped to your organization ({mockAssignment.orgName}).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
|
||||
{/* Structure Tab */}
|
||||
<TabsContent value="structure" className="space-y-6 mt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Programme Structure</h3>
|
||||
<p className="text-muted-foreground">Read-only view of the programme as composed by Super Admin</p>
|
||||
</div>
|
||||
<Badge variant="outline">Read Only</Badge>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="space-y-2">
|
||||
{[
|
||||
{ name: 'Pre-assessment', items: mockProgramme.structure.preAssessment },
|
||||
{ name: 'Pre-learning', items: mockProgramme.structure.preLearning },
|
||||
{ name: 'Classroom Sessions (Offline)', items: mockProgramme.structure.classroom },
|
||||
{ name: 'Post-learning', items: mockProgramme.structure.postLearning }
|
||||
].map((stage, index) => (
|
||||
<AccordionItem key={index} value={`stage-${index}`} className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline">{stage.name}</Badge>
|
||||
<span className="text-muted-foreground">({stage.items.length} items)</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 pb-4">
|
||||
{stage.items.map((item, itemIndex) => (
|
||||
<div key={itemIndex} className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
{getTypeIcon(item.type)}
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
{item.duration && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{item.duration}
|
||||
</span>
|
||||
)}
|
||||
{item.dueDate && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Due: {item.dueDate}
|
||||
</span>
|
||||
)}
|
||||
{item.venue && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{item.venue} {item.room && `• ${item.room}`}
|
||||
</span>
|
||||
)}
|
||||
{item.date && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{item.date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Learners Tab */}
|
||||
<TabsContent value="learners" className="space-y-6 mt-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Learner Progress</h3>
|
||||
<p className="text-muted-foreground">Track individual progress across the programme</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleExport('excel')}
|
||||
disabled={exporting}
|
||||
className="min-tap-44"
|
||||
>
|
||||
{exporting ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Export Data
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search learners..."
|
||||
className="pl-10"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="Not Started">Not Started</SelectItem>
|
||||
<SelectItem value="In-Progress">In Progress</SelectItem>
|
||||
<SelectItem value="Completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={stageFilter} onValueChange={setStageFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Stage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Stages</SelectItem>
|
||||
<SelectItem value="Pre-assessment">Pre-assessment</SelectItem>
|
||||
<SelectItem value="Pre-learning">Pre-learning</SelectItem>
|
||||
<SelectItem value="Classroom">Classroom</SelectItem>
|
||||
<SelectItem value="Post-learning">Post-learning</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={cohortFilter} onValueChange={setCohortFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Cohort" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Cohorts</SelectItem>
|
||||
<SelectItem value="Cohort A">Cohort A</SelectItem>
|
||||
<SelectItem value="Cohort B">Cohort B</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Learners Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader className="sticky-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">Learner</TableHead>
|
||||
<TableHead className="w-[200px]">Current Item</TableHead>
|
||||
<TableHead className="w-[100px]">Progress</TableHead>
|
||||
<TableHead className="w-[150px]">Next Item</TableHead>
|
||||
<TableHead className="w-[120px]">Last Activity</TableHead>
|
||||
<TableHead className="w-[100px]">Stage</TableHead>
|
||||
<TableHead className="w-[80px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredLearners.map((learner) => (
|
||||
<TableRow key={learner.id} className="min-h-[48px]">
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{learner.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{learner.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{getTypeIcon(learner.currentItem.type)}
|
||||
<div>
|
||||
<p className="font-medium text-sm">{learner.currentItem.title}</p>
|
||||
{getStatusBadge(learner.currentItem.status)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Progress value={learner.progressPct} className="w-16" />
|
||||
<span className="text-sm">{learner.progressPct}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{learner.nextItem ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{getTypeIcon(learner.nextItem.type)}
|
||||
<span className="text-sm">{learner.nextItem.title}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">None</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{learner.lastActivity}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{learner.stage}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewLearner(learner)}
|
||||
className="min-tap-44"
|
||||
aria-label={`View details for ${learner.name}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-tap-44"
|
||||
aria-label={`Send reminder to ${learner.name}`}
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Calendar Tab */}
|
||||
<TabsContent value="calendar" className="space-y-6 mt-6">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Calendar className="h-12 w-12 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Programme Calendar</h3>
|
||||
<p>Programme-scoped calendar with webinars, offline classes, and deadlines would be displayed here</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Reports Tab */}
|
||||
<TabsContent value="reports" className="space-y-6 mt-6">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Programme Reports</h3>
|
||||
<p>Detailed analytics and reporting for this programme would be displayed here</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Activity Tab */}
|
||||
<TabsContent value="activity" className="space-y-6 mt-6">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Activity className="h-12 w-12 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Activity Log</h3>
|
||||
<p>Programme activity audit trail would be displayed here</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Learner Details Drawer */}
|
||||
<Sheet open={showLearnerDrawer} onOpenChange={setShowLearnerDrawer}>
|
||||
<SheetContent className="w-[480px] sm:w-[540px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{selectedLearner?.name}</SheetTitle>
|
||||
<SheetDescription>Learner progress and assignment details</SheetDescription>
|
||||
</SheetHeader>
|
||||
{selectedLearner && (
|
||||
<div className="space-y-6 mt-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Assignment Info</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Start Date:</span>
|
||||
<span>{mockAssignment.startDate}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">End Date:</span>
|
||||
<span>{mockAssignment.endDate}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Cohort:</span>
|
||||
<span>{selectedLearner.cohort}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Stage Timeline</h4>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ name: 'Pre-assessment', completed: selectedLearner.stage !== 'Pre-assessment' },
|
||||
{ name: 'Pre-learning', completed: !['Pre-assessment', 'Pre-learning'].includes(selectedLearner.stage) },
|
||||
{ name: 'Classroom', completed: !['Pre-assessment', 'Pre-learning', 'Classroom'].includes(selectedLearner.stage) },
|
||||
{ name: 'Post-learning', completed: false }
|
||||
].map((stage, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
stage.completed ? 'bg-status-success border-status-success' :
|
||||
selectedLearner.stage === stage.name ? 'border-brand-primary bg-brand-primary' :
|
||||
'border-muted'
|
||||
}`}>
|
||||
{stage.completed && <CheckCircle className="w-4 h-4 text-white" />}
|
||||
</div>
|
||||
<span className={`text-sm ${
|
||||
selectedLearner.stage === stage.name ? 'font-medium' : 'text-muted-foreground'
|
||||
}`}>
|
||||
{stage.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Open Learner Record
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1">
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Send Reminder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
367
src/components/ProgrammeSchedule.tsx
Normal file
367
src/components/ProgrammeSchedule.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Users,
|
||||
Video,
|
||||
FileText,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ScheduleEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'webinar' | 'workshop' | 'assessment' | 'deadline' | 'class';
|
||||
date: Date;
|
||||
time: string;
|
||||
duration?: string;
|
||||
programme: string;
|
||||
attendees?: number;
|
||||
maxAttendees?: number;
|
||||
status: 'upcoming' | 'live' | 'completed';
|
||||
}
|
||||
|
||||
interface ProgrammeScheduleProps {
|
||||
events?: ScheduleEvent[];
|
||||
onEventClick?: (event: ScheduleEvent) => void;
|
||||
}
|
||||
|
||||
const mockEvents: ScheduleEvent[] = [
|
||||
{
|
||||
id: 'evt-001',
|
||||
title: 'Leadership Fundamentals Webinar',
|
||||
type: 'webinar',
|
||||
date: new Date('2024-12-28'),
|
||||
time: '10:00 AM',
|
||||
duration: '90 min',
|
||||
programme: 'Leadership Development',
|
||||
attendees: 32,
|
||||
maxAttendees: 50,
|
||||
status: 'upcoming'
|
||||
},
|
||||
{
|
||||
id: 'evt-002',
|
||||
title: 'Technical Skills Assessment',
|
||||
type: 'assessment',
|
||||
date: new Date('2024-12-28'),
|
||||
time: '2:00 PM',
|
||||
duration: '60 min',
|
||||
programme: 'Technical Skills',
|
||||
status: 'upcoming'
|
||||
},
|
||||
{
|
||||
id: 'evt-003',
|
||||
title: 'Communication Workshop',
|
||||
type: 'workshop',
|
||||
date: new Date('2024-12-29'),
|
||||
time: '11:00 AM',
|
||||
duration: '2 hrs',
|
||||
programme: 'Communication Excellence',
|
||||
attendees: 18,
|
||||
maxAttendees: 25,
|
||||
status: 'upcoming'
|
||||
},
|
||||
{
|
||||
id: 'evt-004',
|
||||
title: 'Project Management Live Class',
|
||||
type: 'class',
|
||||
date: new Date('2024-12-29'),
|
||||
time: '3:30 PM',
|
||||
duration: '45 min',
|
||||
programme: 'Project Management',
|
||||
attendees: 45,
|
||||
maxAttendees: 60,
|
||||
status: 'live'
|
||||
},
|
||||
{
|
||||
id: 'evt-005',
|
||||
title: 'Assignment Submission Due',
|
||||
type: 'deadline',
|
||||
date: new Date('2024-12-30'),
|
||||
time: '11:59 PM',
|
||||
programme: 'Leadership Development',
|
||||
status: 'upcoming'
|
||||
},
|
||||
{
|
||||
id: 'evt-006',
|
||||
title: 'Data Analytics Bootcamp',
|
||||
type: 'workshop',
|
||||
date: new Date('2024-12-30'),
|
||||
time: '9:00 AM',
|
||||
duration: '4 hrs',
|
||||
programme: 'Technical Skills',
|
||||
attendees: 22,
|
||||
maxAttendees: 30,
|
||||
status: 'upcoming'
|
||||
}
|
||||
];
|
||||
|
||||
export const ProgrammeSchedule: React.FC<ProgrammeScheduleProps> = ({
|
||||
events = mockEvents,
|
||||
onEventClick
|
||||
}) => {
|
||||
const [selectedProgramme, setSelectedProgramme] = useState('all');
|
||||
const [selectedType, setSelectedType] = useState('all');
|
||||
const [currentWeekStart, setCurrentWeekStart] = useState(() => {
|
||||
const today = new Date();
|
||||
const dayOfWeek = today.getDay();
|
||||
const mondayDate = new Date(today);
|
||||
mondayDate.setDate(today.getDate() - dayOfWeek + 1);
|
||||
return mondayDate;
|
||||
});
|
||||
|
||||
// Get unique programmes and types for filters
|
||||
const programmes = Array.from(new Set(events.map(e => e.programme)));
|
||||
const eventTypes = Array.from(new Set(events.map(e => e.type)));
|
||||
|
||||
// Generate week days
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => {
|
||||
const date = new Date(currentWeekStart);
|
||||
date.setDate(currentWeekStart.getDate() + i);
|
||||
return date;
|
||||
});
|
||||
|
||||
// Filter and group events by date
|
||||
const filteredEvents = events.filter(event => {
|
||||
const matchesProgramme = selectedProgramme === 'all' || event.programme === selectedProgramme;
|
||||
const matchesType = selectedType === 'all' || event.type === selectedType;
|
||||
return matchesProgramme && matchesType;
|
||||
});
|
||||
|
||||
const eventsByDate = weekDays.reduce((acc, date) => {
|
||||
const dateKey = date.toDateString();
|
||||
acc[dateKey] = filteredEvents.filter(event =>
|
||||
event.date.toDateString() === dateKey
|
||||
).sort((a, b) => a.time.localeCompare(b.time));
|
||||
return acc;
|
||||
}, {} as Record<string, ScheduleEvent[]>);
|
||||
|
||||
const navigateWeek = (direction: 'prev' | 'next') => {
|
||||
const newDate = new Date(currentWeekStart);
|
||||
newDate.setDate(currentWeekStart.getDate() + (direction === 'next' ? 7 : -7));
|
||||
setCurrentWeekStart(newDate);
|
||||
};
|
||||
|
||||
const getEventIcon = (type: ScheduleEvent['type']) => {
|
||||
switch (type) {
|
||||
case 'webinar':
|
||||
return <Video className="h-3 w-3" />;
|
||||
case 'workshop':
|
||||
return <Users className="h-3 w-3" />;
|
||||
case 'assessment':
|
||||
return <FileText className="h-3 w-3" />;
|
||||
case 'deadline':
|
||||
return <Clock className="h-3 w-3" />;
|
||||
case 'class':
|
||||
return <Calendar className="h-3 w-3" />;
|
||||
default:
|
||||
return <Calendar className="h-3 w-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getEventColor = (type: ScheduleEvent['type'], status: ScheduleEvent['status']) => {
|
||||
if (status === 'live') return 'bg-status-error text-status-error-foreground';
|
||||
if (status === 'completed') return 'bg-muted text-muted-foreground';
|
||||
|
||||
switch (type) {
|
||||
case 'webinar':
|
||||
return 'bg-brand-primary text-brand-navy-foreground';
|
||||
case 'workshop':
|
||||
return 'bg-status-success text-status-success-foreground';
|
||||
case 'assessment':
|
||||
return 'bg-status-warn text-status-warn-foreground';
|
||||
case 'deadline':
|
||||
return 'bg-status-error text-status-error-foreground';
|
||||
case 'class':
|
||||
return 'bg-brand-charcoal text-brand-charcoal-foreground';
|
||||
default:
|
||||
return 'bg-secondary text-secondary-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString('en-AU', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: 'short'
|
||||
});
|
||||
};
|
||||
|
||||
const isToday = (date: Date) => {
|
||||
const today = new Date();
|
||||
return date.toDateString() === today.toDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Programme Schedule</CardTitle>
|
||||
<CardDescription>Weekly view of upcoming classes, webinars, and deadlines</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedProgramme} onValueChange={setSelectedProgramme}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="All Programmes" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Programmes</SelectItem>
|
||||
{programmes.map(programme => (
|
||||
<SelectItem key={programme} value={programme}>
|
||||
{programme}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedType} onValueChange={setSelectedType}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="All Types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
{eventTypes.map(type => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Week Navigation */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateWeek('prev')}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous Week
|
||||
</Button>
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold">
|
||||
{weekDays[0].toLocaleDateString('en-AU', { day: '2-digit', month: 'short' })} - {weekDays[6].toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: 'numeric' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">Week View</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateWeek('next')}
|
||||
className="min-tap-44"
|
||||
>
|
||||
Next Week
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Horizontal Weekly Calendar */}
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{weekDays.map((date, dayIndex) => {
|
||||
const dateKey = date.toDateString();
|
||||
const dayEvents = eventsByDate[dateKey] || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dayIndex}
|
||||
className={`
|
||||
min-h-[120px] p-3 rounded-lg border
|
||||
${isToday(date)
|
||||
? 'bg-brand-primary/5 border-brand-primary/20'
|
||||
: 'bg-card border-chrome-divider'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="text-center mb-3">
|
||||
<p className={`font-medium ${isToday(date) ? 'text-brand-primary' : ''}`}>
|
||||
{formatDate(date)}
|
||||
</p>
|
||||
{isToday(date) && (
|
||||
<Badge variant="secondary" className="text-xs mt-1">Today</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{dayEvents.slice(0, 3).map((event) => (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={() => onEventClick?.(event)}
|
||||
className={`
|
||||
w-full p-2 rounded text-left text-xs transition-all duration-200
|
||||
hover:shadow-sm hover:scale-105 min-tap-44
|
||||
${getEventColor(event.type, event.status)}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
{getEventIcon(event.type)}
|
||||
<span className="font-medium truncate">{event.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{event.time}</span>
|
||||
{event.status === 'live' && (
|
||||
<Badge variant="destructive" className="text-xs px-1 py-0">
|
||||
LIVE
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{event.attendees && (
|
||||
<div className="flex items-center gap-1 mt-1 text-xs opacity-80">
|
||||
<Users className="h-2 w-2" />
|
||||
<span>{event.attendees}/{event.maxAttendees}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{dayEvents.length > 3 && (
|
||||
<div className="text-center">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{dayEvents.length - 3} more
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dayEvents.length === 0 && (
|
||||
<div className="text-center py-4">
|
||||
<span className="text-xs text-muted-foreground">No events</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-6 pt-4 border-t border-chrome-divider">
|
||||
<p className="text-sm font-medium mb-2">Event Types:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ type: 'webinar', label: 'Webinar' },
|
||||
{ type: 'workshop', label: 'Workshop' },
|
||||
{ type: 'class', label: 'Live Class' },
|
||||
{ type: 'assessment', label: 'Assessment' },
|
||||
{ type: 'deadline', label: 'Deadline' }
|
||||
].map(({ type, label }) => (
|
||||
<div key={type} className="flex items-center gap-1">
|
||||
<div className={`w-3 h-3 rounded ${getEventColor(type as ScheduleEvent['type'], 'upcoming').replace('text-', 'bg-').split(' ')[0]}`} />
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
485
src/components/ProgrammesTable.tsx
Normal file
485
src/components/ProgrammesTable.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Input } from './ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Download,
|
||||
Users,
|
||||
Calendar,
|
||||
BookOpen,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
UserPlus,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Assignment {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
interface Programme {
|
||||
programmeId: string;
|
||||
title: string;
|
||||
status: 'Active' | 'Upcoming' | 'Completed';
|
||||
coursesCount: number;
|
||||
contentCount: number;
|
||||
assignment: Assignment;
|
||||
learnersAssigned: number;
|
||||
type?: 'programme' | 'course';
|
||||
}
|
||||
|
||||
interface ProgrammesTableProps {
|
||||
programmes?: Programme[];
|
||||
onViewProgramme?: (programmeId: string) => void;
|
||||
onViewCourse?: (courseId: string) => void;
|
||||
onAssignLearners?: (programmeId: string) => void;
|
||||
onDownloadTracker?: (programmeId: string) => void;
|
||||
userAccessLevel?: 'full' | 'course-only'; // New prop to determine user access
|
||||
}
|
||||
|
||||
const mockProgrammes: Programme[] = [
|
||||
{
|
||||
programmeId: 'prog-001',
|
||||
title: 'Leadership Development Program',
|
||||
status: 'Active',
|
||||
coursesCount: 8,
|
||||
contentCount: 24,
|
||||
assignment: {
|
||||
startDate: new Date('2024-01-15'),
|
||||
endDate: new Date('2024-06-30')
|
||||
},
|
||||
learnersAssigned: 45,
|
||||
type: 'programme'
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-002',
|
||||
title: 'Technical Skills Bootcamp',
|
||||
status: 'Active',
|
||||
coursesCount: 12,
|
||||
contentCount: 36,
|
||||
assignment: {
|
||||
startDate: new Date('2024-02-01'),
|
||||
endDate: new Date('2024-08-31')
|
||||
},
|
||||
learnersAssigned: 38,
|
||||
type: 'programme'
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-003',
|
||||
title: 'Communication Excellence',
|
||||
status: 'Upcoming',
|
||||
coursesCount: 6,
|
||||
contentCount: 18,
|
||||
assignment: {
|
||||
startDate: new Date('2024-03-01'),
|
||||
endDate: new Date('2024-05-31')
|
||||
},
|
||||
learnersAssigned: 28,
|
||||
type: 'programme'
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-004',
|
||||
title: 'Project Management Certification',
|
||||
status: 'Active',
|
||||
coursesCount: 10,
|
||||
contentCount: 30,
|
||||
assignment: {
|
||||
startDate: new Date('2024-01-01'),
|
||||
endDate: new Date('2024-12-31')
|
||||
},
|
||||
learnersAssigned: 52,
|
||||
type: 'programme'
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-005',
|
||||
title: 'Digital Marketing Mastery',
|
||||
status: 'Completed',
|
||||
coursesCount: 5,
|
||||
contentCount: 15,
|
||||
assignment: {
|
||||
startDate: new Date('2023-09-01'),
|
||||
endDate: new Date('2023-12-31')
|
||||
},
|
||||
learnersAssigned: 32,
|
||||
type: 'programme'
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-006',
|
||||
title: 'Data Analytics Fundamentals',
|
||||
status: 'Completed',
|
||||
coursesCount: 7,
|
||||
contentCount: 21,
|
||||
assignment: {
|
||||
startDate: new Date('2023-10-15'),
|
||||
endDate: new Date('2024-01-15')
|
||||
},
|
||||
learnersAssigned: 29,
|
||||
type: 'programme'
|
||||
}
|
||||
];
|
||||
|
||||
const mockCourses: Programme[] = [
|
||||
{
|
||||
programmeId: 'course-001',
|
||||
title: 'Strategic Thinking Course',
|
||||
status: 'Active',
|
||||
coursesCount: 1,
|
||||
contentCount: 8,
|
||||
assignment: {
|
||||
startDate: new Date('2024-01-15'),
|
||||
endDate: new Date('2024-03-15')
|
||||
},
|
||||
learnersAssigned: 15,
|
||||
type: 'course'
|
||||
},
|
||||
{
|
||||
programmeId: 'course-002',
|
||||
title: 'Data Analysis Fundamentals',
|
||||
status: 'Active',
|
||||
coursesCount: 1,
|
||||
contentCount: 12,
|
||||
assignment: {
|
||||
startDate: new Date('2024-02-01'),
|
||||
endDate: new Date('2024-04-30')
|
||||
},
|
||||
learnersAssigned: 22,
|
||||
type: 'course'
|
||||
},
|
||||
{
|
||||
programmeId: 'course-003',
|
||||
title: 'Public Speaking Mastery',
|
||||
status: 'Upcoming',
|
||||
coursesCount: 1,
|
||||
contentCount: 6,
|
||||
assignment: {
|
||||
startDate: new Date('2024-03-01'),
|
||||
endDate: new Date('2024-04-15')
|
||||
},
|
||||
learnersAssigned: 18,
|
||||
type: 'course'
|
||||
},
|
||||
{
|
||||
programmeId: 'course-004',
|
||||
title: 'Agile Methodology Workshop',
|
||||
status: 'Active',
|
||||
coursesCount: 1,
|
||||
contentCount: 10,
|
||||
assignment: {
|
||||
startDate: new Date('2024-01-01'),
|
||||
endDate: new Date('2024-06-30')
|
||||
},
|
||||
learnersAssigned: 25,
|
||||
type: 'course'
|
||||
},
|
||||
{
|
||||
programmeId: 'course-005',
|
||||
title: 'Excel Advanced Techniques',
|
||||
status: 'Completed',
|
||||
coursesCount: 1,
|
||||
contentCount: 5,
|
||||
assignment: {
|
||||
startDate: new Date('2023-11-01'),
|
||||
endDate: new Date('2023-12-31')
|
||||
},
|
||||
learnersAssigned: 12,
|
||||
type: 'course'
|
||||
}
|
||||
];
|
||||
|
||||
export const ProgrammesTable: React.FC<ProgrammesTableProps> = ({
|
||||
programmes,
|
||||
onViewProgramme,
|
||||
onViewCourse,
|
||||
onAssignLearners,
|
||||
onDownloadTracker,
|
||||
userAccessLevel = 'full'
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('Active');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isAssignModalOpen, setIsAssignModalOpen] = useState(false);
|
||||
const [selectedProgramme, setSelectedProgramme] = useState<Programme | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const itemsPerPage = 5;
|
||||
|
||||
// Determine which data to show based on user access level
|
||||
const defaultData = userAccessLevel === 'course-only' ? mockCourses : mockProgrammes;
|
||||
const displayData = programmes || defaultData;
|
||||
|
||||
const filteredProgrammes = displayData.filter(prog => {
|
||||
const matchesSearch = prog.title.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || prog.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
// Pagination logic
|
||||
const totalPages = Math.ceil(filteredProgrammes.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedProgrammes = filteredProgrammes.slice(startIndex, endIndex);
|
||||
|
||||
// Reset to first page when filters change
|
||||
React.useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, statusFilter]);
|
||||
|
||||
const formatDateRange = (assignment: Assignment) => {
|
||||
const startDate = assignment.startDate.toLocaleDateString('en-AU', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
const endDate = assignment.endDate.toLocaleDateString('en-AU', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
return `${startDate} → ${endDate}`;
|
||||
};
|
||||
|
||||
const handleViewItem = (item: Programme) => {
|
||||
if (item.type === 'course') {
|
||||
onViewCourse?.(item.programmeId);
|
||||
console.log(`Viewing course: ${item.programmeId}`);
|
||||
} else {
|
||||
onViewProgramme?.(item.programmeId);
|
||||
console.log(`Viewing programme: ${item.programmeId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignLearners = (programme: Programme) => {
|
||||
setSelectedProgramme(programme);
|
||||
setIsAssignModalOpen(true);
|
||||
onAssignLearners?.(programme.programmeId);
|
||||
};
|
||||
|
||||
const handleDownloadTracker = async (programmeId: string) => {
|
||||
setIsExporting(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setIsExporting(false);
|
||||
onDownloadTracker?.(programmeId);
|
||||
console.log(`Downloaded tracker for programme: ${programmeId}`);
|
||||
};
|
||||
|
||||
const getStatusBadgeProps = (status: Programme['status']) => {
|
||||
switch (status) {
|
||||
case 'Active':
|
||||
return { variant: 'default' as const, className: 'bg-status-success text-status-success-foreground' };
|
||||
case 'Upcoming':
|
||||
return { variant: 'secondary' as const, className: 'bg-status-warn text-status-warn-foreground' };
|
||||
case 'Completed':
|
||||
return { variant: 'outline' as const, className: 'bg-muted text-muted-foreground' };
|
||||
default:
|
||||
return { variant: 'secondary' as const };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>{userAccessLevel === 'course-only' ? 'Courses' : 'Programmes'}</CardTitle>
|
||||
<CardDescription>
|
||||
{userAccessLevel === 'course-only'
|
||||
? 'Manage course assignments and track progress'
|
||||
: 'Manage programme assignments and track progress'
|
||||
}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={userAccessLevel === 'course-only' ? 'Search courses...' : 'Search programmes...'}
|
||||
className="pl-10 w-[200px]"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
aria-label={userAccessLevel === 'course-only' ? 'Search courses by title' : 'Search programmes by title'}
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="Active">Active</SelectItem>
|
||||
<SelectItem value="Upcoming">Upcoming</SelectItem>
|
||||
<SelectItem value="Completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border" style={{ maxWidth: '1200px' }}>
|
||||
<Table>
|
||||
<TableHeader className="sticky-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[300px]">
|
||||
{userAccessLevel === 'course-only' ? 'Course' : 'Programme/Course'}
|
||||
</TableHead>
|
||||
<TableHead className="w-[150px]">
|
||||
{userAccessLevel === 'course-only' ? 'Content' : 'Courses / Content'}
|
||||
</TableHead>
|
||||
<TableHead className="w-[200px]">Start → End</TableHead>
|
||||
<TableHead className="w-[120px]">Learners Assigned</TableHead>
|
||||
<TableHead className="w-[200px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedProgrammes.map((programme) => (
|
||||
<TableRow key={programme.programmeId} className="min-h-[44px]">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{programme.title}</span>
|
||||
<Badge
|
||||
{...getStatusBadgeProps(programme.status)}
|
||||
aria-label={`Programme status: ${programme.status}`}
|
||||
>
|
||||
{programme.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<span>
|
||||
{userAccessLevel === 'course-only'
|
||||
? `${programme.contentCount} modules`
|
||||
: `${programme.coursesCount} • ${programme.contentCount}`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{formatDateRange(programme.assignment)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{programme.learnersAssigned}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewItem(programme)}
|
||||
className="min-tap-44"
|
||||
aria-label={`View ${programme.type === 'course' ? 'course' : 'programme'} details for ${programme.title}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="sr-only">View {programme.type === 'course' ? 'Course' : 'Programme'}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleAssignLearners(programme)}
|
||||
className="min-tap-44"
|
||||
aria-label={`Assign learners to ${programme.title}`}
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
<span className="sr-only">Assign Learners</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadTracker(programme.programmeId)}
|
||||
disabled={isExporting}
|
||||
className="min-tap-44"
|
||||
aria-label={`Download tracker for ${programme.title}`}
|
||||
>
|
||||
{isExporting ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">Download Tracker</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {startIndex + 1} to {Math.min(endIndex, filteredProgrammes.length)} of {filteredProgrammes.length} {userAccessLevel === 'course-only' ? 'courses' : 'programmes'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="min-tap-44"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredProgrammes.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">
|
||||
No {userAccessLevel === 'course-only' ? 'courses' : 'programmes'} found matching your criteria.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignment Modal */}
|
||||
<Dialog open={isAssignModalOpen} onOpenChange={setIsAssignModalOpen}>
|
||||
<DialogContent className="sm:max-w-[600px]" role="dialog" aria-modal="true">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Assign Learners to {userAccessLevel === 'course-only' ? 'Course' : 'Programme'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedProgramme && `Assign learners to "${selectedProgramme.title}"`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<UserPlus className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p>Assignment wizard would be displayed here</p>
|
||||
<p className="text-sm mt-2">Including org/individual selection, dates, HR contacts, and participant upload</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
317
src/components/TopNav.tsx
Normal file
317
src/components/TopNav.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import klcLogo from '../assets/klc-logo.png';
|
||||
import {
|
||||
Menu,
|
||||
Bell,
|
||||
User,
|
||||
Settings,
|
||||
LogOut,
|
||||
Building2,
|
||||
BookOpen,
|
||||
Sun,
|
||||
Moon,
|
||||
HelpCircle,
|
||||
ChevronDown
|
||||
} from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Avatar, AvatarFallback } from './ui/avatar';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
interface TopNavProps {
|
||||
onMenuToggle?: () => void;
|
||||
showMenuButton?: boolean;
|
||||
onNotificationToggle?: () => void;
|
||||
notificationCount?: number;
|
||||
}
|
||||
|
||||
interface UserPreferences {
|
||||
darkMode: boolean;
|
||||
prefersReducedMotion: boolean;
|
||||
}
|
||||
|
||||
// Custom hook for localStorage
|
||||
const useLocalStorage = <T,>(key: string, initialValue: T): [T, (value: T) => void] => {
|
||||
const [storedValue, setStoredValue] = React.useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const setValue = (value: T) => {
|
||||
try {
|
||||
setStoredValue(value);
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error('Error saving to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue];
|
||||
};
|
||||
|
||||
export const TopNav: React.FC<TopNavProps> = ({
|
||||
onMenuToggle,
|
||||
showMenuButton = false,
|
||||
onNotificationToggle,
|
||||
notificationCount = 0
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuth();
|
||||
const displayName =
|
||||
user?.display_name?.trim() ||
|
||||
[user?.first_name, user?.last_name].filter(Boolean).join(' ').trim() ||
|
||||
'HR User';
|
||||
const emailLabel = user?.email_address ?? 'hr@klc.com';
|
||||
const userInitials =
|
||||
displayName
|
||||
.split(/\s+/)
|
||||
.map((p) => p[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase() || 'HR';
|
||||
const [preferences, setPreferences] = useLocalStorage<UserPreferences>('userPreferences', {
|
||||
darkMode: false,
|
||||
prefersReducedMotion: false
|
||||
});
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
const newDarkMode = !preferences.darkMode;
|
||||
setPreferences({
|
||||
...preferences,
|
||||
darkMode: newDarkMode
|
||||
});
|
||||
document.documentElement.classList.toggle('dark', newDarkMode);
|
||||
};
|
||||
|
||||
const handleSignOut = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
const handleProfileClick = () => {
|
||||
navigate('/hr/profile');
|
||||
};
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
navigate('/hr/settings');
|
||||
};
|
||||
|
||||
const handleHelpClick = () => {
|
||||
window.open('/help', '_blank');
|
||||
};
|
||||
|
||||
const handleSwitchMode = (mode: 'hr' | 'learning') => {
|
||||
if (mode === 'learning') {
|
||||
navigate('/learning/dashboard');
|
||||
} else {
|
||||
navigate('/hr/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-background border-b border-chrome-divider flex items-center justify-between px-4 lg:px-6 sticky top-0 z-50">
|
||||
{/* Left Section */}
|
||||
<div className="flex items-center gap-4">
|
||||
{showMenuButton && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onMenuToggle}
|
||||
className="lg:hidden min-tap-44"
|
||||
aria-label="Toggle navigation menu"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Logo and Brand */}
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={klcLogo}
|
||||
alt="Kautilya Leadership Centre"
|
||||
className="h-8 w-auto"
|
||||
/>
|
||||
<div className="hidden sm:block">
|
||||
<h1 className="text-sm font-semibold">HR Dashboard</h1>
|
||||
<p className="text-xs text-muted-foreground">Knowledge Learning Centre</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Section */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Notifications */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-tap-44 relative"
|
||||
aria-label="Notifications"
|
||||
onClick={onNotificationToggle}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{notificationCount > 0 && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center text-xs"
|
||||
aria-label={`${notificationCount} unread notifications`}
|
||||
>
|
||||
{notificationCount > 9 ? '9+' : notificationCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleDarkMode}
|
||||
className="min-tap-44 hidden sm:flex"
|
||||
aria-label={preferences.darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{preferences.darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
{/* Profile Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex items-center gap-2 px-2 min-tap-44 hover:bg-accent"
|
||||
aria-label="User menu"
|
||||
>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="bg-brand-navy text-white">
|
||||
{userInitials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="hidden md:block text-left">
|
||||
<p className="text-sm font-medium">{displayName}</p>
|
||||
<p className="text-xs text-muted-foreground">{emailLabel}</p>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground hidden sm:block" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
{/* User Profile Section */}
|
||||
<div className="flex items-center gap-3 p-4 bg-muted/20">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarFallback className="bg-brand-navy text-white text-lg">
|
||||
{userInitials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-semibold">{displayName}</p>
|
||||
<p className="text-sm text-muted-foreground">{emailLabel}</p>
|
||||
<Badge variant="outline" className="mt-1 text-xs">
|
||||
Administrator
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 gap-2 p-3 border-b">
|
||||
<div className="text-center p-2 bg-muted/30 rounded">
|
||||
<p className="text-xs text-muted-foreground">Active Learners</p>
|
||||
<p className="text-lg font-semibold">247</p>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-muted/30 rounded">
|
||||
<p className="text-xs text-muted-foreground">Programmes</p>
|
||||
<p className="text-lg font-semibold">12</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Switch Mode Section */}
|
||||
<div className="p-3 border-b">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">SWITCH MODE</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => handleSwitchMode('learning')}
|
||||
>
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
Learning
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 bg-brand-navy text-white hover:bg-brand-navy/90"
|
||||
onClick={() => handleSwitchMode('hr')}
|
||||
>
|
||||
<Building2 className="mr-2 h-4 w-4" />
|
||||
HR Mode
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="p-2">
|
||||
<DropdownMenuItem onClick={handleProfileClick} className="p-3 cursor-pointer">
|
||||
<User className="mr-3 h-4 w-4" />
|
||||
<div>
|
||||
<p className="font-medium">My Profile</p>
|
||||
<p className="text-xs text-muted-foreground">View and edit your profile</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleSettingsClick} className="p-3 cursor-pointer">
|
||||
<Settings className="mr-3 h-4 w-4" />
|
||||
<div>
|
||||
<p className="font-medium">Settings</p>
|
||||
<p className="text-xs text-muted-foreground">Manage your preferences</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleHelpClick} className="p-3 cursor-pointer">
|
||||
<HelpCircle className="mr-3 h-4 w-4" />
|
||||
<div>
|
||||
<p className="font-medium">Help & Support</p>
|
||||
<p className="text-xs text-muted-foreground">Get help and documentation</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Sign out */}
|
||||
<DropdownMenuItem
|
||||
onClick={handleSignOut}
|
||||
className="p-3 text-status-error focus:bg-status-error/10 focus:text-status-error cursor-pointer"
|
||||
>
|
||||
<LogOut className="mr-3 h-4 w-4" />
|
||||
<div>
|
||||
<p className="font-medium">Sign out</p>
|
||||
<p className="text-xs text-muted-foreground">End your session</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 text-center text-xs text-muted-foreground border-t">
|
||||
<p>Version 2.0.0</p>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Mobile Theme Toggle (visible only on small screens) */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleDarkMode}
|
||||
className="min-tap-44 sm:hidden"
|
||||
aria-label={preferences.darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{preferences.darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopNav;
|
||||
69
src/components/shared/ChatBot.tsx
Normal file
69
src/components/shared/ChatBot.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { MessageSquare, X } from 'lucide-react';
|
||||
|
||||
interface ChatBotProps {
|
||||
currentScreen?: string;
|
||||
}
|
||||
|
||||
export const ChatBot: React.FC<ChatBotProps> = ({ currentScreen }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const getChipsForScreen = (screen?: string) => {
|
||||
if (screen === 'profile') {
|
||||
return [
|
||||
"How do I submit a testimonial?",
|
||||
"When will my testimonial be reviewed?",
|
||||
"Can I edit my testimonial?",
|
||||
"What makes a good testimonial?"
|
||||
];
|
||||
}
|
||||
return [
|
||||
"How do I upload a roster?",
|
||||
"How to assign courses?",
|
||||
"View progress reports",
|
||||
"Export learner data"
|
||||
];
|
||||
};
|
||||
|
||||
const chips = getChipsForScreen(currentScreen);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-40">
|
||||
{isOpen && (
|
||||
<div className="mb-4 bg-card border border-chrome-divider rounded-lg shadow-lg p-4 w-80 animate-slide-up">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-semibold">HR Assistant</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="h-6 w-6"
|
||||
aria-label="Close chat"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{chips.map((chip, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="w-full text-left p-2 text-sm bg-muted hover:bg-accent rounded-md transition-colors min-tap-44"
|
||||
>
|
||||
{chip}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="rounded-full h-12 w-12 shadow-lg min-tap-44"
|
||||
aria-label="Open HR chat assistant"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
84
src/components/shared/KPICard.tsx
Normal file
84
src/components/shared/KPICard.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { X, Bell, Clock } from 'lucide-react';
|
||||
import { Announcement } from '../../types';
|
||||
|
||||
interface AnnouncementsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
announcements: Announcement[];
|
||||
onMarkAsRead?: (id: string) => void;
|
||||
}
|
||||
|
||||
export const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
announcements,
|
||||
onMarkAsRead
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-y-0 right-0 w-80 bg-background border-l border-chrome-divider shadow-lg z-50 flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-chrome-divider">
|
||||
<h3 className="font-semibold">Announcements & Reminders</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8"
|
||||
aria-label="Close announcements panel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{announcements.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`
|
||||
p-3 rounded-lg border transition-all duration-200 cursor-pointer hover:bg-muted/50
|
||||
${item.pinned ? 'bg-status-warn/10 border-status-warn/20' : 'bg-card border-chrome-divider'}
|
||||
`}
|
||||
onClick={() => onMarkAsRead?.(item.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-1 rounded ${item.type === 'announcement' ? 'bg-brand-primary' : 'bg-status-warn'}`}>
|
||||
{item.type === 'announcement' ?
|
||||
<Bell className="h-3 w-3 text-white" /> :
|
||||
<Clock className="h-3 w-3 text-white" />
|
||||
}
|
||||
</div>
|
||||
{item.pinned && (
|
||||
<Badge variant="secondary" className="text-xs">Pinned</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{item.timestamp}</span>
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium text-sm mb-1">{item.title}</h4>
|
||||
<p className="text-xs text-muted-foreground">{item.content}</p>
|
||||
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{item.type}
|
||||
</Badge>
|
||||
<Button variant="ghost" size="sm" className="h-auto p-1 text-xs">
|
||||
Mark as read
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-chrome-divider">
|
||||
<Button variant="outline" className="w-full text-sm">
|
||||
View All Notifications
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
17
src/components/toast/useToast.ts
Normal file
17
src/components/toast/useToast.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'info';
|
||||
|
||||
export function useToast() {
|
||||
const showToast = (title: string, description: string, variant: ToastVariant = 'info') => {
|
||||
if (variant === 'success') {
|
||||
toast.success(title, { description });
|
||||
} else if (variant === 'error') {
|
||||
toast.error(title, { description });
|
||||
} else {
|
||||
toast(title, { description });
|
||||
}
|
||||
};
|
||||
|
||||
return { showToast, toast };
|
||||
}
|
||||
@@ -34,25 +34,25 @@ const buttonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
const Button = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
}
|
||||
>(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu@1.2.5";
|
||||
import { cva } from "class-variance-authority@0.7.1";
|
||||
import { ChevronDownIcon } from "lucide-react@0.487.0";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes@0.4.6";
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner@2.0.3";
|
||||
import * as React from 'react';
|
||||
import { Toaster as Sonner, type ToasterProps } from 'sonner';
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
const [theme, setTheme] = React.useState<ToasterProps['theme']>(() =>
|
||||
typeof document !== 'undefined' &&
|
||||
document.documentElement.classList.contains('dark')
|
||||
? 'dark'
|
||||
: 'light',
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = document.documentElement;
|
||||
const update = () =>
|
||||
setTheme(el.classList.contains('dark') ? 'dark' : 'light');
|
||||
update();
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(el, { attributes: true, attributeFilter: ['class'] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
theme={theme}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
|
||||
136
src/context/AuthContext.tsx
Normal file
136
src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
// src/context/AuthContext.tsx
|
||||
import { createContext, useContext, useState, useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useToast } from "../components/toast/useToast";
|
||||
import { useLoginMutation, UserInfo } from "../redux/services/loginApi";
|
||||
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserInfo | null;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [user, setUser] = useState<UserInfo | null>(() => {
|
||||
const storedUser = localStorage.getItem("user");
|
||||
if (storedUser) {
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
return JSON.parse(storedUser);
|
||||
} catch {
|
||||
// If it fails (like "admin" string), return null or handle accordingly
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const [token, setToken] = useState<string | null>(() => {
|
||||
const storedToken = localStorage.getItem("token");
|
||||
return storedToken || null;
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const [loginMutation] = useLoginMutation();
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
try {
|
||||
const response = await loginMutation({
|
||||
email_address: email,
|
||||
password
|
||||
}).unwrap();
|
||||
|
||||
if (response.success) {
|
||||
// Store token and user info
|
||||
localStorage.setItem("token", response.data.access_token);
|
||||
localStorage.setItem("user", JSON.stringify(response.data.user_info));
|
||||
|
||||
setToken(response.data.access_token);
|
||||
setUser(response.data.user_info);
|
||||
|
||||
showToast(
|
||||
"Login Successful",
|
||||
response.message || "You have been logged in successfully.",
|
||||
"success"
|
||||
);
|
||||
|
||||
navigate("/hr/dashboard");
|
||||
} else {
|
||||
showToast(
|
||||
"Login Failed",
|
||||
response.message || "Invalid credentials. Please try again.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Login error:", error);
|
||||
|
||||
showToast(
|
||||
"Error",
|
||||
error.data?.message || error.message || "An error occurred during login",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user");
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
|
||||
showToast(
|
||||
"Logged Out",
|
||||
"You have been logged out successfully.",
|
||||
"success"
|
||||
);
|
||||
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
const isAuthenticated = !!token && !!user;
|
||||
|
||||
// Optional: Add token expiration check
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
try {
|
||||
// Decode token to check expiration (if needed)
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const exp = payload.exp * 1000; // Convert to milliseconds
|
||||
const now = Date.now();
|
||||
|
||||
if (now >= exp) {
|
||||
// Token expired
|
||||
logout();
|
||||
showToast(
|
||||
"Session Expired",
|
||||
"Your session has expired. Please login again.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Invalid token format
|
||||
console.error("Error checking token expiration:", error);
|
||||
}
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, isAuthenticated, token }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
|
||||
return ctx;
|
||||
};
|
||||
25
src/global.d.ts
vendored
25
src/global.d.ts
vendored
@@ -1,26 +1,29 @@
|
||||
// declarations.d.ts
|
||||
|
||||
declare module "*.png" {
|
||||
declare module '*.webp' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.jpg" {
|
||||
declare module '*.png' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.jpeg" {
|
||||
declare module '*.jpg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.svg" {
|
||||
import * as React from "react";
|
||||
const ReactComponent: React.FunctionComponent<
|
||||
React.SVGProps<SVGSVGElement> & { title?: string }
|
||||
>;
|
||||
export { ReactComponent };
|
||||
declare module '*.jpeg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*.gif' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
23
src/hooks/useCountUp.ts
Normal file
23
src/hooks/useCountUp.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useCountUp(end: number, duration: number = 1200) {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let start = 0;
|
||||
const increment = end / (duration / 16);
|
||||
const timer = setInterval(() => {
|
||||
start += increment;
|
||||
if (start >= end) {
|
||||
setCount(end);
|
||||
clearInterval(timer);
|
||||
} else {
|
||||
setCount(Math.floor(start));
|
||||
}
|
||||
}, 16);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [end, duration]);
|
||||
|
||||
return count;
|
||||
}
|
||||
24
src/hooks/useLocalStorage.ts
Normal file
24
src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.error('Error reading from localStorage:', error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const setValue = (value: T) => {
|
||||
try {
|
||||
setStoredValue(value);
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error('Error saving to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue];
|
||||
}
|
||||
2
src/index.css
Normal file
2
src/index.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@import "./styles/globals.css";
|
||||
105
src/layouts/HRLayout.tsx
Normal file
105
src/layouts/HRLayout.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { BreadcrumbNav } from './components/BreadcrumbNav';
|
||||
import { ChatBot } from '../components/shared/ChatBot';
|
||||
import { mockAnnouncements } from '../utils/mockData';
|
||||
import { useLocalStorage } from '../hooks/useLocalStorage';
|
||||
import TopNav from '../components/TopNav';
|
||||
import { HRSidebar } from './components/HRSidebar';
|
||||
import { AnnouncementsPanel } from '../components/shared/KPICard';
|
||||
|
||||
const HRLayout: React.FC = () => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [announcementsOpen, setAnnouncementsOpen] = useState(false);
|
||||
const [isDark] = useLocalStorage('darkMode', false);
|
||||
|
||||
// Apply theme
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
}, [isDark]);
|
||||
|
||||
const handleNotificationToggle = () => {
|
||||
setAnnouncementsOpen(!announcementsOpen);
|
||||
};
|
||||
|
||||
const handleMarkAsRead = (id: string) => {
|
||||
console.log(`Marked notification ${id} as read`);
|
||||
// In a real app, you would call an API to mark as read
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden bg-background flex flex-col">
|
||||
{/* Skip to main content link for accessibility */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-primary text-primary-foreground p-2 rounded z-50"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
|
||||
{/* Top Navigation */}
|
||||
<TopNav
|
||||
onMenuToggle={() => setSidebarOpen(!sidebarOpen)}
|
||||
showMenuButton={true}
|
||||
onNotificationToggle={handleNotificationToggle}
|
||||
notificationCount={mockAnnouncements.length}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 relative overflow-hidden">
|
||||
{/* Desktop Sidebar */}
|
||||
<HRSidebar className="hidden lg:flex lg:flex-shrink-0" />
|
||||
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="absolute left-0 top-0 h-full">
|
||||
<HRSidebar
|
||||
onNavigate={() => setSidebarOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main
|
||||
id="main-content"
|
||||
className={`flex-1 cs-height overflow-y-auto p-4 lg:p-8 transition-all duration-300 ${announcementsOpen ? 'lg:mr-80' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* <div className="sticky top-0 bg-background z-10 pb-2">
|
||||
</div> */}
|
||||
<BreadcrumbNav />
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Announcements Panel */}
|
||||
<AnnouncementsPanel
|
||||
isOpen={announcementsOpen}
|
||||
onClose={() => setAnnouncementsOpen(false)}
|
||||
announcements={mockAnnouncements}
|
||||
onMarkAsRead={handleMarkAsRead}
|
||||
/>
|
||||
|
||||
{/* Chat Bot FAB */}
|
||||
<ChatBot />
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-muted border-t border-chrome-divider p-4 text-center text-sm text-muted-foreground">
|
||||
<p>© {new Date().getFullYear()} Knowledge Learning Centre. All rights reserved.</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HRLayout;
|
||||
63
src/layouts/components/BreadcrumbNav.tsx
Normal file
63
src/layouts/components/BreadcrumbNav.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '../../components/ui/breadcrumb';
|
||||
|
||||
export const BreadcrumbNav: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const pathnames = location.pathname.split('/').filter(x => x);
|
||||
|
||||
const getBreadcrumbName = (path: string) => {
|
||||
switch (path) {
|
||||
case 'hr': return 'HR Portal';
|
||||
case 'dashboard': return 'Dashboard';
|
||||
case 'learners': return 'Learners';
|
||||
case 'reports': return 'Reports';
|
||||
case 'discussions': return 'Discussion Forums';
|
||||
case 'programme': return 'Programme Details';
|
||||
case 'course': return 'Course Details';
|
||||
case 'profile': return 'Profile';
|
||||
default: return path;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Breadcrumb className="mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to="/hr">HR Portal</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
{pathnames.map((name, index) => {
|
||||
const routeTo = `/${pathnames.slice(0, index + 1).join('/')}`;
|
||||
const isLast = index === pathnames.length - 1;
|
||||
|
||||
if (name === 'hr') return null;
|
||||
|
||||
return (
|
||||
<React.Fragment key={name}>
|
||||
<BreadcrumbSeparator aria-hidden="true" />
|
||||
<BreadcrumbItem>
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>{getBreadcrumbName(name)}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to={routeTo}>{getBreadcrumbName(name)}</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
};
|
||||
83
src/layouts/components/HRSidebar.tsx
Normal file
83
src/layouts/components/HRSidebar.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
FolderOpen,
|
||||
Home,
|
||||
MessageSquare,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const menuItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: Home, path: '/hr/dashboard' },
|
||||
{ id: 'learners', label: 'Learners', icon: Users, path: '/hr/learners' },
|
||||
{ id: 'courses', label: 'Courses', icon: BookOpen, path: '/hr/courses' },
|
||||
{ id: 'programmes', label: 'Programmes', icon: FolderOpen, path: '/hr/programmes' },
|
||||
{ id: 'reports', label: 'Reports', icon: BarChart3, path: '/hr/reports' },
|
||||
{ id: 'discussions', label: 'Discussion Forums', icon: MessageSquare, path: '/hr/discussions' }
|
||||
];
|
||||
|
||||
interface HRSidebarProps {
|
||||
className?: string;
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
export const HRSidebar: React.FC<HRSidebarProps> = ({ className = '', onNavigate }) => {
|
||||
const [prefersReducedMotion] = useLocalStorage('prefersReducedMotion', false);
|
||||
const { user } = useAuth();
|
||||
const orgName = user?.principal_organization_name?.trim() || 'Tech Solutions Pvt Ltd';
|
||||
const orgInitials =
|
||||
orgName
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase() || 'TS';
|
||||
|
||||
return (
|
||||
<div className={`w-64 min-w-[248px] cs-height bg-sidebar flex flex-col ${className}`}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-brand-charcoal rounded-md flex items-center justify-center">
|
||||
<span className="text-brand-charcoal-foreground font-bold text-sm">{orgInitials}</span>
|
||||
</div>
|
||||
<span className="font-semibold text-sidebar-foreground">{orgName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4" role="navigation" aria-label="HR Portal Navigation">
|
||||
<ul className="space-y-1">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<NavLink
|
||||
to={item.path}
|
||||
onClick={onNavigate}
|
||||
className={({ isActive }) => `
|
||||
w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm min-tap-44
|
||||
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-sidebar-ring focus:ring-offset-2 focus:ring-offset-sidebar
|
||||
${isActive
|
||||
? 'bg-sidebar-primary text-sidebar-primary-foreground shadow-sm'
|
||||
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
|
||||
}
|
||||
${prefersReducedMotion ? '' : 'animate-scale-hover'}
|
||||
`}
|
||||
aria-label={`Navigate to ${item.label}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
19
src/main.tsx
19
src/main.tsx
@@ -1,7 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
import App from './App';
|
||||
import { store } from './redux/store';
|
||||
import './index.css';
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./styles/globals.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
503
src/pages/CourseViewPage/CourseViewPage.tsx
Normal file
503
src/pages/CourseViewPage/CourseViewPage.tsx
Normal file
@@ -0,0 +1,503 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Progress } from '../../components/ui/progress';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
|
||||
import { Avatar, AvatarFallback } from '../../components/ui/avatar';
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
Clock,
|
||||
Award,
|
||||
Download,
|
||||
Users,
|
||||
Video,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
PlayCircle,
|
||||
File,
|
||||
Link as LinkIcon,
|
||||
MessageSquare,
|
||||
Star,
|
||||
ThumbsUp,
|
||||
Share2,
|
||||
Bookmark,
|
||||
ChevronRight,
|
||||
Menu
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
programmeId: string;
|
||||
programmeName: string;
|
||||
description: string;
|
||||
instructor: {
|
||||
name: string;
|
||||
title: string;
|
||||
avatar?: string;
|
||||
};
|
||||
duration: string;
|
||||
enrolledCount: number;
|
||||
rating: number;
|
||||
progress: number;
|
||||
status: 'Not Started' | 'In Progress' | 'Completed';
|
||||
modules: CourseModule[];
|
||||
resources: Resource[];
|
||||
discussions: Discussion[];
|
||||
}
|
||||
|
||||
interface CourseModule {
|
||||
id: string;
|
||||
title: string;
|
||||
duration: string;
|
||||
type: 'video' | 'reading' | 'quiz' | 'assignment';
|
||||
status: 'locked' | 'available' | 'completed';
|
||||
content?: string;
|
||||
videoUrl?: string;
|
||||
}
|
||||
|
||||
interface Resource {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'pdf' | 'video' | 'link' | 'document';
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Discussion {
|
||||
id: string;
|
||||
user: {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
content: string;
|
||||
timestamp: string;
|
||||
likes: number;
|
||||
replies: number;
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockCourse: Course = {
|
||||
id: '1',
|
||||
title: 'Strategic Thinking for Leaders',
|
||||
programmeId: 'p1',
|
||||
programmeName: 'Leadership Development Program',
|
||||
description: 'Learn how to develop strategic thinking capabilities, analyze complex business situations, and make decisions that drive organizational success.',
|
||||
instructor: {
|
||||
name: 'Prof. Michael Chen',
|
||||
title: 'Senior Leadership Coach',
|
||||
},
|
||||
duration: '4 weeks',
|
||||
enrolledCount: 42,
|
||||
rating: 4.8,
|
||||
progress: 65,
|
||||
status: 'In Progress',
|
||||
modules: [
|
||||
{
|
||||
id: 'm1',
|
||||
title: 'Introduction to Strategic Thinking',
|
||||
duration: '45 min',
|
||||
type: 'video',
|
||||
status: 'completed',
|
||||
videoUrl: '#'
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
title: 'Strategic Analysis Frameworks',
|
||||
duration: '60 min',
|
||||
type: 'video',
|
||||
status: 'completed',
|
||||
videoUrl: '#'
|
||||
},
|
||||
{
|
||||
id: 'm3',
|
||||
title: 'Decision Making Models',
|
||||
duration: '90 min',
|
||||
type: 'reading',
|
||||
status: 'available'
|
||||
},
|
||||
{
|
||||
id: 'm4',
|
||||
title: 'Case Study Analysis',
|
||||
duration: '120 min',
|
||||
type: 'assignment',
|
||||
status: 'available'
|
||||
},
|
||||
{
|
||||
id: 'm5',
|
||||
title: 'Strategic Planning Quiz',
|
||||
duration: '30 min',
|
||||
type: 'quiz',
|
||||
status: 'locked'
|
||||
}
|
||||
],
|
||||
resources: [
|
||||
{ id: 'r1', title: 'Strategic Analysis Template', type: 'document', url: '#' },
|
||||
{ id: 'r2', title: 'Decision Matrix Worksheet', type: 'pdf', url: '#' },
|
||||
{ id: 'r3', title: 'Case Study Materials', type: 'pdf', url: '#' }
|
||||
],
|
||||
discussions: [
|
||||
{
|
||||
id: 'd1',
|
||||
user: { name: 'Sarah Chen' },
|
||||
content: 'The SWOT analysis framework was really helpful. I\'ve already started applying it to my projects.',
|
||||
timestamp: '2 hours ago',
|
||||
likes: 12,
|
||||
replies: 3
|
||||
},
|
||||
{
|
||||
id: 'd2',
|
||||
user: { name: 'David Kim' },
|
||||
content: 'Can someone explain the difference between strategic and operational decisions?',
|
||||
timestamp: '5 hours ago',
|
||||
likes: 5,
|
||||
replies: 8
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const CourseViewPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [course, setCourse] = useState<Course | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [selectedModule, setSelectedModule] = useState<CourseModule | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate API call
|
||||
const timer = setTimeout(() => {
|
||||
setCourse(mockCourse);
|
||||
setSelectedModule(mockCourse.modules[0]);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 w-64 bg-muted animate-pulse rounded" />
|
||||
<div className="h-32 bg-muted animate-pulse rounded" />
|
||||
<div className="h-64 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!course) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<AlertCircle className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h2 className="text-xl font-semibold mb-2">Course not found</h2>
|
||||
<p className="text-muted-foreground mb-4">The course you're looking for doesn't exist.</p>
|
||||
<Button onClick={() => navigate('/dashboard')}>Back to Dashboard</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getModuleIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'video': return <Video className="h-5 w-5" />;
|
||||
case 'reading': return <FileText className="h-5 w-5" />;
|
||||
case 'quiz': return <Award className="h-5 w-5" />;
|
||||
case 'assignment': return <BookOpen className="h-5 w-5" />;
|
||||
default: return <File className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/programme/${course.programmeId}`)}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Programme
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{course.programmeName}</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span className="text-foreground">{course.title}</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mt-1">{course.title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Bookmark className="h-4 w-4 mr-2" />
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Share2 className="h-4 w-4 mr-2" />
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Course Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Your Progress</p>
|
||||
<p className="text-2xl font-bold">{course.progress}%</p>
|
||||
</div>
|
||||
<Progress value={course.progress} className="w-16 h-16" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Duration</p>
|
||||
<p className="text-2xl font-bold">{course.duration}</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Enrolled</p>
|
||||
<p className="text-2xl font-bold">{course.enrolledCount}</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Rating</p>
|
||||
<p className="text-2xl font-bold">{course.rating}/5.0</p>
|
||||
</div>
|
||||
<Star className="h-8 w-8 text-yellow-500 fill-current" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Course Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Course Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>About this Course</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{course.description}</p>
|
||||
|
||||
<div className="flex items-center gap-4 mt-4 pt-4 border-t">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarFallback>
|
||||
{course.instructor.name.split(' ').map(n => n[0]).join('')}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{course.instructor.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{course.instructor.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">{course.status}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Course Modules */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Course Modules</CardTitle>
|
||||
<CardDescription>{course.modules.length} modules • {course.duration} total</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{course.modules.map((module, index) => (
|
||||
<div
|
||||
key={module.id}
|
||||
className={`p-4 rounded-lg border cursor-pointer transition-all ${
|
||||
selectedModule?.id === module.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:border-primary/50'
|
||||
}`}
|
||||
onClick={() => setSelectedModule(module)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
module.status === 'completed'
|
||||
? 'bg-green-100 text-green-600'
|
||||
: module.status === 'available'
|
||||
? 'bg-blue-100 text-blue-600'
|
||||
: 'bg-gray-100 text-gray-400'
|
||||
}`}>
|
||||
{module.status === 'completed' ? (
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
) : (
|
||||
getModuleIcon(module.type)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">{module.title}</h4>
|
||||
<Badge variant={module.status === 'locked' ? 'outline' : 'secondary'}>
|
||||
{module.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{module.duration}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="capitalize">{module.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Discussions Preview */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Discussions</CardTitle>
|
||||
<CardDescription>Join the conversation</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{course.discussions.map(discussion => (
|
||||
<div key={discussion.id} className="border-b last:border-0 pb-4 last:pb-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarFallback>
|
||||
{discussion.user.name.split(' ').map(n => n[0]).join('')}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-medium">{discussion.user.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{discussion.timestamp}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm mb-2">{discussion.content}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<button className="flex items-center gap-1 hover:text-foreground">
|
||||
<ThumbsUp className="h-3 w-3" />
|
||||
{discussion.likes}
|
||||
</button>
|
||||
<button className="flex items-center gap-1 hover:text-foreground">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{discussion.replies} replies
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Current Module View */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="sticky top-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Current Module</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedModule?.title}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedModule && (
|
||||
<>
|
||||
<div className="aspect-video bg-muted rounded-lg flex items-center justify-center">
|
||||
{selectedModule.type === 'video' ? (
|
||||
<PlayCircle className="h-12 w-12 text-muted-foreground" />
|
||||
) : (
|
||||
<FileText className="h-12 w-12 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Type:</span>
|
||||
<span className="capitalize">{selectedModule.type}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span>{selectedModule.duration}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Status:</span>
|
||||
<Badge variant={
|
||||
selectedModule.status === 'completed' ? 'default' :
|
||||
selectedModule.status === 'available' ? 'secondary' : 'outline'
|
||||
}>
|
||||
{selectedModule.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={selectedModule.status === 'locked'}
|
||||
>
|
||||
{selectedModule.status === 'completed' ? 'Review Module' :
|
||||
selectedModule.status === 'available' ? 'Start Module' :
|
||||
'Locked'}
|
||||
</Button>
|
||||
|
||||
{/* Resources */}
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="font-medium mb-3">Resources</h4>
|
||||
<div className="space-y-2">
|
||||
{course.resources.map(resource => (
|
||||
<a
|
||||
key={resource.id}
|
||||
href={resource.url}
|
||||
className="flex items-center gap-2 p-2 rounded-lg hover:bg-muted transition-colors"
|
||||
>
|
||||
{resource.type === 'pdf' && <FileText className="h-4 w-4" />}
|
||||
{resource.type === 'video' && <Video className="h-4 w-4" />}
|
||||
{resource.type === 'link' && <LinkIcon className="h-4 w-4" />}
|
||||
{resource.type === 'document' && <File className="h-4 w-4" />}
|
||||
<span className="text-sm flex-1">{resource.title}</span>
|
||||
<Download className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseViewPage;
|
||||
93
src/pages/Courses/CoursesPage.tsx
Normal file
93
src/pages/Courses/CoursesPage.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { Calendar, Clock, Lock } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Progress } from '../../components/ui/progress';
|
||||
import { useGetAssignedHrCoursesQuery } from '../../redux/services/learnersApi';
|
||||
|
||||
const CoursesPage: React.FC = () => {
|
||||
const { data, isLoading, isError } = useGetAssignedHrCoursesQuery({
|
||||
limit: 10,
|
||||
start: 0,
|
||||
});
|
||||
|
||||
const courseCards = data?.data.course_items ?? [];
|
||||
const totalCourses = data?.data.pagination.total ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">My Courses</h1>
|
||||
|
||||
<Card className="border border-indigo-100 bg-gradient-to-br from-indigo-50 via-white to-cyan-50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-2xl text-indigo-900">Assigned Courses</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{totalCourses} courses assigned by your organization.</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading courses...</p>}
|
||||
|
||||
{isError && <p className="text-sm text-red-600">Failed to load courses. Please try again.</p>}
|
||||
|
||||
{!isLoading && !isError && courseCards.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No courses found.</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{courseCards.map((course) => (
|
||||
<Card key={course.id} className="h-full border border-indigo-100 bg-white/95">
|
||||
<CardContent className="flex h-full flex-col space-y-3 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<img
|
||||
src={course.thumbnail_url}
|
||||
alt={course.course_name}
|
||||
className="h-20 w-20 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-xl font-semibold leading-tight text-indigo-950">{course.course_name}</h3>
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">{course.course_description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
Duration: {course.total_duration}h
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Learners: {course.total_learners}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Status: {course.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">Progress</span>
|
||||
<span className="font-medium">{course.avg_progress}%</span>
|
||||
</div>
|
||||
<Progress value={course.avg_progress} className="h-2 bg-slate-200" />
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-1">
|
||||
{course.status === 'new' ? (
|
||||
<Button className="w-full bg-slate-200 text-slate-700 hover:bg-slate-300">
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Not Started
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-full bg-[#061a72] text-white hover:bg-[#051458]" style={{backgroundColor: '#061a72', color: 'white'}}>View Course</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoursesPage;
|
||||
164
src/pages/Dashboard/DashboardPage.tsx
Normal file
164
src/pages/Dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
import { ProgrammesTable } from '../../components/ProgrammesTable';
|
||||
import { ProgrammeSchedule } from '../../components/ProgrammeSchedule';
|
||||
import { LearningAnalyticsTable } from '../../components/LearningAnalyticsTable';
|
||||
import { DiscussionForumFeed } from '../../components/DiscussionForumFeed';
|
||||
import { Skeleton } from '../../components/ui/skeleton';
|
||||
import { Plus, BookOpen, Download, MessageSquare } from 'lucide-react';
|
||||
import { mockKPIData } from '../../utils/mockData';
|
||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||
import { AnnouncementsPanel } from '../../components/shared/KPICard';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [prefersReducedMotion] = useLocalStorage('prefersReducedMotion', false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setLoading(false), 800);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Helper function to determine user access level
|
||||
const getUserAccessLevel = (): 'full' | 'course-only' => {
|
||||
return 'full'; // Default to full access
|
||||
};
|
||||
|
||||
const handleViewProgramme = (programmeId: string) => {
|
||||
navigate(`/hr/programme/${programmeId}`);
|
||||
};
|
||||
|
||||
const handleViewCourse = (courseId: string) => {
|
||||
navigate(`/hr/course/${courseId}`);
|
||||
};
|
||||
|
||||
const handleNavigate = (path: string, params?: any) => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Welcome Section */}
|
||||
<div className={`space-y-4 ${prefersReducedMotion ? '' : 'animate-fade-in'}`}>
|
||||
<div className="space-y-2">
|
||||
<h1>Welcome Priya 👋</h1>
|
||||
<p className="text-muted-foreground">Manage programmes, track progress, and stay connected with your learning community</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{mockKPIData.map((kpi, index) => (
|
||||
<AnnouncementsPanel
|
||||
key={index}
|
||||
data={kpi}
|
||||
onClick={() => handleNavigate('/hr/reports')}
|
||||
className={prefersReducedMotion ? '' : 'animate-slide-up'}
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Section */}
|
||||
<Card className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '200ms' }}>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
<CardDescription>Common HR tasks and shortcuts</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ title: 'Add Learners', icon: Plus, action: () => handleNavigate('/hr/learners', { action: 'add' }) },
|
||||
{ title: 'Assign', icon: BookOpen, action: () => handleNavigate('/hr/learners', { action: 'assign' }) },
|
||||
{ title: 'Download Reports', icon: Download, action: () => handleNavigate('/hr/reports') },
|
||||
{ title: 'Submit Testimonial', icon: MessageSquare, action: () => handleNavigate('/hr/profile') }
|
||||
].map((link, index) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={link.action}
|
||||
className={`
|
||||
flex flex-col items-center justify-center p-6 bg-muted/50 hover:bg-muted rounded-lg
|
||||
transition-all duration-200 min-h-[120px] min-w-[120px] gap-3 min-tap-44
|
||||
${prefersReducedMotion ? '' : 'animate-scale-hover'}
|
||||
`}
|
||||
aria-label={link.title}
|
||||
>
|
||||
<Icon className="h-6 w-6 text-brand-primary" />
|
||||
<span className="text-sm font-medium text-center">{link.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Programmes Table */}
|
||||
<div className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '400ms' }}>
|
||||
<ProgrammesTable
|
||||
onViewProgramme={handleViewProgramme}
|
||||
onViewCourse={handleViewCourse}
|
||||
onAssignLearners={(programmeId) => console.log(`Assign learners to: ${programmeId}`)}
|
||||
onDownloadTracker={(programmeId) => console.log(`Download tracker for: ${programmeId}`)}
|
||||
userAccessLevel={getUserAccessLevel()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Programme Schedule - Horizontal layout below programmes */}
|
||||
<div className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '500ms' }}>
|
||||
<ProgrammeSchedule
|
||||
onEventClick={(event) => console.log(`Open event: ${event.title}`)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Learning Analytics - Full Width */}
|
||||
<div className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '600ms' }}>
|
||||
<LearningAnalyticsTable
|
||||
onViewLearner={(learnerId) => handleNavigate('/hr/learners', { editEmployee: learnerId })}
|
||||
onNudgeLearner={(learnerId) => console.log(`Nudge learner: ${learnerId}`)}
|
||||
onViewAllAnalytics={(programmeId) => handleNavigate('/hr/reports', { programme: programmeId })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Discussion Forum Feed */}
|
||||
<div className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '800ms' }}>
|
||||
<DiscussionForumFeed
|
||||
onOpenThread={(threadId) => handleNavigate('/hr/discussions', { thread: threadId })}
|
||||
onMarkAsRead={(threadId) => console.log(`Mark as read: ${threadId}`)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
318
src/pages/DiscussionsPage/DiscussionsPage.tsx
Normal file
318
src/pages/DiscussionsPage/DiscussionsPage.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Card, CardContent } from '../../components/ui/card';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Textarea } from '../../components/ui/textarea';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../components/ui/dialog';
|
||||
import { AlertCircle, Calendar, MessageCircle, Plus, Search, User, X } from 'lucide-react';
|
||||
import { useCreateThreadMutation, useGetThreadsQuery } from '../../redux/services/forumApi';
|
||||
import { useToast } from '../../components/toast/useToast';
|
||||
import DiscussionsView from './DiscussionsView';
|
||||
|
||||
const DiscussionsPage: React.FC = () => {
|
||||
const { showToast } = useToast();
|
||||
const [showNewThreadModal, setShowNewThreadModal] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [tagsFilter, setTagsFilter] = useState('all');
|
||||
const [newThreadTitle, setNewThreadTitle] = useState('');
|
||||
const [newThreadContent, setNewThreadContent] = useState('');
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [newThreadTags, setNewThreadTags] = useState<string[]>([]);
|
||||
const [createError, setCreateError] = useState('');
|
||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
|
||||
|
||||
const { data: threadsResponse, isLoading: threadsLoading, isFetching: threadsFetching } =
|
||||
useGetThreadsQuery();
|
||||
const [createThread, { isLoading: isCreatingThread }] = useCreateThreadMutation();
|
||||
|
||||
const threads = threadsResponse?.data ?? [];
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
threads.forEach((thread) => {
|
||||
thread.tags.forEach((tag) => set.add(tag));
|
||||
});
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
}, [threads]);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
return threads.filter((thread) => {
|
||||
const query = searchTerm.trim().toLowerCase();
|
||||
const matchesSearch =
|
||||
!query ||
|
||||
thread.title.toLowerCase().includes(query) ||
|
||||
thread.content.toLowerCase().includes(query) ||
|
||||
thread.tags.some((tag) => tag.toLowerCase().includes(query));
|
||||
const matchesTag = tagsFilter === 'all' || thread.tags.includes(tagsFilter);
|
||||
return matchesSearch && matchesTag;
|
||||
});
|
||||
}, [threads, searchTerm, tagsFilter]);
|
||||
|
||||
const selectedThread = useMemo(
|
||||
() => threads.find((thread) => thread.id === selectedThreadId) ?? null,
|
||||
[threads, selectedThreadId]
|
||||
);
|
||||
|
||||
const getThreadReactionCount = (thread: (typeof threads)[number]) =>
|
||||
thread.reactions.reduce((sum, r) => sum + r.count, 0);
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString('en-GB');
|
||||
};
|
||||
|
||||
const resetModal = () => {
|
||||
setNewThreadTitle('');
|
||||
setNewThreadContent('');
|
||||
setTagInput('');
|
||||
setNewThreadTags([]);
|
||||
setCreateError('');
|
||||
};
|
||||
|
||||
const addTag = (rawTag: string) => {
|
||||
const value = rawTag.trim().toLowerCase();
|
||||
if (!value) return;
|
||||
if (newThreadTags.includes(value)) return;
|
||||
setNewThreadTags((prev) => [...prev, value]);
|
||||
};
|
||||
|
||||
const handleTagKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addTag(tagInput);
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateThread = async () => {
|
||||
setCreateError('');
|
||||
if (!newThreadTitle.trim() || !newThreadContent.trim()) {
|
||||
setCreateError('Title and content are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await createThread({
|
||||
title: newThreadTitle.trim(),
|
||||
content: newThreadContent.trim(),
|
||||
tags: newThreadTags,
|
||||
}).unwrap();
|
||||
showToast('Thread created', response.message || 'Thread created successfully.', 'success');
|
||||
setShowNewThreadModal(false);
|
||||
resetModal();
|
||||
} catch (error: any) {
|
||||
const message = error?.data?.message || 'Failed to create thread.';
|
||||
setCreateError(message);
|
||||
showToast('Create failed', message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{selectedThread ? (
|
||||
<DiscussionsView thread={selectedThread} onBack={() => setSelectedThreadId(null)} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight">Discussion Forums</h1>
|
||||
<p className="text-muted-foreground">Connect, share, and learn with your cohort members</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowNewThreadModal(true)} className="min-tap-44">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Thread
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center gap-3 flex-nowrap">
|
||||
<Select defaultValue="leadership-development-q4-2024">
|
||||
<SelectTrigger className="w-[300px] shrink-0">
|
||||
<SelectValue placeholder="Programme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="leadership-development-q4-2024">Leadership Development Q4 2024</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search threads..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{ paddingLeft: '30px' }}
|
||||
/>
|
||||
</div>
|
||||
<Select value={tagsFilter} onValueChange={setTagsFilter}>
|
||||
<SelectTrigger className="w-[170px] shrink-0">
|
||||
<SelectValue placeholder="All Tags" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Tags</SelectItem>
|
||||
{allTags.map((tag) => (
|
||||
<SelectItem key={tag} value={tag}>
|
||||
{tag}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Discussions ({filteredThreads.length})</h2>
|
||||
<p className="text-muted-foreground">Leadership Development Q4 2024 • 30 members</p>
|
||||
</div>
|
||||
|
||||
{(threadsLoading || threadsFetching) && (
|
||||
<div className="text-sm text-muted-foreground">Loading threads...</div>
|
||||
)}
|
||||
|
||||
{!threadsLoading && filteredThreads.length === 0 && (
|
||||
<div className="rounded-md border p-6 text-sm text-muted-foreground">No threads found.</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredThreads.map((thread) => (
|
||||
<button
|
||||
key={thread.id}
|
||||
type="button"
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm transition hover:bg-slate-50 cursor-pointer"
|
||||
onClick={() => setSelectedThreadId(thread.id)}
|
||||
style={{borderRadius: '12px'}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
||||
L
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pl-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-xl font-semibold leading-tight">{thread.title}</h3>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">{thread.content}</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="h-4 w-4" />
|
||||
Learner
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{formatDate(thread.created_at)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
{thread.reactions.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{thread.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={showNewThreadModal}
|
||||
onOpenChange={(open: boolean) => {
|
||||
setShowNewThreadModal(open);
|
||||
if (!open) resetModal();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Start New Discussion</DialogTitle>
|
||||
<DialogDescription>Create a new thread for this forum.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{createError && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Title *</label>
|
||||
<Input
|
||||
value={newThreadTitle}
|
||||
onChange={(e) => setNewThreadTitle(e.target.value)}
|
||||
placeholder="What would you like to discuss?"
|
||||
maxLength={150}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Content *</label>
|
||||
<Textarea
|
||||
value={newThreadContent}
|
||||
onChange={(e) => setNewThreadContent(e.target.value)}
|
||||
placeholder="Share your thoughts..."
|
||||
className="min-h-[140px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Tags</label>
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={handleTagKeyDown}
|
||||
placeholder="Type a tag and press Enter"
|
||||
/>
|
||||
{newThreadTags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{newThreadTags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="gap-1">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setNewThreadTags((prev) => prev.filter((existingTag) => existingTag !== tag))
|
||||
}
|
||||
aria-label={`Remove ${tag}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleCreateThread}
|
||||
disabled={isCreatingThread || !newThreadTitle.trim() || !newThreadContent.trim()}
|
||||
>
|
||||
{isCreatingThread ? 'Creating...' : 'Create Thread'}
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1" onClick={() => setShowNewThreadModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscussionsPage;
|
||||
356
src/pages/DiscussionsPage/DiscussionsView.tsx
Normal file
356
src/pages/DiscussionsPage/DiscussionsView.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Ellipsis, MessageCircle, RefreshCw, Send, ThumbsUp } from 'lucide-react';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Card, CardContent } from '../../components/ui/card';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import {
|
||||
useGetRepliesByThreadQuery,
|
||||
useReactToForumItemMutation,
|
||||
useReplyToThreadMutation,
|
||||
type ForumReply,
|
||||
type ForumThread,
|
||||
} from '../../redux/services/forumApi';
|
||||
import { useToast } from '../../components/toast/useToast';
|
||||
|
||||
interface DiscussionsViewProps {
|
||||
thread: ForumThread;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const formatDate = (date?: string) => {
|
||||
if (!date) return '-';
|
||||
const parsed = new Date(date);
|
||||
return Number.isNaN(parsed.getTime()) ? '-' : parsed.toLocaleDateString('en-GB');
|
||||
};
|
||||
|
||||
const getLikeCount = (thread: ForumThread) =>
|
||||
thread.reactions.find((r) => r.emoji_code === 'U+1F44D')?.count ?? 0;
|
||||
|
||||
const getReactionCount = (thread: ForumThread) => thread.reactions.reduce((sum, r) => sum + r.count, 0);
|
||||
const reactionOptions = [
|
||||
{ emoji: 'U+1F44D', label: '👍' },
|
||||
{ emoji: 'U+1F602', label: '😂' },
|
||||
{ emoji: 'U+1F60D', label: '😍' },
|
||||
];
|
||||
|
||||
export const DiscussionsView: React.FC<DiscussionsViewProps> = ({ thread, onBack }) => {
|
||||
const { showToast } = useToast();
|
||||
const [replyInput, setReplyInput] = useState('');
|
||||
const [childReplyInputById, setChildReplyInputById] = useState<Record<string, string>>({});
|
||||
const [openChildReplyForId, setOpenChildReplyForId] = useState<string | null>(null);
|
||||
const [openThreadReactionPicker, setOpenThreadReactionPicker] = useState(false);
|
||||
const [openReplyReactionPickerId, setOpenReplyReactionPickerId] = useState<string | null>(null);
|
||||
const threadReactionPickerRef = useRef<HTMLDivElement | null>(null);
|
||||
const replyReactionPickerRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const { data: repliesResponse, isLoading: repliesLoading, refetch: refetchReplies } =
|
||||
useGetRepliesByThreadQuery(thread.id);
|
||||
const [replyToThread, { isLoading: postingReply }] = useReplyToThreadMutation();
|
||||
const [reactToForumItem, { isLoading: reacting }] = useReactToForumItemMutation();
|
||||
|
||||
const normalizeReplies = (items: ForumReply[]): ForumReply[] =>
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
replies: normalizeReplies(item.replies ?? item.children ?? []),
|
||||
}));
|
||||
|
||||
const replies = useMemo(() => normalizeReplies(repliesResponse?.data ?? []), [repliesResponse]);
|
||||
|
||||
const replyCount = useMemo(() => {
|
||||
const countNodes = (nodes: ForumReply[]): number =>
|
||||
nodes.reduce((sum, node) => sum + 1 + countNodes(node.replies ?? []), 0);
|
||||
return countNodes(replies);
|
||||
}, [replies]);
|
||||
|
||||
const getReplyReactionCount = (reply: ForumReply) =>
|
||||
(reply.reactions ?? []).reduce((sum, r) => sum + r.count, 0);
|
||||
const getReactionLabel = (reactionCode: string) =>
|
||||
reactionOptions.find((opt) => opt.emoji === reactionCode)?.label ?? '🙂';
|
||||
|
||||
const postReply = async (content: string, parentId?: string) => {
|
||||
if (!content.trim()) return;
|
||||
try {
|
||||
const response = await replyToThread({
|
||||
threadId: thread.id,
|
||||
content: content.trim(),
|
||||
parent_id: parentId || undefined,
|
||||
}).unwrap();
|
||||
showToast('Reply added', response.message || 'Reply added successfully.', 'success');
|
||||
if (parentId) {
|
||||
setChildReplyInputById((prev) => ({ ...prev, [parentId]: '' }));
|
||||
setOpenChildReplyForId(null);
|
||||
} else {
|
||||
setReplyInput('');
|
||||
}
|
||||
await refetchReplies();
|
||||
} catch (error: any) {
|
||||
const message = error?.data?.message || 'Failed to post reply.';
|
||||
showToast('Reply failed', message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const reactToThread = async (emoji: string) => {
|
||||
try {
|
||||
await reactToForumItem({
|
||||
emoji,
|
||||
thread_id: thread.id,
|
||||
}).unwrap();
|
||||
await refetchReplies();
|
||||
setOpenThreadReactionPicker(false);
|
||||
showToast('Reaction added', 'Your reaction was recorded.', 'success');
|
||||
} catch (error: any) {
|
||||
showToast('Reaction failed', error?.data?.message || 'Could not react right now.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const reactToReply = async (replyId: string, emoji: string) => {
|
||||
try {
|
||||
await reactToForumItem({
|
||||
emoji,
|
||||
reply_id: replyId,
|
||||
}).unwrap();
|
||||
await refetchReplies();
|
||||
setOpenReplyReactionPickerId(null);
|
||||
showToast('Reaction added', 'Your reaction was recorded.', 'success');
|
||||
} catch (error: any) {
|
||||
showToast('Reaction failed', error?.data?.message || 'Could not react right now.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (event: Event) => {
|
||||
const target = event.target as Node | null;
|
||||
if (!target) return;
|
||||
|
||||
if (openThreadReactionPicker && threadReactionPickerRef.current && !threadReactionPickerRef.current.contains(target)) {
|
||||
setOpenThreadReactionPicker(false);
|
||||
}
|
||||
|
||||
if (openReplyReactionPickerId) {
|
||||
const activeReplyRef = replyReactionPickerRefs.current[openReplyReactionPickerId];
|
||||
if (activeReplyRef && !activeReplyRef.contains(target)) {
|
||||
setOpenReplyReactionPickerId(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handleOutsideClick, true);
|
||||
document.addEventListener('touchstart', handleOutsideClick, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handleOutsideClick, true);
|
||||
document.removeEventListener('touchstart', handleOutsideClick, true);
|
||||
};
|
||||
}, [openThreadReactionPicker, openReplyReactionPickerId]);
|
||||
|
||||
const renderReplies = (items: ForumReply[], depth = 0): React.ReactNode =>
|
||||
items.map((reply) => {
|
||||
const childReplyText = childReplyInputById[reply.id] ?? '';
|
||||
const children = reply.replies ?? [];
|
||||
const isChildBoxOpen = openChildReplyForId === reply.id;
|
||||
return (
|
||||
<div
|
||||
key={reply.id}
|
||||
className={'border-b pb-4 last:border-b-0' + (depth > 0 ? ' ml-6 mt-3 border-l pl-4' : '')}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">HR User</span>
|
||||
<span>{formatDate(reply.created_at)}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||
<Ellipsis className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-3 text-sm">{reply.content}</p>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<div
|
||||
className="relative"
|
||||
ref={(el) => {
|
||||
replyReactionPickerRefs.current[reply.id] = el;
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-1 outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 hover:text-foreground"
|
||||
onClick={() =>
|
||||
setOpenReplyReactionPickerId((prev) => (prev === reply.id ? null : reply.id))
|
||||
}
|
||||
disabled={reacting}
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
<span>Like</span>
|
||||
</button>
|
||||
{openReplyReactionPickerId === reply.id && (
|
||||
<div className="absolute bottom-full left-0 z-30 mb-2 flex items-center gap-1 rounded-full border border-slate-200 bg-white p-1 shadow-xl">
|
||||
{reactionOptions.map((option) => (
|
||||
<button
|
||||
key={option.emoji + 'reply' + reply.id}
|
||||
type="button"
|
||||
className="cursor-pointer rounded-full p-1 text-lg leading-none transition hover:bg-muted"
|
||||
onClick={() => reactToReply(reply.id, option.emoji)}
|
||||
disabled={reacting}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span>{getReplyReactionCount(reply)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer hover:text-foreground"
|
||||
onClick={() => setOpenChildReplyForId(isChildBoxOpen ? null : reply.id)}
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isChildBoxOpen && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Input
|
||||
value={childReplyText}
|
||||
onChange={(e) =>
|
||||
setChildReplyInputById((prev) => ({ ...prev, [reply.id]: e.target.value }))
|
||||
}
|
||||
placeholder="Write a sub-reply..."
|
||||
/>
|
||||
<Button
|
||||
onClick={() => postReply(childReplyText, reply.id)}
|
||||
disabled={postingReply || !childReplyText.trim()}
|
||||
>
|
||||
Post
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children.length > 0 && <div className="mt-2">{renderReplies(children, depth + 1)}</div>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Button variant="ghost" onClick={onBack} className="h-auto cursor-pointer p-0 font-medium">
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Back to Forums
|
||||
</Button>
|
||||
<span className="text-muted-foreground">Leadership Development Q4 2024</span>
|
||||
<span className="text-muted-foreground">›</span>
|
||||
<span className="text-muted-foreground">Thread</span>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-4 py-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 className="font-semibold leading-tight">{thread.title}</h2>
|
||||
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||
<Ellipsis className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>By HR User</span>
|
||||
<span>•</span>
|
||||
<span>{formatDate(thread.created_at)}</span>
|
||||
<span>•</span>
|
||||
<span>{replyCount} replies</span>
|
||||
</div>
|
||||
|
||||
<p className="text-lg">{thread.content}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{thread.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
#{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-t pt-4 text-sm text-muted-foreground">
|
||||
<div className="relative" ref={threadReactionPickerRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-1 outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 hover:text-foreground"
|
||||
onClick={() => setOpenThreadReactionPicker((prev) => !prev)}
|
||||
disabled={reacting}
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
<span>Like</span>
|
||||
</button>
|
||||
{openThreadReactionPicker && (
|
||||
<div className="absolute bottom-full left-0 z-30 mb-2 flex items-center gap-1 rounded-full border border-slate-200 bg-white p-1 shadow-xl">
|
||||
{reactionOptions.map((option) => (
|
||||
<button
|
||||
key={option.emoji + 'thread' + thread.id}
|
||||
type="button"
|
||||
className="cursor-pointer rounded-full p-1 text-2xl leading-none transition hover:bg-muted"
|
||||
onClick={() => reactToThread(option.emoji)}
|
||||
disabled={reacting}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span>{getReactionCount(thread)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-4 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-2xl font-semibold">Replies ({replyCount})</h3>
|
||||
<Button variant="outline" size="sm" onClick={() => refetchReplies()} disabled={repliesLoading}>
|
||||
<RefreshCw className={'mr-2 h-4 w-4' + (repliesLoading ? ' animate-spin' : '')} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{repliesLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading replies...</div>
|
||||
) : replies.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No replies yet.</div>
|
||||
) : (
|
||||
renderReplies(replies)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-4 py-6">
|
||||
<h3 className="text-2xl font-semibold">Add Reply</h3>
|
||||
<Input
|
||||
value={replyInput}
|
||||
onChange={(e) => setReplyInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (!postingReply && replyInput.trim()) {
|
||||
void postReply(replyInput);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Share your thoughts or ask a follow-up question..."
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">Press Enter to post</div>
|
||||
<Button
|
||||
className="bg-[#7a78b0] hover:bg-[#69679d]"
|
||||
onClick={() => postReply(replyInput)}
|
||||
disabled={postingReply || !replyInput.trim()}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{postingReply ? 'Posting...' : 'Post Reply'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscussionsView;
|
||||
2030
src/pages/Learners/LearnersPage.tsx
Normal file
2030
src/pages/Learners/LearnersPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
105
src/pages/Login.tsx
Normal file
105
src/pages/Login.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { Card, CardContent, CardHeader } from '../components/ui/card';
|
||||
import klcLogo from '../assets/klc-logo.png';
|
||||
|
||||
const Login = () => {
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/hr/dashboard', { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
await login(email, password);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex min-h-screen w-full flex-col items-center justify-center gap-8 px-4"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, var(--accent-1), var(--accent-1), var(--accent-1))',
|
||||
}}
|
||||
>
|
||||
<Card className="w-full max-w-[450px] shadow-lg" style={{ maxWidth: '400px' }}>
|
||||
<CardHeader className="pb-2 text-center">
|
||||
<img
|
||||
src={klcLogo}
|
||||
alt="Kautilya Leadership Centre"
|
||||
className="mx-auto h-12 w-auto"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-2">HR Portal sign in</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="email" className="font-semibold">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password" className="font-semibold">
|
||||
Password
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="lg" className="mt-2 w-full" disabled={loading}>
|
||||
{loading ? 'Logging in...' : 'Sign In'}
|
||||
</Button>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-center text-sm font-semibold text-primary hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<footer
|
||||
className="absolute bottom-0 flex h-[4%] min-h-10 w-full flex-row items-center justify-center gap-7 border-t border-border bg-muted/80 px-3"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
© {new Date().getFullYear()} Kautilya Leadership Centre
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Privacy Policy</p>
|
||||
<p className="text-sm text-muted-foreground">Terms of Service</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
519
src/pages/ProgrammeViewPage/ProgrammeViewPage.tsx
Normal file
519
src/pages/ProgrammeViewPage/ProgrammeViewPage.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Progress } from '../../components/ui/progress';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
|
||||
import { Avatar, AvatarFallback } from '../../components/ui/avatar';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Users,
|
||||
BookOpen,
|
||||
Clock,
|
||||
Calendar,
|
||||
Award,
|
||||
Download,
|
||||
UserPlus,
|
||||
BarChart3,
|
||||
MessageSquare,
|
||||
FileText,
|
||||
TrendingUp,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
PlayCircle,
|
||||
FileCheck,
|
||||
Video,
|
||||
File,
|
||||
Link as LinkIcon,
|
||||
MoreHorizontal,
|
||||
Edit,
|
||||
Trash2,
|
||||
Share2
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Programme {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
duration: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status: 'Active' | 'Upcoming' | 'Completed';
|
||||
enrolledCount: number;
|
||||
capacity: number;
|
||||
completionRate: number;
|
||||
averageScore: number;
|
||||
instructor: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
};
|
||||
modules: Module[];
|
||||
resources: Resource[];
|
||||
}
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
duration: string;
|
||||
type: 'video' | 'reading' | 'quiz' | 'assignment';
|
||||
status: 'locked' | 'available' | 'completed';
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
interface Resource {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'pdf' | 'video' | 'link' | 'document';
|
||||
url: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
interface EnrolledLearner {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
progress: number;
|
||||
status: 'Active' | 'At Risk' | 'Completed';
|
||||
lastActivity: string;
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockProgramme: Programme = {
|
||||
id: '1',
|
||||
name: 'Leadership Development Program',
|
||||
description: 'Comprehensive leadership program designed for emerging leaders to develop essential management skills, strategic thinking, and team leadership capabilities.',
|
||||
duration: '12 weeks',
|
||||
startDate: '2024-01-15',
|
||||
endDate: '2024-04-05',
|
||||
status: 'Active',
|
||||
enrolledCount: 45,
|
||||
capacity: 50,
|
||||
completionRate: 78,
|
||||
averageScore: 85,
|
||||
instructor: {
|
||||
name: 'Dr. Sarah Johnson',
|
||||
email: 'sarah.johnson@klc.edu'
|
||||
},
|
||||
modules: [
|
||||
{
|
||||
id: 'm1',
|
||||
title: 'Foundations of Leadership',
|
||||
description: 'Understanding leadership styles and core principles',
|
||||
duration: '2 weeks',
|
||||
type: 'video',
|
||||
status: 'completed',
|
||||
progress: 100
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
title: 'Strategic Thinking',
|
||||
description: 'Developing strategic mindset and decision-making',
|
||||
duration: '3 weeks',
|
||||
type: 'reading',
|
||||
status: 'completed',
|
||||
progress: 100
|
||||
},
|
||||
{
|
||||
id: 'm3',
|
||||
title: 'Team Building & Management',
|
||||
description: 'Building and leading high-performance teams',
|
||||
duration: '3 weeks',
|
||||
type: 'assignment',
|
||||
status: 'available',
|
||||
progress: 65
|
||||
},
|
||||
{
|
||||
id: 'm4',
|
||||
title: 'Communication & Influence',
|
||||
description: 'Effective communication and influencing skills',
|
||||
duration: '2 weeks',
|
||||
type: 'video',
|
||||
status: 'available',
|
||||
progress: 30
|
||||
},
|
||||
{
|
||||
id: 'm5',
|
||||
title: 'Change Management',
|
||||
description: 'Leading through organizational change',
|
||||
duration: '2 weeks',
|
||||
type: 'quiz',
|
||||
status: 'locked',
|
||||
progress: 0
|
||||
}
|
||||
],
|
||||
resources: [
|
||||
{ id: 'r1', title: 'Leadership Assessment Tool', type: 'pdf', url: '#', size: '2.5 MB' },
|
||||
{ id: 'r2', title: 'Strategic Planning Template', type: 'document', url: '#', size: '1.8 MB' },
|
||||
{ id: 'r3', title: 'Team Building Activities Guide', type: 'pdf', url: '#', size: '3.2 MB' },
|
||||
{ id: 'r4', title: 'Communication Framework Video', type: 'video', url: '#', size: '45 MB' }
|
||||
]
|
||||
};
|
||||
|
||||
const mockEnrolledLearners: EnrolledLearner[] = [
|
||||
{ id: '1', name: 'Sarah Chen', email: 'sarah.chen@company.com', progress: 92, status: 'Active', lastActivity: '2 hours ago' },
|
||||
{ id: '2', name: 'Michael Rodriguez', email: 'michael.r@company.com', progress: 78, status: 'Active', lastActivity: '1 day ago' },
|
||||
{ id: '3', name: 'Emma Thompson', email: 'emma.thompson@company.com', progress: 45, status: 'At Risk', lastActivity: '5 days ago' },
|
||||
{ id: '4', name: 'David Kim', email: 'david.kim@company.com', progress: 88, status: 'Active', lastActivity: '3 hours ago' },
|
||||
{ id: '5', name: 'Lisa Wang', email: 'lisa.wang@company.com', progress: 95, status: 'Completed', lastActivity: '1 day ago' }
|
||||
];
|
||||
|
||||
const ProgrammeViewPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [programme, setProgramme] = useState<Programme | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate API call
|
||||
const timer = setTimeout(() => {
|
||||
setProgramme(mockProgramme);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 w-64 bg-muted animate-pulse rounded" />
|
||||
<div className="h-32 bg-muted animate-pulse rounded" />
|
||||
<div className="h-64 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!programme) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<AlertCircle className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h2 className="text-xl font-semibold mb-2">Programme not found</h2>
|
||||
<p className="text-muted-foreground mb-4">The programme you're looking for doesn't exist.</p>
|
||||
<Button onClick={() => navigate('/dashboard')}>Back to Dashboard</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Active': return 'default';
|
||||
case 'Completed': return 'secondary';
|
||||
case 'locked': return 'outline';
|
||||
case 'available': return 'default';
|
||||
default: return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const getModuleIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'video': return <Video className="h-4 w-4" />;
|
||||
case 'reading': return <File className="h-4 w-4" />;
|
||||
case 'quiz': return <FileCheck className="h-4 w-4" />;
|
||||
case 'assignment': return <FileText className="h-4 w-4" />;
|
||||
default: return <BookOpen className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(-1)}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{programme.name}</h1>
|
||||
<p className="text-muted-foreground">Programme Overview</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="min-tap-44">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="min-tap-44">
|
||||
<Share2 className="h-4 w-4 mr-2" />
|
||||
Share
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="min-tap-44 text-red-600">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Programme Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Enrolled</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{programme.enrolledCount}/{programme.capacity}
|
||||
</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Completion Rate</p>
|
||||
<p className="text-2xl font-bold">{programme.completionRate}%</p>
|
||||
</div>
|
||||
<TrendingUp className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Average Score</p>
|
||||
<p className="text-2xl font-bold">{programme.averageScore}/100</p>
|
||||
</div>
|
||||
<Award className="h-8 w-8 text-yellow-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Duration</p>
|
||||
<p className="text-2xl font-bold">{programme.duration}</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Content Tabs */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="modules">Modules</TabsTrigger>
|
||||
<TabsTrigger value="learners">Learners</TabsTrigger>
|
||||
<TabsTrigger value="resources">Resources</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="space-y-6 mt-6">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Description</h3>
|
||||
<p className="text-muted-foreground">{programme.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Programme Details</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Start Date:</span>
|
||||
<span>{new Date(programme.startDate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">End Date:</span>
|
||||
<span>{new Date(programme.endDate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Status:</span>
|
||||
<Badge variant={getStatusColor(programme.status)}>
|
||||
{programme.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Instructor</h4>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarFallback>
|
||||
{programme.instructor.name.split(' ').map(n => n[0]).join('')}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{programme.instructor.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{programme.instructor.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Quick Actions</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Button variant="outline" className="h-auto py-4 flex-col gap-2">
|
||||
<UserPlus className="h-6 w-6" />
|
||||
<span>Add Learners</span>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-auto py-4 flex-col gap-2">
|
||||
<Download className="h-6 w-6" />
|
||||
<span>Export Data</span>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-auto py-4 flex-col gap-2">
|
||||
<BarChart3 className="h-6 w-6" />
|
||||
<span>Analytics</span>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-auto py-4 flex-col gap-2">
|
||||
<MessageSquare className="h-6 w-6" />
|
||||
<span>Discussions</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Modules Tab */}
|
||||
<TabsContent value="modules" className="space-y-4 mt-6">
|
||||
{programme.modules.map((module, index) => (
|
||||
<Card key={module.id}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
{getModuleIcon(module.type)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold">{module.title}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{module.description}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={getStatusColor(module.status)}>
|
||||
{module.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{module.duration}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{getModuleIcon(module.type)}
|
||||
{module.type.charAt(0).toUpperCase() + module.type.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{module.progress > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>Progress</span>
|
||||
<span>{module.progress}%</span>
|
||||
</div>
|
||||
<Progress value={module.progress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
{/* Learners Tab */}
|
||||
<TabsContent value="learners" className="space-y-4 mt-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="font-semibold">Enrolled Learners ({programme.enrolledCount})</h3>
|
||||
<Button size="sm">
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Add Learners
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{mockEnrolledLearners.map(learner => (
|
||||
<Card key={learner.id}>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarFallback>
|
||||
{learner.name.split(' ').map(n => n[0]).join('')}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{learner.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{learner.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">{learner.progress}%</p>
|
||||
<Progress value={learner.progress} className="w-24" />
|
||||
</div>
|
||||
<Badge variant={
|
||||
learner.status === 'Active' ? 'default' :
|
||||
learner.status === 'At Risk' ? 'destructive' : 'secondary'
|
||||
}>
|
||||
{learner.status}
|
||||
</Badge>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Resources Tab */}
|
||||
<TabsContent value="resources" className="space-y-4 mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{programme.resources.map(resource => (
|
||||
<Card key={resource.id}>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
{resource.type === 'pdf' && <FileText className="h-5 w-5" />}
|
||||
{resource.type === 'video' && <Video className="h-5 w-5" />}
|
||||
{resource.type === 'link' && <LinkIcon className="h-5 w-5" />}
|
||||
{resource.type === 'document' && <File className="h-5 w-5" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium">{resource.title}</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{resource.type.toUpperCase()} • {resource.size}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="sm">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgrammeViewPage;
|
||||
133
src/pages/Programmes/ProgrammesPage.tsx
Normal file
133
src/pages/Programmes/ProgrammesPage.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BookOpen, Calendar, ChevronRight, FolderOpen, Info, Presentation, Users } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Progress } from '../../components/ui/progress';
|
||||
import { useGetAssignedHrProgrammesQuery } from '../../redux/services/learnersApi';
|
||||
|
||||
const ProgrammesPage: React.FC = () => {
|
||||
const { data, isLoading, isError } = useGetAssignedHrProgrammesQuery({
|
||||
limit: 10,
|
||||
start: 0,
|
||||
});
|
||||
const [openProgrammeId, setOpenProgrammeId] = useState<string | null>(null);
|
||||
|
||||
const programmeItems = data?.data.programme_items ?? [];
|
||||
|
||||
const formatDate = (date: string) =>
|
||||
new Date(date).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">My Programs</h1>
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading programmes...</p>}
|
||||
|
||||
{isError && <p className="text-sm text-red-600">Failed to load programmes. Please try again.</p>}
|
||||
|
||||
{!isLoading && !isError && programmeItems.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No programmes found.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{programmeItems.map((programme) => {
|
||||
const isOpen = openProgrammeId === programme.id;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={programme.id}
|
||||
className="gap-0 overflow-hidden border border-violet-100 bg-gradient-to-br from-violet-50 via-white to-fuchsia-50"
|
||||
>
|
||||
<CardHeader
|
||||
className={`cursor-pointer !pt-4 !pb-4 transition-colors duration-200 ${isOpen ? 'border-b border-violet-100' : 'border-b border-transparent'}`}
|
||||
onClick={() => setOpenProgrammeId((prev) => (prev === programme.id ? null : programme.id))}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<ChevronRight
|
||||
className={`mt-1 h-5 w-5 text-violet-800 transition-transform ${isOpen ? 'rotate-90' : 'rotate-0'}`}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<CardTitle className="text-xl font-semibold leading-tight text-violet-950">{programme.programme_title}</CardTitle>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{programme.progress ?? 0}% Complete</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Ends: {formatDate(programme.end_date)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
{programme.courses} Courses
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Presentation className="h-4 w-4" />
|
||||
{programme.webinars} Webinars
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
{programme.resources} Resources
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{programme.classes} Classes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="shrink-0 border-violet-300 text-violet-800">
|
||||
<Info className="mr-2 h-4 w-4" />
|
||||
Programme Info
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<div
|
||||
className={`grid transition-all duration-300 ease-in-out ${isOpen ? 'grid-rows-[1fr] opacity-100' : 'pointer-events-none grid-rows-[0fr] opacity-0'}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<CardContent className="space-y-5 pt-4">
|
||||
<div className="space-y-2 border-b border-violet-100 pb-4">
|
||||
<h3 className="text-lg font-semibold leading-tight text-foreground">Programme Summary</h3>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<BookOpen className="h-4 w-4 text-[#061a72]" />
|
||||
{programme.courses} Courses
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<FolderOpen className="h-4 w-4 text-[#061a72]" />
|
||||
{programme.resources} Resources
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Presentation className="h-4 w-4 text-[#061a72]" />
|
||||
{programme.webinars} Webinars
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4 text-[#061a72]" />
|
||||
{programme.classes} Classes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">Progress</span>
|
||||
<span className="font-medium">{programme.progress ?? 0}%</span>
|
||||
</div>
|
||||
<Progress value={programme.progress ?? 0} className="h-2 bg-slate-200" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgrammesPage;
|
||||
574
src/pages/ReportsPage/ReportsPage.tsx
Normal file
574
src/pages/ReportsPage/ReportsPage.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Progress } from '../../components/ui/progress';
|
||||
import {
|
||||
BarChart3,
|
||||
Download,
|
||||
FileText,
|
||||
TrendingUp,
|
||||
Users,
|
||||
BookOpen,
|
||||
Clock,
|
||||
Award,
|
||||
Calendar,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
PieChart,
|
||||
LineChart,
|
||||
Table as TableIcon,
|
||||
Eye,
|
||||
Mail,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
DownloadCloud
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
LineChart as ReLineChart,
|
||||
Line,
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart as RePieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
|
||||
// Mock data for charts
|
||||
const completionTrendData = [
|
||||
{ month: 'Jan', completion: 65, enrollment: 78 },
|
||||
{ month: 'Feb', completion: 68, enrollment: 82 },
|
||||
{ month: 'Mar', completion: 72, enrollment: 85 },
|
||||
{ month: 'Apr', completion: 70, enrollment: 88 },
|
||||
{ month: 'May', completion: 75, enrollment: 92 },
|
||||
{ month: 'Jun', completion: 78, enrollment: 95 },
|
||||
{ month: 'Jul', completion: 80, enrollment: 98 },
|
||||
{ month: 'Aug', completion: 82, enrollment: 100 },
|
||||
{ month: 'Sep', completion: 85, enrollment: 102 },
|
||||
{ month: 'Oct', completion: 83, enrollment: 105 },
|
||||
{ month: 'Nov', completion: 87, enrollment: 108 },
|
||||
{ month: 'Dec', completion: 90, enrollment: 110 }
|
||||
];
|
||||
|
||||
const programmePerformanceData = [
|
||||
{ name: 'Leadership', completion: 85, enrollment: 120 },
|
||||
{ name: 'Technical', completion: 72, enrollment: 95 },
|
||||
{ name: 'Communication', completion: 88, enrollment: 80 },
|
||||
{ name: 'Project Mgmt', completion: 78, enrollment: 110 },
|
||||
{ name: 'Sales', completion: 82, enrollment: 70 }
|
||||
];
|
||||
|
||||
const learnerStatusData = [
|
||||
{ name: 'Active', value: 450, color: '#22c55e' },
|
||||
{ name: 'At Risk', value: 85, color: '#ef4444' },
|
||||
{ name: 'Completed', value: 320, color: '#3b82f6' },
|
||||
{ name: 'Pending', value: 45, color: '#eab308' }
|
||||
];
|
||||
|
||||
const activityData = [
|
||||
{ day: 'Mon', video: 45, reading: 30, quiz: 20 },
|
||||
{ day: 'Tue', video: 52, reading: 35, quiz: 25 },
|
||||
{ day: 'Wed', video: 48, reading: 42, quiz: 28 },
|
||||
{ day: 'Thu', video: 55, reading: 38, quiz: 32 },
|
||||
{ day: 'Fri', video: 50, reading: 40, quiz: 30 },
|
||||
{ day: 'Sat', video: 35, reading: 25, quiz: 15 },
|
||||
{ day: 'Sun', video: 30, reading: 20, quiz: 12 }
|
||||
];
|
||||
|
||||
const ReportsPage: React.FC = () => {
|
||||
const [dateRange, setDateRange] = useState('last-30-days');
|
||||
const [selectedProgramme, setSelectedProgramme] = useState('all');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
const programmes = [
|
||||
{ id: 'all', name: 'All Programmes' },
|
||||
{ id: 'leadership', name: 'Leadership Development' },
|
||||
{ id: 'technical', name: 'Technical Skills' },
|
||||
{ id: 'communication', name: 'Communication' },
|
||||
{ id: 'project', name: 'Project Management' }
|
||||
];
|
||||
|
||||
const handleExport = async (format: 'excel' | 'pdf' | 'csv') => {
|
||||
setExporting(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setExporting(false);
|
||||
console.log(`Exported report as ${format}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Reports & Analytics</h1>
|
||||
<p className="text-muted-foreground">Track performance, engagement, and completion metrics</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedProgramme} onValueChange={setSelectedProgramme}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Select Programme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programmes.map(p => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={dateRange} onValueChange={setDateRange}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Date Range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="last-7-days">Last 7 days</SelectItem>
|
||||
<SelectItem value="last-30-days">Last 30 days</SelectItem>
|
||||
<SelectItem value="last-90-days">Last 90 days</SelectItem>
|
||||
<SelectItem value="this-year">This Year</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleExport('excel')}
|
||||
disabled={exporting}
|
||||
className="min-tap-44"
|
||||
>
|
||||
{exporting ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<DownloadCloud className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Learners</p>
|
||||
<p className="text-2xl font-bold">1,247</p>
|
||||
<p className="text-xs text-green-600">+12% from last month</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Completion Rate</p>
|
||||
<p className="text-2xl font-bold">78%</p>
|
||||
<p className="text-xs text-green-600">+5% from last month</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Avg. Time to Complete</p>
|
||||
<p className="text-2xl font-bold">21 days</p>
|
||||
<p className="text-xs text-red-600">+2 days from last month</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-yellow-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Active Programmes</p>
|
||||
<p className="text-2xl font-bold">12</p>
|
||||
<p className="text-xs text-green-600">+3 from last month</p>
|
||||
</div>
|
||||
<BookOpen className="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Tabs */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="programmes">Programmes</TabsTrigger>
|
||||
<TabsTrigger value="learners">Learners</TabsTrigger>
|
||||
<TabsTrigger value="engagement">Engagement</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="space-y-6 mt-6">
|
||||
{/* Completion Trends */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Completion Trends</CardTitle>
|
||||
<CardDescription>Monthly completion and enrollment rates</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={completionTrendData}>
|
||||
<defs>
|
||||
<linearGradient id="completionGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="enrollmentGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#22c55e" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="completion"
|
||||
stroke="#3b82f6"
|
||||
fillOpacity={1}
|
||||
fill="url(#completionGradient)"
|
||||
name="Completion Rate (%)"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="enrollment"
|
||||
stroke="#22c55e"
|
||||
fillOpacity={1}
|
||||
fill="url(#enrollmentGradient)"
|
||||
name="Enrollment Count"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Programme Performance */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Programme Performance</CardTitle>
|
||||
<CardDescription>Completion rates by programme</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={programmePerformanceData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="completion" fill="#3b82f6" name="Completion Rate (%)" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Learner Status Distribution */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Learner Status</CardTitle>
|
||||
<CardDescription>Distribution by current status</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RePieChart>
|
||||
<Pie
|
||||
data={learnerStatusData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
>
|
||||
{learnerStatusData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</RePieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Programmes Tab */}
|
||||
<TabsContent value="programmes" className="space-y-4 mt-6">
|
||||
{programmePerformanceData.map(programme => (
|
||||
<Card key={programme.name}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold">{programme.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{programme.enrollment} enrolled learners
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{programme.completion}% Completion
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>Completion Progress</span>
|
||||
<span className="font-medium">{programme.completion}%</span>
|
||||
</div>
|
||||
<Progress value={programme.completion} className="h-2" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 pt-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Active</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{Math.floor(programme.enrollment * 0.7)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Completed</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{Math.floor(programme.enrollment * 0.25)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">At Risk</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{Math.floor(programme.enrollment * 0.05)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
{/* Learners Tab */}
|
||||
<TabsContent value="learners" className="space-y-4 mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Top Performers */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Performers</CardTitle>
|
||||
<CardDescription>Learners with highest completion rates</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="flex items-center justify-between p-2 bg-muted/30 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-medium">JD</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">John Doe</p>
|
||||
<p className="text-xs text-muted-foreground">Leadership Program</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="default">98%</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* At Risk Learners */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>At Risk Learners</CardTitle>
|
||||
<CardDescription>Learners needing intervention</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="flex items-center justify-between p-2 bg-red-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-red-600">JS</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Jane Smith</p>
|
||||
<p className="text-xs text-muted-foreground">Technical Skills</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="destructive">32%</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Learner Activity Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Learner Activity</CardTitle>
|
||||
<CardDescription>Last 7 days of engagement</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="p-3 text-left text-sm font-medium">Learner</th>
|
||||
<th className="p-3 text-left text-sm font-medium">Programme</th>
|
||||
<th className="p-3 text-left text-sm font-medium">Progress</th>
|
||||
<th className="p-3 text-left text-sm font-medium">Last Activity</th>
|
||||
<th className="p-3 text-left text-sm font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="p-3">Sarah Chen</td>
|
||||
<td className="p-3">Leadership</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={85} className="w-16" />
|
||||
<span>85%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3">2 hours ago</td>
|
||||
<td className="p-3">
|
||||
<Badge>Active</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Engagement Tab */}
|
||||
<TabsContent value="engagement" className="space-y-6 mt-6">
|
||||
{/* Activity Heatmap */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Weekly Activity Pattern</CardTitle>
|
||||
<CardDescription>Learning activity by day and type</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={activityData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="day" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="video" fill="#3b82f6" name="Video" />
|
||||
<Bar dataKey="reading" fill="#22c55e" name="Reading" />
|
||||
<Bar dataKey="quiz" fill="#eab308" name="Quiz" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Engagement Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold">85%</p>
|
||||
<p className="text-sm text-muted-foreground">Video Completion Rate</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold">4.2</p>
|
||||
<p className="text-sm text-muted-foreground">Avg. Hours/Week</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold">1,247</p>
|
||||
<p className="text-sm text-muted-foreground">Forum Posts</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Export Options */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Export Options</CardTitle>
|
||||
<CardDescription>Download reports in various formats</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto py-4 flex-col gap-2"
|
||||
onClick={() => handleExport('excel')}
|
||||
>
|
||||
<FileText className="h-6 w-6" />
|
||||
<span>Excel Report</span>
|
||||
<span className="text-xs text-muted-foreground">.xlsx with charts</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto py-4 flex-col gap-2"
|
||||
onClick={() => handleExport('pdf')}
|
||||
>
|
||||
<FileText className="h-6 w-6" />
|
||||
<span>PDF Report</span>
|
||||
<span className="text-xs text-muted-foreground">Formatted summary</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto py-4 flex-col gap-2"
|
||||
onClick={() => handleExport('csv')}
|
||||
>
|
||||
<TableIcon className="h-6 w-6" />
|
||||
<span>CSV Data</span>
|
||||
<span className="text-xs text-muted-foreground">Raw data export</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportsPage;
|
||||
6
src/redux/hooks.ts
Normal file
6
src/redux/hooks.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { TypedUseSelectorHook } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from './store';
|
||||
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
139
src/redux/services/forumApi.ts
Normal file
139
src/redux/services/forumApi.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
||||
|
||||
export interface ForumReaction {
|
||||
emoji_code: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ForumReply {
|
||||
id: string;
|
||||
content: string;
|
||||
created_at?: string;
|
||||
parent_id?: string | null;
|
||||
reactions?: ForumReaction[];
|
||||
replies?: ForumReply[];
|
||||
children?: ForumReply[];
|
||||
}
|
||||
|
||||
export interface ForumThread {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
latest_activity: string;
|
||||
reactions: ForumReaction[];
|
||||
}
|
||||
|
||||
interface ForumThreadsResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: ForumThread[];
|
||||
errors: unknown;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
interface CreateThreadRequest {
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface ForumRepliesResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: ForumReply[];
|
||||
errors: unknown;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
interface ReplyToThreadRequest {
|
||||
threadId: string;
|
||||
content: string;
|
||||
parent_id?: string;
|
||||
}
|
||||
|
||||
interface ReactToForumItemRequest {
|
||||
emoji: string;
|
||||
thread_id?: string;
|
||||
reply_id?: string;
|
||||
}
|
||||
|
||||
interface ForumActionResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: unknown;
|
||||
errors: unknown;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
export const forumApi = createApi({
|
||||
reducerPath: 'forumApi',
|
||||
tagTypes: ['Threads', 'Replies'],
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: API_BASE_URL,
|
||||
prepareHeaders: (headers) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
headers.set('authorization', `Bearer ${token}`);
|
||||
}
|
||||
return headers;
|
||||
},
|
||||
}),
|
||||
endpoints: (builder) => ({
|
||||
getThreads: builder.query<ForumThreadsResponse, void>({
|
||||
query: () => ({
|
||||
url: '/hr/forum/threads',
|
||||
method: 'GET',
|
||||
}),
|
||||
providesTags: ['Threads'],
|
||||
}),
|
||||
createThread: builder.mutation<ForumThreadsResponse, CreateThreadRequest>({
|
||||
query: (payload) => ({
|
||||
url: '/hr/forum/threads',
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
}),
|
||||
invalidatesTags: ['Threads'],
|
||||
}),
|
||||
getRepliesByThread: builder.query<ForumRepliesResponse, string>({
|
||||
query: (threadId) => ({
|
||||
url: `/hr/forum/threads/${threadId}/replies`,
|
||||
method: 'GET',
|
||||
}),
|
||||
providesTags: (_result, _error, threadId) => [{ type: 'Replies', id: threadId }],
|
||||
}),
|
||||
replyToThread: builder.mutation<ForumActionResponse, ReplyToThreadRequest>({
|
||||
query: ({ threadId, content, parent_id }) => ({
|
||||
url: `/hr/forum/threads/${threadId}/reply`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
content,
|
||||
parent_id: parent_id || undefined,
|
||||
},
|
||||
}),
|
||||
invalidatesTags: (_result, _error, arg) => [{ type: 'Replies', id: arg.threadId }],
|
||||
}),
|
||||
reactToForumItem: builder.mutation<ForumActionResponse, ReactToForumItemRequest>({
|
||||
query: (payload) => ({
|
||||
url: '/hr/forum/reactions',
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
}),
|
||||
invalidatesTags: ['Threads', 'Replies'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetThreadsQuery,
|
||||
useCreateThreadMutation,
|
||||
useGetRepliesByThreadQuery,
|
||||
useReplyToThreadMutation,
|
||||
useReactToForumItemMutation,
|
||||
} = forumApi;
|
||||
555
src/redux/services/learnersApi.ts
Normal file
555
src/redux/services/learnersApi.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
||||
|
||||
export interface LearnersForHrQueryParams {
|
||||
limit: number;
|
||||
offset: number;
|
||||
search_term?: string;
|
||||
status?: string;
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
}
|
||||
|
||||
interface LearnerPrincipalType {
|
||||
id: string;
|
||||
type_name: string;
|
||||
type_code: string;
|
||||
display_order: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface LearnerOrganization {
|
||||
id: string;
|
||||
company_name: string;
|
||||
company_phone_number: string;
|
||||
phone_country_code: string;
|
||||
address: string;
|
||||
remark: string;
|
||||
notes: string | null;
|
||||
joined_date: string;
|
||||
is_draft: boolean;
|
||||
}
|
||||
|
||||
export interface LearnerItem {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email_address: string;
|
||||
phone_country_code: string;
|
||||
phone_number: string;
|
||||
profile_image_url: string | null;
|
||||
joined_date: string;
|
||||
is_active: boolean;
|
||||
principal_type: LearnerPrincipalType;
|
||||
principal_organization: LearnerOrganization;
|
||||
}
|
||||
|
||||
interface LearnersForHrData {
|
||||
total_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
items: LearnerItem[];
|
||||
}
|
||||
|
||||
export interface LearnersForHrResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: LearnersForHrData;
|
||||
errors: unknown;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
export interface CreateLearnerRequest {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email_address: string;
|
||||
phone_country_code: string;
|
||||
phone_number: string;
|
||||
}
|
||||
|
||||
export interface CreateLearnerResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: LearnerItem;
|
||||
errors: unknown;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
export interface BulkCreateLearnersResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: LearnerItem[];
|
||||
errors: unknown;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
interface ExistsResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: {
|
||||
exists: boolean;
|
||||
};
|
||||
errors: unknown;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
export interface ProgrammeListQueryParams {
|
||||
limit: number;
|
||||
offset: number;
|
||||
search_term?: string;
|
||||
programme_status?: string;
|
||||
public_status?: string;
|
||||
}
|
||||
|
||||
interface ProgrammeItem {
|
||||
id: string;
|
||||
programme_title: string;
|
||||
programme_owner_xid: string | null;
|
||||
programme_summary: string;
|
||||
public_status: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
programme_status: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string | null;
|
||||
updated_by: string | null;
|
||||
is_deleted: boolean;
|
||||
deleted_at: string | null;
|
||||
deleted_by: string | null;
|
||||
}
|
||||
|
||||
interface ProgrammeListResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: ProgrammeItem[];
|
||||
errors: unknown;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
interface BulkAssignProgrammeRequest {
|
||||
principal_xids: string[];
|
||||
programme_xids: string[];
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
}
|
||||
|
||||
interface BulkAssignProgrammeResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: unknown;
|
||||
errors: unknown;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
export interface CourseListQueryParams {
|
||||
limit: number;
|
||||
offset: number;
|
||||
search_query?: string;
|
||||
course_category?: string[];
|
||||
price_range?: string;
|
||||
duration_range?: string;
|
||||
min_rating?: number;
|
||||
sort_by?: string;
|
||||
}
|
||||
|
||||
export interface CourseItem {
|
||||
id: string;
|
||||
course_name: string;
|
||||
course_desc: string;
|
||||
thumbnail_img: string | null;
|
||||
course_category_xid: string;
|
||||
course_category_name: string;
|
||||
best_value: number;
|
||||
avg_rating: number;
|
||||
total_reviews: number;
|
||||
retail_type: string;
|
||||
price: number;
|
||||
is_certificate_available: boolean;
|
||||
course_status: string;
|
||||
updated_at: string;
|
||||
total_duration: number | null;
|
||||
no_of_modules: number;
|
||||
media_id: string | null;
|
||||
media_file_type: string | null;
|
||||
media_file_extension: string | null;
|
||||
media_file_name: string | null;
|
||||
}
|
||||
|
||||
interface CourseListResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: {
|
||||
pagination_info: {
|
||||
total_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
applied_filters: {
|
||||
status: string | null;
|
||||
course_category_xid: string | null;
|
||||
content_types_xid: string | null;
|
||||
search_query: string | null;
|
||||
price_range: string | null;
|
||||
duration_range: string | null;
|
||||
min_rating: number | null;
|
||||
sort_by: string | null;
|
||||
};
|
||||
};
|
||||
items: CourseItem[];
|
||||
};
|
||||
errors: unknown;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
interface BulkAssignCourseRequest {
|
||||
principal_xids: string[];
|
||||
course_xids: string[];
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
principal_organization_course_link_xid?: string;
|
||||
}
|
||||
|
||||
interface BulkAssignCourseResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: unknown;
|
||||
errors: unknown;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
interface BulkRevokeCourseRequest {
|
||||
principal_xids: string[];
|
||||
course_xids: string[];
|
||||
principal_organization_course_link_xid?: string;
|
||||
}
|
||||
|
||||
interface BulkRevokeCourseResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: unknown;
|
||||
errors: unknown;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
interface AssignedCoursesResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: CourseItem[];
|
||||
errors: unknown;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
interface LearnerCourseMappingItem {
|
||||
id: string;
|
||||
principal_xid: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
company_name: string;
|
||||
course_xid: string;
|
||||
course_name: string;
|
||||
course_desc: string;
|
||||
is_hr: boolean | null;
|
||||
principal_organization_course_link_xid: string | null;
|
||||
}
|
||||
|
||||
interface LearnerCoursesResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: LearnerCourseMappingItem[];
|
||||
errors: unknown;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
interface UpdateLearnerRequest {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone_country_code: string;
|
||||
phone_number: string;
|
||||
}
|
||||
|
||||
interface UpdateLearnerResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: LearnerItem;
|
||||
errors: unknown;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
export interface AssignedHrCoursesQueryParams {
|
||||
limit: number;
|
||||
start: number;
|
||||
search_query?: string;
|
||||
status?: 'inprogress' | 'completed' | 'new';
|
||||
}
|
||||
|
||||
export interface AssignedHrCourseItem {
|
||||
id: string;
|
||||
course_name: string;
|
||||
course_description: string;
|
||||
thumbnail_url: string;
|
||||
total_duration: number;
|
||||
total_learners: number;
|
||||
avg_progress: number;
|
||||
status: 'inprogress' | 'completed' | 'new';
|
||||
}
|
||||
|
||||
interface AssignedHrCoursesResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: {
|
||||
course_items: AssignedHrCourseItem[];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
start: number;
|
||||
has_next: boolean;
|
||||
};
|
||||
};
|
||||
errors: unknown;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
export interface AssignedHrProgrammesQueryParams {
|
||||
limit: number;
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface AssignedHrProgrammeItem {
|
||||
id: string;
|
||||
programme_title: string;
|
||||
end_date: string;
|
||||
courses: number;
|
||||
webinars: number;
|
||||
resources: number;
|
||||
classes: number;
|
||||
progress: number | null;
|
||||
}
|
||||
|
||||
interface AssignedHrProgrammesResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: {
|
||||
programme_items: AssignedHrProgrammeItem[];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
start: number;
|
||||
has_next: boolean;
|
||||
};
|
||||
};
|
||||
errors: unknown;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
export const learnersApi = createApi({
|
||||
reducerPath: 'learnersApi',
|
||||
tagTypes: ['Learners'],
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: API_BASE_URL,
|
||||
prepareHeaders: (headers) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
headers.set('authorization', `Bearer ${token}`);
|
||||
}
|
||||
return headers;
|
||||
},
|
||||
}),
|
||||
endpoints: (builder) => ({
|
||||
getLearnersForHr: builder.query<LearnersForHrResponse, LearnersForHrQueryParams>({
|
||||
query: (params) => ({
|
||||
url: '/hr/learners/learners_for_hr',
|
||||
method: 'GET',
|
||||
params: {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
status: params.status ?? 'all',
|
||||
search_term: params.search_term || undefined,
|
||||
from_date: params.from_date || undefined,
|
||||
to_date: params.to_date || undefined,
|
||||
},
|
||||
}),
|
||||
providesTags: ['Learners'],
|
||||
}),
|
||||
createLearner: builder.mutation<CreateLearnerResponse, CreateLearnerRequest>({
|
||||
query: (payload) => ({
|
||||
url: '/hr/learners/create',
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
}),
|
||||
invalidatesTags: ['Learners'],
|
||||
}),
|
||||
bulkCreateLearnersForHr: builder.mutation<
|
||||
BulkCreateLearnersResponse,
|
||||
CreateLearnerRequest[]
|
||||
>({
|
||||
query: (payload) => ({
|
||||
url: '/hr/learners/bulk-create-learners-for-hr',
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
}),
|
||||
invalidatesTags: ['Learners'],
|
||||
}),
|
||||
checkEmailExists: builder.query<ExistsResponse, string>({
|
||||
query: (email) => ({
|
||||
url: '/hr/learners/check-email-exists',
|
||||
method: 'GET',
|
||||
params: { email },
|
||||
}),
|
||||
}),
|
||||
checkMobileExists: builder.query<
|
||||
ExistsResponse,
|
||||
{ phone_country_code: string; phone_number: string }
|
||||
>({
|
||||
query: ({ phone_country_code, phone_number }) => ({
|
||||
url: '/hr/learners/check-mobile-exists',
|
||||
method: 'GET',
|
||||
params: { phone_country_code, phone_number },
|
||||
}),
|
||||
}),
|
||||
getProgrammesForHr: builder.query<ProgrammeListResponse, ProgrammeListQueryParams>({
|
||||
query: (params) => ({
|
||||
url: '/hr/organization/list/assigned-programmes',
|
||||
method: 'GET',
|
||||
params: {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
search_term: params.search_term || undefined,
|
||||
programme_status: params.programme_status || undefined,
|
||||
public_status: params.public_status || undefined,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
bulkAssignProgramme: builder.mutation<
|
||||
BulkAssignProgrammeResponse,
|
||||
BulkAssignProgrammeRequest
|
||||
>({
|
||||
query: (payload) => ({
|
||||
url: '/hr/organization/bulk-assign-programme',
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
}),
|
||||
invalidatesTags: ['Learners'],
|
||||
}),
|
||||
getCoursesForHr: builder.query<CourseListResponse, CourseListQueryParams>({
|
||||
query: (params) => ({
|
||||
url: '/hr/programme-course/course/list',
|
||||
method: 'GET',
|
||||
params: {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
search_query: params.search_query || undefined,
|
||||
course_category: params.course_category?.length ? params.course_category : undefined,
|
||||
price_range: params.price_range || undefined,
|
||||
duration_range: params.duration_range || undefined,
|
||||
min_rating: params.min_rating ?? undefined,
|
||||
sort_by: params.sort_by || undefined,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
bulkAssignCourse: builder.mutation<BulkAssignCourseResponse, BulkAssignCourseRequest>({
|
||||
query: (payload) => ({
|
||||
url: '/hr/organization/bulk-assign-course',
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
}),
|
||||
invalidatesTags: ['Learners'],
|
||||
}),
|
||||
bulkRevokeCourse: builder.mutation<BulkRevokeCourseResponse, BulkRevokeCourseRequest>({
|
||||
query: (payload) => ({
|
||||
url: '/hr/learners/bulk-revoke-course',
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
}),
|
||||
invalidatesTags: ['Learners'],
|
||||
}),
|
||||
getAssignedCoursesForOrganization: builder.query<
|
||||
AssignedCoursesResponse,
|
||||
{ limit: number; offset: number; search_query?: string }
|
||||
>({
|
||||
query: (params) => ({
|
||||
url: '/hr/organization/list/assigned-courses',
|
||||
method: 'GET',
|
||||
params: {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
search_query: params.search_query || undefined,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
getLearnerCourses: builder.query<LearnerCoursesResponse, string>({
|
||||
query: (learnerId) => ({
|
||||
url: `/hr/learners/courses/${learnerId}`,
|
||||
method: 'GET',
|
||||
}),
|
||||
providesTags: ['Learners'],
|
||||
}),
|
||||
updateLearner: builder.mutation<UpdateLearnerResponse, UpdateLearnerRequest>({
|
||||
query: (payload) => ({
|
||||
url: '/hr/learners/update-learner',
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
}),
|
||||
invalidatesTags: ['Learners'],
|
||||
}),
|
||||
getAssignedHrCourses: builder.query<AssignedHrCoursesResponse, AssignedHrCoursesQueryParams>({
|
||||
query: (params) => ({
|
||||
url: '/hr/organization/assigned-courses/hr',
|
||||
method: 'GET',
|
||||
params: {
|
||||
limit: params.limit,
|
||||
start: params.start,
|
||||
search_query: params.search_query || undefined,
|
||||
status: params.status || undefined,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
getAssignedHrProgrammes: builder.query<
|
||||
AssignedHrProgrammesResponse,
|
||||
AssignedHrProgrammesQueryParams
|
||||
>({
|
||||
query: (params) => ({
|
||||
url: '/hr/organization/assigned-programmes/hr',
|
||||
method: 'GET',
|
||||
params: {
|
||||
limit: params.limit,
|
||||
start: params.start,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetLearnersForHrQuery,
|
||||
useCreateLearnerMutation,
|
||||
useBulkCreateLearnersForHrMutation,
|
||||
useLazyCheckEmailExistsQuery,
|
||||
useLazyCheckMobileExistsQuery,
|
||||
useGetProgrammesForHrQuery,
|
||||
useBulkAssignProgrammeMutation,
|
||||
useGetCoursesForHrQuery,
|
||||
useBulkAssignCourseMutation,
|
||||
useBulkRevokeCourseMutation,
|
||||
useGetAssignedCoursesForOrganizationQuery,
|
||||
useGetLearnerCoursesQuery,
|
||||
useUpdateLearnerMutation,
|
||||
useGetAssignedHrCoursesQuery,
|
||||
useGetAssignedHrProgrammesQuery,
|
||||
} = learnersApi;
|
||||
52
src/redux/services/loginApi.ts
Normal file
52
src/redux/services/loginApi.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
|
||||
export interface LoginCredentials {
|
||||
email_address: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
principal_organization_xid: string;
|
||||
principal_organization_name: string;
|
||||
email_address: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
display_name: string;
|
||||
principal_type_xid: string;
|
||||
principal_type_code: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
user_info: UserInfo;
|
||||
};
|
||||
errors: null | any;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL
|
||||
|
||||
export const loginApi = createApi({
|
||||
reducerPath: 'loginApi',
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: API_BASE_URL,
|
||||
}),
|
||||
endpoints: (builder) => ({
|
||||
login: builder.mutation<LoginResponse, LoginCredentials>({
|
||||
query: (credentials) => ({
|
||||
url: '/auth/hr/login',
|
||||
method: 'POST',
|
||||
body: credentials,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const useLoginMutation = loginApi.useLoginMutation;
|
||||
17
src/redux/store.ts
Normal file
17
src/redux/store.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { loginApi } from './services/loginApi';
|
||||
import { learnersApi } from './services/learnersApi';
|
||||
import { forumApi } from './services/forumApi';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
[loginApi.reducerPath]: loginApi.reducer,
|
||||
[learnersApi.reducerPath]: learnersApi.reducer,
|
||||
[forumApi.reducerPath]: forumApi.reducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().concat(loginApi.middleware, learnersApi.middleware, forumApi.middleware),
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
14
src/routes/ProtectedRoute.tsx
Normal file
14
src/routes/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
12
src/routes/RootLayout.tsx
Normal file
12
src/routes/RootLayout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { AuthProvider } from '../context/AuthContext';
|
||||
import { Toaster } from '../components/ui/sonner';
|
||||
|
||||
export function RootLayout() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Outlet />
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
79
src/routes/index.tsx
Normal file
79
src/routes/index.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import { RootLayout } from './RootLayout';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
import HRLayout from '../layouts/HRLayout';
|
||||
import Login from '../pages/Login';
|
||||
import DashboardPage from '../pages/Dashboard/DashboardPage';
|
||||
import LearnersPage from '../pages/Learners/LearnersPage';
|
||||
import ReportsPage from '../pages/ReportsPage/ReportsPage';
|
||||
import DiscussionsPage from '../pages/DiscussionsPage/DiscussionsPage';
|
||||
import ProgrammeViewPage from '../pages/ProgrammeViewPage/ProgrammeViewPage';
|
||||
import CourseViewPage from '../pages/CourseViewPage/CourseViewPage';
|
||||
import CoursesPage from '../pages/Courses/CoursesPage';
|
||||
import ProgrammesPage from '../pages/Programmes/ProgrammesPage';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
element: <RootLayout />,
|
||||
children: [
|
||||
{
|
||||
path: '/login',
|
||||
element: <Login />,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <Navigate to="/hr/dashboard" replace />,
|
||||
},
|
||||
{
|
||||
path: '/hr',
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<HRLayout />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="dashboard" replace />,
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
element: <DashboardPage />,
|
||||
},
|
||||
{
|
||||
path: 'learners',
|
||||
element: <LearnersPage />,
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
element: <ReportsPage />,
|
||||
},
|
||||
{
|
||||
path: 'courses',
|
||||
element: <CoursesPage />,
|
||||
},
|
||||
{
|
||||
path: 'programmes',
|
||||
element: <ProgrammesPage />,
|
||||
},
|
||||
{
|
||||
path: 'discussions',
|
||||
element: <DiscussionsPage />,
|
||||
},
|
||||
{
|
||||
path: 'programme/:programmeId',
|
||||
element: <ProgrammeViewPage />,
|
||||
},
|
||||
{
|
||||
path: 'course/:courseId',
|
||||
element: <CourseViewPage />,
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
element: <DashboardPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -1,5 +1,5 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap");
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
@@ -500,8 +500,8 @@
|
||||
/* Utility classes for HR Portal */
|
||||
@layer utilities {
|
||||
.min-tap-44 {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
min-height: 12px;
|
||||
min-width: 12px;
|
||||
}
|
||||
|
||||
.sticky-header {
|
||||
|
||||
135
src/types/index.ts
Normal file
135
src/types/index.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
export interface KPIData {
|
||||
title: string;
|
||||
value: number;
|
||||
change?: number;
|
||||
trend?: 'up' | 'down' | 'neutral';
|
||||
}
|
||||
|
||||
export interface Employee {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
status: 'Active' | 'Inactive' | 'Pending';
|
||||
programme?: string;
|
||||
course?: string;
|
||||
progress?: number;
|
||||
lastActivity?: string;
|
||||
}
|
||||
|
||||
export interface Announcement {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
type: 'announcement' | 'reminder';
|
||||
timestamp: string;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export interface Deadline {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'webinar' | 'profiler';
|
||||
dueDate: string;
|
||||
dueTime: string;
|
||||
}
|
||||
|
||||
export interface Cohort {
|
||||
id: string;
|
||||
name: string;
|
||||
memberCount: number;
|
||||
programme?: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
author: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
cohortId: string;
|
||||
createdAt: string;
|
||||
lastActivity: string;
|
||||
replyCount: number;
|
||||
isPinned?: boolean;
|
||||
tags?: string[];
|
||||
reactions?: { [key: string]: string[] };
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
threadId: string;
|
||||
content: string;
|
||||
author: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
createdAt: string;
|
||||
editedAt?: string;
|
||||
reactions?: { [key: string]: string[] };
|
||||
isReported?: boolean;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
export interface TestimonialFormData {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
organisation: string;
|
||||
programme: string;
|
||||
testimonialText: string;
|
||||
consentToPublish: boolean;
|
||||
}
|
||||
|
||||
export interface Programme {
|
||||
programmeId: string;
|
||||
title: string;
|
||||
status: 'Active' | 'Upcoming' | 'Completed';
|
||||
coursesCount: number;
|
||||
contentCount: number;
|
||||
assignment: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
};
|
||||
learnersAssigned: number;
|
||||
}
|
||||
|
||||
export interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'Published' | 'Draft' | 'Archived';
|
||||
code: string;
|
||||
owner: string;
|
||||
version: number;
|
||||
duration: string;
|
||||
description: string;
|
||||
objectives: string[];
|
||||
tags: string[];
|
||||
modules: CourseModule[];
|
||||
linkedProgrammes: LinkedProgramme[];
|
||||
}
|
||||
|
||||
export interface CourseModule {
|
||||
id: string;
|
||||
title: string;
|
||||
lessons: CourseLesson[];
|
||||
}
|
||||
|
||||
export interface CourseLesson {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'video' | 'quiz' | 'read' | 'assignment';
|
||||
eta: string;
|
||||
dueDate?: string;
|
||||
status?: 'Not Started' | 'In Progress' | 'Completed';
|
||||
}
|
||||
|
||||
export interface LinkedProgramme {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
235
src/utils/mockData.ts
Normal file
235
src/utils/mockData.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { Employee, Announcement, Deadline, Cohort, Thread, Post, Programme, Course } from '../types';
|
||||
|
||||
export const mockKPIData = [
|
||||
{ title: 'Total Learners', value: 1247, change: 12, trend: 'up' as const },
|
||||
{ title: 'Active Courses', value: 89, change: 5, trend: 'up' as const },
|
||||
{ title: 'Completed Profilers', value: 342, change: -8, trend: 'down' as const },
|
||||
{ title: 'Average Progress', value: 73, change: 7, trend: 'up' as const }
|
||||
];
|
||||
|
||||
export const mockEmployees: Employee[] = [
|
||||
{ id: '1', name: 'Sarah Chen', email: 'sarah.chen@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Strategic Thinking', progress: 85, lastActivity: '2 hours ago' },
|
||||
{ id: '2', name: 'Michael Rodriguez', email: 'michael.r@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Technical Skills', course: 'Data Analysis', progress: 62, lastActivity: '1 day ago' },
|
||||
{ id: '3', name: 'Emma Thompson', email: 'emma.thompson@company.com', phone: '+61 4XX XXX XXX', status: 'Pending', programme: 'Communication', course: 'Public Speaking', progress: 0, lastActivity: 'Never' },
|
||||
{ id: '4', name: 'David Kim', email: 'david.kim@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Project Management', course: 'Agile Methodology', progress: 94, lastActivity: '3 hours ago' },
|
||||
{ id: '5', name: 'Lisa Wang', email: 'lisa.wang@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Team Management', progress: 78, lastActivity: '5 hours ago' },
|
||||
{ id: '6', name: 'James Wilson', email: 'james.wilson@company.com', phone: '+61 4XX XXX XXX', status: 'Inactive', programme: 'Technical Skills', course: 'Programming Basics', progress: 34, lastActivity: '2 weeks ago' },
|
||||
{ id: '7', name: 'Maria Garcia', email: 'maria.garcia@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Sales Training', course: 'Customer Relations', progress: 56, lastActivity: '1 day ago' },
|
||||
{ id: '8', name: 'Robert Lee', email: 'robert.lee@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Decision Making', progress: 89, lastActivity: '4 hours ago' },
|
||||
{ id: '9', name: 'Jennifer Davis', email: 'jennifer.davis@company.com', phone: '+61 4XX XXX XXX', status: 'Pending', programme: 'Communication', course: 'Written Communication', progress: 0, lastActivity: 'Never' },
|
||||
{ id: '10', name: 'Thomas Brown', email: 'thomas.brown@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Project Management', course: 'Risk Management', progress: 71, lastActivity: '6 hours ago' }
|
||||
];
|
||||
|
||||
export const mockAnnouncements: Announcement[] = [
|
||||
{ id: '1', title: 'New Learning Module Available', content: 'Advanced Analytics course is now live in the system.', type: 'announcement', timestamp: '2 hours ago', pinned: true },
|
||||
{ id: '2', title: 'Reminder: Quarterly Reviews Due', content: 'Please complete all quarterly progress reviews by Friday.', type: 'reminder', timestamp: '5 hours ago' },
|
||||
{ id: '3', title: 'System Maintenance Scheduled', content: 'Learning platform will be offline Saturday 2-4 AM for updates.', type: 'announcement', timestamp: '1 day ago' }
|
||||
];
|
||||
|
||||
export const mockDeadlines: Deadline[] = [
|
||||
{ id: '1', title: 'Leadership Webinar Series', type: 'webinar', dueDate: 'Today', dueTime: '2:00 PM' },
|
||||
{ id: '2', title: 'Communication Skills Assessment', type: 'profiler', dueDate: 'Tomorrow', dueTime: '11:59 PM' },
|
||||
{ id: '3', title: 'Project Management Workshop', type: 'webinar', dueDate: 'Dec 30', dueTime: '10:00 AM' },
|
||||
{ id: '4', title: 'Technical Skills Profiler', type: 'profiler', dueDate: 'Jan 2', dueTime: '5:00 PM' },
|
||||
{ id: '5', title: 'Team Building Session', type: 'webinar', dueDate: 'Jan 5', dueTime: '3:30 PM' }
|
||||
];
|
||||
|
||||
export const mockCohorts: Cohort[] = [
|
||||
{ id: '1', name: 'Leadership Development Q4 2024', memberCount: 30, programme: 'Leadership Development', isActive: true },
|
||||
{ id: '2', name: 'Technical Skills Cohort A', memberCount: 25, programme: 'Technical Skills', isActive: true },
|
||||
{ id: '3', name: 'Communication Workshop Group', memberCount: 18, programme: 'Communication', isActive: true },
|
||||
{ id: '4', name: 'Project Management Certification', memberCount: 22, programme: 'Project Management', isActive: false }
|
||||
];
|
||||
|
||||
export const mockThreads: Thread[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Best practices for team communication during remote work',
|
||||
content: 'What strategies have you found most effective for maintaining clear communication with remote team members? I\'d love to hear about tools and techniques that have worked well for your teams.',
|
||||
author: { id: 'user1', name: 'Sarah Chen' },
|
||||
cohortId: '1',
|
||||
createdAt: '2024-12-28T10:30:00Z',
|
||||
lastActivity: '2024-12-28T15:45:00Z',
|
||||
replyCount: 12,
|
||||
isPinned: true,
|
||||
tags: ['communication', 'remote-work', 'best-practices'],
|
||||
reactions: { '👍': ['user2', 'user3'], '💡': ['user4'] }
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'How to handle difficult conversations with team members?',
|
||||
content: 'I\'m struggling with addressing performance issues with one of my team members. Any advice on how to approach this sensitively while being direct about expectations?',
|
||||
author: { id: 'user2', name: 'Michael Rodriguez' },
|
||||
cohortId: '1',
|
||||
createdAt: '2024-12-28T09:15:00Z',
|
||||
lastActivity: '2024-12-28T14:20:00Z',
|
||||
replyCount: 8,
|
||||
tags: ['difficult-conversations', 'performance-management'],
|
||||
reactions: { '🤔': ['user1', 'user5'], '💪': ['user3'] }
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Share your leadership book recommendations',
|
||||
content: 'What books have been most influential in your leadership journey? Looking for practical reads that offer actionable insights.',
|
||||
author: { id: 'user3', name: 'Emma Thompson' },
|
||||
cohortId: '1',
|
||||
createdAt: '2024-12-27T16:00:00Z',
|
||||
lastActivity: '2024-12-28T11:30:00Z',
|
||||
replyCount: 15,
|
||||
tags: ['books', 'recommendations', 'learning'],
|
||||
reactions: { '📚': ['user1', 'user2', 'user4', 'user5'], '⭐': ['user6'] }
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Question about the delegation framework from Module 3',
|
||||
content: 'Can someone clarify the difference between the delegation levels we covered? I want to make sure I\'m applying them correctly in my current projects.',
|
||||
author: { id: 'user4', name: 'David Kim' },
|
||||
cohortId: '1',
|
||||
createdAt: '2024-12-27T14:30:00Z',
|
||||
lastActivity: '2024-12-27T18:45:00Z',
|
||||
replyCount: 6,
|
||||
tags: ['module-3', 'delegation', 'clarification'],
|
||||
reactions: { '❓': ['user2'], '👍': ['user1'] }
|
||||
}
|
||||
];
|
||||
|
||||
export const mockPosts: Post[] = [
|
||||
{
|
||||
id: '1',
|
||||
threadId: '1',
|
||||
content: 'Great question! I\'ve found that establishing clear communication protocols at the start of projects makes a huge difference. We use a combination of daily stand-ups via video call and async updates through Slack.',
|
||||
author: { id: 'user5', name: 'Lisa Wang' },
|
||||
createdAt: '2024-12-28T11:00:00Z',
|
||||
reactions: { '👍': ['user1', 'user2'], '💯': ['user3'] }
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
threadId: '1',
|
||||
content: 'One thing that\'s worked well for our team is having "communication preferences" documented for each team member. Some prefer quick calls for complex topics, others prefer detailed written explanations. Knowing this upfront prevents a lot of miscommunication.',
|
||||
author: { id: 'user6', name: 'Robert Lee' },
|
||||
createdAt: '2024-12-28T12:15:00Z',
|
||||
reactions: { '💡': ['user1', 'user4'], '👏': ['user2'] }
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
threadId: '1',
|
||||
content: '@Sarah Chen Thanks for starting this discussion! I\'d add that regular one-on-ones have been crucial for me. Even in remote settings, that personal connection makes a big difference in team dynamics.',
|
||||
author: { id: 'user7', name: 'Jennifer Davis' },
|
||||
createdAt: '2024-12-28T13:30:00Z',
|
||||
reactions: { '🎯': ['user1'], '👍': ['user5'] }
|
||||
}
|
||||
];
|
||||
|
||||
export const mockProgrammes: Programme[] = [
|
||||
{
|
||||
programmeId: 'prog-001',
|
||||
title: 'Leadership Development Program',
|
||||
status: 'Active',
|
||||
coursesCount: 8,
|
||||
contentCount: 24,
|
||||
assignment: {
|
||||
startDate: new Date('2024-01-15'),
|
||||
endDate: new Date('2024-06-30')
|
||||
},
|
||||
learnersAssigned: 45
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-002',
|
||||
title: 'Technical Skills Bootcamp',
|
||||
status: 'Active',
|
||||
coursesCount: 12,
|
||||
contentCount: 36,
|
||||
assignment: {
|
||||
startDate: new Date('2024-02-01'),
|
||||
endDate: new Date('2024-08-31')
|
||||
},
|
||||
learnersAssigned: 38
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-003',
|
||||
title: 'Communication Excellence',
|
||||
status: 'Upcoming',
|
||||
coursesCount: 6,
|
||||
contentCount: 18,
|
||||
assignment: {
|
||||
startDate: new Date('2024-03-01'),
|
||||
endDate: new Date('2024-05-31')
|
||||
},
|
||||
learnersAssigned: 28
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-004',
|
||||
title: 'Project Management Certification',
|
||||
status: 'Active',
|
||||
coursesCount: 10,
|
||||
contentCount: 30,
|
||||
assignment: {
|
||||
startDate: new Date('2024-01-01'),
|
||||
endDate: new Date('2024-12-31')
|
||||
},
|
||||
learnersAssigned: 52
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-005',
|
||||
title: 'Digital Marketing Mastery',
|
||||
status: 'Completed',
|
||||
coursesCount: 5,
|
||||
contentCount: 15,
|
||||
assignment: {
|
||||
startDate: new Date('2023-09-01'),
|
||||
endDate: new Date('2023-12-31')
|
||||
},
|
||||
learnersAssigned: 32
|
||||
}
|
||||
];
|
||||
|
||||
export const mockCourse: Course = {
|
||||
id: 'crs_456',
|
||||
title: 'Strategic Thinking and Decision Making',
|
||||
status: 'Published',
|
||||
code: 'STDM-2024',
|
||||
owner: 'Prof. Michael Chen',
|
||||
version: 1,
|
||||
duration: '6 hours',
|
||||
description: 'This course develops strategic thinking capabilities and decision-making frameworks for leaders at all levels. Participants will learn to analyze complex situations, evaluate options, and make informed decisions.',
|
||||
objectives: [
|
||||
'Apply strategic thinking frameworks to business challenges',
|
||||
'Develop systematic approaches to decision making',
|
||||
'Evaluate risks and opportunities effectively',
|
||||
'Create actionable strategic plans'
|
||||
],
|
||||
tags: ['Strategy', 'Leadership', 'Decision Making', 'Critical Thinking'],
|
||||
modules: [
|
||||
{
|
||||
id: 'm1',
|
||||
title: 'Foundations of Strategic Thinking',
|
||||
lessons: [
|
||||
{ id: 'l1', title: 'Introduction to Strategic Thinking', type: 'video', eta: '15 mins', status: 'Completed' },
|
||||
{ id: 'l2', title: 'Strategic Frameworks Overview', type: 'read', eta: '20 mins', status: 'Completed' },
|
||||
{ id: 'l3', title: 'Knowledge Check', type: 'quiz', eta: '10 mins', status: 'In Progress' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
title: 'Decision Making Models',
|
||||
lessons: [
|
||||
{ id: 'l4', title: 'Rational Decision Making', type: 'video', eta: '25 mins', status: 'Not Started' },
|
||||
{ id: 'l5', title: 'Intuitive vs Analytical Approaches', type: 'read', eta: '15 mins', status: 'Not Started' },
|
||||
{ id: 'l6', title: 'Case Study Analysis', type: 'assignment', eta: '45 mins', dueDate: '2024-01-25', status: 'Not Started' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'm3',
|
||||
title: 'Risk Assessment and Management',
|
||||
lessons: [
|
||||
{ id: 'l7', title: 'Risk Identification Techniques', type: 'video', eta: '20 mins', status: 'Not Started' },
|
||||
{ id: 'l8', title: 'Risk Matrix and Evaluation', type: 'read', eta: '25 mins', status: 'Not Started' },
|
||||
{ id: 'l9', title: 'Final Assessment', type: 'quiz', eta: '30 mins', dueDate: '2024-01-30', status: 'Not Started' }
|
||||
]
|
||||
}
|
||||
],
|
||||
linkedProgrammes: [
|
||||
{ id: 'prg_123', title: 'Executive Leadership Development Programme' },
|
||||
{ id: 'prg_124', title: 'Management Excellence Programme' }
|
||||
]
|
||||
};
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import path from 'path';
|
||||
import * as path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -54,7 +54,7 @@
|
||||
outDir: 'build',
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
port: 3005,
|
||||
open: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user