18 Commits

Author SHA1 Message Date
b77a3157cf Merge pull request 'add dummy assign profiler and added course and programme' (#24) from priyanshu-dev into main
All checks were successful
Build-Check / Build and Test PR (pull_request) Successful in 32s
Sonar Check / SonarQube Scan (pull_request) Successful in 57s
Reviewed-on: #24
2026-04-23 14:24:35 +00:00
priyanshuvish
bd36b3c9d1 add dummy assign profiler and added course and programme
All checks were successful
Build-Check / Build and Test PR (pull_request) Successful in 35s
Sonar Check / SonarQube Scan (pull_request) Successful in 58s
2026-04-23 19:53:27 +05:30
9b175a2d33 Merge pull request 'api endpoint changes' (#22) from priyanshu-dev into main
All checks were successful
Build-Check / Build and Test PR (pull_request) Successful in 33s
Sonar Check / SonarQube Scan (pull_request) Successful in 58s
Reviewed-on: #22
2026-04-22 11:50:44 +00:00
priyanshuvish
26eca02d29 api endpoint changes
All checks were successful
Build-Check / Build and Test PR (pull_request) Successful in 32s
Sonar Check / SonarQube Scan (pull_request) Successful in 57s
2026-04-22 17:20:09 +05:30
e0f32f2ede Merge pull request 'discussion change' (#20) from priyanshu-dev into main
All checks were successful
Build-Check / Build and Test PR (pull_request) Successful in 32s
Sonar Check / SonarQube Scan (pull_request) Successful in 57s
Reviewed-on: #20
2026-04-22 09:25:21 +00:00
priyanshuvish
b7fa790d6e discussion change
All checks were successful
Build-Check / Build and Test PR (pull_request) Successful in 38s
Sonar Check / SonarQube Scan (pull_request) Successful in 59s
2026-04-22 14:54:14 +05:30
742d18ffb9 Merge pull request 'priyanshu-dev' (#18) from priyanshu-dev into main
All checks were successful
Build-Check / Build and Test PR (pull_request) Successful in 59s
Sonar Check / SonarQube Scan (pull_request) Successful in 57s
Reviewed-on: #18
2026-04-14 14:43:25 +00:00
priyanshuvish
04a2c4c529 Merge branch 'main' of http://git.wdipl.com/PriyanshuVishwakarma/KLC-Hr-Dashboard into priyanshu-dev
All checks were successful
Build-Check / Build and Test PR (pull_request) Successful in 31s
Sonar Check / SonarQube Scan (pull_request) Successful in 58s
2026-04-14 20:12:51 +05:30
priyanshuvish
b13d7562e6 hide export button 2026-04-14 20:12:43 +05:30
0c564e5a94 Update .gitea/workflows/build.yml
All checks were successful
Build-Check / Build and Test PR (pull_request) Successful in 32s
Sonar Check / SonarQube Scan (pull_request) Successful in 55s
Deploy / Deploying code in Server (push) Successful in 23s
2026-04-14 13:57:26 +00:00
a4b8de32de Update .gitea/workflows/deploy.yml 2026-04-14 13:56:10 +00:00
d96c7ea424 Update .gitea/workflows/sonar.yml 2026-04-14 13:52:05 +00:00
d75740b6a4 testing (#16)
Co-authored-by: Priyanshu Vishwakarma <priyanshu.vishwakarma@wdimails.com>
Reviewed-on: #16
2026-04-14 13:29:36 +00:00
80c75b73e4 Merge pull request 'replaces course and programme api for learner' (#10) from priyanshu-dev into main
Reviewed-on: #10
2026-04-14 08:26:53 +00:00
priyanshuvish
e1f1ee5ea2 replaces course and programme api for learner 2026-04-14 13:56:14 +05:30
priyanshuvish
f1d231d101 working on learner 2026-04-10 16:38:25 +05:30
priyanshuvish
399b860077 need to fix layout 2026-03-19 13:44:16 +05:30
priyanshuvish
9823bf9a9e all src changes with new one 2025-09-26 16:37:17 +05:30
76 changed files with 11543 additions and 3503 deletions

View File

@@ -0,0 +1,34 @@
name: Build-Check
on:
pull_request:
branches:
- main
- beta
- testing
- client
- staging
- production
jobs:
build-test:
name: Build and Test PR
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install Dependencies
run: npm install
- name: Build Check
run: npm run build
- name: Audit Dependencies
run: npm audit --audit-level=critical

View File

@@ -0,0 +1,67 @@
name: Enforce Image Standards
on:
pull_request:
branches:
- main
- beta
- testing
- client
- staging
- production
types: [opened, synchronize, reopened]
paths:
- '**/*.jpg'
- '**/*.jpeg'
- '**/*.png'
workflow_dispatch:
jobs:
optimize:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ gitea.head_ref }} # IMPORTANT
- name: Install Image Tools
run: |
sudo apt-get update
sudo apt-get install -y imagemagick jpegoptim pngquant
- name: Resize Oversized Images
run: |
find . -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" \) \
-exec mogrify -resize 1920x1920\> {} \;
- name: Optimize JPEG
run: |
find . -type f \( -iname "*.jpg" -o -iname "*.jpeg" \) \
-exec jpegoptim --strip-all --max=85 {} \;
- name: Optimize PNG
run: |
find . -type f -iname "*.png" \
-exec pngquant --force --ext .png --quality=75-90 {} \;
# Commit changes if any
- name: Commit changes
run: |
git config --global user.name "CI Bot"
git config --global user.email "ci@local"
if [ -n "$(git status --porcelain)" ]; then
git add .
git commit -m "chore: optimize images via CI"
else
echo "No changes to commit"
fi
# Push back to PR branch
- name: Push changes
if: success()
run: |
git push origin HEAD:${{ gitea.head_ref }}

View File

@@ -0,0 +1,77 @@
name: Deploy
on:
push:
branches:
- beta
- testing
- client
- staging
- production
jobs:
deploy:
name: Deploying code in Server
runs-on: ubuntu-latest
steps:
- name: Checkout Code in Runner
uses: actions/checkout@v3
- name: Determine Project Folder
run: |
echo "Detected Branch Name : ${{ gitea.ref_name }}"
BRANCH_NAME=${{ gitea.ref_name }}
case "$BRANCH_NAME" in
beta)
echo "PROJECT_FOLDER=null" >> $GITHUB_ENV
;;
testing)
echo "PROJECT_FOLDER=/home/klc/klc-frontend/Test_Release/KLC-Hr-Dashboard-Frontend" >> $GITHUB_ENV
;;
client)
echo "PROJECT_FOLDER=null" >> $GITHUB_ENV
;;
staging)
echo "PROJECT_FOLDER=null" >> $GITHUB_ENV
;;
production)
echo "PROJECT_FOLDER=null" >> $GITHUB_ENV
;;
*)
echo "UNKNOWN BRANCH"
exit 1
;;
esac
echo "BRANCH_NAME=${{ gitea.ref_name }}" >> $GITHUB_ENV
- name: Deploy Using SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.BETA_SERVER_HOST }}
username: ${{ secrets.BETA_SERVER_USERNAME }}
password: ${{ secrets.BETA_SERVER_PASSWORD }}
port: ${{ secrets.BETA_SERVER_PORT }}
envs: BRANCH_NAME,PROJECT_FOLDER
script: |
set -e
if [ "$PROJECT_FOLDER" = "null" ]; then
echo "No deployment configured for this branch"
exit 0
fi
cd $PROJECT_FOLDER
git fetch
git reset --hard origin/$BRANCH_NAME
git stash && git stash clear || true
git pull origin $BRANCH_NAME
echo "Installing Node Modules..."
npm install
echo "Building..."
npm run build

View File

@@ -0,0 +1,39 @@
name: Sonar Check
on:
pull_request:
branches:
- main
- beta
- testing
- client
- staging
- production
jobs:
sonarqube:
name: SonarQube Scan
runs-on: ubuntu-latest
container:
image: sonarsource/sonar-scanner-cli:12.0.0.3214_8.0.1
options: --user root
steps:
- name: Checkout Repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Run Sonar Scan
run: |
REPO_NAME=${{ gitea.event.repository.name }}
sonar-scanner \
-Dsonar.projectKey=$REPO_NAME \
-Dsonar.projectName=$REPO_NAME \
-Dsonar.sources=. \
-Dsonar.host.url=${{ secrets.SONARQUBE_HOST }} \
-Dsonar.token=${{ secrets.SONARQUBE_TOKEN }} \
-Dsonar.exclusions=node_modules/**,dist/**,coverage/** \
-Dsonar.qualitygate.wait=true

View File

@@ -7,7 +7,7 @@
<title>HR Portal Dashboard version 0.1</title>
</head>
<body>
<body style="overflow: hidden;">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

86
package-lock.json generated
View File

@@ -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.9.0",
"@reduxjs/toolkit": "^2.11.2",
"@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": "^6.30.1",
"react-router-dom": "^7.13.1",
"recharts": "^2.15.2",
"sonner": "^2.0.3",
"tailwind-merge": "*",
@@ -1893,14 +1893,14 @@
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz",
"integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==",
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
@@ -1918,15 +1918,6 @@
}
}
},
"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",
@@ -2215,9 +2206,9 @@
]
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
@@ -2891,6 +2882,19 @@
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -3186,9 +3190,9 @@
"license": "ISC"
},
"node_modules/immer": {
"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==",
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -3759,35 +3763,41 @@
}
},
"node_modules/react-router": {
"version": "6.30.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.0"
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=14.0.0"
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=16.8"
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"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==",
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.0",
"react-router": "6.30.1"
"react-router": "7.13.1"
},
"engines": {
"node": ">=14.0.0"
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-smooth": {
@@ -3945,6 +3955,12 @@
"loose-envify": "^1.1.0"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",

View File

@@ -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.9.0",
"@reduxjs/toolkit": "^2.11.2",
"@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": "^6.30.1",
"react-router-dom": "^7.13.1",
"recharts": "^2.15.2",
"sonner": "^2.0.3",
"tailwind-merge": "*",

View File

@@ -1,73 +0,0 @@
/* 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;
}
}

View File

@@ -1,32 +1,9 @@
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();
import React from 'react';
import { RouterProvider } from 'react-router-dom';
import { router } from './routes';
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>
);
function App() {
return <RouterProvider router={router} />;
}
export default App;

0
src/App_new.tsx Normal file
View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,379 @@
import React, { useState } from 'react';
import { Button } from './ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Input } from './ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
import {
Search,
Filter,
Download,
Users,
Calendar,
BookOpen,
RefreshCw,
Eye,
UserPlus,
ChevronLeft,
ChevronRight
} from 'lucide-react';
interface Assignment {
startDate: Date;
endDate: Date;
}
interface Programme {
programmeId: string;
title: string;
status: 'Active' | 'Upcoming' | 'Completed';
coursesCount: number;
contentCount: number;
assignment: Assignment;
learnersAssigned: number;
}
interface ProgrammesTableProps {
programmes?: Programme[];
onViewProgramme?: (programmeId: string) => void;
onAssignLearners?: (programmeId: string) => void;
onDownloadTracker?: (programmeId: string) => void;
}
const mockProgrammes: Programme[] = [
{
programmeId: 'prog-001',
title: 'Leadership Development Program',
status: 'Active',
coursesCount: 8,
contentCount: 24,
assignment: {
startDate: new Date('2024-01-15'),
endDate: new Date('2024-06-30')
},
learnersAssigned: 45
},
{
programmeId: 'prog-002',
title: 'Technical Skills Bootcamp',
status: 'Active',
coursesCount: 12,
contentCount: 36,
assignment: {
startDate: new Date('2024-02-01'),
endDate: new Date('2024-08-31')
},
learnersAssigned: 38
},
{
programmeId: 'prog-003',
title: 'Communication Excellence',
status: 'Upcoming',
coursesCount: 6,
contentCount: 18,
assignment: {
startDate: new Date('2024-03-01'),
endDate: new Date('2024-05-31')
},
learnersAssigned: 28
},
{
programmeId: 'prog-004',
title: 'Project Management Certification',
status: 'Active',
coursesCount: 10,
contentCount: 30,
assignment: {
startDate: new Date('2024-01-01'),
endDate: new Date('2024-12-31')
},
learnersAssigned: 52
},
{
programmeId: 'prog-005',
title: 'Digital Marketing Mastery',
status: 'Completed',
coursesCount: 5,
contentCount: 15,
assignment: {
startDate: new Date('2023-09-01'),
endDate: new Date('2023-12-31')
},
learnersAssigned: 32
},
{
programmeId: 'prog-006',
title: 'Data Analytics Fundamentals',
status: 'Completed',
coursesCount: 7,
contentCount: 21,
assignment: {
startDate: new Date('2023-10-15'),
endDate: new Date('2024-01-15')
},
learnersAssigned: 29
}
];
export const ProgrammesTable: React.FC<ProgrammesTableProps> = ({
programmes = mockProgrammes,
onViewProgramme,
onAssignLearners,
onDownloadTracker
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('Active');
const [currentPage, setCurrentPage] = useState(1);
const [isAssignModalOpen, setIsAssignModalOpen] = useState(false);
const [selectedProgramme, setSelectedProgramme] = useState<Programme | null>(null);
const [isExporting, setIsExporting] = useState(false);
const itemsPerPage = 5;
const filteredProgrammes = programmes.filter(prog => {
const matchesSearch = prog.title.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || prog.status === statusFilter;
return matchesSearch && matchesStatus;
});
// Pagination logic
const totalPages = Math.ceil(filteredProgrammes.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedProgrammes = filteredProgrammes.slice(startIndex, endIndex);
// Reset to first page when filters change
React.useEffect(() => {
setCurrentPage(1);
}, [searchTerm, statusFilter]);
const formatDateRange = (assignment: Assignment) => {
const startDate = assignment.startDate.toLocaleDateString('en-AU', {
day: '2-digit',
month: 'short',
year: 'numeric'
});
const endDate = assignment.endDate.toLocaleDateString('en-AU', {
day: '2-digit',
month: 'short',
year: 'numeric'
});
return `${startDate}${endDate}`;
};
const handleViewProgramme = (programmeId: string) => {
onViewProgramme?.(programmeId);
console.log(`Viewing programme: ${programmeId}`);
};
const handleAssignLearners = (programme: Programme) => {
setSelectedProgramme(programme);
setIsAssignModalOpen(true);
onAssignLearners?.(programme.programmeId);
};
const handleDownloadTracker = async (programmeId: string) => {
setIsExporting(true);
await new Promise(resolve => setTimeout(resolve, 2000));
setIsExporting(false);
onDownloadTracker?.(programmeId);
console.log(`Downloaded tracker for programme: ${programmeId}`);
};
const getStatusBadgeProps = (status: Programme['status']) => {
switch (status) {
case 'Active':
return { variant: 'default' as const, className: 'bg-status-success text-status-success-foreground' };
case 'Upcoming':
return { variant: 'secondary' as const, className: 'bg-status-warn text-status-warn-foreground' };
case 'Completed':
return { variant: 'outline' as const, className: 'bg-muted text-muted-foreground' };
default:
return { variant: 'secondary' as const };
}
};
return (
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<CardTitle>Programmes</CardTitle>
<CardDescription>Manage programme assignments and track progress</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search programmes..."
className="pl-10 w-[200px]"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
aria-label="Search programmes by title"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="Active">Active</SelectItem>
<SelectItem value="Upcoming">Upcoming</SelectItem>
<SelectItem value="Completed">Completed</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border" style={{ maxWidth: '1200px' }}>
<Table>
<TableHeader className="sticky-header">
<TableRow>
<TableHead className="w-[300px]">Programme Title</TableHead>
<TableHead className="w-[150px]">Courses / Content</TableHead>
<TableHead className="w-[200px]">Start End</TableHead>
<TableHead className="w-[120px]">Learners Assigned</TableHead>
<TableHead className="w-[200px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedProgrammes.map((programme) => (
<TableRow key={programme.programmeId} className="min-h-[44px]">
<TableCell>
<div className="flex items-center gap-2">
<span className="font-medium">{programme.title}</span>
<Badge
{...getStatusBadgeProps(programme.status)}
aria-label={`Programme status: ${programme.status}`}
>
{programme.status}
</Badge>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<BookOpen className="h-4 w-4" />
<span>{programme.coursesCount} {programme.contentCount}</span>
</div>
</TableCell>
<TableCell className="text-sm">
{formatDateRange(programme.assignment)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{programme.learnersAssigned}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewProgramme(programme.programmeId)}
className="min-tap-44"
aria-label={`View programme details for ${programme.title}`}
>
<Eye className="h-4 w-4" />
<span className="sr-only">View Programme</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleAssignLearners(programme)}
className="min-tap-44"
aria-label={`Assign learners to ${programme.title}`}
>
<UserPlus className="h-4 w-4" />
<span className="sr-only">Assign Learners</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDownloadTracker(programme.programmeId)}
disabled={isExporting}
className="min-tap-44"
aria-label={`Download tracker for ${programme.title}`}
>
{isExporting ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
<span className="sr-only">Download Tracker</span>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
Showing {startIndex + 1} to {Math.min(endIndex, filteredProgrammes.length)} of {filteredProgrammes.length} programmes
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="min-tap-44"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="min-tap-44"
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
)}
{filteredProgrammes.length === 0 && (
<div className="text-center py-8">
<p className="text-muted-foreground">No programmes found matching your criteria.</p>
</div>
)}
{/* Assignment Modal */}
<Dialog open={isAssignModalOpen} onOpenChange={setIsAssignModalOpen}>
<DialogContent className="sm:max-w-[600px]" role="dialog" aria-modal="true">
<DialogHeader>
<DialogTitle>Assign Learners to Programme</DialogTitle>
<DialogDescription>
{selectedProgramme && `Assign learners to "${selectedProgramme.title}"`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="text-center py-8 text-muted-foreground">
<UserPlus className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p>Assignment wizard would be displayed here</p>
<p className="text-sm mt-2">Including org/individual selection, dates, HR contacts, and participant upload</p>
</div>
</div>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
};

View File

@@ -1,37 +1,79 @@
import { Link } from "react-router-dom";
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "./ui/breadcrumb";
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ChevronRight, Home } from 'lucide-react';
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";
}
export const BreadcrumbNav: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const pathSegments = location.pathname.split('/').filter(Boolean);
const getDisplayName = (segment: string): string => {
const names: Record<string, string> = {
'hr': 'HR Portal',
'dashboard': 'Dashboard',
'learners': 'Learners',
'reports': 'Reports',
'discussions': 'Discussion Forums',
'programme': 'Programme',
'course': 'Course',
'profile': 'Profile',
'settings': 'Settings'
};
// Handle dynamic segments (like programme IDs)
if (segment.match(/^[0-9a-f-]+$/)) {
return 'Details';
}
return names[segment] || segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ');
};
if (pathSegments.length === 0) return null;
return (
<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>
<nav className="flex items-center space-x-2 text-sm mb-6" aria-label="Breadcrumb">
<ol className="flex items-center flex-wrap gap-1">
{/* Home icon */}
<li>
<button
onClick={() => navigate('/hr/dashboard')}
className="text-muted-foreground hover:text-foreground transition-colors p-1"
aria-label="Go to dashboard"
>
<Home className="h-4 w-4" />
</button>
</li>
{pathSegments.map((segment, index) => {
const isLast = index === pathSegments.length - 1;
const path = '/' + pathSegments.slice(0, index + 1).join('/');
const displayName = getDisplayName(segment);
return (
<li key={path} className="flex items-center">
<ChevronRight className="h-4 w-4 text-muted-foreground mx-1" aria-hidden="true" />
{isLast ? (
<span
className="font-medium text-foreground"
aria-current="page"
>
{displayName}
</span>
) : (
<button
onClick={() => navigate(path)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{displayName}
</button>
)}
</li>
);
})}
</ol>
</nav>
);
};
export default BreadcrumbNav;

View File

@@ -1,65 +0,0 @@
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>
);
};

View File

@@ -0,0 +1,839 @@
import React, { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Input } from './ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from './ui/sheet';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
import { Progress } from './ui/progress';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './ui/accordion';
import {
ArrowLeft,
Download,
BarChart3,
MoreHorizontal,
Users,
Clock,
BookOpen,
Award,
Search,
Eye,
Mail,
FileText,
Play,
RefreshCw,
ExternalLink,
CheckCircle,
AlertCircle,
XCircle,
Video,
FileQuestion,
Activity,
Building2
} from 'lucide-react';
// Types
interface Course {
id: string;
title: string;
status: 'Published' | 'Draft' | 'Archived';
code: string;
owner: string;
version: number;
duration: string;
description: string;
objectives: string[];
tags: string[];
modules: CourseModule[];
linkedProgrammes: LinkedProgramme[];
}
interface CourseModule {
id: string;
title: string;
lessons: CourseLesson[];
}
interface CourseLesson {
id: string;
title: string;
type: 'video' | 'quiz' | 'read' | 'assignment';
eta: string;
dueDate?: string;
status?: 'Not Started' | 'In Progress' | 'Completed';
}
interface LinkedProgramme {
id: string;
title: string;
}
interface CourseAssignment {
startDate: string;
endDate: string;
orgId: string;
orgName: string;
}
interface CourseCounts {
learners: number;
avgProgress: number;
modules: number;
lessons: number;
}
interface CourseLearner {
id: string;
name: string;
email: string;
progressPct: number;
currentLesson: {
id: string;
title: string;
status: 'Not Started' | 'In-Progress' | 'Completed';
};
lastActivity: string;
attempts?: number;
avgScore?: number;
}
interface CourseHRViewProps {
courseId: string;
onBack: () => void;
onAssignLearners: (courseId: string) => void;
onDownloadTracker: (courseId: string) => void;
onOpenAnalytics: (courseId: string) => void;
}
// Mock data
const mockCourse: Course = {
id: 'crs_456',
title: 'Strategic Thinking and Decision Making',
status: 'Published',
code: 'STDM-2024',
owner: 'Prof. Michael Chen',
version: 1,
duration: '6 hours',
description: 'This course develops strategic thinking capabilities and decision-making frameworks for leaders at all levels. Participants will learn to analyze complex situations, evaluate options, and make informed decisions.',
objectives: [
'Apply strategic thinking frameworks to business challenges',
'Develop systematic approaches to decision making',
'Evaluate risks and opportunities effectively',
'Create actionable strategic plans'
],
tags: ['Strategy', 'Leadership', 'Decision Making', 'Critical Thinking'],
modules: [
{
id: 'm1',
title: 'Foundations of Strategic Thinking',
lessons: [
{ id: 'l1', title: 'Introduction to Strategic Thinking', type: 'video', eta: '15 mins', status: 'Completed' },
{ id: 'l2', title: 'Strategic Frameworks Overview', type: 'read', eta: '20 mins', status: 'Completed' },
{ id: 'l3', title: 'Knowledge Check', type: 'quiz', eta: '10 mins', status: 'In Progress' }
]
},
{
id: 'm2',
title: 'Decision Making Models',
lessons: [
{ id: 'l4', title: 'Rational Decision Making', type: 'video', eta: '25 mins', status: 'Not Started' },
{ id: 'l5', title: 'Intuitive vs Analytical Approaches', type: 'read', eta: '15 mins', status: 'Not Started' },
{ id: 'l6', title: 'Case Study Analysis', type: 'assignment', eta: '45 mins', dueDate: '2024-01-25', status: 'Not Started' }
]
},
{
id: 'm3',
title: 'Risk Assessment and Management',
lessons: [
{ id: 'l7', title: 'Risk Identification Techniques', type: 'video', eta: '20 mins', status: 'Not Started' },
{ id: 'l8', title: 'Risk Matrix and Evaluation', type: 'read', eta: '25 mins', status: 'Not Started' },
{ id: 'l9', title: 'Final Assessment', type: 'quiz', eta: '30 mins', dueDate: '2024-01-30', status: 'Not Started' }
]
}
],
linkedProgrammes: [
{ id: 'prg_123', title: 'Executive Leadership Development Programme' },
{ id: 'prg_124', title: 'Management Excellence Programme' }
]
};
const mockAssignment: CourseAssignment = {
startDate: '2024-01-01',
endDate: '2024-02-15',
orgId: 'org_123',
orgName: 'Tech Solutions Pvt Ltd'
};
const mockCounts: CourseCounts = {
learners: 15,
avgProgress: 58,
modules: 3,
lessons: 9
};
const mockLearners: CourseLearner[] = [
{
id: 'l1',
name: 'Sarah Chen',
email: 'sarah.chen@company.com',
progressPct: 75,
currentLesson: { id: 'l3', title: 'Knowledge Check', status: 'In-Progress' },
lastActivity: '2 hours ago',
attempts: 2,
avgScore: 87
},
{
id: 'l2',
name: 'Michael Rodriguez',
email: 'michael.r@company.com',
progressPct: 45,
currentLesson: { id: 'l2', title: 'Strategic Frameworks Overview', status: 'In-Progress' },
lastActivity: '1 day ago',
attempts: 1,
avgScore: 92
},
{
id: 'l3',
name: 'Emma Thompson',
email: 'emma.thompson@company.com',
progressPct: 89,
currentLesson: { id: 'l6', title: 'Case Study Analysis', status: 'In-Progress' },
lastActivity: '3 hours ago',
attempts: 3,
avgScore: 94
},
{
id: 'l4',
name: 'David Kim',
email: 'david.kim@company.com',
progressPct: 23,
currentLesson: { id: 'l1', title: 'Introduction to Strategic Thinking', status: 'In-Progress' },
lastActivity: '5 hours ago',
attempts: 1,
avgScore: 78
}
];
export const CourseHRView: React.FC<CourseHRViewProps> = ({
courseId,
onBack,
onAssignLearners,
onDownloadTracker,
onOpenAnalytics
}) => {
const [activeTab, setActiveTab] = useState('overview');
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [selectedLearner, setSelectedLearner] = useState<CourseLearner | null>(null);
const [showLearnerDrawer, setShowLearnerDrawer] = useState(false);
const [exporting, setExporting] = useState(false);
// Simulate loading
useEffect(() => {
const timer = setTimeout(() => setLoading(false), 800);
return () => clearTimeout(timer);
}, []);
const getTypeIcon = (type: string) => {
switch (type) {
case 'video': return <Video className="h-4 w-4" />;
case 'quiz': return <FileQuestion className="h-4 w-4" />;
case 'read': return <FileText className="h-4 w-4" />;
case 'assignment': return <Activity className="h-4 w-4" />;
default: return <BookOpen className="h-4 w-4" />;
}
};
const getStatusBadge = (status?: string) => {
switch (status) {
case 'Completed':
return <Badge variant="default" className="bg-status-success text-white"><CheckCircle className="h-3 w-3 mr-1" />Completed</Badge>;
case 'In Progress':
return <Badge variant="secondary" className="bg-status-warn text-black"><AlertCircle className="h-3 w-3 mr-1" />In Progress</Badge>;
case 'Not Started':
return <Badge variant="outline"><XCircle className="h-3 w-3 mr-1" />Not Started</Badge>;
default:
return null;
}
};
const filteredLearners = mockLearners.filter(learner => {
const matchesSearch = learner.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
learner.email.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || learner.currentLesson.status === statusFilter;
return matchesSearch && matchesStatus;
});
const handleViewLearner = (learner: CourseLearner) => {
setSelectedLearner(learner);
setShowLearnerDrawer(true);
};
const handleExport = async (format: 'excel' | 'csv' | 'pdf') => {
setExporting(true);
await new Promise(resolve => setTimeout(resolve, 2000));
setExporting(false);
console.log(`Exported course tracker as ${format.toUpperCase()}`);
};
if (loading) {
return (
<div className="space-y-6 animate-pulse">
<div className="h-16 bg-muted rounded-lg"></div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-24 bg-muted rounded-lg"></div>
))}
</div>
<div className="h-96 bg-muted rounded-lg"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<Card className="sticky top-0 z-20 bg-background border-b shadow-sm">
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="min-tap-44"
aria-label="Go back to courses list"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-bold">{mockCourse.title}</h1>
<Badge
variant={mockCourse.status === 'Published' ? 'default' : 'secondary'}
className={mockCourse.status === 'Published' ? 'bg-status-success' : ''}
>
{mockCourse.status}
</Badge>
</div>
<p className="text-muted-foreground">
{mockCourse.code} {mockCourse.owner} Version {mockCourse.version} Duration: {mockCourse.duration}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => onAssignLearners(courseId)}
className="min-tap-44"
>
<Users className="h-4 w-4 mr-2" />
Assign Learners
</Button>
<Button
variant="outline"
onClick={() => onDownloadTracker(courseId)}
disabled={exporting}
className="min-tap-44"
>
{exporting ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download Tracker
</Button>
<Button
variant="outline"
onClick={() => onOpenAnalytics(courseId)}
className="min-tap-44"
>
<BarChart3 className="h-4 w-4 mr-2" />
Open Analytics
</Button>
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="min-tap-44"
aria-label="More actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Course Actions</DialogTitle>
<DialogDescription>Additional actions for this course</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Button variant="outline" className="w-full justify-start">
<FileText className="h-4 w-4 mr-2" />
View Syllabus
</Button>
<Button variant="outline" className="w-full justify-start">
<ExternalLink className="h-4 w-4 mr-2" />
Audit Trail
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div>
</CardContent>
</Card>
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Assignment Window</p>
<p className="font-semibold">{new Date(mockAssignment.startDate).toLocaleDateString()} {new Date(mockAssignment.endDate).toLocaleDateString()}</p>
<p className="text-xs text-muted-foreground">{mockAssignment.orgName}</p>
</div>
<Clock className="h-8 w-8 text-brand-primary" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Enrolled Learners</p>
<p className="text-2xl font-bold">{mockCounts.learners}</p>
<Button variant="link" className="p-0 h-auto text-xs">Manage</Button>
</div>
<Users className="h-8 w-8 text-brand-primary" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Avg Progress</p>
<p className="text-2xl font-bold">{mockCounts.avgProgress}%</p>
<Progress value={mockCounts.avgProgress} className="w-16 mt-1" />
</div>
<Award className="h-8 w-8 text-brand-primary" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Course Structure</p>
<div className="flex gap-2 text-sm">
<span>{mockCounts.modules} Modules</span>
<span></span>
<span>{mockCounts.lessons} Lessons</span>
</div>
<p className="text-xs text-muted-foreground">Duration: {mockCourse.duration}</p>
</div>
<BookOpen className="h-8 w-8 text-brand-primary" />
</div>
</CardContent>
</Card>
</div>
{/* Tabs */}
<Card>
<CardContent className="pt-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="syllabus">Syllabus</TabsTrigger>
<TabsTrigger value="learners">Learners</TabsTrigger>
<TabsTrigger value="reports">Reports</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview" className="space-y-6 mt-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Course Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="font-medium mb-2">Description</h4>
<p className="text-sm text-muted-foreground">{mockCourse.description}</p>
</div>
<div>
<h4 className="font-medium mb-2">Learning Objectives</h4>
<ul className="text-sm text-muted-foreground space-y-1">
{mockCourse.objectives.map((objective, index) => (
<li key={index} className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 text-status-success mt-0.5 flex-shrink-0" />
{objective}
</li>
))}
</ul>
</div>
<div>
<h4 className="font-medium mb-2">Tags</h4>
<div className="flex flex-wrap gap-2">
{mockCourse.tags.map((tag, index) => (
<Badge key={index} variant="outline">{tag}</Badge>
))}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Linked Resources</CardTitle>
<CardDescription>Read-only view of course resources</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="font-medium mb-2">Associated Programmes</h4>
{mockCourse.linkedProgrammes.length > 0 ? (
<div className="space-y-2">
<p className="text-sm text-muted-foreground mb-2">
Appears in {mockCourse.linkedProgrammes.length} programmes:
</p>
<div className="space-y-1">
{mockCourse.linkedProgrammes.map((programme, index) => (
<Button
key={index}
variant="outline"
size="sm"
className="w-full justify-start h-auto p-2"
>
<Building2 className="h-4 w-4 mr-2" />
{programme.title}
</Button>
))}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No linked programmes</p>
)}
</div>
<div>
<h4 className="font-medium mb-2">Course Metadata</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Total Duration:</span>
<span>{mockCourse.duration}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Modules:</span>
<span>{mockCourse.modules.length}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Total Lessons:</span>
<span>{mockCourse.modules.reduce((acc, module) => acc + module.lessons.length, 0)}</span>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
{/* Syllabus Tab */}
<TabsContent value="syllabus" className="space-y-6 mt-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">Course Syllabus</h3>
<p className="text-muted-foreground">Read-only view of the course structure and lessons</p>
</div>
<Badge variant="outline">Read Only</Badge>
</div>
<Accordion type="single" collapsible className="space-y-2">
{mockCourse.modules.map((module, index) => (
<AccordionItem key={index} value={`module-${index}`} className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-3">
<Badge variant="outline">Module {index + 1}</Badge>
<span className="font-medium">{module.title}</span>
<span className="text-muted-foreground">({module.lessons.length} lessons)</span>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-3 pb-4">
{module.lessons.map((lesson, lessonIndex) => (
<div key={lessonIndex} className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
<div className="flex items-center gap-3">
{getTypeIcon(lesson.type)}
<div>
<p className="font-medium">{lesson.title}</p>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{lesson.eta}
</span>
{lesson.dueDate && (
<span className="flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
Due: {lesson.dueDate}
</span>
)}
<Badge variant="outline" className="text-xs capitalize">
{lesson.type}
</Badge>
</div>
</div>
</div>
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-2" />
Open
</Button>
</div>
))}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</TabsContent>
{/* Learners Tab */}
<TabsContent value="learners" className="space-y-6 mt-6">
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div>
<h3 className="text-lg font-semibold">Learner Progress</h3>
<p className="text-muted-foreground">Track individual progress through the course</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => handleExport('excel')}
disabled={exporting}
className="min-tap-44"
>
{exporting ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Export Data
</Button>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-4 items-center">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search learners..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="Not Started">Not Started</SelectItem>
<SelectItem value="In-Progress">In Progress</SelectItem>
<SelectItem value="Completed">Completed</SelectItem>
</SelectContent>
</Select>
</div>
{/* Learners Table */}
<div className="rounded-md border">
<Table>
<TableHeader className="sticky-header">
<TableRow>
<TableHead className="w-[200px]">Learner</TableHead>
<TableHead className="w-[100px]">Progress</TableHead>
<TableHead className="w-[200px]">Current Lesson</TableHead>
<TableHead className="w-[120px]">Last Activity</TableHead>
<TableHead className="w-[100px]">Performance</TableHead>
<TableHead className="w-[80px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLearners.map((learner) => (
<TableRow key={learner.id} className="min-h-[48px]">
<TableCell>
<div>
<p className="font-medium">{learner.name}</p>
<p className="text-sm text-muted-foreground">{learner.email}</p>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<Progress value={learner.progressPct} className="w-16" />
<span className="text-sm">{learner.progressPct}%</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{getTypeIcon('video')}
<div>
<p className="font-medium text-sm">{learner.currentLesson.title}</p>
{getStatusBadge(learner.currentLesson.status)}
</div>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{learner.lastActivity}
</TableCell>
<TableCell>
<div className="text-sm">
{learner.avgScore && (
<div>
<span className="font-medium">{learner.avgScore}%</span>
<p className="text-xs text-muted-foreground">
{learner.attempts} attempt{learner.attempts !== 1 ? 's' : ''}
</p>
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewLearner(learner)}
className="min-tap-44"
aria-label={`View details for ${learner.name}`}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="min-tap-44"
aria-label={`Send reminder to ${learner.name}`}
>
<Mail className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TabsContent>
{/* Reports Tab */}
<TabsContent value="reports" className="space-y-6 mt-6">
<div className="text-center py-12 text-muted-foreground">
<BarChart3 className="h-12 w-12 mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">Course Reports</h3>
<p>Detailed analytics and reporting for this course would be displayed here</p>
</div>
</TabsContent>
{/* Activity Tab */}
<TabsContent value="activity" className="space-y-6 mt-6">
<div className="text-center py-12 text-muted-foreground">
<Activity className="h-12 w-12 mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">Activity Log</h3>
<p>Course activity audit trail would be displayed here</p>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* Learner Details Drawer */}
<Sheet open={showLearnerDrawer} onOpenChange={setShowLearnerDrawer}>
<SheetContent className="w-[480px] sm:w-[540px]">
<SheetHeader>
<SheetTitle>{selectedLearner?.name}</SheetTitle>
<SheetDescription>Course progress and performance details</SheetDescription>
</SheetHeader>
{selectedLearner && (
<div className="space-y-6 mt-6">
<div>
<h4 className="font-medium mb-2">Course Progress</h4>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Overall Progress:</span>
<span className="font-medium">{selectedLearner.progressPct}%</span>
</div>
<Progress value={selectedLearner.progressPct} className="w-full" />
</div>
</div>
<div>
<h4 className="font-medium mb-2">Lesson Checklist</h4>
<div className="space-y-3">
{mockCourse.modules.flatMap(module =>
module.lessons.map((lesson, index) => (
<div key={index} className="flex items-center gap-3 p-2 rounded-lg bg-muted/30">
<div className={`w-4 h-4 rounded-full border-2 ${
lesson.status === 'Completed' ? 'bg-status-success border-status-success' :
lesson.status === 'In Progress' ? 'border-brand-primary bg-brand-primary' :
'border-muted'
}`}>
{lesson.status === 'Completed' && <CheckCircle className="w-4 h-4 text-white" />}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
{getTypeIcon(lesson.type)}
<span className={`text-sm ${
lesson.id === selectedLearner.currentLesson.id ? 'font-medium' : 'text-muted-foreground'
}`}>
{lesson.title}
</span>
</div>
<p className="text-xs text-muted-foreground">{lesson.eta}</p>
</div>
</div>
))
)}
</div>
</div>
{selectedLearner.avgScore && (
<div>
<h4 className="font-medium mb-2">Performance Summary</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Average Score:</span>
<span className="font-medium">{selectedLearner.avgScore}%</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Quiz Attempts:</span>
<span>{selectedLearner.attempts}</span>
</div>
</div>
</div>
)}
<div className="flex gap-2">
<Button variant="outline" className="flex-1">
<Eye className="h-4 w-4 mr-2" />
View Detailed Report
</Button>
<Button variant="outline" className="flex-1">
<Mail className="h-4 w-4 mr-2" />
Send Reminder
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
</div>
);
};

View File

@@ -0,0 +1,226 @@
import React, { useState } from 'react';
import { Button } from './ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import {
MessageSquare,
Eye,
Check,
Filter,
Users,
Clock
} from 'lucide-react';
interface ForumThread {
threadId: string;
title: string;
programmeId: string;
programmeName: string;
lastReplyAt: Date;
lastAuthor: string;
unread: boolean;
href: string;
replyCount?: number;
participantCount?: number;
}
interface DiscussionForumFeedProps {
forumFeed?: ForumThread[];
onOpenThread?: (threadId: string) => void;
onMarkAsRead?: (threadId: string) => void;
}
const mockForumFeed: ForumThread[] = [
{
threadId: 'thread-001',
title: 'Leadership in Remote Teams - Best Practices Discussion',
programmeId: 'prog-001',
programmeName: 'Leadership Development',
lastReplyAt: new Date('2024-12-27T15:30:00'),
lastAuthor: 'Sarah Chen',
unread: true,
href: '/discussions/leadership-remote-teams',
replyCount: 12,
participantCount: 8
},
{
threadId: 'thread-002',
title: 'JavaScript ES6 Features - Questions and Examples',
programmeId: 'prog-002',
programmeName: 'Technical Skills Bootcamp',
lastReplyAt: new Date('2024-12-27T14:15:00'),
lastAuthor: 'David Kim',
unread: true,
href: '/discussions/javascript-es6',
replyCount: 18,
participantCount: 15
},
{
threadId: 'thread-003',
title: 'Effective Presentation Techniques - Share Your Tips',
programmeId: 'prog-003',
programmeName: 'Communication Excellence',
lastReplyAt: new Date('2024-12-27T11:45:00'),
lastAuthor: 'Emma Thompson',
unread: false,
href: '/discussions/presentation-techniques',
replyCount: 7,
participantCount: 6
},
{
threadId: 'thread-004',
title: 'Agile vs Waterfall - When to Use Each Methodology',
programmeId: 'prog-004',
programmeName: 'Project Management Certification',
lastReplyAt: new Date('2024-12-27T09:20:00'),
lastAuthor: 'Michael Rodriguez',
unread: false,
href: '/discussions/agile-vs-waterfall',
replyCount: 25,
participantCount: 19
},
{
threadId: 'thread-005',
title: 'Building Trust in Virtual Teams',
programmeId: 'prog-001',
programmeName: 'Leadership Development',
lastReplyAt: new Date('2024-12-26T16:30:00'),
lastAuthor: 'Lisa Wang',
unread: true,
href: '/discussions/trust-virtual-teams',
replyCount: 9,
participantCount: 7
},
{
threadId: 'thread-006',
title: 'Code Review Best Practices - Peer Learning',
programmeId: 'prog-002',
programmeName: 'Technical Skills Bootcamp',
lastReplyAt: new Date('2024-12-26T14:10:00'),
lastAuthor: 'James Wilson',
unread: false,
href: '/discussions/code-review-practices',
replyCount: 14,
participantCount: 11
}
];
const programmeColors = {
'prog-001': '#04045B',
'prog-002': '#F8C301',
'prog-003': '#21a36a',
'prog-004': '#89002D'
};
export const DiscussionForumFeed: React.FC<DiscussionForumFeedProps> = ({
forumFeed = mockForumFeed,
onOpenThread,
onMarkAsRead
}) => {
// Only show unread threads by default to make it concise
const unreadThreads = forumFeed.filter(thread => thread.unread).slice(0, 5);
const formatTimeAgo = (date: Date) => {
const now = new Date();
const diffHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
if (diffHours < 1) {
return 'Just now';
} else if (diffHours < 24) {
return `${diffHours}h ago`;
} else {
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
};
const handleOpenThread = (thread: ForumThread) => {
onOpenThread?.(thread.threadId);
console.log(`Opening thread: ${thread.title}`);
};
const handleMarkAsRead = (threadId: string, event: React.MouseEvent) => {
event.stopPropagation();
onMarkAsRead?.(threadId);
console.log(`Marked thread as read: ${threadId}`);
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Discussion Forums
{unreadThreads.length > 0 && (
<Badge variant="destructive" className="text-xs">
{unreadThreads.length} new
</Badge>
)}
</CardTitle>
<CardDescription>Recent activity from programme discussions</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{unreadThreads.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
<MessageSquare className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm">No new discussion activity</p>
</div>
) : (
unreadThreads.map((thread) => (
<div
key={thread.threadId}
className="flex items-center justify-between p-3 border rounded-lg cursor-pointer transition-colors min-tap-44 bg-blue-50 border-blue-200 hover:bg-blue-100"
onClick={() => handleOpenThread(thread)}
role="button"
tabIndex={0}
aria-label={`Open discussion: ${thread.title}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOpenThread(thread);
}
}}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-sm text-blue-900 truncate">
{thread.title}
</h4>
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-800 flex-shrink-0">
New
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="truncate">{thread.programmeName}</span>
<span></span>
<span>{formatTimeAgo(thread.lastReplyAt)}</span>
<span></span>
<span>by {thread.lastAuthor}</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) => handleMarkAsRead(thread.threadId, e)}
className="min-tap-44 ml-2"
aria-label={`Mark "${thread.title}" as read`}
>
<Check className="h-4 w-4" />
</Button>
</div>
))
)}
</div>
</CardContent>
</Card>
);
};

View File

@@ -1,265 +0,0 @@
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>
// );
// };

View File

@@ -1,153 +0,0 @@
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>
// );
// };

View File

@@ -1,85 +0,0 @@
/* 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;
}
}

View File

@@ -1,132 +0,0 @@
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) + '...';
}

View File

@@ -0,0 +1,502 @@
import React, { useState } from 'react';
import { Button } from './ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
import { Progress } from './ui/progress';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from './ui/sheet';
import {
Eye,
Send,
BarChart3,
User,
BookOpen,
Clock,
TrendingUp
} from 'lucide-react';
interface CurrentItem {
type: 'course' | 'content';
id: string;
title: string;
status: 'Not Started' | 'In-Progress' | 'Completed';
}
interface NextItem {
type: 'course' | 'content';
id: string;
title: string;
}
interface LearnerAnalytics {
learnerId: string;
learnerName: string;
learnerEmail: string;
currentItem?: CurrentItem;
progressPct: number;
nextItem?: NextItem;
lastActivity: Date;
}
interface LearningAnalyticsData {
programmeId: string;
rows: LearnerAnalytics[];
}
interface LearningAnalyticsTableProps {
analyticsData?: LearningAnalyticsData[];
onViewLearner?: (learnerId: string) => void;
onNudgeLearner?: (learnerId: string) => void;
onViewAllAnalytics?: (programmeId: string) => void;
}
const mockAnalyticsData: LearningAnalyticsData[] = [
{
programmeId: 'prog-001',
rows: [
{
learnerId: 'learner-001',
learnerName: 'Sarah Chen',
learnerEmail: 'sarah.chen@company.com',
currentItem: {
type: 'course',
id: 'course-001',
title: 'Strategic Thinking',
status: 'In-Progress'
},
progressPct: 85,
nextItem: {
type: 'course',
id: 'course-002',
title: 'Decision Making'
},
lastActivity: new Date('2024-12-27T14:30:00')
},
{
learnerId: 'learner-002',
learnerName: 'Michael Rodriguez',
learnerEmail: 'michael.r@company.com',
currentItem: {
type: 'content',
id: 'content-003',
title: 'Leadership Styles Assessment',
status: 'Not Started'
},
progressPct: 62,
nextItem: {
type: 'course',
id: 'course-003',
title: 'Team Management'
},
lastActivity: new Date('2024-12-26T09:15:00')
},
{
learnerId: 'learner-003',
learnerName: 'Emma Thompson',
learnerEmail: 'emma.thompson@company.com',
currentItem: {
type: 'course',
id: 'course-002',
title: 'Decision Making',
status: 'Completed'
},
progressPct: 94,
nextItem: {
type: 'content',
id: 'content-005',
title: 'Leadership Reflection Journal'
},
lastActivity: new Date('2024-12-27T16:45:00')
},
{
learnerId: 'learner-004',
learnerName: 'David Kim',
learnerEmail: 'david.kim@company.com',
currentItem: {
type: 'course',
id: 'course-001',
title: 'Strategic Thinking',
status: 'In-Progress'
},
progressPct: 78,
nextItem: {
type: 'course',
id: 'course-002',
title: 'Decision Making'
},
lastActivity: new Date('2024-12-27T11:20:00')
}
]
},
{
programmeId: 'prog-002',
rows: [
{
learnerId: 'learner-005',
learnerName: 'Lisa Wang',
learnerEmail: 'lisa.wang@company.com',
currentItem: {
type: 'course',
id: 'course-101',
title: 'JavaScript Fundamentals',
status: 'In-Progress'
},
progressPct: 56,
nextItem: {
type: 'course',
id: 'course-102',
title: 'React Basics'
},
lastActivity: new Date('2024-12-27T13:10:00')
},
{
learnerId: 'learner-006',
learnerName: 'James Wilson',
learnerEmail: 'james.wilson@company.com',
currentItem: {
type: 'content',
id: 'content-201',
title: 'API Design Best Practices',
status: 'Not Started'
},
progressPct: 34,
nextItem: {
type: 'course',
id: 'course-103',
title: 'Database Design'
},
lastActivity: new Date('2024-12-25T15:30:00')
}
]
}
];
const programmeNames = {
'prog-001': 'Leadership Development',
'prog-002': 'Technical Skills Bootcamp',
'prog-003': 'Communication Excellence',
'prog-004': 'Project Management Certification'
};
export const LearningAnalyticsTable: React.FC<LearningAnalyticsTableProps> = ({
analyticsData = mockAnalyticsData,
onViewLearner,
onNudgeLearner,
onViewAllAnalytics
}) => {
const [selectedProgramme, setSelectedProgramme] = useState(analyticsData[0]?.programmeId || '');
const [selectedLearner, setSelectedLearner] = useState<LearnerAnalytics | null>(null);
const [isLearnerDrawerOpen, setIsLearnerDrawerOpen] = useState(false);
const currentProgrammeData = analyticsData.find(data => data.programmeId === selectedProgramme);
const currentProgrammeName = programmeNames[selectedProgramme as keyof typeof programmeNames] || 'Unknown Programme';
const formatLastActivity = (date: Date) => {
const now = new Date();
const diffHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
if (diffHours < 1) {
return 'Just now';
} else if (diffHours < 24) {
return `${diffHours} hours ago`;
} else {
const diffDays = Math.floor(diffHours / 24);
return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
}
};
const getStatusBadgeProps = (status: CurrentItem['status']) => {
switch (status) {
case 'Completed':
return { variant: 'default' as const, className: 'bg-status-success text-status-success-foreground' };
case 'In-Progress':
return { variant: 'secondary' as const, className: 'bg-status-warn text-status-warn-foreground' };
case 'Not Started':
return { variant: 'outline' as const, className: 'border-status-error text-status-error' };
default:
return { variant: 'secondary' as const };
}
};
const handleViewLearner = (learner: LearnerAnalytics) => {
setSelectedLearner(learner);
setIsLearnerDrawerOpen(true);
onViewLearner?.(learner.learnerId);
};
const handleNudgeLearner = (learnerId: string) => {
onNudgeLearner?.(learnerId);
console.log(`Sent nudge to learner: ${learnerId}`);
};
const handleViewAllAnalytics = () => {
onViewAllAnalytics?.(selectedProgramme);
console.log(`Viewing all analytics for programme: ${selectedProgramme}`);
};
return (
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<CardTitle>Learning Analytics</CardTitle>
<CardDescription>Per-programme learner progress and current activities</CardDescription>
</div>
<div className="flex items-center gap-2">
<Select value={selectedProgramme} onValueChange={setSelectedProgramme}>
<SelectTrigger className="w-[250px]">
<SelectValue placeholder="Select programme" />
</SelectTrigger>
<SelectContent>
{analyticsData.map((data) => (
<SelectItem key={data.programmeId} value={data.programmeId}>
{programmeNames[data.programmeId as keyof typeof programmeNames] || data.programmeId}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
onClick={handleViewAllAnalytics}
className="min-tap-44"
>
<BarChart3 className="h-4 w-4 mr-2" />
View All in Analytics
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Programme Summary */}
<div className="flex items-center justify-between p-4 bg-muted/30 rounded-lg">
<div>
<h4 className="font-medium">{currentProgrammeName}</h4>
<p className="text-sm text-muted-foreground">
{currentProgrammeData?.rows.length || 0} learners enrolled
</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold">
{currentProgrammeData ? Math.round(
currentProgrammeData.rows.reduce((sum, learner) => sum + learner.progressPct, 0) /
currentProgrammeData.rows.length
) : 0}%
</p>
<p className="text-sm text-muted-foreground">Average Progress</p>
</div>
</div>
{/* Analytics Table */}
<div className="rounded-md border">
<Table>
<TableHeader className="sticky-header">
<TableRow>
<TableHead className="w-[200px]">Learner</TableHead>
<TableHead className="w-[250px]">Current Item</TableHead>
<TableHead className="w-[120px]">Progress</TableHead>
<TableHead className="w-[200px]">Next Item</TableHead>
<TableHead className="w-[120px]">Last Activity</TableHead>
<TableHead className="w-[120px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentProgrammeData?.rows.map((learner) => (
<TableRow key={learner.learnerId} className="min-h-[44px]">
<TableCell>
<div>
<p className="font-medium">{learner.learnerName}</p>
<p className="text-sm text-muted-foreground">{learner.learnerEmail}</p>
</div>
</TableCell>
<TableCell>
{learner.currentItem ? (
<div className="space-y-1">
<div className="flex items-center gap-2">
{learner.currentItem.type === 'course' ? (
<BookOpen className="h-4 w-4 text-muted-foreground" />
) : (
<div className="h-4 w-4 bg-muted-foreground rounded-sm" />
)}
<span className="text-sm font-medium">{learner.currentItem.title}</span>
</div>
<Badge
{...getStatusBadgeProps(learner.currentItem.status)}
className="text-xs"
>
{learner.currentItem.status}
</Badge>
</div>
) : (
<span className="text-muted-foreground text-sm">No current item</span>
)}
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Progress
value={learner.progressPct}
className="w-16"
aria-describedby={`progress-${learner.learnerId}`}
/>
<span className="text-sm font-medium">{learner.progressPct}%</span>
</div>
<span id={`progress-${learner.learnerId}`} className="sr-only">
Progress: {learner.progressPct} percent complete
</span>
</div>
</TableCell>
<TableCell>
{learner.nextItem ? (
<div className="flex items-center gap-2">
{learner.nextItem.type === 'course' ? (
<BookOpen className="h-4 w-4 text-muted-foreground" />
) : (
<div className="h-4 w-4 bg-muted-foreground rounded-sm" />
)}
<span className="text-sm">{learner.nextItem.title}</span>
</div>
) : (
<span className="text-muted-foreground text-sm">Programme complete</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
<span>{formatLastActivity(learner.lastActivity)}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewLearner(learner)}
className="min-tap-44"
aria-label={`View details for ${learner.learnerName}`}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleNudgeLearner(learner.learnerId)}
className="min-tap-44"
aria-label={`Send reminder to ${learner.learnerName}`}
>
<Send className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
)) || (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
No learner data available for this programme.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
{/* Learner Detail Drawer */}
<Sheet open={isLearnerDrawerOpen} onOpenChange={setIsLearnerDrawerOpen}>
<SheetContent
className="w-[500px] sm:w-[600px]"
role="dialog"
aria-modal="true"
aria-labelledby="learner-detail-title"
>
<SheetHeader>
<SheetTitle id="learner-detail-title">
{selectedLearner?.learnerName}
</SheetTitle>
<SheetDescription>
Detailed progress and activity in {currentProgrammeName}
</SheetDescription>
</SheetHeader>
{selectedLearner && (
<div className="mt-6 space-y-6">
{/* Contact Info */}
<div className="space-y-2">
<h4 className="font-medium">Contact Information</h4>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">{selectedLearner.learnerEmail}</p>
<p className="text-sm text-muted-foreground">
Last active: {formatLastActivity(selectedLearner.lastActivity)}
</p>
</div>
</div>
{/* Progress Summary */}
<div className="space-y-2">
<h4 className="font-medium">Progress Summary</h4>
<div className="p-4 bg-muted/30 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm">Overall Progress</span>
<span className="font-medium">{selectedLearner.progressPct}%</span>
</div>
<Progress value={selectedLearner.progressPct} className="h-2" />
</div>
</div>
{/* Current Activity */}
{selectedLearner.currentItem && (
<div className="space-y-2">
<h4 className="font-medium">Current Activity</h4>
<div className="p-4 border rounded-lg">
<div className="flex items-center gap-2 mb-2">
{selectedLearner.currentItem.type === 'course' ? (
<BookOpen className="h-4 w-4 text-muted-foreground" />
) : (
<div className="h-4 w-4 bg-muted-foreground rounded-sm" />
)}
<span className="font-medium">{selectedLearner.currentItem.title}</span>
</div>
<Badge {...getStatusBadgeProps(selectedLearner.currentItem.status)}>
{selectedLearner.currentItem.status}
</Badge>
</div>
</div>
)}
{/* Next Up */}
{selectedLearner.nextItem && (
<div className="space-y-2">
<h4 className="font-medium">Next Up</h4>
<div className="p-4 border border-dashed rounded-lg">
<div className="flex items-center gap-2">
{selectedLearner.nextItem.type === 'course' ? (
<BookOpen className="h-4 w-4 text-muted-foreground" />
) : (
<div className="h-4 w-4 bg-muted-foreground rounded-sm" />
)}
<span>{selectedLearner.nextItem.title}</span>
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-4">
<Button className="flex-1">
<Send className="h-4 w-4 mr-2" />
Send Reminder
</Button>
<Button variant="outline" className="flex-1">
<TrendingUp className="h-4 w-4 mr-2" />
View Full Analytics
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,445 @@
import React, { useState } from 'react';
import { Button } from './ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Checkbox } from './ui/checkbox';
import {
Calendar as CalendarIcon,
ChevronLeft,
ChevronRight,
Video,
School,
Clock,
Filter
} from 'lucide-react';
interface CalendarEvent {
id: string;
programmeId: string;
type: 'webinar' | 'class' | 'course_end' | 'content_end' | 'programme_end';
title: string;
start: Date;
end?: Date;
href: string;
}
interface ProgrammeLegend {
programmeId: string;
programmeName: string;
color: string;
}
interface CalendarData {
legend: ProgrammeLegend[];
events: CalendarEvent[];
}
interface ProgrammeCalendarProps {
calendarData?: CalendarData;
onEventClick?: (event: CalendarEvent) => void;
}
const mockCalendarData: CalendarData = {
legend: [
{ programmeId: 'prog-001', programmeName: 'Leadership Development', color: '#04045B' },
{ programmeId: 'prog-002', programmeName: 'Technical Skills', color: '#F8C301' },
{ programmeId: 'prog-003', programmeName: 'Communication', color: '#21a36a' },
{ programmeId: 'prog-004', programmeName: 'Project Management', color: '#89002D' },
{ programmeId: 'prog-005', programmeName: 'Sales Training', color: '#C0C0C0' },
{ programmeId: 'prog-006', programmeName: 'Analytics Program', color: '#6366f1' }
],
events: [
{
id: 'event-001',
programmeId: 'prog-001',
type: 'webinar',
title: 'Leadership Fundamentals Webinar',
start: new Date('2024-12-28T14:00:00'),
end: new Date('2024-12-28T15:30:00'),
href: '/webinars/leadership-fundamentals'
},
{
id: 'event-002',
programmeId: 'prog-002',
type: 'class',
title: 'Hands-on Coding Workshop',
start: new Date('2024-12-30T09:00:00'),
end: new Date('2024-12-30T17:00:00'),
href: '/classes/coding-workshop'
},
{
id: 'event-003',
programmeId: 'prog-001',
type: 'course_end',
title: 'Strategic Thinking Course Due',
start: new Date('2025-01-02T23:59:00'),
href: '/courses/strategic-thinking'
},
{
id: 'event-004',
programmeId: 'prog-003',
type: 'webinar',
title: 'Public Speaking Masterclass',
start: new Date('2025-01-05T11:00:00'),
end: new Date('2025-01-05T12:30:00'),
href: '/webinars/public-speaking'
},
{
id: 'event-005',
programmeId: 'prog-004',
type: 'programme_end',
title: 'Project Management Certification Due',
start: new Date('2025-01-10T23:59:00'),
href: '/programmes/project-management'
}
]
};
export const ProgrammeCalendar: React.FC<ProgrammeCalendarProps> = ({
calendarData = mockCalendarData,
onEventClick
}) => {
const [currentDate, setCurrentDate] = useState(new Date());
const [viewMode, setViewMode] = useState<'month' | 'week'>('month');
const [selectedProgrammes, setSelectedProgrammes] = useState<string[]>(
calendarData.legend.slice(0, 6).map(p => p.programmeId)
);
const [selectedEventTypes, setSelectedEventTypes] = useState<string[]>([
'webinar', 'class', 'course_end', 'content_end', 'programme_end'
]);
const [showFilters, setShowFilters] = useState(false);
const visibleLegend = calendarData.legend.slice(0, 6);
const additionalProgrammes = calendarData.legend.length - 6;
const getEventIcon = (type: CalendarEvent['type']) => {
switch (type) {
case 'webinar':
return <Video className="h-3 w-3" />;
case 'class':
return <School className="h-3 w-3" />;
default:
return <Clock className="h-3 w-3" />;
}
};
const getEventTypeLabel = (type: CalendarEvent['type']) => {
switch (type) {
case 'webinar':
return 'Webinar';
case 'class':
return 'Offline Class';
case 'course_end':
return 'Course End';
case 'content_end':
return 'Content End';
case 'programme_end':
return 'Programme End';
default:
return 'Event';
}
};
const filteredEvents = calendarData.events.filter(event =>
selectedProgrammes.includes(event.programmeId) &&
selectedEventTypes.includes(event.type)
);
const formatEventTime = (start: Date, end?: Date) => {
const startTime = start.toLocaleTimeString('en-AU', {
hour: '2-digit',
minute: '2-digit',
hour12: true
});
if (end) {
const endTime = end.toLocaleTimeString('en-AU', {
hour: '2-digit',
minute: '2-digit',
hour12: true
});
return `${startTime} - ${endTime}`;
}
return `Due ${startTime}`;
};
const getProgrammeColor = (programmeId: string) => {
const programme = calendarData.legend.find(p => p.programmeId === programmeId);
return programme?.color || '#6b7280';
};
const navigateMonth = (direction: 'prev' | 'next') => {
setCurrentDate(prev => {
const newDate = new Date(prev);
if (direction === 'prev') {
newDate.setMonth(prev.getMonth() - 1);
} else {
newDate.setMonth(prev.getMonth() + 1);
}
return newDate;
});
};
const toggleProgrammeFilter = (programmeId: string) => {
setSelectedProgrammes(prev =>
prev.includes(programmeId)
? prev.filter(id => id !== programmeId)
: [...prev, programmeId]
);
};
const toggleEventTypeFilter = (eventType: string) => {
setSelectedEventTypes(prev =>
prev.includes(eventType)
? prev.filter(type => type !== eventType)
: [...prev, eventType]
);
};
const handleEventClick = (event: CalendarEvent) => {
onEventClick?.(event);
console.log(`Clicked event: ${event.title}`);
};
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<CardTitle>Programme Schedule</CardTitle>
<CardDescription>Webinars, classes, and important deadlines</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowFilters(!showFilters)}
className="min-tap-44"
aria-label="Toggle filters"
>
<Filter className="h-4 w-4 mr-2" />
Filters
</Button>
<div className="flex border rounded-md">
<Button
variant={viewMode === 'month' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('month')}
className="rounded-r-none"
>
Month
</Button>
<Button
variant={viewMode === 'week' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('week')}
className="rounded-l-none"
>
Week
</Button>
</div>
</div>
</div>
{/* Calendar Navigation */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigateMonth('prev')}
className="min-tap-44"
aria-label="Previous month"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<h3 className="font-semibold text-lg">
{currentDate.toLocaleDateString('en-AU', {
month: 'long',
year: 'numeric'
})}
</h3>
<Button
variant="outline"
size="sm"
onClick={() => navigateMonth('next')}
className="min-tap-44"
aria-label="Next month"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentDate(new Date())}
className="min-tap-44"
>
Today
</Button>
</div>
{/* Programme Legend */}
<div className="space-y-2">
<h4 className="text-sm font-medium">Programmes</h4>
<div className="flex flex-wrap gap-2">
{visibleLegend.map((programme) => (
<div
key={programme.programmeId}
className="flex items-center gap-1"
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: programme.color }}
aria-hidden="true"
/>
<span className="text-sm">{programme.programmeName}</span>
</div>
))}
{additionalProgrammes > 0 && (
<Badge variant="secondary" className="text-xs">
+{additionalProgrammes} more
</Badge>
)}
</div>
</div>
{/* Filters Panel */}
{showFilters && (
<div className="border rounded-lg p-4 space-y-4 bg-muted/30">
<div>
<h4 className="text-sm font-medium mb-2">Filter by Programme</h4>
<div className="space-y-2">
{calendarData.legend.map((programme) => (
<div key={programme.programmeId} className="flex items-center space-x-2">
<Checkbox
id={`prog-${programme.programmeId}`}
checked={selectedProgrammes.includes(programme.programmeId)}
onCheckedChange={() => toggleProgrammeFilter(programme.programmeId)}
className="min-tap-44"
/>
<label
htmlFor={`prog-${programme.programmeId}`}
className="text-sm font-medium cursor-pointer flex items-center gap-2"
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: programme.color }}
aria-hidden="true"
/>
{programme.programmeName}
</label>
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium mb-2">Filter by Type</h4>
<div className="space-y-2">
{[
{ value: 'webinar', label: 'Webinars' },
{ value: 'class', label: 'Offline Classes' },
{ value: 'course_end', label: 'Course End Dates' },
{ value: 'content_end', label: 'Content End Dates' },
{ value: 'programme_end', label: 'Programme End Dates' }
].map((eventType) => (
<div key={eventType.value} className="flex items-center space-x-2">
<Checkbox
id={`type-${eventType.value}`}
checked={selectedEventTypes.includes(eventType.value)}
onCheckedChange={() => toggleEventTypeFilter(eventType.value)}
className="min-tap-44"
/>
<label
htmlFor={`type-${eventType.value}`}
className="text-sm font-medium cursor-pointer"
>
{eventType.label}
</label>
</div>
))}
</div>
</div>
</div>
)}
</div>
</CardHeader>
<CardContent>
{/* Calendar Grid */}
<div
className="border rounded-lg p-4 min-h-[400px]"
role="grid"
aria-label={`Calendar for ${currentDate.toLocaleDateString('en-AU', { month: 'long', year: 'numeric' })}`}
>
{/* Events List View (simplified for demo) */}
<div className="space-y-3">
{filteredEvents.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<CalendarIcon className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p>No events found for the selected filters.</p>
<p className="text-sm mt-1">Try adjusting your programme or event type filters.</p>
</div>
) : (
filteredEvents.map((event) => {
const programme = calendarData.legend.find(p => p.programmeId === event.programmeId);
return (
<div
key={event.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 cursor-pointer transition-colors min-tap-44"
onClick={() => handleEventClick(event)}
role="button"
tabIndex={0}
aria-label={`${event.title} on ${event.start.toLocaleDateString()}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleEventClick(event);
}
}}
>
<div className="flex items-center gap-3 flex-1">
<div
className="w-4 h-4 rounded-full flex items-center justify-center text-white"
style={{ backgroundColor: getProgrammeColor(event.programmeId) }}
aria-hidden="true"
>
{getEventIcon(event.type)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{event.title}</span>
<Badge variant="outline" className="text-xs">
{getEventTypeLabel(event.type)}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{programme?.programmeName}</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-medium">
{event.start.toLocaleDateString('en-AU', {
weekday: 'short',
month: 'short',
day: 'numeric'
})}
</p>
<p className="text-xs text-muted-foreground">
{formatEventTime(event.start, event.end)}
</p>
</div>
</div>
);
})
)}
</div>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,902 @@
import React, { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Input } from './ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from './ui/sheet';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
import { Progress } from './ui/progress';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Alert, AlertDescription } from './ui/alert';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './ui/accordion';
import {
ArrowLeft,
Download,
BarChart3,
MoreHorizontal,
Users,
Calendar,
Clock,
BookOpen,
Award,
Bell,
Search,
Filter,
Eye,
Mail,
FileText,
Play,
MapPin,
Video,
FileQuestion,
Activity,
Building2,
RefreshCw,
ExternalLink,
Plus,
CheckCircle,
AlertCircle,
XCircle
} from 'lucide-react';
// Types
interface Programme {
id: string;
title: string;
status: 'Active' | 'Upcoming' | 'Completed';
code: string;
owner: string;
version: number;
description: string;
goals: string[];
tags: string[];
structure: {
preAssessment: ProgrammeItem[];
preLearning: ProgrammeItem[];
classroom: ProgrammeItem[];
postLearning: ProgrammeItem[];
};
}
interface ProgrammeItem {
id: string;
type: 'Profiler' | 'Course' | 'Content' | 'Webinar' | 'OfflineSession';
title: string;
duration?: string;
dueDate?: string;
venue?: string;
room?: string;
date?: string;
capacity?: number;
status?: 'Not Started' | 'In Progress' | 'Completed';
}
interface Assignment {
startDate: string;
endDate: string;
orgId: string;
orgName: string;
}
interface ProgrammeCounts {
learners: number;
completionPct: number;
courses: number;
content: number;
webinars: number;
classes: number;
}
interface ProgrammeLearner {
id: string;
name: string;
email: string;
currentItem: {
type: string;
id: string;
title: string;
status: 'Not Started' | 'In-Progress' | 'Completed';
};
progressPct: number;
nextItem?: {
type: string;
id: string;
title: string;
};
lastActivity: string;
stage: 'Pre-assessment' | 'Pre-learning' | 'Classroom' | 'Post-learning';
cohort?: string;
}
interface ProgrammeEvent {
id: string;
programmeId: string;
type: 'webinar' | 'class' | 'course_end' | 'content_end' | 'programme_end';
title: string;
start: string;
end: string;
venue?: string;
room?: string;
}
interface ProgrammeHRViewProps {
programmeId: string;
onBack: () => void;
onAssignLearners: (programmeId: string) => void;
onDownloadTracker: (programmeId: string) => void;
onOpenAnalytics: (programmeId: string) => void;
}
// Mock data
const mockProgramme: Programme = {
id: 'prg_123',
title: 'Executive Leadership Development Programme',
status: 'Active',
code: 'ELDP-2024',
owner: 'Dr. Sarah Johnson',
version: 2,
description: 'A comprehensive leadership development programme designed to build strategic thinking, emotional intelligence, and decision-making capabilities for senior executives.',
goals: [
'Develop strategic thinking and planning capabilities',
'Enhance emotional intelligence and self-awareness',
'Build effective communication and influence skills',
'Master change management and innovation leadership'
],
tags: ['Leadership', 'Executive', 'Strategic Thinking', 'Management'],
structure: {
preAssessment: [
{ id: 'pa1', type: 'Profiler', title: 'Leadership Style Assessment', status: 'Completed' },
{ id: 'pa2', type: 'Profiler', title: '360-Degree Feedback', status: 'In Progress' }
],
preLearning: [
{ id: 'pl1', type: 'Course', title: 'Strategic Thinking Fundamentals', duration: '4 hours', dueDate: '2024-01-15', status: 'Completed' },
{ id: 'pl2', type: 'Content', title: 'Leadership in Crisis Webcast', duration: '45 mins', status: 'In Progress' },
{ id: 'pl3', type: 'Webinar', title: 'Future of Leadership', date: '2024-01-20 10:00 AM AEDT', status: 'Not Started' }
],
classroom: [
{ id: 'c1', type: 'OfflineSession', title: 'Strategic Leadership Workshop', venue: 'Sydney Campus', room: 'Executive Suite A', date: '2024-02-05', capacity: 20 },
{ id: 'c2', type: 'OfflineSession', title: 'Case Study Analysis', venue: 'Sydney Campus', room: 'Conference Room B', date: '2024-02-06', capacity: 20 }
],
postLearning: [
{ id: 'po1', type: 'Course', title: 'Advanced Decision Making', duration: '6 hours', dueDate: '2024-02-20', status: 'Not Started' },
{ id: 'po2', type: 'Content', title: 'Leadership Reflection Journal', duration: '2 weeks', status: 'Not Started' }
]
}
};
const mockAssignment: Assignment = {
startDate: '2024-01-01',
endDate: '2024-03-31',
orgId: 'org_123',
orgName: 'Tech Solutions Pvt Ltd'
};
const mockCounts: ProgrammeCounts = {
learners: 28,
completionPct: 64,
courses: 2,
content: 2,
webinars: 1,
classes: 2
};
const mockLearners: ProgrammeLearner[] = [
{
id: 'l1',
name: 'Sarah Chen',
email: 'sarah.chen@company.com',
currentItem: { type: 'Course', id: 'pl1', title: 'Strategic Thinking Fundamentals', status: 'In-Progress' },
progressPct: 75,
nextItem: { type: 'Content', id: 'pl2', title: 'Leadership in Crisis Webcast' },
lastActivity: '2 hours ago',
stage: 'Pre-learning',
cohort: 'Cohort A'
},
{
id: 'l2',
name: 'Michael Rodriguez',
email: 'michael.r@company.com',
currentItem: { type: 'Profiler', id: 'pa2', title: '360-Degree Feedback', status: 'In-Progress' },
progressPct: 45,
nextItem: { type: 'Course', id: 'pl1', title: 'Strategic Thinking Fundamentals' },
lastActivity: '1 day ago',
stage: 'Pre-assessment',
cohort: 'Cohort A'
},
{
id: 'l3',
name: 'Emma Thompson',
email: 'emma.thompson@company.com',
currentItem: { type: 'OfflineSession', id: 'c1', title: 'Strategic Leadership Workshop', status: 'Completed' },
progressPct: 89,
nextItem: { type: 'Course', id: 'po1', title: 'Advanced Decision Making' },
lastActivity: '3 hours ago',
stage: 'Classroom',
cohort: 'Cohort B'
}
];
const mockEvents: ProgrammeEvent[] = [
{
id: 'e1',
programmeId: 'prg_123',
type: 'webinar',
title: 'Future of Leadership',
start: '2024-01-20T10:00:00',
end: '2024-01-20T11:30:00'
},
{
id: 'e2',
programmeId: 'prg_123',
type: 'class',
title: 'Strategic Leadership Workshop',
start: '2024-02-05T09:00:00',
end: '2024-02-05T17:00:00',
venue: 'Sydney Campus',
room: 'Executive Suite A'
}
];
export const ProgrammeHRView: React.FC<ProgrammeHRViewProps> = ({
programmeId,
onBack,
onAssignLearners,
onDownloadTracker,
onOpenAnalytics
}) => {
const [activeTab, setActiveTab] = useState('overview');
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [stageFilter, setStageFilter] = useState('all');
const [cohortFilter, setCohortFilter] = useState('all');
const [selectedLearner, setSelectedLearner] = useState<ProgrammeLearner | null>(null);
const [showLearnerDrawer, setShowLearnerDrawer] = useState(false);
const [exporting, setExporting] = useState(false);
// Simulate loading
useEffect(() => {
const timer = setTimeout(() => setLoading(false), 800);
return () => clearTimeout(timer);
}, []);
const getTypeIcon = (type: string) => {
switch (type) {
case 'Course': return <BookOpen className="h-4 w-4" />;
case 'Content': return <FileText className="h-4 w-4" />;
case 'Webinar': return <Video className="h-4 w-4" />;
case 'Profiler': return <FileQuestion className="h-4 w-4" />;
case 'OfflineSession': return <Building2 className="h-4 w-4" />;
default: return <BookOpen className="h-4 w-4" />;
}
};
const getStatusBadge = (status?: string) => {
switch (status) {
case 'Completed':
return <Badge variant="default" className="bg-status-success text-white"><CheckCircle className="h-3 w-3 mr-1" />Completed</Badge>;
case 'In Progress':
return <Badge variant="secondary" className="bg-status-warn text-black"><AlertCircle className="h-3 w-3 mr-1" />In Progress</Badge>;
case 'Not Started':
return <Badge variant="outline"><XCircle className="h-3 w-3 mr-1" />Not Started</Badge>;
default:
return null;
}
};
const filteredLearners = mockLearners.filter(learner => {
const matchesSearch = learner.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
learner.email.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || learner.currentItem.status === statusFilter;
const matchesStage = stageFilter === 'all' || learner.stage === stageFilter;
const matchesCohort = cohortFilter === 'all' || learner.cohort === cohortFilter;
return matchesSearch && matchesStatus && matchesStage && matchesCohort;
});
const handleViewLearner = (learner: ProgrammeLearner) => {
setSelectedLearner(learner);
setShowLearnerDrawer(true);
};
const handleExport = async (format: 'excel' | 'csv' | 'pdf') => {
setExporting(true);
await new Promise(resolve => setTimeout(resolve, 2000));
setExporting(false);
console.log(`Exported programme tracker as ${format.toUpperCase()}`);
};
if (loading) {
return (
<div className="space-y-6 animate-pulse">
<div className="h-16 bg-muted rounded-lg"></div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-24 bg-muted rounded-lg"></div>
))}
</div>
<div className="h-96 bg-muted rounded-lg"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<Card className="sticky top-0 z-20 bg-background border-b shadow-sm">
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="min-tap-44"
aria-label="Go back to programmes list"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-bold">{mockProgramme.title}</h1>
<Badge
variant={mockProgramme.status === 'Active' ? 'default' : 'secondary'}
className={mockProgramme.status === 'Active' ? 'bg-status-success' : ''}
>
{mockProgramme.status}
</Badge>
</div>
<p className="text-muted-foreground">
{mockProgramme.code} {mockProgramme.owner} Version {mockProgramme.version}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => onAssignLearners(programmeId)}
className="min-tap-44"
>
<Users className="h-4 w-4 mr-2" />
Assign Learners
</Button>
<Button
variant="outline"
onClick={() => onDownloadTracker(programmeId)}
disabled={exporting}
className="min-tap-44"
>
{exporting ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download Tracker
</Button>
<Button
variant="outline"
onClick={() => onOpenAnalytics(programmeId)}
className="min-tap-44"
>
<BarChart3 className="h-4 w-4 mr-2" />
Open Analytics
</Button>
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="min-tap-44"
aria-label="More actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Programme Actions</DialogTitle>
<DialogDescription>Additional actions for this programme</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Button variant="outline" className="w-full justify-start">
<FileText className="h-4 w-4 mr-2" />
View Structure JSON
</Button>
<Button variant="outline" className="w-full justify-start">
<ExternalLink className="h-4 w-4 mr-2" />
Audit Trail
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div>
</CardContent>
</Card>
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Assignment Window</p>
<p className="font-semibold">{new Date(mockAssignment.startDate).toLocaleDateString()} {new Date(mockAssignment.endDate).toLocaleDateString()}</p>
<p className="text-xs text-muted-foreground">{mockAssignment.orgName}</p>
</div>
<Calendar className="h-8 w-8 text-brand-primary" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Enrolled Learners</p>
<p className="text-2xl font-bold">{mockCounts.learners}</p>
<Button variant="link" className="p-0 h-auto text-xs">Manage</Button>
</div>
<Users className="h-8 w-8 text-brand-primary" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Completion Rate</p>
<p className="text-2xl font-bold">{mockCounts.completionPct}%</p>
<Progress value={mockCounts.completionPct} className="w-16 mt-1" />
</div>
<Award className="h-8 w-8 text-brand-primary" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Items in Programme</p>
<div className="flex gap-2 text-sm">
<span>{mockCounts.courses} Courses</span>
<span></span>
<span>{mockCounts.content} Content</span>
</div>
<div className="flex gap-2 text-sm text-muted-foreground">
<span>{mockCounts.webinars} Webinars</span>
<span></span>
<span>{mockCounts.classes} Classes</span>
</div>
</div>
<BookOpen className="h-8 w-8 text-brand-primary" />
</div>
</CardContent>
</Card>
</div>
{/* Tabs */}
<Card>
<CardContent className="pt-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-6">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="structure">Structure</TabsTrigger>
<TabsTrigger value="learners">Learners</TabsTrigger>
<TabsTrigger value="calendar">Calendar</TabsTrigger>
<TabsTrigger value="reports">Reports</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview" className="space-y-6 mt-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Programme Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="font-medium mb-2">Description</h4>
<p className="text-sm text-muted-foreground">{mockProgramme.description}</p>
</div>
<div>
<h4 className="font-medium mb-2">Learning Goals</h4>
<ul className="text-sm text-muted-foreground space-y-1">
{mockProgramme.goals.map((goal, index) => (
<li key={index} className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 text-status-success mt-0.5 flex-shrink-0" />
{goal}
</li>
))}
</ul>
</div>
<div>
<h4 className="font-medium mb-2">Tags</h4>
<div className="flex flex-wrap gap-2">
{mockProgramme.tags.map((tag, index) => (
<Badge key={index} variant="outline">{tag}</Badge>
))}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Stage Breakdown</CardTitle>
<CardDescription>Programme structure as designed by Super Admin</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{[
{ name: 'Pre-assessment', items: mockProgramme.structure.preAssessment, color: 'bg-chart-1' },
{ name: 'Pre-learning', items: mockProgramme.structure.preLearning, color: 'bg-chart-2' },
{ name: 'Classroom sessions', items: mockProgramme.structure.classroom, color: 'bg-chart-3' },
{ name: 'Post-learning', items: mockProgramme.structure.postLearning, color: 'bg-chart-4' }
].map((stage, index) => (
<div key={index} className="border-l-4 border-l-brand-primary pl-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{stage.name}</h4>
<Badge variant="outline">{stage.items.length} items</Badge>
</div>
<div className="space-y-1">
{stage.items.slice(0, 2).map((item, itemIndex) => (
<div key={itemIndex} className="flex items-center gap-2 text-sm">
{getTypeIcon(item.type)}
<span>{item.title}</span>
{item.status && getStatusBadge(item.status)}
</div>
))}
{stage.items.length > 2 && (
<p className="text-xs text-muted-foreground">
+{stage.items.length - 2} more items
</p>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
<Alert>
<Bell className="h-4 w-4" />
<AlertDescription>
<strong>Guardrails:</strong> Dates and enrolments shown are scoped to your organization ({mockAssignment.orgName}).
</AlertDescription>
</Alert>
</TabsContent>
{/* Structure Tab */}
<TabsContent value="structure" className="space-y-6 mt-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">Programme Structure</h3>
<p className="text-muted-foreground">Read-only view of the programme as composed by Super Admin</p>
</div>
<Badge variant="outline">Read Only</Badge>
</div>
<Accordion type="single" collapsible className="space-y-2">
{[
{ name: 'Pre-assessment', items: mockProgramme.structure.preAssessment },
{ name: 'Pre-learning', items: mockProgramme.structure.preLearning },
{ name: 'Classroom Sessions (Offline)', items: mockProgramme.structure.classroom },
{ name: 'Post-learning', items: mockProgramme.structure.postLearning }
].map((stage, index) => (
<AccordionItem key={index} value={`stage-${index}`} className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-3">
<Badge variant="outline">{stage.name}</Badge>
<span className="text-muted-foreground">({stage.items.length} items)</span>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-3 pb-4">
{stage.items.map((item, itemIndex) => (
<div key={itemIndex} className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
<div className="flex items-center gap-3">
{getTypeIcon(item.type)}
<div>
<p className="font-medium">{item.title}</p>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
{item.duration && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{item.duration}
</span>
)}
{item.dueDate && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
Due: {item.dueDate}
</span>
)}
{item.venue && (
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{item.venue} {item.room && `${item.room}`}
</span>
)}
{item.date && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{item.date}
</span>
)}
</div>
</div>
</div>
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-2" />
Open
</Button>
</div>
))}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</TabsContent>
{/* Learners Tab */}
<TabsContent value="learners" className="space-y-6 mt-6">
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div>
<h3 className="text-lg font-semibold">Learner Progress</h3>
<p className="text-muted-foreground">Track individual progress across the programme</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => handleExport('excel')}
disabled={exporting}
className="min-tap-44"
>
{exporting ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Export Data
</Button>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-4 items-center">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search learners..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="Not Started">Not Started</SelectItem>
<SelectItem value="In-Progress">In Progress</SelectItem>
<SelectItem value="Completed">Completed</SelectItem>
</SelectContent>
</Select>
<Select value={stageFilter} onValueChange={setStageFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Stage" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Stages</SelectItem>
<SelectItem value="Pre-assessment">Pre-assessment</SelectItem>
<SelectItem value="Pre-learning">Pre-learning</SelectItem>
<SelectItem value="Classroom">Classroom</SelectItem>
<SelectItem value="Post-learning">Post-learning</SelectItem>
</SelectContent>
</Select>
<Select value={cohortFilter} onValueChange={setCohortFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Cohort" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Cohorts</SelectItem>
<SelectItem value="Cohort A">Cohort A</SelectItem>
<SelectItem value="Cohort B">Cohort B</SelectItem>
</SelectContent>
</Select>
</div>
{/* Learners Table */}
<div className="rounded-md border">
<Table>
<TableHeader className="sticky-header">
<TableRow>
<TableHead className="w-[200px]">Learner</TableHead>
<TableHead className="w-[200px]">Current Item</TableHead>
<TableHead className="w-[100px]">Progress</TableHead>
<TableHead className="w-[150px]">Next Item</TableHead>
<TableHead className="w-[120px]">Last Activity</TableHead>
<TableHead className="w-[100px]">Stage</TableHead>
<TableHead className="w-[80px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLearners.map((learner) => (
<TableRow key={learner.id} className="min-h-[48px]">
<TableCell>
<div>
<p className="font-medium">{learner.name}</p>
<p className="text-sm text-muted-foreground">{learner.email}</p>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{getTypeIcon(learner.currentItem.type)}
<div>
<p className="font-medium text-sm">{learner.currentItem.title}</p>
{getStatusBadge(learner.currentItem.status)}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<Progress value={learner.progressPct} className="w-16" />
<span className="text-sm">{learner.progressPct}%</span>
</div>
</TableCell>
<TableCell>
{learner.nextItem ? (
<div className="flex items-center gap-2">
{getTypeIcon(learner.nextItem.type)}
<span className="text-sm">{learner.nextItem.title}</span>
</div>
) : (
<span className="text-sm text-muted-foreground">None</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{learner.lastActivity}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{learner.stage}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewLearner(learner)}
className="min-tap-44"
aria-label={`View details for ${learner.name}`}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="min-tap-44"
aria-label={`Send reminder to ${learner.name}`}
>
<Mail className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TabsContent>
{/* Calendar Tab */}
<TabsContent value="calendar" className="space-y-6 mt-6">
<div className="text-center py-12 text-muted-foreground">
<Calendar className="h-12 w-12 mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">Programme Calendar</h3>
<p>Programme-scoped calendar with webinars, offline classes, and deadlines would be displayed here</p>
</div>
</TabsContent>
{/* Reports Tab */}
<TabsContent value="reports" className="space-y-6 mt-6">
<div className="text-center py-12 text-muted-foreground">
<BarChart3 className="h-12 w-12 mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">Programme Reports</h3>
<p>Detailed analytics and reporting for this programme would be displayed here</p>
</div>
</TabsContent>
{/* Activity Tab */}
<TabsContent value="activity" className="space-y-6 mt-6">
<div className="text-center py-12 text-muted-foreground">
<Activity className="h-12 w-12 mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">Activity Log</h3>
<p>Programme activity audit trail would be displayed here</p>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* Learner Details Drawer */}
<Sheet open={showLearnerDrawer} onOpenChange={setShowLearnerDrawer}>
<SheetContent className="w-[480px] sm:w-[540px]">
<SheetHeader>
<SheetTitle>{selectedLearner?.name}</SheetTitle>
<SheetDescription>Learner progress and assignment details</SheetDescription>
</SheetHeader>
{selectedLearner && (
<div className="space-y-6 mt-6">
<div>
<h4 className="font-medium mb-2">Assignment Info</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Start Date:</span>
<span>{mockAssignment.startDate}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">End Date:</span>
<span>{mockAssignment.endDate}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Cohort:</span>
<span>{selectedLearner.cohort}</span>
</div>
</div>
</div>
<div>
<h4 className="font-medium mb-2">Stage Timeline</h4>
<div className="space-y-3">
{[
{ name: 'Pre-assessment', completed: selectedLearner.stage !== 'Pre-assessment' },
{ name: 'Pre-learning', completed: !['Pre-assessment', 'Pre-learning'].includes(selectedLearner.stage) },
{ name: 'Classroom', completed: !['Pre-assessment', 'Pre-learning', 'Classroom'].includes(selectedLearner.stage) },
{ name: 'Post-learning', completed: false }
].map((stage, index) => (
<div key={index} className="flex items-center gap-3">
<div className={`w-4 h-4 rounded-full border-2 ${
stage.completed ? 'bg-status-success border-status-success' :
selectedLearner.stage === stage.name ? 'border-brand-primary bg-brand-primary' :
'border-muted'
}`}>
{stage.completed && <CheckCircle className="w-4 h-4 text-white" />}
</div>
<span className={`text-sm ${
selectedLearner.stage === stage.name ? 'font-medium' : 'text-muted-foreground'
}`}>
{stage.name}
</span>
</div>
))}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" className="flex-1">
<Eye className="h-4 w-4 mr-2" />
Open Learner Record
</Button>
<Button variant="outline" className="flex-1">
<Mail className="h-4 w-4 mr-2" />
Send Reminder
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
</div>
);
};

View File

@@ -0,0 +1,367 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import {
Calendar,
Clock,
Users,
Video,
FileText,
ChevronLeft,
ChevronRight,
Filter
} from 'lucide-react';
interface ScheduleEvent {
id: string;
title: string;
type: 'webinar' | 'workshop' | 'assessment' | 'deadline' | 'class';
date: Date;
time: string;
duration?: string;
programme: string;
attendees?: number;
maxAttendees?: number;
status: 'upcoming' | 'live' | 'completed';
}
interface ProgrammeScheduleProps {
events?: ScheduleEvent[];
onEventClick?: (event: ScheduleEvent) => void;
}
const mockEvents: ScheduleEvent[] = [
{
id: 'evt-001',
title: 'Leadership Fundamentals Webinar',
type: 'webinar',
date: new Date('2024-12-28'),
time: '10:00 AM',
duration: '90 min',
programme: 'Leadership Development',
attendees: 32,
maxAttendees: 50,
status: 'upcoming'
},
{
id: 'evt-002',
title: 'Technical Skills Assessment',
type: 'assessment',
date: new Date('2024-12-28'),
time: '2:00 PM',
duration: '60 min',
programme: 'Technical Skills',
status: 'upcoming'
},
{
id: 'evt-003',
title: 'Communication Workshop',
type: 'workshop',
date: new Date('2024-12-29'),
time: '11:00 AM',
duration: '2 hrs',
programme: 'Communication Excellence',
attendees: 18,
maxAttendees: 25,
status: 'upcoming'
},
{
id: 'evt-004',
title: 'Project Management Live Class',
type: 'class',
date: new Date('2024-12-29'),
time: '3:30 PM',
duration: '45 min',
programme: 'Project Management',
attendees: 45,
maxAttendees: 60,
status: 'live'
},
{
id: 'evt-005',
title: 'Assignment Submission Due',
type: 'deadline',
date: new Date('2024-12-30'),
time: '11:59 PM',
programme: 'Leadership Development',
status: 'upcoming'
},
{
id: 'evt-006',
title: 'Data Analytics Bootcamp',
type: 'workshop',
date: new Date('2024-12-30'),
time: '9:00 AM',
duration: '4 hrs',
programme: 'Technical Skills',
attendees: 22,
maxAttendees: 30,
status: 'upcoming'
}
];
export const ProgrammeSchedule: React.FC<ProgrammeScheduleProps> = ({
events = mockEvents,
onEventClick
}) => {
const [selectedProgramme, setSelectedProgramme] = useState('all');
const [selectedType, setSelectedType] = useState('all');
const [currentWeekStart, setCurrentWeekStart] = useState(() => {
const today = new Date();
const dayOfWeek = today.getDay();
const mondayDate = new Date(today);
mondayDate.setDate(today.getDate() - dayOfWeek + 1);
return mondayDate;
});
// Get unique programmes and types for filters
const programmes = Array.from(new Set(events.map(e => e.programme)));
const eventTypes = Array.from(new Set(events.map(e => e.type)));
// Generate week days
const weekDays = Array.from({ length: 7 }, (_, i) => {
const date = new Date(currentWeekStart);
date.setDate(currentWeekStart.getDate() + i);
return date;
});
// Filter and group events by date
const filteredEvents = events.filter(event => {
const matchesProgramme = selectedProgramme === 'all' || event.programme === selectedProgramme;
const matchesType = selectedType === 'all' || event.type === selectedType;
return matchesProgramme && matchesType;
});
const eventsByDate = weekDays.reduce((acc, date) => {
const dateKey = date.toDateString();
acc[dateKey] = filteredEvents.filter(event =>
event.date.toDateString() === dateKey
).sort((a, b) => a.time.localeCompare(b.time));
return acc;
}, {} as Record<string, ScheduleEvent[]>);
const navigateWeek = (direction: 'prev' | 'next') => {
const newDate = new Date(currentWeekStart);
newDate.setDate(currentWeekStart.getDate() + (direction === 'next' ? 7 : -7));
setCurrentWeekStart(newDate);
};
const getEventIcon = (type: ScheduleEvent['type']) => {
switch (type) {
case 'webinar':
return <Video className="h-3 w-3" />;
case 'workshop':
return <Users className="h-3 w-3" />;
case 'assessment':
return <FileText className="h-3 w-3" />;
case 'deadline':
return <Clock className="h-3 w-3" />;
case 'class':
return <Calendar className="h-3 w-3" />;
default:
return <Calendar className="h-3 w-3" />;
}
};
const getEventColor = (type: ScheduleEvent['type'], status: ScheduleEvent['status']) => {
if (status === 'live') return 'bg-status-error text-status-error-foreground';
if (status === 'completed') return 'bg-muted text-muted-foreground';
switch (type) {
case 'webinar':
return 'bg-brand-primary text-brand-navy-foreground';
case 'workshop':
return 'bg-status-success text-status-success-foreground';
case 'assessment':
return 'bg-status-warn text-status-warn-foreground';
case 'deadline':
return 'bg-status-error text-status-error-foreground';
case 'class':
return 'bg-brand-charcoal text-brand-charcoal-foreground';
default:
return 'bg-secondary text-secondary-foreground';
}
};
const formatDate = (date: Date) => {
return date.toLocaleDateString('en-AU', {
weekday: 'short',
day: '2-digit',
month: 'short'
});
};
const isToday = (date: Date) => {
const today = new Date();
return date.toDateString() === today.toDateString();
};
return (
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<CardTitle>Programme Schedule</CardTitle>
<CardDescription>Weekly view of upcoming classes, webinars, and deadlines</CardDescription>
</div>
<div className="flex items-center gap-2">
<Select value={selectedProgramme} onValueChange={setSelectedProgramme}>
<SelectTrigger className="w-[180px]">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="All Programmes" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Programmes</SelectItem>
{programmes.map(programme => (
<SelectItem key={programme} value={programme}>
{programme}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedType} onValueChange={setSelectedType}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
{eventTypes.map(type => (
<SelectItem key={type} value={type}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
{/* Week Navigation */}
<div className="flex items-center justify-between mb-6">
<Button
variant="outline"
size="sm"
onClick={() => navigateWeek('prev')}
className="min-tap-44"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous Week
</Button>
<div className="text-center">
<h3 className="font-semibold">
{weekDays[0].toLocaleDateString('en-AU', { day: '2-digit', month: 'short' })} - {weekDays[6].toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: 'numeric' })}
</h3>
<p className="text-sm text-muted-foreground">Week View</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigateWeek('next')}
className="min-tap-44"
>
Next Week
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
{/* Horizontal Weekly Calendar */}
<div className="grid grid-cols-7 gap-2">
{weekDays.map((date, dayIndex) => {
const dateKey = date.toDateString();
const dayEvents = eventsByDate[dateKey] || [];
return (
<div
key={dayIndex}
className={`
min-h-[120px] p-3 rounded-lg border
${isToday(date)
? 'bg-brand-primary/5 border-brand-primary/20'
: 'bg-card border-chrome-divider'
}
`}
>
<div className="text-center mb-3">
<p className={`font-medium ${isToday(date) ? 'text-brand-primary' : ''}`}>
{formatDate(date)}
</p>
{isToday(date) && (
<Badge variant="secondary" className="text-xs mt-1">Today</Badge>
)}
</div>
<div className="space-y-1">
{dayEvents.slice(0, 3).map((event) => (
<button
key={event.id}
onClick={() => onEventClick?.(event)}
className={`
w-full p-2 rounded text-left text-xs transition-all duration-200
hover:shadow-sm hover:scale-105 min-tap-44
${getEventColor(event.type, event.status)}
`}
>
<div className="flex items-center gap-1 mb-1">
{getEventIcon(event.type)}
<span className="font-medium truncate">{event.title}</span>
</div>
<div className="flex items-center justify-between">
<span>{event.time}</span>
{event.status === 'live' && (
<Badge variant="destructive" className="text-xs px-1 py-0">
LIVE
</Badge>
)}
</div>
{event.attendees && (
<div className="flex items-center gap-1 mt-1 text-xs opacity-80">
<Users className="h-2 w-2" />
<span>{event.attendees}/{event.maxAttendees}</span>
</div>
)}
</button>
))}
{dayEvents.length > 3 && (
<div className="text-center">
<span className="text-xs text-muted-foreground">
+{dayEvents.length - 3} more
</span>
</div>
)}
{dayEvents.length === 0 && (
<div className="text-center py-4">
<span className="text-xs text-muted-foreground">No events</span>
</div>
)}
</div>
</div>
);
})}
</div>
{/* Legend */}
<div className="mt-6 pt-4 border-t border-chrome-divider">
<p className="text-sm font-medium mb-2">Event Types:</p>
<div className="flex flex-wrap gap-2">
{[
{ type: 'webinar', label: 'Webinar' },
{ type: 'workshop', label: 'Workshop' },
{ type: 'class', label: 'Live Class' },
{ type: 'assessment', label: 'Assessment' },
{ type: 'deadline', label: 'Deadline' }
].map(({ type, label }) => (
<div key={type} className="flex items-center gap-1">
<div className={`w-3 h-3 rounded ${getEventColor(type as ScheduleEvent['type'], 'upcoming').replace('text-', 'bg-').split(' ')[0]}`} />
<span className="text-xs text-muted-foreground">{label}</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,485 @@
import React, { useState } from 'react';
import { Button } from './ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Input } from './ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
import {
Search,
Filter,
Download,
Users,
Calendar,
BookOpen,
RefreshCw,
Eye,
UserPlus,
ChevronLeft,
ChevronRight
} from 'lucide-react';
interface Assignment {
startDate: Date;
endDate: Date;
}
interface Programme {
programmeId: string;
title: string;
status: 'Active' | 'Upcoming' | 'Completed';
coursesCount: number;
contentCount: number;
assignment: Assignment;
learnersAssigned: number;
type?: 'programme' | 'course';
}
interface ProgrammesTableProps {
programmes?: Programme[];
onViewProgramme?: (programmeId: string) => void;
onViewCourse?: (courseId: string) => void;
onAssignLearners?: (programmeId: string) => void;
onDownloadTracker?: (programmeId: string) => void;
userAccessLevel?: 'full' | 'course-only'; // New prop to determine user access
}
const mockProgrammes: Programme[] = [
{
programmeId: 'prog-001',
title: 'Leadership Development Program',
status: 'Active',
coursesCount: 8,
contentCount: 24,
assignment: {
startDate: new Date('2024-01-15'),
endDate: new Date('2024-06-30')
},
learnersAssigned: 45,
type: 'programme'
},
{
programmeId: 'prog-002',
title: 'Technical Skills Bootcamp',
status: 'Active',
coursesCount: 12,
contentCount: 36,
assignment: {
startDate: new Date('2024-02-01'),
endDate: new Date('2024-08-31')
},
learnersAssigned: 38,
type: 'programme'
},
{
programmeId: 'prog-003',
title: 'Communication Excellence',
status: 'Upcoming',
coursesCount: 6,
contentCount: 18,
assignment: {
startDate: new Date('2024-03-01'),
endDate: new Date('2024-05-31')
},
learnersAssigned: 28,
type: 'programme'
},
{
programmeId: 'prog-004',
title: 'Project Management Certification',
status: 'Active',
coursesCount: 10,
contentCount: 30,
assignment: {
startDate: new Date('2024-01-01'),
endDate: new Date('2024-12-31')
},
learnersAssigned: 52,
type: 'programme'
},
{
programmeId: 'prog-005',
title: 'Digital Marketing Mastery',
status: 'Completed',
coursesCount: 5,
contentCount: 15,
assignment: {
startDate: new Date('2023-09-01'),
endDate: new Date('2023-12-31')
},
learnersAssigned: 32,
type: 'programme'
},
{
programmeId: 'prog-006',
title: 'Data Analytics Fundamentals',
status: 'Completed',
coursesCount: 7,
contentCount: 21,
assignment: {
startDate: new Date('2023-10-15'),
endDate: new Date('2024-01-15')
},
learnersAssigned: 29,
type: 'programme'
}
];
const mockCourses: Programme[] = [
{
programmeId: 'course-001',
title: 'Strategic Thinking Course',
status: 'Active',
coursesCount: 1,
contentCount: 8,
assignment: {
startDate: new Date('2024-01-15'),
endDate: new Date('2024-03-15')
},
learnersAssigned: 15,
type: 'course'
},
{
programmeId: 'course-002',
title: 'Data Analysis Fundamentals',
status: 'Active',
coursesCount: 1,
contentCount: 12,
assignment: {
startDate: new Date('2024-02-01'),
endDate: new Date('2024-04-30')
},
learnersAssigned: 22,
type: 'course'
},
{
programmeId: 'course-003',
title: 'Public Speaking Mastery',
status: 'Upcoming',
coursesCount: 1,
contentCount: 6,
assignment: {
startDate: new Date('2024-03-01'),
endDate: new Date('2024-04-15')
},
learnersAssigned: 18,
type: 'course'
},
{
programmeId: 'course-004',
title: 'Agile Methodology Workshop',
status: 'Active',
coursesCount: 1,
contentCount: 10,
assignment: {
startDate: new Date('2024-01-01'),
endDate: new Date('2024-06-30')
},
learnersAssigned: 25,
type: 'course'
},
{
programmeId: 'course-005',
title: 'Excel Advanced Techniques',
status: 'Completed',
coursesCount: 1,
contentCount: 5,
assignment: {
startDate: new Date('2023-11-01'),
endDate: new Date('2023-12-31')
},
learnersAssigned: 12,
type: 'course'
}
];
export const ProgrammesTable: React.FC<ProgrammesTableProps> = ({
programmes,
onViewProgramme,
onViewCourse,
onAssignLearners,
onDownloadTracker,
userAccessLevel = 'full'
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('Active');
const [currentPage, setCurrentPage] = useState(1);
const [isAssignModalOpen, setIsAssignModalOpen] = useState(false);
const [selectedProgramme, setSelectedProgramme] = useState<Programme | null>(null);
const [isExporting, setIsExporting] = useState(false);
const itemsPerPage = 5;
// Determine which data to show based on user access level
const defaultData = userAccessLevel === 'course-only' ? mockCourses : mockProgrammes;
const displayData = programmes || defaultData;
const filteredProgrammes = displayData.filter(prog => {
const matchesSearch = prog.title.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || prog.status === statusFilter;
return matchesSearch && matchesStatus;
});
// Pagination logic
const totalPages = Math.ceil(filteredProgrammes.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedProgrammes = filteredProgrammes.slice(startIndex, endIndex);
// Reset to first page when filters change
React.useEffect(() => {
setCurrentPage(1);
}, [searchTerm, statusFilter]);
const formatDateRange = (assignment: Assignment) => {
const startDate = assignment.startDate.toLocaleDateString('en-AU', {
day: '2-digit',
month: 'short',
year: 'numeric'
});
const endDate = assignment.endDate.toLocaleDateString('en-AU', {
day: '2-digit',
month: 'short',
year: 'numeric'
});
return `${startDate}${endDate}`;
};
const handleViewItem = (item: Programme) => {
if (item.type === 'course') {
onViewCourse?.(item.programmeId);
console.log(`Viewing course: ${item.programmeId}`);
} else {
onViewProgramme?.(item.programmeId);
console.log(`Viewing programme: ${item.programmeId}`);
}
};
const handleAssignLearners = (programme: Programme) => {
setSelectedProgramme(programme);
setIsAssignModalOpen(true);
onAssignLearners?.(programme.programmeId);
};
const handleDownloadTracker = async (programmeId: string) => {
setIsExporting(true);
await new Promise(resolve => setTimeout(resolve, 2000));
setIsExporting(false);
onDownloadTracker?.(programmeId);
console.log(`Downloaded tracker for programme: ${programmeId}`);
};
const getStatusBadgeProps = (status: Programme['status']) => {
switch (status) {
case 'Active':
return { variant: 'default' as const, className: 'bg-status-success text-status-success-foreground' };
case 'Upcoming':
return { variant: 'secondary' as const, className: 'bg-status-warn text-status-warn-foreground' };
case 'Completed':
return { variant: 'outline' as const, className: 'bg-muted text-muted-foreground' };
default:
return { variant: 'secondary' as const };
}
};
return (
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<CardTitle>{userAccessLevel === 'course-only' ? 'Courses' : 'Programmes'}</CardTitle>
<CardDescription>
{userAccessLevel === 'course-only'
? 'Manage course assignments and track progress'
: 'Manage programme assignments and track progress'
}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={userAccessLevel === 'course-only' ? 'Search courses...' : 'Search programmes...'}
className="pl-10 w-[200px]"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
aria-label={userAccessLevel === 'course-only' ? 'Search courses by title' : 'Search programmes by title'}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="Active">Active</SelectItem>
<SelectItem value="Upcoming">Upcoming</SelectItem>
<SelectItem value="Completed">Completed</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border" style={{ maxWidth: '1200px' }}>
<Table>
<TableHeader className="sticky-header">
<TableRow>
<TableHead className="w-[300px]">
{userAccessLevel === 'course-only' ? 'Course' : 'Programme/Course'}
</TableHead>
<TableHead className="w-[150px]">
{userAccessLevel === 'course-only' ? 'Content' : 'Courses / Content'}
</TableHead>
<TableHead className="w-[200px]">Start End</TableHead>
<TableHead className="w-[120px]">Learners Assigned</TableHead>
<TableHead className="w-[200px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedProgrammes.map((programme) => (
<TableRow key={programme.programmeId} className="min-h-[44px]">
<TableCell>
<div className="flex items-center gap-2">
<span className="font-medium">{programme.title}</span>
<Badge
{...getStatusBadgeProps(programme.status)}
aria-label={`Programme status: ${programme.status}`}
>
{programme.status}
</Badge>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<BookOpen className="h-4 w-4" />
<span>
{userAccessLevel === 'course-only'
? `${programme.contentCount} modules`
: `${programme.coursesCount}${programme.contentCount}`
}
</span>
</div>
</TableCell>
<TableCell className="text-sm">
{formatDateRange(programme.assignment)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{programme.learnersAssigned}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewItem(programme)}
className="min-tap-44"
aria-label={`View ${programme.type === 'course' ? 'course' : 'programme'} details for ${programme.title}`}
>
<Eye className="h-4 w-4" />
<span className="sr-only">View {programme.type === 'course' ? 'Course' : 'Programme'}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleAssignLearners(programme)}
className="min-tap-44"
aria-label={`Assign learners to ${programme.title}`}
>
<UserPlus className="h-4 w-4" />
<span className="sr-only">Assign Learners</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDownloadTracker(programme.programmeId)}
disabled={isExporting}
className="min-tap-44"
aria-label={`Download tracker for ${programme.title}`}
>
{isExporting ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
<span className="sr-only">Download Tracker</span>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
Showing {startIndex + 1} to {Math.min(endIndex, filteredProgrammes.length)} of {filteredProgrammes.length} {userAccessLevel === 'course-only' ? 'courses' : 'programmes'}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="min-tap-44"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="min-tap-44"
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
)}
{filteredProgrammes.length === 0 && (
<div className="text-center py-8">
<p className="text-muted-foreground">
No {userAccessLevel === 'course-only' ? 'courses' : 'programmes'} found matching your criteria.
</p>
</div>
)}
{/* Assignment Modal */}
<Dialog open={isAssignModalOpen} onOpenChange={setIsAssignModalOpen}>
<DialogContent className="sm:max-w-[600px]" role="dialog" aria-modal="true">
<DialogHeader>
<DialogTitle>
Assign Learners to {userAccessLevel === 'course-only' ? 'Course' : 'Programme'}
</DialogTitle>
<DialogDescription>
{selectedProgramme && `Assign learners to "${selectedProgramme.title}"`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="text-center py-8 text-muted-foreground">
<UserPlus className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p>Assignment wizard would be displayed here</p>
<p className="text-sm mt-2">Including org/individual selection, dates, HR contacts, and participant upload</p>
</div>
</div>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
};

View File

@@ -1,13 +1,121 @@
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<{
import React from 'react';
import { useNavigate } from 'react-router-dom';
import klcLogo from '../assets/klc-logo.png';
import {
Menu,
Bell,
User,
Settings,
LogOut,
Building2,
BookOpen,
Sun,
Moon,
HelpCircle,
ChevronDown
} from 'lucide-react';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Avatar, AvatarFallback } from './ui/avatar';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu';
import { useAuth } from '../context/AuthContext';
interface TopNavProps {
onMenuToggle?: () => void;
showMenuButton?: boolean;
}> = ({ onMenuToggle, showMenuButton = false }) => {
onNotificationToggle?: () => void;
notificationCount?: number;
}
interface UserPreferences {
darkMode: boolean;
prefersReducedMotion: boolean;
}
// Custom hook for localStorage
const useLocalStorage = <T,>(key: string, initialValue: T): [T, (value: T) => void] => {
const [storedValue, setStoredValue] = React.useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
const setValue = (value: T) => {
try {
setStoredValue(value);
window.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error('Error saving to localStorage:', error);
}
};
return [storedValue, setValue];
};
export const TopNav: React.FC<TopNavProps> = ({
onMenuToggle,
showMenuButton = false,
onNotificationToggle,
notificationCount = 0
}) => {
const navigate = useNavigate();
const { user, logout } = useAuth();
const displayName =
user?.display_name?.trim() ||
[user?.first_name, user?.last_name].filter(Boolean).join(' ').trim() ||
'HR User';
const emailLabel = user?.email_address ?? 'hr@klc.com';
const userInitials =
displayName
.split(/\s+/)
.map((p) => p[0])
.join('')
.slice(0, 2)
.toUpperCase() || 'HR';
const [preferences, setPreferences] = useLocalStorage<UserPreferences>('userPreferences', {
darkMode: false,
prefersReducedMotion: false
});
const toggleDarkMode = () => {
const newDarkMode = !preferences.darkMode;
setPreferences({
...preferences,
darkMode: newDarkMode
});
document.documentElement.classList.toggle('dark', newDarkMode);
};
const handleSignOut = () => {
logout();
};
const handleProfileClick = () => {
navigate('/hr/profile');
};
const handleSettingsClick = () => {
navigate('/hr/settings');
};
const handleHelpClick = () => {
window.open('/help', '_blank');
};
const handleSwitchMode = (mode: 'hr' | 'learning') => {
if (mode === 'learning') {
navigate('/learning/dashboard');
} else {
navigate('/hr/dashboard');
}
};
return (
<header className="h-16 bg-background border-b border-chrome-divider flex items-center justify-between px-4 lg:px-6">
<header className="h-16 bg-background border-b border-chrome-divider flex items-center justify-between px-4 lg:px-6 sticky top-0 z-50">
{/* Left Section */}
<div className="flex items-center gap-4">
{showMenuButton && (
<Button
@@ -16,27 +124,194 @@ export const TopNav: React.FC<{
onClick={onMenuToggle}
className="lg:hidden min-tap-44"
aria-label="Toggle navigation menu"
aria-expanded="false"
>
<Menu className="h-5 w-5" />
</Button>
)}
<div className="flex items-center gap-2">
{/* Logo and Brand */}
<div className="flex items-center gap-3">
<img
src={logo}
alt="Logo"
className="h-8 md:h-12 lg:h-14 w-auto object-contain"
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">
<Button variant="ghost" size="icon" className="min-tap-44" aria-label="Notifications">
{/* Notifications */}
<Button
variant="ghost"
size="icon"
className="min-tap-44 relative"
aria-label="Notifications"
onClick={onNotificationToggle}
>
<Bell className="h-4 w-4" />
{notificationCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center text-xs"
aria-label={`${notificationCount} unread notifications`}
>
{notificationCount > 9 ? '9+' : notificationCount}
</Badge>
)}
</Button>
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
<span className="text-xs font-medium">HR</span>
{/* Theme Toggle */}
<Button
variant="ghost"
size="icon"
onClick={toggleDarkMode}
className="min-tap-44 hidden sm:flex"
aria-label={preferences.darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
>
{preferences.darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
{/* Profile Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex items-center gap-2 px-2 min-tap-44 hover:bg-accent"
aria-label="User menu"
>
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-brand-navy text-white">
{userInitials}
</AvatarFallback>
</Avatar>
<div className="hidden md:block text-left">
<p className="text-sm font-medium">{displayName}</p>
<p className="text-xs text-muted-foreground">{emailLabel}</p>
</div>
<ChevronDown className="h-4 w-4 text-muted-foreground hidden sm:block" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
{/* User Profile Section */}
<div className="flex items-center gap-3 p-4 bg-muted/20">
<Avatar className="h-12 w-12">
<AvatarFallback className="bg-brand-navy text-white text-lg">
{userInitials}
</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold">{displayName}</p>
<p className="text-sm text-muted-foreground">{emailLabel}</p>
<Badge variant="outline" className="mt-1 text-xs">
Administrator
</Badge>
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 gap-2 p-3 border-b">
<div className="text-center p-2 bg-muted/30 rounded">
<p className="text-xs text-muted-foreground">Active Learners</p>
<p className="text-lg font-semibold">247</p>
</div>
<div className="text-center p-2 bg-muted/30 rounded">
<p className="text-xs text-muted-foreground">Programmes</p>
<p className="text-lg font-semibold">12</p>
</div>
</div>
{/* Switch Mode Section */}
<div className="p-3 border-b">
<p className="text-xs font-medium text-muted-foreground mb-2">SWITCH MODE</p>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => handleSwitchMode('learning')}
>
<BookOpen className="mr-2 h-4 w-4" />
Learning
</Button>
<Button
size="sm"
className="flex-1 bg-brand-navy text-white hover:bg-brand-navy/90"
onClick={() => handleSwitchMode('hr')}
>
<Building2 className="mr-2 h-4 w-4" />
HR Mode
</Button>
</div>
</div>
{/* Menu Items */}
<div className="p-2">
<DropdownMenuItem onClick={handleProfileClick} className="p-3 cursor-pointer">
<User className="mr-3 h-4 w-4" />
<div>
<p className="font-medium">My Profile</p>
<p className="text-xs text-muted-foreground">View and edit your profile</p>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSettingsClick} className="p-3 cursor-pointer">
<Settings className="mr-3 h-4 w-4" />
<div>
<p className="font-medium">Settings</p>
<p className="text-xs text-muted-foreground">Manage your preferences</p>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleHelpClick} className="p-3 cursor-pointer">
<HelpCircle className="mr-3 h-4 w-4" />
<div>
<p className="font-medium">Help & Support</p>
<p className="text-xs text-muted-foreground">Get help and documentation</p>
</div>
</DropdownMenuItem>
</div>
<DropdownMenuSeparator />
{/* Sign out */}
<DropdownMenuItem
onClick={handleSignOut}
className="p-3 text-status-error focus:bg-status-error/10 focus:text-status-error cursor-pointer"
>
<LogOut className="mr-3 h-4 w-4" />
<div>
<p className="font-medium">Sign out</p>
<p className="text-xs text-muted-foreground">End your session</p>
</div>
</DropdownMenuItem>
{/* Footer */}
<div className="p-3 text-center text-xs text-muted-foreground border-t">
<p>Version 2.0.0</p>
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Mobile Theme Toggle (visible only on small screens) */}
<Button
variant="ghost"
size="icon"
onClick={toggleDarkMode}
className="min-tap-44 sm:hidden"
aria-label={preferences.darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
>
{preferences.darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
</header>
);
};
export default TopNav;

View File

@@ -1,73 +0,0 @@
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>&copy; 2024 Knowledge Learning Centre. All rights reserved.</p>
</footer>
</main>
</div>
</div>
{/* ChatBot */}
<ChatBot currentScreen={currentScreen} />
</div>
);
}

View File

@@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { Button } from '../ui/button';
import { MessageSquare, X } from 'lucide-react';
interface ChatBotProps {
currentScreen?: string;
}
export const ChatBot: React.FC<ChatBotProps> = ({ currentScreen }) => {
const [isOpen, setIsOpen] = useState(false);
const getChipsForScreen = (screen?: string) => {
if (screen === 'profile') {
return [
"How do I submit a testimonial?",
"When will my testimonial be reviewed?",
"Can I edit my testimonial?",
"What makes a good testimonial?"
];
}
return [
"How do I upload a roster?",
"How to assign courses?",
"View progress reports",
"Export learner data"
];
};
const chips = getChipsForScreen(currentScreen);
return (
<div className="fixed bottom-6 right-6 z-40">
{isOpen && (
<div className="mb-4 bg-card border border-chrome-divider rounded-lg shadow-lg p-4 w-80 animate-slide-up">
<div className="flex justify-between items-center mb-3">
<h3 className="font-semibold">HR Assistant</h3>
<Button
variant="ghost"
size="icon"
onClick={() => setIsOpen(false)}
className="h-6 w-6"
aria-label="Close chat"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
{chips.map((chip, index) => (
<button
key={index}
className="w-full text-left p-2 text-sm bg-muted hover:bg-accent rounded-md transition-colors min-tap-44"
>
{chip}
</button>
))}
</div>
</div>
)}
<Button
onClick={() => setIsOpen(!isOpen)}
className="rounded-full h-12 w-12 shadow-lg min-tap-44"
aria-label="Open HR chat assistant"
aria-expanded={isOpen}
>
<MessageSquare className="h-5 w-5" />
</Button>
</div>
);
};

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { X, Bell, Clock } from 'lucide-react';
import { Announcement } from '../../types';
interface AnnouncementsPanelProps {
isOpen: boolean;
onClose: () => void;
announcements: Announcement[];
onMarkAsRead?: (id: string) => void;
}
export const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({
isOpen,
onClose,
announcements,
onMarkAsRead
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-y-0 right-0 w-80 bg-background border-l border-chrome-divider shadow-lg z-50 flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-chrome-divider">
<h3 className="font-semibold">Announcements & Reminders</h3>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8"
aria-label="Close announcements panel"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{announcements.map((item) => (
<div
key={item.id}
className={`
p-3 rounded-lg border transition-all duration-200 cursor-pointer hover:bg-muted/50
${item.pinned ? 'bg-status-warn/10 border-status-warn/20' : 'bg-card border-chrome-divider'}
`}
onClick={() => onMarkAsRead?.(item.id)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<div className={`p-1 rounded ${item.type === 'announcement' ? 'bg-brand-primary' : 'bg-status-warn'}`}>
{item.type === 'announcement' ?
<Bell className="h-3 w-3 text-white" /> :
<Clock className="h-3 w-3 text-white" />
}
</div>
{item.pinned && (
<Badge variant="secondary" className="text-xs">Pinned</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">{item.timestamp}</span>
</div>
<h4 className="font-medium text-sm mb-1">{item.title}</h4>
<p className="text-xs text-muted-foreground">{item.content}</p>
<div className="flex items-center justify-between mt-2">
<Badge variant="outline" className="text-xs capitalize">
{item.type}
</Badge>
<Button variant="ghost" size="sm" className="h-auto p-1 text-xs">
Mark as read
</Button>
</div>
</div>
))}
</div>
<div className="p-4 border-t border-chrome-divider">
<Button variant="outline" className="w-full text-sm">
View All Notifications
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,17 @@
import { toast } from 'sonner';
export type ToastVariant = 'success' | 'error' | 'info';
export function useToast() {
const showToast = (title: string, description: string, variant: ToastVariant = 'info') => {
if (variant === 'success') {
toast.success(title, { description });
} else if (variant === 'error') {
toast.error(title, { description });
} else {
toast(title, { description });
}
};
return { showToast, toast };
}

View File

@@ -1,103 +0,0 @@
/* 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;
}
}

View File

@@ -34,25 +34,25 @@ const buttonVariants = cva(
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
const Button = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
}
>(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
});
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -1,46 +1,13 @@
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(
// "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],
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border",
className,
)}
{...props}
@@ -49,42 +16,11 @@ 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",
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",
"@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",
className,
)}
{...props}
@@ -93,60 +29,20 @@ 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 font-semibold", responsiveFont[screenSize as keyof typeof responsiveFont], className)}
className={cn("leading-none", 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", responsiveFont[screenSize as keyof typeof responsiveFont], className)}
className={cn("text-muted-foreground", className)}
{...props}
/>
);
@@ -166,65 +62,20 @@ 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(responsivePadding[screenSize as keyof typeof responsivePadding], "[&:last-child]:pb-6", className)}
className={cn("px-6 [&: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",
responsivePadding[screenSize as keyof typeof responsivePadding],
"[.border-t]:pt-6",
className,
)}
className={cn("flex items-center px-6 pb-6 [.border-t]:pt-6", className)}
{...props}
/>
);

View File

@@ -1,7 +1,7 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu@1.2.5";
import { cva } from "class-variance-authority@0.7.1";
import { ChevronDownIcon } from "lucide-react@0.487.0";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "./utils";

View File

@@ -1,20 +1,33 @@
"use client";
import { useTheme } from "next-themes@0.4.6";
import { Toaster as Sonner, ToasterProps } from "sonner@2.0.3";
import * as React from 'react';
import { Toaster as Sonner, type ToasterProps } from 'sonner';
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
const [theme, setTheme] = React.useState<ToasterProps['theme']>(() =>
typeof document !== 'undefined' &&
document.documentElement.classList.contains('dark')
? 'dark'
: 'light',
);
React.useEffect(() => {
const el = document.documentElement;
const update = () =>
setTheme(el.classList.contains('dark') ? 'dark' : 'light');
update();
const observer = new MutationObserver(update);
observer.observe(el, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
return (
<Sonner
theme={theme as ToasterProps["theme"]}
theme={theme}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
} as React.CSSProperties
}
{...props}

136
src/context/AuthContext.tsx Normal file
View File

@@ -0,0 +1,136 @@
// src/context/AuthContext.tsx
import { createContext, useContext, useState, useEffect } from "react";
import type { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { useToast } from "../components/toast/useToast";
import { useLoginMutation, UserInfo } from "../redux/services/loginApi";
interface AuthContextType {
user: UserInfo | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
token: string | null;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<UserInfo | null>(() => {
const storedUser = localStorage.getItem("user");
if (storedUser) {
try {
// Try to parse as JSON first
return JSON.parse(storedUser);
} catch {
// If it fails (like "admin" string), return null or handle accordingly
return null;
}
}
return null;
});
const [token, setToken] = useState<string | null>(() => {
const storedToken = localStorage.getItem("token");
return storedToken || null;
});
const navigate = useNavigate();
const { showToast } = useToast();
const [loginMutation] = useLoginMutation();
const login = async (email: string, password: string) => {
try {
const response = await loginMutation({
email_address: email,
password
}).unwrap();
if (response.success) {
// Store token and user info
localStorage.setItem("token", response.data.access_token);
localStorage.setItem("user", JSON.stringify(response.data.user_info));
setToken(response.data.access_token);
setUser(response.data.user_info);
showToast(
"Login Successful",
response.message || "You have been logged in successfully.",
"success"
);
navigate("/hr/dashboard");
} else {
showToast(
"Login Failed",
response.message || "Invalid credentials. Please try again.",
"error"
);
}
} catch (error: any) {
console.error("Login error:", error);
showToast(
"Error",
error.data?.message || error.message || "An error occurred during login",
"error"
);
}
};
const logout = () => {
localStorage.removeItem("token");
localStorage.removeItem("user");
setToken(null);
setUser(null);
showToast(
"Logged Out",
"You have been logged out successfully.",
"success"
);
navigate("/login");
};
const isAuthenticated = !!token && !!user;
// Optional: Add token expiration check
useEffect(() => {
if (token) {
try {
// Decode token to check expiration (if needed)
const payload = JSON.parse(atob(token.split('.')[1]));
const exp = payload.exp * 1000; // Convert to milliseconds
const now = Date.now();
if (now >= exp) {
// Token expired
logout();
showToast(
"Session Expired",
"Your session has expired. Please login again.",
"error"
);
}
} catch (error) {
// Invalid token format
console.error("Error checking token expiration:", error);
}
}
}, [token]);
return (
<AuthContext.Provider value={{ user, login, logout, isAuthenticated, token }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx;
};

25
src/global.d.ts vendored
View File

@@ -1,26 +1,29 @@
// declarations.d.ts
declare module "*.png" {
declare module '*.webp' {
const src: string;
export default src;
}
declare module "*.jpg" {
declare module '*.png' {
const src: string;
export default src;
}
declare module "*.jpeg" {
declare module '*.jpg' {
const src: string;
export default src;
}
declare module "*.svg" {
import * as React from "react";
const ReactComponent: React.FunctionComponent<
React.SVGProps<SVGSVGElement> & { title?: string }
>;
export { ReactComponent };
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}
declare module '*.gif' {
const src: string;
export default src;
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useState, useEffect } from 'react';
export const useCountUp = (end: number, duration: number = 1200) => {
export function useCountUp(end: number, duration: number = 1200) {
const [count, setCount] = useState(0);
useEffect(() => {
@@ -20,6 +20,4 @@ export const useCountUp = (end: number, duration: number = 1200) => {
}, [end, duration]);
return count;
};
}

View File

@@ -1,11 +1,12 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
export const useLocalStorage = <T,>(key: string, initialValue: T) => {
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error('Error reading from localStorage:', error);
return initialValue;
}
});
@@ -19,7 +20,5 @@ export const useLocalStorage = <T,>(key: string, initialValue: T) => {
}
};
return [storedValue, setValue] as const;
};
return [storedValue, setValue];
}

2
src/index.css Normal file
View File

@@ -0,0 +1,2 @@
@import "tailwindcss";
@import "./styles/globals.css";

105
src/layouts/HRLayout.tsx Normal file
View File

@@ -0,0 +1,105 @@
import React, { useState, useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { BreadcrumbNav } from './components/BreadcrumbNav';
import { ChatBot } from '../components/shared/ChatBot';
import { mockAnnouncements } from '../utils/mockData';
import { useLocalStorage } from '../hooks/useLocalStorage';
import TopNav from '../components/TopNav';
import { HRSidebar } from './components/HRSidebar';
import { AnnouncementsPanel } from '../components/shared/KPICard';
const HRLayout: React.FC = () => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [announcementsOpen, setAnnouncementsOpen] = useState(false);
const [isDark] = useLocalStorage('darkMode', false);
// Apply theme
useEffect(() => {
document.documentElement.classList.toggle('dark', isDark);
}, [isDark]);
const handleNotificationToggle = () => {
setAnnouncementsOpen(!announcementsOpen);
};
const handleMarkAsRead = (id: string) => {
console.log(`Marked notification ${id} as read`);
// In a real app, you would call an API to mark as read
};
return (
<div className="h-screen overflow-hidden bg-background flex flex-col">
{/* Skip to main content link for accessibility */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-primary text-primary-foreground p-2 rounded z-50"
>
Skip to main content
</a>
{/* Top Navigation */}
<TopNav
onMenuToggle={() => setSidebarOpen(!sidebarOpen)}
showMenuButton={true}
onNotificationToggle={handleNotificationToggle}
notificationCount={mockAnnouncements.length}
/>
<div className="flex flex-1 relative overflow-hidden">
{/* Desktop Sidebar */}
<HRSidebar className="hidden lg:flex lg:flex-shrink-0" />
{/* Mobile Sidebar Overlay */}
{sidebarOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
{/* Backdrop */}
<div
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
onClick={() => setSidebarOpen(false)}
aria-hidden="true"
/>
{/* Sidebar */}
<div className="absolute left-0 top-0 h-full">
<HRSidebar
onNavigate={() => setSidebarOpen(false)}
/>
</div>
</div>
)}
{/* Main Content */}
<main
id="main-content"
className={`flex-1 cs-height overflow-y-auto p-4 lg:p-8 transition-all duration-300 ${announcementsOpen ? 'lg:mr-80' : ''
}`}
>
<div className="max-w-7xl mx-auto">
{/* <div className="sticky top-0 bg-background z-10 pb-2">
</div> */}
<BreadcrumbNav />
<Outlet />
</div>
</main>
</div>
{/* Announcements Panel */}
<AnnouncementsPanel
isOpen={announcementsOpen}
onClose={() => setAnnouncementsOpen(false)}
announcements={mockAnnouncements}
onMarkAsRead={handleMarkAsRead}
/>
{/* Chat Bot FAB */}
<ChatBot />
{/* Footer */}
<footer className="bg-muted border-t border-chrome-divider p-4 text-center text-sm text-muted-foreground">
<p>&copy; {new Date().getFullYear()} Knowledge Learning Centre. All rights reserved.</p>
</footer>
</div>
);
};
export default HRLayout;

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '../../components/ui/breadcrumb';
export const BreadcrumbNav: React.FC = () => {
const location = useLocation();
const pathnames = location.pathname.split('/').filter(x => x);
const getBreadcrumbName = (path: string) => {
switch (path) {
case 'hr': return 'HR Portal';
case 'dashboard': return 'Dashboard';
case 'learners': return 'Learners';
case 'reports': return 'Reports';
case 'discussions': return 'Discussion Forums';
case 'programme': return 'Programme Details';
case 'course': return 'Course Details';
case 'profile': return 'Profile';
default: return path;
}
};
return (
<Breadcrumb className="mb-6">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/hr">HR Portal</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{pathnames.map((name, index) => {
const routeTo = `/${pathnames.slice(0, index + 1).join('/')}`;
const isLast = index === pathnames.length - 1;
if (name === 'hr') return null;
return (
<React.Fragment key={name}>
<BreadcrumbSeparator aria-hidden="true" />
<BreadcrumbItem>
{isLast ? (
<BreadcrumbPage>{getBreadcrumbName(name)}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link to={routeTo}>{getBreadcrumbName(name)}</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
</React.Fragment>
);
})}
</BreadcrumbList>
</Breadcrumb>
);
};

View File

@@ -0,0 +1,83 @@
import {
BarChart3,
BookOpen,
FolderOpen,
Home,
MessageSquare,
Users
} from 'lucide-react';
import React from 'react';
import { NavLink } from 'react-router-dom';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { useAuth } from '../../context/AuthContext';
const menuItems = [
{ id: 'dashboard', label: 'Dashboard', icon: Home, path: '/hr/dashboard' },
{ id: 'learners', label: 'Learners', icon: Users, path: '/hr/learners' },
{ id: 'courses', label: 'Courses', icon: BookOpen, path: '/hr/courses' },
{ id: 'programmes', label: 'Programmes', icon: FolderOpen, path: '/hr/programmes' },
{ id: 'reports', label: 'Reports', icon: BarChart3, path: '/hr/reports' },
{ id: 'discussions', label: 'Discussion Forums', icon: MessageSquare, path: '/hr/discussions' }
];
interface HRSidebarProps {
className?: string;
onNavigate?: () => void;
}
export const HRSidebar: React.FC<HRSidebarProps> = ({ className = '', onNavigate }) => {
const [prefersReducedMotion] = useLocalStorage('prefersReducedMotion', false);
const { user } = useAuth();
const orgName = user?.principal_organization_name?.trim() || 'Tech Solutions Pvt Ltd';
const orgInitials =
orgName
.split(/\s+/)
.filter(Boolean)
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase() || 'TS';
return (
<div className={`w-64 min-w-[248px] cs-height bg-sidebar flex flex-col ${className}`}>
<div className="p-6">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-brand-charcoal rounded-md flex items-center justify-center">
<span className="text-brand-charcoal-foreground font-bold text-sm">{orgInitials}</span>
</div>
<span className="font-semibold text-sidebar-foreground">{orgName}</span>
</div>
</div>
<nav className="flex-1 p-4" role="navigation" aria-label="HR Portal Navigation">
<ul className="space-y-1">
{menuItems.map((item) => {
const Icon = item.icon;
return (
<li key={item.id}>
<NavLink
to={item.path}
onClick={onNavigate}
className={({ isActive }) => `
w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm min-tap-44
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-sidebar-ring focus:ring-offset-2 focus:ring-offset-sidebar
${isActive
? 'bg-sidebar-primary text-sidebar-primary-foreground shadow-sm'
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
}
${prefersReducedMotion ? '' : 'animate-scale-hover'}
`}
aria-label={`Navigate to ${item.label}`}
>
<Icon className="h-4 w-4" />
{item.label}
</NavLink>
</li>
);
})}
</ul>
</nav>
</div>
);
};

View File

@@ -1,15 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import App from './App';
import { store } from './redux/store';
import './index.css';
import { createRoot } from "react-dom/client";
import { 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(
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
</React.StrictMode>
);

View File

@@ -1,37 +0,0 @@
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' }
];

View File

@@ -1,218 +0,0 @@
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>
);
};

View File

@@ -0,0 +1,503 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button } from '../../components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
import { Progress } from '../../components/ui/progress';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
import { Avatar, AvatarFallback } from '../../components/ui/avatar';
import {
ArrowLeft,
BookOpen,
Clock,
Award,
Download,
Users,
Video,
FileText,
CheckCircle,
AlertCircle,
PlayCircle,
File,
Link as LinkIcon,
MessageSquare,
Star,
ThumbsUp,
Share2,
Bookmark,
ChevronRight,
Menu
} from 'lucide-react';
interface Course {
id: string;
title: string;
programmeId: string;
programmeName: string;
description: string;
instructor: {
name: string;
title: string;
avatar?: string;
};
duration: string;
enrolledCount: number;
rating: number;
progress: number;
status: 'Not Started' | 'In Progress' | 'Completed';
modules: CourseModule[];
resources: Resource[];
discussions: Discussion[];
}
interface CourseModule {
id: string;
title: string;
duration: string;
type: 'video' | 'reading' | 'quiz' | 'assignment';
status: 'locked' | 'available' | 'completed';
content?: string;
videoUrl?: string;
}
interface Resource {
id: string;
title: string;
type: 'pdf' | 'video' | 'link' | 'document';
url: string;
}
interface Discussion {
id: string;
user: {
name: string;
avatar?: string;
};
content: string;
timestamp: string;
likes: number;
replies: number;
}
// Mock data
const mockCourse: Course = {
id: '1',
title: 'Strategic Thinking for Leaders',
programmeId: 'p1',
programmeName: 'Leadership Development Program',
description: 'Learn how to develop strategic thinking capabilities, analyze complex business situations, and make decisions that drive organizational success.',
instructor: {
name: 'Prof. Michael Chen',
title: 'Senior Leadership Coach',
},
duration: '4 weeks',
enrolledCount: 42,
rating: 4.8,
progress: 65,
status: 'In Progress',
modules: [
{
id: 'm1',
title: 'Introduction to Strategic Thinking',
duration: '45 min',
type: 'video',
status: 'completed',
videoUrl: '#'
},
{
id: 'm2',
title: 'Strategic Analysis Frameworks',
duration: '60 min',
type: 'video',
status: 'completed',
videoUrl: '#'
},
{
id: 'm3',
title: 'Decision Making Models',
duration: '90 min',
type: 'reading',
status: 'available'
},
{
id: 'm4',
title: 'Case Study Analysis',
duration: '120 min',
type: 'assignment',
status: 'available'
},
{
id: 'm5',
title: 'Strategic Planning Quiz',
duration: '30 min',
type: 'quiz',
status: 'locked'
}
],
resources: [
{ id: 'r1', title: 'Strategic Analysis Template', type: 'document', url: '#' },
{ id: 'r2', title: 'Decision Matrix Worksheet', type: 'pdf', url: '#' },
{ id: 'r3', title: 'Case Study Materials', type: 'pdf', url: '#' }
],
discussions: [
{
id: 'd1',
user: { name: 'Sarah Chen' },
content: 'The SWOT analysis framework was really helpful. I\'ve already started applying it to my projects.',
timestamp: '2 hours ago',
likes: 12,
replies: 3
},
{
id: 'd2',
user: { name: 'David Kim' },
content: 'Can someone explain the difference between strategic and operational decisions?',
timestamp: '5 hours ago',
likes: 5,
replies: 8
}
]
};
const CourseViewPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [course, setCourse] = useState<Course | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('overview');
const [selectedModule, setSelectedModule] = useState<CourseModule | null>(null);
useEffect(() => {
// Simulate API call
const timer = setTimeout(() => {
setCourse(mockCourse);
setSelectedModule(mockCourse.modules[0]);
setLoading(false);
}, 500);
return () => clearTimeout(timer);
}, [id]);
if (loading) {
return (
<div className="space-y-6">
<div className="h-8 w-64 bg-muted animate-pulse rounded" />
<div className="h-32 bg-muted animate-pulse rounded" />
<div className="h-64 bg-muted animate-pulse rounded" />
</div>
);
}
if (!course) {
return (
<div className="text-center py-12">
<AlertCircle className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h2 className="text-xl font-semibold mb-2">Course not found</h2>
<p className="text-muted-foreground mb-4">The course you're looking for doesn't exist.</p>
<Button onClick={() => navigate('/dashboard')}>Back to Dashboard</Button>
</div>
);
}
const getModuleIcon = (type: string) => {
switch (type) {
case 'video': return <Video className="h-5 w-5" />;
case 'reading': return <FileText className="h-5 w-5" />;
case 'quiz': return <Award className="h-5 w-5" />;
case 'assignment': return <BookOpen className="h-5 w-5" />;
default: return <File className="h-5 w-5" />;
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/programme/${course.programmeId}`)}
className="min-tap-44"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Programme
</Button>
<div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{course.programmeName}</span>
<ChevronRight className="h-4 w-4" />
<span className="text-foreground">{course.title}</span>
</div>
<h1 className="text-2xl font-bold mt-1">{course.title}</h1>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm">
<Bookmark className="h-4 w-4 mr-2" />
Save
</Button>
<Button variant="outline" size="sm">
<Share2 className="h-4 w-4 mr-2" />
Share
</Button>
</div>
</div>
{/* Course Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Your Progress</p>
<p className="text-2xl font-bold">{course.progress}%</p>
</div>
<Progress value={course.progress} className="w-16 h-16" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Duration</p>
<p className="text-2xl font-bold">{course.duration}</p>
</div>
<Clock className="h-8 w-8 text-blue-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Enrolled</p>
<p className="text-2xl font-bold">{course.enrolledCount}</p>
</div>
<Users className="h-8 w-8 text-green-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Rating</p>
<p className="text-2xl font-bold">{course.rating}/5.0</p>
</div>
<Star className="h-8 w-8 text-yellow-500 fill-current" />
</div>
</CardContent>
</Card>
</div>
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Course Content */}
<div className="lg:col-span-2 space-y-6">
{/* Course Info */}
<Card>
<CardHeader>
<CardTitle>About this Course</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{course.description}</p>
<div className="flex items-center gap-4 mt-4 pt-4 border-t">
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>
{course.instructor.name.split(' ').map(n => n[0]).join('')}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{course.instructor.name}</p>
<p className="text-sm text-muted-foreground">{course.instructor.title}</p>
</div>
</div>
<Badge variant="outline">{course.status}</Badge>
</div>
</CardContent>
</Card>
{/* Course Modules */}
<Card>
<CardHeader>
<CardTitle>Course Modules</CardTitle>
<CardDescription>{course.modules.length} modules {course.duration} total</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{course.modules.map((module, index) => (
<div
key={module.id}
className={`p-4 rounded-lg border cursor-pointer transition-all ${
selectedModule?.id === module.id
? 'border-primary bg-primary/5'
: 'hover:border-primary/50'
}`}
onClick={() => setSelectedModule(module)}
>
<div className="flex items-start gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
module.status === 'completed'
? 'bg-green-100 text-green-600'
: module.status === 'available'
? 'bg-blue-100 text-blue-600'
: 'bg-gray-100 text-gray-400'
}`}>
{module.status === 'completed' ? (
<CheckCircle className="h-5 w-5" />
) : (
getModuleIcon(module.type)
)}
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<h4 className="font-medium">{module.title}</h4>
<Badge variant={module.status === 'locked' ? 'outline' : 'secondary'}>
{module.status}
</Badge>
</div>
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{module.duration}
</span>
<span></span>
<span className="capitalize">{module.type}</span>
</div>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Discussions Preview */}
<Card>
<CardHeader>
<CardTitle>Recent Discussions</CardTitle>
<CardDescription>Join the conversation</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{course.discussions.map(discussion => (
<div key={discussion.id} className="border-b last:border-0 pb-4 last:pb-0">
<div className="flex items-start gap-3">
<Avatar className="w-8 h-8">
<AvatarFallback>
{discussion.user.name.split(' ').map(n => n[0]).join('')}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="font-medium">{discussion.user.name}</span>
<span className="text-xs text-muted-foreground">
{discussion.timestamp}
</span>
</div>
<p className="text-sm mb-2">{discussion.content}</p>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<button className="flex items-center gap-1 hover:text-foreground">
<ThumbsUp className="h-3 w-3" />
{discussion.likes}
</button>
<button className="flex items-center gap-1 hover:text-foreground">
<MessageSquare className="h-3 w-3" />
{discussion.replies} replies
</button>
</div>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Current Module View */}
<div className="lg:col-span-1">
<Card className="sticky top-6">
<CardHeader>
<CardTitle>Current Module</CardTitle>
<CardDescription>
{selectedModule?.title}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{selectedModule && (
<>
<div className="aspect-video bg-muted rounded-lg flex items-center justify-center">
{selectedModule.type === 'video' ? (
<PlayCircle className="h-12 w-12 text-muted-foreground" />
) : (
<FileText className="h-12 w-12 text-muted-foreground" />
)}
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Type:</span>
<span className="capitalize">{selectedModule.type}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Duration:</span>
<span>{selectedModule.duration}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Status:</span>
<Badge variant={
selectedModule.status === 'completed' ? 'default' :
selectedModule.status === 'available' ? 'secondary' : 'outline'
}>
{selectedModule.status}
</Badge>
</div>
</div>
<Button
className="w-full"
disabled={selectedModule.status === 'locked'}
>
{selectedModule.status === 'completed' ? 'Review Module' :
selectedModule.status === 'available' ? 'Start Module' :
'Locked'}
</Button>
{/* Resources */}
<div className="pt-4 border-t">
<h4 className="font-medium mb-3">Resources</h4>
<div className="space-y-2">
{course.resources.map(resource => (
<a
key={resource.id}
href={resource.url}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-muted transition-colors"
>
{resource.type === 'pdf' && <FileText className="h-4 w-4" />}
{resource.type === 'video' && <Video className="h-4 w-4" />}
{resource.type === 'link' && <LinkIcon className="h-4 w-4" />}
{resource.type === 'document' && <File className="h-4 w-4" />}
<span className="text-sm flex-1">{resource.title}</span>
<Download className="h-4 w-4 text-muted-foreground" />
</a>
))}
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
};
export default CourseViewPage;

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { Calendar, Clock, Lock } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Progress } from '../../components/ui/progress';
import { useGetAssignedHrCoursesQuery } from '../../redux/services/learnersApi';
const CoursesPage: React.FC = () => {
const { data, isLoading, isError } = useGetAssignedHrCoursesQuery({
limit: 10,
start: 0,
});
const courseCards = data?.data.course_items ?? [];
const totalCourses = data?.data.pagination.total ?? 0;
return (
<div className="space-y-4">
<h1 className="text-3xl font-bold tracking-tight">My Courses</h1>
<Card className="border border-indigo-100 bg-gradient-to-br from-indigo-50 via-white to-cyan-50">
<CardHeader className="pb-3">
<CardTitle className="text-2xl text-indigo-900">Assigned Courses</CardTitle>
<p className="text-sm text-muted-foreground">{totalCourses} courses assigned by your organization.</p>
</CardHeader>
<CardContent>
{isLoading && <p className="text-sm text-muted-foreground">Loading courses...</p>}
{isError && <p className="text-sm text-red-600">Failed to load courses. Please try again.</p>}
{!isLoading && !isError && courseCards.length === 0 && (
<p className="text-sm text-muted-foreground">No courses found.</p>
)}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{courseCards.map((course) => (
<Card key={course.id} className="h-full border border-indigo-100 bg-white/95">
<CardContent className="flex h-full flex-col space-y-3 p-4">
<div className="flex items-start gap-3">
<img
src={course.thumbnail_url}
alt={course.course_name}
className="h-20 w-20 shrink-0 rounded-lg object-cover"
/>
<div className="min-w-0">
<h3 className="text-xl font-semibold leading-tight text-indigo-950">{course.course_name}</h3>
<p className="line-clamp-2 text-sm text-muted-foreground">{course.course_description}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
Duration: {course.total_duration}h
</span>
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Learners: {course.total_learners}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Status: {course.status}
</span>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Progress</span>
<span className="font-medium">{course.avg_progress}%</span>
</div>
<Progress value={course.avg_progress} className="h-2 bg-slate-200" />
</div>
<div className="mt-auto pt-1">
{course.status === 'new' ? (
<Button className="w-full bg-slate-200 text-slate-700 hover:bg-slate-300">
<Lock className="mr-2 h-4 w-4" />
Not Started
</Button>
) : (
<Button className="w-full bg-[#061a72] text-white hover:bg-[#051458]" style={{backgroundColor: '#061a72', color: 'white'}}>View Course</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
</div>
);
};
export default CoursesPage;

View File

@@ -0,0 +1,164 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { ProgrammesTable } from '../../components/ProgrammesTable';
import { ProgrammeSchedule } from '../../components/ProgrammeSchedule';
import { LearningAnalyticsTable } from '../../components/LearningAnalyticsTable';
import { DiscussionForumFeed } from '../../components/DiscussionForumFeed';
import { Skeleton } from '../../components/ui/skeleton';
import { Plus, BookOpen, Download, MessageSquare } from 'lucide-react';
import { mockKPIData } from '../../utils/mockData';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { AnnouncementsPanel } from '../../components/shared/KPICard';
const DashboardPage: React.FC = () => {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [prefersReducedMotion] = useLocalStorage('prefersReducedMotion', false);
useEffect(() => {
const timer = setTimeout(() => setLoading(false), 800);
return () => clearTimeout(timer);
}, []);
// Helper function to determine user access level
const getUserAccessLevel = (): 'full' | 'course-only' => {
return 'full'; // Default to full access
};
const handleViewProgramme = (programmeId: string) => {
navigate(`/hr/programme/${programmeId}`);
};
const handleViewCourse = (courseId: string) => {
navigate(`/hr/course/${courseId}`);
};
const handleNavigate = (path: string, params?: any) => {
navigate(path);
};
if (loading) {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
{/* Welcome Section */}
<div className={`space-y-4 ${prefersReducedMotion ? '' : 'animate-fade-in'}`}>
<div className="space-y-2">
<h1>Welcome Priya 👋</h1>
<p className="text-muted-foreground">Manage programmes, track progress, and stay connected with your learning community</p>
</div>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{mockKPIData.map((kpi, index) => (
<AnnouncementsPanel
key={index}
data={kpi}
onClick={() => handleNavigate('/hr/reports')}
className={prefersReducedMotion ? '' : 'animate-slide-up'}
style={{ animationDelay: `${index * 100}ms` }}
/>
))}
</div>
{/* Quick Actions Section */}
<Card className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '200ms' }}>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
<CardDescription>Common HR tasks and shortcuts</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ title: 'Add Learners', icon: Plus, action: () => handleNavigate('/hr/learners', { action: 'add' }) },
{ title: 'Assign', icon: BookOpen, action: () => handleNavigate('/hr/learners', { action: 'assign' }) },
{ title: 'Download Reports', icon: Download, action: () => handleNavigate('/hr/reports') },
{ title: 'Submit Testimonial', icon: MessageSquare, action: () => handleNavigate('/hr/profile') }
].map((link, index) => {
const Icon = link.icon;
return (
<button
key={index}
onClick={link.action}
className={`
flex flex-col items-center justify-center p-6 bg-muted/50 hover:bg-muted rounded-lg
transition-all duration-200 min-h-[120px] min-w-[120px] gap-3 min-tap-44
${prefersReducedMotion ? '' : 'animate-scale-hover'}
`}
aria-label={link.title}
>
<Icon className="h-6 w-6 text-brand-primary" />
<span className="text-sm font-medium text-center">{link.title}</span>
</button>
);
})}
</div>
</CardContent>
</Card>
{/* Programmes Table */}
<div className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '400ms' }}>
<ProgrammesTable
onViewProgramme={handleViewProgramme}
onViewCourse={handleViewCourse}
onAssignLearners={(programmeId) => console.log(`Assign learners to: ${programmeId}`)}
onDownloadTracker={(programmeId) => console.log(`Download tracker for: ${programmeId}`)}
userAccessLevel={getUserAccessLevel()}
/>
</div>
{/* Programme Schedule - Horizontal layout below programmes */}
<div className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '500ms' }}>
<ProgrammeSchedule
onEventClick={(event) => console.log(`Open event: ${event.title}`)}
/>
</div>
{/* Learning Analytics - Full Width */}
<div className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '600ms' }}>
<LearningAnalyticsTable
onViewLearner={(learnerId) => handleNavigate('/hr/learners', { editEmployee: learnerId })}
onNudgeLearner={(learnerId) => console.log(`Nudge learner: ${learnerId}`)}
onViewAllAnalytics={(programmeId) => handleNavigate('/hr/reports', { programme: programmeId })}
/>
</div>
{/* Discussion Forum Feed */}
<div className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '800ms' }}>
<DiscussionForumFeed
onOpenThread={(threadId) => handleNavigate('/hr/discussions', { thread: threadId })}
onMarkAsRead={(threadId) => console.log(`Mark as read: ${threadId}`)}
/>
</div>
</div>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,318 @@
import React, { useMemo, useState } from 'react';
import { Button } from '../../components/ui/button';
import { Card, CardContent } from '../../components/ui/card';
import { Input } from '../../components/ui/input';
import { Textarea } from '../../components/ui/textarea';
import { Badge } from '../../components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../components/ui/dialog';
import { AlertCircle, Calendar, MessageCircle, Plus, Search, User, X } from 'lucide-react';
import { useCreateThreadMutation, useGetThreadsQuery } from '../../redux/services/forumApi';
import { useToast } from '../../components/toast/useToast';
import DiscussionsView from './DiscussionsView';
const DiscussionsPage: React.FC = () => {
const { showToast } = useToast();
const [showNewThreadModal, setShowNewThreadModal] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [tagsFilter, setTagsFilter] = useState('all');
const [newThreadTitle, setNewThreadTitle] = useState('');
const [newThreadContent, setNewThreadContent] = useState('');
const [tagInput, setTagInput] = useState('');
const [newThreadTags, setNewThreadTags] = useState<string[]>([]);
const [createError, setCreateError] = useState('');
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const { data: threadsResponse, isLoading: threadsLoading, isFetching: threadsFetching } =
useGetThreadsQuery();
const [createThread, { isLoading: isCreatingThread }] = useCreateThreadMutation();
const threads = threadsResponse?.data ?? [];
const allTags = useMemo(() => {
const set = new Set<string>();
threads.forEach((thread) => {
thread.tags.forEach((tag) => set.add(tag));
});
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [threads]);
const filteredThreads = useMemo(() => {
return threads.filter((thread) => {
const query = searchTerm.trim().toLowerCase();
const matchesSearch =
!query ||
thread.title.toLowerCase().includes(query) ||
thread.content.toLowerCase().includes(query) ||
thread.tags.some((tag) => tag.toLowerCase().includes(query));
const matchesTag = tagsFilter === 'all' || thread.tags.includes(tagsFilter);
return matchesSearch && matchesTag;
});
}, [threads, searchTerm, tagsFilter]);
const selectedThread = useMemo(
() => threads.find((thread) => thread.id === selectedThreadId) ?? null,
[threads, selectedThreadId]
);
const getThreadReactionCount = (thread: (typeof threads)[number]) =>
thread.reactions.reduce((sum, r) => sum + r.count, 0);
const formatDate = (date: string) => {
const d = new Date(date);
return d.toLocaleDateString('en-GB');
};
const resetModal = () => {
setNewThreadTitle('');
setNewThreadContent('');
setTagInput('');
setNewThreadTags([]);
setCreateError('');
};
const addTag = (rawTag: string) => {
const value = rawTag.trim().toLowerCase();
if (!value) return;
if (newThreadTags.includes(value)) return;
setNewThreadTags((prev) => [...prev, value]);
};
const handleTagKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag(tagInput);
setTagInput('');
}
};
const handleCreateThread = async () => {
setCreateError('');
if (!newThreadTitle.trim() || !newThreadContent.trim()) {
setCreateError('Title and content are required.');
return;
}
try {
const response = await createThread({
title: newThreadTitle.trim(),
content: newThreadContent.trim(),
tags: newThreadTags,
}).unwrap();
showToast('Thread created', response.message || 'Thread created successfully.', 'success');
setShowNewThreadModal(false);
resetModal();
} catch (error: any) {
const message = error?.data?.message || 'Failed to create thread.';
setCreateError(message);
showToast('Create failed', message, 'error');
}
};
return (
<div className="space-y-4">
{selectedThread ? (
<DiscussionsView thread={selectedThread} onBack={() => setSelectedThreadId(null)} />
) : (
<>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-4xl font-bold tracking-tight">Discussion Forums</h1>
<p className="text-muted-foreground">Connect, share, and learn with your cohort members</p>
</div>
<Button onClick={() => setShowNewThreadModal(true)} className="min-tap-44">
<Plus className="h-4 w-4 mr-2" />
New Thread
</Button>
</div>
<Card>
<CardContent className="py-4">
<div className="flex items-center gap-3 flex-nowrap">
<Select defaultValue="leadership-development-q4-2024">
<SelectTrigger className="w-[300px] shrink-0">
<SelectValue placeholder="Programme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="leadership-development-q4-2024">Leadership Development Q4 2024</SelectItem>
</SelectContent>
</Select>
<div className="relative min-w-0 flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Search threads..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{ paddingLeft: '30px' }}
/>
</div>
<Select value={tagsFilter} onValueChange={setTagsFilter}>
<SelectTrigger className="w-[170px] shrink-0">
<SelectValue placeholder="All Tags" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Tags</SelectItem>
{allTags.map((tag) => (
<SelectItem key={tag} value={tag}>
{tag}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="py-4 space-y-4">
<div>
<h2 className="text-2xl font-semibold">Discussions ({filteredThreads.length})</h2>
<p className="text-muted-foreground">Leadership Development Q4 2024 30 members</p>
</div>
{(threadsLoading || threadsFetching) && (
<div className="text-sm text-muted-foreground">Loading threads...</div>
)}
{!threadsLoading && filteredThreads.length === 0 && (
<div className="rounded-md border p-6 text-sm text-muted-foreground">No threads found.</div>
)}
<div className="space-y-3">
{filteredThreads.map((thread) => (
<button
key={thread.id}
type="button"
className="w-full rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm transition hover:bg-slate-50 cursor-pointer"
onClick={() => setSelectedThreadId(thread.id)}
style={{borderRadius: '12px'}}
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
L
</div>
<div className="min-w-0 flex-1 pl-3">
<div className="flex items-center gap-2">
<h3 className="truncate text-xl font-semibold leading-tight">{thread.title}</h3>
</div>
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">{thread.content}</p>
<div className="mt-3 flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<User className="h-4 w-4" />
Learner
</span>
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDate(thread.created_at)}
</span>
<span className="flex items-center gap-1">
<MessageCircle className="h-4 w-4" />
{thread.reactions.length}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{thread.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
</div>
</button>
))}
</div>
</CardContent>
</Card>
</>
)}
<Dialog
open={showNewThreadModal}
onOpenChange={(open: boolean) => {
setShowNewThreadModal(open);
if (!open) resetModal();
}}
>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Start New Discussion</DialogTitle>
<DialogDescription>Create a new thread for this forum.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{createError && (
<div className="flex items-center gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
<AlertCircle className="h-4 w-4" />
{createError}
</div>
)}
<div>
<label className="mb-2 block text-sm font-medium">Title *</label>
<Input
value={newThreadTitle}
onChange={(e) => setNewThreadTitle(e.target.value)}
placeholder="What would you like to discuss?"
maxLength={150}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium">Content *</label>
<Textarea
value={newThreadContent}
onChange={(e) => setNewThreadContent(e.target.value)}
placeholder="Share your thoughts..."
className="min-h-[140px]"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium">Tags</label>
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown}
placeholder="Type a tag and press Enter"
/>
{newThreadTags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{newThreadTags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1">
{tag}
<button
type="button"
onClick={() =>
setNewThreadTags((prev) => prev.filter((existingTag) => existingTag !== tag))
}
aria-label={`Remove ${tag}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
<div className="flex gap-2 pt-2">
<Button
className="flex-1"
onClick={handleCreateThread}
disabled={isCreatingThread || !newThreadTitle.trim() || !newThreadContent.trim()}
>
{isCreatingThread ? 'Creating...' : 'Create Thread'}
</Button>
<Button variant="outline" className="flex-1" onClick={() => setShowNewThreadModal(false)}>
Cancel
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
};
export default DiscussionsPage;

View File

@@ -0,0 +1,356 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, Ellipsis, MessageCircle, RefreshCw, Send, ThumbsUp } from 'lucide-react';
import { Button } from '../../components/ui/button';
import { Card, CardContent } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input';
import {
useGetRepliesByThreadQuery,
useReactToForumItemMutation,
useReplyToThreadMutation,
type ForumReply,
type ForumThread,
} from '../../redux/services/forumApi';
import { useToast } from '../../components/toast/useToast';
interface DiscussionsViewProps {
thread: ForumThread;
onBack: () => void;
}
const formatDate = (date?: string) => {
if (!date) return '-';
const parsed = new Date(date);
return Number.isNaN(parsed.getTime()) ? '-' : parsed.toLocaleDateString('en-GB');
};
const getLikeCount = (thread: ForumThread) =>
thread.reactions.find((r) => r.emoji_code === 'U+1F44D')?.count ?? 0;
const getReactionCount = (thread: ForumThread) => thread.reactions.reduce((sum, r) => sum + r.count, 0);
const reactionOptions = [
{ emoji: 'U+1F44D', label: '👍' },
{ emoji: 'U+1F602', label: '😂' },
{ emoji: 'U+1F60D', label: '😍' },
];
export const DiscussionsView: React.FC<DiscussionsViewProps> = ({ thread, onBack }) => {
const { showToast } = useToast();
const [replyInput, setReplyInput] = useState('');
const [childReplyInputById, setChildReplyInputById] = useState<Record<string, string>>({});
const [openChildReplyForId, setOpenChildReplyForId] = useState<string | null>(null);
const [openThreadReactionPicker, setOpenThreadReactionPicker] = useState(false);
const [openReplyReactionPickerId, setOpenReplyReactionPickerId] = useState<string | null>(null);
const threadReactionPickerRef = useRef<HTMLDivElement | null>(null);
const replyReactionPickerRefs = useRef<Record<string, HTMLDivElement | null>>({});
const { data: repliesResponse, isLoading: repliesLoading, refetch: refetchReplies } =
useGetRepliesByThreadQuery(thread.id);
const [replyToThread, { isLoading: postingReply }] = useReplyToThreadMutation();
const [reactToForumItem, { isLoading: reacting }] = useReactToForumItemMutation();
const normalizeReplies = (items: ForumReply[]): ForumReply[] =>
items.map((item) => ({
...item,
replies: normalizeReplies(item.replies ?? item.children ?? []),
}));
const replies = useMemo(() => normalizeReplies(repliesResponse?.data ?? []), [repliesResponse]);
const replyCount = useMemo(() => {
const countNodes = (nodes: ForumReply[]): number =>
nodes.reduce((sum, node) => sum + 1 + countNodes(node.replies ?? []), 0);
return countNodes(replies);
}, [replies]);
const getReplyReactionCount = (reply: ForumReply) =>
(reply.reactions ?? []).reduce((sum, r) => sum + r.count, 0);
const getReactionLabel = (reactionCode: string) =>
reactionOptions.find((opt) => opt.emoji === reactionCode)?.label ?? '🙂';
const postReply = async (content: string, parentId?: string) => {
if (!content.trim()) return;
try {
const response = await replyToThread({
threadId: thread.id,
content: content.trim(),
parent_id: parentId || undefined,
}).unwrap();
showToast('Reply added', response.message || 'Reply added successfully.', 'success');
if (parentId) {
setChildReplyInputById((prev) => ({ ...prev, [parentId]: '' }));
setOpenChildReplyForId(null);
} else {
setReplyInput('');
}
await refetchReplies();
} catch (error: any) {
const message = error?.data?.message || 'Failed to post reply.';
showToast('Reply failed', message, 'error');
}
};
const reactToThread = async (emoji: string) => {
try {
await reactToForumItem({
emoji,
thread_id: thread.id,
}).unwrap();
await refetchReplies();
setOpenThreadReactionPicker(false);
showToast('Reaction added', 'Your reaction was recorded.', 'success');
} catch (error: any) {
showToast('Reaction failed', error?.data?.message || 'Could not react right now.', 'error');
}
};
const reactToReply = async (replyId: string, emoji: string) => {
try {
await reactToForumItem({
emoji,
reply_id: replyId,
}).unwrap();
await refetchReplies();
setOpenReplyReactionPickerId(null);
showToast('Reaction added', 'Your reaction was recorded.', 'success');
} catch (error: any) {
showToast('Reaction failed', error?.data?.message || 'Could not react right now.', 'error');
}
};
useEffect(() => {
const handleOutsideClick = (event: Event) => {
const target = event.target as Node | null;
if (!target) return;
if (openThreadReactionPicker && threadReactionPickerRef.current && !threadReactionPickerRef.current.contains(target)) {
setOpenThreadReactionPicker(false);
}
if (openReplyReactionPickerId) {
const activeReplyRef = replyReactionPickerRefs.current[openReplyReactionPickerId];
if (activeReplyRef && !activeReplyRef.contains(target)) {
setOpenReplyReactionPickerId(null);
}
}
};
document.addEventListener('pointerdown', handleOutsideClick, true);
document.addEventListener('touchstart', handleOutsideClick, true);
return () => {
document.removeEventListener('pointerdown', handleOutsideClick, true);
document.removeEventListener('touchstart', handleOutsideClick, true);
};
}, [openThreadReactionPicker, openReplyReactionPickerId]);
const renderReplies = (items: ForumReply[], depth = 0): React.ReactNode =>
items.map((reply) => {
const childReplyText = childReplyInputById[reply.id] ?? '';
const children = reply.replies ?? [];
const isChildBoxOpen = openChildReplyForId === reply.id;
return (
<div
key={reply.id}
className={'border-b pb-4 last:border-b-0' + (depth > 0 ? ' ml-6 mt-3 border-l pl-4' : '')}
>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">HR User</span>
<span>{formatDate(reply.created_at)}</span>
</div>
<Button variant="ghost" size="icon" className="cursor-pointer">
<Ellipsis className="h-4 w-4" />
</Button>
</div>
<p className="mb-3 text-sm">{reply.content}</p>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div
className="relative"
ref={(el) => {
replyReactionPickerRefs.current[reply.id] = el;
}}
>
<button
type="button"
className="flex cursor-pointer items-center gap-1 outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 hover:text-foreground"
onClick={() =>
setOpenReplyReactionPickerId((prev) => (prev === reply.id ? null : reply.id))
}
disabled={reacting}
>
<ThumbsUp className="h-4 w-4" />
<span>Like</span>
</button>
{openReplyReactionPickerId === reply.id && (
<div className="absolute bottom-full left-0 z-30 mb-2 flex items-center gap-1 rounded-full border border-slate-200 bg-white p-1 shadow-xl">
{reactionOptions.map((option) => (
<button
key={option.emoji + 'reply' + reply.id}
type="button"
className="cursor-pointer rounded-full p-1 text-lg leading-none transition hover:bg-muted"
onClick={() => reactToReply(reply.id, option.emoji)}
disabled={reacting}
>
{option.label}
</button>
))}
</div>
)}
</div>
<span>{getReplyReactionCount(reply)}</span>
<button
type="button"
className="cursor-pointer hover:text-foreground"
onClick={() => setOpenChildReplyForId(isChildBoxOpen ? null : reply.id)}
>
Reply
</button>
</div>
{isChildBoxOpen && (
<div className="mt-3 flex gap-2">
<Input
value={childReplyText}
onChange={(e) =>
setChildReplyInputById((prev) => ({ ...prev, [reply.id]: e.target.value }))
}
placeholder="Write a sub-reply..."
/>
<Button
onClick={() => postReply(childReplyText, reply.id)}
disabled={postingReply || !childReplyText.trim()}
>
Post
</Button>
</div>
)}
{children.length > 0 && <div className="mt-2">{renderReplies(children, depth + 1)}</div>}
</div>
);
});
return (
<div className="space-y-4">
<div className="flex items-center gap-3 text-sm">
<Button variant="ghost" onClick={onBack} className="h-auto cursor-pointer p-0 font-medium">
<ArrowLeft className="mr-1 h-4 w-4" />
Back to Forums
</Button>
<span className="text-muted-foreground">Leadership Development Q4 2024</span>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">Thread</span>
</div>
<Card>
<CardContent className="space-y-4 py-4">
<div className="flex items-start justify-between gap-2">
<h2 className="font-semibold leading-tight">{thread.title}</h2>
<Button variant="ghost" size="icon" className="cursor-pointer">
<Ellipsis className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>By HR User</span>
<span></span>
<span>{formatDate(thread.created_at)}</span>
<span></span>
<span>{replyCount} replies</span>
</div>
<p className="text-lg">{thread.content}</p>
<div className="flex flex-wrap gap-2">
{thread.tags.map((tag) => (
<Badge key={tag} variant="secondary">
#{tag}
</Badge>
))}
</div>
<div className="flex items-center gap-3 border-t pt-4 text-sm text-muted-foreground">
<div className="relative" ref={threadReactionPickerRef}>
<button
type="button"
className="flex cursor-pointer items-center gap-1 outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 hover:text-foreground"
onClick={() => setOpenThreadReactionPicker((prev) => !prev)}
disabled={reacting}
>
<ThumbsUp className="h-4 w-4" />
<span>Like</span>
</button>
{openThreadReactionPicker && (
<div className="absolute bottom-full left-0 z-30 mb-2 flex items-center gap-1 rounded-full border border-slate-200 bg-white p-1 shadow-xl">
{reactionOptions.map((option) => (
<button
key={option.emoji + 'thread' + thread.id}
type="button"
className="cursor-pointer rounded-full p-1 text-2xl leading-none transition hover:bg-muted"
onClick={() => reactToThread(option.emoji)}
disabled={reacting}
>
{option.label}
</button>
))}
</div>
)}
</div>
<span>{getReactionCount(thread)}</span>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-4 py-6">
<div className="flex items-center justify-between">
<h3 className="text-2xl font-semibold">Replies ({replyCount})</h3>
<Button variant="outline" size="sm" onClick={() => refetchReplies()} disabled={repliesLoading}>
<RefreshCw className={'mr-2 h-4 w-4' + (repliesLoading ? ' animate-spin' : '')} />
Refresh
</Button>
</div>
{repliesLoading ? (
<div className="text-sm text-muted-foreground">Loading replies...</div>
) : replies.length === 0 ? (
<div className="text-sm text-muted-foreground">No replies yet.</div>
) : (
renderReplies(replies)
)}
</CardContent>
</Card>
<Card>
<CardContent className="space-y-4 py-6">
<h3 className="text-2xl font-semibold">Add Reply</h3>
<Input
value={replyInput}
onChange={(e) => setReplyInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!postingReply && replyInput.trim()) {
void postReply(replyInput);
}
}
}}
placeholder="Share your thoughts or ask a follow-up question..."
/>
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">Press Enter to post</div>
<Button
className="bg-[#7a78b0] hover:bg-[#69679d]"
onClick={() => postReply(replyInput)}
disabled={postingReply || !replyInput.trim()}
>
<Send className="mr-2 h-4 w-4" />
{postingReply ? 'Posting...' : 'Post Reply'}
</Button>
</div>
</CardContent>
</Card>
</div>
);
};
export default DiscussionsView;

View File

@@ -1,581 +0,0 @@
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

View File

@@ -1,593 +0,0 @@
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>
);
};

105
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,105 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Card, CardContent, CardHeader } from '../components/ui/card';
import klcLogo from '../assets/klc-logo.png';
const Login = () => {
const { login, isAuthenticated } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isAuthenticated) {
navigate('/hr/dashboard', { replace: true });
}
}, [isAuthenticated, navigate]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
await login(email, password);
setLoading(false);
};
return (
<div
className="relative flex min-h-screen w-full flex-col items-center justify-center gap-8 px-4"
style={{
background:
'linear-gradient(135deg, var(--accent-1), var(--accent-1), var(--accent-1))',
}}
>
<Card className="w-full max-w-[450px] shadow-lg" style={{ maxWidth: '400px' }}>
<CardHeader className="pb-2 text-center">
<img
src={klcLogo}
alt="Kautilya Leadership Centre"
className="mx-auto h-12 w-auto"
/>
<p className="text-sm text-muted-foreground mt-2">HR Portal sign in</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="email" className="font-semibold">
Email
</Label>
<Input
id="email"
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={loading}
className="h-11"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password" className="font-semibold">
Password
</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={loading}
className="h-11"
/>
</div>
<Button type="submit" size="lg" className="mt-2 w-full" disabled={loading}>
{loading ? 'Logging in...' : 'Sign In'}
</Button>
<Link
to="/forgot-password"
className="text-center text-sm font-semibold text-primary hover:underline"
>
Forgot password?
</Link>
</form>
</CardContent>
</Card>
<footer
className="absolute bottom-0 flex h-[4%] min-h-10 w-full flex-row items-center justify-center gap-7 border-t border-border bg-muted/80 px-3"
>
<p className="text-sm text-muted-foreground">
© {new Date().getFullYear()} Kautilya Leadership Centre
</p>
<p className="text-sm text-muted-foreground">Privacy Policy</p>
<p className="text-sm text-muted-foreground">Terms of Service</p>
</footer>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,519 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button } from '../../components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
import { Progress } from '../../components/ui/progress';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
import { Avatar, AvatarFallback } from '../../components/ui/avatar';
import {
ArrowLeft,
Users,
BookOpen,
Clock,
Calendar,
Award,
Download,
UserPlus,
BarChart3,
MessageSquare,
FileText,
TrendingUp,
CheckCircle,
AlertCircle,
PlayCircle,
FileCheck,
Video,
File,
Link as LinkIcon,
MoreHorizontal,
Edit,
Trash2,
Share2
} from 'lucide-react';
interface Programme {
id: string;
name: string;
description: string;
duration: string;
startDate: string;
endDate: string;
status: 'Active' | 'Upcoming' | 'Completed';
enrolledCount: number;
capacity: number;
completionRate: number;
averageScore: number;
instructor: {
name: string;
email: string;
avatar?: string;
};
modules: Module[];
resources: Resource[];
}
interface Module {
id: string;
title: string;
description: string;
duration: string;
type: 'video' | 'reading' | 'quiz' | 'assignment';
status: 'locked' | 'available' | 'completed';
progress?: number;
}
interface Resource {
id: string;
title: string;
type: 'pdf' | 'video' | 'link' | 'document';
url: string;
size?: string;
}
interface EnrolledLearner {
id: string;
name: string;
email: string;
progress: number;
status: 'Active' | 'At Risk' | 'Completed';
lastActivity: string;
}
// Mock data
const mockProgramme: Programme = {
id: '1',
name: 'Leadership Development Program',
description: 'Comprehensive leadership program designed for emerging leaders to develop essential management skills, strategic thinking, and team leadership capabilities.',
duration: '12 weeks',
startDate: '2024-01-15',
endDate: '2024-04-05',
status: 'Active',
enrolledCount: 45,
capacity: 50,
completionRate: 78,
averageScore: 85,
instructor: {
name: 'Dr. Sarah Johnson',
email: 'sarah.johnson@klc.edu'
},
modules: [
{
id: 'm1',
title: 'Foundations of Leadership',
description: 'Understanding leadership styles and core principles',
duration: '2 weeks',
type: 'video',
status: 'completed',
progress: 100
},
{
id: 'm2',
title: 'Strategic Thinking',
description: 'Developing strategic mindset and decision-making',
duration: '3 weeks',
type: 'reading',
status: 'completed',
progress: 100
},
{
id: 'm3',
title: 'Team Building & Management',
description: 'Building and leading high-performance teams',
duration: '3 weeks',
type: 'assignment',
status: 'available',
progress: 65
},
{
id: 'm4',
title: 'Communication & Influence',
description: 'Effective communication and influencing skills',
duration: '2 weeks',
type: 'video',
status: 'available',
progress: 30
},
{
id: 'm5',
title: 'Change Management',
description: 'Leading through organizational change',
duration: '2 weeks',
type: 'quiz',
status: 'locked',
progress: 0
}
],
resources: [
{ id: 'r1', title: 'Leadership Assessment Tool', type: 'pdf', url: '#', size: '2.5 MB' },
{ id: 'r2', title: 'Strategic Planning Template', type: 'document', url: '#', size: '1.8 MB' },
{ id: 'r3', title: 'Team Building Activities Guide', type: 'pdf', url: '#', size: '3.2 MB' },
{ id: 'r4', title: 'Communication Framework Video', type: 'video', url: '#', size: '45 MB' }
]
};
const mockEnrolledLearners: EnrolledLearner[] = [
{ id: '1', name: 'Sarah Chen', email: 'sarah.chen@company.com', progress: 92, status: 'Active', lastActivity: '2 hours ago' },
{ id: '2', name: 'Michael Rodriguez', email: 'michael.r@company.com', progress: 78, status: 'Active', lastActivity: '1 day ago' },
{ id: '3', name: 'Emma Thompson', email: 'emma.thompson@company.com', progress: 45, status: 'At Risk', lastActivity: '5 days ago' },
{ id: '4', name: 'David Kim', email: 'david.kim@company.com', progress: 88, status: 'Active', lastActivity: '3 hours ago' },
{ id: '5', name: 'Lisa Wang', email: 'lisa.wang@company.com', progress: 95, status: 'Completed', lastActivity: '1 day ago' }
];
const ProgrammeViewPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [programme, setProgramme] = useState<Programme | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('overview');
useEffect(() => {
// Simulate API call
const timer = setTimeout(() => {
setProgramme(mockProgramme);
setLoading(false);
}, 500);
return () => clearTimeout(timer);
}, [id]);
if (loading) {
return (
<div className="space-y-6">
<div className="h-8 w-64 bg-muted animate-pulse rounded" />
<div className="h-32 bg-muted animate-pulse rounded" />
<div className="h-64 bg-muted animate-pulse rounded" />
</div>
);
}
if (!programme) {
return (
<div className="text-center py-12">
<AlertCircle className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h2 className="text-xl font-semibold mb-2">Programme not found</h2>
<p className="text-muted-foreground mb-4">The programme you're looking for doesn't exist.</p>
<Button onClick={() => navigate('/dashboard')}>Back to Dashboard</Button>
</div>
);
}
const getStatusColor = (status: string) => {
switch (status) {
case 'Active': return 'default';
case 'Completed': return 'secondary';
case 'locked': return 'outline';
case 'available': return 'default';
default: return 'secondary';
}
};
const getModuleIcon = (type: string) => {
switch (type) {
case 'video': return <Video className="h-4 w-4" />;
case 'reading': return <File className="h-4 w-4" />;
case 'quiz': return <FileCheck className="h-4 w-4" />;
case 'assignment': return <FileText className="h-4 w-4" />;
default: return <BookOpen className="h-4 w-4" />;
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
className="min-tap-44"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div>
<h1 className="text-2xl font-bold">{programme.name}</h1>
<p className="text-muted-foreground">Programme Overview</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="min-tap-44">
<Edit className="h-4 w-4 mr-2" />
Edit
</Button>
<Button variant="outline" size="sm" className="min-tap-44">
<Share2 className="h-4 w-4 mr-2" />
Share
</Button>
<Button variant="outline" size="sm" className="min-tap-44 text-red-600">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div>
{/* Programme Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Enrolled</p>
<p className="text-2xl font-bold">
{programme.enrolledCount}/{programme.capacity}
</p>
</div>
<Users className="h-8 w-8 text-blue-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Completion Rate</p>
<p className="text-2xl font-bold">{programme.completionRate}%</p>
</div>
<TrendingUp className="h-8 w-8 text-green-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Average Score</p>
<p className="text-2xl font-bold">{programme.averageScore}/100</p>
</div>
<Award className="h-8 w-8 text-yellow-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Duration</p>
<p className="text-2xl font-bold">{programme.duration}</p>
</div>
<Clock className="h-8 w-8 text-purple-500" />
</div>
</CardContent>
</Card>
</div>
{/* Main Content Tabs */}
<Card>
<CardContent className="pt-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="modules">Modules</TabsTrigger>
<TabsTrigger value="learners">Learners</TabsTrigger>
<TabsTrigger value="resources">Resources</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview" className="space-y-6 mt-6">
<div>
<h3 className="font-semibold mb-2">Description</h3>
<p className="text-muted-foreground">{programme.description}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-medium mb-2">Programme Details</h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Start Date:</span>
<span>{new Date(programme.startDate).toLocaleDateString()}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">End Date:</span>
<span>{new Date(programme.endDate).toLocaleDateString()}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Status:</span>
<Badge variant={getStatusColor(programme.status)}>
{programme.status}
</Badge>
</div>
</div>
</div>
<div>
<h4 className="text-sm font-medium mb-2">Instructor</h4>
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>
{programme.instructor.name.split(' ').map(n => n[0]).join('')}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{programme.instructor.name}</p>
<p className="text-sm text-muted-foreground">{programme.instructor.email}</p>
</div>
</div>
</div>
</div>
<div>
<h3 className="font-semibold mb-4">Quick Actions</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Button variant="outline" className="h-auto py-4 flex-col gap-2">
<UserPlus className="h-6 w-6" />
<span>Add Learners</span>
</Button>
<Button variant="outline" className="h-auto py-4 flex-col gap-2">
<Download className="h-6 w-6" />
<span>Export Data</span>
</Button>
<Button variant="outline" className="h-auto py-4 flex-col gap-2">
<BarChart3 className="h-6 w-6" />
<span>Analytics</span>
</Button>
<Button variant="outline" className="h-auto py-4 flex-col gap-2">
<MessageSquare className="h-6 w-6" />
<span>Discussions</span>
</Button>
</div>
</div>
</TabsContent>
{/* Modules Tab */}
<TabsContent value="modules" className="space-y-4 mt-6">
{programme.modules.map((module, index) => (
<Card key={module.id}>
<CardContent className="pt-6">
<div className="flex items-start gap-4">
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
{getModuleIcon(module.type)}
</div>
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<h4 className="font-semibold">{module.title}</h4>
<p className="text-sm text-muted-foreground">
{module.description}
</p>
</div>
<Badge variant={getStatusColor(module.status)}>
{module.status}
</Badge>
</div>
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{module.duration}
</span>
<span className="flex items-center gap-1">
{getModuleIcon(module.type)}
{module.type.charAt(0).toUpperCase() + module.type.slice(1)}
</span>
</div>
{module.progress > 0 && (
<div className="mt-3">
<div className="flex justify-between text-sm mb-1">
<span>Progress</span>
<span>{module.progress}%</span>
</div>
<Progress value={module.progress} className="h-2" />
</div>
)}
</div>
</div>
</CardContent>
</Card>
))}
</TabsContent>
{/* Learners Tab */}
<TabsContent value="learners" className="space-y-4 mt-6">
<div className="flex justify-between items-center">
<h3 className="font-semibold">Enrolled Learners ({programme.enrolledCount})</h3>
<Button size="sm">
<UserPlus className="h-4 w-4 mr-2" />
Add Learners
</Button>
</div>
<div className="space-y-3">
{mockEnrolledLearners.map(learner => (
<Card key={learner.id}>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>
{learner.name.split(' ').map(n => n[0]).join('')}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{learner.name}</p>
<p className="text-sm text-muted-foreground">{learner.email}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm font-medium">{learner.progress}%</p>
<Progress value={learner.progress} className="w-24" />
</div>
<Badge variant={
learner.status === 'Active' ? 'default' :
learner.status === 'At Risk' ? 'destructive' : 'secondary'
}>
{learner.status}
</Badge>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
{/* Resources Tab */}
<TabsContent value="resources" className="space-y-4 mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{programme.resources.map(resource => (
<Card key={resource.id}>
<CardContent className="pt-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
{resource.type === 'pdf' && <FileText className="h-5 w-5" />}
{resource.type === 'video' && <Video className="h-5 w-5" />}
{resource.type === 'link' && <LinkIcon className="h-5 w-5" />}
{resource.type === 'document' && <File className="h-5 w-5" />}
</div>
<div className="flex-1">
<h4 className="font-medium">{resource.title}</h4>
<p className="text-xs text-muted-foreground">
{resource.type.toUpperCase()} {resource.size}
</p>
</div>
<Button variant="ghost" size="sm">
<Download className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
);
};
export default ProgrammeViewPage;

View File

@@ -0,0 +1,133 @@
import React, { useState } from 'react';
import { BookOpen, Calendar, ChevronRight, FolderOpen, Info, Presentation, Users } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Progress } from '../../components/ui/progress';
import { useGetAssignedHrProgrammesQuery } from '../../redux/services/learnersApi';
const ProgrammesPage: React.FC = () => {
const { data, isLoading, isError } = useGetAssignedHrProgrammesQuery({
limit: 10,
start: 0,
});
const [openProgrammeId, setOpenProgrammeId] = useState<string | null>(null);
const programmeItems = data?.data.programme_items ?? [];
const formatDate = (date: string) =>
new Date(date).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
return (
<div className="space-y-4">
<h1 className="text-3xl font-bold tracking-tight">My Programs</h1>
{isLoading && <p className="text-sm text-muted-foreground">Loading programmes...</p>}
{isError && <p className="text-sm text-red-600">Failed to load programmes. Please try again.</p>}
{!isLoading && !isError && programmeItems.length === 0 && (
<p className="text-sm text-muted-foreground">No programmes found.</p>
)}
<div className="space-y-4">
{programmeItems.map((programme) => {
const isOpen = openProgrammeId === programme.id;
return (
<Card
key={programme.id}
className="gap-0 overflow-hidden border border-violet-100 bg-gradient-to-br from-violet-50 via-white to-fuchsia-50"
>
<CardHeader
className={`cursor-pointer !pt-4 !pb-4 transition-colors duration-200 ${isOpen ? 'border-b border-violet-100' : 'border-b border-transparent'}`}
onClick={() => setOpenProgrammeId((prev) => (prev === programme.id ? null : programme.id))}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
<ChevronRight
className={`mt-1 h-5 w-5 text-violet-800 transition-transform ${isOpen ? 'rotate-90' : 'rotate-0'}`}
/>
<div className="space-y-2">
<CardTitle className="text-xl font-semibold leading-tight text-violet-950">{programme.programme_title}</CardTitle>
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<span>{programme.progress ?? 0}% Complete</span>
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Ends: {formatDate(programme.end_date)}
</span>
<span className="flex items-center gap-1">
<BookOpen className="h-4 w-4" />
{programme.courses} Courses
</span>
<span className="flex items-center gap-1">
<Presentation className="h-4 w-4" />
{programme.webinars} Webinars
</span>
<span className="flex items-center gap-1">
<FolderOpen className="h-4 w-4" />
{programme.resources} Resources
</span>
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{programme.classes} Classes
</span>
</div>
</div>
</div>
<Button variant="outline" className="shrink-0 border-violet-300 text-violet-800">
<Info className="mr-2 h-4 w-4" />
Programme Info
</Button>
</div>
</CardHeader>
<div
className={`grid transition-all duration-300 ease-in-out ${isOpen ? 'grid-rows-[1fr] opacity-100' : 'pointer-events-none grid-rows-[0fr] opacity-0'}`}
>
<div className="overflow-hidden">
<CardContent className="space-y-5 pt-4">
<div className="space-y-2 border-b border-violet-100 pb-4">
<h3 className="text-lg font-semibold leading-tight text-foreground">Programme Summary</h3>
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<BookOpen className="h-4 w-4 text-[#061a72]" />
{programme.courses} Courses
</span>
<span className="flex items-center gap-1">
<FolderOpen className="h-4 w-4 text-[#061a72]" />
{programme.resources} Resources
</span>
<span className="flex items-center gap-1">
<Presentation className="h-4 w-4 text-[#061a72]" />
{programme.webinars} Webinars
</span>
<span className="flex items-center gap-1">
<Users className="h-4 w-4 text-[#061a72]" />
{programme.classes} Classes
</span>
</div>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Progress</span>
<span className="font-medium">{programme.progress ?? 0}%</span>
</div>
<Progress value={programme.progress ?? 0} className="h-2 bg-slate-200" />
</div>
</CardContent>
</div>
</div>
</Card>
);
})}
</div>
</div>
);
};
export default ProgrammesPage;

View File

@@ -0,0 +1,574 @@
import React, { useState } from 'react';
import { Button } from '../../components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
import { Badge } from '../../components/ui/badge';
import { Progress } from '../../components/ui/progress';
import {
BarChart3,
Download,
FileText,
TrendingUp,
Users,
BookOpen,
Clock,
Award,
Calendar,
Filter,
RefreshCw,
ChevronDown,
PieChart,
LineChart,
Table as TableIcon,
Eye,
Mail,
AlertCircle,
CheckCircle,
XCircle,
DownloadCloud
} from 'lucide-react';
import {
LineChart as ReLineChart,
Line,
AreaChart,
Area,
BarChart,
Bar,
PieChart as RePieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
// Mock data for charts
const completionTrendData = [
{ month: 'Jan', completion: 65, enrollment: 78 },
{ month: 'Feb', completion: 68, enrollment: 82 },
{ month: 'Mar', completion: 72, enrollment: 85 },
{ month: 'Apr', completion: 70, enrollment: 88 },
{ month: 'May', completion: 75, enrollment: 92 },
{ month: 'Jun', completion: 78, enrollment: 95 },
{ month: 'Jul', completion: 80, enrollment: 98 },
{ month: 'Aug', completion: 82, enrollment: 100 },
{ month: 'Sep', completion: 85, enrollment: 102 },
{ month: 'Oct', completion: 83, enrollment: 105 },
{ month: 'Nov', completion: 87, enrollment: 108 },
{ month: 'Dec', completion: 90, enrollment: 110 }
];
const programmePerformanceData = [
{ name: 'Leadership', completion: 85, enrollment: 120 },
{ name: 'Technical', completion: 72, enrollment: 95 },
{ name: 'Communication', completion: 88, enrollment: 80 },
{ name: 'Project Mgmt', completion: 78, enrollment: 110 },
{ name: 'Sales', completion: 82, enrollment: 70 }
];
const learnerStatusData = [
{ name: 'Active', value: 450, color: '#22c55e' },
{ name: 'At Risk', value: 85, color: '#ef4444' },
{ name: 'Completed', value: 320, color: '#3b82f6' },
{ name: 'Pending', value: 45, color: '#eab308' }
];
const activityData = [
{ day: 'Mon', video: 45, reading: 30, quiz: 20 },
{ day: 'Tue', video: 52, reading: 35, quiz: 25 },
{ day: 'Wed', video: 48, reading: 42, quiz: 28 },
{ day: 'Thu', video: 55, reading: 38, quiz: 32 },
{ day: 'Fri', video: 50, reading: 40, quiz: 30 },
{ day: 'Sat', video: 35, reading: 25, quiz: 15 },
{ day: 'Sun', video: 30, reading: 20, quiz: 12 }
];
const ReportsPage: React.FC = () => {
const [dateRange, setDateRange] = useState('last-30-days');
const [selectedProgramme, setSelectedProgramme] = useState('all');
const [exporting, setExporting] = useState(false);
const [activeTab, setActiveTab] = useState('overview');
const programmes = [
{ id: 'all', name: 'All Programmes' },
{ id: 'leadership', name: 'Leadership Development' },
{ id: 'technical', name: 'Technical Skills' },
{ id: 'communication', name: 'Communication' },
{ id: 'project', name: 'Project Management' }
];
const handleExport = async (format: 'excel' | 'pdf' | 'csv') => {
setExporting(true);
await new Promise(resolve => setTimeout(resolve, 2000));
setExporting(false);
console.log(`Exported report as ${format}`);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Reports & Analytics</h1>
<p className="text-muted-foreground">Track performance, engagement, and completion metrics</p>
</div>
<div className="flex items-center gap-2">
<Select value={selectedProgramme} onValueChange={setSelectedProgramme}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select Programme" />
</SelectTrigger>
<SelectContent>
{programmes.map(p => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={dateRange} onValueChange={setDateRange}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Date Range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="last-7-days">Last 7 days</SelectItem>
<SelectItem value="last-30-days">Last 30 days</SelectItem>
<SelectItem value="last-90-days">Last 90 days</SelectItem>
<SelectItem value="this-year">This Year</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
onClick={() => handleExport('excel')}
disabled={exporting}
className="min-tap-44"
>
{exporting ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<DownloadCloud className="h-4 w-4 mr-2" />
)}
Export
</Button>
</div>
</div>
{/* Overview Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total Learners</p>
<p className="text-2xl font-bold">1,247</p>
<p className="text-xs text-green-600">+12% from last month</p>
</div>
<Users className="h-8 w-8 text-blue-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Completion Rate</p>
<p className="text-2xl font-bold">78%</p>
<p className="text-xs text-green-600">+5% from last month</p>
</div>
<CheckCircle className="h-8 w-8 text-green-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Avg. Time to Complete</p>
<p className="text-2xl font-bold">21 days</p>
<p className="text-xs text-red-600">+2 days from last month</p>
</div>
<Clock className="h-8 w-8 text-yellow-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Active Programmes</p>
<p className="text-2xl font-bold">12</p>
<p className="text-xs text-green-600">+3 from last month</p>
</div>
<BookOpen className="h-8 w-8 text-purple-500" />
</div>
</CardContent>
</Card>
</div>
{/* Main Tabs */}
<Card>
<CardContent className="pt-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="programmes">Programmes</TabsTrigger>
<TabsTrigger value="learners">Learners</TabsTrigger>
<TabsTrigger value="engagement">Engagement</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview" className="space-y-6 mt-6">
{/* Completion Trends */}
<Card>
<CardHeader>
<CardTitle>Completion Trends</CardTitle>
<CardDescription>Monthly completion and enrollment rates</CardDescription>
</CardHeader>
<CardContent>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={completionTrendData}>
<defs>
<linearGradient id="completionGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
</linearGradient>
<linearGradient id="enrollmentGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#22c55e" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Legend />
<Area
type="monotone"
dataKey="completion"
stroke="#3b82f6"
fillOpacity={1}
fill="url(#completionGradient)"
name="Completion Rate (%)"
/>
<Area
type="monotone"
dataKey="enrollment"
stroke="#22c55e"
fillOpacity={1}
fill="url(#enrollmentGradient)"
name="Enrollment Count"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Programme Performance */}
<Card>
<CardHeader>
<CardTitle>Programme Performance</CardTitle>
<CardDescription>Completion rates by programme</CardDescription>
</CardHeader>
<CardContent>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={programmePerformanceData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="completion" fill="#3b82f6" name="Completion Rate (%)" />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Learner Status Distribution */}
<Card>
<CardHeader>
<CardTitle>Learner Status</CardTitle>
<CardDescription>Distribution by current status</CardDescription>
</CardHeader>
<CardContent>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<RePieChart>
<Pie
data={learnerStatusData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={5}
dataKey="value"
>
{learnerStatusData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</RePieChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
{/* Programmes Tab */}
<TabsContent value="programmes" className="space-y-4 mt-6">
{programmePerformanceData.map(programme => (
<Card key={programme.name}>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-semibold">{programme.name}</h3>
<p className="text-sm text-muted-foreground">
{programme.enrollment} enrolled learners
</p>
</div>
<Badge variant="outline">
{programme.completion}% Completion
</Badge>
</div>
<div className="space-y-3">
<div>
<div className="flex justify-between text-sm mb-1">
<span>Completion Progress</span>
<span className="font-medium">{programme.completion}%</span>
</div>
<Progress value={programme.completion} className="h-2" />
</div>
<div className="grid grid-cols-3 gap-4 pt-2">
<div>
<p className="text-xs text-muted-foreground">Active</p>
<p className="text-lg font-semibold">
{Math.floor(programme.enrollment * 0.7)}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Completed</p>
<p className="text-lg font-semibold">
{Math.floor(programme.enrollment * 0.25)}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">At Risk</p>
<p className="text-lg font-semibold">
{Math.floor(programme.enrollment * 0.05)}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</TabsContent>
{/* Learners Tab */}
<TabsContent value="learners" className="space-y-4 mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Top Performers */}
<Card>
<CardHeader>
<CardTitle>Top Performers</CardTitle>
<CardDescription>Learners with highest completion rates</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="flex items-center justify-between p-2 bg-muted/30 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<span className="text-sm font-medium">JD</span>
</div>
<div>
<p className="font-medium">John Doe</p>
<p className="text-xs text-muted-foreground">Leadership Program</p>
</div>
</div>
<Badge variant="default">98%</Badge>
</div>
))}
</div>
</CardContent>
</Card>
{/* At Risk Learners */}
<Card>
<CardHeader>
<CardTitle>At Risk Learners</CardTitle>
<CardDescription>Learners needing intervention</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="flex items-center justify-between p-2 bg-red-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-red-100 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-red-600">JS</span>
</div>
<div>
<p className="font-medium">Jane Smith</p>
<p className="text-xs text-muted-foreground">Technical Skills</p>
</div>
</div>
<Badge variant="destructive">32%</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Learner Activity Table */}
<Card>
<CardHeader>
<CardTitle>Recent Learner Activity</CardTitle>
<CardDescription>Last 7 days of engagement</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="p-3 text-left text-sm font-medium">Learner</th>
<th className="p-3 text-left text-sm font-medium">Programme</th>
<th className="p-3 text-left text-sm font-medium">Progress</th>
<th className="p-3 text-left text-sm font-medium">Last Activity</th>
<th className="p-3 text-left text-sm font-medium">Status</th>
</tr>
</thead>
<tbody>
{[1, 2, 3, 4, 5].map(i => (
<tr key={i} className="border-t">
<td className="p-3">Sarah Chen</td>
<td className="p-3">Leadership</td>
<td className="p-3">
<div className="flex items-center gap-2">
<Progress value={85} className="w-16" />
<span>85%</span>
</div>
</td>
<td className="p-3">2 hours ago</td>
<td className="p-3">
<Badge>Active</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Engagement Tab */}
<TabsContent value="engagement" className="space-y-6 mt-6">
{/* Activity Heatmap */}
<Card>
<CardHeader>
<CardTitle>Weekly Activity Pattern</CardTitle>
<CardDescription>Learning activity by day and type</CardDescription>
</CardHeader>
<CardContent>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={activityData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="day" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="video" fill="#3b82f6" name="Video" />
<Bar dataKey="reading" fill="#22c55e" name="Reading" />
<Bar dataKey="quiz" fill="#eab308" name="Quiz" />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Engagement Metrics */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="text-center">
<p className="text-3xl font-bold">85%</p>
<p className="text-sm text-muted-foreground">Video Completion Rate</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-center">
<p className="text-3xl font-bold">4.2</p>
<p className="text-sm text-muted-foreground">Avg. Hours/Week</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-center">
<p className="text-3xl font-bold">1,247</p>
<p className="text-sm text-muted-foreground">Forum Posts</p>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* Export Options */}
<Card>
<CardHeader>
<CardTitle>Export Options</CardTitle>
<CardDescription>Download reports in various formats</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Button
variant="outline"
className="h-auto py-4 flex-col gap-2"
onClick={() => handleExport('excel')}
>
<FileText className="h-6 w-6" />
<span>Excel Report</span>
<span className="text-xs text-muted-foreground">.xlsx with charts</span>
</Button>
<Button
variant="outline"
className="h-auto py-4 flex-col gap-2"
onClick={() => handleExport('pdf')}
>
<FileText className="h-6 w-6" />
<span>PDF Report</span>
<span className="text-xs text-muted-foreground">Formatted summary</span>
</Button>
<Button
variant="outline"
className="h-auto py-4 flex-col gap-2"
onClick={() => handleExport('csv')}
>
<TableIcon className="h-6 w-6" />
<span>CSV Data</span>
<span className="text-xs text-muted-foreground">Raw data export</span>
</Button>
</div>
</CardContent>
</Card>
</div>
);
};
export default ReportsPage;

View File

@@ -1,109 +0,0 @@
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>
);
};

View File

@@ -1,399 +0,0 @@
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 (12000 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>
);
};

6
src/redux/hooks.ts Normal file
View File

@@ -0,0 +1,6 @@
import { useDispatch, useSelector } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

@@ -1,24 +0,0 @@
// 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;

View File

@@ -0,0 +1,139 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export interface ForumReaction {
emoji_code: string;
count: number;
}
export interface ForumReply {
id: string;
content: string;
created_at?: string;
parent_id?: string | null;
reactions?: ForumReaction[];
replies?: ForumReply[];
children?: ForumReply[];
}
export interface ForumThread {
id: string;
title: string;
content: string;
tags: string[];
created_at: string;
latest_activity: string;
reactions: ForumReaction[];
}
interface ForumThreadsResponse {
success: boolean;
status: number;
message: string;
data: ForumThread[];
errors: unknown;
correlation_id: string;
}
interface CreateThreadRequest {
title: string;
content: string;
tags: string[];
}
interface ForumRepliesResponse {
success: boolean;
status: number;
message: string;
data: ForumReply[];
errors: unknown;
correlation_id: string;
}
interface ReplyToThreadRequest {
threadId: string;
content: string;
parent_id?: string;
}
interface ReactToForumItemRequest {
emoji: string;
thread_id?: string;
reply_id?: string;
}
interface ForumActionResponse {
success: boolean;
status: number;
message: string;
data: unknown;
errors: unknown;
correlation_id: string;
}
const API_BASE_URL = import.meta.env.VITE_API_URL;
export const forumApi = createApi({
reducerPath: 'forumApi',
tagTypes: ['Threads', 'Replies'],
baseQuery: fetchBaseQuery({
baseUrl: API_BASE_URL,
prepareHeaders: (headers) => {
const token = localStorage.getItem('token');
if (token) {
headers.set('authorization', `Bearer ${token}`);
}
return headers;
},
}),
endpoints: (builder) => ({
getThreads: builder.query<ForumThreadsResponse, void>({
query: () => ({
url: '/hr/forum/threads',
method: 'GET',
}),
providesTags: ['Threads'],
}),
createThread: builder.mutation<ForumThreadsResponse, CreateThreadRequest>({
query: (payload) => ({
url: '/hr/forum/threads',
method: 'POST',
body: payload,
}),
invalidatesTags: ['Threads'],
}),
getRepliesByThread: builder.query<ForumRepliesResponse, string>({
query: (threadId) => ({
url: `/hr/forum/threads/${threadId}/replies`,
method: 'GET',
}),
providesTags: (_result, _error, threadId) => [{ type: 'Replies', id: threadId }],
}),
replyToThread: builder.mutation<ForumActionResponse, ReplyToThreadRequest>({
query: ({ threadId, content, parent_id }) => ({
url: `/hr/forum/threads/${threadId}/reply`,
method: 'POST',
body: {
content,
parent_id: parent_id || undefined,
},
}),
invalidatesTags: (_result, _error, arg) => [{ type: 'Replies', id: arg.threadId }],
}),
reactToForumItem: builder.mutation<ForumActionResponse, ReactToForumItemRequest>({
query: (payload) => ({
url: '/hr/forum/reactions',
method: 'POST',
body: payload,
}),
invalidatesTags: ['Threads', 'Replies'],
}),
}),
});
export const {
useGetThreadsQuery,
useCreateThreadMutation,
useGetRepliesByThreadQuery,
useReplyToThreadMutation,
useReactToForumItemMutation,
} = forumApi;

View File

@@ -0,0 +1,555 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export interface LearnersForHrQueryParams {
limit: number;
offset: number;
search_term?: string;
status?: string;
from_date?: string;
to_date?: string;
}
interface LearnerPrincipalType {
id: string;
type_name: string;
type_code: string;
display_order: number;
is_active: boolean;
}
interface LearnerOrganization {
id: string;
company_name: string;
company_phone_number: string;
phone_country_code: string;
address: string;
remark: string;
notes: string | null;
joined_date: string;
is_draft: boolean;
}
export interface LearnerItem {
id: string;
first_name: string;
last_name: string;
email_address: string;
phone_country_code: string;
phone_number: string;
profile_image_url: string | null;
joined_date: string;
is_active: boolean;
principal_type: LearnerPrincipalType;
principal_organization: LearnerOrganization;
}
interface LearnersForHrData {
total_count: number;
limit: number;
offset: number;
items: LearnerItem[];
}
export interface LearnersForHrResponse {
success: boolean;
status: number;
message: string;
data: LearnersForHrData;
errors: unknown;
correlation_id: string;
}
export interface CreateLearnerRequest {
first_name: string;
last_name: string;
email_address: string;
phone_country_code: string;
phone_number: string;
}
export interface CreateLearnerResponse {
success: boolean;
status: number;
message: string;
data: LearnerItem;
errors: unknown;
correlation_id: string;
}
export interface BulkCreateLearnersResponse {
success: boolean;
status: number;
message: string;
data: LearnerItem[];
errors: unknown;
correlation_id: string;
}
interface ExistsResponse {
success: boolean;
status: number;
message: string;
data: {
exists: boolean;
};
errors: unknown;
correlation_id: string;
}
export interface ProgrammeListQueryParams {
limit: number;
offset: number;
search_term?: string;
programme_status?: string;
public_status?: string;
}
interface ProgrammeItem {
id: string;
programme_title: string;
programme_owner_xid: string | null;
programme_summary: string;
public_status: string;
start_date: string;
end_date: string;
programme_status: string;
is_active: boolean;
created_at: string;
updated_at: string;
created_by: string | null;
updated_by: string | null;
is_deleted: boolean;
deleted_at: string | null;
deleted_by: string | null;
}
interface ProgrammeListResponse {
success: boolean;
status: number;
message: string;
data: ProgrammeItem[];
errors: unknown;
correlation_id: string;
}
interface BulkAssignProgrammeRequest {
principal_xids: string[];
programme_xids: string[];
start_date: string;
end_date: string;
}
interface BulkAssignProgrammeResponse {
success: boolean;
status: number;
message: string;
data: unknown;
errors: unknown;
correlation_id: string;
}
export interface CourseListQueryParams {
limit: number;
offset: number;
search_query?: string;
course_category?: string[];
price_range?: string;
duration_range?: string;
min_rating?: number;
sort_by?: string;
}
export interface CourseItem {
id: string;
course_name: string;
course_desc: string;
thumbnail_img: string | null;
course_category_xid: string;
course_category_name: string;
best_value: number;
avg_rating: number;
total_reviews: number;
retail_type: string;
price: number;
is_certificate_available: boolean;
course_status: string;
updated_at: string;
total_duration: number | null;
no_of_modules: number;
media_id: string | null;
media_file_type: string | null;
media_file_extension: string | null;
media_file_name: string | null;
}
interface CourseListResponse {
success: boolean;
status: number;
message: string;
data: {
pagination_info: {
total_count: number;
limit: number;
offset: number;
applied_filters: {
status: string | null;
course_category_xid: string | null;
content_types_xid: string | null;
search_query: string | null;
price_range: string | null;
duration_range: string | null;
min_rating: number | null;
sort_by: string | null;
};
};
items: CourseItem[];
};
errors: unknown;
correlation_id: string;
}
interface BulkAssignCourseRequest {
principal_xids: string[];
course_xids: string[];
start_date: string;
end_date: string;
principal_organization_course_link_xid?: string;
}
interface BulkAssignCourseResponse {
success: boolean;
status: number;
message: string;
data: unknown;
errors: unknown;
correlation_id: string;
}
interface BulkRevokeCourseRequest {
principal_xids: string[];
course_xids: string[];
principal_organization_course_link_xid?: string;
}
interface BulkRevokeCourseResponse {
success: boolean;
status: number;
message: string;
data: unknown;
errors: unknown;
correlation_id: string;
}
interface AssignedCoursesResponse {
success: boolean;
status: number;
message: string;
data: CourseItem[];
errors: unknown;
correlation_id: string;
}
interface LearnerCourseMappingItem {
id: string;
principal_xid: string;
first_name: string;
last_name: string;
company_name: string;
course_xid: string;
course_name: string;
course_desc: string;
is_hr: boolean | null;
principal_organization_course_link_xid: string | null;
}
interface LearnerCoursesResponse {
success: boolean;
status: number;
message: string;
data: LearnerCourseMappingItem[];
errors: unknown;
correlation_id: string;
}
interface UpdateLearnerRequest {
id: string;
first_name: string;
last_name: string;
phone_country_code: string;
phone_number: string;
}
interface UpdateLearnerResponse {
success: boolean;
status: number;
message: string;
data: LearnerItem;
errors: unknown;
correlation_id: string;
}
export interface AssignedHrCoursesQueryParams {
limit: number;
start: number;
search_query?: string;
status?: 'inprogress' | 'completed' | 'new';
}
export interface AssignedHrCourseItem {
id: string;
course_name: string;
course_description: string;
thumbnail_url: string;
total_duration: number;
total_learners: number;
avg_progress: number;
status: 'inprogress' | 'completed' | 'new';
}
interface AssignedHrCoursesResponse {
success: boolean;
status: number;
message: string;
data: {
course_items: AssignedHrCourseItem[];
pagination: {
total: number;
limit: number;
start: number;
has_next: boolean;
};
};
errors: unknown;
correlation_id: string;
}
export interface AssignedHrProgrammesQueryParams {
limit: number;
start: number;
}
export interface AssignedHrProgrammeItem {
id: string;
programme_title: string;
end_date: string;
courses: number;
webinars: number;
resources: number;
classes: number;
progress: number | null;
}
interface AssignedHrProgrammesResponse {
success: boolean;
status: number;
message: string;
data: {
programme_items: AssignedHrProgrammeItem[];
pagination: {
total: number;
limit: number;
start: number;
has_next: boolean;
};
};
errors: unknown;
correlation_id: string;
}
const API_BASE_URL = import.meta.env.VITE_API_URL;
export const learnersApi = createApi({
reducerPath: 'learnersApi',
tagTypes: ['Learners'],
baseQuery: fetchBaseQuery({
baseUrl: API_BASE_URL,
prepareHeaders: (headers) => {
const token = localStorage.getItem('token');
if (token) {
headers.set('authorization', `Bearer ${token}`);
}
return headers;
},
}),
endpoints: (builder) => ({
getLearnersForHr: builder.query<LearnersForHrResponse, LearnersForHrQueryParams>({
query: (params) => ({
url: '/hr/learners/learners_for_hr',
method: 'GET',
params: {
limit: params.limit,
offset: params.offset,
status: params.status ?? 'all',
search_term: params.search_term || undefined,
from_date: params.from_date || undefined,
to_date: params.to_date || undefined,
},
}),
providesTags: ['Learners'],
}),
createLearner: builder.mutation<CreateLearnerResponse, CreateLearnerRequest>({
query: (payload) => ({
url: '/hr/learners/create',
method: 'POST',
body: payload,
}),
invalidatesTags: ['Learners'],
}),
bulkCreateLearnersForHr: builder.mutation<
BulkCreateLearnersResponse,
CreateLearnerRequest[]
>({
query: (payload) => ({
url: '/hr/learners/bulk-create-learners-for-hr',
method: 'POST',
body: payload,
}),
invalidatesTags: ['Learners'],
}),
checkEmailExists: builder.query<ExistsResponse, string>({
query: (email) => ({
url: '/hr/learners/check-email-exists',
method: 'GET',
params: { email },
}),
}),
checkMobileExists: builder.query<
ExistsResponse,
{ phone_country_code: string; phone_number: string }
>({
query: ({ phone_country_code, phone_number }) => ({
url: '/hr/learners/check-mobile-exists',
method: 'GET',
params: { phone_country_code, phone_number },
}),
}),
getProgrammesForHr: builder.query<ProgrammeListResponse, ProgrammeListQueryParams>({
query: (params) => ({
url: '/hr/organization/list/assigned-programmes',
method: 'GET',
params: {
limit: params.limit,
offset: params.offset,
search_term: params.search_term || undefined,
programme_status: params.programme_status || undefined,
public_status: params.public_status || undefined,
},
}),
}),
bulkAssignProgramme: builder.mutation<
BulkAssignProgrammeResponse,
BulkAssignProgrammeRequest
>({
query: (payload) => ({
url: '/hr/organization/bulk-assign-programme',
method: 'POST',
body: payload,
}),
invalidatesTags: ['Learners'],
}),
getCoursesForHr: builder.query<CourseListResponse, CourseListQueryParams>({
query: (params) => ({
url: '/hr/programme-course/course/list',
method: 'GET',
params: {
limit: params.limit,
offset: params.offset,
search_query: params.search_query || undefined,
course_category: params.course_category?.length ? params.course_category : undefined,
price_range: params.price_range || undefined,
duration_range: params.duration_range || undefined,
min_rating: params.min_rating ?? undefined,
sort_by: params.sort_by || undefined,
},
}),
}),
bulkAssignCourse: builder.mutation<BulkAssignCourseResponse, BulkAssignCourseRequest>({
query: (payload) => ({
url: '/hr/organization/bulk-assign-course',
method: 'POST',
body: payload,
}),
invalidatesTags: ['Learners'],
}),
bulkRevokeCourse: builder.mutation<BulkRevokeCourseResponse, BulkRevokeCourseRequest>({
query: (payload) => ({
url: '/hr/learners/bulk-revoke-course',
method: 'POST',
body: payload,
}),
invalidatesTags: ['Learners'],
}),
getAssignedCoursesForOrganization: builder.query<
AssignedCoursesResponse,
{ limit: number; offset: number; search_query?: string }
>({
query: (params) => ({
url: '/hr/organization/list/assigned-courses',
method: 'GET',
params: {
limit: params.limit,
offset: params.offset,
search_query: params.search_query || undefined,
},
}),
}),
getLearnerCourses: builder.query<LearnerCoursesResponse, string>({
query: (learnerId) => ({
url: `/hr/learners/courses/${learnerId}`,
method: 'GET',
}),
providesTags: ['Learners'],
}),
updateLearner: builder.mutation<UpdateLearnerResponse, UpdateLearnerRequest>({
query: (payload) => ({
url: '/hr/learners/update-learner',
method: 'POST',
body: payload,
}),
invalidatesTags: ['Learners'],
}),
getAssignedHrCourses: builder.query<AssignedHrCoursesResponse, AssignedHrCoursesQueryParams>({
query: (params) => ({
url: '/hr/organization/assigned-courses/hr',
method: 'GET',
params: {
limit: params.limit,
start: params.start,
search_query: params.search_query || undefined,
status: params.status || undefined,
},
}),
}),
getAssignedHrProgrammes: builder.query<
AssignedHrProgrammesResponse,
AssignedHrProgrammesQueryParams
>({
query: (params) => ({
url: '/hr/organization/assigned-programmes/hr',
method: 'GET',
params: {
limit: params.limit,
start: params.start,
},
}),
}),
}),
});
export const {
useGetLearnersForHrQuery,
useCreateLearnerMutation,
useBulkCreateLearnersForHrMutation,
useLazyCheckEmailExistsQuery,
useLazyCheckMobileExistsQuery,
useGetProgrammesForHrQuery,
useBulkAssignProgrammeMutation,
useGetCoursesForHrQuery,
useBulkAssignCourseMutation,
useBulkRevokeCourseMutation,
useGetAssignedCoursesForOrganizationQuery,
useGetLearnerCoursesQuery,
useUpdateLearnerMutation,
useGetAssignedHrCoursesQuery,
useGetAssignedHrProgrammesQuery,
} = learnersApi;

View File

@@ -0,0 +1,52 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export interface LoginCredentials {
email_address: string;
password: string;
}
export interface UserInfo {
id: string;
principal_organization_xid: string;
principal_organization_name: string;
email_address: string;
first_name: string;
last_name: string;
display_name: string;
principal_type_xid: string;
principal_type_code: string;
is_active: boolean;
}
export interface LoginResponse {
success: boolean;
status: number;
message: string;
data: {
access_token: string;
token_type: string;
user_info: UserInfo;
};
errors: null | any;
correlation_id: string;
}
const API_BASE_URL = import.meta.env.VITE_API_URL
export const loginApi = createApi({
reducerPath: 'loginApi',
baseQuery: fetchBaseQuery({
baseUrl: API_BASE_URL,
}),
endpoints: (builder) => ({
login: builder.mutation<LoginResponse, LoginCredentials>({
query: (credentials) => ({
url: '/auth/hr/login',
method: 'POST',
body: credentials,
}),
}),
}),
});
export const useLoginMutation = loginApi.useLoginMutation;

17
src/redux/store.ts Normal file
View File

@@ -0,0 +1,17 @@
import { configureStore } from '@reduxjs/toolkit';
import { loginApi } from './services/loginApi';
import { learnersApi } from './services/learnersApi';
import { forumApi } from './services/forumApi';
export const store = configureStore({
reducer: {
[loginApi.reducerPath]: loginApi.reducer,
[learnersApi.reducerPath]: learnersApi.reducer,
[forumApi.reducerPath]: forumApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(loginApi.middleware, learnersApi.middleware, forumApi.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@@ -1,13 +0,0 @@
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;

View File

@@ -0,0 +1,14 @@
import { Navigate, useLocation } from 'react-router-dom';
import type { ReactNode } from 'react';
import { useAuth } from '../context/AuthContext';
export function ProtectedRoute({ children }: { children: ReactNode }) {
const { isAuthenticated } = useAuth();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
}

12
src/routes/RootLayout.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { Outlet } from 'react-router-dom';
import { AuthProvider } from '../context/AuthContext';
import { Toaster } from '../components/ui/sonner';
export function RootLayout() {
return (
<AuthProvider>
<Outlet />
<Toaster />
</AuthProvider>
);
}

79
src/routes/index.tsx Normal file
View File

@@ -0,0 +1,79 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { RootLayout } from './RootLayout';
import { ProtectedRoute } from './ProtectedRoute';
import HRLayout from '../layouts/HRLayout';
import Login from '../pages/Login';
import DashboardPage from '../pages/Dashboard/DashboardPage';
import LearnersPage from '../pages/Learners/LearnersPage';
import ReportsPage from '../pages/ReportsPage/ReportsPage';
import DiscussionsPage from '../pages/DiscussionsPage/DiscussionsPage';
import ProgrammeViewPage from '../pages/ProgrammeViewPage/ProgrammeViewPage';
import CourseViewPage from '../pages/CourseViewPage/CourseViewPage';
import CoursesPage from '../pages/Courses/CoursesPage';
import ProgrammesPage from '../pages/Programmes/ProgrammesPage';
export const router = createBrowserRouter([
{
element: <RootLayout />,
children: [
{
path: '/login',
element: <Login />,
},
{
path: '/',
element: <Navigate to="/hr/dashboard" replace />,
},
{
path: '/hr',
element: (
<ProtectedRoute>
<HRLayout />
</ProtectedRoute>
),
children: [
{
index: true,
element: <Navigate to="dashboard" replace />,
},
{
path: 'dashboard',
element: <DashboardPage />,
},
{
path: 'learners',
element: <LearnersPage />,
},
{
path: 'reports',
element: <ReportsPage />,
},
{
path: 'courses',
element: <CoursesPage />,
},
{
path: 'programmes',
element: <ProgrammesPage />,
},
{
path: 'discussions',
element: <DiscussionsPage />,
},
{
path: 'programme/:programmeId',
element: <ProgrammeViewPage />,
},
{
path: 'course/:courseId',
element: <CourseViewPage />,
},
{
path: 'profile',
element: <DashboardPage />,
},
],
},
],
},
]);

View File

@@ -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,30 +62,19 @@
--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 */
@@ -94,22 +83,14 @@
--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;
@@ -415,55 +396,28 @@
}
@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,
@@ -546,8 +500,8 @@
/* Utility classes for HR Portal */
@layer utilities {
.min-tap-44 {
min-height: 44px;
min-width: 44px;
min-height: 12px;
min-width: 12px;
}
.sticky-header {
@@ -564,96 +518,18 @@
}
.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;
}
}
}
/* 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;
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
}

View File

@@ -1,47 +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 TestimonialFormData {
name: string;
email: string;
phone: string;
organisation: string;
programme: string;
testimonialText: string;
consentToPublish: boolean;
}

135
src/types/index.ts Normal file
View File

@@ -0,0 +1,135 @@
export interface KPIData {
title: string;
value: number;
change?: number;
trend?: 'up' | 'down' | 'neutral';
}
export interface Employee {
id: string;
name: string;
email: string;
phone: string;
status: 'Active' | 'Inactive' | 'Pending';
programme?: string;
course?: string;
progress?: number;
lastActivity?: string;
}
export interface Announcement {
id: string;
title: string;
content: string;
type: 'announcement' | 'reminder';
timestamp: string;
pinned?: boolean;
}
export interface Deadline {
id: string;
title: string;
type: 'webinar' | 'profiler';
dueDate: string;
dueTime: string;
}
export interface Cohort {
id: string;
name: string;
memberCount: number;
programme?: string;
isActive: boolean;
}
export interface Thread {
id: string;
title: string;
content: string;
author: {
id: string;
name: string;
avatar?: string;
};
cohortId: string;
createdAt: string;
lastActivity: string;
replyCount: number;
isPinned?: boolean;
tags?: string[];
reactions?: { [key: string]: string[] };
}
export interface Post {
id: string;
threadId: string;
content: string;
author: {
id: string;
name: string;
avatar?: string;
};
createdAt: string;
editedAt?: string;
reactions?: { [key: string]: string[] };
isReported?: boolean;
parentId?: string;
}
export interface TestimonialFormData {
name: string;
email: string;
phone: string;
organisation: string;
programme: string;
testimonialText: string;
consentToPublish: boolean;
}
export interface Programme {
programmeId: string;
title: string;
status: 'Active' | 'Upcoming' | 'Completed';
coursesCount: number;
contentCount: number;
assignment: {
startDate: Date;
endDate: Date;
};
learnersAssigned: number;
}
export interface Course {
id: string;
title: string;
status: 'Published' | 'Draft' | 'Archived';
code: string;
owner: string;
version: number;
duration: string;
description: string;
objectives: string[];
tags: string[];
modules: CourseModule[];
linkedProgrammes: LinkedProgramme[];
}
export interface CourseModule {
id: string;
title: string;
lessons: CourseLesson[];
}
export interface CourseLesson {
id: string;
title: string;
type: 'video' | 'quiz' | 'read' | 'assignment';
eta: string;
dueDate?: string;
status?: 'Not Started' | 'In Progress' | 'Completed';
}
export interface LinkedProgramme {
id: string;
title: string;
}

235
src/utils/mockData.ts Normal file
View File

@@ -0,0 +1,235 @@
import { Employee, Announcement, Deadline, Cohort, Thread, Post, Programme, Course } from '../types';
export const mockKPIData = [
{ title: 'Total Learners', value: 1247, change: 12, trend: 'up' as const },
{ title: 'Active Courses', value: 89, change: 5, trend: 'up' as const },
{ title: 'Completed Profilers', value: 342, change: -8, trend: 'down' as const },
{ title: 'Average Progress', value: 73, change: 7, trend: 'up' as const }
];
export const mockEmployees: Employee[] = [
{ id: '1', name: 'Sarah Chen', email: 'sarah.chen@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Strategic Thinking', progress: 85, lastActivity: '2 hours ago' },
{ id: '2', name: 'Michael Rodriguez', email: 'michael.r@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Technical Skills', course: 'Data Analysis', progress: 62, lastActivity: '1 day ago' },
{ id: '3', name: 'Emma Thompson', email: 'emma.thompson@company.com', phone: '+61 4XX XXX XXX', status: 'Pending', programme: 'Communication', course: 'Public Speaking', progress: 0, lastActivity: 'Never' },
{ id: '4', name: 'David Kim', email: 'david.kim@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Project Management', course: 'Agile Methodology', progress: 94, lastActivity: '3 hours ago' },
{ id: '5', name: 'Lisa Wang', email: 'lisa.wang@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Team Management', progress: 78, lastActivity: '5 hours ago' },
{ id: '6', name: 'James Wilson', email: 'james.wilson@company.com', phone: '+61 4XX XXX XXX', status: 'Inactive', programme: 'Technical Skills', course: 'Programming Basics', progress: 34, lastActivity: '2 weeks ago' },
{ id: '7', name: 'Maria Garcia', email: 'maria.garcia@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Sales Training', course: 'Customer Relations', progress: 56, lastActivity: '1 day ago' },
{ id: '8', name: 'Robert Lee', email: 'robert.lee@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Decision Making', progress: 89, lastActivity: '4 hours ago' },
{ id: '9', name: 'Jennifer Davis', email: 'jennifer.davis@company.com', phone: '+61 4XX XXX XXX', status: 'Pending', programme: 'Communication', course: 'Written Communication', progress: 0, lastActivity: 'Never' },
{ id: '10', name: 'Thomas Brown', email: 'thomas.brown@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Project Management', course: 'Risk Management', progress: 71, lastActivity: '6 hours ago' }
];
export const mockAnnouncements: Announcement[] = [
{ id: '1', title: 'New Learning Module Available', content: 'Advanced Analytics course is now live in the system.', type: 'announcement', timestamp: '2 hours ago', pinned: true },
{ id: '2', title: 'Reminder: Quarterly Reviews Due', content: 'Please complete all quarterly progress reviews by Friday.', type: 'reminder', timestamp: '5 hours ago' },
{ id: '3', title: 'System Maintenance Scheduled', content: 'Learning platform will be offline Saturday 2-4 AM for updates.', type: 'announcement', timestamp: '1 day ago' }
];
export const mockDeadlines: Deadline[] = [
{ id: '1', title: 'Leadership Webinar Series', type: 'webinar', dueDate: 'Today', dueTime: '2:00 PM' },
{ id: '2', title: 'Communication Skills Assessment', type: 'profiler', dueDate: 'Tomorrow', dueTime: '11:59 PM' },
{ id: '3', title: 'Project Management Workshop', type: 'webinar', dueDate: 'Dec 30', dueTime: '10:00 AM' },
{ id: '4', title: 'Technical Skills Profiler', type: 'profiler', dueDate: 'Jan 2', dueTime: '5:00 PM' },
{ id: '5', title: 'Team Building Session', type: 'webinar', dueDate: 'Jan 5', dueTime: '3:30 PM' }
];
export const mockCohorts: Cohort[] = [
{ id: '1', name: 'Leadership Development Q4 2024', memberCount: 30, programme: 'Leadership Development', isActive: true },
{ id: '2', name: 'Technical Skills Cohort A', memberCount: 25, programme: 'Technical Skills', isActive: true },
{ id: '3', name: 'Communication Workshop Group', memberCount: 18, programme: 'Communication', isActive: true },
{ id: '4', name: 'Project Management Certification', memberCount: 22, programme: 'Project Management', isActive: false }
];
export const mockThreads: Thread[] = [
{
id: '1',
title: 'Best practices for team communication during remote work',
content: 'What strategies have you found most effective for maintaining clear communication with remote team members? I\'d love to hear about tools and techniques that have worked well for your teams.',
author: { id: 'user1', name: 'Sarah Chen' },
cohortId: '1',
createdAt: '2024-12-28T10:30:00Z',
lastActivity: '2024-12-28T15:45:00Z',
replyCount: 12,
isPinned: true,
tags: ['communication', 'remote-work', 'best-practices'],
reactions: { '👍': ['user2', 'user3'], '💡': ['user4'] }
},
{
id: '2',
title: 'How to handle difficult conversations with team members?',
content: 'I\'m struggling with addressing performance issues with one of my team members. Any advice on how to approach this sensitively while being direct about expectations?',
author: { id: 'user2', name: 'Michael Rodriguez' },
cohortId: '1',
createdAt: '2024-12-28T09:15:00Z',
lastActivity: '2024-12-28T14:20:00Z',
replyCount: 8,
tags: ['difficult-conversations', 'performance-management'],
reactions: { '🤔': ['user1', 'user5'], '💪': ['user3'] }
},
{
id: '3',
title: 'Share your leadership book recommendations',
content: 'What books have been most influential in your leadership journey? Looking for practical reads that offer actionable insights.',
author: { id: 'user3', name: 'Emma Thompson' },
cohortId: '1',
createdAt: '2024-12-27T16:00:00Z',
lastActivity: '2024-12-28T11:30:00Z',
replyCount: 15,
tags: ['books', 'recommendations', 'learning'],
reactions: { '📚': ['user1', 'user2', 'user4', 'user5'], '⭐': ['user6'] }
},
{
id: '4',
title: 'Question about the delegation framework from Module 3',
content: 'Can someone clarify the difference between the delegation levels we covered? I want to make sure I\'m applying them correctly in my current projects.',
author: { id: 'user4', name: 'David Kim' },
cohortId: '1',
createdAt: '2024-12-27T14:30:00Z',
lastActivity: '2024-12-27T18:45:00Z',
replyCount: 6,
tags: ['module-3', 'delegation', 'clarification'],
reactions: { '❓': ['user2'], '👍': ['user1'] }
}
];
export const mockPosts: Post[] = [
{
id: '1',
threadId: '1',
content: 'Great question! I\'ve found that establishing clear communication protocols at the start of projects makes a huge difference. We use a combination of daily stand-ups via video call and async updates through Slack.',
author: { id: 'user5', name: 'Lisa Wang' },
createdAt: '2024-12-28T11:00:00Z',
reactions: { '👍': ['user1', 'user2'], '💯': ['user3'] }
},
{
id: '2',
threadId: '1',
content: 'One thing that\'s worked well for our team is having "communication preferences" documented for each team member. Some prefer quick calls for complex topics, others prefer detailed written explanations. Knowing this upfront prevents a lot of miscommunication.',
author: { id: 'user6', name: 'Robert Lee' },
createdAt: '2024-12-28T12:15:00Z',
reactions: { '💡': ['user1', 'user4'], '👏': ['user2'] }
},
{
id: '3',
threadId: '1',
content: '@Sarah Chen Thanks for starting this discussion! I\'d add that regular one-on-ones have been crucial for me. Even in remote settings, that personal connection makes a big difference in team dynamics.',
author: { id: 'user7', name: 'Jennifer Davis' },
createdAt: '2024-12-28T13:30:00Z',
reactions: { '🎯': ['user1'], '👍': ['user5'] }
}
];
export const mockProgrammes: Programme[] = [
{
programmeId: 'prog-001',
title: 'Leadership Development Program',
status: 'Active',
coursesCount: 8,
contentCount: 24,
assignment: {
startDate: new Date('2024-01-15'),
endDate: new Date('2024-06-30')
},
learnersAssigned: 45
},
{
programmeId: 'prog-002',
title: 'Technical Skills Bootcamp',
status: 'Active',
coursesCount: 12,
contentCount: 36,
assignment: {
startDate: new Date('2024-02-01'),
endDate: new Date('2024-08-31')
},
learnersAssigned: 38
},
{
programmeId: 'prog-003',
title: 'Communication Excellence',
status: 'Upcoming',
coursesCount: 6,
contentCount: 18,
assignment: {
startDate: new Date('2024-03-01'),
endDate: new Date('2024-05-31')
},
learnersAssigned: 28
},
{
programmeId: 'prog-004',
title: 'Project Management Certification',
status: 'Active',
coursesCount: 10,
contentCount: 30,
assignment: {
startDate: new Date('2024-01-01'),
endDate: new Date('2024-12-31')
},
learnersAssigned: 52
},
{
programmeId: 'prog-005',
title: 'Digital Marketing Mastery',
status: 'Completed',
coursesCount: 5,
contentCount: 15,
assignment: {
startDate: new Date('2023-09-01'),
endDate: new Date('2023-12-31')
},
learnersAssigned: 32
}
];
export const mockCourse: Course = {
id: 'crs_456',
title: 'Strategic Thinking and Decision Making',
status: 'Published',
code: 'STDM-2024',
owner: 'Prof. Michael Chen',
version: 1,
duration: '6 hours',
description: 'This course develops strategic thinking capabilities and decision-making frameworks for leaders at all levels. Participants will learn to analyze complex situations, evaluate options, and make informed decisions.',
objectives: [
'Apply strategic thinking frameworks to business challenges',
'Develop systematic approaches to decision making',
'Evaluate risks and opportunities effectively',
'Create actionable strategic plans'
],
tags: ['Strategy', 'Leadership', 'Decision Making', 'Critical Thinking'],
modules: [
{
id: 'm1',
title: 'Foundations of Strategic Thinking',
lessons: [
{ id: 'l1', title: 'Introduction to Strategic Thinking', type: 'video', eta: '15 mins', status: 'Completed' },
{ id: 'l2', title: 'Strategic Frameworks Overview', type: 'read', eta: '20 mins', status: 'Completed' },
{ id: 'l3', title: 'Knowledge Check', type: 'quiz', eta: '10 mins', status: 'In Progress' }
]
},
{
id: 'm2',
title: 'Decision Making Models',
lessons: [
{ id: 'l4', title: 'Rational Decision Making', type: 'video', eta: '25 mins', status: 'Not Started' },
{ id: 'l5', title: 'Intuitive vs Analytical Approaches', type: 'read', eta: '15 mins', status: 'Not Started' },
{ id: 'l6', title: 'Case Study Analysis', type: 'assignment', eta: '45 mins', dueDate: '2024-01-25', status: 'Not Started' }
]
},
{
id: 'm3',
title: 'Risk Assessment and Management',
lessons: [
{ id: 'l7', title: 'Risk Identification Techniques', type: 'video', eta: '20 mins', status: 'Not Started' },
{ id: 'l8', title: 'Risk Matrix and Evaluation', type: 'read', eta: '25 mins', status: 'Not Started' },
{ id: 'l9', title: 'Final Assessment', type: 'quiz', eta: '30 mins', dueDate: '2024-01-30', status: 'Not Started' }
]
}
],
linkedProgrammes: [
{ id: 'prg_123', title: 'Executive Leadership Development Programme' },
{ id: 'prg_124', title: 'Management Excellence Programme' }
]
};

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -1,7 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import path from 'path';
import * as path from 'path';
export default defineConfig({
plugins: [react()],
@@ -54,7 +54,7 @@
outDir: 'build',
},
server: {
port: 3000,
port: 3005,
open: true,
},
});