working on learner
This commit is contained in:
@@ -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
100
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
17
src/components/toast/useToast.ts
Normal file
17
src/components/toast/useToast.ts
Normal 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 };
|
||||
}
|
||||
@@ -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
136
src/context/AuthContext.tsx
Normal 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;
|
||||
};
|
||||
@@ -1141,6 +1141,10 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cs-height{
|
||||
height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
.flex-shrink-0, .shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{(threadsLoading || threadsFetching) && (
|
||||
<div className="text-sm text-muted-foreground">Loading threads...</div>
|
||||
)}
|
||||
|
||||
{!threadsLoading && filteredThreads.length === 0 && (
|
||||
<div className="rounded-md border p-6 text-sm text-muted-foreground">No threads found.</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredThreads.map((thread) => (
|
||||
<button
|
||||
key={thread.id}
|
||||
type="button"
|
||||
className="w-full rounded-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">
|
||||
<Button
|
||||
onClick={handleCreateThread}
|
||||
disabled={!newThread.title || !newThread.content || !newThread.category}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
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>
|
||||
|
||||
296
src/pages/DiscussionsPage/DiscussionsView.tsx
Normal file
296
src/pages/DiscussionsPage/DiscussionsView.tsx
Normal 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
105
src/pages/Login.tsx
Normal 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
6
src/redux/hooks.ts
Normal 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;
|
||||
139
src/redux/services/forumApi.ts
Normal file
139
src/redux/services/forumApi.ts
Normal 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;
|
||||
461
src/redux/services/learnersApi.ts
Normal file
461
src/redux/services/learnersApi.ts
Normal 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;
|
||||
52
src/redux/services/loginApi.ts
Normal file
52
src/redux/services/loginApi.ts
Normal 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
17
src/redux/store.ts
Normal 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;
|
||||
14
src/routes/ProtectedRoute.tsx
Normal file
14
src/routes/ProtectedRoute.tsx
Normal 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
12
src/routes/RootLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,45 +12,58 @@ 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user