Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f87e71d231 |
@@ -1,34 +0,0 @@
|
||||
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
|
||||
@@ -1,67 +0,0 @@
|
||||
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 }}
|
||||
@@ -1,77 +0,0 @@
|
||||
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
|
||||
@@ -1,39 +0,0 @@
|
||||
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 style="overflow: hidden;">
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
86
package-lock.json
generated
86
package-lock.json
generated
@@ -34,7 +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",
|
||||
"@reduxjs/toolkit": "^2.9.0",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "*",
|
||||
@@ -50,7 +50,7 @@
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"recharts": "^2.15.2",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "*",
|
||||
@@ -1893,14 +1893,14 @@
|
||||
"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==",
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz",
|
||||
"integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"immer": "^10.0.3",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
@@ -1918,6 +1918,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||
"integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -2206,9 +2215,9 @@
|
||||
]
|
||||
},
|
||||
"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==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
@@ -2882,19 +2891,6 @@
|
||||
"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",
|
||||
@@ -3190,9 +3186,9 @@
|
||||
"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==",
|
||||
"version": "10.1.3",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
|
||||
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -3763,41 +3759,35 @@
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
|
||||
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
"@remix-run/router": "1.23.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
|
||||
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.13.1"
|
||||
"@remix-run/router": "1.23.0",
|
||||
"react-router": "6.30.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
@@ -3955,12 +3945,6 @@
|
||||
"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,7 +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",
|
||||
"@reduxjs/toolkit": "^2.9.0",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "*",
|
||||
@@ -45,7 +45,7 @@
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"recharts": "^2.15.2",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "*",
|
||||
|
||||
73
src/App.css
Normal file
73
src/App.css
Normal file
@@ -0,0 +1,73 @@
|
||||
/* Add this to your main global CSS file */
|
||||
@media (max-width: 640px) {
|
||||
.employee-card-mobile-view {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.employee-card-desktop-view {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.employee-card-mobile-view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.employee-card-desktop-view {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom styles for the compact table */
|
||||
.compact-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.compact-table th,
|
||||
.compact-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Responsive table container */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Ensure buttons are touch-friendly on mobile */
|
||||
.min-tap-44 {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Truncate text for small containers */
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Animation for card entrance */
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-slide-up {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
37
src/App.tsx
37
src/App.tsx
@@ -1,9 +1,32 @@
|
||||
import React from 'react';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { router } from './routes';
|
||||
import React from "react";
|
||||
import { Routes, Route, Navigate, useNavigate } from "react-router-dom";
|
||||
import HRHomeScreen from "./pages/HRHomeScreen";
|
||||
import LearnersScreen from "./pages/LearnersScreen";
|
||||
import AnalyticsScreen from "./pages/AnalyticsScreen";
|
||||
import TestimonialsScreen from "./pages/TestimonialsScreen";
|
||||
import SettingsScreen from "./pages/SettingsScreen";
|
||||
// import Layout from "./pages/layout/layout";
|
||||
import Layout from "./components/layout/layout";
|
||||
export default function App() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
function App() {
|
||||
return <RouterProvider router={router} />;
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/hr/home" replace />} />
|
||||
|
||||
<Route path="/hr" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/hr/home" replace />} />
|
||||
<Route
|
||||
path="home"
|
||||
element={<HRHomeScreen onNavigate={(screen) => navigate(`/hr/${screen}`)} />}
|
||||
/>
|
||||
<Route path="learners" element={<LearnersScreen />} />
|
||||
<Route path="analytics" element={<AnalyticsScreen />} />
|
||||
<Route path="testimonials" element={<TestimonialsScreen />} />
|
||||
<Route path="settings" element={<SettingsScreen />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/hr/home" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 24 KiB |
@@ -1,379 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,79 +1,37 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight, Home } from 'lucide-react';
|
||||
import { Link } from "react-router-dom";
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "./ui/breadcrumb";
|
||||
|
||||
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';
|
||||
export const BreadcrumbNav: React.FC<{ currentScreen: string }> = ({ currentScreen }) => {
|
||||
const getBreadcrumbText = (screen: string) => {
|
||||
switch (screen) {
|
||||
case "home":
|
||||
return "HR Home";
|
||||
case "learners":
|
||||
return "Learners";
|
||||
case "analytics":
|
||||
return "Analytics & Reports";
|
||||
case "settings":
|
||||
return "HR Settings";
|
||||
case "testimonials":
|
||||
return "Testimonials";
|
||||
default:
|
||||
return "HR Portal";
|
||||
}
|
||||
|
||||
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>
|
||||
<Breadcrumb className="mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to="/hr">HR Portal</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator aria-hidden="true" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{getBreadcrumbText(currentScreen)}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
};
|
||||
|
||||
export default BreadcrumbNav;
|
||||
65
src/components/ChatBot.tsx
Normal file
65
src/components/ChatBot.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { MessageSquare, X } from "lucide-react";
|
||||
|
||||
export const ChatBot: React.FC<{ currentScreen?: string }> = ({ currentScreen }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const getChipsForScreen = (screen?: string) => {
|
||||
if (screen === 'testimonials') {
|
||||
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-50">
|
||||
{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>
|
||||
);
|
||||
};
|
||||
@@ -1,839 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,226 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
265
src/components/EmployeeTable.tsx
Normal file
265
src/components/EmployeeTable.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import React from 'react';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../components/ui/table';
|
||||
import { Progress } from '../components/ui/progress';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { Edit } from 'lucide-react';
|
||||
import type { Employee } from '../types';
|
||||
|
||||
export const EmployeeTable: React.FC<{
|
||||
employees: Employee[];
|
||||
onEdit?: (employee: Employee) => void;
|
||||
showProgress?: boolean;
|
||||
maxHeight?: string;
|
||||
compact?: any
|
||||
}> = ({ employees, onEdit, showProgress = true, maxHeight = '400px' }) => {
|
||||
return (
|
||||
<div className={`rounded-md border`} style={{ maxHeight }}>
|
||||
<Table>
|
||||
<TableHeader className="sticky-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">Employee</TableHead>
|
||||
<TableHead className="w-[250px]">Email</TableHead>
|
||||
<TableHead className="w-[150px]">Phone</TableHead>
|
||||
<TableHead className="w-[100px]">Status</TableHead>
|
||||
{showProgress && (
|
||||
<>
|
||||
<TableHead className="w-[200px]">Programme/Course</TableHead>
|
||||
<TableHead className="w-[100px]">Progress</TableHead>
|
||||
<TableHead className="w-[120px]">Last Activity</TableHead>
|
||||
</>
|
||||
)}
|
||||
<TableHead className="w-[80px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{employees.map((employee) => (
|
||||
<TableRow
|
||||
key={employee.id}
|
||||
className="min-h-[48px] cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => onEdit?.(employee)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onEdit?.(employee);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableCell className="font-medium">{employee.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{employee.email}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{employee.phone}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
employee.status === 'Active' ? 'default' :
|
||||
employee.status === 'Pending' ? 'secondary' : 'destructive'
|
||||
}
|
||||
aria-describedby={`status-${employee.id}`}
|
||||
>
|
||||
{employee.status}
|
||||
</Badge>
|
||||
<span id={`status-${employee.id}`} className="sr-only">
|
||||
Employee status is {employee.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
{showProgress && (
|
||||
<>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium text-sm">{employee.programme}</div>
|
||||
<div className="text-xs text-muted-foreground">{employee.course}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{employee.progress !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<Progress
|
||||
value={employee.progress}
|
||||
className="w-16"
|
||||
aria-describedby={`progress-${employee.id}`}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{employee.progress}%</span>
|
||||
<span id={`progress-${employee.id}`} className="sr-only">
|
||||
Progress: {employee.progress} percent complete
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{employee.lastActivity}</TableCell>
|
||||
</>
|
||||
)}
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-tap-44"
|
||||
onClick={(e: any) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(employee);
|
||||
}}
|
||||
aria-label={`Edit ${employee.name}`}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// import React from "react";
|
||||
// import { Button } from "../components/ui/button";
|
||||
// import {
|
||||
// Table,
|
||||
// TableBody,
|
||||
// TableCell,
|
||||
// TableHead,
|
||||
// TableHeader,
|
||||
// TableRow,
|
||||
// } from "../components/ui/table";
|
||||
// import { Progress } from "../components/ui/progress";
|
||||
// import { Badge } from "../components/ui/badge";
|
||||
// import { Edit } from "lucide-react";
|
||||
// import type { Employee } from "../types";
|
||||
|
||||
// export const EmployeeTable: React.FC<{
|
||||
// employees: Employee[];
|
||||
// onEdit?: (employee: Employee) => void;
|
||||
// showProgress?: boolean;
|
||||
// maxHeight?: string;
|
||||
// compact?: any
|
||||
// }> = ({ employees, onEdit, showProgress = true, maxHeight = "400px" }) => {
|
||||
// return (
|
||||
// <div
|
||||
// className="rounded-md border overflow-x-auto"
|
||||
// style={{ maxHeight }}
|
||||
// >
|
||||
// <Table className="min-w-[800px] w-full">
|
||||
// {/* sticky header */}
|
||||
// <TableHeader className="sticky top-0 bg-background z-10">
|
||||
// <TableRow>
|
||||
// <TableHead className="min-w-[180px]">Employee</TableHead>
|
||||
// <TableHead className="min-w-[220px]">Email</TableHead>
|
||||
// <TableHead className="min-w-[150px]">Phone</TableHead>
|
||||
// <TableHead className="min-w-[100px]">Status</TableHead>
|
||||
// {showProgress && (
|
||||
// <>
|
||||
// <TableHead className="min-w-[200px]">Programme/Course</TableHead>
|
||||
// <TableHead className="min-w-[120px]">Progress</TableHead>
|
||||
// <TableHead className="min-w-[140px]">Last Activity</TableHead>
|
||||
// </>
|
||||
// )}
|
||||
// <TableHead className="min-w-[80px]">Actions</TableHead>
|
||||
// </TableRow>
|
||||
// </TableHeader>
|
||||
|
||||
// {/* table body */}
|
||||
// <TableBody>
|
||||
// {employees.map((employee) => (
|
||||
// <TableRow
|
||||
// key={employee.id}
|
||||
// className="min-h-[48px] cursor-pointer hover:bg-muted/50"
|
||||
// onClick={() => onEdit?.(employee)}
|
||||
// role="button"
|
||||
// tabIndex={0}
|
||||
// onKeyDown={(e) => {
|
||||
// if (e.key === "Enter" || e.key === " ") {
|
||||
// e.preventDefault();
|
||||
// onEdit?.(employee);
|
||||
// }
|
||||
// }}
|
||||
// >
|
||||
// <TableCell className="font-medium text-sm sm:text-base">
|
||||
// {employee.name}
|
||||
// </TableCell>
|
||||
// <TableCell className="text-muted-foreground text-xs sm:text-sm">
|
||||
// {employee.email}
|
||||
// </TableCell>
|
||||
// <TableCell className="text-muted-foreground text-xs sm:text-sm">
|
||||
// {employee.phone}
|
||||
// </TableCell>
|
||||
// <TableCell>
|
||||
// <Badge
|
||||
// variant={
|
||||
// employee.status === "Active"
|
||||
// ? "default"
|
||||
// : employee.status === "Pending"
|
||||
// ? "secondary"
|
||||
// : "destructive"
|
||||
// }
|
||||
// aria-describedby={`status-${employee.id}`}
|
||||
// >
|
||||
// {employee.status}
|
||||
// </Badge>
|
||||
// <span id={`status-${employee.id}`} className="sr-only">
|
||||
// Employee status is {employee.status}
|
||||
// </span>
|
||||
// </TableCell>
|
||||
|
||||
// {showProgress && (
|
||||
// <>
|
||||
// <TableCell>
|
||||
// <div>
|
||||
// <div className="font-medium text-sm">
|
||||
// {employee.programme}
|
||||
// </div>
|
||||
// <div className="text-xs text-muted-foreground">
|
||||
// {employee.course}
|
||||
// </div>
|
||||
// </div>
|
||||
// </TableCell>
|
||||
// <TableCell>
|
||||
// {employee.progress !== undefined && (
|
||||
// <div className="space-y-1">
|
||||
// <Progress
|
||||
// value={employee.progress}
|
||||
// className="w-16"
|
||||
// aria-describedby={`progress-${employee.id}`}
|
||||
// />
|
||||
// <span className="text-xs text-muted-foreground">
|
||||
// {employee.progress}%
|
||||
// </span>
|
||||
// <span
|
||||
// id={`progress-${employee.id}`}
|
||||
// className="sr-only"
|
||||
// >
|
||||
// Progress: {employee.progress} percent complete
|
||||
// </span>
|
||||
// </div>
|
||||
// )}
|
||||
// </TableCell>
|
||||
// <TableCell className="text-xs sm:text-sm text-muted-foreground">
|
||||
// {employee.lastActivity}
|
||||
// </TableCell>
|
||||
// </>
|
||||
// )}
|
||||
|
||||
// <TableCell>
|
||||
// <Button
|
||||
// variant="ghost"
|
||||
// size="sm"
|
||||
// className="min-tap-44"
|
||||
// onClick={(e: any) => {
|
||||
// e.stopPropagation();
|
||||
// onEdit?.(employee);
|
||||
// }}
|
||||
// aria-label={`Edit ${employee.name}`}
|
||||
// >
|
||||
// <Edit className="h-4 w-4" />
|
||||
// </Button>
|
||||
// </TableCell>
|
||||
// </TableRow>
|
||||
// ))}
|
||||
// </TableBody>
|
||||
// </Table>
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
||||
|
||||
153
src/components/HRSidebar.tsx
Normal file
153
src/components/HRSidebar.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Home, Users, Settings, BarChart3, MessageSquare } from "lucide-react";
|
||||
import { useLocalStorage } from "../hooks/useLocalStorage";
|
||||
import { NavLink } from "react-router-dom";
|
||||
|
||||
export const HRSidebar: React.FC<{
|
||||
activePath: string;
|
||||
onNavigatePath?: (path: string) => void;
|
||||
className?: string;
|
||||
}> = ({ activePath, onNavigatePath, className = '' }) => {
|
||||
const [prefersReducedMotion] = useLocalStorage('prefersReducedMotion', false);
|
||||
|
||||
const menuItems = [
|
||||
{ id: 'home', label: 'Dashboard', icon: Home, path: '/hr/home' },
|
||||
{ id: 'learners', label: 'Learners', icon: Users, path: '/hr/learners' },
|
||||
{ id: 'analytics', label: 'Analytics', icon: BarChart3, path: '/hr/analytics' },
|
||||
{ id: 'testimonials', label: 'Testimonials', icon: MessageSquare, path: '/hr/testimonials' },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings, path: '/hr/settings' }
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
<div
|
||||
className={` w-64 min-w-[248px] 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">AC</span>
|
||||
</div>
|
||||
<span className="font-semibold text-sidebar-foreground">Acme Corp</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;
|
||||
const isActive = activePath.startsWith(item.path);
|
||||
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<NavLink
|
||||
to={item.path}
|
||||
className={`
|
||||
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'}
|
||||
`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// import React, { useState } from "react";
|
||||
// import { Home, Users, Settings, BarChart3, MessageSquare, Menu } from "lucide-react";
|
||||
// import { useLocalStorage } from "../hooks/useLocalStorage";
|
||||
// import { NavLink } from "react-router-dom";
|
||||
|
||||
// export const HRSidebar: React.FC<{
|
||||
// activePath: string;
|
||||
// onNavigatePath?: (path: string) => void;
|
||||
// className?: string;
|
||||
// }> = ({ activePath, onNavigatePath, className = "" }) => {
|
||||
// const [prefersReducedMotion] = useLocalStorage("prefersReducedMotion", false);
|
||||
|
||||
// // 🔹 Collapsed state
|
||||
// const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// const menuItems = [
|
||||
// { id: "home", label: "Dashboard", icon: Home, path: "/hr/home" },
|
||||
// { id: "learners", label: "Learners", icon: Users, path: "/hr/learners" },
|
||||
// { id: "analytics", label: "Analytics", icon: BarChart3, path: "/hr/analytics" },
|
||||
// { id: "testimonials", label: "Testimonials", icon: MessageSquare, path: "/hr/testimonials" },
|
||||
// { id: "settings", label: "Settings", icon: Settings, path: "/hr/settings" },
|
||||
// ];
|
||||
|
||||
// return (
|
||||
// <div
|
||||
// className={`bg-sidebar flex flex-col transition-all duration-300
|
||||
// ${collapsed ? "w-20 min-w-[80px]" : "w-64 min-w-[248px]"} ${className}`}
|
||||
// >
|
||||
// {/* Header */}
|
||||
// <div className="p-4 flex items-center justify-between">
|
||||
// <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">AC</span>
|
||||
// </div>
|
||||
// {!collapsed && (
|
||||
// <span className="font-semibold text-sidebar-foreground">Acme Corp</span>
|
||||
// )}
|
||||
// </div>
|
||||
|
||||
// {/* 🔹 Toggle button */}
|
||||
// <button
|
||||
// onClick={() => setCollapsed((prev) => !prev)}
|
||||
// className="text-sidebar-foreground hover:bg-sidebar-accent rounded-md p-1"
|
||||
// >
|
||||
// <Menu className="h-5 w-5" />
|
||||
// </button>
|
||||
// </div>
|
||||
|
||||
// {/* Navigation */}
|
||||
// <nav className="flex-1 p-2" role="navigation" aria-label="HR Portal Navigation">
|
||||
// <ul className="space-y-1">
|
||||
// {menuItems.map((item) => {
|
||||
// const Icon = item.icon;
|
||||
// const isActive = activePath.startsWith(item.path);
|
||||
|
||||
// return (
|
||||
// <li key={item.id}>
|
||||
// <NavLink
|
||||
// to={item.path}
|
||||
// className={`
|
||||
// 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"}
|
||||
// `}
|
||||
// >
|
||||
// <Icon className="h-5 w-5" />
|
||||
// {!collapsed && <span>{item.label}</span>}
|
||||
// </NavLink>
|
||||
// </li>
|
||||
// );
|
||||
// })}
|
||||
// </ul>
|
||||
// </nav>
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
85
src/components/KPICard.css
Normal file
85
src/components/KPICard.css
Normal file
@@ -0,0 +1,85 @@
|
||||
/* KPICard.css */
|
||||
.kpi-card {
|
||||
min-height: 96px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.kpi-card {
|
||||
min-height: 88px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Compact mode */
|
||||
.kpi-card.compact {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
/* Animation for counting up */
|
||||
@keyframes countUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-count-up {
|
||||
animation: countUp 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
.kpi-card {
|
||||
border: 1px solid;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-count-up {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.kpi-card {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover effects for non-touch devices */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.kpi-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch device adjustments */
|
||||
@media (pointer: coarse) {
|
||||
.kpi-card {
|
||||
min-height: 96px;
|
||||
/* Larger tap target */
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small screens (smartwatch size) */
|
||||
@media (max-width: 320px) {
|
||||
.kpi-card {
|
||||
min-height: 76px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
132
src/components/KPICard.tsx
Normal file
132
src/components/KPICard.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/Card';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { useLocalStorage } from '../hooks/useLocalStorage';
|
||||
import { useCountUp } from '../hooks/useCountUp';
|
||||
import type { KPIData } from '../types';
|
||||
import './KPICard.css'; // We'll create this CSS file
|
||||
|
||||
export const KPICard: React.FC<{
|
||||
data: KPIData;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
style?: any;
|
||||
compact?: boolean;
|
||||
}> = ({ data, onClick, className = '', style = {}, compact = false }) => {
|
||||
const countedValue = useCountUp(data.value);
|
||||
const [prefersReducedMotion] = useLocalStorage('prefersReducedMotion', false);
|
||||
const [screenSize, setScreenSize] = React.useState(getScreenSize());
|
||||
|
||||
// Get initial screen size
|
||||
function getScreenSize() {
|
||||
const width = window.innerWidth;
|
||||
if (width < 640) return 'xs';
|
||||
if (width < 768) return 'sm';
|
||||
if (width < 1024) return 'md';
|
||||
if (width < 1280) return 'lg';
|
||||
return 'xl';
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setScreenSize(getScreenSize());
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Responsive values based on screen size
|
||||
const responsiveValues = {
|
||||
fontSize: compact || screenSize === 'xs' ? 'text-xl' : 'text-2xl',
|
||||
showFullTitle: !compact && screenSize !== 'xs',
|
||||
badgeSize: compact || screenSize === 'xs' ? 'text-xs' : 'text-xs',
|
||||
padding: compact || screenSize === 'xs' ? 'p-3' : 'p-4'
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
// className={`kpi-card cursor-pointer transition-all duration-200 hover:shadow-md min-tap-44 border border-gray-300 bg-green-500 ${responsiveValues.padding as string} ${className}`}
|
||||
className={`kpi-card cursor-pointer transition-all duration-200 hover:shadow-md min-h-[44px] border border-gray-300 bg-green-500 rounded-lg ${responsiveValues.padding as string} ${className}`}
|
||||
|
||||
// className={`kpi-card cursor-pointer transition-all duration-200 hover:shadow-md min-tap-44 border border-gray-300 rounded-lg ${responsiveValues.padding as string} ${className}`}
|
||||
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${data.title}: ${data.value}${data.title.includes('Progress') ? '%' : ''}`}
|
||||
onKeyDown={(e: any) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
style={style}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle
|
||||
className={`font-medium text-muted-foreground ${compact || screenSize === 'xs' ? 'text-xs' : 'text-sm'}`}
|
||||
title={responsiveValues.showFullTitle ? undefined : data.title}
|
||||
>
|
||||
{responsiveValues.showFullTitle
|
||||
? data.title
|
||||
: truncateTitle(data.title, screenSize, compact)
|
||||
}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className={`font-bold ${responsiveValues.fontSize} ${prefersReducedMotion ? '' : 'animate-count-up'}`}>
|
||||
{prefersReducedMotion ? data.value : countedValue}
|
||||
{data.title.includes('Progress') && '%'}
|
||||
</span>
|
||||
{data.change !== undefined && (
|
||||
<Badge
|
||||
variant={data.trend === 'up' ? 'default' : 'destructive'}
|
||||
className={`${responsiveValues.badgeSize} ${compact ? 'scale-90' : ''}`}
|
||||
>
|
||||
{data.trend === 'up' ? '+' : ''}{data.change}{data.title.includes('Progress') ? '%' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!responsiveValues.showFullTitle && (
|
||||
<div className="mt-1 text-xs text-muted-foreground truncate" title={data.title}>
|
||||
{data.title}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to truncate titles for small screens
|
||||
function truncateTitle(title: string, screenSize: string, compact: boolean): string {
|
||||
if (compact) {
|
||||
// For compact mode, use abbreviations
|
||||
const abbreviations: Record<string, string> = {
|
||||
'Total Learners': 'Learners',
|
||||
'Active Courses': 'Courses',
|
||||
'Completion Rate': 'Complete %',
|
||||
'Avg. Progress': 'Progress',
|
||||
'Completed Profilers': 'Profiles',
|
||||
'Satisfaction Score': 'Satisfaction'
|
||||
};
|
||||
|
||||
return abbreviations[title] || title.substring(0, 12) + (title.length > 12 ? '...' : '');
|
||||
}
|
||||
|
||||
// For different screen sizes
|
||||
const maxLengths: Record<string, number> = {
|
||||
'xs': 12,
|
||||
'sm': 16,
|
||||
'md': 20,
|
||||
'lg': 24,
|
||||
'xl': 30
|
||||
};
|
||||
|
||||
const maxLength = maxLengths[screenSize] || 20;
|
||||
if (title.length <= maxLength) return title;
|
||||
return title
|
||||
// return title.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
@@ -1,502 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,445 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,902 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,367 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,485 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,121 +1,13 @@
|
||||
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 {
|
||||
import { Bell, Menu } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
// import logo from '../src/assets/klc-logo.png';
|
||||
import logo from '../assets/klc-logo.png'
|
||||
export const TopNav: React.FC<{
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
}> = ({ onMenuToggle, showMenuButton = false }) => {
|
||||
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 */}
|
||||
<header className="h-16 bg-background border-b border-chrome-divider flex items-center justify-between px-4 lg:px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{showMenuButton && (
|
||||
<Button
|
||||
@@ -124,194 +16,27 @@ export const TopNav: React.FC<TopNavProps> = ({
|
||||
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}
|
||||
>
|
||||
<img
|
||||
src={logo}
|
||||
alt="Logo"
|
||||
className="h-8 md:h-12 lg:h-14 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" className="min-tap-44" aria-label="Notifications">
|
||||
<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 className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
|
||||
<span className="text-xs font-medium">HR</span>
|
||||
</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;
|
||||
73
src/components/layout/layout.tsx
Normal file
73
src/components/layout/layout.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { useState } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { TopNav } from "../../components/TopNav";
|
||||
import { HRSidebar } from "../../components/HRSidebar";
|
||||
import { BreadcrumbNav } from "../../components/BreadcrumbNav";
|
||||
import { ChatBot } from "../../components/ChatBot";
|
||||
|
||||
|
||||
export default function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const pathToScreen = (pathname: string) => {
|
||||
if (pathname.startsWith("/hr/learners")) return "learners";
|
||||
if (pathname.startsWith("/hr/analytics")) return "analytics";
|
||||
if (pathname.startsWith("/hr/testimonials")) return "testimonials";
|
||||
if (pathname.startsWith("/hr/settings")) return "settings";
|
||||
return "home";
|
||||
};
|
||||
|
||||
const currentScreen = pathToScreen(location.pathname);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<TopNav onMenuToggle={() => setSidebarOpen(!sidebarOpen)} showMenuButton />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<div className="hidden lg:flex flex-shrink-0">
|
||||
<HRSidebar activePath={location.pathname} />
|
||||
</div>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
<HRSidebar activePath={location.pathname} className="relative z-10" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-shrink-0 bg-background px-4 lg:px-8 flex items-center">
|
||||
<div className="max-w-7xl mx-auto w-full pt-4">
|
||||
<BreadcrumbNav currentScreen={currentScreen} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-4 lg:p-0">
|
||||
{/* <div className="max-w-7xl mx-auto p-8">
|
||||
<Outlet />
|
||||
</div> */}
|
||||
<div className="max-w-7xl mx-auto p-4 sm:p-6 md:p-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-muted border-t border-chrome-divider p-4 text-center text-sm text-muted-foreground mt-8">
|
||||
<p>© 2024 Knowledge Learning Centre. All rights reserved.</p>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ChatBot */}
|
||||
<ChatBot currentScreen={currentScreen} />
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
103
src/components/ui/Card.css
Normal file
103
src/components/ui/Card.css
Normal file
@@ -0,0 +1,103 @@
|
||||
/* Card.css */
|
||||
.responsive-card {
|
||||
/* transition: all 0.2s ease; */
|
||||
}
|
||||
|
||||
/* Mobile-first responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.responsive-card {
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet and larger screens */
|
||||
@media (min-width: 768px) {
|
||||
.responsive-card {
|
||||
border-radius: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop screens */
|
||||
@media (min-width: 1024px) {
|
||||
.responsive-card {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Card hover effects for non-touch devices */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.responsive-card:hover {
|
||||
/* box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), */
|
||||
/* 0 8px 10px -6px rgba(0, 0, 0, 0.1); */
|
||||
/* transform: translateY(-2px); */
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
.responsive-card {
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.responsive-card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.responsive-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.responsive-card {
|
||||
border-color: rgba(112, 112, 112, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small screens (smartwatch size) */
|
||||
@media (max-width: 320px) {
|
||||
.responsive-card {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Container query support for card header */
|
||||
@container card-header (max-width: 300px) {
|
||||
[data-slot="card-header"] {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
[data-slot="card-action"] {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
justify-self: start;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch device optimizations */
|
||||
@media (pointer: coarse) {
|
||||
.responsive-card {
|
||||
min-height: 44px;
|
||||
/* Minimum tap target size */
|
||||
}
|
||||
|
||||
[data-slot="card-action"] button,
|
||||
[data-slot="card-action"] a {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.responsive-card {
|
||||
border: 1px solid #000;
|
||||
box-shadow: none;
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
@@ -34,25 +34,25 @@ const buttonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
const Button = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> &
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: 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,13 +1,46 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import "./Card.css"; // We'll create this CSS file
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const [screenSize, setScreenSize] = React.useState(getScreenSize());
|
||||
|
||||
// Get initial screen size
|
||||
function getScreenSize() {
|
||||
const width = window.innerWidth;
|
||||
if (width < 640) return 'xs';
|
||||
if (width < 768) return 'sm';
|
||||
if (width < 1024) return 'md';
|
||||
if (width < 1280) return 'lg';
|
||||
return 'xl';
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setScreenSize(getScreenSize());
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Responsive values based on screen size
|
||||
const responsivePadding = {
|
||||
'xs': 'p-4',
|
||||
'sm': 'p-5',
|
||||
'md': 'p-5',
|
||||
'lg': 'p-6',
|
||||
'xl': 'p-6'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border",
|
||||
// "responsive-card bg-card text-card-foreground flex flex-col gap-4 rounded-xl border",
|
||||
// responsivePadding[screenSize as keyof typeof responsivePadding],
|
||||
"responsive-card bg-card text-card-foreground flex flex-col gap-4 rounded-xl border border-gray-300",
|
||||
responsivePadding[screenSize as keyof typeof responsivePadding],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -16,11 +49,42 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const [screenSize] = React.useState(getScreenSize());
|
||||
|
||||
function getScreenSize() {
|
||||
const width = window.innerWidth;
|
||||
if (width < 640) return 'xs';
|
||||
if (width < 768) return 'sm';
|
||||
if (width < 1024) return 'md';
|
||||
if (width < 1280) return 'lg';
|
||||
return 'xl';
|
||||
}
|
||||
|
||||
// Responsive values based on screen size
|
||||
const responsiveGap = {
|
||||
'xs': 'gap-1',
|
||||
'sm': 'gap-1.5',
|
||||
'md': 'gap-1.5',
|
||||
'lg': 'gap-1.5',
|
||||
'xl': 'gap-1.5'
|
||||
};
|
||||
|
||||
const responsivePadding = {
|
||||
'xs': 'px-4 pt-4',
|
||||
'sm': 'px-5 pt-5',
|
||||
'md': 'px-5 pt-5',
|
||||
'lg': 'px-6 pt-6',
|
||||
'xl': 'px-6 pt-6'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start",
|
||||
responsiveGap[screenSize as keyof typeof responsiveGap],
|
||||
responsivePadding[screenSize as keyof typeof responsivePadding],
|
||||
"has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -29,20 +93,60 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const [screenSize] = React.useState(getScreenSize());
|
||||
|
||||
function getScreenSize() {
|
||||
const width = window.innerWidth;
|
||||
if (width < 640) return 'xs';
|
||||
if (width < 768) return 'sm';
|
||||
if (width < 1024) return 'md';
|
||||
if (width < 1280) return 'lg';
|
||||
return 'xl';
|
||||
}
|
||||
|
||||
// Responsive font sizes
|
||||
const responsiveFont = {
|
||||
'xs': 'text-lg',
|
||||
'sm': 'text-xl',
|
||||
'md': 'text-xl',
|
||||
'lg': 'text-2xl',
|
||||
'xl': 'text-2xl'
|
||||
};
|
||||
|
||||
return (
|
||||
<h4
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none", className)}
|
||||
className={cn("leading-none font-semibold", responsiveFont[screenSize as keyof typeof responsiveFont], className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const [screenSize] = React.useState(getScreenSize());
|
||||
|
||||
function getScreenSize() {
|
||||
const width = window.innerWidth;
|
||||
if (width < 640) return 'xs';
|
||||
if (width < 768) return 'sm';
|
||||
if (width < 1024) return 'md';
|
||||
if (width < 1280) return 'lg';
|
||||
return 'xl';
|
||||
}
|
||||
|
||||
// Responsive font sizes
|
||||
const responsiveFont = {
|
||||
'xs': 'text-sm',
|
||||
'sm': 'text-base',
|
||||
'md': 'text-base',
|
||||
'lg': 'text-base',
|
||||
'xl': 'text-base'
|
||||
};
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
className={cn("text-muted-foreground", responsiveFont[screenSize as keyof typeof responsiveFont], className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -62,20 +166,65 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const [screenSize] = React.useState(getScreenSize());
|
||||
|
||||
function getScreenSize() {
|
||||
const width = window.innerWidth;
|
||||
if (width < 640) return 'xs';
|
||||
if (width < 768) return 'sm';
|
||||
if (width < 1024) return 'md';
|
||||
if (width < 1280) return 'lg';
|
||||
return 'xl';
|
||||
}
|
||||
|
||||
// Responsive padding
|
||||
const responsivePadding = {
|
||||
'xs': 'px-4',
|
||||
'sm': 'px-5',
|
||||
'md': 'px-5',
|
||||
'lg': 'px-6',
|
||||
'xl': 'px-6'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6 [&:last-child]:pb-6", className)}
|
||||
className={cn(responsivePadding[screenSize as keyof typeof responsivePadding], "[&:last-child]:pb-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const [screenSize] = React.useState(getScreenSize());
|
||||
|
||||
function getScreenSize() {
|
||||
const width = window.innerWidth;
|
||||
if (width < 640) return 'xs';
|
||||
if (width < 768) return 'sm';
|
||||
if (width < 1024) return 'md';
|
||||
if (width < 1280) return 'lg';
|
||||
return 'xl';
|
||||
}
|
||||
|
||||
// Responsive padding
|
||||
const responsivePadding = {
|
||||
'xs': 'px-4 pb-4',
|
||||
'sm': 'px-5 pb-5',
|
||||
'md': 'px-5 pb-5',
|
||||
'lg': 'px-6 pb-6',
|
||||
'xl': 'px-6 pb-6'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 pb-6 [.border-t]:pt-6", className)}
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
responsivePadding[screenSize as keyof typeof responsivePadding],
|
||||
"[.border-t]:pt-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { ChevronDownIcon } from "lucide-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 { cn } from "./utils";
|
||||
|
||||
|
||||
@@ -1,33 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import { Toaster as Sonner, type ToasterProps } from 'sonner';
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes@0.4.6";
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner@2.0.3";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
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();
|
||||
}, []);
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme}
|
||||
theme={theme as ToasterProps["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}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
// 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,29 +1,26 @@
|
||||
declare module '*.webp' {
|
||||
// declarations.d.ts
|
||||
|
||||
declare module "*.png" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
declare module "*.jpg" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*.jpg' {
|
||||
declare module "*.jpeg" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*.jpeg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*.gif' {
|
||||
declare module "*.svg" {
|
||||
import * as React from "react";
|
||||
const ReactComponent: React.FunctionComponent<
|
||||
React.SVGProps<SVGSVGElement> & { title?: string }
|
||||
>;
|
||||
export { ReactComponent };
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useCountUp(end: number, duration: number = 1200) {
|
||||
export const useCountUp = (end: number, duration: number = 1200) => {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -20,4 +20,6 @@ export function useCountUp(end: number, duration: number = 1200) {
|
||||
}, [end, duration]);
|
||||
|
||||
return count;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
|
||||
export const useLocalStorage = <T,>(key: string, initialValue: T) => {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
return item ? (JSON.parse(item) as T) : initialValue;
|
||||
} catch (error) {
|
||||
console.error('Error reading from localStorage:', error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
@@ -20,5 +19,7 @@ export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T)
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue];
|
||||
}
|
||||
return [storedValue, setValue] as const;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@import "./styles/globals.css";
|
||||
@@ -1,105 +0,0 @@
|
||||
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;
|
||||
@@ -1,63 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
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,14 +1,15 @@
|
||||
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';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./styles/globals.css";
|
||||
import { Provider } from "react-redux";
|
||||
import { store } from "./redux/store";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
37
src/mock.ts
Normal file
37
src/mock.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { KPIData, Employee, Announcement, Deadline } from './types';
|
||||
|
||||
export const mockKPIData: KPIData[] = [
|
||||
{ title: 'Total Learners', value: 1247, change: 12, trend: 'up' },
|
||||
{ title: 'Active Courses', value: 89, change: 5, trend: 'up' },
|
||||
{ title: 'Completed Profilers', value: 342, change: -8, trend: 'down' },
|
||||
{ title: 'Average Progress', value: 73, change: 7, trend: 'up' }
|
||||
];
|
||||
|
||||
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' }
|
||||
];
|
||||
|
||||
|
||||
218
src/pages/AnalyticsScreen.tsx
Normal file
218
src/pages/AnalyticsScreen.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useState } from "react";
|
||||
import { Employee, KPIData } from "../types";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/Card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Download } from "lucide-react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { EmployeeTable } from "../components/EmployeeTable";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { KPICard } from "../components/KPICard";
|
||||
// import { mockEmployees } from "../data/mockEmployees";
|
||||
|
||||
export default function AnalyticsScreen({ filters }: { filters?: any }) {
|
||||
const [dateRange, setDateRange] = useState('last-30-days');
|
||||
const [selectedProgrammes, setSelectedProgrammes] = useState<string[]>(['all']);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const analyticsKPIData: KPIData[] = [
|
||||
{ title: 'Total Learners', value: 1247, change: 8.2, trend: 'up' },
|
||||
{ title: 'New Enrolments', value: 89, change: 15.3, trend: 'up' },
|
||||
{ title: 'Course Completions', value: 342, change: -2.1, trend: 'down' },
|
||||
{ title: 'Assessment Rates', value: 78, change: 5.7, trend: 'up' }
|
||||
];
|
||||
|
||||
const handleExport = async (format: 'csv' | 'pdf') => {
|
||||
setExporting(true);
|
||||
// Simulate export process
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setExporting(false);
|
||||
console.log(`Exported as ${format.toUpperCase()}`);
|
||||
};
|
||||
|
||||
const handleRunReport = () => {
|
||||
setLoading(true);
|
||||
// Simulate report generation
|
||||
setTimeout(() => setLoading(false), 1500);
|
||||
};
|
||||
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' }
|
||||
];
|
||||
|
||||
const chartData = [
|
||||
{ month: 'Jan', enrolments: 45, completions: 38, assessments: 42 },
|
||||
{ month: 'Feb', enrolments: 52, completions: 41, assessments: 38 },
|
||||
{ month: 'Mar', enrolments: 48, completions: 44, assessments: 46 },
|
||||
{ month: 'Apr', enrolments: 61, completions: 49, assessments: 52 },
|
||||
{ month: 'May', enrolments: 55, completions: 52, assessments: 48 },
|
||||
{ month: 'Jun', enrolments: 67, completions: 58, assessments: 61 }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filter Bar */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||
<div>
|
||||
<label htmlFor="date-range" className="block text-sm font-medium mb-2">
|
||||
Date Range
|
||||
</label>
|
||||
<Select value={dateRange} onValueChange={setDateRange}>
|
||||
<SelectTrigger id="date-range" className="w-[180px]" aria-controls="kpi-charts">
|
||||
<SelectValue />
|
||||
</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="custom">Custom range</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="programme-filter" className="block text-sm font-medium mb-2">
|
||||
Programmes
|
||||
</label>
|
||||
<Select>
|
||||
<SelectTrigger id="programme-filter" className="w-[200px]" aria-controls="kpi-charts">
|
||||
<SelectValue placeholder="Select programmes" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Programmes</SelectItem>
|
||||
<SelectItem value="leadership">Leadership Development</SelectItem>
|
||||
<SelectItem value="technical">Technical Skills</SelectItem>
|
||||
<SelectItem value="communication">Communication</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleRunReport} disabled={loading} className="min-tap-44">
|
||||
{loading && <RefreshCw className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Run Report
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div id="kpi-charts" className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{analyticsKPIData.map((kpi, index) => (
|
||||
<KPICard
|
||||
key={index}
|
||||
data={kpi}
|
||||
className="animate-fade-in"
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
aria-label={`${kpi.title}: ${kpi.value}${kpi.title.includes('Rates') ? '%' : ''}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts Panel */}
|
||||
<Card className="animate-slide-up" style={{ animationDelay: '400ms' }}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Learning Analytics Overview</CardTitle>
|
||||
<CardDescription>Key metrics over time</CardDescription>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Last refreshed: 10 minutes ago
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="h-80 flex items-center justify-center border border-dashed border-muted rounded-lg"
|
||||
role="img"
|
||||
aria-describedby="chart-description"
|
||||
>
|
||||
<div className="text-center">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">Interactive chart would be rendered here</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Line/Bar chart showing enrolments, completions, and assessments over time
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chart-description" className="sr-only">
|
||||
Line and bar chart showing learning analytics over the selected time period.
|
||||
Displays new enrolments, course completions, and assessment completion rates.
|
||||
Chart includes interactive legend for toggling data series visibility.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detailed Table */}
|
||||
<Card className="animate-slide-up" style={{ animationDelay: '600ms' }}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Assignments & Progress Detail</CardTitle>
|
||||
<CardDescription>Complete learner progress breakdown</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport('csv')}
|
||||
disabled={exporting}
|
||||
className="min-tap-44"
|
||||
aria-live="polite"
|
||||
>
|
||||
{exporting ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport('pdf')}
|
||||
disabled={exporting}
|
||||
className="min-tap-44"
|
||||
aria-live="polite"
|
||||
>
|
||||
{exporting ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Export PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmployeeTable
|
||||
employees={mockEmployees}
|
||||
showProgress={true}
|
||||
maxHeight="500px"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Data Freshness Note */}
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<span role="tooltip" title="Data is automatically refreshed every 15 minutes">
|
||||
Last refreshed: {new Date().toLocaleTimeString()} •
|
||||
Next refresh in 4 minutes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,503 +0,0 @@
|
||||
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;
|
||||
@@ -1,93 +0,0 @@
|
||||
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;
|
||||
@@ -1,164 +0,0 @@
|
||||
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;
|
||||
@@ -1,318 +0,0 @@
|
||||
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;
|
||||
@@ -1,356 +0,0 @@
|
||||
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;
|
||||
581
src/pages/HRHomeScreen.tsx
Normal file
581
src/pages/HRHomeScreen.tsx
Normal file
@@ -0,0 +1,581 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocalStorage } from "../hooks/useLocalStorage";
|
||||
import { Card } from "../components/ui/Card";
|
||||
import { CardHeader } from "../components/ui/Card";
|
||||
import { CardTitle } from "../components/ui/Card";
|
||||
import { CardDescription } from "../components/ui/Card";
|
||||
import { Skeleton } from "../components/ui/skeleton";
|
||||
import { CardContent } from "../components/ui/Card";
|
||||
import { KPICard } from "../components/KPICard";
|
||||
import { BarChart3, ChevronRight } from "lucide-react";
|
||||
import { Download } from "lucide-react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Select } from "../components/ui/select";
|
||||
import { SelectItem } from "../components/ui/select";
|
||||
import { SelectTrigger } from "../components/ui/select";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { SelectValue } from "../components/ui/select";
|
||||
import { SelectContent } from "../components/ui/select";
|
||||
import { mockKPIData } from "../mock";
|
||||
import { mockDeadlines } from "../mock";
|
||||
import { Plus } from "lucide-react";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { EmployeeTable } from "../components/EmployeeTable";
|
||||
import { Announcement, Employee } from "../types";
|
||||
import { useGetPostsQuery } from "../redux/services/demo.services";
|
||||
|
||||
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' }
|
||||
];
|
||||
// Screen Components
|
||||
export default function HRHomeScreen({ onNavigate }: { onNavigate: (screen: string, filters?: any) => void }) {
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [prefersReducedMotion] = useLocalStorage('prefersReducedMotion', false);
|
||||
const { data: posts, isLoading, error } = useGetPostsQuery();
|
||||
console.log(posts, 'posts');
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setLoading(false), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleKPIClick = (kpiTitle: string) => {
|
||||
let filters = {};
|
||||
switch (kpiTitle) {
|
||||
case 'Total Learners':
|
||||
filters = { status: 'all' };
|
||||
break;
|
||||
case 'Active Courses':
|
||||
filters = { status: 'active' };
|
||||
break;
|
||||
case 'Completed Profilers':
|
||||
filters = { completed: true };
|
||||
break;
|
||||
default:
|
||||
filters = {};
|
||||
}
|
||||
onNavigate('learners', filters);
|
||||
};
|
||||
|
||||
const cohortData = [
|
||||
{ name: 'Leadership Development', notStarted: 15, inProgress: 28, completed: 42 },
|
||||
{ name: 'Technical Skills', notStarted: 22, inProgress: 35, completed: 38 },
|
||||
{ name: 'Communication', notStarted: 18, inProgress: 24, completed: 31 },
|
||||
{ name: 'Project Management', notStarted: 12, inProgress: 19, completed: 28 }
|
||||
];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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' }
|
||||
];
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Welcome Section */}
|
||||
<div className={`space-y-4 ${prefersReducedMotion ? '' : 'animate-fade-in'}`}>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold text-foreground">Hello HR Pooja 👋</h1>
|
||||
<p className="text-lg text-muted-foreground">See what's happening today at Acme Corp</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> */}
|
||||
|
||||
{mockKPIData.map((kpi, index) => (
|
||||
<KPICard
|
||||
key={index}
|
||||
data={kpi}
|
||||
onClick={() => handleKPIClick(kpi.title)}
|
||||
className={prefersReducedMotion ? '' : 'animate-fade-in'}
|
||||
style={{ animationDelay: prefersReducedMotion ? '0ms' : `${index * 100 + 200}ms`, }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Employee Assignment & Progress */}
|
||||
|
||||
<Card className={prefersReducedMotion ? '' : 'animate-slide-up'}
|
||||
style={{ animationDelay: '600ms' }}
|
||||
>
|
||||
<CardHeader >
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-lg sm:text-xl truncate">Employee Assignment & Progress</CardTitle>
|
||||
<CardDescription className="truncate">Snapshot of current learning activities</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select defaultValue="all">
|
||||
<SelectTrigger className="w-full sm:w-[140px] lg:w-[180px]">
|
||||
<SelectValue placeholder="Filter by programme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Programmes</SelectItem>
|
||||
<SelectItem value="leadership">Leadership Development</SelectItem>
|
||||
<SelectItem value="technical">Technical Skills</SelectItem>
|
||||
<SelectItem value="communication">Communication</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onNavigate('analytics')}
|
||||
className="min-tap-44 flex-1 sm:flex-initial"
|
||||
size="sm"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 mr-1 sm:mr-2" />
|
||||
<span className="hidden sm:inline">View all in Analytics</span>
|
||||
<span className="sm:hidden">Analytics</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-tap-44 flex-1 sm:flex-initial"
|
||||
size="sm"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Download CSV</span>
|
||||
<span className="sm:hidden">CSV</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<EmployeeTable
|
||||
employees={mockEmployees.slice(0, 6)}
|
||||
onEdit={(employee: any) => onNavigate('learners', { editEmployee: employee.id })}
|
||||
maxHeight="360px"
|
||||
compact={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile view alternative */}
|
||||
<div className="sm:hidden mt-4 space-y-3">
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => onNavigate('analytics')}
|
||||
className="text-sm"
|
||||
>
|
||||
View full employee table in Analytics
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{/* {mockEmployees.slice(0, 3).map((employee) => (
|
||||
<div key={employee.id} className="p-3 border rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-medium text-sm truncate">{employee.name}</h4>
|
||||
<p className="text-xs text-muted-foreground truncate">{employee.department}</p>
|
||||
</div>
|
||||
<Badge variant={employee.status === 'Active' ? 'default' :
|
||||
employee.status === 'Inactive' ? 'secondary' : 'destructive'}>
|
||||
{employee.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Progress: {employee.completion}%
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onNavigate('learners', { editEmployee: employee.id })}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))} */}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Cohort Progress Chart */}
|
||||
{/* <Card className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '800ms' }}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Cohort Progress</CardTitle>
|
||||
<CardDescription>Progress overview by programme</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Auto-refresh
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="space-y-4"
|
||||
role="img"
|
||||
aria-describedby="cohort-chart-desc"
|
||||
>
|
||||
<div id="cohort-chart-desc" className="sr-only">
|
||||
Stacked bar chart showing progress across different learning programmes.
|
||||
Each bar represents not started, in progress, and completed learners.
|
||||
</div>
|
||||
{cohortData.map((cohort, index) => {
|
||||
const total = cohort.notStarted + cohort.inProgress + cohort.completed;
|
||||
const notStartedPercent = (cohort.notStarted / total) * 100;
|
||||
const inProgressPercent = (cohort.inProgress / total) * 100;
|
||||
const completedPercent = (cohort.completed / total) * 100;
|
||||
|
||||
return (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">{cohort.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{total} learners</span>
|
||||
</div>
|
||||
<div className="flex h-4 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="bg-status-error"
|
||||
style={{ width: `${notStartedPercent}%` }}
|
||||
title={`Not Started: ${cohort.notStarted}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-status-warn"
|
||||
style={{ width: `${inProgressPercent}%` }}
|
||||
title={`In Progress: ${cohort.inProgress}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-status-success"
|
||||
style={{ width: `${completedPercent}%` }}
|
||||
title={`Completed: ${cohort.completed}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>Not Started: {cohort.notStarted}</span>
|
||||
<span>In Progress: {cohort.inProgress}</span>
|
||||
<span>Completed: {cohort.completed}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card> */}
|
||||
|
||||
{/* Upcoming Deadlines */}
|
||||
{/* <Card className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '900ms' }}>
|
||||
<CardHeader>
|
||||
<CardTitle>Upcoming Deadlines</CardTitle>
|
||||
<CardDescription>Next 7 days</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{mockDeadlines.map((deadline) => (
|
||||
<div
|
||||
key={deadline.id}
|
||||
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg hover:bg-muted transition-colors cursor-pointer min-tap-44"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${deadline.title} due ${deadline.dueDate} at ${deadline.dueTime}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-md ${deadline.type === 'webinar' ? 'bg-brand-primary text-brand-contrast' : 'bg-status-warn text-status-warn-foreground'}`}>
|
||||
{deadline.type === 'webinar' ?
|
||||
<Calendar className="h-4 w-4" /> :
|
||||
<FileText className="h-4 w-4" />
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{deadline.title}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">{deadline.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge variant={deadline.dueDate === 'Today' ? 'destructive' : 'secondary'} className="text-xs">
|
||||
{deadline.dueDate}
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground mt-1">{deadline.dueTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card> */}
|
||||
{/* </div> */}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6 lg:gap-8 w-full">
|
||||
{/* Cohort Progress Chart */}
|
||||
<Card
|
||||
className={`flex flex-col ${prefersReducedMotion ? '' : 'animate-slide-up'}`}
|
||||
style={{ animationDelay: '800ms' }}
|
||||
>
|
||||
<CardHeader className="px-4 sm:px-6 py-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base sm:text-lg md:text-xl">Cohort Progress</CardTitle>
|
||||
<CardDescription className="text-sm md:text-base">
|
||||
Progress overview by programme
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] sm:text-xs flex items-center gap-1 self-start sm:self-center"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Auto-refresh
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 sm:px-6 pb-4 sm:pb-6">
|
||||
<div
|
||||
className="space-y-5"
|
||||
role="img"
|
||||
aria-describedby="cohort-chart-desc"
|
||||
>
|
||||
<div id="cohort-chart-desc" className="sr-only">
|
||||
Stacked bar chart showing progress across different learning programmes.
|
||||
Each bar represents not started, in progress, and completed learners.
|
||||
</div>
|
||||
|
||||
{cohortData.map((cohort, index) => {
|
||||
const total = cohort.notStarted + cohort.inProgress + cohort.completed;
|
||||
const notStartedPercent = (cohort.notStarted / total) * 100;
|
||||
const inProgressPercent = (cohort.inProgress / total) * 100;
|
||||
const completedPercent = (cohort.completed / total) * 100;
|
||||
|
||||
return (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm md:text-base font-medium">{cohort.name}</span>
|
||||
<span className="text-xs md:text-sm text-muted-foreground">{total} learners</span>
|
||||
</div>
|
||||
<div className="flex h-3 sm:h-4 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="bg-status-error"
|
||||
style={{ width: `${notStartedPercent}%` }}
|
||||
title={`Not Started: ${cohort.notStarted}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-status-warn"
|
||||
style={{ width: `${inProgressPercent}%` }}
|
||||
title={`In Progress: ${cohort.inProgress}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-status-success"
|
||||
style={{ width: `${completedPercent}%` }}
|
||||
title={`Completed: ${cohort.completed}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px] sm:text-xs text-muted-foreground">
|
||||
<span>Not Started: {cohort.notStarted}</span>
|
||||
<span>In Progress: {cohort.inProgress}</span>
|
||||
<span>Completed: {cohort.completed}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Upcoming Deadlines */}
|
||||
<Card
|
||||
className={`flex flex-col ${prefersReducedMotion ? '' : 'animate-slide-up'}`}
|
||||
style={{ animationDelay: '900ms' }}
|
||||
>
|
||||
<CardHeader className="px-4 sm:px-6 py-4">
|
||||
<CardTitle className="text-base sm:text-lg md:text-xl">Upcoming Deadlines</CardTitle>
|
||||
<CardDescription className="text-sm md:text-base">Next 7 days</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 sm:px-6 pb-4 sm:pb-6">
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{mockDeadlines.map((deadline) => (
|
||||
<div
|
||||
key={deadline.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 sm:p-4 bg-muted/50 rounded-lg hover:bg-muted transition-colors cursor-pointer min-tap-44 gap-2 sm:gap-0"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${deadline.title} due ${deadline.dueDate} at ${deadline.dueTime}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`p-2 rounded-md ${deadline.type === 'webinar'
|
||||
? 'bg-brand-primary text-brand-contrast'
|
||||
: 'bg-status-warn text-status-warn-foreground'
|
||||
}`}
|
||||
>
|
||||
{deadline.type === 'webinar' ? (
|
||||
<Calendar className="h-4 w-4" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm md:text-base">{deadline.title}</p>
|
||||
<p className="text-xs md:text-sm text-muted-foreground capitalize">
|
||||
{deadline.type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left sm:text-right">
|
||||
<Badge
|
||||
variant={deadline.dueDate === 'Today' ? 'destructive' : 'secondary'}
|
||||
className="text-[10px] sm:text-xs"
|
||||
>
|
||||
{deadline.dueDate}
|
||||
</Badge>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mt-1">
|
||||
{deadline.dueTime}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
{/* Quick Links */}
|
||||
<Card className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '1000ms' }}>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
<CardDescription>Common HR tasks</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ title: 'Add Learners', icon: Plus, action: () => onNavigate('learners', { action: 'add' }) },
|
||||
{ title: 'Assign Courses', icon: BookOpen, action: () => onNavigate('learners', { action: 'assign' }) },
|
||||
{ title: 'Download Reports', icon: Download, action: () => onNavigate('analytics') },
|
||||
{ title: 'Testimonials Queue', icon: MessageSquare, action: () => onNavigate('testimonials') }
|
||||
].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}
|
||||
aria-controls={link.title === 'Add Learners' ? 'learners-screen' : undefined}
|
||||
>
|
||||
<Icon className="h-6 w-6 text-brand-primary" />
|
||||
<span className="text-sm font-medium text-center">{link.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Announcements & Reminders */}
|
||||
<Card className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '1100ms' }}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Announcements & Reminders</CardTitle>
|
||||
<CardDescription>Recent updates and notifications</CardDescription>
|
||||
</div>
|
||||
<Select defaultValue="all">
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="announcements">Announcements</SelectItem>
|
||||
<SelectItem value="reminders">Reminders</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{mockAnnouncements.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`
|
||||
p-4 rounded-lg border transition-all duration-180
|
||||
${item.pinned ? 'bg-status-warn/10 border-status-warn/20' : 'bg-muted/50 border-transparent'}
|
||||
hover:border-chrome-divider cursor-pointer
|
||||
`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded="false"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-sm">{item.title}</h4>
|
||||
{item.pinned && (
|
||||
<Badge variant="secondary" className="text-xs">Pinned</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2">{item.content}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{item.type}
|
||||
</Badge>
|
||||
<span>{item.timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
593
src/pages/LearnersScreen.tsx
Normal file
593
src/pages/LearnersScreen.tsx
Normal file
@@ -0,0 +1,593 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Employee } from "../types";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/Card";
|
||||
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "../components/ui/sheet";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog";
|
||||
import { Tabs } from "../components/ui/tabs";
|
||||
import { TabsList } from "../components/ui/tabs";
|
||||
import { TabsTrigger } from "../components/ui/tabs";
|
||||
import { TabsContent } from "../components/ui/tabs";
|
||||
import { Progress } from "../components/ui/progress";
|
||||
import { Download, Edit, MoreHorizontal, Plus, Search, Upload } from "lucide-react";
|
||||
// import { mockEmployees } from "../data/mockEmployees";
|
||||
|
||||
export default function LearnersScreen({ filters }: { filters?: any }) {
|
||||
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' }
|
||||
];
|
||||
const [employees, setEmployees] = useState(mockEmployees);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [selectedEmployees, setSelectedEmployees] = useState<string[]>([]);
|
||||
const [showAddDrawer, setShowAddDrawer] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showAssignModal, setShowAssignModal] = useState(false);
|
||||
const [showEditDrawer, setShowEditDrawer] = useState(false);
|
||||
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null);
|
||||
const [newEmployee, setNewEmployee] = useState({ name: '', email: '', phone: '' });
|
||||
const [bulkActionVisible, setBulkActionVisible] = useState(false);
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
(term: string) => {
|
||||
// Simulating search with 300ms delay
|
||||
const timer = setTimeout(() => {
|
||||
setSearchTerm(term);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const filteredEmployees = employees.filter((emp: any) => {
|
||||
const matchesSearch = emp.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
emp.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || emp.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const handleEmployeeSelect = (employeeId: string, selected: boolean) => {
|
||||
if (selected) {
|
||||
setSelectedEmployees(prev => [...prev, employeeId]);
|
||||
} else {
|
||||
setSelectedEmployees(prev => prev.filter(id => id !== employeeId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkSelect = (selectAll: boolean) => {
|
||||
if (selectAll) {
|
||||
setSelectedEmployees(filteredEmployees.map((emp: any) => emp.id));
|
||||
} else {
|
||||
setSelectedEmployees([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setBulkActionVisible(selectedEmployees.length > 0);
|
||||
}, [selectedEmployees]);
|
||||
|
||||
const handleAddEmployee = () => {
|
||||
if (newEmployee.name && newEmployee.email) {
|
||||
const newEmp: Employee = {
|
||||
id: Date.now().toString(),
|
||||
name: newEmployee.name,
|
||||
email: newEmployee.email,
|
||||
phone: newEmployee.phone,
|
||||
status: 'Pending'
|
||||
};
|
||||
setEmployees((prev: any) => [...prev, newEmp]);
|
||||
setNewEmployee({ name: '', email: '', phone: '' });
|
||||
setShowAddDrawer(false);
|
||||
// Show success toast (simulated)
|
||||
console.log('Employee added successfully');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditEmployee = (employee: Employee) => {
|
||||
setEditingEmployee(employee);
|
||||
setShowEditDrawer(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Toolbar */}
|
||||
{/* <Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center flex-1">
|
||||
<div className="relative flex-1 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"
|
||||
onChange={(e) => debouncedSearch(e.target.value)}
|
||||
aria-label="Search learners by name or email"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="Active">Active</SelectItem>
|
||||
<SelectItem value="Inactive">Inactive</SelectItem>
|
||||
<SelectItem value="Pending">Pending</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setShowAddDrawer(true)}
|
||||
className="min-tap-44"
|
||||
aria-label="Add new learner"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Learner
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowImportModal(true)}
|
||||
className="min-tap-44"
|
||||
aria-label="Import learners from CSV"
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Import Learners
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card> */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center justify-between">
|
||||
{/* Left side (Search + Filter) */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-stretch sm:items-center flex-1 w-full">
|
||||
<div className="relative flex-1 max-w-md w-full">
|
||||
<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 w-full"
|
||||
onChange={(e) => debouncedSearch(e.target.value)}
|
||||
aria-label="Search learners by name or email"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full sm:w-[150px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="Active">Active</SelectItem>
|
||||
<SelectItem value="Inactive">Inactive</SelectItem>
|
||||
<SelectItem value="Pending">Pending</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Right side (Buttons) */}
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
||||
<Button
|
||||
onClick={() => setShowAddDrawer(true)}
|
||||
className="w-full sm:w-auto py-6"
|
||||
aria-label="Add new learner"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Learner
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowImportModal(true)}
|
||||
className="w-full sm:w-auto py-6"
|
||||
aria-label="Import learners from CSV"
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Import Learners
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
{/* Bulk Action Bar */}
|
||||
{bulkActionVisible && (
|
||||
<Card className="animate-slide-in-right">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{selectedEmployees.length} learner{selectedEmployees.length !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAssignModal(true)}
|
||||
className="min-tap-44"
|
||||
>
|
||||
Assign to Programme/Course
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-tap-44"
|
||||
>
|
||||
Deactivate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-tap-44"
|
||||
>
|
||||
Reactivate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Learners Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Learners ({filteredEmployees.length})</CardTitle>
|
||||
<CardDescription>Manage learner accounts and assignments</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBulkSelect(selectedEmployees.length !== filteredEmployees.length)}
|
||||
className="min-tap-44"
|
||||
>
|
||||
{selectedEmployees.length === filteredEmployees.length ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border min-h-[60vh]">
|
||||
<Table>
|
||||
<TableHeader className="sticky-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">Select</TableHead>
|
||||
<TableHead className="w-[200px]">Name</TableHead>
|
||||
<TableHead className="w-[250px]">Email</TableHead>
|
||||
<TableHead className="w-[150px]">Phone</TableHead>
|
||||
<TableHead className="w-[100px]">Status</TableHead>
|
||||
<TableHead className="w-[80px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredEmployees.map((employee: any) => (
|
||||
<TableRow
|
||||
key={employee.id}
|
||||
className="min-h-[48px]"
|
||||
>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEmployees.includes(employee.id)}
|
||||
onChange={(e) => handleEmployeeSelect(employee.id, e.target.checked)}
|
||||
className="min-tap-44"
|
||||
aria-label={`Select ${employee.name}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{employee.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{employee.email}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{employee.phone}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={employee.status === 'Active' ? 'default' : employee.status === 'Pending' ? 'secondary' : 'destructive'}
|
||||
|
||||
|
||||
|
||||
>
|
||||
{employee.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditEmployee(employee)}
|
||||
className="min-tap-44"
|
||||
aria-label={`Edit ${employee.name}`}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-tap-44"
|
||||
aria-label={`More actions for ${employee.name}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add Learner Drawer */}
|
||||
<Sheet open={showAddDrawer} onOpenChange={setShowAddDrawer}>
|
||||
<SheetContent
|
||||
className="w-[480px] sm:w-[540px]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="add-learner-title"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle id="add-learner-title">Add New Learner</SheetTitle>
|
||||
<SheetDescription>
|
||||
Add a new learner to the system. Email cannot be changed after saving.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="mt-6 space-y-4 p-4">
|
||||
<div>
|
||||
<label htmlFor="employee-name" className="block text-sm font-medium mb-2">
|
||||
Employee Name *
|
||||
</label>
|
||||
<Input
|
||||
id="employee-name"
|
||||
value={newEmployee.name}
|
||||
onChange={(e) => setNewEmployee(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Enter full name"
|
||||
required
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="employee-email" className="block text-sm font-medium mb-2">
|
||||
Email Address *
|
||||
</label>
|
||||
<Input
|
||||
id="employee-email"
|
||||
type="email"
|
||||
value={newEmployee.email}
|
||||
onChange={(e) => setNewEmployee(prev => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="email@company.com"
|
||||
required
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="employee-phone" className="block text-sm font-medium mb-2">
|
||||
Phone Number
|
||||
</label>
|
||||
<Input
|
||||
id="employee-phone"
|
||||
type="tel"
|
||||
value={newEmployee.phone}
|
||||
onChange={(e) => setNewEmployee(prev => ({ ...prev, phone: e.target.value }))}
|
||||
placeholder="+61 4XX XXX XXX"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button onClick={handleAddEmployee} className="flex-1">
|
||||
Save Learner
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowAddDrawer(false)} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Import Modal */}
|
||||
<Dialog open={showImportModal} onOpenChange={setShowImportModal}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[600px]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="import-title"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle id="import-title">Import Learners</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload a CSV file to import multiple learners. Maximum file size: 5MB.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Step 1: Download Template</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Download our CSV template with the required fields: Name, Email, Phone.
|
||||
</p>
|
||||
<Button variant="outline" className="min-tap-44">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download CSV Template
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Step 2: Upload File</h4>
|
||||
<div className="border-2 border-dashed border-muted rounded-lg p-8 text-center">
|
||||
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Drag and drop your CSV file here, or click to browse
|
||||
</p>
|
||||
<Button variant="outline" className="mt-2 min-tap-44">
|
||||
Choose File
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1">Import Learners</Button>
|
||||
<Button variant="outline" onClick={() => setShowImportModal(false)} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Assign Modal */}
|
||||
<Dialog open={showAssignModal} onOpenChange={setShowAssignModal}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[500px]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="assign-title"
|
||||
style={{ padding: "8px" }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle id="assign-title">Assign to Programme/Course</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign {selectedEmployees.length} selected learner{selectedEmployees.length !== 1 ? 's' : ''} to a programme or course.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="programme-select" className="block text-sm font-medium mb-2">
|
||||
Select Programme/Course
|
||||
</label>
|
||||
<Select>
|
||||
<SelectTrigger id="programme-select">
|
||||
<SelectValue placeholder="Choose programme or course" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="leadership">Leadership Development</SelectItem>
|
||||
<SelectItem value="technical">Technical Skills</SelectItem>
|
||||
<SelectItem value="communication">Communication</SelectItem>
|
||||
<SelectItem value="project">Project Management</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="start-date" className="block text-sm font-medium mb-2">
|
||||
Start Date (Optional)
|
||||
</label>
|
||||
<Input id="start-date" type="date" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1">Assign</Button>
|
||||
<Button variant="outline" onClick={() => setShowAssignModal(false)} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit/Assign Drawer */}
|
||||
<Sheet open={showEditDrawer} onOpenChange={setShowEditDrawer}>
|
||||
<SheetContent
|
||||
className="w-[600px] sm:w-[700px]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="edit-learner-title"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle id="edit-learner-title">
|
||||
{editingEmployee?.name}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Edit learner details and manage course assignments.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{editingEmployee && (
|
||||
<Tabs defaultValue="details" className="mt-6 px-4 lg:px-8">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
<TabsTrigger value="enrolments">Enrolments</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="details" className="space-y-4 mt-4">
|
||||
<div>
|
||||
<label htmlFor="edit-name" className="block text-sm font-medium mb-2">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
defaultValue={editingEmployee.name}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="edit-phone" className="block text-sm font-medium mb-2">
|
||||
Phone
|
||||
</label>
|
||||
<Input
|
||||
id="edit-phone"
|
||||
defaultValue={editingEmployee.phone}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="edit-status" className="block text-sm font-medium mb-2">
|
||||
Status
|
||||
</label>
|
||||
<Select defaultValue={editingEmployee.status}>
|
||||
<SelectTrigger id="edit-status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Active">Active</SelectItem>
|
||||
<SelectItem value="Inactive">Inactive</SelectItem>
|
||||
<SelectItem value="Pending">Pending</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="enrolments" className="space-y-4 mt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">Current Enrolments</h4>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Assign Course
|
||||
</Button>
|
||||
</div>
|
||||
{editingEmployee.programme && (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{editingEmployee.programme}</p>
|
||||
<p className="text-sm text-muted-foreground">{editingEmployee.course}</p>
|
||||
{editingEmployee.progress !== undefined && (
|
||||
<Progress value={editingEmployee.progress} className="w-32 mt-2" />
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
Unassign
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<div className="flex gap-2 mt-6">
|
||||
<Button className="flex-1">Save Changes</Button>
|
||||
<Button variant="outline" onClick={() => setShowEditDrawer(false)} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
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;
|
||||
@@ -1,519 +0,0 @@
|
||||
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;
|
||||
@@ -1,133 +0,0 @@
|
||||
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;
|
||||
@@ -1,574 +0,0 @@
|
||||
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;
|
||||
109
src/pages/SettingsScreen.tsx
Normal file
109
src/pages/SettingsScreen.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent } from "../components/ui/Card";
|
||||
import { Tabs, TabsList, TabsContent } from "../components/ui/tabs";
|
||||
import { TabsTrigger } from "../components/ui/tabs";
|
||||
import { Building2 } from "lucide-react";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import { Shield } from "lucide-react";
|
||||
import { CardHeader } from "../components/ui/Card";
|
||||
import { CardTitle } from "../components/ui/Card";
|
||||
import { CardDescription } from "../components/ui/Card";
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const [activeTab, setActiveTab] = useState('profile');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Settings Tabs */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList
|
||||
className="grid w-full grid-cols-3 mb-6"
|
||||
role="tablist"
|
||||
aria-label="Settings navigation"
|
||||
>
|
||||
<TabsTrigger
|
||||
value="profile"
|
||||
role="tab"
|
||||
aria-controls="profile-content"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Org Profile</span>
|
||||
<span className="sm:hidden">Profile</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="billing"
|
||||
role="tab"
|
||||
aria-controls="billing-content"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<CreditCard className="h-4 w-4" />
|
||||
Billing
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="roles"
|
||||
role="tab"
|
||||
aria-controls="roles-content"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
Roles
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" role="tabpanel" id="profile-content">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Organisation Profile</CardTitle>
|
||||
<CardDescription>Manage your organisation's profile and settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Building2 className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p>Organisation profile settings would be displayed here</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="billing" role="tabpanel" id="billing-content">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Billing & Subscriptions</CardTitle>
|
||||
<CardDescription>Manage your billing information and subscription plans</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<CreditCard className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p>Billing and subscription management would be displayed here</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="roles" role="tabpanel" id="roles-content">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Roles & Permissions</CardTitle>
|
||||
<CardDescription>Manage user roles and access permissions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Shield className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p>Roles and permissions management would be displayed here</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
399
src/pages/TestimonialsScreen.tsx
Normal file
399
src/pages/TestimonialsScreen.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import { useState } from "react";
|
||||
import { useLocalStorage } from "../hooks/useLocalStorage";
|
||||
import { useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/Card";
|
||||
import { Skeleton } from "../components/ui/skeleton";
|
||||
import { Alert, AlertDescription } from "../components/ui/alert";
|
||||
import { CheckCircle } from "lucide-react";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
|
||||
import { Checkbox } from "../components/ui/checkbox";
|
||||
import { Info } from "lucide-react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { TestimonialFormData } from "../types";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
export default function TestimonialsScreen() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formData, setFormData] = useState<TestimonialFormData>({
|
||||
name: 'Alex Sharma',
|
||||
email: 'alex.sharma@company.com',
|
||||
phone: '',
|
||||
organisation: 'Acme Corp',
|
||||
programme: '',
|
||||
testimonialText: '',
|
||||
consentToPublish: false
|
||||
});
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false);
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
const [charCount, setCharCount] = useState(0);
|
||||
const [prefersReducedMotion] = useLocalStorage('prefersReducedMotion', false);
|
||||
|
||||
// Simulate profile pre-fill loading
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setLoading(false), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const programmes = [
|
||||
'Leadership Development',
|
||||
'Technical Skills',
|
||||
'Communication',
|
||||
'Project Management',
|
||||
'Sales Training'
|
||||
];
|
||||
|
||||
const validateForm = () => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!formData.testimonialText.trim()) {
|
||||
errors.testimonialText = 'Testimonial text is required';
|
||||
} else if (formData.testimonialText.length < 1) {
|
||||
errors.testimonialText = 'Testimonial must be at least 1 character';
|
||||
} else if (formData.testimonialText.length > 2000) {
|
||||
errors.testimonialText = 'Testimonial must be 2000 characters or less';
|
||||
}
|
||||
|
||||
if (!formData.consentToPublish) {
|
||||
errors.consentToPublish = 'You must consent to publish your testimonial';
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const isFormValid = () => {
|
||||
const errors = validateForm();
|
||||
return Object.keys(errors).length === 0 && formData.testimonialText.trim().length > 0;
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof TestimonialFormData, value: string | boolean) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
if (field === 'testimonialText' && typeof value === 'string') {
|
||||
setCharCount(value.length);
|
||||
}
|
||||
|
||||
// Clear field error when user starts typing
|
||||
if (formErrors[field]) {
|
||||
setFormErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const errors = validateForm();
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFormErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitError('');
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// Simulate occasional error for testing
|
||||
if (Math.random() > 0.9) {
|
||||
throw new Error('Submission failed. Please try again.');
|
||||
}
|
||||
|
||||
setSubmitSuccess(true);
|
||||
setFormData(prev => ({ ...prev, testimonialText: '', programme: '', consentToPublish: false }));
|
||||
setCharCount(0);
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : 'An error occurred. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSubmitSuccess(false);
|
||||
setSubmitError('');
|
||||
setFormErrors({});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-full max-w-md" />
|
||||
<Skeleton className="h-10 w-full max-w-md" />
|
||||
<Skeleton className="h-32 w-full max-w-md" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Success State */}
|
||||
{submitSuccess && (
|
||||
<Alert
|
||||
className={`border-status-success/20 bg-status-success/10 ${prefersReducedMotion ? '' : 'animate-fade-in'}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 text-status-success" />
|
||||
<AlertDescription className="text-status-success">
|
||||
Thanks — your testimonial is pending review by KLC.
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 ml-2 h-auto text-status-success underline"
|
||||
onClick={resetForm}
|
||||
>
|
||||
Submit another testimonial
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{submitError && (
|
||||
<Alert
|
||||
className="border-status-error/20 bg-status-error/10"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 text-status-error" />
|
||||
<AlertDescription className="text-status-error">
|
||||
{submitError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Testimonial Form */}
|
||||
<Card className="max-w-3xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Submit Testimonial</CardTitle>
|
||||
<CardDescription>
|
||||
Share your experience with KLC programmes to help others discover the value of our learning solutions.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-[720px] mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Your Name */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-2">
|
||||
Your Name
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
readOnly
|
||||
className="bg-muted cursor-not-allowed"
|
||||
aria-describedby="name-help"
|
||||
/>
|
||||
<p id="name-help" className="text-xs text-muted-foreground mt-1">
|
||||
Pre-filled from your profile
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Work Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-2">
|
||||
Work Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
value={formData.email}
|
||||
readOnly
|
||||
className="bg-muted cursor-not-allowed"
|
||||
aria-describedby="email-help"
|
||||
/>
|
||||
<p id="email-help" className="text-xs text-muted-foreground mt-1">
|
||||
Pre-filled from your profile
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium mb-2">
|
||||
Phone <span className="text-muted-foreground">(Optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||
placeholder="+61 4XX XXX XXX"
|
||||
className="min-tap-44"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Organisation */}
|
||||
<div>
|
||||
<label htmlFor="organisation" className="block text-sm font-medium mb-2">
|
||||
Organisation
|
||||
</label>
|
||||
<Input
|
||||
id="organisation"
|
||||
value={formData.organisation}
|
||||
readOnly
|
||||
className="bg-muted cursor-not-allowed"
|
||||
aria-describedby="org-help"
|
||||
/>
|
||||
<p id="org-help" className="text-xs text-muted-foreground mt-1">
|
||||
Pre-filled from your profile
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Programme */}
|
||||
<div>
|
||||
<label htmlFor="programme" className="block text-sm font-medium mb-2">
|
||||
Programme <span className="text-muted-foreground">(Optional)</span>
|
||||
</label>
|
||||
<Select
|
||||
value={formData.programme}
|
||||
onValueChange={(value: any) => handleInputChange('programme', value)}
|
||||
>
|
||||
<SelectTrigger id="programme" className="min-tap-44">
|
||||
<SelectValue placeholder="Select a programme (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programmes.map((programme) => (
|
||||
<SelectItem key={programme} value={programme}>
|
||||
{programme}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Testimonial Text */}
|
||||
<div>
|
||||
<label htmlFor="testimonial" className="block text-sm font-medium mb-2">
|
||||
Testimonial Text *
|
||||
</label>
|
||||
<Textarea
|
||||
id="testimonial"
|
||||
value={formData.testimonialText}
|
||||
onChange={(e) => handleInputChange('testimonialText', e.target.value)}
|
||||
placeholder="Share your experience (1–2000 chars)…"
|
||||
className={`min-h-[120px] min-tap-44 ${formErrors.testimonialText ? 'border-status-error' : ''}`}
|
||||
aria-invalid={!!formErrors.testimonialText}
|
||||
aria-describedby="testimonial-help testimonial-counter testimonial-error"
|
||||
maxLength={2000}
|
||||
required
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<div>
|
||||
<p id="testimonial-help" className="text-xs text-muted-foreground">
|
||||
Share what you learned, how it helped, or what you'd recommend to others
|
||||
</p>
|
||||
{formErrors.testimonialText && (
|
||||
<p id="testimonial-error" className="text-xs text-status-error mt-1" role="alert">
|
||||
{formErrors.testimonialText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
id="testimonial-counter"
|
||||
className={`text-xs ${charCount > 2000 ? 'text-status-error' : 'text-muted-foreground'}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{charCount}/2000
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Consent Checkbox */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="consent"
|
||||
checked={formData.consentToPublish}
|
||||
onCheckedChange={(checked: any) => handleInputChange('consentToPublish', !!checked)}
|
||||
className={`min-tap-44 mt-1 ${formErrors.consentToPublish ? 'border-status-error' : ''}`}
|
||||
aria-invalid={!!formErrors.consentToPublish}
|
||||
aria-describedby="consent-label consent-error"
|
||||
required
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="consent"
|
||||
id="consent-label"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
I consent to publish this testimonial *
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your testimonial may be used in marketing materials and on our website.
|
||||
You can request removal at any time by contacting us.
|
||||
</p>
|
||||
{formErrors.consentToPublish && (
|
||||
<p id="consent-error" className="text-xs text-status-error" role="alert">
|
||||
{formErrors.consentToPublish}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isFormValid() || isSubmitting || submitSuccess}
|
||||
className="w-full md:w-auto min-tap-44"
|
||||
aria-describedby="submit-help"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
'Submit Testimonial'
|
||||
)}
|
||||
</Button>
|
||||
<p id="submit-help" className="text-xs text-muted-foreground mt-2">
|
||||
Your testimonial will be reviewed before being published
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status Notice */}
|
||||
<Card className="max-w-3xl mx-auto" role="note">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-brand-primary mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm">
|
||||
Submissions are reviewed by KLC Super-Admins before appearing publicly.
|
||||
<Button variant="link" className="p-0 ml-1 h-auto text-sm underline">
|
||||
View policy
|
||||
<ExternalLink className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
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;
|
||||
24
src/redux/services/demo.services.ts
Normal file
24
src/redux/services/demo.services.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// src/services/demo.service.ts
|
||||
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
|
||||
|
||||
export const demoApi = createApi({
|
||||
reducerPath: "demoApi",
|
||||
baseQuery: fetchBaseQuery({ baseUrl: "https://jsonplaceholder.typicode.com" }),
|
||||
endpoints: (builder) => ({
|
||||
// GET example
|
||||
getPosts: builder.query<any[], void>({
|
||||
query: () => "/posts",
|
||||
}),
|
||||
|
||||
// POST example
|
||||
createPost: builder.mutation<any, { title: string; body: string; userId: number }>({
|
||||
query: (newPost) => ({
|
||||
url: "/posts",
|
||||
method: "POST",
|
||||
body: newPost,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetPostsQuery, useCreatePostMutation } = demoApi;
|
||||
@@ -1,139 +0,0 @@
|
||||
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;
|
||||
@@ -1,555 +0,0 @@
|
||||
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;
|
||||
@@ -1,52 +0,0 @@
|
||||
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;
|
||||
@@ -1,17 +0,0 @@
|
||||
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;
|
||||
13
src/redux/store.tsx
Normal file
13
src/redux/store.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { demoApi } from "./services/demo.services";
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
[demoApi.reducerPath]: demoApi.reducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().concat(demoApi.middleware),
|
||||
});
|
||||
|
||||
// Types
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
@@ -1,14 +0,0 @@
|
||||
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}</>;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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 {
|
||||
@@ -62,19 +62,30 @@
|
||||
--chart-5: #C0C0C0;
|
||||
|
||||
/* Spacing Scale (4-32px) */
|
||||
--spacing-4: 0.286rem; /* 4px */
|
||||
--spacing-8: 0.571rem; /* 8px */
|
||||
--spacing-12: 0.857rem; /* 12px */
|
||||
--spacing-16: 1.143rem; /* 16px */
|
||||
--spacing-20: 1.429rem; /* 20px */
|
||||
--spacing-24: 1.714rem; /* 24px */
|
||||
--spacing-28: 2rem; /* 28px */
|
||||
--spacing-32: 2.286rem; /* 32px */
|
||||
--spacing-4: 0.286rem;
|
||||
/* 4px */
|
||||
--spacing-8: 0.571rem;
|
||||
/* 8px */
|
||||
--spacing-12: 0.857rem;
|
||||
/* 12px */
|
||||
--spacing-16: 1.143rem;
|
||||
/* 16px */
|
||||
--spacing-20: 1.429rem;
|
||||
/* 20px */
|
||||
--spacing-24: 1.714rem;
|
||||
/* 24px */
|
||||
--spacing-28: 2rem;
|
||||
/* 28px */
|
||||
--spacing-32: 2.286rem;
|
||||
/* 32px */
|
||||
|
||||
/* Radius Scale */
|
||||
--radius-4: 0.286rem; /* 4px */
|
||||
--radius-8: 0.571rem; /* 8px */
|
||||
--radius-12: 0.857rem; /* 12px */
|
||||
--radius-4: 0.286rem;
|
||||
/* 4px */
|
||||
--radius-8: 0.571rem;
|
||||
/* 8px */
|
||||
--radius-12: 0.857rem;
|
||||
/* 12px */
|
||||
--radius: var(--radius-8);
|
||||
|
||||
/* Shadow Scale */
|
||||
@@ -83,14 +94,22 @@
|
||||
--shadow-3: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||
|
||||
/* Type Scale (12-32px) */
|
||||
--text-12: 0.857rem; /* 12px */
|
||||
--text-14: 1rem; /* 14px */
|
||||
--text-16: 1.143rem; /* 16px */
|
||||
--text-18: 1.286rem; /* 18px */
|
||||
--text-20: 1.429rem; /* 20px */
|
||||
--text-24: 1.714rem; /* 24px */
|
||||
--text-28: 2rem; /* 28px */
|
||||
--text-32: 2.286rem; /* 32px */
|
||||
--text-12: 0.857rem;
|
||||
/* 12px */
|
||||
--text-14: 1rem;
|
||||
/* 14px */
|
||||
--text-16: 1.143rem;
|
||||
/* 16px */
|
||||
--text-18: 1.286rem;
|
||||
/* 18px */
|
||||
--text-20: 1.429rem;
|
||||
/* 20px */
|
||||
--text-24: 1.714rem;
|
||||
/* 24px */
|
||||
--text-28: 2rem;
|
||||
/* 28px */
|
||||
--text-32: 2.286rem;
|
||||
/* 32px */
|
||||
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-normal: 400;
|
||||
@@ -396,28 +415,55 @@
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from { opacity: 0; transform: translateX(100%); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes countUp {
|
||||
from { transform: scale(0.8); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
from {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility: Reduce motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.animate-fade-in,
|
||||
.animate-slide-up,
|
||||
.animate-count-up,
|
||||
@@ -500,8 +546,8 @@
|
||||
/* Utility classes for HR Portal */
|
||||
@layer utilities {
|
||||
.min-tap-44 {
|
||||
min-height: 12px;
|
||||
min-width: 12px;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
.sticky-header {
|
||||
@@ -518,18 +564,96 @@
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
background: linear-gradient(90deg,
|
||||
var(--muted) 0%,
|
||||
var(--accent) 50%,
|
||||
var(--muted) 100%
|
||||
);
|
||||
var(--muted) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Employee Card Mobile View */
|
||||
/* Add this to your main global CSS file */
|
||||
@media (max-width: 640px) {
|
||||
.employee-card-mobile-view {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.employee-card-desktop-view {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.employee-card-mobile-view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.employee-card-desktop-view {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom styles for the compact table */
|
||||
.compact-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.compact-table th,
|
||||
.compact-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Responsive table container */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Ensure buttons are touch-friendly on mobile */
|
||||
.min-tap-44 {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Truncate text for small containers */
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Animation for card entrance */
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-slide-up {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
47
src/types.ts
Normal file
47
src/types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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 TestimonialFormData {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
organisation: string;
|
||||
programme: string;
|
||||
testimonialText: string;
|
||||
consentToPublish: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
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
1
src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import * as path from 'path';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -54,7 +54,7 @@ import * as path from 'path';
|
||||
outDir: 'build',
|
||||
},
|
||||
server: {
|
||||
port: 3005,
|
||||
port: 3000,
|
||||
open: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user