working on learner

This commit is contained in:
priyanshuvish
2026-04-10 16:38:25 +05:30
parent 399b860077
commit f1d231d101
24 changed files with 2843 additions and 864 deletions

View File

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

100
package-lock.json generated
View File

@@ -34,6 +34,7 @@
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/postcss": "^4.1.12",
"class-variance-authority": "^0.7.1",
"clsx": "*",
@@ -47,6 +48,7 @@
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.55.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^2.1.7",
"react-router-dom": "^7.13.1",
"recharts": "^2.15.2",
@@ -1890,6 +1892,32 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2177,6 +2205,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/core": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz",
@@ -2764,6 +2804,12 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react-swc": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz",
@@ -3143,6 +3189,16 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/input-otp": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
@@ -3626,6 +3682,29 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
@@ -3806,6 +3885,27 @@
"decimal.js-light": "^2.4.1"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.49.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.49.0.tgz",

View File

@@ -29,6 +29,7 @@
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/postcss": "^4.1.12",
"class-variance-authority": "^0.7.1",
"clsx": "*",
@@ -42,6 +43,7 @@
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.55.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^2.1.7",
"react-router-dom": "^7.13.1",
"recharts": "^2.15.2",

View File

@@ -18,6 +18,7 @@ import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Avatar, AvatarFallback } from './ui/avatar';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu';
import { useAuth } from '../context/AuthContext';
interface TopNavProps {
onMenuToggle?: () => void;
@@ -61,6 +62,19 @@ export const TopNav: React.FC<TopNavProps> = ({
notificationCount = 0
}) => {
const navigate = useNavigate();
const { user, logout } = useAuth();
const displayName =
user?.display_name?.trim() ||
[user?.first_name, user?.last_name].filter(Boolean).join(' ').trim() ||
'HR User';
const emailLabel = user?.email_address ?? 'hr@klc.com';
const userInitials =
displayName
.split(/\s+/)
.map((p) => p[0])
.join('')
.slice(0, 2)
.toUpperCase() || 'HR';
const [preferences, setPreferences] = useLocalStorage<UserPreferences>('userPreferences', {
darkMode: false,
prefersReducedMotion: false
@@ -76,12 +90,7 @@ export const TopNav: React.FC<TopNavProps> = ({
};
const handleSignOut = () => {
// Add your sign out logic here
console.log('Signing out...');
// Clear any auth tokens/user data
localStorage.removeItem('authToken');
sessionStorage.clear();
navigate('/login');
logout();
};
const handleProfileClick = () => {
@@ -105,7 +114,7 @@ export const TopNav: React.FC<TopNavProps> = ({
};
return (
<header className="h-16 bg-background border-b border-chrome-divider flex items-center justify-between px-4 lg:px-6 sticky top-0 z-30">
<header className="h-16 bg-background border-b border-chrome-divider flex items-center justify-between px-4 lg:px-6 sticky top-0 z-50">
{/* Left Section */}
<div className="flex items-center gap-4">
{showMenuButton && (
@@ -178,12 +187,12 @@ export const TopNav: React.FC<TopNavProps> = ({
>
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-brand-navy text-white">
HR
{userInitials}
</AvatarFallback>
</Avatar>
<div className="hidden md:block text-left">
<p className="text-sm font-medium">HR Manager</p>
<p className="text-xs text-muted-foreground">hr@klc.com</p>
<p className="text-sm font-medium">{displayName}</p>
<p className="text-xs text-muted-foreground">{emailLabel}</p>
</div>
<ChevronDown className="h-4 w-4 text-muted-foreground hidden sm:block" />
</Button>
@@ -194,12 +203,12 @@ export const TopNav: React.FC<TopNavProps> = ({
<div className="flex items-center gap-3 p-4 bg-muted/20">
<Avatar className="h-12 w-12">
<AvatarFallback className="bg-brand-navy text-white text-lg">
HR
{userInitials}
</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold">HR Manager</p>
<p className="text-sm text-muted-foreground">hr@klc.com</p>
<p className="font-semibold">{displayName}</p>
<p className="text-sm text-muted-foreground">{emailLabel}</p>
<Badge variant="outline" className="mt-1 text-xs">
Administrator
</Badge>

View File

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

View File

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

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

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

View File

@@ -1141,6 +1141,10 @@
flex: 1;
}
.cs-height{
height: calc(100vh - 56px);
}
.flex-shrink-0, .shrink-0 {
flex-shrink: 0;
}

View File

@@ -71,13 +71,13 @@ const HRLayout: React.FC = () => {
{/* Main Content */}
<main
id="main-content"
className={`flex-1 overflow-y-auto p-4 lg:p-8 transition-all duration-300 ${announcementsOpen ? 'lg:mr-80' : ''
className={`flex-1 cs-height overflow-y-auto p-4 lg:p-8 transition-all duration-300 ${announcementsOpen ? 'lg:mr-80' : ''
}`}
>
<div className="max-w-7xl mx-auto">
<div className="sticky top-0 bg-background z-10 pb-2">
<BreadcrumbNav />
</div>
{/* <div className="sticky top-0 bg-background z-10 pb-2">
</div> */}
<BreadcrumbNav />
<Outlet />
</div>
</main>

View File

@@ -1,13 +1,13 @@
import {
BarChart3,
Home,
MessageSquare,
Users
} from 'lucide-react';
import React from 'react';
import { NavLink } from 'react-router-dom';
import {
Home,
Users,
BarChart3,
MessageSquare
} from 'lucide-react';
import { Button } from '../../components/ui/button';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { useAuth } from '../../context/AuthContext';
const menuItems = [
{ id: 'dashboard', label: 'Dashboard', icon: Home, path: '/hr/dashboard' },
@@ -23,17 +23,27 @@ interface HRSidebarProps {
export const HRSidebar: React.FC<HRSidebarProps> = ({ className = '', onNavigate }) => {
const [prefersReducedMotion] = useLocalStorage('prefersReducedMotion', false);
const { user } = useAuth();
const orgName = user?.principal_organization_name?.trim() || 'Tech Solutions Pvt Ltd';
const orgInitials =
orgName
.split(/\s+/)
.filter(Boolean)
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase() || 'TS';
return (
<div className={`w-64 min-w-[248px] h-full bg-sidebar flex flex-col ${className}`}>
<div className="p-6">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-brand-charcoal rounded-md flex items-center justify-center">
<span className="text-brand-charcoal-foreground font-bold text-sm">TS</span>
<div className={`w-64 min-w-[248px] cs-height bg-sidebar flex flex-col ${className}`}>
<div className="p-6">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-brand-charcoal rounded-md flex items-center justify-center">
<span className="text-brand-charcoal-foreground font-bold text-sm">{orgInitials}</span>
</div>
<span className="font-semibold text-sidebar-foreground">{orgName}</span>
</div>
<span className="font-semibold text-sidebar-foreground">Tech Solutions Pvt Ltd</span>
</div>
</div>
<nav className="flex-1 p-4" role="navigation" aria-label="HR Portal Navigation">
<ul className="space-y-1">

View File

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

View File

@@ -1,702 +1,316 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import React, { useMemo, useState } from 'react';
import { Button } from '../../components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { Card, CardContent } from '../../components/ui/card';
import { Input } from '../../components/ui/input';
import { Textarea } from '../../components/ui/textarea';
import { Badge } from '../../components/ui/badge';
import { Avatar, AvatarFallback } from '../../components/ui/avatar';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../components/ui/dialog';
import {
MessageSquare,
Plus,
Search,
Filter,
Pin,
Heart,
Reply,
Share2,
Flag,
Eye,
Clock,
Users,
Hash,
ThumbsUp,
MessageCircle,
ChevronRight,
ArrowLeft,
Send,
MoreHorizontal,
Bookmark,
Bell,
CheckCircle,
AlertCircle
} from 'lucide-react';
interface Author {
id: string;
name: string;
avatar?: string;
role?: string;
}
interface Thread {
id: string;
title: string;
content: string;
author: Author;
category: string;
tags: string[];
createdAt: string;
lastActivity: string;
replies: number;
views: number;
likes: number;
isPinned: boolean;
isLocked: boolean;
isSolved: boolean;
}
interface Reply {
id: string;
threadId: string;
content: string;
author: Author;
createdAt: string;
likes: number;
isBestAnswer: boolean;
parentId?: string;
}
// Mock data
const mockThreads: Thread[] = [
{
id: '1',
title: 'Best practices for remote team communication',
content: 'What strategies have you found most effective for maintaining clear communication with remote team members? I\'d love to hear about tools and techniques that have worked well for your teams.',
author: { id: 'u1', name: 'Sarah Chen', role: 'HR Manager' },
category: 'best-practices',
tags: ['communication', 'remote-work', 'leadership'],
createdAt: '2024-12-28T10:30:00Z',
lastActivity: '2024-12-28T15:45:00Z',
replies: 12,
views: 245,
likes: 34,
isPinned: true,
isLocked: false,
isSolved: false
},
{
id: '2',
title: 'How to handle difficult conversations with team members?',
content: 'I\'m struggling with addressing performance issues with one of my team members. Any advice on how to approach this sensitively while being direct about expectations?',
author: { id: 'u2', name: 'Michael Rodriguez', role: 'Team Lead' },
category: 'advice',
tags: ['difficult-conversations', 'performance-management'],
createdAt: '2024-12-28T09:15:00Z',
lastActivity: '2024-12-28T14:20:00Z',
replies: 8,
views: 156,
likes: 21,
isPinned: false,
isLocked: false,
isSolved: true
},
{
id: '3',
title: 'Share your leadership book recommendations',
content: 'What books have been most influential in your leadership journey? Looking for practical reads that offer actionable insights.',
author: { id: 'u3', name: 'Emma Thompson', role: 'Learning Specialist' },
category: 'recommendations',
tags: ['books', 'learning', 'leadership'],
createdAt: '2024-12-27T16:00:00Z',
lastActivity: '2024-12-28T11:30:00Z',
replies: 15,
views: 312,
likes: 45,
isPinned: false,
isLocked: false,
isSolved: false
},
{
id: '4',
title: 'Question about delegation framework from Module 3',
content: 'Can someone clarify the difference between the delegation levels we covered? I want to make sure I\'m applying them correctly in my current projects.',
author: { id: 'u4', name: 'David Kim', role: 'Project Manager' },
category: 'questions',
tags: ['delegation', 'module-3', 'clarification'],
createdAt: '2024-12-27T14:30:00Z',
lastActivity: '2024-12-27T18:45:00Z',
replies: 6,
views: 98,
likes: 12,
isPinned: false,
isLocked: false,
isSolved: false
}
];
const mockReplies: Reply[] = [
{
id: 'r1',
threadId: '1',
content: 'Great question! I\'ve found that establishing clear communication protocols at the start of projects makes a huge difference. We use a combination of daily stand-ups via video call and async updates through Slack.',
author: { id: 'u5', name: 'Lisa Wang', role: 'HR Coordinator' },
createdAt: '2024-12-28T11:00:00Z',
likes: 8,
isBestAnswer: false
},
{
id: 'r2',
threadId: '1',
content: 'One thing that\'s worked well for our team is having "communication preferences" documented for each team member. Some prefer quick calls for complex topics, others prefer detailed written explanations.',
author: { id: 'u6', name: 'Robert Lee', role: 'Team Lead' },
createdAt: '2024-12-28T12:15:00Z',
likes: 12,
isBestAnswer: true
}
];
import { AlertCircle, MessageCircle, Plus, Search, ThumbsUp, X } from 'lucide-react';
import { useCreateThreadMutation, useGetThreadsQuery } from '../../redux/services/forumApi';
import { useToast } from '../../components/toast/useToast';
import DiscussionsView from './DiscussionsView';
const DiscussionsPage: React.FC = () => {
const navigate = useNavigate();
const [selectedCategory, setSelectedCategory] = useState('all');
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState('latest');
const [viewMode, setViewMode] = useState<'list' | 'detail'>('list');
const [selectedThread, setSelectedThread] = useState<Thread | null>(null);
const { showToast } = useToast();
const [showNewThreadModal, setShowNewThreadModal] = useState(false);
const [newThread, setNewThread] = useState({ title: '', content: '', category: '', tags: '' });
const [replyContent, setReplyContent] = useState('');
const [bookmarkedThreads, setBookmarkedThreads] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [tagsFilter, setTagsFilter] = useState('all');
const [newThreadTitle, setNewThreadTitle] = useState('');
const [newThreadContent, setNewThreadContent] = useState('');
const [tagInput, setTagInput] = useState('');
const [newThreadTags, setNewThreadTags] = useState<string[]>([]);
const [createError, setCreateError] = useState('');
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const categories = [
{ id: 'all', name: 'All Discussions', count: mockThreads.length },
{ id: 'best-practices', name: 'Best Practices', count: mockThreads.filter(t => t.category === 'best-practices').length },
{ id: 'advice', name: 'Advice', count: mockThreads.filter(t => t.category === 'advice').length },
{ id: 'recommendations', name: 'Recommendations', count: mockThreads.filter(t => t.category === 'recommendations').length },
{ id: 'questions', name: 'Questions', count: mockThreads.filter(t => t.category === 'questions').length }
];
const { data: threadsResponse, isLoading: threadsLoading, isFetching: threadsFetching } =
useGetThreadsQuery();
const [createThread, { isLoading: isCreatingThread }] = useCreateThreadMutation();
const filteredThreads = mockThreads
.filter(thread => {
const matchesCategory = selectedCategory === 'all' || thread.category === selectedCategory;
const matchesSearch = thread.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
thread.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
thread.tags.some(tag => tag.includes(searchTerm.toLowerCase()));
return matchesCategory && matchesSearch;
})
.sort((a, b) => {
if (sortBy === 'latest') return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
if (sortBy === 'popular') return b.views - a.views;
if (sortBy === 'active') return new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime();
return 0;
const threads = threadsResponse?.data ?? [];
const allTags = useMemo(() => {
const set = new Set<string>();
threads.forEach((thread) => {
thread.tags.forEach((tag) => set.add(tag));
});
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [threads]);
const threadReplies = selectedThread ? mockReplies.filter(r => r.threadId === selectedThread.id) : [];
const filteredThreads = useMemo(() => {
return threads.filter((thread) => {
const query = searchTerm.trim().toLowerCase();
const matchesSearch =
!query ||
thread.title.toLowerCase().includes(query) ||
thread.content.toLowerCase().includes(query) ||
thread.tags.some((tag) => tag.toLowerCase().includes(query));
const matchesTag = tagsFilter === 'all' || thread.tags.includes(tagsFilter);
return matchesSearch && matchesTag;
});
}, [threads, searchTerm, tagsFilter]);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
const selectedThread = useMemo(
() => threads.find((thread) => thread.id === selectedThreadId) ?? null,
[threads, selectedThreadId]
);
if (diffHours < 1) return 'Just now';
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
const getReactionCount = (emoji: string) =>
(threadsResponse?.data ?? [])
.flatMap((t) => t.reactions)
.find(() => false) && 0;
const getThreadLikeCount = (thread: (typeof threads)[number]) =>
thread.reactions.find((r) => r.emoji_code === 'U+1F44D')?.count ?? 0;
const getThreadReactionCount = (thread: (typeof threads)[number]) =>
thread.reactions.reduce((sum, r) => sum + r.count, 0);
const formatDate = (date: string) => {
const d = new Date(date);
return d.toLocaleDateString('en-GB');
};
const handleCreateThread = () => {
console.log('Creating thread:', newThread);
setShowNewThreadModal(false);
setNewThread({ title: '', content: '', category: '', tags: '' });
const resetModal = () => {
setNewThreadTitle('');
setNewThreadContent('');
setTagInput('');
setNewThreadTags([]);
setCreateError('');
};
const handleAddReply = () => {
if (replyContent.trim() && selectedThread) {
console.log('Adding reply to thread:', selectedThread.id, replyContent);
setReplyContent('');
const addTag = (rawTag: string) => {
const value = rawTag.trim().toLowerCase();
if (!value) return;
if (newThreadTags.includes(value)) return;
setNewThreadTags((prev) => [...prev, value]);
};
const handleTagKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag(tagInput);
setTagInput('');
}
};
const toggleBookmark = (threadId: string) => {
setBookmarkedThreads(prev =>
prev.includes(threadId) ? prev.filter(id => id !== threadId) : [...prev, threadId]
);
const handleCreateThread = async () => {
setCreateError('');
if (!newThreadTitle.trim() || !newThreadContent.trim()) {
setCreateError('Title and content are required.');
return;
}
try {
const response = await createThread({
title: newThreadTitle.trim(),
content: newThreadContent.trim(),
tags: newThreadTags,
}).unwrap();
showToast('Thread created', response.message || 'Thread created successfully.', 'success');
setShowNewThreadModal(false);
resetModal();
} catch (error: any) {
const message = error?.data?.message || 'Failed to create thread.';
setCreateError(message);
showToast('Create failed', message, 'error');
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Discussion Forums</h1>
<p className="text-muted-foreground">Connect, share, and learn with your peers</p>
</div>
<Button onClick={() => setShowNewThreadModal(true)} className="min-tap-44">
<Plus className="h-4 w-4 mr-2" />
New Discussion
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total Discussions</p>
<p className="text-2xl font-bold">{mockThreads.length}</p>
</div>
<MessageSquare className="h-8 w-8 text-blue-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Active Today</p>
<p className="text-2xl font-bold">
{mockThreads.filter(t => {
const lastActivity = new Date(t.lastActivity);
const today = new Date();
return lastActivity.toDateString() === today.toDateString();
}).length}
</p>
</div>
<Users className="h-8 w-8 text-green-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total Replies</p>
<p className="text-2xl font-bold">
{mockThreads.reduce((acc, t) => acc + t.replies, 0)}
</p>
</div>
<Reply className="h-8 w-8 text-purple-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Solved</p>
<p className="text-2xl font-bold">
{mockThreads.filter(t => t.isSolved).length}
</p>
</div>
<CheckCircle className="h-8 w-8 text-yellow-500" />
</div>
</CardContent>
</Card>
</div>
{viewMode === 'list' ? (
<div className="space-y-4">
{selectedThread ? (
<DiscussionsView thread={selectedThread} onBack={() => setSelectedThreadId(null)} />
) : (
<>
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-4xl font-bold tracking-tight">Discussion Forums</h1>
<p className="text-muted-foreground">Connect, share, and learn with your cohort members</p>
</div>
<Button onClick={() => setShowNewThreadModal(true)} className="min-tap-44">
<Plus className="h-4 w-4 mr-2" />
New Thread
</Button>
</div>
<Card>
<CardContent className="pt-6">
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center justify-between">
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center flex-1">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search discussions..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="latest">Latest</SelectItem>
<SelectItem value="active">Most Active</SelectItem>
<SelectItem value="popular">Most Viewed</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="min-tap-44">
<Filter className="h-4 w-4 mr-2" />
Filters
</Button>
<CardContent className="py-4">
<div className="flex items-center gap-3 flex-nowrap">
<Select defaultValue="leadership-development-q4-2024">
<SelectTrigger className="w-[300px] shrink-0">
<SelectValue placeholder="Programme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="leadership-development-q4-2024">Leadership Development Q4 2024</SelectItem>
</SelectContent>
</Select>
<div className="relative min-w-0 flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Search threads..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{ paddingLeft: '30px' }}
/>
</div>
<Select value={tagsFilter} onValueChange={setTagsFilter}>
<SelectTrigger className="w-[170px] shrink-0">
<SelectValue placeholder="All Tags" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Tags</SelectItem>
{allTags.map((tag) => (
<SelectItem key={tag} value={tag}>
{tag}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Categories and Threads */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Categories Sidebar */}
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>Categories</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{categories.map(category => (
<button
key={category.id}
onClick={() => setSelectedCategory(category.id)}
className={`w-full flex items-center justify-between p-2 rounded-lg transition-colors ${
selectedCategory === category.id
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted'
}`}
>
<span>{category.name}</span>
<Badge variant={selectedCategory === category.id ? 'secondary' : 'outline'}>
{category.count}
</Badge>
</button>
))}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="py-4 space-y-4">
<div>
<h2 className="text-2xl font-semibold">Discussions ({filteredThreads.length})</h2>
<p className="text-muted-foreground">Leadership Development Q4 2024 30 members</p>
</div>
{/* Threads List */}
<div className="lg:col-span-3 space-y-4">
{filteredThreads.map(thread => (
<Card
key={thread.id}
className="cursor-pointer hover:shadow-lg transition-all"
onClick={() => {
setSelectedThread(thread);
setViewMode('detail');
}}
>
<CardContent className="pt-6">
<div className="flex items-start gap-4">
<Avatar className="w-10 h-10">
<AvatarFallback>
{thread.author.name.split(' ').map(n => n[0]).join('')}
</AvatarFallback>
</Avatar>
{(threadsLoading || threadsFetching) && (
<div className="text-sm text-muted-foreground">Loading threads...</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{thread.isPinned && (
<Pin className="h-4 w-4 text-yellow-500" />
)}
{thread.isSolved && (
<CheckCircle className="h-4 w-4 text-green-500" />
)}
<h3 className="font-semibold truncate">{thread.title}</h3>
</div>
{!threadsLoading && filteredThreads.length === 0 && (
<div className="rounded-md border p-6 text-sm text-muted-foreground">No threads found.</div>
)}
<p className="text-sm text-muted-foreground line-clamp-2 mb-2">
{thread.content}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{thread.author.name}
</span>
<div className="space-y-3">
{filteredThreads.map((thread) => (
<button
key={thread.id}
type="button"
className="w-full rounded-xl border bg-card p-4 text-left hover:bg-muted/20"
onClick={() => setSelectedThreadId(thread.id)}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="text-lg font-semibold">{thread.title}</h3>
<p className="mt-2 text-sm text-muted-foreground">{thread.content}</p>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>By HR User</span>
<span></span>
<span>{thread.author.role}</span>
<span>{formatDate(thread.created_at)}</span>
<span></span>
<span>{formatDate(thread.createdAt)}</span>
<span>Last activity {formatDate(thread.latest_activity)}</span>
</div>
<div className="flex flex-wrap items-center gap-3 mt-3">
{thread.tags.map(tag => (
<Badge key={tag} variant="secondary" className="text-xs">
#{tag}
<div className="mt-2 flex flex-wrap gap-2">
{thread.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs bg-muted">
{tag}
</Badge>
))}
</div>
</div>
<div className="flex flex-col items-end gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<MessageCircle className="h-4 w-4" />
{thread.replies}
</span>
<span className="flex items-center gap-1">
<Eye className="h-4 w-4" />
{thread.views}
</span>
<span className="flex items-center gap-1">
<Heart className="h-4 w-4" />
{thread.likes}
</span>
</div>
<span className="text-xs">
Last activity {formatDate(thread.lastActivity)}
<div className="flex shrink-0 items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<MessageCircle className="h-4 w-4" />
0
</span>
<span className="flex items-center gap-1">
<ThumbsUp className="h-4 w-4" />
{getThreadLikeCount(thread)}
</span>
<span>{getThreadReactionCount(thread)}</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
</button>
))}
</div>
</CardContent>
</Card>
</>
) : (
/* Thread Detail View */
selectedThread && (
<div className="space-y-6">
{/* Back Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setViewMode('list')}
className="min-tap-44"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Discussions
</Button>
{/* Main Thread */}
<Card>
<CardContent className="pt-6">
<div className="flex items-start gap-4">
<Avatar className="w-12 h-12">
<AvatarFallback>
{selectedThread.author.name.split(' ').map(n => n[0]).join('')}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
{selectedThread.isPinned && (
<Pin className="h-4 w-4 text-yellow-500" />
)}
{selectedThread.isSolved && (
<CheckCircle className="h-4 w-4 text-green-500" />
)}
<h2 className="text-xl font-semibold">{selectedThread.title}</h2>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-4">
<span className="font-medium text-foreground">
{selectedThread.author.name}
</span>
<span></span>
<span>{selectedThread.author.role}</span>
<span></span>
<span>{formatDate(selectedThread.createdAt)}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleBookmark(selectedThread.id)}
className="min-tap-44"
>
<Bookmark className={`h-4 w-4 ${
bookmarkedThreads.includes(selectedThread.id) ? 'fill-current' : ''
}`} />
</Button>
<Button variant="ghost" size="sm" className="min-tap-44">
<Share2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="min-tap-44">
<Flag className="h-4 w-4" />
</Button>
</div>
</div>
<div className="prose prose-sm max-w-none mt-4">
<p>{selectedThread.content}</p>
</div>
<div className="flex flex-wrap items-center gap-2 mt-4">
{selectedThread.tags.map(tag => (
<Badge key={tag} variant="secondary">
#{tag}
</Badge>
))}
</div>
<div className="flex items-center gap-4 mt-4 pt-4 border-t">
<Button variant="ghost" size="sm" className="min-tap-44">
<Heart className="h-4 w-4 mr-2" />
Like ({selectedThread.likes})
</Button>
<Button variant="ghost" size="sm" className="min-tap-44">
<Reply className="h-4 w-4 mr-2" />
Reply
</Button>
<span className="text-sm text-muted-foreground">
{selectedThread.views} views
</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Replies */}
<Card>
<CardHeader>
<CardTitle>Replies ({threadReplies.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
{threadReplies.map(reply => (
<div key={reply.id} className={`p-4 rounded-lg ${
reply.isBestAnswer ? 'bg-green-50 border border-green-200' : 'bg-muted/30'
}`}>
<div className="flex items-start gap-3">
<Avatar className="w-8 h-8">
<AvatarFallback>
{reply.author.name.split(' ').map(n => n[0]).join('')}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="font-medium">{reply.author.name}</span>
<span className="text-xs text-muted-foreground">
{reply.author.role}
</span>
<span className="text-xs text-muted-foreground">
{formatDate(reply.createdAt)}
</span>
{reply.isBestAnswer && (
<Badge variant="default" className="text-xs">
Best Answer
</Badge>
)}
</div>
<Button variant="ghost" size="sm" className="min-tap-44">
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
<p className="text-sm">{reply.content}</p>
<div className="flex items-center gap-4 mt-2">
<Button variant="ghost" size="sm" className="h-8 px-2">
<Heart className="h-4 w-4 mr-1" />
{reply.likes}
</Button>
<Button variant="ghost" size="sm" className="h-8 px-2">
<Reply className="h-4 w-4 mr-1" />
Reply
</Button>
</div>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Add Reply */}
<Card>
<CardHeader>
<CardTitle>Add Reply</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Textarea
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
placeholder="Share your thoughts or answer the question..."
className="min-h-[100px]"
/>
<div className="flex justify-end">
<Button
onClick={handleAddReply}
disabled={!replyContent.trim()}
className="min-tap-44"
>
<Send className="h-4 w-4 mr-2" />
Post Reply
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
)
)}
{/* New Thread Modal */}
<Dialog open={showNewThreadModal} onOpenChange={setShowNewThreadModal}>
<Dialog
open={showNewThreadModal}
onOpenChange={(open: boolean) => {
setShowNewThreadModal(open);
if (!open) resetModal();
}}
>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Start New Discussion</DialogTitle>
<DialogDescription>
Share your thoughts, ask a question, or start a conversation with the community.
</DialogDescription>
<DialogDescription>Create a new thread for this forum.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{createError && (
<div className="flex items-center gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
<AlertCircle className="h-4 w-4" />
{createError}
</div>
)}
<div>
<label className="block text-sm font-medium mb-2">Title *</label>
<label className="mb-2 block text-sm font-medium">Title *</label>
<Input
value={newThread.title}
onChange={(e) => 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}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Category *</label>
<Select onValueChange={(value) => setNewThread(prev => ({ ...prev, category: value }))}>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="best-practices">Best Practices</SelectItem>
<SelectItem value="advice">Advice</SelectItem>
<SelectItem value="recommendations">Recommendations</SelectItem>
<SelectItem value="questions">Questions</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="block text-sm font-medium mb-2">Content *</label>
<label className="mb-2 block text-sm font-medium">Content *</label>
<Textarea
value={newThread.content}
onChange={(e) => setNewThread(prev => ({ ...prev, content: e.target.value }))}
placeholder="Share your thoughts in detail..."
className="min-h-[150px]"
value={newThreadContent}
onChange={(e) => setNewThreadContent(e.target.value)}
placeholder="Share your thoughts..."
className="min-h-[140px]"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Tags (comma separated)</label>
<label className="mb-2 block text-sm font-medium">Tags</label>
<Input
value={newThread.tags}
onChange={(e) => 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 && (
<div className="mt-2 flex flex-wrap gap-2">
{newThreadTags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1">
{tag}
<button
type="button"
onClick={() =>
setNewThreadTags((prev) => prev.filter((existingTag) => existingTag !== tag))
}
aria-label={`Remove ${tag}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
<div className="flex gap-2 pt-4">
<div className="flex gap-2 pt-2">
<Button
onClick={handleCreateThread}
disabled={!newThread.title || !newThread.content || !newThread.category}
className="flex-1"
onClick={handleCreateThread}
disabled={isCreatingThread || !newThreadTitle.trim() || !newThreadContent.trim()}
>
Create Discussion
{isCreatingThread ? 'Creating...' : 'Create Thread'}
</Button>
<Button variant="outline" onClick={() => setShowNewThreadModal(false)} className="flex-1">
<Button variant="outline" className="flex-1" onClick={() => setShowNewThreadModal(false)}>
Cancel
</Button>
</div>

View File

@@ -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<DiscussionsViewProps> = ({ thread, onBack }) => {
const { showToast } = useToast();
const [replyInput, setReplyInput] = useState('');
const [childReplyInputById, setChildReplyInputById] = useState<Record<string, string>>({});
const [openChildReplyForId, setOpenChildReplyForId] = useState<string | null>(null);
const { 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 (
<div
key={reply.id}
className={'border-b pb-4 last:border-b-0' + (depth > 0 ? ' ml-6 mt-3 border-l pl-4' : '')}
>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">HR User</span>
<span>{formatDate(reply.created_at)}</span>
</div>
<Button variant="ghost" size="icon">
<Ellipsis className="h-4 w-4" />
</Button>
</div>
<p className="mb-3 text-sm">{reply.content}</p>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>{getReplyReactionCount(reply)} reactions</span>
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={() => reactToReply(reply.id)}
disabled={reacting}
>
<ThumbsUp className="h-4 w-4" />
{getReplyLikeCount(reply)}
</button>
<button
type="button"
className="hover:text-foreground"
onClick={() => setOpenChildReplyForId(isChildBoxOpen ? null : reply.id)}
>
Reply
</button>
</div>
{isChildBoxOpen && (
<div className="mt-3 flex gap-2">
<Input
value={childReplyText}
onChange={(e) =>
setChildReplyInputById((prev) => ({ ...prev, [reply.id]: e.target.value }))
}
placeholder="Write a sub-reply..."
/>
<Button
onClick={() => postReply(childReplyText, reply.id)}
disabled={postingReply || !childReplyText.trim()}
>
Post
</Button>
</div>
)}
{children.length > 0 && <div className="mt-2">{renderReplies(children, depth + 1)}</div>}
</div>
);
});
return (
<div className="space-y-4">
<div className="flex items-center gap-3 text-sm">
<Button variant="ghost" onClick={onBack} className="h-auto p-0 font-medium">
<ArrowLeft className="mr-1 h-4 w-4" />
Back to Forums
</Button>
<span className="text-muted-foreground">Leadership Development Q4 2024</span>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">Thread</span>
</div>
<Card>
<CardContent className="space-y-4 py-6">
<div className="flex items-start justify-between gap-2">
<h2 className="text-4xl font-semibold">{thread.title}</h2>
<Button variant="ghost" size="icon">
<Ellipsis className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>By HR User</span>
<span></span>
<span>{formatDate(thread.created_at)}</span>
<span></span>
<span>{replyCount} replies</span>
</div>
<p className="text-lg">{thread.content}</p>
<div className="flex flex-wrap gap-2">
{thread.tags.map((tag) => (
<Badge key={tag} variant="secondary">
#{tag}
</Badge>
))}
</div>
<div className="flex items-center gap-6 border-t pt-4 text-sm text-muted-foreground">
<span>{getReactionCount(thread)} 🤔</span>
<button
type="button"
onClick={reactToThread}
disabled={reacting}
className="flex items-center gap-1 hover:text-foreground"
>
<ThumbsUp className="h-4 w-4" />
{getLikeCount(thread)}
</button>
<span className="flex items-center gap-1">
<Smile className="h-4 w-4" />0
</span>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-4 py-6">
<div className="flex items-center justify-between">
<h3 className="text-2xl font-semibold">Replies ({replyCount})</h3>
<Button variant="outline" size="sm" onClick={() => refetchReplies()} disabled={repliesLoading}>
<RefreshCw className={'mr-2 h-4 w-4' + (repliesLoading ? ' animate-spin' : '')} />
Refresh
</Button>
</div>
{repliesLoading ? (
<div className="text-sm text-muted-foreground">Loading replies...</div>
) : replies.length === 0 ? (
<div className="text-sm text-muted-foreground">No replies yet.</div>
) : (
renderReplies(replies)
)}
</CardContent>
</Card>
<Card>
<CardContent className="space-y-4 py-6">
<h3 className="text-2xl font-semibold">Add Reply</h3>
<Input
value={replyInput}
onChange={(e) => setReplyInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!postingReply && replyInput.trim()) {
void postReply(replyInput);
}
}
}}
placeholder="Share your thoughts or ask a follow-up question..."
/>
<div className="flex items-center justify-between">
{/* <div className="flex items-center gap-3 text-muted-foreground">
<Button variant="ghost" size="icon" className="h-8 w-8">
<span className="text-sm font-semibold">B</span>
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8">
<span className="text-sm italic">I</span>
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Link2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8">
<List className="h-4 w-4" />
</Button>
</div> */}
<Button
className="bg-[#7a78b0] hover:bg-[#69679d]"
onClick={() => postReply(replyInput)}
disabled={postingReply || !replyInput.trim()}
>
<Send className="mr-2 h-4 w-4" />
{postingReply ? 'Posting...' : 'Post Reply'}
</Button>
</div>
</CardContent>
</Card>
</div>
);
};
export default DiscussionsView;

File diff suppressed because it is too large Load Diff

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

@@ -1,5 +1,8 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { RootLayout } from './RootLayout';
import { ProtectedRoute } from './ProtectedRoute';
import HRLayout from '../layouts/HRLayout';
import Login from '../pages/Login';
import DashboardPage from '../pages/Dashboard/DashboardPage';
import LearnersPage from '../pages/Learners/LearnersPage';
import ReportsPage from '../pages/ReportsPage/ReportsPage';
@@ -9,44 +12,57 @@ import CourseViewPage from '../pages/CourseViewPage/CourseViewPage';
export const router = createBrowserRouter([
{
path: '/',
element: <Navigate to="/hr/dashboard" replace />,
},
{
path: '/hr',
element: <HRLayout />,
element: <RootLayout />,
children: [
{
index: true,
element: <Navigate to="dashboard" replace />,
path: '/login',
element: <Login />,
},
{
path: 'dashboard',
element: <DashboardPage />,
path: '/',
element: <Navigate to="/hr/dashboard" replace />,
},
{
path: 'learners',
element: <LearnersPage />,
},
{
path: 'reports',
element: <ReportsPage />,
},
{
path: 'discussions',
element: <DiscussionsPage />,
},
{
path: 'programme/:programmeId',
element: <ProgrammeViewPage />,
},
{
path: 'course/:courseId',
element: <CourseViewPage />,
},
{
path: 'profile',
element: <DashboardPage />, // You can create a separate ProfilePage later
path: '/hr',
element: (
<ProtectedRoute>
<HRLayout />
</ProtectedRoute>
),
children: [
{
index: true,
element: <Navigate to="dashboard" replace />,
},
{
path: 'dashboard',
element: <DashboardPage />,
},
{
path: 'learners',
element: <LearnersPage />,
},
{
path: 'reports',
element: <ReportsPage />,
},
{
path: 'discussions',
element: <DiscussionsPage />,
},
{
path: 'programme/:programmeId',
element: <ProgrammeViewPage />,
},
{
path: 'course/:courseId',
element: <CourseViewPage />,
},
{
path: 'profile',
element: <DashboardPage />,
},
],
},
],
},

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

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