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