discussion change
All checks were successful
Build-Check / Build and Test PR (pull_request) Successful in 38s
Sonar Check / SonarQube Scan (pull_request) Successful in 59s

This commit is contained in:
priyanshuvish
2026-04-22 14:54:14 +05:30
parent 04a2c4c529
commit b7fa790d6e
5 changed files with 227 additions and 135 deletions

View File

@@ -6,7 +6,7 @@ import { Textarea } from '../../components/ui/textarea';
import { Badge } from '../../components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../components/ui/dialog';
import { AlertCircle, MessageCircle, Plus, Search, ThumbsUp, X } from 'lucide-react';
import { AlertCircle, Calendar, MessageCircle, Plus, Search, User, X } from 'lucide-react';
import { useCreateThreadMutation, useGetThreadsQuery } from '../../redux/services/forumApi';
import { useToast } from '../../components/toast/useToast';
import DiscussionsView from './DiscussionsView';
@@ -55,14 +55,6 @@ const DiscussionsPage: React.FC = () => {
[threads, selectedThreadId]
);
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);
@@ -192,39 +184,41 @@ const DiscussionsPage: React.FC = () => {
<button
key={thread.id}
type="button"
className="w-full rounded-xl border bg-card p-4 text-left hover:bg-muted/20"
className="w-full rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm transition hover:bg-slate-50 cursor-pointer"
onClick={() => setSelectedThreadId(thread.id)}
style={{borderRadius: '12px'}}
>
<div className="flex items-start 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>{formatDate(thread.created_at)}</span>
<span></span>
<span>Last activity {formatDate(thread.latest_activity)}</span>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
L
</div>
<div className="min-w-0 flex-1 border-l-[3px] border-[#0a2f6f] pl-3">
<div className="flex items-center gap-2">
<h3 className="truncate text-3xl font-semibold leading-tight">{thread.title}</h3>
</div>
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">{thread.content}</p>
<div className="mt-3 flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<User className="h-4 w-4" />
Learner
</span>
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDate(thread.created_at)}
</span>
<span className="flex items-center gap-1">
<MessageCircle className="h-4 w-4" />
{thread.reactions.length}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{thread.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs bg-muted">
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
<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>
</button>
))}

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react';
import { ArrowLeft, Ellipsis, Link2, List, RefreshCw, Send, Smile, ThumbsUp } from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, Ellipsis, MessageCircle, RefreshCw, Send, ThumbsUp } from 'lucide-react';
import { Button } from '../../components/ui/button';
import { Card, CardContent } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
@@ -28,12 +28,21 @@ const getLikeCount = (thread: ForumThread) =>
thread.reactions.find((r) => r.emoji_code === 'U+1F44D')?.count ?? 0;
const getReactionCount = (thread: ForumThread) => thread.reactions.reduce((sum, r) => sum + r.count, 0);
const reactionOptions = [
{ emoji: 'U+1F44D', label: '👍' },
{ emoji: 'U+1F602', label: '😂' },
{ emoji: 'U+1F60D', label: '😍' },
];
export const DiscussionsView: React.FC<DiscussionsViewProps> = ({ thread, onBack }) => {
const { showToast } = useToast();
const [replyInput, setReplyInput] = useState('');
const [childReplyInputById, setChildReplyInputById] = useState<Record<string, string>>({});
const [openChildReplyForId, setOpenChildReplyForId] = useState<string | null>(null);
const [openThreadReactionPicker, setOpenThreadReactionPicker] = useState(false);
const [openReplyReactionPickerId, setOpenReplyReactionPickerId] = useState<string | null>(null);
const threadReactionPickerRef = useRef<HTMLDivElement | null>(null);
const replyReactionPickerRefs = useRef<Record<string, HTMLDivElement | null>>({});
const { data: repliesResponse, isLoading: repliesLoading, refetch: refetchReplies } =
useGetRepliesByThreadQuery(thread.id);
@@ -54,10 +63,10 @@ export const DiscussionsView: React.FC<DiscussionsViewProps> = ({ thread, onBack
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 getReactionLabel = (reactionCode: string) =>
reactionOptions.find((opt) => opt.emoji === reactionCode)?.label ?? '🙂';
const postReply = async (content: string, parentId?: string) => {
if (!content.trim()) return;
@@ -81,33 +90,59 @@ export const DiscussionsView: React.FC<DiscussionsViewProps> = ({ thread, onBack
}
};
const reactToThread = async () => {
const reactToThread = async (emoji: string) => {
try {
await reactToForumItem({
emoji: 'U+1F44D',
emoji,
thread_id: thread.id,
}).unwrap();
await refetchReplies();
setOpenThreadReactionPicker(false);
showToast('Reaction added', 'Your reaction was recorded.', 'success');
} catch (error: any) {
showToast('Reaction failed', error?.data?.message || 'Could not react right now.', 'error');
}
};
const reactToReply = async (replyId: string) => {
const reactToReply = async (replyId: string, emoji: string) => {
try {
await reactToForumItem({
emoji: 'U+1F44D',
thread_id: thread.id,
emoji,
reply_id: replyId,
}).unwrap();
await refetchReplies();
setOpenReplyReactionPickerId(null);
showToast('Reaction added', 'Your reaction was recorded.', 'success');
} catch (error: any) {
showToast('Reaction failed', error?.data?.message || 'Could not react right now.', 'error');
}
};
useEffect(() => {
const handleOutsideClick = (event: Event) => {
const target = event.target as Node | null;
if (!target) return;
if (openThreadReactionPicker && threadReactionPickerRef.current && !threadReactionPickerRef.current.contains(target)) {
setOpenThreadReactionPicker(false);
}
if (openReplyReactionPickerId) {
const activeReplyRef = replyReactionPickerRefs.current[openReplyReactionPickerId];
if (activeReplyRef && !activeReplyRef.contains(target)) {
setOpenReplyReactionPickerId(null);
}
}
};
document.addEventListener('pointerdown', handleOutsideClick, true);
document.addEventListener('touchstart', handleOutsideClick, true);
return () => {
document.removeEventListener('pointerdown', handleOutsideClick, true);
document.removeEventListener('touchstart', handleOutsideClick, true);
};
}, [openThreadReactionPicker, openReplyReactionPickerId]);
const renderReplies = (items: ForumReply[], depth = 0): React.ReactNode =>
items.map((reply) => {
const childReplyText = childReplyInputById[reply.id] ?? '';
@@ -123,25 +158,49 @@ export const DiscussionsView: React.FC<DiscussionsViewProps> = ({ thread, onBack
<span className="font-medium text-foreground">HR User</span>
<span>{formatDate(reply.created_at)}</span>
</div>
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" className="cursor-pointer">
<Ellipsis className="h-4 w-4" />
</Button>
</div>
<p className="mb-3 text-sm">{reply.content}</p>
<div className="flex items-center gap-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}
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div
className="relative"
ref={(el) => {
replyReactionPickerRefs.current[reply.id] = el;
}}
>
<ThumbsUp className="h-4 w-4" />
{getReplyLikeCount(reply)}
</button>
<button
type="button"
className="flex cursor-pointer items-center gap-1 outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 hover:text-foreground"
onClick={() =>
setOpenReplyReactionPickerId((prev) => (prev === reply.id ? null : reply.id))
}
disabled={reacting}
>
<ThumbsUp className="h-4 w-4" />
<span>Like</span>
</button>
{openReplyReactionPickerId === reply.id && (
<div className="absolute bottom-full left-0 z-30 mb-2 flex items-center gap-1 rounded-full border border-slate-200 bg-white p-1 shadow-xl">
{reactionOptions.map((option) => (
<button
key={option.emoji + 'reply' + reply.id}
type="button"
className="cursor-pointer rounded-full p-1 text-lg leading-none transition hover:bg-muted"
onClick={() => reactToReply(reply.id, option.emoji)}
disabled={reacting}
>
{option.label}
</button>
))}
</div>
)}
</div>
<span>{getReplyReactionCount(reply)}</span>
<button
type="button"
className="hover:text-foreground"
className="cursor-pointer hover:text-foreground"
onClick={() => setOpenChildReplyForId(isChildBoxOpen ? null : reply.id)}
>
Reply
@@ -174,7 +233,7 @@ export const DiscussionsView: React.FC<DiscussionsViewProps> = ({ thread, onBack
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">
<Button variant="ghost" onClick={onBack} className="h-auto cursor-pointer p-0 font-medium">
<ArrowLeft className="mr-1 h-4 w-4" />
Back to Forums
</Button>
@@ -184,10 +243,10 @@ export const DiscussionsView: React.FC<DiscussionsViewProps> = ({ thread, onBack
</div>
<Card>
<CardContent className="space-y-4 py-6">
<CardContent className="space-y-4 py-4">
<div className="flex items-start justify-between gap-2">
<h2 className="text-4xl font-semibold">{thread.title}</h2>
<Button variant="ghost" size="icon">
<h2 className="font-semibold leading-tight">{thread.title}</h2>
<Button variant="ghost" size="icon" className="cursor-pointer">
<Ellipsis className="h-4 w-4" />
</Button>
</div>
@@ -210,20 +269,34 @@ export const DiscussionsView: React.FC<DiscussionsViewProps> = ({ thread, onBack
))}
</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 className="flex items-center gap-3 border-t pt-4 text-sm text-muted-foreground">
<div className="relative" ref={threadReactionPickerRef}>
<button
type="button"
className="flex cursor-pointer items-center gap-1 outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 hover:text-foreground"
onClick={() => setOpenThreadReactionPicker((prev) => !prev)}
disabled={reacting}
>
<ThumbsUp className="h-4 w-4" />
<span>Like</span>
</button>
{openThreadReactionPicker && (
<div className="absolute bottom-full left-0 z-30 mb-2 flex items-center gap-1 rounded-full border border-slate-200 bg-white p-1 shadow-xl">
{reactionOptions.map((option) => (
<button
key={option.emoji + 'thread' + thread.id}
type="button"
className="cursor-pointer rounded-full p-1 text-2xl leading-none transition hover:bg-muted"
onClick={() => reactToThread(option.emoji)}
disabled={reacting}
>
{option.label}
</button>
))}
</div>
)}
</div>
<span>{getReactionCount(thread)}</span>
</div>
</CardContent>
</Card>
@@ -264,20 +337,7 @@ export const DiscussionsView: React.FC<DiscussionsViewProps> = ({ thread, onBack
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> */}
<div className="text-sm text-muted-foreground">Press Enter to post</div>
<Button
className="bg-[#7a78b0] hover:bg-[#69679d]"
onClick={() => postReply(replyInput)}

View File

@@ -98,17 +98,19 @@ const LearnersPage: React.FC = () => {
const [editForm, setEditForm] = useState({
firstName: '',
lastName: '',
phoneCountryCode: '+1',
phoneCountryCode: '+91',
phoneNumber: '',
});
const [editSaveError, setEditSaveError] = useState('');
const [assignedCourses, setAssignedCourses] = useState<AssignedCourse[]>([]);
const [courseActionCourseId, setCourseActionCourseId] = useState<string | null>(null);
const [programmeStartDate, setProgrammeStartDate] = useState('');
const [programmeEndDate, setProgrammeEndDate] = useState('');
const [newEmployee, setNewEmployee] = useState({
firstName: '',
lastName: '',
email: '',
phoneCountryCode: '+1',
phoneCountryCode: '+91',
phone: '',
});
const [addLearnerError, setAddLearnerError] = useState('');
@@ -309,9 +311,9 @@ const LearnersPage: React.FC = () => {
checkEmailExists(emailToCheck).unwrap(),
phoneNumber
? checkMobileExists({
phone_country_code: phoneCountryCode,
phone_number: phoneNumber,
}).unwrap()
phone_country_code: phoneCountryCode,
phone_number: phoneNumber,
}).unwrap()
: Promise.resolve({ data: { exists: false } }),
]);
@@ -631,9 +633,9 @@ const LearnersPage: React.FC = () => {
checkEmailExists(email).unwrap(),
phoneNumber
? checkMobileExists({
phone_country_code: phoneCountryCode,
phone_number: phoneNumber,
}).unwrap()
phone_country_code: phoneCountryCode,
phone_number: phoneNumber,
}).unwrap()
: Promise.resolve({ data: { exists: false } }),
]);
@@ -707,11 +709,21 @@ const LearnersPage: React.FC = () => {
setAssignError('Please select a programme.');
return;
}
if (!programmeStartDate || !programmeEndDate) {
setAssignError('Please select start date and end date.');
return;
}
if (new Date(programmeEndDate) < new Date(programmeStartDate)) {
setAssignError('End date cannot be before start date.');
return;
}
try {
const response = await bulkAssignProgramme({
principal_xids: selectedEmployees,
programme_xids: [selectedProgrammeId],
start_date: programmeStartDate,
end_date: programmeEndDate,
}).unwrap();
showToast(
@@ -721,6 +733,8 @@ const LearnersPage: React.FC = () => {
);
setShowAssignModal(false);
setSelectedProgrammeId('');
setProgrammeStartDate('');
setProgrammeEndDate('');
setSelectedEmployees([]);
} catch (error: any) {
const message = error?.data?.message || 'Failed to assign programme.';
@@ -1372,28 +1386,28 @@ const LearnersPage: React.FC = () => {
<h4 className="font-medium">Step 3: Preview CSV Data ({csvRows.length})</h4>
<div className="max-h-[320px] overflow-auto rounded-md border">
<div className="min-w-[760px]">
<Table>
<TableHeader className="sticky top-0 bg-background">
<TableRow>
<TableHead>First Name</TableHead>
<TableHead>Last Name</TableHead>
<TableHead>Email Address</TableHead>
<TableHead>Country Code</TableHead>
<TableHead>Phone Number</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{csvRows.map((row, index) => (
<TableRow key={row.email_address + '-' + index}>
<TableCell>{row.first_name}</TableCell>
<TableCell>{row.last_name}</TableCell>
<TableCell>{row.email_address}</TableCell>
<TableCell>{row.phone_country_code}</TableCell>
<TableCell>{row.phone_number}</TableCell>
<Table>
<TableHeader className="sticky top-0 bg-background">
<TableRow>
<TableHead>First Name</TableHead>
<TableHead>Last Name</TableHead>
<TableHead>Email Address</TableHead>
<TableHead>Country Code</TableHead>
<TableHead>Phone Number</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{csvRows.map((row, index) => (
<TableRow key={row.email_address + '-' + index}>
<TableCell>{row.first_name}</TableCell>
<TableCell>{row.last_name}</TableCell>
<TableCell>{row.email_address}</TableCell>
<TableCell>{row.phone_country_code}</TableCell>
<TableCell>{row.phone_number}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
@@ -1468,6 +1482,26 @@ const LearnersPage: React.FC = () => {
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium mb-2">Start Date *</label>
<Input
type="date"
value={programmeStartDate}
onChange={(e) => setProgrammeStartDate(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">End Date *</label>
<Input
type="date"
value={programmeEndDate}
onChange={(e) => setProgrammeEndDate(e.target.value)}
required
/>
</div>
</div>
<div className="flex gap-2 pt-4">
<Button
className="flex-1"
@@ -1482,6 +1516,8 @@ const LearnersPage: React.FC = () => {
setShowAssignModal(false);
setAssignError('');
setSelectedProgrammeId('');
setProgrammeStartDate('');
setProgrammeEndDate('');
}}
className="flex-1"
disabled={isAssigningProgramme}
@@ -1623,16 +1659,16 @@ const LearnersPage: React.FC = () => {
setEditForm((prev) => ({ ...prev, phoneCountryCode: value }))
}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{countryCodeOptions.map((country) => (
<SelectItem key={country.code} value={country.code}>
{country.code}
</SelectItem>
))}
</SelectContent>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{countryCodeOptions.map((country) => (
<SelectItem key={country.code} value={country.code}>
{country.code}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={editForm.phoneNumber}
@@ -1772,17 +1808,17 @@ const LearnersPage: React.FC = () => {
onClick={() =>
isEnrolled
? handleUnassignCourse(
orgCourse.id,
linkByCourseXid[orgCourse.id]
)
orgCourse.id,
linkByCourseXid[orgCourse.id]
)
: handleAssignCourseToLearner(orgCourse.id)
}
>
{isBusy
? '...'
: isEnrolled
? 'Unassign'
: 'Assign'}
? 'Unassign'
: 'Assign'}
</Button>
</div>
);

View File

@@ -35,7 +35,7 @@ const Login = () => {
'linear-gradient(135deg, var(--accent-1), var(--accent-1), var(--accent-1))',
}}
>
<Card className="w-full max-w-[450px] shadow-lg" style={{ maxWidth: '450px' }}>
<Card className="w-full max-w-[450px] shadow-lg" style={{ maxWidth: '400px' }}>
<CardHeader className="pb-2 text-center">
<img
src={klcLogo}

View File

@@ -135,6 +135,8 @@ interface ProgrammeListResponse {
interface BulkAssignProgrammeRequest {
principal_xids: string[];
programme_xids: string[];
start_date: string;
end_date: string;
}
interface BulkAssignProgrammeResponse {