const Icon = ({ children }) => ( {children} ); const Icons = { Sun: () => , Moon: () => , Coffee: () => , Target: () => , Stages: () => , Kanban: () => , Activity: () => , MessageSquare: () => , User: () => , Users: () => , Settings: () => , Check: () => , CheckCircle: () => , Edit: () => , Lock: () => , Plus: () => , X: () => , Trash: () => , Clock: () => , WifiOff: () => , Play: () => , Stop: () => , Pause: () => , Folder: () => , Upload: () => , Download: () => , Calendar: () => , List: () => , History: () => , Euro: () => , Package: () => , PieChart: () => , Home: () => , Bell: () => , AlertTriangle: () => , CheckSquare: () => , Square: () => , UserPlus: () => , UserMinus: () => , Undo: () => , Star: () => , Link: () => , ArrowUp: () => , ArrowDown: () => , Repeat: () => , Mail: () => , Phone: () => , Hash: () => , Hourglass: () => , Lightbulb: () => , Eye: () => , Flag: () => , FileText: () => , EmojiGood: () => , EmojiMid: () => , EmojiBad: () => , }; const API_URL = '/api'; const WS_BASE_URL = API_URL.replace('http', 'ws'); const GLOBAL_CATEGORIES = ["Finances", "Administratif", "Communication", "Rédaction", "Événementiel", "Logistique", "Technique", "Informatique", "Social", "Général"]; function formatTime(seconds) { if (!seconds) return "0h 0m"; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); if (h > 0) return `${h}h ${m}m`; return `${m}m`; } function formatChrono(seconds) { const m = Math.floor(seconds / 60).toString().padStart(2, '0'); const s = Math.floor(seconds % 60).toString().padStart(2, '0'); return `${m}:${s}`; } // --- HOOK EN-LIGNE / HORS-LIGNE --- function useOnlineStatus() { const [isOnline, setIsOnline] = useState(navigator.onLine); useEffect(() => { const goOnline = () => setIsOnline(true); const goOffline = () => setIsOnline(false); window.addEventListener('online', goOnline); window.addEventListener('offline', goOffline); return () => { window.removeEventListener('online', goOnline); window.removeEventListener('offline', goOffline); }; }, []); return isOnline; } // --- FETCH API --- const apiFetch = async (endpoint, options = {}) => { const token = localStorage.getItem('jwt_token'); const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) }; if (token) headers['Authorization'] = `Bearer ${token}`; try { const response = await fetch(`${API_URL}${endpoint}`, { ...options, headers }); if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.detail || err.error || 'Erreur API'); } return response.json(); } catch (err) { if (!navigator.onLine) throw new Error("Vous êtes hors-ligne. Impossible de contacter le serveur."); throw err; } }; function SkeletonProject() { return (
); } // --- AUDIO AUTOPLAY SAFE --- let audioCtx = null; const unlockAudio = () => { if (!audioCtx) { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } if (audioCtx.state === 'suspended') { audioCtx.resume(); } document.removeEventListener('click', unlockAudio); document.removeEventListener('keydown', unlockAudio); }; if (typeof document !== 'undefined') { document.addEventListener('click', unlockAudio, { once: true }); document.addEventListener('keydown', unlockAudio, { once: true }); } function playBeep() { try { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); if (audioCtx.state === 'suspended') audioCtx.resume(); const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.connect(gain); gain.connect(audioCtx.destination); osc.type = 'sine'; osc.frequency.setValueAtTime(523.25, audioCtx.currentTime); gain.gain.setValueAtTime(0, audioCtx.currentTime); gain.gain.linearRampToValueAtTime(0.1, audioCtx.currentTime + 0.05); gain.gain.exponentialRampToValueAtTime(0.00001, audioCtx.currentTime + 0.5); osc.start(audioCtx.currentTime); osc.stop(audioCtx.currentTime + 0.5); } catch(e) {} } // --- NOTIFICATIONS ASYNCHRONES --- const pushNotify = async (title, body) => { if (!("Notification" in window)) return; let permission = Notification.permission; if (permission === "default") { permission = await Notification.requestPermission(); } if (permission === "granted") { new Notification(title, { body, icon: "images/icon-app.png" }); } }; const AuthContext = createContext(); function AuthProvider({ children }) { const [user, setUser] = useState(localStorage.getItem('currentUser') || ''); const [userProfile, setUserProfile] = useState(null); const [theme, setTheme] = useState('light'); useEffect(() => { if (user) { apiFetch(`/users/${user}/profile`) .then(p => { setUserProfile(p); if (p.theme) setTheme(p.theme); }) .catch(() => {}); } else { setUserProfile(null); setTheme('light'); } }, [user]); useEffect(() => { document.documentElement.setAttribute('data-theme', theme); }, [theme]); const updateProfileData = (key, value) => { if (!user) return; setUserProfile(prev => { const updated = { ...(prev || {}), [key]: value }; apiFetch(`/users/${user}/profile`, { method: 'POST', body: JSON.stringify(updated) }) .catch(console.error); return updated; }); }; const cycleTheme = () => { const nextTheme = theme === 'light' ? 'dark' : (theme === 'dark' ? 'sepia' : 'light'); setTheme(nextTheme); updateProfileData('theme', nextTheme); }; const login = (uid, token) => { setUser(uid); localStorage.setItem('currentUser', uid); localStorage.setItem('jwt_token', token); }; const logout = () => { setUser(""); localStorage.removeItem('currentUser'); localStorage.removeItem('jwt_token'); }; return ( {children} ); } const useAuth = () => useContext(AuthContext); function useProjectData(projectId, pausePolling = false, savedPwds = {}, savePwd = () => {}) { const [data, setData] = useState(null); const [projectPwd, setProjectPwd] = useState(""); const [needsPwd, setNeedsPwd] = useState(false); const [error, setError] = useState(""); const [isAdmin, setIsAdmin] = useState(false); const isOnline = useOnlineStatus(); const savedPwdsRef = useRef(savedPwds); const savePwdRef = useRef(savePwd); useEffect(() => { savedPwdsRef.current = savedPwds; }, [savedPwds]); useEffect(() => { savePwdRef.current = savePwd; }, [savePwd]); const fetchProject = useCallback(async (pwdAttempt = "") => { const pwdToUse = pwdAttempt || savedPwdsRef.current[projectId] || ""; try { const json = await apiFetch(`/projects/${projectId}?pwd=${encodeURIComponent(pwdToUse)}`); setData(json.data); setProjectPwd(json.password || ""); setNeedsPwd(false); setError(""); setIsAdmin(json.isAdmin !== false); if(pwdToUse && savedPwdsRef.current[projectId] !== pwdToUse) savePwdRef.current(projectId, pwdToUse); return true; } catch(e) { if (e.message.includes("Mot de passe") || e.message.includes("requis")) setNeedsPwd(true); else if (isOnline) setError(e.message); return false; } }, [projectId, isOnline]); const saveData = async ({newData, newPwd = null, newPublicPassword = null, newPassword = null}) => { console.log(newData); if (!newData) throw new Error("Les données sont vide !"); if (!isOnline) return { success: false, error: "Sauvegarde impossible en mode hors-ligne." }; const pwdToSave = newPwd !== null ? newPwd : projectPwd; try { await apiFetch(`/projects/${projectId}`, { method: 'POST', body: JSON.stringify({ project_password: pwdToSave, newPublicPassword: newPublicPassword, newAdminPassword: newPassword, data: newData }) }); if(newPwd !== null) setProjectPwd(newPwd); return { success: true }; } catch(e) { return { success: false, error: e.message }; } }; useEffect(() => { if (!projectId || pausePolling || !isOnline) return; fetchProject(); }, [projectId, pausePolling, isOnline, fetchProject]); return { data, setData, projectPwd, needsPwd, error, isAdmin, fetchProject, saveData, isOnline }; } const updateRemoteTask = async (projectId, taskId, updates, actionDesc, user, userProfile) => { try { const pwd = userProfile?.projectPwds?.[projectId] || ""; const res = await apiFetch(`/projects/${projectId}?pwd=${encodeURIComponent(pwd)}`); const projectData = res.data; const projectPwd = res.password || ""; const task = projectData.tasks.find(t => t.id === taskId); if (!task) return null; let newTasks = projectData.tasks.map(t => t.id === taskId ? { ...t, ...updates } : t); const newLogs = [{ id: Date.now(), user: userProfile?.username || "Anonyme", action: actionDesc, date: new Date().toISOString() }, ...(projectData.auditLogs || [])].slice(0, 100); await apiFetch(`/projects/${projectId}`, { method: 'POST', body: JSON.stringify({ project_password: projectPwd, data: { ...projectData, tasks: newTasks, auditLogs: newLogs } }) }); return projectData; } catch (error) { console.error("Erreur updateRemoteTask:", error); return null; } }; // --- HEADER --- function Header({ route, navigate, setShowProfile }) { const { user, userProfile, theme, cycleTheme } = useAuth(); const ThemeIcon = theme === 'light' ? Icons.Sun : (theme === 'dark' ? Icons.Moon : Icons.Coffee); return (
{user ? ( <> setShowProfile(true)} style={{padding: '8px 15px'}}> {userProfile?.username} ) : ( route.path !== 'home' && ( ) )}
); } // --- APP CONTENT --- function AppContent() { const { user, userProfile, login, logout } = useAuth(); const [route, setRoute] = useState({ path: 'home', id: null }); const [showProfile, setShowProfile] = useState(false); const [activeFocus, setActiveFocus] = useState(null); const navigate = (path, id = null) => { if (id) window.location.hash = `#${path}/${id}`; else if (path === 'home') window.location.hash = ''; else window.location.hash = `#${path}`; }; useEffect(() => { const handleHash = () => { const hash = window.location.hash; if (hash.startsWith('#project/')) setRoute({ path: 'project', id: hash.replace('#project/', '') }); else if (hash.startsWith('#admin/')) setRoute({ path: 'admin', id: hash.replace('#admin/', '') }); else if (hash === '#global-user') setRoute({ path: 'global-user', id: null }); else setRoute({ path: 'home', id: null }); }; window.addEventListener('hashchange', handleHash); handleHash(); return () => window.removeEventListener('hashchange', handleHash); }, []); // --- FONCTIONS DU CHRONO GLOBAL --- const startGlobalFocus = async (projectId, task) => { const updatedProject = await updateRemoteTask(projectId, task.id, { activeBy: user }, `a lancé le chrono sur "${task.title}"`, user, userProfile); if (updatedProject) { setActiveFocus({ task: { ...task, activeBy: user }, projectId: projectId, enableReview: updatedProject.features?.enableReview || false }); } else { alert("Erreur lors du lancement du chrono. Êtes-vous hors-ligne ?"); } }; const handleGlobalEndFocus = async (task, complete, elapsedSeconds) => { if (!activeFocus) return; const { projectId, enableReview } = activeFocus; const elapsed = Math.floor(elapsedSeconds); const newLogs = [...(task.timeLogs || []), { id: Date.now(), user, durationSeconds: elapsed, date: new Date().toISOString() }]; const newTotal = (task.timeSpent || 0) + elapsed; let targetStatus = task.status; let logMsg = `a travaillé ${formatTime(elapsed)} sur "${task.title}"`; if (complete) { targetStatus = enableReview ? 'review' : 'done'; logMsg = enableReview ? `a envoyé la tâche "${task.title}" en validation (+${formatTime(elapsed)})` : `a validé la tâche "${task.title}" (+${formatTime(elapsed)})`; } await updateRemoteTask( projectId, task.id, { activeBy: null, status: targetStatus, timeSpent: newTotal, timeLogs: newLogs }, logMsg, user, userProfile ); setActiveFocus(null); }; return (
{route.path === 'home' && } {route.path === 'global-user' && } {route.path === 'project' && } {route.path === 'admin' && } {showProfile && user && ( setShowProfile(false)} onLogout={() => { logout(); setShowProfile(false); navigate('home'); }} /> )} {activeFocus && ( )}
); } // --- ERROR BOUNDARY --- class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error) { return { hasError: true, error }; } componentDidCatch(error, errorInfo) { console.error("ErrorBoundary intercept:", error, errorInfo); } render() { if (this.state.hasError) { return (

Oups, une erreur inattendue est survenue.

L'application a rencontré un problème d'affichage. Pas de panique, vos données serveur sont saines. Merci de contacter l'administrateur du site en lui donnant l'erreur ci-dessous.

{this.state.error && this.state.error.toString()}
); } return this.props.children; } } // --- APP --- function App(props) { return ( ); } function HomeView({ navigate }) { const { user, userProfile, updateProfileData, login } = useAuth(); // États d'authentification const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [isRegistering, setIsRegistering] = useState(false); const [authError, setAuthError] = useState(""); // États de gestion de projet const [joinId, setJoinId] = useState(""); const [createName, setCreateName] = useState(""); const [template, setTemplate] = useState("blank"); const [templates, setTemplates] = useState([]); // État pour stocker les détails de "Mes Projets" const [myProjects, setMyProjects] = useState([]); const favorites = userProfile?.favorites || []; useEffect(() => { apiFetch(`/config`) .then(d => { if (d && d.templates) setTemplates(d.templates); }) .catch(err => console.error("Erreur chargement templates:", err)); if (user) { apiFetch(`/users/me/projects`) .then(data => setMyProjects(data)) .catch(err => console.error("Erreur chargement de mes projets:", err)); } }, [user]); const handleAuth = async () => { if (!username.trim() || !password.trim()) { setAuthError("Veuillez remplir tous les champs"); return; } try { const endpoint = isRegistering ? '/auth/register' : '/auth/login'; const data = await apiFetch(endpoint, { method: 'POST', body: JSON.stringify({ username: username.trim(), password }) }); login(data.uid, data.token); setAuthError(""); } catch (e) { setAuthError(e.message); } }; const joinProject = (id) => { if(!id.trim()) return; navigate('project', id.trim()); }; const createProject = async () => { if(!createName.trim() || !user) return; try { const data = await apiFetch(`/projects/create`, { method: 'POST', body: JSON.stringify({ name: createName, templateId: template, user }) }); navigate('admin', data.projectId); } catch(e) { alert("Erreur lors de la création : " + e.message); } }; const handleImportProject = async (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = async (evt) => { try { let parsedData; try { parsedData = JSON.parse(evt.target.result); } catch (parseError) { throw new Error("Le fichier importé n'est pas un JSON valide."); } const data = await apiFetch(`/projects/import`, { method: 'POST', body: JSON.stringify({ data: parsedData, user }) }); navigate('admin', data.projectId); } catch(err) { alert("Erreur lors de l'import : " + err.message); } }; reader.readAsText(file); e.target.value = null; }; // --- RENDU : Non connecté --- if (!user) { return (

Task.us

Gérez vos projets et votre temps, simplement.

{authError &&
{authError}
}
setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&handleAuth()} placeholder="Pseudo" style={{fontSize: '1.2rem', textAlign: 'center'}} />
setPassword(e.target.value)} onKeyDown={e=>e.key==='Enter'&&handleAuth()} placeholder="Mot de passe" style={{fontSize: '1.2rem', textAlign: 'center'}} />
{ e.preventDefault(); setIsRegistering(!isRegistering); setAuthError(""); }}> {isRegistering ? "Déjà un compte ? Se connecter" : "Nouveau ? Créer un compte"}
); } const currentHour = new Date().getHours(); const greeting = (currentHour >= 6 && currentHour <= 19) ? "Bonjour" : "Bonsoir"; const favoriteProjectsToDisplay = favorites.map(favId => { const projectDetails = myProjects.find(p => p.id === favId); return { id: favId, name: projectDetails ? projectDetails.name : "Projet Inconnu" }; }); const otherProjects = myProjects.filter(p => !favorites.includes(p.id)); return (

{greeting}, {userProfile?.username} !

Prêt à avancer sur vos projets ?

{}

Projets Favoris

{favoriteProjectsToDisplay.length > 0 ? favoriteProjectsToDisplay.map(project => (
{project.name}
ID: {project.id}
)) : (

Aucun projet en favoris.

)}
{}

Mes projets récents

{otherProjects.length > 0 ? (
{otherProjects.map(project => (
{project.name}
ID: {project.id}
))}
) : (

Aucun projet récent.

) }
{}
{}

Nouveau Projet

setCreateName(e.target.value)} onKeyDown={e=>e.key==='Enter'&&createProject()} placeholder="Ex: Refonte Site Web" />
{}

Rejoindre un Projet

setJoinId(e.target.value)} onKeyDown={e => e.key === 'Enter' && joinProject(joinId)} placeholder="Ex: un-super-projet" />
); } function TaskCard({ task, user, navigate, navigateView, assignTask, toggleInProgress, startFocus, completeTask, undoTask, toggleBlockTask, approveTask, rejectTask, locked, hideAssigneeBadge = false, hideActions = false, projectName = null, index = 0, totalTasks = 1, isPriority = false, onOpenDiscussion = null, onOpenSubtasks = null, openProfile = null, enableReview = false }) { const [assigneeProfile, setAssigneeProfile] = useState({}); // --- VARIABLES D'ÉTAT --- const isDone = task.status === 'done'; const isReview = task.status === 'review'; const isInProgress = task.status === 'in_progress'; const isFocused = task.activeBy; // --- STYLES & COULEURS --- const baseColor = task.isBlocked ? 'var(--danger)' : (isDone ? 'var(--success)' : (isReview ? 'var(--primary)' : (isInProgress ? 'var(--warning)' : 'var(--task-todo)'))); const dullness = totalTasks > 1 ? (index / (totalTasks - 1)) : 0; const opacityValue = Math.max(0.7, 1 - (dullness * 0.3)); const borderAndShadowColor = isDone || isReview || task.isBlocked ? baseColor : `color-mix(in srgb, ${baseColor} ${100 - Math.round(dullness * 80)}%, var(--gray-light))`; const cardStyle = { borderLeftColor: isPriority || isInProgress || isReview || task.isBlocked ? baseColor : borderAndShadowColor }; if (!isDone && !isReview && !isInProgress && !isPriority && !task.isBlocked && totalTasks > 1) { cardStyle.opacity = opacityValue; } // --- DONNÉES DÉRIVÉES --- const commentCount = task.comments ? task.comments.length : 0; const subs = task.subtasks || []; const subsDone = subs.filter(s => s.done).length; // --- LOGIQUE D'AFFICHAGE DES BOUTONS --- const showActions = !hideActions && !locked && user; const isAssignee = task.assignee === user; const canReview = isReview && !task.isBlocked && user && task.assignee !== user; const showAssignBtn = !isDone && !task.assignee && assignTask; const showBlockBtn = !isDone && isAssignee && toggleBlockTask; const showPlayBtn = !isDone && isAssignee && !task.isBlocked && !isInProgress && !isReview && toggleInProgress; const showActiveBtns = !isDone && isAssignee && !task.isBlocked && isInProgress; // Play, Focus, Complete const showUnassignBtn = !isDone && isAssignee && assignTask; const showUndoBtn = isDone && undoTask; useEffect(() => { if (!task.assignee) return; apiFetch(`/users/${task.assignee}/profile`) .then(d => { setAssigneeProfile(d); }) .catch(() => { }); }, [task.assignee]); return (
{}

{task.title || "Tâche sans titre"} {isDone && }

{task.description}

{}
{task.isBlocked && {task.blockReason || "Bloqué"} } {isReview && À valider } {projectName && { e.stopPropagation(); navigate('project', task._projectId); }}> {projectName} } { e.stopPropagation(); if(navigateView) navigateView('categories', task.category); }}> {task.category} {task.plannedCost > 0 && {task.plannedCost} € } {task.plannedMaterial && {task.materialQty || 1}x {task.plannedMaterial} } {task.timeSpent > 0 && {formatTime(task.timeSpent)} } {task.deadline && { e.stopPropagation(); if(navigateView) navigateView('gantt'); }}> {task.deadline} } {!hideAssigneeBadge && task.assignee && ( { e.stopPropagation(); if(openProfile) openProfile(task.assignee); }}> {assigneeProfile['username']} )} {subs.length > 0 && onOpenSubtasks && ( { e.stopPropagation(); onOpenSubtasks(task); }}> {subsDone}/{subs.length} )} {task.attachment && ( e.stopPropagation()}> Fichier )} {onOpenDiscussion && ( { e.stopPropagation(); onOpenDiscussion(task); }}> {commentCount > 0 && commentCount} )}
{} {showActions && (
{} {showAssignBtn && ( )} {} {isAssignee && ( <> {showBlockBtn && ( )} {showPlayBtn && ( )} {showActiveBtns && ( <> {startFocus && ( )} {completeTask && ( )} {toggleInProgress && ( )} )} {showUnassignBtn && ( )} )} {} {canReview && approveTask && rejectTask && ( <> )} {} {showUndoBtn && ( )}
)}
); } function GlobalUserView({ navigate, startGlobalFocus }) { const { user, userProfile } = useAuth(); const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const [discussionTask, setDiscussionTask] = useState(null); const [subtasksTask, setSubtasksTask] = useState(null); useEffect(() => { if (!user) return; apiFetch(`/users/${user}/tasks`) .then(d => { setTasks(d.tasks || []); setLoading(false); }) .catch(() => { setLoading(false); }); }, [user]); if (loading) return ; const activeTasks = tasks.filter(t => t.status !== 'done'); const doneTasks = tasks.filter(t => t.status === 'done'); const sortedActive = [...activeTasks].sort((a, b) => { return ( (new Date(a.deadline || 8.64e15) * (a._id || 0) + 1) - (new Date(b.deadline || 8.64e15) * (b._id || 0) + 1) ); }); const pinnedTask = sortedActive.length > 0 ? sortedActive[0] : null; const otherActiveTasks = sortedActive.slice(1); const startFocus = (task) => { if (startGlobalFocus) startGlobalFocus(task._projectId, task); }; const completeTask = async (taskId) => { const task = tasks.find(t => t.id === taskId); if (!task) return; setTasks(prev => prev.map(t => t.id === taskId ? { ...t, status: 'done', activeBy: null } : t)); await updateRemoteTask( task._projectId, task.id, { status: 'done', activeBy: null }, `a validé la tâche "${task.title}" depuis l'accueil`, user, userProfile ); }; const toggleBlockTask = async (taskId) => { const task = tasks.find(t => t.id === taskId); if (!task) return; let isBlocked = false; let reason = ''; if (!task.isBlocked) { reason = window.prompt("Raison du blocage ?"); if (!reason) return; isBlocked = true; } setTasks(prev => prev.map(t => t.id === taskId ? { ...t, isBlocked, blockReason: reason } : t)); await updateRemoteTask( task._projectId, task.id, { isBlocked, blockReason: reason }, `a ${isBlocked ? 'bloqué' : 'débloqué'} la tâche "${task.title}" depuis l'accueil`, user, userProfile ); }; // --- NOUVELLES FONCTIONS POUR LES BOUTONS --- const toggleInProgress = async (taskId) => { const task = tasks.find(t => t.id === taskId); if (!task) return; const newStatus = task.status === 'in_progress' ? 'todo' : 'in_progress'; const actionDesc = newStatus === 'in_progress' ? `a mis la tâche "${task.title}" en cours` : `a mis la tâche "${task.title}" en pause`; setTasks(prev => prev.map(t => t.id === taskId ? { ...t, status: newStatus } : t)); await updateRemoteTask(task._projectId, task.id, { status: newStatus }, actionDesc, user, userProfile); }; const assignTask = async (taskId, newAssignee) => { const task = tasks.find(t => t.id === taskId); if (!task) return; const actionDesc = newAssignee ? `s'est assigné la tâche "${task.title}"` : `s'est désassigné de la tâche "${task.title}"`; setTasks(prev => prev.map(t => t.id === taskId ? { ...t, assignee: newAssignee } : t)); await updateRemoteTask( task._projectId, task.id, { assignee: newAssignee }, actionDesc, user, userProfile ); }; const undoTask = async (task) => { setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: 'todo' } : t)); await updateRemoteTask( task._projectId, task.id, { status: 'todo' }, `a annulé la complétion de la tâche "${task.title}" depuis l'accueil`, user, userProfile ); }; const handleModalUpdate = async (taskId, projectId, updates, logMsg) => { setTasks(prev => prev.map(t => t.id === taskId ? { ...t, ...updates } : t)); await updateRemoteTask(projectId, taskId, updates, logMsg, user, userProfile); }; // --- ON INJECTE TOUT DANS LES PROPS --- const commonCardProps = { user, navigate, startFocus, completeTask, onOpenDiscussion: setDiscussionTask, onOpenSubtasks: setSubtasksTask }; return (

Mes tâches

{pinnedTask && (

Tâche Prioritaire

)}

Tâches en cours ({otherActiveTasks.length})

{otherActiveTasks.length === 0 ?

Aucune autre tâche en cours.

: (
{otherActiveTasks.map((t, idx) => ( ))}
)}
{doneTasks.length > 0 && (
Tâches terminées ({doneTasks.length})
{doneTasks.map((t, idx) => ( ))}
)} {discussionTask && ( setDiscussionTask(null)} projectUsers={[user]} onUpdateTask={(updates, logMsg) => { handleModalUpdate(discussionTask.id, discussionTask._projectId, updates, logMsg); setDiscussionTask({...discussionTask, ...updates}); }} /> )} {subtasksTask && ( setSubtasksTask(null)} onUpdateTask={(updates, logMsg) => { handleModalUpdate(subtasksTask.id, subtasksTask._projectId, updates, logMsg); setSubtasksTask({...subtasksTask, ...updates}); }} /> )}
); } function DailyCheckIn({ }) { // On récupère directement les données depuis le contexte global const { userProfile, updateProfileData } = useAuth(); const todayStr = new Date().toISOString().split('T')[0]; const hasCheckedIn = userProfile?.mood?.date === todayStr; const value = userProfile?.mood?.value; // Si l'utilisateur a déjà répondu aujourd'hui, on masque le composant // if (hasCheckedIn) return null; const handleMoodSelect = (emoji, label) => { updateProfileData('mood', { date: todayStr, value: emoji, text: label }); }; const style = { fontSize: '1rem', padding: '5px', borderRadius: '12px', transition: 'transform 0.2s' } return (

Comment te sens-tu aujourd'hui ?

); } function NotificationBell({ navigate }) { const [notifications, setNotifications] = useState([]); const [isOpen, setIsOpen] = useState(false); const knownIdsRef = useRef(new Set()); const { user } = useAuth(); const fetchNotifs = async () => { try { const data = await apiFetch('/users/me/notifications'); if (knownIdsRef.current.size > 0) { const newUnread = data.filter(n => !n.isRead && !knownIdsRef.current.has(n.id)); newUnread.forEach(n => { if (pushNotify) { pushNotify(`Nouveau sur ${n.projectName}`, n.message); } }); } const newKnownIds = new Set(data.map(n => n.id)); knownIdsRef.current = newKnownIds; setNotifications(data); } catch (e) { console.error("Erreur notifs:", e); } }; useEffect(() => { if (!user) return; fetchNotifs(); let ws = null; let reconnectTimeout = null; let isMounted = true; const connectWebSocket = () => { if (!isMounted) return; const wsBaseUrl = window.location.origin.replace('http', 'ws'); ws = new WebSocket(`${wsBaseUrl}/api/ws/user/${user}`); ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.event === 'new_notification') { fetchNotifs(); } } catch (e) {} }; ws.onerror = () => { if (ws) ws.close(); }; ws.onclose = () => { if (!isMounted) return; reconnectTimeout = setTimeout(connectWebSocket, 5000); // Reconnexion silencieuse }; }; connectWebSocket(); return () => { isMounted = false; if (reconnectTimeout) clearTimeout(reconnectTimeout); if (ws) { ws.onclose = null; ws.close(); } }; }, [user]); const unreadCount = notifications.filter(n => !n.isRead).length; const handleNotificationClick = async (notif) => { if (!notif.isRead) { try { await apiFetch(`/users/me/notifications/${notif.id}/read`, { method: 'PATCH' }); setNotifications(notifications.map(n => n.id === notif.id ? { ...n, isRead: true } : n)); } catch (e) { console.error(e); } } navigate('project', notif.projectId); }; const markAllAsRead = async () => { Promise.all(notifications.map(notif => apiFetch(`/users/me/notifications/${notif.id}/read`, { method: 'PATCH' }))) .then(notifs => {setNotifications(notifications.map(n => ({ ...n, isRead: true })));}); }; return (
{isOpen && (

Notifications

{unreadCount > 0 && ( )}
{notifications.length === 0 ? (
Aucune notification.
) : ( notifications.map(n => (
handleNotificationClick(n)} style={{ padding: '12px 15px', borderBottom: '1px solid var(--border-color)', background: n.isRead ? 'transparent' : 'color-mix(in srgb, var(--primary) 10%, transparent)', cursor: 'pointer', transition: 'background 0.2s' }} >
{n.projectName}
{n.message}
{new Date(n.date).toLocaleString('fr')}
)) )}
)}
); } const createAuditLogs = (user, actionDesc, existingLogs = []) => { if (!actionDesc) return existingLogs; return [ { id: Date.now(), user: user || "Anonyme", action: actionDesc, date: new Date().toISOString() }, ...existingLogs ].slice(0, 10); }; const generateRecurrentTask = (task, updates) => { if (updates.status === 'done' && task.status !== 'done' && task.recurrence && task.recurrence !== 'none') { const nextDate = new Date(task.deadline || Date.now()); if (task.recurrence === 'daily') nextDate.setDate(nextDate.getDate() + 1); if (task.recurrence === 'weekly') nextDate.setDate(nextDate.getDate() + 7); if (task.recurrence === 'monthly') nextDate.setMonth(nextDate.getMonth() + 1); const newTask = { ...task, id: Date.now() + Math.random(), status: 'todo', activeBy: null, timeSpent: 0, timeLogs: [], comments: [], attachment: '', deadline: nextDate.toISOString().split('T')[0], subtasks: (task.subtasks || []).map(s => ({...s, done: false})) }; return { newTask, nextDate }; } return { newTask: null, nextDate: null }; }; const isStageLockedLogic = (order, stages = [], tasks = []) => { for (let s of stages.filter(s => s.order < order)) { const stageTasks = tasks.filter(t => t.stageId === s.id); if (stageTasks.length === 0 || stageTasks.some(t => t.status !== 'done')) return true; } return false; }; function useProjectActions({ data, setData, user, userProfile, isOnline, saveData, startGlobalFocus, projectId }) { const enableReview = data?.features?.enableReview || false; const updateTask = async (taskId, updates, actionDesc = null) => { if (!data) return; const task = data.tasks.find(t => t.id === taskId); if (!task) return; const previousData = data; let newTasks = data.tasks.map(t => t.id === taskId ? { ...t, ...updates } : t); let finalActionDesc = actionDesc; // Gestion des tâches récurrentes const { newTask, nextDate } = generateRecurrentTask(task, updates); if (newTask) { newTasks.push(newTask); // En Optimistic UI if (finalActionDesc) finalActionDesc += ` (Occurrence suivante au ${nextDate.toLocaleDateString()})`; } setData({ ...data, tasks: newTasks }); try { // Mise à jour de la tâche actuelle await apiFetch(`/projects/${projectId}/tasks/${taskId}`, { method: 'PATCH', body: JSON.stringify({ ...updates, logMsg: finalActionDesc }) }); // Si on doit créer une tâche récurrente, on appelle la bonne route POST if (newTask) { await apiFetch(`/projects/${projectId}/tasks`, { method: 'POST', body: JSON.stringify({ title: newTask.title, stageId: newTask.stageId, category: newTask.category, logMsg: "Génération automatique de la tâche récurrente" }) }); // Idéalement, on forcerait un rechargement ici pour récupérer le VRAI ID SQL de la nouvelle tâche } } catch (e) { setData(previousData); // Rollback } }; const assignTask = async (taskId, newAssignee) => { const task = data?.tasks.find(t => t.id === taskId); const actionDesc = newAssignee ? `s'est assigné la tâche "${task?.title}"` : `s'est désassigné de la tâche "${task?.title}"`; await updateTask(taskId, { assignee: newAssignee }, actionDesc); }; const toggleInProgress = async (taskId) => { const task = data.tasks.find(t => t.id === taskId); const newStatus = task.status === 'in_progress' ? 'todo' : 'in_progress'; const actionDesc = newStatus === 'in_progress' ? `a mis la tâche "${task.title}" en cours` : `a mis la tâche "${task.title}" en pause`; await updateTask(taskId, { status: newStatus }, actionDesc); }; const completeTask = async (taskId) => { const task = data.tasks.find(t => t.id === taskId); const newStatus = enableReview ? 'review' : 'done'; const actionDesc = enableReview ? `a envoyé la tâche "${task.title}" en validation` : `a terminé la tâche "${task.title}"`; await updateTask(taskId, { status: newStatus, isBlocked: false }, actionDesc); }; const toggleBlockTask = (taskId) => { const task = data.tasks.find(t => t.id === taskId); if (task.isBlocked) { updateTask(taskId, { isBlocked: false, blockReason: '' }, `a débloqué la tâche "${task.title}"`); } else { const reason = window.prompt("Raison du blocage ?"); if (reason) updateTask(taskId, { isBlocked: true, blockReason: reason }, `a bloqué la tâche "${task.title}"`); } }; // ... autres actions rapides qui utilisent updateTask ... const approveTask = (task) => updateTask(task.id, { status: 'done' }, `a approuvé et finalisé la tâche "${task.title}"`); const rejectTask = (task) => updateTask(task.id, { status: 'in_progress' }, `a rejeté la tâche "${task.title}" (Retour en cours)`); const undoTask = (task) => updateTask(task.id, { status: 'todo' }, `a annulé la complétion de la tâche "${task.title}"`); const addExpense = async (label, amount) => { const previousData = data; const tempExpense = { id: Date.now(), label, amount, user, date: new Date().toISOString()}; setData({ ...data, expenses: [...data.expenses, tempExpense] }); try { await apiFetch(`/projects/${projectId}/expenses`, { method: 'POST', body: JSON.stringify({ label, amount, logMsg: `a ajouté une dépense : ${label} (${amount}€)` }) }); } catch (e) { setData(previousData); } }; const toggleSubtask = async (subtaskId, newDoneState, taskTitle) => { const previousData = data; const updatedTasks = data.tasks.map(t => ({ ...t, subtasks: t.subtasks.map(s => s.id === subtaskId ? { ...s, done: newDoneState } : s) })); setData({ ...data, tasks: updatedTasks }); try { await apiFetch(`/projects/${projectId}/subtasks/${subtaskId}`, { method: 'PATCH', body: JSON.stringify({ done: newDoneState, logMsg: newDoneState ? `a coché une sous-tâche de "${taskTitle}"` : "" }) }); } catch (e) { setData(previousData); } }; const updateProjectData = async (newData, actionDesc = null) => { // Garde cette fonction uniquement pour les gros réglages (nouveau nom de projet, etc.) const previousData = data; const newLogs = createAuditLogs(user, actionDesc, newData.auditLogs); const optimisticData = { ...newData, auditLogs: newLogs }; if (setData) setData(optimisticData); const result = await saveData({newData: optimisticData}); if (!result?.success && isOnline) { if (setData) setData(previousData); alert(result?.error || "Erreur de sauvegarde."); } }; const startFocus = (task) => { if (startGlobalFocus) startGlobalFocus(projectId, task); }; const addEvent = async (title, dateIso) => { const previousData = data; const tempEvent = { id: Date.now(), title, date: dateIso }; // Optimistic UI setData({ ...data, events: [...(data.events || []), tempEvent] }); try { await apiFetch(`/projects/${projectId}/events`, { method: 'POST', body: JSON.stringify({ title, date: dateIso, logMsg: `a ajouté un jalon : ${title}` }) }); } catch (e) { setData(previousData); } }; const deleteEvent = async (eventId) => { const previousData = data; setData({ ...data, events: (data.events || []).filter(e => e.id !== eventId) }); try { await apiFetch(`/projects/${projectId}/events/${eventId}`, { method: 'DELETE' }); } catch (e) { setData(previousData); } }; return { updateProjectData, updateTask, assignTask, toggleInProgress, startFocus, completeTask, approveTask, rejectTask, undoTask, toggleBlockTask, addExpense, toggleSubtask, addEvent, deleteEvent }; } const NavigationMenu = ({ data, currentView, onViewChange }) => { const navItems = [ { id: 'stages', icon: , label: 'Étapes' }, { id: 'kanban', icon: , label: 'Kanban' }, { id: 'categories', icon: , label: 'Catégories' }, { id: 'gantt', icon: , label: 'Planning' }, { id: 'team', icon: , label: 'Équipe' }, { id: 'budget', icon: , label: 'Budget', disable: !data.features?.enableBudget}, { id: 'inventory', icon: , label: 'Matériel', disable: !data.features?.enableInventory}, { id: 'stats', icon: , label: 'Stats & Temps' }, { id: 'history', icon: , label: 'Historique' } ]; return (
{navItems.map(item => { if (item.disable) return; return ( ) })}
); }; function ProjectViewer({ projectId, navigate, startGlobalFocus, useProjectData }) { const { user, userProfile, updateProfileData } = useAuth(); const [pwdAttempt, setPwdAttempt] = useState(""); const defaultView = userProfile?.savedViews?.[projectId] || 'stages'; const [view, setViewLocal] = useState(defaultView); const [discussionTask, setDiscussionTask] = useState(null); const [subtasksTask, setSubtasksTask] = useState(null); const [profileUser, setProfileUser] = useState(null); const savedPwds = userProfile?.projectPwds || {}; const savePwd = (id, pwd) => { updateProfileData('projectPwds', { ...savedPwds, [id]: pwd }); }; const { data, setData, error, saveData, needsPwd, fetchProject, isOnline } = useProjectData(projectId, false, savedPwds, savePwd); const actions = useProjectActions({ data, setData, user, userProfile, isOnline, saveData, startGlobalFocus, projectId }); if (!data && !error && !needsPwd) return ; if (error) return (

Erreur

{error}

); if (needsPwd) return (

Projet Protégé

Ce projet nécessite un mot de passe pour être visualisé.

setPwdAttempt(e.target.value)} onKeyDown={e => e.key === 'Enter' && fetchProject(pwdAttempt)} placeholder="Mot de passe" className="mb-1" />
); const setView = (v) => { setViewLocal(v); if (updateProfileData && userProfile) { updateProfileData('savedViews', { ...(userProfile.savedViews || {}), [projectId]: v }); } }; const isFav = (userProfile?.favorites || []).includes(projectId); const toggleFav = () => { const favs = userProfile?.favorites || []; if (isFav) updateProfileData('favorites', favs.filter(id => id !== projectId)); else updateProfileData('favorites', [...favs, projectId]); }; const projectUsers = Array.from(new Set((data.tasks||[]).map(t => t.assignee).filter(Boolean))); const focusUsers = Array.from(new Set((data.tasks||[]).filter(t => t.activeBy).map(t => t.activeBy))); const commonViewProps = { data, user, navigate, navigateView: setView, userProfile, openProfile: setProfileUser, ...actions }; return (
{!isOnline &&
Mode Hors-Ligne. Changements limités ou en attente.
}

{user && } {data.name}

{data.description &&

{data.description}

}
{view === 'stages' && isStageLockedLogic(order, data.stages, data.tasks)} onOpenDiscussion={setDiscussionTask} onOpenSubtasks={setSubtasksTask} /> } {view === 'kanban' && } {view === 'categories' && } {view === 'gantt' && } {view === 'team' && } {view === 'budget' && } {view === 'inventory' && } {view === 'stats' && } {view === 'history' && } {discussionTask && ( setDiscussionTask(null)} projectUsers={projectUsers} onUpdateTask={(updates, logMsg) => { actions.updateTask(discussionTask.id, updates, logMsg); setDiscussionTask({...discussionTask, ...updates}); }} /> )} {subtasksTask && ( setSubtasksTask(null)} toggleSubtask={actions.toggleSubtask} onUpdateTask={(updates, logMsg) => { actions.updateTask(subtasksTask.id, updates, logMsg); setSubtasksTask({...subtasksTask, ...updates}); }} /> )} {profileUser && ( setProfileUser(null)} /> )} {focusUsers.length > 0 && (
{focusUsers.length} en ligne
)}
); } const exportProjectAsJSON = (data) => { const jsonStr = JSON.stringify(data, null, 2); const blob = new Blob([jsonStr], { type: "application/json" }); const url = URL.createObjectURL(blob); const downloadAnchorNode = document.createElement('a'); downloadAnchorNode.setAttribute("href", url); downloadAnchorNode.setAttribute("download", `Taskus_Export_${data.name.replace(/\s+/g, '_')}.json`); document.body.appendChild(downloadAnchorNode); downloadAnchorNode.click(); downloadAnchorNode.remove(); URL.revokeObjectURL(url); // Nettoyage de la mémoire }; const exportProjectAsPDF = (data) => { const printWindow = window.open('', '', 'width=900,height=700'); const totalBudget = data.budgetLimit || 0; const expenses = data.expenses || []; const taskExpenses = (data.tasks||[]).filter(t => t.status === 'done' && t.plannedCost > 0).map(t => ({ label: `[Tâche validée] ${t.title}`, amount: t.plannedCost, date: t.timeLogs?.[0]?.date || '' })); const allExpenses = [...expenses, ...taskExpenses]; const totalSpent = allExpenses.reduce((acc, e) => acc + e.amount, 0); const plannedTaskExpenses = (data.tasks||[]).filter(t => t.status !== 'done' && t.plannedCost > 0).map(t => ({ label: `[Tâche en attente] ${t.title}`, amount: t.plannedCost })); const totalPlanned = plannedTaskExpenses.reduce((acc, e) => acc + e.amount, 0); let html = ` Rapport Financier - ${data.name}

Rapport Financier : ${data.name}

Généré le ${new Date().toLocaleString()}
Budget Global
${totalBudget > 0 ? totalBudget + ' €' : 'Non défini'}
Total Dépensé (Validé)
${totalSpent} €
Coûts Futurs (Prévus)
${totalPlanned} €

Dépenses Validées

${allExpenses.length === 0 ? '' : ''} ${allExpenses.map(e => ``).join('')}
Date / RéférenceDescriptionMontant
Aucune dépense enregistrée.
${e.date ? new Date(e.date).toLocaleDateString("fr") : 'N/A'}${e.label}${e.amount} €
${plannedTaskExpenses.length > 0 ? `

Dépenses Futures (Selon tâches en attente)

${plannedTaskExpenses.map(e => ``).join('')}
Description de la tâcheMontant Estimé
${e.label}${e.amount} €
` : ''} `; printWindow.document.write(html); printWindow.document.close(); setTimeout(() => { printWindow.print(); }, 500); }; function useAdminActions({ data, updateData }) { const moveStage = (idx, direction) => { const sortedStages = [...(data.stages||[])].sort((a, b) => a.order - b.order); if (idx + direction < 0 || idx + direction >= sortedStages.length) return; const tempOrder = sortedStages[idx].order; sortedStages[idx].order = sortedStages[idx + direction].order; sortedStages[idx + direction].order = tempOrder; updateData({ ...data, stages: sortedStages }); }; const addTaskToStage = (stageId) => { const fallbackCat = data.categories && data.categories.length > 0 ? data.categories[0] : "Général"; updateData({ ...data, tasks: [ ...(data.tasks||[]), { id: Date.now(), stageId: stageId, title: "", description: "", attachment: "", category: fallbackCat, importance: 1, deadline: "", assignee: null, status: "todo", activeBy: null, comments: [], subtasks: [], recurrence: 'none', isBlocked: false, blockReason: '', plannedCost: 0, plannedMaterial: "", materialQty: 1, timeSpent: 0, timeLogs: [] } ] }); }; const moveTaskWithinStage = (task, direction) => { const stageTasks = (data.tasks||[]).filter(t => t.stageId === task.stageId).sort((a,b) => a.importance - b.importance); const idx = stageTasks.findIndex(t => t.id === task.id); const targetIdx = idx + direction; if (targetIdx < 0 || targetIdx >= stageTasks.length) return; const targetTask = stageTasks[targetIdx]; const newTasks = data.tasks.map(t => { if(t.id === task.id) return {...t, importance: targetTask.importance}; if(t.id === targetTask.id) return {...t, importance: task.importance}; return t; }); updateData({...data, tasks: newTasks}); }; const deleteGlobalComment = (taskId, commentId) => { if(window.confirm("Supprimer ce message définitivement ?")) { const newTasks = data.tasks.map(t => t.id === taskId ? {...t, comments: t.comments.filter(c => c.id !== commentId)} : t); updateData({...data, tasks: newTasks}); } }; const handleFileUpload = async (event, taskId) => { const file = event.target.files[0]; if (!file) return; try { const formData = new FormData(); formData.append("file", file); const token = localStorage.getItem('jwt_token'); const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; // Attention : API_URL doit être importé ou accessible ici const res = await fetch(`${API_URL}/upload`, { method: 'POST', body: formData, headers }); if(!res.ok) throw new Error("Échec upload"); const responseData = await res.json(); updateData({...data, tasks: data.tasks.map(t => t.id === taskId ? { ...t, attachment: responseData.url } : t)}); } catch(e) { alert("Erreur upload: " + e.message); } }; return { moveStage, addTaskToStage, moveTaskWithinStage, deleteGlobalComment, handleFileUpload }; } function ProjectAdminSettings({ data, projectId, updateProjectData, editPwd, navigate }) { const { userProfile, updateProfileData } = useAuth(); const [slug, setSlug] = useState(data.slug || projectId); const [newAdminPwd, setNewAdminPwd] = useState(""); const [confirmAdminPwd, setConfirmAdminPwd] = useState(""); const [newPublicPwd, setNewPublicPwd] = useState(""); const [confirmPublicPwd, setConfirmPublicPwd] = useState(""); const [status, setStatus] = useState({ type: '', msg: '' }); const handleSaveAdminSettings = async () => { setStatus({ type: '', msg: '' }); if (newAdminPwd || confirmAdminPwd) { if (newAdminPwd !== confirmAdminPwd) { return setStatus({ type: 'error', msg: "Les mots de passe Admin ne correspondent pas." }); } if (newAdminPwd.length < 6) { return setStatus({ type: 'error', msg: "Le mot de passe Admin doit faire au moins 6 caractères." }); } } if (newPublicPwd || confirmPublicPwd) { if (newPublicPwd !== confirmPublicPwd) { return setStatus({ type: 'error', msg: "Les mots de passe Public ne correspondent pas." }); } if (newPublicPwd.length < 6) { return setStatus({ type: 'error', msg: "Le mot de passe Public doit faire au moins 6 caractères." }); } } // Formatage du slug (URL friendly : minuscules, tirets au lieu d'espaces) const formattedSlug = slug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, ''); try { await updateProjectData({ newData: { ...data, slug: formattedSlug ? formattedSlug : null }, newPassword: newAdminPwd ? newAdminPwd : null, newPublicPassword: newPublicPwd ? newPublicPwd : null }); setStatus({ type: 'success', msg: "Paramètres enregistrés !" }); setNewAdminPwd(""); setNewPublicPwd(""); setConfirmAdminPwd(""); setConfirmPublicPwd(""); setSlug(formattedSlug); } catch (error) { setStatus({ type: 'error', msg: error.message || "Erreur lors de la sauvegarde." }); } }; const handleDeleteProject = async () => { if (window.confirm("⚠️ ATTENTION ⚠️\nVoulez-vous vraiment supprimer définitivement ce projet ? Cette action est irréversible.")) { try { const token = localStorage.getItem('jwt_token'); const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; const response = await fetch(`${API_URL}/projects/${projectId}?pwd=${encodeURIComponent(editPwd)}`, { method: 'DELETE', headers }); if (!response.ok) throw new Error("Erreur de suppression"); const knownProjects = userProfile?.knownProjects || []; const favs = userProfile?.favorites || []; updateProfileData('knownProjects', knownProjects.filter(id => id !== projectId)); updateProfileData('favorites', favs.filter(id => id !== projectId)); alert("Projet supprimé !"); navigate('home'); } catch (e) { alert("Impossible de supprimer : Vérifiez le mot de passe"); } } }; const style = { width: '300px', background: 'var(--bg-color)', padding: '15px', borderRadius: '8px' }; return (

Paramètres Sensibles du Projet

{}

URL Personnalisée

Choisissez un texte facile à retenir pour le lien du projet.

/#projects/ setSlug(e.target.value)} style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, flex: 1 }} placeholder="mon-super-projet" />
{}

Changer le mot de passe Admin

Laissez vide si vous ne souhaitez pas le changer.

setNewAdminPwd(e.target.value)} />
setConfirmAdminPwd(e.target.value)} />
{}

Changer le mot de passe Public

Laissez vide si vous ne souhaitez pas le changer.

setNewPublicPwd(e.target.value)} />
setConfirmPublicPwd(e.target.value)} />
{status.msg && (
{status.msg}
)}
); } // --- COMPOSANT PRINCIPAL --- function AdminView({ projectId, navigate }) { const { user, userProfile, updateProfileData } = useAuth(); const savedPwds = userProfile?.projectPwds || {}; const savePwd = (id, pwd) => { updateProfileData('projectPwds', { ...savedPwds, [id]: pwd }); }; const { data, setData, projectPwd, needsPwd, error, isAdmin, fetchProject, saveData } = useProjectData(projectId, false, savedPwds, savePwd); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [adminError, setAdminError] = useState(""); const [editPwd, setEditPwd] = useState(""); const [newSubtext, setNewSubtext] = useState({}); const [newEventTitle, setNewEventTitle] = useState(""); const [newEventDate, setNewEventDate] = useState(""); useEffect(() => { setEditPwd(projectPwd); }, [projectPwd]); const updateData = (newData) => { setData(newData); setHasUnsavedChanges(true); }; const { moveStage, addTaskToStage, moveTaskWithinStage, deleteGlobalComment, handleFileUpload } = useAdminActions({ data, updateData }); if (needsPwd || !isAdmin || !user) return (

Projet Protégé

Les paramètres d'administration nécessitent le mot de passe administrateur.

{error &&

{error}

} setEditPwd(e.target.value)} onKeyDown={e => e.key === 'Enter' && fetchProject(editPwd)} placeholder="Mot de passe" className="mb-1" />
); if (!data) return ; const handleSaveAll = async () => { const result = await saveData({newData: data, editPwd}); if (!result.success) { setAdminError(result.error); setTimeout(() => setAdminError(""), 10 * 1000); } else { setHasUnsavedChanges(false); } }; const addSubtask = (taskId) => { const text = newSubtext[taskId]; if(text && text.trim()) { const newTasks = data.tasks.map(t => t.id === taskId ? {...t, subtasks: [...(t.subtasks||[]), {id: Date.now(), text: text.trim(), done: false}]} : t); updateData({...data, tasks: newTasks}); setNewSubtext({...newSubtext, [taskId]: ''}); } }; const toggleFeature = (feature, checked) => { updateData({ ...data, features: { ...(data.features || {}), [feature]: checked } }) }; const publicLink = window.location.origin + window.location.pathname + "#project/" + (data.slug || projectId); const sortedStages = [...(data.stages||[])].sort((a, b) => a.order - b.order); const missingGlobals = GLOBAL_CATEGORIES.filter(c => !(data.categories||[]).includes(c)); const allComments = (data.tasks||[]) .flatMap(t => (t.comments||[]) .map(c => ({...c, taskId: t.id, taskTitle: t.title}))) .sort((a,b) => new Date(b.date) - new Date(a.date)); const renderAdminTask = (task, stageTasks, tIdx) => { const opacity = Math.max(0.2, 1 - (tIdx / Math.max(stageTasks.length, 1)) * 0.8); const isReview = task.status === 'review'; const baseColor = task.isBlocked ? 'var(--danger)' : ( task.status === 'done' ? 'var(--success)' : ( isReview ? 'var(--primary)' : ( task.status === 'in_progress' ? 'var(--warning)' : 'var(--task-todo)' ))); let dateWarningMsg = null; const currentStage = (data.stages||[]).find(s => s.id === task.stageId); if (currentStage && task.deadline) { const tDate = new Date(task.deadline); const prevStageIds = (data.stages||[]).filter(s => s.order < currentStage.order).map(s => s.id); const nextStageIds = (data.stages||[]).filter(s => s.order > currentStage.order).map(s => s.id); const prevDates = (data.tasks||[]).filter(t => prevStageIds.includes(t.stageId) && t.deadline).map(t => new Date(t.deadline)); const nextDates = (data.tasks||[]).filter(t => nextStageIds.includes(t.stageId) && t.deadline).map(t => new Date(t.deadline)); if (prevDates.length > 0) { const maxPrev = new Date(Math.max(...prevDates)); if (tDate < maxPrev) dateWarningMsg = "⚠️ Antérieure à la fin de l'étape précédente."; } if (!dateWarningMsg && nextDates.length > 0) { const minNext = new Date(Math.min(...nextDates)); if (tDate > minNext) dateWarningMsg = "⚠️ Postérieure au début de l'étape suivante."; } } return (
updateData({...data, tasks: data.tasks.map(t => t.id === task.id ? { ...t, title: e.target.value } : t)})} placeholder="Titre de la tâche..." style={{fontWeight:'bold'}} />
updateData({...data, maxTasksPerUser: parseInt(e.target.value) || 0})} />
updateData({...data, budgetLimit: parseInt(e.target.value) || 0})} placeholder="0 = illimité" />
updateData({...data, webhookUrl: e.target.value})} placeholder="URL Discord (https://discord.com/api/webhooks/...)" /> updateData({...data, matrixWebhookUrl: e.target.value})} placeholder="URL Matrix (Hookshot / Bridge...)" />
Événements qui déclenchent les Webhooks :
{(data.categories || []).map(c => {c} ) }

Jalons

Les jalons s'affichent sous forme de losanges sur le planning.

{}
setNewEventTitle(e.target.value)} style={{flex: 1, minWidth: '200px'}} /> setNewEventDate(e.target.value)} style={{flex: 1, minWidth: '200px'}} />
{} {(data.events || []).length === 0 ? (

Aucun jalon défini pour le moment.

) : (
{(data.events || []).sort((a,b) => new Date(a.date) - new Date(b.date)).map(evt => (
♦ {evt.title}
Prévu le {new Date(evt.date).toLocaleDateString('fr')}
))}
)}

Étapes

{sortedStages.map((stage, sIdx) => { const stageTasks = (data.tasks||[]).filter(t => t.stageId === stage.id).sort((a,b) => a.importance - b.importance); const activeTasks = stageTasks.filter(t => t.status !== 'done'); const doneTasks = stageTasks.filter(t => t.status === 'done'); return (
updateData({...data, stages: data.stages.map(s => s.id === stage.id ? { ...s, name: e.target.value } : s)})} style={{fontWeight: 'bold', fontSize: '1.1rem'}} /> updateData({...data, stages: data.stages.map(s => s.id === stage.id ? { ...s, description: e.target.value } : s)})} placeholder="Description de l'étape" />
{activeTasks.map( (task) => renderAdminTask(task, stageTasks, stageTasks.findIndex(t => t.id === task.id))) } {doneTasks.length > 0 && (
Tâches terminées ({doneTasks.length})
{doneTasks.map((task) => renderAdminTask(task, stageTasks, stageTasks.findIndex(t => t.id === task.id)))}
)}
); })}

Modération des discussions

{allComments.length === 0 ?

Aucun message sur ce projet.

: (
{allComments.map(c => (
Dans la tâche : {c.taskTitle} - par {c.author} le {new Date(c.date).toLocaleString()}
{c.text}
))}
)}
); } function BudgetView({ data, user, updateProjectData, addExpense }) { const [label, setLabel] = useState(""); const [amount, setAmount] = useState(""); const manualExpenses = data.expenses || []; const tasks = data.tasks || []; const budgetLimit = data.budgetLimit || 0; // Dépenses Actuelles (Manuelles + Tâches terminées) const doneTaskExpenses = tasks .filter(t => t.status === 'done' && t.plannedCost > 0) .map(t => ({ id: `task-${t.id}`, label: `[Tâche] ${t.title}`, amount: t.plannedCost, isTask: true })); const actualExpenses = [...manualExpenses, ...doneTaskExpenses]; const totalActualSpent = actualExpenses.reduce((acc, exp) => acc + exp.amount, 0); // Dépenses Prévues (Tâches en attente) const plannedTaskExpenses = tasks .filter(t => t.status !== 'done' && t.plannedCost > 0) .map(t => ({ id: `task-p-${t.id}`, label: `[Tâche] ${t.title}`, amount: t.plannedCost, isTask: true })); const totalPlannedSpent = plannedTaskExpenses.reduce((acc, exp) => acc + exp.amount, 0); // Pourcentages pour la jauge (si budget défini) const spentPercent = budgetLimit > 0 ? Math.min(100, (totalActualSpent / budgetLimit) * 100) : 0; const plannedPercent = budgetLimit > 0 ? Math.min(100 - spentPercent, (totalPlannedSpent / budgetLimit) * 100) : 0; const handleAdd = () => { if (!label || !amount) return; // On appelle juste la fonction du Boss avec les données ! addExpense(label, parseFloat(amount)); setLabel(""); setAmount(""); }; const handleDeleteExpense = (expId, expLabel) => { if (window.confirm(`Supprimer la dépense "${expLabel}" ?`)) { const newExpenses = manualExpenses.filter(e => e.id !== expId); updateProjectData( { ...data, expenses: newExpenses }, `a supprimé la dépense : ${expLabel}` ); } }; return (

Gestion du Budget

{}
0 ? '15px' : '0'}}>
0 && totalActualSpent > budgetLimit ? 'var(--danger)' : 'var(--success)'}}> Total Dépensé : {totalActualSpent} €
Reste Prévu : {totalPlannedSpent} €
{budgetLimit > 0 && (
Budget initial alloué : {budgetLimit} €
)}
{} {budgetLimit > 0 && (
budgetLimit ? 'var(--danger)' : 'var(--success)', transition: 'width 0.3s ease'}} title={`Dépensé: ${totalActualSpent}€`} />
)}
{} {user && (
setLabel(e.target.value)} style={{flex: 2, minWidth: '200px'}} /> {} setAmount(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} style={{flex: 1, minWidth: '100px'}} /> {}
)} {} {actualExpenses.length > 0 &&

Acquis / Dépensé

} {actualExpenses.length > 0 && (
{actualExpenses.map(exp => (
{exp.label} {exp.isTask && Par tâche terminée} {!exp.isTask && exp.user && par {exp.user}}
{exp.amount} € {} {!exp.isTask && user && ( )}
))}
)} {} {plannedTaskExpenses.length > 0 &&

Futurs Coûts Prévus (Tâches non finies)

} {plannedTaskExpenses.length > 0 && (
{plannedTaskExpenses.map(exp => (
{exp.label}
{exp.amount} €
))}
)}
); } function CategoriesView({ data, user, navigate, navigateView, assignTask, toggleInProgress, startFocus, completeTask, undoTask, toggleBlockTask, approveTask, rejectTask, onOpenDiscussion, onOpenSubtasks, openProfile }) { const props = { user, navigate, navigateView, assignTask, toggleInProgress, startFocus, completeTask, undoTask, toggleBlockTask, approveTask, rejectTask, onOpenDiscussion, onOpenSubtasks, openProfile } const cats = data.categories && data.categories.length > 0 ? data.categories : GLOBAL_CATEGORIES; const enableReview = data.features?.enableReview || false; const columns = cats.map(c => ({ id: c, title: c, tasks: (data.tasks||[]).filter(t => t.category === c) })); const otherTasks = (data.tasks||[]).filter(t => !cats.includes(t.category)); if (otherTasks.length > 0) columns.push({ id: 'others', title: 'Autres', tasks: otherTasks }); return (
{columns.map(col => { if(col.tasks.length === 0) return null; return (

{col.title} ({col.tasks.length})

{col.tasks.map((task) => )}
)})}
); } function GanttView({ data, user, navigate, navigateView, assignTask, toggleInProgress, startFocus, completeTask, undoTask, toggleBlockTask, approveTask, rejectTask, onOpenDiscussion, onOpenSubtasks, openProfile }) { const [activeEvent, setActiveEvent] = useState(null); const [selectedTask, setSelectedTask] = useState(null); const [isMobile, setIsMobile] = useState(window.innerWidth <= 768); const enableReview = data.features?.enableReview || false; useEffect(() => { const handleResize = () => setIsMobile(window.innerWidth <= 768); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); const datedTasks = (data.tasks||[]).filter(t => t.deadline).sort((a,b) => new Date(a.deadline) - new Date(b.deadline)); const events = data.events || []; if (datedTasks.length === 0 && events.length === 0) return (
Aucune tâche ni événement n'a de date définie pour le moment.
); const today = new Date().getTime(); const allDates = [ today, ...datedTasks.map(t => new Date(t.deadline).getTime()), ...events.map(e => new Date(e.date).getTime()) ]; const minDate = Math.min(...allDates); const maxDate = Math.max(...allDates); const range = maxDate - minDate; let todayPercent = 50; if (range > 0) todayPercent = (((today - minDate) / range) * 100); todayPercent = Math.max(5, Math.min(95, 5 + (todayPercent * 0.9))); const activeCategories = [...new Set(datedTasks.map(t => t.category))]; if(activeCategories.length === 0) activeCategories.push("Général"); const stagesWithBounds = (data.stages||[]).map(s => { const stageTasks = datedTasks.filter(t => t.stageId === s.id); if (stageTasks.length === 0) return null; const minT = new Date(stageTasks[0].deadline).getTime(); const maxT = new Date(stageTasks[stageTasks.length-1].deadline).getTime(); const startPct = range === 0 ? 50 : 5 + (((minT - minDate) / range) * 90); const endPct = range === 0 ? 50 : 5 + (((maxT - minDate) / range) * 90); return { ...s, startPct, endPct }; }).filter(Boolean); const handleCloseModalAnd = (actionStr, arg) => { const actions = { assign: assignTask, toggleProgress: toggleInProgress, focus: startFocus, complete: completeTask, approve: approveTask, reject: rejectTask, discussion: onOpenDiscussion, subtasks: onOpenSubtasks }; setSelectedTask(null); if (actions[actionStr]) actions[actionStr](arg); }; const taskActions = { user, navigate, navigateView, openProfile, undoTask, toggleBlockTask, enableReview, assignTask: (tId, u) => handleCloseModalAnd('assign', tId), toggleInProgress: (t) => handleCloseModalAnd('toggleProgress', t), startFocus: (t) => handleCloseModalAnd('focus', t), completeTask: (t) => handleCloseModalAnd('complete', t), approveTask: (t) => handleCloseModalAnd('approve', t), rejectTask: (t) => handleCloseModalAnd('reject', t), onOpenDiscussion: (t) => handleCloseModalAnd('discussion', t), onOpenSubtasks: (t) => handleCloseModalAnd('subtasks', t) }; const mobileMinHeight = Math.max(800, activeCategories.length * 150) + 'px'; const writingMode = isMobile ? 'vertical-rl' : 'horizontal-tb'; return (
Début : {new Date(minDate).toLocaleDateString("fr")} Fin : {new Date(maxDate).toLocaleDateString("fr")}
{activeCategories.map(cat => (
{cat}
))}
{stagesWithBounds.map(s => (
Étape {s.order}
))} {}
{} {events.map(evt => { const evtTime = new Date(evt.date).getTime(); const percent = range === 0 ? 50 : 5 + (((evtTime - minDate) / range) * 90); return (
{}
{ e.stopPropagation(); setActiveEvent(evt); }} style={{ position: 'absolute', [isMobile ? 'left' : 'top']: '50%', [isMobile ? 'top' : 'left']: '50%', transform: 'translate(-50%, -50%) rotate(45deg)', backgroundColor: 'var(--primary)', zIndex: 6, borderRadius: '2%' }} title="Cliquez pour voir les détails" />
); })} {} {activeCategories.map(cat => { const catTasks = datedTasks.filter(t => t.category === cat); return (
{catTasks.map(task => { const percent = range === 0 ? 50 : 5 + (((new Date(task.deadline).getTime() - minDate) / range) * 90); const dotColor = task.isBlocked ? 'var(--danger)' : (task.status === 'done' ? 'var(--success)' : (task.status === 'review' ? 'var(--primary)' : (task.status === 'in_progress' ? 'var(--warning)' : 'var(--task-todo)'))); return (
setSelectedTask(task)} style={{ [isMobile ? 'top' : 'left']: `${percent}%`, [isMobile ? 'left' : 'top']: '50%', transform: 'translate(-50%, -50%)', backgroundColor: dotColor }} title={`${task.title} (${task.deadline})`} >
); })}
); })}
{} {activeEvent && (
setActiveEvent(null)} style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.6)', zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }}>
e.stopPropagation()} style={{ maxWidth: '350px', width: '100%' }}>

Détail du Jalon

{activeEvent.title}

Date : {new Date(activeEvent.date).toLocaleDateString("fr")}

)} {} {selectedTask && (
setSelectedTask(null)} style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' }}>
e.stopPropagation()} style={{ width: '100%', maxWidth: '600px' }}>

Détail de la tâche

)}
); } function HistoryView({ data }) { const getIconForAction = (action) => { const a = action.toLowerCase(); if (a.includes('supprimé')) return ; if (a.includes('validé') || a.includes('terminé') || a.includes('approuvé')) return ; if (a.includes('commenté')) return ; if (a.includes('créé') || a.includes('ajouté')) return ; if (a.includes('assigné')) return ; if (a.includes('bloqué') || a.includes('rejeté')) return ; if (a.includes('cours') || a.includes('lancé')) return ; if (a.includes('validation')) return ; return ; }; const getColorForAction = (action) => { const a = action.toLowerCase(); if (a.includes('supprimé') || a.includes('bloqué') || a.includes('rejeté')) return 'var(--danger)'; if (a.includes('validé') || a.includes('approuvé')) return 'var(--success)'; if (a.includes('commenté') || a.includes('cours') || a.includes('lancé')) return 'var(--warning)'; return 'var(--primary)'; }; return (

Registre des Actions (Audit)

{(data.auditLogs || []).length === 0 ?
Aucun historique.
:
{data.auditLogs.map(log => { const iconColor = getColorForAction(log.action); return (
{getIconForAction(log.action)}
{new Date(log.date).toLocaleString()}
{log.username} {log.action}
); })}
}
); } function InventoryView({ data, user, updateProjectData }) { const [label, setLabel] = useState(""); const [quantity, setQuantity] = useState(1); const manualInventory = data.inventory || []; const tasks = data.tasks || []; // --- 1. PRÉPARATION DES DONNÉES --- // Matériel issu des tâches terminées const actualTaskInventory = tasks .filter(t => t.status === 'done' && t.plannedMaterial) .map(t => ({ id: `task-${t.id}`, label: t.plannedMaterial, quantity: t.materialQty || 1, isTask: true, taskTitle: t.title // On garde le titre pour le contexte visuel })); const allActualInventory = [...manualInventory, ...actualTaskInventory]; // Matériel issu des tâches prévues (non terminées) const plannedTaskInventory = tasks .filter(t => t.status !== 'done' && t.plannedMaterial) .map(t => ({ id: `task-p-${t.id}`, label: t.plannedMaterial, quantity: t.materialQty || 1, isTask: true, taskTitle: t.title })); // --- 2. ACTIONS --- const handleAdd = () => { if (!label.trim()) return; // Sécurité : on s'assure que la quantité est au moins de 1 const safeQuantity = Math.max(1, parseInt(quantity) || 1); const newItem = { id: Date.now() + Math.random(), label: label.trim(), quantity: safeQuantity, user // On trace qui a ajouté l'item }; updateProjectData( { ...data, inventory: [...manualInventory, newItem] }, `a ajouté ${safeQuantity}x "${newItem.label}" à l'inventaire` ); setLabel(""); setQuantity(1); }; const handleDelete = (itemId, itemLabel) => { if (window.confirm(`Voulez-vous vraiment retirer "${itemLabel}" de l'inventaire ?`)) { const newInventory = manualInventory.filter(item => item.id !== itemId); updateProjectData( { ...data, inventory: newInventory }, `a retiré "${itemLabel}" de l'inventaire` ); } }; // --- RENDU --- return (

Inventaire Matériel

{} {user && (
setLabel(e.target.value)} style={{flex: 2, minWidth: '200px'}} /> setQuantity(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} style={{flex: 1, minWidth: '80px', maxWidth: '120px'}} />
)} {}

Matériel Acquis

{allActualInventory.length === 0 ? (

Aucun matériel acquis pour le moment.

) : (
{allActualInventory.map(item => (
{item.label} {} {item.isTask ? Via tâche : {item.taskTitle} : item.user && Ajouté par {item.user} }
x{item.quantity} {} {!item.isTask && user && ( )}
))}
)} {}

Futurs Besoins Prévus (Tâches non finies)

{plannedTaskInventory.length === 0 ? (

Aucun besoin prévu dans les tâches actuelles.

) : (
{plannedTaskInventory.map(item => (
{item.label} Pour : {item.taskTitle}
x{item.quantity}
))}
)}
); } function KanbanView({ data, user, navigate, navigateView, assignTask, toggleInProgress, startFocus, completeTask, undoTask, toggleBlockTask, approveTask, rejectTask, onOpenDiscussion, onOpenSubtasks, openProfile }) { const enableReview = data.features?.enableReview || false; const columns = [ { id: 'todo', title: 'À faire', tasks: (data.tasks||[]).filter(t => t.status !== 'done' && t.status !== 'review' && t.status !== 'in_progress' && !t.assignee) }, { id: 'assigned', title: 'Assigné', tasks: (data.tasks||[]).filter(t => t.status !== 'done' && t.status !== 'review' && t.status !== 'in_progress' && t.assignee) }, { id: 'in_progress', title: 'En cours', tasks: (data.tasks||[]).filter(t => t.status === 'in_progress') } ]; if (enableReview) { columns.push({ id: 'review', title: 'À valider', tasks: (data.tasks||[]).filter(t => t.status === 'review') }); } columns.push({ id: 'done', title: 'Terminé', tasks: (data.tasks||[]).filter(t => t.status === 'done') }); return (
{columns.map(col => (

{col.title} ({col.tasks.length})

{col.tasks.map((task) => )}
))}
); } function StagesView({ data, user, navigate, navigateView, isStageLocked, assignTask, toggleInProgress, startFocus, completeTask, undoTask, toggleBlockTask, approveTask, rejectTask, onOpenDiscussion, onOpenSubtasks, openProfile, userProfile }) { const sortedStages = [...(data.stages || [])].sort((a,b) => a.order - b.order); const isSingleStage = sortedStages.length === 1; const enableReview = data.features?.enableReview || false; const tasks = data.tasks || []; let currentStageId = null; for (let s of sortedStages) { if (isStageLocked(s.order)) continue; const stageTasks = tasks.filter(t => t.stageId === s.id); const hasUnfinishedTasks = stageTasks.some(t => t.status !== 'done'); const isEmptyStage = stageTasks.length === 0; if (hasUnfinishedTasks || isEmptyStage) { currentStageId = s.id; break; } } const userSkills = userProfile?.skills || []; let suggestedTasks = []; const isUserAssignedToAnyTask = tasks.some(t => t.assignee === user); if (!isUserAssignedToAnyTask) { suggestedTasks = tasks.filter(t => !t.assignee && t.stageId === currentStageId && t.status !== 'done' && userSkills.includes(t.category) ); } // --- REGROUPEMENT DES PROPS POUR TASKCARD --- const commonTaskProps = { user, navigate, navigateView, assignTask, toggleInProgress, startFocus, completeTask, undoTask, toggleBlockTask, approveTask, rejectTask, onOpenDiscussion, onOpenSubtasks, openProfile, enableReview }; return (
{} {suggestedTasks.length > 0 && (

Suggestion selon vos compétences

)} {} {sortedStages.map(stage => { const locked = isStageLocked(stage.order); const isCurrent = stage.id === currentStageId; const stageTasks = tasks .filter(t => t.stageId === stage.id) .sort((a, b) => a.importance - b.importance); // Ne pas afficher les étapes vides passées ou futures if (stageTasks.length === 0 && !isCurrent) return null; const activeTasks = stageTasks.filter(t => t.status !== 'done'); const doneTasks = stageTasks.filter(t => t.status === 'done'); // Classes CSS dynamiques let stageClasses = isSingleStage ? 'stage-single' : 'stage-card'; if (locked) stageClasses += ' stage-locked'; if (isCurrent && !isSingleStage) stageClasses += ' stage-current'; return (
{} {!isSingleStage && (

{locked && } Étape {stage.order} : {stage.name}

)} {}
{activeTasks.length === 0 && doneTasks.length === 0 &&

Aucune tâche.

} {activeTasks.map((task, idx) => )}
{} {doneTasks.length > 0 && (
Tâches terminées ({doneTasks.length})
{doneTasks.map((task, idx) => )}
)}
); })}
); } function TimeTrackingView({ data }) { // 1. Tâches avec du temps enregistré const tasksWithTime = (data.tasks || []) .filter(t => t.timeSpent > 0) .sort((a, b) => b.timeSpent - a.timeSpent); // 2. Temps total cumulé par utilisateur const userTimes = {}; (data.tasks || []).forEach(t => { (t.timeLogs || []).forEach(log => { if (!userTimes[log.user]) userTimes[log.user] = 0; userTimes[log.user] += log.durationSeconds; }); }); const hasUserTimes = Object.keys(userTimes).length > 0; return (
{}

Temps Total par Membre

{!hasUserTimes ? (

Aucun temps enregistré pour le moment.

) : (
{Object.entries(userTimes) .sort((a, b) => b[1] - a[1]) // Tri du plus gros bosseur au plus petit .map(([u, seconds]) => (
{u} {formatTime(seconds)}
))}
)}
{}

Tâches les plus longues

{tasksWithTime.length === 0 ? (

Aucune tâche n'a été chronométrée.

) : (
{tasksWithTime.slice(0, 10).map((t, index) => ( // On limite au top 10 pour l'UI
{t.title}
{formatTime(t.timeSpent)}
))}
)}
); }; function StatsView({ projectId, data }) { const [stats, setStats] = useState(null); const [error, setError] = useState(null); useEffect(() => { let isMounted = true; apiFetch(`/projects/${projectId}/stats`) .then(data => { if (isMounted) setStats(data); }) .catch(err => { if (isMounted) setError("Impossible de charger les statistiques globales."); console.error(err); }); return () => { isMounted = false; }; }, [projectId, apiFetch]); if (error) return
{error}
; if (!stats) return (

Calcul des statistiques en cours...

); const hasWorkload = Object.keys(stats.workload || {}).length > 0; // nombre de tâches de la personne la plus chargée const maxWorkload = hasWorkload ? Math.max(...Object.values(stats.workload).map((data) => data['count'])) : 1; // --- LOGIQUE DE L'HUMEUR DE L'ÉQUIPE --- const teamMood = stats.teamMood || {}; const totalMoods = (teamMood['😊'] || 0) + (teamMood['😐'] || 0) + (teamMood['😴'] || 0); // Pourcentages pour la barre de répartition const happyPct = totalMoods > 0 ? ((teamMood['😊'] || 0) / totalMoods) * 100 : 0; const neutralPct = totalMoods > 0 ? ((teamMood['😐'] || 0) / totalMoods) * 100 : 0; const tiredPct = totalMoods > 0 ? ((teamMood['😴'] || 0) / totalMoods) * 100 : 0; return (
{}
{}

Avancement Projet

{stats.progress}%
{}

Budget Utilisé

{stats.totalSpent} €
{}

Énergie de l'équipe

{totalMoods === 0 ? (

Personne n'a encore partagé sa météo aujourd'hui.

) : (
{}
{happyPct > 0 &&
} {neutralPct > 0 &&
} {tiredPct > 0 &&
}
{}
{teamMood['😊'] || 0} En forme
{teamMood['😐'] || 0} Normal
{teamMood['😴'] || 0} Fatigué(e)
{} {tiredPct >= 50 && (
Attention : Une grande partie de l'équipe est fatiguée. C'est peut-être le moment de réduire la pression ou de reporter les tâches non urgentes.
)} {happyPct >= 50 && (
Super dynamique : L'équipe est en pleine forme aujourd'hui ! C'est le bon moment pour s'attaquer aux tâches complexes.
)}
)}
{}

Charge de travail

{!hasWorkload ? (

Personne n'a de tâche en cours de traitement.

) : (
{Object.entries(stats.workload) .sort((a, b) => b[1] - a[1]) // Tri du plus surchargé au moins surchargé .map(([uid, data]) => { const fillPercentage = (data['count'] / stats.totalTasks) * 100; // Changement de couleur dynamique selon la charge const barColor = fillPercentage > 50 ? 'var(--danger)' // Rouge si très chargé : (fillPercentage > 25 ? 'var(--warning)' : 'var(--primary)'); // Jaune ou Primaire return (
{data['username']} {data['count']} tâche(s) en cours
); })}
)}
{}

Détails du Suivi du Temps

); } function TeamView({ data, openProfile }) { const [activeUsers, setActiveUsers] = useState([]); const [userStats, setUserStats] = useState({}); useEffect(() => { if (!data.tasks || data.tasks.length === 0) return; const uniqueAssigneeIds = [...new Set(data.tasks.map(task => task.assignee).filter(Boolean))]; Promise.all(uniqueAssigneeIds.map(uid => apiFetch(`/users/${uid}/profile`))) .then(profiles => { const newActiveUsers = []; const newStats = {}; profiles.forEach(profile => { if (!profile) return; newActiveUsers.push({ uid: profile.uid, username: profile.username }); const userTasks = data.tasks.filter(t => t.assignee === profile.uid); let total = 0, done = 0, inProgress = 0; userTasks.forEach(task => { total ++; if (task.status === 'done') done ++; else if (task.status === 'in_progress' || task.status === 'review') inProgress ++; }); newStats[profile.uid] = { total, done, inProgress }; }); setActiveUsers(newActiveUsers); setUserStats(newStats); }) .catch(error => { console.error("Erreur lors de la récupération des profils :", error); }); }, [data.tasks]); return (

Équipe du projet

{ activeUsers.length === 0 ?

Personne n'est encore assigné à une tâche sur ce projet.

: (
{activeUsers.map((user, idx) => (
{user.username.charAt(0).toUpperCase()}
{user.username}
Tâches totales : {userStats[user.uid]?.total || 0}
Tâches terminées : {userStats[user.uid]?.done || 0}
Tâches en cours/val. : {userStats[user.uid]?.inProgress || 0}
{openProfile && }
))}
) }
); } function MentionInput({ value, onChange, onKeyDown, placeholder, activeUsers }) { const [showMenu, setShowMenu] = useState(false); const [usernames, setUsernames] = useState([]); const [filter, setFilter] = useState(""); const [cursorPos, setCursorPos] = useState(0); const inputRef = useRef(null); useEffect(() => { if (!activeUsers) return null; setUsernames([]); for (const user of activeUsers) { apiFetch(`/users/${user}/profile`) .then(data => { setUsernames([...usernames, data.username]) }) .catch(() => { }); } }, [activeUsers]); const handleInput = (e) => { const val = e.target.value; onChange(e); const pos = e.target.selectionStart; const textBeforeCursor = val.substring(0, pos); // La Regex vérifie qu'on est au début de la ligne (^) OU après un espace (\s) // Cela évite de déclencher les mentions au milieu d'une adresse email. const match = textBeforeCursor.match(/(?:^|\s)@(\w*)$/); if (match) { setShowMenu(true); setFilter(match[1].toLowerCase()); setCursorPos(pos); } else { setShowMenu(false); } }; const insertMention = (username) => { // On remplace le texte tapé par la mention complète const textBefore = value.substring(0, cursorPos).replace(/@\w*$/, `@${username} `); const textAfter = value.substring(cursorPos); const newVal = textBefore + textAfter; // On simule un événement onChange pour mettre à jour le state parent onChange({ target: { value: newVal } }); setShowMenu(false); if (inputRef.current) inputRef.current.focus(); }; const safeActiveUsers = usernames || []; const filteredUsers = safeActiveUsers.filter(u => u.toLowerCase().includes(filter)); return (
{showMenu && filteredUsers.length > 0 && (
    {filteredUsers.map(u => (
  • insertMention(u)} style={{ padding: '8px 15px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }} onMouseEnter={(e) => e.currentTarget.style.background = 'var(--gray-light)'} onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} > {u}
  • ))}
)}
); } function DiscussionModal({ task, onClose, user, onUpdateTask, isAdmin, projectUsers }) { const [text, setText] = useState(""); const messagesEndRef = useRef(null); const comments = task.comments || []; // AUTO-SCROLL : Défile vers le bas à l'ouverture et à chaque nouveau message useEffect(() => { if (messagesEndRef.current) { messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); } }, [comments]); // --- ACTIONS --- const handleSubmit = () => { if (!text.trim()) return; // Extraction des mentions pour l'audit log const mentions = text.match(/@[0-9A-Za-zÀ-ÖØ-öø-ÿ\-_]+/g) || []; const mentionLog = mentions.length > 0 ? ` et a mentionné ${mentions.join(', ')}` : ''; const logMsg = `a commenté la tâche "${task.title}"${mentionLog}`; const newComment = { id: Date.now(), author: user, text: text.trim(), date: new Date().toISOString() }; onUpdateTask({ comments: [...comments, newComment] }, logMsg); setText(""); }; const handleDeleteComment = (commentId) => { if (window.confirm("Voulez-vous vraiment supprimer ce message ?")) { onUpdateTask( { comments: comments.filter(c => c.id !== commentId) }, `a supprimé un commentaire sur la tâche "${task.title}"` ); } }; return (
{}
{}

Discussion

{}
{comments.length === 0 ? (

Aucun message pour le moment.
Soyez le premier à lancer la discussion !

) : ( comments.map(c => { const isMyMessage = c.author === user; // Coloration des mentions (@pseudo) const renderedText = c.text.split(/(@\w+)/g).map((part, i) => part.startsWith('@') ? {part} : part ); return (
{isMyMessage ? 'Vous' : c.author} • {new Date(c.date).toLocaleString([], {timeStyle:'short', dateStyle:'short'})} {(isMyMessage || isAdmin) && ( )}
{renderedText}
); }) )} {}
{} {user ? (
setText(e.target.value)} onKeyDown={(e) => { // On vérifie si Shift n'est pas appuyé pour permettre le retour à la ligne if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }} placeholder="Taper @ pour mentionner quelqu'un..." activeUsers={projectUsers} />
) : (
Vous devez être connecté pour participer à la discussion.
)}
); } function FocusTimerOverlay({ task, onEndFocus, enableReview, userProfile }) { // 1. Initialisation des paramètres de temps (en secondes) const workDuration = (userProfile?.focusPrefs?.work || 25) * 60; const restDuration = (userProfile?.focusPrefs?.rest || 5) * 60; // 2. États const [isResting, setIsResting] = useState(false); const [phaseTimeLeft, setPhaseTimeLeft] = useState(workDuration); const [totalWorkElapsed, setTotalWorkElapsed] = useState(0); const [isRunning, setIsRunning] = useState(true); // 3. Logique du chronomètre useEffect(() => { let interval; if (isRunning) { interval = setInterval(() => { setPhaseTimeLeft(prev => { if (prev <= 1) { // Fin de la phase (Travail ou Pause) if (userProfile?.notifPrefs?.sound !== false) playBeep(); const willRest = !isResting; setIsResting(willRest); // Retourne la durée de la nouvelle phase return willRest ? restDuration : workDuration; } return prev - 1; }); // On incrémente le temps de travail total seulement si on n'est pas en pause if (!isResting) { setTotalWorkElapsed(t => t + 1); } }, 1000); } return () => clearInterval(interval); }, [isRunning, isResting, workDuration, restDuration, userProfile]); // 4. Actions const handleStop = (complete) => { onEndFocus(task, complete, totalWorkElapsed); }; // Calcul de la fraction pour le cercle SVG (entre 0 et 1) const fraction = phaseTimeLeft / (isResting ? restDuration : workDuration); // --- VARIABLES DE STYLE --- const themeColor = isResting ? 'var(--focus-rest-bg)' : 'var(--focus-bg)'; const overlayClass = `focus-overlay ${isResting ? 'mode-rest' : 'mode-work'}`; // Paramètres du cercle SVG const circleRadius = 130; const circleCircumference = 2 * Math.PI * circleRadius; const strokeDashoffset = circleCircumference * (1 - fraction); return (
{}
{isResting ? ( <> Pause bien méritée ) : ( <> Mode Focus )}
{}
{formatChrono(phaseTimeLeft)}
{}
{}

{task.title}

{task.description}

Temps total cumulé : {formatTime(totalWorkElapsed)}

{}
); } function SubtaskModal({ task, onClose, user, toggleSubtask }) { const subtasks = task.subtasks || []; const totalSubs = subtasks.length; const doneSubs = subtasks.filter(s => s.done).length; // Calcul de la progression (entre 0 et 100) const progressPercent = totalSubs === 0 ? 0 : Math.round((doneSubs / totalSubs) * 100); const handleToggleSub = (sub) => { if (!user) return; // Sécurité si mode lecture seule const isNowDone = !sub.done; if (toggleSubtask) { toggleSubtask(sub.id, isNowDone, task.title); } else { console.error("La prop toggleSubtask est manquante !"); } }; return (
{}
{}

Étapes : {task.title}

{} {totalSubs > 0 && (
Avancement {doneSubs} / {totalSubs} ({progressPercent}%)
)} {}
{totalSubs === 0 ? (

Aucune étape définie pour cette tâche.

) : (
{subtasks.map((s, index) => (
handleToggleSub(s)} style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '12px 15px', cursor: user ? 'pointer' : 'default', borderBottom: index < totalSubs - 1 ? '1px solid var(--border-color)' : 'none', background: s.done ? 'color-mix(in srgb, var(--success) 5%, var(--card-bg))' : 'var(--card-bg)', opacity: s.done ? 0.7 : 1, transition: 'all 0.2s' }} >
{s.done ? : }
{s.text}
))}
)}
); } function UserProfileModal({ uid, onClose, onLogout }) { const { user: currentUser, userProfile, updateProfileData } = useAuth(); const isMe = (uid === currentUser); const defaultPrefs = { done: true, comments: true, assigned: true, started: true, files: true, mentions: true, review: true }; const defaultFocus = { work: 25, rest: 5 }; const [pwdData, setPwdData] = useState({ old: '', new: '', confirm: '' }); const [pwdStatus, setPwdStatus] = useState({ error: '', success: '' }); const [profile, setProfile] = useState( isMe && userProfile ? { ...userProfile, notifPrefs: { ...defaultPrefs, ...(userProfile.notifPrefs || {}) }, focusPrefs: { ...defaultFocus, ...(userProfile.focusPrefs || {}) } } : { skills: [], email: "", phone: "", matrix: "", notifPrefs: defaultPrefs, focusPrefs: defaultFocus } ); const [isEditing, setIsEditing] = useState(false); const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(!isMe); useEffect(() => { if (!isMe) { setLoading(true); apiFetch(`/users/${uid}/profile`) .then(d => { setProfile(d); setLoading(false); }) .catch(() => { setLoading(false); }); } apiFetch(`/users/${uid}/tasks`) .then(d => setTasks(d.tasks || [])) .catch(() => {}); }, [uid, isMe, userProfile]); const handleSaveProfile = async () => { try { await apiFetch(`/users/${uid}/profile`, { method: 'POST', body: JSON.stringify(profile) }); if (isMe && updateProfileData) { updateProfileData('skills', profile.skills); updateProfileData('email', profile.email); updateProfileData('phone', profile.phone); updateProfileData('matrix', profile.matrix); updateProfileData('notifPrefs', profile.notifPrefs); updateProfileData('focusPrefs', profile.focusPrefs); updateProfileData('username', profile.username); } setIsEditing(false); } catch(e) { alert("Erreur d'enregistrement : " + e.message); } }; const handleChangePassword = async () => { setPwdStatus({ error: '', success: '' }); if (pwdData.new !== pwdData.confirm) { return setPwdStatus({ error: "Les nouveaux mots de passe ne correspondent pas.", success: '' }); } if (pwdData.new.length < 6) { return setPwdStatus({ error: "Le mot de passe doit faire au moins 6 caractères.", success: '' }); } try { const res = await apiFetch(`/users/${uid}/password`, { method: 'PUT', body: JSON.stringify({ old_password: pwdData.old, new_password: pwdData.new }) }); if (res.success) { setPwdStatus({ error: '', success: 'Mot de passe mis à jour avec succès !' }); setPwdData({ old: '', new: '', confirm: '' }); // On vide les champs } } catch(e) { setPwdStatus({ error: e.message || "Erreur lors du changement de mot de passe.", success: '' }); } }; const toggleSkill = (skill) => { const currentSkills = profile.skills || []; const newSkills = currentSkills.includes(skill) ? currentSkills.filter(s => s !== skill) : [...currentSkills, skill]; setProfile({ ...profile, skills: newSkills }); }; const requestPushPermission = () => { if ("Notification" in window) { Notification.requestPermission().then(permission => { if (permission === "granted") setProfile({ ...profile, notifPrefs: { ...(profile.notifPrefs || {}), push: true } }); else alert("Permission refusée par le navigateur."); }); } else { alert("Les notifications ne sont pas supportées par ce navigateur."); } }; const todayStr = new Date().toISOString().split('T')[0]; const mood = profile?.mood?.date === todayStr ? profile.mood.value : null; return (
{}
{}

Mood : {mood ? [{mood}] : "Aucun."}

{}
{(profile?.username || "Anonyme").charAt(0).toUpperCase()}

{profile?.username || "Anonyme"}

{tasks.length} tâche(s) assignée(s)

{}
{(loading || !profile?.username) ? (
Chargement...
) : isEditing ? (
{}

Identité

setProfile({...profile, username: e.target.value})} placeholder="Ex: Jean Dupont" /> C'est ce nom qui apparaîtra sur vos tâches.

{}

Sécurité & Mot de passe

setPwdData({...pwdData, old: e.target.value})} />
setPwdData({...pwdData, new: e.target.value})} />
setPwdData({...pwdData, confirm: e.target.value})} />
{pwdStatus.error &&
{pwdStatus.error}
} {pwdStatus.success &&
{pwdStatus.success}
}

Coordonnées

setProfile({...profile, email:e.target.value})} placeholder="Email (ex: contact@...)" />
setProfile({...profile, phone:e.target.value})} placeholder="Téléphone (ex: +33...)" />
setProfile({...profile, matrix:e.target.value})} placeholder="Matrix / Discord / Autre" />

Domaines de compétences

{GLOBAL_CATEGORIES.map(c => ( ))}

Temps de Focus (Chrono)

setProfile({...profile, focusPrefs: {...(profile.focusPrefs||{}), work: parseInt(e.target.value)||25}})} />
setProfile({...profile, focusPrefs: {...(profile.focusPrefs||{}), rest: parseInt(e.target.value)||5}})} />

Notifications

) : (

Coordonnées

{profile.email && } {profile.phone && } {profile.matrix &&
{profile.matrix}
} {!profile.email && !profile.phone && !profile.matrix &&

Aucune coordonnée renseignée.

}

Compétences

{profile.skills && profile.skills.length > 0 ? profile.skills.map(s => {s}) : Aucune compétence spécifiée. }
{} {isMe && (
{onLogout && ( )}
)}
)}
); }