// ProjectDetailView - Two-column view for project details and workflow
function ProjectDetailView({ projectId, onBack, focusCommentId, focusStepId, onFocusHandled, onTriggerRegistration, onViewJob }) {
const { user: currentUser, isAdmin } = useAuth();
const toast = useToast();
const [project, setProject] = React.useState(null);
const [referenti, setReferenti] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [description, setDescription] = React.useState('');
const [hasChanges, setHasChanges] = React.useState(false);
// Comments state
const [comments, setComments] = React.useState([]);
const [loadingComments, setLoadingComments] = React.useState(false);
const [newComment, setNewComment] = React.useState('');
const [addingComment, setAddingComment] = React.useState(false);
const [confirmDelete, setConfirmDelete] = React.useState(null);
const [actionModal, setActionModal] = React.useState(null); // { type: 'cancel' | 'complete', loading: false }
const [showCancelForm, setShowCancelForm] = React.useState(false);
const [selectedCancelReason, setSelectedCancelReason] = React.useState('');
const [cancelNotes, setCancelNotes] = React.useState('');
const [cancelLoading, setCancelLoading] = React.useState(false);
const loadData = async (silent = false) => {
if (!silent) setLoading(true);
try {
// Use getProject to fetch full project data including assignments
const [projectData, referentiData] = await Promise.all([
ApiUtils.getProject(projectId),
ApiUtils.fetchReferenti(null, false),
]);
if (projectData) {
setProject(projectData);
setDescription(projectData.description || '');
}
setReferenti(referentiData.filter(r => r.is_active));
} catch (err) {
if (!silent) toast('Errore nel caricamento dati: ' + err.message, 'error');
} finally {
if (!silent) setLoading(false);
}
};
const loadComments = async () => {
setLoadingComments(true);
try {
const commentsData = await ApiUtils.getProjectComments(projectId);
setComments(commentsData || []);
} catch (err) {
toast('Errore nel caricamento commenti: ' + err.message, 'error');
} finally {
setLoadingComments(false);
}
};
React.useEffect(() => {
if (projectId) {
loadData();
loadComments();
}
}, [projectId]);
// Handle scrolling to focused project comment (not step comment)
React.useEffect(() => {
if (focusCommentId && !focusStepId && !loadingComments && comments.length > 0) {
// This is a project-level comment - scroll to it
const commentElement = document.getElementById(`project-comment-${focusCommentId}`);
if (commentElement) {
// Scroll the comment into view
commentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Add highlight animation
commentElement.classList.add('ring-2', 'ring-brand-500', 'ring-offset-2', 'bg-brand-50');
// Remove highlight after animation
setTimeout(() => {
commentElement.classList.remove('ring-2', 'ring-brand-500', 'ring-offset-2', 'bg-brand-50');
if (onFocusHandled) onFocusHandled();
}, 3000);
} else if (onFocusHandled) {
// Comment not found, clear focus state anyway
onFocusHandled();
}
}
}, [focusCommentId, focusStepId, loadingComments, comments, onFocusHandled]);
// Track changes (only description now, notes removed)
React.useEffect(() => {
if (project) {
const descChanged = description !== (project.description || '');
setHasChanges(descChanged);
}
}, [description, project]);
const handleSave = async () => {
if (!hasChanges) return;
setSaving(true);
try {
await ApiUtils.updateProject(projectId, { description });
toast('Modifiche salvate con successo', 'success');
setHasChanges(false);
// Reload project data
await loadData();
} catch (err) {
toast('Errore nel salvataggio: ' + err.message, 'error');
} finally {
setSaving(false);
}
};
const handleAddComment = async () => {
if (!newComment?.trim()) return;
setAddingComment(true);
try {
await ApiUtils.addProjectComment(projectId, newComment.trim());
setNewComment('');
await loadComments();
toast('Commento aggiunto', 'success');
} catch (err) {
toast('Errore nell\'aggiunta commento: ' + err.message, 'error');
} finally {
setAddingComment(false);
}
};
const handleDeleteComment = (commentId) => {
setConfirmDelete({ commentId });
};
const executeDelete = async () => {
if (!confirmDelete) return;
try {
await ApiUtils.deleteProjectComment(projectId, confirmDelete.commentId);
toast('Commento eliminato', 'success');
setConfirmDelete(null);
await loadComments();
} catch (err) {
toast('Errore nell\'eliminazione: ' + err.message, 'error');
setConfirmDelete(null);
}
};
const getProjectReferenti = () => {
if (!project) return [];
return referenti.filter(r => r.project_ids && r.project_ids.includes(project.id));
};
const getProjectAssignees = () => {
if (!project?.assignments || project.assignments.length === 0) return [];
return project.assignments.map(a => ({
id: a.user_id,
name: a.user_name || a.user_email,
email: a.user_email,
avatar: a.user_avatar
}));
};
const formatRelativeTime = Formatters.formatRelativeTime;
// Check if user can perform project actions (admin or assigned)
const canPerformActions = isAdmin || project?.assignments?.some(a => a.user_id === currentUser?.id);
const handleCancelProject = () => {
setShowCancelForm(true);
setSelectedCancelReason('');
setCancelNotes('');
};
const executeCancelProject = async () => {
if (!selectedCancelReason) {
toast('Seleziona un motivo per l\'annullamento', 'error');
return;
}
// Combine reason and notes
const fullReason = cancelNotes.trim()
? `${selectedCancelReason} - ${cancelNotes.trim()}`
: selectedCancelReason;
setCancelLoading(true);
try {
await ApiUtils.cancelProject(project.id, fullReason);
await ApiUtils.archiveProject(project.id);
toast('Pratica annullata e archiviata', 'success');
onBack && onBack();
} catch (err) {
toast('Errore: ' + err.message, 'error');
} finally {
setCancelLoading(false);
setShowCancelForm(false);
}
};
const handleCompleteProject = () => {
setActionModal({
type: 'complete',
title: 'Chiudi Pratica',
message: `Sei sicuro di voler chiudere positivamente la pratica "${project?.name}"?\n\nLa pratica verrà marcata come completata.`,
confirmText: 'Chiudi Pratica',
variant: 'success',
loading: false,
});
};
const handleRestoreProject = async () => {
try {
// Unarchive also clears cancelled/completed status on the backend
await ApiUtils.unarchiveProject(project.id);
toast('Pratica ripristinata', 'success');
await loadData();
} catch (err) {
toast('Errore nel ripristino: ' + err.message, 'error');
}
};
const executeAction = async () => {
if (!actionModal) return;
setActionModal({ ...actionModal, loading: true });
try {
if (actionModal.type === 'complete') {
// Set status to completed then archive it
await ApiUtils.updateProject(project.id, { status: 'completed' });
await ApiUtils.archiveProject(project.id);
toast('Pratica chiusa e archiviata con successo', 'success');
// Go back to list since project is now archived
onBack && onBack();
}
setActionModal(null);
} catch (err) {
toast('Errore: ' + err.message, 'error');
setActionModal(null);
}
};
if (loading) {
return (
);
}
if (!project) {
return (
Pratica non trovata
Torna alla lista pratiche
);
}
const projectRefs = getProjectReferenti();
const assignees = getProjectAssignees();
const isAssigned = project?.assignments?.some(a => a.user_id === currentUser?.id);
return (
{/* Header with back button */}
{project.name}
{project.slug}
{hasChanges && isAdmin && (
{saving ? (
<>
Salvataggio...
>
) : (
<>
Salva Modifiche
>
)}
)}
{/* Cancelled Banner */}
{project.is_cancelled && (
)}
{/* Completed Banner */}
{!project.is_cancelled && project.status === 'completed' && (
)}
{/* Two Column Layout - 30/70 ratio */}
{/* Left Column - Project Details and Comments (30%) */}
{/* Meta Info - Compact */}
{/* Date & Created By - inline */}
{project.start_date
? new Date(project.start_date).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' })
: 'Data non impostata'
}
•
da {project.created_by_name || 'N/A'}
{/* Referenti */}
Referenti
{projectRefs.length > 0 ? (
projectRefs.map(ref => (
{ref.full_name}
))
) : (
—
)}
{/* Assignees */}
Assegnati
{assignees.length > 0 ? (
assignees.map(user => {
const avatarUrl = user.avatar ? window.ApiUsers?.getAvatarUrl(user.avatar) : null;
return (
{avatarUrl ? (
) : null}
{user.name}
);
})
) : (
—
)}
{/* Description - Compact */}
Descrizione
{isAdmin ? (
) : (
{project.description ? : — }
)}
{/* ID Card Section */}
loadData(true)}
/>
{/* Email Registration Section */}
loadData(true)}
onViewJob={onViewJob}
/>
{/* Comments Section - Compact */}
{/* Comments List */}
{loadingComments ? (
) : comments.length > 0 ? (
{comments.map(comment => (
))}
) : (
Nessun commento. Inizia la conversazione!
)}
{/* Add Comment */}
{
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAddComment();
}
}}
/>
{addingComment ? '...' : '+'}
{/* Right Column - Workflow Timeline (70%) */}
{
// Optionally reload project data after step changes
}}
focusCommentId={focusStepId ? focusCommentId : null}
focusStepId={focusStepId}
onFocusHandled={onFocusHandled}
/>
{/* Action Buttons Footer - Compact */}
{canPerformActions && !project.is_cancelled && project.status !== 'completed' && (
{!showCancelForm ? (
) : (
)}
)}
{/* Restore Button - Compact */}
{canPerformActions && (project.is_cancelled || project.status === 'completed') && (
)}
{/* Action Confirmation Modal */}
{actionModal && (
setActionModal(null)}
/>
)}
{/* Confirmation Modal for Delete */}
{confirmDelete && (
setConfirmDelete(null)}
/>
)}
);
}
// Comment display component for project comments
function ProjectCommentItem({ comment, currentUser, formatRelativeTime, onDelete }) {
const canDelete = comment.created_by === currentUser?.id || currentUser?.role === 'admin';
const avatarUrl = comment.created_by_avatar ? window.ApiUsers?.getAvatarUrl(comment.created_by_avatar) : null;
return (
);
}
// ID Card Section Component - Always expanded panel for ID card upload and extraction
function IDCardSection({ projectId, project, onUpdate }) {
const hasImages = project?.id_card_front_path || project?.id_card_back_path;
return (
Documento d'Identita
{hasImages && (
Caricato
)}
{/* Image Uploader */}
{/* Extracted Data Form - Always visible for manual input */}
);
}
// Email Registration Section Component
function EmailSection({ project, onTrigger, onUpdate, onViewJob }) {
const toast = useToast();
const [email, setEmail] = React.useState(project?.email_address || '');
const [password, setPassword] = React.useState(project?.email_password || '');
const [saving, setSaving] = React.useState(false);
const [editing, setEditing] = React.useState(false);
const [linkedJob, setLinkedJob] = React.useState(null);
const [loadingJob, setLoadingJob] = React.useState(false);
const [showPassword, setShowPassword] = React.useState(false);
// Check if personal data exists (required for automation)
const hasPersonalData = project?.personal_data?.first_name &&
project?.personal_data?.last_name &&
project?.personal_data?.date_of_birth;
// Check if email already exists (blocks automation)
const hasEmail = !!project?.email_address;
// Check if there's a pending/running job (job exists but no email yet)
const hasPendingJob = project?.email_job_id && !hasEmail;
// Update local state when project changes
React.useEffect(() => {
setEmail(project?.email_address || '');
setPassword(project?.email_password || '');
}, [project?.email_address, project?.email_password]);
// Fetch linked job status when there's a pending job
React.useEffect(() => {
console.log('EmailSection effect:', {
email_job_id: project?.email_job_id,
email_address: project?.email_address,
hasPendingJob,
hasEmail
});
if (hasPendingJob && project?.email_job_id) {
setLoadingJob(true);
console.log('Fetching job status for:', project.email_job_id);
fetch(`/queue/job/${project.email_job_id}`)
.then(r => {
console.log('Job fetch response:', r.status, r.ok);
return r.ok ? r.json() : null;
})
.then(data => {
console.log('Job data received:', data);
if (data) setLinkedJob(data);
})
.catch(err => console.error('Job fetch error:', err))
.finally(() => setLoadingJob(false));
} else {
setLinkedJob(null);
}
}, [project?.email_job_id, hasPendingJob]);
// Clear job link when job fails (allow retry)
const handleClearJobLink = async () => {
setSaving(true);
try {
await ApiUtils.updateProject(project.id, { email_job_id: null });
toast('Collegamento rimosso', 'success');
setLinkedJob(null);
onUpdate?.();
} catch (err) {
toast('Errore: ' + err.message, 'error');
} finally {
setSaving(false);
}
};
const handleSaveCredentials = async () => {
setSaving(true);
try {
await ApiUtils.updateProject(project.id, {
email_address: email || null,
email_password: password || null
});
toast('Credenziali salvate', 'success');
setEditing(false);
onUpdate?.();
} catch (err) {
toast('Errore: ' + err.message, 'error');
} finally {
setSaving(false);
}
};
const handleClearCredentials = async () => {
setSaving(true);
try {
await ApiUtils.updateProject(project.id, {
email_address: null,
email_password: null,
email_job_id: null
});
toast('Credenziali rimosse', 'success');
setEmail('');
setPassword('');
onUpdate?.();
} catch (err) {
toast('Errore: ' + err.message, 'error');
} finally {
setSaving(false);
}
};
// Get job status display info
const getJobStatusDisplay = () => {
if (!linkedJob) return null;
const status = linkedJob.status;
if (status === 'queued') return { icon: '⏳', label: 'In coda', color: 'bg-yellow-50 text-yellow-700 border-yellow-200' };
if (status === 'running') return { icon: '⚙️', label: 'In esecuzione', color: 'bg-blue-50 text-blue-700 border-blue-200' };
if (status === 'error') return { icon: '❌', label: 'Errore', color: 'bg-red-50 text-red-700 border-red-200' };
if (status === 'cancelled') return { icon: '⛔', label: 'Annullato', color: 'bg-slate-50 text-slate-700 border-slate-200' };
return null;
};
const jobStatus = getJobStatusDisplay();
return (
Registrazione Email
{hasEmail && (
Completata
)}
{hasPendingJob && linkedJob && (
{jobStatus?.icon} {jobStatus?.label || 'In corso'}
)}
{/* Show pending/running/failed job status */}
{hasPendingJob && (
{loadingJob ? (
Caricamento stato...
) : linkedJob ? (
<>
{jobStatus?.icon}
{jobStatus?.label}
{linkedJob.status === 'running' && }
{linkedJob.message || 'Automazione in corso...'}
onViewJob?.(linkedJob)}
className="text-[10px] text-blue-600 hover:text-blue-800 underline"
>
Vedi Job #{project.email_job_id?.slice(-8)} →
{/* Show retry option for failed jobs */}
{(linkedJob.status === 'error' || linkedJob.status === 'cancelled') && (
{
// Clear the job link first, then trigger new automation
await handleClearJobLink();
// Small delay to let state update, then trigger
setTimeout(() => onTrigger?.(project), 100);
}}
disabled={saving}
className="text-xs text-blue-600 hover:text-blue-800 underline"
>
Riprova automazione
setEditing(true)}
className="text-xs text-slate-600 hover:text-slate-800 underline"
>
Inserisci manualmente
)}
>
) : (
Impossibile caricare lo stato del job
)}
)}
{/* Show Email credentials if exists */}
{hasEmail && !editing ? (
✓
Credenziali Email
{project.email_password ? (
<>
{showPassword ? project.email_password : '••••••••'}
setShowPassword(!showPassword)}
className="text-slate-400 hover:text-slate-600"
title={showPassword ? 'Nascondi' : 'Mostra'}
>
{showPassword ? (
) : (
)}
>
) : (
Non impostata
)}
{project.email_job_id && (
Collegata a job #{project.email_job_id.slice(-8)}
)}
setEditing(true)}
className="text-xs text-blue-600 hover:text-blue-800 underline"
>
Modifica
Rimuovi
) : editing || (!hasEmail && !hasPersonalData) ? (
/* Manual Credentials Entry */
) : null}
{/* Trigger Automation Button - only show when personal_data exists, no email, and no pending job */}
{hasPersonalData && !hasEmail && !hasPendingJob && !editing && (
)}
);
}
window.ProjectDetailView = ProjectDetailView;