// ProjectTimeline - Modern split-panel workflow component for projects // SVG Status Icons const StatusIcons = { in_attesa: ( ), in_corso: ( ), completato: ( ), bloccato: ( ), annullato: ( ), }; // Status configuration const getStatusConfig = (status) => { const configs = { in_attesa: { label: 'In Attesa', bgColor: 'bg-slate-100', textColor: 'text-slate-600', borderColor: 'border-slate-300', dotColor: 'bg-slate-400', iconColor: 'text-slate-400', }, in_corso: { label: 'In Corso', bgColor: 'bg-blue-50', textColor: 'text-blue-700', borderColor: 'border-blue-300', dotColor: 'bg-blue-500', iconColor: 'text-blue-500', }, completato: { label: 'Completato', bgColor: 'bg-green-50', textColor: 'text-green-700', borderColor: 'border-green-300', dotColor: 'bg-green-500', iconColor: 'text-green-500', }, bloccato: { label: 'Bloccato', bgColor: 'bg-red-50', textColor: 'text-red-700', borderColor: 'border-red-300', dotColor: 'bg-red-500', iconColor: 'text-red-500', }, annullato: { label: 'Annullato', bgColor: 'bg-gray-50', textColor: 'text-gray-500', borderColor: 'border-gray-200', dotColor: 'bg-gray-400', iconColor: 'text-gray-400', }, }; return configs[status] || configs.in_attesa; }; const statusOptions = [ { value: 'in_attesa', label: 'In Attesa' }, { value: 'in_corso', label: 'In Corso' }, { value: 'completato', label: 'Completato' }, { value: 'bloccato', label: 'Bloccato' }, { value: 'annullato', label: 'Annullato' }, ]; // Available emoji reactions const REACTION_EMOJIS = ['👍', '❤️', '😄', '🎉', '👀', '🚀']; // Compact step list item function StepListItem({ step, isSelected, onClick, formatRelativeTime }) { const config = getStatusConfig(step.status); const commentCount = step.comments?.length || 0; const attachmentCount = (step.attachments?.length || 0) + (step.comments || []).reduce((sum, c) => sum + (c.attachments?.length || 0), 0); return ( ); } // Inline status dropdown function StatusDropdown({ currentStatus, onStatusChange, disabled, isUpdating }) { const [isOpen, setIsOpen] = React.useState(false); const dropdownRef = React.useRef(null); const config = getStatusConfig(currentStatus); // Close dropdown when clicking outside React.useEffect(() => { const handleClickOutside = (event) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { setIsOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const handleStatusSelect = (newStatus) => { if (newStatus === currentStatus) { setIsOpen(false); return; } // Immediately change status without notes dialog onStatusChange(newStatus, null); setIsOpen(false); }; if (disabled) { return ( {StatusIcons[currentStatus]} {config.label} ); } return (
{isOpen && (
{statusOptions.map(opt => { const optConfig = getStatusConfig(opt.value); const isSelected = opt.value === currentStatus; return ( ); })}
)}
); } // Attachment display component function AttachmentItem({ attachment, currentUser, formatFileSize, formatRelativeTime, getFileIcon, onDownload, onDelete }) { const canDelete = attachment.uploaded_by === currentUser?.id || currentUser?.role === 'admin'; return (
{getFileIcon(attachment.content_type)}
{formatFileSize(attachment.file_size)} • {formatRelativeTime(attachment.uploaded_at)}
{canDelete && ( )}
); } // Comment display component with attachments and reactions function CommentItem({ comment, step, projectId, currentUser, formatRelativeTime, formatFileSize, getFileIcon, uploadingAttachment, onDeleteComment, onUploadAttachment, onDownloadAttachment, onDeleteAttachment }) { const toast = useToast(); const [showAttachUpload, setShowAttachUpload] = React.useState(false); const [showEmojiPicker, setShowEmojiPicker] = React.useState(false); const [reactions, setReactions] = React.useState(comment.reactions || {}); const [togglingReaction, setTogglingReaction] = React.useState(false); const canDelete = comment.created_by === currentUser?.id || currentUser?.role === 'admin'; const commentAttachments = comment.attachments || []; React.useEffect(() => { loadReactions(); }, [comment.id]); const loadReactions = async () => { try { const data = await ApiUtils.getCommentReactions(projectId, step.id, comment.id); setReactions(data.reactions || {}); } catch (err) { // Silently fail - reactions are optional } }; const handleToggleReaction = async (emoji) => { if (togglingReaction) return; setTogglingReaction(true); setShowEmojiPicker(false); try { const result = await ApiUtils.toggleCommentReaction(projectId, step.id, comment.id, emoji); setReactions(prev => { const newReactions = { ...prev }; const currentUserId = currentUser?.id; if (result.action === 'added') { if (!newReactions[emoji]) { newReactions[emoji] = []; } if (!newReactions[emoji].some(u => u.user_id === currentUserId)) { newReactions[emoji].push({ user_id: currentUserId, user_name: currentUser?.full_name || currentUser?.email, }); } } else { if (newReactions[emoji]) { newReactions[emoji] = newReactions[emoji].filter(u => u.user_id !== currentUserId); if (newReactions[emoji].length === 0) { delete newReactions[emoji]; } } } return newReactions; }); } catch (err) { toast('Errore nella reazione: ' + err.message, 'error'); } finally { setTogglingReaction(false); } }; const hasUserReacted = (emoji) => { return reactions[emoji]?.some(u => u.user_id === currentUser?.id); }; const getReactionTooltip = (emoji) => { const users = reactions[emoji] || []; if (users.length === 0) return ''; if (users.length <= 3) { return users.map(u => u.user_name || 'Utente').join(', '); } return `${users.slice(0, 2).map(u => u.user_name || 'Utente').join(', ')} e altri ${users.length - 2}`; }; const avatarUrl = comment.created_by_avatar ? window.ApiUsers?.getAvatarUrl(comment.created_by_avatar) : null; return (
{/* Avatar */} {avatarUrl ? ( {comment.created_by_name} ) : (
{comment.created_by_name?.charAt(0).toUpperCase() || '?'}
)} {/* Content */}
{comment.created_by_name || 'Utente'} {formatRelativeTime(comment.created_at)} {/* Actions - show on hover */}
{showEmojiPicker && (
{REACTION_EMOJIS.map(emoji => ( ))}
)}
{canDelete && ( )}

{/* Reactions */} {Object.keys(reactions).length > 0 && (
{Object.entries(reactions).map(([emoji, users]) => ( ))}
)} {/* Comment Attachments */} {commentAttachments.length > 0 && (
{commentAttachments.map(att => (
{getFileIcon(att.content_type)} {(att.uploaded_by === currentUser?.id || currentUser?.role === 'admin') && ( )}
))}
)} {/* Upload attachment */} {showAttachUpload && (
)}
); } // Step detail panel function StepDetailPanel({ step, projectId, currentUser, canChangeStatus, isUpdating, onStatusChange, newComment, setNewComment, addingComment, onAddComment, uploadingAttachment, onUploadAttachment, onDeleteComment, onDownloadAttachment, onDeleteAttachment, onUploadCommentAttachment, formatRelativeTime, formatFileSize, formatDate, getFileIcon }) { const config = getStatusConfig(step.status); const stepAttachments = step.attachments || []; return (
{/* Header */}

{step.step_name}

{step.history && step.history.length > 0 && (

Aggiornato da {step.history[0].changed_by_name || 'Utente'} {' '}{formatRelativeTime(step.history[0].changed_at)}

)}
onStatusChange(step, newStatus, notes)} disabled={!canChangeStatus} isUpdating={isUpdating} />
{/* Content - scrollable */}
{/* Notes */} {step.notes && (

Note

)} {/* Attachments */}

Allegati {stepAttachments.length > 0 && ( ({stepAttachments.length}) )}

{stepAttachments.length > 0 ? (
{stepAttachments.map(att => ( ))}
) : (

Nessun allegato

)}
{/* Comments */}

Conversazione {step.comments && step.comments.length > 0 && ( ({step.comments.length}) )}

{step.comments && step.comments.length > 0 ? (
{step.comments.map(comment => ( ))}
) : (

Nessun commento. Inizia la conversazione!

)}
{/* Comment input - fixed at bottom */}
setNewComment(prev => ({ ...prev, [step.id]: val }))} placeholder="Scrivi un commento..." rows={1} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onAddComment(step.id); } }} />
{/* Footer - timestamps */}
Creato: {formatDate(step.created_at)} Aggiornato: {formatDate(step.updated_at)}
); } // Main ProjectTimeline component function ProjectTimeline({ projectId, canEdit = false, isAssigned = false, isCancelled = false, onStepChange = null, focusCommentId = null, focusStepId = null, onFocusHandled = null }) { const { user: currentUser, isAdmin } = useAuth(); const isWorkflowLocked = isCancelled; const canChangeStatus = canEdit && (isAdmin || isAssigned) && !isWorkflowLocked; const toast = useToast(); const [timeline, setTimeline] = React.useState(null); const [loading, setLoading] = React.useState(true); const [selectedStepId, setSelectedStepId] = React.useState(null); const [updatingStep, setUpdatingStep] = React.useState(null); const [newComment, setNewComment] = React.useState({}); const [addingComment, setAddingComment] = React.useState({}); const [uploadingAttachment, setUploadingAttachment] = React.useState({}); const [confirmDelete, setConfirmDelete] = React.useState(null); const [focusHandled, setFocusHandled] = React.useState(false); // Use shared formatters const formatDate = Formatters.formatDate; const formatRelativeTime = Formatters.formatRelativeTime; const formatFileSize = Formatters.formatFileSize; const getFileIcon = (contentType) => { if (contentType.startsWith('image/')) return '🖼️'; if (contentType.includes('pdf')) return '📄'; if (contentType.includes('word') || contentType.includes('document')) return '📝'; if (contentType.includes('excel') || contentType.includes('spreadsheet')) return '📊'; if (contentType.includes('zip') || contentType.includes('rar') || contentType.includes('7z')) return '📦'; return '📎'; }; const loadTimeline = async () => { await new Promise(resolve => setTimeout(resolve, 100)); try { const data = await ApiUtils.getProjectTimeline(projectId, true); const newTimeline = { ...data, steps: data.steps ? data.steps.map(s => ({ ...s, _key: Date.now(), history: s.history ? s.history.map(h => ({ ...h, _key: Date.now() })) : [], comments: s.comments ? s.comments.map(c => ({ ...c, _key: Date.now() })) : [], attachments: s.attachments ? s.attachments.map(a => ({ ...a, _key: Date.now() })) : [], })) : [], _refreshed: Date.now() }; setTimeline(newTimeline); // Auto-select first incomplete step (or last if all complete) if (!selectedStepId && newTimeline.steps?.length > 0) { const firstIncomplete = newTimeline.steps.find(s => s.status !== 'completato'); setSelectedStepId(firstIncomplete?.id || newTimeline.steps[newTimeline.steps.length - 1].id); } } catch (err) { toast('Errore nel caricamento timeline: ' + err.message, 'error'); } finally { setLoading(false); } }; React.useEffect(() => { if (projectId) { loadTimeline(); } }, [projectId]); // Handle focus on specific comment React.useEffect(() => { if (focusCommentId && focusStepId && timeline && !loading && !focusHandled) { setSelectedStepId(focusStepId); setTimeout(() => { const commentElement = document.getElementById(`step-comment-${focusCommentId}`); if (commentElement) { commentElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); commentElement.classList.add('ring-2', 'ring-brand-500', 'ring-offset-2', 'bg-brand-50', 'rounded-lg'); setTimeout(() => { commentElement.classList.remove('ring-2', 'ring-brand-500', 'ring-offset-2', 'bg-brand-50', 'rounded-lg'); setFocusHandled(true); if (onFocusHandled) onFocusHandled(); }, 3000); } else { setFocusHandled(true); if (onFocusHandled) onFocusHandled(); } }, 300); } }, [focusCommentId, focusStepId, timeline, loading, focusHandled, onFocusHandled]); React.useEffect(() => { if (focusCommentId && focusStepId) { setFocusHandled(false); } }, [focusCommentId, focusStepId]); const handleStatusChange = async (step, newStatus, notes = null) => { setUpdatingStep(step.id); try { await ApiUtils.updateStepStatus(projectId, step.id, { status: newStatus, notes: notes }); toast('Stato aggiornato con successo', 'success'); await loadTimeline(); if (onStepChange) { onStepChange(projectId); } window.dispatchEvent(new CustomEvent('project-step-updated', { detail: { projectId } })); localStorage.setItem('rosa_projects_need_refresh', Date.now().toString()); } catch (err) { toast('Errore nell\'aggiornamento: ' + err.message, 'error'); } finally { setUpdatingStep(null); } }; const handleAddComment = async (stepId) => { const content = newComment[stepId]; if (!content?.trim()) return; setAddingComment(prev => ({ ...prev, [stepId]: true })); try { await ApiUtils.addStepComment(projectId, stepId, content.trim()); setNewComment(prev => ({ ...prev, [stepId]: '' })); await loadTimeline(); toast('Commento aggiunto', 'success'); } catch (err) { toast('Errore nell\'aggiunta commento: ' + err.message, 'error'); } finally { setAddingComment(prev => ({ ...prev, [stepId]: false })); } }; const handleDeleteComment = (stepId, commentId) => { setConfirmDelete({ type: 'comment', stepId, commentId }); }; const handleUploadStepAttachment = async (stepId, file) => { setUploadingAttachment(prev => ({ ...prev, [`step-${stepId}`]: true })); try { await ApiUtils.uploadStepAttachment(projectId, stepId, file); await loadTimeline(); toast('Allegato caricato con successo', 'success'); } catch (err) { toast('Errore nel caricamento: ' + err.message, 'error'); } finally { setUploadingAttachment(prev => ({ ...prev, [`step-${stepId}`]: false })); } }; const handleUploadCommentAttachment = async (stepId, commentId, file) => { setUploadingAttachment(prev => ({ ...prev, [`comment-${commentId}`]: true })); try { await ApiUtils.uploadCommentAttachment(projectId, stepId, commentId, file); await loadTimeline(); toast('Allegato caricato con successo', 'success'); } catch (err) { toast('Errore nel caricamento: ' + err.message, 'error'); } finally { setUploadingAttachment(prev => ({ ...prev, [`comment-${commentId}`]: false })); } }; const handleDeleteAttachment = (attachmentId, filename) => { setConfirmDelete({ type: 'attachment', attachmentId, filename }); }; const executeDelete = async () => { if (!confirmDelete) return; try { if (confirmDelete.type === 'comment') { await ApiUtils.deleteStepComment(projectId, confirmDelete.stepId, confirmDelete.commentId); toast('Commento eliminato', 'success'); } else if (confirmDelete.type === 'attachment') { await ApiUtils.deleteAttachment(confirmDelete.attachmentId); toast('Allegato eliminato', 'success'); } setConfirmDelete(null); await loadTimeline(); } catch (err) { toast('Errore nell\'eliminazione: ' + err.message, 'error'); setConfirmDelete(null); } }; const handleDownloadAttachment = (attachment) => { const url = ApiUtils.downloadAttachment(attachment.id); window.open(url, '_blank'); }; // Keyboard navigation React.useEffect(() => { const handleKeyDown = (e) => { if (!timeline?.steps || document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') { return; } const currentIndex = timeline.steps.findIndex(s => s.id === selectedStepId); if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { e.preventDefault(); if (currentIndex > 0) { setSelectedStepId(timeline.steps[currentIndex - 1].id); } } else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { e.preventDefault(); if (currentIndex < timeline.steps.length - 1) { setSelectedStepId(timeline.steps[currentIndex + 1].id); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [timeline, selectedStepId]); if (loading) { return (

Caricamento workflow...

); } if (!timeline || !timeline.steps || timeline.steps.length === 0) { return (
Nessuna timeline disponibile per questa pratica
); } const completedCount = timeline.steps.filter(s => s.status === 'completato').length; const totalSteps = timeline.steps.length; const progressPercent = Math.round((completedCount / totalSteps) * 100); const selectedStep = timeline.steps.find(s => s.id === selectedStepId); return (
{/* Workflow Locked Warning */} {isWorkflowLocked && (
Workflow bloccato - Pratica conclusa
)} {/* Progress bar - minimal */}
Progresso {completedCount}/{totalSteps} ({progressPercent}%)
{/* Split panel layout */}
{/* Step list - left panel */}
{timeline.steps.map(step => ( setSelectedStepId(step.id)} formatRelativeTime={formatRelativeTime} /> ))}

Usa ↑↓ per navigare

{/* Detail panel - right */}
{selectedStep ? ( ) : (
Seleziona uno step per vedere i dettagli
)}
{/* Confirmation Modal */} {confirmDelete && ( setConfirmDelete(null)} /> )} {/* CSS for animations */}
); } window.ProjectTimeline = ProjectTimeline;