pmtool / src /components /tasks /TaskCommentSection.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
17.3 kB
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { MessageSquare, Send, Trash, Loader2, ArrowDownUp, Clock, Calendar, Search, X } from "lucide-react";
import { commentsApi, Comment } from "@/services/commentsApi";
import { useAuth } from "@/lib/auth-context";
import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Employee, employeeApi } from "@/services/employeeApi";
import { formatDistanceToNow, format, parseISO } from "date-fns";
import { SlateRichTextEditor } from "@/components/custom/SlateRichTextEditor";
interface TaskCommentSectionProps {
taskId: number;
entityType?: "Task" | "Issue" | "Project";
}
const TaskCommentSection: React.FC<TaskCommentSectionProps> = ({
taskId,
entityType = "Task"
}) => {
const [newComment, setNewComment] = useState("");
const [sortOrder, setSortOrder] = useState<"desc" | "asc">("desc"); // Newest first by default
const [searchQuery, setSearchQuery] = useState("");
const { userData } = useAuth();
const queryClient = useQueryClient();
// Fetch employees for avatar display
const { data: employees = [] } = useQuery({
queryKey: ["employees"],
queryFn: () => employeeApi.getAll(),
});
// Fetch comments for this entity
const {
data: comments = [],
isLoading,
isError,
} = useQuery({
queryKey: ["comments", entityType, taskId],
queryFn: () => commentsApi.getByEntity(entityType, taskId),
});
// Add comment mutation
const addCommentMutation = useMutation({
mutationFn: (commentText: string) =>
commentsApi.create({
entityId: taskId,
entityType: entityType,
commentText,
userId: userData?.userId || 0,
createdBy: null,
createdAt: new Date().toISOString(),
updatedBy: null,
updatedAt: null
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["comments", entityType, taskId] });
toast.success("Comment added successfully");
setNewComment("");
},
onError: (error) => {
toast.error(`Failed to add comment: ${error}`);
}
});
// Delete comment mutation
const deleteCommentMutation = useMutation({
mutationFn: (commentId: number) => commentsApi.delete(commentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["comments", entityType, taskId] });
toast.success("Comment deleted successfully");
},
onError: (error) => {
toast.error(`Failed to delete comment: ${error}`);
}
});
const handleSubmit = () => {
if (!hasValidContent(newComment)) return;
if (!userData?.userId) {
toast.error("You must be logged in to add comments");
return;
}
addCommentMutation.mutate(newComment);
};
// Helper function to check if HTML content has meaningful text
const hasValidContent = (htmlContent: string): boolean => {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
const textContent = tempDiv.textContent || tempDiv.innerText || '';
return textContent.trim().length > 0;
};
// Handle keyboard submission with Ctrl+Enter or Cmd+Enter
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
};
const handleDeleteComment = (commentId: number) => {
if (confirm("Are you sure you want to delete this comment?")) {
deleteCommentMutation.mutate(commentId);
}
};
// Format comment date
const formatCommentDate = (dateString: string): string => {
try {
const date = parseISO(dateString);
const now = new Date();
const diffInHours = Math.abs(now.getTime() - date.getTime()) / 36e5;
// If less than 24 hours ago, show relative time
if (diffInHours < 24) {
return formatDistanceToNow(date, { addSuffix: true });
}
// Otherwise show the actual date
return format(date, 'MMM d, yyyy • h:mm a');
} catch (error) {
return 'Unknown date';
}
};
// Utility to strip HTML tags from text
const stripHtmlTags = (html: string): string => {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
return tempDiv.textContent || tempDiv.innerText || '';
};
// Get user initials for avatar
const getUserInitials = (userId: number): string => {
const employee = employees.find(emp => emp.userId === userId);
if (employee) {
return `${employee.firstName.charAt(0)}${employee.lastName.charAt(0)}`.toUpperCase();
}
return "U";
};
// Get user name for display
const getUserName = (userId: number): string => {
const employee = employees.find(emp => emp.userId === userId);
if (employee) {
return `${employee.firstName} ${employee.lastName}`;
}
return `User ${userId}`;
};
// Filter comments based on search query
const filteredComments = comments.filter((comment) => {
if (!searchQuery.trim()) return true;
const searchTerm = searchQuery.toLowerCase();
// Extract text content from HTML for searching
const tempDiv = document.createElement('div');
tempDiv.innerHTML = comment.commentText;
const commentText = (tempDiv.textContent || tempDiv.innerText || '').toLowerCase();
const authorName = getUserName(comment.userId).toLowerCase();
return commentText.includes(searchTerm) || authorName.includes(searchTerm);
});
// Sort filtered comments by date
const sortedComments = [...filteredComments].sort((a, b) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
return sortOrder === "desc" ? dateB - dateA : dateA - dateB;
});
return (
<Card className="border border-gray-200 shadow-sm overflow-visible">
<div className="bg-white border-b border-gray-200 p-5 relative">
<div className="flex flex-wrap justify-between items-center gap-3">
<div className="flex items-center gap-2">
<div className="bg-indigo-100 p-2 rounded-full">
<MessageSquare className="h-4 w-4 text-indigo-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg text-gray-900">
{entityType} Discussion
</CardTitle>
<CardDescription className="text-sm text-gray-500 mt-0.5">
{comments.length === 0
? "No comments yet"
: searchQuery.trim()
? `${filteredComments.length} of ${comments.length} comments`
: comments.length === 1
? "1 comment"
: `${comments.length} comments`}
</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className={`flex items-center gap-1 text-xs sm:text-sm h-8 ${sortOrder === "desc" ? "bg-indigo-50 text-indigo-600 border-indigo-200" : ""}`}
onClick={() => setSortOrder(sortOrder === "desc" ? "asc" : "desc")}
>
<ArrowDownUp className="h-3 w-3 sm:h-3.5 sm:w-3.5 mr-1" />
{sortOrder === "desc" ? "Newest first" : "Oldest first"}
</Button>
<div className="h-6 border-r border-gray-200"></div>
<Button
variant="outline"
size="sm"
className="text-xs sm:text-sm h-8"
onClick={() => setNewComment("")}
disabled={!hasValidContent(newComment)}
>
Clear
</Button>
</div>
</div>
</div>
{/* Search input */}
{comments.length > 0 && (
<div className="px-5 py-3 bg-gray-50 border-b border-gray-200">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="Search comments..."
className="pl-9 pr-9 text-sm border-gray-200 focus:border-indigo-300 bg-white"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-6 w-6 p-0 hover:bg-gray-100"
onClick={() => setSearchQuery("")}
>
<X className="h-3 w-3 text-gray-400" />
</Button>
)}
</div>
</div>
)}
<CardContent className="p-0">
{/* Comment input */}
<div className="p-5 bg-white border-b border-gray-200">
<div className="flex items-start gap-3">
<Avatar className="h-8 w-8 sm:h-9 sm:w-9 mt-0.5 border-2 border-white ring-1 ring-gray-200">
<AvatarFallback className="bg-indigo-600 text-white text-xs sm:text-sm font-medium">
{userData?.userId ? getUserInitials(userData.userId) : "U"}
</AvatarFallback>
</Avatar>
<div className="flex-1" onKeyDown={handleKeyDown}>
<SlateRichTextEditor
placeholder="Add your comment..."
className="text-sm border-gray-200 focus:border-indigo-300 rounded-md shadow-none"
minHeight="70px"
initialValue={newComment}
onChange={(htmlContent) => setNewComment(htmlContent)}
/>
<div className="flex items-center justify-between mt-3">
<p className="text-xs text-gray-500">Press Ctrl+Enter to post</p>
<Button
className="bg-indigo-600 hover:bg-indigo-700 transition-colors shadow-none text-white"
size="sm"
onClick={handleSubmit}
disabled={!hasValidContent(newComment) || addCommentMutation.isPending}
>
{addCommentMutation.isPending ? (
<div className="flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Posting...</span>
</div>
) : (
<div className="flex items-center gap-1.5">
<Send className="h-3 w-3" />
<span>Post</span>
</div>
)}
</Button>
</div>
</div>
</div>
</div>
{/* Comments list */}
<div className="p-5 bg-gray-50 max-h-[400px] overflow-y-auto relative z-0">
{isLoading ? (
<div className="flex items-center justify-center py-6">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-6 w-6 animate-spin text-indigo-600" />
<span className="text-sm text-gray-600">Loading comments...</span>
</div>
</div>
) : isError ? (
<div className="text-center bg-red-50 text-red-700 py-4 px-4 rounded-md border border-red-100">
<p className="text-sm font-medium">Unable to load comments</p>
</div>
) : comments.length > 0 ? (
<div className="space-y-0.5">
<div className="flex items-center justify-between mb-3 pb-2 border-b border-gray-200">
<div className="flex items-center gap-2">
<h3 className="text-xs sm:text-sm font-medium text-gray-500">
{searchQuery.trim() ? "Search Results" : "Comments"}
</h3>
<span className="inline-flex items-center justify-center rounded-full bg-indigo-100 text-indigo-700 font-medium text-xs px-2 py-0.5">
{searchQuery.trim() ? filteredComments.length : comments.length}
</span>
</div>
<div className="flex items-center gap-1.5 text-xs font-medium text-indigo-600 bg-indigo-50 px-2 py-1 rounded-full">
<span>{sortOrder === "desc" ? "Newest first" : "Oldest first"}</span>
<ArrowDownUp className="h-3 w-3" />
</div>
</div>
{filteredComments.length === 0 && searchQuery.trim() ? (
<div className="bg-white rounded-md p-6 text-center border border-gray-200">
<div className="mx-auto w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mb-3">
<Search className="h-6 w-6 text-gray-400" />
</div>
<h3 className="text-sm font-medium text-gray-900 mb-1">No comments found</h3>
<p className="text-sm text-gray-500 max-w-xs mx-auto mb-4">
No comments match your search for "{searchQuery}". Try adjusting your search terms.
</p>
<Button
variant="outline"
size="sm"
onClick={() => setSearchQuery("")}
className="text-xs"
>
Clear search
</Button>
</div>
) : (
sortedComments.map((comment, index) => (
<div key={comment.commentId} className="mb-4 last:mb-0">
<div className="flex gap-3">
<Avatar className="h-8 w-8 sm:h-9 sm:w-9 mt-0.5 border-2 border-white ring-1 ring-gray-200">
<AvatarFallback className="bg-indigo-600 text-white text-xs sm:text-sm font-medium">
{getUserInitials(comment.userId)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="bg-white p-3 sm:p-4 rounded-md relative group shadow-sm border border-gray-200">
<div className="flex items-center justify-between gap-2 mb-2">
<span className="font-semibold text-sm text-gray-900">{getUserName(comment.userId)}</span>
<span className="text-xs text-gray-500">
{formatCommentDate(comment.createdAt)}
</span>
</div>
<div
className="text-sm text-gray-700 leading-relaxed prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: comment.commentText }}
/>
{/* Delete button - only visible for the comment author */}
{userData?.userId === comment.userId && (
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity bg-white hover:bg-red-50 hover:text-red-600"
onClick={() => handleDeleteComment(comment.commentId)}
disabled={deleteCommentMutation.isPending}
>
<Trash className="h-3 w-3" />
<span className="sr-only">Delete comment</span>
</Button>
)}
</div>
</div>
</div>
</div>
))
)}
</div>
) : (
<div className="bg-white rounded-md p-6 text-center border border-gray-200">
<div className="mx-auto w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mb-3">
<MessageSquare className="h-6 w-6 text-gray-400" />
</div>
<h3 className="text-sm font-medium text-gray-900 mb-1">No comments yet</h3>
<p className="text-sm text-gray-500 max-w-xs mx-auto">
Start the conversation by adding the first comment to this task.
</p>
</div>
)}
</div>
</CardContent>
</Card>
);
};
export default TaskCommentSection;