Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef } from 'react'; | |
| import { format } from 'date-fns'; | |
| import { | |
| MessageCircle, | |
| Plus, | |
| Search, | |
| Edit, | |
| Trash2, | |
| Calendar, | |
| Users, | |
| Info, | |
| MessageSquare, | |
| Video, | |
| Phone, | |
| Building2, | |
| MoreHorizontal, | |
| Loader2, | |
| LayoutDashboard, | |
| Check, | |
| AlertCircle, | |
| PlusCircle, | |
| Briefcase, | |
| Filter, | |
| ChevronDown, | |
| CalendarIcon, | |
| X | |
| } from 'lucide-react'; | |
| import { Communication, ProjectApi, CommunicationUpdateRequest, CommunicationCreateRequest } from '../types'; | |
| import { | |
| getAllCommunications, | |
| createCommunication, | |
| updateCommunication, | |
| deleteCommunication, | |
| } from '../services/communicationApi'; | |
| // UI Components | |
| import { Button } from '@/components/ui/button'; | |
| import { Input } from '@/components/ui/input'; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogDescription, | |
| DialogFooter, | |
| DialogHeader, | |
| DialogTitle, | |
| } from '@/components/ui/dialog'; | |
| import { | |
| AlertDialog, | |
| AlertDialogAction, | |
| AlertDialogCancel, | |
| AlertDialogContent, | |
| AlertDialogDescription, | |
| AlertDialogFooter, | |
| AlertDialogHeader, | |
| AlertDialogTitle, | |
| } from '@/components/ui/alert-dialog'; | |
| import { | |
| Card, | |
| CardContent, | |
| CardDescription, | |
| CardFooter, | |
| CardHeader, | |
| CardTitle, | |
| } from '@/components/ui/card'; | |
| import { | |
| Table, | |
| TableBody, | |
| TableCaption, | |
| TableCell, | |
| TableHead, | |
| TableHeader, | |
| TableRow, | |
| } from '@/components/ui/table'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'; | |
| import { Label } from '@/components/ui/label'; | |
| import { Textarea } from '@/components/ui/textarea'; | |
| import CustomDialog from '@/components/CustomDialog'; | |
| import Header from '@/components/layout/header'; | |
| import Sidebar from '@/components/layout/sidebar'; | |
| import { useIsMobile } from '@/hooks/use-mobile'; | |
| import { toast } from '@/lib/custom-toast'; | |
| import { | |
| Select as RadixSelect, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from '@/components/ui/select'; | |
| import { projectService } from '@/lib/api'; | |
| import { | |
| DropdownMenu, | |
| DropdownMenuContent, | |
| DropdownMenuItem, | |
| DropdownMenuLabel, | |
| DropdownMenuSeparator, | |
| DropdownMenuTrigger, | |
| } from "@/components/ui/dropdown-menu"; | |
| import { | |
| Popover, | |
| PopoverContent, | |
| PopoverTrigger, | |
| } from "@/components/ui/popover"; | |
| import { Calendar as CalendarComponent } from "@/components/ui/calendar"; | |
| import { cn } from "@/lib/utils"; | |
| import { useLocation, useNavigate } from 'react-router-dom'; | |
| import SlateRichTextEditor from "@/components/custom/SlateRichTextEditor"; | |
| // Mode icons mapping | |
| const getModeIcon = (mode: string) => { | |
| switch (mode.toLowerCase()) { | |
| case 'in-person': | |
| return <Building2 className="h-4 w-4 text-primary" />; | |
| case 'virtual': | |
| return <Video className="h-4 w-4 text-purple-500" />; | |
| case 'phone': | |
| return <Phone className="h-4 w-4 text-orange-500" />; | |
| case 'email': | |
| return <MessageSquare className="h-4 w-4 text-blue-500" />; | |
| default: | |
| return <Info className="h-4 w-4 text-gray-500" />; | |
| } | |
| }; | |
| // Badge variant mapping | |
| const getModeBadgeVariant = (mode: string): "default" | "secondary" | "destructive" | "outline" => { | |
| switch (mode.toLowerCase()) { | |
| case 'in-person': | |
| return 'default'; | |
| case 'virtual': | |
| return 'secondary'; | |
| case 'phone': | |
| return 'destructive'; | |
| case 'email': | |
| return 'outline'; | |
| default: | |
| return 'outline'; | |
| } | |
| }; | |
| // Add HtmlContent component | |
| const HtmlContent: React.FC<{ html: string }> = ({ html }) => { | |
| return ( | |
| <div dangerouslySetInnerHTML={{ __html: html }} /> | |
| ); | |
| }; | |
| const Communications: React.FC = () => { | |
| // State management | |
| const isMobile = useIsMobile(); | |
| const [isSidebarOpen, setIsSidebarOpen] = useState(!isMobile); | |
| const [communications, setCommunications] = useState<Communication[]>([]); | |
| const [filteredCommunications, setFilteredCommunications] = useState<Communication[]>([]); | |
| const [loading, setLoading] = useState(true); | |
| const [error, setError] = useState<string | null>(null); | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const [projects, setProjects] = useState<ProjectApi[]>([]); | |
| const [projectsLoading, setProjectsLoading] = useState(false); | |
| // Get query parameters | |
| const location = useLocation(); | |
| const queryParams = new URLSearchParams(location.search); | |
| const shouldOpenCreateDialog = queryParams.get('new') === 'true'; | |
| const preSelectedProjectId = queryParams.get('projectId'); | |
| // Close sidebar on mobile when route changes | |
| useEffect(() => { | |
| if (isMobile) { | |
| setIsSidebarOpen(false); | |
| } | |
| }, [location, isMobile]); | |
| // Update sidebar state when screen size changes | |
| useEffect(() => { | |
| setIsSidebarOpen(!isMobile); | |
| }, [isMobile]); | |
| // Filter states | |
| const [modeFilter, setModeFilter] = useState<string | null>(null); | |
| const [dateFilter, setDateFilter] = useState<Date | null>(null); | |
| const [projectFilter, setProjectFilter] = useState<number | null>(null); | |
| // Dialog states | |
| const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); | |
| const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); | |
| const [openDeleteDialog, setOpenDeleteDialog] = useState(false); | |
| const [notification, setNotification] = useState<{ type: 'success' | 'error' | 'info'; message: string } | null>(null); | |
| const [selectedCommunication, setSelectedCommunication] = useState<Communication | null>(null); | |
| // Refs for event handlers | |
| const dateFilterClickHandlerRef = useRef<((evt: MouseEvent) => void) | null>(null); | |
| const modeFilterClickHandlerRef = useRef<((evt: MouseEvent) => void) | null>(null); | |
| const projectFilterClickHandlerRef = useRef<((evt: MouseEvent) => void) | null>(null); | |
| // Form data | |
| const [formData, setFormData] = useState<Partial<Communication>>({ | |
| topic: '', | |
| details: '', | |
| decisionMade: '', | |
| mode: 'In-person', | |
| participants: '', | |
| communicationDate: new Date().toISOString(), | |
| projectId: preSelectedProjectId ? parseInt(preSelectedProjectId) : null, | |
| }); | |
| const [submitLoading, setSubmitLoading] = useState(false); | |
| const navigate = useNavigate(); | |
| // Fetch communications on component mount | |
| useEffect(() => { | |
| fetchCommunications(); | |
| fetchProjects(); | |
| }, []); | |
| // Open create dialog if query param is present | |
| useEffect(() => { | |
| if (shouldOpenCreateDialog && projects.length > 0) { | |
| handleOpenCreateDialog(); | |
| // Remove query parameters after opening dialog | |
| navigate('/communications', { replace: true }); | |
| } | |
| }, [shouldOpenCreateDialog, projects.length, navigate]); | |
| // Fetch all projects | |
| const fetchProjects = async () => { | |
| try { | |
| setProjectsLoading(true); | |
| const projectsData = await projectService.getAll(); | |
| setProjects(projectsData); | |
| setProjectsLoading(false); | |
| } catch (err) { | |
| console.error('Error fetching projects:', err); | |
| setProjectsLoading(false); | |
| } | |
| }; | |
| // Filter communications when search query or filters change | |
| useEffect(() => { | |
| let filtered = communications; | |
| // Text search filter | |
| if (searchQuery.trim() !== '') { | |
| const lowercaseQuery = searchQuery.toLowerCase(); | |
| filtered = filtered.filter( | |
| (comm) => | |
| comm.topic.toLowerCase().includes(lowercaseQuery) || | |
| comm.details.toLowerCase().includes(lowercaseQuery) || | |
| comm.participants.toLowerCase().includes(lowercaseQuery) || | |
| comm.mode.toLowerCase().includes(lowercaseQuery) | |
| ); | |
| } | |
| // Mode filter | |
| if (modeFilter) { | |
| filtered = filtered.filter( | |
| (comm) => comm.mode.toLowerCase() === modeFilter.toLowerCase() | |
| ); | |
| } | |
| // Date filter | |
| if (dateFilter) { | |
| const filterDate = new Date(dateFilter); | |
| filterDate.setHours(0, 0, 0, 0); | |
| filtered = filtered.filter((comm) => { | |
| const commDate = new Date(comm.communicationDate); | |
| commDate.setHours(0, 0, 0, 0); | |
| return commDate.getTime() === filterDate.getTime(); | |
| }); | |
| } | |
| // Project filter | |
| if (projectFilter !== null) { | |
| filtered = filtered.filter( | |
| (comm) => comm.projectId === projectFilter | |
| ); | |
| } | |
| setFilteredCommunications(filtered); | |
| }, [searchQuery, communications, modeFilter, dateFilter, projectFilter]); | |
| const clearAllFilters = () => { | |
| setSearchQuery(''); | |
| setModeFilter(null); | |
| setDateFilter(null); | |
| setProjectFilter(null); | |
| }; | |
| // Toggle sidebar | |
| const toggleSidebar = () => { | |
| setIsSidebarOpen(!isSidebarOpen); | |
| }; | |
| const fetchCommunications = async () => { | |
| try { | |
| setLoading(true); | |
| setError(null); // Clear any previous errors | |
| const response = await getAllCommunications(); | |
| console.log("Communications API response:", response); | |
| // If response is an array, it's a direct data array | |
| if (Array.isArray(response)) { | |
| setCommunications(response); | |
| setFilteredCommunications(response); | |
| } | |
| // If response has a success property, it's a ResponseData object | |
| else if (response && typeof response.success !== 'undefined') { | |
| if (response.success && Array.isArray(response.data)) { | |
| setCommunications(response.data); | |
| setFilteredCommunications(response.data); | |
| } else { | |
| // API returned success: false | |
| setError(response.message || 'Failed to fetch communications'); | |
| } | |
| } | |
| // Unexpected response format | |
| else { | |
| console.error('Unexpected API response format:', response); | |
| setError('Received invalid data format from server'); | |
| } | |
| setLoading(false); | |
| } catch (err) { | |
| console.error('Error fetching communications:', err); | |
| setError('Failed to connect to the server. Please try again later.'); | |
| setLoading(false); | |
| } | |
| }; | |
| const handleOpenCreateDialog = () => { | |
| setSelectedCommunication(null); | |
| setFormData({ | |
| topic: '', | |
| details: '', | |
| decisionMade: '', | |
| mode: 'In-person', | |
| participants: '', | |
| communicationDate: new Date().toISOString(), | |
| projectId: preSelectedProjectId ? parseInt(preSelectedProjectId) : null, | |
| }); | |
| setIsCreateDialogOpen(true); | |
| }; | |
| const handleOpenEditDialog = (communication: Communication) => { | |
| setSelectedCommunication(communication); | |
| setFormData({ | |
| communicationId: communication.communicationId, | |
| topic: communication.topic, | |
| details: communication.details, | |
| decisionMade: communication.decisionMade, | |
| mode: communication.mode, | |
| participants: communication.participants, | |
| communicationDate: communication.communicationDate, | |
| projectId: communication.projectId, | |
| }); | |
| setIsEditDialogOpen(true); | |
| }; | |
| const handleCloseCreateDialog = () => { | |
| setIsCreateDialogOpen(false); | |
| }; | |
| const handleCloseEditDialog = () => { | |
| setIsEditDialogOpen(false); | |
| }; | |
| const handleOpenDeleteDialog = (communication: Communication) => { | |
| setSelectedCommunication(communication); | |
| setOpenDeleteDialog(true); | |
| }; | |
| const handleCloseDeleteDialog = () => { | |
| setOpenDeleteDialog(false); | |
| }; | |
| 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 handleProjectChange = (value: string) => { | |
| setFormData((prev) => ({ | |
| ...prev, | |
| projectId: value === "none" ? null : parseInt(value) | |
| })); | |
| }; | |
| const handleModeChange = (value: string) => { | |
| setFormData((prev) => ({ | |
| ...prev, | |
| mode: value | |
| })); | |
| }; | |
| const handleCreateSubmit = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| // Validate required fields | |
| if (!formData.projectId) { | |
| toast.error('Please select a project'); | |
| return; | |
| } | |
| 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: formData.projectId, | |
| }; | |
| console.log('Creating communication with data:', createData); | |
| // Create new communication | |
| await createCommunication(createData); | |
| toast.success('Communication created successfully'); | |
| handleCloseCreateDialog(); | |
| fetchCommunications(); | |
| } 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); | |
| } | |
| }; | |
| const handleEditSubmit = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (!selectedCommunication) return; | |
| setSubmitLoading(true); | |
| try { | |
| const updateData: CommunicationUpdateRequest = { | |
| communicationId: selectedCommunication.communicationId, | |
| topic: formData.topic || '', | |
| details: formData.details || '', | |
| decisionMade: formData.decisionMade || '', | |
| mode: formData.mode || 'In-person', | |
| participants: formData.participants || '', | |
| communicationDate: formData.communicationDate || new Date().toISOString(), | |
| projectId: formData.projectId, | |
| }; | |
| console.log('Updating communication with data:', updateData); | |
| // Update existing communication | |
| await updateCommunication( | |
| selectedCommunication.communicationId, | |
| updateData | |
| ); | |
| toast.success('Communication updated successfully'); | |
| handleCloseEditDialog(); | |
| fetchCommunications(); | |
| } catch (err: any) { | |
| console.error('Error updating communication:', err); | |
| const errorMessage = err.response?.data?.message || err.message || 'Failed to update communication'; | |
| toast.error(errorMessage); | |
| } finally { | |
| setSubmitLoading(false); | |
| } | |
| }; | |
| const handleDelete = async () => { | |
| if (!selectedCommunication) return; | |
| try { | |
| console.log(`Deleting communication with ID: ${selectedCommunication.communicationId}`); | |
| const response = await deleteCommunication(selectedCommunication.communicationId); | |
| if (response.success) { | |
| toast.success('Communication deleted successfully'); | |
| handleCloseDeleteDialog(); | |
| fetchCommunications(); | |
| } else { | |
| toast.error(response.message || 'Failed to delete communication'); | |
| } | |
| } catch (err: any) { | |
| console.error('Error deleting communication:', err); | |
| const errorMessage = err.response?.data?.message || err.message || 'Failed to delete communication'; | |
| toast.error(errorMessage); | |
| } | |
| }; | |
| const formatDate = (dateString: string) => { | |
| try { | |
| return format(new Date(dateString), 'MMM dd, yyyy hh:mm a'); | |
| } catch (err) { | |
| return 'Invalid date'; | |
| } | |
| }; | |
| // Statistics for summary cards | |
| const totalCommunications = communications.length; | |
| const inPersonMeetings = communications.filter(c => c.mode.toLowerCase() === 'in-person').length; | |
| const virtualMeetings = communications.filter(c => c.mode.toLowerCase() === 'virtual').length; | |
| const otherCommunications = totalCommunications - inPersonMeetings - virtualMeetings; | |
| // Get project name by ID | |
| const getProjectName = (projectId: number | null | undefined): string => { | |
| if (!projectId) return 'None'; | |
| const project = projects.find(p => p.id === projectId); | |
| return project ? project.projectName : 'Unknown Project'; | |
| }; | |
| return ( | |
| <div className="flex min-h-screen flex-col"> | |
| <Header toggleSidebar={toggleSidebar} isSidebarOpen={isSidebarOpen} /> | |
| <Sidebar isOpen={isSidebarOpen} setIsOpen={setIsSidebarOpen} /> | |
| <main className={`flex-1 p-6 pt-20 md:p-10 md:pt-24 ${isSidebarOpen ? "md:ml-64" : ""} transition-all duration-300`}> | |
| <div className="container mx-auto"> | |
| {notification && ( | |
| <Alert variant={notification.type === 'error' ? 'destructive' : 'default'} className="mb-4"> | |
| <AlertCircle className="h-4 w-4" /> | |
| <AlertTitle>{notification.type === 'success' ? 'Success' : notification.type === 'error' ? 'Error' : 'Info'}</AlertTitle> | |
| <AlertDescription>{notification.message}</AlertDescription> | |
| </Alert> | |
| )} | |
| {error && ( | |
| <Alert variant="destructive" className="mb-4"> | |
| <AlertCircle className="h-4 w-4" /> | |
| <AlertTitle>Error</AlertTitle> | |
| <AlertDescription>{error}</AlertDescription> | |
| </Alert> | |
| )} | |
| <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-4"> | |
| <div> | |
| <h1 className="text-3xl font-bold tracking-tight">Communication Trail</h1> | |
| <p className="text-muted-foreground"> | |
| Track and manage all communications within the organization | |
| </p> | |
| </div> | |
| <Button onClick={() => handleOpenCreateDialog()}> | |
| <PlusCircle className="mr-2 h-4 w-4" /> New Communication | |
| </Button> | |
| </div> | |
| {/* Stats Cards */} | |
| <div className="grid grid-cols-1 md:grid-cols-4 gap-4"> | |
| <Card> | |
| <CardHeader className="pb-2"> | |
| <CardTitle className="text-sm font-medium text-muted-foreground"> | |
| Total Communications | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="flex items-center justify-between"> | |
| <div className="text-2xl font-bold">{totalCommunications}</div> | |
| <LayoutDashboard className="h-4 w-4 text-muted-foreground" /> | |
| </div> | |
| <p className="text-xs text-muted-foreground mt-1">All recorded communications</p> | |
| </CardContent> | |
| </Card> | |
| <Card> | |
| <CardHeader className="pb-2"> | |
| <CardTitle className="text-sm font-medium text-muted-foreground"> | |
| In-Person Meetings | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="flex items-center justify-between"> | |
| <div className="text-2xl font-bold">{inPersonMeetings}</div> | |
| <Building2 className="h-4 w-4 text-primary" /> | |
| </div> | |
| <p className="text-xs text-muted-foreground mt-1">Face-to-face communications</p> | |
| </CardContent> | |
| </Card> | |
| <Card> | |
| <CardHeader className="pb-2"> | |
| <CardTitle className="text-sm font-medium text-muted-foreground"> | |
| Virtual Meetings | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="flex items-center justify-between"> | |
| <div className="text-2xl font-bold">{virtualMeetings}</div> | |
| <Video className="h-4 w-4 text-purple-500" /> | |
| </div> | |
| <p className="text-xs text-muted-foreground mt-1">Online video conferences</p> | |
| </CardContent> | |
| </Card> | |
| <Card> | |
| <CardHeader className="pb-2"> | |
| <CardTitle className="text-sm font-medium text-muted-foreground"> | |
| Other Communications | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="flex items-center justify-between"> | |
| <div className="text-2xl font-bold">{otherCommunications}</div> | |
| <Phone className="h-4 w-4 text-orange-500" /> | |
| </div> | |
| <p className="text-xs text-muted-foreground mt-1">Phone, email and other formats</p> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| {/* Search and Filters */} | |
| <div className="mt-6 mb-4"> | |
| <div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center mb-3"> | |
| <div className="relative flex-1"> | |
| <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> | |
| <Input | |
| placeholder="Search communications..." | |
| className="pl-10" | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| /> | |
| </div> | |
| <div className="flex gap-2 flex-wrap sm:flex-nowrap"> | |
| {/* Date filter - custom dropdown */} | |
| <div className="relative inline-block"> | |
| <Button | |
| variant="outline" | |
| className="flex gap-1 items-center h-10 pr-10" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| const id = 'date-filter-dropdown'; | |
| const dropdown = document.getElementById(id); | |
| // Close all other dropdowns first | |
| ['mode-filter-dropdown', 'project-filter-dropdown'].forEach(dropdownId => { | |
| if (dropdownId !== id) { | |
| document.getElementById(dropdownId)?.classList.add('hidden'); | |
| } | |
| }); | |
| if (dropdown) { | |
| dropdown.classList.toggle('hidden'); | |
| // Remove any existing listeners first to prevent duplicates | |
| if (dateFilterClickHandlerRef.current) { | |
| document.removeEventListener('click', dateFilterClickHandlerRef.current); | |
| } | |
| // Add click outside handler | |
| dateFilterClickHandlerRef.current = (evt: MouseEvent) => { | |
| if (!dropdown.contains(evt.target as Node) && | |
| !(e.target as HTMLElement).contains(evt.target as Node)) { | |
| dropdown.classList.add('hidden'); | |
| if (dateFilterClickHandlerRef.current) { | |
| document.removeEventListener('click', dateFilterClickHandlerRef.current); | |
| } | |
| } | |
| }; | |
| // Delay to avoid immediate triggering | |
| setTimeout(() => { | |
| if (dateFilterClickHandlerRef.current) { | |
| document.addEventListener('click', dateFilterClickHandlerRef.current); | |
| } | |
| }, 0); | |
| } | |
| }} | |
| > | |
| <CalendarIcon className="h-4 w-4" /> | |
| {dateFilter ? format(dateFilter, 'MMM dd, yyyy') : "Filter by Date"} | |
| <ChevronDown className="h-3 w-3 ml-1 opacity-50 absolute right-3" /> | |
| </Button> | |
| <div | |
| id="date-filter-dropdown" | |
| className="absolute right-0 mt-2 z-10 bg-white shadow-lg border rounded-md p-2 w-[300px] hidden" | |
| > | |
| <div className="flex flex-col space-y-1 w-full"> | |
| <div className="grid grid-cols-7 gap-1 text-center text-xs font-medium text-gray-500"> | |
| <div>S</div> | |
| <div>M</div> | |
| <div>T</div> | |
| <div>W</div> | |
| <div>T</div> | |
| <div>F</div> | |
| <div>S</div> | |
| </div> | |
| <div className="grid grid-cols-7 gap-1"> | |
| {Array.from({ length: 31 }, (_, i) => { | |
| const day = i + 1; | |
| const date = new Date(); | |
| date.setDate(day); | |
| // Calculate if this is the selected date | |
| const isSelected = dateFilter ? | |
| date.getDate() === dateFilter.getDate() && | |
| date.getMonth() === dateFilter.getMonth() : false; | |
| return ( | |
| <button | |
| key={day} | |
| className={`h-8 w-8 rounded-md text-sm flex items-center justify-center ${ | |
| isSelected | |
| ? 'bg-primary text-white font-medium' | |
| : 'hover:bg-slate-100' | |
| }`} | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| const newDate = new Date(); | |
| newDate.setDate(day); | |
| setDateFilter(newDate); | |
| document.getElementById('date-filter-dropdown')?.classList.add('hidden'); | |
| }} | |
| > | |
| {day} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| {dateFilter && ( | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className="mt-2" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| setDateFilter(null); | |
| document.getElementById('date-filter-dropdown')?.classList.add('hidden'); | |
| }} | |
| > | |
| Clear Date | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Mode filter - custom dropdown */} | |
| <div className="relative inline-block"> | |
| <Button | |
| variant="outline" | |
| className="flex gap-1 items-center h-10 pr-10" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| const id = 'mode-filter-dropdown'; | |
| const dropdown = document.getElementById(id); | |
| // Close all other dropdowns first | |
| ['date-filter-dropdown', 'project-filter-dropdown'].forEach(dropdownId => { | |
| if (dropdownId !== id) { | |
| document.getElementById(dropdownId)?.classList.add('hidden'); | |
| } | |
| }); | |
| if (dropdown) { | |
| dropdown.classList.toggle('hidden'); | |
| // Remove any existing listeners first to prevent duplicates | |
| if (modeFilterClickHandlerRef.current) { | |
| document.removeEventListener('click', modeFilterClickHandlerRef.current); | |
| } | |
| // Add click outside handler | |
| modeFilterClickHandlerRef.current = (evt: MouseEvent) => { | |
| if (!dropdown.contains(evt.target as Node) && | |
| !(e.target as HTMLElement).contains(evt.target as Node)) { | |
| dropdown.classList.add('hidden'); | |
| if (modeFilterClickHandlerRef.current) { | |
| document.removeEventListener('click', modeFilterClickHandlerRef.current); | |
| } | |
| } | |
| }; | |
| // Delay to avoid immediate triggering | |
| setTimeout(() => { | |
| if (modeFilterClickHandlerRef.current) { | |
| document.addEventListener('click', modeFilterClickHandlerRef.current); | |
| } | |
| }, 0); | |
| } | |
| }} | |
| > | |
| <Filter className="h-4 w-4" /> | |
| {modeFilter ? `Mode: ${modeFilter}` : "Filter by Mode"} | |
| <ChevronDown className="h-3 w-3 ml-1 opacity-50 absolute right-3" /> | |
| </Button> | |
| <div | |
| id="mode-filter-dropdown" | |
| className="absolute right-0 mt-2 z-10 bg-white shadow-lg border rounded-md p-2 min-w-[180px] hidden" | |
| > | |
| <div className="py-1"> | |
| <div className="px-2 text-xs font-medium text-gray-500 mb-1">Communication Mode</div> | |
| <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={() => { | |
| setModeFilter('In-person'); | |
| document.getElementById('mode-filter-dropdown')?.classList.add('hidden'); | |
| }} | |
| > | |
| <Building2 className="h-4 w-4 mr-2 text-primary" /> | |
| <span>In-person</span> | |
| </button> | |
| <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={() => { | |
| setModeFilter('Virtual'); | |
| document.getElementById('mode-filter-dropdown')?.classList.add('hidden'); | |
| }} | |
| > | |
| <Video className="h-4 w-4 mr-2 text-purple-500" /> | |
| <span>Virtual</span> | |
| </button> | |
| <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={() => { | |
| setModeFilter('Phone'); | |
| document.getElementById('mode-filter-dropdown')?.classList.add('hidden'); | |
| }} | |
| > | |
| <Phone className="h-4 w-4 mr-2 text-orange-500" /> | |
| <span>Phone</span> | |
| </button> | |
| <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={() => { | |
| setModeFilter('Email'); | |
| document.getElementById('mode-filter-dropdown')?.classList.add('hidden'); | |
| }} | |
| > | |
| <MessageSquare className="h-4 w-4 mr-2 text-blue-500" /> | |
| <span>Email</span> | |
| </button> | |
| </div> | |
| {modeFilter && ( | |
| <div className="pt-1 mt-1 border-t"> | |
| <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={() => { | |
| setModeFilter(null); | |
| document.getElementById('mode-filter-dropdown')?.classList.add('hidden'); | |
| }} | |
| > | |
| <span>Clear Mode Filter</span> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Project filter - custom dropdown */} | |
| {projects.length > 0 && ( | |
| <div className="relative inline-block"> | |
| <Button | |
| variant="outline" | |
| className="flex gap-1 items-center h-10 pr-10" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| const id = 'project-filter-dropdown'; | |
| const dropdown = document.getElementById(id); | |
| // Close all other dropdowns first | |
| ['date-filter-dropdown', 'mode-filter-dropdown'].forEach(dropdownId => { | |
| if (dropdownId !== id) { | |
| document.getElementById(dropdownId)?.classList.add('hidden'); | |
| } | |
| }); | |
| if (dropdown) { | |
| dropdown.classList.toggle('hidden'); | |
| // Remove any existing listeners first to prevent duplicates | |
| if (projectFilterClickHandlerRef.current) { | |
| document.removeEventListener('click', projectFilterClickHandlerRef.current); | |
| } | |
| // Add click outside handler | |
| projectFilterClickHandlerRef.current = (evt: MouseEvent) => { | |
| if (!dropdown.contains(evt.target as Node) && | |
| !(e.target as HTMLElement).contains(evt.target as Node)) { | |
| dropdown.classList.add('hidden'); | |
| if (projectFilterClickHandlerRef.current) { | |
| document.removeEventListener('click', projectFilterClickHandlerRef.current); | |
| } | |
| } | |
| }; | |
| // Delay to avoid immediate triggering | |
| setTimeout(() => { | |
| if (projectFilterClickHandlerRef.current) { | |
| document.addEventListener('click', projectFilterClickHandlerRef.current); | |
| } | |
| }, 0); | |
| } | |
| }} | |
| > | |
| <Briefcase className="h-4 w-4" /> | |
| {projectFilter !== null | |
| ? `Project: ${getProjectName(projectFilter)}` | |
| : "Filter by Project"} | |
| <ChevronDown className="h-3 w-3 ml-1 opacity-50 absolute right-3" /> | |
| </Button> | |
| <div | |
| id="project-filter-dropdown" | |
| className="absolute right-0 mt-2 z-10 bg-white shadow-lg border rounded-md p-2 min-w-[200px] max-h-[300px] overflow-y-auto hidden" | |
| > | |
| <div className="py-1"> | |
| <div className="px-2 text-xs font-medium text-gray-500 mb-1">Select Project</div> | |
| {projects.map((project) => ( | |
| <button | |
| key={project.id} | |
| 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={() => { | |
| setProjectFilter(project.id); | |
| document.getElementById('project-filter-dropdown')?.classList.add('hidden'); | |
| }} | |
| > | |
| <span>{project.projectName}</span> | |
| </button> | |
| ))} | |
| </div> | |
| {projectFilter !== null && ( | |
| <div className="pt-1 mt-1 border-t"> | |
| <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={() => { | |
| setProjectFilter(null); | |
| document.getElementById('project-filter-dropdown')?.classList.add('hidden'); | |
| }} | |
| > | |
| <span>Clear Project Filter</span> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {(searchQuery || modeFilter || dateFilter || projectFilter !== null) && ( | |
| <Button | |
| variant="ghost" | |
| onClick={clearAllFilters} | |
| size="sm" | |
| className="h-10" | |
| > | |
| Clear All | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| {/* Active Filters */} | |
| {(modeFilter || dateFilter || projectFilter !== null) && ( | |
| <div className="flex gap-2 flex-wrap mb-3"> | |
| {modeFilter && ( | |
| <Badge variant="secondary" className="flex items-center gap-1"> | |
| {getModeIcon(modeFilter)} | |
| Mode: {modeFilter} | |
| <button | |
| className="ml-1 h-4 w-4 rounded-full inline-flex items-center justify-center hover:bg-slate-200" | |
| onClick={() => setModeFilter(null)} | |
| > | |
| <X className="h-3 w-3" /> | |
| </button> | |
| </Badge> | |
| )} | |
| {dateFilter && ( | |
| <Badge variant="secondary" className="flex items-center gap-1"> | |
| <CalendarIcon className="h-3 w-3" /> | |
| Date: {format(dateFilter, 'MMM dd, yyyy')} | |
| <button | |
| className="ml-1 h-4 w-4 rounded-full inline-flex items-center justify-center hover:bg-slate-200" | |
| onClick={() => setDateFilter(null)} | |
| > | |
| <X className="h-3 w-3" /> | |
| </button> | |
| </Badge> | |
| )} | |
| {projectFilter !== null && ( | |
| <Badge variant="secondary" className="flex items-center gap-1"> | |
| <Briefcase className="h-3 w-3" /> | |
| Project: {getProjectName(projectFilter)} | |
| <button | |
| className="ml-1 h-4 w-4 rounded-full inline-flex items-center justify-center hover:bg-slate-200" | |
| onClick={() => setProjectFilter(null)} | |
| > | |
| <X className="h-3 w-3" /> | |
| </button> | |
| </Badge> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| {/* Communications Table/Cards */} | |
| {loading ? ( | |
| <div className="flex justify-center items-center p-8"> | |
| <Loader2 className="h-8 w-8 animate-spin text-primary" /> | |
| </div> | |
| ) : error ? ( | |
| <Alert variant="destructive" className="m-4"> | |
| <AlertCircle className="h-4 w-4" /> | |
| <AlertTitle>Error</AlertTitle> | |
| <AlertDescription>{error}</AlertDescription> | |
| </Alert> | |
| ) : ( | |
| <> | |
| {/* Desktop View - Table */} | |
| <div className="hidden md:block"> | |
| <Card> | |
| <CardContent className="p-0"> | |
| <div className="relative overflow-x-auto"> | |
| <Table> | |
| <TableHeader> | |
| <TableRow> | |
| <TableHead>Topic</TableHead> | |
| <TableHead>Date & Time</TableHead> | |
| <TableHead>Mode</TableHead> | |
| <TableHead>Participants</TableHead> | |
| <TableHead>Project</TableHead> | |
| <TableHead>Decision Made</TableHead> | |
| <TableHead className="text-right">Actions</TableHead> | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {filteredCommunications.length === 0 ? ( | |
| <TableRow> | |
| <TableCell colSpan={7} className="text-center py-8 text-muted-foreground"> | |
| No communications found | |
| </TableCell> | |
| </TableRow> | |
| ) : ( | |
| filteredCommunications.map((communication) => ( | |
| <TableRow key={communication.communicationId}> | |
| <TableCell className="font-medium">{communication.topic}</TableCell> | |
| <TableCell> | |
| <div className="flex items-center gap-1"> | |
| <Calendar className="h-3.5 w-3.5 text-muted-foreground" /> | |
| {formatDate(communication.communicationDate)} | |
| </div> | |
| </TableCell> | |
| <TableCell> | |
| <Badge | |
| variant={getModeBadgeVariant(communication.mode)} | |
| className="flex items-center gap-1" | |
| > | |
| {getModeIcon(communication.mode)} | |
| {communication.mode} | |
| </Badge> | |
| </TableCell> | |
| <TableCell> | |
| <div className="flex items-center gap-1"> | |
| <Users className="h-3.5 w-3.5 text-muted-foreground" /> | |
| <span className="truncate max-w-[200px]" title={communication.participants}> | |
| {communication.participants.split(',').length > 2 | |
| ? `${communication.participants.split(',').slice(0, 2).join(', ')} +${ | |
| communication.participants.split(',').length - 2 | |
| }` | |
| : communication.participants} | |
| </span> | |
| </div> | |
| </TableCell> | |
| <TableCell> | |
| <div className="flex items-center gap-1"> | |
| <Briefcase className="h-3.5 w-3.5 text-muted-foreground" /> | |
| {getProjectName(communication.projectId)} | |
| </div> | |
| </TableCell> | |
| <TableCell> | |
| <div className="truncate max-w-[200px]" title={communication.decisionMade || ''}> | |
| {communication.decisionMade && ( | |
| <HtmlContent html={communication.decisionMade} /> | |
| )} | |
| </div> | |
| </TableCell> | |
| <TableCell className="text-right"> | |
| <div className="flex justify-end gap-2"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => handleOpenEditDialog(communication)} | |
| className="h-8 w-8 p-0" | |
| > | |
| <Edit className="h-4 w-4" /> | |
| <span className="sr-only">Edit</span> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => handleOpenDeleteDialog(communication)} | |
| className="h-8 w-8 p-0 text-destructive hover:text-destructive/90 hover:bg-destructive/10" | |
| > | |
| <Trash2 className="h-4 w-4" /> | |
| <span className="sr-only">Delete</span> | |
| </Button> | |
| </div> | |
| </TableCell> | |
| </TableRow> | |
| )) | |
| )} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| {/* Mobile View - Cards */} | |
| <div className="md:hidden space-y-4"> | |
| {filteredCommunications.length === 0 ? ( | |
| <Card> | |
| <CardContent className="text-center py-8 text-muted-foreground"> | |
| No communications found | |
| </CardContent> | |
| </Card> | |
| ) : ( | |
| filteredCommunications.map((communication) => ( | |
| <Card key={communication.communicationId}> | |
| <CardHeader className="pb-2"> | |
| <div className="flex justify-between items-start"> | |
| <CardTitle className="text-lg">{communication.topic}</CardTitle> | |
| <div className="relative"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-8 w-8 p-0" | |
| onClick={() => { | |
| const id = `mobile-actions-${communication.communicationId}`; | |
| const menu = document.getElementById(id); | |
| if (menu) menu.classList.toggle('hidden'); | |
| }} | |
| > | |
| <MoreHorizontal className="h-4 w-4" /> | |
| </Button> | |
| <div | |
| id={`mobile-actions-${communication.communicationId}`} | |
| className="absolute right-0 top-8 z-10 min-w-[8rem] overflow-hidden rounded-md border border-slate-200 bg-white p-1 shadow-md hidden" | |
| > | |
| <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={() => { | |
| const id = `mobile-actions-${communication.communicationId}`; | |
| const menu = document.getElementById(id); | |
| if (menu) menu.classList.add('hidden'); | |
| handleOpenEditDialog(communication); | |
| }} | |
| > | |
| <Edit className="h-4 w-4 mr-2" /> | |
| Edit | |
| </button> | |
| <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-red-600" | |
| onClick={() => { | |
| const id = `mobile-actions-${communication.communicationId}`; | |
| const menu = document.getElementById(id); | |
| if (menu) menu.classList.add('hidden'); | |
| handleOpenDeleteDialog(communication); | |
| }} | |
| > | |
| <Trash2 className="h-4 w-4 mr-2" /> | |
| Delete | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex flex-wrap gap-2 mt-2"> | |
| <Badge | |
| variant={getModeBadgeVariant(communication.mode)} | |
| className="flex items-center gap-1" | |
| > | |
| {getModeIcon(communication.mode)} | |
| {communication.mode} | |
| </Badge> | |
| <Badge variant="outline" className="flex items-center gap-1"> | |
| <Calendar className="h-3.5 w-3.5" /> | |
| {formatDate(communication.communicationDate).split(',')[0]} | |
| </Badge> | |
| </div> | |
| </CardHeader> | |
| <CardContent className="pb-3 space-y-3"> | |
| <div> | |
| <div className="text-sm font-medium text-muted-foreground mb-1">Participants:</div> | |
| <div className="flex items-center gap-1 text-sm"> | |
| <Users className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" /> | |
| <span>{communication.participants}</span> | |
| </div> | |
| </div> | |
| <div> | |
| <div className="text-sm font-medium text-muted-foreground mb-1">Project:</div> | |
| <div className="flex items-center gap-1 text-sm"> | |
| <Briefcase className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" /> | |
| <span>{getProjectName(communication.projectId)}</span> | |
| </div> | |
| </div> | |
| {communication.decisionMade && ( | |
| <div> | |
| <div className="text-sm font-medium text-muted-foreground mb-1">Decision:</div> | |
| <div className="text-sm"> | |
| <HtmlContent html={communication.decisionMade} /> | |
| </div> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| )) | |
| )} | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| {/* Create Communication Dialog */} | |
| <CustomDialog | |
| isOpen={isCreateDialogOpen} | |
| onClose={handleCloseCreateDialog} | |
| title="Add New Communication" | |
| description="Create a new communication record. Fill out the form below." | |
| isPending={submitLoading} | |
| > | |
| <form onSubmit={handleCreateSubmit}> | |
| <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('create-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"> | |
| {getModeIcon(formData.mode || 'In-person')} | |
| {formData.mode || 'Select Mode'} | |
| </span> | |
| <ChevronDown className="h-4 w-4 opacity-50" /> | |
| </Button> | |
| <div | |
| id="create-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('create-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('create-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('create-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('create-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 className="grid grid-cols-1 gap-2"> | |
| <Label htmlFor="projectId"> | |
| Project <span className="text-destructive">*</span> | |
| </Label> | |
| <div className="relative"> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| className={`w-full justify-between ${!formData.projectId ? 'border-destructive' : ''}`} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| const dropdown = document.getElementById('create-project-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"> | |
| <Briefcase className="h-4 w-4" /> | |
| {formData.projectId ? getProjectName(formData.projectId) : 'Select Project'} | |
| </span> | |
| <ChevronDown className="h-4 w-4 opacity-50" /> | |
| </Button> | |
| {!formData.projectId && ( | |
| <p className="text-xs text-destructive mt-1">Project selection is required</p> | |
| )} | |
| <div | |
| id="create-project-dropdown" | |
| className="absolute left-0 right-0 top-full mt-1 z-50 bg-white shadow-lg border rounded-md p-1 max-h-[200px] overflow-y-auto hidden" | |
| > | |
| {projects.map((project) => ( | |
| <button | |
| key={project.id} | |
| 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(); | |
| handleProjectChange(project.id.toString()); | |
| document.getElementById('create-project-dropdown')?.classList.add('hidden'); | |
| }} | |
| > | |
| <span>{project.projectName}</span> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex justify-end gap-2 mt-4"> | |
| <Button type="button" variant="outline" onClick={handleCloseCreateDialog} disabled={submitLoading}> | |
| Cancel | |
| </Button> | |
| <Button type="submit" disabled={submitLoading}> | |
| {submitLoading ? ( | |
| <> | |
| <Loader2 className="mr-2 h-4 w-4 animate-spin" /> | |
| Creating... | |
| </> | |
| ) : ( | |
| 'Create Communication' | |
| )} | |
| </Button> | |
| </div> | |
| </form> | |
| </CustomDialog> | |
| {/* Edit Communication Dialog */} | |
| <CustomDialog | |
| isOpen={isEditDialogOpen} | |
| onClose={handleCloseEditDialog} | |
| title="Edit Communication" | |
| description="Update the communication record. Make changes to the form below." | |
| isPending={submitLoading} | |
| > | |
| <form onSubmit={handleEditSubmit}> | |
| <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('edit-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"> | |
| {getModeIcon(formData.mode || 'In-person')} | |
| {formData.mode || 'Select Mode'} | |
| </span> | |
| <ChevronDown className="h-4 w-4 opacity-50" /> | |
| </Button> | |
| <div | |
| id="edit-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('edit-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('edit-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('edit-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('edit-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 className="grid grid-cols-1 gap-2"> | |
| <Label htmlFor="projectId">Project</Label> | |
| <div className="relative"> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| className="w-full justify-between" | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| const dropdown = document.getElementById('edit-project-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"> | |
| <Briefcase className="h-4 w-4" /> | |
| {formData.projectId ? getProjectName(formData.projectId) : 'Select Project'} | |
| </span> | |
| <ChevronDown className="h-4 w-4 opacity-50" /> | |
| </Button> | |
| <div | |
| id="edit-project-dropdown" | |
| className="absolute left-0 right-0 top-full mt-1 z-50 bg-white shadow-lg border rounded-md p-1 max-h-[200px] overflow-y-auto 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(); | |
| handleProjectChange('none'); | |
| document.getElementById('edit-project-dropdown')?.classList.add('hidden'); | |
| }} | |
| > | |
| <span>None</span> | |
| </button> | |
| {projects.map((project) => ( | |
| <button | |
| key={project.id} | |
| 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(); | |
| handleProjectChange(project.id.toString()); | |
| document.getElementById('edit-project-dropdown')?.classList.add('hidden'); | |
| }} | |
| > | |
| <span>{project.projectName}</span> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex justify-end gap-2 mt-4"> | |
| <Button type="button" variant="outline" onClick={handleCloseEditDialog} disabled={submitLoading}> | |
| Cancel | |
| </Button> | |
| <Button type="submit" disabled={submitLoading}> | |
| {submitLoading ? ( | |
| <> | |
| <Loader2 className="mr-2 h-4 w-4 animate-spin" /> | |
| Updating... | |
| </> | |
| ) : ( | |
| 'Update Communication' | |
| )} | |
| </Button> | |
| </div> | |
| </form> | |
| </CustomDialog> | |
| {/* Delete Confirmation Dialog */} | |
| <AlertDialog open={openDeleteDialog} onOpenChange={setOpenDeleteDialog}> | |
| <AlertDialogContent> | |
| <AlertDialogHeader> | |
| <AlertDialogTitle>Delete Communication</AlertDialogTitle> | |
| <AlertDialogDescription> | |
| Are you sure you want to delete this communication record?<br/> | |
| This action cannot be undone. | |
| </AlertDialogDescription> | |
| </AlertDialogHeader> | |
| <AlertDialogFooter> | |
| <AlertDialogCancel onClick={handleCloseDeleteDialog}>Cancel</AlertDialogCancel> | |
| <AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> | |
| Delete | |
| </AlertDialogAction> | |
| </AlertDialogFooter> | |
| </AlertDialogContent> | |
| </AlertDialog> | |
| </main> | |
| </div> | |
| ); | |
| }; | |
| export default Communications; |