Spaces:
Sleeping
Sleeping
| 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; |