import React, { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import { PencilSquareIcon, TrashIcon, EllipsisHorizontalCircleIcon, ClipboardDocumentListIcon, PlayIcon, ClockIcon, CheckCircleIcon, XCircleIcon, ShareIcon, ExclamationTriangleIcon, } from '@heroicons/react/24/outline'; import { Project, ProjectStatus } from '../../entities/Project'; import { useTranslation } from 'react-i18next'; import { useToast } from '../Shared/ToastContext'; import { getCurrentUser } from '../../utils/userUtils'; import Tooltip from '../Shared/Tooltip'; import { differenceInCalendarDays } from 'date-fns'; import { listShares, ListSharesResponseRow } from '../../utils/sharesService'; import { getApiPath } from '../../config/paths'; interface ProjectItemProps { project: Project; viewMode: 'cards' | 'list'; getCompletionPercentage: () => number; activeDropdown: number | null; setActiveDropdown: React.Dispatch>; handleEditProject: (project: Project) => void; setProjectToDelete: React.Dispatch>; setIsConfirmDialogOpen: React.Dispatch>; onOpenShare: (project: Project) => void; } const getProjectInitials = (name: string, maxLetters?: number) => { const words = name .trim() .split(' ') .filter((word) => word.length > 0); if (words.length === 1) { const singleWord = name.toUpperCase(); return maxLetters ? singleWord.substring(0, maxLetters) : singleWord; } const initials = words.map((word) => word[0].toUpperCase()).join(''); return maxLetters ? initials.substring(0, maxLetters) : initials; }; const getStatusIcon = (status: ProjectStatus | undefined) => { switch (status) { case 'not_started': return { icon: EllipsisHorizontalCircleIcon }; case 'planned': return { icon: ClipboardDocumentListIcon }; case 'in_progress': return { icon: PlayIcon }; case 'waiting': return { icon: ClockIcon }; case 'done': return { icon: CheckCircleIcon }; case 'cancelled': return { icon: XCircleIcon }; default: return { icon: EllipsisHorizontalCircleIcon }; } }; const getStatusLabel = (status: ProjectStatus | undefined, t: any): string => { switch (status) { case 'not_started': return t('projectStatus.not_started', 'Not Started'); case 'planned': return t('projectStatus.planned', 'Planned'); case 'in_progress': return t('projectStatus.in_progress', 'In Progress'); case 'waiting': return t('projectStatus.waiting', 'Waiting'); case 'done': return t('projectStatus.done', 'Completed'); case 'cancelled': return t('projectStatus.cancelled', 'Cancelled'); default: return t('projectStatus.not_started', 'Not Started'); } }; const projectShareCache = new Map(); const failedShareCache = new Set(); const MAX_SHARE_AVATARS = 4; const getShareInitials = (value?: string | null) => { if (!value) return '?'; const cleaned = value .replace(/@.*/, '') .split(/[\s._-]+/) .filter((part) => part.length > 0) .map((part) => part[0].toUpperCase()) .join(''); return cleaned.substring(0, 2) || '?'; }; const ProjectItem: React.FC = ({ project, viewMode, getCompletionPercentage, activeDropdown, setActiveDropdown, handleEditProject, setProjectToDelete, setIsConfirmDialogOpen, onOpenShare, }) => { const { t } = useTranslation(); const { showErrorToast } = useToast(); const currentUser = getCurrentUser(); const isOwner = currentUser && (project as any).user_uid === currentUser.uid; const descriptionText = project.description?.trim(); const listTitleClasses = 'block w-full text-md font-semibold text-gray-900 dark:text-gray-100 hover:text-gray-700 dark:hover:text-gray-200 transition-colors truncate'; const listTitleLink = ( {project.name} ); const [sharedUsers, setSharedUsers] = useState< ListSharesResponseRow[] | null >(() => { if (project.uid && projectShareCache.has(project.uid)) { return projectShareCache.get(project.uid) || null; } return null; }); useEffect(() => { if (project.uid && projectShareCache.has(project.uid)) { setSharedUsers(projectShareCache.get(project.uid) || null); } else if (!project.is_shared) { setSharedUsers(null); } }, [project.uid, project.is_shared]); useEffect(() => { if ( !project.is_shared || !project.uid || projectShareCache.has(project.uid) || failedShareCache.has(project.uid) ) { return; } let isMounted = true; listShares('project', project.uid) .then((rows) => { if (!isMounted) return; const filtered = rows.filter((row) => !row.is_owner); projectShareCache.set(project.uid as string, filtered); setSharedUsers(filtered); }) .catch((error) => { if (!isMounted) return; failedShareCache.add(project.uid as string); console.error( 'Failed to fetch shares for project', project.uid, error ); }); return () => { isMounted = false; }; }, [project.uid, project.is_shared]); const dueInfo = useMemo(() => { if (!project.due_date_at) { return { text: t('projectItem.noDueDate', 'No due date'), isOverdue: false, }; } const dueDate = new Date(project.due_date_at); if (Number.isNaN(dueDate.getTime())) { return { text: t('projectItem.noDueDate', 'No due date'), isOverdue: false, }; } const diff = differenceInCalendarDays(dueDate, new Date()); if (diff === 0) { return { text: t('projectItem.dueToday', 'Due today'), isOverdue: false, }; } const unit = Math.abs(diff) === 1 ? t('projectItem.day', 'day') : t('projectItem.days', 'days'); if (diff > 0) { return { text: t('projectItem.dueIn', 'Due in {{count}} {{unit}}', { count: diff, unit, }), isOverdue: false, }; } return { text: t('projectItem.overdue', 'Overdue {{count}} {{unit}} ago', { count: Math.abs(diff), unit, }), isOverdue: true, }; }, [project.due_date_at, t]); const shareAvatars = useMemo(() => { if (!project.is_shared) { return { avatars: [] as ListSharesResponseRow[], remaining: 0, }; } const knownShares = sharedUsers ?? []; const avatars = knownShares.slice(0, MAX_SHARE_AVATARS); const totalCount = (sharedUsers?.length ?? project.share_count ?? avatars.length) || 0; const remaining = Math.max(0, totalCount - avatars.length); return { avatars, remaining }; }, [project.is_shared, project.share_count, sharedUsers]); const getShareDisplayName = (email?: string | null) => { if (!email) { return t('projectItem.sharedUser', 'Shared user'); } const [namePart] = email.split('@'); if (!namePart) return email; return namePart.charAt(0).toUpperCase() + namePart.slice(1); }; return (
{viewMode === 'cards' && (
{project.image_url ? ( {project.name} ) : (
)}
{project.is_shared && ( )} {(() => { const { icon: StatusIcon } = getStatusIcon( project.status ); return ( ); })()}
{project.id !== undefined && activeDropdown === project.id && (
{isOwner && ( )}
)}
)} {viewMode === 'cards' && (

{project.name}

{descriptionText && (

{descriptionText}

)}
} className="w-full" > {project.name}
{(project as any).task_status ? `${(project as any).task_status.done}/${(project as any).task_status.total}` : '0/0'}
{dueInfo.isOverdue ? ( {dueInfo.text} ) : ( {dueInfo.text} )}
{project.is_shared && (
<> {shareAvatars.avatars.map( (share) => ( {share.avatar_image ? ( {getShareDisplayName( ) : ( {getShareInitials( share.email )} )} ) )} {shareAvatars.remaining > 0 && ( + {shareAvatars.remaining} )}
)}
)} {viewMode === 'list' && ( {project.image_url ? ( {project.name} ) : (
{getProjectInitials(project.name, 2)}
)} )} {viewMode === 'list' && (
{listTitleLink}
{isOwner && ( )}
)} ); }; export default ProjectItem;