all src changes with new one
This commit is contained in:
2715
src/App.tsx
2715
src/App.tsx
File diff suppressed because it is too large
Load Diff
2343
src/App_new.tsx
Normal file
2343
src/App_new.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 14 KiB |
379
src/components/ActiveProgrammesTable.tsx
Normal file
379
src/components/ActiveProgrammesTable.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Input } from './ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Download,
|
||||
Users,
|
||||
Calendar,
|
||||
BookOpen,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
UserPlus,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Assignment {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
interface Programme {
|
||||
programmeId: string;
|
||||
title: string;
|
||||
status: 'Active' | 'Upcoming' | 'Completed';
|
||||
coursesCount: number;
|
||||
contentCount: number;
|
||||
assignment: Assignment;
|
||||
learnersAssigned: number;
|
||||
}
|
||||
|
||||
interface ProgrammesTableProps {
|
||||
programmes?: Programme[];
|
||||
onViewProgramme?: (programmeId: string) => void;
|
||||
onAssignLearners?: (programmeId: string) => void;
|
||||
onDownloadTracker?: (programmeId: string) => void;
|
||||
}
|
||||
|
||||
const mockProgrammes: Programme[] = [
|
||||
{
|
||||
programmeId: 'prog-001',
|
||||
title: 'Leadership Development Program',
|
||||
status: 'Active',
|
||||
coursesCount: 8,
|
||||
contentCount: 24,
|
||||
assignment: {
|
||||
startDate: new Date('2024-01-15'),
|
||||
endDate: new Date('2024-06-30')
|
||||
},
|
||||
learnersAssigned: 45
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-002',
|
||||
title: 'Technical Skills Bootcamp',
|
||||
status: 'Active',
|
||||
coursesCount: 12,
|
||||
contentCount: 36,
|
||||
assignment: {
|
||||
startDate: new Date('2024-02-01'),
|
||||
endDate: new Date('2024-08-31')
|
||||
},
|
||||
learnersAssigned: 38
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-003',
|
||||
title: 'Communication Excellence',
|
||||
status: 'Upcoming',
|
||||
coursesCount: 6,
|
||||
contentCount: 18,
|
||||
assignment: {
|
||||
startDate: new Date('2024-03-01'),
|
||||
endDate: new Date('2024-05-31')
|
||||
},
|
||||
learnersAssigned: 28
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-004',
|
||||
title: 'Project Management Certification',
|
||||
status: 'Active',
|
||||
coursesCount: 10,
|
||||
contentCount: 30,
|
||||
assignment: {
|
||||
startDate: new Date('2024-01-01'),
|
||||
endDate: new Date('2024-12-31')
|
||||
},
|
||||
learnersAssigned: 52
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-005',
|
||||
title: 'Digital Marketing Mastery',
|
||||
status: 'Completed',
|
||||
coursesCount: 5,
|
||||
contentCount: 15,
|
||||
assignment: {
|
||||
startDate: new Date('2023-09-01'),
|
||||
endDate: new Date('2023-12-31')
|
||||
},
|
||||
learnersAssigned: 32
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-006',
|
||||
title: 'Data Analytics Fundamentals',
|
||||
status: 'Completed',
|
||||
coursesCount: 7,
|
||||
contentCount: 21,
|
||||
assignment: {
|
||||
startDate: new Date('2023-10-15'),
|
||||
endDate: new Date('2024-01-15')
|
||||
},
|
||||
learnersAssigned: 29
|
||||
}
|
||||
];
|
||||
|
||||
export const ProgrammesTable: React.FC<ProgrammesTableProps> = ({
|
||||
programmes = mockProgrammes,
|
||||
onViewProgramme,
|
||||
onAssignLearners,
|
||||
onDownloadTracker
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('Active');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isAssignModalOpen, setIsAssignModalOpen] = useState(false);
|
||||
const [selectedProgramme, setSelectedProgramme] = useState<Programme | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const itemsPerPage = 5;
|
||||
|
||||
const filteredProgrammes = programmes.filter(prog => {
|
||||
const matchesSearch = prog.title.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || prog.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
// Pagination logic
|
||||
const totalPages = Math.ceil(filteredProgrammes.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedProgrammes = filteredProgrammes.slice(startIndex, endIndex);
|
||||
|
||||
// Reset to first page when filters change
|
||||
React.useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, statusFilter]);
|
||||
|
||||
const formatDateRange = (assignment: Assignment) => {
|
||||
const startDate = assignment.startDate.toLocaleDateString('en-AU', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
const endDate = assignment.endDate.toLocaleDateString('en-AU', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
return `${startDate} → ${endDate}`;
|
||||
};
|
||||
|
||||
const handleViewProgramme = (programmeId: string) => {
|
||||
onViewProgramme?.(programmeId);
|
||||
console.log(`Viewing programme: ${programmeId}`);
|
||||
};
|
||||
|
||||
const handleAssignLearners = (programme: Programme) => {
|
||||
setSelectedProgramme(programme);
|
||||
setIsAssignModalOpen(true);
|
||||
onAssignLearners?.(programme.programmeId);
|
||||
};
|
||||
|
||||
const handleDownloadTracker = async (programmeId: string) => {
|
||||
setIsExporting(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setIsExporting(false);
|
||||
onDownloadTracker?.(programmeId);
|
||||
console.log(`Downloaded tracker for programme: ${programmeId}`);
|
||||
};
|
||||
|
||||
const getStatusBadgeProps = (status: Programme['status']) => {
|
||||
switch (status) {
|
||||
case 'Active':
|
||||
return { variant: 'default' as const, className: 'bg-status-success text-status-success-foreground' };
|
||||
case 'Upcoming':
|
||||
return { variant: 'secondary' as const, className: 'bg-status-warn text-status-warn-foreground' };
|
||||
case 'Completed':
|
||||
return { variant: 'outline' as const, className: 'bg-muted text-muted-foreground' };
|
||||
default:
|
||||
return { variant: 'secondary' as const };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Programmes</CardTitle>
|
||||
<CardDescription>Manage programme assignments and track progress</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search programmes..."
|
||||
className="pl-10 w-[200px]"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
aria-label="Search programmes by title"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="Active">Active</SelectItem>
|
||||
<SelectItem value="Upcoming">Upcoming</SelectItem>
|
||||
<SelectItem value="Completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border" style={{ maxWidth: '1200px' }}>
|
||||
<Table>
|
||||
<TableHeader className="sticky-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[300px]">Programme Title</TableHead>
|
||||
<TableHead className="w-[150px]">Courses / Content</TableHead>
|
||||
<TableHead className="w-[200px]">Start → End</TableHead>
|
||||
<TableHead className="w-[120px]">Learners Assigned</TableHead>
|
||||
<TableHead className="w-[200px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedProgrammes.map((programme) => (
|
||||
<TableRow key={programme.programmeId} className="min-h-[44px]">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{programme.title}</span>
|
||||
<Badge
|
||||
{...getStatusBadgeProps(programme.status)}
|
||||
aria-label={`Programme status: ${programme.status}`}
|
||||
>
|
||||
{programme.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<span>{programme.coursesCount} • {programme.contentCount}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{formatDateRange(programme.assignment)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{programme.learnersAssigned}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewProgramme(programme.programmeId)}
|
||||
className="min-tap-44"
|
||||
aria-label={`View programme details for ${programme.title}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="sr-only">View Programme</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleAssignLearners(programme)}
|
||||
className="min-tap-44"
|
||||
aria-label={`Assign learners to ${programme.title}`}
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
<span className="sr-only">Assign Learners</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadTracker(programme.programmeId)}
|
||||
disabled={isExporting}
|
||||
className="min-tap-44"
|
||||
aria-label={`Download tracker for ${programme.title}`}
|
||||
>
|
||||
{isExporting ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">Download Tracker</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {startIndex + 1} to {Math.min(endIndex, filteredProgrammes.length)} of {filteredProgrammes.length} programmes
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="min-tap-44"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredProgrammes.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">No programmes found matching your criteria.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignment Modal */}
|
||||
<Dialog open={isAssignModalOpen} onOpenChange={setIsAssignModalOpen}>
|
||||
<DialogContent className="sm:max-w-[600px]" role="dialog" aria-modal="true">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Learners to Programme</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedProgramme && `Assign learners to "${selectedProgramme.title}"`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<UserPlus className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p>Assignment wizard would be displayed here</p>
|
||||
<p className="text-sm mt-2">Including org/individual selection, dates, HR contacts, and participant upload</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
839
src/components/CourseHRView.tsx
Normal file
839
src/components/CourseHRView.tsx
Normal file
@@ -0,0 +1,839 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Input } from './ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from './ui/sheet';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
||||
import { Progress } from './ui/progress';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './ui/accordion';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
BarChart3,
|
||||
MoreHorizontal,
|
||||
Users,
|
||||
Clock,
|
||||
BookOpen,
|
||||
Award,
|
||||
Search,
|
||||
Eye,
|
||||
Mail,
|
||||
FileText,
|
||||
Play,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
XCircle,
|
||||
Video,
|
||||
FileQuestion,
|
||||
Activity,
|
||||
Building2
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'Published' | 'Draft' | 'Archived';
|
||||
code: string;
|
||||
owner: string;
|
||||
version: number;
|
||||
duration: string;
|
||||
description: string;
|
||||
objectives: string[];
|
||||
tags: string[];
|
||||
modules: CourseModule[];
|
||||
linkedProgrammes: LinkedProgramme[];
|
||||
}
|
||||
|
||||
interface CourseModule {
|
||||
id: string;
|
||||
title: string;
|
||||
lessons: CourseLesson[];
|
||||
}
|
||||
|
||||
interface CourseLesson {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'video' | 'quiz' | 'read' | 'assignment';
|
||||
eta: string;
|
||||
dueDate?: string;
|
||||
status?: 'Not Started' | 'In Progress' | 'Completed';
|
||||
}
|
||||
|
||||
interface LinkedProgramme {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface CourseAssignment {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
orgId: string;
|
||||
orgName: string;
|
||||
}
|
||||
|
||||
interface CourseCounts {
|
||||
learners: number;
|
||||
avgProgress: number;
|
||||
modules: number;
|
||||
lessons: number;
|
||||
}
|
||||
|
||||
interface CourseLearner {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
progressPct: number;
|
||||
currentLesson: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'Not Started' | 'In-Progress' | 'Completed';
|
||||
};
|
||||
lastActivity: string;
|
||||
attempts?: number;
|
||||
avgScore?: number;
|
||||
}
|
||||
|
||||
interface CourseHRViewProps {
|
||||
courseId: string;
|
||||
onBack: () => void;
|
||||
onAssignLearners: (courseId: string) => void;
|
||||
onDownloadTracker: (courseId: string) => void;
|
||||
onOpenAnalytics: (courseId: string) => void;
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockCourse: Course = {
|
||||
id: 'crs_456',
|
||||
title: 'Strategic Thinking and Decision Making',
|
||||
status: 'Published',
|
||||
code: 'STDM-2024',
|
||||
owner: 'Prof. Michael Chen',
|
||||
version: 1,
|
||||
duration: '6 hours',
|
||||
description: 'This course develops strategic thinking capabilities and decision-making frameworks for leaders at all levels. Participants will learn to analyze complex situations, evaluate options, and make informed decisions.',
|
||||
objectives: [
|
||||
'Apply strategic thinking frameworks to business challenges',
|
||||
'Develop systematic approaches to decision making',
|
||||
'Evaluate risks and opportunities effectively',
|
||||
'Create actionable strategic plans'
|
||||
],
|
||||
tags: ['Strategy', 'Leadership', 'Decision Making', 'Critical Thinking'],
|
||||
modules: [
|
||||
{
|
||||
id: 'm1',
|
||||
title: 'Foundations of Strategic Thinking',
|
||||
lessons: [
|
||||
{ id: 'l1', title: 'Introduction to Strategic Thinking', type: 'video', eta: '15 mins', status: 'Completed' },
|
||||
{ id: 'l2', title: 'Strategic Frameworks Overview', type: 'read', eta: '20 mins', status: 'Completed' },
|
||||
{ id: 'l3', title: 'Knowledge Check', type: 'quiz', eta: '10 mins', status: 'In Progress' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
title: 'Decision Making Models',
|
||||
lessons: [
|
||||
{ id: 'l4', title: 'Rational Decision Making', type: 'video', eta: '25 mins', status: 'Not Started' },
|
||||
{ id: 'l5', title: 'Intuitive vs Analytical Approaches', type: 'read', eta: '15 mins', status: 'Not Started' },
|
||||
{ id: 'l6', title: 'Case Study Analysis', type: 'assignment', eta: '45 mins', dueDate: '2024-01-25', status: 'Not Started' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'm3',
|
||||
title: 'Risk Assessment and Management',
|
||||
lessons: [
|
||||
{ id: 'l7', title: 'Risk Identification Techniques', type: 'video', eta: '20 mins', status: 'Not Started' },
|
||||
{ id: 'l8', title: 'Risk Matrix and Evaluation', type: 'read', eta: '25 mins', status: 'Not Started' },
|
||||
{ id: 'l9', title: 'Final Assessment', type: 'quiz', eta: '30 mins', dueDate: '2024-01-30', status: 'Not Started' }
|
||||
]
|
||||
}
|
||||
],
|
||||
linkedProgrammes: [
|
||||
{ id: 'prg_123', title: 'Executive Leadership Development Programme' },
|
||||
{ id: 'prg_124', title: 'Management Excellence Programme' }
|
||||
]
|
||||
};
|
||||
|
||||
const mockAssignment: CourseAssignment = {
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-02-15',
|
||||
orgId: 'org_123',
|
||||
orgName: 'Tech Solutions Pvt Ltd'
|
||||
};
|
||||
|
||||
const mockCounts: CourseCounts = {
|
||||
learners: 15,
|
||||
avgProgress: 58,
|
||||
modules: 3,
|
||||
lessons: 9
|
||||
};
|
||||
|
||||
const mockLearners: CourseLearner[] = [
|
||||
{
|
||||
id: 'l1',
|
||||
name: 'Sarah Chen',
|
||||
email: 'sarah.chen@company.com',
|
||||
progressPct: 75,
|
||||
currentLesson: { id: 'l3', title: 'Knowledge Check', status: 'In-Progress' },
|
||||
lastActivity: '2 hours ago',
|
||||
attempts: 2,
|
||||
avgScore: 87
|
||||
},
|
||||
{
|
||||
id: 'l2',
|
||||
name: 'Michael Rodriguez',
|
||||
email: 'michael.r@company.com',
|
||||
progressPct: 45,
|
||||
currentLesson: { id: 'l2', title: 'Strategic Frameworks Overview', status: 'In-Progress' },
|
||||
lastActivity: '1 day ago',
|
||||
attempts: 1,
|
||||
avgScore: 92
|
||||
},
|
||||
{
|
||||
id: 'l3',
|
||||
name: 'Emma Thompson',
|
||||
email: 'emma.thompson@company.com',
|
||||
progressPct: 89,
|
||||
currentLesson: { id: 'l6', title: 'Case Study Analysis', status: 'In-Progress' },
|
||||
lastActivity: '3 hours ago',
|
||||
attempts: 3,
|
||||
avgScore: 94
|
||||
},
|
||||
{
|
||||
id: 'l4',
|
||||
name: 'David Kim',
|
||||
email: 'david.kim@company.com',
|
||||
progressPct: 23,
|
||||
currentLesson: { id: 'l1', title: 'Introduction to Strategic Thinking', status: 'In-Progress' },
|
||||
lastActivity: '5 hours ago',
|
||||
attempts: 1,
|
||||
avgScore: 78
|
||||
}
|
||||
];
|
||||
|
||||
export const CourseHRView: React.FC<CourseHRViewProps> = ({
|
||||
courseId,
|
||||
onBack,
|
||||
onAssignLearners,
|
||||
onDownloadTracker,
|
||||
onOpenAnalytics
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [selectedLearner, setSelectedLearner] = useState<CourseLearner | null>(null);
|
||||
const [showLearnerDrawer, setShowLearnerDrawer] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
// Simulate loading
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setLoading(false), 800);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'video': return <Video className="h-4 w-4" />;
|
||||
case 'quiz': return <FileQuestion className="h-4 w-4" />;
|
||||
case 'read': return <FileText className="h-4 w-4" />;
|
||||
case 'assignment': return <Activity className="h-4 w-4" />;
|
||||
default: return <BookOpen className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'Completed':
|
||||
return <Badge variant="default" className="bg-status-success text-white"><CheckCircle className="h-3 w-3 mr-1" />Completed</Badge>;
|
||||
case 'In Progress':
|
||||
return <Badge variant="secondary" className="bg-status-warn text-black"><AlertCircle className="h-3 w-3 mr-1" />In Progress</Badge>;
|
||||
case 'Not Started':
|
||||
return <Badge variant="outline"><XCircle className="h-3 w-3 mr-1" />Not Started</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLearners = mockLearners.filter(learner => {
|
||||
const matchesSearch = learner.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
learner.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || learner.currentLesson.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const handleViewLearner = (learner: CourseLearner) => {
|
||||
setSelectedLearner(learner);
|
||||
setShowLearnerDrawer(true);
|
||||
};
|
||||
|
||||
const handleExport = async (format: 'excel' | 'csv' | 'pdf') => {
|
||||
setExporting(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setExporting(false);
|
||||
console.log(`Exported course tracker as ${format.toUpperCase()}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div className="h-16 bg-muted rounded-lg"></div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-24 bg-muted rounded-lg"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-96 bg-muted rounded-lg"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<Card className="sticky top-0 z-20 bg-background border-b shadow-sm">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
className="min-tap-44"
|
||||
aria-label="Go back to courses list"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h1 className="text-2xl font-bold">{mockCourse.title}</h1>
|
||||
<Badge
|
||||
variant={mockCourse.status === 'Published' ? 'default' : 'secondary'}
|
||||
className={mockCourse.status === 'Published' ? 'bg-status-success' : ''}
|
||||
>
|
||||
{mockCourse.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{mockCourse.code} • {mockCourse.owner} • Version {mockCourse.version} • Duration: {mockCourse.duration}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => onAssignLearners(courseId)}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Assign Learners
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onDownloadTracker(courseId)}
|
||||
disabled={exporting}
|
||||
className="min-tap-44"
|
||||
>
|
||||
{exporting ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Download Tracker
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenAnalytics(courseId)}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
Open Analytics
|
||||
</Button>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-tap-44"
|
||||
aria-label="More actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Course Actions</DialogTitle>
|
||||
<DialogDescription>Additional actions for this course</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
View Syllabus
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Audit Trail
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Assignment Window</p>
|
||||
<p className="font-semibold">{new Date(mockAssignment.startDate).toLocaleDateString()} → {new Date(mockAssignment.endDate).toLocaleDateString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{mockAssignment.orgName}</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-brand-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Enrolled Learners</p>
|
||||
<p className="text-2xl font-bold">{mockCounts.learners}</p>
|
||||
<Button variant="link" className="p-0 h-auto text-xs">Manage</Button>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-brand-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Avg Progress</p>
|
||||
<p className="text-2xl font-bold">{mockCounts.avgProgress}%</p>
|
||||
<Progress value={mockCounts.avgProgress} className="w-16 mt-1" />
|
||||
</div>
|
||||
<Award className="h-8 w-8 text-brand-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Course Structure</p>
|
||||
<div className="flex gap-2 text-sm">
|
||||
<span>{mockCounts.modules} Modules</span>
|
||||
<span>•</span>
|
||||
<span>{mockCounts.lessons} Lessons</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Duration: {mockCourse.duration}</p>
|
||||
</div>
|
||||
<BookOpen className="h-8 w-8 text-brand-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="syllabus">Syllabus</TabsTrigger>
|
||||
<TabsTrigger value="learners">Learners</TabsTrigger>
|
||||
<TabsTrigger value="reports">Reports</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="space-y-6 mt-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Course Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Description</h4>
|
||||
<p className="text-sm text-muted-foreground">{mockCourse.description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Learning Objectives</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
{mockCourse.objectives.map((objective, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-status-success mt-0.5 flex-shrink-0" />
|
||||
{objective}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Tags</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mockCourse.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Linked Resources</CardTitle>
|
||||
<CardDescription>Read-only view of course resources</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Associated Programmes</h4>
|
||||
{mockCourse.linkedProgrammes.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Appears in {mockCourse.linkedProgrammes.length} programmes:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{mockCourse.linkedProgrammes.map((programme, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start h-auto p-2"
|
||||
>
|
||||
<Building2 className="h-4 w-4 mr-2" />
|
||||
{programme.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No linked programmes</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Course Metadata</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total Duration:</span>
|
||||
<span>{mockCourse.duration}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Modules:</span>
|
||||
<span>{mockCourse.modules.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total Lessons:</span>
|
||||
<span>{mockCourse.modules.reduce((acc, module) => acc + module.lessons.length, 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Syllabus Tab */}
|
||||
<TabsContent value="syllabus" className="space-y-6 mt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Course Syllabus</h3>
|
||||
<p className="text-muted-foreground">Read-only view of the course structure and lessons</p>
|
||||
</div>
|
||||
<Badge variant="outline">Read Only</Badge>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="space-y-2">
|
||||
{mockCourse.modules.map((module, index) => (
|
||||
<AccordionItem key={index} value={`module-${index}`} className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline">Module {index + 1}</Badge>
|
||||
<span className="font-medium">{module.title}</span>
|
||||
<span className="text-muted-foreground">({module.lessons.length} lessons)</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 pb-4">
|
||||
{module.lessons.map((lesson, lessonIndex) => (
|
||||
<div key={lessonIndex} className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
{getTypeIcon(lesson.type)}
|
||||
<div>
|
||||
<p className="font-medium">{lesson.title}</p>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{lesson.eta}
|
||||
</span>
|
||||
{lesson.dueDate && (
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Due: {lesson.dueDate}
|
||||
</span>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{lesson.type}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Learners Tab */}
|
||||
<TabsContent value="learners" className="space-y-6 mt-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Learner Progress</h3>
|
||||
<p className="text-muted-foreground">Track individual progress through the course</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleExport('excel')}
|
||||
disabled={exporting}
|
||||
className="min-tap-44"
|
||||
>
|
||||
{exporting ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Export Data
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search learners..."
|
||||
className="pl-10"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="Not Started">Not Started</SelectItem>
|
||||
<SelectItem value="In-Progress">In Progress</SelectItem>
|
||||
<SelectItem value="Completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Learners Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader className="sticky-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">Learner</TableHead>
|
||||
<TableHead className="w-[100px]">Progress</TableHead>
|
||||
<TableHead className="w-[200px]">Current Lesson</TableHead>
|
||||
<TableHead className="w-[120px]">Last Activity</TableHead>
|
||||
<TableHead className="w-[100px]">Performance</TableHead>
|
||||
<TableHead className="w-[80px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredLearners.map((learner) => (
|
||||
<TableRow key={learner.id} className="min-h-[48px]">
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{learner.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{learner.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Progress value={learner.progressPct} className="w-16" />
|
||||
<span className="text-sm">{learner.progressPct}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{getTypeIcon('video')}
|
||||
<div>
|
||||
<p className="font-medium text-sm">{learner.currentLesson.title}</p>
|
||||
{getStatusBadge(learner.currentLesson.status)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{learner.lastActivity}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
{learner.avgScore && (
|
||||
<div>
|
||||
<span className="font-medium">{learner.avgScore}%</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{learner.attempts} attempt{learner.attempts !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewLearner(learner)}
|
||||
className="min-tap-44"
|
||||
aria-label={`View details for ${learner.name}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-tap-44"
|
||||
aria-label={`Send reminder to ${learner.name}`}
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Reports Tab */}
|
||||
<TabsContent value="reports" className="space-y-6 mt-6">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Course Reports</h3>
|
||||
<p>Detailed analytics and reporting for this course would be displayed here</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Activity Tab */}
|
||||
<TabsContent value="activity" className="space-y-6 mt-6">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Activity className="h-12 w-12 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Activity Log</h3>
|
||||
<p>Course activity audit trail would be displayed here</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Learner Details Drawer */}
|
||||
<Sheet open={showLearnerDrawer} onOpenChange={setShowLearnerDrawer}>
|
||||
<SheetContent className="w-[480px] sm:w-[540px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{selectedLearner?.name}</SheetTitle>
|
||||
<SheetDescription>Course progress and performance details</SheetDescription>
|
||||
</SheetHeader>
|
||||
{selectedLearner && (
|
||||
<div className="space-y-6 mt-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Course Progress</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Overall Progress:</span>
|
||||
<span className="font-medium">{selectedLearner.progressPct}%</span>
|
||||
</div>
|
||||
<Progress value={selectedLearner.progressPct} className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Lesson Checklist</h4>
|
||||
<div className="space-y-3">
|
||||
{mockCourse.modules.flatMap(module =>
|
||||
module.lessons.map((lesson, index) => (
|
||||
<div key={index} className="flex items-center gap-3 p-2 rounded-lg bg-muted/30">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
lesson.status === 'Completed' ? 'bg-status-success border-status-success' :
|
||||
lesson.status === 'In Progress' ? 'border-brand-primary bg-brand-primary' :
|
||||
'border-muted'
|
||||
}`}>
|
||||
{lesson.status === 'Completed' && <CheckCircle className="w-4 h-4 text-white" />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{getTypeIcon(lesson.type)}
|
||||
<span className={`text-sm ${
|
||||
lesson.id === selectedLearner.currentLesson.id ? 'font-medium' : 'text-muted-foreground'
|
||||
}`}>
|
||||
{lesson.title}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{lesson.eta}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedLearner.avgScore && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Performance Summary</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Average Score:</span>
|
||||
<span className="font-medium">{selectedLearner.avgScore}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Quiz Attempts:</span>
|
||||
<span>{selectedLearner.attempts}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View Detailed Report
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1">
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Send Reminder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
226
src/components/DiscussionForumFeed.tsx
Normal file
226
src/components/DiscussionForumFeed.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import {
|
||||
MessageSquare,
|
||||
Eye,
|
||||
Check,
|
||||
Filter,
|
||||
Users,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ForumThread {
|
||||
threadId: string;
|
||||
title: string;
|
||||
programmeId: string;
|
||||
programmeName: string;
|
||||
lastReplyAt: Date;
|
||||
lastAuthor: string;
|
||||
unread: boolean;
|
||||
href: string;
|
||||
replyCount?: number;
|
||||
participantCount?: number;
|
||||
}
|
||||
|
||||
interface DiscussionForumFeedProps {
|
||||
forumFeed?: ForumThread[];
|
||||
onOpenThread?: (threadId: string) => void;
|
||||
onMarkAsRead?: (threadId: string) => void;
|
||||
}
|
||||
|
||||
const mockForumFeed: ForumThread[] = [
|
||||
{
|
||||
threadId: 'thread-001',
|
||||
title: 'Leadership in Remote Teams - Best Practices Discussion',
|
||||
programmeId: 'prog-001',
|
||||
programmeName: 'Leadership Development',
|
||||
lastReplyAt: new Date('2024-12-27T15:30:00'),
|
||||
lastAuthor: 'Sarah Chen',
|
||||
unread: true,
|
||||
href: '/discussions/leadership-remote-teams',
|
||||
replyCount: 12,
|
||||
participantCount: 8
|
||||
},
|
||||
{
|
||||
threadId: 'thread-002',
|
||||
title: 'JavaScript ES6 Features - Questions and Examples',
|
||||
programmeId: 'prog-002',
|
||||
programmeName: 'Technical Skills Bootcamp',
|
||||
lastReplyAt: new Date('2024-12-27T14:15:00'),
|
||||
lastAuthor: 'David Kim',
|
||||
unread: true,
|
||||
href: '/discussions/javascript-es6',
|
||||
replyCount: 18,
|
||||
participantCount: 15
|
||||
},
|
||||
{
|
||||
threadId: 'thread-003',
|
||||
title: 'Effective Presentation Techniques - Share Your Tips',
|
||||
programmeId: 'prog-003',
|
||||
programmeName: 'Communication Excellence',
|
||||
lastReplyAt: new Date('2024-12-27T11:45:00'),
|
||||
lastAuthor: 'Emma Thompson',
|
||||
unread: false,
|
||||
href: '/discussions/presentation-techniques',
|
||||
replyCount: 7,
|
||||
participantCount: 6
|
||||
},
|
||||
{
|
||||
threadId: 'thread-004',
|
||||
title: 'Agile vs Waterfall - When to Use Each Methodology',
|
||||
programmeId: 'prog-004',
|
||||
programmeName: 'Project Management Certification',
|
||||
lastReplyAt: new Date('2024-12-27T09:20:00'),
|
||||
lastAuthor: 'Michael Rodriguez',
|
||||
unread: false,
|
||||
href: '/discussions/agile-vs-waterfall',
|
||||
replyCount: 25,
|
||||
participantCount: 19
|
||||
},
|
||||
{
|
||||
threadId: 'thread-005',
|
||||
title: 'Building Trust in Virtual Teams',
|
||||
programmeId: 'prog-001',
|
||||
programmeName: 'Leadership Development',
|
||||
lastReplyAt: new Date('2024-12-26T16:30:00'),
|
||||
lastAuthor: 'Lisa Wang',
|
||||
unread: true,
|
||||
href: '/discussions/trust-virtual-teams',
|
||||
replyCount: 9,
|
||||
participantCount: 7
|
||||
},
|
||||
{
|
||||
threadId: 'thread-006',
|
||||
title: 'Code Review Best Practices - Peer Learning',
|
||||
programmeId: 'prog-002',
|
||||
programmeName: 'Technical Skills Bootcamp',
|
||||
lastReplyAt: new Date('2024-12-26T14:10:00'),
|
||||
lastAuthor: 'James Wilson',
|
||||
unread: false,
|
||||
href: '/discussions/code-review-practices',
|
||||
replyCount: 14,
|
||||
participantCount: 11
|
||||
}
|
||||
];
|
||||
|
||||
const programmeColors = {
|
||||
'prog-001': '#04045B',
|
||||
'prog-002': '#F8C301',
|
||||
'prog-003': '#21a36a',
|
||||
'prog-004': '#89002D'
|
||||
};
|
||||
|
||||
export const DiscussionForumFeed: React.FC<DiscussionForumFeedProps> = ({
|
||||
forumFeed = mockForumFeed,
|
||||
onOpenThread,
|
||||
onMarkAsRead
|
||||
}) => {
|
||||
// Only show unread threads by default to make it concise
|
||||
const unreadThreads = forumFeed.filter(thread => thread.unread).slice(0, 5);
|
||||
|
||||
const formatTimeAgo = (date: Date) => {
|
||||
const now = new Date();
|
||||
const diffHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
|
||||
|
||||
if (diffHours < 1) {
|
||||
return 'Just now';
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours}h ago`;
|
||||
} else {
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenThread = (thread: ForumThread) => {
|
||||
onOpenThread?.(thread.threadId);
|
||||
console.log(`Opening thread: ${thread.title}`);
|
||||
};
|
||||
|
||||
const handleMarkAsRead = (threadId: string, event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onMarkAsRead?.(threadId);
|
||||
console.log(`Marked thread as read: ${threadId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Discussion Forums
|
||||
{unreadThreads.length > 0 && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{unreadThreads.length} new
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>Recent activity from programme discussions</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{unreadThreads.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<MessageSquare className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm">No new discussion activity</p>
|
||||
</div>
|
||||
) : (
|
||||
unreadThreads.map((thread) => (
|
||||
<div
|
||||
key={thread.threadId}
|
||||
className="flex items-center justify-between p-3 border rounded-lg cursor-pointer transition-colors min-tap-44 bg-blue-50 border-blue-200 hover:bg-blue-100"
|
||||
onClick={() => handleOpenThread(thread)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Open discussion: ${thread.title}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleOpenThread(thread);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-sm text-blue-900 truncate">
|
||||
{thread.title}
|
||||
</h4>
|
||||
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-800 flex-shrink-0">
|
||||
New
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="truncate">{thread.programmeName}</span>
|
||||
<span>•</span>
|
||||
<span>{formatTimeAgo(thread.lastReplyAt)}</span>
|
||||
<span>•</span>
|
||||
<span>by {thread.lastAuthor}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => handleMarkAsRead(thread.threadId, e)}
|
||||
className="min-tap-44 ml-2"
|
||||
aria-label={`Mark "${thread.title}" as read`}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
502
src/components/LearningAnalyticsTable.tsx
Normal file
502
src/components/LearningAnalyticsTable.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
||||
import { Progress } from './ui/progress';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from './ui/sheet';
|
||||
import {
|
||||
Eye,
|
||||
Send,
|
||||
BarChart3,
|
||||
User,
|
||||
BookOpen,
|
||||
Clock,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
|
||||
interface CurrentItem {
|
||||
type: 'course' | 'content';
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'Not Started' | 'In-Progress' | 'Completed';
|
||||
}
|
||||
|
||||
interface NextItem {
|
||||
type: 'course' | 'content';
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface LearnerAnalytics {
|
||||
learnerId: string;
|
||||
learnerName: string;
|
||||
learnerEmail: string;
|
||||
currentItem?: CurrentItem;
|
||||
progressPct: number;
|
||||
nextItem?: NextItem;
|
||||
lastActivity: Date;
|
||||
}
|
||||
|
||||
interface LearningAnalyticsData {
|
||||
programmeId: string;
|
||||
rows: LearnerAnalytics[];
|
||||
}
|
||||
|
||||
interface LearningAnalyticsTableProps {
|
||||
analyticsData?: LearningAnalyticsData[];
|
||||
onViewLearner?: (learnerId: string) => void;
|
||||
onNudgeLearner?: (learnerId: string) => void;
|
||||
onViewAllAnalytics?: (programmeId: string) => void;
|
||||
}
|
||||
|
||||
const mockAnalyticsData: LearningAnalyticsData[] = [
|
||||
{
|
||||
programmeId: 'prog-001',
|
||||
rows: [
|
||||
{
|
||||
learnerId: 'learner-001',
|
||||
learnerName: 'Sarah Chen',
|
||||
learnerEmail: 'sarah.chen@company.com',
|
||||
currentItem: {
|
||||
type: 'course',
|
||||
id: 'course-001',
|
||||
title: 'Strategic Thinking',
|
||||
status: 'In-Progress'
|
||||
},
|
||||
progressPct: 85,
|
||||
nextItem: {
|
||||
type: 'course',
|
||||
id: 'course-002',
|
||||
title: 'Decision Making'
|
||||
},
|
||||
lastActivity: new Date('2024-12-27T14:30:00')
|
||||
},
|
||||
{
|
||||
learnerId: 'learner-002',
|
||||
learnerName: 'Michael Rodriguez',
|
||||
learnerEmail: 'michael.r@company.com',
|
||||
currentItem: {
|
||||
type: 'content',
|
||||
id: 'content-003',
|
||||
title: 'Leadership Styles Assessment',
|
||||
status: 'Not Started'
|
||||
},
|
||||
progressPct: 62,
|
||||
nextItem: {
|
||||
type: 'course',
|
||||
id: 'course-003',
|
||||
title: 'Team Management'
|
||||
},
|
||||
lastActivity: new Date('2024-12-26T09:15:00')
|
||||
},
|
||||
{
|
||||
learnerId: 'learner-003',
|
||||
learnerName: 'Emma Thompson',
|
||||
learnerEmail: 'emma.thompson@company.com',
|
||||
currentItem: {
|
||||
type: 'course',
|
||||
id: 'course-002',
|
||||
title: 'Decision Making',
|
||||
status: 'Completed'
|
||||
},
|
||||
progressPct: 94,
|
||||
nextItem: {
|
||||
type: 'content',
|
||||
id: 'content-005',
|
||||
title: 'Leadership Reflection Journal'
|
||||
},
|
||||
lastActivity: new Date('2024-12-27T16:45:00')
|
||||
},
|
||||
{
|
||||
learnerId: 'learner-004',
|
||||
learnerName: 'David Kim',
|
||||
learnerEmail: 'david.kim@company.com',
|
||||
currentItem: {
|
||||
type: 'course',
|
||||
id: 'course-001',
|
||||
title: 'Strategic Thinking',
|
||||
status: 'In-Progress'
|
||||
},
|
||||
progressPct: 78,
|
||||
nextItem: {
|
||||
type: 'course',
|
||||
id: 'course-002',
|
||||
title: 'Decision Making'
|
||||
},
|
||||
lastActivity: new Date('2024-12-27T11:20:00')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-002',
|
||||
rows: [
|
||||
{
|
||||
learnerId: 'learner-005',
|
||||
learnerName: 'Lisa Wang',
|
||||
learnerEmail: 'lisa.wang@company.com',
|
||||
currentItem: {
|
||||
type: 'course',
|
||||
id: 'course-101',
|
||||
title: 'JavaScript Fundamentals',
|
||||
status: 'In-Progress'
|
||||
},
|
||||
progressPct: 56,
|
||||
nextItem: {
|
||||
type: 'course',
|
||||
id: 'course-102',
|
||||
title: 'React Basics'
|
||||
},
|
||||
lastActivity: new Date('2024-12-27T13:10:00')
|
||||
},
|
||||
{
|
||||
learnerId: 'learner-006',
|
||||
learnerName: 'James Wilson',
|
||||
learnerEmail: 'james.wilson@company.com',
|
||||
currentItem: {
|
||||
type: 'content',
|
||||
id: 'content-201',
|
||||
title: 'API Design Best Practices',
|
||||
status: 'Not Started'
|
||||
},
|
||||
progressPct: 34,
|
||||
nextItem: {
|
||||
type: 'course',
|
||||
id: 'course-103',
|
||||
title: 'Database Design'
|
||||
},
|
||||
lastActivity: new Date('2024-12-25T15:30:00')
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const programmeNames = {
|
||||
'prog-001': 'Leadership Development',
|
||||
'prog-002': 'Technical Skills Bootcamp',
|
||||
'prog-003': 'Communication Excellence',
|
||||
'prog-004': 'Project Management Certification'
|
||||
};
|
||||
|
||||
export const LearningAnalyticsTable: React.FC<LearningAnalyticsTableProps> = ({
|
||||
analyticsData = mockAnalyticsData,
|
||||
onViewLearner,
|
||||
onNudgeLearner,
|
||||
onViewAllAnalytics
|
||||
}) => {
|
||||
const [selectedProgramme, setSelectedProgramme] = useState(analyticsData[0]?.programmeId || '');
|
||||
const [selectedLearner, setSelectedLearner] = useState<LearnerAnalytics | null>(null);
|
||||
const [isLearnerDrawerOpen, setIsLearnerDrawerOpen] = useState(false);
|
||||
|
||||
const currentProgrammeData = analyticsData.find(data => data.programmeId === selectedProgramme);
|
||||
const currentProgrammeName = programmeNames[selectedProgramme as keyof typeof programmeNames] || 'Unknown Programme';
|
||||
|
||||
const formatLastActivity = (date: Date) => {
|
||||
const now = new Date();
|
||||
const diffHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
|
||||
|
||||
if (diffHours < 1) {
|
||||
return 'Just now';
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours} hours ago`;
|
||||
} else {
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeProps = (status: CurrentItem['status']) => {
|
||||
switch (status) {
|
||||
case 'Completed':
|
||||
return { variant: 'default' as const, className: 'bg-status-success text-status-success-foreground' };
|
||||
case 'In-Progress':
|
||||
return { variant: 'secondary' as const, className: 'bg-status-warn text-status-warn-foreground' };
|
||||
case 'Not Started':
|
||||
return { variant: 'outline' as const, className: 'border-status-error text-status-error' };
|
||||
default:
|
||||
return { variant: 'secondary' as const };
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewLearner = (learner: LearnerAnalytics) => {
|
||||
setSelectedLearner(learner);
|
||||
setIsLearnerDrawerOpen(true);
|
||||
onViewLearner?.(learner.learnerId);
|
||||
};
|
||||
|
||||
const handleNudgeLearner = (learnerId: string) => {
|
||||
onNudgeLearner?.(learnerId);
|
||||
console.log(`Sent nudge to learner: ${learnerId}`);
|
||||
};
|
||||
|
||||
const handleViewAllAnalytics = () => {
|
||||
onViewAllAnalytics?.(selectedProgramme);
|
||||
console.log(`Viewing all analytics for programme: ${selectedProgramme}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Learning Analytics</CardTitle>
|
||||
<CardDescription>Per-programme learner progress and current activities</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedProgramme} onValueChange={setSelectedProgramme}>
|
||||
<SelectTrigger className="w-[250px]">
|
||||
<SelectValue placeholder="Select programme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{analyticsData.map((data) => (
|
||||
<SelectItem key={data.programmeId} value={data.programmeId}>
|
||||
{programmeNames[data.programmeId as keyof typeof programmeNames] || data.programmeId}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleViewAllAnalytics}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
View All in Analytics
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Programme Summary */}
|
||||
<div className="flex items-center justify-between p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-medium">{currentProgrammeName}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentProgrammeData?.rows.length || 0} learners enrolled
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold">
|
||||
{currentProgrammeData ? Math.round(
|
||||
currentProgrammeData.rows.reduce((sum, learner) => sum + learner.progressPct, 0) /
|
||||
currentProgrammeData.rows.length
|
||||
) : 0}%
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Average Progress</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader className="sticky-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">Learner</TableHead>
|
||||
<TableHead className="w-[250px]">Current Item</TableHead>
|
||||
<TableHead className="w-[120px]">Progress</TableHead>
|
||||
<TableHead className="w-[200px]">Next Item</TableHead>
|
||||
<TableHead className="w-[120px]">Last Activity</TableHead>
|
||||
<TableHead className="w-[120px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentProgrammeData?.rows.map((learner) => (
|
||||
<TableRow key={learner.learnerId} className="min-h-[44px]">
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{learner.learnerName}</p>
|
||||
<p className="text-sm text-muted-foreground">{learner.learnerEmail}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{learner.currentItem ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{learner.currentItem.type === 'course' ? (
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<div className="h-4 w-4 bg-muted-foreground rounded-sm" />
|
||||
)}
|
||||
<span className="text-sm font-medium">{learner.currentItem.title}</span>
|
||||
</div>
|
||||
<Badge
|
||||
{...getStatusBadgeProps(learner.currentItem.status)}
|
||||
className="text-xs"
|
||||
>
|
||||
{learner.currentItem.status}
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">No current item</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={learner.progressPct}
|
||||
className="w-16"
|
||||
aria-describedby={`progress-${learner.learnerId}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">{learner.progressPct}%</span>
|
||||
</div>
|
||||
<span id={`progress-${learner.learnerId}`} className="sr-only">
|
||||
Progress: {learner.progressPct} percent complete
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{learner.nextItem ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{learner.nextItem.type === 'course' ? (
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<div className="h-4 w-4 bg-muted-foreground rounded-sm" />
|
||||
)}
|
||||
<span className="text-sm">{learner.nextItem.title}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">Programme complete</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{formatLastActivity(learner.lastActivity)}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewLearner(learner)}
|
||||
className="min-tap-44"
|
||||
aria-label={`View details for ${learner.learnerName}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleNudgeLearner(learner.learnerId)}
|
||||
className="min-tap-44"
|
||||
aria-label={`Send reminder to ${learner.learnerName}`}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)) || (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||
No learner data available for this programme.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Learner Detail Drawer */}
|
||||
<Sheet open={isLearnerDrawerOpen} onOpenChange={setIsLearnerDrawerOpen}>
|
||||
<SheetContent
|
||||
className="w-[500px] sm:w-[600px]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="learner-detail-title"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle id="learner-detail-title">
|
||||
{selectedLearner?.learnerName}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Detailed progress and activity in {currentProgrammeName}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{selectedLearner && (
|
||||
<div className="mt-6 space-y-6">
|
||||
{/* Contact Info */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Contact Information</h4>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">{selectedLearner.learnerEmail}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last active: {formatLastActivity(selectedLearner.lastActivity)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Summary */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Progress Summary</h4>
|
||||
<div className="p-4 bg-muted/30 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm">Overall Progress</span>
|
||||
<span className="font-medium">{selectedLearner.progressPct}%</span>
|
||||
</div>
|
||||
<Progress value={selectedLearner.progressPct} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Activity */}
|
||||
{selectedLearner.currentItem && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Current Activity</h4>
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{selectedLearner.currentItem.type === 'course' ? (
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<div className="h-4 w-4 bg-muted-foreground rounded-sm" />
|
||||
)}
|
||||
<span className="font-medium">{selectedLearner.currentItem.title}</span>
|
||||
</div>
|
||||
<Badge {...getStatusBadgeProps(selectedLearner.currentItem.status)}>
|
||||
{selectedLearner.currentItem.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next Up */}
|
||||
{selectedLearner.nextItem && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Next Up</h4>
|
||||
<div className="p-4 border border-dashed rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedLearner.nextItem.type === 'course' ? (
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<div className="h-4 w-4 bg-muted-foreground rounded-sm" />
|
||||
)}
|
||||
<span>{selectedLearner.nextItem.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button className="flex-1">
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Send Reminder
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1">
|
||||
<TrendingUp className="h-4 w-4 mr-2" />
|
||||
View Full Analytics
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
445
src/components/ProgrammeCalendar.tsx
Normal file
445
src/components/ProgrammeCalendar.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Video,
|
||||
School,
|
||||
Clock,
|
||||
Filter
|
||||
} from 'lucide-react';
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
programmeId: string;
|
||||
type: 'webinar' | 'class' | 'course_end' | 'content_end' | 'programme_end';
|
||||
title: string;
|
||||
start: Date;
|
||||
end?: Date;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface ProgrammeLegend {
|
||||
programmeId: string;
|
||||
programmeName: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface CalendarData {
|
||||
legend: ProgrammeLegend[];
|
||||
events: CalendarEvent[];
|
||||
}
|
||||
|
||||
interface ProgrammeCalendarProps {
|
||||
calendarData?: CalendarData;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
const mockCalendarData: CalendarData = {
|
||||
legend: [
|
||||
{ programmeId: 'prog-001', programmeName: 'Leadership Development', color: '#04045B' },
|
||||
{ programmeId: 'prog-002', programmeName: 'Technical Skills', color: '#F8C301' },
|
||||
{ programmeId: 'prog-003', programmeName: 'Communication', color: '#21a36a' },
|
||||
{ programmeId: 'prog-004', programmeName: 'Project Management', color: '#89002D' },
|
||||
{ programmeId: 'prog-005', programmeName: 'Sales Training', color: '#C0C0C0' },
|
||||
{ programmeId: 'prog-006', programmeName: 'Analytics Program', color: '#6366f1' }
|
||||
],
|
||||
events: [
|
||||
{
|
||||
id: 'event-001',
|
||||
programmeId: 'prog-001',
|
||||
type: 'webinar',
|
||||
title: 'Leadership Fundamentals Webinar',
|
||||
start: new Date('2024-12-28T14:00:00'),
|
||||
end: new Date('2024-12-28T15:30:00'),
|
||||
href: '/webinars/leadership-fundamentals'
|
||||
},
|
||||
{
|
||||
id: 'event-002',
|
||||
programmeId: 'prog-002',
|
||||
type: 'class',
|
||||
title: 'Hands-on Coding Workshop',
|
||||
start: new Date('2024-12-30T09:00:00'),
|
||||
end: new Date('2024-12-30T17:00:00'),
|
||||
href: '/classes/coding-workshop'
|
||||
},
|
||||
{
|
||||
id: 'event-003',
|
||||
programmeId: 'prog-001',
|
||||
type: 'course_end',
|
||||
title: 'Strategic Thinking Course Due',
|
||||
start: new Date('2025-01-02T23:59:00'),
|
||||
href: '/courses/strategic-thinking'
|
||||
},
|
||||
{
|
||||
id: 'event-004',
|
||||
programmeId: 'prog-003',
|
||||
type: 'webinar',
|
||||
title: 'Public Speaking Masterclass',
|
||||
start: new Date('2025-01-05T11:00:00'),
|
||||
end: new Date('2025-01-05T12:30:00'),
|
||||
href: '/webinars/public-speaking'
|
||||
},
|
||||
{
|
||||
id: 'event-005',
|
||||
programmeId: 'prog-004',
|
||||
type: 'programme_end',
|
||||
title: 'Project Management Certification Due',
|
||||
start: new Date('2025-01-10T23:59:00'),
|
||||
href: '/programmes/project-management'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const ProgrammeCalendar: React.FC<ProgrammeCalendarProps> = ({
|
||||
calendarData = mockCalendarData,
|
||||
onEventClick
|
||||
}) => {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [viewMode, setViewMode] = useState<'month' | 'week'>('month');
|
||||
const [selectedProgrammes, setSelectedProgrammes] = useState<string[]>(
|
||||
calendarData.legend.slice(0, 6).map(p => p.programmeId)
|
||||
);
|
||||
const [selectedEventTypes, setSelectedEventTypes] = useState<string[]>([
|
||||
'webinar', 'class', 'course_end', 'content_end', 'programme_end'
|
||||
]);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const visibleLegend = calendarData.legend.slice(0, 6);
|
||||
const additionalProgrammes = calendarData.legend.length - 6;
|
||||
|
||||
const getEventIcon = (type: CalendarEvent['type']) => {
|
||||
switch (type) {
|
||||
case 'webinar':
|
||||
return <Video className="h-3 w-3" />;
|
||||
case 'class':
|
||||
return <School className="h-3 w-3" />;
|
||||
default:
|
||||
return <Clock className="h-3 w-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getEventTypeLabel = (type: CalendarEvent['type']) => {
|
||||
switch (type) {
|
||||
case 'webinar':
|
||||
return 'Webinar';
|
||||
case 'class':
|
||||
return 'Offline Class';
|
||||
case 'course_end':
|
||||
return 'Course End';
|
||||
case 'content_end':
|
||||
return 'Content End';
|
||||
case 'programme_end':
|
||||
return 'Programme End';
|
||||
default:
|
||||
return 'Event';
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEvents = calendarData.events.filter(event =>
|
||||
selectedProgrammes.includes(event.programmeId) &&
|
||||
selectedEventTypes.includes(event.type)
|
||||
);
|
||||
|
||||
const formatEventTime = (start: Date, end?: Date) => {
|
||||
const startTime = start.toLocaleTimeString('en-AU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
|
||||
if (end) {
|
||||
const endTime = end.toLocaleTimeString('en-AU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
return `${startTime} - ${endTime}`;
|
||||
}
|
||||
|
||||
return `Due ${startTime}`;
|
||||
};
|
||||
|
||||
const getProgrammeColor = (programmeId: string) => {
|
||||
const programme = calendarData.legend.find(p => p.programmeId === programmeId);
|
||||
return programme?.color || '#6b7280';
|
||||
};
|
||||
|
||||
const navigateMonth = (direction: 'prev' | 'next') => {
|
||||
setCurrentDate(prev => {
|
||||
const newDate = new Date(prev);
|
||||
if (direction === 'prev') {
|
||||
newDate.setMonth(prev.getMonth() - 1);
|
||||
} else {
|
||||
newDate.setMonth(prev.getMonth() + 1);
|
||||
}
|
||||
return newDate;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleProgrammeFilter = (programmeId: string) => {
|
||||
setSelectedProgrammes(prev =>
|
||||
prev.includes(programmeId)
|
||||
? prev.filter(id => id !== programmeId)
|
||||
: [...prev, programmeId]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleEventTypeFilter = (eventType: string) => {
|
||||
setSelectedEventTypes(prev =>
|
||||
prev.includes(eventType)
|
||||
? prev.filter(type => type !== eventType)
|
||||
: [...prev, eventType]
|
||||
);
|
||||
};
|
||||
|
||||
const handleEventClick = (event: CalendarEvent) => {
|
||||
onEventClick?.(event);
|
||||
console.log(`Clicked event: ${event.title}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Programme Schedule</CardTitle>
|
||||
<CardDescription>Webinars, classes, and important deadlines</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="min-tap-44"
|
||||
aria-label="Toggle filters"
|
||||
>
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filters
|
||||
</Button>
|
||||
<div className="flex border rounded-md">
|
||||
<Button
|
||||
variant={viewMode === 'month' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('month')}
|
||||
className="rounded-r-none"
|
||||
>
|
||||
Month
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'week' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('week')}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
Week
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateMonth('prev')}
|
||||
className="min-tap-44"
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{currentDate.toLocaleDateString('en-AU', {
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateMonth('next')}
|
||||
className="min-tap-44"
|
||||
aria-label="Next month"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentDate(new Date())}
|
||||
className="min-tap-44"
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Programme Legend */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Programmes</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visibleLegend.map((programme) => (
|
||||
<div
|
||||
key={programme.programmeId}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: programme.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm">{programme.programmeName}</span>
|
||||
</div>
|
||||
))}
|
||||
{additionalProgrammes > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{additionalProgrammes} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters Panel */}
|
||||
{showFilters && (
|
||||
<div className="border rounded-lg p-4 space-y-4 bg-muted/30">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Filter by Programme</h4>
|
||||
<div className="space-y-2">
|
||||
{calendarData.legend.map((programme) => (
|
||||
<div key={programme.programmeId} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`prog-${programme.programmeId}`}
|
||||
checked={selectedProgrammes.includes(programme.programmeId)}
|
||||
onCheckedChange={() => toggleProgrammeFilter(programme.programmeId)}
|
||||
className="min-tap-44"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`prog-${programme.programmeId}`}
|
||||
className="text-sm font-medium cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: programme.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{programme.programmeName}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Filter by Type</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ value: 'webinar', label: 'Webinars' },
|
||||
{ value: 'class', label: 'Offline Classes' },
|
||||
{ value: 'course_end', label: 'Course End Dates' },
|
||||
{ value: 'content_end', label: 'Content End Dates' },
|
||||
{ value: 'programme_end', label: 'Programme End Dates' }
|
||||
].map((eventType) => (
|
||||
<div key={eventType.value} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`type-${eventType.value}`}
|
||||
checked={selectedEventTypes.includes(eventType.value)}
|
||||
onCheckedChange={() => toggleEventTypeFilter(eventType.value)}
|
||||
className="min-tap-44"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`type-${eventType.value}`}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{eventType.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/* Calendar Grid */}
|
||||
<div
|
||||
className="border rounded-lg p-4 min-h-[400px]"
|
||||
role="grid"
|
||||
aria-label={`Calendar for ${currentDate.toLocaleDateString('en-AU', { month: 'long', year: 'numeric' })}`}
|
||||
>
|
||||
{/* Events List View (simplified for demo) */}
|
||||
<div className="space-y-3">
|
||||
{filteredEvents.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<CalendarIcon className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p>No events found for the selected filters.</p>
|
||||
<p className="text-sm mt-1">Try adjusting your programme or event type filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredEvents.map((event) => {
|
||||
const programme = calendarData.legend.find(p => p.programmeId === event.programmeId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 cursor-pointer transition-colors min-tap-44"
|
||||
onClick={() => handleEventClick(event)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${event.title} on ${event.start.toLocaleDateString()}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleEventClick(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex items-center justify-center text-white"
|
||||
style={{ backgroundColor: getProgrammeColor(event.programmeId) }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{getEventIcon(event.type)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{event.title}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getEventTypeLabel(event.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{programme?.programmeName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">
|
||||
{event.start.toLocaleDateString('en-AU', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatEventTime(event.start, event.end)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
902
src/components/ProgrammeHRView.tsx
Normal file
902
src/components/ProgrammeHRView.tsx
Normal file
@@ -0,0 +1,902 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Input } from './ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from './ui/sheet';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
||||
import { Progress } from './ui/progress';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './ui/accordion';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
BarChart3,
|
||||
MoreHorizontal,
|
||||
Users,
|
||||
Calendar,
|
||||
Clock,
|
||||
BookOpen,
|
||||
Award,
|
||||
Bell,
|
||||
Search,
|
||||
Filter,
|
||||
Eye,
|
||||
Mail,
|
||||
FileText,
|
||||
Play,
|
||||
MapPin,
|
||||
Video,
|
||||
FileQuestion,
|
||||
Activity,
|
||||
Building2,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
Plus,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
XCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
interface Programme {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'Active' | 'Upcoming' | 'Completed';
|
||||
code: string;
|
||||
owner: string;
|
||||
version: number;
|
||||
description: string;
|
||||
goals: string[];
|
||||
tags: string[];
|
||||
structure: {
|
||||
preAssessment: ProgrammeItem[];
|
||||
preLearning: ProgrammeItem[];
|
||||
classroom: ProgrammeItem[];
|
||||
postLearning: ProgrammeItem[];
|
||||
};
|
||||
}
|
||||
|
||||
interface ProgrammeItem {
|
||||
id: string;
|
||||
type: 'Profiler' | 'Course' | 'Content' | 'Webinar' | 'OfflineSession';
|
||||
title: string;
|
||||
duration?: string;
|
||||
dueDate?: string;
|
||||
venue?: string;
|
||||
room?: string;
|
||||
date?: string;
|
||||
capacity?: number;
|
||||
status?: 'Not Started' | 'In Progress' | 'Completed';
|
||||
}
|
||||
|
||||
interface Assignment {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
orgId: string;
|
||||
orgName: string;
|
||||
}
|
||||
|
||||
interface ProgrammeCounts {
|
||||
learners: number;
|
||||
completionPct: number;
|
||||
courses: number;
|
||||
content: number;
|
||||
webinars: number;
|
||||
classes: number;
|
||||
}
|
||||
|
||||
interface ProgrammeLearner {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
currentItem: {
|
||||
type: string;
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'Not Started' | 'In-Progress' | 'Completed';
|
||||
};
|
||||
progressPct: number;
|
||||
nextItem?: {
|
||||
type: string;
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
lastActivity: string;
|
||||
stage: 'Pre-assessment' | 'Pre-learning' | 'Classroom' | 'Post-learning';
|
||||
cohort?: string;
|
||||
}
|
||||
|
||||
interface ProgrammeEvent {
|
||||
id: string;
|
||||
programmeId: string;
|
||||
type: 'webinar' | 'class' | 'course_end' | 'content_end' | 'programme_end';
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
venue?: string;
|
||||
room?: string;
|
||||
}
|
||||
|
||||
interface ProgrammeHRViewProps {
|
||||
programmeId: string;
|
||||
onBack: () => void;
|
||||
onAssignLearners: (programmeId: string) => void;
|
||||
onDownloadTracker: (programmeId: string) => void;
|
||||
onOpenAnalytics: (programmeId: string) => void;
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockProgramme: Programme = {
|
||||
id: 'prg_123',
|
||||
title: 'Executive Leadership Development Programme',
|
||||
status: 'Active',
|
||||
code: 'ELDP-2024',
|
||||
owner: 'Dr. Sarah Johnson',
|
||||
version: 2,
|
||||
description: 'A comprehensive leadership development programme designed to build strategic thinking, emotional intelligence, and decision-making capabilities for senior executives.',
|
||||
goals: [
|
||||
'Develop strategic thinking and planning capabilities',
|
||||
'Enhance emotional intelligence and self-awareness',
|
||||
'Build effective communication and influence skills',
|
||||
'Master change management and innovation leadership'
|
||||
],
|
||||
tags: ['Leadership', 'Executive', 'Strategic Thinking', 'Management'],
|
||||
structure: {
|
||||
preAssessment: [
|
||||
{ id: 'pa1', type: 'Profiler', title: 'Leadership Style Assessment', status: 'Completed' },
|
||||
{ id: 'pa2', type: 'Profiler', title: '360-Degree Feedback', status: 'In Progress' }
|
||||
],
|
||||
preLearning: [
|
||||
{ id: 'pl1', type: 'Course', title: 'Strategic Thinking Fundamentals', duration: '4 hours', dueDate: '2024-01-15', status: 'Completed' },
|
||||
{ id: 'pl2', type: 'Content', title: 'Leadership in Crisis Webcast', duration: '45 mins', status: 'In Progress' },
|
||||
{ id: 'pl3', type: 'Webinar', title: 'Future of Leadership', date: '2024-01-20 10:00 AM AEDT', status: 'Not Started' }
|
||||
],
|
||||
classroom: [
|
||||
{ id: 'c1', type: 'OfflineSession', title: 'Strategic Leadership Workshop', venue: 'Sydney Campus', room: 'Executive Suite A', date: '2024-02-05', capacity: 20 },
|
||||
{ id: 'c2', type: 'OfflineSession', title: 'Case Study Analysis', venue: 'Sydney Campus', room: 'Conference Room B', date: '2024-02-06', capacity: 20 }
|
||||
],
|
||||
postLearning: [
|
||||
{ id: 'po1', type: 'Course', title: 'Advanced Decision Making', duration: '6 hours', dueDate: '2024-02-20', status: 'Not Started' },
|
||||
{ id: 'po2', type: 'Content', title: 'Leadership Reflection Journal', duration: '2 weeks', status: 'Not Started' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const mockAssignment: Assignment = {
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-03-31',
|
||||
orgId: 'org_123',
|
||||
orgName: 'Tech Solutions Pvt Ltd'
|
||||
};
|
||||
|
||||
const mockCounts: ProgrammeCounts = {
|
||||
learners: 28,
|
||||
completionPct: 64,
|
||||
courses: 2,
|
||||
content: 2,
|
||||
webinars: 1,
|
||||
classes: 2
|
||||
};
|
||||
|
||||
const mockLearners: ProgrammeLearner[] = [
|
||||
{
|
||||
id: 'l1',
|
||||
name: 'Sarah Chen',
|
||||
email: 'sarah.chen@company.com',
|
||||
currentItem: { type: 'Course', id: 'pl1', title: 'Strategic Thinking Fundamentals', status: 'In-Progress' },
|
||||
progressPct: 75,
|
||||
nextItem: { type: 'Content', id: 'pl2', title: 'Leadership in Crisis Webcast' },
|
||||
lastActivity: '2 hours ago',
|
||||
stage: 'Pre-learning',
|
||||
cohort: 'Cohort A'
|
||||
},
|
||||
{
|
||||
id: 'l2',
|
||||
name: 'Michael Rodriguez',
|
||||
email: 'michael.r@company.com',
|
||||
currentItem: { type: 'Profiler', id: 'pa2', title: '360-Degree Feedback', status: 'In-Progress' },
|
||||
progressPct: 45,
|
||||
nextItem: { type: 'Course', id: 'pl1', title: 'Strategic Thinking Fundamentals' },
|
||||
lastActivity: '1 day ago',
|
||||
stage: 'Pre-assessment',
|
||||
cohort: 'Cohort A'
|
||||
},
|
||||
{
|
||||
id: 'l3',
|
||||
name: 'Emma Thompson',
|
||||
email: 'emma.thompson@company.com',
|
||||
currentItem: { type: 'OfflineSession', id: 'c1', title: 'Strategic Leadership Workshop', status: 'Completed' },
|
||||
progressPct: 89,
|
||||
nextItem: { type: 'Course', id: 'po1', title: 'Advanced Decision Making' },
|
||||
lastActivity: '3 hours ago',
|
||||
stage: 'Classroom',
|
||||
cohort: 'Cohort B'
|
||||
}
|
||||
];
|
||||
|
||||
const mockEvents: ProgrammeEvent[] = [
|
||||
{
|
||||
id: 'e1',
|
||||
programmeId: 'prg_123',
|
||||
type: 'webinar',
|
||||
title: 'Future of Leadership',
|
||||
start: '2024-01-20T10:00:00',
|
||||
end: '2024-01-20T11:30:00'
|
||||
},
|
||||
{
|
||||
id: 'e2',
|
||||
programmeId: 'prg_123',
|
||||
type: 'class',
|
||||
title: 'Strategic Leadership Workshop',
|
||||
start: '2024-02-05T09:00:00',
|
||||
end: '2024-02-05T17:00:00',
|
||||
venue: 'Sydney Campus',
|
||||
room: 'Executive Suite A'
|
||||
}
|
||||
];
|
||||
|
||||
export const ProgrammeHRView: React.FC<ProgrammeHRViewProps> = ({
|
||||
programmeId,
|
||||
onBack,
|
||||
onAssignLearners,
|
||||
onDownloadTracker,
|
||||
onOpenAnalytics
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [stageFilter, setStageFilter] = useState('all');
|
||||
const [cohortFilter, setCohortFilter] = useState('all');
|
||||
const [selectedLearner, setSelectedLearner] = useState<ProgrammeLearner | null>(null);
|
||||
const [showLearnerDrawer, setShowLearnerDrawer] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
// Simulate loading
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setLoading(false), 800);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'Course': return <BookOpen className="h-4 w-4" />;
|
||||
case 'Content': return <FileText className="h-4 w-4" />;
|
||||
case 'Webinar': return <Video className="h-4 w-4" />;
|
||||
case 'Profiler': return <FileQuestion className="h-4 w-4" />;
|
||||
case 'OfflineSession': return <Building2 className="h-4 w-4" />;
|
||||
default: return <BookOpen className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'Completed':
|
||||
return <Badge variant="default" className="bg-status-success text-white"><CheckCircle className="h-3 w-3 mr-1" />Completed</Badge>;
|
||||
case 'In Progress':
|
||||
return <Badge variant="secondary" className="bg-status-warn text-black"><AlertCircle className="h-3 w-3 mr-1" />In Progress</Badge>;
|
||||
case 'Not Started':
|
||||
return <Badge variant="outline"><XCircle className="h-3 w-3 mr-1" />Not Started</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLearners = mockLearners.filter(learner => {
|
||||
const matchesSearch = learner.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
learner.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || learner.currentItem.status === statusFilter;
|
||||
const matchesStage = stageFilter === 'all' || learner.stage === stageFilter;
|
||||
const matchesCohort = cohortFilter === 'all' || learner.cohort === cohortFilter;
|
||||
return matchesSearch && matchesStatus && matchesStage && matchesCohort;
|
||||
});
|
||||
|
||||
const handleViewLearner = (learner: ProgrammeLearner) => {
|
||||
setSelectedLearner(learner);
|
||||
setShowLearnerDrawer(true);
|
||||
};
|
||||
|
||||
const handleExport = async (format: 'excel' | 'csv' | 'pdf') => {
|
||||
setExporting(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setExporting(false);
|
||||
console.log(`Exported programme tracker as ${format.toUpperCase()}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div className="h-16 bg-muted rounded-lg"></div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-24 bg-muted rounded-lg"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-96 bg-muted rounded-lg"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<Card className="sticky top-0 z-20 bg-background border-b shadow-sm">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
className="min-tap-44"
|
||||
aria-label="Go back to programmes list"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h1 className="text-2xl font-bold">{mockProgramme.title}</h1>
|
||||
<Badge
|
||||
variant={mockProgramme.status === 'Active' ? 'default' : 'secondary'}
|
||||
className={mockProgramme.status === 'Active' ? 'bg-status-success' : ''}
|
||||
>
|
||||
{mockProgramme.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{mockProgramme.code} • {mockProgramme.owner} • Version {mockProgramme.version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => onAssignLearners(programmeId)}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Assign Learners
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onDownloadTracker(programmeId)}
|
||||
disabled={exporting}
|
||||
className="min-tap-44"
|
||||
>
|
||||
{exporting ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Download Tracker
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenAnalytics(programmeId)}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
Open Analytics
|
||||
</Button>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-tap-44"
|
||||
aria-label="More actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Programme Actions</DialogTitle>
|
||||
<DialogDescription>Additional actions for this programme</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
View Structure JSON
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Audit Trail
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Assignment Window</p>
|
||||
<p className="font-semibold">{new Date(mockAssignment.startDate).toLocaleDateString()} → {new Date(mockAssignment.endDate).toLocaleDateString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{mockAssignment.orgName}</p>
|
||||
</div>
|
||||
<Calendar className="h-8 w-8 text-brand-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Enrolled Learners</p>
|
||||
<p className="text-2xl font-bold">{mockCounts.learners}</p>
|
||||
<Button variant="link" className="p-0 h-auto text-xs">Manage</Button>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-brand-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Completion Rate</p>
|
||||
<p className="text-2xl font-bold">{mockCounts.completionPct}%</p>
|
||||
<Progress value={mockCounts.completionPct} className="w-16 mt-1" />
|
||||
</div>
|
||||
<Award className="h-8 w-8 text-brand-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Items in Programme</p>
|
||||
<div className="flex gap-2 text-sm">
|
||||
<span>{mockCounts.courses} Courses</span>
|
||||
<span>•</span>
|
||||
<span>{mockCounts.content} Content</span>
|
||||
</div>
|
||||
<div className="flex gap-2 text-sm text-muted-foreground">
|
||||
<span>{mockCounts.webinars} Webinars</span>
|
||||
<span>•</span>
|
||||
<span>{mockCounts.classes} Classes</span>
|
||||
</div>
|
||||
</div>
|
||||
<BookOpen className="h-8 w-8 text-brand-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-6">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="structure">Structure</TabsTrigger>
|
||||
<TabsTrigger value="learners">Learners</TabsTrigger>
|
||||
<TabsTrigger value="calendar">Calendar</TabsTrigger>
|
||||
<TabsTrigger value="reports">Reports</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="space-y-6 mt-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Programme Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Description</h4>
|
||||
<p className="text-sm text-muted-foreground">{mockProgramme.description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Learning Goals</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
{mockProgramme.goals.map((goal, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-status-success mt-0.5 flex-shrink-0" />
|
||||
{goal}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Tags</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mockProgramme.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stage Breakdown</CardTitle>
|
||||
<CardDescription>Programme structure as designed by Super Admin</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ name: 'Pre-assessment', items: mockProgramme.structure.preAssessment, color: 'bg-chart-1' },
|
||||
{ name: 'Pre-learning', items: mockProgramme.structure.preLearning, color: 'bg-chart-2' },
|
||||
{ name: 'Classroom sessions', items: mockProgramme.structure.classroom, color: 'bg-chart-3' },
|
||||
{ name: 'Post-learning', items: mockProgramme.structure.postLearning, color: 'bg-chart-4' }
|
||||
].map((stage, index) => (
|
||||
<div key={index} className="border-l-4 border-l-brand-primary pl-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium">{stage.name}</h4>
|
||||
<Badge variant="outline">{stage.items.length} items</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{stage.items.slice(0, 2).map((item, itemIndex) => (
|
||||
<div key={itemIndex} className="flex items-center gap-2 text-sm">
|
||||
{getTypeIcon(item.type)}
|
||||
<span>{item.title}</span>
|
||||
{item.status && getStatusBadge(item.status)}
|
||||
</div>
|
||||
))}
|
||||
{stage.items.length > 2 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
+{stage.items.length - 2} more items
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Bell className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>Guardrails:</strong> Dates and enrolments shown are scoped to your organization ({mockAssignment.orgName}).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
|
||||
{/* Structure Tab */}
|
||||
<TabsContent value="structure" className="space-y-6 mt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Programme Structure</h3>
|
||||
<p className="text-muted-foreground">Read-only view of the programme as composed by Super Admin</p>
|
||||
</div>
|
||||
<Badge variant="outline">Read Only</Badge>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="space-y-2">
|
||||
{[
|
||||
{ name: 'Pre-assessment', items: mockProgramme.structure.preAssessment },
|
||||
{ name: 'Pre-learning', items: mockProgramme.structure.preLearning },
|
||||
{ name: 'Classroom Sessions (Offline)', items: mockProgramme.structure.classroom },
|
||||
{ name: 'Post-learning', items: mockProgramme.structure.postLearning }
|
||||
].map((stage, index) => (
|
||||
<AccordionItem key={index} value={`stage-${index}`} className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline">{stage.name}</Badge>
|
||||
<span className="text-muted-foreground">({stage.items.length} items)</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 pb-4">
|
||||
{stage.items.map((item, itemIndex) => (
|
||||
<div key={itemIndex} className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
{getTypeIcon(item.type)}
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
{item.duration && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{item.duration}
|
||||
</span>
|
||||
)}
|
||||
{item.dueDate && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Due: {item.dueDate}
|
||||
</span>
|
||||
)}
|
||||
{item.venue && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{item.venue} {item.room && `• ${item.room}`}
|
||||
</span>
|
||||
)}
|
||||
{item.date && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{item.date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Learners Tab */}
|
||||
<TabsContent value="learners" className="space-y-6 mt-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Learner Progress</h3>
|
||||
<p className="text-muted-foreground">Track individual progress across the programme</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleExport('excel')}
|
||||
disabled={exporting}
|
||||
className="min-tap-44"
|
||||
>
|
||||
{exporting ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Export Data
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search learners..."
|
||||
className="pl-10"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="Not Started">Not Started</SelectItem>
|
||||
<SelectItem value="In-Progress">In Progress</SelectItem>
|
||||
<SelectItem value="Completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={stageFilter} onValueChange={setStageFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Stage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Stages</SelectItem>
|
||||
<SelectItem value="Pre-assessment">Pre-assessment</SelectItem>
|
||||
<SelectItem value="Pre-learning">Pre-learning</SelectItem>
|
||||
<SelectItem value="Classroom">Classroom</SelectItem>
|
||||
<SelectItem value="Post-learning">Post-learning</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={cohortFilter} onValueChange={setCohortFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Cohort" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Cohorts</SelectItem>
|
||||
<SelectItem value="Cohort A">Cohort A</SelectItem>
|
||||
<SelectItem value="Cohort B">Cohort B</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Learners Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader className="sticky-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">Learner</TableHead>
|
||||
<TableHead className="w-[200px]">Current Item</TableHead>
|
||||
<TableHead className="w-[100px]">Progress</TableHead>
|
||||
<TableHead className="w-[150px]">Next Item</TableHead>
|
||||
<TableHead className="w-[120px]">Last Activity</TableHead>
|
||||
<TableHead className="w-[100px]">Stage</TableHead>
|
||||
<TableHead className="w-[80px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredLearners.map((learner) => (
|
||||
<TableRow key={learner.id} className="min-h-[48px]">
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{learner.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{learner.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{getTypeIcon(learner.currentItem.type)}
|
||||
<div>
|
||||
<p className="font-medium text-sm">{learner.currentItem.title}</p>
|
||||
{getStatusBadge(learner.currentItem.status)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Progress value={learner.progressPct} className="w-16" />
|
||||
<span className="text-sm">{learner.progressPct}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{learner.nextItem ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{getTypeIcon(learner.nextItem.type)}
|
||||
<span className="text-sm">{learner.nextItem.title}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">None</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{learner.lastActivity}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{learner.stage}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewLearner(learner)}
|
||||
className="min-tap-44"
|
||||
aria-label={`View details for ${learner.name}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-tap-44"
|
||||
aria-label={`Send reminder to ${learner.name}`}
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Calendar Tab */}
|
||||
<TabsContent value="calendar" className="space-y-6 mt-6">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Calendar className="h-12 w-12 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Programme Calendar</h3>
|
||||
<p>Programme-scoped calendar with webinars, offline classes, and deadlines would be displayed here</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Reports Tab */}
|
||||
<TabsContent value="reports" className="space-y-6 mt-6">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Programme Reports</h3>
|
||||
<p>Detailed analytics and reporting for this programme would be displayed here</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Activity Tab */}
|
||||
<TabsContent value="activity" className="space-y-6 mt-6">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Activity className="h-12 w-12 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Activity Log</h3>
|
||||
<p>Programme activity audit trail would be displayed here</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Learner Details Drawer */}
|
||||
<Sheet open={showLearnerDrawer} onOpenChange={setShowLearnerDrawer}>
|
||||
<SheetContent className="w-[480px] sm:w-[540px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{selectedLearner?.name}</SheetTitle>
|
||||
<SheetDescription>Learner progress and assignment details</SheetDescription>
|
||||
</SheetHeader>
|
||||
{selectedLearner && (
|
||||
<div className="space-y-6 mt-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Assignment Info</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Start Date:</span>
|
||||
<span>{mockAssignment.startDate}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">End Date:</span>
|
||||
<span>{mockAssignment.endDate}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Cohort:</span>
|
||||
<span>{selectedLearner.cohort}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Stage Timeline</h4>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ name: 'Pre-assessment', completed: selectedLearner.stage !== 'Pre-assessment' },
|
||||
{ name: 'Pre-learning', completed: !['Pre-assessment', 'Pre-learning'].includes(selectedLearner.stage) },
|
||||
{ name: 'Classroom', completed: !['Pre-assessment', 'Pre-learning', 'Classroom'].includes(selectedLearner.stage) },
|
||||
{ name: 'Post-learning', completed: false }
|
||||
].map((stage, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
stage.completed ? 'bg-status-success border-status-success' :
|
||||
selectedLearner.stage === stage.name ? 'border-brand-primary bg-brand-primary' :
|
||||
'border-muted'
|
||||
}`}>
|
||||
{stage.completed && <CheckCircle className="w-4 h-4 text-white" />}
|
||||
</div>
|
||||
<span className={`text-sm ${
|
||||
selectedLearner.stage === stage.name ? 'font-medium' : 'text-muted-foreground'
|
||||
}`}>
|
||||
{stage.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Open Learner Record
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1">
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Send Reminder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
367
src/components/ProgrammeSchedule.tsx
Normal file
367
src/components/ProgrammeSchedule.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Users,
|
||||
Video,
|
||||
FileText,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ScheduleEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'webinar' | 'workshop' | 'assessment' | 'deadline' | 'class';
|
||||
date: Date;
|
||||
time: string;
|
||||
duration?: string;
|
||||
programme: string;
|
||||
attendees?: number;
|
||||
maxAttendees?: number;
|
||||
status: 'upcoming' | 'live' | 'completed';
|
||||
}
|
||||
|
||||
interface ProgrammeScheduleProps {
|
||||
events?: ScheduleEvent[];
|
||||
onEventClick?: (event: ScheduleEvent) => void;
|
||||
}
|
||||
|
||||
const mockEvents: ScheduleEvent[] = [
|
||||
{
|
||||
id: 'evt-001',
|
||||
title: 'Leadership Fundamentals Webinar',
|
||||
type: 'webinar',
|
||||
date: new Date('2024-12-28'),
|
||||
time: '10:00 AM',
|
||||
duration: '90 min',
|
||||
programme: 'Leadership Development',
|
||||
attendees: 32,
|
||||
maxAttendees: 50,
|
||||
status: 'upcoming'
|
||||
},
|
||||
{
|
||||
id: 'evt-002',
|
||||
title: 'Technical Skills Assessment',
|
||||
type: 'assessment',
|
||||
date: new Date('2024-12-28'),
|
||||
time: '2:00 PM',
|
||||
duration: '60 min',
|
||||
programme: 'Technical Skills',
|
||||
status: 'upcoming'
|
||||
},
|
||||
{
|
||||
id: 'evt-003',
|
||||
title: 'Communication Workshop',
|
||||
type: 'workshop',
|
||||
date: new Date('2024-12-29'),
|
||||
time: '11:00 AM',
|
||||
duration: '2 hrs',
|
||||
programme: 'Communication Excellence',
|
||||
attendees: 18,
|
||||
maxAttendees: 25,
|
||||
status: 'upcoming'
|
||||
},
|
||||
{
|
||||
id: 'evt-004',
|
||||
title: 'Project Management Live Class',
|
||||
type: 'class',
|
||||
date: new Date('2024-12-29'),
|
||||
time: '3:30 PM',
|
||||
duration: '45 min',
|
||||
programme: 'Project Management',
|
||||
attendees: 45,
|
||||
maxAttendees: 60,
|
||||
status: 'live'
|
||||
},
|
||||
{
|
||||
id: 'evt-005',
|
||||
title: 'Assignment Submission Due',
|
||||
type: 'deadline',
|
||||
date: new Date('2024-12-30'),
|
||||
time: '11:59 PM',
|
||||
programme: 'Leadership Development',
|
||||
status: 'upcoming'
|
||||
},
|
||||
{
|
||||
id: 'evt-006',
|
||||
title: 'Data Analytics Bootcamp',
|
||||
type: 'workshop',
|
||||
date: new Date('2024-12-30'),
|
||||
time: '9:00 AM',
|
||||
duration: '4 hrs',
|
||||
programme: 'Technical Skills',
|
||||
attendees: 22,
|
||||
maxAttendees: 30,
|
||||
status: 'upcoming'
|
||||
}
|
||||
];
|
||||
|
||||
export const ProgrammeSchedule: React.FC<ProgrammeScheduleProps> = ({
|
||||
events = mockEvents,
|
||||
onEventClick
|
||||
}) => {
|
||||
const [selectedProgramme, setSelectedProgramme] = useState('all');
|
||||
const [selectedType, setSelectedType] = useState('all');
|
||||
const [currentWeekStart, setCurrentWeekStart] = useState(() => {
|
||||
const today = new Date();
|
||||
const dayOfWeek = today.getDay();
|
||||
const mondayDate = new Date(today);
|
||||
mondayDate.setDate(today.getDate() - dayOfWeek + 1);
|
||||
return mondayDate;
|
||||
});
|
||||
|
||||
// Get unique programmes and types for filters
|
||||
const programmes = Array.from(new Set(events.map(e => e.programme)));
|
||||
const eventTypes = Array.from(new Set(events.map(e => e.type)));
|
||||
|
||||
// Generate week days
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => {
|
||||
const date = new Date(currentWeekStart);
|
||||
date.setDate(currentWeekStart.getDate() + i);
|
||||
return date;
|
||||
});
|
||||
|
||||
// Filter and group events by date
|
||||
const filteredEvents = events.filter(event => {
|
||||
const matchesProgramme = selectedProgramme === 'all' || event.programme === selectedProgramme;
|
||||
const matchesType = selectedType === 'all' || event.type === selectedType;
|
||||
return matchesProgramme && matchesType;
|
||||
});
|
||||
|
||||
const eventsByDate = weekDays.reduce((acc, date) => {
|
||||
const dateKey = date.toDateString();
|
||||
acc[dateKey] = filteredEvents.filter(event =>
|
||||
event.date.toDateString() === dateKey
|
||||
).sort((a, b) => a.time.localeCompare(b.time));
|
||||
return acc;
|
||||
}, {} as Record<string, ScheduleEvent[]>);
|
||||
|
||||
const navigateWeek = (direction: 'prev' | 'next') => {
|
||||
const newDate = new Date(currentWeekStart);
|
||||
newDate.setDate(currentWeekStart.getDate() + (direction === 'next' ? 7 : -7));
|
||||
setCurrentWeekStart(newDate);
|
||||
};
|
||||
|
||||
const getEventIcon = (type: ScheduleEvent['type']) => {
|
||||
switch (type) {
|
||||
case 'webinar':
|
||||
return <Video className="h-3 w-3" />;
|
||||
case 'workshop':
|
||||
return <Users className="h-3 w-3" />;
|
||||
case 'assessment':
|
||||
return <FileText className="h-3 w-3" />;
|
||||
case 'deadline':
|
||||
return <Clock className="h-3 w-3" />;
|
||||
case 'class':
|
||||
return <Calendar className="h-3 w-3" />;
|
||||
default:
|
||||
return <Calendar className="h-3 w-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getEventColor = (type: ScheduleEvent['type'], status: ScheduleEvent['status']) => {
|
||||
if (status === 'live') return 'bg-status-error text-status-error-foreground';
|
||||
if (status === 'completed') return 'bg-muted text-muted-foreground';
|
||||
|
||||
switch (type) {
|
||||
case 'webinar':
|
||||
return 'bg-brand-primary text-brand-navy-foreground';
|
||||
case 'workshop':
|
||||
return 'bg-status-success text-status-success-foreground';
|
||||
case 'assessment':
|
||||
return 'bg-status-warn text-status-warn-foreground';
|
||||
case 'deadline':
|
||||
return 'bg-status-error text-status-error-foreground';
|
||||
case 'class':
|
||||
return 'bg-brand-charcoal text-brand-charcoal-foreground';
|
||||
default:
|
||||
return 'bg-secondary text-secondary-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString('en-AU', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: 'short'
|
||||
});
|
||||
};
|
||||
|
||||
const isToday = (date: Date) => {
|
||||
const today = new Date();
|
||||
return date.toDateString() === today.toDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Programme Schedule</CardTitle>
|
||||
<CardDescription>Weekly view of upcoming classes, webinars, and deadlines</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedProgramme} onValueChange={setSelectedProgramme}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="All Programmes" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Programmes</SelectItem>
|
||||
{programmes.map(programme => (
|
||||
<SelectItem key={programme} value={programme}>
|
||||
{programme}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedType} onValueChange={setSelectedType}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="All Types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
{eventTypes.map(type => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Week Navigation */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateWeek('prev')}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous Week
|
||||
</Button>
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold">
|
||||
{weekDays[0].toLocaleDateString('en-AU', { day: '2-digit', month: 'short' })} - {weekDays[6].toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: 'numeric' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">Week View</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateWeek('next')}
|
||||
className="min-tap-44"
|
||||
>
|
||||
Next Week
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Horizontal Weekly Calendar */}
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{weekDays.map((date, dayIndex) => {
|
||||
const dateKey = date.toDateString();
|
||||
const dayEvents = eventsByDate[dateKey] || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dayIndex}
|
||||
className={`
|
||||
min-h-[120px] p-3 rounded-lg border
|
||||
${isToday(date)
|
||||
? 'bg-brand-primary/5 border-brand-primary/20'
|
||||
: 'bg-card border-chrome-divider'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="text-center mb-3">
|
||||
<p className={`font-medium ${isToday(date) ? 'text-brand-primary' : ''}`}>
|
||||
{formatDate(date)}
|
||||
</p>
|
||||
{isToday(date) && (
|
||||
<Badge variant="secondary" className="text-xs mt-1">Today</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{dayEvents.slice(0, 3).map((event) => (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={() => onEventClick?.(event)}
|
||||
className={`
|
||||
w-full p-2 rounded text-left text-xs transition-all duration-200
|
||||
hover:shadow-sm hover:scale-105 min-tap-44
|
||||
${getEventColor(event.type, event.status)}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
{getEventIcon(event.type)}
|
||||
<span className="font-medium truncate">{event.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{event.time}</span>
|
||||
{event.status === 'live' && (
|
||||
<Badge variant="destructive" className="text-xs px-1 py-0">
|
||||
LIVE
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{event.attendees && (
|
||||
<div className="flex items-center gap-1 mt-1 text-xs opacity-80">
|
||||
<Users className="h-2 w-2" />
|
||||
<span>{event.attendees}/{event.maxAttendees}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{dayEvents.length > 3 && (
|
||||
<div className="text-center">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{dayEvents.length - 3} more
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dayEvents.length === 0 && (
|
||||
<div className="text-center py-4">
|
||||
<span className="text-xs text-muted-foreground">No events</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-6 pt-4 border-t border-chrome-divider">
|
||||
<p className="text-sm font-medium mb-2">Event Types:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ type: 'webinar', label: 'Webinar' },
|
||||
{ type: 'workshop', label: 'Workshop' },
|
||||
{ type: 'class', label: 'Live Class' },
|
||||
{ type: 'assessment', label: 'Assessment' },
|
||||
{ type: 'deadline', label: 'Deadline' }
|
||||
].map(({ type, label }) => (
|
||||
<div key={type} className="flex items-center gap-1">
|
||||
<div className={`w-3 h-3 rounded ${getEventColor(type as ScheduleEvent['type'], 'upcoming').replace('text-', 'bg-').split(' ')[0]}`} />
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
485
src/components/ProgrammesTable.tsx
Normal file
485
src/components/ProgrammesTable.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Input } from './ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Download,
|
||||
Users,
|
||||
Calendar,
|
||||
BookOpen,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
UserPlus,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Assignment {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
interface Programme {
|
||||
programmeId: string;
|
||||
title: string;
|
||||
status: 'Active' | 'Upcoming' | 'Completed';
|
||||
coursesCount: number;
|
||||
contentCount: number;
|
||||
assignment: Assignment;
|
||||
learnersAssigned: number;
|
||||
type?: 'programme' | 'course';
|
||||
}
|
||||
|
||||
interface ProgrammesTableProps {
|
||||
programmes?: Programme[];
|
||||
onViewProgramme?: (programmeId: string) => void;
|
||||
onViewCourse?: (courseId: string) => void;
|
||||
onAssignLearners?: (programmeId: string) => void;
|
||||
onDownloadTracker?: (programmeId: string) => void;
|
||||
userAccessLevel?: 'full' | 'course-only'; // New prop to determine user access
|
||||
}
|
||||
|
||||
const mockProgrammes: Programme[] = [
|
||||
{
|
||||
programmeId: 'prog-001',
|
||||
title: 'Leadership Development Program',
|
||||
status: 'Active',
|
||||
coursesCount: 8,
|
||||
contentCount: 24,
|
||||
assignment: {
|
||||
startDate: new Date('2024-01-15'),
|
||||
endDate: new Date('2024-06-30')
|
||||
},
|
||||
learnersAssigned: 45,
|
||||
type: 'programme'
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-002',
|
||||
title: 'Technical Skills Bootcamp',
|
||||
status: 'Active',
|
||||
coursesCount: 12,
|
||||
contentCount: 36,
|
||||
assignment: {
|
||||
startDate: new Date('2024-02-01'),
|
||||
endDate: new Date('2024-08-31')
|
||||
},
|
||||
learnersAssigned: 38,
|
||||
type: 'programme'
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-003',
|
||||
title: 'Communication Excellence',
|
||||
status: 'Upcoming',
|
||||
coursesCount: 6,
|
||||
contentCount: 18,
|
||||
assignment: {
|
||||
startDate: new Date('2024-03-01'),
|
||||
endDate: new Date('2024-05-31')
|
||||
},
|
||||
learnersAssigned: 28,
|
||||
type: 'programme'
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-004',
|
||||
title: 'Project Management Certification',
|
||||
status: 'Active',
|
||||
coursesCount: 10,
|
||||
contentCount: 30,
|
||||
assignment: {
|
||||
startDate: new Date('2024-01-01'),
|
||||
endDate: new Date('2024-12-31')
|
||||
},
|
||||
learnersAssigned: 52,
|
||||
type: 'programme'
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-005',
|
||||
title: 'Digital Marketing Mastery',
|
||||
status: 'Completed',
|
||||
coursesCount: 5,
|
||||
contentCount: 15,
|
||||
assignment: {
|
||||
startDate: new Date('2023-09-01'),
|
||||
endDate: new Date('2023-12-31')
|
||||
},
|
||||
learnersAssigned: 32,
|
||||
type: 'programme'
|
||||
},
|
||||
{
|
||||
programmeId: 'prog-006',
|
||||
title: 'Data Analytics Fundamentals',
|
||||
status: 'Completed',
|
||||
coursesCount: 7,
|
||||
contentCount: 21,
|
||||
assignment: {
|
||||
startDate: new Date('2023-10-15'),
|
||||
endDate: new Date('2024-01-15')
|
||||
},
|
||||
learnersAssigned: 29,
|
||||
type: 'programme'
|
||||
}
|
||||
];
|
||||
|
||||
const mockCourses: Programme[] = [
|
||||
{
|
||||
programmeId: 'course-001',
|
||||
title: 'Strategic Thinking Course',
|
||||
status: 'Active',
|
||||
coursesCount: 1,
|
||||
contentCount: 8,
|
||||
assignment: {
|
||||
startDate: new Date('2024-01-15'),
|
||||
endDate: new Date('2024-03-15')
|
||||
},
|
||||
learnersAssigned: 15,
|
||||
type: 'course'
|
||||
},
|
||||
{
|
||||
programmeId: 'course-002',
|
||||
title: 'Data Analysis Fundamentals',
|
||||
status: 'Active',
|
||||
coursesCount: 1,
|
||||
contentCount: 12,
|
||||
assignment: {
|
||||
startDate: new Date('2024-02-01'),
|
||||
endDate: new Date('2024-04-30')
|
||||
},
|
||||
learnersAssigned: 22,
|
||||
type: 'course'
|
||||
},
|
||||
{
|
||||
programmeId: 'course-003',
|
||||
title: 'Public Speaking Mastery',
|
||||
status: 'Upcoming',
|
||||
coursesCount: 1,
|
||||
contentCount: 6,
|
||||
assignment: {
|
||||
startDate: new Date('2024-03-01'),
|
||||
endDate: new Date('2024-04-15')
|
||||
},
|
||||
learnersAssigned: 18,
|
||||
type: 'course'
|
||||
},
|
||||
{
|
||||
programmeId: 'course-004',
|
||||
title: 'Agile Methodology Workshop',
|
||||
status: 'Active',
|
||||
coursesCount: 1,
|
||||
contentCount: 10,
|
||||
assignment: {
|
||||
startDate: new Date('2024-01-01'),
|
||||
endDate: new Date('2024-06-30')
|
||||
},
|
||||
learnersAssigned: 25,
|
||||
type: 'course'
|
||||
},
|
||||
{
|
||||
programmeId: 'course-005',
|
||||
title: 'Excel Advanced Techniques',
|
||||
status: 'Completed',
|
||||
coursesCount: 1,
|
||||
contentCount: 5,
|
||||
assignment: {
|
||||
startDate: new Date('2023-11-01'),
|
||||
endDate: new Date('2023-12-31')
|
||||
},
|
||||
learnersAssigned: 12,
|
||||
type: 'course'
|
||||
}
|
||||
];
|
||||
|
||||
export const ProgrammesTable: React.FC<ProgrammesTableProps> = ({
|
||||
programmes,
|
||||
onViewProgramme,
|
||||
onViewCourse,
|
||||
onAssignLearners,
|
||||
onDownloadTracker,
|
||||
userAccessLevel = 'full'
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('Active');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isAssignModalOpen, setIsAssignModalOpen] = useState(false);
|
||||
const [selectedProgramme, setSelectedProgramme] = useState<Programme | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const itemsPerPage = 5;
|
||||
|
||||
// Determine which data to show based on user access level
|
||||
const defaultData = userAccessLevel === 'course-only' ? mockCourses : mockProgrammes;
|
||||
const displayData = programmes || defaultData;
|
||||
|
||||
const filteredProgrammes = displayData.filter(prog => {
|
||||
const matchesSearch = prog.title.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || prog.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
// Pagination logic
|
||||
const totalPages = Math.ceil(filteredProgrammes.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedProgrammes = filteredProgrammes.slice(startIndex, endIndex);
|
||||
|
||||
// Reset to first page when filters change
|
||||
React.useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, statusFilter]);
|
||||
|
||||
const formatDateRange = (assignment: Assignment) => {
|
||||
const startDate = assignment.startDate.toLocaleDateString('en-AU', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
const endDate = assignment.endDate.toLocaleDateString('en-AU', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
return `${startDate} → ${endDate}`;
|
||||
};
|
||||
|
||||
const handleViewItem = (item: Programme) => {
|
||||
if (item.type === 'course') {
|
||||
onViewCourse?.(item.programmeId);
|
||||
console.log(`Viewing course: ${item.programmeId}`);
|
||||
} else {
|
||||
onViewProgramme?.(item.programmeId);
|
||||
console.log(`Viewing programme: ${item.programmeId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignLearners = (programme: Programme) => {
|
||||
setSelectedProgramme(programme);
|
||||
setIsAssignModalOpen(true);
|
||||
onAssignLearners?.(programme.programmeId);
|
||||
};
|
||||
|
||||
const handleDownloadTracker = async (programmeId: string) => {
|
||||
setIsExporting(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setIsExporting(false);
|
||||
onDownloadTracker?.(programmeId);
|
||||
console.log(`Downloaded tracker for programme: ${programmeId}`);
|
||||
};
|
||||
|
||||
const getStatusBadgeProps = (status: Programme['status']) => {
|
||||
switch (status) {
|
||||
case 'Active':
|
||||
return { variant: 'default' as const, className: 'bg-status-success text-status-success-foreground' };
|
||||
case 'Upcoming':
|
||||
return { variant: 'secondary' as const, className: 'bg-status-warn text-status-warn-foreground' };
|
||||
case 'Completed':
|
||||
return { variant: 'outline' as const, className: 'bg-muted text-muted-foreground' };
|
||||
default:
|
||||
return { variant: 'secondary' as const };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>{userAccessLevel === 'course-only' ? 'Courses' : 'Programmes'}</CardTitle>
|
||||
<CardDescription>
|
||||
{userAccessLevel === 'course-only'
|
||||
? 'Manage course assignments and track progress'
|
||||
: 'Manage programme assignments and track progress'
|
||||
}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={userAccessLevel === 'course-only' ? 'Search courses...' : 'Search programmes...'}
|
||||
className="pl-10 w-[200px]"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
aria-label={userAccessLevel === 'course-only' ? 'Search courses by title' : 'Search programmes by title'}
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="Active">Active</SelectItem>
|
||||
<SelectItem value="Upcoming">Upcoming</SelectItem>
|
||||
<SelectItem value="Completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border" style={{ maxWidth: '1200px' }}>
|
||||
<Table>
|
||||
<TableHeader className="sticky-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[300px]">
|
||||
{userAccessLevel === 'course-only' ? 'Course' : 'Programme/Course'}
|
||||
</TableHead>
|
||||
<TableHead className="w-[150px]">
|
||||
{userAccessLevel === 'course-only' ? 'Content' : 'Courses / Content'}
|
||||
</TableHead>
|
||||
<TableHead className="w-[200px]">Start → End</TableHead>
|
||||
<TableHead className="w-[120px]">Learners Assigned</TableHead>
|
||||
<TableHead className="w-[200px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedProgrammes.map((programme) => (
|
||||
<TableRow key={programme.programmeId} className="min-h-[44px]">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{programme.title}</span>
|
||||
<Badge
|
||||
{...getStatusBadgeProps(programme.status)}
|
||||
aria-label={`Programme status: ${programme.status}`}
|
||||
>
|
||||
{programme.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<span>
|
||||
{userAccessLevel === 'course-only'
|
||||
? `${programme.contentCount} modules`
|
||||
: `${programme.coursesCount} • ${programme.contentCount}`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{formatDateRange(programme.assignment)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{programme.learnersAssigned}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewItem(programme)}
|
||||
className="min-tap-44"
|
||||
aria-label={`View ${programme.type === 'course' ? 'course' : 'programme'} details for ${programme.title}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="sr-only">View {programme.type === 'course' ? 'Course' : 'Programme'}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleAssignLearners(programme)}
|
||||
className="min-tap-44"
|
||||
aria-label={`Assign learners to ${programme.title}`}
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
<span className="sr-only">Assign Learners</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadTracker(programme.programmeId)}
|
||||
disabled={isExporting}
|
||||
className="min-tap-44"
|
||||
aria-label={`Download tracker for ${programme.title}`}
|
||||
>
|
||||
{isExporting ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">Download Tracker</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {startIndex + 1} to {Math.min(endIndex, filteredProgrammes.length)} of {filteredProgrammes.length} {userAccessLevel === 'course-only' ? 'courses' : 'programmes'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="min-tap-44"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="min-tap-44"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredProgrammes.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">
|
||||
No {userAccessLevel === 'course-only' ? 'courses' : 'programmes'} found matching your criteria.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignment Modal */}
|
||||
<Dialog open={isAssignModalOpen} onOpenChange={setIsAssignModalOpen}>
|
||||
<DialogContent className="sm:max-w-[600px]" role="dialog" aria-modal="true">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Assign Learners to {userAccessLevel === 'course-only' ? 'Course' : 'Programme'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedProgramme && `Assign learners to "${selectedProgramme.title}"`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<UserPlus className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p>Assignment wizard would be displayed here</p>
|
||||
<p className="text-sm mt-2">Including org/individual selection, dates, HR contacts, and participant upload</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
25
src/global.d.ts
vendored
25
src/global.d.ts
vendored
@@ -1,26 +1,29 @@
|
||||
// declarations.d.ts
|
||||
|
||||
declare module "*.png" {
|
||||
declare module '*.webp' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.jpg" {
|
||||
declare module '*.png' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.jpeg" {
|
||||
declare module '*.jpg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.svg" {
|
||||
import * as React from "react";
|
||||
const ReactComponent: React.FunctionComponent<
|
||||
React.SVGProps<SVGSVGElement> & { title?: string }
|
||||
>;
|
||||
export { ReactComponent };
|
||||
declare module '*.jpeg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*.gif' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
3967
src/index.css
Normal file
3967
src/index.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./styles/globals.css";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap");
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
@@ -500,8 +500,8 @@
|
||||
/* Utility classes for HR Portal */
|
||||
@layer utilities {
|
||||
.min-tap-44 {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
min-height: 12px;
|
||||
min-width: 12px;
|
||||
}
|
||||
|
||||
.sticky-header {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import path from 'path';
|
||||
import * as path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -54,7 +54,7 @@
|
||||
outDir: 'build',
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
port: 3005,
|
||||
open: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user