pmtool / src /components /projects /ProjectCommunicationTrailSection.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
28.4 kB
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" />
Print
</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;