);
}
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 (
);
}
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")}
setActiveEvent(null)}>Fermer
)}
{}
{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
setSelectedTask(null)}>
)}
);
}
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'}}
/>
Ajouter
)}
{}
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 && (
handleDelete(item.id, item.label)}
title="Retirer cet élément"
>
)}
))}
)}
{}
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 &&
openProfile(user.uid)}>Voir le profil complet }
))}
)
}
);
}
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 (
);
}
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) && (
handleDeleteComment(c.id)}
title="Supprimer le message"
>
)}
{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)}
{}
setIsRunning(!isRunning)}
title={isRunning ? "Mettre en pause" : "Reprendre"}
>
{isRunning ? : }
{}
{task.title}
{task.description}
Temps total cumulé : {formatTime(totalWorkElapsed)}
{}
handleStop(false)}
>
Arrêter & Sauvegarder
handleStop(true)}
>
{enableReview ? <> Envoyer en validation> : <> Valider la tâche>}
);
}
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é
Pseudo affiché (Display Name)
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
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 => (
toggleSkill(c)} />
{c}
))}
Notifications
setProfile({...profile, notifPrefs: {...(profile.notifPrefs||{}), mentions: e.target.checked}})} /> On me mentionne
setProfile({...profile, notifPrefs: {...(profile.notifPrefs||{}), assigned: e.target.checked}})} /> On m'assigne
setProfile({...profile, notifPrefs: {...(profile.notifPrefs||{}), comments: e.target.checked}})} /> Nouveaux coms
setProfile({...profile, notifPrefs: {...(profile.notifPrefs||{}), review: e.target.checked}})} /> Tâches "À valider"
setProfile({...profile, notifPrefs: {...(profile.notifPrefs||{}), started: e.target.checked}})} /> Mises en cours
setProfile({...profile, notifPrefs: {...(profile.notifPrefs||{}), done: e.target.checked}})} /> Tâches terminées
setProfile({...profile, notifPrefs: {...(profile.notifPrefs||{}), files: e.target.checked}})} /> Nouveaux fichiers
Enregistrer le profil
setIsEditing(false)}>Annuler
) : (
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 && (
setIsEditing(true)}>
Modifier mon profil & préférences
{onLogout && (
Se déconnecter
)}
)}
)}
);
}