+ {createError && (
+
+ )}
-
+
setNewThread(prev => ({ ...prev, title: e.target.value }))}
+ value={newThreadTitle}
+ onChange={(e) => setNewThreadTitle(e.target.value)}
placeholder="What would you like to discuss?"
- maxLength={120}
+ maxLength={150}
/>
-
+
-
-
-
-
-
-
+
-
+
-
+
setNewThread(prev => ({ ...prev, tags: e.target.value }))}
- placeholder="leadership, communication, remote-work"
+ value={tagInput}
+ onChange={(e) => setTagInput(e.target.value)}
+ onKeyDown={handleTagKeyDown}
+ placeholder="Type a tag and press Enter"
/>
+ {newThreadTags.length > 0 && (
+
+ {newThreadTags.map((tag) => (
+
+ {tag}
+
+
+ ))}
+
+ )}
-
-
-
diff --git a/src/pages/DiscussionsPage/DiscussionsView.tsx b/src/pages/DiscussionsPage/DiscussionsView.tsx
new file mode 100644
index 0000000..e191a84
--- /dev/null
+++ b/src/pages/DiscussionsPage/DiscussionsView.tsx
@@ -0,0 +1,296 @@
+import React, { useMemo, useState } from 'react';
+import { ArrowLeft, Ellipsis, Link2, List, RefreshCw, Send, Smile, ThumbsUp } from 'lucide-react';
+import { Button } from '../../components/ui/button';
+import { Card, CardContent } from '../../components/ui/card';
+import { Badge } from '../../components/ui/badge';
+import { Input } from '../../components/ui/input';
+import {
+ useGetRepliesByThreadQuery,
+ useReactToForumItemMutation,
+ useReplyToThreadMutation,
+ type ForumReply,
+ type ForumThread,
+} from '../../redux/services/forumApi';
+import { useToast } from '../../components/toast/useToast';
+
+interface DiscussionsViewProps {
+ thread: ForumThread;
+ onBack: () => void;
+}
+
+const formatDate = (date?: string) => {
+ if (!date) return '-';
+ const parsed = new Date(date);
+ return Number.isNaN(parsed.getTime()) ? '-' : parsed.toLocaleDateString('en-GB');
+};
+
+const getLikeCount = (thread: ForumThread) =>
+ thread.reactions.find((r) => r.emoji_code === 'U+1F44D')?.count ?? 0;
+
+const getReactionCount = (thread: ForumThread) => thread.reactions.reduce((sum, r) => sum + r.count, 0);
+
+export const DiscussionsView: React.FC
= ({ thread, onBack }) => {
+ const { showToast } = useToast();
+ const [replyInput, setReplyInput] = useState('');
+ const [childReplyInputById, setChildReplyInputById] = useState>({});
+ const [openChildReplyForId, setOpenChildReplyForId] = useState(null);
+
+ const { data: repliesResponse, isLoading: repliesLoading, refetch: refetchReplies } =
+ useGetRepliesByThreadQuery(thread.id);
+ const [replyToThread, { isLoading: postingReply }] = useReplyToThreadMutation();
+ const [reactToForumItem, { isLoading: reacting }] = useReactToForumItemMutation();
+
+ const normalizeReplies = (items: ForumReply[]): ForumReply[] =>
+ items.map((item) => ({
+ ...item,
+ replies: normalizeReplies(item.replies ?? item.children ?? []),
+ }));
+
+ const replies = useMemo(() => normalizeReplies(repliesResponse?.data ?? []), [repliesResponse]);
+
+ const replyCount = useMemo(() => {
+ const countNodes = (nodes: ForumReply[]): number =>
+ nodes.reduce((sum, node) => sum + 1 + countNodes(node.replies ?? []), 0);
+ return countNodes(replies);
+ }, [replies]);
+
+ const getReplyLikeCount = (reply: ForumReply) =>
+ (reply.reactions ?? []).find((r) => r.emoji_code === 'U+1F44D')?.count ?? 0;
+ const getReplyReactionCount = (reply: ForumReply) =>
+ (reply.reactions ?? []).reduce((sum, r) => sum + r.count, 0);
+
+ const postReply = async (content: string, parentId?: string) => {
+ if (!content.trim()) return;
+ try {
+ const response = await replyToThread({
+ threadId: thread.id,
+ content: content.trim(),
+ parent_id: parentId || undefined,
+ }).unwrap();
+ showToast('Reply added', response.message || 'Reply added successfully.', 'success');
+ if (parentId) {
+ setChildReplyInputById((prev) => ({ ...prev, [parentId]: '' }));
+ setOpenChildReplyForId(null);
+ } else {
+ setReplyInput('');
+ }
+ await refetchReplies();
+ } catch (error: any) {
+ const message = error?.data?.message || 'Failed to post reply.';
+ showToast('Reply failed', message, 'error');
+ }
+ };
+
+ const reactToThread = async () => {
+ try {
+ await reactToForumItem({
+ emoji: 'U+1F44D',
+ thread_id: thread.id,
+ }).unwrap();
+ await refetchReplies();
+ showToast('Reaction added', 'Your reaction was recorded.', 'success');
+ } catch (error: any) {
+ showToast('Reaction failed', error?.data?.message || 'Could not react right now.', 'error');
+ }
+ };
+
+ const reactToReply = async (replyId: string) => {
+ try {
+ await reactToForumItem({
+ emoji: 'U+1F44D',
+ thread_id: thread.id,
+ reply_id: replyId,
+ }).unwrap();
+ await refetchReplies();
+ showToast('Reaction added', 'Your reaction was recorded.', 'success');
+ } catch (error: any) {
+ showToast('Reaction failed', error?.data?.message || 'Could not react right now.', 'error');
+ }
+ };
+
+ const renderReplies = (items: ForumReply[], depth = 0): React.ReactNode =>
+ items.map((reply) => {
+ const childReplyText = childReplyInputById[reply.id] ?? '';
+ const children = reply.replies ?? [];
+ const isChildBoxOpen = openChildReplyForId === reply.id;
+ return (
+ 0 ? ' ml-6 mt-3 border-l pl-4' : '')}
+ >
+
+
+ HR User
+ {formatDate(reply.created_at)}
+
+
+
+
+
+
{reply.content}
+
+ {getReplyReactionCount(reply)} reactions
+ reactToReply(reply.id)}
+ disabled={reacting}
+ >
+
+ {getReplyLikeCount(reply)}
+
+ setOpenChildReplyForId(isChildBoxOpen ? null : reply.id)}
+ >
+ Reply
+
+
+
+ {isChildBoxOpen && (
+
+
+ setChildReplyInputById((prev) => ({ ...prev, [reply.id]: e.target.value }))
+ }
+ placeholder="Write a sub-reply..."
+ />
+ postReply(childReplyText, reply.id)}
+ disabled={postingReply || !childReplyText.trim()}
+ >
+ Post
+
+
+ )}
+
+ {children.length > 0 &&
{renderReplies(children, depth + 1)}
}
+
+ );
+ });
+
+ return (
+
+
+
+
+ Back to Forums
+
+
Leadership Development Q4 2024
+
›
+
Thread
+
+
+
+
+
+
{thread.title}
+
+
+
+
+
+
+ By HR User
+ •
+ {formatDate(thread.created_at)}
+ •
+ {replyCount} replies
+
+
+ {thread.content}
+
+
+ {thread.tags.map((tag) => (
+
+ #{tag}
+
+ ))}
+
+
+
+ {getReactionCount(thread)} 🤔
+
+
+ {getLikeCount(thread)}
+
+
+ 0
+
+
+
+
+
+
+
+
+
Replies ({replyCount})
+ refetchReplies()} disabled={repliesLoading}>
+
+ Refresh
+
+
+ {repliesLoading ? (
+ Loading replies...
+ ) : replies.length === 0 ? (
+ No replies yet.
+ ) : (
+ renderReplies(replies)
+ )}
+
+
+
+
+
+ Add Reply
+ setReplyInput(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ if (!postingReply && replyInput.trim()) {
+ void postReply(replyInput);
+ }
+ }
+ }}
+ placeholder="Share your thoughts or ask a follow-up question..."
+ />
+
+ {/*
+
+ B
+
+
+ I
+
+
+
+
+
+
+
+
*/}
+
postReply(replyInput)}
+ disabled={postingReply || !replyInput.trim()}
+ >
+
+ {postingReply ? 'Posting...' : 'Post Reply'}
+
+
+
+
+
+ );
+};
+
+export default DiscussionsView;
diff --git a/src/pages/Learners/LearnersPage.tsx b/src/pages/Learners/LearnersPage.tsx
index fa05441..4228452 100644
--- a/src/pages/Learners/LearnersPage.tsx
+++ b/src/pages/Learners/LearnersPage.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useRef, useState, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { Button } from '../../components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
@@ -36,59 +36,220 @@ import {
XCircle,
Clock
} from 'lucide-react';
-import { mockEmployees } from '../../utils/mockData';
import { Employee } from '../../types';
+import {
+ useBulkAssignCourseMutation,
+ useBulkAssignProgrammeMutation,
+ useBulkRevokeCourseMutation,
+ useBulkCreateLearnersForHrMutation,
+ useGetCoursesForHrQuery,
+ useGetAssignedCoursesForOrganizationQuery,
+ useGetLearnerCoursesQuery,
+ useGetProgrammesForHrQuery,
+ useLazyCheckEmailExistsQuery,
+ useLazyCheckMobileExistsQuery,
+ useCreateLearnerMutation,
+ useGetLearnersForHrQuery,
+ useUpdateLearnerMutation
+} from '../../redux/services/learnersApi';
+import { useToast } from '../../components/toast/useToast';
+
+interface CsvLearnerRow {
+ first_name: string;
+ last_name: string;
+ email_address: string;
+ phone_country_code: string;
+ phone_number: string;
+}
+
+// Interface for assigned course from API
+interface AssignedCourse {
+ id: string;
+ principal_xid: string;
+ first_name: string;
+ last_name: string;
+ company_name: string;
+ course_xid: string;
+ course_name: string;
+ course_desc: string;
+ is_hr: boolean | null;
+ principal_organization_course_link_xid: string | null;
+}
const LearnersPage: React.FC = () => {
+ const { showToast } = useToast();
const location = useLocation();
- const [employees, setEmployees] = useState(mockEmployees);
+ const [localEmployees, setLocalEmployees] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
- const [programmeFilter, setProgrammeFilter] = useState('all');
+ const [searchInput, setSearchInput] = useState('');
const [selectedEmployees, setSelectedEmployees] = useState([]);
const [showAddDrawer, setShowAddDrawer] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [showAssignModal, setShowAssignModal] = useState(false);
+ const [showAssignCourseModal, setShowAssignCourseModal] = useState(false);
const [showEditDrawer, setShowEditDrawer] = useState(false);
+ const [selectedProgrammeId, setSelectedProgrammeId] = useState('');
+ const [selectedCourseId, setSelectedCourseId] = useState('');
+ const [assignError, setAssignError] = useState('');
+ const [assignCourseError, setAssignCourseError] = useState('');
const [editingEmployee, setEditingEmployee] = useState(null);
- const [newEmployee, setNewEmployee] = useState({ name: '', email: '', phone: '' });
+ const [editForm, setEditForm] = useState({
+ firstName: '',
+ lastName: '',
+ phoneCountryCode: '+1',
+ phoneNumber: '',
+ });
+ const [editSaveError, setEditSaveError] = useState('');
+ const [assignedCourses, setAssignedCourses] = useState([]);
+ const [courseActionCourseId, setCourseActionCourseId] = useState(null);
+ const [newEmployee, setNewEmployee] = useState({
+ firstName: '',
+ lastName: '',
+ email: '',
+ phoneCountryCode: '+1',
+ phone: '',
+ });
+ const [addLearnerError, setAddLearnerError] = useState('');
const [bulkActionVisible, setBulkActionVisible] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [loading, setLoading] = useState(false);
const [viewMode, setViewMode] = useState<'table' | 'grid'>('table');
+ const [csvRows, setCsvRows] = useState([]);
+ const [importError, setImportError] = useState('');
+ const [importRowErrors, setImportRowErrors] = useState([]);
+ const [isLoadingCourses, setIsLoadingCourses] = useState(false);
+ const [showOrgCoursesCatalog, setShowOrgCoursesCatalog] = useState(false);
+ const fileInputRef = useRef(null);
+ const countryCodeOptions = [
+ { code: '+1', label: 'US +1' },
+ { code: '+44', label: 'GB +44' },
+ { code: '+91', label: 'IN +91' },
+ { code: '+61', label: 'AU +61' },
+ { code: '+86', label: 'CN +86' },
+ ];
- // Get unique programmes for filter
- const programmes = Array.from(new Set(employees.map(e => e.programme).filter(Boolean)));
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setSearchTerm(searchInput.trim());
+ }, 300);
+ return () => clearTimeout(timer);
+ }, [searchInput]);
- const debouncedSearch = useCallback(
- (term: string) => {
- const timer = setTimeout(() => {
- setSearchTerm(term);
- }, 300);
- return () => clearTimeout(timer);
- },
- []
+ const startIndex = (currentPage - 1) * itemsPerPage;
+ const {
+ data: learnersResponse,
+ isLoading: learnersLoading,
+ isFetching: learnersFetching,
+ isError: learnersError,
+ } = useGetLearnersForHrQuery({
+ limit: itemsPerPage,
+ offset: startIndex,
+ status: statusFilter,
+ search_term: searchTerm || undefined,
+ });
+ const [createLearner, { isLoading: creatingLearner }] = useCreateLearnerMutation();
+ const [bulkCreateLearnersForHr, { isLoading: isBulkImporting }] =
+ useBulkCreateLearnersForHrMutation();
+ const [bulkAssignProgramme, { isLoading: isAssigningProgramme }] =
+ useBulkAssignProgrammeMutation();
+ const [bulkAssignCourse, { isLoading: isAssigningCourse }] =
+ useBulkAssignCourseMutation();
+ const [bulkRevokeCourse, { isLoading: isRevokingCourse }] =
+ useBulkRevokeCourseMutation();
+ const [updateLearner, { isLoading: isUpdatingLearner }] = useUpdateLearnerMutation();
+ const [checkEmailExists] = useLazyCheckEmailExistsQuery();
+ const [checkMobileExists] = useLazyCheckMobileExistsQuery();
+ const {
+ data: programmesResponse,
+ isLoading: programmesLoading,
+ } = useGetProgrammesForHrQuery({
+ limit: 100,
+ offset: 0,
+ });
+ const {
+ data: coursesResponse,
+ isLoading: coursesLoading,
+ refetch: refetchCourses
+ } = useGetCoursesForHrQuery({
+ limit: 100,
+ offset: 0,
+ });
+
+ const { data: learnerCoursesResponse, refetch: refetchLearnerCourses, isLoading: learnerCoursesLoading } =
+ useGetLearnerCoursesQuery(editingEmployee?.id ?? '', {
+ skip: !editingEmployee?.id,
+ });
+
+ const {
+ data: orgAssignedCoursesResponse,
+ isLoading: orgCoursesLoading,
+ isFetching: orgCoursesFetching,
+ refetch: refetchOrgCourses,
+ } = useGetAssignedCoursesForOrganizationQuery(
+ { limit: 100, offset: 0 },
+ { skip: !showOrgCoursesCatalog || !editingEmployee?.id }
);
- const filteredEmployees = employees.filter(emp => {
- const matchesSearch = emp.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
- emp.email.toLowerCase().includes(searchTerm.toLowerCase());
- const matchesStatus = statusFilter === 'all' || emp.status === statusFilter;
- const matchesProgramme = programmeFilter === 'all' || emp.programme === programmeFilter;
- return matchesSearch && matchesStatus && matchesProgramme;
- });
+ // Load learner's assigned courses when edit drawer opens
+ useEffect(() => {
+ if (showEditDrawer && editingEmployee?.id) {
+ setIsLoadingCourses(true);
+ refetchLearnerCourses().finally(() => {
+ setIsLoadingCourses(false);
+ });
+ }
+ }, [showEditDrawer, editingEmployee?.id, refetchLearnerCourses]);
- // Pagination calculations
- const totalPages = Math.ceil(filteredEmployees.length / itemsPerPage);
- const startIndex = (currentPage - 1) * itemsPerPage;
- const endIndex = startIndex + itemsPerPage;
- const paginatedEmployees = filteredEmployees.slice(startIndex, endIndex);
+ // Update assigned courses when data changes
+ useEffect(() => {
+ if (learnerCoursesResponse?.data) {
+ setAssignedCourses(learnerCoursesResponse.data);
+ }
+ }, [learnerCoursesResponse]);
+
+ const linkByCourseXid = useMemo(() => {
+ const map: Record = {};
+ assignedCourses.forEach((c) => {
+ map[c.course_xid] = c.principal_organization_course_link_xid;
+ });
+ return map;
+ }, [assignedCourses]);
+
+ const apiEmployees = useMemo(() => {
+ const items = learnersResponse?.data?.items ?? [];
+ return items.map((item) => ({
+ id: item.id,
+ name: `${item.first_name} ${item.last_name}`.trim(),
+ email: item.email_address,
+ phone: `${item.phone_country_code ?? ''} ${item.phone_number ?? ''}`.trim(),
+ status: item.is_active ? 'Active' : 'Inactive',
+ programme: '-',
+ progress: 0,
+ lastActivity: item.joined_date || '-',
+ }));
+ }, [learnersResponse]);
+
+ const employees = useMemo(() => {
+ const merged = [...localEmployees, ...apiEmployees];
+ const seen = new Set();
+ return merged.filter((emp) => {
+ if (seen.has(emp.id)) return false;
+ seen.add(emp.id);
+ return true;
+ });
+ }, [localEmployees, apiEmployees]);
+
+ const totalCount = learnersResponse?.data?.total_count ?? employees.length;
+ const totalPages = Math.max(1, Math.ceil(totalCount / itemsPerPage));
+ const endIndex = Math.min(startIndex + employees.length, totalCount);
+ const paginatedEmployees = employees;
// Reset to first page when filters change
useEffect(() => {
setCurrentPage(1);
- }, [searchTerm, statusFilter, programmeFilter]);
+ }, [searchTerm, statusFilter]);
useEffect(() => {
setBulkActionVisible(selectedEmployees.length > 0);
@@ -131,28 +292,180 @@ const LearnersPage: React.FC = () => {
setSelectedEmployees([]);
};
- const handleAddEmployee = () => {
- if (newEmployee.name && newEmployee.email) {
- const newEmp: Employee = {
- id: Date.now().toString(),
- name: newEmployee.name,
- email: newEmployee.email,
- phone: newEmployee.phone,
- status: 'Pending',
+ const handleAddEmployee = async () => {
+ setAddLearnerError('');
+
+ if (!newEmployee.firstName.trim() || !newEmployee.lastName.trim() || !newEmployee.email.trim()) {
+ setAddLearnerError('First name, last name, and email are required.');
+ return;
+ }
+
+ try {
+ const emailToCheck = newEmployee.email.trim().toLowerCase();
+ const phoneCountryCode = newEmployee.phoneCountryCode.trim() || '+91';
+ const phoneNumber = newEmployee.phone.trim();
+
+ const [emailCheck, phoneCheck] = await Promise.all([
+ checkEmailExists(emailToCheck).unwrap(),
+ phoneNumber
+ ? checkMobileExists({
+ phone_country_code: phoneCountryCode,
+ phone_number: phoneNumber,
+ }).unwrap()
+ : Promise.resolve({ data: { exists: false } }),
+ ]);
+
+ const singleCreateErrors: string[] = [];
+ if (emailCheck?.data?.exists) {
+ singleCreateErrors.push('Email already exists in the system.');
+ }
+ if (phoneCheck?.data?.exists) {
+ singleCreateErrors.push('Phone number already exists in the system.');
+ }
+ if (singleCreateErrors.length > 0) {
+ const errorMessage = singleCreateErrors.join(' ');
+ setAddLearnerError(errorMessage);
+ showToast('Validation failed', errorMessage, 'error');
+ return;
+ }
+
+ const response = await createLearner({
+ first_name: newEmployee.firstName.trim(),
+ last_name: newEmployee.lastName.trim(),
+ email_address: emailToCheck,
+ phone_country_code: phoneCountryCode,
+ phone_number: phoneNumber,
+ }).unwrap();
+
+ const created = response.data;
+ const createdEmployee: Employee = {
+ id: created.id,
+ name: `${created.first_name} ${created.last_name}`.trim(),
+ email: created.email_address,
+ phone: `${created.phone_country_code ?? ''} ${created.phone_number ?? ''}`.trim(),
+ status: created.is_active ? 'Active' : 'Inactive',
+ programme: '-',
progress: 0,
- lastActivity: 'Just now'
+ lastActivity: created.joined_date || 'Just now',
};
- setEmployees(prev => [...prev, newEmp]);
- setNewEmployee({ name: '', email: '', phone: '' });
+
+ setLocalEmployees(prev => [createdEmployee, ...prev]);
+ setNewEmployee({
+ firstName: '',
+ lastName: '',
+ email: '',
+ phoneCountryCode: '+1',
+ phone: '',
+ });
+ setCurrentPage(1);
setShowAddDrawer(false);
+ showToast('Learner created', response.message || 'Learner created successfully.', 'success');
+ } catch (error: any) {
+ const errorMessage =
+ error?.data?.message || 'Failed to create learner. Please try again.';
+ setAddLearnerError(errorMessage);
+ showToast('Create failed', errorMessage, 'error');
}
};
const handleEditEmployee = (employee: Employee) => {
+ const [firstName, ...rest] = employee.name.split(' ');
+ const lastName = rest.join(' ');
+ const phoneParts = employee.phone.trim().split(/\s+/);
+ const defaultCode = phoneParts[0]?.startsWith('+') ? phoneParts[0] : '+1';
+ const defaultPhone = phoneParts[0]?.startsWith('+')
+ ? phoneParts.slice(1).join(' ')
+ : employee.phone;
setEditingEmployee(employee);
+ setEditForm({
+ firstName: firstName || '',
+ lastName: lastName || '',
+ phoneCountryCode: defaultCode || '+1',
+ phoneNumber: defaultPhone || '',
+ });
+ setEditSaveError('');
+ setShowOrgCoursesCatalog(false);
setShowEditDrawer(true);
};
+ const handleSaveLearnerDetails = async () => {
+ if (!editingEmployee) return;
+ setEditSaveError('');
+ if (!editForm.firstName.trim() || !editForm.lastName.trim()) {
+ setEditSaveError('First name and last name are required.');
+ return;
+ }
+
+ try {
+ const response = await updateLearner({
+ id: editingEmployee.id,
+ first_name: editForm.firstName.trim(),
+ last_name: editForm.lastName.trim(),
+ phone_country_code: editForm.phoneCountryCode.trim(),
+ phone_number: editForm.phoneNumber.trim(),
+ }).unwrap();
+
+ const updatedName = `${response.data.first_name} ${response.data.last_name}`.trim();
+ const updatedPhone = `${response.data.phone_country_code ?? ''} ${response.data.phone_number ?? ''}`.trim();
+ setLocalEmployees((prev) =>
+ prev.map((emp) =>
+ emp.id === editingEmployee.id
+ ? { ...emp, name: updatedName, phone: updatedPhone }
+ : emp
+ )
+ );
+ setEditingEmployee((prev) =>
+ prev ? { ...prev, name: updatedName, phone: updatedPhone } : prev
+ );
+ showToast('Learner updated', response.message || 'Learner details updated.', 'success');
+ } catch (error: any) {
+ const message = error?.data?.message || 'Failed to update learner.';
+ setEditSaveError(message);
+ showToast('Update failed', message, 'error');
+ }
+ };
+
+ const handleUnassignCourse = async (courseId: string, linkXid?: string | null) => {
+ if (!editingEmployee) return;
+ setCourseActionCourseId(courseId);
+ try {
+ const response = await bulkRevokeCourse({
+ principal_xids: [editingEmployee.id],
+ course_xids: [courseId],
+ principal_organization_course_link_xid: linkXid ?? undefined,
+ }).unwrap();
+
+ await refetchLearnerCourses();
+ showToast('Course revoked', response.message || 'Course unassigned successfully.', 'success');
+ } catch (error: any) {
+ const message = error?.data?.message || 'Failed to unassign course.';
+ showToast('Unassignment failed', message, 'error');
+ } finally {
+ setCourseActionCourseId(null);
+ }
+ };
+
+ const handleAssignCourseToLearner = async (courseId: string) => {
+ if (!editingEmployee) return;
+ if (assignedCourses.some((c) => c.course_xid === courseId)) return;
+
+ setCourseActionCourseId(courseId);
+ try {
+ const response = await bulkAssignCourse({
+ principal_xids: [editingEmployee.id],
+ course_xids: [courseId],
+ }).unwrap();
+
+ await refetchLearnerCourses();
+ showToast('Course assigned', response.message || 'Course assigned successfully.', 'success');
+ } catch (error: any) {
+ const message = error?.data?.message || 'Failed to assign course.';
+ showToast('Assignment failed', message, 'error');
+ } finally {
+ setCourseActionCourseId(null);
+ }
+ };
+
const handleExport = async (format: 'excel' | 'csv' | 'pdf') => {
setLoading(true);
await new Promise(resolve => setTimeout(resolve, 2000));
@@ -160,6 +473,292 @@ const LearnersPage: React.FC = () => {
console.log(`Exported learner data as ${format}`);
};
+ const parseCSV = (text: string): CsvLearnerRow[] => {
+ const lines = text
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+
+ if (lines.length < 2) {
+ throw new Error('CSV file is empty.');
+ }
+
+ const headers = lines[0].split(',').map((h) => h.trim().toLowerCase());
+ const requiredHeaders = [
+ 'first_name',
+ 'last_name',
+ 'email_address',
+ 'phone_country_code',
+ 'phone_number',
+ ] as const;
+
+ const headerIndex: Record = {};
+ for (const key of requiredHeaders) {
+ const index = headers.indexOf(key);
+ if (index === -1) {
+ throw new Error(`Missing required column: ${key}`);
+ }
+ headerIndex[key] = index;
+ }
+
+ const parsedRows: CsvLearnerRow[] = [];
+
+ for (let i = 1; i < lines.length; i++) {
+ const rowText = lines[i];
+ const values: string[] = [];
+ let current = '';
+ let inQuotes = false;
+
+ for (let j = 0; j < rowText.length; j++) {
+ const ch = rowText[j];
+ if (ch === '"') {
+ inQuotes = !inQuotes;
+ } else if (ch === ',' && !inQuotes) {
+ values.push(current.trim().replace(/^"|"$/g, ''));
+ current = '';
+ } else {
+ current += ch;
+ }
+ }
+ values.push(current.trim().replace(/^"|"$/g, ''));
+
+ parsedRows.push({
+ first_name: values[headerIndex.first_name] ?? '',
+ last_name: values[headerIndex.last_name] ?? '',
+ email_address: values[headerIndex.email_address] ?? '',
+ phone_country_code: values[headerIndex.phone_country_code] ?? '',
+ phone_number: values[headerIndex.phone_number] ?? '',
+ });
+ }
+
+ return parsedRows.filter(
+ (row) =>
+ row.first_name ||
+ row.last_name ||
+ row.email_address ||
+ row.phone_country_code ||
+ row.phone_number
+ );
+ };
+
+ const downloadImportTemplate = () => {
+ const template = [
+ 'first_name,last_name,email_address,phone_country_code,phone_number',
+ '"John","Doe","john.doe@example.com","+91","9876543210"',
+ '"Jane","Smith","jane.smith@example.com","+44","7700900100"',
+ ].join('\n');
+
+ const blob = new Blob([template], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = 'learners_bulk_template.csv';
+ link.click();
+ URL.revokeObjectURL(url);
+ };
+
+ const handleImportFileChange = async (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+
+ setImportError('');
+ setImportRowErrors([]);
+ if (!file.name.toLowerCase().endsWith('.csv')) {
+ setImportError('Please upload a valid CSV file.');
+ setCsvRows([]);
+ return;
+ }
+
+ try {
+ const text = await file.text();
+ const rows = parseCSV(text);
+ if (rows.length === 0) {
+ throw new Error('No valid rows found in CSV.');
+ }
+ setCsvRows(rows);
+ showToast('CSV loaded', `${rows.length} rows ready to import.`, 'success');
+ } catch (error: any) {
+ setCsvRows([]);
+ setImportError(error?.message || 'Failed to parse CSV file.');
+ }
+ };
+
+ const handleBulkImportLearners = async () => {
+ if (csvRows.length === 0) {
+ setImportError('Please choose a CSV file first.');
+ setImportRowErrors([]);
+ return;
+ }
+
+ setImportError('');
+ setImportRowErrors([]);
+ try {
+ const seenEmails = new Set();
+ const seenPhones = new Set();
+ const validRows: CsvLearnerRow[] = [];
+ const rowErrors: string[] = [];
+
+ for (let i = 0; i < csvRows.length; i++) {
+ const row = csvRows[i];
+ const rowNumber = i + 1;
+ const email = row.email_address.trim().toLowerCase();
+ const phoneCountryCode = row.phone_country_code.trim() || '+91';
+ const phoneNumber = row.phone_number.trim();
+ const phoneKey = `${phoneCountryCode}-${phoneNumber}`;
+
+ if (!row.first_name.trim() || !row.last_name.trim() || !email) {
+ rowErrors.push(`Row ${rowNumber}: first_name, last_name and email_address are required.`);
+ continue;
+ }
+
+ if (seenEmails.has(email)) {
+ rowErrors.push(`Row ${rowNumber}: duplicate email in CSV.`);
+ continue;
+ }
+ seenEmails.add(email);
+
+ if (phoneNumber) {
+ if (seenPhones.has(phoneKey)) {
+ rowErrors.push(`Row ${rowNumber}: duplicate phone in CSV.`);
+ continue;
+ }
+ seenPhones.add(phoneKey);
+ }
+
+ const [emailCheck, phoneCheck] = await Promise.all([
+ checkEmailExists(email).unwrap(),
+ phoneNumber
+ ? checkMobileExists({
+ phone_country_code: phoneCountryCode,
+ phone_number: phoneNumber,
+ }).unwrap()
+ : Promise.resolve({ data: { exists: false } }),
+ ]);
+
+ const existingFieldErrors: string[] = [];
+ if (emailCheck?.data?.exists) {
+ existingFieldErrors.push('email already exists in system');
+ }
+ if (phoneCheck?.data?.exists) {
+ existingFieldErrors.push('phone already exists in system');
+ }
+ if (existingFieldErrors.length > 0) {
+ rowErrors.push(`Row ${rowNumber}: ${existingFieldErrors.join(' and ')}.`);
+ continue;
+ }
+
+ validRows.push({
+ first_name: row.first_name.trim(),
+ last_name: row.last_name.trim(),
+ email_address: email,
+ phone_country_code: phoneCountryCode,
+ phone_number: phoneNumber,
+ });
+ }
+
+ if (validRows.length === 0) {
+ setImportError('No valid rows to import.');
+ setImportRowErrors(rowErrors);
+ showToast(
+ 'Import blocked',
+ rowErrors.join(' | ') || 'All rows already exist or are invalid.',
+ 'error'
+ );
+ return;
+ }
+
+ const response = await bulkCreateLearnersForHr(validRows).unwrap();
+ showToast(
+ 'Import successful',
+ response.message || `${validRows.length} learners imported.`,
+ 'success'
+ );
+ if (rowErrors.length > 0) {
+ setImportRowErrors(rowErrors);
+ showToast(
+ 'Some rows skipped',
+ `${rowErrors.length} row(s) skipped due to duplicates/existing records.`,
+ 'error'
+ );
+ }
+ setCsvRows([]);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ setShowImportModal(false);
+ setCurrentPage(1);
+ } catch (error: any) {
+ setImportError(
+ error?.data?.message || 'Failed to import learners. Please check your file and try again.'
+ );
+ setImportRowErrors([]);
+ }
+ };
+
+ const handleAssignProgramme = async () => {
+ setAssignError('');
+ if (selectedEmployees.length === 0) {
+ setAssignError('Please select at least one learner.');
+ return;
+ }
+ if (!selectedProgrammeId) {
+ setAssignError('Please select a programme.');
+ return;
+ }
+
+ try {
+ const response = await bulkAssignProgramme({
+ principal_xids: selectedEmployees,
+ programme_xids: [selectedProgrammeId],
+ }).unwrap();
+
+ showToast(
+ 'Programme assigned',
+ response.message || 'Learners assigned to programme successfully.',
+ 'success'
+ );
+ setShowAssignModal(false);
+ setSelectedProgrammeId('');
+ setSelectedEmployees([]);
+ } catch (error: any) {
+ const message = error?.data?.message || 'Failed to assign programme.';
+ setAssignError(message);
+ showToast('Assign failed', message, 'error');
+ }
+ };
+
+ const handleAssignCourse = async () => {
+ setAssignCourseError('');
+ if (selectedEmployees.length === 0) {
+ setAssignCourseError('Please select at least one learner.');
+ return;
+ }
+ if (!selectedCourseId) {
+ setAssignCourseError('Please select a course.');
+ return;
+ }
+
+ try {
+ const response = await bulkAssignCourse({
+ principal_xids: selectedEmployees,
+ course_xids: [selectedCourseId],
+ }).unwrap();
+
+ showToast(
+ 'Course assigned',
+ response.message || 'Learners assigned to course successfully.',
+ 'success'
+ );
+ setShowAssignCourseModal(false);
+ setSelectedCourseId('');
+ setSelectedEmployees([]);
+ } catch (error: any) {
+ const message = error?.data?.message || 'Failed to assign course.';
+ setAssignCourseError(message);
+ showToast('Assign failed', message, 'error');
+ }
+ };
+
const getStatusColor = (status: string) => {
switch (status) {
case 'Active': return 'default';
@@ -255,7 +854,8 @@ const LearnersPage: React.FC = () => {
debouncedSearch(e.target.value)}
+ value={searchInput}
+ onChange={(e) => setSearchInput(e.target.value)}
aria-label="Search learners by name or email"
/>
@@ -271,18 +871,6 @@ const LearnersPage: React.FC = () => {