Spaces:
Sleeping
Sleeping
| import React, { useState, useRef } from "react"; | |
| import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Avatar, AvatarFallback } from "@/components/ui/avatar"; | |
| import { | |
| MessageCircle, | |
| Calendar, | |
| Users, | |
| Info, | |
| MessageSquare, | |
| Video, | |
| Phone, | |
| Briefcase, | |
| Plus, | |
| ChevronDown, | |
| Loader2, | |
| ArrowDownUp, | |
| Printer, | |
| Building2 | |
| } from "lucide-react"; | |
| import { format, parseISO } from "date-fns"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { Communication, ProjectApi, CommunicationCreateRequest } from "@/types"; | |
| import { getCommunicationsByProjectId, createCommunication } from "@/services/communicationApi"; | |
| import { useQuery, useQueryClient } from "@tanstack/react-query"; | |
| import { cn } from "@/lib/utils"; | |
| import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; | |
| import { Label } from "@/components/ui/label"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Textarea } from "@/components/ui/textarea"; | |
| import { useNavigate } from "react-router-dom"; | |
| import { projectService } from "@/lib/api"; | |
| import CustomDialog from "@/components/CustomDialog"; | |
| import { toast } from "@/lib/custom-toast"; | |
| import SlateRichTextEditor from "@/components/custom/SlateRichTextEditor"; | |
| interface ProjectCommunicationTrailSectionProps { | |
| projectId: number; | |
| } | |
| const ProjectCommunicationTrailSection: React.FC<ProjectCommunicationTrailSectionProps> = ({ | |
| projectId | |
| }) => { | |
| const [sortOrder, setSortOrder] = useState<"desc" | "asc">("desc"); // Newest first by default | |
| const [printMode, setPrintMode] = useState(false); | |
| const printRef = useRef<HTMLDivElement>(null); | |
| const navigate = useNavigate(); | |
| const queryClient = useQueryClient(); | |
| // Add dialog state | |
| const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); | |
| const [submitLoading, setSubmitLoading] = useState(false); | |
| const [formData, setFormData] = useState<Partial<Communication>>({ | |
| topic: '', | |
| details: '', | |
| decisionMade: '', | |
| mode: 'In-person', | |
| participants: '', | |
| communicationDate: new Date().toISOString(), | |
| projectId: projectId, | |
| }); | |
| // Fetch project details to get the project name | |
| const { data: project } = useQuery({ | |
| queryKey: ['project', projectId], | |
| queryFn: () => projectService.getById(projectId), | |
| enabled: !!projectId | |
| }); | |
| // Get project name | |
| const projectName = project?.projectName || `Project #${projectId}`; | |
| // Fetch communications | |
| const { | |
| data: communicationsResponse = { data: [], success: true }, | |
| isLoading, | |
| isError, | |
| refetch | |
| } = useQuery({ | |
| queryKey: ["communications", projectId], | |
| queryFn: () => getCommunicationsByProjectId(projectId), | |
| }); | |
| // Add debug logs | |
| console.log("Communications response:", communicationsResponse); | |
| // Extract communications array from response with defensive coding | |
| const communications = | |
| // If response has data property and it's an array | |
| (communicationsResponse && 'data' in communicationsResponse && Array.isArray(communicationsResponse.data)) | |
| ? communicationsResponse.data | |
| // If response is directly an array (unexpected format but handle it) | |
| : Array.isArray(communicationsResponse) | |
| ? communicationsResponse | |
| // Default to empty array for safety | |
| : []; | |
| console.log("Extracted communications array:", communications); | |
| // Get communication icon based on mode | |
| const getCommunicationIcon = (mode: string) => { | |
| const m = mode.toLowerCase(); | |
| if (m.includes('virtual') || m.includes('online') || m.includes('video')) { | |
| return <Video className="h-5 w-5 text-blue-500" />; | |
| } else if (m.includes('call') || m.includes('phone')) { | |
| return <Phone className="h-5 w-5 text-green-500" />; | |
| } else if (m.includes('person') || m.includes('meeting')) { | |
| return <Users className="h-5 w-5 text-purple-500" />; | |
| } else { | |
| return <MessageCircle className="h-5 w-5 text-gray-500" />; | |
| } | |
| }; | |
| // Format date | |
| const formatDate = (dateString: string) => { | |
| try { | |
| return format(parseISO(dateString), 'MMM dd, yyyy h:mm a'); | |
| } catch (error) { | |
| return 'Invalid date'; | |
| } | |
| }; | |
| // Sort communications by date | |
| const sortedCommunications = Array.isArray(communications) | |
| ? [...communications].sort((a, b) => { | |
| if (!a || !b) return 0; | |
| if (!a.communicationDate) return 1; // items without dates go last | |
| if (!b.communicationDate) return -1; | |
| try { | |
| const dateA = new Date(a.communicationDate).getTime(); | |
| const dateB = new Date(b.communicationDate).getTime(); | |
| if (isNaN(dateA) && isNaN(dateB)) return 0; | |
| if (isNaN(dateA)) return 1; | |
| if (isNaN(dateB)) return -1; | |
| return sortOrder === "desc" ? dateB - dateA : dateA - dateB; | |
| } catch (error) { | |
| console.error("Error sorting dates:", error); | |
| return 0; | |
| } | |
| }) | |
| : []; | |
| // Handle form input changes | |
| const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |
| const { name, value } = e.target; | |
| setFormData((prev) => ({ ...prev, [name]: value })); | |
| }; | |
| const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| setFormData((prev) => ({ ...prev, communicationDate: new Date(e.target.value).toISOString() })); | |
| }; | |
| const handleModeChange = (value: string) => { | |
| setFormData((prev) => ({ | |
| ...prev, | |
| mode: value | |
| })); | |
| }; | |
| // Handle dialog open/close | |
| const handleOpenAddDialog = () => { | |
| setFormData({ | |
| topic: '', | |
| details: '', | |
| decisionMade: '', | |
| mode: 'In-person', | |
| participants: '', | |
| communicationDate: new Date().toISOString(), | |
| projectId: projectId, | |
| }); | |
| setIsAddDialogOpen(true); | |
| }; | |
| const handleCloseAddDialog = () => { | |
| setIsAddDialogOpen(false); | |
| }; | |
| // Handle form submission | |
| const handleAddSubmit = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setSubmitLoading(true); | |
| try { | |
| const createData: CommunicationCreateRequest = { | |
| topic: formData.topic || '', | |
| details: formData.details || '', | |
| decisionMade: formData.decisionMade || '', | |
| mode: formData.mode || 'In-person', | |
| participants: formData.participants || '', | |
| communicationDate: formData.communicationDate || new Date().toISOString(), | |
| projectId: projectId, | |
| }; | |
| console.log('Creating communication with data:', createData); | |
| // Create new communication | |
| await createCommunication(createData); | |
| toast.success('Communication added successfully'); | |
| handleCloseAddDialog(); | |
| // Refetch communications to update the list | |
| refetch(); | |
| } catch (err: any) { | |
| console.error('Error creating communication:', err); | |
| const errorMessage = err.response?.data?.message || err.message || 'Failed to create communication'; | |
| toast.error(errorMessage); | |
| } finally { | |
| setSubmitLoading(false); | |
| } | |
| }; | |
| // Handle print button click | |
| const handlePrint = () => { | |
| setPrintMode(true); | |
| // Create a new window for printing | |
| const printWindow = window.open('', '_blank'); | |
| if (printWindow && printRef.current) { | |
| // Get the HTML content directly | |
| const printContents = printRef.current.innerHTML; | |
| const totalItems = sortedCommunications.length; | |
| printWindow.document.write(` | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>${projectName} - Communication Trail</title> | |
| <style> | |
| @page { margin: 1cm; } | |
| body { | |
| font-family: Arial, sans-serif; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| .print-container { | |
| width: 100%; | |
| padding: 20px; | |
| } | |
| .header { | |
| display: flex; | |
| flex-direction: column; | |
| margin-bottom: 20px; | |
| border-bottom: 1px solid #ccc; | |
| padding-bottom: 10px; | |
| } | |
| .header h1 { | |
| margin: 0 0 10px 0; | |
| } | |
| .header .project-name { | |
| font-size: 16px; | |
| font-weight: 500; | |
| color: #555; | |
| margin-bottom: 5px; | |
| word-break: break-word; | |
| } | |
| .content-info { | |
| margin-bottom: 20px; | |
| font-style: italic; | |
| color: #666; | |
| } | |
| .communication-item { | |
| margin-bottom: 25px; | |
| page-break-inside: avoid; | |
| border-bottom: 1px solid #eee; | |
| padding-bottom: 20px; | |
| } | |
| .communication-item h3 { | |
| margin-bottom: 10px; | |
| color: #333; | |
| } | |
| .mode-badge { | |
| display: inline-block; | |
| padding: 3px 8px; | |
| background-color: #f3f4f6; | |
| border: 1px solid #e5e7eb; | |
| border-radius: 4px; | |
| font-size: 12px; | |
| margin-bottom: 10px; | |
| } | |
| .details { | |
| margin-bottom: 15px; | |
| white-space: pre-wrap; | |
| } | |
| .decision { | |
| background-color: #f9fafb; | |
| padding: 10px; | |
| border-radius: 4px; | |
| margin-bottom: 15px; | |
| } | |
| .decision-label { | |
| font-weight: bold; | |
| margin-bottom: 5px; | |
| } | |
| .meta-info { | |
| color: #666; | |
| font-size: 12px; | |
| margin-bottom: 5px; | |
| } | |
| .meta-info-label { | |
| font-weight: bold; | |
| display: inline-block; | |
| width: 100px; | |
| } | |
| @media print { | |
| .no-print { display: none; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="print-container"> | |
| <div class="header"> | |
| <h1>Project Communication Trail</h1> | |
| <div class="project-name">${projectName}</div> | |
| </div> | |
| <div class="content-info"> | |
| Total communications: ${totalItems} | |
| </div> | |
| <div class="print-content"> | |
| ${printContents} | |
| </div> | |
| <div class="no-print" style="margin-top: 30px; text-align: center;"> | |
| <button onclick="window.print()" style="padding: 8px 16px; margin-right: 10px;">Print</button> | |
| <button onclick="window.close()" style="padding: 8px 16px;">Close</button> | |
| </div> | |
| </div> | |
| </body> | |
| </html> | |
| `); | |
| printWindow.document.close(); | |
| // Add event listeners | |
| printWindow.onafterprint = () => { | |
| setPrintMode(false); | |
| }; | |
| printWindow.onunload = () => { | |
| setPrintMode(false); | |
| }; | |
| // Use a longer delay to ensure content is properly rendered | |
| setTimeout(() => { | |
| // Log to debug | |
| console.log(`Preparing to print ${totalItems} communications`); | |
| try { | |
| printWindow.focus(); | |
| printWindow.print(); | |
| } catch (error) { | |
| console.error("Print error:", error); | |
| setPrintMode(false); | |
| } | |
| }, 1200); | |
| } else { | |
| // If window couldn't be opened, reset the state | |
| setPrintMode(false); | |
| alert("Could not open print window. Please check your popup blocker settings."); | |
| } | |
| }; | |
| // Add a utility function to safely render HTML | |
| const HtmlContent: React.FC<{ html: string }> = ({ html }) => { | |
| return ( | |
| <div dangerouslySetInnerHTML={{ __html: html }} /> | |
| ); | |
| }; | |
| return ( | |
| <Card className="shadow-sm"> | |
| <CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0"> | |
| <div> | |
| <CardTitle className="text-xl flex items-center gap-2"> | |
| <MessageCircle className="h-5 w-5" /> | |
| Communication Trail | |
| </CardTitle> | |
| <CardDescription> | |
| Project communications and meeting notes | |
| </CardDescription> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className={cn( | |
| "flex items-center gap-1 text-xs sm:text-sm h-8", | |
| sortOrder === "desc" ? "bg-slate-50" : "" | |
| )} | |
| 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> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className="flex items-center gap-1 h-8" | |
| onClick={handlePrint} | |
| disabled={!Array.isArray(sortedCommunications) || sortedCommunications.length === 0} | |
| > | |
| <Printer className="h-3.5 w-3.5 mr-1" /> | |
| </Button> | |
| <Button | |
| size="sm" | |
| className="flex items-center gap-1" | |
| onClick={handleOpenAddDialog} | |
| > | |
| <Plus className="h-3.5 w-3.5" /> | |
| <span>Add</span> | |
| </Button> | |
| </div> | |
| </CardHeader> | |
| <CardContent> | |
| {isLoading ? ( | |
| <div className="flex justify-center items-center py-12"> | |
| <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> | |
| </div> | |
| ) : isError ? ( | |
| <div className="text-center bg-red-50 text-red-700 py-6 px-4 rounded-md border border-red-100"> | |
| <p className="text-sm font-medium">Unable to load communication trail</p> | |
| </div> | |
| ) : !Array.isArray(sortedCommunications) || sortedCommunications.length === 0 ? ( | |
| <div className="text-center py-12 border border-dashed rounded-lg flex flex-col items-center"> | |
| <MessageCircle className="h-12 w-12 text-muted-foreground mb-2" /> | |
| <p className="text-base font-medium text-muted-foreground mb-2">No communications yet</p> | |
| <p className="text-sm text-muted-foreground mb-4"> | |
| Record important communications and meeting decisions for this project | |
| </p> | |
| <Button onClick={handleOpenAddDialog}> | |
| Add Communication | |
| </Button> | |
| </div> | |
| ) : ( | |
| <> | |
| <div className="space-y-4"> | |
| {sortedCommunications.map((communication) => ( | |
| <div | |
| key={communication.communicationId} | |
| className="border rounded-lg p-4 hover:bg-accent/20 transition-colors" | |
| > | |
| <div className="flex items-start gap-3"> | |
| <div className="p-2 bg-slate-100 rounded-full flex-shrink-0"> | |
| {getCommunicationIcon(communication.mode)} | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center justify-between mb-2 flex-wrap gap-2"> | |
| <h3 className="font-medium">{communication.topic}</h3> | |
| <Badge variant="outline" className="text-xs font-normal"> | |
| {communication.mode} | |
| </Badge> | |
| </div> | |
| <p className="text-sm text-muted-foreground mb-3 whitespace-pre-wrap"> | |
| {communication.details} | |
| </p> | |
| {communication.decisionMade && ( | |
| <div className="mb-3 bg-slate-50 p-2 rounded-md"> | |
| <p className="text-xs font-medium text-slate-500 mb-1">Decision:</p> | |
| <p className="text-sm">{communication.decisionMade}</p> | |
| </div> | |
| )} | |
| <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500"> | |
| <div className="flex items-center gap-1"> | |
| <Calendar className="h-3.5 w-3.5" /> | |
| <span>{formatDate(communication.communicationDate)}</span> | |
| </div> | |
| <div className="flex items-center gap-1"> | |
| <Users className="h-3.5 w-3.5" /> | |
| <span> | |
| {communication.participants} | |
| </span> | |
| </div> | |
| {communication.createdByName && ( | |
| <div className="flex items-center gap-1"> | |
| <Info className="h-3.5 w-3.5" /> | |
| <span>Added by {communication.createdByName}</span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| {/* Printable version - hidden from normal view */} | |
| <div className="hidden"> | |
| <div ref={printRef} className="print-content"> | |
| {sortedCommunications.map((communication, index) => ( | |
| <div key={communication.communicationId} className="communication-item" data-index={index}> | |
| <h3>{communication.topic}</h3> | |
| <div className="mode-badge">{communication.mode}</div> | |
| <div className="details"> | |
| <strong>Details:</strong><br /> | |
| <HtmlContent html={communication.details || "No details provided."} /> | |
| </div> | |
| {communication.decisionMade && ( | |
| <div className="decision"> | |
| <div className="decision-label">Decision:</div> | |
| <HtmlContent html={communication.decisionMade} /> | |
| </div> | |
| )} | |
| <div className="meta-info"> | |
| <span className="meta-info-label">Date/Time:</span> | |
| {formatDate(communication.communicationDate)} | |
| </div> | |
| <div className="meta-info"> | |
| <span className="meta-info-label">Participants:</span> | |
| {communication.participants} | |
| </div> | |
| {communication.createdByName && ( | |
| <div className="meta-info"> | |
| <span className="meta-info-label">Added by:</span> | |
| {communication.createdByName} | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </> | |
| )} | |
| </CardContent> | |
| {/* Add Communication Dialog */} | |
| <CustomDialog | |
| isOpen={isAddDialogOpen} | |
| onClose={handleCloseAddDialog} | |
| title="Add Communication" | |
| description={`Add a new communication record for ${projectName}`} | |
| isPending={submitLoading} | |
| > | |
| <form onSubmit={handleAddSubmit}> | |
| <div className="grid gap-4 py-4"> | |
| <div className="grid grid-cols-1 gap-2"> | |
| <Label htmlFor="topic">Topic</Label> | |
| <Input | |
| id="topic" | |
| name="topic" | |
| placeholder="Enter communication topic" | |
| value={formData.topic || ''} | |
| onChange={handleInputChange} | |
| required | |
| /> | |
| </div> | |
| <div className="grid grid-cols-1 gap-2"> | |
| <Label htmlFor="details">Details</Label> | |
| <SlateRichTextEditor | |
| initialValue={formData.details || ''} | |
| onChange={(value) => { | |
| setFormData(prev => ({ | |
| ...prev, | |
| details: value | |
| })); | |
| }} | |
| placeholder="Enter communication details" | |
| minHeight="150px" | |
| /> | |
| </div> | |
| <div className="grid grid-cols-1 gap-2"> | |
| <Label htmlFor="decisionMade">Decision Made</Label> | |
| <SlateRichTextEditor | |
| initialValue={formData.decisionMade || ''} | |
| onChange={(value) => { | |
| setFormData(prev => ({ | |
| ...prev, | |
| decisionMade: value | |
| })); | |
| }} | |
| placeholder="Enter decisions made during this communication" | |
| minHeight="150px" | |
| /> | |
| </div> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div className="grid grid-cols-1 gap-2"> | |
| <Label htmlFor="mode">Mode</Label> | |
| <div className="relative"> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| className="w-full justify-between" | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| const dropdown = document.getElementById('add-mode-dropdown'); | |
| if (dropdown) { | |
| dropdown.classList.toggle('hidden'); | |
| // Add click outside handler | |
| const clickHandler = (evt: any) => { | |
| if (!dropdown.contains(evt.target) && | |
| !(e.target as HTMLElement).contains(evt.target)) { | |
| dropdown.classList.add('hidden'); | |
| document.removeEventListener('click', clickHandler); | |
| } | |
| }; | |
| // Delay to avoid immediate triggering | |
| setTimeout(() => { | |
| document.addEventListener('click', clickHandler); | |
| }, 0); | |
| } | |
| }} | |
| > | |
| <span className="flex items-center gap-2"> | |
| {getCommunicationIcon(formData.mode || 'In-person')} | |
| {formData.mode || 'Select Mode'} | |
| </span> | |
| <ChevronDown className="h-4 w-4 opacity-50" /> | |
| </Button> | |
| <div | |
| id="add-mode-dropdown" | |
| className="absolute left-0 right-0 top-full mt-1 z-50 bg-white shadow-lg border rounded-md p-1 hidden" | |
| > | |
| <button | |
| type="button" | |
| className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-slate-100 text-slate-700" | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| handleModeChange('In-person'); | |
| document.getElementById('add-mode-dropdown')?.classList.add('hidden'); | |
| }} | |
| > | |
| <Building2 className="h-4 w-4 mr-2 text-primary" /> | |
| <span>In-person</span> | |
| </button> | |
| <button | |
| type="button" | |
| className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-slate-100 text-slate-700" | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| handleModeChange('Virtual'); | |
| document.getElementById('add-mode-dropdown')?.classList.add('hidden'); | |
| }} | |
| > | |
| <Video className="h-4 w-4 mr-2 text-purple-500" /> | |
| <span>Virtual</span> | |
| </button> | |
| <button | |
| type="button" | |
| className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-slate-100 text-slate-700" | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| handleModeChange('Phone'); | |
| document.getElementById('add-mode-dropdown')?.classList.add('hidden'); | |
| }} | |
| > | |
| <Phone className="h-4 w-4 mr-2 text-orange-500" /> | |
| <span>Phone</span> | |
| </button> | |
| <button | |
| type="button" | |
| className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-slate-100 text-slate-700" | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| handleModeChange('Email'); | |
| document.getElementById('add-mode-dropdown')?.classList.add('hidden'); | |
| }} | |
| > | |
| <MessageSquare className="h-4 w-4 mr-2 text-blue-500" /> | |
| <span>Email</span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 gap-2"> | |
| <Label htmlFor="communicationDate">Date</Label> | |
| <Input | |
| id="communicationDate" | |
| name="communicationDate" | |
| type="datetime-local" | |
| value={formData.communicationDate ? new Date(formData.communicationDate).toISOString().slice(0, 16) : ''} | |
| onChange={handleDateChange} | |
| required | |
| /> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 gap-2"> | |
| <Label htmlFor="participants">Participants</Label> | |
| <Input | |
| id="participants" | |
| name="participants" | |
| placeholder="Enter participant names (comma separated)" | |
| value={formData.participants || ''} | |
| onChange={handleInputChange} | |
| required | |
| /> | |
| </div> | |
| </div> | |
| <div className="flex justify-end gap-2 mt-4"> | |
| <Button type="button" variant="outline" onClick={handleCloseAddDialog} disabled={submitLoading}> | |
| Cancel | |
| </Button> | |
| <Button type="submit" disabled={submitLoading}> | |
| {submitLoading ? ( | |
| <> | |
| <Loader2 className="mr-2 h-4 w-4 animate-spin" /> | |
| Adding... | |
| </> | |
| ) : ( | |
| 'Add Communication' | |
| )} | |
| </Button> | |
| </div> | |
| </form> | |
| </CustomDialog> | |
| </Card> | |
| ); | |
| }; | |
| export default ProjectCommunicationTrailSection; |